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
| Change | Pattern |
|---|---|
| Add a column | Consumer accepts it (nullable) → producer emits it → consumer makes it required |
| Remove a column | Consumer relaxes it to nullable → producer stops emitting → consumer drops the field → migration drops it |
| Rename a column | Producer 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.
Where each link lives
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.
| Link | Repo | File | Schema authority |
|---|---|---|---|
| dbt model (producer) | airflow-dags | dbt/dbt-adgem/models/metrics/campaign_metrics.sql | yes |
| dbt column docs | airflow-dags | dbt/dbt-adgem/models/metrics/schema.yml | descriptions only |
| Sync DAG | airflow-dags | adgem/ops/send_metrics_to_dash.py (sync_campaign_metrics) | — |
| Redshift → HTTP client | airflow-dags | lib/adgem/campaign_metrics.py (CampaignMetrics) | yes — its SELECT is the wire contract |
| HTTP DTO (validation) | new_dashboard | app/Data/CampaignMetricData.php | yes |
| Eloquent model | new_dashboard | app/Models/CampaignMetric.php ($fillable / $visible; extends AdGem\Common\Models\CampaignMetric) | yes |
| Base model + migration | adgem/common (Composer) | Models/CampaignMetric + the campaign_metrics migration | yes |
| EventBridge publish | new_dashboard | app/Jobs/EventBridge/PublishCampaignMetricsChangedEventJob.php | derived (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_rpcis in the Redshift table but never crosses the HTTP boundary. The SELECT inairflow-dags/lib/adgem/campaign_metrics.py::CampaignMetrics.get_data_from_dbtis the wire contract, not the dbt model itself. Downstream consumers seepredicted_rpconly vianetwork_epc = predicted_rpc * 0.65.- The
d30columns are placeholder zeros. The dbt model emitsrpc_d30,epc_d30,rpm_d30as hardcoded0to keep the DTO contract honest; no d30 cohort is computed today. rpc_d7andrpc_d14are sourced fromref('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_clicksis the 31-dayclick_dtwindow fromcohort_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:
- The consumer learns about the new shape first. It must accept the new field (or new name) before the producer starts emitting it.
- 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
adgem/common— add the column to thecampaign_metricsmigration as nullable; add it to the baseCampaignMetricmodel's fillable list. Release a new minor version of the package.new_dashboard— bumpadgem/common. Add the field toCampaignMetricData(constructor,updateOrCreatepayload,rules()) asnullableto start. Add it toCampaignMetric::$fillableand$visible. Deploy. The DTO now accepts the field if dbt sends it and tolerates its absence if dbt does not.airflow-dags— add the column tocampaign_metrics.sql, the SELECT inlib/adgem/campaign_metrics.py::CampaignMetrics.get_data_from_dbt, and theDataFramecolumns list. Document it indbt/dbt-adgem/models/metrics/schema.yml. Deploy.new_dashboard— once the daily DAG has pushed the new field successfully, promote it fromnullabletorequiredin 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
new_dashboard— relax the field in the DTO fromrequiredtonullable. Keep it in$fillableand$visiblefor now. Deploy. The DTO now tolerates the absence of the field.airflow-dags— remove the column fromcampaign_metrics.sql, fromCampaignMetrics.get_data_from_dbt's SELECT andDataFramecolumns, and fromschema.yml. Deploy. Wait one daily run.new_dashboard— drop the field fromCampaignMetricData, fromCampaignMetric::$fillableand$visible, and from the EventBridge publish job if it was explicitly listed. Deploy.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.
adgem/common— add the new column nullable alongside the old one; keep the old column on the model. Release.new_dashboard— bumpadgem/common. Add the new field to the DTO asnullable; keep the old field. Decide which oneupdateOrCreatewrites (prefer the new one when present, otherwise the old one). Deploy.airflow-dags— emit both the old and the new column fromcampaign_metrics.sql. Add the new column to the Python SELECT andDataFrame. Deploy. The DAG now writes both names; the DTO accepts both.new_dashboard— promote the new field torequired; relax the old field tonullable. Deploy.airflow-dags— drop the old column from the dbt model and the Python layer. Deploy. Wait one daily run.new_dashboard— drop the old field from the DTO and model. Deploy.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.
Related ADRs
- ADR 0008: Sort Data Delivery to AdGem — upstream cohort metrics that feed
campaign_metrics. - ADR 0010: Targeted API Architecture — context for the Offer API consumer surface.
Quick decision flow
- Are you adding a new column? → consumer first (nullable), then producer,
then tighten consumer to
required. - Are you removing a column? → relax consumer to nullable, then drop on producer, then drop on consumer, then drop the migration.
- Are you renaming a column? → emit both names from the producer, accept either on the consumer, then drop the old name in remove order.
- 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.