Authenticating...
Skip to main content

Campaign Metrics Propagation Guide

The campaign_metrics dbt model in airflow-dags is the canonical source of campaign-level performance KPIs (RPC, EPC, RPM, ECPI, ERPI, network EPC/EPI, conversion rate, ECR). Its rows are pushed daily into new_dashboard via an HTTP endpoint, persisted through the shared adgem/common Composer package, and re-emitted as a CampaignMetricsChanged EventBridge event consumed by the Offer API.

Any column change therefore touches four moving parts in three repos plus a Composer package. The wrong deploy order — or trying to swap a column name atomically across all of them — breaks the daily push silently and leaves stale metrics downstream: the Spatie DTO marks every metric as required, so a missing or renamed key rejects the whole row until the consumers catch up.

Treat every column change as an additive deprecation. At no point should the producer and consumer disagree about the schema. The new shape lands on the consumer first, the old shape leaves the producer first, and the two overlap in between.

TL;DR

ChangePattern
Add a columnConsumer accepts it (nullable) → producer emits it → consumer makes it required
Remove a columnConsumer relaxes it to nullable → producer stops emitting → consumer drops the field → migration drops it
Rename a columnProducer emits both names → consumer accepts either → producer drops the old name → consumer drops the old name

If you can't describe your change as one of these three patterns, you are about to break the chain. Stop and plan it as a deprecation before touching code.

The propagation chain

Each arrow is a contract. The DAG runs daily, so a mismatch shows up at the next push, not at deploy time. The table below names each link.

The Schema authority column flags the files whose column lists together form the contract — if those disagree, the daily push fails or silently drops columns. The other rows are runtime plumbing for the same chain.

LinkRepoFileSchema authority
dbt model (producer)airflow-dagsdbt/dbt-adgem/models/metrics/campaign_metrics.sqlyes
dbt column docsairflow-dagsdbt/dbt-adgem/models/metrics/schema.ymldescriptions only
Sync DAGairflow-dagsadgem/ops/send_metrics_to_dash.py (sync_campaign_metrics)
Redshift → HTTP clientairflow-dagslib/adgem/campaign_metrics.py (CampaignMetrics)yes — its SELECT is the wire contract
HTTP DTO (validation)new_dashboardapp/Data/CampaignMetricData.phpyes
Eloquent modelnew_dashboardapp/Models/CampaignMetric.php ($fillable / $visible; extends AdGem\Common\Models\CampaignMetric)yes
Base model + migrationadgem/common (Composer)Models/CampaignMetric + the campaign_metrics migrationyes
EventBridge publishnew_dashboardapp/Jobs/EventBridge/PublishCampaignMetricsChangedEventJob.phpderived (CampaignMetric->makeHidden([...])->toArray())

Things that aren't obvious from reading the schema

These hold across column changes and are worth knowing before you read the files above:

  • predicted_rpc is in the Redshift table but never crosses the HTTP boundary. The SELECT in airflow-dags/lib/adgem/campaign_metrics.py::CampaignMetrics.get_data_from_dbt is the wire contract, not the dbt model itself. Downstream consumers see predicted_rpc only via network_epc = predicted_rpc * 0.65.
  • The d30 columns are placeholder zeros. The dbt model emits rpc_d30, epc_d30, rpm_d30 as hardcoded 0 to keep the DTO contract honest; no d30 cohort is computed today.
  • rpc_d7 and rpc_d14 are sourced from ref('predicted_rpc') (organic_rpc_d7 / organic_rpc_d14, renamed on read) so there's one source of truth for the cohort-based d7/d14 RPC.
  • The Spatie DTO marks every metric as 'required'. A missing or renamed key rejects the whole row. This is by design — silent drops are worse than a loud failure — but it is also why every change has to be staged as a deprecation.
  • distinct_clicks is the 31-day click_dt window from cohort_daily_adgem_stats; the cohort filter (days_after_click_load <= N) is applied to revenue/earnings only, not to the click denominator.

Change recipes

The two principles, applied to all three change kinds:

  1. The consumer learns about the new shape first. It must accept the new field (or new name) before the producer starts emitting it.
  2. The producer forgets the old shape first. It must stop emitting the field (or old name) before the consumer drops the requirement.

Adding a column

  1. adgem/common — add the column to the campaign_metrics migration as nullable; add it to the base CampaignMetric model's fillable list. Release a new minor version of the package.
  2. new_dashboard — bump adgem/common. Add the field to CampaignMetricData (constructor, updateOrCreate payload, rules()) as nullable to start. Add it to CampaignMetric::$fillable and $visible. Deploy. The DTO now accepts the field if dbt sends it and tolerates its absence if dbt does not.
  3. airflow-dags — add the column to campaign_metrics.sql, the SELECT in lib/adgem/campaign_metrics.py::CampaignMetrics.get_data_from_dbt, and the DataFrame columns list. Document it in dbt/dbt-adgem/models/metrics/schema.yml. Deploy.
  4. new_dashboard — once the daily DAG has pushed the new field successfully, promote it from nullable to required in the DTO. Deploy.

The window between steps 2 and 3 is the deprecation window in reverse: the consumer is prepared for a field that doesn't yet exist. The window between 3 and 4 is brief — usually one daily run — and exists only to confirm end-to-end before tightening the contract.

Removing a column

  1. new_dashboard — relax the field in the DTO from required to nullable. Keep it in $fillable and $visible for now. Deploy. The DTO now tolerates the absence of the field.
  2. airflow-dags — remove the column from campaign_metrics.sql, from CampaignMetrics.get_data_from_dbt's SELECT and DataFrame columns, and from schema.yml. Deploy. Wait one daily run.
  3. new_dashboard — drop the field from CampaignMetricData, from CampaignMetric::$fillable and $visible, and from the EventBridge publish job if it was explicitly listed. Deploy.
  4. adgem/common — drop the column from the migration. Release.

Doing step 2 before step 1 means the next daily push arrives missing a required field and the DTO rejects every row.

Renaming a column

A rename is an add and a remove run back-to-back, with both names co-existing in the middle.

  1. adgem/common — add the new column nullable alongside the old one; keep the old column on the model. Release.
  2. new_dashboard — bump adgem/common. Add the new field to the DTO as nullable; keep the old field. Decide which one updateOrCreate writes (prefer the new one when present, otherwise the old one). Deploy.
  3. airflow-dags — emit both the old and the new column from campaign_metrics.sql. Add the new column to the Python SELECT and DataFrame. Deploy. The DAG now writes both names; the DTO accepts both.
  4. new_dashboard — promote the new field to required; relax the old field to nullable. Deploy.
  5. airflow-dags — drop the old column from the dbt model and the Python layer. Deploy. Wait one daily run.
  6. new_dashboard — drop the old field from the DTO and model. Deploy.
  7. adgem/common — drop the old column from the migration. Release.

It is more steps than an atomic swap. That is the point — an atomic rename across three repos always has a window in which one side has the new name and the other side does not.

Known consumers of CampaignMetricsChanged

The PublishCampaignMetricsChangedEventJob fires an EventBridge event CampaignMetricsChanged whose patch_data.offer_metrics is the full CampaignMetric row (minus id, campaign_id, timestamps). Today the Offer API is the consumer of record. New consumers should be added here so future rename/remove plans don't miss them.

Quick decision flow

  1. Are you adding a new column? → consumer first (nullable), then producer, then tighten consumer to required.
  2. Are you removing a column? → relax consumer to nullable, then drop on producer, then drop on consumer, then drop the migration.
  3. Are you renaming a column? → emit both names from the producer, accept either on the consumer, then drop the old name in remove order.
  4. Are you doing none of the above? → you are still bound by the rule: the consumer must accept the new shape before the producer emits it, and the producer must stop emitting before the consumer drops support.