From fde279562f71e194b3bdda35d3f3ee2e4df2b415 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 16:52:18 -0500 Subject: [PATCH 01/37] Add design spec for auction and Prebid metrics to Tinybird and Grafana --- ...-prebid-metrics-tinybird-grafana-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md new file mode 100644 index 000000000..85c606f9e --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -0,0 +1,194 @@ +# Auction and Prebid metrics to Tinybird and Grafana + +Date: 2026-06-22 +Status: Design, pending implementation plan + +## Problem + +Trusted Server runs server-side auctions against multiple bid providers +(Prebid Server, APS, mediators). All auction internals (bids per seat, +per-provider latency, win/no-bid/error status, CPM) are computed in the +`OrchestrationResult` but only ever rendered as plain-text log strings at +[auction/endpoints.rs:266](../../../crates/trusted-server-core/src/auction/endpoints.rs#L266). +Operations teams have no QPS/error/latency view of the `/auction` endpoint, and +the revenue team has no fill/win/CPM visibility. We want both on one dashboard. + +## Constraints that shape the design + +- **Fastly Compute is stateless and ephemeral.** Instances are per-request and + short-lived. There is no shared process memory to accumulate counters in, so + an in-process metrics registry plus a `/metrics` scrape endpoint cannot work. + The aggregation state must live off the edge. +- **No TTFB holds on the hot path.** Nothing in the metrics path may add a + synchronous network call before the auction response is returned. +- **The named "Fastly Prometheus exporter" (`fastly/fastly-exporter`) only + surfaces Fastly's own service stats** (requests, status codes, edge/origin + latency, bandwidth, cache hit ratio). It cannot see inside the auction. It is + therefore not the mechanism for auction internals; it is at most an + alternative ops-only source, which this design does not use. + +## Decision + +Emit one structured log event per auction outcome from the edge, stream it via +Fastly real-time logging to Tinybird's Events API, aggregate in Tinybird, and +render in Grafana. Tinybird is the always-on stateful aggregator that Compute +cannot be. Ops and yield both land in Tinybird so there is a single store and a +single Grafana datasource. + +``` +edge (handle_auction) + -> build event rows from OrchestrationResult (pure fn, off hot path) + -> Fastly real-time log endpoint "ts_auction_events" (NDJSON, async batched) + -> Tinybird Events API POST /v0/events?name=auction_events_raw + -> landing datasource (append-only, 30-day TTL) + -> materialized views (per-minute rollups) + -> published pipe endpoints + -> Grafana (Tinybird datasource) + +edge (every request) + -> Fastly real-time log endpoint "ts_access_logs" (one access line / request) + -> Tinybird Events API POST /v0/events?name=access_logs_raw + -> rollups -> endpoints -> Grafana (ops panels) +``` + +## Resolved decisions + +1. **No EC id is emitted.** `ec_id` is omitted from the schema entirely for + privacy. Page URL is reduced to `page_path` (no query string). No per-user + identifier leaves the edge in this pipeline. +2. **Raw retention is 30 days** via TTL on `auction_events_raw` and + `access_logs_raw`. Per-minute rollups in materialized views may be retained + longer. +3. **Phase 1 ships ops and yield together** so the operations team and the + revenue team both get visibility in the first release. + +## Components + +### A. Edge event emission (Rust, `trusted-server-core`) + +A pure function `build_auction_events(result: &OrchestrationResult, ctx: ...) -> +Vec` converts orchestration output into rows at the grain **one +row per (auction x provider x seat-response)**. A provider that returns no bid +emits exactly one row with `status = nobid` and a null `price_cpm`. A provider +error emits one row with `status = error`. Each row carries the auction-level +fields denormalized. + +Serialization writes NDJSON (one JSON object per line) directly to a dedicated +Fastly log endpoint via `fastly::log::Endpoint::from_name("ts_auction_events")` +and `writeln!`. It deliberately bypasses the `log`/fern text formatter used for +`tslog` so the stream is clean JSON with no `timestamp LEVEL [module]` prefix. +Fastly real-time logging batches and POSTs asynchronously after the response is +sent, so there is no hot-path cost. + +Emission happens regardless of consent state (the rows contain no PII). Consent +flags are recorded as booleans for analysis, not used to gate emission. + +#### `auction_events_raw` row schema + +Auction-level (denormalized onto every row): + +| field | type | notes | +| -------------------- | --------- | -------------------------------------- | +| `event_ts` | DateTime | auction completion time (UTC) | +| `auction_id` | String | UUID, drill-down only | +| `publisher_domain` | String | | +| `page_path` | String | path only, no query string | +| `country` | String | from geo lookup | +| `region` | String | from geo lookup | +| `device_type` | String | derived from UA/signals | +| `gdpr_applies` | UInt8 | 0/1 | +| `consent_present` | UInt8 | 0/1 | +| `slot_count` | UInt16 | | +| `total_time_ms` | UInt32 | orchestration wall time | +| `winning_bid_count` | UInt16 | | + +Per seat-response: + +| field | type | notes | +| --------------------------- | --------- | ---------------------------------- | +| `slot_id` | String | | +| `slot_w` | UInt16 | | +| `slot_h` | UInt16 | | +| `media_type` | String | banner/video/native | +| `provider` | String | prebid/aps/mediator | +| `seat` | String | bidder/seat name | +| `status` | String | bid / nobid / error | +| `price_cpm` | Float64 | null for nobid/error | +| `currency` | String | | +| `provider_response_time_ms` | UInt32 | per-provider latency | +| `is_win` | UInt8 | 1 if this bid is a winning bid | +| `ad_domain` | String | advertiser domain, optional | +| `ad_id` | String | creative id, optional | + +Privacy note: no `ec_id`, no full URL, no IP, no user agent string. Geo is kept +at country/region granularity only. + +### B. Tinybird (auction yield) + +- **Landing datasource** `auction_events_raw`: `ENGINE MergeTree`, sorting key + `(event_date, publisher_domain, provider, seat)` where `event_date = + toDate(event_ts)`. `TTL event_date + INTERVAL 30 DAY`. +- **Materialized view** `auction_provider_stats_mv`: per + `(minute, publisher_domain, provider, seat)` aggregate requests, bids, nobids, + errors, wins, `quantilesState` of `provider_response_time_ms`, and + `quantilesState` of `price_cpm` over winning bids. Longer retention than raw. +- **Materialized view** `auction_overview_mv`: per `(minute, publisher_domain)` + aggregate auctions, slots, winning bids, and `quantilesState` of + `total_time_ms`. +- **Published pipe endpoints** parametrized by time range, publisher, and + provider: a yield-summary endpoint (fill rate, win rate by seat, no-bid rate, + CPM quantiles) and a latency endpoint (per-provider/seat quantiles). + +Fill rate, win rate, and no-bid rate are computed in pipes from the counts, not +stored, so definitions stay in one place. + +### C. Tinybird (ops) + +A second Fastly real-time log endpoint `ts_access_logs` emits one access line +per request to `access_logs_raw`: `event_ts`, `method`, `path` (normalized to a +route label so cardinality stays bounded), `status`, `time_elapsed_ms`, +`cache_state` (HIT/MISS/PASS from `fastly_info.state`), `country`. A per-minute +materialized view drives QPS, status-code class rates, endpoint latency +quantiles, and cache hit ratio. This replaces what `fastly-exporter` would give +for ops; the tradeoff (no POP-level analytics aggregates) was accepted. + +### D. Grafana + +Tinybird Grafana datasource (or grafana-infinity against the published endpoint +URLs with a read token) drives one dashboard with two rows: + +- Ops: QPS, error-rate by status class, p50/p95/p99 endpoint latency, cache hit + ratio. +- Yield: fill rate, win rate by seat, no-bid rate, CPM distribution, per-seat + latency heatmap, filterable by publisher and provider. + +## Testing + +- `build_auction_events` is pure and unit-tested with Arrange-Act-Assert: given + an `OrchestrationResult` with a winning provider, a no-bid provider, and an + errored provider, assert one row per seat, a no-bid row present with null + price, correct `is_win` flags, and that the auction-level fields are + identical across all rows. +- NDJSON serialization test: each row serializes to a single line of valid JSON + with the expected keys and no log-formatter prefix. +- Tinybird pipes: fixture NDJSON plus `tb test` cases asserting fill/win/no-bid + math and quantile endpoints. + +## Risks and notes + +- **Log volume.** Grain is N rows per auction (N = providers x responding + seats). At high QPS this multiplies ingest volume into Tinybird. The 30-day + raw TTL and rollup MVs bound storage; monitor ingest cost after launch. +- **Schema drift.** The edge NDJSON shape and the Tinybird datasource schema + must stay in sync. Keep the Rust struct and the `.datasource` definition + reviewed together; a malformed row is dropped by Tinybird, so add an ingest + error check to the dashboard. +- **Token handling.** The Tinybird ingest token is configured on the Fastly log + endpoint (Authorization header), provisioned as a Fastly service resource, not + committed to the repo. + +## Out of scope + +- Alerting rules (Grafana alerts can be added once panels exist). +- Real-time analytics API / `fastly-exporter` integration. +- Per-creative or per-deal analytics beyond seat-level CPM. From 5669bdb185b697e0e511c523ee526fc9991290e8 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 16:55:59 -0500 Subject: [PATCH 02/37] Note self-managed Tinybird (tb infra) deployment in metrics spec --- ...-prebid-metrics-tinybird-grafana-design.md | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md index 85c606f9e..a66d54e2a 100644 --- a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -35,19 +35,23 @@ render in Grafana. Tinybird is the always-on stateful aggregator that Compute cannot be. Ops and yield both land in Tinybird so there is a single store and a single Grafana datasource. +Tinybird runs **self-managed** (`tb infra`), not Tinybird Cloud, to control +cost (see Deployment below). The Events API and published pipe endpoints are +served from our own cluster host, so the URLs differ from `api.tinybird.co`. + ``` edge (handle_auction) -> build event rows from OrchestrationResult (pure fn, off hot path) -> Fastly real-time log endpoint "ts_auction_events" (NDJSON, async batched) - -> Tinybird Events API POST /v0/events?name=auction_events_raw + -> self-managed Tinybird Events API POST https:///v0/events?name=auction_events_raw -> landing datasource (append-only, 30-day TTL) -> materialized views (per-minute rollups) -> published pipe endpoints - -> Grafana (Tinybird datasource) + -> Grafana (Tinybird datasource -> ) edge (every request) -> Fastly real-time log endpoint "ts_access_logs" (one access line / request) - -> Tinybird Events API POST /v0/events?name=access_logs_raw + -> self-managed Tinybird Events API POST https:///v0/events?name=access_logs_raw -> rollups -> endpoints -> Grafana (ops panels) ``` @@ -61,6 +65,41 @@ edge (every request) longer. 3. **Phase 1 ships ops and yield together** so the operations team and the revenue team both get visibility in the first release. +4. **Tinybird is self-managed (`tb infra`)**, not Tinybird Cloud, to start, for + cost control. See Deployment. + +## Deployment: self-managed Tinybird + +Per the `tb infra` model +(), `tb infra` generates Kubernetes +manifests for a containerized Tinybird that runs in our own AWS account. It +deploys the OLAP database (ClickHouse), the ingestion APIs (Events API), an API +gateway for published endpoints, and observability and backpressure components. +Management still goes through `cloud.tinybird.co` by selecting the self-managed +region, or by connecting the UI to the local image. + +Implications for this design: + +- **Hosts change.** Ingestion is `POST https:///v0/events?name=...` and + pipe endpoints are served from ``, where `` is our cluster's + gateway ingress, not `api.tinybird.co`. Auth is unchanged: Bearer tokens. +- **Fastly must reach ``.** The cluster gateway needs a + publicly resolvable, TLS-terminated ingress so Fastly's HTTPS real-time log + sink can POST to it. Lock it down to token auth and, if possible, restrict + source ranges to Fastly. +- **Single-node to start.** The current `tb infra` offering is single-node with + no HA, no S3-persistence optimization, and manual vertical scaling. That is + acceptable here because this pipeline is a non-critical, fire-and-forget + analytics sink: if Tinybird is down, Fastly real-time logging buffers and + then drops, auctions are unaffected, and we lose analytics for the outage + window only. It must never be on the auction request path. +- **Sizing.** Plan reference is 4+ CPU / 16GB+ RAM / 100GB+ SSD, roughly + $150 to $600+ per month of infrastructure, traded against Tinybird Cloud's + usage-based pricing. Confirm the node size against expected ingest volume + (see Risks: log volume). +- **Migration path.** Datasources, pipes, and endpoints are defined as code + (`.datasource` / `.pipe` files), so moving to multi-node self-managed or to + Tinybird Cloud later is a redeploy against a different host, not a rewrite. ## Components @@ -155,7 +194,8 @@ for ops; the tradeoff (no POP-level analytics aggregates) was accepted. ### D. Grafana Tinybird Grafana datasource (or grafana-infinity against the published endpoint -URLs with a read token) drives one dashboard with two rows: +URLs with a read token), pointed at ``, drives one dashboard with two +rows: - Ops: QPS, error-rate by status class, p50/p95/p99 endpoint latency, cache hit ratio. @@ -176,9 +216,15 @@ URLs with a read token) drives one dashboard with two rows: ## Risks and notes -- **Log volume.** Grain is N rows per auction (N = providers x responding - seats). At high QPS this multiplies ingest volume into Tinybird. The 30-day - raw TTL and rollup MVs bound storage; monitor ingest cost after launch. +- **Log volume vs node size.** Grain is N rows per auction (N = providers x + responding seats). At high QPS this multiplies ingest volume into Tinybird, + and on single-node self-managed there is no autoscaling, so volume has to fit + the chosen node. The 30-day raw TTL and rollup MVs bound storage; size the + node against measured ingest and add a sampling knob on the ops access-log + stream if needed. +- **Single-node availability.** Self-managed `tb infra` is single-node with no + HA today. A Tinybird outage loses analytics for the window only (Fastly + logging is fire-and-forget); it must never sit on the auction request path. - **Schema drift.** The edge NDJSON shape and the Tinybird datasource schema must stay in sync. Keep the Rust struct and the `.datasource` definition reviewed together; a malformed row is dropped by Tinybird, so add an ingest @@ -186,6 +232,9 @@ URLs with a read token) drives one dashboard with two rows: - **Token handling.** The Tinybird ingest token is configured on the Fastly log endpoint (Authorization header), provisioned as a Fastly service resource, not committed to the repo. +- **Ingress reachability.** Fastly's HTTPS log sink must reach `` over + public TLS, so the self-managed cluster needs a resolvable, TLS-terminated + gateway. Restrict it to token auth and, where feasible, Fastly source ranges. ## Out of scope From 0a0164bcca26b7768a37da960681143ae696a6b2 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 16:58:25 -0500 Subject: [PATCH 03/37] Use vertical scaling for single-node Tinybird in test phase --- ...2-auction-prebid-metrics-tinybird-grafana-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md index a66d54e2a..48da103c3 100644 --- a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -217,11 +217,11 @@ rows: ## Risks and notes - **Log volume vs node size.** Grain is N rows per auction (N = providers x - responding seats). At high QPS this multiplies ingest volume into Tinybird, - and on single-node self-managed there is no autoscaling, so volume has to fit - the chosen node. The 30-day raw TTL and rollup MVs bound storage; size the - node against measured ingest and add a sampling knob on the ops access-log - stream if needed. + responding seats). At high QPS this multiplies ingest volume into Tinybird. + For this testing phase the mitigation is vertical scaling of the single node; + manual resize is acceptable and no autoscaling is expected. The 30-day raw TTL + and rollup MVs bound storage. Sampling the ops access-log stream is deferred + and revisited only if a larger or multi-node deployment is needed later. - **Single-node availability.** Self-managed `tb infra` is single-node with no HA today. A Tinybird outage loses analytics for the window only (Fastly logging is fire-and-forget); it must never sit on the auction request path. From 34c62820f2441dfff6a67db06a37f0fa30f23a6a Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 17:06:34 -0500 Subject: [PATCH 04/37] Correct emission layering, device signal, and access-log scope after code verification --- ...-prebid-metrics-tinybird-grafana-design.md | 137 ++++++++++++++---- 1 file changed, 108 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md index 48da103c3..607adb770 100644 --- a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -41,8 +41,9 @@ served from our own cluster host, so the URLs differ from `api.tinybird.co`. ``` edge (handle_auction) - -> build event rows from OrchestrationResult (pure fn, off hot path) - -> Fastly real-time log endpoint "ts_auction_events" (NDJSON, async batched) + -> build event rows from OrchestrationResult (pure fn, cheap, inline) + -> sink: buffered non-blocking write, host flushes async + -> Fastly real-time log endpoint "ts_auction_events" (NDJSON, batched delivery) -> self-managed Tinybird Events API POST https:///v0/events?name=auction_events_raw -> landing datasource (append-only, 30-day TTL) -> materialized views (per-minute rollups) @@ -61,8 +62,8 @@ edge (every request) privacy. Page URL is reduced to `page_path` (no query string). No per-user identifier leaves the edge in this pipeline. 2. **Raw retention is 30 days** via TTL on `auction_events_raw` and - `access_logs_raw`. Per-minute rollups in materialized views may be retained - longer. + `access_logs_raw`. Per-minute rollups in materialized views are retained 13 + months (adjustable), since they are small. 3. **Phase 1 ships ops and yield together** so the operations team and the revenue team both get visibility in the first release. 4. **Tinybird is self-managed (`tb infra`)**, not Tinybird Cloud, to start, for @@ -105,22 +106,44 @@ Implications for this design: ### A. Edge event emission (Rust, `trusted-server-core`) -A pure function `build_auction_events(result: &OrchestrationResult, ctx: ...) -> -Vec` converts orchestration output into rows at the grain **one -row per (auction x provider x seat-response)**. A provider that returns no bid -emits exactly one row with `status = nobid` and a null `price_cpm`. A provider -error emits one row with `status = error`. Each row carries the auction-level -fields denormalized. - -Serialization writes NDJSON (one JSON object per line) directly to a dedicated -Fastly log endpoint via `fastly::log::Endpoint::from_name("ts_auction_events")` -and `writeln!`. It deliberately bypasses the `log`/fern text formatter used for -`tslog` so the stream is clean JSON with no `timestamp LEVEL [module]` prefix. -Fastly real-time logging batches and POSTs asynchronously after the response is -sent, so there is no hot-path cost. +Two pieces, split along the existing layering. The codebase keeps +`trusted-server-core` platform-agnostic (it logs through the `log` facade and +reaches Fastly only through a platform `services` abstraction); the Fastly +adapter owns `log_fastly` and the real endpoints. The metrics path follows the +same split: + +- **Builder (core, pure).** `build_auction_events(result: &OrchestrationResult, + ctx: &AuctionEventContext) -> Vec` converts orchestration output + into rows at the grain **one row per (auction x provider x seat-response)**. A + provider that returns no bid emits one row with `status = nobid` and a null + `price_cpm`; a provider error emits one row with `status = error`. Each row + carries the auction-level fields denormalized. The builder must include + `mediator_response` when a mediator is configured, since the winner can come + from there, not only from `provider_responses`. +- **Sink (abstraction in core, implementation in adapter).** Core defines a + small `AuctionEventSink` trait (or a method on the existing platform services + object that already provides `services.geo()`). The Fastly adapter implements + it with a dedicated endpoint via `fastly::log::Endpoint::from_name( + "ts_auction_events")` and `writeln!`, emitting NDJSON (one JSON object per + line) with no `timestamp LEVEL [module]` prefix, deliberately bypassing the + fern formatter used for `tslog`. Tests use a no-op or in-memory sink, which + also keeps the native (non-Fastly) build clean. + +`is_win` is set by matching each provider/seat bid against `winning_bids` +(keyed by `slot_id`, value a full `Bid` clone) on `(slot_id, bidder, ad_id)`. +`ad_id` is `Option`, so when it is absent (e.g. APS/TAM) the match falls back to +`(slot_id, bidder)` and may be ambiguous if one seat returns multiple bids for a +slot; acceptable for analytics, noted as a known imprecision. + +The sink write is a non-blocking, host-buffered append performed during request +handling; the Fastly host flushes to the log endpoint asynchronously, so it does +not add measurable response latency. There is no synchronous network call on the +request path. Emission happens regardless of consent state (the rows contain no PII). Consent -flags are recorded as booleans for analysis, not used to gate emission. +state is recorded as booleans (`gdpr_applies` from `consent.gdpr_applies`, +`consent_present` from whether a consent context exists) for analysis, not used +to gate emission. #### `auction_events_raw` row schema @@ -133,8 +156,8 @@ Auction-level (denormalized onto every row): | `publisher_domain` | String | | | `page_path` | String | path only, no query string | | `country` | String | from geo lookup | -| `region` | String | from geo lookup | -| `device_type` | String | derived from UA/signals | +| `region` | String | from geo lookup; nullable | +| `is_mobile` | UInt8 | 0=desktop, 1=mobile, 2=unknown; see note | | `gdpr_applies` | UInt8 | 0/1 | | `consent_present` | UInt8 | 0/1 | | `slot_count` | UInt16 | | @@ -162,6 +185,21 @@ Per seat-response: Privacy note: no `ec_id`, no full URL, no IP, no user agent string. Geo is kept at country/region granularity only. +`is_mobile` note: the auction request carries only the raw `user_agent`; the +0/1/2 classifier already exists as `DeviceSignals.is_mobile` +([ec/device.rs](../../../crates/trusted-server-core/src/ec/device.rs)) but is +computed in the adapter and not currently threaded into the auction request. +Phase 1 either plumbs that value into `AuctionEventContext` or computes +`is_mobile` from `user_agent` at build time using the same parser. Tablet is not +distinguished. If neither is cheap, defer the column to Phase 2 and ship without +it. + +`price_cpm` note: `Bid.price` is `Option` and is `None` for providers that +return an encoded price decoded only by the mediation layer (APS/TAM). CPM +distributions therefore cover only providers that expose a decoded CPM; rows +with null price still count toward bid/win/no-bid rates. State this on the CPM +panel so it is not read as total-market CPM. + ### B. Tinybird (auction yield) - **Landing datasource** `auction_events_raw`: `ENGINE MergeTree`, sorting key @@ -183,13 +221,19 @@ stored, so definitions stay in one place. ### C. Tinybird (ops) -A second Fastly real-time log endpoint `ts_access_logs` emits one access line -per request to `access_logs_raw`: `event_ts`, `method`, `path` (normalized to a -route label so cardinality stays bounded), `status`, `time_elapsed_ms`, -`cache_state` (HIT/MISS/PASS from `fastly_info.state`), `country`. A per-minute -materialized view drives QPS, status-code class rates, endpoint latency -quantiles, and cache hit ratio. This replaces what `fastly-exporter` would give -for ops; the tradeoff (no POP-level analytics aggregates) was accepted. +No per-request access logging exists today (the adapter logs only errors, +warnings, and specific conditions). So this is a new cross-cutting emission in +the adapter, added where the response is finalized in the top-level request flow +([adapter/.../main.rs](../../../crates/trusted-server-adapter-fastly/src/main.rs)), +since `status` and elapsed time are known only there. It writes one line per +request to a second endpoint `ts_access_logs` -> `access_logs_raw`: `event_ts`, +`method`, `path` (normalized to a bounded route label, not the raw path, so +cardinality stays bounded), `status`, `time_elapsed_ms`, `cache_state` +(HIT/MISS/PASS), `country`. Same buffered, non-blocking write as the auction +sink, so no added latency. A per-minute materialized view drives QPS, +status-code class rates, endpoint latency quantiles, and cache hit ratio. This +replaces what `fastly-exporter` would give for ops; the tradeoff (no POP-level +analytics aggregates) was accepted. ### D. Grafana @@ -225,10 +269,20 @@ rows: - **Single-node availability.** Self-managed `tb infra` is single-node with no HA today. A Tinybird outage loses analytics for the window only (Fastly logging is fire-and-forget); it must never sit on the auction request path. +- **Fastly HTTPS delivery framing (highest integration unknown).** Fastly's + HTTPS real-time log endpoint batches multiple log lines per delivery and can + prepend framing depending on the endpoint's message-type/format settings. + Tinybird's Events API wants a clean NDJSON body (one JSON object per line), + the right `Content-Type`, and the Bearer token header. Before building + dashboards, verify end to end that a batched Fastly delivery lands as valid + rows: set the endpoint message type to a blank/raw format (no syslog prefix), + confirm newline batching, and check Tinybird's quarantine table for rejects. + This is the one piece that cannot be fully validated from code and must be + tested against the real endpoints. - **Schema drift.** The edge NDJSON shape and the Tinybird datasource schema must stay in sync. Keep the Rust struct and the `.datasource` definition - reviewed together; a malformed row is dropped by Tinybird, so add an ingest - error check to the dashboard. + reviewed together; a malformed row is dropped by Tinybird (quarantine), so add + an ingest-error / quarantine check to the dashboard. - **Token handling.** The Tinybird ingest token is configured on the Fastly log endpoint (Authorization header), provisioned as a Fastly service resource, not committed to the repo. @@ -236,6 +290,31 @@ rows: public TLS, so the self-managed cluster needs a resolvable, TLS-terminated gateway. Restrict it to token auth and, where feasible, Fastly source ranges. +## Prerequisites (provisioned outside the repo) + +- Self-managed Tinybird cluster via `tb infra`, with a TLS-terminated gateway + reachable from Fastly (``). +- Two Tinybird ingest tokens (or one scoped to both datasources) and a read + token for Grafana. +- Two Fastly real-time log endpoints on the service: `ts_auction_events` and + `ts_access_logs`, each configured with the `` Events API URL, the + Bearer token header, and a raw/blank message format. +- Grafana with the Tinybird datasource (or grafana-infinity) pointed at + ``. + +## Success criteria + +- A live auction produces N rows in `auction_events_raw` (one per seat-response, + including no-bid and error rows) with correct `is_win` flags and no PII. +- The yield dashboard shows fill rate, win rate by seat, no-bid rate, CPM + quantiles (decoded-CPM providers only), and per-seat latency for a chosen + time range, filterable by publisher and provider. +- The ops dashboard shows QPS, status-class error rate, endpoint latency + quantiles, and cache hit ratio from `access_logs_raw`. +- Tinybird quarantine stays empty under normal traffic; a malformed row is + visible on the ingest-error panel. +- No measurable change in `/auction` response latency attributable to emission. + ## Out of scope - Alerting rules (Grafana alerts can be added once panels exist). From 60b3fb99551cf9bcfedae22b5e3eae0cf0987c32 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 17:17:38 -0500 Subject: [PATCH 05/37] Thread device signals into auction events, refine APS CPM and hosted-Tinybird fallback --- ...-prebid-metrics-tinybird-grafana-design.md | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md index 607adb770..b2461896f 100644 --- a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -157,7 +157,8 @@ Auction-level (denormalized onto every row): | `page_path` | String | path only, no query string | | `country` | String | from geo lookup | | `region` | String | from geo lookup; nullable | -| `is_mobile` | UInt8 | 0=desktop, 1=mobile, 2=unknown; see note | +| `is_mobile` | UInt8 | 0=desktop, 1=mobile, 2=unknown | +| `is_known_browser` | UInt8 | 0=bot, 1=browser, 2=unknown (JA4/H2) | | `gdpr_applies` | UInt8 | 0/1 | | `consent_present` | UInt8 | 0/1 | | `slot_count` | UInt16 | | @@ -185,20 +186,24 @@ Per seat-response: Privacy note: no `ec_id`, no full URL, no IP, no user agent string. Geo is kept at country/region granularity only. -`is_mobile` note: the auction request carries only the raw `user_agent`; the -0/1/2 classifier already exists as `DeviceSignals.is_mobile` -([ec/device.rs](../../../crates/trusted-server-core/src/ec/device.rs)) but is -computed in the adapter and not currently threaded into the auction request. -Phase 1 either plumbs that value into `AuctionEventContext` or computes -`is_mobile` from `user_agent` at build time using the same parser. Tablet is not -distinguished. If neither is cheap, defer the column to Phase 2 and ship without -it. - -`price_cpm` note: `Bid.price` is `Option` and is `None` for providers that -return an encoded price decoded only by the mediation layer (APS/TAM). CPM -distributions therefore cover only providers that expose a decoded CPM; rows -with null price still count toward bid/win/no-bid rates. State this on the CPM -panel so it is not read as total-market CPM. +Device signals note: both `is_mobile` (0/1/2) and the bot-vs-browser bit come +from `derive_device_signals()`, already computed in the adapter before routing +at [main.rs:409](../../../crates/trusted-server-adapter-fastly/src/main.rs#L409) +(`DeviceSignals`, [ec/device.rs](../../../crates/trusted-server-core/src/ec/device.rs)). +Phase 1 threads that struct into `AuctionEventContext`; no UA re-parsing. +`is_known_browser` maps `DeviceSignals.known_browser: Option` to 1/0/2 and +lets the yield dashboard exclude bot traffic. Tablet is not distinguished. + +`price_cpm` note: `Bid.price` is `Option`. In this repo the APS adapter +deliberately leaves it `None` and stores the encoded `amznbid`/`amznp` in +metadata for the mediation layer to decode +([integrations/aps.rs:407](../../../crates/trusted-server-core/src/integrations/aps.rs#L407), +locked by `test_aps_bids_have_no_decoded_price`). So **raw per-seat CPM excludes +APS/TAM**, but the **winning** bid can still carry a decoded CPM via +`mediator_response`/`winning_bids`, so winner-CPM may include APS. Rows with null +price still count toward bid/win/no-bid rates. Label the CPM panel so it is not +read as total-market CPM. Decoding `amznbid` at ingest to recover per-seat APS +CPM needs the Amazon price table and is out of scope. ### B. Tinybird (auction yield) @@ -278,7 +283,11 @@ rows: rows: set the endpoint message type to a blank/raw format (no syslog prefix), confirm newline batching, and check Tinybird's quarantine table for rejects. This is the one piece that cannot be fully validated from code and must be - tested against the real endpoints. + tested against the real endpoints. **Fallback:** Tinybird Cloud's Events API + is a documented, known-good target for Fastly HTTPS logging. If the + self-managed HTTPS framing proves fiddly, point the same endpoints at hosted + Tinybird to de-risk ingestion, trading the saved cost for usage-based pricing. + The datasources/pipes are identical (as-code), so it is a host swap. - **Schema drift.** The edge NDJSON shape and the Tinybird datasource schema must stay in sync. Keep the Rust struct and the `.datasource` definition reviewed together; a malformed row is dropped by Tinybird (quarantine), so add @@ -307,8 +316,9 @@ rows: - A live auction produces N rows in `auction_events_raw` (one per seat-response, including no-bid and error rows) with correct `is_win` flags and no PII. - The yield dashboard shows fill rate, win rate by seat, no-bid rate, CPM - quantiles (decoded-CPM providers only), and per-seat latency for a chosen - time range, filterable by publisher and provider. + quantiles (decoded-CPM bids plus mediated winners; APS raw bids excluded), + and per-seat latency for a chosen time range, filterable by publisher, + provider, and bot-vs-browser. - The ops dashboard shows QPS, status-class error rate, endpoint latency quantiles, and cache hit ratio from `access_logs_raw`. - Tinybird quarantine stays empty under normal traffic; a malformed row is From 1a441aca435bb38009e29d44c04249320cf5a9b0 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 17:19:02 -0500 Subject: [PATCH 06/37] Frame APS CPM as adapter-dependent, forward-compatible with OpenRTB Prebid path --- ...-prebid-metrics-tinybird-grafana-design.md | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md index b2461896f..2c1b1ec6d 100644 --- a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -194,16 +194,26 @@ Phase 1 threads that struct into `AuctionEventContext`; no UA re-parsing. `is_known_browser` maps `DeviceSignals.known_browser: Option` to 1/0/2 and lets the yield dashboard exclude bot traffic. Tablet is not distinguished. -`price_cpm` note: `Bid.price` is `Option`. In this repo the APS adapter -deliberately leaves it `None` and stores the encoded `amznbid`/`amznp` in -metadata for the mediation layer to decode -([integrations/aps.rs:407](../../../crates/trusted-server-core/src/integrations/aps.rs#L407), -locked by `test_aps_bids_have_no_decoded_price`). So **raw per-seat CPM excludes -APS/TAM**, but the **winning** bid can still carry a decoded CPM via -`mediator_response`/`winning_bids`, so winner-CPM may include APS. Rows with null -price still count toward bid/win/no-bid rates. Label the CPM panel so it is not -read as total-market CPM. Decoding `amznbid` at ingest to recover per-seat APS -CPM needs the Amazon price table and is out of scope. +`price_cpm` note: `Bid.price` is `Option`, so the design handles decoded +and undecoded prices uniformly. Whether APS per-seat CPM is present depends on +which APS adapter is wired, not on this design: + +- The adapter currently in the repo uses the legacy targeting-key flow: it + leaves `price = None` and stores the encoded `amznbid`/`amznp` in metadata for + the mediation layer to decode + ([integrations/aps.rs:407](../../../crates/trusted-server-core/src/integrations/aps.rs#L407), + locked by `test_aps_bids_have_no_decoded_price`). Under this adapter, raw + per-seat CPM excludes APS, though the mediated **winner** can still carry a + decoded CPM via `mediator_response`/`winning_bids`. +- Amazon's newer OpenRTB Prebid Server adapter (`POST + https://web.ads.aps.amazon-adsystem.com/e/pb/bid`) returns a standard decoded + `seatbid[].bid[].price`. When TS uses that path, APS `price` populates like any + other bidder and `price_cpm` fills for APS seats with **no schema or pipe + change**, because both already treat price as nullable. + +Either way, rows with null price still count toward bid/win/no-bid rates. Label +the CPM panel so a window with the legacy adapter is not read as total-market +CPM. ### B. Tinybird (auction yield) @@ -316,9 +326,10 @@ rows: - A live auction produces N rows in `auction_events_raw` (one per seat-response, including no-bid and error rows) with correct `is_win` flags and no PII. - The yield dashboard shows fill rate, win rate by seat, no-bid rate, CPM - quantiles (decoded-CPM bids plus mediated winners; APS raw bids excluded), - and per-seat latency for a chosen time range, filterable by publisher, - provider, and bot-vs-browser. + quantiles (any seat with a decoded price; under the legacy APS adapter that + excludes APS raw bids but still includes mediated winners), and per-seat + latency for a chosen time range, filterable by publisher, provider, and + bot-vs-browser. - The ops dashboard shows QPS, status-class error rate, endpoint latency quantiles, and cache hit ratio from `access_logs_raw`. - Tinybird quarantine stays empty under normal traffic; a malformed row is From 783e8b79d3666fea96f1b508e499ad0540a7142c Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 18:30:36 -0500 Subject: [PATCH 07/37] Clarify relay reframing and ingestion-only failure scope --- ...-prebid-metrics-tinybird-grafana-design.md | 641 ++++++++++++------ 1 file changed, 435 insertions(+), 206 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md index 2c1b1ec6d..fc54e77f5 100644 --- a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -1,17 +1,24 @@ # Auction and Prebid metrics to Tinybird and Grafana Date: 2026-06-22 -Status: Design, pending implementation plan +Status: Design, revised for all-auction lifecycle coverage ## Problem Trusted Server runs server-side auctions against multiple bid providers -(Prebid Server, APS, mediators). All auction internals (bids per seat, -per-provider latency, win/no-bid/error status, CPM) are computed in the -`OrchestrationResult` but only ever rendered as plain-text log strings at -[auction/endpoints.rs:266](../../../crates/trusted-server-core/src/auction/endpoints.rs#L266). -Operations teams have no QPS/error/latency view of the `/auction` endpoint, and -the revenue team has no fill/win/CPM visibility. We want both on one dashboard. +(Prebid Server, APS, mediators) through three initiation paths: initial publisher +navigation using the split `dispatch_auction`/`collect_dispatched_auction` SSAT +flow, SPA navigation using `GET /__ts/page-bids`, and the explicit `POST +/auction` API. Auction internals (bids per seat, provider latency, +win/no-bid/error status, CPM) are computed in `OrchestrationResult`, but the +three paths currently expose them only through scattered plain-text logs such as +[auction/endpoints.rs:266](../../../crates/trusted-server-core/src/auction/endpoints.rs#L266) +and the publisher streaming collection logs. + +Operations teams have no structured QPS/error/latency view of Trusted Server, +and the revenue team has no broad, directional fill/win/CPM visibility across +all auction paths. We want both on one dashboard without treating `/auction` as +the only auction entry point. ## Constraints that shape the design @@ -29,168 +36,304 @@ the revenue team has no fill/win/CPM visibility. We want both on one dashboard. ## Decision -Emit one structured log event per auction outcome from the edge, stream it via -Fastly real-time logging to Tinybird's Events API, aggregate in Tinybird, and -render in Grafana. Tinybird is the always-on stateful aggregator that Compute -cannot be. Ops and yield both land in Tinybird so there is a single store and a -single Grafana datasource. +Emit best-effort structured lifecycle rows for every auction decision and +execution from the edge, stream them via Fastly real-time logging to Tinybird's +Events API, aggregate in Tinybird, and render in Grafana. Tinybird is the +always-on stateful aggregator that Compute cannot be. Ops and yield both land in +Tinybird so there is a single analytical store and a single Grafana datasource. -Tinybird runs **self-managed** (`tb infra`), not Tinybird Cloud, to control -cost (see Deployment below). The Events API and published pipe endpoints are -served from our own cluster host, so the URLs differ from `api.tinybird.co`. +Phase 1 uses **hosted Tinybird Cloud**. The Events API and published pipe +endpoints use the API base URL for the selected Tinybird region; the host must +match the workspace and token region (see Deployment below). ``` -edge (handle_auction) - -> build event rows from OrchestrationResult (pure fn, cheap, inline) - -> sink: buffered non-blocking write, host flushes async +edge (all auction paths) + -> initial navigation: dispatch_auction -> origin race -> collect or abandon + -> SPA navigation: GET /__ts/page-bids -> run_auction + -> explicit API: POST /auction -> run_auction + -> build one summary row plus provider-call and bid rows + -> sink: buffered non-blocking writes, host flushes async -> Fastly real-time log endpoint "ts_auction_events" (NDJSON, batched delivery) - -> self-managed Tinybird Events API POST https:///v0/events?name=auction_events_raw + -> customer-controlled HTTPS relay "" + -> hosted Tinybird Events API POST https:///v0/events?name=auction_events_raw -> landing datasource (append-only, 30-day TTL) -> materialized views (per-minute rollups) -> published pipe endpoints - -> Grafana (Tinybird datasource -> ) + -> Grafana Infinity datasource -> published endpoints on edge (every request) -> Fastly real-time log endpoint "ts_access_logs" (one access line / request) - -> self-managed Tinybird Events API POST https:///v0/events?name=access_logs_raw + -> customer-controlled HTTPS relay "" + -> hosted Tinybird Events API POST https:///v0/events?name=access_logs_raw -> rollups -> endpoints -> Grafana (ops panels) ``` ## Resolved decisions 1. **No EC id is emitted.** `ec_id` is omitted from the schema entirely for - privacy. Page URL is reduced to `page_path` (no query string). No per-user - identifier leaves the edge in this pipeline. + privacy. Page URL is reduced to a bounded, normalized `page_path` with no + query string, fragment, or dynamic identifier segment. No per-user identifier + leaves the edge in this pipeline. 2. **Raw retention is 30 days** via TTL on `auction_events_raw` and `access_logs_raw`. Per-minute rollups in materialized views are retained 13 months (adjustable), since they are small. 3. **Phase 1 ships ops and yield together** so the operations team and the revenue team both get visibility in the first release. -4. **Tinybird is self-managed (`tb infra`)**, not Tinybird Cloud, to start, for - cost control. See Deployment. - -## Deployment: self-managed Tinybird - -Per the `tb infra` model -(), `tb infra` generates Kubernetes -manifests for a containerized Tinybird that runs in our own AWS account. It -deploys the OLAP database (ClickHouse), the ingestion APIs (Events API), an API -gateway for published endpoints, and observability and backpressure components. -Management still goes through `cloud.tinybird.co` by selecting the self-managed -region, or by connecting the UI to the local image. +4. **Phase 1 uses hosted Tinybird Cloud.** Tinybird manages the data plane, + scaling, and service availability. See Deployment. +5. **All auction initiation paths are in scope.** Initial-navigation SSAT, SPA + page-bids, and `POST /auction` share one telemetry model and are identified by + `auction_source`. +6. **Telemetry auction IDs are independent random UUIDs.** The pipeline never + copies `AuctionRequest.id`: the SSAT request builder may derive that internal + ID from the EC value, so using it would violate the no-EC requirement. +7. **This is directional observability, not a system of record.** The data is + intended to reveal SSP behavior and trends that help publishers make + optimization decisions. It is not suitable for billing, payment, + reconciliation, contractual reporting, or revenue-share calculations. + +## Data quality and intended use + +The dashboard provides best-effort operational and yield insight over meaningful +time windows. It should answer questions such as whether an SSP's no-bid rate is +rising, whether latency differs by provider, and whether fill or CPM trends +change after a publisher configuration adjustment. It is not an event ledger. + +The application-side model still preserves low-cost correctness fundamentals: + +- Use separate summary, provider-call, and bid grains so auction totals are not + multiplied by the number of returned bids. +- Observe all three known auction initiation paths so comparisons are not + accidentally limited to `POST /auction` traffic. +- Represent only facts present in the source data; do not manufacture seat-level + no-bids from empty provider responses. +- Generate a telemetry UUID independent of EC and internal request identifiers. + +Those safeguards prevent predictable bias without introducing billing-grade +infrastructure. Phase 1 deliberately does **not** provide transactional writes, +exactly-once delivery, deduplication, durable replay, outage backfill, or +cross-system reconciliation. Fastly log delivery and Tinybird ingestion are +best effort, so rows can be missing during delivery or ingestion failures, and +a replayed delivery can create duplicates. The application is designed to emit +one summary per candidate auction, but the destination is not guaranteed to +contain exactly one. + +In other words, the calculations should be correct for the rows that arrive, +but the dataset is not guaranteed to contain every row that occurred. + +Grafana panels must label the data as directional and show the underlying sample +volume alongside rates or quantiles. Comparisons should use sufficiently large +time windows and the same `auction_source` filter. The dashboard also exposes +ingestion freshness and quarantine counts so users can recognize incomplete or +degraded windows instead of interpreting them as real SSP behavior. + +## Deployment: hosted Tinybird Cloud + +Phase 1 deploys the Tinybird project to a hosted Tinybird Cloud workspace. A +workspace belongs to one region, and that region has a specific API base URL +(). Use the +workspace's actual API host everywhere below; do not assume `api.tinybird.co`, +because other regions use different hosts. Implications for this design: -- **Hosts change.** Ingestion is `POST https:///v0/events?name=...` and - pipe endpoints are served from ``, where `` is our cluster's - gateway ingress, not `api.tinybird.co`. Auth is unchanged: Bearer tokens. -- **Fastly must reach ``.** The cluster gateway needs a - publicly resolvable, TLS-terminated ingress so Fastly's HTTPS real-time log - sink can POST to it. Lock it down to token auth and, if possible, restrict - source ranges to Fastly. -- **Single-node to start.** The current `tb infra` offering is single-node with - no HA, no S3-persistence optimization, and manual vertical scaling. That is - acceptable here because this pipeline is a non-critical, fire-and-forget - analytics sink: if Tinybird is down, Fastly real-time logging buffers and - then drops, auctions are unaffected, and we lose analytics for the outage - window only. It must never be on the auction request path. -- **Sizing.** Plan reference is 4+ CPU / 16GB+ RAM / 100GB+ SSD, roughly - $150 to $600+ per month of infrastructure, traded against Tinybird Cloud's - usage-based pricing. Confirm the node size against expected ingest volume - (see Risks: log volume). -- **Migration path.** Datasources, pipes, and endpoints are defined as code - (`.datasource` / `.pipe` files), so moving to multi-node self-managed or to - Tinybird Cloud later is a redeploy against a different host, not a rewrite. +- **Region-specific host.** Ingestion is `POST + https:///v0/events?name=...`; published pipe endpoints use the + same regional API base. The token and host must belong to the same region. +- **Fastly delivery relay.** Fastly's generic HTTPS logging endpoint validates + control of the destination hostname through + `/.well-known/fastly/logging/challenge`. Because the hosted Tinybird API + hostname is not controlled by us, Phase 1 uses a thin HTTPS relay at + `` unless Fastly and Tinybird confirm a supported direct + integration. The relay answers the validation challenge, normalizes Fastly's + delivery framing if needed (strip any syslog prefix, guarantee + newline-delimited JSON), and forwards the batched NDJSON body to the regional + Tinybird Events API. It performs no analytical transformation and is + asynchronous from the auction's perspective. It is a single point of failure + for ingestion only, never on the auction request path. +- **Managed data plane.** Tinybird operates ClickHouse, ingestion, endpoint + serving, persistence, scaling, and platform observability. There is no EKS + cluster, gateway ingress, node sizing, storage class, or manual upgrade work + for this phase. +- **Authentication.** Fastly authenticates to the relay with a dedicated secret. + The relay holds resource-scoped Tinybird tokens with `DATASOURCE:APPEND` for + the target datasources. Grafana Infinity uses a read token scoped to the + published endpoints; neither relay nor ingest credentials are shared with + Grafana. +- **Development and deployment.** Keep datasources, materializations, endpoints, + fixtures, tests, and scoped tokens as code. Use Tinybird Cloud Branches for + validation, then deploy the reviewed project to the main Cloud workspace. The + branch structure is isolated from production data by default + (). +- **Plan and limits.** Choose a hosted plan after estimating auction and access + log volume. Validate Events API request-size and request-rate limits for the + organization, monitor `413`/`429` responses and ingestion health, and resize + or upgrade the plan if sustained volume requires it. Fastly's batching keeps + Events API request count lower than the emitted row count. +- **Failure isolation.** Tinybird remains off the request path. A hosted service + or ingestion outage can lose analytics for that window, but auctions continue + unaffected. This is acceptable under the directional data-quality contract. ## Components ### A. Edge event emission (Rust, `trusted-server-core`) -Two pieces, split along the existing layering. The codebase keeps -`trusted-server-core` platform-agnostic (it logs through the `log` facade and -reaches Fastly only through a platform `services` abstraction); the Fastly -adapter owns `log_fastly` and the real endpoints. The metrics path follows the -same split: - -- **Builder (core, pure).** `build_auction_events(result: &OrchestrationResult, - ctx: &AuctionEventContext) -> Vec` converts orchestration output - into rows at the grain **one row per (auction x provider x seat-response)**. A - provider that returns no bid emits one row with `status = nobid` and a null - `price_cpm`; a provider error emits one row with `status = error`. Each row - carries the auction-level fields denormalized. The builder must include - `mediator_response` when a mediator is configured, since the winner can come - from there, not only from `provider_responses`. -- **Sink (abstraction in core, implementation in adapter).** Core defines a - small `AuctionEventSink` trait (or a method on the existing platform services - object that already provides `services.geo()`). The Fastly adapter implements - it with a dedicated endpoint via `fastly::log::Endpoint::from_name( - "ts_auction_events")` and `writeln!`, emitting NDJSON (one JSON object per - line) with no `timestamp LEVEL [module]` prefix, deliberately bypassing the - fern formatter used for `tslog`. Tests use a no-op or in-memory sink, which - also keeps the native (non-Fastly) build clean. - -`is_win` is set by matching each provider/seat bid against `winning_bids` -(keyed by `slot_id`, value a full `Bid` clone) on `(slot_id, bidder, ad_id)`. -`ad_id` is `Option`, so when it is absent (e.g. APS/TAM) the match falls back to -`(slot_id, bidder)` and may be ambiguous if one seat returns multiple bids for a -slot; acceptable for analytics, noted as a known imprecision. - -The sink write is a non-blocking, host-buffered append performed during request -handling; the Fastly host flushes to the log endpoint asynchronously, so it does -not add measurable response latency. There is no synchronous network call on the -request path. - -Emission happens regardless of consent state (the rows contain no PII). Consent -state is recorded as booleans (`gdpr_applies` from `consent.gdpr_applies`, -`consent_present` from whether a consent context exists) for analysis, not used -to gate emission. +The implementation is split along the existing layering. `trusted-server-core` +owns the observation lifecycle, pure row builder, and sink abstraction. The +Fastly adapter owns `log_fastly` and the named real-time log endpoint. + +#### Auction lifecycle coverage + +Every candidate auction with matched slots gets an owned +`AuctionObservationContext` before it is executed or gated. It contains a fresh +random telemetry UUID, `auction_source`, normalized page context, coarse geo and +device signals, and consent booleans. It never contains an EC ID, raw user agent, +IP address, or the internal `AuctionRequest.id`. + +| `auction_source` | initiation path | terminal observation point | +| -------------------- | ------------------------------------------ | ---------------------------------------------------- | +| `initial_navigation` | publisher handler calls `dispatch_auction` | `collect_dispatched_auction` or explicit abandonment | +| `spa_navigation` | `GET /__ts/page-bids` calls `run_auction` | `run_auction` success or failure | +| `auction_api` | `POST /auction` calls `run_auction` | `run_auction` success or failure | + +Initial-navigation SSAT is split-phase. `dispatch_auction` launches provider +requests with Fastly `send_async`, then the publisher origin request races them. +The returned `DispatchedAuction` must own the observation context alongside its +cloned `AuctionRequest`. On rewritable HTML, the adapter commits response headers +and streams the body; collection occurs at the held `` tail or EOF, where +`collect_dispatched_auction` produces the final result. Emitting the completed +rows there cannot hold TTFB because headers and earlier body chunks have already +been handed to the client. + +Not every dispatched SSAT auction reaches collection. If the origin request +fails, or the response is pass-through, non-processable, non-successful, or uses +an unsupported encoding, the provider calls have already consumed quota but no +`OrchestrationResult` is produced. Those branches must consume the +`DispatchedAuction` through an explicit abandonment path and emit one summary +row with `terminal_status = abandoned` plus provider-call rows with `status = +abandoned`. The token must not be silently dropped. + +Within one successful Compute execution, the lifecycle is designed to emit +exactly one summary row per candidate auction. This is an application invariant, +not an exactly-once delivery guarantee: + +- `completed` for an `OrchestrationResult`, including a valid zero-bid result. +- `execution_failed` when synchronous orchestration fails. +- `dispatch_failed` when no provider request could be launched. +- `abandoned` when split-phase SSAT launched providers but cannot collect them. +- `skipped` when matched slots exist but policy prevents initiation; + `terminal_reason` distinguishes consent, bot, prefetch, and disabled cases. + +Requests with no matching creative-opportunity slots are ordinary page traffic, +not auction candidates, and are represented only in access logs. + +The split `dispatch_auction`/`collect_dispatched_auction` path must preserve the +same provider accounting as `run_auction`: launch failures, parse failures, +transport failures, timeouts, no-bids, and successful responses become explicit +provider-call outcomes. Today some split-path failures are only plain-text logs; +the implementation must retain them in the dispatched token or collection +result so telemetry does not silently undercount errors. + +#### Builder and sink + +- **Builder (core, pure).** `build_auction_events` consumes the owned observation + context, terminal outcome, optional `AuctionRequest`, provider-call outcomes, + and optional `OrchestrationResult`. It returns three row kinds: exactly one + `summary`, zero or more `provider_call` rows, and zero or more `bid` rows. +- **Sink (core abstraction, Fastly implementation).** Core defines a small + `AuctionEventSink` trait or a method on the existing runtime services object. + The Fastly adapter writes each serialized row to + `fastly::log::Endpoint::from_name("ts_auction_events")` with `writeln!`. + Output is NDJSON with no fern `timestamp LEVEL [module]` prefix. Tests use a + no-op or in-memory sink so native builds stay clean. + +The sink append is a buffered host call. Fastly flushes to the remote endpoint +asynchronously, so there is no synchronous network call from Compute to +Tinybird. JSON serialization and host-call cost still exist and are covered by +the latency success criterion. + +Bid rows are emitted for actual provider-returned bids. Provider-level no-bid +and error information belongs on `provider_call` rows because an empty +`AuctionResponse` has no seat or slot identity. When mediation is configured, +the mediator gets its own provider-call row, but its bids are not emitted again +when they can be matched to an original provider bid. Each `winning_bids` entry +is matched to at most one provider bid on `(slot_id, bidder, ad_id)`, falling +back to `(slot_id, bidder)` when `ad_id` is absent. A matched row receives +`is_win = 1` and, when its raw price is null, the mediator's decoded winning +price. If no original bid can be matched, emit one mediator-derived canonical +winner row. Never mark both an original and mediator copy as the same win. + +Emission is not gated by consent. `gdpr_applies` comes from +`ConsentContext.gdpr_applies`; `consent_present` is `!ConsentContext::is_empty()` +because an `EcContext` always contains a consent context, even when no signal was +supplied. #### `auction_events_raw` row schema -Auction-level (denormalized onto every row): - -| field | type | notes | -| -------------------- | --------- | -------------------------------------- | -| `event_ts` | DateTime | auction completion time (UTC) | -| `auction_id` | String | UUID, drill-down only | -| `publisher_domain` | String | | -| `page_path` | String | path only, no query string | -| `country` | String | from geo lookup | -| `region` | String | from geo lookup; nullable | -| `is_mobile` | UInt8 | 0=desktop, 1=mobile, 2=unknown | -| `is_known_browser` | UInt8 | 0=bot, 1=browser, 2=unknown (JA4/H2) | -| `gdpr_applies` | UInt8 | 0/1 | -| `consent_present` | UInt8 | 0/1 | -| `slot_count` | UInt16 | | -| `total_time_ms` | UInt32 | orchestration wall time | -| `winning_bid_count` | UInt16 | | - -Per seat-response: - -| field | type | notes | -| --------------------------- | --------- | ---------------------------------- | -| `slot_id` | String | | -| `slot_w` | UInt16 | | -| `slot_h` | UInt16 | | -| `media_type` | String | banner/video/native | -| `provider` | String | prebid/aps/mediator | -| `seat` | String | bidder/seat name | -| `status` | String | bid / nobid / error | -| `price_cpm` | Float64 | null for nobid/error | -| `currency` | String | | -| `provider_response_time_ms` | UInt32 | per-provider latency | -| `is_win` | UInt8 | 1 if this bid is a winning bid | -| `ad_domain` | String | advertiser domain, optional | -| `ad_id` | String | creative id, optional | - -Privacy note: no `ec_id`, no full URL, no IP, no user agent string. Geo is kept -at country/region granularity only. +All row kinds share these columns: + +| field | type | notes | +| ------------------ | ---------------------- | ------------------------------------------------- | +| `event_ts` | DateTime64(3) | terminal observation time, UTC | +| `event_kind` | LowCardinality(String) | summary / provider_call / bid | +| `auction_id` | UUID | fresh telemetry UUID; never `AuctionRequest.id` | +| `auction_source` | LowCardinality(String) | initial_navigation / spa_navigation / auction_api | +| `publisher_domain` | String | | +| `page_path` | String | bounded normalized route; no query or fragment | +| `country` | LowCardinality(String) | coarse geo | +| `region` | Nullable(String) | coarse geo | +| `is_mobile` | UInt8 | 0=desktop, 1=mobile, 2=unknown | +| `is_known_browser` | UInt8 | 0=bot, 1=browser, 2=unknown | +| `gdpr_applies` | UInt8 | 0/1 | +| `consent_present` | UInt8 | 0/1 | + +Fields that do not apply to a row kind are null. Summary-only fields: + +| field | type | notes | +| ------------------- | -------------------------------- | ------------------------------------------------------------------------------ | +| `terminal_status` | LowCardinality(Nullable(String)) | `completed` / `execution_failed` / `dispatch_failed` / `abandoned` / `skipped` | +| `terminal_reason` | LowCardinality(Nullable(String)) | bounded machine-readable reason | +| `slot_count` | Nullable(UInt16) | requested slots | +| `total_time_ms` | Nullable(UInt32) | elapsed until completion or abandonment | +| `winning_bid_count` | Nullable(UInt16) | zero for non-completed outcomes | + +Provider-call fields: + +| field | type | notes | +| --------------------------- | -------------------------------- | ------------------------------------------------------------------------------------ | +| `provider` | LowCardinality(Nullable(String)) | prebid / aps / mediator name | +| `provider_role` | LowCardinality(Nullable(String)) | bidder / mediator | +| `status` | LowCardinality(Nullable(String)) | success / nobid / launch_error / parse_error / transport_error / timeout / abandoned | +| `provider_response_time_ms` | Nullable(UInt32) | provider-call latency; null if unavailable | +| `provider_bid_count` | Nullable(UInt16) | number of parsed bids | + +Bid fields: + +| field | type | notes | +| ------------ | -------------------------------- | -------------------------------------------------------- | +| `slot_id` | Nullable(String) | | +| `slot_w` | Nullable(UInt16) | returned creative width | +| `slot_h` | Nullable(UInt16) | returned creative height | +| `media_type` | LowCardinality(Nullable(String)) | banner / video / native | +| `provider` | LowCardinality(Nullable(String)) | originating provider; mediator only for unmatched winner | +| `seat` | LowCardinality(Nullable(String)) | bidder/seat name | +| `price_cpm` | Nullable(Float64) | null when the provider has no decoded price | +| `currency` | LowCardinality(Nullable(String)) | | +| `is_win` | Nullable(UInt8) | one canonical winning row per slot | +| `ad_domain` | Nullable(String) | advertiser domain, optional | +| `ad_id` | Nullable(String) | creative ID, optional | + +Privacy note: `auction_id` is generated independently for telemetry. No EC ID, +internal auction request ID, full URL, IP, or user-agent string is emitted. Page +paths use the same bounded route-normalization principle as access logs so a +dynamic path segment cannot become a per-user identifier. Geo remains at +country/region granularity. Device signals note: both `is_mobile` (0/1/2) and the bot-vs-browser bit come from `derive_device_signals()`, already computed in the adapter before routing at [main.rs:409](../../../crates/trusted-server-adapter-fastly/src/main.rs#L409) (`DeviceSignals`, [ec/device.rs](../../../crates/trusted-server-core/src/ec/device.rs)). -Phase 1 threads that struct into `AuctionEventContext`; no UA re-parsing. +Phase 1 snapshots that struct into `AuctionObservationContext`; no UA re-parsing. `is_known_browser` maps `DeviceSignals.known_browser: Option` to 1/0/2 and lets the yield dashboard exclude bot traffic. Tablet is not distinguished. @@ -211,28 +354,56 @@ which APS adapter is wired, not on this design: other bidder and `price_cpm` fills for APS seats with **no schema or pipe change**, because both already treat price as nullable. -Either way, rows with null price still count toward bid/win/no-bid rates. Label -the CPM panel so a window with the legacy adapter is not read as total-market -CPM. +Either way, null-price bid rows still count as bids. They do not contribute to +CPM quantiles. Provider no-bid rates come from provider-call status, not from +invented seat rows. Label the CPM panel so a window with the legacy adapter is +not read as total-market CPM. ### B. Tinybird (auction yield) - **Landing datasource** `auction_events_raw`: `ENGINE MergeTree`, sorting key - `(event_date, publisher_domain, provider, seat)` where `event_date = - toDate(event_ts)`. `TTL event_date + INTERVAL 30 DAY`. -- **Materialized view** `auction_provider_stats_mv`: per - `(minute, publisher_domain, provider, seat)` aggregate requests, bids, nobids, - errors, wins, `quantilesState` of `provider_response_time_ms`, and - `quantilesState` of `price_cpm` over winning bids. Longer retention than raw. -- **Materialized view** `auction_overview_mv`: per `(minute, publisher_domain)` - aggregate auctions, slots, winning bids, and `quantilesState` of - `total_time_ms`. + `(event_date, publisher_domain, event_kind, auction_source, auction_id)` where + `event_date = toDate(event_ts)`. Nullable row-kind-specific fields are not used + in the raw sorting key. `TTL event_date + INTERVAL 30 DAY`. +- **Materialized target datasources** use `AggregatingMergeTree`, retain 13 + months, store `AggregateFunction` columns, and include every dimension in + their sorting keys. Materialized pipes use `*State` combinators; published + endpoints read them with the corresponding `*Merge` combinators. +- **Materialized view** `auction_overview_mv`: filters `summary` event rows. It + aggregates per `(minute, publisher_domain, auction_source, terminal_status, + terminal_reason)`. Because there is exactly one summary row per auction, + auctions, requested slots, winning bids, completion/abandonment rates, and + `quantilesState` of `total_time_ms` are not multiplied by the number of + provider or bid rows. +- **Materialized view** `auction_provider_stats_mv`: filters `provider_call` + event rows. It aggregates per `(minute, publisher_domain, auction_source, + provider, provider_role)` requests, successes, nobids, errors, timeouts, + abandonments, parsed bids, and `quantilesState` of + `provider_response_time_ms`. +- **Materialized view** `auction_bid_stats_mv`: filters `bid` event rows. It + aggregates per `(minute, publisher_domain, auction_source, provider, seat)` + bids, wins, and `quantilesState` of decoded `price_cpm` over winning bids. - **Published pipe endpoints** parametrized by time range, publisher, and - provider: a yield-summary endpoint (fill rate, win rate by seat, no-bid rate, - CPM quantiles) and a latency endpoint (per-provider/seat quantiles). - -Fill rate, win rate, and no-bid rate are computed in pipes from the counts, not -stored, so definitions stay in one place. + optional source/provider filters: an auction-summary endpoint, a + provider-health endpoint, a seat-yield endpoint, and a provider-latency + endpoint. + +Definitions stay in the published pipes: + +- Fill rate = completed-auction winning slots / completed-auction requested + slots, from summary rows. +- Provider no-bid rate = provider calls with `status = nobid` / all + non-abandoned provider calls. +- Provider error rate = error and timeout provider calls / all provider calls. +- Seat win rate = canonical winning bid rows / returned bid rows for that seat. +- Abandonment rate = abandoned summary rows / summary rows that reached an + execution attempt (`completed`, `execution_failed`, `dispatch_failed`, or + `abandoned`). + +Seat-level no-bid rate is deliberately not claimed: an empty provider response +does not identify which stored-request seats failed to bid. If future provider +adapters expose attempted-seat outcomes, add a fourth `opportunity` row kind +rather than manufacturing seat identities from an empty response. ### C. Tinybird (ops) @@ -252,92 +423,150 @@ analytics aggregates) was accepted. ### D. Grafana -Tinybird Grafana datasource (or grafana-infinity against the published endpoint -URLs with a read token), pointed at ``, drives one dashboard with two -rows: +Grafana Infinity calls the published Tinybird endpoint URLs on +`` using a scoped read token stored in Grafana's secure datasource +configuration. This keeps Grafana on the published API contract rather than +granting workspace-wide query access. It drives one dashboard with two rows: - Ops: QPS, error-rate by status class, p50/p95/p99 endpoint latency, cache hit ratio. -- Yield: fill rate, win rate by seat, no-bid rate, CPM distribution, per-seat - latency heatmap, filterable by publisher and provider. +- Yield: fill rate, completion/abandonment rate, provider no-bid/error rate, win + rate and CPM distribution by seat, and provider latency heatmap, filterable by + publisher, auction source, and provider. + +Every rate and quantile panel includes its sample count. The dashboard displays +an explicit "directional, best-effort analytics" notice plus ingestion freshness +and quarantine indicators. It must not present these values as billable totals +or reconciled revenue. ## Testing -- `build_auction_events` is pure and unit-tested with Arrange-Act-Assert: given - an `OrchestrationResult` with a winning provider, a no-bid provider, and an - errored provider, assert one row per seat, a no-bid row present with null - price, correct `is_win` flags, and that the auction-level fields are - identical across all rows. +- `build_auction_events` is pure and unit-tested with Arrange-Act-Assert. A + completed result with successful, no-bid, and errored providers produces + exactly one summary row, one provider-call row per attempted provider, and one + bid row per returned bid. Assert that no-bid/error provider rows do not invent + seat or slot values and that mediated wins produce exactly one `is_win = 1` + bid row per winning slot. +- Lifecycle tests cover all three sources: `POST /auction`, SPA page-bids, and + initial-navigation SSAT dispatch/collect. Each produces exactly one terminal + summary with the correct `auction_source`. +- SSAT route tests cover origin failure, pass-through content, + buffered-unmodified content, and unsupported encoding after successful + dispatch. Each consumes the dispatch token and emits one `abandoned` summary + plus abandoned provider-call rows instead of silently dropping the token. +- Failure tests cover provider launch, parse, transport, and timeout outcomes on + both `run_auction` and split dispatch/collect paths. +- Privacy tests assert that the telemetry `auction_id` is a fresh UUID and never + equals or contains the internal `AuctionRequest.id` or EC value. Page paths are + normalized and bounded before serialization. - NDJSON serialization test: each row serializes to a single line of valid JSON with the expected keys and no log-formatter prefix. -- Tinybird pipes: fixture NDJSON plus `tb test` cases asserting fill/win/no-bid - math and quantile endpoints. +- Tinybird pipes: fixture NDJSON containing all three row kinds and auction + sources, plus `tb test` cases asserting fill/win/no-bid/error/abandonment math + and quantile endpoints. Fixtures include multiple bids for one auction to + prove the summary MV counts that auction only once. ## Risks and notes -- **Log volume vs node size.** Grain is N rows per auction (N = providers x - responding seats). At high QPS this multiplies ingest volume into Tinybird. - For this testing phase the mitigation is vertical scaling of the single node; - manual resize is acceptable and no autoscaling is expected. The 30-day raw TTL - and rollup MVs bound storage. Sampling the ops access-log stream is deferred - and revisited only if a larger or multi-node deployment is needed later. -- **Single-node availability.** Self-managed `tb infra` is single-node with no - HA today. A Tinybird outage loses analytics for the window only (Fastly - logging is fire-and-forget); it must never sit on the auction request path. -- **Fastly HTTPS delivery framing (highest integration unknown).** Fastly's - HTTPS real-time log endpoint batches multiple log lines per delivery and can - prepend framing depending on the endpoint's message-type/format settings. - Tinybird's Events API wants a clean NDJSON body (one JSON object per line), - the right `Content-Type`, and the Bearer token header. Before building - dashboards, verify end to end that a batched Fastly delivery lands as valid - rows: set the endpoint message type to a blank/raw format (no syslog prefix), - confirm newline batching, and check Tinybird's quarantine table for rejects. - This is the one piece that cannot be fully validated from code and must be - tested against the real endpoints. **Fallback:** Tinybird Cloud's Events API - is a documented, known-good target for Fastly HTTPS logging. If the - self-managed HTTPS framing proves fiddly, point the same endpoints at hosted - Tinybird to de-risk ingestion, trading the saved cost for usage-based pricing. - The datasources/pipes are identical (as-code), so it is a host swap. +- **Log volume vs hosted plan.** A completed auction emits one summary row plus P + provider-call rows and B returned-bid rows. Skipped or failed-before-dispatch + auctions emit only a summary; abandoned SSAT auctions emit a summary plus + provider-call rows. At high QPS this multiplies ingest volume into Tinybird. + The 30-day raw TTL and rollup MVs bound storage. Monitor hosted-plan usage, + ingestion latency, and `413`/`429` responses, then resize the plan if needed. + Sampling the ops access-log stream is deferred until measured volume justifies + it. +- **Lifecycle completeness.** A future auction call site could bypass the + observation lifecycle. Keep `run_auction`, `dispatch_auction`, and + `collect_dispatched_auction` instrumentation centralized, require an + `auction_source` when constructing an observation, and add a test that + enumerates the known entry paths. Explicit abandonment must replace every + branch that currently drops a `DispatchedAuction`. +- **Directional accuracy.** Fastly real-time logging, the relay, and Tinybird + ingestion are best effort. Missing or duplicate rows can distort a short + interval, especially at low volume. Show sample sizes and ingestion health, + compare like-for-like sources over longer windows, and do not use the dataset + for financial reconciliation. Phase 1 accepts this limitation instead of + adding a queue, replay store, or deduplication layer. +- **Hosted service availability.** A relay or Tinybird outage can lose analytics + for the affected window. Both remain off the auction request path, so auctions + continue unaffected. +- **Fastly HTTPS validation and delivery framing (highest integration + unknown).** Fastly's HTTPS real-time log endpoint batches multiple log lines + per delivery and can prepend framing depending on the endpoint's + message-type/format settings. + The relay must answer Fastly's domain-control challenge and forward a clean + NDJSON body with the correct `Content-Type` and Tinybird Bearer token. Before + building dashboards, verify end to end that a batched Fastly delivery lands + as valid rows: use blank/raw message framing, confirm newline batching, and + check Tinybird's quarantine table for rejects. A direct Fastly-to-Tinybird + destination may replace the relay only after its validation and framing are + proven against the hosted regional endpoint. - **Schema drift.** The edge NDJSON shape and the Tinybird datasource schema must stay in sync. Keep the Rust struct and the `.datasource` definition reviewed together; a malformed row is dropped by Tinybird (quarantine), so add an ingest-error / quarantine check to the dashboard. -- **Token handling.** The Tinybird ingest token is configured on the Fastly log - endpoint (Authorization header), provisioned as a Fastly service resource, not - committed to the repo. -- **Ingress reachability.** Fastly's HTTPS log sink must reach `` over - public TLS, so the self-managed cluster needs a resolvable, TLS-terminated - gateway. Restrict it to token auth and, where feasible, Fastly source ranges. +- **Token handling.** Tinybird append tokens are secrets held by the relay, not + committed to the repo. The separate Fastly-to-relay secret is configured on + the Fastly log endpoints. The relay must not log either credential or request + authorization headers. +- **Regional configuration drift.** A token used against the wrong Tinybird API + host will fail ingestion. Keep `` and scoped token configuration + together and validate both in deployment smoke tests. ## Prerequisites (provisioned outside the repo) -- Self-managed Tinybird cluster via `tb infra`, with a TLS-terminated gateway - reachable from Fastly (``). -- Two Tinybird ingest tokens (or one scoped to both datasources) and a read - token for Grafana. +- Hosted Tinybird Cloud workspace in the selected region, with its regional + `` recorded in deployment configuration. +- Two Tinybird `DATASOURCE:APPEND` tokens (or one scoped to both datasources) + held by the relay, plus a read token scoped to the published endpoints for + Grafana. +- A customer-controlled, TLS-terminated `` that answers Fastly's + logging challenge and forwards batched NDJSON to the regional Tinybird Events + API. This prerequisite can be removed only after a direct hosted integration + is validated. - Two Fastly real-time log endpoints on the service: `ts_auction_events` and - `ts_access_logs`, each configured with the `` Events API URL, the - Bearer token header, and a raw/blank message format. -- Grafana with the Tinybird datasource (or grafana-infinity) pointed at - ``. + `ts_access_logs`, each configured with placement `none`, the appropriate relay + URL, `Content-Type: application/json`, the relay authentication header, + newline-delimited JSON, and blank/raw line framing. +- Grafana with the Infinity datasource configured for the published endpoint + URLs on `` and the scoped read token. ## Success criteria -- A live auction produces N rows in `auction_events_raw` (one per seat-response, - including no-bid and error rows) with correct `is_win` flags and no PII. -- The yield dashboard shows fill rate, win rate by seat, no-bid rate, CPM - quantiles (any seat with a decoded price; under the legacy APS adapter that - excludes APS raw bids but still includes mediated winners), and per-seat - latency for a chosen time range, filterable by publisher, provider, and - bot-vs-browser. +- The application emits one summary for each initial-navigation SSAT, SPA + page-bids, and `POST /auction` candidate with the correct `auction_source`, + plus provider-call and returned-bid rows where applicable. Under normal + healthy delivery those rows appear in `auction_events_raw`; exactly-once + storage is not required. +- An SSAT auction whose provider requests were dispatched but whose origin + response cannot be rewritten produces an `abandoned` summary and provider + rows; it is not silently omitted. +- Provider launch, parse, transport, timeout, no-bid, and success outcomes are + represented consistently on synchronous and split-phase paths. +- Telemetry auction IDs are fresh random UUIDs unrelated to internal request IDs + and EC values. No EC ID, full URL, IP, or raw user-agent string is emitted. +- The yield dashboard shows fill rate, completion/abandonment rate, provider + no-bid/error rate, win rate by seat, CPM quantiles (any seat with a decoded + price; under the legacy APS adapter that excludes APS raw bids but still + includes mediated winners), and provider latency for a chosen time range, + filterable by publisher, auction source, provider, and bot-vs-browser. +- Rate and quantile panels show sample counts and the dashboard clearly labels + the data as directional and best effort, with ingestion freshness and + quarantine visibility. - The ops dashboard shows QPS, status-class error rate, endpoint latency quantiles, and cache hit ratio from `access_logs_raw`. - Tinybird quarantine stays empty under normal traffic; a malformed row is visible on the ingest-error panel. -- No measurable change in `/auction` response latency attributable to emission. +- No measurable change in `/auction`, page-bids, or initial-navigation TTFB + attributable to emission. Initial-navigation completion emission occurs only + during post-header auction collection. ## Out of scope - Alerting rules (Grafana alerts can be added once panels exist). - Real-time analytics API / `fastly-exporter` integration. - Per-creative or per-deal analytics beyond seat-level CPM. +- Billing, invoicing, payment, revenue-share calculation, contractual reporting, + or reconciliation against SSP/ad-server financial records. +- Exactly-once delivery, durable replay, outage backfill, and duplicate removal. From 79482a602dea0cc10d7937761879878bd5d8cec4 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 18:39:25 -0500 Subject: [PATCH 08/37] Add implementation plan for core auction telemetry --- .../2026-06-22-auction-telemetry-core.md | 1305 +++++++++++++++++ 1 file changed, 1305 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-auction-telemetry-core.md diff --git a/docs/superpowers/plans/2026-06-22-auction-telemetry-core.md b/docs/superpowers/plans/2026-06-22-auction-telemetry-core.md new file mode 100644 index 000000000..9bf8f8140 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-auction-telemetry-core.md @@ -0,0 +1,1305 @@ +# Core Auction Telemetry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a pure, platform-agnostic telemetry layer in `trusted-server-core` that turns an auction observation into the summary / provider-call / bid rows the Tinybird pipeline ingests, plus the sink abstraction the Fastly adapter will implement. + +**Architecture:** A new `auction::telemetry` module holds value types (no I/O, no clock, no Fastly), a pure `build_auction_events` builder over the existing `OrchestrationResult`/`Bid`/`AuctionResponse` types, and an `AuctionEventSink` trait with in-memory/no-op test implementations. Wiring into `run_auction`, the SSAT dispatch/collect path, and the Fastly sink are deliberately out of scope here (separate plans) so this plan stands alone and is fully unit-testable with `cargo test`. + +**Tech Stack:** Rust 2024 edition, `serde` + `serde_json` for NDJSON, `uuid` for the telemetry id type. + +## Global Constraints + +Copied verbatim from the project conventions; every task implicitly includes these: + +- Rust **2024 edition**. +- No `unwrap()` in non-test code; use `expect("should ...")` only where a panic is truly impossible. `Option::unwrap_or`/`unwrap_or_else` are allowed (they do not panic). +- No `println!`/`eprintln!`; use `log` macros if logging is needed. +- Comments on their own line **above** the code, never inline. +- `use super::*;` is allowed only in `#[cfg(test)]` modules. No other wildcard imports. No imports inside functions. +- Tests: Arrange-Act-Assert, `expect()`/`expect_err()` with `"should ..."` messages, descriptive assertion messages, `serde_json::json!` instead of raw JSON strings. +- Prefer `&[T]` over `&Vec`. Functions take no more than 7 arguments. +- Each public item has a doc comment: one-line summary, blank line, details. +- Git commit messages: sentence case, imperative, no semantic prefixes (`feat:`/`fix:`), no bracketed tags, no `Co-Authored-By` trailer. +- `event_ts` is intentionally **not** produced here. Core is clock-free; the Fastly sink (separate plan) stamps `event_ts` at serialization, or Tinybird defaults it at ingestion. Do not add a clock dependency to core. +- `media_type` on bid rows is left `None` here; it requires the request slot definition, which this layer does not receive. A later wiring plan fills it. Do not guess it from the creative. + +--- + +### Task 1: Module scaffold and serialized enums + +**Files:** +- Create: `crates/trusted-server-core/src/auction/telemetry/mod.rs` +- Create: `crates/trusted-server-core/src/auction/telemetry/types.rs` +- Modify: `crates/trusted-server-core/src/auction/mod.rs` (add module declaration + re-exports) +- Test: inline `#[cfg(test)]` in `types.rs` + +**Interfaces:** +- Produces: enums `AuctionSource`, `TerminalStatus`, `ProviderCallStatus`, `ProviderRole`, `EventKind`, each `#[derive(Serialize)]` with the exact wire strings asserted below. + +- [ ] **Step 1: Write the failing test** + +Create `crates/trusted-server-core/src/auction/telemetry/types.rs` with only the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enums_serialize_to_expected_wire_strings() { + assert_eq!( + serde_json::to_string(&AuctionSource::InitialNavigation) + .expect("should serialize source"), + "\"initial_navigation\"", + "should use snake_case wire form" + ); + assert_eq!( + serde_json::to_string(&TerminalStatus::ExecutionFailed) + .expect("should serialize status"), + "\"execution_failed\"", + "should use snake_case wire form" + ); + assert_eq!( + serde_json::to_string(&ProviderCallStatus::NoBid) + .expect("should serialize provider status"), + "\"nobid\"", + "should render NoBid as the single token nobid" + ); + assert_eq!( + serde_json::to_string(&EventKind::ProviderCall) + .expect("should serialize kind"), + "\"provider_call\"", + "should use snake_case wire form" + ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::types` +Expected: FAIL to compile (`AuctionSource` not found) — module not declared yet. + +- [ ] **Step 3: Write minimal implementation** + +Prepend to `types.rs` (above the test module): + +```rust +//! Value types for auction telemetry rows. +//! +//! These types are pure data: no I/O, no clock, no Fastly dependency. They are +//! shared by the builder and serialized as NDJSON by the Fastly sink. + +use serde::Serialize; + +/// Auction initiation path that produced an observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionSource { + /// Initial publisher navigation via split-phase SSAT. + InitialNavigation, + /// Single-page-app navigation via `GET /__ts/page-bids`. + SpaNavigation, + /// Explicit `POST /auction` API call. + AuctionApi, +} + +/// Terminal status of a candidate auction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminalStatus { + /// Produced an `OrchestrationResult`, including a valid zero-bid result. + Completed, + /// Synchronous orchestration failed. + ExecutionFailed, + /// No provider request could be launched. + DispatchFailed, + /// Split-phase SSAT launched providers but could not collect them. + Abandoned, + /// Matched slots existed but policy prevented initiation. + Skipped, +} + +/// Outcome of a single provider call. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderCallStatus { + /// Provider returned at least one bid. + Success, + /// Provider responded with no bid. + #[serde(rename = "nobid")] + NoBid, + /// Provider request could not be launched. + LaunchError, + /// Provider response could not be parsed. + ParseError, + /// Provider request failed in transport. + TransportError, + /// Provider did not respond before the auction deadline. + Timeout, + /// Provider was dispatched but never collected. + Abandoned, +} + +/// Role a provider played in the auction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderRole { + /// A bidder. + Bidder, + /// The mediation layer. + Mediator, +} + +/// Discriminator for the row grain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + /// One per candidate auction. + Summary, + /// One per provider call. + ProviderCall, + /// One per returned bid (or unmatched mediator winner). + Bid, +} +``` + +Create `crates/trusted-server-core/src/auction/telemetry/mod.rs`: + +```rust +//! Pure auction telemetry: row types, builder, and sink abstraction. +//! +//! Wiring into the orchestrator, SSAT dispatch/collect, and the Fastly sink +//! lives in separate modules; this module performs no I/O. + +pub mod types; + +pub use types::{ + AuctionSource, EventKind, ProviderCallStatus, ProviderRole, TerminalStatus, +}; +``` + +In `crates/trusted-server-core/src/auction/mod.rs`, add the module declaration next to the other `pub mod` lines (after `pub mod orchestrator;`): + +```rust +pub mod telemetry; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::types` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/ crates/trusted-server-core/src/auction/mod.rs +git commit -m "Add auction telemetry module scaffold and serialized enums" +``` + +--- + +### Task 2: Observation context and outcome inputs + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/telemetry/types.rs` +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export new types) +- Test: inline `#[cfg(test)]` in `types.rs` + +**Interfaces:** +- Consumes: `AuctionSource` (Task 1). +- Produces: `AuctionObservationContext`, `TerminalOutcome`, `ProviderCallOutcome` structs with the public fields listed below. Later tasks construct these directly. + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `types.rs`: + +```rust +#[test] +fn observation_context_holds_snapshotted_primitives() { + let ctx = AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/news".to_string(), + country: "US".to_string(), + region: Some("CA".to_string()), + is_mobile: 1, + is_known_browser: 1, + gdpr_applies: false, + consent_present: true, + }; + assert_eq!(ctx.source, AuctionSource::AuctionApi, "should retain source"); + assert_eq!(ctx.region.as_deref(), Some("CA"), "should retain region"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::types` +Expected: FAIL to compile (`AuctionObservationContext` not found). + +- [ ] **Step 3: Write minimal implementation** + +Add to `types.rs` (above the test module). Note the `use` for `uuid::Uuid`: + +```rust +use uuid::Uuid; + +/// Immutable, PII-free snapshot describing one candidate auction. +/// +/// Built once per candidate auction by the wiring layer and carried to the +/// terminal observation point. Contains no EC id, raw user agent, IP, or +/// internal `AuctionRequest.id`. +#[derive(Debug, Clone)] +pub struct AuctionObservationContext { + /// Telemetry-only identifier, minted independently of any request id. + pub auction_id: Uuid, + /// Initiation path. + pub source: AuctionSource, + /// Publisher domain. + pub publisher_domain: String, + /// Bounded, normalized route. No query string or fragment. + pub page_path: String, + /// Coarse country from geo lookup. + pub country: String, + /// Coarse region from geo lookup, when available. + pub region: Option, + /// `0` = desktop, `1` = mobile, `2` = unknown. + pub is_mobile: u8, + /// `0` = bot, `1` = browser, `2` = unknown. + pub is_known_browser: u8, + /// Whether GDPR applies for this request. + pub gdpr_applies: bool, + /// Whether any consent signal was present. + pub consent_present: bool, +} + +/// Terminal outcome of a candidate auction, used for the summary row. +#[derive(Debug, Clone)] +pub struct TerminalOutcome { + /// Terminal status. + pub status: TerminalStatus, + /// Bounded machine-readable reason, e.g. for `skipped` cases. + pub reason: Option, + /// Requested slot count. + pub slot_count: Option, + /// Elapsed time until completion or abandonment. + pub total_time_ms: Option, + /// Winning bid count; zero for non-completed outcomes. + pub winning_bid_count: Option, +} + +/// Outcome of a single provider call, used for provider-call rows. +#[derive(Debug, Clone)] +pub struct ProviderCallOutcome { + /// Provider name, e.g. `prebid`, `aps`, or a mediator name. + pub provider: String, + /// Role the provider played. + pub role: ProviderRole, + /// Provider call status. + pub status: ProviderCallStatus, + /// Provider call latency, when known. + pub response_time_ms: Option, + /// Number of parsed bids, when known. + pub bid_count: Option, +} +``` + +Update `mod.rs` re-export to include the new types: + +```rust +pub use types::{ + AuctionObservationContext, AuctionSource, EventKind, ProviderCallOutcome, + ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::types` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/ +git commit -m "Add observation context and outcome input types for telemetry" +``` + +--- + +### Task 3: Row struct and NDJSON serialization + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/telemetry/types.rs` +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export `AuctionEventRow`, `to_ndjson`) +- Test: inline `#[cfg(test)]` in `types.rs` + +**Interfaces:** +- Consumes: all enums + `AuctionObservationContext` (Tasks 1-2). +- Produces: + - `AuctionEventRow` (all public fields, `#[derive(Serialize)]`). + - `AuctionEventRow::base(ctx: &AuctionObservationContext, kind: EventKind) -> AuctionEventRow` — shared fields filled, all kind-specific fields `None`. + - `pub fn to_ndjson(rows: &[AuctionEventRow]) -> Result` — newline-delimited, one compact JSON object per line, no trailing newline. + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `types.rs`: + +```rust +fn sample_context() -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::SpaNavigation, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 0, + is_known_browser: 1, + gdpr_applies: true, + consent_present: false, + } +} + +#[test] +fn base_row_fills_shared_fields_and_nulls_the_rest() { + let row = AuctionEventRow::base(&sample_context(), EventKind::Summary); + assert_eq!(row.event_kind, EventKind::Summary, "should set kind"); + assert_eq!(row.gdpr_applies, 1, "should map true to 1"); + assert_eq!(row.consent_present, 0, "should map false to 0"); + assert!(row.terminal_status.is_none(), "should null summary fields"); + assert!(row.provider.is_none(), "should null provider fields"); + assert!(row.slot_id.is_none(), "should null bid fields"); +} + +#[test] +fn to_ndjson_is_one_compact_object_per_line() { + let rows = vec![ + AuctionEventRow::base(&sample_context(), EventKind::Summary), + AuctionEventRow::base(&sample_context(), EventKind::Bid), + ]; + let ndjson = to_ndjson(&rows).expect("should serialize rows"); + let lines: Vec<&str> = ndjson.split('\n').collect(); + assert_eq!(lines.len(), 2, "should emit one line per row with no trailing newline"); + for line in &lines { + let value: serde_json::Value = + serde_json::from_str(line).expect("each line should be valid JSON"); + assert!(value.get("event_kind").is_some(), "should always include event_kind"); + assert!(value.get("auction_id").is_some(), "should always include auction_id"); + assert!(value.get("region").is_some(), "should include region key even when null"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::types` +Expected: FAIL to compile (`AuctionEventRow` not found). + +- [ ] **Step 3: Write minimal implementation** + +Add to `types.rs` (above the test module): + +```rust +/// One serialized telemetry row. A single flat shape covers all three grains; +/// fields that do not apply to a row kind are `None` and serialize to JSON +/// `null` so the NDJSON shape is stable across rows. +/// +/// `event_ts` is intentionally absent: core is clock-free and the sink or +/// Tinybird supplies the timestamp. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct AuctionEventRow { + /// Row grain discriminator. + pub event_kind: EventKind, + /// Telemetry id, hyphenated UUID string. + pub auction_id: String, + /// Initiation path. + pub auction_source: AuctionSource, + /// Publisher domain. + pub publisher_domain: String, + /// Bounded normalized route. + pub page_path: String, + /// Coarse country. + pub country: String, + /// Coarse region. + pub region: Option, + /// `0`/`1`/`2` device class. + pub is_mobile: u8, + /// `0`/`1`/`2` browser-legitimacy class. + pub is_known_browser: u8, + /// `0`/`1`. + pub gdpr_applies: u8, + /// `0`/`1`. + pub consent_present: u8, + /// Summary: terminal status. + pub terminal_status: Option, + /// Summary: bounded reason. + pub terminal_reason: Option, + /// Summary: requested slots. + pub slot_count: Option, + /// Summary: elapsed ms. + pub total_time_ms: Option, + /// Summary: winning bid count. + pub winning_bid_count: Option, + /// Provider-call and bid: provider name. + pub provider: Option, + /// Provider-call: role. + pub provider_role: Option, + /// Provider-call: status. + pub status: Option, + /// Provider-call: latency ms. + pub provider_response_time_ms: Option, + /// Provider-call: parsed bid count. + pub provider_bid_count: Option, + /// Bid: slot id. + pub slot_id: Option, + /// Bid: returned creative width. + pub slot_w: Option, + /// Bid: returned creative height. + pub slot_h: Option, + /// Bid: media type, filled by a later wiring plan. + pub media_type: Option, + /// Bid: seat/bidder name. + pub seat: Option, + /// Bid: decoded CPM when available. + pub price_cpm: Option, + /// Bid: currency. + pub currency: Option, + /// Bid: `1` for the one canonical winning row per slot, else `0`. + pub is_win: Option, + /// Bid: first advertiser domain. + pub ad_domain: Option, + /// Bid: creative id. + pub ad_id: Option, +} + +impl AuctionEventRow { + /// Build a row with the shared columns filled from `ctx` and every + /// kind-specific column set to `None`. + #[must_use] + pub fn base(ctx: &AuctionObservationContext, kind: EventKind) -> Self { + Self { + event_kind: kind, + auction_id: ctx.auction_id.to_string(), + auction_source: ctx.source, + publisher_domain: ctx.publisher_domain.clone(), + page_path: ctx.page_path.clone(), + country: ctx.country.clone(), + region: ctx.region.clone(), + is_mobile: ctx.is_mobile, + is_known_browser: ctx.is_known_browser, + gdpr_applies: u8::from(ctx.gdpr_applies), + consent_present: u8::from(ctx.consent_present), + terminal_status: None, + terminal_reason: None, + slot_count: None, + total_time_ms: None, + winning_bid_count: None, + provider: None, + provider_role: None, + status: None, + provider_response_time_ms: None, + provider_bid_count: None, + slot_id: None, + slot_w: None, + slot_h: None, + media_type: None, + seat: None, + price_cpm: None, + currency: None, + is_win: None, + ad_domain: None, + ad_id: None, + } + } +} + +/// Serialize rows as newline-delimited JSON with no trailing newline. +/// +/// # Errors +/// +/// Returns the underlying `serde_json` error if a row cannot be serialized. +pub fn to_ndjson(rows: &[AuctionEventRow]) -> Result { + let mut out = String::new(); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(&serde_json::to_string(row)?); + } + Ok(out) +} +``` + +Update `mod.rs` re-export: + +```rust +pub use types::{ + to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, + ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::types` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/ +git commit -m "Add flat telemetry row struct and NDJSON serialization" +``` + +--- + +### Task 4: Sink abstraction with test implementations + +**Files:** +- Create: `crates/trusted-server-core/src/auction/telemetry/sink.rs` +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `sink`, re-export) +- Test: inline `#[cfg(test)]` in `sink.rs` + +**Interfaces:** +- Consumes: `AuctionEventRow` (Task 3). +- Produces: + - `pub trait AuctionEventSink: Send + Sync { fn emit(&self, rows: &[AuctionEventRow]); }` + - `NoopSink` (does nothing). + - `InMemorySink` with `fn rows(&self) -> Vec` for tests. + +- [ ] **Step 1: Write the failing test** + +Create `crates/trusted-server-core/src/auction/telemetry/sink.rs` with the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::types::{AuctionObservationContext, AuctionSource, EventKind}; + + fn ctx() -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 2, + is_known_browser: 2, + gdpr_applies: false, + consent_present: false, + } + } + + #[test] + fn in_memory_sink_captures_emitted_rows() { + let sink = InMemorySink::default(); + let rows = vec![AuctionEventRow::base(&ctx(), EventKind::Summary)]; + sink.emit(&rows); + sink.emit(&rows); + assert_eq!(sink.rows().len(), 2, "should accumulate rows across emit calls"); + } + + #[test] + fn noop_sink_accepts_rows() { + let sink = NoopSink; + sink.emit(&[AuctionEventRow::base(&ctx(), EventKind::Summary)]); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::sink` +Expected: FAIL to compile (`sink` module not declared; `AuctionEventSink` not found). + +- [ ] **Step 3: Write minimal implementation** + +Prepend to `sink.rs` (above the test module): + +```rust +//! Sink abstraction for emitting telemetry rows. +//! +//! Core defines the trait and test implementations. The Fastly adapter provides +//! the real implementation that serializes rows to a named log endpoint. + +use std::sync::Mutex; + +use crate::auction::telemetry::types::AuctionEventRow; + +/// Destination for telemetry rows. +/// +/// Implementations must be cheap and non-blocking from the caller's view; the +/// Fastly implementation performs a buffered host write. +pub trait AuctionEventSink: Send + Sync { + /// Emit a batch of rows for one auction observation. + fn emit(&self, rows: &[AuctionEventRow]); +} + +/// Sink that discards rows. Used where telemetry is disabled and in tests. +#[derive(Debug, Default)] +pub struct NoopSink; + +impl AuctionEventSink for NoopSink { + fn emit(&self, _rows: &[AuctionEventRow]) {} +} + +/// Sink that accumulates rows in memory for assertions in tests. +#[derive(Debug, Default)] +pub struct InMemorySink { + captured: Mutex>, +} + +impl InMemorySink { + /// Return a clone of all captured rows in emission order. + #[must_use] + pub fn rows(&self) -> Vec { + self.captured + .lock() + .expect("should lock captured rows") + .clone() + } +} + +impl AuctionEventSink for InMemorySink { + fn emit(&self, rows: &[AuctionEventRow]) { + self.captured + .lock() + .expect("should lock captured rows") + .extend_from_slice(rows); + } +} +``` + +Update `mod.rs`: add `pub mod sink;` after `pub mod types;`, and extend re-exports: + +```rust +pub mod sink; +pub mod types; + +pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; +pub use types::{ + to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, + ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::sink` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/ +git commit -m "Add auction event sink trait and test sinks" +``` + +--- + +### Task 5: Builder for summary and provider-call rows + +**Files:** +- Create: `crates/trusted-server-core/src/auction/telemetry/builder.rs` +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `builder`, re-export `build_auction_events`) +- Test: inline `#[cfg(test)]` in `builder.rs` + +**Interfaces:** +- Consumes: `AuctionObservationContext`, `TerminalOutcome`, `ProviderCallOutcome`, `AuctionEventRow`, `EventKind` (Tasks 1-3). +- Produces: `pub fn build_auction_events(ctx: &AuctionObservationContext, outcome: &TerminalOutcome, provider_calls: &[ProviderCallOutcome], result: Option<&OrchestrationResult>) -> Vec`. This task implements the `result == None` behavior (summary + provider-call rows only); Task 6 adds bid rows when `result` is `Some`. + +- [ ] **Step 1: Write the failing test** + +Create `crates/trusted-server-core/src/auction/telemetry/builder.rs` with the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::types::{ + AuctionObservationContext, AuctionSource, EventKind, ProviderCallOutcome, + ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, + }; + + fn ctx(source: AuctionSource) -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 1, + is_known_browser: 1, + gdpr_applies: false, + consent_present: true, + } + } + + #[test] + fn abandoned_auction_emits_summary_plus_provider_calls_no_bids() { + let outcome = TerminalOutcome { + status: TerminalStatus::Abandoned, + reason: Some("origin_unrewritable".to_string()), + slot_count: Some(2), + total_time_ms: Some(120), + winning_bid_count: Some(0), + }; + let calls = vec![ + ProviderCallOutcome { + provider: "prebid".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::Abandoned, + response_time_ms: None, + bid_count: None, + }, + ProviderCallOutcome { + provider: "aps".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::Abandoned, + response_time_ms: None, + bid_count: None, + }, + ]; + + let rows = build_auction_events(&ctx(AuctionSource::InitialNavigation), &outcome, &calls, None); + + let summaries: Vec<_> = rows.iter().filter(|r| r.event_kind == EventKind::Summary).collect(); + assert_eq!(summaries.len(), 1, "should emit exactly one summary row"); + assert_eq!( + summaries[0].terminal_status, + Some(TerminalStatus::Abandoned), + "should record the terminal status on the summary" + ); + assert_eq!( + rows.iter().filter(|r| r.event_kind == EventKind::ProviderCall).count(), + 2, + "should emit one provider-call row per outcome" + ); + assert_eq!( + rows.iter().filter(|r| r.event_kind == EventKind::Bid).count(), + 0, + "should emit no bid rows when there is no result" + ); + } + + #[test] + fn skipped_auction_emits_only_a_summary() { + let outcome = TerminalOutcome { + status: TerminalStatus::Skipped, + reason: Some("consent".to_string()), + slot_count: Some(3), + total_time_ms: None, + winning_bid_count: Some(0), + }; + let rows = build_auction_events(&ctx(AuctionSource::AuctionApi), &outcome, &[], None); + assert_eq!(rows.len(), 1, "should emit only the summary row"); + assert_eq!(rows[0].event_kind, EventKind::Summary, "should be a summary"); + assert_eq!(rows[0].terminal_reason.as_deref(), Some("consent"), "should carry the reason"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::builder` +Expected: FAIL to compile (`builder` module not declared; `build_auction_events` not found). + +- [ ] **Step 3: Write minimal implementation** + +Prepend to `builder.rs` (above the test module): + +```rust +//! Pure builder that turns an auction observation into telemetry rows. + +use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::telemetry::types::{ + AuctionEventRow, AuctionObservationContext, EventKind, ProviderCallOutcome, TerminalOutcome, +}; + +/// Build all telemetry rows for one auction observation. +/// +/// Always emits exactly one summary row, one provider-call row per entry in +/// `provider_calls`, and (when `result` is `Some`) one bid row per returned bid +/// plus one row for any winning slot not matched to a returned bid. +#[must_use] +pub fn build_auction_events( + ctx: &AuctionObservationContext, + outcome: &TerminalOutcome, + provider_calls: &[ProviderCallOutcome], + result: Option<&OrchestrationResult>, +) -> Vec { + let mut rows = Vec::new(); + rows.push(summary_row(ctx, outcome)); + for call in provider_calls { + rows.push(provider_call_row(ctx, call)); + } + if let Some(result) = result { + rows.extend(build_bid_rows(ctx, result)); + } + rows +} + +/// Build the single summary row. +fn summary_row(ctx: &AuctionObservationContext, outcome: &TerminalOutcome) -> AuctionEventRow { + let mut row = AuctionEventRow::base(ctx, EventKind::Summary); + row.terminal_status = Some(outcome.status); + row.terminal_reason = outcome.reason.clone(); + row.slot_count = outcome.slot_count; + row.total_time_ms = outcome.total_time_ms; + row.winning_bid_count = outcome.winning_bid_count; + row +} + +/// Build one provider-call row. +fn provider_call_row( + ctx: &AuctionObservationContext, + call: &ProviderCallOutcome, +) -> AuctionEventRow { + let mut row = AuctionEventRow::base(ctx, EventKind::ProviderCall); + row.provider = Some(call.provider.clone()); + row.provider_role = Some(call.role); + row.status = Some(call.status); + row.provider_response_time_ms = call.response_time_ms; + row.provider_bid_count = call.bid_count; + row +} + +/// Build bid rows from a completed orchestration result. Implemented in Task 6. +fn build_bid_rows( + _ctx: &AuctionObservationContext, + _result: &OrchestrationResult, +) -> Vec { + Vec::new() +} +``` + +Update `mod.rs`: add `pub mod builder;` before `pub mod sink;`, and add the re-export: + +```rust +pub mod builder; +pub mod sink; +pub mod types; + +pub use builder::build_auction_events; +pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; +pub use types::{ + to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, + ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::builder` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/ +git commit -m "Add telemetry builder for summary and provider-call rows" +``` + +--- + +### Task 6: Bid rows with win matching and mediator dedup + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/telemetry/builder.rs` (replace the `build_bid_rows` stub, add helpers) +- Test: inline `#[cfg(test)]` in `builder.rs` + +**Interfaces:** +- Consumes: `OrchestrationResult` (`provider_responses: Vec`, `mediator_response: Option`, `winning_bids: HashMap`), `Bid`, `AuctionResponse` from `crate::auction::types`. +- Produces: a real `build_bid_rows` so that `build_auction_events(.., Some(result))` emits bid rows. + +Matching rules (from the spec): +- One bid row per returned bid across `provider_responses`. Mediator bids are not re-emitted when matchable to an original provider bid. +- A bid is the winner for its slot when it matches `winning_bids[slot_id]` on `(slot_id, bidder, ad_id)`, falling back to `(slot_id, bidder)` when `ad_id` is absent. At most one winning row per slot (first match claims it). +- A matched winning row whose own `price` is `None` takes the winner's decoded `price`. +- A winning slot not matched to any returned bid emits one mediator-derived canonical winner row. + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `builder.rs`: + +```rust +use crate::auction::types::{AuctionResponse, Bid, BidStatus}; +use std::collections::HashMap; + +fn bid(slot: &str, bidder: &str, price: Option, ad_id: Option<&str>) -> Bid { + Bid { + slot_id: slot.to_string(), + price, + currency: "USD".to_string(), + creative: None, + adomain: Some(vec!["advertiser.example".to_string()]), + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: ad_id.map(str::to_string), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } +} + +fn response(provider: &str, bids: Vec, status: BidStatus) -> AuctionResponse { + AuctionResponse { + provider: provider.to_string(), + bids, + status, + response_time_ms: 42, + metadata: HashMap::new(), + } +} + +fn completed_outcome() -> TerminalOutcome { + TerminalOutcome { + status: TerminalStatus::Completed, + reason: None, + slot_count: Some(1), + total_time_ms: Some(50), + winning_bid_count: Some(1), + } +} + +#[test] +fn emits_one_bid_row_per_returned_bid_with_single_winner() { + let winner = bid("slot-1", "kargo", Some(2.5), Some("creative-1")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-1".to_string(), winner.clone()); + let result = OrchestrationResult { + provider_responses: vec![response( + "prebid", + vec![ + bid("slot-1", "kargo", Some(2.5), Some("creative-1")), + bid("slot-1", "ix", Some(1.0), Some("creative-2")), + ], + BidStatus::Success, + )], + mediator_response: None, + winning_bids, + total_time_ms: 50, + metadata: HashMap::new(), + }; + + let rows = build_auction_events(&ctx(AuctionSource::AuctionApi), &completed_outcome(), &[], Some(&result)); + let bid_rows: Vec<_> = rows.iter().filter(|r| r.event_kind == EventKind::Bid).collect(); + + assert_eq!(bid_rows.len(), 2, "should emit one row per returned bid"); + assert_eq!( + bid_rows.iter().filter(|r| r.is_win == Some(1)).count(), + 1, + "should mark exactly one winning row per slot" + ); + let winning = bid_rows.iter().find(|r| r.is_win == Some(1)).expect("should have a winner"); + assert_eq!(winning.seat.as_deref(), Some("kargo"), "should win for the matched seat"); +} + +#[test] +fn fills_decoded_price_on_null_priced_winner() { + let winner = bid("slot-1", "aps", Some(3.1), Some("amzn-1")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-1".to_string(), winner); + let result = OrchestrationResult { + // The original APS bid has no decoded price. + provider_responses: vec![response( + "aps", + vec![bid("slot-1", "aps", None, Some("amzn-1"))], + BidStatus::Success, + )], + mediator_response: Some(response("mediator", vec![], BidStatus::Success)), + winning_bids, + total_time_ms: 60, + metadata: HashMap::new(), + }; + + let rows = build_auction_events(&ctx(AuctionSource::AuctionApi), &completed_outcome(), &[], Some(&result)); + let winning = rows + .iter() + .find(|r| r.event_kind == EventKind::Bid && r.is_win == Some(1)) + .expect("should have a winning bid row"); + assert_eq!(winning.price_cpm, Some(3.1), "should fill decoded winner price on a null-priced bid"); +} + +#[test] +fn unmatched_winner_emits_one_mediator_row() { + let winner = bid("slot-9", "exclusive-seat", Some(5.0), Some("only-here")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-9".to_string(), winner); + let result = OrchestrationResult { + // No provider response contains the winning bid. + provider_responses: vec![response("prebid", vec![], BidStatus::NoBid)], + mediator_response: Some(response("mediator", vec![], BidStatus::Success)), + winning_bids, + total_time_ms: 70, + metadata: HashMap::new(), + }; + + let rows = build_auction_events(&ctx(AuctionSource::AuctionApi), &completed_outcome(), &[], Some(&result)); + let bid_rows: Vec<_> = rows.iter().filter(|r| r.event_kind == EventKind::Bid).collect(); + assert_eq!(bid_rows.len(), 1, "should synthesize one row for the unmatched winner"); + assert_eq!(bid_rows[0].is_win, Some(1), "should mark the synthesized row as the win"); + assert_eq!(bid_rows[0].provider.as_deref(), Some("mediator"), "should attribute it to the mediator"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::builder` +Expected: FAIL — the three new tests fail (`build_bid_rows` returns empty). + +- [ ] **Step 3: Write minimal implementation** + +In `builder.rs`, extend the imports at the top to bring in the bid types: + +```rust +use crate::auction::types::Bid; +``` + +Replace the `build_bid_rows` stub with the real implementation plus helpers: + +```rust +/// Build bid rows from a completed orchestration result. +fn build_bid_rows( + ctx: &AuctionObservationContext, + result: &OrchestrationResult, +) -> Vec { + let mut rows = Vec::new(); + // Slots whose winning row has already been emitted, so each slot has at + // most one `is_win = 1` row. + let mut claimed_slots: Vec = Vec::new(); + + for response in &result.provider_responses { + for bid in &response.bids { + let winner = result.winning_bids.get(&bid.slot_id); + let is_win = match winner { + Some(winner) => { + matches_winner(bid, winner) && !claimed_slots.contains(&bid.slot_id) + } + None => false, + }; + let price_override = if is_win && bid.price.is_none() { + winner.and_then(|winner| winner.price) + } else { + None + }; + if is_win { + claimed_slots.push(bid.slot_id.clone()); + } + rows.push(bid_row(ctx, &response.provider, bid, is_win, price_override)); + } + } + + // Any winning slot not matched to a returned bid gets one canonical + // mediator-derived winner row. + let mediator_provider = result + .mediator_response + .as_ref() + .map(|response| response.provider.clone()) + .unwrap_or_else(|| "mediator".to_string()); + for (slot_id, winner) in &result.winning_bids { + if !claimed_slots.contains(slot_id) { + rows.push(bid_row(ctx, &mediator_provider, winner, true, winner.price)); + } + } + + rows +} + +/// Whether a returned bid is the winner for its slot. +fn matches_winner(candidate: &Bid, winner: &Bid) -> bool { + if candidate.slot_id != winner.slot_id || candidate.bidder != winner.bidder { + return false; + } + match (&candidate.ad_id, &winner.ad_id) { + (Some(left), Some(right)) => left == right, + // Fall back to (slot, seat) identity when ad ids are absent. + _ => true, + } +} + +/// Build one bid row. `price_override` carries a mediator-decoded price for a +/// winning bid whose own price is null. +fn bid_row( + ctx: &AuctionObservationContext, + provider: &str, + bid: &Bid, + is_win: bool, + price_override: Option, +) -> AuctionEventRow { + let mut row = AuctionEventRow::base(ctx, EventKind::Bid); + row.provider = Some(provider.to_string()); + row.slot_id = Some(bid.slot_id.clone()); + row.slot_w = Some(clamp_dimension(bid.width)); + row.slot_h = Some(clamp_dimension(bid.height)); + row.seat = Some(bid.bidder.clone()); + row.price_cpm = price_override.or(bid.price); + row.currency = Some(bid.currency.clone()); + row.is_win = Some(u8::from(is_win)); + row.ad_domain = bid.adomain.as_ref().and_then(|domains| domains.first().cloned()); + row.ad_id = bid.ad_id.clone(); + row +} + +/// Clamp a `u32` creative dimension into the `u16` schema column without +/// panicking. Real creative sizes are always well within `u16`. +fn clamp_dimension(value: u32) -> u16 { + value.min(u32::from(u16::MAX)) as u16 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::builder` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/builder.rs +git commit -m "Build bid rows with win matching and mediator dedup" +``` + +--- + +### Task 7: End-to-end builder test over a mixed result + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/telemetry/builder.rs` (test only) +- Test: inline `#[cfg(test)]` in `builder.rs` + +**Interfaces:** +- Consumes: everything from Tasks 1-6. No production code changes; this task locks the combined behavior with one realistic case and guards against regressions. + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `builder.rs`: + +```rust +#[test] +fn completed_result_with_mixed_providers_produces_expected_grains() { + // Arrange: one successful provider with two bids (one wins), one no-bid + // provider, and an explicit provider-call list mirroring those outcomes. + let winner = bid("slot-1", "kargo", Some(4.0), Some("c-1")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-1".to_string(), winner); + let result = OrchestrationResult { + provider_responses: vec![ + response( + "prebid", + vec![ + bid("slot-1", "kargo", Some(4.0), Some("c-1")), + bid("slot-1", "ix", Some(2.0), Some("c-2")), + ], + BidStatus::Success, + ), + response("aps", vec![], BidStatus::NoBid), + ], + mediator_response: None, + winning_bids, + total_time_ms: 88, + metadata: HashMap::new(), + }; + let calls = vec![ + ProviderCallOutcome { + provider: "prebid".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::Success, + response_time_ms: Some(42), + bid_count: Some(2), + }, + ProviderCallOutcome { + provider: "aps".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::NoBid, + response_time_ms: Some(40), + bid_count: Some(0), + }, + ]; + + // Act + let rows = build_auction_events(&ctx(AuctionSource::SpaNavigation), &completed_outcome(), &calls, Some(&result)); + + // Assert: exactly one summary, two provider-call rows, two bid rows, and no + // invented seats on the no-bid provider. + assert_eq!( + rows.iter().filter(|r| r.event_kind == EventKind::Summary).count(), + 1, + "should emit exactly one summary" + ); + assert_eq!( + rows.iter().filter(|r| r.event_kind == EventKind::ProviderCall).count(), + 2, + "should emit one provider-call row per provider" + ); + let bid_rows: Vec<_> = rows.iter().filter(|r| r.event_kind == EventKind::Bid).collect(); + assert_eq!(bid_rows.len(), 2, "should emit a bid row only for returned bids"); + assert!( + bid_rows.iter().all(|r| r.seat.is_some()), + "should never emit a bid row without a seat" + ); + assert_eq!( + bid_rows.iter().filter(|r| r.is_win == Some(1)).count(), + 1, + "should mark exactly one winning bid" + ); +} +``` + +- [ ] **Step 2: Run test to verify it fails or passes** + +Run: `cargo test -p trusted-server-core telemetry::builder` +Expected: PASS immediately (behavior already implemented in Tasks 5-6). If it fails, fix the implementation, not the test. + +- [ ] **Step 3: Run the full module suite** + +Run: `cargo test -p trusted-server-core telemetry` +Expected: PASS (all telemetry tests across types/sink/builder). + +- [ ] **Step 4: Run format and clippy gates** + +Run: `cargo fmt --all -- --check` +Expected: no diff. + +Run: `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` +Expected: no warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/builder.rs +git commit -m "Add end-to-end telemetry builder test over a mixed result" +``` + +--- + +## Self-Review + +**Spec coverage (this plan's slice):** +- Three row grains (summary / provider_call / bid) with one summary per auction: Tasks 3, 5, 6, 7. +- Bid rows only for returned bids; no invented seats on no-bid/error: Tasks 5, 6, 7. +- Win matching on `(slot_id, bidder, ad_id)` with `(slot_id, bidder)` fallback, one win per slot, decoded-price fill, mediator dedup, unmatched-winner synthesis: Task 6. +- Stable NDJSON shape with nulls, no `event_ts` in core: Task 3. +- Sink abstraction in core with test implementations: Task 4. +- Schema column set and wire strings: Tasks 1, 3. + +**Deferred to later plans (not gaps in this one):** +- Constructing `AuctionObservationContext` from `EcContext`/geo/`DeviceSignals`, telemetry-UUID independence from `AuctionRequest.id`, and page-path normalization: Plan 2/3 wiring (those inputs are not available to this pure layer). +- Mapping `BidStatus`/dispatch outcomes to `ProviderCallStatus` and populating `provider_calls`: Plan 2/3. +- `media_type` population from request slots: later wiring plan. +- Fastly sink implementation, `event_ts` stamping, access logs: Plan 4. + +**Placeholder scan:** No `TBD`/`TODO`/"handle edge cases"; every code step shows complete code. + +**Type consistency:** `build_auction_events`, `AuctionEventRow::base`, `to_ndjson`, `AuctionEventSink::emit`, and all field names are used identically across tasks. Field names match the verified `Bid`/`AuctionResponse`/`OrchestrationResult` definitions. From f32642336f132fbe1e63dad70c724599dddc03db Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 18:51:08 -0500 Subject: [PATCH 09/37] Add auction telemetry module scaffold and serialized enums --- crates/trusted-server-core/src/auction/mod.rs | 1 + .../src/auction/telemetry/mod.rs | 10 ++ .../src/auction/telemetry/types.rs | 110 ++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 crates/trusted-server-core/src/auction/telemetry/mod.rs create mode 100644 crates/trusted-server-core/src/auction/telemetry/types.rs diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index ac61a4fa8..886bcd6f7 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -21,6 +21,7 @@ pub mod orchestrator; pub mod provider; #[cfg(test)] pub(crate) mod test_support; +pub mod telemetry; pub mod types; pub use config::AuctionConfig; diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs new file mode 100644 index 000000000..518cfc9f6 --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -0,0 +1,10 @@ +//! Pure auction telemetry: row types, builder, and sink abstraction. +//! +//! Wiring into the orchestrator, SSAT dispatch/collect, and the Fastly sink +//! lives in separate modules; this module performs no I/O. + +pub mod types; + +pub use types::{ + AuctionSource, EventKind, ProviderCallStatus, ProviderRole, TerminalStatus, +}; diff --git a/crates/trusted-server-core/src/auction/telemetry/types.rs b/crates/trusted-server-core/src/auction/telemetry/types.rs new file mode 100644 index 000000000..12a1a9dc1 --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry/types.rs @@ -0,0 +1,110 @@ +//! Value types for auction telemetry rows. +//! +//! These types are pure data: no I/O, no clock, no Fastly dependency. They are +//! shared by the builder and serialized as NDJSON by the Fastly sink. + +use serde::Serialize; + +/// Auction initiation path that produced an observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionSource { + /// Initial publisher navigation via split-phase SSAT. + InitialNavigation, + /// Single-page-app navigation via `GET /__ts/page-bids`. + SpaNavigation, + /// Explicit `POST /auction` API call. + AuctionApi, +} + +/// Terminal status of a candidate auction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminalStatus { + /// Produced an `OrchestrationResult`, including a valid zero-bid result. + Completed, + /// Synchronous orchestration failed. + ExecutionFailed, + /// No provider request could be launched. + DispatchFailed, + /// Split-phase SSAT launched providers but could not collect them. + Abandoned, + /// Matched slots existed but policy prevented initiation. + Skipped, +} + +/// Outcome of a single provider call. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderCallStatus { + /// Provider returned at least one bid. + Success, + /// Provider responded with no bid. + #[serde(rename = "nobid")] + NoBid, + /// Provider request could not be launched. + LaunchError, + /// Provider response could not be parsed. + ParseError, + /// Provider request failed in transport. + TransportError, + /// Provider did not respond before the auction deadline. + Timeout, + /// Provider was dispatched but never collected. + Abandoned, +} + +/// Role a provider played in the auction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderRole { + /// A bidder. + Bidder, + /// The mediation layer. + Mediator, +} + +/// Discriminator for the row grain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + /// One per candidate auction. + Summary, + /// One per provider call. + ProviderCall, + /// One per returned bid (or unmatched mediator winner). + Bid, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enums_serialize_to_expected_wire_strings() { + assert_eq!( + serde_json::to_string(&AuctionSource::InitialNavigation) + .expect("should serialize source"), + "\"initial_navigation\"", + "should use snake_case wire form" + ); + assert_eq!( + serde_json::to_string(&TerminalStatus::ExecutionFailed) + .expect("should serialize status"), + "\"execution_failed\"", + "should use snake_case wire form" + ); + assert_eq!( + serde_json::to_string(&ProviderCallStatus::NoBid) + .expect("should serialize provider status"), + "\"nobid\"", + "should render NoBid as the single token nobid" + ); + assert_eq!( + serde_json::to_string(&EventKind::ProviderCall) + .expect("should serialize kind"), + "\"provider_call\"", + "should use snake_case wire form" + ); + } +} From be0082991c601ec352026614b3e27c48a422c5cb Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 18:56:09 -0500 Subject: [PATCH 10/37] Add observation context and outcome input types for telemetry --- .../src/auction/telemetry/mod.rs | 3 +- .../src/auction/telemetry/types.rs | 78 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index 518cfc9f6..fe3a10fd2 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -6,5 +6,6 @@ pub mod types; pub use types::{ - AuctionSource, EventKind, ProviderCallStatus, ProviderRole, TerminalStatus, + AuctionObservationContext, AuctionSource, EventKind, ProviderCallOutcome, + ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, }; diff --git a/crates/trusted-server-core/src/auction/telemetry/types.rs b/crates/trusted-server-core/src/auction/telemetry/types.rs index 12a1a9dc1..5e952ea3e 100644 --- a/crates/trusted-server-core/src/auction/telemetry/types.rs +++ b/crates/trusted-server-core/src/auction/telemetry/types.rs @@ -4,6 +4,7 @@ //! shared by the builder and serialized as NDJSON by the Fastly sink. use serde::Serialize; +use uuid::Uuid; /// Auction initiation path that produced an observation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -76,10 +77,87 @@ pub enum EventKind { Bid, } +/// Immutable, PII-free snapshot describing one candidate auction. +/// +/// Built once per candidate auction by the wiring layer and carried to the +/// terminal observation point. Contains no EC id, raw user agent, IP, or +/// internal `AuctionRequest.id`. +#[derive(Debug, Clone)] +pub struct AuctionObservationContext { + /// Telemetry-only identifier, minted independently of any request id. + pub auction_id: Uuid, + /// Initiation path. + pub source: AuctionSource, + /// Publisher domain. + pub publisher_domain: String, + /// Bounded, normalized route. No query string or fragment. + pub page_path: String, + /// Coarse country from geo lookup. + pub country: String, + /// Coarse region from geo lookup, when available. + pub region: Option, + /// `0` = desktop, `1` = mobile, `2` = unknown. + pub is_mobile: u8, + /// `0` = bot, `1` = browser, `2` = unknown. + pub is_known_browser: u8, + /// Whether GDPR applies for this request. + pub gdpr_applies: bool, + /// Whether any consent signal was present. + pub consent_present: bool, +} + +/// Terminal outcome of a candidate auction, used for the summary row. +#[derive(Debug, Clone)] +pub struct TerminalOutcome { + /// Terminal status. + pub status: TerminalStatus, + /// Bounded machine-readable reason, e.g. for `skipped` cases. + pub reason: Option, + /// Requested slot count. + pub slot_count: Option, + /// Elapsed time until completion or abandonment. + pub total_time_ms: Option, + /// Winning bid count; zero for non-completed outcomes. + pub winning_bid_count: Option, +} + +/// Outcome of a single provider call, used for provider-call rows. +#[derive(Debug, Clone)] +pub struct ProviderCallOutcome { + /// Provider name, e.g. `prebid`, `aps`, or a mediator name. + pub provider: String, + /// Role the provider played. + pub role: ProviderRole, + /// Provider call status. + pub status: ProviderCallStatus, + /// Provider call latency, when known. + pub response_time_ms: Option, + /// Number of parsed bids, when known. + pub bid_count: Option, +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn observation_context_holds_snapshotted_primitives() { + let ctx = AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/news".to_string(), + country: "US".to_string(), + region: Some("CA".to_string()), + is_mobile: 1, + is_known_browser: 1, + gdpr_applies: false, + consent_present: true, + }; + assert_eq!(ctx.source, AuctionSource::AuctionApi, "should retain source"); + assert_eq!(ctx.region.as_deref(), Some("CA"), "should retain region"); + } + #[test] fn enums_serialize_to_expected_wire_strings() { assert_eq!( From 98422a8b32d21a135aceedfd6f175dbd53b6f3f9 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 18:58:42 -0500 Subject: [PATCH 11/37] Add flat telemetry row struct and NDJSON serialization --- .../src/auction/telemetry/mod.rs | 4 +- .../src/auction/telemetry/types.rs | 173 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index fe3a10fd2..af7e0b0a0 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -6,6 +6,6 @@ pub mod types; pub use types::{ - AuctionObservationContext, AuctionSource, EventKind, ProviderCallOutcome, - ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, + to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, + ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, }; diff --git a/crates/trusted-server-core/src/auction/telemetry/types.rs b/crates/trusted-server-core/src/auction/telemetry/types.rs index 5e952ea3e..5f1c54d91 100644 --- a/crates/trusted-server-core/src/auction/telemetry/types.rs +++ b/crates/trusted-server-core/src/auction/telemetry/types.rs @@ -136,10 +136,154 @@ pub struct ProviderCallOutcome { pub bid_count: Option, } +/// One serialized telemetry row. A single flat shape covers all three grains; +/// fields that do not apply to a row kind are `None` and serialize to JSON +/// `null` so the NDJSON shape is stable across rows. +/// +/// `event_ts` is intentionally absent: core is clock-free and the sink or +/// Tinybird supplies the timestamp. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct AuctionEventRow { + /// Row grain discriminator. + pub event_kind: EventKind, + /// Telemetry id, hyphenated UUID string. + pub auction_id: String, + /// Initiation path. + pub auction_source: AuctionSource, + /// Publisher domain. + pub publisher_domain: String, + /// Bounded normalized route. + pub page_path: String, + /// Coarse country. + pub country: String, + /// Coarse region. + pub region: Option, + /// `0`/`1`/`2` device class. + pub is_mobile: u8, + /// `0`/`1`/`2` browser-legitimacy class. + pub is_known_browser: u8, + /// `0`/`1`. + pub gdpr_applies: u8, + /// `0`/`1`. + pub consent_present: u8, + /// Summary: terminal status. + pub terminal_status: Option, + /// Summary: bounded reason. + pub terminal_reason: Option, + /// Summary: requested slots. + pub slot_count: Option, + /// Summary: elapsed ms. + pub total_time_ms: Option, + /// Summary: winning bid count. + pub winning_bid_count: Option, + /// Provider-call and bid: provider name. + pub provider: Option, + /// Provider-call: role. + pub provider_role: Option, + /// Provider-call: status. + pub status: Option, + /// Provider-call: latency ms. + pub provider_response_time_ms: Option, + /// Provider-call: parsed bid count. + pub provider_bid_count: Option, + /// Bid: slot id. + pub slot_id: Option, + /// Bid: returned creative width. + pub slot_w: Option, + /// Bid: returned creative height. + pub slot_h: Option, + /// Bid: media type, filled by a later wiring plan. + pub media_type: Option, + /// Bid: seat/bidder name. + pub seat: Option, + /// Bid: decoded CPM when available. + pub price_cpm: Option, + /// Bid: currency. + pub currency: Option, + /// Bid: `1` for the one canonical winning row per slot, else `0`. + pub is_win: Option, + /// Bid: first advertiser domain. + pub ad_domain: Option, + /// Bid: creative id. + pub ad_id: Option, +} + +impl AuctionEventRow { + /// Build a row with the shared columns filled from `ctx` and every + /// kind-specific column set to `None`. + #[must_use] + pub fn base(ctx: &AuctionObservationContext, kind: EventKind) -> Self { + Self { + event_kind: kind, + auction_id: ctx.auction_id.to_string(), + auction_source: ctx.source, + publisher_domain: ctx.publisher_domain.clone(), + page_path: ctx.page_path.clone(), + country: ctx.country.clone(), + region: ctx.region.clone(), + is_mobile: ctx.is_mobile, + is_known_browser: ctx.is_known_browser, + gdpr_applies: u8::from(ctx.gdpr_applies), + consent_present: u8::from(ctx.consent_present), + terminal_status: None, + terminal_reason: None, + slot_count: None, + total_time_ms: None, + winning_bid_count: None, + provider: None, + provider_role: None, + status: None, + provider_response_time_ms: None, + provider_bid_count: None, + slot_id: None, + slot_w: None, + slot_h: None, + media_type: None, + seat: None, + price_cpm: None, + currency: None, + is_win: None, + ad_domain: None, + ad_id: None, + } + } +} + +/// Serialize rows as newline-delimited JSON with no trailing newline. +/// +/// # Errors +/// +/// Returns the underlying `serde_json` error if a row cannot be serialized. +pub fn to_ndjson(rows: &[AuctionEventRow]) -> Result { + let mut out = String::new(); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(&serde_json::to_string(row)?); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; + fn sample_context() -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::SpaNavigation, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 0, + is_known_browser: 1, + gdpr_applies: true, + consent_present: false, + } + } + #[test] fn observation_context_holds_snapshotted_primitives() { let ctx = AuctionObservationContext { @@ -185,4 +329,33 @@ mod tests { "should use snake_case wire form" ); } + + #[test] + fn base_row_fills_shared_fields_and_nulls_the_rest() { + let row = AuctionEventRow::base(&sample_context(), EventKind::Summary); + assert_eq!(row.event_kind, EventKind::Summary, "should set kind"); + assert_eq!(row.gdpr_applies, 1, "should map true to 1"); + assert_eq!(row.consent_present, 0, "should map false to 0"); + assert!(row.terminal_status.is_none(), "should null summary fields"); + assert!(row.provider.is_none(), "should null provider fields"); + assert!(row.slot_id.is_none(), "should null bid fields"); + } + + #[test] + fn to_ndjson_is_one_compact_object_per_line() { + let rows = vec![ + AuctionEventRow::base(&sample_context(), EventKind::Summary), + AuctionEventRow::base(&sample_context(), EventKind::Bid), + ]; + let ndjson = to_ndjson(&rows).expect("should serialize rows"); + let lines: Vec<&str> = ndjson.split('\n').collect(); + assert_eq!(lines.len(), 2, "should emit one line per row with no trailing newline"); + for line in &lines { + let value: serde_json::Value = + serde_json::from_str(line).expect("each line should be valid JSON"); + assert!(value.get("event_kind").is_some(), "should always include event_kind"); + assert!(value.get("auction_id").is_some(), "should always include auction_id"); + assert!(value.get("region").is_some(), "should include region key even when null"); + } + } } From 82a66f3d84c90a80dd88003a5c210f29df5c310f Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:04:12 -0500 Subject: [PATCH 12/37] Add auction event sink trait and test sinks --- .../src/auction/telemetry/mod.rs | 2 + .../src/auction/telemetry/sink.rs | 97 +++++++++++++++++++ .../src/auction/telemetry/types.rs | 30 ++++-- 3 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 crates/trusted-server-core/src/auction/telemetry/sink.rs diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index af7e0b0a0..39e7425fc 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -3,8 +3,10 @@ //! Wiring into the orchestrator, SSAT dispatch/collect, and the Fastly sink //! lives in separate modules; this module performs no I/O. +pub mod sink; pub mod types; +pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; pub use types::{ to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, diff --git a/crates/trusted-server-core/src/auction/telemetry/sink.rs b/crates/trusted-server-core/src/auction/telemetry/sink.rs new file mode 100644 index 000000000..cd5281351 --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry/sink.rs @@ -0,0 +1,97 @@ +//! Sink abstraction for emitting telemetry rows. +//! +//! Core defines the trait and test implementations. The Fastly adapter provides +//! the real implementation that serializes rows to a named log endpoint. + +use std::sync::Mutex; + +use crate::auction::telemetry::types::AuctionEventRow; + +/// Destination for telemetry rows. +/// +/// Implementations must be cheap and non-blocking from the caller's view; the +/// Fastly implementation performs a buffered host write. +pub trait AuctionEventSink: Send + Sync { + /// Emit a batch of rows for one auction observation. + fn emit(&self, rows: &[AuctionEventRow]); +} + +/// Sink that discards rows. Used where telemetry is disabled and in tests. +#[derive(Debug, Default)] +pub struct NoopSink; + +impl AuctionEventSink for NoopSink { + fn emit(&self, _rows: &[AuctionEventRow]) {} +} + +/// Sink that accumulates rows in memory for assertions in tests. +#[derive(Debug, Default)] +pub struct InMemorySink { + captured: Mutex>, +} + +impl InMemorySink { + /// Return a clone of all captured rows in emission order. + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned (never happens in correct use). + #[must_use] + pub fn rows(&self) -> Vec { + self.captured + .lock() + .expect("should lock captured rows") + .clone() + } +} + +impl AuctionEventSink for InMemorySink { + fn emit(&self, rows: &[AuctionEventRow]) { + self.captured + .lock() + .expect("should lock captured rows") + .extend_from_slice(rows); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::types::{ + AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, + }; + + fn ctx() -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 2, + is_known_browser: 2, + gdpr_applies: false, + consent_present: false, + } + } + + #[test] + fn in_memory_sink_captures_emitted_rows() { + let sink = InMemorySink::default(); + let rows = vec![AuctionEventRow::base(&ctx(), EventKind::Summary)]; + sink.emit(&rows); + sink.emit(&rows); + assert_eq!( + sink.rows().len(), + 2, + "should accumulate rows across emit calls" + ); + } + + #[test] + fn noop_sink_accepts_rows() { + let sink = NoopSink; + sink.emit(&[AuctionEventRow::base(&ctx(), EventKind::Summary)]); + } +} diff --git a/crates/trusted-server-core/src/auction/telemetry/types.rs b/crates/trusted-server-core/src/auction/telemetry/types.rs index 5f1c54d91..5f4734c6a 100644 --- a/crates/trusted-server-core/src/auction/telemetry/types.rs +++ b/crates/trusted-server-core/src/auction/telemetry/types.rs @@ -298,7 +298,11 @@ mod tests { gdpr_applies: false, consent_present: true, }; - assert_eq!(ctx.source, AuctionSource::AuctionApi, "should retain source"); + assert_eq!( + ctx.source, + AuctionSource::AuctionApi, + "should retain source" + ); assert_eq!(ctx.region.as_deref(), Some("CA"), "should retain region"); } @@ -323,8 +327,7 @@ mod tests { "should render NoBid as the single token nobid" ); assert_eq!( - serde_json::to_string(&EventKind::ProviderCall) - .expect("should serialize kind"), + serde_json::to_string(&EventKind::ProviderCall).expect("should serialize kind"), "\"provider_call\"", "should use snake_case wire form" ); @@ -349,13 +352,26 @@ mod tests { ]; let ndjson = to_ndjson(&rows).expect("should serialize rows"); let lines: Vec<&str> = ndjson.split('\n').collect(); - assert_eq!(lines.len(), 2, "should emit one line per row with no trailing newline"); + assert_eq!( + lines.len(), + 2, + "should emit one line per row with no trailing newline" + ); for line in &lines { let value: serde_json::Value = serde_json::from_str(line).expect("each line should be valid JSON"); - assert!(value.get("event_kind").is_some(), "should always include event_kind"); - assert!(value.get("auction_id").is_some(), "should always include auction_id"); - assert!(value.get("region").is_some(), "should include region key even when null"); + assert!( + value.get("event_kind").is_some(), + "should always include event_kind" + ); + assert!( + value.get("auction_id").is_some(), + "should always include auction_id" + ); + assert!( + value.get("region").is_some(), + "should include region key even when null" + ); } } } From bf8ec7f6d25b9cee965e9a9d88a589d9cc6a7944 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:07:48 -0500 Subject: [PATCH 13/37] Add telemetry builder for summary and provider-call rows --- .../src/auction/telemetry/builder.rs | 168 ++++++++++++++++++ .../src/auction/telemetry/mod.rs | 2 + 2 files changed, 170 insertions(+) create mode 100644 crates/trusted-server-core/src/auction/telemetry/builder.rs diff --git a/crates/trusted-server-core/src/auction/telemetry/builder.rs b/crates/trusted-server-core/src/auction/telemetry/builder.rs new file mode 100644 index 000000000..ba9c6bb4e --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry/builder.rs @@ -0,0 +1,168 @@ +//! Pure builder that turns an auction observation into telemetry rows. + +use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::telemetry::types::{ + AuctionEventRow, AuctionObservationContext, EventKind, ProviderCallOutcome, TerminalOutcome, +}; + +/// Build all telemetry rows for one auction observation. +/// +/// Always emits exactly one summary row, one provider-call row per entry in +/// `provider_calls`, and (when `result` is `Some`) one bid row per returned bid +/// plus one row for any winning slot not matched to a returned bid. +#[must_use] +pub fn build_auction_events( + ctx: &AuctionObservationContext, + outcome: &TerminalOutcome, + provider_calls: &[ProviderCallOutcome], + result: Option<&OrchestrationResult>, +) -> Vec { + let mut rows = Vec::new(); + rows.push(summary_row(ctx, outcome)); + for call in provider_calls { + rows.push(provider_call_row(ctx, call)); + } + if let Some(result) = result { + rows.extend(build_bid_rows(ctx, result)); + } + rows +} + +/// Build the single summary row. +fn summary_row(ctx: &AuctionObservationContext, outcome: &TerminalOutcome) -> AuctionEventRow { + let mut row = AuctionEventRow::base(ctx, EventKind::Summary); + row.terminal_status = Some(outcome.status); + row.terminal_reason = outcome.reason.clone(); + row.slot_count = outcome.slot_count; + row.total_time_ms = outcome.total_time_ms; + row.winning_bid_count = outcome.winning_bid_count; + row +} + +/// Build one provider-call row. +fn provider_call_row( + ctx: &AuctionObservationContext, + call: &ProviderCallOutcome, +) -> AuctionEventRow { + let mut row = AuctionEventRow::base(ctx, EventKind::ProviderCall); + row.provider = Some(call.provider.clone()); + row.provider_role = Some(call.role); + row.status = Some(call.status); + row.provider_response_time_ms = call.response_time_ms; + row.provider_bid_count = call.bid_count; + row +} + +/// Build bid rows from a completed orchestration result. Implemented in Task 6. +fn build_bid_rows( + _ctx: &AuctionObservationContext, + _result: &OrchestrationResult, +) -> Vec { + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::types::{ + AuctionObservationContext, AuctionSource, EventKind, ProviderCallOutcome, + ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, + }; + + fn ctx(source: AuctionSource) -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 1, + is_known_browser: 1, + gdpr_applies: false, + consent_present: true, + } + } + + #[test] + fn abandoned_auction_emits_summary_plus_provider_calls_no_bids() { + let outcome = TerminalOutcome { + status: TerminalStatus::Abandoned, + reason: Some("origin_unrewritable".to_string()), + slot_count: Some(2), + total_time_ms: Some(120), + winning_bid_count: Some(0), + }; + let calls = vec![ + ProviderCallOutcome { + provider: "prebid".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::Abandoned, + response_time_ms: None, + bid_count: None, + }, + ProviderCallOutcome { + provider: "aps".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::Abandoned, + response_time_ms: None, + bid_count: None, + }, + ]; + + let rows = build_auction_events( + &ctx(AuctionSource::InitialNavigation), + &outcome, + &calls, + None, + ); + + let summaries: Vec<_> = rows + .iter() + .filter(|r| r.event_kind == EventKind::Summary) + .collect(); + assert_eq!(summaries.len(), 1, "should emit exactly one summary row"); + assert_eq!( + summaries[0].terminal_status, + Some(TerminalStatus::Abandoned), + "should record the terminal status on the summary" + ); + assert_eq!( + rows.iter() + .filter(|r| r.event_kind == EventKind::ProviderCall) + .count(), + 2, + "should emit one provider-call row per outcome" + ); + assert_eq!( + rows.iter() + .filter(|r| r.event_kind == EventKind::Bid) + .count(), + 0, + "should emit no bid rows when there is no result" + ); + } + + #[test] + fn skipped_auction_emits_only_a_summary() { + let outcome = TerminalOutcome { + status: TerminalStatus::Skipped, + reason: Some("consent".to_string()), + slot_count: Some(3), + total_time_ms: None, + winning_bid_count: Some(0), + }; + let rows = build_auction_events(&ctx(AuctionSource::AuctionApi), &outcome, &[], None); + assert_eq!(rows.len(), 1, "should emit only the summary row"); + assert_eq!( + rows[0].event_kind, + EventKind::Summary, + "should be a summary" + ); + assert_eq!( + rows[0].terminal_reason.as_deref(), + Some("consent"), + "should carry the reason" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index 39e7425fc..a7b99258e 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -3,9 +3,11 @@ //! Wiring into the orchestrator, SSAT dispatch/collect, and the Fastly sink //! lives in separate modules; this module performs no I/O. +pub mod builder; pub mod sink; pub mod types; +pub use builder::build_auction_events; pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; pub use types::{ to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, From ea6a114ddf9e6df763d1a694049f8f9e042242ea Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:16:31 -0500 Subject: [PATCH 14/37] Build bid rows with win matching and mediator dedup --- .../src/auction/telemetry/builder.rs | 264 +++++++++++++++++- 1 file changed, 260 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/auction/telemetry/builder.rs b/crates/trusted-server-core/src/auction/telemetry/builder.rs index ba9c6bb4e..ff01b93c3 100644 --- a/crates/trusted-server-core/src/auction/telemetry/builder.rs +++ b/crates/trusted-server-core/src/auction/telemetry/builder.rs @@ -4,6 +4,7 @@ use crate::auction::orchestrator::OrchestrationResult; use crate::auction::telemetry::types::{ AuctionEventRow, AuctionObservationContext, EventKind, ProviderCallOutcome, TerminalOutcome, }; +use crate::auction::types::Bid; /// Build all telemetry rows for one auction observation. /// @@ -53,12 +54,101 @@ fn provider_call_row( row } -/// Build bid rows from a completed orchestration result. Implemented in Task 6. +/// Build bid rows from a completed orchestration result. fn build_bid_rows( - _ctx: &AuctionObservationContext, - _result: &OrchestrationResult, + ctx: &AuctionObservationContext, + result: &OrchestrationResult, ) -> Vec { - Vec::new() + let mut rows = Vec::new(); + // Slots whose winning row has already been emitted, so each slot has at + // most one `is_win = 1` row. + let mut claimed_slots: Vec = Vec::new(); + + for response in &result.provider_responses { + for bid in &response.bids { + let winner = result.winning_bids.get(&bid.slot_id); + let is_win = match winner { + Some(winner) => { + matches_winner(bid, winner) && !claimed_slots.contains(&bid.slot_id) + } + None => false, + }; + let price_override = if is_win && bid.price.is_none() { + winner.and_then(|winner| winner.price) + } else { + None + }; + if is_win { + claimed_slots.push(bid.slot_id.clone()); + } + rows.push(bid_row( + ctx, + &response.provider, + bid, + is_win, + price_override, + )); + } + } + + // Any winning slot not matched to a returned bid gets one canonical + // mediator-derived winner row. + let mediator_provider = result + .mediator_response + .as_ref() + .map(|response| response.provider.clone()) + .unwrap_or_else(|| "mediator".to_string()); + for (slot_id, winner) in &result.winning_bids { + if !claimed_slots.contains(slot_id) { + rows.push(bid_row(ctx, &mediator_provider, winner, true, winner.price)); + } + } + + rows +} + +/// Whether a returned bid is the winner for its slot. +fn matches_winner(candidate: &Bid, winner: &Bid) -> bool { + if candidate.slot_id != winner.slot_id || candidate.bidder != winner.bidder { + return false; + } + match (&candidate.ad_id, &winner.ad_id) { + (Some(left), Some(right)) => left == right, + // Fall back to (slot, seat) identity when ad ids are absent. + _ => true, + } +} + +/// Build one bid row. `price_override` carries a mediator-decoded price for a +/// winning bid whose own price is null. +fn bid_row( + ctx: &AuctionObservationContext, + provider: &str, + bid: &Bid, + is_win: bool, + price_override: Option, +) -> AuctionEventRow { + let mut row = AuctionEventRow::base(ctx, EventKind::Bid); + row.provider = Some(provider.to_string()); + row.slot_id = Some(bid.slot_id.clone()); + row.slot_w = Some(clamp_dimension(bid.width)); + row.slot_h = Some(clamp_dimension(bid.height)); + row.seat = Some(bid.bidder.clone()); + row.price_cpm = price_override.or(bid.price); + row.currency = Some(bid.currency.clone()); + row.is_win = Some(u8::from(is_win)); + row.ad_domain = bid + .adomain + .as_ref() + .and_then(|domains| domains.first().cloned()); + row.ad_id = bid.ad_id.clone(); + row +} + +/// Clamp a `u32` creative dimension into the `u16` schema column without +/// panicking. Real creative sizes are always well within `u16`. +fn clamp_dimension(value: u32) -> u16 { + value.min(u32::from(u16::MAX)) as u16 } #[cfg(test)] @@ -68,6 +158,8 @@ mod tests { AuctionObservationContext, AuctionSource, EventKind, ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, }; + use crate::auction::types::{AuctionResponse, Bid, BidStatus}; + use std::collections::HashMap; fn ctx(source: AuctionSource) -> AuctionObservationContext { AuctionObservationContext { @@ -84,6 +176,46 @@ mod tests { } } + fn bid(slot: &str, bidder: &str, price: Option, ad_id: Option<&str>) -> Bid { + Bid { + slot_id: slot.to_string(), + price, + currency: "USD".to_string(), + creative: None, + adomain: Some(vec!["advertiser.example".to_string()]), + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: ad_id.map(str::to_string), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + + fn response(provider: &str, bids: Vec, status: BidStatus) -> AuctionResponse { + AuctionResponse { + provider: provider.to_string(), + bids, + status, + response_time_ms: 42, + metadata: HashMap::new(), + } + } + + fn completed_outcome() -> TerminalOutcome { + TerminalOutcome { + status: TerminalStatus::Completed, + reason: None, + slot_count: Some(1), + total_time_ms: Some(50), + winning_bid_count: Some(1), + } + } + #[test] fn abandoned_auction_emits_summary_plus_provider_calls_no_bids() { let outcome = TerminalOutcome { @@ -165,4 +297,128 @@ mod tests { "should carry the reason" ); } + + #[test] + fn emits_one_bid_row_per_returned_bid_with_single_winner() { + let winner = bid("slot-1", "kargo", Some(2.5), Some("creative-1")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-1".to_string(), winner.clone()); + let result = OrchestrationResult { + provider_responses: vec![response( + "prebid", + vec![ + bid("slot-1", "kargo", Some(2.5), Some("creative-1")), + bid("slot-1", "ix", Some(1.0), Some("creative-2")), + ], + BidStatus::Success, + )], + mediator_response: None, + winning_bids, + total_time_ms: 50, + metadata: HashMap::new(), + }; + + let rows = build_auction_events( + &ctx(AuctionSource::AuctionApi), + &completed_outcome(), + &[], + Some(&result), + ); + let bid_rows: Vec<_> = rows + .iter() + .filter(|r| r.event_kind == EventKind::Bid) + .collect(); + + assert_eq!(bid_rows.len(), 2, "should emit one row per returned bid"); + assert_eq!( + bid_rows.iter().filter(|r| r.is_win == Some(1)).count(), + 1, + "should mark exactly one winning row per slot" + ); + let winning = bid_rows + .iter() + .find(|r| r.is_win == Some(1)) + .expect("should have a winner"); + assert_eq!( + winning.seat.as_deref(), + Some("kargo"), + "should win for the matched seat" + ); + } + + #[test] + fn fills_decoded_price_on_null_priced_winner() { + let winner = bid("slot-1", "aps", Some(3.1), Some("amzn-1")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-1".to_string(), winner); + let result = OrchestrationResult { + // The original APS bid has no decoded price. + provider_responses: vec![response( + "aps", + vec![bid("slot-1", "aps", None, Some("amzn-1"))], + BidStatus::Success, + )], + mediator_response: Some(response("mediator", vec![], BidStatus::Success)), + winning_bids, + total_time_ms: 60, + metadata: HashMap::new(), + }; + + let rows = build_auction_events( + &ctx(AuctionSource::AuctionApi), + &completed_outcome(), + &[], + Some(&result), + ); + let winning = rows + .iter() + .find(|r| r.event_kind == EventKind::Bid && r.is_win == Some(1)) + .expect("should have a winning bid row"); + assert_eq!( + winning.price_cpm, + Some(3.1), + "should fill decoded winner price on a null-priced bid" + ); + } + + #[test] + fn unmatched_winner_emits_one_mediator_row() { + let winner = bid("slot-9", "exclusive-seat", Some(5.0), Some("only-here")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-9".to_string(), winner); + let result = OrchestrationResult { + // No provider response contains the winning bid. + provider_responses: vec![response("prebid", vec![], BidStatus::NoBid)], + mediator_response: Some(response("mediator", vec![], BidStatus::Success)), + winning_bids, + total_time_ms: 70, + metadata: HashMap::new(), + }; + + let rows = build_auction_events( + &ctx(AuctionSource::AuctionApi), + &completed_outcome(), + &[], + Some(&result), + ); + let bid_rows: Vec<_> = rows + .iter() + .filter(|r| r.event_kind == EventKind::Bid) + .collect(); + assert_eq!( + bid_rows.len(), + 1, + "should synthesize one row for the unmatched winner" + ); + assert_eq!( + bid_rows[0].is_win, + Some(1), + "should mark the synthesized row as the win" + ); + assert_eq!( + bid_rows[0].provider.as_deref(), + Some("mediator"), + "should attribute it to the mediator" + ); + } } From 225d5cc8ebabe6602774ea828b72534a353e8758 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:22:46 -0500 Subject: [PATCH 15/37] Add end-to-end telemetry builder test over a mixed result Completes the telemetry module suite by testing one realistic case with multiple providers, mixed bid outcomes, and complete event grain emission. --- .../src/auction/telemetry/builder.rs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/trusted-server-core/src/auction/telemetry/builder.rs b/crates/trusted-server-core/src/auction/telemetry/builder.rs index ff01b93c3..3a2751c8c 100644 --- a/crates/trusted-server-core/src/auction/telemetry/builder.rs +++ b/crates/trusted-server-core/src/auction/telemetry/builder.rs @@ -421,4 +421,89 @@ mod tests { "should attribute it to the mediator" ); } + + #[test] + fn completed_result_with_mixed_providers_produces_expected_grains() { + // Arrange: one successful provider with two bids (one wins), one no-bid + // provider, and an explicit provider-call list mirroring those outcomes. + let winner = bid("slot-1", "kargo", Some(4.0), Some("c-1")); + let mut winning_bids = HashMap::new(); + winning_bids.insert("slot-1".to_string(), winner); + let result = OrchestrationResult { + provider_responses: vec![ + response( + "prebid", + vec![ + bid("slot-1", "kargo", Some(4.0), Some("c-1")), + bid("slot-1", "ix", Some(2.0), Some("c-2")), + ], + BidStatus::Success, + ), + response("aps", vec![], BidStatus::NoBid), + ], + mediator_response: None, + winning_bids, + total_time_ms: 88, + metadata: HashMap::new(), + }; + let calls = vec![ + ProviderCallOutcome { + provider: "prebid".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::Success, + response_time_ms: Some(42), + bid_count: Some(2), + }, + ProviderCallOutcome { + provider: "aps".to_string(), + role: ProviderRole::Bidder, + status: ProviderCallStatus::NoBid, + response_time_ms: Some(40), + bid_count: Some(0), + }, + ]; + + // Act + let rows = build_auction_events( + &ctx(AuctionSource::SpaNavigation), + &completed_outcome(), + &calls, + Some(&result), + ); + + // Assert: exactly one summary, two provider-call rows, two bid rows, and no + // invented seats on the no-bid provider. + assert_eq!( + rows.iter() + .filter(|r| r.event_kind == EventKind::Summary) + .count(), + 1, + "should emit exactly one summary" + ); + assert_eq!( + rows.iter() + .filter(|r| r.event_kind == EventKind::ProviderCall) + .count(), + 2, + "should emit one provider-call row per provider" + ); + let bid_rows: Vec<_> = rows + .iter() + .filter(|r| r.event_kind == EventKind::Bid) + .collect(); + assert_eq!( + bid_rows.len(), + 2, + "should emit a bid row only for returned bids" + ); + assert!( + bid_rows.iter().all(|r| r.seat.is_some()), + "should never emit a bid row without a seat" + ); + assert_eq!( + bid_rows.iter().filter(|r| r.is_win == Some(1)).count(), + 1, + "should mark exactly one winning bid" + ); + } } From acb65137c95377311d981d25b573235f86de745c Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:27:29 -0500 Subject: [PATCH 16/37] Order telemetry module declaration alphabetically --- crates/trusted-server-core/src/auction/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 886bcd6f7..1bea2a639 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -19,9 +19,9 @@ pub mod endpoints; pub mod formats; pub mod orchestrator; pub mod provider; +pub mod telemetry; #[cfg(test)] pub(crate) mod test_support; -pub mod telemetry; pub mod types; pub use config::AuctionConfig; From 4c85d966ee371db3f8dfa5209ead8c614033bea4 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:52:34 -0500 Subject: [PATCH 17/37] Add minimal plan for auction telemetry result mapping --- .../2026-06-22-auction-telemetry-mapping.md | 448 ++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md diff --git a/docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md b/docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md new file mode 100644 index 000000000..1b6639e08 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md @@ -0,0 +1,448 @@ +# Auction Telemetry Mapping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a pure mapping layer in `trusted-server-core` that turns a real `OrchestrationResult` (a completed auction) into the Plan 1 telemetry inputs and the full row set, so a later wiring plan can call one function at the auction call sites. + +**Architecture:** A new `auction::telemetry::mapping` module reads the per-provider outcomes the orchestrator already records (provider name, `BidStatus`, `response_time_ms`, and the `error_type` metadata for `launch_failed`/`parse_response`/`transport`) and produces `ProviderCallOutcome`s and a `Completed` `TerminalOutcome`, then delegates to the existing `build_auction_events`. Pure, no I/O, no edits to the orchestrator or any handler. + +**Tech Stack:** Rust 2024, `serde_json` (only for reading `error_type` from metadata in tests/values). + +## Global Constraints + +Same as the core telemetry plan; every task implicitly includes these: + +- Rust **2024 edition**. No `unwrap()` in non-test code (use `expect("should ...")`; `unwrap_or`/`unwrap_or_else` are allowed). No `println!`/`eprintln!`. +- Comments on their own line above the code, never inline. No imports inside functions; no wildcard imports outside `#[cfg(test)]` (`use super::*;` allowed there). +- Tests: Arrange-Act-Assert, `expect()` with `"should ..."` messages, descriptive assertion messages, `serde_json::json!` over raw JSON strings. +- Each public item has a doc comment. +- Git commit messages: sentence case, imperative, no semantic prefixes (`feat:`/`fix:`), no bracketed tags, no `Co-Authored-By` trailer. Use the exact message in each task's commit step. + +**Scope boundary (what this plan deliberately does NOT do):** It does not call these functions from any handler, does not touch `run_auction`/`dispatch_auction`/`collect_dispatched_auction`, does not implement the Fastly sink, does not emit access logs, and does not handle SSAT abandonment. Those are later plans. This plan only adds pure, unit-tested core functions. + +**Verified facts this plan relies on (from the current code):** +- `OrchestrationResult` (`crates/trusted-server-core/src/auction/orchestrator.rs`): `provider_responses: Vec`, `mediator_response: Option`, `winning_bids: HashMap`, `total_time_ms: u64`, `metadata`. +- `AuctionResponse` (`auction/types.rs`): `provider: String`, `bids: Vec`, `status: BidStatus`, `response_time_ms: u64`, `metadata: HashMap`. +- On an `Error` response the orchestrator writes `metadata["error_type"]` to one of `"launch_failed"`, `"parse_response"`, `"transport"`. +- `BidStatus` variants: `Success`, `NoBid`, `Error`, `Pending`. + +--- + +### Task 1: Map a completed result to provider-call outcomes + +**Files:** +- Create: `crates/trusted-server-core/src/auction/telemetry/mapping.rs` +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `mapping`, re-export `provider_calls_from_result`) +- Test: inline `#[cfg(test)]` in `mapping.rs` + +**Interfaces:** +- Consumes: `OrchestrationResult` (orchestrator), `AuctionResponse`, `BidStatus`, `Bid` (auction/types), and `ProviderCallOutcome`, `ProviderCallStatus`, `ProviderRole` (telemetry::types). +- Produces: `pub fn provider_calls_from_result(result: &OrchestrationResult) -> Vec` — one outcome per `provider_responses` entry (role `Bidder`) plus one for `mediator_response` when present (role `Mediator`). Status mapping: `Success -> Success`, `NoBid -> NoBid`, `Pending -> Timeout`, `Error -> {launch_failed: LaunchError, parse_response: ParseError, transport: TransportError, else: TransportError}`. + +- [ ] **Step 1: Write the failing test** + +Create `crates/trusted-server-core/src/auction/telemetry/mapping.rs` with the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::orchestrator::OrchestrationResult; + use crate::auction::types::{AuctionResponse, Bid, BidStatus}; + use crate::auction::telemetry::types::{ProviderCallStatus, ProviderRole}; + use std::collections::HashMap; + + fn bid(slot: &str, bidder: &str) -> Bid { + Bid { + slot_id: slot.to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + + fn response( + provider: &str, + status: BidStatus, + time: u64, + bids: Vec, + error_type: Option<&str>, + ) -> AuctionResponse { + let mut metadata = HashMap::new(); + if let Some(kind) = error_type { + metadata.insert("error_type".to_string(), serde_json::json!(kind)); + } + AuctionResponse { + provider: provider.to_string(), + bids, + status, + response_time_ms: time, + metadata, + } + } + + fn result( + provider_responses: Vec, + mediator_response: Option, + ) -> OrchestrationResult { + OrchestrationResult { + provider_responses, + mediator_response, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + } + } + + #[test] + fn maps_each_status_to_the_expected_provider_call_status() { + let res = result( + vec![ + response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None), + response("rubicon", BidStatus::NoBid, 30, vec![], None), + response("ix", BidStatus::Error, 10, vec![], Some("launch_failed")), + response("appnexus", BidStatus::Error, 55, vec![], Some("parse_response")), + response("openx", BidStatus::Error, 60, vec![], Some("transport")), + response("smaato", BidStatus::Error, 5, vec![], None), + response("teads", BidStatus::Pending, 70, vec![], None), + ], + None, + ); + + let calls = provider_calls_from_result(&res); + + assert_eq!(calls.len(), 7, "should emit one outcome per provider response"); + assert_eq!(calls[0].status, ProviderCallStatus::Success, "Success maps to Success"); + assert_eq!(calls[0].bid_count, Some(1), "should count returned bids"); + assert_eq!(calls[0].response_time_ms, Some(40), "should carry response time"); + assert_eq!(calls[0].role, ProviderRole::Bidder, "provider responses are bidders"); + assert_eq!(calls[1].status, ProviderCallStatus::NoBid, "NoBid maps to NoBid"); + assert_eq!(calls[2].status, ProviderCallStatus::LaunchError, "launch_failed maps to LaunchError"); + assert_eq!(calls[3].status, ProviderCallStatus::ParseError, "parse_response maps to ParseError"); + assert_eq!(calls[4].status, ProviderCallStatus::TransportError, "transport maps to TransportError"); + assert_eq!( + calls[5].status, + ProviderCallStatus::TransportError, + "an Error with no recognized error_type falls back to TransportError" + ); + assert_eq!(calls[6].status, ProviderCallStatus::Timeout, "Pending maps to Timeout"); + } + + #[test] + fn appends_a_mediator_outcome_when_present() { + let res = result( + vec![response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None)], + Some(response("mediator", BidStatus::Success, 12, vec![], None)), + ); + + let calls = provider_calls_from_result(&res); + + assert_eq!(calls.len(), 2, "should append one outcome for the mediator"); + let mediator = calls.last().expect("should have a mediator outcome"); + assert_eq!(mediator.role, ProviderRole::Mediator, "mediator outcome uses the Mediator role"); + assert_eq!(mediator.provider, "mediator", "should carry the mediator provider name"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::mapping` +Expected: FAIL to compile (`mapping` module not declared; `provider_calls_from_result` not found). + +- [ ] **Step 3: Write minimal implementation** + +Prepend to `mapping.rs` (above the test module): + +```rust +//! Maps a real `OrchestrationResult` into telemetry inputs. +//! +//! This is the adapter between the orchestrator's output types and the pure +//! telemetry builder. It performs no I/O and does not modify the auction. + +use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::telemetry::types::{ + ProviderCallOutcome, ProviderCallStatus, ProviderRole, +}; +use crate::auction::types::{AuctionResponse, BidStatus}; + +/// Build one provider-call outcome per provider response, plus one for the +/// mediator when a mediator response is present. +#[must_use] +pub fn provider_calls_from_result(result: &OrchestrationResult) -> Vec { + let mut calls: Vec = result + .provider_responses + .iter() + .map(|response| provider_call_outcome(response, ProviderRole::Bidder)) + .collect(); + if let Some(mediator) = &result.mediator_response { + calls.push(provider_call_outcome(mediator, ProviderRole::Mediator)); + } + calls +} + +/// Map one response to a provider-call outcome with the given role. +fn provider_call_outcome(response: &AuctionResponse, role: ProviderRole) -> ProviderCallOutcome { + ProviderCallOutcome { + provider: response.provider.clone(), + role, + status: provider_call_status(response), + response_time_ms: Some(clamp_u32(response.response_time_ms)), + bid_count: Some(clamp_u16(response.bids.len())), + } +} + +/// Classify a response into a provider-call status. For `Error`, read the +/// orchestrator's `error_type` metadata; an unrecognized or absent value falls +/// back to `TransportError` since the orchestrator only emits the three known +/// error types. +fn provider_call_status(response: &AuctionResponse) -> ProviderCallStatus { + match response.status { + BidStatus::Success => ProviderCallStatus::Success, + BidStatus::NoBid => ProviderCallStatus::NoBid, + BidStatus::Pending => ProviderCallStatus::Timeout, + BidStatus::Error => match response + .metadata + .get("error_type") + .and_then(|value| value.as_str()) + { + Some("launch_failed") => ProviderCallStatus::LaunchError, + Some("parse_response") => ProviderCallStatus::ParseError, + Some("transport") => ProviderCallStatus::TransportError, + _ => ProviderCallStatus::TransportError, + }, + } +} + +/// Clamp a `u64` millisecond count into the `u32` schema column without +/// panicking. +fn clamp_u32(value: u64) -> u32 { + value.min(u64::from(u32::MAX)) as u32 +} + +/// Clamp a count into the `u16` schema column without panicking. +fn clamp_u16(value: usize) -> u16 { + value.min(usize::from(u16::MAX)) as u16 +} +``` + +Update `mod.rs`: add `pub mod mapping;` (alphabetically, before `pub mod sink;`) and add the re-export `pub use mapping::provider_calls_from_result;` near the other `pub use` lines. The full module file should read: + +```rust +//! Pure auction telemetry: row types, builder, and sink abstraction. +//! +//! Wiring into the orchestrator, SSAT dispatch/collect, and the Fastly sink +//! lives in separate modules; this module performs no I/O. + +pub mod builder; +pub mod mapping; +pub mod sink; +pub mod types; + +pub use builder::build_auction_events; +pub use mapping::provider_calls_from_result; +pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; +pub use types::{ + to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, + ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::mapping` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/mapping.rs crates/trusted-server-core/src/auction/telemetry/mod.rs +git commit -m "Map orchestration result to provider-call telemetry outcomes" +``` + +--- + +### Task 2: Build the full completed-auction row set from a result + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/telemetry/mapping.rs` (add two functions + tests) +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export the two new functions) +- Test: inline `#[cfg(test)]` in `mapping.rs` + +**Interfaces:** +- Consumes: `provider_calls_from_result` (Task 1), `build_auction_events`, `AuctionObservationContext`, `AuctionEventRow`, `TerminalOutcome`, `TerminalStatus` (telemetry), `OrchestrationResult`. +- Produces: + - `pub fn completed_outcome(result: &OrchestrationResult, slot_count: u16) -> TerminalOutcome` — `status = Completed`, `reason = None`, `slot_count = Some(slot_count)`, `total_time_ms = Some(result.total_time_ms clamped)`, `winning_bid_count = Some(result.winning_bids.len() clamped)`. + - `pub fn build_completed_auction_events(ctx: &AuctionObservationContext, slot_count: u16, result: &OrchestrationResult) -> Vec` — the single entry point a later wiring plan calls for a completed auction. Equivalent to `build_auction_events(ctx, &completed_outcome(result, slot_count), &provider_calls_from_result(result), Some(result))`. + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `mapping.rs` (the `bid`, `response`, `result` helpers from Task 1 are already present; add the new imports and tests): + +```rust +use crate::auction::telemetry::types::{ + AuctionObservationContext, AuctionSource, EventKind, TerminalStatus, +}; + +fn ctx() -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 1, + is_known_browser: 1, + gdpr_applies: false, + consent_present: true, + } +} + +#[test] +fn completed_outcome_carries_counts_from_the_result() { + let mut res = result( + vec![response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None)], + None, + ); + res.total_time_ms = 88; + res.winning_bids.insert("s1".to_string(), bid("s1", "kargo")); + + let outcome = completed_outcome(&res, 2); + + assert_eq!(outcome.status, TerminalStatus::Completed, "should be Completed"); + assert!(outcome.reason.is_none(), "completed auctions have no reason"); + assert_eq!(outcome.slot_count, Some(2), "should carry the requested slot count"); + assert_eq!(outcome.total_time_ms, Some(88), "should carry total time"); + assert_eq!(outcome.winning_bid_count, Some(1), "should count winning bids"); +} + +#[test] +fn build_completed_auction_events_emits_summary_provider_and_bid_rows() { + let mut res = result( + vec![ + response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None), + response("aps", BidStatus::NoBid, 30, vec![], None), + ], + None, + ); + res.winning_bids.insert("s1".to_string(), bid("s1", "kargo")); + + let rows = build_completed_auction_events(&ctx(), 1, &res); + + assert_eq!( + rows.iter().filter(|r| r.event_kind == EventKind::Summary).count(), + 1, + "should emit exactly one summary" + ); + assert_eq!( + rows.iter().filter(|r| r.event_kind == EventKind::ProviderCall).count(), + 2, + "should emit one provider-call row per provider" + ); + assert_eq!( + rows.iter().filter(|r| r.event_kind == EventKind::Bid).count(), + 1, + "should emit a bid row for the returned bid" + ); + let summary = rows + .iter() + .find(|r| r.event_kind == EventKind::Summary) + .expect("should have a summary row"); + assert_eq!(summary.terminal_status, Some(TerminalStatus::Completed), "summary is Completed"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::mapping` +Expected: FAIL to compile (`completed_outcome` and `build_completed_auction_events` not found). + +- [ ] **Step 3: Write minimal implementation** + +Add to the import block at the top of `mapping.rs`: + +```rust +use crate::auction::telemetry::builder::build_auction_events; +use crate::auction::telemetry::types::{ + AuctionEventRow, AuctionObservationContext, TerminalOutcome, TerminalStatus, +}; +``` + +Add the two functions above the test module (after the existing functions): + +```rust +/// Build the terminal outcome for a completed auction. `slot_count` is the +/// number of requested slots, which the result alone does not carry. +#[must_use] +pub fn completed_outcome(result: &OrchestrationResult, slot_count: u16) -> TerminalOutcome { + TerminalOutcome { + status: TerminalStatus::Completed, + reason: None, + slot_count: Some(slot_count), + total_time_ms: Some(clamp_u32(result.total_time_ms)), + winning_bid_count: Some(clamp_u16(result.winning_bids.len())), + } +} + +/// Build all telemetry rows for a completed auction. This is the single entry +/// point a wiring layer calls when `run_auction`/`collect_dispatched_auction` +/// returns an `OrchestrationResult`. +#[must_use] +pub fn build_completed_auction_events( + ctx: &AuctionObservationContext, + slot_count: u16, + result: &OrchestrationResult, +) -> Vec { + let outcome = completed_outcome(result, slot_count); + let provider_calls = provider_calls_from_result(result); + build_auction_events(ctx, &outcome, &provider_calls, Some(result)) +} +``` + +Update `mod.rs` re-export line for `mapping` to include all three functions: + +```rust +pub use mapping::{build_completed_auction_events, completed_outcome, provider_calls_from_result}; +``` + +- [ ] **Step 4: Run test to verify it passes, then the gates** + +Run: `cargo test -p trusted-server-core telemetry` +Expected: PASS (the full telemetry suite, including the 4 mapping tests). + +Run: `cargo fmt --all -- --check` +Expected: no diff. + +Run: `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` +Expected: no warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/mapping.rs crates/trusted-server-core/src/auction/telemetry/mod.rs +git commit -m "Build completed-auction telemetry rows from orchestration result" +``` + +--- + +## Self-Review + +**Spec coverage (this plan's slice):** Provider-call status vocabulary mapping (`success`/`nobid`/`launch_error`/`parse_error`/`transport_error`/`timeout`) from the orchestrator's existing outputs: Task 1. Completed terminal outcome and the single `build_completed_auction_events` entry point: Task 2. Both are pure and reuse the Plan 1 builder. + +**Deferred (not gaps in this plan):** Calling these from `handle_auction`/`handle_page_bids`/SSAT collect; abandoned/skipped/dispatch-failed/execution-failed outcomes; `media_type` and the observation-context construction from `EcContext`/geo/`DeviceSignals`; the Fastly sink and `event_ts`; access logs. Those are later plans. + +**Placeholder scan:** No `TBD`/`TODO`; every code step is complete. + +**Type consistency:** `provider_calls_from_result`, `completed_outcome`, `build_completed_auction_events`, and `build_auction_events` signatures match across tasks and match the verified `OrchestrationResult`/`AuctionResponse`/`BidStatus` field set. `clamp_u32`/`clamp_u16` are defined in Task 1 and reused in Task 2. From bbe070bc5d892a1039d1607bc29b2b6513818608 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:55:19 -0500 Subject: [PATCH 18/37] Map orchestration result to provider-call telemetry outcomes --- .../src/auction/telemetry/mapping.rs | 180 ++++++++++++++++++ .../src/auction/telemetry/mod.rs | 2 + 2 files changed, 182 insertions(+) create mode 100644 crates/trusted-server-core/src/auction/telemetry/mapping.rs diff --git a/crates/trusted-server-core/src/auction/telemetry/mapping.rs b/crates/trusted-server-core/src/auction/telemetry/mapping.rs new file mode 100644 index 000000000..1eb203176 --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry/mapping.rs @@ -0,0 +1,180 @@ +//! Maps a real `OrchestrationResult` into telemetry inputs. +//! +//! This is the adapter between the orchestrator's output types and the pure +//! telemetry builder. It performs no I/O and does not modify the auction. + +use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::telemetry::types::{ + ProviderCallOutcome, ProviderCallStatus, ProviderRole, +}; +use crate::auction::types::{AuctionResponse, BidStatus}; + +/// Build one provider-call outcome per provider response, plus one for the +/// mediator when a mediator response is present. +#[must_use] +pub fn provider_calls_from_result(result: &OrchestrationResult) -> Vec { + let mut calls: Vec = result + .provider_responses + .iter() + .map(|response| provider_call_outcome(response, ProviderRole::Bidder)) + .collect(); + if let Some(mediator) = &result.mediator_response { + calls.push(provider_call_outcome(mediator, ProviderRole::Mediator)); + } + calls +} + +/// Map one response to a provider-call outcome with the given role. +fn provider_call_outcome(response: &AuctionResponse, role: ProviderRole) -> ProviderCallOutcome { + ProviderCallOutcome { + provider: response.provider.clone(), + role, + status: provider_call_status(response), + response_time_ms: Some(clamp_u32(response.response_time_ms)), + bid_count: Some(clamp_u16(response.bids.len())), + } +} + +/// Classify a response into a provider-call status. For `Error`, read the +/// orchestrator's `error_type` metadata; an unrecognized or absent value falls +/// back to `TransportError` since the orchestrator only emits the three known +/// error types. +fn provider_call_status(response: &AuctionResponse) -> ProviderCallStatus { + match response.status { + BidStatus::Success => ProviderCallStatus::Success, + BidStatus::NoBid => ProviderCallStatus::NoBid, + BidStatus::Pending => ProviderCallStatus::Timeout, + BidStatus::Error => match response + .metadata + .get("error_type") + .and_then(|value| value.as_str()) + { + Some("launch_failed") => ProviderCallStatus::LaunchError, + Some("parse_response") => ProviderCallStatus::ParseError, + Some("transport") => ProviderCallStatus::TransportError, + _ => ProviderCallStatus::TransportError, + }, + } +} + +/// Clamp a `u64` millisecond count into the `u32` schema column without +/// panicking. +fn clamp_u32(value: u64) -> u32 { + value.min(u64::from(u32::MAX)) as u32 +} + +/// Clamp a count into the `u16` schema column without panicking. +fn clamp_u16(value: usize) -> u16 { + value.min(usize::from(u16::MAX)) as u16 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::orchestrator::OrchestrationResult; + use crate::auction::types::{AuctionResponse, Bid, BidStatus}; + use crate::auction::telemetry::types::{ProviderCallStatus, ProviderRole}; + use std::collections::HashMap; + + fn bid(slot: &str, bidder: &str) -> Bid { + Bid { + slot_id: slot.to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + + fn response( + provider: &str, + status: BidStatus, + time: u64, + bids: Vec, + error_type: Option<&str>, + ) -> AuctionResponse { + let mut metadata = HashMap::new(); + if let Some(kind) = error_type { + metadata.insert("error_type".to_string(), serde_json::json!(kind)); + } + AuctionResponse { + provider: provider.to_string(), + bids, + status, + response_time_ms: time, + metadata, + } + } + + fn result( + provider_responses: Vec, + mediator_response: Option, + ) -> OrchestrationResult { + OrchestrationResult { + provider_responses, + mediator_response, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + } + } + + #[test] + fn maps_each_status_to_the_expected_provider_call_status() { + let res = result( + vec![ + response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None), + response("rubicon", BidStatus::NoBid, 30, vec![], None), + response("ix", BidStatus::Error, 10, vec![], Some("launch_failed")), + response("appnexus", BidStatus::Error, 55, vec![], Some("parse_response")), + response("openx", BidStatus::Error, 60, vec![], Some("transport")), + response("smaato", BidStatus::Error, 5, vec![], None), + response("teads", BidStatus::Pending, 70, vec![], None), + ], + None, + ); + + let calls = provider_calls_from_result(&res); + + assert_eq!(calls.len(), 7, "should emit one outcome per provider response"); + assert_eq!(calls[0].status, ProviderCallStatus::Success, "Success maps to Success"); + assert_eq!(calls[0].bid_count, Some(1), "should count returned bids"); + assert_eq!(calls[0].response_time_ms, Some(40), "should carry response time"); + assert_eq!(calls[0].role, ProviderRole::Bidder, "provider responses are bidders"); + assert_eq!(calls[1].status, ProviderCallStatus::NoBid, "NoBid maps to NoBid"); + assert_eq!(calls[2].status, ProviderCallStatus::LaunchError, "launch_failed maps to LaunchError"); + assert_eq!(calls[3].status, ProviderCallStatus::ParseError, "parse_response maps to ParseError"); + assert_eq!(calls[4].status, ProviderCallStatus::TransportError, "transport maps to TransportError"); + assert_eq!( + calls[5].status, + ProviderCallStatus::TransportError, + "an Error with no recognized error_type falls back to TransportError" + ); + assert_eq!(calls[6].status, ProviderCallStatus::Timeout, "Pending maps to Timeout"); + } + + #[test] + fn appends_a_mediator_outcome_when_present() { + let res = result( + vec![response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None)], + Some(response("mediator", BidStatus::Success, 12, vec![], None)), + ); + + let calls = provider_calls_from_result(&res); + + assert_eq!(calls.len(), 2, "should append one outcome for the mediator"); + let mediator = calls.last().expect("should have a mediator outcome"); + assert_eq!(mediator.role, ProviderRole::Mediator, "mediator outcome uses the Mediator role"); + assert_eq!(mediator.provider, "mediator", "should carry the mediator provider name"); + } +} diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index a7b99258e..9ed5da4ab 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -4,10 +4,12 @@ //! lives in separate modules; this module performs no I/O. pub mod builder; +pub mod mapping; pub mod sink; pub mod types; pub use builder::build_auction_events; +pub use mapping::provider_calls_from_result; pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; pub use types::{ to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, From fcb3255f8d11cad3e0bfdd1181f524deacf97e32 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:57:15 -0500 Subject: [PATCH 19/37] Format telemetry mapping module --- .../src/auction/telemetry/mapping.rs | 95 +++++++++++++++---- 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/auction/telemetry/mapping.rs b/crates/trusted-server-core/src/auction/telemetry/mapping.rs index 1eb203176..4ef9c1e18 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mapping.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mapping.rs @@ -4,9 +4,7 @@ //! telemetry builder. It performs no I/O and does not modify the auction. use crate::auction::orchestrator::OrchestrationResult; -use crate::auction::telemetry::types::{ - ProviderCallOutcome, ProviderCallStatus, ProviderRole, -}; +use crate::auction::telemetry::types::{ProviderCallOutcome, ProviderCallStatus, ProviderRole}; use crate::auction::types::{AuctionResponse, BidStatus}; /// Build one provider-call outcome per provider response, plus one for the @@ -72,8 +70,8 @@ fn clamp_u16(value: usize) -> u16 { mod tests { use super::*; use crate::auction::orchestrator::OrchestrationResult; - use crate::auction::types::{AuctionResponse, Bid, BidStatus}; use crate::auction::telemetry::types::{ProviderCallStatus, ProviderRole}; + use crate::auction::types::{AuctionResponse, Bid, BidStatus}; use std::collections::HashMap; fn bid(slot: &str, bidder: &str) -> Bid { @@ -133,10 +131,22 @@ mod tests { fn maps_each_status_to_the_expected_provider_call_status() { let res = result( vec![ - response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None), + response( + "prebid", + BidStatus::Success, + 40, + vec![bid("s1", "kargo")], + None, + ), response("rubicon", BidStatus::NoBid, 30, vec![], None), response("ix", BidStatus::Error, 10, vec![], Some("launch_failed")), - response("appnexus", BidStatus::Error, 55, vec![], Some("parse_response")), + response( + "appnexus", + BidStatus::Error, + 55, + vec![], + Some("parse_response"), + ), response("openx", BidStatus::Error, 60, vec![], Some("transport")), response("smaato", BidStatus::Error, 5, vec![], None), response("teads", BidStatus::Pending, 70, vec![], None), @@ -146,27 +156,69 @@ mod tests { let calls = provider_calls_from_result(&res); - assert_eq!(calls.len(), 7, "should emit one outcome per provider response"); - assert_eq!(calls[0].status, ProviderCallStatus::Success, "Success maps to Success"); + assert_eq!( + calls.len(), + 7, + "should emit one outcome per provider response" + ); + assert_eq!( + calls[0].status, + ProviderCallStatus::Success, + "Success maps to Success" + ); assert_eq!(calls[0].bid_count, Some(1), "should count returned bids"); - assert_eq!(calls[0].response_time_ms, Some(40), "should carry response time"); - assert_eq!(calls[0].role, ProviderRole::Bidder, "provider responses are bidders"); - assert_eq!(calls[1].status, ProviderCallStatus::NoBid, "NoBid maps to NoBid"); - assert_eq!(calls[2].status, ProviderCallStatus::LaunchError, "launch_failed maps to LaunchError"); - assert_eq!(calls[3].status, ProviderCallStatus::ParseError, "parse_response maps to ParseError"); - assert_eq!(calls[4].status, ProviderCallStatus::TransportError, "transport maps to TransportError"); + assert_eq!( + calls[0].response_time_ms, + Some(40), + "should carry response time" + ); + assert_eq!( + calls[0].role, + ProviderRole::Bidder, + "provider responses are bidders" + ); + assert_eq!( + calls[1].status, + ProviderCallStatus::NoBid, + "NoBid maps to NoBid" + ); + assert_eq!( + calls[2].status, + ProviderCallStatus::LaunchError, + "launch_failed maps to LaunchError" + ); + assert_eq!( + calls[3].status, + ProviderCallStatus::ParseError, + "parse_response maps to ParseError" + ); + assert_eq!( + calls[4].status, + ProviderCallStatus::TransportError, + "transport maps to TransportError" + ); assert_eq!( calls[5].status, ProviderCallStatus::TransportError, "an Error with no recognized error_type falls back to TransportError" ); - assert_eq!(calls[6].status, ProviderCallStatus::Timeout, "Pending maps to Timeout"); + assert_eq!( + calls[6].status, + ProviderCallStatus::Timeout, + "Pending maps to Timeout" + ); } #[test] fn appends_a_mediator_outcome_when_present() { let res = result( - vec![response("prebid", BidStatus::Success, 40, vec![bid("s1", "kargo")], None)], + vec![response( + "prebid", + BidStatus::Success, + 40, + vec![bid("s1", "kargo")], + None, + )], Some(response("mediator", BidStatus::Success, 12, vec![], None)), ); @@ -174,7 +226,14 @@ mod tests { assert_eq!(calls.len(), 2, "should append one outcome for the mediator"); let mediator = calls.last().expect("should have a mediator outcome"); - assert_eq!(mediator.role, ProviderRole::Mediator, "mediator outcome uses the Mediator role"); - assert_eq!(mediator.provider, "mediator", "should carry the mediator provider name"); + assert_eq!( + mediator.role, + ProviderRole::Mediator, + "mediator outcome uses the Mediator role" + ); + assert_eq!( + mediator.provider, "mediator", + "should carry the mediator provider name" + ); } } From f06f902871e73e9224b83fc21148ace825ba4c58 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 19:59:10 -0500 Subject: [PATCH 20/37] Build completed-auction telemetry rows from orchestration result --- .../src/auction/telemetry/mapping.rs | 145 +++++++++++++++++- .../src/auction/telemetry/mod.rs | 2 +- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/auction/telemetry/mapping.rs b/crates/trusted-server-core/src/auction/telemetry/mapping.rs index 4ef9c1e18..390abf29c 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mapping.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mapping.rs @@ -4,7 +4,11 @@ //! telemetry builder. It performs no I/O and does not modify the auction. use crate::auction::orchestrator::OrchestrationResult; -use crate::auction::telemetry::types::{ProviderCallOutcome, ProviderCallStatus, ProviderRole}; +use crate::auction::telemetry::builder::build_auction_events; +use crate::auction::telemetry::types::{ + AuctionEventRow, AuctionObservationContext, ProviderCallOutcome, ProviderCallStatus, + ProviderRole, TerminalOutcome, TerminalStatus, +}; use crate::auction::types::{AuctionResponse, BidStatus}; /// Build one provider-call outcome per provider response, plus one for the @@ -66,11 +70,41 @@ fn clamp_u16(value: usize) -> u16 { value.min(usize::from(u16::MAX)) as u16 } +/// Build the terminal outcome for a completed auction. `slot_count` is the +/// number of requested slots, which the result alone does not carry. +#[must_use] +pub fn completed_outcome(result: &OrchestrationResult, slot_count: u16) -> TerminalOutcome { + TerminalOutcome { + status: TerminalStatus::Completed, + reason: None, + slot_count: Some(slot_count), + total_time_ms: Some(clamp_u32(result.total_time_ms)), + winning_bid_count: Some(clamp_u16(result.winning_bids.len())), + } +} + +/// Build all telemetry rows for a completed auction. This is the single entry +/// point a wiring layer calls when `run_auction`/`collect_dispatched_auction` +/// returns an `OrchestrationResult`. +#[must_use] +pub fn build_completed_auction_events( + ctx: &AuctionObservationContext, + slot_count: u16, + result: &OrchestrationResult, +) -> Vec { + let outcome = completed_outcome(result, slot_count); + let provider_calls = provider_calls_from_result(result); + build_auction_events(ctx, &outcome, &provider_calls, Some(result)) +} + #[cfg(test)] mod tests { use super::*; use crate::auction::orchestrator::OrchestrationResult; - use crate::auction::telemetry::types::{ProviderCallStatus, ProviderRole}; + use crate::auction::telemetry::types::{ + AuctionObservationContext, AuctionSource, EventKind, ProviderCallStatus, ProviderRole, + TerminalStatus, + }; use crate::auction::types::{AuctionResponse, Bid, BidStatus}; use std::collections::HashMap; @@ -127,6 +161,21 @@ mod tests { } } + fn ctx() -> AuctionObservationContext { + AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 1, + is_known_browser: 1, + gdpr_applies: false, + consent_present: true, + } + } + #[test] fn maps_each_status_to_the_expected_provider_call_status() { let res = result( @@ -236,4 +285,96 @@ mod tests { "should carry the mediator provider name" ); } + + #[test] + fn completed_outcome_carries_counts_from_the_result() { + let mut res = result( + vec![response( + "prebid", + BidStatus::Success, + 40, + vec![bid("s1", "kargo")], + None, + )], + None, + ); + res.total_time_ms = 88; + res.winning_bids + .insert("s1".to_string(), bid("s1", "kargo")); + + let outcome = completed_outcome(&res, 2); + + assert_eq!( + outcome.status, + TerminalStatus::Completed, + "should be Completed" + ); + assert!( + outcome.reason.is_none(), + "completed auctions have no reason" + ); + assert_eq!( + outcome.slot_count, + Some(2), + "should carry the requested slot count" + ); + assert_eq!(outcome.total_time_ms, Some(88), "should carry total time"); + assert_eq!( + outcome.winning_bid_count, + Some(1), + "should count winning bids" + ); + } + + #[test] + fn build_completed_auction_events_emits_summary_provider_and_bid_rows() { + let mut res = result( + vec![ + response( + "prebid", + BidStatus::Success, + 40, + vec![bid("s1", "kargo")], + None, + ), + response("aps", BidStatus::NoBid, 30, vec![], None), + ], + None, + ); + res.winning_bids + .insert("s1".to_string(), bid("s1", "kargo")); + + let rows = build_completed_auction_events(&ctx(), 1, &res); + + assert_eq!( + rows.iter() + .filter(|r| r.event_kind == EventKind::Summary) + .count(), + 1, + "should emit exactly one summary" + ); + assert_eq!( + rows.iter() + .filter(|r| r.event_kind == EventKind::ProviderCall) + .count(), + 2, + "should emit one provider-call row per provider" + ); + assert_eq!( + rows.iter() + .filter(|r| r.event_kind == EventKind::Bid) + .count(), + 1, + "should emit a bid row for the returned bid" + ); + let summary = rows + .iter() + .find(|r| r.event_kind == EventKind::Summary) + .expect("should have a summary row"); + assert_eq!( + summary.terminal_status, + Some(TerminalStatus::Completed), + "summary is Completed" + ); + } } diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index 9ed5da4ab..c4a77271f 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -9,7 +9,7 @@ pub mod sink; pub mod types; pub use builder::build_auction_events; -pub use mapping::provider_calls_from_result; +pub use mapping::{build_completed_auction_events, completed_outcome, provider_calls_from_result}; pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; pub use types::{ to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, From e8748f5c0c7e600e01d100b36d19baedfac5c19e Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 22:50:33 -0500 Subject: [PATCH 21/37] Add wiring plan for POST /auction telemetry emission --- .../2026-06-22-auction-telemetry-wiring.md | 674 ++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md diff --git a/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md b/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md new file mode 100644 index 000000000..9b2354f64 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md @@ -0,0 +1,674 @@ +# Auction Telemetry Wiring (POST /auction) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a completed `POST /auction` emit telemetry rows end-to-end: build the observation context at the call site, run it through the Plan 1/2 mapping/builder, and write NDJSON to the Fastly `ts_auction_events` real-time log endpoint. + +**Architecture:** A new `auction::telemetry::context` builder turns the request/geo/consent into an `AuctionObservationContext`. `RuntimeServices` gains an `AuctionEventSink` (default no-op, so all existing call sites keep working) that `handle_auction` calls after `run_auction`. The Fastly adapter supplies a real sink that serializes each row with an injected `event_ts` and writes it to the named log endpoint. Emission is buffered/non-blocking and never on the response path. + +**Tech Stack:** Rust 2024, `serde_json`, `uuid`, `chrono` (adapter), `fastly` 0.11 log endpoint. + +## Global Constraints + +Copied from the project conventions and the prior telemetry plans; every task implicitly includes these: + +- Rust **2024 edition**. No `unwrap()` in non-test code (use `expect("should ...")`; `unwrap_or`/`unwrap_or_else`/`unwrap_or_default` are allowed). No `println!`/`eprintln!`; use `log` macros. +- Functions take at most 7 arguments. Comments on their own line above the code. No imports inside functions; no wildcard imports outside `#[cfg(test)]` (`use super::*;` allowed there). +- Tests: Arrange-Act-Assert, `expect()`/`expect_err()` with `"should ..."` messages, descriptive assertion messages, `serde_json::json!` over raw JSON strings. Only example/fictional domains (`example.com`, `test-publisher.com`). +- Each public item has a doc comment. +- Git commit messages: sentence case, imperative, no semantic prefixes (`feat:`/`fix:`), no bracketed tags, no `Co-Authored-By` trailer. Use the exact message in each task's commit step. +- The adapter crate targets `wasm32-wasip1`; verify adapter changes with `cargo check --package trusted-server-adapter-fastly --target wasm32-wasip1`. + +**Scope boundary (deliberately NOT in this plan):** `handle_page_bids` wiring, the SSAT `dispatch_auction`/`collect_dispatched_auction` path and its abandoned/skipped/dispatch-failed/execution-failed outcomes, real device-signal population (`is_mobile`/`is_known_browser` are passed as `2` = unknown here), access logs, and the Tinybird/relay/Grafana side. Those are later plans. This plan covers only the completed `POST /auction` path. + +**Verified facts this plan relies on (current code):** +- `handle_auction(settings, orchestrator, kv, registry, ec_context, services, req)` lives at `crates/trusted-server-core/src/auction/endpoints.rs`; after `run_auction` the `result: OrchestrationResult`, `auction_request: AuctionRequest`, and `services: &RuntimeServices` are all in scope (endpoints.rs:259-274). `geo` and `consent_context` locals are moved into `convert_tsjs_to_auction_request` earlier, so telemetry reads geo/consent back off `auction_request.device`/`auction_request.user.consent`. +- `AuctionRequest`: `publisher: PublisherInfo { domain: String, page_url: Option }`, `slots: Vec`, `device: Option, .. }>`, `user: UserInfo { consent: Option, .. }` (auction/types.rs). +- `GeoInfo { country: String, region: Option, .. }` (`crate::platform::GeoInfo`). `ConsentContext { gdpr_applies: bool, .. }` with `fn is_empty(&self) -> bool` and `Default` (`crate::consent::ConsentContext`). +- `RuntimeServices` (crates/trusted-server-core/src/platform/types.rs) uses an `Option`-field builder that `expect()`s on `build()`, plus `with_kv_store(self, ..) -> Self`. Test factory `noop_services()` (platform::test_support). +- `fastly::log::Endpoint::from_name(name: &str) -> Self` implements `std::io::Write` (fastly 0.11.13). `chrono::Utc::now()` is already used in the adapter. +- Plan 1/2 already provide, under `crate::auction::telemetry`: `AuctionObservationContext`, `AuctionSource`, `EventKind`, `AuctionEventRow` (+ `AuctionEventRow::base`), `AuctionEventSink`, `NoopSink`, `InMemorySink`, and `build_completed_auction_events(ctx, slot_count, result)`. + +--- + +### Task 1: Observation-context builder + +**Files:** +- Create: `crates/trusted-server-core/src/auction/telemetry/context.rs` +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `context`, re-export `build_observation_context`) +- Test: inline `#[cfg(test)]` in `context.rs` + +**Interfaces:** +- Consumes: `AuctionObservationContext`, `AuctionSource` (telemetry::types); `crate::platform::GeoInfo`; `crate::consent::ConsentContext`. +- Produces: + - `pub fn build_observation_context(source: AuctionSource, publisher_domain: &str, page_url: Option<&str>, geo: Option<&GeoInfo>, consent: Option<&ConsentContext>, is_mobile: u8, is_known_browser: u8) -> AuctionObservationContext` — mints a fresh `Uuid::new_v4()`, normalizes `page_url` to a path, derives country/region from geo, and `gdpr_applies`/`consent_present` from consent (both `false` when `consent` is `None`). + +- [ ] **Step 1: Write the failing test** + +Create `crates/trusted-server-core/src/auction/telemetry/context.rs` with the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::types::AuctionSource; + use crate::consent::ConsentContext; + use crate::platform::GeoInfo; + + fn geo() -> GeoInfo { + GeoInfo { + city: "Springfield".to_string(), + country: "US".to_string(), + continent: "NA".to_string(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_string()), + asn: None, + } + } + + #[test] + fn normalizes_full_url_to_path_without_query_or_fragment() { + assert_eq!( + normalize_page_path("https://www.example.com/news/article?utm=x#top"), + "/news/article", + "should keep only the path" + ); + assert_eq!( + normalize_page_path("/already/a/path?q=1"), + "/already/a/path", + "should strip the query from a bare path" + ); + assert_eq!( + normalize_page_path("https://example.com"), + "/", + "a URL with no path normalizes to /" + ); + assert_eq!(normalize_page_path(""), "/", "empty input normalizes to /"); + } + + #[test] + fn builds_context_from_geo_and_consent() { + let consent = ConsentContext { + gdpr_applies: true, + ..ConsentContext::default() + }; + let ctx = build_observation_context( + AuctionSource::AuctionApi, + "example.com", + Some("https://example.com/p?x=1"), + Some(&geo()), + Some(&consent), + 1, + 1, + ); + assert_eq!(ctx.source, AuctionSource::AuctionApi, "should carry the source"); + assert_eq!(ctx.publisher_domain, "example.com", "should carry the domain"); + assert_eq!(ctx.page_path, "/p", "should carry the normalized path"); + assert_eq!(ctx.country, "US", "should carry country from geo"); + assert_eq!(ctx.region.as_deref(), Some("CA"), "should carry region from geo"); + assert!(ctx.gdpr_applies, "should carry gdpr_applies from consent"); + assert!(!ctx.consent_present, "a default consent is empty so consent_present is false"); + assert!(!ctx.auction_id.is_nil(), "should mint a fresh telemetry id"); + } + + #[test] + fn defaults_country_and_consent_when_absent() { + let ctx = build_observation_context( + AuctionSource::AuctionApi, + "example.com", + None, + None, + None, + 2, + 2, + ); + assert_eq!(ctx.country, "", "no geo means empty country"); + assert!(ctx.region.is_none(), "no geo means no region"); + assert_eq!(ctx.page_path, "/", "no page url normalizes to /"); + assert!(!ctx.gdpr_applies, "no consent means gdpr_applies false"); + assert!(!ctx.consent_present, "no consent means consent_present false"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::context` +Expected: FAIL to compile (`context` module not declared; `build_observation_context`/`normalize_page_path` not found). + +- [ ] **Step 3: Write minimal implementation** + +Prepend to `context.rs` (above the test module): + +```rust +//! Builds an `AuctionObservationContext` from request, geo, and consent inputs. +//! +//! This is the only telemetry code that mints the telemetry id and normalizes +//! the page path. It performs no I/O. + +use uuid::Uuid; + +use crate::auction::telemetry::types::{AuctionObservationContext, AuctionSource}; +use crate::consent::ConsentContext; +use crate::platform::GeoInfo; + +/// Build a PII-free observation context for one auction. +/// +/// `is_mobile` and `is_known_browser` use `0`/`1`/`2` (`2` = unknown); a later +/// plan threads real device signals. `consent` is optional because a +/// non-regulated auction may carry no consent context. +#[must_use] +pub fn build_observation_context( + source: AuctionSource, + publisher_domain: &str, + page_url: Option<&str>, + geo: Option<&GeoInfo>, + consent: Option<&ConsentContext>, + is_mobile: u8, + is_known_browser: u8, +) -> AuctionObservationContext { + AuctionObservationContext { + auction_id: Uuid::new_v4(), + source, + publisher_domain: publisher_domain.to_string(), + page_path: page_url + .map(normalize_page_path) + .unwrap_or_else(|| "/".to_string()), + country: geo.map(|info| info.country.clone()).unwrap_or_default(), + region: geo.and_then(|info| info.region.clone()), + is_mobile, + is_known_browser, + gdpr_applies: consent.is_some_and(|context| context.gdpr_applies), + consent_present: consent.is_some_and(|context| !context.is_empty()), + } +} + +/// Reduce a page URL or path to a bounded path with no scheme, host, query, or +/// fragment. Empty or path-less inputs normalize to `/`. +fn normalize_page_path(page_url: &str) -> String { + let without_fragment = page_url.split('#').next().unwrap_or(""); + let without_query = without_fragment.split('?').next().unwrap_or(""); + let path = match without_query.find("://") { + Some(scheme_end) => { + let after_scheme = &without_query[scheme_end + 3..]; + match after_scheme.find('/') { + Some(slash) => &after_scheme[slash..], + None => "/", + } + } + None => without_query, + }; + let path = if path.is_empty() { "/" } else { path }; + path.chars().take(512).collect() +} +``` + +In `mod.rs`, add `pub mod context;` (alphabetically, before `pub mod mapping;`) and add `pub use context::build_observation_context;` to the re-export block. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::context` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/context.rs crates/trusted-server-core/src/auction/telemetry/mod.rs +git commit -m "Add auction observation context builder" +``` + +--- + +### Task 2: Auction event sink on RuntimeServices + +**Files:** +- Modify: `crates/trusted-server-core/src/platform/types.rs` (struct field, builder, accessor, `with_` method, imports) +- Test: inline `#[cfg(test)]` in `platform/types.rs` + +**Interfaces:** +- Consumes: `AuctionEventSink`, `NoopSink` (telemetry). +- Produces, on `RuntimeServices`: + - `pub fn auction_event_sink(&self) -> &dyn AuctionEventSink` + - `pub fn with_auction_event_sink(self, sink: Arc) -> Self` + - builder method `pub fn auction_event_sink(self, sink: Arc) -> Self` + - `build()` defaults the sink to `Arc::new(NoopSink)` when unset, so every existing `RuntimeServices` construction keeps compiling. + +- [ ] **Step 1: Write the failing test** + +Add a test module at the bottom of `platform/types.rs` (if the file already has a `#[cfg(test)] mod tests`, add these into it instead): + +```rust +#[cfg(test)] +mod auction_sink_tests { + use super::*; + use crate::auction::telemetry::types::{AuctionObservationContext, AuctionSource, EventKind}; + use crate::auction::telemetry::{AuctionEventRow, InMemorySink}; + use crate::platform::test_support::noop_services; + + fn row() -> AuctionEventRow { + let ctx = AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 2, + is_known_browser: 2, + gdpr_applies: false, + consent_present: false, + }; + AuctionEventRow::base(&ctx, EventKind::Summary) + } + + #[test] + fn default_sink_is_noop_and_does_not_panic() { + let services = noop_services(); + services.auction_event_sink().emit(&[row()]); + } + + #[test] + fn injected_sink_captures_emitted_rows() { + let sink = std::sync::Arc::new(InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + services.auction_event_sink().emit(&[row()]); + assert_eq!(sink.rows().len(), 1, "should route emitted rows to the injected sink"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core platform::types::auction_sink_tests` +Expected: FAIL to compile (`auction_event_sink`/`with_auction_event_sink` not found). + +- [ ] **Step 3: Write minimal implementation** + +In `platform/types.rs`: + +Add the import near the top (with the other `use crate::...` lines): + +```rust +use crate::auction::telemetry::{AuctionEventSink, NoopSink}; +``` + +Add the field to the `RuntimeServices` struct (after `client_info`): + +```rust + /// Sink for auction telemetry rows. Defaults to a no-op; the Fastly adapter + /// installs a real implementation. + pub(crate) auction_event_sink: Arc, +``` + +Add the accessor inside `impl RuntimeServices` (next to `client_info()`): + +```rust + /// Returns the auction telemetry sink. + #[must_use] + pub fn auction_event_sink(&self) -> &dyn AuctionEventSink { + &*self.auction_event_sink + } +``` + +Add the `with_` method inside `impl RuntimeServices` (next to `with_kv_store`): + +```rust + /// Return a clone of these services with a different auction event sink. + #[must_use] + pub fn with_auction_event_sink(self, sink: Arc) -> Self { + Self { + auction_event_sink: sink, + ..self + } + } +``` + +Add the builder field to `RuntimeServicesBuilder` (after `client_info: Option,`): + +```rust + auction_event_sink: Option>, +``` + +Set it to `None` in `RuntimeServicesBuilder::new()` (after `client_info: None,`): + +```rust + auction_event_sink: None, +``` + +Add the builder method inside `impl RuntimeServicesBuilder` (next to `client_info`): + +```rust + /// Set the auction telemetry sink. Defaults to a no-op when unset. + #[must_use] + pub fn auction_event_sink(mut self, sink: Arc) -> Self { + self.auction_event_sink = Some(sink); + self + } +``` + +In `build()`, add the field to the returned `RuntimeServices` (after `client_info: ...`). Unlike the other fields, this one defaults instead of panicking: + +```rust + auction_event_sink: self + .auction_event_sink + .unwrap_or_else(|| Arc::new(NoopSink)), +``` + +- [ ] **Step 4: Run test + confirm existing constructions still compile** + +Run: `cargo test -p trusted-server-core platform::types::auction_sink_tests` +Expected: PASS (2 tests). + +Run: `cargo test -p trusted-server-core` +Expected: PASS (the whole core suite; this proves no existing `RuntimeServices::builder()` call broke, since the sink defaults). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/platform/types.rs +git commit -m "Add auction event sink to runtime services with no-op default" +``` + +--- + +### Task 3: Emit telemetry from handle_auction + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` (add `use`, insert emission after `run_auction`, add test) +- Test: inline `#[cfg(test)]` in `endpoints.rs` + +**Interfaces:** +- Consumes: `build_observation_context`, `build_completed_auction_events`, `AuctionSource` (telemetry); `RuntimeServices::auction_event_sink` (Task 2). +- Produces: no new public API; `POST /auction` now emits to `services.auction_event_sink()`. + +- [ ] **Step 1: Write the failing test** + +Add to the existing `#[cfg(test)] mod tests` in `endpoints.rs` (it already imports `create_test_settings`, `make_ec_context`, `noop_services`, `AuctionConfig`, `Jurisdiction`, `json`, `Arc`, `StatusCode`, `EdgeBody`, `Request`, `handle_auction`): + +```rust + #[tokio::test] + async fn auction_endpoint_emits_completed_telemetry() { + // A non-regulated, ungated auction with no providers still completes and + // must emit one summary row tagged auction_api to the injected sink. + let settings = create_test_settings(); + let config = AuctionConfig { + enabled: true, + providers: vec![], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let orchestrator = AuctionOrchestrator::new(config); + let sink = Arc::new(crate::auction::telemetry::InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + + let body = json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + }); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build auction request"); + + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + .expect("auction should return a valid response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let rows = sink.rows(); + assert!( + rows.iter().any(|r| r.event_kind + == crate::auction::telemetry::EventKind::Summary + && r.auction_source == crate::auction::telemetry::AuctionSource::AuctionApi), + "should emit a summary row tagged auction_api, got {} rows", + rows.len() + ); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core auction_endpoint_emits_completed_telemetry` +Expected: FAIL — the assertion fails because nothing emits yet (`sink.rows()` is empty). (It compiles, because `with_auction_event_sink`/`InMemorySink` exist from Tasks 1-2.) + +- [ ] **Step 3: Write minimal implementation** + +Add this `use` to the top imports of `endpoints.rs` (with the other `use crate::auction::...` lines): + +```rust +use crate::auction::telemetry::{build_completed_auction_events, build_observation_context, AuctionSource}; +``` + +Insert the emission block immediately after the `log::info!("Auction completed: ...")` statement and before the `convert_to_openrtb_response(...)` line (endpoints.rs ~line 272). Geo and consent are read back off `auction_request` because the original locals were moved into the request builder: + +```rust + // Emit completed-auction telemetry. The sink write is buffered and + // non-blocking in production and a no-op by default in tests, so this never + // affects the response. Device signals are unknown (`2`) until a later plan + // threads them through. + let observation = build_observation_context( + AuctionSource::AuctionApi, + &auction_request.publisher.domain, + auction_request.publisher.page_url.as_deref(), + auction_request + .device + .as_ref() + .and_then(|device| device.geo.as_ref()), + auction_request.user.consent.as_ref(), + 2, + 2, + ); + let slot_count = u16::try_from(auction_request.slots.len()).unwrap_or(u16::MAX); + let telemetry_rows = build_completed_auction_events(&observation, slot_count, &result); + services.auction_event_sink().emit(&telemetry_rows); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core auction_endpoint_emits_completed_telemetry` +Expected: PASS. + +Run: `cargo test -p trusted-server-core` +Expected: PASS (whole core suite; confirms the existing consent-gate test still passes, i.e. the gated path still emits nothing because it returns before this code). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/endpoints.rs +git commit -m "Emit completed-auction telemetry from the auction endpoint" +``` + +--- + +### Task 4: Fastly auction event sink + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/telemetry/types.rs` (add `to_json_line_with_event_ts` + test) +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export it) +- Create: `crates/trusted-server-adapter-fastly/src/auction_sink.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (declare `mod auction_sink;`) +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` (install the sink in `build_runtime_services`) +- Test: inline `#[cfg(test)]` in `types.rs` + +**Interfaces:** +- Consumes: `AuctionEventRow`, `AuctionEventSink` (telemetry). +- Produces: + - core: `pub fn to_json_line_with_event_ts(row: &AuctionEventRow, event_ts: &str) -> Result` — the row as a single JSON object with an injected `event_ts` field. + - adapter: `pub struct FastlyAuctionEventSink;` implementing `AuctionEventSink`, installed on the runtime services. + +- [ ] **Step 1: Write the failing test (core helper)** + +Add to the existing `#[cfg(test)] mod tests` in `telemetry/types.rs` (it already has a `sample_context()` helper from the core plan): + +```rust + #[test] + fn json_line_injects_event_ts_and_keeps_row_fields() { + let row = AuctionEventRow::base(&sample_context(), EventKind::Summary); + let line = to_json_line_with_event_ts(&row, "2026-06-22T00:00:00.000Z") + .expect("should serialize the row"); + let value: serde_json::Value = + serde_json::from_str(&line).expect("line should be valid JSON"); + assert_eq!( + value.get("event_ts").and_then(serde_json::Value::as_str), + Some("2026-06-22T00:00:00.000Z"), + "should inject event_ts" + ); + assert!(value.get("event_kind").is_some(), "should retain the row fields"); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::types::tests::json_line_injects_event_ts_and_keeps_row_fields` +Expected: FAIL to compile (`to_json_line_with_event_ts` not found). + +- [ ] **Step 3: Write the core helper** + +Add to `telemetry/types.rs` (after the `to_ndjson` function): + +```rust +/// Serialize one row to a single JSON object string with an injected `event_ts` +/// field. Core stays clock-free; the caller supplies the timestamp. +/// +/// # Errors +/// +/// Returns the underlying `serde_json` error if the row cannot be serialized. +pub fn to_json_line_with_event_ts( + row: &AuctionEventRow, + event_ts: &str, +) -> Result { + let mut value = serde_json::to_value(row)?; + if let serde_json::Value::Object(map) = &mut value { + map.insert( + "event_ts".to_string(), + serde_json::Value::String(event_ts.to_string()), + ); + } + serde_json::to_string(&value) +} +``` + +In `mod.rs`, add `to_json_line_with_event_ts` to the `pub use types::{...}` re-export list. + +- [ ] **Step 4: Run the core test** + +Run: `cargo test -p trusted-server-core telemetry::types` +Expected: PASS (includes the new test). + +- [ ] **Step 5: Write the Fastly sink** + +Create `crates/trusted-server-adapter-fastly/src/auction_sink.rs`: + +```rust +//! Fastly implementation of the auction telemetry sink. +//! +//! Writes one NDJSON line per telemetry row to the `ts_auction_events` +//! real-time log endpoint, stamping a shared `event_ts` per batch. The write is +//! buffered by the host and flushed asynchronously, so it never blocks the +//! response. + +use std::io::Write as _; + +use chrono::{SecondsFormat, Utc}; +use fastly::log::Endpoint; +use trusted_server_core::auction::telemetry::{ + to_json_line_with_event_ts, AuctionEventRow, AuctionEventSink, +}; + +/// Name of the Fastly real-time log endpoint provisioned for auction telemetry. +const AUCTION_EVENTS_ENDPOINT: &str = "ts_auction_events"; + +/// Sink that serializes telemetry rows to NDJSON and writes them to the Fastly +/// auction-events log endpoint. +pub struct FastlyAuctionEventSink; + +impl AuctionEventSink for FastlyAuctionEventSink { + fn emit(&self, rows: &[AuctionEventRow]) { + if rows.is_empty() { + return; + } + let event_ts = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let mut endpoint = Endpoint::from_name(AUCTION_EVENTS_ENDPOINT); + for row in rows { + match to_json_line_with_event_ts(row, &event_ts) { + Ok(line) => { + if let Err(error) = writeln!(endpoint, "{line}") { + log::warn!("auction telemetry log write failed: {error}"); + break; + } + } + Err(error) => { + log::warn!("auction telemetry serialization failed: {error}"); + } + } + } + } +} +``` + +- [ ] **Step 6: Install the sink and declare the module** + +In `crates/trusted-server-adapter-fastly/src/main.rs`, add the module declaration with the other `mod` lines: + +```rust +mod auction_sink; +``` + +In `crates/trusted-server-adapter-fastly/src/platform.rs`, add the sink to `build_runtime_services` (after the `.client_info(...)` call, before `.build()`): + +```rust + .auction_event_sink(std::sync::Arc::new(crate::auction_sink::FastlyAuctionEventSink)) +``` + +- [ ] **Step 7: Verify the adapter builds for wasm + core tests pass** + +Run: `cargo check --package trusted-server-adapter-fastly --target wasm32-wasip1` +Expected: builds with no errors. + +Run: `cargo test -p trusted-server-core telemetry` +Expected: PASS. + +Run: `cargo fmt --all -- --check` +Expected: no diff. + +Run: `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` +Expected: no warnings. + +- [ ] **Step 8: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/types.rs crates/trusted-server-core/src/auction/telemetry/mod.rs crates/trusted-server-adapter-fastly/src/auction_sink.rs crates/trusted-server-adapter-fastly/src/main.rs crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Add Fastly auction telemetry sink and install it on runtime services" +``` + +--- + +## Self-Review + +**Spec coverage (this plan's slice):** Observation context construction from request/geo/consent with a fresh telemetry id and normalized page path (Task 1). Sink seam on `RuntimeServices` with a no-op default so existing call sites keep working (Task 2). `POST /auction` emits completed-auction rows (Task 3). The Fastly sink writes per-row NDJSON with `event_ts` to `ts_auction_events` (Task 4). Emission is off the response path (buffered host write), satisfying the no-TTFB-hold constraint. + +**Deferred (not gaps in this plan):** `handle_page_bids` wiring (same pattern, heavier harness), SSAT dispatch/collect + abandoned/skipped/dispatch-failed/execution-failed outcomes, real `is_mobile`/`is_known_browser` population (passed as `2`), access logs, and the deferred Minor from the mapping plan (share the orchestrator's `ERROR_TYPE_*` constants). + +**Placeholder scan:** No `TBD`/`TODO`; every code step shows complete code. + +**Type consistency:** `build_observation_context` (7 args, `consent: Option<&ConsentContext>`) is defined in Task 1 and called identically in Task 3. `auction_event_sink()`/`with_auction_event_sink()` are defined in Task 2 and used in Tasks 2-4. `to_json_line_with_event_ts(&AuctionEventRow, &str) -> Result` is defined in Task 4 core and consumed by the Task 4 adapter sink. `build_completed_auction_events(ctx, slot_count, result)` matches the mapping plan's signature. `Endpoint::from_name(&str) -> Self` + `Write` matches fastly 0.11.13. From a9913ed0a6adc127dceca6705ac8fc31e467db45 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 22:55:43 -0500 Subject: [PATCH 22/37] Add auction observation context builder --- .../src/auction/telemetry/context.rs | 161 ++++++++++++++++++ .../src/auction/telemetry/mod.rs | 2 + 2 files changed, 163 insertions(+) create mode 100644 crates/trusted-server-core/src/auction/telemetry/context.rs diff --git a/crates/trusted-server-core/src/auction/telemetry/context.rs b/crates/trusted-server-core/src/auction/telemetry/context.rs new file mode 100644 index 000000000..ab6e41c00 --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry/context.rs @@ -0,0 +1,161 @@ +//! Builds an `AuctionObservationContext` from request, geo, and consent inputs. +//! +//! This is the only telemetry code that mints the telemetry id and normalizes +//! the page path. It performs no I/O. + +use uuid::Uuid; + +use crate::auction::telemetry::types::{AuctionObservationContext, AuctionSource}; +use crate::consent::ConsentContext; +use crate::platform::GeoInfo; + +/// Build a PII-free observation context for one auction. +/// +/// `is_mobile` and `is_known_browser` use `0`/`1`/`2` (`2` = unknown); a later +/// plan threads real device signals. `consent` is optional because a +/// non-regulated auction may carry no consent context. +#[must_use] +pub fn build_observation_context( + source: AuctionSource, + publisher_domain: &str, + page_url: Option<&str>, + geo: Option<&GeoInfo>, + consent: Option<&ConsentContext>, + is_mobile: u8, + is_known_browser: u8, +) -> AuctionObservationContext { + AuctionObservationContext { + auction_id: Uuid::new_v4(), + source, + publisher_domain: publisher_domain.to_string(), + page_path: page_url + .map(normalize_page_path) + .unwrap_or_else(|| "/".to_string()), + country: geo.map(|info| info.country.clone()).unwrap_or_default(), + region: geo.and_then(|info| info.region.clone()), + is_mobile, + is_known_browser, + gdpr_applies: consent.is_some_and(|context| context.gdpr_applies), + consent_present: consent.is_some_and(|context| !context.is_empty()), + } +} + +/// Reduce a page URL or path to a bounded path with no scheme, host, query, or +/// fragment. Empty or path-less inputs normalize to `/`. +fn normalize_page_path(page_url: &str) -> String { + let without_fragment = page_url.split('#').next().unwrap_or(""); + let without_query = without_fragment.split('?').next().unwrap_or(""); + let path = match without_query.find("://") { + Some(scheme_end) => { + let after_scheme = &without_query[scheme_end + 3..]; + match after_scheme.find('/') { + Some(slash) => &after_scheme[slash..], + None => "/", + } + } + None => without_query, + }; + let path = if path.is_empty() { "/" } else { path }; + path.chars().take(512).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::types::AuctionSource; + use crate::consent::ConsentContext; + use crate::platform::GeoInfo; + + fn geo() -> GeoInfo { + GeoInfo { + city: "Springfield".to_string(), + country: "US".to_string(), + continent: "NA".to_string(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_string()), + asn: None, + } + } + + #[test] + fn normalizes_full_url_to_path_without_query_or_fragment() { + assert_eq!( + normalize_page_path("https://www.example.com/news/article?utm=x#top"), + "/news/article", + "should keep only the path" + ); + assert_eq!( + normalize_page_path("/already/a/path?q=1"), + "/already/a/path", + "should strip the query from a bare path" + ); + assert_eq!( + normalize_page_path("https://example.com"), + "/", + "a URL with no path normalizes to /" + ); + assert_eq!(normalize_page_path(""), "/", "empty input normalizes to /"); + } + + #[test] + fn builds_context_from_geo_and_consent() { + let consent = ConsentContext { + gdpr_applies: true, + ..ConsentContext::default() + }; + let ctx = build_observation_context( + AuctionSource::AuctionApi, + "example.com", + Some("https://example.com/p?x=1"), + Some(&geo()), + Some(&consent), + 1, + 1, + ); + assert_eq!( + ctx.source, + AuctionSource::AuctionApi, + "should carry the source" + ); + assert_eq!( + ctx.publisher_domain, "example.com", + "should carry the domain" + ); + assert_eq!(ctx.page_path, "/p", "should carry the normalized path"); + assert_eq!(ctx.country, "US", "should carry country from geo"); + assert_eq!( + ctx.region.as_deref(), + Some("CA"), + "should carry region from geo" + ); + assert!(ctx.gdpr_applies, "should carry gdpr_applies from consent"); + assert!( + !ctx.consent_present, + "a default consent is empty so consent_present is false" + ); + assert!(!ctx.auction_id.is_nil(), "should mint a fresh telemetry id"); + } + + #[test] + fn defaults_country_and_consent_when_absent() { + let ctx = build_observation_context( + AuctionSource::AuctionApi, + "example.com", + None, + None, + None, + 2, + 2, + ); + assert_eq!(ctx.country, "", "no geo means empty country"); + assert!(ctx.region.is_none(), "no geo means no region"); + assert_eq!(ctx.page_path, "/", "no page url normalizes to /"); + assert!(!ctx.gdpr_applies, "no consent means gdpr_applies false"); + assert!( + !ctx.consent_present, + "no consent means consent_present false" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index c4a77271f..b75039468 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -4,11 +4,13 @@ //! lives in separate modules; this module performs no I/O. pub mod builder; +pub mod context; pub mod mapping; pub mod sink; pub mod types; pub use builder::build_auction_events; +pub use context::build_observation_context; pub use mapping::{build_completed_auction_events, completed_outcome, provider_calls_from_result}; pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; pub use types::{ From 5499aa79ae4f0fb33a6cd84b9b0dc1e8c919417e Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Mon, 22 Jun 2026 23:00:08 -0500 Subject: [PATCH 23/37] Add auction event sink to runtime services with no-op default --- .../trusted-server-core/src/platform/types.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 33250d1d1..bce457781 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -7,6 +7,7 @@ use super::{ PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; +use crate::auction::telemetry::{AuctionEventSink, NoopSink}; /// Geographic information extracted from a request. /// @@ -160,6 +161,9 @@ pub struct RuntimeServices { pub(crate) geo: Arc, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, + /// Sink for auction telemetry rows. Defaults to a no-op; the Fastly adapter + /// installs a real implementation. + pub(crate) auction_event_sink: Arc, } impl RuntimeServices { @@ -229,6 +233,12 @@ impl RuntimeServices { &self.client_info } + /// Returns the auction telemetry sink. + #[must_use] + pub fn auction_event_sink(&self) -> &dyn AuctionEventSink { + &*self.auction_event_sink + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -248,6 +258,15 @@ impl RuntimeServices { ..self } } + + /// Return a clone of these services with a different auction event sink. + #[must_use] + pub fn with_auction_event_sink(self, sink: Arc) -> Self { + Self { + auction_event_sink: sink, + ..self + } + } } impl fmt::Debug for RuntimeServices { @@ -270,6 +289,7 @@ pub struct RuntimeServicesBuilder { http_client: Option>, geo: Option>, client_info: Option, + auction_event_sink: Option>, } impl RuntimeServicesBuilder { @@ -282,6 +302,7 @@ impl RuntimeServicesBuilder { http_client: None, geo: None, client_info: None, + auction_event_sink: None, } } @@ -334,6 +355,13 @@ impl RuntimeServicesBuilder { self } + /// Set the auction telemetry sink. Defaults to a no-op when unset. + #[must_use] + pub fn auction_event_sink(mut self, sink: Arc) -> Self { + self.auction_event_sink = Some(sink); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -363,6 +391,50 @@ impl RuntimeServicesBuilder { client_info: self .client_info .expect("should set client_info before building RuntimeServices"), + auction_event_sink: self + .auction_event_sink + .unwrap_or_else(|| Arc::new(NoopSink)), } } } + +#[cfg(test)] +mod auction_sink_tests { + use crate::auction::telemetry::types::{AuctionObservationContext, AuctionSource, EventKind}; + use crate::auction::telemetry::{AuctionEventRow, InMemorySink}; + use crate::platform::test_support::noop_services; + + fn row() -> AuctionEventRow { + let ctx = AuctionObservationContext { + auction_id: uuid::Uuid::nil(), + source: AuctionSource::AuctionApi, + publisher_domain: "example.com".to_string(), + page_path: "/p".to_string(), + country: "US".to_string(), + region: None, + is_mobile: 2, + is_known_browser: 2, + gdpr_applies: false, + consent_present: false, + }; + AuctionEventRow::base(&ctx, EventKind::Summary) + } + + #[test] + fn default_sink_is_noop_and_does_not_panic() { + let services = noop_services(); + services.auction_event_sink().emit(&[row()]); + } + + #[test] + fn injected_sink_captures_emitted_rows() { + let sink = std::sync::Arc::new(InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + services.auction_event_sink().emit(&[row()]); + assert_eq!( + sink.rows().len(), + 1, + "should route emitted rows to the injected sink" + ); + } +} From d2a16b8aa3fa040d676a7679438efc937f2a753a Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 05:28:52 -0500 Subject: [PATCH 24/37] Fix wiring plan Task 3 test to use a completing auction harness --- .../2026-06-22-auction-telemetry-wiring.md | 84 +++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md b/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md index 9b2354f64..a63602052 100644 --- a/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md +++ b/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md @@ -385,24 +385,96 @@ git commit -m "Add auction event sink to runtime services with no-op default" - [ ] **Step 1: Write the failing test** -Add to the existing `#[cfg(test)] mod tests` in `endpoints.rs` (it already imports `create_test_settings`, `make_ec_context`, `noop_services`, `AuctionConfig`, `Jurisdiction`, `json`, `Arc`, `StatusCode`, `EdgeBody`, `Request`, `handle_auction`): +The orchestrator errors on both an empty provider list ("No providers configured") and an all-failed-to-launch auction ("All N configured provider(s) ... failed to launch"). To exercise the **completed** path the test registers a provider that launches successfully through a stubbed HTTP client and parses a no-bid success — the exact harness the orchestrator's own tests use. + +This test needs imports the `tests` module does not already have. Add these to the `use` lines at the top of the `#[cfg(test)] mod tests` block: + +```rust + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use crate::platform::PlatformHttpRequest; + use error_stack::ResultExt as _; +``` + +(The module already imports `create_test_settings`, `make_ec_context`, `AuctionConfig`, `AuctionProvider`, `Jurisdiction`, `json`, `Arc`, `StatusCode`, `EdgeBody`, `Request`, `handle_auction`, `AuctionRequest`, `AuctionResponse`, `PlatformPendingRequest`, `PlatformResponse`, and `error_stack::Report` via the existing test setup.) + +First add this provider struct inside the `tests` module (next to the existing `PanicOnBidProvider`). It mirrors `StubAuctionProvider` from the orchestrator tests: + +```rust + /// Provider that launches through the stub HTTP client and parses a no-bid + /// success, so `run_auction` returns a completed `OrchestrationResult`. This + /// is the path that must emit telemetry. + struct StubLaunchProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for StubLaunchProvider { + fn provider_name(&self) -> &'static str { + "stub_provider" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let req = PlatformHttpRequest::new( + Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(EdgeBody::empty()) + .expect("should build stub bid request"), + "stub-backend", + ); + context + .services + .http_client() + .send_async(req) + .await + .change_context(TrustedServerError::Auction { + message: "stub launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success("stub_provider", vec![], response_time_ms)) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some("stub-backend".to_string()) + } + } +``` + +Then add the test itself: ```rust #[tokio::test] async fn auction_endpoint_emits_completed_telemetry() { - // A non-regulated, ungated auction with no providers still completes and - // must emit one summary row tagged auction_api to the injected sink. + // A non-regulated, ungated auction completes (one provider launches via + // the stub HTTP client and parses a no-bid success), so it must emit one + // summary row tagged auction_api to the injected sink. let settings = create_test_settings(); let config = AuctionConfig { enabled: true, - providers: vec![], + providers: vec!["stub_provider".to_string()], timeout_ms: 2000, mediator: None, ..Default::default() }; - let orchestrator = AuctionOrchestrator::new(config); + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubLaunchProvider)); + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(200, b"{}".to_vec()); let sink = Arc::new(crate::auction::telemetry::InMemorySink::default()); - let services = noop_services().with_auction_event_sink(sink.clone()); + let services = + build_services_with_http_client(http_client).with_auction_event_sink(sink.clone()); let ec_id = format!("{}.ABC123", "a".repeat(64)); let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); From 5e4a784c322df19c3da1e253d600abfed8310592 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 05:31:59 -0500 Subject: [PATCH 25/37] Emit completed-auction telemetry from the auction endpoint --- .../src/auction/endpoints.rs | 147 +++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 5ed59aae5..0dea8f371 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,6 +9,9 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::telemetry::{ + build_completed_auction_events, build_observation_context, AuctionSource, +}; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::COOKIE_TS_EIDS; use crate::ec::eids::{resolve_partner_ids, to_eids}; @@ -270,6 +273,26 @@ pub async fn handle_auction( result.total_time_ms ); + // Emit completed-auction telemetry. The sink write is buffered and + // non-blocking in production and a no-op by default in tests, so this never + // affects the response. Device signals are unknown (`2`) until a later plan + // threads them through. + let observation = build_observation_context( + AuctionSource::AuctionApi, + &auction_request.publisher.domain, + auction_request.publisher.page_url.as_deref(), + auction_request + .device + .as_ref() + .and_then(|device| device.geo.as_ref()), + auction_request.user.consent.as_ref(), + 2, + 2, + ); + let slot_count = u16::try_from(auction_request.slots.len()).unwrap_or(u16::MAX); + let telemetry_rows = build_completed_auction_events(&observation, slot_count, &result); + services.auction_event_sink().emit(&telemetry_rows); + // Convert to OpenRTB response format with inline creative HTML convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed()) } @@ -490,8 +513,10 @@ mod tests { use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; use crate::openrtb::Uid; - use crate::platform::test_support::noop_services; - use crate::platform::{PlatformPendingRequest, PlatformResponse}; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; + use crate::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; use crate::test_support::tests::create_test_settings; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; @@ -543,6 +568,61 @@ mod tests { } } + /// Provider that launches through the stub HTTP client and parses a no-bid + /// success, so `run_auction` returns a completed `OrchestrationResult`. This + /// is the path that must emit telemetry. + struct StubLaunchProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for StubLaunchProvider { + fn provider_name(&self) -> &'static str { + "stub_provider" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let req = PlatformHttpRequest::new( + Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(EdgeBody::empty()) + .expect("should build stub bid request"), + "stub-backend", + ); + context + .services + .http_client() + .send_async(req) + .await + .change_context(TrustedServerError::Auction { + message: "stub launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + "stub_provider", + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some("stub-backend".to_string()) + } + } + #[tokio::test] async fn auction_endpoint_consent_gate_returns_no_bid_without_contacting_providers() { // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run @@ -962,6 +1042,69 @@ mod tests { ); } + #[tokio::test] + async fn auction_endpoint_emits_completed_telemetry() { + // A non-regulated, ungated auction completes (one provider launches via + // the stub HTTP client and parses a no-bid success), so it must emit one + // summary row tagged auction_api to the injected sink. + let settings = create_test_settings(); + let config = AuctionConfig { + enabled: true, + providers: vec!["stub_provider".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubLaunchProvider)); + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(200, b"{}".to_vec()); + let sink = Arc::new(crate::auction::telemetry::InMemorySink::default()); + let services = + build_services_with_http_client(http_client).with_auction_event_sink(sink.clone()); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + + let body = json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + }); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build auction request"); + + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + .expect("auction should return a valid response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let rows = sink.rows(); + assert!( + rows.iter().any( + |r| r.event_kind == crate::auction::telemetry::EventKind::Summary + && r.auction_source == crate::auction::telemetry::AuctionSource::AuctionApi + ), + "should emit a summary row tagged auction_api, got {} rows", + rows.len() + ); + } + #[tokio::test] async fn auction_rejects_oversized_body() { use edgezero_core::body::Body as EdgeBody; From c108a72791676ea661f10bfb2e30e661aadb49f2 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 05:54:21 -0500 Subject: [PATCH 26/37] Add Fastly auction telemetry sink and install it on runtime services --- .../src/auction_sink.rs | 44 +++++++++++++++++++ .../trusted-server-adapter-fastly/src/main.rs | 1 + .../src/platform.rs | 3 ++ .../src/auction/telemetry/mod.rs | 5 ++- .../src/auction/telemetry/types.rs | 38 ++++++++++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 crates/trusted-server-adapter-fastly/src/auction_sink.rs diff --git a/crates/trusted-server-adapter-fastly/src/auction_sink.rs b/crates/trusted-server-adapter-fastly/src/auction_sink.rs new file mode 100644 index 000000000..e97fcab42 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/auction_sink.rs @@ -0,0 +1,44 @@ +//! Fastly implementation of the auction telemetry sink. +//! +//! Writes one NDJSON line per telemetry row to the `ts_auction_events` +//! real-time log endpoint, stamping a shared `event_ts` per batch. The write is +//! buffered by the host and flushed asynchronously, so it never blocks the +//! response. + +use std::io::Write as _; + +use chrono::{SecondsFormat, Utc}; +use fastly::log::Endpoint; +use trusted_server_core::auction::telemetry::{ + to_json_line_with_event_ts, AuctionEventRow, AuctionEventSink, +}; + +/// Name of the Fastly real-time log endpoint provisioned for auction telemetry. +const AUCTION_EVENTS_ENDPOINT: &str = "ts_auction_events"; + +/// Sink that serializes telemetry rows to NDJSON and writes them to the Fastly +/// auction-events log endpoint. +pub struct FastlyAuctionEventSink; + +impl AuctionEventSink for FastlyAuctionEventSink { + fn emit(&self, rows: &[AuctionEventRow]) { + if rows.is_empty() { + return; + } + let event_ts = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let mut endpoint = Endpoint::from_name(AUCTION_EVENTS_ENDPOINT); + for row in rows { + match to_json_line_with_event_ts(row, &event_ts) { + Ok(line) => { + if let Err(error) = writeln!(endpoint, "{line}") { + log::warn!("auction telemetry log write failed: {error}"); + break; + } + } + Err(error) => { + log::warn!("auction telemetry serialization failed: {error}"); + } + } + } + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 2ef07eada..907c745a9 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -50,6 +50,7 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; +mod auction_sink; mod error; mod logging; mod management_api; diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 7e96a5455..b3ab8a431 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -576,6 +576,9 @@ pub fn build_runtime_services( server_hostname: std::env::var("FASTLY_HOSTNAME").ok(), server_region: std::env::var("FASTLY_REGION").ok(), }) + .auction_event_sink(std::sync::Arc::new( + crate::auction_sink::FastlyAuctionEventSink, + )) .build() } diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index b75039468..ce196ad3b 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -14,6 +14,7 @@ pub use context::build_observation_context; pub use mapping::{build_completed_auction_events, completed_outcome, provider_calls_from_result}; pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; pub use types::{ - to_ndjson, AuctionEventRow, AuctionObservationContext, AuctionSource, EventKind, - ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, + to_json_line_with_event_ts, to_ndjson, AuctionEventRow, AuctionObservationContext, + AuctionSource, EventKind, ProviderCallOutcome, ProviderCallStatus, ProviderRole, + TerminalOutcome, TerminalStatus, }; diff --git a/crates/trusted-server-core/src/auction/telemetry/types.rs b/crates/trusted-server-core/src/auction/telemetry/types.rs index 5f4734c6a..6128dfefc 100644 --- a/crates/trusted-server-core/src/auction/telemetry/types.rs +++ b/crates/trusted-server-core/src/auction/telemetry/types.rs @@ -265,6 +265,26 @@ pub fn to_ndjson(rows: &[AuctionEventRow]) -> Result Ok(out) } +/// Serialize one row to a single JSON object string with an injected `event_ts` +/// field. Core stays clock-free; the caller supplies the timestamp. +/// +/// # Errors +/// +/// Returns the underlying `serde_json` error if the row cannot be serialized. +pub fn to_json_line_with_event_ts( + row: &AuctionEventRow, + event_ts: &str, +) -> Result { + let mut value = serde_json::to_value(row)?; + if let serde_json::Value::Object(map) = &mut value { + map.insert( + "event_ts".to_string(), + serde_json::Value::String(event_ts.to_string()), + ); + } + serde_json::to_string(&value) +} + #[cfg(test)] mod tests { use super::*; @@ -344,6 +364,24 @@ mod tests { assert!(row.slot_id.is_none(), "should null bid fields"); } + #[test] + fn json_line_injects_event_ts_and_keeps_row_fields() { + let row = AuctionEventRow::base(&sample_context(), EventKind::Summary); + let line = to_json_line_with_event_ts(&row, "2026-06-22T00:00:00.000Z") + .expect("should serialize the row"); + let value: serde_json::Value = + serde_json::from_str(&line).expect("line should be valid JSON"); + assert_eq!( + value.get("event_ts").and_then(serde_json::Value::as_str), + Some("2026-06-22T00:00:00.000Z"), + "should inject event_ts" + ); + assert!( + value.get("event_kind").is_some(), + "should retain the row fields" + ); + } + #[test] fn to_ndjson_is_one_compact_object_per_line() { let rows = vec![ From fed49da02ff7ca8ab0fabcdf90ace1adae2cc85c Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:11:05 -0500 Subject: [PATCH 27/37] Add page-bids telemetry wiring plan --- .../2026-06-23-auction-telemetry-page-bids.md | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md new file mode 100644 index 000000000..136ff0ea1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md @@ -0,0 +1,429 @@ +# Auction Telemetry Wiring (page-bids) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a completed `GET /__ts/page-bids` (SPA navigation) auction emit telemetry, by extracting a shared emission helper and calling it from both `handle_auction` and `handle_page_bids`. + +**Architecture:** A new `auction::telemetry::emit::emit_completed_auction_telemetry` builds the observation context from the `AuctionRequest` (reading geo/consent off the request, which both handlers populate), runs the Plan 1/2 builder, and emits via the runtime sink. `handle_auction` is refactored to call it (DRY), and `handle_page_bids` calls it in its `Ok(result)` branch. No orchestrator or response-path changes. + +**Tech Stack:** Rust 2024, existing telemetry module. + +## Global Constraints + +- Rust **2024 edition**. No `unwrap()` in non-test code (`u16::try_from(..).unwrap_or(u16::MAX)`, `unwrap_or`, `expect("should ...")` allowed). No `println!`/`eprintln!`. +- Functions take at most 7 args. Comments on their own line above the code. No imports inside functions; no wildcard imports outside `#[cfg(test)]` (`use super::*;` allowed there). +- Tests: Arrange-Act-Assert, `expect()` with `"should ..."`, descriptive assertion messages, fictional domains only (`example.com`; the existing page-bids tests use `test-publisher.com` for the request URI, which is acceptable to mirror). +- Each public item has a doc comment. +- Commit messages: sentence case, imperative, no semantic prefixes, no bracketed tags, no `Co-Authored-By` trailer. +- Run `cargo fmt --all` before committing (a prior task forgot it). Commit only when the focused test, `cargo fmt --all -- --check`, and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` are all green. + +**Scope boundary (NOT in this plan):** the SSAT dispatch/collect path and its abandoned/skipped outcomes, real device signals (`is_mobile`/`is_known_browser` stay `2`), access logs. + +**Verified facts (current code):** +- `handle_page_bids(settings, services: &RuntimeServices, kv, auction: AuctionDispatch<'_>, ec_context, req)` (publisher.rs:1733). Its `Ok(result)` branch is `Ok(result) => result.winning_bids` (publisher.rs:1878). `auction_request`, `services`, `geo`, `consent_context` are all alive there; `build_auction_request` sets `user.consent = Some(consent_context.clone())` and geo is set on `auction_request.device.geo`, so reading geo/consent off `auction_request` is correct. +- `handle_auction` (endpoints.rs) currently has an inline emission block added in the previous plan, using `build_observation_context` + `build_completed_auction_events` + `services.auction_event_sink().emit(..)`, and imports `use crate::auction::telemetry::{build_completed_auction_events, build_observation_context, AuctionSource};`. +- `AuctionDispatch<'a> { orchestrator, slots, registry }` (publisher.rs:1016). `AuctionOrchestrator` rejects empty providers and all-launch-failed auctions; a completing auction needs a provider that launches via `services.http_client().send_async` and parses a no-bid success (the `StubHttpClient` harness). +- The publisher test module already imports `build_services_with_http_client, noop_services, StubHttpClient`. Test helpers `settings_with_co()`, `article_slot()`, `make_page_bids_request(path)`, `consent_allowing_ec_context()` exist. `is_bot_user_agent` only flags UAs containing bot fragments, so a request with no UA is not a bot. +- Telemetry re-exports live under `crate::auction::telemetry` (`build_observation_context`, `build_completed_auction_events`, `AuctionSource`, `EventKind`, `InMemorySink`). `RuntimeServices::with_auction_event_sink` and `auction_event_sink()` exist. + +--- + +### Task 1: Shared emission helper + +**Files:** +- Create: `crates/trusted-server-core/src/auction/telemetry/emit.rs` +- Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `emit`, re-export the helper) +- Test: inline `#[cfg(test)]` in `emit.rs` + +**Interfaces:** +- Consumes: `build_observation_context`, `build_completed_auction_events`, `AuctionSource` (telemetry); `AuctionRequest` (auction::types); `OrchestrationResult` (orchestrator); `RuntimeServices` (platform). +- Produces: `pub fn emit_completed_auction_telemetry(services: &RuntimeServices, source: AuctionSource, request: &AuctionRequest, result: &OrchestrationResult)` — builds rows for a completed auction and emits them; reads geo/consent off `request`; device signals unknown (`2`). + +- [ ] **Step 1: Write the failing test** + +Create `crates/trusted-server-core/src/auction/telemetry/emit.rs` with the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::{EventKind, InMemorySink}; + use crate::auction::types::{PublisherInfo, UserInfo}; + use crate::platform::test_support::noop_services; + use std::collections::HashMap; + use std::sync::Arc; + + fn request() -> AuctionRequest { + AuctionRequest { + id: "internal-id".to_string(), + slots: vec![], + publisher: PublisherInfo { + domain: "example.com".to_string(), + page_url: Some("https://example.com/news?x=1".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn empty_result() -> OrchestrationResult { + OrchestrationResult { + provider_responses: vec![], + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + } + } + + #[test] + fn emits_one_summary_tagged_with_the_given_source() { + let sink = Arc::new(InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + + emit_completed_auction_telemetry( + &services, + AuctionSource::SpaNavigation, + &request(), + &empty_result(), + ); + + let rows = sink.rows(); + let summary = rows + .iter() + .find(|r| r.event_kind == EventKind::Summary) + .expect("should emit a summary row"); + assert_eq!( + summary.auction_source, + AuctionSource::SpaNavigation, + "should tag the summary with the given source" + ); + assert_eq!( + summary.publisher_domain, "example.com", + "should carry the publisher domain" + ); + assert_eq!( + summary.page_path, "/news", + "should carry the normalized page path" + ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::emit` +Expected: FAIL to compile (`emit` module not declared; `emit_completed_auction_telemetry` not found). + +- [ ] **Step 3: Write minimal implementation** + +Prepend to `emit.rs` (above the test module): + +```rust +//! Wiring helper that emits completed-auction telemetry from a handler. +//! +//! Reads geo and consent off the `AuctionRequest` (a handler's local copies may +//! have been moved). Device signals are unknown (`2`) until a later plan threads +//! them. The sink write is buffered/non-blocking in production. + +use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::telemetry::context::build_observation_context; +use crate::auction::telemetry::mapping::build_completed_auction_events; +use crate::auction::telemetry::types::AuctionSource; +use crate::auction::types::AuctionRequest; +use crate::platform::RuntimeServices; + +/// Build and emit completed-auction telemetry for a finished auction. +pub fn emit_completed_auction_telemetry( + services: &RuntimeServices, + source: AuctionSource, + request: &AuctionRequest, + result: &OrchestrationResult, +) { + let observation = build_observation_context( + source, + &request.publisher.domain, + request.publisher.page_url.as_deref(), + request.device.as_ref().and_then(|device| device.geo.as_ref()), + request.user.consent.as_ref(), + 2, + 2, + ); + let slot_count = u16::try_from(request.slots.len()).unwrap_or(u16::MAX); + let rows = build_completed_auction_events(&observation, slot_count, result); + services.auction_event_sink().emit(&rows); +} +``` + +In `mod.rs`: add `pub mod emit;` (alphabetically, before `pub mod mapping;`) and add `pub use emit::emit_completed_auction_telemetry;` to the re-export block. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trusted-server-core telemetry::emit` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/emit.rs crates/trusted-server-core/src/auction/telemetry/mod.rs +git commit -m "Add shared completed-auction telemetry emission helper" +``` + +--- + +### Task 2: Refactor handle_auction onto the helper + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` (replace the inline emission block + its import) +- Test: the existing `auction_endpoint_emits_completed_telemetry` is the regression gate (no new test). + +**Interfaces:** +- Consumes: `emit_completed_auction_telemetry`, `AuctionSource` (Task 1 / telemetry). +- Produces: no behavior change; `handle_auction` now emits via the shared helper. + +- [ ] **Step 1: Replace the import** + +In `endpoints.rs`, change the telemetry import line from: + +```rust +use crate::auction::telemetry::{build_completed_auction_events, build_observation_context, AuctionSource}; +``` + +to: + +```rust +use crate::auction::telemetry::{emit_completed_auction_telemetry, AuctionSource}; +``` + +- [ ] **Step 2: Replace the inline emission block** + +Replace the inline emission block (the `let observation = build_observation_context(...)` through `services.auction_event_sink().emit(&telemetry_rows);`, immediately after the `log::info!("Auction completed: ...")` and before `convert_to_openrtb_response(...)`) with a single call: + +```rust + // Emit completed-auction telemetry off the response path via the shared + // helper. Buffered/non-blocking in production, no-op by default in tests. + emit_completed_auction_telemetry( + services, + AuctionSource::AuctionApi, + &auction_request, + &result, + ); +``` + +- [ ] **Step 3: Run the regression test + gates** + +Run: `cargo test -p trusted-server-core auction_endpoint_emits_completed_telemetry` +Expected: PASS (unchanged behavior; the helper does exactly what the inline block did). + +Run: `cargo test -p trusted-server-core` +Expected: PASS. + +Run: `cargo fmt --all -- --check` (after `cargo fmt --all`) and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` +Expected: clean (in particular, no unused-import warning for the removed `build_observation_context`/`build_completed_auction_events`). + +- [ ] **Step 4: Commit** + +```bash +git add crates/trusted-server-core/src/auction/endpoints.rs +git commit -m "Refactor auction endpoint emission onto the shared helper" +``` + +--- + +### Task 3: Emit telemetry from handle_page_bids + +**Files:** +- Modify: `crates/trusted-server-core/src/publisher.rs` (import, emit in the `Ok` branch, add a test provider + test) +- Test: inline `#[cfg(test)]` in `publisher.rs` + +**Interfaces:** +- Consumes: `emit_completed_auction_telemetry`, `AuctionSource` (telemetry). +- Produces: `GET /__ts/page-bids` emits a `spa_navigation` row set on a completed auction. + +- [ ] **Step 1: Write the failing test** + +The publisher test module already imports `build_services_with_http_client`, `noop_services`, `StubHttpClient`, `Arc`, `StatusCode`, `Method`, `Request`, `EdgeBody`, `AuctionOrchestrator`, `AuctionDispatch`, and the page-bids helpers. Add any of the following that are not already imported, to the `use` lines of the `#[cfg(test)] mod tests` block: + +```rust + use crate::auction::config::AuctionConfig; + use crate::auction::provider::AuctionProvider; + use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse}; + use crate::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; + use error_stack::{Report, ResultExt as _}; +``` + +Add this provider struct inside the `tests` module (it launches via the stub HTTP client and parses a no-bid success, so the auction completes — the path that emits): + +```rust + struct StubLaunchProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for StubLaunchProvider { + fn provider_name(&self) -> &'static str { + "stub_provider" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let req = PlatformHttpRequest::new( + Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(EdgeBody::empty()) + .expect("should build stub bid request"), + "stub-backend", + ); + context + .services + .http_client() + .send_async(req) + .await + .change_context(TrustedServerError::Auction { + message: "stub launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success("stub_provider", vec![], response_time_ms)) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some("stub-backend".to_string()) + } + } +``` + +Add the test: + +```rust + #[tokio::test] + async fn page_bids_emits_spa_navigation_telemetry() { + // A consent-allowed page-bids auction that completes (one provider + // launches via the stub HTTP client and parses a no-bid success) must + // emit one summary row tagged spa_navigation to the injected sink. + let settings = settings_with_co(); + let config = AuctionConfig { + enabled: true, + providers: vec!["stub_provider".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubLaunchProvider)); + let slots = article_slot(); + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(200, b"{}".to_vec()); + let sink = Arc::new(crate::auction::telemetry::InMemorySink::default()); + let services = + build_services_with_http_client(http_client).with_auction_event_sink(sink.clone()); + let ec_context = consent_allowing_ec_context(); + let req = make_page_bids_request("/2024/01/my-article/"); + + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &ec_context, + req, + ) + .await + .expect("should return ok response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let rows = sink.rows(); + assert!( + rows.iter().any(|r| r.event_kind + == crate::auction::telemetry::EventKind::Summary + && r.auction_source == crate::auction::telemetry::AuctionSource::SpaNavigation), + "should emit a summary row tagged spa_navigation, got {} rows", + rows.len() + ); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core page_bids_emits_spa_navigation_telemetry` +Expected: FAIL — the assertion fails (`sink.rows()` empty) because page-bids does not emit yet. It must COMPILE. + +- [ ] **Step 3: Write minimal implementation** + +Add the import with the other `use crate::auction::...` lines at the top of `publisher.rs`: + +```rust +use crate::auction::telemetry::{emit_completed_auction_telemetry, AuctionSource}; +``` + +Change the `run_auction` `Ok` branch (publisher.rs ~line 1878) from: + +```rust + Ok(result) => result.winning_bids, +``` + +to: + +```rust + Ok(result) => { + // Emit completed-auction telemetry off the response path. + emit_completed_auction_telemetry( + services, + AuctionSource::SpaNavigation, + &auction_request, + &result, + ); + result.winning_bids + } +``` + +- [ ] **Step 4: Run test to verify it passes + gates** + +Run: `cargo test -p trusted-server-core page_bids_emits_spa_navigation_telemetry` +Expected: PASS. + +Run: `cargo test -p trusted-server-core` +Expected: PASS. + +Run: `cargo fmt --all -- --check` (after `cargo fmt --all`) and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Emit completed-auction telemetry from the page-bids handler" +``` + +--- + +## Self-Review + +**Spec coverage (this plan's slice):** A shared, unit-tested emission helper (Task 1); `handle_auction` refactored onto it with no behavior change (Task 2); `GET /__ts/page-bids` emits a `spa_navigation` row set on a completed auction (Task 3). Both `run_auction` call sites now emit; emission is off the response path. + +**Deferred (not gaps):** SSAT dispatch/collect + non-completed outcomes, real device signals, access logs. + +**Placeholder scan:** No `TBD`/`TODO`; every code step is complete. + +**Type consistency:** `emit_completed_auction_telemetry(services, source, request, result)` is defined in Task 1 and called identically in Tasks 2 and 3. The `StubLaunchProvider` mirrors the proven harness used in the auction-endpoint test. `build_completed_auction_events`/`build_observation_context`/`AuctionSource` signatures match the prior plans. From 9c7a13c5fa07730b1e195816177e0624ba97601a Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:12:41 -0500 Subject: [PATCH 28/37] Add shared completed-auction telemetry emission helper --- .../src/auction/telemetry/emit.rs | 107 ++++++++++++++++++ .../src/auction/telemetry/mod.rs | 2 + 2 files changed, 109 insertions(+) create mode 100644 crates/trusted-server-core/src/auction/telemetry/emit.rs diff --git a/crates/trusted-server-core/src/auction/telemetry/emit.rs b/crates/trusted-server-core/src/auction/telemetry/emit.rs new file mode 100644 index 000000000..6c2685bcf --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry/emit.rs @@ -0,0 +1,107 @@ +//! Wiring helper that emits completed-auction telemetry from a handler. +//! +//! Reads geo and consent off the `AuctionRequest` (a handler's local copies may +//! have been moved). Device signals are unknown (`2`) until a later plan threads +//! them. The sink write is buffered/non-blocking in production. + +use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::telemetry::context::build_observation_context; +use crate::auction::telemetry::mapping::build_completed_auction_events; +use crate::auction::telemetry::types::AuctionSource; +use crate::auction::types::AuctionRequest; +use crate::platform::RuntimeServices; + +/// Build and emit completed-auction telemetry for a finished auction. +pub fn emit_completed_auction_telemetry( + services: &RuntimeServices, + source: AuctionSource, + request: &AuctionRequest, + result: &OrchestrationResult, +) { + let observation = build_observation_context( + source, + &request.publisher.domain, + request.publisher.page_url.as_deref(), + request + .device + .as_ref() + .and_then(|device| device.geo.as_ref()), + request.user.consent.as_ref(), + 2, + 2, + ); + let slot_count = u16::try_from(request.slots.len()).unwrap_or(u16::MAX); + let rows = build_completed_auction_events(&observation, slot_count, result); + services.auction_event_sink().emit(&rows); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::telemetry::{EventKind, InMemorySink}; + use crate::auction::types::{PublisherInfo, UserInfo}; + use crate::platform::test_support::noop_services; + use std::collections::HashMap; + use std::sync::Arc; + + fn request() -> AuctionRequest { + AuctionRequest { + id: "internal-id".to_string(), + slots: vec![], + publisher: PublisherInfo { + domain: "example.com".to_string(), + page_url: Some("https://example.com/news?x=1".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn empty_result() -> OrchestrationResult { + OrchestrationResult { + provider_responses: vec![], + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + } + } + + #[test] + fn emits_one_summary_tagged_with_the_given_source() { + let sink = Arc::new(InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + + emit_completed_auction_telemetry( + &services, + AuctionSource::SpaNavigation, + &request(), + &empty_result(), + ); + + let rows = sink.rows(); + let summary = rows + .iter() + .find(|r| r.event_kind == EventKind::Summary) + .expect("should emit a summary row"); + assert_eq!( + summary.auction_source, + AuctionSource::SpaNavigation, + "should tag the summary with the given source" + ); + assert_eq!( + summary.publisher_domain, "example.com", + "should carry the publisher domain" + ); + assert_eq!( + summary.page_path, "/news", + "should carry the normalized page path" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/telemetry/mod.rs b/crates/trusted-server-core/src/auction/telemetry/mod.rs index ce196ad3b..b0d3e6f59 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mod.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mod.rs @@ -5,12 +5,14 @@ pub mod builder; pub mod context; +pub mod emit; pub mod mapping; pub mod sink; pub mod types; pub use builder::build_auction_events; pub use context::build_observation_context; +pub use emit::emit_completed_auction_telemetry; pub use mapping::{build_completed_auction_events, completed_outcome, provider_calls_from_result}; pub use sink::{AuctionEventSink, InMemorySink, NoopSink}; pub use types::{ From 83aecaa0f711513a88f73811801151dd4bffb767 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:15:46 -0500 Subject: [PATCH 29/37] Refactor auction endpoint emission onto the shared helper --- .../src/auction/endpoints.rs | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 0dea8f371..108f687f9 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,9 +9,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::auction::telemetry::{ - build_completed_auction_events, build_observation_context, AuctionSource, -}; +use crate::auction::telemetry::{emit_completed_auction_telemetry, AuctionSource}; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::COOKIE_TS_EIDS; use crate::ec::eids::{resolve_partner_ids, to_eids}; @@ -273,25 +271,14 @@ pub async fn handle_auction( result.total_time_ms ); - // Emit completed-auction telemetry. The sink write is buffered and - // non-blocking in production and a no-op by default in tests, so this never - // affects the response. Device signals are unknown (`2`) until a later plan - // threads them through. - let observation = build_observation_context( + // Emit completed-auction telemetry off the response path via the shared + // helper. Buffered/non-blocking in production, no-op by default in tests. + emit_completed_auction_telemetry( + services, AuctionSource::AuctionApi, - &auction_request.publisher.domain, - auction_request.publisher.page_url.as_deref(), - auction_request - .device - .as_ref() - .and_then(|device| device.geo.as_ref()), - auction_request.user.consent.as_ref(), - 2, - 2, + &auction_request, + &result, ); - let slot_count = u16::try_from(auction_request.slots.len()).unwrap_or(u16::MAX); - let telemetry_rows = build_completed_auction_events(&observation, slot_count, &result); - services.auction_event_sink().emit(&telemetry_rows); // Convert to OpenRTB response format with inline creative HTML convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed()) From f64bd3bc245d569258425f45f3555327f0feb91e Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:22:01 -0500 Subject: [PATCH 30/37] Emit completed-auction telemetry from the page-bids handler --- crates/trusted-server-core/src/publisher.rs | 126 +++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index d19f761d3..9babc39c6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -33,6 +33,7 @@ use crate::auction::endpoints::{ merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, }; use crate::auction::orchestrator::{AuctionOrchestrator, DispatchedAuction}; +use crate::auction::telemetry::{emit_completed_auction_telemetry, AuctionSource}; use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; @@ -1876,7 +1877,16 @@ pub async fn handle_page_bids( .run_auction(&auction_request, &auction_context) .await { - Ok(result) => result.winning_bids, + Ok(result) => { + // Emit completed-auction telemetry off the response path. + emit_completed_auction_telemetry( + services, + AuctionSource::SpaNavigation, + &auction_request, + &result, + ); + result.winning_bids + } Err(e) => { log::warn!("page-bids auction failed: {e:?}"); std::collections::HashMap::new() @@ -3958,11 +3968,19 @@ mod tests { mod page_bids_no_match_tests { use super::super::*; + use crate::auction::config::AuctionConfig; + use crate::auction::provider::AuctionProvider; + use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse}; use crate::auction::AuctionOrchestrator; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; - use crate::platform::test_support::noop_services; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; + use crate::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; use crate::test_support::tests::crate_test_settings_str; + use error_stack::Report; use http::Method; + use std::sync::Arc; fn settings_with_co() -> Settings { let toml = format!( @@ -4379,5 +4397,109 @@ mod tests { "consent denial must produce no bids" ); } + + struct StubLaunchProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for StubLaunchProvider { + fn provider_name(&self) -> &'static str { + "stub_provider" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let req = PlatformHttpRequest::new( + Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(EdgeBody::empty()) + .expect("should build stub bid request"), + "stub-backend", + ); + context + .services + .http_client() + .send_async(req) + .await + .change_context(TrustedServerError::Auction { + message: "stub launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + "stub_provider", + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some("stub-backend".to_string()) + } + } + + #[tokio::test] + async fn page_bids_emits_spa_navigation_telemetry() { + // A consent-allowed page-bids auction that completes (one provider + // launches via the stub HTTP client and parses a no-bid success) must + // emit one summary row tagged spa_navigation to the injected sink. + let settings = settings_with_co(); + let config = AuctionConfig { + enabled: true, + providers: vec!["stub_provider".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubLaunchProvider)); + let slots = article_slot(); + let http_client = Arc::new(StubHttpClient::new()); + http_client.push_response(200, b"{}".to_vec()); + let sink = Arc::new(crate::auction::telemetry::InMemorySink::default()); + let services = + build_services_with_http_client(http_client).with_auction_event_sink(sink.clone()); + let ec_context = consent_allowing_ec_context(); + let req = make_page_bids_request("/2024/01/my-article/"); + + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &ec_context, + req, + ) + .await + .expect("should return ok response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let rows = sink.rows(); + assert!( + rows.iter().any( + |r| r.event_kind == crate::auction::telemetry::EventKind::Summary + && r.auction_source + == crate::auction::telemetry::AuctionSource::SpaNavigation + ), + "should emit a summary row tagged spa_navigation, got {} rows", + rows.len() + ); + } } } From 8491cf8062d891f6b181765c83b66d54a0a420cc Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:28:03 -0500 Subject: [PATCH 31/37] Add device-signals telemetry plan --- ...-06-23-auction-telemetry-device-signals.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md new file mode 100644 index 000000000..d9a10829d --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md @@ -0,0 +1,162 @@ +# Auction Telemetry Device Signals Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Populate `is_mobile` and `is_known_browser` on auction telemetry rows with real values derived from the request, replacing the hardcoded `2` (unknown). + +**Architecture:** The shared `emit_completed_auction_telemetry` helper already has the request and `RuntimeServices`. It computes `DeviceSignals::derive(ua, ja4, h2)` from the request's user agent and the client's TLS JA4 / H2 fingerprint (already carried in `ClientInfo`), then maps to the `0`/`1`/`2` schema columns. Single-helper change; both auction handlers benefit at once. + +**Tech Stack:** Rust 2024, existing `crate::ec::device::DeviceSignals`. + +## Global Constraints + +- Rust **2024 edition**. No `unwrap()` in non-test code (`unwrap_or`, `expect("should ...")` allowed). No `println!`/`eprintln!`. +- Comments on their own line above the code. No imports inside functions; no wildcard imports outside `#[cfg(test)]`. +- Tests: Arrange-Act-Assert, `expect()` with `"should ..."`, descriptive assertion messages, fictional domains only. +- Commit messages: sentence case, imperative, no semantic prefixes, no bracketed tags, no `Co-Authored-By` trailer. +- Run `cargo fmt --all` before committing. Commit only when the focused test, `cargo fmt --all -- --check`, and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` are all green. + +**Verified facts (current code):** +- `crate::ec::device::DeviceSignals::derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> DeviceSignals`; fields `is_mobile: u8` (0=desktop, 1=mobile, 2=unknown via `parse_is_mobile`: iPhone/iPad/Android→1, Macintosh/Windows/Linux→0, else→2) and `known_browser: Option` (`None` when JA4 or H2 is absent). +- `RuntimeServices::client_info() -> &ClientInfo`; `ClientInfo { tls_ja4: Option, h2_fingerprint: Option, .. }`. +- `emit_completed_auction_telemetry(services, source, request, result)` lives in `crates/trusted-server-core/src/auction/telemetry/emit.rs` and currently passes `2, 2` as the device-signal args to `build_observation_context`. The request's UA is at `request.device.as_ref().and_then(|d| d.user_agent.as_deref())`. +- `AuctionRequest.device: Option, ip, geo }>` (auction::types). + +--- + +### Task 1: Derive device signals in the emission helper + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/telemetry/emit.rs` (import, replace the `2, 2` args, add a test) +- Test: inline `#[cfg(test)]` in `emit.rs` + +**Interfaces:** +- Consumes: `DeviceSignals::derive`, `ClientInfo` (via `services.client_info()`). +- Produces: no signature change to `emit_completed_auction_telemetry`; the emitted rows now carry derived `is_mobile`/`is_known_browser`. + +- [ ] **Step 1: Write the failing test** + +Add to the existing `#[cfg(test)] mod tests` in `emit.rs` (it already imports `EventKind`, `InMemorySink`, `PublisherInfo`, `UserInfo`, `noop_services`, `HashMap`, `Arc`, and defines `request()` and `empty_result()`). Add the `DeviceInfo` import to the test `use` lines: + +```rust + use crate::auction::types::DeviceInfo; +``` + +Then add the test: + +```rust + #[test] + fn derives_is_mobile_from_user_agent() { + let sink = Arc::new(InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + let mut req = request(); + req.device = Some(DeviceInfo { + user_agent: Some( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" + .to_string(), + ), + ip: None, + geo: None, + }); + + emit_completed_auction_telemetry( + &services, + AuctionSource::AuctionApi, + &req, + &empty_result(), + ); + + let rows = sink.rows(); + let summary = rows + .iter() + .find(|r| r.event_kind == EventKind::Summary) + .expect("should emit a summary row"); + assert_eq!(summary.is_mobile, 1, "an iPhone user agent should classify as mobile"); + assert_eq!( + summary.is_known_browser, 2, + "with no JA4/H2 fingerprint the browser-legitimacy signal is unknown" + ); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core telemetry::emit` +Expected: FAIL — `derives_is_mobile_from_user_agent` fails because the helper still passes `2, 2`, so `summary.is_mobile` is `2`, not `1`. (The existing `emits_one_summary_tagged_with_the_given_source` test still passes; its request has `device: None`, so `is_mobile` stays `2`.) + +- [ ] **Step 3: Write minimal implementation** + +In `emit.rs`, add the import with the other top-of-file `use` lines: + +```rust +use crate::ec::device::DeviceSignals; +``` + +Replace the body of `emit_completed_auction_telemetry` so it derives the signals and passes them to `build_observation_context` (replacing the `2, 2` args): + +```rust +pub fn emit_completed_auction_telemetry( + services: &RuntimeServices, + source: AuctionSource, + request: &AuctionRequest, + result: &OrchestrationResult, +) { + let user_agent = request + .device + .as_ref() + .and_then(|device| device.user_agent.as_deref()) + .unwrap_or(""); + let client_info = services.client_info(); + let signals = DeviceSignals::derive( + user_agent, + client_info.tls_ja4.as_deref(), + client_info.h2_fingerprint.as_deref(), + ); + // Map the optional browser-legitimacy bit to the 0/1/2 schema column. + let is_known_browser = match signals.known_browser { + Some(true) => 1, + Some(false) => 0, + None => 2, + }; + let observation = build_observation_context( + source, + &request.publisher.domain, + request.publisher.page_url.as_deref(), + request.device.as_ref().and_then(|device| device.geo.as_ref()), + request.user.consent.as_ref(), + signals.is_mobile, + is_known_browser, + ); + let slot_count = u16::try_from(request.slots.len()).unwrap_or(u16::MAX); + let rows = build_completed_auction_events(&observation, slot_count, result); + services.auction_event_sink().emit(&rows); +} +``` + +- [ ] **Step 4: Run test to verify it passes + gates** + +Run: `cargo test -p trusted-server-core telemetry::emit` +Expected: PASS (2 tests). + +Run: `cargo test -p trusted-server-core` +Expected: PASS. + +Run: `cargo fmt --all -- --check` (after `cargo fmt --all`) and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/auction/telemetry/emit.rs +git commit -m "Derive real device signals for auction telemetry rows" +``` + +--- + +## Self-Review + +**Spec coverage:** `is_mobile`/`is_known_browser` now derive from the request UA and client JA4/H2 instead of hardcoded `2`. Both `handle_auction` and `handle_page_bids` benefit because they share the helper. + +**Placeholder scan:** No `TBD`/`TODO`; complete code. + +**Type consistency:** `DeviceSignals::derive(ua, ja4, h2)` and `signals.is_mobile`/`signals.known_browser` match the verified API; `ClientInfo.tls_ja4`/`h2_fingerprint` are `Option` and passed as `Option<&str>` via `as_deref()`. The helper signature is unchanged, so the call sites from the prior plan are unaffected. From a8e2e4317011b4b23054bc475d8888c5a302c9c3 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:29:23 -0500 Subject: [PATCH 32/37] Derive real device signals for auction telemetry rows --- .../src/auction/telemetry/emit.rs | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/auction/telemetry/emit.rs b/crates/trusted-server-core/src/auction/telemetry/emit.rs index 6c2685bcf..cb9917a6d 100644 --- a/crates/trusted-server-core/src/auction/telemetry/emit.rs +++ b/crates/trusted-server-core/src/auction/telemetry/emit.rs @@ -9,6 +9,7 @@ use crate::auction::telemetry::context::build_observation_context; use crate::auction::telemetry::mapping::build_completed_auction_events; use crate::auction::telemetry::types::AuctionSource; use crate::auction::types::AuctionRequest; +use crate::ec::device::DeviceSignals; use crate::platform::RuntimeServices; /// Build and emit completed-auction telemetry for a finished auction. @@ -18,6 +19,23 @@ pub fn emit_completed_auction_telemetry( request: &AuctionRequest, result: &OrchestrationResult, ) { + let user_agent = request + .device + .as_ref() + .and_then(|device| device.user_agent.as_deref()) + .unwrap_or(""); + let client_info = services.client_info(); + let signals = DeviceSignals::derive( + user_agent, + client_info.tls_ja4.as_deref(), + client_info.h2_fingerprint.as_deref(), + ); + // Map the optional browser-legitimacy bit to the 0/1/2 schema column. + let is_known_browser = match signals.known_browser { + Some(true) => 1, + Some(false) => 0, + None => 2, + }; let observation = build_observation_context( source, &request.publisher.domain, @@ -27,8 +45,8 @@ pub fn emit_completed_auction_telemetry( .as_ref() .and_then(|device| device.geo.as_ref()), request.user.consent.as_ref(), - 2, - 2, + signals.is_mobile, + is_known_browser, ); let slot_count = u16::try_from(request.slots.len()).unwrap_or(u16::MAX); let rows = build_completed_auction_events(&observation, slot_count, result); @@ -39,7 +57,7 @@ pub fn emit_completed_auction_telemetry( mod tests { use super::*; use crate::auction::telemetry::{EventKind, InMemorySink}; - use crate::auction::types::{PublisherInfo, UserInfo}; + use crate::auction::types::{DeviceInfo, PublisherInfo, UserInfo}; use crate::platform::test_support::noop_services; use std::collections::HashMap; use std::sync::Arc; @@ -104,4 +122,40 @@ mod tests { "should carry the normalized page path" ); } + + #[test] + fn derives_is_mobile_from_user_agent() { + let sink = Arc::new(InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + let mut req = request(); + req.device = Some(DeviceInfo { + user_agent: Some( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" + .to_string(), + ), + ip: None, + geo: None, + }); + + emit_completed_auction_telemetry( + &services, + AuctionSource::AuctionApi, + &req, + &empty_result(), + ); + + let rows = sink.rows(); + let summary = rows + .iter() + .find(|r| r.event_kind == EventKind::Summary) + .expect("should emit a summary row"); + assert_eq!( + summary.is_mobile, 1, + "an iPhone user agent should classify as mobile" + ); + assert_eq!( + summary.is_known_browser, 2, + "with no JA4/H2 fingerprint the browser-legitimacy signal is unknown" + ); + } } From 47b18578dfa7d6718ba1449bb7af2a8db612a93d Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:31:33 -0500 Subject: [PATCH 33/37] Update emit module doc to reflect derived device signals --- crates/trusted-server-core/src/auction/telemetry/emit.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/auction/telemetry/emit.rs b/crates/trusted-server-core/src/auction/telemetry/emit.rs index cb9917a6d..b3b53b30d 100644 --- a/crates/trusted-server-core/src/auction/telemetry/emit.rs +++ b/crates/trusted-server-core/src/auction/telemetry/emit.rs @@ -1,8 +1,9 @@ //! Wiring helper that emits completed-auction telemetry from a handler. //! //! Reads geo and consent off the `AuctionRequest` (a handler's local copies may -//! have been moved). Device signals are unknown (`2`) until a later plan threads -//! them. The sink write is buffered/non-blocking in production. +//! have been moved). Device signals are derived from the request user agent and +//! the client TLS/H2 fingerprints via [`DeviceSignals`]. The sink write is +//! buffered/non-blocking in production. use crate::auction::orchestrator::OrchestrationResult; use crate::auction::telemetry::context::build_observation_context; From 424005c7b4e1c89a928f7d473ea3aab1b7c978d8 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:39:19 -0500 Subject: [PATCH 34/37] Add SSAT completed-auction telemetry plan --- ...-06-23-auction-telemetry-ssat-completed.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md new file mode 100644 index 000000000..47f89e06c --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md @@ -0,0 +1,208 @@ +# Auction Telemetry Wiring (SSAT completed) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Emit `initial_navigation` telemetry when a server-side ad templates (SSAT) auction completes via `collect_dispatched_auction`, bringing the third auction source online for the completed path. + +**Architecture:** The two publisher collect sites consume the `DispatchedAuction` (which carries the `AuctionRequest`). Each clones the request via a new getter before calling `collect_dispatched_auction`, then calls the shared `emit_completed_auction_telemetry` helper with `AuctionSource::InitialNavigation` after the result returns. No orchestrator behavior change beyond a read-only getter. + +**Tech Stack:** Rust 2024, existing telemetry helper. + +## Global Constraints + +- Rust **2024 edition**. No `unwrap()` in non-test code (`unwrap_or`, `expect("should ...")` allowed). No `println!`/`eprintln!`. +- Comments on their own line above the code. No imports inside functions; no wildcard imports outside `#[cfg(test)]` (`use super::*;` allowed there). +- Tests: Arrange-Act-Assert, `expect()` with `"should ..."`, descriptive assertion messages, fictional domains only (existing SSAT test helpers use `test-publisher.com`, acceptable to mirror). +- Each public item has a doc comment. +- Commit messages: sentence case, imperative, no semantic prefixes, no bracketed tags, no `Co-Authored-By` trailer. +- Run `cargo fmt --all` before committing. Commit only when the focused test, `cargo fmt --all -- --check`, and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` are all green. + +**Scope boundary (NOT in this plan):** SSAT non-completed outcomes (abandoned dispatched tokens at the pass-through / buffered-unmodified branches, skipped, dispatch-failed), and access logs. Those are follow-ups. + +**Verified facts (current code):** +- `DispatchedAuction` (orchestrator.rs:22) has a private `request: AuctionRequest` field and a `#[cfg(test)] impl` with `empty_for_test(request, timeout_ms)`. There is no non-test getter yet. +- `collect_dispatched_auction(&self, dispatched: DispatchedAuction, services, context) -> OrchestrationResult` (orchestrator.rs:854) consumes `dispatched` by value. +- Collect site A, `collect_stream_auction(dispatched: DispatchedAuction, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, settings: &Settings)` (publisher.rs:954) — used by the HTML close-body hold loop. It calls `collect_dispatched_auction` then `write_bids_to_state`. +- Collect site B, the non-HTML branch in `stream_publisher_body_async` (publisher.rs:517-538) — calls `collect_dispatched_auction` then `write_bids_to_state` then returns. +- `publisher.rs` already imports `use crate::auction::telemetry::{emit_completed_auction_telemetry, AuctionSource};` (from a prior plan). +- Test helper `test_auction_request()` (publisher.rs:2072) returns an `AuctionRequest`. `DispatchedAuction::empty_for_test` and `PriceGranularity`, `Mutex`, `noop_services`, `AuctionOrchestrator` are available in the publisher test module (the existing `body_close_hold_loop_processes_close_tail...` test uses them). `collect_dispatched_auction` on an empty dispatched token returns an empty `OrchestrationResult` (no providers, no error). +- `emit_completed_auction_telemetry(services, source, request, result)` emits one summary row tagged with `source`. + +--- + +### Task 1: Emit completed telemetry from the SSAT collect sites + +**Files:** +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` (add a `request()` getter to `DispatchedAuction`) +- Modify: `crates/trusted-server-core/src/publisher.rs` (emit at both collect sites; add a test) +- Test: inline `#[cfg(test)]` in `publisher.rs` + +**Interfaces:** +- Consumes: `emit_completed_auction_telemetry`, `AuctionSource` (already imported in publisher.rs). +- Produces: `DispatchedAuction::request(&self) -> &AuctionRequest` (pub(crate)); both SSAT collect sites emit `initial_navigation` rows. + +- [ ] **Step 1: Write the failing test** + +Add to the publisher.rs `#[cfg(test)] mod tests` (it already imports `DispatchedAuction`, `AuctionOrchestrator`, `noop_services`, `Arc`, `Mutex`, `PriceGranularity`, and the `test_auction_request` helper; if any is missing, add it): + +```rust + #[tokio::test] + async fn collect_stream_auction_emits_initial_navigation_telemetry() { + // A completed SSAT auction (collected at the body-close hold) must emit + // one summary row tagged initial_navigation to the injected sink. An + // empty dispatched token collects to an empty result, which still emits + // a summary. + let settings = create_test_settings(); + let sink = Arc::new(crate::auction::telemetry::InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); + let ad_bids_state = Arc::new(Mutex::new(None)); + + collect_stream_auction( + dispatched, + PriceGranularity::default(), + &ad_bids_state, + &orchestrator, + &services, + &settings, + ) + .await; + + let rows = sink.rows(); + assert!( + rows.iter().any(|r| r.event_kind + == crate::auction::telemetry::EventKind::Summary + && r.auction_source == crate::auction::telemetry::AuctionSource::InitialNavigation), + "should emit an initial_navigation summary, got {} rows", + rows.len() + ); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trusted-server-core collect_stream_auction_emits_initial_navigation_telemetry` +Expected: FAIL — the assertion fails (`sink.rows()` empty) because nothing emits yet. It must COMPILE. + +- [ ] **Step 3: Add the getter** + +In `orchestrator.rs`, add a non-test impl block for the getter immediately after the `DispatchedAuction` struct definition (before the `#[cfg(test)] impl DispatchedAuction`): + +```rust +impl DispatchedAuction { + /// The auction request carried by this dispatched auction. + pub(crate) fn request(&self) -> &AuctionRequest { + &self.request + } +} +``` + +- [ ] **Step 4: Emit at both collect sites** + +In `publisher.rs`, in `collect_stream_auction` (the function that calls `collect_dispatched_auction` then `write_bids_to_state`), clone the request before collect and emit after `write_bids_to_state`. The function currently looks like: + +```rust + let collect_ctx = make_collect_context(settings, services, &placeholder); + let result = orchestrator + .collect_dispatched_auction(dispatched, services, &collect_ctx) + .await; +``` + +Change it to capture the request first, then add the emit after the existing `write_bids_to_state(...)` call in that function: + +```rust + let collect_ctx = make_collect_context(settings, services, &placeholder); + let request = dispatched.request().clone(); + let result = orchestrator + .collect_dispatched_auction(dispatched, services, &collect_ctx) + .await; +``` + +and, immediately after the `write_bids_to_state(...)` call already present in `collect_stream_auction`: + +```rust + // Emit completed-auction telemetry off the response path. + emit_completed_auction_telemetry( + services, + AuctionSource::InitialNavigation, + &request, + &result, + ); +``` + +In the non-HTML branch of `stream_publisher_body_async`, apply the same pattern. The branch currently is: + +```rust + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), + ) + .await; + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, + ); + return stream_publisher_body(body, output, params, settings, integration_registry); +``` + +Change it to capture the request before collect and emit after `write_bids_to_state`: + +```rust + let request = dispatched.request().clone(); + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), + ) + .await; + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, + ); + // Emit completed-auction telemetry off the response path. + emit_completed_auction_telemetry( + services, + AuctionSource::InitialNavigation, + &request, + &result, + ); + return stream_publisher_body(body, output, params, settings, integration_registry); +``` + +- [ ] **Step 5: Run test to verify it passes + gates** + +Run: `cargo test -p trusted-server-core collect_stream_auction_emits_initial_navigation_telemetry` +Expected: PASS. + +Run: `cargo test -p trusted-server-core` +Expected: PASS. + +Run: `cargo fmt --all -- --check` (after `cargo fmt --all`) and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/auction/orchestrator.rs crates/trusted-server-core/src/publisher.rs +git commit -m "Emit completed-auction telemetry from the SSAT collect path" +``` + +--- + +## Self-Review + +**Spec coverage:** Completed SSAT auctions (collected at the body-close hold and the non-HTML branch) emit `initial_navigation` rows via the shared helper. All three auction sources now emit on the completed path: `auction_api`, `spa_navigation`, `initial_navigation`. + +**Deferred (not gaps):** SSAT non-completed outcomes (abandoned dispatched tokens, skipped, dispatch-failed) and access logs. + +**Placeholder scan:** No `TBD`/`TODO`; complete code. + +**Type consistency:** `DispatchedAuction::request()` returns `&AuctionRequest`; `.clone()` yields the owned `AuctionRequest` the helper takes by reference. `emit_completed_auction_telemetry(services, AuctionSource::InitialNavigation, &request, &result)` matches the helper signature used by the other two handlers. The test uses the existing `DispatchedAuction::empty_for_test` / `test_auction_request` harness. From 6a828ff5eba699e146732bd01ef237238d6b327f Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 23 Jun 2026 06:43:44 -0500 Subject: [PATCH 35/37] Emit completed-auction telemetry from the SSAT collect path --- .../src/auction/orchestrator.rs | 7 +++ crates/trusted-server-core/src/publisher.rs | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index c654d39ee..4222b14a4 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -29,6 +29,13 @@ pub struct DispatchedAuction { request: AuctionRequest, } +impl DispatchedAuction { + /// The auction request carried by this dispatched auction. + pub(crate) fn request(&self) -> &AuctionRequest { + &self.request + } +} + #[cfg(test)] impl DispatchedAuction { pub(crate) fn empty_for_test(request: AuctionRequest, timeout_ms: u32) -> Self { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 9babc39c6..e004341e8 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -522,6 +522,7 @@ pub async fn stream_publisher_body_async( .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) .body(EdgeBody::empty()) .unwrap_or_else(|_| Request::new(EdgeBody::empty())); + let request = dispatched.request().clone(); let result = orchestrator .collect_dispatched_auction( dispatched, @@ -535,6 +536,13 @@ pub async fn stream_publisher_body_async( ¶ms.ad_bids_state, settings.debug.inject_adm_for_testing, ); + // Emit completed-auction telemetry off the response path. + emit_completed_auction_telemetry( + services, + AuctionSource::InitialNavigation, + &request, + &result, + ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -965,6 +973,7 @@ async fn collect_stream_auction( .body(EdgeBody::empty()) .unwrap_or_else(|_| Request::new(EdgeBody::empty())); let collect_ctx = make_collect_context(settings, services, &placeholder); + let request = dispatched.request().clone(); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; @@ -978,6 +987,13 @@ async fn collect_stream_auction( ad_bids_state, settings.debug.inject_adm_for_testing, ); + // Emit completed-auction telemetry off the response path. + emit_completed_auction_telemetry( + services, + AuctionSource::InitialNavigation, + &request, + &result, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("stream", &result, ad_bids_state); @@ -4502,4 +4518,39 @@ mod tests { ); } } + + #[tokio::test] + async fn collect_stream_auction_emits_initial_navigation_telemetry() { + // A completed SSAT auction (collected at the body-close hold) must emit + // one summary row tagged initial_navigation to the injected sink. An + // empty dispatched token collects to an empty result, which still emits + // a summary. + let settings = create_test_settings(); + let sink = Arc::new(crate::auction::telemetry::InMemorySink::default()); + let services = noop_services().with_auction_event_sink(sink.clone()); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); + let ad_bids_state = Arc::new(Mutex::new(None)); + + collect_stream_auction( + dispatched, + PriceGranularity::default(), + &ad_bids_state, + &orchestrator, + &services, + &settings, + ) + .await; + + let rows = sink.rows(); + assert!( + rows.iter().any( + |r| r.event_kind == crate::auction::telemetry::EventKind::Summary + && r.auction_source + == crate::auction::telemetry::AuctionSource::InitialNavigation + ), + "should emit an initial_navigation summary, got {} rows", + rows.len() + ); + } } From 52f1f0fb0a1d5d5c7346b3caf4d263612b24832b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 23 Jun 2026 12:35:45 -0500 Subject: [PATCH 36/37] feat: make auction telemetry endpoint configurable Add configured auction event log endpoint to settings and wire it into Fastly telemetry sink with whitespace-safe fallback to default. Harden auction completion telemetry by adding explicit timeout/launch/parse/transport handling and mapping timeout status. Improve page path normalization with sensitive-segment redaction rules for better privacy. --- .../src/auction_sink.rs | 27 +- .../trusted-server-adapter-fastly/src/main.rs | 3 +- .../src/platform.rs | 10 +- .../trusted-server-core/src/auction/README.md | 1 + .../src/auction/orchestrator.rs | 486 +++++++++++++++++- .../src/auction/telemetry/context.rs | 135 ++++- .../src/auction/telemetry/mapping.rs | 18 +- .../src/auction_config_types.rs | 51 ++ docs/guide/auction-orchestration.md | 13 +- docs/guide/configuration.md | 17 +- .../2026-06-22-auction-telemetry-core.md | 17 + .../2026-06-22-auction-telemetry-mapping.md | 5 + .../2026-06-22-auction-telemetry-wiring.md | 9 + ...-06-23-auction-telemetry-device-signals.md | 3 + .../2026-06-23-auction-telemetry-page-bids.md | 7 + ...-06-23-auction-telemetry-ssat-completed.md | 3 + ...-prebid-metrics-tinybird-grafana-design.md | 18 +- trusted-server.toml | 1 + 18 files changed, 766 insertions(+), 58 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/auction_sink.rs b/crates/trusted-server-adapter-fastly/src/auction_sink.rs index e97fcab42..907e74116 100644 --- a/crates/trusted-server-adapter-fastly/src/auction_sink.rs +++ b/crates/trusted-server-adapter-fastly/src/auction_sink.rs @@ -1,6 +1,6 @@ //! Fastly implementation of the auction telemetry sink. //! -//! Writes one NDJSON line per telemetry row to the `ts_auction_events` +//! Writes one NDJSON line per telemetry row to the configured Fastly //! real-time log endpoint, stamping a shared `event_ts` per batch. The write is //! buffered by the host and flushed asynchronously, so it never blocks the //! response. @@ -12,13 +12,28 @@ use fastly::log::Endpoint; use trusted_server_core::auction::telemetry::{ to_json_line_with_event_ts, AuctionEventRow, AuctionEventSink, }; - -/// Name of the Fastly real-time log endpoint provisioned for auction telemetry. -const AUCTION_EVENTS_ENDPOINT: &str = "ts_auction_events"; +use trusted_server_core::auction_config_types::DEFAULT_AUCTION_TELEMETRY_LOG_ENDPOINT; /// Sink that serializes telemetry rows to NDJSON and writes them to the Fastly /// auction-events log endpoint. -pub struct FastlyAuctionEventSink; +pub struct FastlyAuctionEventSink { + endpoint_name: String, +} + +impl FastlyAuctionEventSink { + /// Create a sink that writes to `endpoint_name`. + #[must_use] + pub fn new(endpoint_name: impl Into) -> Self { + let endpoint_name = endpoint_name.into(); + let endpoint_name = endpoint_name.trim(); + let endpoint_name = if endpoint_name.is_empty() { + DEFAULT_AUCTION_TELEMETRY_LOG_ENDPOINT.to_string() + } else { + endpoint_name.to_string() + }; + Self { endpoint_name } + } +} impl AuctionEventSink for FastlyAuctionEventSink { fn emit(&self, rows: &[AuctionEventRow]) { @@ -26,7 +41,7 @@ impl AuctionEventSink for FastlyAuctionEventSink { return; } let event_ts = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - let mut endpoint = Endpoint::from_name(AUCTION_EVENTS_ENDPOINT); + let mut endpoint = Endpoint::from_name(&self.endpoint_name); for row in rows { match to_json_line_with_event_ts(row, &event_ts) { Ok(line) => { diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 907c745a9..b7529c15f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -187,7 +187,8 @@ fn main() { // any request-derived context or converting to the core HTTP types. compat::sanitize_fastly_forwarded_headers(&mut req); - let runtime_services = build_runtime_services(&req, kv_store); + let runtime_services = + build_runtime_services(&req, kv_store, settings.auction.telemetry_log_endpoint()); let http_req = compat::from_fastly_request(req); let route_result = futures::executor::block_on(route_request( diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index b3ab8a431..5b5cb4846 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -555,10 +555,14 @@ impl PlatformGeo for FastlyPlatformGeo { /// /// `kv_store` is an [`Arc`] opened by the caller for /// the primary KV store. Use [`open_kv_store`] to construct it. +/// +/// `auction_event_log_endpoint` names the Fastly real-time log endpoint used +/// for auction telemetry. #[must_use] pub fn build_runtime_services( req: &Request, kv_store: Arc, + auction_event_log_endpoint: &str, ) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) @@ -577,7 +581,7 @@ pub fn build_runtime_services( server_region: std::env::var("FASTLY_REGION").ok(), }) .auction_event_sink(std::sync::Arc::new( - crate::auction_sink::FastlyAuctionEventSink, + crate::auction_sink::FastlyAuctionEventSink::new(auction_event_log_endpoint), )) .build() } @@ -699,7 +703,7 @@ mod tests { #[test] fn build_runtime_services_client_info_is_none_without_tls() { let req = Request::get("https://example.com/"); - let services = build_runtime_services(&req, noop_kv_store()); + let services = build_runtime_services(&req, noop_kv_store(), "ts_auction_events"); assert!( services.client_info().tls_protocol.is_none(), @@ -714,7 +718,7 @@ mod tests { #[test] fn build_runtime_services_returns_cloneable_services() { let req = Request::get("https://example.com/"); - let services = build_runtime_services(&req, noop_kv_store()); + let services = build_runtime_services(&req, noop_kv_store(), "ts_auction_events"); let cloned = services.clone(); assert_eq!( diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index dcc9e1506..94eda1980 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -445,6 +445,7 @@ enabled = true # Enable/disable auction orchestration providers = ["prebid", "aps"] # List of bidder providers mediator = "adserver_mock" # Optional: if set, uses mediation; if omitted, highest bid wins timeout_ms = 2000 # Overall auction timeout +telemetry_log_endpoint = "ts_auction_events" # Fastly auction telemetry log endpoint ``` **Strategy Auto-Detection:** diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4222b14a4..5ec8f1bf1 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -22,6 +22,7 @@ use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStat pub struct DispatchedAuction { pending_requests: Vec, backend_to_provider: HashMap)>, + launch_failures: Vec, auction_start: Instant, timeout_ms: u32, floor_prices: HashMap, @@ -42,6 +43,7 @@ impl DispatchedAuction { Self { pending_requests: Vec::new(), backend_to_provider: HashMap::new(), + launch_failures: Vec::new(), auction_start: Instant::now(), timeout_ms, floor_prices: HashMap::new(), @@ -55,6 +57,7 @@ const PROVIDER_ERROR_MESSAGE_CHARS: usize = 500; const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed"; const ERROR_TYPE_TRANSPORT: &str = "transport"; +const ERROR_TYPE_TIMEOUT: &str = "timeout"; // SECURITY: the returned string is included verbatim (truncated to // PROVIDER_ERROR_MESSAGE_CHARS) in the public /auction response via @@ -100,6 +103,31 @@ fn provider_transport_failed_response( .with_metadata("message", serde_json::json!("Provider request failed")) } +fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> AuctionResponse { + AuctionResponse::error(provider_name, response_time_ms) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_TIMEOUT)) + .with_metadata("message", serde_json::json!("Provider request timed out")) +} + +fn append_transport_failures_from_dispatched( + responses: &mut Vec, + backend_to_provider: &mut HashMap)>, +) { + let mut failures: Vec<(String, u64)> = backend_to_provider + .drain() + .map(|(_, (provider_name, start_time, _))| { + (provider_name, start_time.elapsed().as_millis() as u64) + }) + .collect(); + failures.sort_by(|left, right| left.0.cmp(&right.0)); + for (provider_name, response_time_ms) in failures { + responses.push(provider_transport_failed_response( + &provider_name, + response_time_ms, + )); + } +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -237,10 +265,14 @@ impl AuctionOrchestrator { if remaining_ms == 0 { log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); + let response_time_ms = mediation_start.elapsed().as_millis() as u64; let winning = self.select_winning_bids(&provider_responses, &floor_prices); return Ok(OrchestrationResult { provider_responses, - mediator_response: None, + mediator_response: Some(provider_timeout_response( + mediator.provider_name(), + response_time_ms, + )), winning_bids: winning, total_time_ms: 0, metadata: HashMap::new(), @@ -506,6 +538,19 @@ impl AuctionOrchestrator { Ok(r) => r, Err(e) => { log::warn!("select() failed: {:?}", e); + let mut failures: Vec<(&str, u64)> = backend_to_provider + .drain() + .map(|(_, (provider_name, start_time, _))| { + (provider_name, start_time.elapsed().as_millis() as u64) + }) + .collect(); + failures.sort_by(|left, right| left.0.cmp(right.0)); + for (provider_name, response_time_ms) in failures { + responses.push(provider_transport_failed_response( + provider_name, + response_time_ms, + )); + } break; } }; @@ -599,10 +644,40 @@ impl AuctionOrchestrator { "Auction timeout reached; dropping {} remaining request(s)", remaining.len() ); + let mut timeouts: Vec<(&str, u64)> = backend_to_provider + .drain() + .map(|(_, (provider_name, start_time, _))| { + (provider_name, start_time.elapsed().as_millis() as u64) + }) + .collect(); + timeouts.sort_by(|left, right| left.0.cmp(right.0)); + for (provider_name, response_time_ms) in timeouts { + responses.push(provider_timeout_response(provider_name, response_time_ms)); + } break; } } + if !backend_to_provider.is_empty() { + log::warn!( + "{} provider request(s) were not accounted for after select loop; marking as transport failures", + backend_to_provider.len() + ); + let mut failures: Vec<(&str, u64)> = backend_to_provider + .drain() + .map(|(_, (provider_name, start_time, _))| { + (provider_name, start_time.elapsed().as_millis() as u64) + }) + .collect(); + failures.sort_by(|left, right| left.0.cmp(right.0)); + for (provider_name, response_time_ms) in failures { + responses.push(provider_transport_failed_response( + provider_name, + response_time_ms, + )); + } + } + Ok(responses) } @@ -750,6 +825,7 @@ impl AuctionOrchestrator { let mut backend_to_provider: HashMap)> = HashMap::new(); let mut pending_requests: Vec = Vec::new(); + let mut launch_failures: Vec = Vec::new(); for provider_name in provider_names { let provider = match self.providers.get(provider_name) { @@ -819,11 +895,16 @@ impl AuctionOrchestrator { pending_requests.push(pending.with_backend_name(backend_name)); } Err(e) => { + let response_time_ms = start_time.elapsed().as_millis() as u64; log::warn!( "Provider '{}' failed to dispatch request: {:?}", provider.provider_name(), e ); + launch_failures.push(provider_launch_failed_response( + provider.provider_name(), + response_time_ms, + )); } } } @@ -841,6 +922,7 @@ impl AuctionOrchestrator { Some(DispatchedAuction { pending_requests, backend_to_provider, + launch_failures, auction_start, timeout_ms: context.timeout_ms, floor_prices: self.floor_prices_by_slot(request), @@ -867,6 +949,7 @@ impl AuctionOrchestrator { let DispatchedAuction { pending_requests, mut backend_to_provider, + launch_failures, auction_start, timeout_ms, floor_prices, @@ -880,7 +963,7 @@ impl AuctionOrchestrator { remaining_budget_ms(auction_start, timeout_ms), ); - let mut responses: Vec = Vec::new(); + let mut responses: Vec = launch_failures; let mut remaining = pending_requests; while !remaining.is_empty() { @@ -894,6 +977,10 @@ impl AuctionOrchestrator { Ok(r) => r, Err(e) => { log::warn!("select() failed during auction collection: {:?}", e); + append_transport_failures_from_dispatched( + &mut responses, + &mut backend_to_provider, + ); break; } }; @@ -927,8 +1014,12 @@ impl AuctionOrchestrator { } Err(e) => { log::warn!("Provider '{}' parse failed: {:?}", provider_name, e); - responses - .push(AuctionResponse::error(&provider_name, response_time_ms)); + responses.push(provider_error_response( + &provider_name, + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &e, + )); } } } else { @@ -939,7 +1030,30 @@ impl AuctionOrchestrator { } } Err(e) => { - log::warn!("A provider request failed during collection: {:?}", e); + if let Some(ref backend_name) = select_result.failed_backend_name { + if let Some((provider_name, start_time, _)) = + backend_to_provider.remove(backend_name) + { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!( + "Provider '{}' request failed during collection: {:?}", + provider_name, + e + ); + responses.push(provider_transport_failed_response( + &provider_name, + response_time_ms, + )); + } else { + log::warn!( + "A provider request failed during collection (backend '{}' not tracked): {:?}", + backend_name, + e + ); + } + } else { + log::warn!("A provider request failed during collection: {:?}", e); + } } } @@ -951,6 +1065,14 @@ impl AuctionOrchestrator { // below still observes A_deadline via `remaining_budget_ms`. } + if !backend_to_provider.is_empty() { + log::warn!( + "{} dispatched provider request(s) were not accounted for after collection; marking as transport failures", + backend_to_provider.len() + ); + append_transport_failures_from_dispatched(&mut responses, &mut backend_to_provider); + } + let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { match self.providers.get(mediator_name.as_str()) { Some(mediator) => { @@ -970,11 +1092,15 @@ impl AuctionOrchestrator { responses.len(), ); let winning = self.select_winning_bids(&responses, &floor_prices); + let total_time_ms = auction_start.elapsed().as_millis() as u64; return OrchestrationResult { provider_responses: responses, - mediator_response: None, + mediator_response: Some(provider_timeout_response( + mediator.provider_name(), + total_time_ms, + )), winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, + total_time_ms, metadata: HashMap::new(), }; } @@ -1054,31 +1180,57 @@ impl AuctionOrchestrator { mediator.provider_name(), e ); + let response_time_ms = + mediator_start.elapsed().as_millis() as u64; + let mediator_response = provider_error_response( + mediator.provider_name(), + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &e, + ); let winning = self.select_winning_bids(&responses, &floor_prices); - (None, winning) + (Some(mediator_response), winning) } } } Err(e) => { + let response_time_ms = + mediator_start.elapsed().as_millis() as u64; log::warn!("Mediator request failed: {:?}", e); - (None, self.select_winning_bids(&responses, &floor_prices)) + ( + Some(provider_transport_failed_response( + mediator.provider_name(), + response_time_ms, + )), + self.select_winning_bids(&responses, &floor_prices), + ) } } } Err(e) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; log::warn!( "Mediator '{}' failed to dispatch: {:?}", mediator.provider_name(), e ); - (None, self.select_winning_bids(&responses, &floor_prices)) + ( + Some(provider_launch_failed_response( + mediator.provider_name(), + response_time_ms, + )), + self.select_winning_bids(&responses, &floor_prices), + ) } } } None => { log::warn!("Mediator '{}' not registered", mediator_name); - (None, self.select_winning_bids(&responses, &floor_prices)) + ( + Some(provider_launch_failed_response(mediator_name, 0)), + self.select_winning_bids(&responses, &floor_prices), + ) } } } else { @@ -1158,6 +1310,7 @@ mod tests { use error_stack::{Report, ResultExt}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; + use std::time::{Duration, Instant}; use super::AuctionOrchestrator; @@ -1220,6 +1373,99 @@ mod tests { } } + struct SlowParsingProvider { + name: &'static str, + backend: &'static str, + delay: Duration, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for SlowParsingProvider { + fn provider_name(&self) -> &'static str { + self.name + } + + async fn request_bids( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + StubAuctionProvider { + name: self.name, + backend: self.backend, + } + .request_bids(request, context) + .await + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + let start = Instant::now(); + while start.elapsed() < self.delay { + core::hint::spin_loop(); + } + Ok(AuctionResponse::success( + self.name, + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some(self.backend.to_string()) + } + } + + struct ContextParseFailingProvider { + name: &'static str, + backend: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for ContextParseFailingProvider { + fn provider_name(&self) -> &'static str { + self.name + } + + async fn request_bids( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + StubAuctionProvider { + name: self.name, + backend: self.backend, + } + .request_bids(request, context) + .await + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + Err(Report::new(TrustedServerError::Auction { + message: "parse failed in test provider".to_string(), + })) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some(self.backend.to_string()) + } + } + /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring /// `adserver_mock`), while its context-free parse does not. Lets a test prove /// the synchronous mediation path calls `parse_response_with_context`. @@ -1617,21 +1863,10 @@ mod tests { ); } - // TODO: Re-enable provider integration tests after implementing mock support - // for `PlatformHttpClient::send_async()`. Mock providers currently cannot - // create realistic pending requests for the select loop without real - // platform-backed transport handles. - // - // Untested timeout enforcement paths (require real backends): - // - Deadline check in select() loop (drops remaining requests) + // Remaining timeout enforcement paths that still need focused coverage: // - Mediator skip when remaining_ms == 0 (bidding exhausts budget) // - Provider skip when effective_timeout == 0 (budget exhausted before launch) // - Provider context receives reduced timeout_ms per remaining budget - // - // Follow-up: introduce a thin abstraction over `PlatformHttpClient::select()` - // so the deadline/drop logic can be unit-tested with mock futures instead - // of requiring real platform backends. An `#[ignore]` integration test - // exercising the full path via Viceroy would also catch regressions. #[tokio::test] async fn test_no_providers_configured() { @@ -1642,6 +1877,7 @@ mod tests { timeout_ms: 2000, creative_store: "creative_store".to_string(), allowed_context_keys: HashSet::from(["permutive_segments".to_string()]), + ..Default::default() }; let orchestrator = AuctionOrchestrator::new(config); @@ -1827,6 +2063,210 @@ mod tests { ); } + #[tokio::test] + async fn synchronous_timeout_records_uncollected_provider_response() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + let config = AuctionConfig { + enabled: true, + providers: vec!["slow".to_string(), "late".to_string()], + timeout_ms: 1, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(SlowParsingProvider { + name: "slow", + backend: "slow-backend", + delay: Duration::from_millis(3), + })); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "late", + backend: "late-backend", + })); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 1, + provider_responses: None, + services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should complete with best available responses"); + + let late = result + .provider_responses + .iter() + .find(|response| response.provider == "late") + .expect("should record the uncollected provider"); + assert_eq!( + late.status, + BidStatus::Error, + "should expose the uncollected provider as an error response" + ); + assert_eq!( + late.metadata + .get("error_type") + .and_then(serde_json::Value::as_str), + Some("timeout"), + "should tag the uncollected provider as a timeout" + ); + } + + #[tokio::test] + async fn dispatched_collect_preserves_launch_and_parse_failures() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + let config = AuctionConfig { + enabled: true, + providers: vec![ + "launch-failing".to_string(), + "parse-failing".to_string(), + "ok".to_string(), + ], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(LaunchFailingProvider)); + orchestrator.register_provider(Arc::new(ContextParseFailingProvider { + name: "parse-failing", + backend: "parse-backend", + })); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "ok", + backend: "ok-backend", + })); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + let dispatched = orchestrator + .dispatch_auction(&request, &context) + .await + .expect("should dispatch at least one provider"); + + let result = orchestrator + .collect_dispatched_auction(dispatched, services, &context) + .await; + + let launch = result + .provider_responses + .iter() + .find(|response| response.provider == "launch-failing") + .expect("should retain launch failure from dispatch"); + assert_eq!( + launch + .metadata + .get("error_type") + .and_then(serde_json::Value::as_str), + Some("launch_failed"), + "should tag launch failures for telemetry mapping" + ); + let parse = result + .provider_responses + .iter() + .find(|response| response.provider == "parse-failing") + .expect("should retain parse failure from collect"); + assert_eq!( + parse + .metadata + .get("error_type") + .and_then(serde_json::Value::as_str), + Some("parse_response"), + "should tag parse failures for telemetry mapping" + ); + } + + #[tokio::test] + async fn dispatched_collect_preserves_transport_failures() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_error(); + let services = build_services_with_http_client(stub); + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + let config = AuctionConfig { + enabled: true, + providers: vec!["transport-failing".to_string(), "ok".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "transport-failing", + backend: "transport-backend", + })); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "ok", + backend: "ok-backend", + })); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + let dispatched = orchestrator + .dispatch_auction(&request, &context) + .await + .expect("should dispatch providers"); + + let result = orchestrator + .collect_dispatched_auction(dispatched, services, &context) + .await; + + let transport = result + .provider_responses + .iter() + .find(|response| response.provider == "transport-failing") + .expect("should retain transport failure from collect"); + assert_eq!( + transport + .metadata + .get("error_type") + .and_then(serde_json::Value::as_str), + Some("transport"), + "should tag transport failures for telemetry mapping" + ); + } + #[test] fn test_apply_floor_prices_drops_bids_with_undecoded_price() { // Bids that reach apply_floor_prices with `price=None` cannot have a diff --git a/crates/trusted-server-core/src/auction/telemetry/context.rs b/crates/trusted-server-core/src/auction/telemetry/context.rs index ab6e41c00..601a5827e 100644 --- a/crates/trusted-server-core/src/auction/telemetry/context.rs +++ b/crates/trusted-server-core/src/auction/telemetry/context.rs @@ -3,6 +3,8 @@ //! This is the only telemetry code that mints the telemetry id and normalizes //! the page path. It performs no I/O. +use std::borrow::Cow; + use uuid::Uuid; use crate::auction::telemetry::types::{AuctionObservationContext, AuctionSource}; @@ -40,6 +42,14 @@ pub fn build_observation_context( } } +const MAX_PAGE_PATH_CHARS: usize = 512; +const MAX_PAGE_PATH_INPUT_CHARS: usize = 2048; +const REDACTED_PATH_SEGMENT: &str = "{redacted}"; +const SENSITIVE_PARENT_SEGMENTS: &[&str] = &[ + "account", "accounts", "invite", "invites", "member", "members", "order", "orders", "profile", + "profiles", "reset", "session", "sessions", "user", "users", +]; + /// Reduce a page URL or path to a bounded path with no scheme, host, query, or /// fragment. Empty or path-less inputs normalize to `/`. fn normalize_page_path(page_url: &str) -> String { @@ -56,7 +66,92 @@ fn normalize_page_path(page_url: &str) -> String { None => without_query, }; let path = if path.is_empty() { "/" } else { path }; - path.chars().take(512).collect() + let bounded_path: String = path.chars().take(MAX_PAGE_PATH_INPUT_CHARS).collect(); + let normalized_path = if bounded_path.starts_with('/') { + bounded_path + } else { + format!("/{bounded_path}") + }; + redact_sensitive_path_segments(&normalized_path) + .chars() + .take(MAX_PAGE_PATH_CHARS) + .collect() +} + +fn redact_sensitive_path_segments(path: &str) -> String { + let mut redacted = String::with_capacity(path.len().min(MAX_PAGE_PATH_CHARS)); + let mut previous_segment_is_sensitive_parent = false; + for (index, segment) in path.split('/').enumerate() { + if index > 0 { + redacted.push('/'); + } + if previous_segment_is_sensitive_parent && !segment.is_empty() { + redacted.push_str(REDACTED_PATH_SEGMENT); + } else { + redacted.push_str(&redact_path_segment(segment)); + } + previous_segment_is_sensitive_parent = is_sensitive_parent_segment(segment); + } + if redacted.is_empty() { + "/".to_string() + } else { + redacted + } +} + +fn redact_path_segment(segment: &str) -> Cow<'_, str> { + if should_redact_path_segment(segment) { + Cow::Borrowed(REDACTED_PATH_SEGMENT) + } else { + Cow::Borrowed(segment) + } +} + +fn should_redact_path_segment(segment: &str) -> bool { + let decoded = urlencoding::decode(segment).unwrap_or(Cow::Borrowed(segment)); + let segment = decoded.trim(); + if segment.is_empty() { + return false; + } + segment.contains('@') + || segment.chars().all(|ch| ch.is_ascii_digit()) + || uuid::Uuid::parse_str(segment).is_ok() + || looks_like_hex_token(segment) + || looks_like_high_entropy_token(segment) +} + +fn is_sensitive_parent_segment(segment: &str) -> bool { + let decoded = urlencoding::decode(segment).unwrap_or(Cow::Borrowed(segment)); + let normalized = decoded.trim().to_ascii_lowercase(); + SENSITIVE_PARENT_SEGMENTS.contains(&normalized.as_str()) +} + +fn looks_like_hex_token(segment: &str) -> bool { + segment.len() >= 16 && segment.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn looks_like_high_entropy_token(segment: &str) -> bool { + if segment.len() < 20 { + return false; + } + let mut has_alpha = false; + let mut has_digit = false; + let mut token_chars = 0usize; + let mut separators = 0usize; + for ch in segment.chars() { + if ch.is_ascii_alphabetic() { + has_alpha = true; + token_chars += 1; + } else if ch.is_ascii_digit() { + has_digit = true; + token_chars += 1; + } else if matches!(ch, '-' | '_' | '=' | '.') { + separators += 1; + } else { + return false; + } + } + has_alpha && has_digit && token_chars >= 16 && separators <= 4 } #[cfg(test)] @@ -99,6 +194,44 @@ mod tests { assert_eq!(normalize_page_path(""), "/", "empty input normalizes to /"); } + #[test] + fn redacts_sensitive_dynamic_path_segments() { + assert_eq!( + normalize_page_path("https://example.com/account/12345/orders"), + "/account/{redacted}/orders", + "should redact numeric identifiers" + ); + assert_eq!( + normalize_page_path("/reset/550e8400-e29b-41d4-a716-446655440000"), + "/reset/{redacted}", + "should redact UUID path segments" + ); + assert_eq!( + normalize_page_path("/reset/3xY9AbCDef0123456789Z"), + "/reset/{redacted}", + "should redact high-entropy token path segments" + ); + assert_eq!( + normalize_page_path("/users/user%40example.com/profile"), + "/users/{redacted}/profile", + "should redact percent-encoded email-like path segments" + ); + assert_eq!( + normalize_page_path("/users/john-smith/profile"), + "/users/{redacted}/profile", + "should redact handle-like segments after sensitive route parents" + ); + } + + #[test] + fn preserves_ordinary_slug_path_segments() { + assert_eq!( + normalize_page_path("blog/how-to-build-fast-websites"), + "/blog/how-to-build-fast-websites", + "should keep ordinary route slugs and add a leading slash" + ); + } + #[test] fn builds_context_from_geo_and_consent() { let consent = ConsentContext { diff --git a/crates/trusted-server-core/src/auction/telemetry/mapping.rs b/crates/trusted-server-core/src/auction/telemetry/mapping.rs index 390abf29c..2898862ce 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mapping.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mapping.rs @@ -39,8 +39,7 @@ fn provider_call_outcome(response: &AuctionResponse, role: ProviderRole) -> Prov /// Classify a response into a provider-call status. For `Error`, read the /// orchestrator's `error_type` metadata; an unrecognized or absent value falls -/// back to `TransportError` since the orchestrator only emits the three known -/// error types. +/// back to `TransportError`. fn provider_call_status(response: &AuctionResponse) -> ProviderCallStatus { match response.status { BidStatus::Success => ProviderCallStatus::Success, @@ -54,6 +53,7 @@ fn provider_call_status(response: &AuctionResponse) -> ProviderCallStatus { Some("launch_failed") => ProviderCallStatus::LaunchError, Some("parse_response") => ProviderCallStatus::ParseError, Some("transport") => ProviderCallStatus::TransportError, + Some("timeout") => ProviderCallStatus::Timeout, _ => ProviderCallStatus::TransportError, }, } @@ -199,6 +199,13 @@ mod tests { response("openx", BidStatus::Error, 60, vec![], Some("transport")), response("smaato", BidStatus::Error, 5, vec![], None), response("teads", BidStatus::Pending, 70, vec![], None), + response( + "timeout-bidder", + BidStatus::Error, + 80, + vec![], + Some("timeout"), + ), ], None, ); @@ -207,7 +214,7 @@ mod tests { assert_eq!( calls.len(), - 7, + 8, "should emit one outcome per provider response" ); assert_eq!( @@ -256,6 +263,11 @@ mod tests { ProviderCallStatus::Timeout, "Pending maps to Timeout" ); + assert_eq!( + calls[7].status, + ProviderCallStatus::Timeout, + "timeout error metadata maps to Timeout" + ); } #[test] diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index bc486ded9..05717daf2 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -3,6 +3,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; +/// Default Fastly real-time log endpoint for auction telemetry events. +pub const DEFAULT_AUCTION_TELEMETRY_LOG_ENDPOINT: &str = "ts_auction_events"; + /// Auction orchestration configuration. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuctionConfig { @@ -34,6 +37,10 @@ pub struct AuctionConfig { /// silently dropped. An empty list blocks all context keys. #[serde(default = "default_allowed_context_keys")] pub allowed_context_keys: HashSet, + + /// Fastly real-time log endpoint used for auction telemetry rows. + #[serde(default = "default_telemetry_log_endpoint")] + pub telemetry_log_endpoint: String, } impl Default for AuctionConfig { @@ -45,6 +52,7 @@ impl Default for AuctionConfig { timeout_ms: default_timeout(), creative_store: default_creative_store(), allowed_context_keys: HashSet::new(), + telemetry_log_endpoint: default_telemetry_log_endpoint(), } } } @@ -61,6 +69,10 @@ fn default_allowed_context_keys() -> HashSet { HashSet::new() } +fn default_telemetry_log_endpoint() -> String { + DEFAULT_AUCTION_TELEMETRY_LOG_ENDPOINT.to_string() +} + #[allow(dead_code)] // Methods used in runtime but not in build script impl AuctionConfig { /// Get all provider names. @@ -74,4 +86,43 @@ impl AuctionConfig { pub fn has_mediator(&self) -> bool { self.mediator.is_some() } + + /// Return the configured auction telemetry log endpoint. + #[must_use] + pub fn telemetry_log_endpoint(&self) -> &str { + let endpoint = self.telemetry_log_endpoint.trim(); + if endpoint.is_empty() { + DEFAULT_AUCTION_TELEMETRY_LOG_ENDPOINT + } else { + endpoint + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_telemetry_log_endpoint_matches_existing_endpoint() { + let config = AuctionConfig::default(); + assert_eq!( + config.telemetry_log_endpoint(), + DEFAULT_AUCTION_TELEMETRY_LOG_ENDPOINT, + "should preserve the existing Fastly log endpoint by default" + ); + } + + #[test] + fn blank_telemetry_log_endpoint_falls_back_to_default() { + let config = AuctionConfig { + telemetry_log_endpoint: " ".to_string(), + ..Default::default() + }; + assert_eq!( + config.telemetry_log_endpoint(), + DEFAULT_AUCTION_TELEMETRY_LOG_ENDPOINT, + "should not pass an empty endpoint name to Fastly" + ); + } } diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index d75958812..5b9cdbe8b 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -608,12 +608,13 @@ price_floor = 0.50 #### `[auction]` -| Field | Type | Default | Description | -| ------------ | -------- | ------- | --------------------------------------------------------------- | -| `enabled` | bool | `false` | Enable the auction system | -| `providers` | string[] | `[]` | Ordered list of provider names to call | -| `mediator` | string? | `null` | Provider name to use as mediator (enables `parallel_mediation`) | -| `timeout_ms` | u32 | `2000` | Overall auction timeout in milliseconds | +| Field | Type | Default | Description | +| ------------------------ | -------- | --------------------- | --------------------------------------------------------------- | +| `enabled` | bool | `false` | Enable the auction system | +| `providers` | string[] | `[]` | Ordered list of provider names to call | +| `mediator` | string? | `null` | Provider name to use as mediator (enables `parallel_mediation`) | +| `timeout_ms` | u32 | `2000` | Overall auction timeout in milliseconds | +| `telemetry_log_endpoint` | string | `"ts_auction_events"` | Fastly real-time log endpoint for auction telemetry rows | #### `[integrations.prebid]` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 92474453a..b5abc3ba8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1081,13 +1081,14 @@ Settings for the auction orchestrator that coordinates multiple bid providers. ### `[auction]` -| Field | Type | Default | Description | -| ---------------- | ------------- | ------------------ | ----------------------------------------------------------- | -| `enabled` | Boolean | `false` | Enable the auction orchestrator | -| `providers` | Array[String] | `[]` | Provider names that participate (e.g., `["prebid", "aps"]`) | -| `mediator` | String | Optional | Mediator provider name (runs parallel mediation when set) | -| `timeout_ms` | Integer | `2000` | Auction timeout in milliseconds | -| `creative_store` | String | `"creative_store"` | Deprecated; creatives are now delivered inline | +| Field | Type | Default | Description | +| ------------------------ | ------------- | --------------------- | ----------------------------------------------------------- | +| `enabled` | Boolean | `false` | Enable the auction orchestrator | +| `providers` | Array[String] | `[]` | Provider names that participate (e.g., `["prebid", "aps"]`) | +| `mediator` | String | Optional | Mediator provider name (runs parallel mediation when set) | +| `timeout_ms` | Integer | `2000` | Auction timeout in milliseconds | +| `telemetry_log_endpoint` | String | `"ts_auction_events"` | Fastly real-time log endpoint for auction telemetry rows | +| `creative_store` | String | `"creative_store"` | Deprecated; creatives are now delivered inline | **Example**: @@ -1096,6 +1097,7 @@ Settings for the auction orchestrator that coordinates multiple bid providers. enabled = true providers = ["aps", "prebid"] timeout_ms = 2000 +telemetry_log_endpoint = "ts_auction_events" [integrations.aps] enabled = true @@ -1116,6 +1118,7 @@ TRUSTED_SERVER__AUCTION__PROVIDERS__0=aps TRUSTED_SERVER__AUCTION__PROVIDERS__1=prebid TRUSTED_SERVER__AUCTION__MEDIATOR=adserver_mock TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000 +TRUSTED_SERVER__AUCTION__TELEMETRY_LOG_ENDPOINT=ts_auction_events TRUSTED_SERVER__AUCTION__CREATIVE_STORE=creative_store ``` diff --git a/docs/superpowers/plans/2026-06-22-auction-telemetry-core.md b/docs/superpowers/plans/2026-06-22-auction-telemetry-core.md index 9bf8f8140..ca2bd35a3 100644 --- a/docs/superpowers/plans/2026-06-22-auction-telemetry-core.md +++ b/docs/superpowers/plans/2026-06-22-auction-telemetry-core.md @@ -29,12 +29,14 @@ Copied verbatim from the project conventions; every task implicitly includes the ### Task 1: Module scaffold and serialized enums **Files:** + - Create: `crates/trusted-server-core/src/auction/telemetry/mod.rs` - Create: `crates/trusted-server-core/src/auction/telemetry/types.rs` - Modify: `crates/trusted-server-core/src/auction/mod.rs` (add module declaration + re-exports) - Test: inline `#[cfg(test)]` in `types.rs` **Interfaces:** + - Produces: enums `AuctionSource`, `TerminalStatus`, `ProviderCallStatus`, `ProviderRole`, `EventKind`, each `#[derive(Serialize)]` with the exact wire strings asserted below. - [ ] **Step 1: Write the failing test** @@ -203,11 +205,13 @@ git commit -m "Add auction telemetry module scaffold and serialized enums" ### Task 2: Observation context and outcome inputs **Files:** + - Modify: `crates/trusted-server-core/src/auction/telemetry/types.rs` - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export new types) - Test: inline `#[cfg(test)]` in `types.rs` **Interfaces:** + - Consumes: `AuctionSource` (Task 1). - Produces: `AuctionObservationContext`, `TerminalOutcome`, `ProviderCallOutcome` structs with the public fields listed below. Later tasks construct these directly. @@ -333,11 +337,13 @@ git commit -m "Add observation context and outcome input types for telemetry" ### Task 3: Row struct and NDJSON serialization **Files:** + - Modify: `crates/trusted-server-core/src/auction/telemetry/types.rs` - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export `AuctionEventRow`, `to_ndjson`) - Test: inline `#[cfg(test)]` in `types.rs` **Interfaces:** + - Consumes: all enums + `AuctionObservationContext` (Tasks 1-2). - Produces: - `AuctionEventRow` (all public fields, `#[derive(Serialize)]`). @@ -560,11 +566,13 @@ git commit -m "Add flat telemetry row struct and NDJSON serialization" ### Task 4: Sink abstraction with test implementations **Files:** + - Create: `crates/trusted-server-core/src/auction/telemetry/sink.rs` - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `sink`, re-export) - Test: inline `#[cfg(test)]` in `sink.rs` **Interfaces:** + - Consumes: `AuctionEventRow` (Task 3). - Produces: - `pub trait AuctionEventSink: Send + Sync { fn emit(&self, rows: &[AuctionEventRow]); }` @@ -706,11 +714,13 @@ git commit -m "Add auction event sink trait and test sinks" ### Task 5: Builder for summary and provider-call rows **Files:** + - Create: `crates/trusted-server-core/src/auction/telemetry/builder.rs` - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `builder`, re-export `build_auction_events`) - Test: inline `#[cfg(test)]` in `builder.rs` **Interfaces:** + - Consumes: `AuctionObservationContext`, `TerminalOutcome`, `ProviderCallOutcome`, `AuctionEventRow`, `EventKind` (Tasks 1-3). - Produces: `pub fn build_auction_events(ctx: &AuctionObservationContext, outcome: &TerminalOutcome, provider_calls: &[ProviderCallOutcome], result: Option<&OrchestrationResult>) -> Vec`. This task implements the `result == None` behavior (summary + provider-call rows only); Task 6 adds bid rows when `result` is `Some`. @@ -912,14 +922,17 @@ git commit -m "Add telemetry builder for summary and provider-call rows" ### Task 6: Bid rows with win matching and mediator dedup **Files:** + - Modify: `crates/trusted-server-core/src/auction/telemetry/builder.rs` (replace the `build_bid_rows` stub, add helpers) - Test: inline `#[cfg(test)]` in `builder.rs` **Interfaces:** + - Consumes: `OrchestrationResult` (`provider_responses: Vec`, `mediator_response: Option`, `winning_bids: HashMap`), `Bid`, `AuctionResponse` from `crate::auction::types`. - Produces: a real `build_bid_rows` so that `build_auction_events(.., Some(result))` emits bid rows. Matching rules (from the spec): + - One bid row per returned bid across `provider_responses`. Mediator bids are not re-emitted when matchable to an original provider bid. - A bid is the winner for its slot when it matches `winning_bids[slot_id]` on `(slot_id, bidder, ad_id)`, falling back to `(slot_id, bidder)` when `ad_id` is absent. At most one winning row per slot (first match claims it). - A matched winning row whose own `price` is `None` takes the winner's decoded `price`. @@ -1176,10 +1189,12 @@ git commit -m "Build bid rows with win matching and mediator dedup" ### Task 7: End-to-end builder test over a mixed result **Files:** + - Modify: `crates/trusted-server-core/src/auction/telemetry/builder.rs` (test only) - Test: inline `#[cfg(test)]` in `builder.rs` **Interfaces:** + - Consumes: everything from Tasks 1-6. No production code changes; this task locks the combined behavior with one realistic case and guards against regressions. - [ ] **Step 1: Write the failing test** @@ -1287,6 +1302,7 @@ git commit -m "Add end-to-end telemetry builder test over a mixed result" ## Self-Review **Spec coverage (this plan's slice):** + - Three row grains (summary / provider_call / bid) with one summary per auction: Tasks 3, 5, 6, 7. - Bid rows only for returned bids; no invented seats on no-bid/error: Tasks 5, 6, 7. - Win matching on `(slot_id, bidder, ad_id)` with `(slot_id, bidder)` fallback, one win per slot, decoded-price fill, mediator dedup, unmatched-winner synthesis: Task 6. @@ -1295,6 +1311,7 @@ git commit -m "Add end-to-end telemetry builder test over a mixed result" - Schema column set and wire strings: Tasks 1, 3. **Deferred to later plans (not gaps in this one):** + - Constructing `AuctionObservationContext` from `EcContext`/geo/`DeviceSignals`, telemetry-UUID independence from `AuctionRequest.id`, and page-path normalization: Plan 2/3 wiring (those inputs are not available to this pure layer). - Mapping `BidStatus`/dispatch outcomes to `ProviderCallStatus` and populating `provider_calls`: Plan 2/3. - `media_type` population from request slots: later wiring plan. diff --git a/docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md b/docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md index 1b6639e08..58f2d0799 100644 --- a/docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md +++ b/docs/superpowers/plans/2026-06-22-auction-telemetry-mapping.md @@ -21,6 +21,7 @@ Same as the core telemetry plan; every task implicitly includes these: **Scope boundary (what this plan deliberately does NOT do):** It does not call these functions from any handler, does not touch `run_auction`/`dispatch_auction`/`collect_dispatched_auction`, does not implement the Fastly sink, does not emit access logs, and does not handle SSAT abandonment. Those are later plans. This plan only adds pure, unit-tested core functions. **Verified facts this plan relies on (from the current code):** + - `OrchestrationResult` (`crates/trusted-server-core/src/auction/orchestrator.rs`): `provider_responses: Vec`, `mediator_response: Option`, `winning_bids: HashMap`, `total_time_ms: u64`, `metadata`. - `AuctionResponse` (`auction/types.rs`): `provider: String`, `bids: Vec`, `status: BidStatus`, `response_time_ms: u64`, `metadata: HashMap`. - On an `Error` response the orchestrator writes `metadata["error_type"]` to one of `"launch_failed"`, `"parse_response"`, `"transport"`. @@ -31,11 +32,13 @@ Same as the core telemetry plan; every task implicitly includes these: ### Task 1: Map a completed result to provider-call outcomes **Files:** + - Create: `crates/trusted-server-core/src/auction/telemetry/mapping.rs` - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `mapping`, re-export `provider_calls_from_result`) - Test: inline `#[cfg(test)]` in `mapping.rs` **Interfaces:** + - Consumes: `OrchestrationResult` (orchestrator), `AuctionResponse`, `BidStatus`, `Bid` (auction/types), and `ProviderCallOutcome`, `ProviderCallStatus`, `ProviderRole` (telemetry::types). - Produces: `pub fn provider_calls_from_result(result: &OrchestrationResult) -> Vec` — one outcome per `provider_responses` entry (role `Bidder`) plus one for `mediator_response` when present (role `Mediator`). Status mapping: `Success -> Success`, `NoBid -> NoBid`, `Pending -> Timeout`, `Error -> {launch_failed: LaunchError, parse_response: ParseError, transport: TransportError, else: TransportError}`. @@ -276,11 +279,13 @@ git commit -m "Map orchestration result to provider-call telemetry outcomes" ### Task 2: Build the full completed-auction row set from a result **Files:** + - Modify: `crates/trusted-server-core/src/auction/telemetry/mapping.rs` (add two functions + tests) - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export the two new functions) - Test: inline `#[cfg(test)]` in `mapping.rs` **Interfaces:** + - Consumes: `provider_calls_from_result` (Task 1), `build_auction_events`, `AuctionObservationContext`, `AuctionEventRow`, `TerminalOutcome`, `TerminalStatus` (telemetry), `OrchestrationResult`. - Produces: - `pub fn completed_outcome(result: &OrchestrationResult, slot_count: u16) -> TerminalOutcome` — `status = Completed`, `reason = None`, `slot_count = Some(slot_count)`, `total_time_ms = Some(result.total_time_ms clamped)`, `winning_bid_count = Some(result.winning_bids.len() clamped)`. diff --git a/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md b/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md index a63602052..5fdf6fce7 100644 --- a/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md +++ b/docs/superpowers/plans/2026-06-22-auction-telemetry-wiring.md @@ -22,6 +22,7 @@ Copied from the project conventions and the prior telemetry plans; every task im **Scope boundary (deliberately NOT in this plan):** `handle_page_bids` wiring, the SSAT `dispatch_auction`/`collect_dispatched_auction` path and its abandoned/skipped/dispatch-failed/execution-failed outcomes, real device-signal population (`is_mobile`/`is_known_browser` are passed as `2` = unknown here), access logs, and the Tinybird/relay/Grafana side. Those are later plans. This plan covers only the completed `POST /auction` path. **Verified facts this plan relies on (current code):** + - `handle_auction(settings, orchestrator, kv, registry, ec_context, services, req)` lives at `crates/trusted-server-core/src/auction/endpoints.rs`; after `run_auction` the `result: OrchestrationResult`, `auction_request: AuctionRequest`, and `services: &RuntimeServices` are all in scope (endpoints.rs:259-274). `geo` and `consent_context` locals are moved into `convert_tsjs_to_auction_request` earlier, so telemetry reads geo/consent back off `auction_request.device`/`auction_request.user.consent`. - `AuctionRequest`: `publisher: PublisherInfo { domain: String, page_url: Option }`, `slots: Vec`, `device: Option, .. }>`, `user: UserInfo { consent: Option, .. }` (auction/types.rs). - `GeoInfo { country: String, region: Option, .. }` (`crate::platform::GeoInfo`). `ConsentContext { gdpr_applies: bool, .. }` with `fn is_empty(&self) -> bool` and `Default` (`crate::consent::ConsentContext`). @@ -34,11 +35,13 @@ Copied from the project conventions and the prior telemetry plans; every task im ### Task 1: Observation-context builder **Files:** + - Create: `crates/trusted-server-core/src/auction/telemetry/context.rs` - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `context`, re-export `build_observation_context`) - Test: inline `#[cfg(test)]` in `context.rs` **Interfaces:** + - Consumes: `AuctionObservationContext`, `AuctionSource` (telemetry::types); `crate::platform::GeoInfo`; `crate::consent::ConsentContext`. - Produces: - `pub fn build_observation_context(source: AuctionSource, publisher_domain: &str, page_url: Option<&str>, geo: Option<&GeoInfo>, consent: Option<&ConsentContext>, is_mobile: u8, is_known_browser: u8) -> AuctionObservationContext` — mints a fresh `Uuid::new_v4()`, normalizes `page_url` to a path, derives country/region from geo, and `gdpr_applies`/`consent_present` from consent (both `false` when `consent` is `None`). @@ -224,10 +227,12 @@ git commit -m "Add auction observation context builder" ### Task 2: Auction event sink on RuntimeServices **Files:** + - Modify: `crates/trusted-server-core/src/platform/types.rs` (struct field, builder, accessor, `with_` method, imports) - Test: inline `#[cfg(test)]` in `platform/types.rs` **Interfaces:** + - Consumes: `AuctionEventSink`, `NoopSink` (telemetry). - Produces, on `RuntimeServices`: - `pub fn auction_event_sink(&self) -> &dyn AuctionEventSink` @@ -376,10 +381,12 @@ git commit -m "Add auction event sink to runtime services with no-op default" ### Task 3: Emit telemetry from handle_auction **Files:** + - Modify: `crates/trusted-server-core/src/auction/endpoints.rs` (add `use`, insert emission after `run_auction`, add test) - Test: inline `#[cfg(test)]` in `endpoints.rs` **Interfaces:** + - Consumes: `build_observation_context`, `build_completed_auction_events`, `AuctionSource` (telemetry); `RuntimeServices::auction_event_sink` (Task 2). - Produces: no new public API; `POST /auction` now emits to `services.auction_event_sink()`. @@ -575,6 +582,7 @@ git commit -m "Emit completed-auction telemetry from the auction endpoint" ### Task 4: Fastly auction event sink **Files:** + - Modify: `crates/trusted-server-core/src/auction/telemetry/types.rs` (add `to_json_line_with_event_ts` + test) - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (re-export it) - Create: `crates/trusted-server-adapter-fastly/src/auction_sink.rs` @@ -583,6 +591,7 @@ git commit -m "Emit completed-auction telemetry from the auction endpoint" - Test: inline `#[cfg(test)]` in `types.rs` **Interfaces:** + - Consumes: `AuctionEventRow`, `AuctionEventSink` (telemetry). - Produces: - core: `pub fn to_json_line_with_event_ts(row: &AuctionEventRow, event_ts: &str) -> Result` — the row as a single JSON object with an injected `event_ts` field. diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md index d9a10829d..af1efcc3d 100644 --- a/docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-device-signals.md @@ -17,6 +17,7 @@ - Run `cargo fmt --all` before committing. Commit only when the focused test, `cargo fmt --all -- --check`, and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` are all green. **Verified facts (current code):** + - `crate::ec::device::DeviceSignals::derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> DeviceSignals`; fields `is_mobile: u8` (0=desktop, 1=mobile, 2=unknown via `parse_is_mobile`: iPhone/iPad/Android→1, Macintosh/Windows/Linux→0, else→2) and `known_browser: Option` (`None` when JA4 or H2 is absent). - `RuntimeServices::client_info() -> &ClientInfo`; `ClientInfo { tls_ja4: Option, h2_fingerprint: Option, .. }`. - `emit_completed_auction_telemetry(services, source, request, result)` lives in `crates/trusted-server-core/src/auction/telemetry/emit.rs` and currently passes `2, 2` as the device-signal args to `build_observation_context`. The request's UA is at `request.device.as_ref().and_then(|d| d.user_agent.as_deref())`. @@ -27,10 +28,12 @@ ### Task 1: Derive device signals in the emission helper **Files:** + - Modify: `crates/trusted-server-core/src/auction/telemetry/emit.rs` (import, replace the `2, 2` args, add a test) - Test: inline `#[cfg(test)]` in `emit.rs` **Interfaces:** + - Consumes: `DeviceSignals::derive`, `ClientInfo` (via `services.client_info()`). - Produces: no signature change to `emit_completed_auction_telemetry`; the emitted rows now carry derived `is_mobile`/`is_known_browser`. diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md index 136ff0ea1..75809ba6e 100644 --- a/docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-page-bids.md @@ -20,6 +20,7 @@ **Scope boundary (NOT in this plan):** the SSAT dispatch/collect path and its abandoned/skipped outcomes, real device signals (`is_mobile`/`is_known_browser` stay `2`), access logs. **Verified facts (current code):** + - `handle_page_bids(settings, services: &RuntimeServices, kv, auction: AuctionDispatch<'_>, ec_context, req)` (publisher.rs:1733). Its `Ok(result)` branch is `Ok(result) => result.winning_bids` (publisher.rs:1878). `auction_request`, `services`, `geo`, `consent_context` are all alive there; `build_auction_request` sets `user.consent = Some(consent_context.clone())` and geo is set on `auction_request.device.geo`, so reading geo/consent off `auction_request` is correct. - `handle_auction` (endpoints.rs) currently has an inline emission block added in the previous plan, using `build_observation_context` + `build_completed_auction_events` + `services.auction_event_sink().emit(..)`, and imports `use crate::auction::telemetry::{build_completed_auction_events, build_observation_context, AuctionSource};`. - `AuctionDispatch<'a> { orchestrator, slots, registry }` (publisher.rs:1016). `AuctionOrchestrator` rejects empty providers and all-launch-failed auctions; a completing auction needs a provider that launches via `services.http_client().send_async` and parses a no-bid success (the `StubHttpClient` harness). @@ -31,11 +32,13 @@ ### Task 1: Shared emission helper **Files:** + - Create: `crates/trusted-server-core/src/auction/telemetry/emit.rs` - Modify: `crates/trusted-server-core/src/auction/telemetry/mod.rs` (declare `emit`, re-export the helper) - Test: inline `#[cfg(test)]` in `emit.rs` **Interfaces:** + - Consumes: `build_observation_context`, `build_completed_auction_events`, `AuctionSource` (telemetry); `AuctionRequest` (auction::types); `OrchestrationResult` (orchestrator); `RuntimeServices` (platform). - Produces: `pub fn emit_completed_auction_telemetry(services: &RuntimeServices, source: AuctionSource, request: &AuctionRequest, result: &OrchestrationResult)` — builds rows for a completed auction and emits them; reads geo/consent off `request`; device signals unknown (`2`). @@ -180,10 +183,12 @@ git commit -m "Add shared completed-auction telemetry emission helper" ### Task 2: Refactor handle_auction onto the helper **Files:** + - Modify: `crates/trusted-server-core/src/auction/endpoints.rs` (replace the inline emission block + its import) - Test: the existing `auction_endpoint_emits_completed_telemetry` is the regression gate (no new test). **Interfaces:** + - Consumes: `emit_completed_auction_telemetry`, `AuctionSource` (Task 1 / telemetry). - Produces: no behavior change; `handle_auction` now emits via the shared helper. @@ -239,10 +244,12 @@ git commit -m "Refactor auction endpoint emission onto the shared helper" ### Task 3: Emit telemetry from handle_page_bids **Files:** + - Modify: `crates/trusted-server-core/src/publisher.rs` (import, emit in the `Ok` branch, add a test provider + test) - Test: inline `#[cfg(test)]` in `publisher.rs` **Interfaces:** + - Consumes: `emit_completed_auction_telemetry`, `AuctionSource` (telemetry). - Produces: `GET /__ts/page-bids` emits a `spa_navigation` row set on a completed auction. diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md index 47f89e06c..1b9e4c1fd 100644 --- a/docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-ssat-completed.md @@ -20,6 +20,7 @@ **Scope boundary (NOT in this plan):** SSAT non-completed outcomes (abandoned dispatched tokens at the pass-through / buffered-unmodified branches, skipped, dispatch-failed), and access logs. Those are follow-ups. **Verified facts (current code):** + - `DispatchedAuction` (orchestrator.rs:22) has a private `request: AuctionRequest` field and a `#[cfg(test)] impl` with `empty_for_test(request, timeout_ms)`. There is no non-test getter yet. - `collect_dispatched_auction(&self, dispatched: DispatchedAuction, services, context) -> OrchestrationResult` (orchestrator.rs:854) consumes `dispatched` by value. - Collect site A, `collect_stream_auction(dispatched: DispatchedAuction, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, settings: &Settings)` (publisher.rs:954) — used by the HTML close-body hold loop. It calls `collect_dispatched_auction` then `write_bids_to_state`. @@ -33,11 +34,13 @@ ### Task 1: Emit completed telemetry from the SSAT collect sites **Files:** + - Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` (add a `request()` getter to `DispatchedAuction`) - Modify: `crates/trusted-server-core/src/publisher.rs` (emit at both collect sites; add a test) - Test: inline `#[cfg(test)]` in `publisher.rs` **Interfaces:** + - Consumes: `emit_completed_auction_telemetry`, `AuctionSource` (already imported in publisher.rs). - Produces: `DispatchedAuction::request(&self) -> &AuctionRequest` (pub(crate)); both SSAT collect sites emit `initial_navigation` rows. diff --git a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md index fc54e77f5..f2f6b6cee 100644 --- a/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -53,7 +53,7 @@ edge (all auction paths) -> explicit API: POST /auction -> run_auction -> build one summary row plus provider-call and bid rows -> sink: buffered non-blocking writes, host flushes async - -> Fastly real-time log endpoint "ts_auction_events" (NDJSON, batched delivery) + -> configured Fastly real-time log endpoint (default "ts_auction_events", NDJSON, batched delivery) -> customer-controlled HTTPS relay "" -> hosted Tinybird Events API POST https:///v0/events?name=auction_events_raw -> landing datasource (append-only, 30-day TTL) @@ -138,7 +138,7 @@ because other regions use different hosts. Implications for this design: - **Region-specific host.** Ingestion is `POST - https:///v0/events?name=...`; published pipe endpoints use the +https:///v0/events?name=...`; published pipe endpoints use the same regional API base. The token and host must belong to the same region. - **Fastly delivery relay.** Fastly's generic HTTPS logging endpoint validates control of the destination hostname through @@ -243,7 +243,8 @@ result so telemetry does not silently undercount errors. - **Sink (core abstraction, Fastly implementation).** Core defines a small `AuctionEventSink` trait or a method on the existing runtime services object. The Fastly adapter writes each serialized row to - `fastly::log::Endpoint::from_name("ts_auction_events")` with `writeln!`. + `fastly::log::Endpoint::from_name(settings.auction.telemetry_log_endpoint)` + with `writeln!`. Output is NDJSON with no fern `timestamp LEVEL [module]` prefix. Tests use a no-op or in-memory sink so native builds stay clean. @@ -349,7 +350,7 @@ which APS adapter is wired, not on this design: per-seat CPM excludes APS, though the mediated **winner** can still carry a decoded CPM via `mediator_response`/`winning_bids`. - Amazon's newer OpenRTB Prebid Server adapter (`POST - https://web.ads.aps.amazon-adsystem.com/e/pb/bid`) returns a standard decoded +https://web.ads.aps.amazon-adsystem.com/e/pb/bid`) returns a standard decoded `seatbid[].bid[].price`. When TS uses that path, APS `price` populates like any other bidder and `price_cpm` fills for APS seats with **no schema or pipe change**, because both already treat price as nullable. @@ -371,13 +372,13 @@ not read as total-market CPM. endpoints read them with the corresponding `*Merge` combinators. - **Materialized view** `auction_overview_mv`: filters `summary` event rows. It aggregates per `(minute, publisher_domain, auction_source, terminal_status, - terminal_reason)`. Because there is exactly one summary row per auction, +terminal_reason)`. Because there is exactly one summary row per auction, auctions, requested slots, winning bids, completion/abandonment rates, and `quantilesState` of `total_time_ms` are not multiplied by the number of provider or bid rows. - **Materialized view** `auction_provider_stats_mv`: filters `provider_call` event rows. It aggregates per `(minute, publisher_domain, auction_source, - provider, provider_role)` requests, successes, nobids, errors, timeouts, +provider, provider_role)` requests, successes, nobids, errors, timeouts, abandonments, parsed bids, and `quantilesState` of `provider_response_time_ms`. - **Materialized view** `auction_bid_stats_mv`: filters `bid` event rows. It @@ -525,8 +526,9 @@ or reconciled revenue. logging challenge and forwards batched NDJSON to the regional Tinybird Events API. This prerequisite can be removed only after a direct hosted integration is validated. -- Two Fastly real-time log endpoints on the service: `ts_auction_events` and - `ts_access_logs`, each configured with placement `none`, the appropriate relay +- Two Fastly real-time log endpoints on the service: the configured auction + telemetry endpoint (default `ts_auction_events`) and `ts_access_logs`, each + configured with placement `none`, the appropriate relay URL, `Content-Type: application/json`, the relay authentication header, newline-delimited JSON, and blank/raw line framing. - Grafana with the Infinity datasource configured for the published endpoint diff --git a/trusted-server.toml b/trusted-server.toml index 5514596f3..bc1dfd731 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -322,6 +322,7 @@ enabled = true providers = ["prebid"] # mediator = "adserver_mock" timeout_ms = 2000 # override per-publisher via TRUSTED_SERVER__AUCTION__TIMEOUT_MS +telemetry_log_endpoint = "ts_auction_events" # Fastly real-time log endpoint for auction telemetry # Context keys the JS client is allowed to forward into auction requests. # Keys not in this list are silently dropped. An empty list blocks all keys. allowed_context_keys = ["permutive_segments"] From 0ddcf7a7c33bde979d7a9b8d3b351c04dc71c28c Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 23 Jun 2026 13:40:59 -0500 Subject: [PATCH 37/37] refactor auction telemetry cleanup --- .../src/auction/orchestrator.rs | 434 +++++++++--------- .../src/auction/telemetry/builder.rs | 6 +- .../src/auction/telemetry/context.rs | 8 +- .../src/auction/telemetry/mapping.rs | 27 +- .../trusted-server-core/src/auction/types.rs | 104 +++++ crates/trusted-server-core/src/publisher.rs | 3 +- 6 files changed, 340 insertions(+), 242 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 5ec8f1bf1..dd96aa0e9 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -6,11 +6,13 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::error::TrustedServerError; -use crate::platform::{PlatformPendingRequest, RuntimeServices}; +use crate::platform::PlatformPendingRequest; use super::config::AuctionConfig; use super::provider::AuctionProvider; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +use super::types::{ + AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, ProviderErrorType, +}; /// In-flight auction requests dispatched to SSP backends. /// @@ -54,11 +56,6 @@ impl DispatchedAuction { const PROVIDER_ERROR_MESSAGE_CHARS: usize = 500; -const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; -const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed"; -const ERROR_TYPE_TRANSPORT: &str = "transport"; -const ERROR_TYPE_TIMEOUT: &str = "timeout"; - // SECURITY: the returned string is included verbatim (truncated to // PROVIDER_ERROR_MESSAGE_CHARS) in the public /auction response via // ProviderSummary.metadata["message"]. Providers MUST NOT interpolate @@ -77,17 +74,17 @@ fn provider_error_message(error: &Report) -> String { fn provider_error_response( provider_name: &str, response_time_ms: u64, - error_type: &str, + error_type: ProviderErrorType, error: &Report, ) -> AuctionResponse { AuctionResponse::error(provider_name, response_time_ms) - .with_metadata("error_type", serde_json::json!(error_type)) + .with_error_type(error_type) .with_metadata("message", serde_json::json!(provider_error_message(error))) } fn provider_launch_failed_response(provider_name: &str, response_time_ms: u64) -> AuctionResponse { AuctionResponse::error(provider_name, response_time_ms) - .with_metadata("error_type", serde_json::json!(ERROR_TYPE_LAUNCH_FAILED)) + .with_error_type(ProviderErrorType::LaunchFailed) .with_metadata("message", serde_json::json!("Provider launch failed")) } @@ -99,32 +96,35 @@ fn provider_transport_failed_response( response_time_ms: u64, ) -> AuctionResponse { AuctionResponse::error(provider_name, response_time_ms) - .with_metadata("error_type", serde_json::json!(ERROR_TYPE_TRANSPORT)) + .with_error_type(ProviderErrorType::Transport) .with_metadata("message", serde_json::json!("Provider request failed")) } fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> AuctionResponse { AuctionResponse::error(provider_name, response_time_ms) - .with_metadata("error_type", serde_json::json!(ERROR_TYPE_TIMEOUT)) + .with_error_type(ProviderErrorType::Timeout) .with_metadata("message", serde_json::json!("Provider request timed out")) } -fn append_transport_failures_from_dispatched( +fn append_drained_provider_responses( responses: &mut Vec, - backend_to_provider: &mut HashMap)>, -) { + backend_to_provider: &mut HashMap, + make_response: fn(&str, u64) -> AuctionResponse, +) where + N: AsRef, +{ let mut failures: Vec<(String, u64)> = backend_to_provider .drain() .map(|(_, (provider_name, start_time, _))| { - (provider_name, start_time.elapsed().as_millis() as u64) + ( + provider_name.as_ref().to_string(), + start_time.elapsed().as_millis() as u64, + ) }) .collect(); failures.sort_by(|left, right| left.0.cmp(&right.0)); for (provider_name, response_time_ms) in failures { - responses.push(provider_transport_failed_response( - &provider_name, - response_time_ms, - )); + responses.push(make_response(&provider_name, response_time_ms)); } } @@ -538,19 +538,11 @@ impl AuctionOrchestrator { Ok(r) => r, Err(e) => { log::warn!("select() failed: {:?}", e); - let mut failures: Vec<(&str, u64)> = backend_to_provider - .drain() - .map(|(_, (provider_name, start_time, _))| { - (provider_name, start_time.elapsed().as_millis() as u64) - }) - .collect(); - failures.sort_by(|left, right| left.0.cmp(right.0)); - for (provider_name, response_time_ms) in failures { - responses.push(provider_transport_failed_response( - provider_name, - response_time_ms, - )); - } + append_drained_provider_responses( + &mut responses, + &mut backend_to_provider, + provider_transport_failed_response, + ); break; } }; @@ -597,7 +589,7 @@ impl AuctionOrchestrator { responses.push(provider_error_response( provider_name, response_time_ms, - ERROR_TYPE_PARSE_RESPONSE, + ProviderErrorType::ParseResponse, &e, )); } @@ -644,16 +636,11 @@ impl AuctionOrchestrator { "Auction timeout reached; dropping {} remaining request(s)", remaining.len() ); - let mut timeouts: Vec<(&str, u64)> = backend_to_provider - .drain() - .map(|(_, (provider_name, start_time, _))| { - (provider_name, start_time.elapsed().as_millis() as u64) - }) - .collect(); - timeouts.sort_by(|left, right| left.0.cmp(right.0)); - for (provider_name, response_time_ms) in timeouts { - responses.push(provider_timeout_response(provider_name, response_time_ms)); - } + append_drained_provider_responses( + &mut responses, + &mut backend_to_provider, + provider_timeout_response, + ); break; } } @@ -663,19 +650,11 @@ impl AuctionOrchestrator { "{} provider request(s) were not accounted for after select loop; marking as transport failures", backend_to_provider.len() ); - let mut failures: Vec<(&str, u64)> = backend_to_provider - .drain() - .map(|(_, (provider_name, start_time, _))| { - (provider_name, start_time.elapsed().as_millis() as u64) - }) - .collect(); - failures.sort_by(|left, right| left.0.cmp(right.0)); - for (provider_name, response_time_ms) in failures { - responses.push(provider_transport_failed_response( - provider_name, - response_time_ms, - )); - } + append_drained_provider_responses( + &mut responses, + &mut backend_to_provider, + provider_transport_failed_response, + ); } Ok(responses) @@ -943,7 +922,6 @@ impl AuctionOrchestrator { pub async fn collect_dispatched_auction( &self, dispatched: DispatchedAuction, - services: &RuntimeServices, context: &AuctionContext<'_>, ) -> OrchestrationResult { let DispatchedAuction { @@ -967,7 +945,8 @@ impl AuctionOrchestrator { let mut remaining = pending_requests; while !remaining.is_empty() { - let select_result = match services + let select_result = match context + .services .http_client() .select(remaining) .await @@ -977,9 +956,10 @@ impl AuctionOrchestrator { Ok(r) => r, Err(e) => { log::warn!("select() failed during auction collection: {:?}", e); - append_transport_failures_from_dispatched( + append_drained_provider_responses( &mut responses, &mut backend_to_provider, + provider_transport_failed_response, ); break; } @@ -1017,7 +997,7 @@ impl AuctionOrchestrator { responses.push(provider_error_response( &provider_name, response_time_ms, - ERROR_TYPE_PARSE_RESPONSE, + ProviderErrorType::ParseResponse, &e, )); } @@ -1070,179 +1050,189 @@ impl AuctionOrchestrator { "{} dispatched provider request(s) were not accounted for after collection; marking as transport failures", backend_to_provider.len() ); - append_transport_failures_from_dispatched(&mut responses, &mut backend_to_provider); + append_drained_provider_responses( + &mut responses, + &mut backend_to_provider, + provider_transport_failed_response, + ); } - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - match self.providers.get(mediator_name.as_str()) { - Some(mediator) => { - // Cap the mediator at whichever is tighter: its own configured - // timeout or the remaining auction budget (A_deadline). The old - // comment here claimed origin drain could exhaust the budget before - // collection, but SSP backends are given first_byte_timeout = - // effective_timeout (capped at their provider timeout) at dispatch - // time, so they cannot run past A_deadline independently. Giving - // the mediator an uncapped timeout lets it run past A_deadline, - // violating the bounded hold invariant. - let remaining = remaining_budget_ms(auction_start, timeout_ms); - if remaining == 0 { - log::warn!( - "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", - mediator.provider_name(), - responses.len(), - ); - let winning = self.select_winning_bids(&responses, &floor_prices); - let total_time_ms = auction_start.elapsed().as_millis() as u64; - return OrchestrationResult { - provider_responses: responses, - mediator_response: Some(provider_timeout_response( - mediator.provider_name(), - total_time_ms, - )), - winning_bids: winning, - total_time_ms, - metadata: HashMap::new(), - }; - } - let mediator_timeout = remaining.min(mediator.timeout_ms()); - let mediator_start = Instant::now(); - log::info!( - "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", - mediator.provider_name(), - mediator_timeout, - remaining, - mediator.timeout_ms(), - ); - // The mediator runs on the collect path. See the doc-comment on - // `AuctionContext::request`: the real client request was already - // consumed by `send_async` during dispatch, so we substitute a - // canonical placeholder URL. Any future mediator that needs real - // client headers must snapshot them at dispatch time onto - // `DispatchedAuction` rather than reading `context.request` here. - let placeholder = http::Request::builder() - .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) - .body(edgezero_core::body::Body::empty()) - .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); - let mediator_context = AuctionContext { - settings: context.settings, - request: &placeholder, - timeout_ms: mediator_timeout, - provider_responses: Some(&responses), - services: context.services, - }; - match mediator.request_bids(&request, &mediator_context).await { - Ok(pending) => { - let platform_resp = services.http_client().wait(pending).await; - match platform_resp.change_context(TrustedServerError::Auction { - message: format!( - "Mediator {} request failed", - mediator.provider_name() - ), - }) { - Ok(platform_resp) => { - let response_time_ms = - mediator_start.elapsed().as_millis() as u64; - // Mirror run_parallel_mediation: use the - // context-aware parse so the mediator sees - // the collected provider responses. - match mediator - .parse_response_with_context( - platform_resp, - response_time_ms, - &mediator_context, - ) - .await - { - Ok(mediator_resp) => { - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); - let winning = - self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_resp), winning) - } - Err(e) => { - log::warn!( - "Mediator '{}' parse failed: {:?}", - mediator.provider_name(), - e - ); - let response_time_ms = - mediator_start.elapsed().as_millis() as u64; - let mediator_response = provider_error_response( - mediator.provider_name(), - response_time_ms, - ERROR_TYPE_PARSE_RESPONSE, - &e, - ); - let winning = - self.select_winning_bids(&responses, &floor_prices); - (Some(mediator_response), winning) - } - } - } - Err(e) => { - let response_time_ms = - mediator_start.elapsed().as_millis() as u64; - log::warn!("Mediator request failed: {:?}", e); - ( - Some(provider_transport_failed_response( + let (mediator_response, winning_bids) = self + .run_dispatched_mediator( + &responses, + &floor_prices, + &request, + auction_start, + timeout_ms, + context, + ) + .await; + + OrchestrationResult { + provider_responses: responses, + mediator_response, + winning_bids, + total_time_ms: auction_start.elapsed().as_millis() as u64, + metadata: HashMap::new(), + } + } + + async fn run_dispatched_mediator( + &self, + responses: &[AuctionResponse], + floor_prices: &HashMap, + request: &AuctionRequest, + auction_start: Instant, + timeout_ms: u32, + context: &AuctionContext<'_>, + ) -> (Option, HashMap) { + let Some(mediator_name) = self.config.mediator.as_deref() else { + return (None, self.select_winning_bids(responses, floor_prices)); + }; + + let Some(mediator) = self.providers.get(mediator_name) else { + log::warn!("Mediator '{}' not registered", mediator_name); + return ( + Some(provider_launch_failed_response(mediator_name, 0)), + self.select_winning_bids(responses, floor_prices), + ); + }; + + // Cap the mediator at whichever is tighter: its own configured timeout + // or the remaining auction budget (A_deadline). SSP backends are given + // first_byte_timeout = effective_timeout at dispatch time, so they + // cannot run past A_deadline independently; the mediator must also be + // capped to preserve the bounded hold invariant. + let remaining = remaining_budget_ms(auction_start, timeout_ms); + if remaining == 0 { + log::warn!( + "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", + mediator.provider_name(), + responses.len(), + ); + return ( + Some(provider_timeout_response( + mediator.provider_name(), + auction_start.elapsed().as_millis() as u64, + )), + self.select_winning_bids(responses, floor_prices), + ); + } + + let mediator_timeout = remaining.min(mediator.timeout_ms()); + let mediator_start = Instant::now(); + log::info!( + "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", + mediator.provider_name(), + mediator_timeout, + remaining, + mediator.timeout_ms(), + ); + + // The mediator runs on the collect path. See the doc-comment on + // `AuctionContext::request`: the real client request was already + // consumed by `send_async` during dispatch, so we substitute a canonical + // placeholder URL. Any future mediator that needs real client headers + // must snapshot them at dispatch time onto `DispatchedAuction` rather + // than reading `context.request` here. + let placeholder = http::Request::builder() + .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) + .body(edgezero_core::body::Body::empty()) + .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); + let mediator_context = AuctionContext { + settings: context.settings, + request: &placeholder, + timeout_ms: mediator_timeout, + provider_responses: Some(responses), + services: context.services, + }; + + match mediator.request_bids(request, &mediator_context).await { + Ok(pending) => match context + .services + .http_client() + .wait(pending) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} request failed", mediator.provider_name()), + }) { + Ok(platform_resp) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + match mediator + .parse_response_with_context( + platform_resp, + response_time_ms, + &mediator_context, + ) + .await + { + Ok(mediator_response) => { + let winning = mediator_response + .bids + .iter() + .filter_map(|bid| { + if bid.price.is_none() { + log::warn!( + "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", mediator.provider_name(), - response_time_ms, - )), - self.select_winning_bids(&responses, &floor_prices), - ) - } - } + bid.slot_id + ); + None + } else { + Some((bid.slot_id.clone(), bid.clone())) + } + }) + .collect(); + let winning = self.apply_floor_prices(winning, floor_prices); + (Some(mediator_response), winning) } Err(e) => { - let response_time_ms = mediator_start.elapsed().as_millis() as u64; log::warn!( - "Mediator '{}' failed to dispatch: {:?}", + "Mediator '{}' parse failed: {:?}", mediator.provider_name(), e ); + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + let mediator_response = provider_error_response( + mediator.provider_name(), + response_time_ms, + ProviderErrorType::ParseResponse, + &e, + ); ( - Some(provider_launch_failed_response( - mediator.provider_name(), - response_time_ms, - )), - self.select_winning_bids(&responses, &floor_prices), + Some(mediator_response), + self.select_winning_bids(responses, floor_prices), ) } } } - None => { - log::warn!("Mediator '{}' not registered", mediator_name); + Err(e) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + log::warn!("Mediator request failed: {:?}", e); ( - Some(provider_launch_failed_response(mediator_name, 0)), - self.select_winning_bids(&responses, &floor_prices), + Some(provider_transport_failed_response( + mediator.provider_name(), + response_time_ms, + )), + self.select_winning_bids(responses, floor_prices), ) } + }, + Err(e) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + log::warn!( + "Mediator '{}' failed to dispatch: {:?}", + mediator.provider_name(), + e + ); + ( + Some(provider_launch_failed_response( + mediator.provider_name(), + response_time_ms, + )), + self.select_winning_bids(responses, floor_prices), + ) } - } else { - (None, self.select_winning_bids(&responses, &floor_prices)) - }; - - OrchestrationResult { - provider_responses: responses, - mediator_response, - winning_bids, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), } } @@ -1710,8 +1700,12 @@ mod tests { }) .attach("internal/source.rs:12:34"); - let response = - super::provider_error_response("prebid", 37, super::ERROR_TYPE_PARSE_RESPONSE, &error); + let response = super::provider_error_response( + "prebid", + 37, + super::ProviderErrorType::ParseResponse, + &error, + ); assert_eq!( response.status, @@ -2174,7 +2168,7 @@ mod tests { .expect("should dispatch at least one provider"); let result = orchestrator - .collect_dispatched_auction(dispatched, services, &context) + .collect_dispatched_auction(dispatched, &context) .await; let launch = result @@ -2249,7 +2243,7 @@ mod tests { .expect("should dispatch providers"); let result = orchestrator - .collect_dispatched_auction(dispatched, services, &context) + .collect_dispatched_auction(dispatched, &context) .await; let transport = result diff --git a/crates/trusted-server-core/src/auction/telemetry/builder.rs b/crates/trusted-server-core/src/auction/telemetry/builder.rs index 3a2751c8c..422f6cdec 100644 --- a/crates/trusted-server-core/src/auction/telemetry/builder.rs +++ b/crates/trusted-server-core/src/auction/telemetry/builder.rs @@ -1,5 +1,7 @@ //! Pure builder that turns an auction observation into telemetry rows. +use std::collections::HashSet; + use crate::auction::orchestrator::OrchestrationResult; use crate::auction::telemetry::types::{ AuctionEventRow, AuctionObservationContext, EventKind, ProviderCallOutcome, TerminalOutcome, @@ -62,7 +64,7 @@ fn build_bid_rows( let mut rows = Vec::new(); // Slots whose winning row has already been emitted, so each slot has at // most one `is_win = 1` row. - let mut claimed_slots: Vec = Vec::new(); + let mut claimed_slots = HashSet::new(); for response in &result.provider_responses { for bid in &response.bids { @@ -79,7 +81,7 @@ fn build_bid_rows( None }; if is_win { - claimed_slots.push(bid.slot_id.clone()); + claimed_slots.insert(bid.slot_id.clone()); } rows.push(bid_row( ctx, diff --git a/crates/trusted-server-core/src/auction/telemetry/context.rs b/crates/trusted-server-core/src/auction/telemetry/context.rs index 601a5827e..690688472 100644 --- a/crates/trusted-server-core/src/auction/telemetry/context.rs +++ b/crates/trusted-server-core/src/auction/telemetry/context.rs @@ -88,7 +88,7 @@ fn redact_sensitive_path_segments(path: &str) -> String { if previous_segment_is_sensitive_parent && !segment.is_empty() { redacted.push_str(REDACTED_PATH_SEGMENT); } else { - redacted.push_str(&redact_path_segment(segment)); + redacted.push_str(redact_path_segment(segment)); } previous_segment_is_sensitive_parent = is_sensitive_parent_segment(segment); } @@ -99,11 +99,11 @@ fn redact_sensitive_path_segments(path: &str) -> String { } } -fn redact_path_segment(segment: &str) -> Cow<'_, str> { +fn redact_path_segment(segment: &str) -> &str { if should_redact_path_segment(segment) { - Cow::Borrowed(REDACTED_PATH_SEGMENT) + REDACTED_PATH_SEGMENT } else { - Cow::Borrowed(segment) + segment } } diff --git a/crates/trusted-server-core/src/auction/telemetry/mapping.rs b/crates/trusted-server-core/src/auction/telemetry/mapping.rs index 2898862ce..4f14c3947 100644 --- a/crates/trusted-server-core/src/auction/telemetry/mapping.rs +++ b/crates/trusted-server-core/src/auction/telemetry/mapping.rs @@ -9,7 +9,7 @@ use crate::auction::telemetry::types::{ AuctionEventRow, AuctionObservationContext, ProviderCallOutcome, ProviderCallStatus, ProviderRole, TerminalOutcome, TerminalStatus, }; -use crate::auction::types::{AuctionResponse, BidStatus}; +use crate::auction::types::{AuctionResponse, BidStatus, ProviderErrorType}; /// Build one provider-call outcome per provider response, plus one for the /// mediator when a mediator response is present. @@ -38,23 +38,19 @@ fn provider_call_outcome(response: &AuctionResponse, role: ProviderRole) -> Prov } /// Classify a response into a provider-call status. For `Error`, read the -/// orchestrator's `error_type` metadata; an unrecognized or absent value falls -/// back to `TransportError`. +/// orchestrator's provider error type metadata; an unrecognized or absent value +/// falls back to `TransportError`. fn provider_call_status(response: &AuctionResponse) -> ProviderCallStatus { match response.status { BidStatus::Success => ProviderCallStatus::Success, BidStatus::NoBid => ProviderCallStatus::NoBid, BidStatus::Pending => ProviderCallStatus::Timeout, - BidStatus::Error => match response - .metadata - .get("error_type") - .and_then(|value| value.as_str()) - { - Some("launch_failed") => ProviderCallStatus::LaunchError, - Some("parse_response") => ProviderCallStatus::ParseError, - Some("transport") => ProviderCallStatus::TransportError, - Some("timeout") => ProviderCallStatus::Timeout, - _ => ProviderCallStatus::TransportError, + BidStatus::Error => match response.provider_error_type() { + Some(ProviderErrorType::LaunchFailed) => ProviderCallStatus::LaunchError, + Some(ProviderErrorType::ParseResponse) => ProviderCallStatus::ParseError, + Some(ProviderErrorType::Transport) => ProviderCallStatus::TransportError, + Some(ProviderErrorType::Timeout) => ProviderCallStatus::Timeout, + None => ProviderCallStatus::TransportError, }, } } @@ -137,7 +133,10 @@ mod tests { ) -> AuctionResponse { let mut metadata = HashMap::new(); if let Some(kind) = error_type { - metadata.insert("error_type".to_string(), serde_json::json!(kind)); + metadata.insert( + ProviderErrorType::METADATA_KEY.to_string(), + serde_json::json!(kind), + ); } AuctionResponse { provider: provider.to_string(), diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index ed248bc2a..519080a01 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -273,6 +273,48 @@ pub enum BidStatus { Pending, } +/// Machine-readable provider error category carried in +/// `AuctionResponse.metadata["error_type"]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProviderErrorType { + /// Provider request could not be launched. + LaunchFailed, + /// Provider response could not be parsed. + ParseResponse, + /// Provider request failed at the transport layer. + Transport, + /// Provider did not complete before its deadline. + Timeout, +} + +impl ProviderErrorType { + /// Metadata key used on [`AuctionResponse`]. + pub(crate) const METADATA_KEY: &'static str = "error_type"; + + /// Stable metadata wire value. + #[must_use] + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::LaunchFailed => "launch_failed", + Self::ParseResponse => "parse_response", + Self::Transport => "transport", + Self::Timeout => "timeout", + } + } + + /// Parse a metadata wire value. + #[must_use] + pub(crate) fn from_str(value: &str) -> Option { + match value { + "launch_failed" => Some(Self::LaunchFailed), + "parse_response" => Some(Self::ParseResponse), + "transport" => Some(Self::Transport), + "timeout" => Some(Self::Timeout), + _ => None, + } + } +} + impl AuctionResponse { /// Create a new successful auction response. pub fn success(provider: impl Into, bids: Vec, response_time_ms: u64) -> Self { @@ -312,6 +354,23 @@ impl AuctionResponse { self.metadata.insert(key.into(), value); self } + + /// Add the machine-readable provider error category metadata. + pub(crate) fn with_error_type(self, error_type: ProviderErrorType) -> Self { + self.with_metadata( + ProviderErrorType::METADATA_KEY, + serde_json::json!(error_type.as_str()), + ) + } + + /// Read the machine-readable provider error category metadata. + #[must_use] + pub(crate) fn provider_error_type(&self) -> Option { + self.metadata + .get(ProviderErrorType::METADATA_KEY) + .and_then(|value| value.as_str()) + .and_then(ProviderErrorType::from_str) + } } #[cfg(test)] @@ -339,6 +398,51 @@ mod tests { } } + #[test] + fn provider_error_type_round_trips_metadata_wire_values() { + let cases = [ + (ProviderErrorType::LaunchFailed, "launch_failed"), + (ProviderErrorType::ParseResponse, "parse_response"), + (ProviderErrorType::Transport, "transport"), + (ProviderErrorType::Timeout, "timeout"), + ]; + + for (error_type, wire_value) in cases { + assert_eq!( + error_type.as_str(), + wire_value, + "should serialize provider error type to stable metadata value" + ); + assert_eq!( + ProviderErrorType::from_str(wire_value), + Some(error_type), + "should parse provider error type from stable metadata value" + ); + } + assert_eq!( + ProviderErrorType::from_str("unknown"), + None, + "unknown provider error types should not parse" + ); + } + + #[test] + fn auction_response_reads_and_writes_provider_error_type() { + let response = + AuctionResponse::error("prebid", 42).with_error_type(ProviderErrorType::Timeout); + + assert_eq!( + response.metadata[ProviderErrorType::METADATA_KEY], + json!("timeout"), + "should store the wire value in metadata" + ); + assert_eq!( + response.provider_error_type(), + Some(ProviderErrorType::Timeout), + "should read provider error type from metadata" + ); + } + #[test] fn provider_summary_from_successful_response() { let response = AuctionResponse::success( diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e004341e8..bc3999899 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -526,7 +526,6 @@ pub async fn stream_publisher_body_async( let result = orchestrator .collect_dispatched_auction( dispatched, - services, &make_collect_context(settings, services, &placeholder), ) .await; @@ -975,7 +974,7 @@ async fn collect_stream_auction( let collect_ctx = make_collect_context(settings, services, &placeholder); let request = dispatched.request().clone(); let result = orchestrator - .collect_dispatched_auction(dispatched, services, &collect_ctx) + .collect_dispatched_auction(dispatched, &collect_ctx) .await; log::info!( "body_close_hold_loop: collect complete - {} winning bid(s)",