feat(DENG-11237): export referral totals CSV to GCS - #9738
Conversation
Desktop MVP for the Firefox Referral Program. Counts Firefox first_run installs per referral (invite) code via the GA4 / download-attribution path (fxrefer: utm_content -> dl_token -> baseline_clients_first_seen), following the cfs_ga4_attr_v1 pattern, and exports cumulative totals to the Website team's GCS bucket as CSV. - referral_installs_daily_v1: incremental per-code first_run counts - referral_installs_totals_v1: cumulative rollup, zero-install codes omitted - referral_installs_totals_to_gcs_v1: CSV extract to GCS (bucket TBD) - checks.sql: warn on invite_code length drift (expected 17 chars) - tests/sql fixture for the daily query Fenix is stubbed (commented UNION ALL) pending the Play Store attribution field; iOS is out of scope. GCS destination is a placeholder pending the bucket + IAM from the Website team.
referral_installs_daily_v1 sets require_partition_filter: true, so the full-refresh totals query must filter submission_date or BigQuery rejects it at runtime and in dry-run CI. Add an all-time filter that scans every partition while satisfying the requirement.
The stage-deploy CI runs 'bqetl deploy --tables --isolated' over changed paths and discovers the query.py extract task as a table artifact. With no schema.yaml it can't resolve a schema (a .py can't be dry-run, and the new firefox_referral_derived dataset doesn't exist in prod for get_table), so the deploy fails. Ship a schema.yaml so schema resolution short-circuits; it also documents the exported CSV column contract.
The export task was scheduled in bqetl_firefox_referral with a placeholder destination bucket, so post-deploy Airflow would run it daily and fail until the Website team supplies the real bucket + IAM. Remove the export task (referral_installs_totals_to_gcs_v1) and its referral_export module from this PR, keeping just the two aggregate tables. The export lands as a fast-follow once the bucket is available.
The Test SQL job runs the daily-query fixture against BigQuery. attribution_ext
is a JSON column; the fixture provided it as a quoted JSON string, which loads
as a JSON string scalar so attribution_ext.dltoken is NULL and the join drops
every row (query returned []). Use an inline YAML object, matching how other
fixtures represent JSON columns (e.g. event_extra: {attempt: 1}).
Fast-follow to the referral aggregation tables. Now that the Website team has provisioned the bucket (fx-referral-data-prod in moz-fx-springfield-prod) and granted the Airflow workload SA write access (webservices-infra#11912), wire up the CSV-to-GCS export: - referral_installs_totals_to_gcs_v1: query.py extract of referral_installs_totals_v1 to referral_data-<ts>.csv at the bucket root, no header, daily. Joins the bqetl_firefox_referral DAG downstream of totals. - bigquery_etl/referral_export: CSV extract module; --destination-prefix is now optional (writes to bucket root when omitted). Stacked on the aggregation-tables PR (#9724).
There was a problem hiding this comment.
This adds a query.py GKEPodOperator task (referral_installs_totals_to_gcs_v1) that extracts referral_installs_totals_v1 to a timestamped headerless CSV in gs://fx-referral-data-prod, a new bigquery_etl/referral_export module implementing the extract, and updates the bqetl_firefox_referral DAG description. The task wires into the DAG downstream of the totals table via scheduling.referenced_tables, and the CLI/metadata shape closely follows the existing newtab_merino_extract_to_gcs_v3 precedent.
Main thing to resolve is the schema.yaml: unlike the newtab export tasks, its presence makes deploys create an empty table in firefox_referral_derived rather than skipping the artifact. The other two comments are about the unreachable post-result() error branch and the non-idempotent wall-clock filename.
On the open question in the description about root vs. subfolder: that's a contract question for the Website team and not something I can verify from the repo, so it's untouched here.
| @@ -0,0 +1,17 @@ | |||
| # This task extracts referral_installs_totals_v1 to a CSV in GCS; it does not | |||
| # build a queryable table. The schema is declared so stage-deploy CI can resolve | |||
There was a problem hiding this comment.
issue: this schema.yaml will cause deploys to create a real, permanently empty table moz-fx-data-shared-prod.firefox_referral_derived.referral_installs_totals_to_gcs_v1.
The stated reason is backwards. query.* matches query.py, so the dir is already collected as a table artifact (bigquery_etl/cli/query.py schema deploy, and stage deploy via bigquery_etl/cli/stage.py:597) — the thing that keeps a query.py export task from being deployed is the absence of a schema file: deploy_table raises SkippedDeployException("Schema missing for ...") at bigquery_etl/deploy.py:93-96, and skips are non-fatal (bigquery_etl/cli/deploy.py:1129-1131). By adding the file you turn that skip into a _create_or_update against prod and stage.
The two existing GCS-export analogues, sql/moz-fx-data-shared-prod/telemetry_derived/newtab_merino_extract_to_gcs_v3/ and newtab_merino_priors_to_gcs_v1/, contain only metadata.yaml + query.py for exactly this reason.
Fix: delete this file and move the CSV column contract into the metadata.yaml description. If you want to keep the schema as documentation, also set scheduling.destination_table: null in metadata.yaml, which deploy_table honours explicitly (bigquery_etl/deploy.py:71-79).
There was a problem hiding this comment.
need to keep the empty schema table to pass CI, the empty table is harmless
| ) | ||
| extract_job.result() # Waits for the job to complete. | ||
|
|
||
| if extract_job.state != "DONE": |
There was a problem hiding this comment.
suggestion: this branch is unreachable. extract_job.result() on line 85 blocks until the job finishes and raises the underlying GoogleAPICallError if it failed, so by the time line 87 runs state is always "DONE" — a failed export surfaces as the exception from result(), never as this raise. (The newtab_merino module has the same shape, but there it only log.errors, so the dead code is harmless there.)
Drop lines 87-88 and let result() propagate, or if you want a distinct message, wrap the call:
try:
extract_job.result() # Waits for the job to complete.
except Exception as e:
raise click.ClickException(
f"Export to {destination_uri} failed: {extract_job.errors}"
) from eThere was a problem hiding this comment.
added try/except raising click.ClickException(f"Export to {destination_uri} failed: {extract_job.errors}")
| """Extract the referral totals table to a timestamped CSV file in GCS.""" | ||
| client = bigquery.Client(source_project) | ||
|
|
||
| timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dZ%H:%M:%S") |
There was a problem hiding this comment.
suggestion: deriving the filename from wall-clock time makes the task non-idempotent. An Airflow retry (or a manual rerun/clear) after a partially-completed run writes a second referral_data-*.csv for the same logical date rather than replacing the first, and nothing in this module or the destination bucket prunes old objects — so the bucket accumulates one-plus file per day indefinitely and the Website ingestion has to infer which object is authoritative.
Two options that fit existing repo patterns:
- Pass the run date through and use it in the name:
--date={{ds}}inscheduling.argumentsis well established forquery.pytasks (e.g.sql/moz-fx-data-shared-prod/ai_usage_derived/claude_usage_v1/metadata.yaml:18). A retry then overwrites the same object. - Write a stable
referral_data-latest.csvalongside the timestamped copy, and add a retention option, asbigquery_etl/newtab_merino/__init__.pydoes withlatest.json+--deletion-days-old.
There was a problem hiding this comment.
added an idempotent filename using param --date to the module and --date={{ds}}
This comment has been minimized.
This comment has been minimized.
- No empty companion table: set scheduling.destination_table: null so
deploy_table skips table creation (prod + stage). Kept schema.yaml because
the --isolated stage deploy resolves the query.py artifact via
_resolve_isolated_schema, which raises (not skips) when no schema resolves;
destination_table: null is the mechanism that prevents the table. Moved the
CSV column contract into metadata.yaml.
- Idempotent output filename: take --date={{ds}} and name the file
referral_data-<date>.csv so an Airflow retry overwrites the same object
instead of accumulating one file per run.
- Dropped the unreachable post-result() state check; wrap result() to raise a
distinct ClickException with the destination and job errors.
…port The prior fix used scheduling.destination_table: null, but that field with a query.py trips DAG task validation (task.py: destination_table None and query_file_path None -> 'One of destination_table or query_file_path must be specified'); the only existing destination_table:null tasks are query.sql. Use the reviewer's primary suggestion instead: ship only metadata.yaml + query.py (no schema.yaml), matching the newtab_merino GCS-export tasks. On the current deploy code a missing schema is a non-fatal SkippedDeployException (dry-run path) / _resolve_isolated_schema is skipped when there's no manifest, so no empty table is created and stage-deploy doesn't fail. CSV column contract stays documented in metadata.yaml.
4fad00f to
44091d9
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…ds it)
Removing schema.yaml re-broke 'Deploy Changes to Stage': the --isolated deploy
resolves the query.py artifact via the schema ladder and hard-fails (can't
dry-run .py; SELECT*/get_table on the nonexistent table denied) — it does NOT
skip. And destination_table: null (the clean no-table signal) is incompatible
with query.py: of_python_script sets query_file_path after construction, so the
Task __attrs_post_init__ check fails DAG generation.
So the only config that passes both Test bqetl and stage-deploy is schema.yaml
present + no destination_table: null. Downside: an empty companion table is
created on deploy. Documented the constraint + tradeoff in schema.yaml.
Keeps the --date={{ds}} idempotency and distinct-error-message fixes.
This comment has been minimized.
This comment has been minimized.
|
Couple of comments |
| referral_installs_daily_v1 -> referral_installs_totals_v1 -> | ||
| referral_installs_totals_to_gcs_v1. | ||
| repo: bigquery-etl | ||
| schedule_interval: 0 4 * * * |
There was a problem hiding this comment.
The date is off by one: the DAG runs 0 4 * * *, so {{ds}} is the previous day. The file written on Aug 6 is named referral_data-2026-08-05.csv. If the Website team's job looks for today's date it might not find any file.
There was a problem hiding this comment.
this is expected and intentional. {{ds}} is the logical date (interval start), so the 0 4 * * * run on Aug 6 writes referral_data-2026-08-05.csv — a day behind wall-clock. This keeps the export idempotent: a retry or backfill for a logical date overwrites the same file rather than creating a new one.
| -- test codes (e.g. TESTCODE01) and any pre-rollout data may not be 17 chars yet. | ||
|
|
||
| #warn | ||
| ASSERT ( |
There was a problem hiding this comment.
Do we need checks.sql for exporting jobs? Seems redundant.
Also checks.sql is obsolete. We have moved to Bigeye checks.
There was a problem hiding this comment.
good catch - that checks.sql was a leftover from PR 9724 and should not be included - removed!
alekhyamoz
left a comment
There was a problem hiding this comment.
r+wc
Approved as long as concerns are resolved
This branch predates the checks->Bigeye migration in #9724, so its diff was re-adding referral_installs_daily_v1/checks.sql — a file that no longer exists on main (replaced by bigconfig.yml) and that belongs to the daily table, not this export PR. Remove it so #9738 only touches the export artifacts.
Integration report
|
Right now referral_export has no retention, it writes one referral_data-.csv per day (a retry overwrites the same day's file, but each new day adds one), so left alone the bucket accumulates a small CSV per day. Regarding ownership, since fx-referral-data-prod is the Website team's bucket in their project - if they want to delete files after a certain amount of time - my preference is a GCS Object Lifecycle rule on their side (auto-delete objects older than N days) - zero code, and it keeps our Airflow SA write-only (objectCreator). @stevejalim it is your bucket: do you prefer to keep the CSVs accumulating each day, creating lifecycle TTL rule on your side, or us pruning in the exporter? Not urgent (files are tiny and empty until the instrumentation is live). |
Yeah, we have a 90-day retention policy on the bucket via GCS Object Lifecycle - sounds like we're on the same page ✅ |
Description
Fast-follow to #9724 (the referral aggregation tables). Wires up the CSV-to-GCS export now that the Website team has provisioned the destination bucket and granted IAM.
deng-11237-firefox-referral-pipeline, so this diff shows only the export. Merge #9724 first; GitHub will retarget this tomainautomatically once it does.What this adds
referral_installs_totals_to_gcs_v1— aquery.pytask that extractsreferral_installs_totals_v1to a timestamped CSV (referral_data-<ts>.csv, no header) at the root of the destination bucket. Joins the existingbqetl_firefox_referralDAG downstream ofreferral_installs_totals_v1(viascheduling.referenced_tables).bigquery_etl/referral_export/— the CSV extract module.--destination-prefixis now optional (writes to the bucket root when omitted).Destination
fx-referral-data-prod(projectmoz-fx-springfield-prod), owned by the Website team.default-workloads@moz-fx-data-airflow-gke-prod.iam.gserviceaccount.comwas granted write access via mozilla/webservices-infra#11912.invite_code,total_installs(no header), one file per daily run — matches the Website team's requested format.Notes
--destination-prefixif a subfolder is preferred.fxrefer:instrumentation ships and codes begin flowing — expected.Related Tickets & Documents
Reviewer, please follow this checklist