Cartographie du Pipeline ETL Hozana

Générée le 2026-03-24 par analyse exhaustive du code source, du backend Symfony, de l'infrastructure /infra/. etl/main.py : 76 tasks enregistrées.

76
Tasks enregistrées
5
Sources (MySQL, GA4, Mailchimp, GSheets, Search Console)
3
Bases cibles (hozana_data, rosario, archive)
4
Tables ré-importées en prod (data_*)

Diagrammes

Data Lineage Global

flowchart LR subgraph PROD["Production"] HOZANA_DB["hozana"] CRM_DB["hozana_crm"] ROSARIO_DB["rosario"] ARCHIVE_SRC["mail_events
feed_items"] end subgraph EXT["Externes"] GA4["GA4"] MC["Mailchimp"] GS["GSheets"] end subgraph DATA["Serveur Data"] ETL_DB["ETL MySQL"] ETL_P["ETL Python"] MB["Metabase"] DT["data_*"] end PROD -->|dump| ETL_DB EXT --> ETL_P ETL_DB --> ETL_P ETL_P --> ETL_DB ETL_DB --> MB ETL_P --> DT DT -->|"RENAME TABLE"| PROD

Cycle Nightly

flowchart LR DUMP["Dump + SCP"] --> ANON["Anonymisation"] ANON --> ETL["ETL 76 tasks"] ETL --> EXPORT["Export data_*"] EXPORT --> PROD["PROD swap"] style ETL fill:#DFF7DA,stroke:#3D7732,color:#16181A

Flux Bidirectionnel

flowchart LR subgraph PROD["Prod"] WH["Webhooks"] APP["Symfony"] end subgraph ETL["ETL"] TASKS["76 tasks"] DATA["data_*"] end APP -->|dump| TASKS WH -->|mail_events| TASKS TASKS --> DATA DATA -->|"atomic swap"| APP style DATA fill:#DFF7DA,stroke:#3D7732,color:#16181A

Dependances inter-tasks

flowchart TD Lang --> UsersUTM & UniqueUsers UsersCC["users_country_code"] --> UsersTargetZone["users_target_zone"] UsersSubs["users_subscriptions"] --> UniquePrayers["unique_prayers"] & ReadRate["read_rate"] UniqueUsers["unique_users"] --> UsersLifeAll["users_life_all"] & NsmUsers["nsm_users"] & UsersStats["users_stats"] UsersLifeAll --> UsersSeg["users_segmentations"] & UsersDonScore["users_donation_score"] ReadRate --> PubStats["publication_stats"] --> ComStats["community_stats"]
flowchart LR subgraph PROD["Production (mysql-replica.hozana.io)"] HOZANA_DB["hozana (main)"] CRM_DB["hozana_crm"] ROSARIO_DB["rosario"] ARCHIVE_ME["mail_events"] ARCHIVE_FI["feed_items"] end subgraph External["Sources Externes"] GA4["Google Analytics 4"] MC["Mailchimp API"] GS["Google Sheets (5 spreadsheets)"] GSC["Google Search Console"] end subgraph DATA_SERVER["Serveur Data (data.hozana.org)"] direction TB DUMP_FILES["Dumps .sql.gz"] ETL_DB["ETL MySQL"] ETL_PIPELINE["ETL Python (76 tasks)"] METABASE["Metabase"] DATA_TABLES["data_* tables"] end subgraph PROD_IMPORT["Production Import"] PROD_DU["data_users"] PROD_DP["data_publications"] PROD_DC["data_communities"] PROD_DA["data_announcements"] end HOZANA_DB -->|dump| DUMP_FILES CRM_DB -->|dump| DUMP_FILES ROSARIO_DB -->|dump| DUMP_FILES ARCHIVE_ME -->|dump archive| DUMP_FILES ARCHIVE_FI -->|dump archive| DUMP_FILES DUMP_FILES --> ETL_DB ETL_DB --> ETL_PIPELINE GA4 -->|API| ETL_PIPELINE MC -->|API| ETL_PIPELINE GS -->|gspread| ETL_PIPELINE ETL_PIPELINE --> ETL_DB ETL_DB --> METABASE ETL_PIPELINE --> DATA_TABLES DATA_TABLES -->|atomic swap| PROD_DU DATA_TABLES -->|atomic swap| PROD_DP DATA_TABLES -->|atomic swap| PROD_DC DATA_TABLES -->|atomic swap| PROD_DA GSC -->|cron day4| PROD
flowchart TD A["PROD - send_mysql_dump_to_data.sh"] --> B["SCP 4 fichiers .sql.gz"] B --> C["Import + anonymisation"] C --> POLL["ETL-starter.sh - poll toutes les 5 min"] POLL --> START["after_sql_dump_sent.sh.new"] START --> GIT["git pull"] GIT --> ETL["python3.10 -m etl.main"] ETL --> PREP["prepare_data_for_prod.sh"] PREP --> PRODNODE["PROD - RENAME TABLE atomique"] style POLL fill:#FFF4D5,stroke:#AD5700,color:#16181A style ETL fill:#DFF7DA,stroke:#3D7732,color:#16181A style PRODNODE fill:#FDEEED,stroke:#AE0A4B,color:#16181A click A call openNode("infra:mysql_replica") click B call openNode("infra:dump_files") click C call openNode("infra:anonymisation_node") click POLL call openNode("infra:etl_starter") click START call openNode("infra:after_sql_script") click ETL call openNode("infra:etl_pipeline") click PREP call openNode("infra:prepare_data_prod") click PRODNODE call openNode("infra:prod_import") click GIT call openNode("infra:after_sql_script")
flowchart LR subgraph PROD["Production Hozana (Symfony)"] WH["Webhooks SendGrid/SES"] APP["Application Logic"] CRON["Crons Symfony"] API["API Endpoints"] end subgraph ETL_DB["ETL Server MySQL"] DUMP["Dump Nightly"] ETL["76 ETL Tasks"] DATA["data_users
data_publications
data_communities
data_announcements"] end subgraph PROD_DB["Production DB"] TABLES["users, prayers,
communities, etc."] ME["mail_events"] FI["feed_items"] SESS["sessions"] DTABLES["data_users, data_publications,
data_communities, data_announcements"] end APP --> TABLES WH --> ME APP --> FI APP --> SESS CRON -->|clean daily| ME CRON -->|clean daily| FI TABLES -->|dump 22h| DUMP ME -->|dump 22h| DUMP FI -->|dump 22h| DUMP SESS -->|dump 22h| DUMP DUMP --> ETL ETL --> DATA DATA -->|scp + atomic swap| DTABLES style DATA fill:#DFF7DA,stroke:#3D7732,color:#16181A style DTABLES fill:#DFF7DA,stroke:#3D7732,color:#16181A click WH call openNode("infra:prod_hozana") click APP call openNode("infra:prod_hozana") click CRON call openNode("infra:prod_hozana") click API call openNode("infra:prod_hozana") click DUMP call openNode("infra:dump_files") click ETL call openNode("infra:etl_pipeline") click DATA call openNode("infra:data_tables_node") click TABLES call openNode("infra:hozana_main") click ME call openNode("infra:archive_db") click FI call openNode("infra:archive_db") click SESS call openNode("infra:hozana_main") click DTABLES call openNode("infra:data_users_node")
%%{init: {'flowchart': {'rankSpacing': 120, 'nodeSpacing': 30, 'padding': 20}}}%% flowchart LR subgraph PREP["Preparation"] direction TB PrepareDB["prepare_db
daily
L: subscriptions, publications
W: subscriptions, publications, r.participation, r.prayer"] Lang["lang
daily
L: users, prayers, sessions, messages
R: users
W: prayers, sessions, messages, user_referrals"] CrmOrders["crm_orders
daily
R: orders, contacts
W: orders"] FeedItems["feed_items
daily
L: feed_items, subscriptions, publications
R: a.feed_items, publications, subscriptions
W: a.feed_items"] end subgraph USERS["Utilisateurs"] direction TB UsersAge["users_age
daily
L: users
R: users
W: users"] UsersCC["users_country_code
daily
L: users
R: users
W: users"] UsersTargetZone["users_target_zone
daily
L: users
R: users
W: users"] UsersSubs["users_subscriptions
daily
L: users, subscriptions
R: users, subscriptions
W: users, subscriptions"] UsersUTM["users_utm
daily
L: users
R: users, user_referrals, utm_normalized_lookup
W: users"] UsersIndices["users_indices
daily
L: users
W: users"] UniqueUsers["unique_users
daily
L: mail_events, sessions
R: a.mail_events, sessions, users
W: unique_open_emails, unique_users, alive_subscribers"] end subgraph UTM_GROUP["UTM & Sources"] direction TB SubsUTM["subscriptions_utm
daily
L: subscriptions, users
R: subscriptions, users
W: subscriptions"] PresubUTM["presubscribers_utm
daily
L: users, subscriptions
R: presubscribers, users, subscriptions
W: presubscribers, users, subscriptions"] end subgraph COMMUNITY["Communautes"] direction TB ReadRate["read_rate
daily
L: publications, subscriptions
R: subscriptions, publications, a.feed_items, communities
W: publication_reads, community_reads, community_first_reads"] PubStats["publication_stats
daily
L: publications
R: subscriptions, publications, a.feed_items
W: publications"] GACom["ga_communities
daily
R: GA4 API
W: a.ga_communities"] GAPub["ga_publications
daily
L: publications
R: GA4 API
W: a.ga_publications, publications"] GASubs["ga_subscriptions_page_views
daily
R: GA4 API, utm_normalized_lookup
W: ga_subscriptions_page_views"] CommTransfo["community_transformation_rate
daily
R: ga_subscriptions_page_views
W: community_join_groups"] ComStats["community_stats
daily
L: communities
R: publications, communities, community_first_reads, a.ga_communities, a.mail_events, community_joined_emails, community_join_groups, new_community_message_emails
W: communities, community_joined_emails, daily_community_shares, a.shares_total"] end subgraph CRM["CRM"] direction TB CrmContacts["crm_contacts
daily
R: contacts, users, orders
W: contacts, contacts_hu, contacts_ru"] end subgraph ANALYTICS["Analytics & Segmentation"] direction TB UsersLifeAll["users_life_all
SAT
R: users, unique_users, orders, contacts, contacts_hu, subscriptions, intentions, unique_prayers, comments, messages
W: users_life_day, users_life_week, users_life_month, users_life_quarter, users_life_semester, users_life_year, users_life_total"] UsersSeg["users_segmentations
SAT
R: users_life_quarter, users_life_month, users
W: users_segments"] UsersSegH["users_segmentations_history
SAT
R: users_segments, users_segments_history
W: users_segments_history, segments_history"] UsersDonScore["users_donation_score
daily
R: users, users_life_quarter, contacts
W: user_donation_score"] UsersStats["users_stats
daily
L: unique_users
R: users, users_segments, user_donation_score, prayers, comments, unique_users, last_sent_emails, orders, contacts, contacts_hu
W: data_users"] LastSentEmails["last_sent_emails
daily
L: mail_events, last_sent_emails
R: a.mail_events, a.mail_events_archive
W: last_sent_emails"] CommonUsers["common_users
daily
L: users, r.user
R: users, r.user
W: users, r.user"] NsmUsers["nsm_users
daily
R: unique_users, users
W: user_nsm_temp, data_north_star_metric, nsm_users"] DauMau["dau_mau
SAT
R: unique_users, users
W: dau_mau_by_lang, dau_mau_by_target_zone"] ActivityStats["activity_stats
daily
L: daily_activity
R: unique_open_emails, sessions, subscriptions, intentions, prayers, comments, messages, a.feed_items, users
W: daily_activity"] UsersLifeCP["users_life_cp
SAT
R: subscriptions, unique_prayers
W: users_life_cp_total, users_life_cp_day, users_life_cp_week, users_life_cp_month"] end subgraph ROSARIO["Rosario"] direction TB RosarioPart["participations
daily
L: participation
R: r.participation
W: r.participation"] Guides["guides
daily
L: guides
R: guides, user_referrals, contacts_hu, orders
W: guides"] UniquePrayersTask["unique_prayers
daily
L: prayers
R: prayers, subscriptions, publications, users
W: unique_prayers"] UsersLifeIntentions["users_life_intentions
SAT
R: unique_prayers, intentions
W: intention_product_usage, users_life_intentions_total, users_life_intentions_day, users_life_intentions_week, users_life_intentions_month, users_life_intentions_quarter, users_life_intentions_semester, users_life_intentions_year"] RosarioStreamToPrayer["stream_to_prayer
daily
L: r.prayer
R: r.stream
W: r.prayer"] RosarioDeviceLife["device_life
daily
R: r.user, r.prayer, orders, contacts, contacts_ru, r.participation
W: r.device_life_day, r.device_life_week, r.device_life_month, r.device_life_quarter, r.device_life_semester, r.device_life_year, r.device_life_total"] RosarioGroupStats["group_stats
daily
L: chapelet_completion_history, chapelet_activity_history
R: r.chapelet, r.participation, r.prayer
W: r.chapelet_completion_history, r.chapelet_activity_history"] RosarioParishStats["parish_stats
daily
R: r.participation, r.chapelet, r.parish, r.prayer
W: r.parish_activity_history"] RosarioActivityScore["activity_score
daily
L: user_activity_history
R: r.user, r.prayer, r.participation, r.chapelet, contacts, orders
W: r.user_activity_history"] end %% === KEY TABLES (hexagons, different color) === T_users{{users}} T_unique_users{{unique_users}} T_unique_prayers{{unique_prayers}} T_subscriptions{{subscriptions}} T_publications{{publications}} T_orders{{orders}} T_contacts{{contacts}} T_contacts_hu{{contacts_hozana_users}} T_users_life_q{{users_life_quarter}} T_users_segments{{users_segments}} T_user_don_score{{user_donation_score}} T_data_users{{data_users}} T_community_first_reads{{community_first_reads}} T_prayers{{prayers}} T_sessions{{sessions}} T_mail_events{{mail_events}} T_feed_items{{feed_items}} T_rosario_prayer{{rosario.prayer}} T_rosario_participation{{rosario.participation}} %% === TASK DEPENDENCIES (solid arrows) === Lang --> UsersUTM Lang -.-> T_prayers & T_sessions UsersCC --> UsersTargetZone UsersCC --> UsersIndices UsersAge --> UsersIndices UsersSubs --> UniquePrayersTask UsersSubs -.-> T_subscriptions UsersSubs --> ReadRate UsersSubs --> UsersLifeCP PrepareDB --> ReadRate PrepareDB --> RosarioPart FeedItems --> ReadRate SubsUTM --> ReadRate UsersUTM --> SubsUTM SubsUTM --> PresubUTM GASubs --> CommTransfo %% === TABLE FLOWS (dotted arrows) === UniquePrayersTask -.-> T_unique_prayers T_unique_prayers -.-> UsersLifeAll & UsersLifeCP & UsersLifeIntentions UniqueUsers -.-> T_unique_users T_unique_users -.-> UsersLifeAll & NsmUsers & DauMau & ActivityStats & UsersStats CrmOrders -.-> T_orders T_orders -.-> UsersLifeAll & UsersStats & RosarioDeviceLife & RosarioActivityScore & Guides CrmContacts -.-> T_contacts & T_contacts_hu T_contacts -.-> UsersDonScore T_contacts_hu -.-> UsersLifeAll & UsersStats & RosarioDeviceLife & Guides UsersLifeAll -.-> T_users_life_q T_users_life_q -.-> UsersSeg & UsersDonScore UsersSeg -.-> T_users_segments T_users_segments -.-> UsersStats & UsersSegH UsersDonScore -.-> T_user_don_score T_user_don_score -.-> UsersStats UsersStats -.-> T_data_users ReadRate -.-> T_community_first_reads T_community_first_reads -.-> ComStats ReadRate --> PubStats PubStats --> ComStats GACom --> ComStats CommTransfo --> ComStats LastSentEmails --> UsersStats CommonUsers --> UsersStats %% === ROSARIO FLOWS === RosarioPart -.-> T_rosario_participation RosarioStreamToPrayer -.-> T_rosario_prayer T_rosario_prayer -.-> RosarioGroupStats & RosarioDeviceLife & RosarioParishStats & RosarioActivityScore T_rosario_participation -.-> RosarioGroupStats & RosarioDeviceLife & RosarioParishStats %% === STYLES === style T_users fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_unique_users fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_unique_prayers fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_subscriptions fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_publications fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_orders fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_contacts fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_contacts_hu fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_users_life_q fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_users_segments fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_user_don_score fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_data_users fill:#DFF7DA,stroke:#3D7732,color:#16181A,color:#3fb950 style T_community_first_reads fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_prayers fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_sessions fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_mail_events fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_feed_items fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_rosario_prayer fill:#FFF2D8,stroke:#DA7918,color:#16181A style T_rosario_participation fill:#FFF2D8,stroke:#DA7918,color:#16181A

Sources de Données

MySQL Prod Base de données Hozana (hozana_data)

Description: Base de production Hozana, dumpée chaque nuit vers le serveur ETL. Contient les tables utilisateurs, prières, communautés, publications, abonnements, intentions, messages, commentaires, sessions.

Tables principales lues: users, prayers, publications, subscriptions, communities, intentions, comments, messages, sessions, authentications, user_referrals, orders, contacts, contacts_hozana_users, crm_users

Fréquence: Dump quotidien ~22h00

Config: HOZANA_DATA_DB_* env vars (host, port, user, password, db_name)

Connexion: etl/mysql/connect.py — MySQLdb + SQLAlchemy

MySQL Prod Base Rosario (rosario)

Description: Base de production de l'app Rosario (chapelet), dumpée chaque nuit. Contient les utilisateurs, prières, participations, groupes, paroisses, devices.

Tables lues: user, prayer, participation, chapelet, community, parish, device, adjust_event, stream

Config: HOZANA_DATA_DB_ROSARIO_NAME

MySQL Prod Base Archive (hozana_archive)

Description: Base d'archive contenant les données historiques volumineuses : mail_events, mail_events_archive, feed_items, ga_communities, ga_publications.

Tables lues: mail_events, mail_events_archive, feed_items, ga_communities, ga_publications

Config: HOZANA_DATA_DB_ARCHIVE_NAME

GA4 Google Analytics 4

Description: Données de trafic web (page views, sessions, sources) récupérées via l'API GA4 Data.

Interface: etl/utils/ga4_interface.py

Credentials: GOOGLE_APPLICATION_CREDENTIALS=/home/hozana/data-tools/hozana-data-ga4.json

Tasks consommatrices: ga_communities, ga_publications, ga_subscriptions_page_views

Mailchimp Mailchimp API

Description: Statistiques de campagnes Mailchimp (ouvertures, clics, envois).

Interface: etl/utils/mailchimp_interface.py

Tasks consommatrices: mailchimp_stats.mailchimp_stats

GSheets Google Sheets (5 spreadsheets import + 1 export)

Description: Données publicitaires et budgétaires saisies manuellement par l'équipe marketing.

Interface: etl/utils/gsheet_interface.py (gspread via service account GCP hozana-data-tool-1712212735082)

Spreadsheets importes (task ads_data) — mapping vérifié avec les fichiers reels :

Nom réel du GSheetConfig ETLTable cibleColonnesAnomalies
Hozana Advertising Export to Sheetads_dataads_dataDate, Traffic source, Campaign, Cost, Clicks, Impressions, Account creation [Hozana], Installs [Rosario], Inscription LP [Rosario]-
Coreg budgetcoreg_budgetcoreg_budgetDate, Produit, Media, utm_source/Adgroup, utm_campaign/Campaign, utm_content/Creative, Budget, Rémunération/acquisitionColonne utm_medium présente dans le sheet mais PAS mappée dans le code ETL
Partenariat budgetpartnership_budgetpartnership_budgetDate, Produit, Type, utm_source/Adgroup, utm_campaign/Campaign, utm_content/Creative, Budget, Rémunération/acquisition-
Autre budgetprint_budgetprint_budgetDate, Type, Produit, Media, Campagne, utm_source/campaign/content/medium (si QR), Budget, Rémunération, Lien trackeNomme "Autre budget" dans GSheet mais "print_budget" dans le code
SEO Budgetseo_budgetseo_budgetMois, Nom, Langue, Type, Guide, Nb articles, Nb heures, Cout HT, Nb motsLe sheet utilise IMPORTRANGE vers un autre spreadsheet — les colonnes sont des formules

Spreadsheet d'export (task nsm_users): NSM KPI export vers GSheet 1ArteY1YZU5k79mXbJU3RxrBLCDzGdCnicjS47uo4_SM

Catalogue des Tasks

76 tasks enregistrées dans etl/main.py. Cliquez sur une card pour voir le détail en modale avec le mini-graphe de dépendances.

Graphe de Dépendances

Dépendances inter-tasks (principales)

Cliquez sur le diagramme pour l'ouvrir en grand avec noeuds cliquables.

flowchart TD Lang --> UsersUTM & UniqueUsers UsersCC["users_country_code"] --> UsersTargetZone["users_target_zone"] UsersSubs["users_subscriptions"] --> UniquePrayers["unique_prayers"] & ReadRate["read_rate"] UniqueUsers["unique_users"] --> UsersLifeAll["users_life_all"] & NsmUsers["nsm_users"] & UsersStats["users_stats"] UsersLifeAll --> UsersSeg["users_segmentations"] & UsersDonScore["users_donation_score"] ReadRate --> PubStats["publication_stats"] --> ComStats["community_stats"]

Locks de Tables

Table lockéeTasks qui la lockent
userslang, users_cleanup, users_age, users_gender, users_country_code, users_seniority, users_target_zone, users_authentication_type, users_utm, users_subscriptions, users_indices, common_users, crm_contacts, npe_update_users, digest_stats_update_users, presubscribers_utm, subscriptions_utm
subscriptionsprepare_db, users_subscriptions, feed_items, read_rate, subscriptions_utm, presubscribers_utm
publicationsprepare_db, feed_items, read_rate, publication_stats, ga_publications, npe_update_publications
prayerslang, unique_prayers
sessionslang, unique_users
messageslang, anonymization.hozana_messages
mail_eventsunique_users, email_stats, last_sent_emails, digest_stats_prepare
communitiescommunity_stats
unique_usersusers_stats
orderscrm_orders, anonymization.crm_anonymization
contactscrm_contacts, anonymization.crm_anonymization
announcementsannouncement_stats
intentionsanonymization.intentions_anonymization
guidesguides
feed_itemsfeed_items
notesanonymization.crm_anonymization
next_stepanonymization.crm_anonymization
last_sent_emailslast_sent_emails
daily_activityactivity_stats
digest_emailsdigest_stats_prepare
participationrosario.participations
rosario.usercommon_users, rosario.derived_tables, rosario.user_adjust_parameters, rosario.users_target_zone
rosario.prayerrosario.stream_to_prayer
rosario.chapeletrosario.chapelet_structure
rosario.adjust_eventrosario.adjust_events
rosario.messageanonymization.rosario_messages
rosario.user_derivedrosario.ghost_users
rosario.dau_maurosario.dau_mau
user_activity_historyuser_reactivation.user_activity_history, rosario.activity_score
user_reactivationuser_reactivation.user_reactivation
chapelet_completion_historyrosario.group_stats
chapelet_activity_historyrosario.group_stats

Cartographie des Tables

Tables prod (écrasées chaque nuit par le dump)

TableDBEnrichie parColonnes ajoutées
usershozana_datalang, users_age, users_gender, users_country_code, users_seniority, users_target_zone, users_utm, users_authentication_type, users_subscriptions, users_indices, common_users, users_cleanuplang, age, title_gender, country_code, number_of_days_since_subscription, target_zone, authentication_type, n_joined_communities_active, is_rosario_user, rosario_user_id, utm_*
prayershozana_datalanglang
sessionshozana_datalanglang
messageshozana_datalanglang
user_referralshozana_datalanglang
subscriptionshozana_datausers_subscriptions, read_ratesubscription_number, has_read_first_publication, first_publication_number, n_received_publications, utm_*
publicationshozana_datapublication_stats, read_ratepublication_number, n_received_*, n_read_*, n_shares
communitieshozana_datacommunity_statsn_legacy_message_*, last_publication_scheduled_time, n_publications_read, read_rate, corrected_read_rate_*, n_ga_page_views, n_community_joined_email_*, corrected_transfo_rate_*
ordershozana_datacrm_ordersamount_eq_euro, is_confirmed_regular_donation, exclude_from_statistics, amount_eq_euro_ltv_x50_x10_7
contactshozana_datacrm_contactsn_valid_donations, first_hozana_user_id
rosario.participationrosariorosario.participationsparticipation_number
rosario.userrosariocommon_usersis_hozana_user, hozana_user_id

Tables ETL-only (persistantes, NON écrasées par le dump)

TableDBEcrite parType
unique_open_emailshozana_dataunique_usersIncrémental (append)
unique_usershozana_dataunique_usersRecréé chaque run
alive_subscribershozana_dataunique_usersRecréé chaque run
unique_prayershozana_dataunique_prayersIncrémental (UPSERT)
data_usershozana_datausers_statsRecréé chaque run
users_segmentshozana_datausers_segmentationsRecréé (saturday)
users_segments_v2hozana_datausers_segmentations_v2Recréé (saturday)
users_segments_historyhozana_datausers_segmentations_historyPersistante (append)
segments_historyhozana_datausers_segmentations_historyPersistante (append)
users_life_*hozana_datausers_life_allRecréé (saturday)
users_life_cp_*hozana_datausers_life_cpRecréé (saturday)
users_life_intentions_*hozana_datausers_life_intentionsRecréé (saturday)
intention_product_usagehozana_datausers_life_intentionsRecréé (saturday)
user_donation_scorehozana_datausers_donation_scoreRecréé chaque run
publication_readshozana_dataread_rateRecréé chaque run
community_readshozana_dataread_rateRecréé chaque run
community_first_readshozana_dataread_rateRecréé chaque run
email_statshozana_dataemail_statsIncrémental (UPSERT)
last_sent_emailshozana_datalast_sent_emailsPersistante (UPSERT)
daily_activityhozana_dataactivity_statsPersistante (36 mois)
dau_mau_by_langhozana_datadau_mauPersistante (incrémental)
dau_mau_by_target_zonehozana_datadau_mauPersistante (incrémental)
okr_monthly_etpmhozana_dataetpmPersistante (incrémental)
okr_yearly_etpmhozana_dataetpmRecréé chaque run
data_north_star_metrichozana_datansm_usersPersistante (monthly append)
nsm_usershozana_datansm_usersPersistante
etl_performanceshozana_dataorchestratorPersistante (daily append)
ads_data, coreg_budget, partnership_budget, print_budget, seo_budgethozana_dataads_dataReplace chaque run
community_joined_emailshozana_datacommunity_statsIncrémental
daily_community_shareshozana_datacommunity_statsRecréé chaque run
shares_totalhozana_archivecommunity_statsPersistante (daily append)
user_activity_historyhozana_datauser_réactivation.user_activity_historyPersistante
rosario.user_activity_historyrosariorosario tasksPersistante
rosario.data_north_star_metricrosariorosario.nsm_usersPersistante

Origines des Données (Backend Symfony)

Analyse du backend Symfony Hozana (/infra/backend/) — 91 entités Doctrine, commandes cron, webhooks.

Flux Bidirectionnel ETL ↔ Production

flowchart LR subgraph PROD["Production Hozana (Symfony)"] WH["Webhooks SendGrid/SES"] APP["Application Logic"] CRON["Crons Symfony"] API["API Endpoints"] end subgraph ETL_DB["ETL Server MySQL"] DUMP["Dump Nightly"] ETL["76 ETL Tasks"] DATA["data_users
data_publications
data_communities
data_announcements"] end subgraph PROD_DB["Production DB"] TABLES["users, prayers,
communities, etc."] ME["mail_events"] FI["feed_items"] SESS["sessions"] DTABLES["data_users, data_publications,
data_communities, data_announcements"] end APP --> TABLES WH --> ME APP --> FI APP --> SESS CRON -->|clean daily| ME CRON -->|clean daily| FI TABLES -->|dump 22h| DUMP ME -->|dump 22h| DUMP FI -->|dump 22h| DUMP SESS -->|dump 22h| DUMP DUMP --> ETL ETL --> DATA DATA -->|scp + atomic swap| DTABLES style DATA fill:#DFF7DA,stroke:#3D7732,color:#16181A style DTABLES fill:#DFF7DA,stroke:#3D7732,color:#16181A

Découverte cle : Le pipeline est bidirectionnel. L'ETL lit les tables prod, mais écrit aussi 4 tables data_* qui sont re-importées en production chaque nuit via load_data_tables_from_data_server.sh (swap atomique de tables via RENAME TABLE).

Provenance des Tables Source (Backend → ETL)

Webhook mail_events — SendGrid/SES Webhooks

Entité Doctrine : src/Dbal/Entity/Mail/MailEvent.php

Creation : Webhook POST /sendgrid/webhookMailEventController::sendGridEvents()

Colonnes : mail_event_id, email, user_id, timestamp, event (bounce/open/click/delivered/etc.), category, community_id, publication_id, announcement_id, lang, mailer_provider (sendGrid/SES), ses_message_id, ses_tags

Retention : Nettoyee quotidiennement par api:cron:clean (cron day4) — conserve ~60 jours. Les anciens events sont archives dans hozana_archive.mail_events_archive par l'ETL (task email_stats).

Volume : Tres volumineux (millions de lignes/mois). L'ETL itere par batches de 1M mail_event_id.

Application feed_items — Tracking de lecture des publications

Entité Doctrine : src/Dbal/Entity/FeedItem.php

Creation : Logique applicative quand une publication est poussee a un abonne. Aussi via CreateFeedItemsForPublicationCommand.

Colonnes : feed_item_id, user_id, community_id, subscription_id, publication_id, pushed_at (timestamp ms), read_at, first_read_from (enum: email, feed-read-more, community-read-more, publication-page)

DB : hozana_archive (les feed_items sont dans la base archive, pas hozana_data)

Retention : Nettoyee quotidiennement par cron day4.

Application sessions — Sessions utilisateur

Entité Doctrine : src/Dbal/Entity/UserSession.php

Creation : Enregistree a chaque activite utilisateur, 1 par user par jour calendaire.

Colonnes : session_id, user_id, session_time (DATE), app_type (web/mobile/unknown)

Cle unique : (user_id, session_time) — pas de doublons possible.

Application authentications — Authentification sociale

Entité Doctrine : src/Dbal/Entity/Authentication.php

Creation : Lors de la connexion OAuth (Facebook, Google, Apple).

Colonnes : authentication_id, user_id, provider_uid (ex: "facebook_12345"), access_token

Usage ETL : Lu par users_authentication_type pour deduire le type d'auth via LIKE sur provider_uid.

Application user_referrals — Tracking d'acquisition

Entité Doctrine : src/Dbal/Entity/UserReferral.php (PK = user_id, FK vers users)

Creation : A l'inscription de l'utilisateur, capture le referrer HTTP et l'URL de landing.

Colonnes : user_id, referrer (raw), landing_url, landing_date, source, first_subscription

Usage ETL : Source primaire pour le calcul des UTMs (task users_utm). La logique complexe d'extraction et normalisation est dans etl/utils/utms.py.

Application users — Table principale utilisateurs

Entité Doctrine : src/Dbal/Entity/User.php (40+ colonnes)

Colonnes clés : user_id, email, name, first_name, lang, birthdate, title, created_at, deleted_at, disabled, is_reported, user_country_code, auto_country_code, sponsor_key, invite_key

L'ETL ajoute : age, title_gender, country_code, number_of_days_since_subscription, target_zone, authentication_type, n_joined_communities_active, is_rosario_user, rosario_user_id, utm_*, last_digest_delivered, last_digest_open, n_unread_*

Cron Données SEO (Google Search Console)

Commande : api:cron:fetch-seo-clicks (cron day4, 4h AM)

Source : API Google Search Console — 365 jours de données de clics

Écrit dans : Community.seoClicks et Publication.seoClicks (colonnes directement dans les entités prod)

Garde-fou : Abort si < 100 communautés avec clics (indique un probleme API).

Tables ETL Re-importées en Production — Usage Fonctionnel Complet

PROD data_users → Ciblage donations, segmentation, Mailchimp

Entité : UserStats (Hozana) + Data\UserStats (Rosario)

Colonnes : behavioral_segment, ppd_segment, first_donation_probability, n_prayers, n_comments, last_activity, last_sent_email, is_rosario_user, is_donor_in_last_90_days

ConsommateurFichierUsage metier
CTADonationApi/Service/CTA/CTADonation.phpCiblage donation : Si first_donation_probability ≥ 0.2 → 80% de chance d'afficher le CTA. Si < 0.2 → 10%. Utilise aussi is_donor_in_last_90_days.
MailServiceApi/Service/MailService.phpGeneration de CTA contextualises dans les emails digest, welcome, new publication
/api/user/meApi/Controller/UserController.phpRenvoie behavioralSegment au frontend (default: "new-comers"). Utilisé pour personnaliser l'UX.
Admin usersAdmin/Controller/UserController.phpDashboard admin : n_prayers, n_comments par utilisateur
Rosario Mailchimprosario/backend/Service/MailchimpService.phpMerge fields Mailchimp : Si PPD80 ou PPD85 → ppdxx = 1 → ciblage campagnes fundraising Rosario
PROD data_publications → Analytics lectures par source

Entité : PublicationStats

Colonnes : n_read, n_read_email, n_read_feed_read_more, n_read_community_read_more, n_read_publication_page, n_ga_page_views

ConsommateurFichierUsage metier
PublicationRepositoryDbal/Repository/PublicationRepository.php:669LEFT JOIN PublicationStats dans getStatisticsByPublicationsOfCommunity() — stats de lecture ventilées par canal (email, feed, community, page directe)
Admin communauteFrontend-adminDashboard stats publications pour les responsables de communautés
PROD data_communities → Read rate et ranking

Entité : CommunityStats

Colonnes : read_rate, corrected_read_rate_in_first_publications, corrected_read_rate_quantile, corrected_transfo_rate_in_last_six_months

ConsommateurFichierUsage metier
CommunityControllerApi/Controller/Community/CommunityController.php:514Admin-only : expose readRate, correctedReadRate, correctedReadRateQuantile dans /api/community/{id}
Frontend-adminCommunityAdminForm.tsxAffiche le taux de lecture en % et le percentile : -(quantile - 11) * 10 pour convertir 0-10 en 0-100%
Frontend-5getCommunity.types.tsType optionnel pour les champs read rate
PROD data_announcements → Stats emails annonces

Entité : AnnouncementStats

Colonnes : n_email_delivered, n_email_viewed, n_email_clicked

ConsommateurFichierUsage metier
AnnouncementSerializerApi/Serializer/Announcement/AnnouncementSerializer.phpSerialise deliveredCount, openedCount, clickedCount dans toute API response Announcement

Mécanisme d'import : prepare_data_for_prod.sh créé des tables data_*_temp → dump hozana_data_for_prod.dump.sql.gz → SCP vers prod → load_data_tables_from_data_server.sh → import dans schema temp → core:data:load-data-tables-from-data-server → RENAME TABLE atomique (zero downtime).

Tables "Mystérieuses" — Résolution

TableOrigineExplication
unique_open_emailsETL-onlyCréée manuellement lors du premier deploiement ETL. Remplie incrémentalement par unique_users. Pas d'entité Doctrine — la table n'existe pas en prod.
community_joined_emailsETL-onlyMeme pattern : créée manuellement, remplie incrémentalement par community_stats depuis mail_events.
new_community_message_emailsETL-onlyTable ETL remplie depuis mail_events (category = new_community_message). Pas de creation visible dans le code.
community_join_groupsETLCréée par community_transformation_rate.py via pandas to_sql(if_exists='replace').
ga_communitiesETLCréée par ga_communities.py via pangres upsert (create_table=True). Stockee dans hozana_archive.
ga_publicationsETLCréée par ga_publications.py via pangres upsert. Stockee dans hozana_archive.
digest_emailsETL-onlyPattern incrémental depuis mail_events (category LIKE 'digest'). Creation initiale manuelle.
new_publication_emailsETL-onlyPattern incrémental depuis mail_events (category LIKE 'new_publication'). Creation initiale manuelle.

Acces au Serveur Data

# Prerequis : être connecté au VPN Hozana
ssh tsihapanya@data.hozana.org    # ou votre user SSH
sudo su hozana                     # switch vers le user hozana
cd ~                               # /home/hozana/
# Le projet ETL est dans ~/data-tools/
# Metabase tourne comme JAR : ~/metabase.jar (port 3000)
# Logs ETL : ~/data-tools/log/etl.log
# Performances : ~/data-tools/log/task_execution_times.csv

Déclenchement du Pipeline (Polling ETL-starter.sh)

Le crontab du user hozana contient :

*/5 23,0,1,2 * * * /home/hozana/ETL-starter.sh

Mécanisme réel (decouvert via SSH) : ETL-starter.sh est un polling intelligent execute toutes les 5 minutes entre 23h00 et 02h59 :

  1. Guard : Si pgrep -f "python.*etl.main" trouve un process → exit 0 (evite les doublons)
  2. Check DB : SELECT task FROM etl_performances WHERE status='SUCCESS' ORDER BY date DESC, start_time DESC LIMIT 1
  3. Condition : Si la dernière task contient mysql-anonymisation → le dump est importe et anonymisé
  4. Lancement : Insere starting-etl dans etl_performances puis appelle after_sql_dump_sent.sh.new

Fichiers sur le serveur :

Emplacement des dumps : /mnt/nvme/mysqldump/ (le chemin load_data.sh référence /mnt/nvme/mysqldump_from_prod/ qui n'existe plus)

# Fichiers trouvés sur le serveur :
/mnt/nvme/mysqldump/metabase.dump.sql.gz
/mnt/nvme/mysqldump/hozana_archive.dump.sql.gz
flowchart TD P["PROD send_mysql_dump_to_data.sh
mysqldump depuis mysql-replica.hozana.io"] --> S["SCP .sql.gz → /mnt/nvme/mysqldump/"] S --> LOAD["Import dumps + anonymisation
Écrit 'mysql-anonymisation' dans etl_performances"] LOAD --> POLL["Crontab */5 23-02h
ETL-starter.sh poll"] POLL -->|"Détecte mysql-anonymisation"| START["after_sql_dump_sent.sh.new"] START --> GIT["git pull"] GIT --> ETL["python3.10 -m etl.main
76 tasks ~7h
NB: load_data.sh est COMMENTE
dans .new — les dumps sont déjà importes
"] ETL --> PREP["prepare_data_for_prod.sh
Créé data_*_temp, dump .sql.gz"] PREP --> PROD["PROD load_data_tables_from_data_server.sh
RENAME TABLE atomique"] style POLL fill:#FFF4D5,stroke:#AD5700,color:#16181A style ETL fill:#DFF7DA,stroke:#3D7732,color:#16181A style PROD fill:#FDEEED,stroke:#AE0A4B,color:#16181A

Metabase

URL : https://metabase.data.hozana.org

Execution : JAR Java directement sur le serveur (~/metabase.jar), port 3000, derrière Apache avec SSL Let's Encrypt

Backups : Manuels avant chaque mise a jour (9 fichiers .tar.gz dans ~/, de oct 2024 a dec 2025)

Connexion DB : Directement au MySQL local (hozana_data, rosario, hozana_archive + schema metabase pour ses propres données)

Consommateurs : Équipe data/produit Hozana — dashboards, requêtes ad-hoc, exports

Infrastructure Serveur

ComposantDetails
Serveur Datadata.hozana.org — héberge l'ETL, MySQL ETL, Metabase
Metabasemetabase.data.hozana.org — port 3000 derrière Apache + SSL Let's Encrypt. Connecte directement a MySQL ETL.
Replica MySQLmysql-replica.hozana.io — source des dumps (pas le MySQL prod principal)
Dumps/mnt/nvme/mysqldump/ — 4 fichiers : hozana_prod, hozana_crm, hozana_rosario, hozana_archive (.sql.gz)
BackupsAWS S3 (4 buckets, 20j retention) + rsync.net (chiffre). Verification auto via monitoring.hozana.org + Sentry.
Stack ProdMySQL 8.4 + Elasticsearch + Redis + RabbitMQ + Apache + PHP-API (Symfony)

Tables exclues du dump principal

Le script send_mysql_dump_to_data.sh (/infra/backend/tools/sync/) exclut ces tables du dump hozana_prod :

Table exclueRaison probableOu va-t-elle ?
feed_itemsTres volumineuseDump archive séparé (seuls les items lus + events 3j+)
mail_eventsTres volumineuseDump archive séparé (events 3j+)
mailsEmails envoyes (PII)Exclue complètement
authentications_users_usersTokens OAuth (securite)Exclue complètement
cronlogsLogs techniques inutilesExclue complètement

Conséquence : L'ETL accede a feed_items et mail_events via hozana_archive, pas hozana_data. Toute task qui les chercherait dans hozana_data échouérait silencieusement.

Crons Backend (impactant l'ETL)

CronFréquenceImpact ETL
api:cron:cleanQuotidien 4h AMNettoie mail_events (~60j), feed_items, sent_push_notifications. L'ETL doit tourner AVANT le nettoyage ou utiliser mail_events_archive.
api:cron:fetch-seo-clicksQuotidien 4h AMÉcrit seoClicks dans communities/publications. Non lu par l'ETL (mais present dans le dump).
crm:cron:mailchimp:update-merge-fieldsHoraireSync champs Mailchimp. L'ETL lit les stats Mailchimp séparément.
core:data:load-data-tables-from-data-serverPost-ETLRe-importe les tables data_* calculées par l'ETL dans la prod.

Zones d'Ombre & Rapport d'Étonnement

Risques & Securite

🔴 Injection SQL potentielle dans plusieurs tasks

Nombreuses requêtes SQL construites par .format() sans paramétrage (bind variables). Exemples :

  • etl/task/email_stats.py:73-84 — format() avec min/max mail_event_id
  • etl/task/dau_mau.py:62-103 — format() avec day
  • etl/task/last_sent_emails.py:137-141 — format() avec values
  • etl/mysql/operations.py — quasi toutes les fonctions

Dans le contexte ETL interne c'est un risque faible (pas d'input utilisateur), mais c'est une mauvaise pratique systematique. Les connexions SQLAlchemy avec text() dans users_life_all sont un meilleur modele.

🔴 Mot de passe root en clair dans docker-compose.yml

docker-compose.yml:12 : MYSQL_ROOT_PASSWORD: root et HOZANA_DATA_DB_PASSWORD=root. Acceptable en dev/ETL interne, mais dangereux si le fichier est deploye en production ou si le port 3307 est expose.

🔴 Spreadsheet keys en dur dans le code

etl/task/ads_data.py et etl/task/nsm_users.py:18 contiennent des Google Sheets keys hardcodées. Si ces spreadsheets sont supprimés ou déplaces, les tasks échouént silencieusement.

Incoherences & Code mort

🟡 Tasks d'anonymisation présentes mais non enregistrées

Les fichiers suivants existent dans etl/task/ mais ne sont pas enregistrés dans main.py :

  • anonymization/hozana_users_anonymization.py
  • anonymization/rosario_users_anonymization.py
  • crm_contacts_names.py — locke contacts, lit last_name qui est droppé par crm_anonymization
  • special_appel_don_fr_paques_2026.py — task one-off

Sont-ils exécutés ailleurs ? Sont-ils obsolètes ?

🟡 users_segmentations v1 vs v2 : coexistence non documentee

Deux systemes de segmentation coexistent (users_segments et users_segments_v2). users_stats ne référence que v1 (users_segments). Quel est le statut de v2 ? Est-il utilise en production ? La v1 sera-t-elle supprimee ?

🟡 Seuils de segmentation hardcodes sans documentation

etl/task/users_segmentations.py:196-208 : les seuils de clustering (0.0153, 0.3254, 325.6437, 332.519) sont issus d'un notebook Jupyter (seg_comportementale hozana.ipynb) qui n'est pas dans le repo. Ces valeurs magiques sont figees et jamais re-évaluées.

Meme probleme dans users_donation_score.py:91-105 : coefficients de regression logistique hardcodes.

🟡 Seuils PPD percentile hardcodes par langue

etl/task/users_donation_score.py:119-147 : les seuils de PERCENT_RANK pour PPD80/85/90/95 sont differents par langue et semblent avoir été calibres une seule fois. Aucun mécanisme de recalibration.

🟡 open_coefficients hardcodes dans email_stats

etl/task/email_stats.py:224-231 : les coefficients (0.16, 0.33, 0.45, 0.51) pour estimer les unique opens sont hardcodes "by looking at sendgrid dashboard". Plus Sendgrid est utilise depuis ? Ces coefficients sont-ils encore valides avec Amazon SES ?

Données orphelines & Origines floues

🟡 Tables lues mais jamais créées/peuplées par l'ETL

Ces tables sont lues par des tasks mais ne sont pas peuplées par l'ETL — elles doivent provenir du dump prod :

  • authentications (lu par users_authentication_type)
  • user_referrals (lu par lang, users_utm)
  • intentions (lu par users_life_all, users_life_intentions, activity_stats)
  • comments (lu par users_life_all, activity_stats, users_stats)
  • new_community_message_emails (lu par community_stats)
  • community_join_groups (lu par community_stats)
  • feed_items (lu par read_rate, publication_stats — dans hozana_archive)
  • ga_communities, ga_publications (archive — peuplées par tasks GA4)

La plupart sont des tables prod dumpées. Mais new_community_message_emails et community_join_groups pourraient être des tables ETL dont la creation n'est pas visible.

🟡 Table unique_open_emails : pas de creation visible

etl/task/unique_users.py:29-47 fait un SELECT max(mail_event_id) FROM unique_open_emails puis un INSERT IGNORE. Mais la creation de cette table n'est pas dans le code ETL. Elle doit exister avant le premier run. Creation manuelle ? Script d'initialisation perdu ?

🔵 community_joined_emails : pas de creation visible

etl/task/community_stats.py:177 lit community_joined_emails mais la creation de cette table n'est pas dans le code ETL visible. Meme probleme que unique_open_emails.

Risques operationnels

🔴 Tasks Saturday-only dependantes de tasks daily

users_donation_score est daily mais depend de UsersLifeAll qui est saturday-only. Du lundi au vendredi, elle utilise les données du dernier samedi. C'est documente et intentionnel (le code le gere), mais le risque est que si le run de samedi échoué, les 6 jours suivants utilisent des données de 2 semaines.

Meme pattern pour users_stats qui lit users_segments (saturday) mais ne declare pas la dépendance car "elle ne tourné que le samedi" (commentaire dans le code).

🟡 Pas de validate_data() sur la majorite des tasks

Sur 76 tasks, 9 implémentent validate_data() : users_stats, users_donation_score, read_rate, community_stats, crm_contacts, rosario.user_adjust_parameters, rosario.users_target_zone, rosario.ghost_users, rosario.activity_score. Les 67 autres n'ont aucune validation post-exécution. Les tasks critiques sans validation incluent : users_life_all, users_segmentations, unique_users, nsm_users.

🟡 unique_users : DROP TABLE puis re-creation chaque run

etl/task/unique_users.py:101-113 fait un DROP TABLE IF EXISTS unique_users puis recréé la table. Si le run échoué entre le DROP et l'INSERT final, la table est vide. Toutes les tasks dependantes (users_stats, nsm_users, dau_mau, activity_stats) échouéront ou produiront des résultats vides.

🟡 users_life_all : consommation mémoire potentiellement elevee

La task charge des dictionnaires Python count_per_user en mémoire pour chaque chunk (1/8 des users). Avec ~3M users, chaque chunk représente ~375K users x 7 period_types x N périodes. Le commentaire indique un split en chunks pour eviter l'OOM, mais le risque persiste si la base grossit.

🔵 email_stats : archive mail_events > 33 jours

La task déplace les mail_events de plus de 33 jours dans mail_events_archive. Ce seuil est hardcode dans email_stats.py:235. Aucune documentation expliquant pourquoi 33 jours (probablement la période maximale d'un mail bounce + marge).

🔵 read_rate utilise ThreadPoolExecutor (4 workers)

etl/task/read_rate.py:121 est la seule task qui utilise du multi-threading interne. Cela signifie que quand l'orchestrateur la lance, elle consomme 4 connexions MySQL simultanées, ce qui peut interferer avec les autres tasks parallèles.

🔵 Pas de mécanisme de retry

L'orchestrateur n'a aucun mécanisme de retry. Si une task échoué (OOM, timeout MySQL, erreur reseau GA4/Mailchimp), elle est marquee failed et toutes ses dependantes sont skipped. Pour les tasks qui font des appels API externes (ga4, mailchimp, gsheet), c'est un risque.

Questions ouvertes

🟡 RÉSOLU : Tables incrémentales sans CREATE TABLE visible

Verdict après scan du backend : Les tables unique_open_emails, community_joined_emails, new_publication_emails, digest_emails, new_community_message_emails sont des tables ETL-only qui n'existent PAS en production (pas d'entité Doctrine correspondante). Elles ont été créées manuellement lors du premier deploiement ETL et sont remplies incrémentalement. Risque : si la base ETL est recréé from scratch, ces tables n'existeront pas et les tasks échouéront. Un script d'initialisation devrait être versionne.

🔵 RÉSOLU : Tables rosario — proviennent du dump prod Rosario

Les tables prayer, participation, user, chapelet, community, parish, device, stream, adjust_event sont dumpées depuis le backend Rosario (projet app + backend separe du backend Hozana Symfony — non encore accessible pour analyse). Le dump passe par send_mysql_dump_to_data.sh sous le nom hozana_rosario.dump.sql.gz.

🔴 DECOUVERTE : Flux bidirectionnel non documente

L'ETL n'est pas qu'un consommateur de la prod : il produit 4 tables (data_users, data_publications, data_communities, data_announcements) qui sont re-importées en production via load_data_tables_from_data_server.sh + swap atomique. Ce flux retour n'est documente ni dans CLAUDE.md ni dans le README. Si l'ETL échoué, la prod utilise des données stale. Les entités Doctrine correspondantes existent dans src/Dbal/Entity/Data/.

🟡 DECOUVERTE : Le dump utilise un REPLICA, pas la prod principale

Le script send_mysql_dump_to_data.sh dumpe depuis mysql-replica.hozana.io, pas le serveur MySQL principal. Si la replication a du retard (lag), l'ETL travaille sur des données decalees. Aucun check de lag de replication n'est visible dans le script.

🟡 DECOUVERTE : 5 tables exclues du dump principal

send_mysql_dump_to_data.sh exclut feed_items, mail_events, mails, authentications_users_users, cronlogs. Les deux premières vont dans le dump archive. Les trois autres sont perdues. Si une task ETL tente de lire mails ou authentications_users_users, elle échouéra. Actuellement users_authentication_type lit authentications (sans _users_users) donc ca fonctionne — mais la distinction de nommage est fragile.

🔵 DECOUVERTE : Le Perl inline dans load_data.sh supprime TOUTES les FK

scripts/load_data.sh:42-57 contient un filtre Perl qui supprime toutes les lignes CONSTRAINT du dump SQL avant import. C'est une approche agressive mais cohérente avec prepare_db.py qui drop aussi les FK. Cela signifie que la base ETL n'a aucune FK, même celles que l'ETL n'a pas besoin de supprimer.

🟡 DECOUVERTE : Conflit potentiel entre nettoyage cron et ETL

Le cron backend api:cron:clean (4h AM) nettoie mail_events (~60 jours), feed_items, etc. Si l'ETL n'a pas encore tourné quand le nettoyage s'execute, des données peuvent être perdues. L'ETL pallie partiellement en archivant mail_events dans mail_events_archive (task email_stats), mais le timing est critique.

🔵 Quelle est la durée typique d'un run complet ?

Le code prévoit une alerte Sentry si le run dépasse 18h (etl/utils/orchestrator.py:120). Cela implique que le run habituel est probablement entre 4-8h. Le fichier log/task_execution_times.csv contient l'historique mais n'est pas dans le repo.

🔴 LIVE : unique_users en échec le 24/03 — cascade de skips

Le run du 24/03/2026 montre que unique_users a échoué, entrainant le skip de activity_stats, users_stats, et probablement nsm_users, dau_mau. La table data_users re-importée en prod utilise donc des données du run precedent. Impact prod : segmentation marketing et scoring donateurs potentiellement stale.

🟡 Temps CPU cumulé a 18.8h — seuil Sentry a 18h

Le temps CPU cumulé du run du 24/03 est de 67 669s (18.8h). Le code orchestre un alert Sentry quand le temps reel dépasse 18h (orchestrator.py:120). Mais le temps réel etait de 7.1h grâce au parallelisme. Le risque est qu'un samedi (avec les tasks weekly), le temps réel dépasse le seuil.

🟡 read_rate monopolise 51% du temps d'execution

read_rate a pris 3h37 (13 082s) sur le run du 24/03 — plus de la moitie du temps réel total. Cette task utilise ThreadPoolExecutor(max_workers=4) en interne, multipliant les connexions MySQL. C'est le goulot d'etranglement principal du pipeline.

🟡 Chemin des dumps a change + script .new non commité

Chemin : load_data.sh référence /mnt/nvme/mysqldump_from_prod/ mais les dumps sont en réalité dans /mnt/nvme/mysqldump/. Seuls 2 fichiers trouvés : hozana_archive.dump.sql.gz et metabase.dump.sql.gz (les 4 dumps attendus ne sont pas tous présents — soit ils sont nettoyés après import, soit le chemin a encore change).

Script .new : Le crontab appelle after_sql_dump_sent.sh.new (mai 2025), pas l'original (fev 2023). Ce fichier .new n'est probablement pas dans le repo git (il est dans scripts/ mais avec un suffixe non standard). Risque : si git pull est execute, le .new peut ne pas être ecrase mais le .sh original oui.

🟡 Google Sheet "SEO Budget" depend d'un IMPORTRANGE

Le sheet SEO Budget utilise IMPORTRANGE vers un autre spreadsheet (1HqAp4IuwWlF_...). L'ETL lit le sheet via l'API GSheets avec value_render_option='UNFORMATTED_VALUE' — les formules IMPORTRANGE ne sont pas évaluées cote API si le sheet n'a pas été ouvert recemment dans un navigateur. Risque de données vides.

🔵 Google Sheet "Coreg budget" — colonne utm_medium non mappée

Le fichier réel contient une colonne utm_medium absente du columns_mapping dans ads_data.py. Cette colonne est importée dans la table mais avec son nom GSheet original ("utm_medium"), pas normalise. Pas critique mais incoherent avec le pattern des autres sheets.

🔵 Metabase : backups manuels, pas de monitoring automatisé

Metabase tourne comme un JAR Java sur le serveur data. Les backups sont manuels (9 tar.gz dans ~/ de oct 2024 a dec 2025). Pas de backup automatisé, pas de monitoring de disponibilité. Si le processus Java meurt, personne n'est alerte.

🔵 Naming incoherent : "Autre budget" vs "print_budget"

Le Google Sheet s'appelle "Autre budget" mais est mappe comme print_budget dans le code ETL. La table cible s'appelle aussi print_budget. Le contenu (Date, Media, Campagne, utm_*, Budget) correspond bien a du print, mais le naming du sheet prete a confusion.

🔵 etl/utils/sql.py référence mais non explore

where_chunk() dans etl/utils/sql.py est utilise par users_life_all, users_life_cp, users_life_intentions pour partitionner les queries. Ce fichier n'a pas été lu (non inclus dans les fichiers explores). Il implemente probablement un WHERE user_id % N = chunk.

Operations Quotidiennes

Healthcheck du matin (5 min)

#ActionCommande / URLQuoi vérifier
1Vérifier le rapport emailBoite mail (arnaud@, thomas-sihapanya@)Subject "ETL OK" ou "X erreurs ETL". Ouvrir si erreurs.
2Derniere ligne du log
ssh data.hozana.org
sudo su hozana
tail -5 ~/data-tools/log/etl.log
Doit finir par "End ETL main process" + heure raisonnable (< 08h)
3Temps d'execution
tail -3 ~/data-tools/log/task_execution_times.csv
Ligne "real" < 30000s (8.3h). Ligne "total" < 65000s (18h). Alerte si approche du seuil Sentry.
4Tasks en échec
grep "failed\|skipped" ~/data-tools/log/etl.log | tail -20
Identifier les tasks failed et les cascades de skips. Prioriser : unique_users, users_life_all, crm_orders.
5Metabase accessiblemetabase.data.hozana.orgPage de login chargée = OK. Si timeout → JAR Java peut-être crashe.

Commandes de diagnostic

# Connexion au serveur data
ssh [user]@data.hozana.org && sudo su hozana && cd ~

# Vérifier si l'ETL tourné actuellement
pgrep -f "python.*etl.main" && echo "ETL EN COURS" || echo "ETL PAS EN COURS"

# Derniere task reussie dans la DB
mycli --login-path data -D hozana_data -e \
  "SELECT date, task, duration, status FROM etl_performances ORDER BY date DESC, start_time DESC LIMIT 10;"

# Taille des tables ETL-only (sont-elles peuplées ?)
mycli --login-path data -D hozana_data -e \
  "SELECT table_name, table_rows, ROUND(data_length/1024/1024,1) as size_mb
   FROM information_schema.tables WHERE table_schema='hozana_data'
   AND table_name IN ('unique_users','unique_prayers','data_users','users_segments','user_donation_score');"

# Vérifier le dump exportée vers la prod
ls -lh ~/hozana_data_for_prod.dump.sql.gz

# Logs d'une task spécifique (ex: read_rate)
tail -50 ~/data-tools/log/etl.task.read_rate.log

# Relancer l'ETL manuellement (ATTENTION: vérifier qu'il ne tourne pas déjà)
cd ~/data-tools && python3.10 -m etl.main

# Relancer UNE task spécifique
cd ~/data-tools && python3.10 -m etl.run_task etl.task.users_donation_score

Alertes automatiques existantes

AlerteConditionCanalAction
SentryTemps réel > 18hSentry dashboardIdentifier la task la plus lente (probablement read_rate). Vérifier si ThreadPoolExecutor bloque.
Email rapportToujours envoyearnaud@, thomas-sihapanya@Lire le rapport. Si erreur de validation → vérifier la coherence des données.
Sentry bash-hookErreur dans les scripts shellSentryETL-starter.sh, after_sql_dump_sent.sh.new, prepare_data_for_prod.sh rapportent a Sentry.

Alertes manquantes (recommandées)

Alerte proposeeConditionImpact si absent
Metabase downHTTP check sur metabase.data.hozana.orgÉquipe data aveugle sans dashboards
Dump non arrivéETL-starter.sh poll sans mysql-anonymisation après 02hETL ne tourne pas, données stale en prod
unique_users échecTask failed dans etl_performancesCascade : users_stats, nsm_users, dau_mau, activity_stats tous skipped
data_* stale en proddata_users.updated_at > 48h (verifiable cote Rosario UserStats)CTA donation et Mailchimp merge fields désynchronisés

Maintenir ce Document Vivant

Pourquoi ce document va devenir obsolete

Ce document est un snapshot au 24/03/2026. Il deviendra obsolete des que :

Axes d'amélioration

AxeDescriptionEffort
Auto-generationParser main.py, les get_dependencies(), get_locked_tables(), only_on_saturday() pour générer le graphe de dépendances et le catalogue automatiquement. Un script Python qui produit le HTML.2-3j
Données liveInterroger etl_performances pour afficher les durées réelles du dernier run, les tasks en échec, les tendances. Remplacer les KPIs statiques par des requêtes SQL live.1j
Validation coverageAjouter validate_data() aux 72 tasks qui n'en ont pas. Commencer par les tasks critiques : unique_users, users_life_all, nsm_users.3-5j
ObservabilitéDashboards Metabase dédiés : durée par task sur 30j, taux d'échec, tendance du temps total. Alertes Slack/email si task critique échoué.1-2j
Tests d'intégrationTests qui vérifient que les tables source existent avec les colonnes attendues avant de lancer l'ETL. Evite les échecs silencieux.2j

Skills Claude Code recommandées

Voici des skills a creer pour maintenir ce document et monitorer l'ETL :

/etl-health — Diagnostic rapide de l'ETL (a lancer quotidiennement)

Déclencheur : /etl-health

Actions :

  1. Lire log/etl.log (derniers 50 lignes)
  2. Lire log/task_execution_times.csv (dernier run)
  3. Identifier les tasks failed/skipped
  4. Calculer le temps réel et CPU cumulé
  5. Comparer avec les seuils (8h reel, 18h CPU)
  6. Produire un rapport resume avec recommandations
/etl-cartography-update — Mettre a jour cette cartographie

Déclencheur : /etl-cartography-update

Actions :

  1. Parser main.py pour détecter les nouvelles tasks
  2. Pour chaque nouvelle task : lire le code, extraire dependencies/locks/saturday
  3. Vérifier que prepare_data_for_prod.sh n'a pas change
  4. Mettre a jour docs/etl-cartography.html
  5. Lister les changements dans un resume
/etl-sql-validator — Valider les queries SQL avant merge

Déclencheur : Pre-commit ou PR review

Actions :

  1. Détecter les fichiers ETL modifies dans le diff
  2. Extraire les requêtes SQL
  3. Vérifier : pas de SELECT * sans WHERE, pas de DROP TABLE sans IF EXISTS, paramétrage des queries (pas de .format() avec des variables utilisateur)
  4. Vérifier la coherence des locks (si une task UPDATE une table, elle devrait la declarer dans get_locked_tables)
/etl-pr — Creer un PR structure pour un fix ETL

Déclencheur : /etl-pr

Skill existante : Deja configuree dans les skills Claude Code du projet.