Authenticating...
Skip to main content

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:

  1. The computed value — dbt aggregates GROUP BY campaign_id (no app dimension), the dashboard stores one row per campaign, and the CampaignMetricsChanged EventBridge event broadcasts the same metric blob to every affected app.
  2. The Prism sort fieldMetricsOfferSorter is hardcoded to sort by stats.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 bespoke app_rpc column.
  • Respect the campaign-metrics propagation contract. The existing campaign_metrics chain (dbt model → Python SELECT wire contract → new_dashboard Spatie DTO → adgem/common model + migration → CampaignMetricsChanged → Offer API) is governed by the Campaign Metrics Propagation Guide. The DTO marks every metric required, 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_metrics table (composite key app_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-wide campaign_metrics intact as a fallback.
  • Option 2 — Add app_id to the existing campaign_metrics table and change its key to (app_id, campaign_id). Fewer tables, but breaks every existing network-wide reader and the current updateOrCreate(['campaign_id' => …]); no clean fallback.
  • Option 3 — Reuse the legacy app_epcs table (app_id, campaign_id, epc, clicks, conversions, composite index, no Eloquent model). Direct per-app prior art, but too thin (single epc, 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_metricsdecimal(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 teach new_dashboard's DTO to accept per-app rows (nested per_app: [{app_id, …}]) as nullable. Only after the daily DAG has pushed per-app data successfully is the field tightened toward required. 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).
  • PublishCampaignMetricsChangedEventJob gains a per-app payload (an app_id → metrics map, or per-app events) instead of one blob fanned to all affected_app_ids; the event addressing is already app-aware. The Offer API consumer and eventsync:diff are updated to compare per-app, and the new per-app payload is registered in the guide's Known consumers of CampaignMetricsChanged section.
  • offer-api is largely pass-through: offer_metrics is already per (adgem_app_id, campaign_id); it stores the per-app values and exposes the per-app RPC in the offer stats Prism 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_metrics and the per-app pipeline exist, adopting per-app epc, 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_metrics remains 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_campaign allowlist size, but materially larger).
  • More surface area to keep consistent: the additive DTO, per-app event payload, and eventsync:diff must 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_id worsens sparsity. v1 mitigation (deliberate): a coverage threshold plus fallback to network_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_metrics DTO marks every metric required; 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 own app_campaign_metrics chain rather than mutating campaign_metrics.

NOTES

References

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 itemCodebaseRough size
Per-app DBT model (campaign × app grain, maturity/noise handling)airflow-dags (dbt)M (3–4 d)
app_campaign_metrics table + model + backward-compatadgem/commonM (2–3 d)
DTO / endpoint per-app ingest + validationnew_dashboardS–M (2 d)
EventBridge per-app payload + eventsync:diffnew_dashboard + offer-apiM (3 d)
Offer API: expose per-app RPC in stats (+ optional column)offer-apiS (1–2 d)
Prism: sort field + stats resource + flag rollouttargeted-apiS–M (2–3 d)
API: GraphQL fragmentapiS (1 d)
offer_order on clicksapi (+ offerwall-ui optional)S (1–2 d)
Alignment (Data/PI) + tests / QAM (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/commonnew_dashboardairflow-dags, then tighten) gated on daily DAG runs per the propagation guide, so calendar time exceeds raw dev-days.