0071: Adopt Per-App Campaign Metrics for Offer Ranking (RPC-first)
STATUS
Accepted
CONTEXT
The offerwall — and every publisher served through Prism (targeted-api) — currently ranks offers by a single network-wide RPC score (network_epc, computed in dbt as predicted_rpc * 0.65 — the 0.65 is the publisher revenue share (publishers receive 65%, AdGem retains a 35% margin), so network_epc is the publisher-facing RPC; predicted_rpc itself never crosses the HTTP boundary, so downstream sees only network_epc). One value per campaign is applied identically to every app in the network.
The variance investigation (PEX-227) proved this pooling loses real signal: per-app RPC diverges from the network value by 5.2× on iOS and 3.7× on Android across the top campaigns. A campaign can rank #2 network-wide because it performs on a few large apps while ranking #6 on apps whose users never engage with it. The GO decision for per-app ranking was made off that investigation.
The engineering spike (PEX-286) mapped the full code path (reproduced in the Appendix) and found that the pipeline is already per-app end-to-end for storage and offer resolution. Only two things are network-wide today:
- The computed value — dbt aggregates
GROUP BY campaign_id(no app dimension), the dashboard stores one row per campaign, and theCampaignMetricsChangedEventBridge event broadcasts the same metric blob to every affected app. - The Prism sort field —
MetricsOfferSorteris hardcoded to sort bystats.network_epc.
Everything downstream of the dashboard already resolves per (adgem_app_id, campaign_id): Offer API offers and their 1:1 offer_metrics rows are per-app, Prism scopes and suppresses per app+player, and api calls Prism with a per-app OAuth token. No new service, queue, or sync channel is required to make ranking per-app — the per-app storage slot exists; it just carries a network number.
Three framing constraints shape this decision:
- Network-wide, not offerwall-only. Prism serves the whole network. Because offers are already per-app in Offer API and Prism, a per-app sort key is inherently network-wide — there is no offerwall-only code path to build. This must be stated up front to avoid the (correct) objection from Publisher Integrations that Prism sorting changes must apply to all pubs.
- Generalize beyond RPC. RPC is the first and highest-value consumer, but
epc,rpm,ecr, install metrics, etc. share the exact same "network-wide today, per-app desired" limitation. The store and pipeline should be built once for all campaign metrics keyed per app, with RPC as the first field wired to ranking — not a bespokeapp_rpccolumn. - Respect the campaign-metrics propagation contract. The existing
campaign_metricschain (dbt model → Python SELECT wire contract →new_dashboardSpatie DTO →adgem/commonmodel + migration →CampaignMetricsChanged→ Offer API) is governed by the Campaign Metrics Propagation Guide. The DTO marks every metricrequired, so any change must be staged as an additive deprecation, consumer-first (the consumer accepts the new shape before the producer emits it) or the daily push silently rejects whole rows and leaves downstream metrics stale. The per-app work must obey the same discipline.
Two prior ADRs are directly adjacent: 0057 — Real-Time Offer API Syncing (the EventBridge sync this extends) and 0008 — Sort Data Delivery to AdGem.
Considered Options
- Option 1 — New
app_campaign_metricstable (composite keyapp_id, campaign_id), mirroring all metric columns; additive per-app payload; Prism sorts on a per-app field behind a flag. Generic across all metrics; leaves the network-widecampaign_metricsintact as a fallback. - Option 2 — Add
app_idto the existingcampaign_metricstable and change its key to(app_id, campaign_id). Fewer tables, but breaks every existing network-wide reader and the currentupdateOrCreate(['campaign_id' => …]); no clean fallback. - Option 3 — Reuse the legacy
app_epcstable (app_id, campaign_id, epc, clicks, conversions, composite index, no Eloquent model). Direct per-app prior art, but too thin (singleepc, no cohorts) and orphaned; extending it is more work than a clean table. - Option 4 — Keep metrics network-wide; compute per-app only at read time in Prism (e.g., join a per-app signal during sorting). Avoids pipeline changes but pushes analytical computation into the request path and doesn't generalize; rejected on latency and separation-of-concerns grounds.
- Ownership — Data team owns the DBT model (per PEX-289) vs. PE owns the full stack end-to-end. See DECISION.
DECISION
Adopt per-app campaign metrics as a first-class, network-wide capability, delivered RPC-first but generic across all campaign metrics, and owned end-to-end by Player Experience (PE).
1. Data model (Option 1). Introduce app_campaign_metrics in adgem/common, keyed by a unique composite (app_id, campaign_id), mirroring the metric columns of campaign_metrics — decimal(8,4) for rpc_d1/d7/d14/d30, epc_d1/d7/d14/d30, conversion_rate, ecr_30min, ecpi, erpi, network_epi, network_epc, and decimal(12,4) for rpm_d1/d7/d14/d30 (the one precision exception, kept identical so the per-app table matches the network table column-for-column). The existing campaign-grain campaign_metrics table is retained unchanged as the network-wide fallback. This is the platform for all per-app metrics, not just RPC.
2. Computation (data layer). Extend the dbt metrics models to a campaign × app grain by adding app_id to the GROUP BY/SELECT of rpc.sql (and the sibling metric models as they are adopted), threaded through campaign_metrics.sql. Prefer the already-summarized models (daily_stats, cohort_daily_adgem_stats — both carry app_id, revenue and clicks) as the source where they suffice, rather than re-aggregating events_30days; the click-level join is only needed where a summary model can't supply the denominator. Revenue field is adgem_revenue (not the legacy revenue), and the per-app RPC ranked on is the publisher-facing value (after the × 0.65 publisher share, matching network_epc). predicted_rpc and cold start: predicted_rpc is the network-level source RPC behind network_epc. v1 re-aggregates at the campaign × app grain with a coverage threshold and a fallback to network_epc for thin apps; a per-app predicted / shrinkage-toward-the-network-prior approach is explicitly deferred (fast-follow / Phase 3), not part of v1. The detailed DBT model design and maturity/Prism-noise handling remain specified in PEX-289, now delivered by PE (see ownership below).
3. Ingest & sync (additive, staged — per the propagation guide). Per-app is a new grain (many rows per campaign), so it does not fit the guide's three flat-column recipes (add/remove/rename on the single campaign_metrics row). It is delivered as a parallel propagation chain for app_campaign_metrics that adopts the guide's discipline verbatim, leaving the flat campaign_metrics chain and its all-required DTO untouched:
- Consumer learns the new shape first. Add the nullable per-app columns to
adgem/common(model + migration, new minor release); then teachnew_dashboard's DTO to accept per-app rows (nestedper_app: [{app_id, …}]) as nullable. Only after the daily DAG has pushed per-app data successfully is the field tightened towardrequired. Route/auth/middleware unchanged. - Producer emits after. The dbt/Python layer emits the per-app rows once the consumer tolerates them (the reverse-deprecation window the guide describes).
PublishCampaignMetricsChangedEventJobgains a per-app payload (anapp_id → metricsmap, or per-app events) instead of one blob fanned to allaffected_app_ids; the event addressing is already app-aware. The Offer API consumer andeventsync:diffare updated to compare per-app, and the new per-app payload is registered in the guide's Known consumers ofCampaignMetricsChangedsection.offer-apiis largely pass-through:offer_metricsis already per(adgem_app_id, campaign_id); it stores the per-app values and exposes the per-app RPC in the offerstatsPrism reads.
4. Ranking (the behavioral change). In targeted-api, add a per-app RPC case to MetricsOfferSortFields, expose it on OfferStatsResource, and have MetricsOfferSorter/OfferSorterFactory sort on it. api's GraphQL stats {} fragment requests the new field. Rollout is gated by a DevCycle/Datadog flag per app, falling back to network_epc when per-app data is absent. injectNewOffers gating is revisited for the new field. The legacy in-api MetricsCampaignSorter (non-Prism path) is left as-is until removed.
5. offer_order on clicks. Thread offer_order (list position) into the click path (CLICK_URL_PASSTHROUGH_FIELDS + appendTargetedApiClickParams + ClickEvent). It is carried on impression events today but not clicks; it is not required to ship ranking but is required to measure position→CTR and per-position RPC. Ship it early, before the ranking flip, to capture a pre-change baseline.
6. Ownership — PE owns the full stack. PE (Player Experience) is Responsible and Accountable for the entire delivery including the data-layer DBT model, a deliberate shift from the earlier split where the Data team owned the DBT workflow (PEX-289). Rationale: the metric, its storage, its sync, and its use in ranking form one vertical that PE can iterate on without cross-team handoffs, and PE already owns the four app-layer codebases involved. The Data team is Consulted on metric semantics (deep-funnel goal gap, adgem_revenue correctness, Prism-ramp noise) and remains the reviewer of the DBT change; Publisher Integrations (Gustavo) is Consulted on the Prism sorting contract as a network-wide change; Campaign Delivery is Informed.
CONSEQUENCES
Positive
- Captures the revenue signal quantified in PEX-227 (5.2× iOS / 3.7× Android spread) by ranking each app on its own RPC.
- Builds a reusable per-app metric platform — once
app_campaign_metricsand the per-app pipeline exist, adopting per-appepc,rpm,ecr, install metrics, or composite signals (Phase 3 / H4) is incremental. - Network-wide by construction: every Prism pub benefits, no offerwall-only fork.
- Backward-compatible:
campaign_metricsremains the fallback; the flag makes rollout reversible per app. - Single-team ownership (PE) removes cross-team handoff latency across the data → dashboard → offer-api → Prism → api chain.
Negative / costs
- PE takes on data-layer (DBT) ownership outside its usual remit. Requires knowledge transfer from Data and disciplined consultation on metric correctness; the risk of a subtly wrong metric is higher when the owning team is not the data-domain expert.
- Storage and event volume grow from campaign-grain to campaign × app grain (bounded by
app_campaignallowlist size, but materially larger). - More surface area to keep consistent: the additive DTO, per-app event payload, and
eventsync:diffmust all stay aligned, or offer-api drifts from the dashboard.
Risks
- Metric accuracy. ~83% of campaigns have deep-funnel goals that haven't fired within D7, so RPC d7 is understated for those; per-app splits thin the data further. Mitigation: maturity filter in the DBT model, treat as a known caveat (not a data error), Data consulted.
- Prism-ramp noise. Apps mid A/B ramp (e.g., ShopBack ~90%) have split traffic; exclude from the primary metric or treat separately.
- Sparse per-app data / cold start. New or long-tail apps have few-to-no clicks, so a raw per-app RPC is noisy or missing, and segmenting by
app_idworsens sparsity. v1 mitigation (deliberate): a coverage threshold plus fallback tonetwork_epc; per-app prediction / shrinkage toward the network prior is deferred to a fast-follow (see Computation §2). - Ranking regression. A bad per-app value could tank an app's revenue. Mitigation: per-app DevCycle/Datadog flag, gradual rollout,
offer_order-based before/after measurement. - PE-owned DBT. Concentrates delivery but also single-team risk on an unfamiliar layer; mitigated by Data review and staged handoff.
- Propagation-order regression. The
campaign_metricsDTO marks every metricrequired; a change deployed producer-first — or any atomic edit to the shared flat chain — silently rejects the daily push and leaves downstream metrics stale. Mitigation: follow the propagation guide (consumer-first, nullable-first, one daily-run confirmation before tightening) and keep the per-app grain on its ownapp_campaign_metricschain rather than mutatingcampaign_metrics.
NOTES
References
- Spike: PEX-286 — Technical Feasibility, Architecture & Responsibilities — the backing investigation; its code-path map is reproduced in this ADR's Appendix.
- PEX-227 — RPC per App vs. Network-Level RPC: Variance & Revenue Simulation (variance confirmed, GO decision).
- PEX-289 — Data Layer Feasibility & DBT Model Design (DBT model spec; ownership moves to PE per this ADR).
- Campaign Metrics Propagation Guide — the additive-deprecation contract this ADR's ingest/sync changes must follow.
- Adjacent ADRs: 0057 — Real-Time Offer API Syncing, 0008 — Sort Data Delivery to AdGem.
- PR #175: docs(adr): per-app campaign metrics adoption — RPC-first, PE-owned (PEX-286)
Original Author
Quintin Soto (Player Experience)
Approval date
2026-07-30
Approved by
@ronco, @paco22dt, @micahwierenga (PR #175 reviewers — all approved).
Appendix
Current code path (network-level RPC)
Target code path (per-app metrics)
Estimated engineering effort (PE, end-to-end)
| Work item | Codebase | Rough size |
|---|---|---|
Per-app DBT model (campaign × app grain, maturity/noise handling) | airflow-dags (dbt) | M (3–4 d) |
app_campaign_metrics table + model + backward-compat | adgem/common | M (2–3 d) |
| DTO / endpoint per-app ingest + validation | new_dashboard | S–M (2 d) |
EventBridge per-app payload + eventsync:diff | new_dashboard + offer-api | M (3 d) |
| Offer API: expose per-app RPC in stats (+ optional column) | offer-api | S (1–2 d) |
| Prism: sort field + stats resource + flag rollout | targeted-api | S–M (2–3 d) |
| API: GraphQL fragment | api | S (1 d) |
offer_order on clicks | api (+ offerwall-ui optional) | S (1–2 d) |
| Alignment (Data/PI) + tests / QA | — | M (3 d) |
Total: ~3–4 weeks for one engineer, now including the data-layer model that PE owns end-to-end. The per-app app-layer plumbing already exists; the DBT metric is the critical path. Note the ingest/sync changes are staged multi-repo deploys (adgem/common → new_dashboard → airflow-dags, then tighten) gated on daily DAG runs per the propagation guide, so calendar time exceeds raw dev-days.