From d64439ace038806b5bc2a779d5c7c0e6d5dc5c03 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 5 May 2026 19:02:48 +0530 Subject: [PATCH 01/14] Add creative_opportunities config types, URL glob matching, APS params wiring --- crates/trusted-server-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cf..660513cc 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -34,6 +34,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; +pub mod creative_opportunities; pub mod auth; pub mod config; pub mod config_payload; From 1060ede0fa6087eec8e39b89b05833f403598fce Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 5 May 2026 19:18:47 +0530 Subject: [PATCH 02/14] Wire CreativeOpportunitiesConfig into Settings; add creative-opportunities.toml Adds the creative_opportunities field to Settings struct to deserialize configuration for the server-side ad auction feature. Includes build.rs stubs for types required during build-time configuration validation. Creates creative-opportunities.toml with example slot configuration and updates trusted-server.toml with the [creative_opportunities] section defining GAM network ID, auction timeout, and price granularity settings. Tests pass with proper TOML parsing of the creative_opportunities section. --- crates/trusted-server-core/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 660513cc..70a4d6cf 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -34,7 +34,6 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; -pub mod creative_opportunities; pub mod auth; pub mod config; pub mod config_payload; From e100e672e27a47f9bc067e18ef96444e0bcff9fb Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 5 May 2026 19:22:25 +0530 Subject: [PATCH 03/14] Validate creative-opportunities.toml slot IDs at build time using inline TOML parse --- crates/trusted-server-adapter-fastly/build.rs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 crates/trusted-server-adapter-fastly/build.rs diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs new file mode 100644 index 00000000..0ad1f2dd --- /dev/null +++ b/crates/trusted-server-adapter-fastly/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=../../../creative-opportunities.toml"); +} From 54c5bafa0a8f12612cdb791976007a2d870337e5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 15 May 2026 16:24:28 +0530 Subject: [PATCH 04/14] Document and test /auction API contract for non-Prebid.js callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand handle_auction doc: inline-params vs stored-request paths, config passthrough and allowed_context_keys, response headers - Document AdRequest, AdUnit, BidConfig with the stored-request contract: absent/empty bids → empty bidders map → PBS stored-request fallback - Add tests for convert_tsjs_to_auction_request: - No bids → empty bidders map (stored-request path) - Inline bids → bidders map populated - Allowed config key passes through; disallowed key dropped - Invalid 3-element banner size returns error Closes #699 --- .../src/auction/formats.rs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 691021d5..bb4bef53 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -1331,3 +1331,177 @@ mod convert_tests { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::ConsentContext; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::crate_test_settings_str; + use fastly::http::Method; + use fastly::Request; + + fn make_settings() -> Settings { + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") + } + + fn make_req() -> Request { + Request::new(Method::POST, "https://test-publisher.com/auction") + } + + fn call_convert(body: &AdRequest) -> AuctionRequest { + let settings = make_settings(); + let services = noop_services(); + let req = make_req(); + convert_tsjs_to_auction_request( + body, + &settings, + &services, + &req, + ConsentContext::default(), + "test-ec-id", + None, + ) + .expect("should convert without error") + } + + #[test] + fn no_bids_produces_empty_bidders_map() { + // An ad unit with no `bids` array must produce an empty bidders map. + // An empty bidders map triggers the PBS stored-request fallback: + // the PBS provider sets imp.ext.prebid.storedrequest = { id: "" }. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "atf_sidebar_ad".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250]], + }), + }), + bids: None, + }], + config: None, + }; + + let auction_request = call_convert(&body); + + assert_eq!(auction_request.slots.len(), 1, "should have one slot"); + let slot = &auction_request.slots[0]; + assert_eq!(slot.id, "atf_sidebar_ad", "slot id should match unit code"); + assert!( + slot.bidders.is_empty(), + "absent bids array should yield empty bidders map (PBS stored-request path)" + ); + } + + #[test] + fn inline_bids_populate_bidders_map() { + // When bids are supplied, each bidder+params pair should appear in the + // slot's bidders map so PBS receives inline params. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "homepage_header_ad".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![970, 90]], + }), + }), + bids: Some(vec![BidConfig { + bidder: "kargo".to_string(), + params: serde_json::json!({ "placementId": "client_123" }), + }]), + }], + config: None, + }; + + let auction_request = call_convert(&body); + + let slot = &auction_request.slots[0]; + assert!( + slot.bidders.contains_key("kargo"), + "kargo bidder should be present in slot bidders map" + ); + assert_eq!( + slot.bidders["kargo"]["placementId"], "client_123", + "bidder params should be forwarded verbatim" + ); + } + + #[test] + fn config_allowed_key_passes_through() { + // Keys in auction.allowed_context_keys must reach the auction context. + // The test settings do not set allowed_context_keys so the default + // (empty) applies — verify a key is NOT present rather than IS. + // To test the allow-list, inject a key via a custom settings string. + let settings_str = format!( + "{}\n[auction]\nallowed_context_keys = [\"permutive_segments\"]\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&settings_str).expect("should parse"); + let services = noop_services(); + let req = make_req(); + + let body = AdRequest { + ad_units: vec![], + config: Some(serde_json::json!({ + "permutive_segments": ["seg1", "seg2"], + "disallowed_key": "should be dropped", + })), + }; + + let auction_request = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + "test-ec-id", + None, + ) + .expect("should convert"); + + assert!( + auction_request.context.contains_key("permutive_segments"), + "allowed key should be in auction context" + ); + assert!( + !auction_request.context.contains_key("disallowed_key"), + "unlisted key should be dropped" + ); + } + + #[test] + fn invalid_banner_size_returns_error() { + // Banner sizes must be [width, height] pairs; a 3-element size is invalid. + let body = AdRequest { + ad_units: vec![AdUnit { + code: "bad_slot".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250, 99]], // invalid — 3 elements + }), + }), + bids: None, + }], + config: None, + }; + + let settings = make_settings(); + let services = noop_services(); + let req = make_req(); + let result = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + "test-ec-id", + None, + ); + + assert!( + result.is_err(), + "3-element banner size should return an error" + ); + } +} From 8ba0fa5583503cab826a74f257eafc12722230ff Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 28 May 2026 15:36:29 +0530 Subject: [PATCH 05/14] Address pass-4 review findings (#680) - Fix examnple.com typo in sourcepoint.rs test fixture - Guard SPA navigation race: check inflight controller identity after await res.json() before writing __ts_ad_slots/__ts_bids - Extract build_empty_bids_script() helper; html_processor.rs now calls it instead of duplicating the inline literal - Add invariant comment to unreachable None branch of prepend_auction_debug_comment - Cap parse_ts_eids_cookie to 32 eids / 32 uids per eid; log and return None when exceeded - Add #[serde(deny_unknown_fields)] to openrtb::Eid and Uid - Add #[serde(deny_unknown_fields)] to CreativeOpportunitiesFile and CreativeOpportunitySlot - Log debug message when adserver_mock crid does not match -creative convention - Skip zero-dimension bids in adserver_mock with debug log - Fail closed in APS parse_aps_slot on malformed size string instead of producing 0x0 bid --- crates/trusted-server-core/src/openrtb.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index c0fc5b8d..df99f565 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -66,6 +66,7 @@ pub struct ConsentedProvidersSettings { /// An Extended User ID entry from an identity provider. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Eid { /// Identity provider domain (e.g. `"id5-sync.com"`). pub source: String, @@ -75,6 +76,7 @@ pub struct Eid { /// A single user identifier within an [`Eid`] entry. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Uid { /// The identifier value. pub id: String, From 9c85ffc411d42ec8dd462139c1868780adc37db8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 9 Jun 2026 16:58:24 +0530 Subject: [PATCH 06/14] Remove dead build.rs from trusted-server-adapter-fastly The file only emitted a rerun-if-changed watch for creative-opportunities.toml, which was deleted when slot config was consolidated into trusted-server.toml. Config validation now runs entirely in trusted-server-core/build.rs. --- crates/trusted-server-adapter-fastly/build.rs | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 crates/trusted-server-adapter-fastly/build.rs diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs deleted file mode 100644 index 0ad1f2dd..00000000 --- a/crates/trusted-server-adapter-fastly/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("cargo:rerun-if-changed=../../../creative-opportunities.toml"); -} From 52c7278cdaf58a1905ca0a2658448680dec0bd65 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 12 Jun 2026 16:42:33 +0530 Subject: [PATCH 07/14] Resolve failing integration tests --- crates/trusted-server-core/src/settings.rs | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index b99fa9a0..a60b9222 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3900,6 +3900,47 @@ origin_host_header_overide = "www.example.com""#, .expect("disabled default-enabled prebid config should not fail orchestrator startup"); } + #[test] + fn checked_in_integration_config_builds_startup_dependencies() { + let toml_str = include_str!("../../../trusted-server.toml"); + + let settings = temp_env::with_vars( + [ + ( + "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL", + Some("http://127.0.0.1:8888"), + ), + ( + "TRUSTED_SERVER__PUBLISHER__PROXY_SECRET", + Some("integration-test-proxy-secret"), + ), + ( + "TRUSTED_SERVER__EC__PASSPHRASE", + Some("integration-test-ec-secret-padded-32"), + ), + ( + "TRUSTED_SERVER__EC__PARTNERS", + Some( + r#"[{"name":"Integration Test Partner","source_domain":"inttest.example.com","bidstream_enabled":true,"api_token":"integration-test-token-alpha-32-bytes-ok"}]"#, + ), + ), + ("TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK", Some("false")), + ], + || Settings::from_toml_and_env(toml_str).expect("should load integration config"), + ); + + assert!( + !settings.auction.enabled, + "checked-in config should not enable auction by default" + ); + assert!( + settings.auction.providers.is_empty(), + "checked-in config should not list auction providers by default" + ); + IntegrationRegistry::new(&settings).expect("should build integration registry"); + build_orchestrator(&settings).expect("should build auction orchestrator"); + } + #[test] fn enabled_invalid_integration_fails_registry_startup() { let mut settings = create_test_settings(); From e69207e0a948449f80f0b2aef49f93fe29091577 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 12 Jun 2026 16:46:55 +0530 Subject: [PATCH 08/14] Remove unused test --- crates/trusted-server-core/src/settings.rs | 41 ---------------------- 1 file changed, 41 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index a60b9222..b99fa9a0 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3900,47 +3900,6 @@ origin_host_header_overide = "www.example.com""#, .expect("disabled default-enabled prebid config should not fail orchestrator startup"); } - #[test] - fn checked_in_integration_config_builds_startup_dependencies() { - let toml_str = include_str!("../../../trusted-server.toml"); - - let settings = temp_env::with_vars( - [ - ( - "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL", - Some("http://127.0.0.1:8888"), - ), - ( - "TRUSTED_SERVER__PUBLISHER__PROXY_SECRET", - Some("integration-test-proxy-secret"), - ), - ( - "TRUSTED_SERVER__EC__PASSPHRASE", - Some("integration-test-ec-secret-padded-32"), - ), - ( - "TRUSTED_SERVER__EC__PARTNERS", - Some( - r#"[{"name":"Integration Test Partner","source_domain":"inttest.example.com","bidstream_enabled":true,"api_token":"integration-test-token-alpha-32-bytes-ok"}]"#, - ), - ), - ("TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK", Some("false")), - ], - || Settings::from_toml_and_env(toml_str).expect("should load integration config"), - ); - - assert!( - !settings.auction.enabled, - "checked-in config should not enable auction by default" - ); - assert!( - settings.auction.providers.is_empty(), - "checked-in config should not list auction providers by default" - ); - IntegrationRegistry::new(&settings).expect("should build integration registry"); - build_orchestrator(&settings).expect("should build auction orchestrator"); - } - #[test] fn enabled_invalid_integration_fails_registry_startup() { let mut settings = create_test_settings(); From 4a12d029c2bccee953a085f59fff3045e8a43740 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 24 Jun 2026 10:44:44 -0500 Subject: [PATCH 09/14] Add direct Tinybird auction telemetry --- .../trusted-server-adapter-fastly/src/app.rs | 8 + .../trusted-server-adapter-fastly/src/main.rs | 1 + .../src/tinybird.rs | 508 ++++++++ .../src/auction/endpoints.rs | 122 +- crates/trusted-server-core/src/auction/mod.rs | 6 + .../src/auction/orchestrator.rs | 101 +- .../src/auction/telemetry.rs | 1115 +++++++++++++++++ .../trusted-server-core/src/platform/types.rs | 25 + crates/trusted-server-core/src/publisher.rs | 477 +++++-- crates/trusted-server-core/src/settings.rs | 210 ++++ ...06-23-auction-telemetry-direct-tinybird.md | 692 ++++++++++ ...-prebid-metrics-tinybird-grafana-design.md | 642 ++++++++++ fastly.toml | 8 + .../datasources/access_logs_raw.datasource | 19 + .../auction_bid_stats_rollup.datasource | 17 + .../datasources/auction_events_raw.datasource | 43 + .../auction_overview_rollup.datasource | 18 + .../auction_provider_stats_rollup.datasource | 22 + tinybird/fixtures/auction_events_raw.ndjson | 7 + tinybird/pipes/auction_bid_stats_mv.pipe | 28 + tinybird/pipes/auction_overview_mv.pipe | 28 + tinybird/pipes/auction_provider_stats_mv.pipe | 34 + tinybird/pipes/auction_summary.pipe | 37 + tinybird/pipes/ingestion_freshness.pipe | 18 + tinybird/pipes/provider_health.pipe | 46 + tinybird/pipes/provider_latency.pipe | 23 + tinybird/pipes/quarantine_counts.pipe | 13 + tinybird/pipes/seat_yield.pipe | 36 + tinybird/tests/auction_summary.yaml | 13 + tinybird/tests/provider_health.yaml | 13 + tinybird/tests/seat_yield.yaml | 6 + 31 files changed, 4222 insertions(+), 114 deletions(-) create mode 100644 crates/trusted-server-adapter-fastly/src/tinybird.rs create mode 100644 crates/trusted-server-core/src/auction/telemetry.rs create mode 100644 docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md create mode 100644 docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md create mode 100644 tinybird/datasources/access_logs_raw.datasource create mode 100644 tinybird/datasources/auction_bid_stats_rollup.datasource create mode 100644 tinybird/datasources/auction_events_raw.datasource create mode 100644 tinybird/datasources/auction_overview_rollup.datasource create mode 100644 tinybird/datasources/auction_provider_stats_rollup.datasource create mode 100644 tinybird/fixtures/auction_events_raw.ndjson create mode 100644 tinybird/pipes/auction_bid_stats_mv.pipe create mode 100644 tinybird/pipes/auction_overview_mv.pipe create mode 100644 tinybird/pipes/auction_provider_stats_mv.pipe create mode 100644 tinybird/pipes/auction_summary.pipe create mode 100644 tinybird/pipes/ingestion_freshness.pipe create mode 100644 tinybird/pipes/provider_health.pipe create mode 100644 tinybird/pipes/provider_latency.pipe create mode 100644 tinybird/pipes/quarantine_counts.pipe create mode 100644 tinybird/pipes/seat_yield.pipe create mode 100644 tinybird/tests/auction_summary.yaml create mode 100644 tinybird/tests/provider_health.yaml create mode 100644 tinybird/tests/seat_yield.yaml diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index db321394..45dd84c1 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -94,6 +94,7 @@ use edgezero_core::http::{ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::batch_sync::handle_batch_sync; @@ -147,6 +148,7 @@ pub(crate) struct AppState { pub(crate) orchestrator: Arc, pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, + pub(crate) auction_telemetry_sink: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -173,6 +175,7 @@ pub(crate) fn build_state_from_settings( let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; + let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; Ok(Arc::new(AppState { @@ -180,6 +183,7 @@ pub(crate) fn build_state_from_settings( orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + auction_telemetry_sink, })) } @@ -254,6 +258,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(Arc::new(FastlyPlatformGeo)) + .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) .build() } @@ -1352,6 +1357,9 @@ mod tests { let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; let state = Arc::new(super::AppState { + auction_telemetry_sink: Arc::new( + trusted_server_core::auction::NoopAuctionTelemetrySink, + ), settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b1813122..d20de533 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -34,6 +34,7 @@ mod management_api; mod middleware; mod platform; mod rate_limiter; +mod tinybird; use crate::app::{load_settings_from_config_store, EcFinalizeState, TrustedServerApp}; use crate::ec_kv::FastlyEcKvStore; diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs new file mode 100644 index 00000000..b84915c5 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -0,0 +1,508 @@ +//! Tinybird direct-ingest telemetry sink for the Fastly adapter. + +use std::sync::Arc; +use std::time::Duration; + +use edgezero_core::body::Body; +use edgezero_core::http::{header, request_builder, HeaderValue, Method}; +use error_stack::{Report, ResultExt as _}; +use trusted_server_core::auction::telemetry::{ + AuctionEventBatch, AuctionTelemetrySink, NoopAuctionTelemetrySink, +}; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::platform::{ + PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, StoreName, +}; +use trusted_server_core::settings::{Settings, TinybirdSettings}; + +const TINYBIRD_EVENTS_PATH: &str = "/v0/events"; +const TINYBIRD_NDJSON_CONTENT_TYPE: &str = "application/x-ndjson"; +const TINYBIRD_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(2); +const TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH: usize = 512; + +/// Build the configured auction telemetry sink. +#[must_use] +pub(crate) fn auction_sink_from_settings(settings: &Settings) -> Arc { + if settings.tinybird.enabled { + Arc::new(FastlyTinybirdAuctionTelemetrySink::new( + settings.tinybird.clone(), + )) + } else { + Arc::new(NoopAuctionTelemetrySink) + } +} + +#[derive(Debug, Clone)] +struct FastlyTinybirdAuctionTelemetrySink { + config: TinybirdSettings, +} + +impl FastlyTinybirdAuctionTelemetrySink { + fn new(config: TinybirdSettings) -> Self { + Self { config } + } +} + +#[async_trait::async_trait(?Send)] +impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { + async fn emit_auction_events( + &self, + services: &RuntimeServices, + batch: AuctionEventBatch, + ) -> Result<(), Report> { + if !self.config.enabled || batch.is_empty() { + return Ok(()); + } + if batch.row_count() > TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "auction telemetry batch has {} rows, exceeding {} row limit", + batch.row_count(), + TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH + ), + })); + } + + let body = batch.to_ndjson(self.config.max_body_bytes)?; + let token = services + .secret_store() + .get_string( + &StoreName::from(self.config.secret_store.as_str()), + &self.config.auction_token_secret, + ) + .change_context(TrustedServerError::Proxy { + message: "Tinybird auction append token unavailable".to_owned(), + })?; + let token = token.trim(); + if token.is_empty() { + return Err(Report::new(TrustedServerError::Proxy { + message: "Tinybird auction append token is empty".to_owned(), + })); + } + + let backend_name = services + .backend() + .ensure(&tinybird_backend_spec(&self.config.api_host)) + .change_context(TrustedServerError::Proxy { + message: "Tinybird backend registration failed".to_owned(), + })?; + + let uri = tinybird_events_uri(&self.config.api_host, &self.config.auction_dataset); + let auth_header = HeaderValue::from_str(&format!("Bearer {token}")).change_context( + TrustedServerError::InvalidHeaderValue { + message: "invalid Tinybird authorization header".to_owned(), + }, + )?; + let request = request_builder() + .method(Method::POST) + .uri(uri) + .header(header::AUTHORIZATION, auth_header) + .header(header::CONTENT_TYPE, TINYBIRD_NDJSON_CONTENT_TYPE) + .body(Body::from(body)) + .change_context(TrustedServerError::Proxy { + message: "failed to build Tinybird Events API request".to_owned(), + })?; + + let pending = services + .http_client() + .send_async(PlatformHttpRequest::new(request, backend_name)) + .await + .change_context(TrustedServerError::Proxy { + message: "failed to start Tinybird Events API request".to_owned(), + })?; + drop(pending); + Ok(()) + } +} + +fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { + PlatformBackendSpec { + scheme: "https".to_owned(), + host: api_host.to_owned(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, + } +} + +fn tinybird_events_uri(api_host: &str, dataset: &str) -> String { + format!( + "https://{api_host}{TINYBIRD_EVENTS_PATH}?name={}", + urlencoding::encode(dataset) + ) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use error_stack::Report; + use trusted_server_core::auction::telemetry::{AuctionEventBatch, AuctionEventRow}; + use trusted_server_core::platform::{ + ClientInfo, PlatformBackend, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformHttpClient, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, + PlatformSelectResult, RuntimeServices, StoreId, + }; + + use super::*; + + struct NoopConfigStore; + + impl PlatformConfigStore for NoopConfigStore { + fn get( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct MapSecretStore(HashMap>); + + impl PlatformSecretStore for MapSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + self.0 + .get(key) + .cloned() + .ok_or_else(|| Report::new(PlatformError::SecretStore)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + #[derive(Default)] + struct RecordingBackend { + specs: Mutex>, + } + + impl PlatformBackend for RecordingBackend { + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("tinybird-backend".to_owned()) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.specs + .lock() + .expect("should lock backend specs") + .push(spec.clone()); + Ok("tinybird-backend".to_owned()) + } + } + + #[derive(Debug)] + struct RecordedRequest { + backend_name: String, + method: String, + uri: String, + headers: Vec<(String, String)>, + body: Vec, + } + + #[derive(Default)] + struct RecordingHttpClient { + requests: Mutex>, + select_calls: Mutex, + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for RecordingHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend_name = request.backend_name; + let (parts, body) = request.request.into_parts(); + let headers = parts + .headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect(); + let recorded = RecordedRequest { + backend_name, + method: parts.method.to_string(), + uri: parts.uri.to_string(), + headers, + body: body.into_bytes().unwrap_or_default().to_vec(), + }; + self.requests + .lock() + .expect("should lock recorded requests") + .push(recorded); + Ok(PlatformPendingRequest::new(()).with_backend_name("tinybird-backend")) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + *self.select_calls.lock().expect("should lock select calls") += 1; + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct NoopGeo; + + impl PlatformGeo for NoopGeo { + fn lookup( + &self, + _client_ip: Option, + ) -> Result, Report> { + Ok(None) + } + } + + fn test_row() -> AuctionEventRow { + AuctionEventRow { + event_ts: "2026-06-23 00:00:00.000".to_owned(), + event_kind: "summary".to_owned(), + auction_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + auction_source: "auction_api".to_owned(), + publisher_domain: "test-publisher.example".to_owned(), + page_path: "/".to_owned(), + country: "US".to_owned(), + region: None, + is_mobile: 0, + is_known_browser: 1, + gdpr_applies: 0, + consent_present: 0, + terminal_status: Some("completed".to_owned()), + terminal_reason: None, + slot_count: Some(1), + total_time_ms: Some(1), + winning_bid_count: Some(0), + 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, + } + } + + fn services( + backend: Arc, + http_client: Arc, + secrets: HashMap>, + ) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(MapSecretStore(secrets))) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(backend) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo::default()) + .build() + } + + fn enabled_config() -> TinybirdSettings { + TinybirdSettings { + enabled: true, + api_host: "api.us-east.aws.tinybird.co".to_owned(), + secret_store: "ts_secrets".to_owned(), + auction_dataset: "auction_events_raw".to_owned(), + auction_token_secret: "tinybird_auction_append_token".to_owned(), + access_enabled: false, + access_dataset: "access_logs_raw".to_owned(), + access_token_secret: "tinybird_access_append_token".to_owned(), + access_sample_rate: 0.0, + max_body_bytes: 1024 * 1024, + } + } + + #[test] + fn events_uri_targets_dataset_on_region_host() { + assert_eq!( + tinybird_events_uri("api.us-east.aws.tinybird.co", "auction_events_raw"), + "https://api.us-east.aws.tinybird.co/v0/events?name=auction_events_raw" + ); + } + + #[test] + fn backend_spec_uses_matching_tls_host() { + let spec = tinybird_backend_spec("api.us-east.aws.tinybird.co"); + assert_eq!(spec.scheme, "https"); + assert_eq!(spec.host, "api.us-east.aws.tinybird.co"); + assert_eq!(spec.host_header_override, None); + assert!(spec.certificate_check, "should verify Tinybird TLS cert"); + } + + #[test] + fn sink_posts_ndjson_with_secret_token_and_does_not_wait() { + let backend = Arc::new(RecordingBackend::default()); + let http_client = Arc::new(RecordingHttpClient::default()); + let services = services( + Arc::clone(&backend), + Arc::clone(&http_client), + HashMap::from([( + "tinybird_auction_append_token".to_owned(), + b" append-token\n".to_vec(), + )]), + ); + let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + + futures::executor::block_on( + sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), + ) + .expect("should start Tinybird request"); + + let specs = backend.specs.lock().expect("should lock specs"); + assert_eq!(specs.len(), 1, "should ensure one backend"); + assert_eq!(specs[0].host, "api.us-east.aws.tinybird.co"); + drop(specs); + + let requests = http_client + .requests + .lock() + .expect("should lock recorded requests"); + assert_eq!(requests.len(), 1, "should start one async POST"); + assert_eq!(requests[0].backend_name, "tinybird-backend"); + assert_eq!(requests[0].method, Method::POST.to_string()); + assert_eq!( + requests[0].uri, + "https://api.us-east.aws.tinybird.co/v0/events?name=auction_events_raw" + ); + assert_eq!( + header_value(&requests[0].headers, header::CONTENT_TYPE.as_str()), + Some(TINYBIRD_NDJSON_CONTENT_TYPE) + ); + assert_eq!( + header_value(&requests[0].headers, header::AUTHORIZATION.as_str()), + Some("Bearer append-token") + ); + assert!( + std::str::from_utf8(&requests[0].body) + .expect("should record utf8 ndjson body") + .ends_with('\n'), + "should send newline-delimited JSON" + ); + assert_eq!( + *http_client + .select_calls + .lock() + .expect("should lock select calls"), + 0, + "should not wait for the Tinybird response" + ); + } + + #[test] + fn sink_drops_missing_secret_as_setup_error() { + let backend = Arc::new(RecordingBackend::default()); + let http_client = Arc::new(RecordingHttpClient::default()); + let services = services(backend, Arc::clone(&http_client), HashMap::new()); + let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + + let result = futures::executor::block_on( + sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), + ); + + assert!( + result.is_err(), + "best-effort caller will suppress this error" + ); + assert!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .is_empty(), + "should not send without a token" + ); + } + + #[test] + fn sink_drops_row_count_oversize_before_sending() { + let backend = Arc::new(RecordingBackend::default()); + let http_client = Arc::new(RecordingHttpClient::default()); + let services = services( + backend, + Arc::clone(&http_client), + HashMap::from([( + "tinybird_auction_append_token".to_owned(), + b"append-token".to_vec(), + )]), + ); + let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + let rows = vec![test_row(); TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH + 1]; + + let result = futures::executor::block_on( + sink.emit_auction_events(&services, AuctionEventBatch::new(rows)), + ); + + assert!( + result.is_err(), + "best-effort caller will suppress this error" + ); + assert!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .is_empty(), + "should not send oversized row batches" + ); + } + + fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } +} diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 56c104ab..836a3600 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -24,6 +24,10 @@ use crate::platform::RuntimeServices; use crate::settings::Settings; use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request}; +use super::telemetry::{ + build_auction_events, emit_auction_events_best_effort, AuctionObservationContext, + AuctionSource, AuctionTerminalOutcome, +}; use super::types::AuctionContext; use super::AuctionOrchestrator; @@ -188,6 +192,20 @@ pub async fn handle_auction( ec_id, None, )?; + let observation = AuctionObservationContext::from_auction_request( + AuctionSource::AuctionApi, + &auction_request, + ec_context, + ); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Skipped { + reason: "consent_denied", + elapsed_ms: 0, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + let empty_result = OrchestrationResult { provider_responses: Vec::new(), mediator_response: None, @@ -268,13 +286,41 @@ pub async fn handle_auction( services, }; + let observation = AuctionObservationContext::from_auction_request( + AuctionSource::AuctionApi, + &auction_request, + ec_context, + ); + // Run the auction - let result = orchestrator - .run_auction(&auction_request, &context) - .await - .change_context(TrustedServerError::Auction { - message: "Auction orchestration failed".to_string(), - })?; + let result = match orchestrator.run_auction(&auction_request, &context).await { + Ok(result) => result, + Err(err) => { + let elapsed_ms = observation.elapsed_ms(); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::ExecutionFailed { + request: Some(&auction_request), + provider_responses: &[], + reason: "execution_failed", + elapsed_ms, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + return Err(err.change_context(TrustedServerError::Auction { + message: "Auction orchestration failed".to_string(), + })); + } + }; + + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &auction_request, + result: &result, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; log::info!( "Auction completed: {} providers, {} winning bids, {}ms total", @@ -499,17 +545,54 @@ mod tests { use super::*; use crate::auction::config::AuctionConfig; use crate::auction::provider::AuctionProvider; + use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; use crate::auction::types::{AuctionRequest, AuctionResponse}; 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::{ + noop_services, NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, + }; + use crate::platform::{ClientInfo, PlatformPendingRequest, PlatformResponse}; use crate::test_support::tests::create_test_settings; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use serde_json::json; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct RecordingTelemetrySink { + batches: Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionTelemetrySink for RecordingTelemetrySink { + async fn emit_auction_events( + &self, + _services: &RuntimeServices, + batch: AuctionEventBatch, + ) -> Result<(), Report> { + self.batches + .lock() + .expect("should lock telemetry batches") + .push(batch); + Ok(()) + } + } + + fn services_with_telemetry(sink: Arc) -> RuntimeServices { + let telemetry_sink: Arc = sink; + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .auction_telemetry_sink(telemetry_sink) + .client_info(ClientInfo::default()) + .build() + } fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { EcContext::new_for_test( @@ -572,7 +655,8 @@ mod tests { }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(PanicOnBidProvider)); - let services = noop_services(); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with_telemetry(Arc::clone(&telemetry_sink)); let ec_id = format!("{}.ABC123", "a".repeat(64)); let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); @@ -620,6 +704,24 @@ mod tests { seatbid_empty, "gated auction must return no bids, got: {parsed}" ); + + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + assert_eq!(batches.len(), 1, "should emit one telemetry batch"); + let rows = batches[0].rows(); + assert_eq!(rows.len(), 1, "skipped auction should emit one summary row"); + assert_eq!(rows[0].event_kind, "summary"); + assert_eq!(rows[0].terminal_status.as_deref(), Some("skipped")); + assert_eq!(rows[0].terminal_reason.as_deref(), Some("consent_denied")); + let ndjson = batches[0] + .to_ndjson(16 * 1024) + .expect("should serialize telemetry"); + assert!( + !ndjson.contains(&ec_id), + "telemetry must not serialize EC identifiers" + ); } /// Provider that records whether the auction request it received carried diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index b0b0545f..f7461b56 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -19,6 +19,7 @@ pub mod endpoints; pub mod formats; pub mod orchestrator; pub mod provider; +pub mod telemetry; #[cfg(test)] pub(crate) mod test_support; pub mod types; @@ -27,6 +28,11 @@ pub use config::AuctionConfig; pub use context::{build_url_with_context_params, ContextQueryParams, ContextValue}; pub use orchestrator::AuctionOrchestrator; pub use provider::AuctionProvider; +pub use telemetry::{ + build_auction_events, emit_auction_events_best_effort, AbandonedProviderCall, + AuctionEventBatch, AuctionEventRow, AuctionObservationContext, AuctionSource, + AuctionTelemetrySink, AuctionTerminalOutcome, NoopAuctionTelemetrySink, +}; pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, }; diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 7137ebdb..589873e0 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -11,6 +11,7 @@ use crate::platform::{PlatformPendingRequest, RuntimeServices}; use super::config::AuctionConfig; use super::provider::AuctionProvider; +use super::telemetry::AbandonedProviderCall; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; /// In-flight auction requests dispatched to SSP backends. @@ -23,6 +24,7 @@ use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStat pub struct DispatchedAuction { pending_requests: Vec, backend_to_provider: HashMap)>, + launch_responses: Vec, auction_start: Instant, timeout_ms: u32, floor_prices: HashMap, @@ -30,12 +32,56 @@ pub struct DispatchedAuction { request: AuctionRequest, } +/// Outcome of attempting to dispatch split-phase auction provider requests. +pub enum DispatchAuctionOutcome { + /// No provider request was started and no provider failure was observed. + NotStarted, + /// No provider request could be launched, but launch failures were observed. + DispatchFailed { + /// Original auction request. + request: AuctionRequest, + /// Provider launch-failure responses. + provider_responses: Vec, + /// Elapsed dispatch time. + elapsed_ms: u64, + }, + /// One or more provider requests are in flight. + Dispatched(DispatchedAuction), +} + +impl DispatchedAuction { + /// Consume the dispatch token without collecting provider responses. + #[must_use] + pub fn abandon( + self, + ) -> ( + AuctionRequest, + Vec, + Vec, + u64, + ) { + let elapsed_ms = self.auction_start.elapsed().as_millis() as u64; + let abandoned = self + .backend_to_provider + .into_values() + .map(|(provider_name, start_time, _)| { + AbandonedProviderCall::bidder( + provider_name, + Some(u32::try_from(start_time.elapsed().as_millis()).unwrap_or(u32::MAX)), + ) + }) + .collect(); + (self.request, self.launch_responses, abandoned, elapsed_ms) + } +} + #[cfg(test)] impl DispatchedAuction { pub(crate) fn empty_for_test(request: AuctionRequest, timeout_ms: u32) -> Self { Self { pending_requests: Vec::new(), backend_to_provider: HashMap::new(), + launch_responses: Vec::new(), auction_start: Instant::now(), timeout_ms, floor_prices: HashMap::new(), @@ -49,6 +95,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 @@ -94,6 +141,12 @@ 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")) +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, @@ -631,6 +684,12 @@ impl AuctionOrchestrator { } } + for (provider_name, start_time, _) in backend_to_provider.into_values() { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!("Provider '{provider_name}' timed out before auction collection completed"); + responses.push(provider_timeout_response(provider_name, response_time_ms)); + } + Ok(responses) } @@ -760,18 +819,19 @@ impl AuctionOrchestrator { /// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips /// while WASM continues to `pending_origin.wait()`. /// - /// Returns `None` when no providers are configured or all providers are - /// disabled / over budget. The caller should fall back to the synchronous - /// `run_auction` path. + /// Returns [`DispatchAuctionOutcome::NotStarted`] when no providers are configured or + /// all providers are disabled / over budget. Returns + /// [`DispatchAuctionOutcome::DispatchFailed`] when provider launch attempts + /// happened but none could be started. #[must_use] pub async fn dispatch_auction( &self, request: &AuctionRequest, context: &AuctionContext<'_>, - ) -> Option { + ) -> DispatchAuctionOutcome { let provider_names = self.config.provider_names(); if provider_names.is_empty() { - return None; + return DispatchAuctionOutcome::NotStarted; } // Mirror run_providers_parallel: reject multi-provider fan-out before @@ -788,13 +848,14 @@ impl AuctionOrchestrator { concurrent fan-out support", provider_names.len(), ); - return None; + return DispatchAuctionOutcome::NotStarted; } let auction_start = Instant::now(); let mut backend_to_provider: HashMap)> = HashMap::new(); let mut pending_requests: Vec = Vec::new(); + let mut launch_responses: Vec = Vec::new(); for provider_name in provider_names { let provider = match self.providers.get(provider_name) { @@ -866,17 +927,30 @@ 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_responses.push(provider_launch_failed_response( + provider.provider_name(), + response_time_ms, + )); } } } if pending_requests.is_empty() { - return None; + return if launch_responses.is_empty() { + DispatchAuctionOutcome::NotStarted + } else { + DispatchAuctionOutcome::DispatchFailed { + request: request.clone(), + provider_responses: launch_responses, + elapsed_ms: auction_start.elapsed().as_millis() as u64, + } + }; } log::info!( @@ -885,9 +959,10 @@ impl AuctionOrchestrator { context.timeout_ms ); - Some(DispatchedAuction { + DispatchAuctionOutcome::Dispatched(DispatchedAuction { pending_requests, backend_to_provider, + launch_responses, auction_start, timeout_ms: context.timeout_ms, floor_prices: self.floor_prices_by_slot(request), @@ -914,6 +989,7 @@ impl AuctionOrchestrator { let DispatchedAuction { pending_requests, mut backend_to_provider, + launch_responses, auction_start, timeout_ms, floor_prices, @@ -927,7 +1003,7 @@ impl AuctionOrchestrator { remaining_budget_ms(auction_start, timeout_ms), ); - let mut responses: Vec = Vec::new(); + let mut responses: Vec = launch_responses; let mut remaining = pending_requests; while !remaining.is_empty() { @@ -1038,6 +1114,13 @@ impl AuctionOrchestrator { // `remaining_budget_ms`. } + for (provider_name, start_time, _) in backend_to_provider.values() { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!("Provider '{provider_name}' timed out before dispatched auction collection completed"); + responses.push(provider_timeout_response(provider_name, response_time_ms)); + } + backend_to_provider.clear(); + let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { match self.providers.get(mediator_name.as_str()) { Some(mediator) => { diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs new file mode 100644 index 00000000..3f5999a4 --- /dev/null +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -0,0 +1,1115 @@ +//! Auction telemetry row construction and sink abstraction. +//! +//! Core owns the privacy-preserving auction observation model and pure row +//! builder. Platform adapters provide the concrete sink implementation. + +use std::collections::HashSet; +use std::time::Instant; + +use chrono::Utc; +use error_stack::Report; +use serde::Serialize; +use uuid::Uuid; + +use crate::auction::orchestrator::OrchestrationResult; +use crate::auction::types::{AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType}; +use crate::ec::EcContext; +use crate::error::TrustedServerError; +use crate::platform::RuntimeServices; + +const MAX_PAGE_PATH_BYTES: usize = 256; +const DYNAMIC_SEGMENT_REPLACEMENT: &str = ":id"; + +/// Source path that initiated an auction candidate. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum AuctionSource { + /// Initial publisher navigation using server-side ad templates. + InitialNavigation, + /// SPA navigation through `GET /__ts/page-bids`. + SpaNavigation, + /// Explicit `POST /auction` API. + AuctionApi, +} + +impl AuctionSource { + fn as_str(self) -> &'static str { + match self { + Self::InitialNavigation => "initial_navigation", + Self::SpaNavigation => "spa_navigation", + Self::AuctionApi => "auction_api", + } + } +} + +/// Terminal status for one auction observation. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum AuctionTerminalStatus { + /// Auction completed and produced an [`OrchestrationResult`]. + Completed, + /// Auction execution failed after initiation. + ExecutionFailed, + /// No provider request could be launched. + DispatchFailed, + /// Split-phase auction was dispatched but could not be collected. + Abandoned, + /// Candidate was skipped by policy before provider calls were made. + Skipped, +} + +impl AuctionTerminalStatus { + fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::ExecutionFailed => "execution_failed", + Self::DispatchFailed => "dispatch_failed", + Self::Abandoned => "abandoned", + Self::Skipped => "skipped", + } + } +} + +/// Provider call that was still pending when a split auction was abandoned. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct AbandonedProviderCall { + /// Provider name. + pub provider: String, + /// Provider role, usually `bidder` or `mediator`. + pub provider_role: &'static str, + /// Optional elapsed time for this provider at abandonment. + pub response_time_ms: Option, +} + +impl AbandonedProviderCall { + /// Construct an abandoned bidder call. + #[must_use] + pub fn bidder(provider: impl Into, response_time_ms: Option) -> Self { + Self { + provider: provider.into(), + provider_role: "bidder", + response_time_ms, + } + } +} + +/// Privacy-preserving context shared by all rows in one auction observation. +#[derive(Debug, Clone)] +pub struct AuctionObservationContext { + /// Fresh telemetry UUID, independent of EC and internal auction IDs. + pub auction_id: Uuid, + /// Source path that initiated the auction candidate. + pub auction_source: AuctionSource, + /// Publisher domain. + pub publisher_domain: String, + /// Normalized, bounded page path. + pub page_path: String, + /// Country code, when available. + pub country: String, + /// Region code, 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. + pub gdpr_applies: bool, + /// Whether any consent signal was present. + pub consent_present: bool, + /// Requested slot count for this candidate. + pub slot_count: u16, + started_at: Instant, +} + +impl AuctionObservationContext { + /// Build an observation context from an auction request. + #[must_use] + pub fn from_auction_request( + auction_source: AuctionSource, + request: &AuctionRequest, + ec_context: &EcContext, + ) -> Self { + let raw_path = request + .publisher + .page_url + .as_deref() + .and_then(|page_url| url::Url::parse(page_url).ok()) + .map(|url| url.path().to_owned()) + .unwrap_or_else(|| "/".to_owned()); + Self::from_parts( + auction_source, + &request.publisher.domain, + &raw_path, + request.slots.len(), + ec_context, + ) + } + + /// Build an observation context from publisher request parts. + #[must_use] + pub fn from_parts( + auction_source: AuctionSource, + publisher_domain: &str, + raw_page_path: &str, + slot_count: usize, + ec_context: &EcContext, + ) -> Self { + let device = ec_context.device_signals(); + let geo = ec_context.geo_info(); + let consent = ec_context.consent(); + let slot_count = u16::try_from(slot_count).unwrap_or(u16::MAX); + Self { + auction_id: Uuid::new_v4(), + auction_source, + publisher_domain: publisher_domain.to_owned(), + page_path: normalize_page_path(raw_page_path), + country: geo + .map(|info| info.country.clone()) + .filter(|country| !country.is_empty()) + .unwrap_or_else(|| "ZZ".to_owned()), + region: geo.and_then(|info| info.region.clone()), + is_mobile: device.map_or(2, |signals| signals.is_mobile), + is_known_browser: match device.and_then(|signals| signals.known_browser) { + Some(true) => 1, + Some(false) => 0, + None => 2, + }, + gdpr_applies: consent.gdpr_applies, + consent_present: !consent.is_empty(), + slot_count, + started_at: Instant::now(), + } + } + + /// Return elapsed milliseconds since the observation was created. + #[must_use] + pub fn elapsed_ms(&self) -> u64 { + u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX) + } + + #[cfg(test)] + fn for_test(auction_source: AuctionSource, page_path: &str, slot_count: u16) -> Self { + Self { + auction_id: Uuid::new_v4(), + auction_source, + publisher_domain: "test-publisher.example".to_owned(), + page_path: normalize_page_path(page_path), + country: "US".to_owned(), + region: Some("CA".to_owned()), + is_mobile: 0, + is_known_browser: 1, + gdpr_applies: false, + consent_present: false, + slot_count, + started_at: Instant::now(), + } + } +} + +/// Terminal outcome used by the row builder. +pub enum AuctionTerminalOutcome<'a> { + /// Completed auction. + Completed { + /// Auction request used for slot and bid context. + request: &'a AuctionRequest, + /// Orchestration result. + result: &'a OrchestrationResult, + }, + /// Execution failure. + ExecutionFailed { + /// Optional auction request. + request: Option<&'a AuctionRequest>, + /// Provider responses observed before the failure. + provider_responses: &'a [AuctionResponse], + /// Bounded terminal reason. + reason: &'a str, + /// Elapsed time in milliseconds. + elapsed_ms: u64, + }, + /// Dispatch failure. + DispatchFailed { + /// Auction request. + request: &'a AuctionRequest, + /// Provider responses observed during dispatch. + provider_responses: &'a [AuctionResponse], + /// Bounded terminal reason. + reason: &'a str, + /// Elapsed time in milliseconds. + elapsed_ms: u64, + }, + /// Split auction was abandoned. + Abandoned { + /// Auction request. + request: &'a AuctionRequest, + /// Provider responses observed before abandonment. + provider_responses: &'a [AuctionResponse], + /// Providers still pending at abandonment time. + abandoned_providers: &'a [AbandonedProviderCall], + /// Bounded terminal reason. + reason: &'a str, + /// Elapsed time in milliseconds. + elapsed_ms: u64, + }, + /// Candidate was skipped before provider calls. + Skipped { + /// Bounded terminal reason. + reason: &'a str, + /// Elapsed time in milliseconds. + elapsed_ms: u64, + }, +} + +/// One row for the `auction_events_raw` datasource. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct AuctionEventRow { + /// Terminal observation timestamp in UTC. + pub event_ts: String, + /// `summary`, `provider_call`, or `bid`. + pub event_kind: String, + /// Fresh telemetry auction UUID. + pub auction_id: String, + /// Source path label. + pub auction_source: String, + /// Publisher domain. + pub publisher_domain: String, + /// Normalized page path. + pub page_path: String, + /// Country code. + pub country: String, + /// Region code. + pub region: Option, + /// `0` = desktop, `1` = mobile, `2` = unknown. + pub is_mobile: u8, + /// `0` = bot, `1` = browser, `2` = unknown. + pub is_known_browser: u8, + /// `0` or `1`. + pub gdpr_applies: u8, + /// `0` or `1`. + pub consent_present: u8, + /// Summary terminal status. + pub terminal_status: Option, + /// Summary terminal reason. + pub terminal_reason: Option, + /// Requested slots. + pub slot_count: Option, + /// Total elapsed time. + pub total_time_ms: Option, + /// Number of winning bids. + pub winning_bid_count: Option, + /// Provider name. + pub provider: Option, + /// `bidder` or `mediator`. + pub provider_role: Option, + /// Provider-call status. + pub status: Option, + /// Provider elapsed time. + pub provider_response_time_ms: Option, + /// Parsed provider bid count. + pub provider_bid_count: Option, + /// Bid slot ID. + pub slot_id: Option, + /// Creative width. + pub slot_w: Option, + /// Creative height. + pub slot_h: Option, + /// Media type. + pub media_type: Option, + /// Seat/bidder. + pub seat: Option, + /// Decoded CPM. + pub price_cpm: Option, + /// Currency. + pub currency: Option, + /// Whether this is the canonical winning row for its slot. + pub is_win: Option, + /// Advertiser domain. + pub ad_domain: Option, + /// Creative/ad ID. + pub ad_id: Option, +} + +impl AuctionEventRow { + fn base(observation: &AuctionObservationContext, event_kind: &str, event_ts: &str) -> Self { + Self { + event_ts: event_ts.to_owned(), + event_kind: event_kind.to_owned(), + auction_id: observation.auction_id.to_string(), + auction_source: observation.auction_source.as_str().to_owned(), + publisher_domain: observation.publisher_domain.clone(), + page_path: observation.page_path.clone(), + country: observation.country.clone(), + region: observation.region.clone(), + is_mobile: observation.is_mobile, + is_known_browser: observation.is_known_browser, + gdpr_applies: u8::from(observation.gdpr_applies), + consent_present: u8::from(observation.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, + } + } +} + +/// A bounded group of rows emitted for one auction observation. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AuctionEventBatch { + rows: Vec, +} + +impl AuctionEventBatch { + /// Create a batch from rows. + #[must_use] + pub fn new(rows: Vec) -> Self { + Self { rows } + } + + /// Return rows in this batch. + #[must_use] + pub fn rows(&self) -> &[AuctionEventRow] { + &self.rows + } + + /// Return the number of rows in this batch. + #[must_use] + pub fn row_count(&self) -> usize { + self.rows.len() + } + + /// Return true when there are no rows. + #[must_use] + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + /// Serialize rows as newline-delimited JSON and enforce a maximum body size. + /// + /// # Errors + /// + /// Returns an error when serialization fails or the body exceeds `max_body_bytes`. + pub fn to_ndjson(&self, max_body_bytes: usize) -> Result> { + let mut body = String::new(); + for row in &self.rows { + let line = serde_json::to_string(row).map_err(|err| { + Report::new(TrustedServerError::Proxy { + message: format!("failed to serialize auction telemetry row: {err}"), + }) + })?; + body.push_str(&line); + body.push('\n'); + if body.len() > max_body_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "auction telemetry payload exceeds {max_body_bytes} byte limit" + ), + })); + } + } + Ok(body) + } +} + +/// Sink for auction telemetry batches. +#[async_trait::async_trait(?Send)] +pub trait AuctionTelemetrySink: Send + Sync { + /// Emit an auction telemetry batch. + /// + /// # Errors + /// + /// Returns an error when the sink cannot start emission. Callers should use + /// [`emit_auction_events_best_effort`] on the hot path. + async fn emit_auction_events( + &self, + services: &RuntimeServices, + batch: AuctionEventBatch, + ) -> Result<(), Report>; +} + +/// No-op auction telemetry sink used when telemetry is disabled. +pub struct NoopAuctionTelemetrySink; + +#[async_trait::async_trait(?Send)] +impl AuctionTelemetrySink for NoopAuctionTelemetrySink { + async fn emit_auction_events( + &self, + _services: &RuntimeServices, + _batch: AuctionEventBatch, + ) -> Result<(), Report> { + Ok(()) + } +} + +/// Emit a telemetry batch without letting errors affect customer traffic. +pub async fn emit_auction_events_best_effort(services: &RuntimeServices, batch: AuctionEventBatch) { + if batch.is_empty() { + return; + } + if let Err(err) = services + .auction_telemetry_sink() + .emit_auction_events(services, batch) + .await + { + log::warn!("auction telemetry emission skipped: {err:?}"); + } +} + +/// Build auction telemetry rows for one terminal observation. +#[allow( + clippy::needless_pass_by_value, + reason = "call sites hand off terminal observations by value to keep ownership explicit" +)] +#[must_use] +pub fn build_auction_events( + observation: AuctionObservationContext, + terminal: AuctionTerminalOutcome<'_>, +) -> AuctionEventBatch { + let event_ts = current_event_timestamp(); + let mut rows = Vec::new(); + + match terminal { + AuctionTerminalOutcome::Completed { request, result } => { + push_summary( + &mut rows, + &observation, + &event_ts, + AuctionTerminalStatus::Completed, + None, + result.total_time_ms, + result.winning_bids.len(), + ); + push_provider_rows( + &mut rows, + &observation, + &event_ts, + &result.provider_responses, + "bidder", + ); + if let Some(mediator_response) = &result.mediator_response { + push_provider_row( + &mut rows, + &observation, + &event_ts, + mediator_response, + "mediator", + ); + } + push_bid_rows(&mut rows, &observation, &event_ts, request, result); + } + AuctionTerminalOutcome::ExecutionFailed { + request: _, + provider_responses, + reason, + elapsed_ms, + } => { + push_summary( + &mut rows, + &observation, + &event_ts, + AuctionTerminalStatus::ExecutionFailed, + Some(reason), + elapsed_ms, + 0, + ); + push_provider_rows( + &mut rows, + &observation, + &event_ts, + provider_responses, + "bidder", + ); + } + AuctionTerminalOutcome::DispatchFailed { + request: _, + provider_responses, + reason, + elapsed_ms, + } => { + push_summary( + &mut rows, + &observation, + &event_ts, + AuctionTerminalStatus::DispatchFailed, + Some(reason), + elapsed_ms, + 0, + ); + push_provider_rows( + &mut rows, + &observation, + &event_ts, + provider_responses, + "bidder", + ); + } + AuctionTerminalOutcome::Abandoned { + request: _, + provider_responses, + abandoned_providers, + reason, + elapsed_ms, + } => { + push_summary( + &mut rows, + &observation, + &event_ts, + AuctionTerminalStatus::Abandoned, + Some(reason), + elapsed_ms, + 0, + ); + push_provider_rows( + &mut rows, + &observation, + &event_ts, + provider_responses, + "bidder", + ); + for provider in abandoned_providers { + let mut row = AuctionEventRow::base(&observation, "provider_call", &event_ts); + row.provider = Some(provider.provider.clone()); + row.provider_role = Some(provider.provider_role.to_owned()); + row.status = Some("abandoned".to_owned()); + row.provider_response_time_ms = provider.response_time_ms; + row.provider_bid_count = Some(0); + rows.push(row); + } + } + AuctionTerminalOutcome::Skipped { reason, elapsed_ms } => { + push_summary( + &mut rows, + &observation, + &event_ts, + AuctionTerminalStatus::Skipped, + Some(reason), + elapsed_ms, + 0, + ); + } + } + + AuctionEventBatch::new(rows) +} + +fn push_summary( + rows: &mut Vec, + observation: &AuctionObservationContext, + event_ts: &str, + status: AuctionTerminalStatus, + reason: Option<&str>, + elapsed_ms: u64, + winning_bid_count: usize, +) { + let mut row = AuctionEventRow::base(observation, "summary", event_ts); + row.terminal_status = Some(status.as_str().to_owned()); + row.terminal_reason = reason.map(sanitize_reason); + row.slot_count = Some(observation.slot_count); + row.total_time_ms = Some(u32::try_from(elapsed_ms).unwrap_or(u32::MAX)); + row.winning_bid_count = Some(u16::try_from(winning_bid_count).unwrap_or(u16::MAX)); + rows.push(row); +} + +fn push_provider_rows( + rows: &mut Vec, + observation: &AuctionObservationContext, + event_ts: &str, + responses: &[AuctionResponse], + role: &str, +) { + for response in responses { + push_provider_row(rows, observation, event_ts, response, role); + } +} + +fn push_provider_row( + rows: &mut Vec, + observation: &AuctionObservationContext, + event_ts: &str, + response: &AuctionResponse, + role: &str, +) { + let mut row = AuctionEventRow::base(observation, "provider_call", event_ts); + row.provider = Some(response.provider.clone()); + row.provider_role = Some(role.to_owned()); + row.status = Some(provider_status(response).to_owned()); + row.provider_response_time_ms = + Some(u32::try_from(response.response_time_ms).unwrap_or(u32::MAX)); + row.provider_bid_count = Some(u16::try_from(response.bids.len()).unwrap_or(u16::MAX)); + rows.push(row); +} + +fn push_bid_rows( + rows: &mut Vec, + observation: &AuctionObservationContext, + event_ts: &str, + request: &AuctionRequest, + result: &OrchestrationResult, +) { + let mut matched_wins = HashSet::new(); + + for response in &result.provider_responses { + for bid in &response.bids { + let matched_slot = result + .winning_bids + .iter() + .find(|(slot_id, winning)| { + !matched_wins.contains(*slot_id) && bid_matches_winning_bid(bid, winning) + }) + .map(|(slot_id, winning)| (slot_id.clone(), winning)); + let (is_win, price) = if let Some((slot_id, winning)) = matched_slot { + matched_wins.insert(slot_id); + (1, bid.price.or(winning.price)) + } else { + (0, bid.price) + }; + rows.push(bid_row( + observation, + event_ts, + request, + &response.provider, + bid, + is_win, + price, + )); + } + } + + if let Some(mediator_response) = &result.mediator_response { + for (slot_id, winning) in &result.winning_bids { + if matched_wins.contains(slot_id) { + continue; + } + if mediator_response + .bids + .iter() + .any(|bid| bid_matches_winning_bid(bid, winning)) + { + rows.push(bid_row( + observation, + event_ts, + request, + &mediator_response.provider, + winning, + 1, + winning.price, + )); + matched_wins.insert(slot_id.clone()); + } + } + } +} + +fn bid_row( + observation: &AuctionObservationContext, + event_ts: &str, + request: &AuctionRequest, + provider: &str, + bid: &Bid, + is_win: u8, + price: Option, +) -> AuctionEventRow { + let mut row = AuctionEventRow::base(observation, "bid", event_ts); + row.provider = Some(provider.to_owned()); + row.slot_id = Some(bid.slot_id.clone()); + row.slot_w = Some(u16::try_from(bid.width).unwrap_or(u16::MAX)); + row.slot_h = Some(u16::try_from(bid.height).unwrap_or(u16::MAX)); + row.media_type = media_type_for_slot(request, &bid.slot_id).map(str::to_owned); + row.seat = Some(bid.bidder.clone()); + row.price_cpm = price; + row.currency = Some(bid.currency.clone()); + row.is_win = Some(is_win); + row.ad_domain = bid + .adomain + .as_ref() + .and_then(|domains| domains.first().cloned()); + row.ad_id = bid.ad_id.clone(); + row +} + +fn bid_matches_winning_bid(candidate: &Bid, winning: &Bid) -> bool { + if candidate.slot_id != winning.slot_id || candidate.bidder != winning.bidder { + return false; + } + match winning.ad_id.as_deref() { + Some(winning_ad_id) => candidate.ad_id.as_deref() == Some(winning_ad_id), + None => true, + } +} + +fn media_type_for_slot<'a>(request: &'a AuctionRequest, slot_id: &str) -> Option<&'a str> { + request + .slots + .iter() + .find(|slot| slot.id == slot_id) + .and_then(|slot| slot.formats.first()) + .map(|format| match format.media_type { + MediaType::Banner => "banner", + MediaType::Video => "video", + MediaType::Native => "native", + }) +} + +fn provider_status(response: &AuctionResponse) -> &'static str { + match response.status { + BidStatus::Success => "success", + BidStatus::NoBid => "nobid", + BidStatus::Error => match response + .metadata + .get("error_type") + .and_then(serde_json::Value::as_str) + { + Some("launch_failed") => "launch_error", + Some("parse_response") => "parse_error", + Some("transport") => "transport_error", + Some("timeout") => "timeout", + _ => "transport_error", + }, + BidStatus::Pending => "timeout", + } +} + +fn current_event_timestamp() -> String { + Utc::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string() +} + +fn sanitize_reason(reason: &str) -> String { + let mut output = String::new(); + for ch in reason.chars().take(64) { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + output.push(ch.to_ascii_lowercase()); + } else if ch.is_ascii_whitespace() || ch == '/' || ch == ':' { + output.push('_'); + } + } + if output.is_empty() { + "unknown".to_owned() + } else { + output + } +} + +/// Normalize a raw page path into a bounded, low-cardinality route label. +#[must_use] +pub fn normalize_page_path(raw: &str) -> String { + let without_query = raw.split(['?', '#']).next().unwrap_or("/"); + let path = if without_query.starts_with('/') { + without_query.to_owned() + } else { + format!("/{without_query}") + }; + let mut normalized = String::new(); + for segment in path.split('/') { + if segment.is_empty() { + if normalized.is_empty() { + normalized.push('/'); + } + continue; + } + if normalized.len() > 1 && !normalized.ends_with('/') { + normalized.push('/'); + } + if is_dynamic_segment(segment) { + normalized.push_str(DYNAMIC_SEGMENT_REPLACEMENT); + } else { + normalized.push_str(segment); + } + if normalized.len() >= MAX_PAGE_PATH_BYTES { + truncate_to_char_boundary(&mut normalized, MAX_PAGE_PATH_BYTES); + break; + } + } + if normalized.is_empty() { + "/".to_owned() + } else { + normalized + } +} + +fn truncate_to_char_boundary(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + let boundary = (0..=max_bytes) + .rev() + .find(|index| value.is_char_boundary(*index)) + .unwrap_or(0); + value.truncate(boundary); +} + +fn is_dynamic_segment(segment: &str) -> bool { + let trimmed = segment.trim(); + if trimmed.len() >= 6 && trimmed.chars().all(|ch| ch.is_ascii_digit()) { + return true; + } + if looks_like_uuid(trimmed) { + return true; + } + if trimmed.len() >= 16 + && trimmed + .chars() + .all(|ch| ch.is_ascii_hexdigit() || matches!(ch, '-' | '_')) + { + return true; + } + trimmed.len() >= 24 + && trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) +} + +fn looks_like_uuid(value: &str) -> bool { + let parts: Vec<_> = value.split('-').collect(); + if parts.len() != 5 { + return false; + } + let lengths = [8, 4, 4, 4, 12]; + parts + .iter() + .zip(lengths) + .all(|(part, len)| part.len() == len && part.chars().all(|ch| ch.is_ascii_hexdigit())) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + + use crate::auction::types::{AdFormat, AdSlot, PublisherInfo, UserInfo}; + + use super::*; + + fn test_request(id: &str) -> AuctionRequest { + AuctionRequest { + id: id.to_owned(), + slots: vec![AdSlot { + id: "slot-1".to_owned(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }], + publisher: PublisherInfo { + domain: "test-publisher.example".to_owned(), + page_url: Some("https://test-publisher.example/articles/123456?x=1".to_owned()), + }, + user: UserInfo { + id: Some("ec-value-that-must-not-leak".to_owned()), + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid { + Bid { + slot_id: slot_id.to_owned(), + price, + currency: "USD".to_owned(), + creative: None, + adomain: Some(vec!["advertiser.example".to_owned()]), + bidder: bidder.to_owned(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: ad_id.map(str::to_owned), + cache_id: None, + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + + #[test] + fn normalize_page_path_strips_query_and_redacts_dynamic_segments() { + assert_eq!( + normalize_page_path("/article/123456/comments?user=abc#frag"), + "/article/:id/comments", + "should strip query and redact long numeric segment" + ); + assert_eq!( + normalize_page_path("product/550e8400-e29b-41d4-a716-446655440000"), + "/product/:id", + "should force leading slash and redact UUID" + ); + } + + #[test] + fn normalize_page_path_truncates_unicode_at_char_boundary() { + let normalized = normalize_page_path(&format!("/{}", "é".repeat(200))); + + assert!( + normalized.len() <= MAX_PAGE_PATH_BYTES, + "should stay within byte cap" + ); + assert!( + normalized.is_char_boundary(normalized.len()), + "should not panic or split utf8 when truncating" + ); + } + + #[test] + fn build_completed_events_keeps_summary_provider_and_bid_grains_separate() { + let request = test_request("ts-ec-derived-id"); + let provider_success = AuctionResponse::success( + "prebid", + vec![bid("slot-1", "kargo", Some("ad-1"), Some(1.25))], + 42, + ); + let provider_no_bid = AuctionResponse::no_bid("aps", 55); + let provider_error = + AuctionResponse::error("mock", 12).with_metadata("error_type", json!("parse_response")); + let winning = provider_success.bids[0].clone(); + let result = OrchestrationResult { + provider_responses: vec![provider_success, provider_no_bid, provider_error], + mediator_response: None, + winning_bids: HashMap::from([("slot-1".to_owned(), winning)]), + total_time_ms: 99, + metadata: HashMap::new(), + }; + let observation = + AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/article/1", 1); + + let batch = build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &request, + result: &result, + }, + ); + + let rows = batch.rows(); + assert_eq!( + rows.iter() + .filter(|row| row.event_kind == "summary") + .count(), + 1, + "should emit exactly one summary row" + ); + assert_eq!( + rows.iter() + .filter(|row| row.event_kind == "provider_call") + .count(), + 3, + "should emit one provider row per provider response" + ); + assert_eq!( + rows.iter().filter(|row| row.event_kind == "bid").count(), + 1, + "should emit bid rows only for actual bids" + ); + assert_eq!( + rows.iter() + .find(|row| row.provider.as_deref() == Some("aps")) + .and_then(|row| row.slot_id.as_deref()), + None, + "no-bid provider row should not invent slot data" + ); + assert_eq!( + rows.iter() + .find(|row| row.provider.as_deref() == Some("mock")) + .and_then(|row| row.status.as_deref()), + Some("parse_error"), + "should map provider parse failures" + ); + } + + #[test] + fn mediated_win_marks_original_bid_once() { + let request = test_request("req"); + let original_bid = bid("slot-1", "kargo", Some("ad-1"), None); + let provider_success = AuctionResponse::success("prebid", vec![original_bid], 42); + let mediator_bid = bid("slot-1", "kargo", Some("ad-1"), Some(2.0)); + let mediator_response = + AuctionResponse::success("adserver_mock", vec![mediator_bid.clone()], 15); + let result = OrchestrationResult { + provider_responses: vec![provider_success], + mediator_response: Some(mediator_response), + winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]), + total_time_ms: 80, + metadata: HashMap::new(), + }; + let observation = + AuctionObservationContext::for_test(AuctionSource::InitialNavigation, "/", 1); + + let batch = build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &request, + result: &result, + }, + ); + let winning_rows: Vec<_> = batch + .rows() + .iter() + .filter(|row| row.event_kind == "bid" && row.is_win == Some(1)) + .collect(); + + assert_eq!(winning_rows.len(), 1, "should have one canonical winner"); + assert_eq!( + winning_rows[0].provider.as_deref(), + Some("prebid"), + "should mark original provider row when mediator winner matches" + ); + assert_eq!( + winning_rows[0].price_cpm, + Some(2.0), + "should copy mediator decoded price onto original null-price bid" + ); + } + + #[test] + fn ndjson_serialization_has_one_json_object_per_line_and_no_private_ids() { + let request = test_request("ts-ec-derived-id"); + let result = OrchestrationResult { + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 1, + metadata: HashMap::new(), + }; + let observation = + AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/auction", 1); + + let body = build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &request, + result: &result, + }, + ) + .to_ndjson(4096) + .expect("should serialize ndjson"); + + assert!(body.ends_with('\n'), "should end each row with newline"); + for line in body.lines() { + let parsed: serde_json::Value = serde_json::from_str(line).expect("should parse row"); + assert_eq!(parsed["event_kind"], "summary"); + } + assert!( + !body.contains("ts-ec-derived-id") && !body.contains("ec-value-that-must-not-leak"), + "should not serialize internal auction request IDs or EC values" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 23f57a58..a81c2410 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -3,6 +3,8 @@ use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; +use crate::auction::telemetry::{AuctionTelemetrySink, NoopAuctionTelemetrySink}; + use super::{ PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, @@ -162,6 +164,8 @@ pub struct RuntimeServices { pub(crate) http_client: Arc, /// Geographic information lookup. pub(crate) geo: Arc, + /// Auction telemetry sink. + pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, } @@ -227,6 +231,12 @@ impl RuntimeServices { &*self.geo } + /// Returns the auction telemetry sink. + #[must_use] + pub fn auction_telemetry_sink(&self) -> &dyn AuctionTelemetrySink { + &*self.auction_telemetry_sink + } + /// Returns per-request client metadata (IP address, TLS details). #[must_use] pub fn client_info(&self) -> &ClientInfo { @@ -273,6 +283,7 @@ pub struct RuntimeServicesBuilder { backend: Option>, http_client: Option>, geo: Option>, + auction_telemetry_sink: Option>, client_info: Option, } @@ -285,6 +296,7 @@ impl RuntimeServicesBuilder { backend: None, http_client: None, geo: None, + auction_telemetry_sink: None, client_info: None, } } @@ -331,6 +343,16 @@ impl RuntimeServicesBuilder { self } + /// Set the auction telemetry sink. + #[must_use] + pub fn auction_telemetry_sink( + mut self, + auction_telemetry_sink: Arc, + ) -> Self { + self.auction_telemetry_sink = Some(auction_telemetry_sink); + self + } + /// Set the per-request client metadata. #[must_use] pub fn client_info(mut self, client_info: ClientInfo) -> Self { @@ -364,6 +386,9 @@ impl RuntimeServicesBuilder { geo: self .geo .expect("should set geo before building RuntimeServices"), + auction_telemetry_sink: self + .auction_telemetry_sink + .unwrap_or_else(|| Arc::new(NoopAuctionTelemetrySink)), client_info: self .client_info .expect("should set client_info before building RuntimeServices"), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 7482de88..9b724eaa 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -30,7 +30,13 @@ use http::{header, HeaderValue, Method, Request, Response, StatusCode, Uri}; use crate::auction::endpoints::{ merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, }; -use crate::auction::orchestrator::{AuctionOrchestrator, DispatchedAuction}; +use crate::auction::orchestrator::{ + AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, +}; +use crate::auction::telemetry::{ + build_auction_events, emit_auction_events_best_effort, AuctionObservationContext, + AuctionSource, AuctionTerminalOutcome, +}; use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; @@ -432,6 +438,10 @@ pub struct OwnedProcessResponseParams { pub(crate) content_type: String, pub(crate) ad_slots_script: Option, pub(crate) ad_bids_state: Arc>>, + /// Observation context for the in-flight auction. + pub(crate) auction_observation: Option, + /// Auction request snapshot used for telemetry after collection. + pub(crate) auction_request: Option, /// In-flight SSP bids dispatched before `pending_origin.wait()`. /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. @@ -645,6 +655,10 @@ pub async fn stream_publisher_body_async( // No auction — use the existing sync pipeline unchanged. return stream_publisher_body(body, output, params, settings, integration_registry); }; + let telemetry = AuctionTelemetryCarry { + observation: params.auction_observation.take(), + auction_request: params.auction_request.take(), + }; let is_html = is_html_content_type(¶ms.content_type); @@ -659,6 +673,19 @@ pub async fn stream_publisher_body_async( &make_collect_context(settings, services, &placeholder), ) .await; + if let (Some(observation), Some(auction_request)) = + (telemetry.observation, telemetry.auction_request.as_ref()) + { + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + } + write_bids_to_state( &result.winning_bids, params.price_granularity, @@ -671,7 +698,7 @@ pub async fn stream_publisher_body_async( // HTML: build the processor once and drive it chunk by chunk. // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin // EOF, then await auction and process chunk N (which contains ). - let mut processor = create_html_stream_processor( + let mut processor = match create_html_stream_processor( ¶ms.origin_host, ¶ms.request_host, ¶ms.request_scheme, @@ -679,7 +706,19 @@ pub async fn stream_publisher_body_async( integration_registry, params.ad_slots_script.as_deref().map(str::to_string), params.ad_bids_state.clone(), - )?; + ) { + Ok(processor) => processor, + Err(err) => { + emit_abandoned_auction( + services, + telemetry.observation, + dispatched, + "processor_init_error", + ) + .await; + return Err(err); + } + }; let compression = Compression::from_content_encoding(¶ms.content_encoding); stream_html_with_auction_hold( @@ -689,6 +728,7 @@ pub async fn stream_publisher_body_async( compression, AuctionCollectCtx { dispatched, + telemetry, price_granularity: params.price_granularity, ad_bids_state: ¶ms.ad_bids_state, orchestrator, @@ -849,9 +889,25 @@ pub(crate) fn prepend_auction_debug_comment( } } +/// Telemetry context carried from dispatch to collect. +struct AuctionTelemetryCarry { + observation: Option, + auction_request: Option, +} + +impl AuctionTelemetryCarry { + fn take(&mut self) -> Self { + Self { + observation: self.observation.take(), + auction_request: self.auction_request.take(), + } + } +} + /// Bundles the auction-collection dependencies passed through the streaming helpers. struct AuctionCollectCtx<'a> { dispatched: DispatchedAuction, + telemetry: AuctionTelemetryCarry, price_granularity: PriceGranularity, ad_bids_state: &'a Arc>>, orchestrator: &'a AuctionOrchestrator, @@ -979,6 +1035,7 @@ async fn body_close_hold_loop( ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, + mut telemetry, price_granularity, ad_bids_state, orchestrator, @@ -998,6 +1055,7 @@ async fn body_close_hold_loop( .expect("should have dispatched auction to collect"); collect_stream_auction( dispatched, + telemetry.take(), price_granularity, ad_bids_state, orchestrator, @@ -1034,14 +1092,25 @@ async fn body_close_hold_loop( Ok(n) => { if let Some(hold_buffer) = hold.as_mut() { let ready = hold_buffer.push(&buffer[..n]); - write_processed_chunk( + if let Err(err) = write_processed_chunk( writer, processor, &ready, false, "Failed to process chunk", "Failed to write chunk", - )?; + ) { + if let Some(dispatched) = dispatched.take() { + emit_abandoned_auction( + services, + telemetry.observation.take(), + dispatched, + "stream_process_error", + ) + .await; + } + return Err(err); + } if hold_buffer.found_close() { let dispatched = dispatched @@ -1049,6 +1118,7 @@ async fn body_close_hold_loop( .expect("should have dispatched auction to collect"); collect_stream_auction( dispatched, + telemetry.take(), price_granularity, ad_bids_state, orchestrator, @@ -1082,6 +1152,15 @@ async fn body_close_hold_loop( } } Err(e) => { + if let Some(dispatched) = dispatched.take() { + emit_abandoned_auction( + services, + telemetry.observation.take(), + dispatched, + "stream_read_error", + ) + .await; + } return Err(Report::new(TrustedServerError::Proxy { message: format!("Failed to read origin body: {e}"), })); @@ -1095,8 +1174,32 @@ async fn body_close_hold_loop( Ok(()) } +async fn emit_abandoned_auction( + services: &RuntimeServices, + observation: Option, + dispatched: DispatchedAuction, + reason: &'static str, +) { + let Some(observation) = observation else { + return; + }; + let (request, provider_responses, abandoned_providers, elapsed_ms) = dispatched.abandon(); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Abandoned { + request: &request, + provider_responses: &provider_responses, + abandoned_providers: &abandoned_providers, + reason, + elapsed_ms, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; +} + async fn collect_stream_auction( dispatched: DispatchedAuction, + telemetry: AuctionTelemetryCarry, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, orchestrator: &AuctionOrchestrator, @@ -1109,6 +1212,18 @@ async fn collect_stream_auction( let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; + if let (Some(observation), Some(auction_request)) = + (telemetry.observation, telemetry.auction_request.as_ref()) + { + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + } log::info!( "body_close_hold_loop: collect complete - {} winning bid(s)", result.winning_bids.len() @@ -1333,53 +1448,125 @@ pub async fn handle_publisher_request( // (User-Agent, x-forwarded-for, cookies, etc.). The borrow ends when // dispatch_auction returns — DispatchedAuction holds no lifetime — so req // can be mutated and sent to origin immediately after. - let dispatched_auction = if should_run_auction { - let slots_ctx = MatchedSlotsContext { - matched_slots: &matched_slots, - request_path: &request_path, - }; - let mut auction_request = build_auction_request( - &slots_ctx, - ec_id, - &consent_context, - &request_info, - req.headers() - .get("user-agent") - .and_then(|v| v.to_str().ok()), + let mut auction_observation: Option = None; + let mut auction_request_for_telemetry: Option = None; + let mut dispatched_auction = if matched_slots.is_empty() { + None + } else { + let observation = AuctionObservationContext::from_parts( + AuctionSource::InitialNavigation, + request_host, + &request_path, + matched_slots.len(), + ec_context, ); - apply_auction_eids_and_device( - &mut auction_request, - &AuctionEidTargeting { - cookie_jar: cookie_jar.as_ref(), + + if should_run_auction { + let slots_ctx = MatchedSlotsContext { + matched_slots: &matched_slots, + request_path: &request_path, + }; + let mut auction_request = build_auction_request( + &slots_ctx, ec_id, - kv, - partner_registry: auction.registry, - ec_context, + &consent_context, + &request_info, + req.headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()), + ); + apply_auction_eids_and_device( + &mut auction_request, + &AuctionEidTargeting { + cookie_jar: cookie_jar.as_ref(), + ec_id, + kv, + partner_registry: auction.registry, + ec_context, + services, + geo: geo.as_ref(), + path_label: "Server-side", + }, + ); + let auction_context = AuctionContext { + settings, + request: &req, + timeout_ms: auction_timeout_ms, + provider_responses: None, services, - geo: geo.as_ref(), - path_label: "Server-side", - }, - ); - let auction_context = AuctionContext { - settings, - request: &req, - timeout_ms: auction_timeout_ms, - provider_responses: None, - services, - }; - auction - .orchestrator - .dispatch_auction(&auction_request, &auction_context) - .await - } else { - None + }; + match auction + .orchestrator + .dispatch_auction(&auction_request, &auction_context) + .await + { + DispatchAuctionOutcome::Dispatched(dispatched) => { + auction_request_for_telemetry = Some(auction_request); + auction_observation = Some(observation); + Some(dispatched) + } + DispatchAuctionOutcome::DispatchFailed { + request, + provider_responses, + elapsed_ms, + } => { + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::DispatchFailed { + request: &request, + provider_responses: &provider_responses, + reason: "dispatch_failed", + elapsed_ms, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + None + } + DispatchAuctionOutcome::NotStarted => { + let elapsed_ms = observation.elapsed_ms(); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::DispatchFailed { + request: &auction_request, + provider_responses: &[], + reason: "no_provider_dispatched", + elapsed_ms, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + None + } + } + } else { + let skip_reason = if !auction.orchestrator.is_enabled() { + "auction_disabled" + } else if !consent_allows_auction { + "consent_denied" + } else if is_bot { + "bot" + } else if is_prefetch { + "prefetch" + } else { + "not_ad_stack_eligible" + }; + let elapsed_ms = observation.elapsed_ms(); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Skipped { + reason: skip_reason, + elapsed_ms, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + None + } }; log::info!( "dispatch_auction: {}", if dispatched_auction.is_some() { "Some — auction running async" } else { - "None — falling back to sync or skipped" + "None — not dispatched or skipped" } ); @@ -1401,14 +1588,27 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. - let mut response = services + let mut response = match services .http_client() .send(PlatformHttpRequest::new(req, backend_name)) .await - .change_context(TrustedServerError::Proxy { - message: "Failed to proxy request to origin".to_string(), - })? - .response; + { + Ok(platform_response) => platform_response.response, + Err(err) => { + if let Some(dispatched) = dispatched_auction.take() { + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "origin_proxy_error", + ) + .await; + } + return Err(err.change_context(TrustedServerError::Proxy { + message: "Failed to proxy request to origin".to_string(), + })); + } + }; log::debug!( "Publisher origin response received: status={}, header_count={}", @@ -1474,7 +1674,7 @@ pub async fn handle_publisher_request( content_type, status, ); - if dispatched_auction.is_some() { + if let Some(dispatched) = dispatched_auction.take() { // should_run_auction is decided from request signals before the // origin content-type is known. A pass-through (2xx non-HTML) // response has no `` to inject bids into, so the dispatched @@ -1484,6 +1684,13 @@ pub async fn handle_publisher_request( content_type, status, ); + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "pass_through_response", + ) + .await; } let (parts, body) = response.into_parts(); let response = Response::from_parts(parts, EdgeBody::empty()); @@ -1507,7 +1714,7 @@ pub async fn handle_publisher_request( status, ); } - if dispatched_auction.is_some() { + if let Some(dispatched) = dispatched_auction.take() { // Same wasted-dispatch case as the pass-through arm: an // unprocessable/non-2xx response can't carry injected bids, so // the in-flight SSP requests are left uncollected. @@ -1516,6 +1723,13 @@ pub async fn handle_publisher_request( content_type, status, ); + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "buffered_unmodified_response", + ) + .await; } Ok(PublisherResponse::Buffered(response)) } @@ -1541,6 +1755,8 @@ pub async fn handle_publisher_request( content_type, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), + auction_observation, + auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, }), @@ -2048,56 +2264,109 @@ pub async fn handle_page_bids( // skip the live auction, matching the existing bot/prefetch behaviour. let ad_stack_enabled = auction_enabled && consent_allows_auction; - let winning_bids = if ad_stack_enabled && !matched_slots.is_empty() && !is_bot && !is_prefetch { - let slots_ctx = MatchedSlotsContext { - matched_slots: &matched_slots, - request_path: &path_param, - }; - let mut auction_request = build_auction_request( - &slots_ctx, - ec_id, - consent_context, - &request_info, - req.headers() - .get("user-agent") - .and_then(|v| v.to_str().ok()), + let winning_bids = if matched_slots.is_empty() { + std::collections::HashMap::new() + } else { + let observation = AuctionObservationContext::from_parts( + AuctionSource::SpaNavigation, + &request_info.host, + &path_param, + matched_slots.len(), + ec_context, ); - apply_auction_eids_and_device( - &mut auction_request, - &AuctionEidTargeting { - cookie_jar: cookie_jar.as_ref(), + if ad_stack_enabled && !is_bot && !is_prefetch { + let slots_ctx = MatchedSlotsContext { + matched_slots: &matched_slots, + request_path: &path_param, + }; + let mut auction_request = build_auction_request( + &slots_ctx, ec_id, - kv, - partner_registry: auction.registry, - ec_context, + consent_context, + &request_info, + req.headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()), + ); + apply_auction_eids_and_device( + &mut auction_request, + &AuctionEidTargeting { + cookie_jar: cookie_jar.as_ref(), + ec_id, + kv, + partner_registry: auction.registry, + ec_context, + services, + geo: geo.as_ref(), + path_label: "Page-bids", + }, + ); + let timeout_ms = co_config + .auction_timeout_ms + .unwrap_or(settings.auction.timeout_ms); + let auction_context = AuctionContext { + settings, + request: &req, + timeout_ms, + provider_responses: None, services, - geo: geo.as_ref(), - path_label: "Page-bids", - }, - ); - let timeout_ms = co_config - .auction_timeout_ms - .unwrap_or(settings.auction.timeout_ms); - let auction_context = AuctionContext { - settings, - request: &req, - timeout_ms, - provider_responses: None, - services, - }; - match auction - .orchestrator - .run_auction(&auction_request, &auction_context) - .await - { - Ok(result) => result.winning_bids, - Err(e) => { - log::warn!("page-bids auction failed: {e:?}"); - std::collections::HashMap::new() + }; + match auction + .orchestrator + .run_auction(&auction_request, &auction_context) + .await + { + Ok(result) => { + let winning_bids = result.winning_bids.clone(); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &auction_request, + result: &result, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + winning_bids + } + Err(e) => { + log::warn!("page-bids auction failed: {e:?}"); + let elapsed_ms = observation.elapsed_ms(); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::ExecutionFailed { + request: Some(&auction_request), + provider_responses: &[], + reason: "execution_failed", + elapsed_ms, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + std::collections::HashMap::new() + } } + } else { + let skip_reason = if !auction_enabled { + "auction_disabled" + } else if !consent_allows_auction { + "consent_denied" + } else if is_bot { + "bot" + } else if is_prefetch { + "prefetch" + } else { + "not_ad_stack_eligible" + }; + let elapsed_ms = observation.elapsed_ms(); + let telemetry_batch = build_auction_events( + observation, + AuctionTerminalOutcome::Skipped { + reason: skip_reason, + elapsed_ms, + }, + ); + emit_auction_events_best_effort(services, telemetry_batch).await; + std::collections::HashMap::new() } - } else { - std::collections::HashMap::new() }; let bid_map = build_bid_map( @@ -2248,6 +2517,8 @@ mod tests { content_type: "application/json".to_owned(), ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), + auction_observation: None, + auction_request: None, dispatched_auction: None, price_granularity: Default::default(), } @@ -2655,6 +2926,10 @@ mod tests { let ad_bids_state = Arc::new(Mutex::new(None)); let ctx = AuctionCollectCtx { dispatched, + telemetry: AuctionTelemetryCarry { + observation: None, + auction_request: None, + }, price_granularity: PriceGranularity::default(), ad_bids_state: &ad_bids_state, orchestrator: &orchestrator, @@ -3296,6 +3571,8 @@ mod tests { content_type: "text/css".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -3341,6 +3618,8 @@ mod tests { content_type: "text/html; charset=utf-8".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -3381,6 +3660,8 @@ mod tests { .to_string(), ), ad_bids_state: state, + auction_observation: None, + auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -3428,6 +3709,8 @@ mod tests { content_type: "text/html".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -3533,6 +3816,8 @@ mod tests { content_type: "text/html; charset=utf-8".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; @@ -3587,6 +3872,8 @@ mod tests { content_type: "text/html".to_string(), ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), }; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index b99fa9a0..7389ca47 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1721,6 +1721,170 @@ impl Proxy { } } +/// Direct Tinybird Events API telemetry configuration. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TinybirdSettings { + /// Master enablement for auction telemetry ingestion. + #[serde(default)] + pub enabled: bool, + /// Regional Tinybird API host, without scheme or path. + #[serde(default)] + pub api_host: String, + /// Fastly Secret Store name containing Tinybird append tokens. + #[serde(default = "default_tinybird_secret_store")] + pub secret_store: String, + /// Auction Events API datasource name. + #[serde(default = "default_tinybird_auction_dataset")] + pub auction_dataset: String, + /// Secret key containing the auction datasource APPEND token. + #[serde(default = "default_tinybird_auction_token_secret")] + pub auction_token_secret: String, + /// Enable optional access-log telemetry. + #[serde(default)] + pub access_enabled: bool, + /// Access-log Events API datasource name. + #[serde(default = "default_tinybird_access_dataset")] + pub access_dataset: String, + /// Secret key containing the access-log datasource APPEND token. + #[serde(default = "default_tinybird_access_token_secret")] + pub access_token_secret: String, + /// Fraction of requests to emit for optional access telemetry. + #[serde(default)] + pub access_sample_rate: f64, + /// Defensive maximum NDJSON body size for one Events API request. + #[serde(default = "default_tinybird_max_body_bytes")] + pub max_body_bytes: usize, +} + +fn default_tinybird_secret_store() -> String { + "ts_secrets".to_owned() +} + +fn default_tinybird_auction_dataset() -> String { + "auction_events_raw".to_owned() +} + +fn default_tinybird_auction_token_secret() -> String { + "tinybird_auction_append_token".to_owned() +} + +fn default_tinybird_access_dataset() -> String { + "access_logs_raw".to_owned() +} + +fn default_tinybird_access_token_secret() -> String { + "tinybird_access_append_token".to_owned() +} + +fn default_tinybird_max_body_bytes() -> usize { + 1024 * 1024 +} + +impl Default for TinybirdSettings { + fn default() -> Self { + Self { + enabled: false, + api_host: String::new(), + secret_store: default_tinybird_secret_store(), + auction_dataset: default_tinybird_auction_dataset(), + auction_token_secret: default_tinybird_auction_token_secret(), + access_enabled: false, + access_dataset: default_tinybird_access_dataset(), + access_token_secret: default_tinybird_access_token_secret(), + access_sample_rate: 0.0, + max_body_bytes: default_tinybird_max_body_bytes(), + } + } +} + +impl TinybirdSettings { + fn normalize(&mut self) { + self.api_host = self.api_host.trim().to_ascii_lowercase(); + self.secret_store = self.secret_store.trim().to_owned(); + self.auction_dataset = self.auction_dataset.trim().to_owned(); + self.auction_token_secret = self.auction_token_secret.trim().to_owned(); + self.access_dataset = self.access_dataset.trim().to_owned(); + self.access_token_secret = self.access_token_secret.trim().to_owned(); + } + + fn prepare_runtime(&mut self) -> Result<(), Report> { + self.normalize(); + if !(0.0..=1.0).contains(&self.access_sample_rate) { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird.access_sample_rate must be between 0.0 and 1.0".to_owned(), + })); + } + if self.max_body_bytes < 1024 { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird.max_body_bytes must be at least 1024".to_owned(), + })); + } + if !self.enabled && !self.access_enabled { + return Ok(()); + } + validate_tinybird_api_host(&self.api_host)?; + if self.secret_store.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: + "tinybird.secret_store must not be empty when Tinybird telemetry is enabled" + .to_owned(), + })); + } + if self.enabled { + validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; + validate_tinybird_secret(&self.auction_token_secret, "tinybird.auction_token_secret")?; + } + if self.access_enabled { + validate_tinybird_dataset(&self.access_dataset, "tinybird.access_dataset")?; + validate_tinybird_secret(&self.access_token_secret, "tinybird.access_token_secret")?; + } + Ok(()) + } +} + +fn validate_tinybird_api_host(host: &str) -> Result<(), Report> { + if host.is_empty() + || host.contains('/') + || host.contains(':') + || host.chars().any(char::is_control) + || host.starts_with("http://") + || host.starts_with("https://") + { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird.api_host must be a regional host without scheme, port, or path" + .to_owned(), + })); + } + validate_host_header_override_value(host).map_err(|reason| { + Report::new(TrustedServerError::Configuration { + message: format!("tinybird.api_host {reason}"), + }) + }) +} + +fn validate_tinybird_dataset(value: &str, setting: &str) -> Result<(), Report> { + if value.is_empty() + || value.len() > 128 + || !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{setting} must be a non-empty datasource identifier"), + })); + } + Ok(()) +} + +fn validate_tinybird_secret(value: &str, setting: &str) -> Result<(), Report> { + if value.is_empty() || value.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{setting} must be a non-empty Secret Store key"), + })); + } + Ok(()) +} + /// Debug-only features. All flags default to `false` (off in production). #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -1789,6 +1953,8 @@ pub struct Settings { #[serde(default)] pub image_optimizer: ImageOptimizerSettings, #[serde(default)] + pub tinybird: TinybirdSettings, + #[serde(default)] pub debug: DebugConfig, } @@ -1892,6 +2058,7 @@ impl Settings { pub fn prepare_runtime(&mut self) -> Result<(), Report> { self.image_optimizer.prepare_runtime()?; self.proxy.prepare_runtime()?; + self.tinybird.prepare_runtime()?; self.validate_asset_image_optimizer_profile_sets()?; for handler in &self.handlers { @@ -2430,6 +2597,49 @@ mod tests { use crate::redacted::Redacted; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + #[test] + fn tinybird_defaults_to_disabled_placeholders() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without tinybird block"); + + assert!( + !settings.tinybird.enabled, + "Tinybird should default disabled" + ); + assert_eq!(settings.tinybird.secret_store, "ts_secrets"); + assert_eq!(settings.tinybird.auction_dataset, "auction_events_raw"); + assert_eq!( + settings.tinybird.auction_token_secret, + "tinybird_auction_append_token" + ); + } + + #[test] + fn tinybird_enabled_requires_host_dataset_and_token() { + let toml = format!( + "{}\n[tinybird]\nenabled = true\napi_host = \"https://api.example.com/path\"\n", + crate_test_settings_str() + ); + + let err = Settings::from_toml(&toml).expect_err("should reject invalid api host"); + assert!( + format!("{err:?}").contains("tinybird.api_host"), + "should report tinybird.api_host validation error: {err:?}" + ); + } + + #[test] + fn tinybird_accepts_region_host_without_scheme() { + let toml = format!( + "{}\n[tinybird]\nenabled = true\napi_host = \"api.us-east.aws.tinybird.co\"\n", + crate_test_settings_str() + ); + + let settings = Settings::from_toml(&toml).expect("should accept Tinybird region host"); + assert!(settings.tinybird.enabled); + assert_eq!(settings.tinybird.api_host, "api.us-east.aws.tinybird.co"); + } + #[test] fn test_settings_from_valid_toml() { let toml_str = crate_test_settings_str(); diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md new file mode 100644 index 00000000..a03495b1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md @@ -0,0 +1,692 @@ +# Auction telemetry direct Tinybird implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans or an equivalent task-by-task execution workflow before coding. This plan is intentionally implementation-only guidance; do not treat the revised spec as optional. + +**Goal:** Replace the prior Fastly real-time logging + relay direction with direct, best-effort Tinybird Events API ingestion from Fastly Compute. Auction telemetry is required for phase 1. Ops access telemetry is optional and must stay configuration-gated. + +**Core invariant:** Telemetry failure must never fail, delay, or change a customer request. Backend creation, Secret Store lookup, JSON/NDJSON serialization, and `send_async` setup errors are logged at a bounded level and dropped. + +**Delivery tradeoff:** Direct Tinybird ingestion has weaker delivery guarantees than Fastly real-time logging. It has no Fastly-managed batching, retry, response inspection, durable replay, or delivery confirmation because the application intentionally calls `send_async` and drops the pending Tinybird response. + +**Reference spec:** `docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md` + +--- + +## 1. Desired implementation summary + +Implement a core-owned auction telemetry lifecycle and row builder, plus a Fastly adapter-owned Tinybird direct-ingest sink: + +```text +auction candidate + -> create AuctionObservationContext with fresh telemetry UUID + -> run / skip / dispatch / collect / abandon auction + -> build one bounded batch of summary/provider_call/bid rows + -> serialize rows as NDJSON + -> dynamic backend https:// with SNI + Host = + -> POST /v0/events?name=auction_events_raw + -> Authorization: Bearer + -> Request::send_async(...) + -> drop PendingRequest without waiting for Tinybird response +``` + +The implementation must: + +- cover `POST /auction`, `GET /__ts/page-bids`, and initial-navigation SSAT dispatch/collect; +- emit exactly one summary row per auction candidate per successful Compute execution path; +- batch all rows for one auction observation into one Tinybird Events API POST; +- omit EC ID, internal `AuctionRequest.id`, IP, raw user agent, full URL, query strings, and fragments; +- generate a fresh random telemetry UUID unrelated to EC or request IDs; +- emit provider-call rows for provider launch, parse, transport, timeout/no-response, no-bid, success, and abandoned outcomes where observable; +- avoid invented seat-level no-bids; +- keep the customer response path isolated from telemetry failures. + +--- + +## 2. Current code areas inspected + +### Auction core + +- `crates/trusted-server-core/src/auction/mod.rs` + - Exports auction types and orchestrator. +- `crates/trusted-server-core/src/auction/endpoints.rs` + - `handle_auction` runs the explicit `POST /auction` path. + - It short-circuits on consent denial with an empty OpenRTB response. +- `crates/trusted-server-core/src/auction/orchestrator.rs` + - `run_auction` handles synchronous page-bids and explicit auction execution. + - `dispatch_auction` launches split-phase SSAT provider requests and currently returns `Option`. + - `collect_dispatched_auction` collects split-phase responses and returns best-effort `OrchestrationResult`. + - Launch failures are represented as `AuctionResponse::error` in the synchronous path, but split dispatch currently logs launch failures without retaining them on the token. + - Outstanding split requests that are never collected are currently dropped by caller branches. +- `crates/trusted-server-core/src/auction/types.rs` + - `AuctionRequest.id` can be EC-derived on SSAT paths and must not be reused for telemetry. + - `AuctionResponse`, `Bid`, and `BidStatus` contain most row-builder source data. + +### Publisher auction paths + +- `crates/trusted-server-core/src/publisher.rs` + - `handle_publisher_request` creates SSAT auction requests, calls `dispatch_auction`, and later returns `PublisherResponse::Stream` carrying `OwnedProcessResponseParams.dispatched_auction`. + - `stream_publisher_body_async`, `stream_html_with_auction_hold`, `body_close_hold_loop`, and `collect_stream_auction` collect SSAT auctions during streaming after headers/body chunks can already be committed. + - `ResponseRoute::PassThrough` and `ResponseRoute::BufferedUnmodified` currently log that dispatched SSP requests will not be collected; those branches need explicit abandonment telemetry. + - Origin proxy errors after dispatch currently return an error and drop the dispatch token; those also need abandonment telemetry. + - `handle_page_bids` runs SPA auctions synchronously with `run_auction` and swallows auction errors into an empty bids response. + +### Platform and Fastly adapter + +- `crates/trusted-server-core/src/platform/` + - `RuntimeServices` already exposes `secret_store()`, `backend()`, `http_client()`, `geo()`, and `client_info()`. + - `PlatformHttpClient::send_async` is platform-neutral and returns `PlatformPendingRequest`. +- `crates/trusted-server-adapter-fastly/src/platform.rs` + - `FastlyPlatformSecretStore` reads Fastly Secret Store values. + - `FastlyPlatformBackend` delegates dynamic backend creation to `BackendConfig`. + - `FastlyPlatformHttpClient::send_async` converts edge HTTP requests to `fastly::Request`, calls Fastly `send_async`, and does not require awaiting a response. +- `crates/trusted-server-adapter-fastly/src/backend.rs` + - Dynamic backend creation already sets `.enable_ssl().sni_hostname(self.host)` and `.override_host(&host_header)`. + - For a Tinybird API host, use `scheme=https`, `host=`, no host override unless needed; SNI and Host will match the host. +- `crates/trusted-server-adapter-fastly/src/main.rs` + - Legacy path routes all initial-navigation SSAT today. + - EdgeZero path currently disables publisher fallback SSAT by passing empty slots; do not rely on EdgeZero initial-navigation telemetry until EdgeZero SSAT is wired, but keep new APIs compatible with that future path. +- `crates/trusted-server-adapter-fastly/src/app.rs` + - EdgeZero `POST /auction` uses `handle_auction` and should inherit explicit auction telemetry when services/sink are wired there. +- `trusted-server.toml` + - Needs a new `[tinybird]` section with disabled placeholders and no real credentials. +- `fastly.toml` + - Dynamic backends do not need static declaration. + - Local Secret Store fixtures can add placeholder Tinybird token keys if needed for tests/dev. + +### Tinybird project + +- No existing `tinybird/` directory was found. Plan to create one. + +--- + +## 3. Proposed module/API shape + +### 3.1 Core telemetry module + +Create `crates/trusted-server-core/src/auction/telemetry.rs` and export it from `auction/mod.rs`. + +Recommended core types: + +- `AuctionSource` + - `InitialNavigation` + - `SpaNavigation` + - `AuctionApi` +- `AuctionTerminalStatus` + - `Completed` + - `ExecutionFailed` + - `DispatchFailed` + - `Abandoned` + - `Skipped` +- `AuctionSkipReason` + - `ConsentDenied` + - `AuctionDisabled` + - `Bot` + - `Prefetch` + - `NoProviders` + - bounded fallback reason +- `TriStateFlag` + - numeric mapping for Tinybird: `0`, `1`, `2` for false/true/unknown according to the spec fields. +- `AuctionObservationContext` + - `auction_id: uuid::Uuid` generated with `Uuid::new_v4()`. + - `auction_source`. + - `publisher_domain`. + - normalized `page_path` with no query, no fragment, bounded length, and dynamic-looking segments redacted/bucketed. + - `country`, `region` from existing geo context. + - `is_mobile`, `is_known_browser` snapshotted from existing device signals; do not reparse UA. + - `gdpr_applies` and `consent_present`. + - `slot_count` and start instant/time metadata needed for elapsed time. +- `AuctionEventRow` + - serde-serializable row matching `auction_events_raw` schema. + - Use nullable fields (`Option`) for row-kind-specific columns. +- `AuctionEventBatch` + - owns `Vec` and exposes `row_count()` / `to_ndjson(max_body_bytes)`. + - Serialization appends `\n` after every row. + +Recommended pure builder API: + +```rust +pub fn build_auction_events(input: AuctionTelemetryInput<'_>) -> AuctionEventBatch; + +pub struct AuctionTelemetryInput<'a> { + pub observation: AuctionObservationContext, + pub terminal: AuctionTerminalOutcome<'a>, +} + +pub enum AuctionTerminalOutcome<'a> { + Completed { + request: &'a AuctionRequest, + result: &'a OrchestrationResult, + }, + ExecutionFailed { + request: Option<&'a AuctionRequest>, + provider_responses: &'a [AuctionResponse], + reason: &'a str, + elapsed_ms: u64, + }, + DispatchFailed { + request: &'a AuctionRequest, + provider_responses: &'a [AuctionResponse], + reason: &'a str, + elapsed_ms: u64, + }, + Abandoned { + request: &'a AuctionRequest, + provider_responses: &'a [AuctionResponse], + abandoned_providers: &'a [AbandonedProviderCall], + reason: &'a str, + elapsed_ms: u64, + }, + Skipped { + reason: &'a str, + elapsed_ms: u64, + }, +} +``` + +Keep the builder pure and independently testable. It must not read settings, secrets, environment, backends, or clocks other than values supplied in the input. + +### 3.2 Sink abstraction + +Add a small core trait so core paths can emit without knowing Tinybird/Fastly details. The lowest-plumbing option is to add it to `RuntimeServices`: + +```rust +#[async_trait::async_trait(?Send)] +pub trait AuctionTelemetrySink: Send + Sync { + async fn emit_auction_events( + &self, + services: &RuntimeServices, + rows: &[AuctionEventRow], + ) -> Result<(), Report>; +} +``` + +Add: + +- `NoopAuctionTelemetrySink` in core for defaults/tests. +- `RuntimeServicesBuilder::auction_telemetry_sink(...)` with default/noop if omitted, or require it explicitly like existing services. +- `RuntimeServices::auction_telemetry_sink()` accessor. +- A helper such as `emit_auction_events_best_effort(services, batch).await` that catches and logs errors and returns `()`. + +The helper is important: call sites should not accidentally propagate telemetry errors. + +### 3.3 Fastly Tinybird sink + +Create adapter-owned module: + +- `crates/trusted-server-adapter-fastly/src/tinybird.rs` + +Types/functions: + +- `TinybirdConfig` parsed from `Settings.tinybird`. +- `FastlyTinybirdAuctionTelemetrySink` implementing `AuctionTelemetrySink`. +- `build_tinybird_backend_spec(api_host, timeout)` returning `PlatformBackendSpec` with: + - `scheme = "https"` + - `host = ` + - `host_header_override = None` + - `certificate_check = true` + - short `first_byte_timeout` for setup path; the response is not awaited, but backend config still needs sane values. +- `events_api_uri(api_host, dataset)` returning `https:///v0/events?name=`. +- `authorization_header(token)` that never logs or exposes token values. + +Behavior: + +1. If `tinybird.enabled = false`, return `Ok(())` without doing work. +2. Convert rows to NDJSON, enforcing `tinybird.max_body_bytes` and a defensive row-count cap. +3. Read append token from Fastly Secret Store via configured store/key. +4. Ensure dynamic backend for ``. +5. Build `POST` request with: + - `Authorization: Bearer ` + - `Content-Type: application/x-ndjson` unless final Tinybird docs/tests prove a different header is required. Current Tinybird Events API docs consume NDJSON by default and examples omit a content type, so `application/x-ndjson` should be treated as an implementation choice to smoke-test, not as an assumption that Tinybird rejects other content types. + - NDJSON body with trailing newline. +6. Call `services.http_client().send_async(...)`. +7. Drop the returned `PlatformPendingRequest` immediately. +8. Return only setup errors to the best-effort wrapper, which logs and suppresses them. + +Do not inspect Tinybird HTTP responses in production emission. + +### 3.4 Settings shape + +Add a new settings struct in `crates/trusted-server-core/src/settings.rs`: + +```toml +[tinybird] +enabled = false +api_host = "" +secret_store = "ts_secrets" +auction_dataset = "auction_events_raw" +auction_token_secret = "tinybird_auction_append_token" +access_enabled = false +access_dataset = "access_logs_raw" +access_token_secret = "tinybird_access_append_token" +access_sample_rate = 0.0 +max_body_bytes = 1048576 +``` + +Notes: + +- The revised spec lists token secret keys but not a Secret Store name. The implementation needs a store name because `PlatformSecretStore` reads by `StoreName`; use `tinybird.secret_store` with a safe default such as `ts_secrets` unless the user prefers separate per-token store settings. +- Validate only when `enabled` or `access_enabled` is true: + - `api_host` must be non-empty, host-only, no scheme, no path, no control characters. + - dataset names must be non-empty and bounded. + - secret key names must be non-empty. + - `access_sample_rate` in `[0.0, 1.0]`. + - `max_body_bytes` above a small minimum and below a defensive cap. +- Do not commit real hosts or tokens. + +--- + +## 4. Step-by-step implementation phases + +### Phase 0: Pre-flight docs/API verification + +- [ ] Re-read the revised spec. +- [ ] Check Tinybird Events API docs immediately before implementation: + - endpoint is still `POST /v0/events?name=`; + - token scope is still `DATASOURCE:APPEND` / datasource APPEND token; + - NDJSON remains the default event format; + - request size/rate limits for the selected plan are known; + - whether Tinybird documents or requires a specific `Content-Type` for NDJSON. +- [ ] Confirm no real customer names, real domains, or credentials are introduced in docs/tests/config. + +### Phase 1: Add settings and no-op plumbing + +- [ ] Add `TinybirdSettings` to `settings.rs` with defaults and validation. +- [ ] Add `[tinybird]` disabled placeholder block to `trusted-server.toml`. +- [ ] If helpful for local smoke tests, add placeholder local Secret Store entries to `fastly.toml`; keep values obviously fake. +- [ ] Add the core `AuctionTelemetrySink` trait and no-op implementation. +- [ ] Add the sink to `RuntimeServices` and update all builders/test support. +- [ ] Wire `NoopAuctionTelemetrySink` initially in both Fastly service builders so behavior is unchanged. +- [ ] Tests: + - settings parse/defaults; + - enabled Tinybird requires `api_host` and token secret key; + - invalid `api_host` with scheme/path/control characters is rejected; + - `access_sample_rate` bounds. + +### Phase 2: Add pure auction event model and builder + +- [ ] Create `auction/telemetry.rs` with row schema structs, enums, and `build_auction_events`. +- [ ] Implement route-safe page path normalization: + - strip query and fragment; + - force leading `/`; + - cap length; + - redact UUID-like, long numeric, base64/hash-like dynamic segments. +- [ ] Implement provider status mapping: + - `BidStatus::Success` with bids => `success`; + - `BidStatus::NoBid` or success with zero parsed bids where provider semantics indicate no-bid => `nobid`; + - metadata `error_type = launch_failed` => `launch_error`; + - metadata `error_type = parse_response` => `parse_error`; + - metadata `error_type = transport` => `transport_error`; + - explicit timeout/no-response outcomes => `timeout` when available; + - split uncollected providers => `abandoned`. +- [ ] Implement bid row construction: + - one row per actual provider-returned bid; + - no invented seat rows for no-bids/errors; + - canonical mediator win matching by `(slot_id, bidder, ad_id)`, fallback `(slot_id, bidder)`; + - exactly one `is_win = 1` per winning slot; + - mediator-derived winner only when no original provider bid matches. +- [ ] Tests: + - completed auction with success/no-bid/error providers emits one summary, provider rows, and bid rows; + - no-bid/error provider rows have no slot/seat values; + - mediated wins do not double-count; + - telemetry UUID is fresh and not equal to `AuctionRequest.id` or EC-like values; + - page path normalization removes query/fragment and redacts dynamic segments; + - NDJSON serialization emits one valid JSON object per line with no log prefix. + +### Phase 3: Retain provider outcomes needed by telemetry + +- [ ] Refactor `DispatchedAuction` to carry: + - cloned `AuctionRequest` already present; + - observation context once telemetry is wired; + - launch-failure `AuctionResponse` rows from failed `request_bids` calls; + - pending backend/provider names still in flight; + - enough provider metadata to build abandoned provider-call rows without waiting. +- [ ] Replace or extend `dispatch_auction` return type so callers can distinguish: + - not a candidate / no configured providers; + - all providers skipped or launch failed (`dispatch_failed`); + - at least one provider launched (`DispatchedAuction`). +- [ ] Preserve minimal-change compatibility where possible, but do not keep an `Option` that loses all-failed launch details. +- [ ] Ensure `collect_dispatched_auction` includes launch-failure responses from dispatch token in the final `OrchestrationResult`. +- [ ] Add an `abandon_dispatched_auction(dispatched, reason)` helper that consumes a token without selecting/waiting and returns abandonment telemetry input: + - one summary with `terminal_status = abandoned`; + - prior launch-failure provider rows if any; + - abandoned provider-call rows for every still-pending provider. +- [ ] Tests: + - split dispatch launch failures are retained through collect; + - all launch failures produce `dispatch_failed` telemetry data instead of disappearing; + - abandonment helper emits one abandoned summary and one abandoned provider row per pending provider. + +### Phase 4: Instrument explicit `POST /auction` + +- [ ] Create `AuctionObservationContext` early after parsing the request body and before consent gating. +- [ ] If consent denies server-side auction, emit `skipped` with reason `consent_denied` and return the existing no-bid response. +- [ ] On `run_auction` success, emit `completed` after result creation and before response conversion. +- [ ] On `run_auction` error, emit `execution_failed` or `dispatch_failed` best-effort before returning the existing error. +- [ ] Do not let telemetry errors change `/auction` status/body. +- [ ] Tests: + - consent-gated `/auction` emits skipped and does not contact providers; + - successful `/auction` emits exactly one completed summary; + - failed `/auction` emits a failure summary and still returns the existing error behavior. + +### Phase 5: Instrument SPA `GET /__ts/page-bids` + +- [ ] Create an observation only when matched slots exist; no matched slots are not auction candidates. +- [ ] Emit `skipped` for matched-slot requests prevented by: + - `[auction].enabled = false`; + - consent denial; + - bot; + - prefetch. +- [ ] On `run_auction` success, emit `completed` and use the existing winning-bids response. +- [ ] On `run_auction` error, emit `execution_failed` best-effort and preserve existing behavior of returning empty bids. +- [ ] Tests: + - same-origin valid page-bids emits completed when auction succeeds; + - disabled/consent/bot/prefetch matched-slot cases emit skipped; + - no matched slot emits no auction telemetry; + - auction failure emits failure telemetry but response remains valid JSON with empty bids. + +### Phase 6: Instrument initial-navigation SSAT dispatch/collect/abandon + +- [ ] In `handle_publisher_request`, create observation for matched-slot auction candidates before calling dispatch. +- [ ] Emit `skipped` when matched slots exist but policy prevents initiation: + - auction disabled; + - consent denied; + - bot; + - prefetch. +- [ ] Attach observation to the `DispatchedAuction` token for successful dispatch. +- [ ] If dispatch cannot launch any provider requests, emit `dispatch_failed` and continue origin proxying/injection behavior as today. +- [ ] On origin proxy error after dispatch, consume token with `abandoned` reason such as `origin_proxy_error`, emit, then return the existing proxy error. +- [ ] In `ResponseRoute::PassThrough`, consume token with `abandoned` reason `pass_through_response` before returning `PublisherResponse::PassThrough`. +- [ ] In `ResponseRoute::BufferedUnmodified`, consume token with `abandoned` reason `unmodified_response` or more specific bounded reason (`unsupported_encoding`, `non_success_status`, `empty_request_host`) before returning buffered response. +- [ ] In streaming collect helpers, emit `completed` after `collect_dispatched_auction` returns. +- [ ] In stream read/process error branches before collection, consume token with `abandoned` reason such as `stream_read_error` where possible. +- [ ] Avoid a `Drop`-based async cleanup design; Rust `Drop` cannot `await` or safely perform best-effort async Tinybird emission. Prefer explicit token consumption at every branch. +- [ ] Tests: + - stream collection emits exactly one completed summary; + - pass-through after dispatch emits abandoned instead of dropping token; + - buffered-unmodified after dispatch emits abandoned; + - origin proxy error after dispatch emits abandoned; + - unsupported encoding abandoned provider rows are emitted. + +### Phase 7: Implement Fastly Tinybird direct sink + +- [ ] Add `tinybird.rs` in the Fastly adapter and wire module imports. +- [ ] Build sink from settings in `build_state_from_settings` or service construction. +- [ ] Put the sink into both legacy `build_runtime_services` and EdgeZero `build_per_request_services` paths. +- [ ] Implement Secret Store token lookup with `StoreName::from(settings.tinybird.secret_store.as_str())`. +- [ ] Implement dynamic backend ensure with TLS SNI and Host matching `tinybird.api_host`. +- [ ] Implement NDJSON POST request to `/v0/events?name=auction_events_raw` or configured dataset. +- [ ] Use `send_async` and drop the pending response. +- [ ] Never log token values or the Authorization header. +- [ ] Bound payload size and row count; drop oversize telemetry batch with `log::warn!`. +- [ ] Tests with mock platform services: + - one auction observation creates one POST; + - URI path/query is `/v0/events?name=auction_events_raw`; + - backend spec host is `` and scheme is `https`; + - `Authorization` uses bearer token from secret interface; + - NDJSON content type is present; + - response is not awaited (`send_async` called, no `select`/`wait`); + - missing secret disables/drops emission without panic. + +### Phase 8: Add Tinybird project files + +Create a new `tinybird/` directory if none exists. + +Recommended structure: + +```text +tinybird/ + datasources/ + auction_events_raw.datasource + access_logs_raw.datasource # optional/config-gated ops phase + auction_overview_rollup.datasource + auction_provider_stats_rollup.datasource + auction_bid_stats_rollup.datasource + pipes/ + auction_overview_mv.pipe + auction_provider_stats_mv.pipe + auction_bid_stats_mv.pipe + endpoints/ + auction_summary.pipe + provider_health.pipe + provider_latency.pipe + seat_yield.pipe + ingestion_freshness.pipe + quarantine_counts.pipe + fixtures/ + auction_events_raw.ndjson + tests/ + auction_summary.test + provider_health.test + seat_yield.test +``` + +Datasource requirements: + +- `auction_events_raw` + - append-only landing datasource; + - `ENGINE MergeTree`; + - sorting key roughly `(event_date, publisher_domain, event_kind, auction_source, auction_id)`; + - `event_date = toDate(event_ts)`; + - `TTL event_date + INTERVAL 30 DAY`; + - declare scoped token: `TOKEN ts_ingest APPEND`. +- Optional `access_logs_raw` + - only if ops access telemetry is implemented in the same PR; + - separate token: `TOKEN ts_access_ingest APPEND`. +- Rollups + - `AggregatingMergeTree`; + - retain approximately 13 months; + - materialized pipes use `*State`, published endpoints use `*Merge`. + +Published endpoint requirements: + +- parameters for time range, publisher, optional auction source/provider filters; +- sample counts exposed alongside rates/quantiles; +- directional/best-effort dashboard note supported by endpoint data; +- ingestion freshness and quarantine visibility. + +Tinybird fixture tests must prove: + +- summary rows are not multiplied by provider/bid rows; +- fill rate uses completed summary rows; +- provider no-bid/error rates use provider-call rows; +- seat win rate uses canonical bid rows; +- abandonment rate includes abandoned summaries; +- multiple bids for one auction still count as one auction. + +### Phase 9: Optional ops access telemetry + +Do this only if requested for the implementation PR; auction telemetry alone satisfies phase 1. + +- [ ] Add `AccessTelemetrySink` or extend the Tinybird sink with `emit_access_log`. +- [ ] Emit one sampled row at top-level response finalization where status and elapsed time are known. +- [ ] Normalize path to bounded route label, not raw path. +- [ ] Use separate dataset and append token. +- [ ] Respect `tinybird.access_enabled` and `tinybird.access_sample_rate`. +- [ ] Label sampled counts as estimates in endpoints/dashboard docs. + +--- + +## 5. Files likely to change + +### Core Rust + +- `crates/trusted-server-core/src/auction/mod.rs` +- `crates/trusted-server-core/src/auction/telemetry.rs` (new) +- `crates/trusted-server-core/src/auction/endpoints.rs` +- `crates/trusted-server-core/src/auction/orchestrator.rs` +- `crates/trusted-server-core/src/auction/types.rs` only if shared types need small additions +- `crates/trusted-server-core/src/publisher.rs` +- `crates/trusted-server-core/src/platform/mod.rs` +- `crates/trusted-server-core/src/platform/traits.rs` or new `platform/telemetry.rs` +- `crates/trusted-server-core/src/platform/types.rs` +- `crates/trusted-server-core/src/platform/test_support.rs` +- `crates/trusted-server-core/src/settings.rs` +- `crates/trusted-server-core/Cargo.toml` if new dependency features are needed; prefer existing `uuid`, `serde`, and `serde_json`. + +### Fastly adapter + +- `crates/trusted-server-adapter-fastly/src/main.rs` +- `crates/trusted-server-adapter-fastly/src/app.rs` +- `crates/trusted-server-adapter-fastly/src/platform.rs` +- `crates/trusted-server-adapter-fastly/src/tinybird.rs` (new) +- `crates/trusted-server-adapter-fastly/src/backend.rs` only if backend helper tests need adjustment; existing SNI/Host behavior likely suffices. +- `crates/trusted-server-adapter-fastly/src/route_tests.rs` if route-level telemetry assertions are placed there. + +### Config/docs/Tinybird + +- `trusted-server.toml` +- `fastly.toml` +- `tinybird/**` (new project files) +- `docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md` only if implementation discovers spec corrections; otherwise leave it. +- Potential Grafana dashboard docs under `docs/` if the implementation includes dashboard JSON/guidance. + +--- + +## 6. Fastly adapter configuration and Secret Store handling + +- Dynamic backend: + - do not add static `fastly.toml` backends for Tinybird; + - use runtime dynamic backend with `https` scheme and `api_host` as host; + - TLS SNI must equal `api_host`; + - outbound `Host` must equal `api_host`. +- Secret Store: + - Tinybird append token lives in Fastly Secret Store, not config store and not repo files; + - use a datasource-scoped APPEND token for `auction_events_raw`, declared as `TOKEN ts_ingest APPEND` in Tinybird datasource code or created through the Tinybird UI; + - do not use Grafana read token or admin token for ingest; + - recommended config separates `secret_store` from `auction_token_secret` key name; + - missing store/key or invalid UTF-8 token must drop telemetry and continue. +- Local/dev: + - keep `tinybird.enabled = false` by default; + - any local fixture token must be fake and clearly non-production; + - smoke tests can enable Tinybird through env/config override only. + +--- + +## 7. Tests to add + +### Pure builder/unit tests + +- Completed auction emits: + - exactly one summary row; + - one provider-call row per attempted provider; + - one bid row per provider-returned bid. +- No-bid/error providers do not invent slot, seat, or bid rows. +- Launch, parse, transport, timeout, no-bid, success, abandoned statuses map correctly. +- Mediated winners produce exactly one canonical winning bid row per slot. +- Telemetry UUID is fresh and independent of `AuctionRequest.id`/EC. +- Privacy fields are absent from serialized rows: + - no EC ID; + - no internal request ID; + - no IP; + - no raw UA; + - no full URL/query/fragment. +- Page path normalization is bounded and redacts dynamic segments. +- NDJSON serialization emits valid single-line JSON rows and a trailing newline. + +### Lifecycle tests + +- `POST /auction`: + - consent denied => skipped summary; + - success => completed summary; + - execution failure => failure summary and unchanged error behavior. +- `GET /__ts/page-bids`: + - success => completed summary; + - disabled/consent/bot/prefetch with matched slots => skipped summary; + - no matched slots => no auction telemetry. +- Initial navigation SSAT: + - dispatch + collect => completed summary; + - pass-through response => abandoned summary/provider rows; + - buffered-unmodified response => abandoned summary/provider rows; + - origin proxy error after dispatch => abandoned summary/provider rows; + - dispatch all providers failed => dispatch_failed summary. + +### Adapter sink tests + +- Enabled sink creates one POST per auction observation. +- URL is `https:///v0/events?name=auction_events_raw`. +- Dynamic backend spec uses TLS host and Host header matching ``. +- Authorization bearer token is read from the secret interface. +- Token values are not logged or exposed in errors. +- Missing token/store drops emission safely. +- Oversize payload drops emission before send. +- `send_async` is called and response is not awaited. + +### Tinybird tests + +- Fixture NDJSON covers all row kinds and all auction sources. +- Rollups do not multiply summary counts by provider/bid rows. +- Endpoint tests verify fill, no-bid, error, win, CPM, latency, and abandonment calculations. +- Quarantine/freshness endpoint queries compile. + +--- + +## 8. Validation commands + +Run at minimum after implementation: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace +cd docs && npm run format +``` + +If JS/TS files are touched: + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && npm run format +``` + +If Tinybird files are added and the CLI/project supports these commands: + +```bash +cd tinybird && tb test +cd tinybird && tb deploy --dry-run +``` + +Deployment smoke test guidance: + +```bash +# Pseudocode: use the real regional host and a scoped APPEND token from Secret Store. +curl \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/x-ndjson" \ + --data-binary @tinybird/fixtures/auction_events_raw.ndjson \ + "https:///v0/events?name=auction_events_raw" +``` + +Then verify rows arrive, ingestion freshness updates, and Tinybird quarantine stays empty. This smoke test is required because production fire-and-forget emission does not inspect Tinybird responses. + +--- + +## 9. Risks, gotchas, and open questions + +- **Weaker delivery guarantees:** Direct `send_async` ingestion can lose rows when setup fails, the guest exits before background delivery finishes, Tinybird rejects the request, or token/host/plan configuration is wrong. This is accepted only because the data is directional. +- **No customer request failure:** Every call site must use a best-effort helper that logs and suppresses telemetry errors. +- **Content type:** Current Tinybird Events API docs describe NDJSON ingestion and examples omit `Content-Type`; the spec expects `application/x-ndjson`. Use `application/x-ndjson` unless pre-implementation verification or smoke tests show Tinybird requires otherwise. +- **Secret Store name:** The spec names token keys but not the Secret Store name. Add `tinybird.secret_store` or confirm a project-wide default before coding. +- **Split-path abandonment:** Current pass-through/buffered-unmodified/origin-error branches drop dispatched auctions. Missing any branch will undercount abandoned SSAT provider work. +- **Async cleanup:** Do not rely on `Drop` to emit abandonment telemetry; it cannot await. Explicitly consume tokens. +- **Provider outcome fidelity:** Split dispatch currently loses launch failures. Retain these in `DispatchedAuction` or a richer dispatch result before building telemetry. +- **Timeout classification:** Some dropped pending requests are indistinguishable from abandoned unless the select loop observes a timeout/transport error. Do not label unobserved dropped split requests as timeout; use `abandoned`. +- **TTFB/latency:** JSON serialization, Secret Store lookup, backend ensure, and `send_async` setup still cost CPU/runtime time. Keep payload bounded and do not wait for Tinybird. +- **EdgeZero initial navigation:** EdgeZero publisher fallback currently passes no slots and does not run SSAT. Keep telemetry APIs reusable, but do not claim EdgeZero initial-navigation coverage until SSAT is wired there. +- **Tinybird host/token region drift:** A token from one region against another region’s API host returns ingestion errors that production emission will not inspect. Smoke tests must verify host+token together. +- **Schema drift:** Rust row structs and Tinybird datasource columns must be reviewed together. Malformed rows can go to quarantine. +- **Ops telemetry volume:** Access telemetry can create one additional async POST per sampled request. Keep it disabled unless volume and plan limits are known. +- **Financial misuse:** Dashboard/endpoints must label data as directional/best-effort, not billing, reconciliation, payment, or revenue-share data. + +--- + +## 10. Acceptance checklist for later implementation + +- [ ] Auction telemetry rows are built by pure core code and covered by unit tests. +- [ ] All three auction sources are represented with correct `auction_source` values. +- [ ] Matched-slot skip paths emit `skipped` summaries with bounded reasons. +- [ ] SSAT pass-through/unmodified/origin-error branches emit `abandoned` instead of dropping dispatch tokens. +- [ ] Direct Tinybird sink uses Fastly Secret Store append token and dynamic backend to regional API host. +- [ ] One auction observation creates at most one NDJSON Events API POST. +- [ ] Tinybird response is not awaited in production emission. +- [ ] Missing token/backend/send setup failures do not affect customer responses. +- [ ] Tinybird datasource/pipe/test files validate rollup math. +- [ ] Validation commands pass. 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 00000000..af166260 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-auction-prebid-metrics-tinybird-grafana-design.md @@ -0,0 +1,642 @@ +# Auction and Prebid metrics to Tinybird and Grafana + +Date: 2026-06-22 +Status: Design, revised for direct Tinybird ingestion from Fastly Compute + +## Problem + +Trusted Server runs server-side auctions against multiple bid providers +(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. + +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 + +- **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. +- **Fastly real-time logging is no longer the phase-1 transport.** It avoids + request-path delivery work, but it also requires Fastly logging endpoint setup + and an RFC 8615 validation relay for hosted Tinybird. Phase 1 favors a simpler + Fastly Compute `send_async` POST directly to Tinybird's Events API. +- **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 best-effort structured lifecycle rows for every auction decision and +execution from the edge, POST them directly from Fastly Compute to Tinybird's +Events API using asynchronous backend requests, aggregate in Tinybird, and render +in Grafana. Tinybird is the always-on stateful aggregator that Compute cannot +be. Auction yield telemetry is the phase-1 requirement. Ops access telemetry can +use the same Tinybird store and Grafana datasource when enabled, but it is +configuration-gated because direct per-request ingest has higher volume impact. + +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. + +```text +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 + -> direct ingest sink: one bounded NDJSON body per auction observation + -> Fastly dynamic backend for https:// + -> Request::send_async(...) and drop the pending response + -> 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 Infinity datasource -> published endpoints on + +edge (every request, if ops telemetry is enabled) + -> one access-log row when the response is finalized + -> direct ingest sink: one small NDJSON body per request, optionally sampled + -> Fastly dynamic backend for https:// + -> Request::send_async(...) and drop the pending response + -> hosted Tinybird Events API POST https:///v0/events?name=access_logs_raw + -> rollups -> endpoints -> Grafana (ops panels) +``` + +This direct-ingest design removes the customer-controlled logging relay, Fastly +real-time log endpoints, and Fastly logging domain-control challenge for this +phase. The tradeoff is that delivery is less durable than Fastly real-time +logging: there is no Fastly-managed batching, retry, or response inspection +unless the application deliberately waits, which it must not do on the hot path. + +## Resolved decisions + +1. **No EC id is emitted.** `ec_id` is omitted from the schema entirely for + 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 yield telemetry and optionally ops access telemetry.** Auction + telemetry is the primary requirement. Ops access telemetry uses the same + direct-ingest transport but must be volume-gated with configuration and may be + sampled or disabled if one async POST per request is too expensive for the + Tinybird plan. +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. +8. **Tinybird ingest credentials live in Fastly Secret Store.** The guest reads a + dedicated Tinybird append token from Secret Store at runtime and sends it as + `Authorization: Bearer ` on the direct Events API request. The token is + resource-scoped to APPEND on the relevant datasource only. It is not the + Grafana read token and is never an admin token. The implementation should keep + Secret Store access cheap on the hot path by reusing handles or memoized + configuration where Fastly's runtime permits; if lookup or token retrieval + fails, telemetry emission is skipped and the customer response continues. + +## 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. +- Batch all rows from one auction observation into a single Events API POST so a + completed auction does not create one backend request per row. + +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, delivery +confirmation, or cross-system reconciliation. 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. Direct +fire-and-forget ingestion can lose rows when the async request cannot be started, +when Fastly terminates background delivery before completion, when Tinybird +rejects the request, or when the token/host/plan is misconfigured. It can also +create duplicates if application code emits a lifecycle twice. + +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 with direct Compute ingest + +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: + +- **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 dynamic backend.** The adapter creates or resolves a Fastly backend + for `https://` and sends ordinary Compute backend requests to it. + TLS SNI and the HTTP `Host` header must match ``. If backend + construction or resolution fails, the adapter drops that telemetry batch and + continues the customer response. This is not a Fastly real-time logging + endpoint, so no `/.well-known/fastly/logging/challenge` is required. +- **Fire-and-forget send.** The adapter builds a `fastly::Request`, attaches the + Tinybird bearer token and NDJSON body, calls `send_async`, and intentionally + drops the `PendingRequest`. It may log immediate send setup failures, but it + must not wait for the Tinybird response before returning or streaming the + customer response. +- **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.** Store Tinybird append tokens in Fastly Secret Store. Use + resource-scoped tokens generated from the datasource definitions or the + Tinybird UI: `TOKEN ts_ingest APPEND` on + `tinybird/datasources/auction_events_raw.datasource` and, if ops telemetry is + enabled, a separate `TOKEN ts_access_ingest APPEND` on + `tinybird/datasources/access_logs_raw.datasource`. `tb token create static` + creates workspace-level static scopes and is not the preferred way to create a + per-datasource APPEND token. Grafana Infinity uses a separate read token scoped + to the published endpoints. Deployment smoke tests must prove the configured + regional host and Secret Store token can ingest fixture NDJSON and leave + Tinybird quarantine empty. +- **Development and deployment.** Keep datasources, materializations, endpoints, + fixtures, tests, and scoped token declarations 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. Direct auction ingest + batches rows per auction, but direct access-log ingest can add one Events API + request per incoming request if enabled without sampling. +- **Failure isolation.** Tinybird remains off the request path. A hosted service, + token, backend, 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` and Fastly adapter) + +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 Tinybird-specific direct ingest: Secret Store lookup, dynamic +backend construction, and `send_async`. + +#### 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. The Tinybird POST still must be +`send_async` and not awaited. + +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 when ops +telemetry is enabled. + +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 direct-ingest 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 abstraction (core).** Core defines a small `AuctionEventSink` trait or a + method on the existing runtime services object. Tests use a no-op or in-memory + sink so native builds stay clean. +- **Fastly direct-ingest implementation (adapter).** The adapter serializes all + rows from one auction observation as newline-delimited JSON, creates a POST to + `/v0/events?name=auction_events_raw` on the configured Tinybird regional host, + adds `Authorization: Bearer ` and the Tinybird-documented + NDJSON `Content-Type` (`application/x-ndjson` unless Tinybird's Events API + requires otherwise), sends via `send_async`, and ignores the response. + +Output is NDJSON with no fern `timestamp LEVEL [module]` prefix. JSON +serialization and host-call setup cost still exist and are covered by the +latency success criterion. The direct sink should bound payload size and row +count defensively; if a payload would exceed a configured maximum, drop the +telemetry batch and log a warning rather than holding the response path. + +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 + +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 raw 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 the adapter's already-derived device signals. Phase 1 snapshots that struct +into `AuctionObservationContext`; no UA re-parsing. `is_known_browser` maps +`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`, so the design handles decoded +and undecoded prices uniformly. 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 APS 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, 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`. Declare the + resource-scoped append token in this datasource as `TOKEN ts_ingest APPEND`. +- **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 + 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) + +No per-request access logging exists today (the adapter logs only errors, +warnings, and specific conditions). If enabled, the adapter emits a new +cross-cutting access row where the response is finalized in the top-level request +flow, since `status` and elapsed time are known only there. It writes one line +per sampled request to `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), and +`country`. + +Unlike Fastly real-time logging, direct access telemetry creates an additional +asynchronous backend POST for each sampled request. This is acceptable only when +configured volume and Tinybird plan limits are understood. The implementation +therefore exposes configuration for enablement and sample rate. When enabled at +100%, the data drives QPS, status-code class rates, endpoint latency quantiles, +and cache hit ratio. When sampled, ops panels must account for the sample rate +or label counts as sampled estimates. + +Declare a separate resource-scoped append token in +`access_logs_raw.datasource`, for example `TOKEN ts_access_ingest APPEND`, and +store its value in Fastly Secret Store separately from `ts_ingest`. + +### D. Grafana + +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. If access telemetry is sampled, panels are labeled accordingly. +- 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. + +## Configuration + +The Fastly adapter needs runtime configuration for direct Tinybird ingestion: + +| setting | purpose | +| ------------------------------- | ---------------------------------------------------------- | +| `tinybird.enabled` | master enable/disable switch | +| `tinybird.api_host` | regional Tinybird API host, without path | +| `tinybird.auction_dataset` | usually `auction_events_raw` | +| `tinybird.auction_token_secret` | Fastly Secret Store key containing the `ts_ingest` token | +| `tinybird.access_enabled` | enable/disable direct access-log telemetry | +| `tinybird.access_dataset` | usually `access_logs_raw` | +| `tinybird.access_token_secret` | Fastly Secret Store key containing the access append token | +| `tinybird.access_sample_rate` | fraction of requests to emit for ops telemetry | +| `tinybird.max_body_bytes` | defensive maximum body size for one direct ingest POST | + +The adapter must not log token values or request `Authorization` headers. Local +and test environments may use disabled/no-op sinks or fixture secrets. + +## Testing + +- `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. +- Direct-ingest sink tests use a mock Fastly adapter seam where possible to prove + one auction observation creates one POST to + `/v0/events?name=auction_events_raw`, includes the Tinybird-documented NDJSON + content type, includes a bearer token sourced through the secret interface, and + does not wait for a response. +- Token handling tests assert the ingest sink disables emission safely when the + configured secret is missing, without panicking or blocking the customer + response path. +- 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 + +- **Direct ingest loses Fastly logging durability.** `send_async` can begin a + backend request and let it continue after the guest exits, but the application + does not receive Tinybird's response when it drops the pending request. Failed + auth, `429`, `413`, `5xx`, or quarantine outcomes are visible only through + Tinybird observability, freshness panels, and smoke tests. This is acceptable + only because the dataset is directional. +- **Request volume vs hosted plan.** A completed auction emits one summary row + plus P provider-call rows and B returned-bid rows, batched into one Tinybird + POST. Access telemetry, if enabled, can add one Tinybird POST per sampled + request. Monitor hosted-plan usage, ingestion latency, request-rate limits, + request-size limits, and quarantine counts. Resize the plan or lower the + access sample rate if sustained volume requires 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.** Direct fire-and-forget ingestion 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 Tinybird, backend, DNS, or secret + configuration outage can lose analytics for the affected window. Tinybird + remains off the auction request path, so auctions continue unaffected. +- **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.** Tinybird append tokens are secrets held in Fastly Secret + Store, not committed to the repo. The adapter must not log credentials or + request authorization headers. The token should be limited to APPEND on a + single datasource. +- **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. +- **Content type drift.** The implementation should verify Tinybird's current + Events API requirements before coding the final header. The intended payload is + NDJSON; use `application/x-ndjson` unless the documented Events API requires a + different content type for newline-delimited JSON. + +## Prerequisites (provisioned outside the repo) + +- Hosted Tinybird Cloud workspace in the selected region, with its regional + `` recorded in deployment configuration. +- `TOKEN ts_ingest APPEND` declared on + `tinybird/datasources/auction_events_raw.datasource`; after `tb deploy`, copy + the generated token value from `tb --cloud token ls` or the Tinybird UI into + Fastly Secret Store. +- If ops access telemetry is enabled, `TOKEN ts_access_ingest APPEND` declared on + `tinybird/datasources/access_logs_raw.datasource`; copy that token value into + Fastly Secret Store separately. +- A Fastly service configuration that allows a dynamic backend request to the + Tinybird regional API host, with TLS SNI and `Host` matching that host. +- A deployment smoke test that sends fixture NDJSON through the configured + Fastly/Tinybird settings and verifies rows arrive in the target datasource with + no quarantine rejects. +- Grafana with the Infinity datasource configured for the published endpoint URLs + on `` and the scoped read token. + +The previous relay prerequisite is intentionally removed. A customer-controlled +`` and Fastly real-time logging endpoints are not required for +the direct-ingest architecture. + +## Success criteria + +- 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 direct ingestion 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 Fastly adapter sends auction rows to Tinybird through a direct asynchronous + backend POST using a Secret Store token scoped to APPEND on + `auction_events_raw`; it does not configure or require Fastly real-time + logging, a relay host, or an RFC 8615 logging challenge. +- The adapter batches all rows for one auction observation into one NDJSON Events + API request and does not wait for the Tinybird response before returning or + streaming the customer response. +- If access telemetry is enabled, the ops dashboard shows QPS, status-class error + rate, endpoint latency quantiles, and cache hit ratio from `access_logs_raw`, + with sampling clearly labeled when the sample rate is below 100%. +- The yield dashboard shows fill rate, completion/abandonment rate, provider + no-bid/error rate, win rate by seat, CPM quantiles, 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. +- Tinybird quarantine stays empty under normal traffic; a malformed row is + visible on the ingest-error panel. +- 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. +- Fastly real-time logging and relay setup for auction telemetry. +- 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, delivery confirmation, + and duplicate removal. diff --git a/fastly.toml b/fastly.toml index 02dbe72b..56002bc5 100644 --- a/fastly.toml +++ b/fastly.toml @@ -53,6 +53,14 @@ build = """ key = "api_key" env = "FASTLY_KEY" + [[local_server.secret_stores.ts_secrets]] + key = "tinybird_auction_append_token" + data = "test-tinybird-auction-append-token" + + [[local_server.secret_stores.ts_secrets]] + key = "tinybird_access_append_token" + data = "test-tinybird-access-append-token" + [local_server.config_stores] [local_server.config_stores.trusted_server_config] format = "inline-toml" diff --git a/tinybird/datasources/access_logs_raw.datasource b/tinybird/datasources/access_logs_raw.datasource new file mode 100644 index 00000000..42f214e0 --- /dev/null +++ b/tinybird/datasources/access_logs_raw.datasource @@ -0,0 +1,19 @@ +DESCRIPTION > + Optional sampled Trusted Server access telemetry rows. Disabled by default in Fastly config. + +SCHEMA > + `event_ts` DateTime64(3), + `method` LowCardinality(String), + `path` String, + `status` UInt16, + `time_elapsed_ms` UInt32, + `cache_state` LowCardinality(Nullable(String)), + `country` LowCardinality(String), + `sample_rate` Float64, + `event_date` Date DEFAULT toDate(event_ts) + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "event_date, path, status, method" +TTL "event_date + INTERVAL 30 DAY" + +TOKEN ts_access_ingest APPEND diff --git a/tinybird/datasources/auction_bid_stats_rollup.datasource b/tinybird/datasources/auction_bid_stats_rollup.datasource new file mode 100644 index 00000000..23f10d8b --- /dev/null +++ b/tinybird/datasources/auction_bid_stats_rollup.datasource @@ -0,0 +1,17 @@ +DESCRIPTION > + Per-minute bid and canonical win rollup target. + +SCHEMA > + `minute` DateTime, + `publisher_domain` String, + `auction_source` LowCardinality(String), + `provider` LowCardinality(String), + `seat` LowCardinality(String), + `bids` AggregateFunction(count), + `wins` AggregateFunction(sum, UInt64), + `winning_cpm_quantiles` AggregateFunction(quantiles(0.5, 0.95, 0.99), Float64), + `event_date` Date DEFAULT toDate(minute) + +ENGINE "AggregatingMergeTree" +ENGINE_SORTING_KEY "event_date, minute, publisher_domain, auction_source, provider, seat" +TTL "event_date + INTERVAL 13 MONTH" diff --git a/tinybird/datasources/auction_events_raw.datasource b/tinybird/datasources/auction_events_raw.datasource new file mode 100644 index 00000000..d62f8ae5 --- /dev/null +++ b/tinybird/datasources/auction_events_raw.datasource @@ -0,0 +1,43 @@ +DESCRIPTION > + Raw best-effort Trusted Server auction telemetry rows from Fastly Compute. + +SCHEMA > + `event_ts` DateTime64(3), + `event_kind` LowCardinality(String), + `auction_id` UUID, + `auction_source` LowCardinality(String), + `publisher_domain` String, + `page_path` String, + `country` LowCardinality(String), + `region` Nullable(String), + `is_mobile` UInt8, + `is_known_browser` UInt8, + `gdpr_applies` UInt8, + `consent_present` UInt8, + `terminal_status` LowCardinality(Nullable(String)), + `terminal_reason` LowCardinality(Nullable(String)), + `slot_count` Nullable(UInt16), + `total_time_ms` Nullable(UInt32), + `winning_bid_count` Nullable(UInt16), + `provider` LowCardinality(Nullable(String)), + `provider_role` LowCardinality(Nullable(String)), + `status` LowCardinality(Nullable(String)), + `provider_response_time_ms` Nullable(UInt32), + `provider_bid_count` Nullable(UInt16), + `slot_id` Nullable(String), + `slot_w` Nullable(UInt16), + `slot_h` Nullable(UInt16), + `media_type` LowCardinality(Nullable(String)), + `seat` LowCardinality(Nullable(String)), + `price_cpm` Nullable(Float64), + `currency` LowCardinality(Nullable(String)), + `is_win` Nullable(UInt8), + `ad_domain` Nullable(String), + `ad_id` Nullable(String), + `event_date` Date DEFAULT toDate(event_ts) + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "event_date, publisher_domain, event_kind, auction_source, auction_id" +TTL "event_date + INTERVAL 30 DAY" + +TOKEN ts_ingest APPEND diff --git a/tinybird/datasources/auction_overview_rollup.datasource b/tinybird/datasources/auction_overview_rollup.datasource new file mode 100644 index 00000000..ead16277 --- /dev/null +++ b/tinybird/datasources/auction_overview_rollup.datasource @@ -0,0 +1,18 @@ +DESCRIPTION > + Per-minute auction summary rollup target. + +SCHEMA > + `minute` DateTime, + `publisher_domain` String, + `auction_source` LowCardinality(String), + `terminal_status` LowCardinality(String), + `terminal_reason` LowCardinality(Nullable(String)), + `auctions` AggregateFunction(count), + `requested_slots` AggregateFunction(sum, UInt64), + `winning_bids` AggregateFunction(sum, UInt64), + `total_time_quantiles` AggregateFunction(quantiles(0.5, 0.95, 0.99), UInt32), + `event_date` Date DEFAULT toDate(minute) + +ENGINE "AggregatingMergeTree" +ENGINE_SORTING_KEY "event_date, minute, publisher_domain, auction_source, terminal_status, terminal_reason" +TTL "event_date + INTERVAL 13 MONTH" diff --git a/tinybird/datasources/auction_provider_stats_rollup.datasource b/tinybird/datasources/auction_provider_stats_rollup.datasource new file mode 100644 index 00000000..f1cbf6cb --- /dev/null +++ b/tinybird/datasources/auction_provider_stats_rollup.datasource @@ -0,0 +1,22 @@ +DESCRIPTION > + Per-minute provider-call rollup target. + +SCHEMA > + `minute` DateTime, + `publisher_domain` String, + `auction_source` LowCardinality(String), + `provider` LowCardinality(String), + `provider_role` LowCardinality(String), + `requests` AggregateFunction(count), + `successes` AggregateFunction(sum, UInt64), + `nobids` AggregateFunction(sum, UInt64), + `errors` AggregateFunction(sum, UInt64), + `timeouts` AggregateFunction(sum, UInt64), + `abandonments` AggregateFunction(sum, UInt64), + `parsed_bids` AggregateFunction(sum, UInt64), + `latency_quantiles` AggregateFunction(quantiles(0.5, 0.95, 0.99), UInt32), + `event_date` Date DEFAULT toDate(minute) + +ENGINE "AggregatingMergeTree" +ENGINE_SORTING_KEY "event_date, minute, publisher_domain, auction_source, provider, provider_role" +TTL "event_date + INTERVAL 13 MONTH" diff --git a/tinybird/fixtures/auction_events_raw.ndjson b/tinybird/fixtures/auction_events_raw.ndjson new file mode 100644 index 00000000..8099e3e7 --- /dev/null +++ b/tinybird/fixtures/auction_events_raw.ndjson @@ -0,0 +1,7 @@ +{"event_ts":"2026-06-23 12:00:00.000","event_kind":"summary","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":"completed","terminal_reason":null,"slot_count":2,"total_time_ms":120,"winning_bid_count":1,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} +{"event_ts":"2026-06-23 12:00:00.000","event_kind":"provider_call","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":"bidder","status":"success","provider_response_time_ms":80,"provider_bid_count":2,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} +{"event_ts":"2026-06-23 12:00:00.000","event_kind":"provider_call","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"aps","provider_role":"bidder","status":"nobid","provider_response_time_ms":95,"provider_bid_count":0,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} +{"event_ts":"2026-06-23 12:00:00.000","event_kind":"bid","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":"slot-1","slot_w":300,"slot_h":250,"media_type":"banner","seat":"kargo","price_cpm":1.25,"currency":"USD","is_win":1,"ad_domain":"advertiser.example","ad_id":"ad-1"} +{"event_ts":"2026-06-23 12:01:00.000","event_kind":"summary","auction_id":"650e8400-e29b-41d4-a716-446655440000","auction_source":"initial_navigation","publisher_domain":"test-publisher.example","page_path":"/sports","country":"US","region":"CA","is_mobile":1,"is_known_browser":1,"gdpr_applies":0,"consent_present":1,"terminal_status":"abandoned","terminal_reason":"pass_through_response","slot_count":1,"total_time_ms":35,"winning_bid_count":0,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} +{"event_ts":"2026-06-23 12:01:00.000","event_kind":"provider_call","auction_id":"650e8400-e29b-41d4-a716-446655440000","auction_source":"initial_navigation","publisher_domain":"test-publisher.example","page_path":"/sports","country":"US","region":"CA","is_mobile":1,"is_known_browser":1,"gdpr_applies":0,"consent_present":1,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":"bidder","status":"abandoned","provider_response_time_ms":35,"provider_bid_count":0,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} +{"event_ts":"2026-06-23 12:02:00.000","event_kind":"summary","auction_id":"750e8400-e29b-41d4-a716-446655440000","auction_source":"spa_navigation","publisher_domain":"test-publisher.example","page_path":"/privacy","country":"DE","region":null,"is_mobile":2,"is_known_browser":2,"gdpr_applies":1,"consent_present":1,"terminal_status":"skipped","terminal_reason":"consent_denied","slot_count":1,"total_time_ms":0,"winning_bid_count":0,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} diff --git a/tinybird/pipes/auction_bid_stats_mv.pipe b/tinybird/pipes/auction_bid_stats_mv.pipe new file mode 100644 index 00000000..06a406b1 --- /dev/null +++ b/tinybird/pipes/auction_bid_stats_mv.pipe @@ -0,0 +1,28 @@ +DESCRIPTION > + Materialized per-minute bid and canonical winner rollup. + +NODE auction_bid_stats_mv +SQL > + SELECT + toStartOfMinute(event_ts) AS minute, + publisher_domain, + auction_source, + assumeNotNull(provider) AS provider, + assumeNotNull(seat) AS seat, + countState() AS bids, + sumState(toUInt64(coalesce(is_win, 0) = 1)) AS wins, + quantilesStateIf(0.5, 0.95, 0.99)(assumeNotNull(price_cpm), coalesce(is_win, 0) = 1) AS winning_cpm_quantiles + FROM auction_events_raw + WHERE event_kind = 'bid' + AND provider IS NOT NULL + AND seat IS NOT NULL + AND price_cpm IS NOT NULL + GROUP BY + minute, + publisher_domain, + auction_source, + provider, + seat + +TYPE materialized +DATASOURCE auction_bid_stats_rollup diff --git a/tinybird/pipes/auction_overview_mv.pipe b/tinybird/pipes/auction_overview_mv.pipe new file mode 100644 index 00000000..bd3a3ea0 --- /dev/null +++ b/tinybird/pipes/auction_overview_mv.pipe @@ -0,0 +1,28 @@ +DESCRIPTION > + Materialized per-minute auction overview from summary rows. + +NODE auction_overview_mv +SQL > + SELECT + toStartOfMinute(event_ts) AS minute, + publisher_domain, + auction_source, + assumeNotNull(terminal_status) AS terminal_status, + terminal_reason, + countState() AS auctions, + sumState(toUInt64(coalesce(slot_count, 0))) AS requested_slots, + sumState(toUInt64(coalesce(winning_bid_count, 0))) AS winning_bids, + quantilesState(0.5, 0.95, 0.99)(assumeNotNull(total_time_ms)) AS total_time_quantiles + FROM auction_events_raw + WHERE event_kind = 'summary' + AND terminal_status IS NOT NULL + AND total_time_ms IS NOT NULL + GROUP BY + minute, + publisher_domain, + auction_source, + terminal_status, + terminal_reason + +TYPE materialized +DATASOURCE auction_overview_rollup diff --git a/tinybird/pipes/auction_provider_stats_mv.pipe b/tinybird/pipes/auction_provider_stats_mv.pipe new file mode 100644 index 00000000..e44dd9f9 --- /dev/null +++ b/tinybird/pipes/auction_provider_stats_mv.pipe @@ -0,0 +1,34 @@ +DESCRIPTION > + Materialized per-minute provider-call health and latency rollup. + +NODE auction_provider_stats_mv +SQL > + SELECT + toStartOfMinute(event_ts) AS minute, + publisher_domain, + auction_source, + assumeNotNull(provider) AS provider, + assumeNotNull(provider_role) AS provider_role, + countState() AS requests, + sumState(toUInt64(status = 'success')) AS successes, + sumState(toUInt64(status = 'nobid')) AS nobids, + sumState(toUInt64(status IN ('launch_error', 'parse_error', 'transport_error'))) AS errors, + sumState(toUInt64(status = 'timeout')) AS timeouts, + sumState(toUInt64(status = 'abandoned')) AS abandonments, + sumState(toUInt64(coalesce(provider_bid_count, 0))) AS parsed_bids, + quantilesState(0.5, 0.95, 0.99)(assumeNotNull(provider_response_time_ms)) AS latency_quantiles + FROM auction_events_raw + WHERE event_kind = 'provider_call' + AND provider IS NOT NULL + AND provider_role IS NOT NULL + AND status IS NOT NULL + AND provider_response_time_ms IS NOT NULL + GROUP BY + minute, + publisher_domain, + auction_source, + provider, + provider_role + +TYPE materialized +DATASOURCE auction_provider_stats_rollup diff --git a/tinybird/pipes/auction_summary.pipe b/tinybird/pipes/auction_summary.pipe new file mode 100644 index 00000000..86d7c396 --- /dev/null +++ b/tinybird/pipes/auction_summary.pipe @@ -0,0 +1,37 @@ +DESCRIPTION > + Published auction summary endpoint for Grafana. Data is directional and best effort. + +NODE endpoint +SQL > + SELECT + minute, + publisher_domain, + auction_source, + terminal_status, + terminal_reason, + auctions, + requested_slots, + winning_bids, + if(requested_slots = 0, 0, toFloat64(winning_bids) / requested_slots) AS fill_rate, + total_time_ms_quantiles + FROM ( + SELECT + minute, + publisher_domain, + auction_source, + terminal_status, + terminal_reason, + countMerge(auctions) AS auctions, + sumMerge(requested_slots) AS requested_slots, + sumMerge(winning_bids) AS winning_bids, + quantilesMerge(0.5, 0.95, 0.99)(total_time_quantiles) AS total_time_ms_quantiles + FROM auction_overview_rollup + WHERE minute >= parseDateTimeBestEffort({{DateTime(start, '2026-01-01 00:00:00')}}) + AND minute < parseDateTimeBestEffort({{DateTime(end, '2027-01-01 00:00:00')}}) + AND ({{String(publisher, '')}} = '' OR publisher_domain = {{String(publisher, '')}}) + AND ({{String(source, '')}} = '' OR auction_source = {{String(source, '')}}) + GROUP BY minute, publisher_domain, auction_source, terminal_status, terminal_reason + ) + ORDER BY minute ASC + +TYPE endpoint diff --git a/tinybird/pipes/ingestion_freshness.pipe b/tinybird/pipes/ingestion_freshness.pipe new file mode 100644 index 00000000..ac4733a9 --- /dev/null +++ b/tinybird/pipes/ingestion_freshness.pipe @@ -0,0 +1,18 @@ +DESCRIPTION > + Published ingestion freshness endpoint for dashboard health panels. + +NODE endpoint +SQL > + SELECT + publisher_domain, + auction_source, + max(event_ts) AS latest_event_ts, + dateDiff('second', max(event_ts), now()) AS freshness_lag_seconds, + count() AS raw_rows + FROM auction_events_raw + WHERE event_ts >= now() - INTERVAL 1 DAY + AND ({{String(publisher, '')}} = '' OR publisher_domain = {{String(publisher, '')}}) + GROUP BY publisher_domain, auction_source + ORDER BY latest_event_ts DESC + +TYPE endpoint diff --git a/tinybird/pipes/provider_health.pipe b/tinybird/pipes/provider_health.pipe new file mode 100644 index 00000000..4e630b0f --- /dev/null +++ b/tinybird/pipes/provider_health.pipe @@ -0,0 +1,46 @@ +DESCRIPTION > + Published provider no-bid/error/abandonment endpoint for Grafana. + +NODE endpoint +SQL > + SELECT + minute, + publisher_domain, + auction_source, + provider, + provider_role, + requests, + successes, + nobids, + errors, + timeouts, + abandonments, + parsed_bids, + if(requests = 0, 0, toFloat64(nobids) / requests) AS nobid_rate, + if(requests = 0, 0, toFloat64(errors + timeouts) / requests) AS error_rate, + if(requests = 0, 0, toFloat64(abandonments) / requests) AS abandonment_rate + FROM ( + SELECT + minute, + publisher_domain, + auction_source, + provider, + provider_role, + countMerge(requests) AS requests, + sumMerge(successes) AS successes, + sumMerge(nobids) AS nobids, + sumMerge(errors) AS errors, + sumMerge(timeouts) AS timeouts, + sumMerge(abandonments) AS abandonments, + sumMerge(parsed_bids) AS parsed_bids + FROM auction_provider_stats_rollup + WHERE minute >= parseDateTimeBestEffort({{DateTime(start, '2026-01-01 00:00:00')}}) + AND minute < parseDateTimeBestEffort({{DateTime(end, '2027-01-01 00:00:00')}}) + AND ({{String(publisher, '')}} = '' OR publisher_domain = {{String(publisher, '')}}) + AND ({{String(source, '')}} = '' OR auction_source = {{String(source, '')}}) + AND ({{String(provider_filter, '')}} = '' OR provider = {{String(provider_filter, '')}}) + GROUP BY minute, publisher_domain, auction_source, provider, provider_role + ) + ORDER BY minute ASC + +TYPE endpoint diff --git a/tinybird/pipes/provider_latency.pipe b/tinybird/pipes/provider_latency.pipe new file mode 100644 index 00000000..21c2381c --- /dev/null +++ b/tinybird/pipes/provider_latency.pipe @@ -0,0 +1,23 @@ +DESCRIPTION > + Published provider latency quantile endpoint for Grafana. + +NODE endpoint +SQL > + SELECT + minute, + publisher_domain, + auction_source, + provider, + provider_role, + countMerge(requests) AS sample_count, + quantilesMerge(0.5, 0.95, 0.99)(latency_quantiles) AS provider_response_time_ms_quantiles + FROM auction_provider_stats_rollup + WHERE minute >= parseDateTimeBestEffort({{DateTime(start, '2026-01-01 00:00:00')}}) + AND minute < parseDateTimeBestEffort({{DateTime(end, '2027-01-01 00:00:00')}}) + AND ({{String(publisher, '')}} = '' OR publisher_domain = {{String(publisher, '')}}) + AND ({{String(source, '')}} = '' OR auction_source = {{String(source, '')}}) + AND ({{String(provider_filter, '')}} = '' OR provider = {{String(provider_filter, '')}}) + GROUP BY minute, publisher_domain, auction_source, provider, provider_role + ORDER BY minute ASC + +TYPE endpoint diff --git a/tinybird/pipes/quarantine_counts.pipe b/tinybird/pipes/quarantine_counts.pipe new file mode 100644 index 00000000..61b01824 --- /dev/null +++ b/tinybird/pipes/quarantine_counts.pipe @@ -0,0 +1,13 @@ +DESCRIPTION > + Quarantine panel contract for Grafana. Tinybird quarantine metadata is workspace-specific; + deployments should replace this compatibility endpoint with the workspace quarantine source. + +NODE endpoint +SQL > + SELECT + 'auction_events_raw' AS datasource, + CAST(NULL, 'Nullable(UInt64)') AS quarantined_rows, + now() AS checked_at, + 'unconfigured' AS status + +TYPE endpoint diff --git a/tinybird/pipes/seat_yield.pipe b/tinybird/pipes/seat_yield.pipe new file mode 100644 index 00000000..2aab6d3a --- /dev/null +++ b/tinybird/pipes/seat_yield.pipe @@ -0,0 +1,36 @@ +DESCRIPTION > + Published seat yield endpoint for Grafana. CPM is directional and excludes null-price bids. + +NODE endpoint +SQL > + SELECT + minute, + publisher_domain, + auction_source, + provider, + seat, + priced_bid_samples, + wins, + if(priced_bid_samples = 0, 0, toFloat64(wins) / priced_bid_samples) AS win_rate, + winning_cpm_quantiles + FROM ( + SELECT + minute, + publisher_domain, + auction_source, + provider, + seat, + countMerge(bids) AS priced_bid_samples, + sumMerge(wins) AS wins, + quantilesMerge(0.5, 0.95, 0.99)(winning_cpm_quantiles) AS winning_cpm_quantiles + FROM auction_bid_stats_rollup + WHERE minute >= parseDateTimeBestEffort({{DateTime(start, '2026-01-01 00:00:00')}}) + AND minute < parseDateTimeBestEffort({{DateTime(end, '2027-01-01 00:00:00')}}) + AND ({{String(publisher, '')}} = '' OR publisher_domain = {{String(publisher, '')}}) + AND ({{String(source, '')}} = '' OR auction_source = {{String(source, '')}}) + AND ({{String(provider_filter, '')}} = '' OR provider = {{String(provider_filter, '')}}) + GROUP BY minute, publisher_domain, auction_source, provider, seat + ) + ORDER BY minute ASC + +TYPE endpoint diff --git a/tinybird/tests/auction_summary.yaml b/tinybird/tests/auction_summary.yaml new file mode 100644 index 00000000..e27bf68b --- /dev/null +++ b/tinybird/tests/auction_summary.yaml @@ -0,0 +1,13 @@ +- name: auction_api_completed_fill_rate + description: Completed auction summary counts one auction and computes fill rate from summary rows. + expected_http_status: 200 + parameters: start=2026-06-23%2012:00:00&end=2026-06-23%2012:01:00&publisher=test-publisher.example&source=auction_api + expected_result: | + {"minute":"2026-06-23 12:00:00","publisher_domain":"test-publisher.example","auction_source":"auction_api","terminal_status":"completed","terminal_reason":null,"auctions":1,"requested_slots":2,"winning_bids":1,"fill_rate":0.5,"total_time_ms_quantiles":[120,120,120]} + +- name: skipped_spa_navigation_visible + description: Skipped SPA navigation summaries remain queryable for consent-denied traffic. + expected_http_status: 200 + parameters: start=2026-06-23%2012:02:00&end=2026-06-23%2012:03:00&publisher=test-publisher.example&source=spa_navigation + expected_result: | + {"minute":"2026-06-23 12:02:00","publisher_domain":"test-publisher.example","auction_source":"spa_navigation","terminal_status":"skipped","terminal_reason":"consent_denied","auctions":1,"requested_slots":1,"winning_bids":0,"fill_rate":0,"total_time_ms_quantiles":[0,0,0]} diff --git a/tinybird/tests/provider_health.yaml b/tinybird/tests/provider_health.yaml new file mode 100644 index 00000000..910b32f5 --- /dev/null +++ b/tinybird/tests/provider_health.yaml @@ -0,0 +1,13 @@ +- name: aps_nobid_rate + description: No-bid provider rows compute request and no-bid rates without multiplying by summary rows. + expected_http_status: 200 + parameters: start=2026-06-23%2012:00:00&end=2026-06-23%2012:01:00&publisher=test-publisher.example&source=auction_api&provider_filter=aps + expected_result: | + {"minute":"2026-06-23 12:00:00","publisher_domain":"test-publisher.example","auction_source":"auction_api","provider":"aps","provider_role":"bidder","requests":1,"successes":0,"nobids":1,"errors":0,"timeouts":0,"abandonments":0,"parsed_bids":0,"nobid_rate":1,"error_rate":0,"abandonment_rate":0} + +- name: abandoned_initial_navigation + description: Abandoned provider calls are visible for wasted dispatch observability. + expected_http_status: 200 + parameters: start=2026-06-23%2012:01:00&end=2026-06-23%2012:02:00&publisher=test-publisher.example&source=initial_navigation&provider_filter=prebid + expected_result: | + {"minute":"2026-06-23 12:01:00","publisher_domain":"test-publisher.example","auction_source":"initial_navigation","provider":"prebid","provider_role":"bidder","requests":1,"successes":0,"nobids":0,"errors":0,"timeouts":0,"abandonments":1,"parsed_bids":0,"nobid_rate":0,"error_rate":0,"abandonment_rate":1} diff --git a/tinybird/tests/seat_yield.yaml b/tinybird/tests/seat_yield.yaml new file mode 100644 index 00000000..bd4779e9 --- /dev/null +++ b/tinybird/tests/seat_yield.yaml @@ -0,0 +1,6 @@ +- name: prebid_kargo_winning_cpm + description: Seat yield exposes priced bid samples, canonical wins, and decoded winning CPM quantiles. + expected_http_status: 200 + parameters: start=2026-06-23%2012:00:00&end=2026-06-23%2012:01:00&publisher=test-publisher.example&source=auction_api&provider_filter=prebid + expected_result: | + {"minute":"2026-06-23 12:00:00","publisher_domain":"test-publisher.example","auction_source":"auction_api","provider":"prebid","seat":"kargo","priced_bid_samples":1,"wins":1,"win_rate":1,"winning_cpm_quantiles":[1.25,1.25,1.25]} From a7b1677d56f46751dc2aac42f14eff3e3c5400cd Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 24 Jun 2026 14:00:09 -0500 Subject: [PATCH 10/14] add log --- crates/trusted-server-adapter-fastly/src/tinybird.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index b84915c5..66d00d3c 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -93,6 +93,7 @@ impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { message: "invalid Tinybird authorization header".to_owned(), }, )?; + let body_len = body.len(); let request = request_builder() .method(Method::POST) .uri(uri) @@ -103,6 +104,15 @@ impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { message: "failed to build Tinybird Events API request".to_owned(), })?; + log::info!( + "sending auction telemetry to Tinybird dataset={} rows={} bytes={} host={} backend={}", + self.config.auction_dataset, + batch.row_count(), + body_len, + self.config.api_host, + backend_name + ); + let pending = services .http_client() .send_async(PlatformHttpRequest::new(request, backend_name)) From b4f88f71ff262f14634c19eec90ad6b3a1a9335a Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 25 Jun 2026 11:02:03 -0500 Subject: [PATCH 11/14] Refactor Tinybird sink and cache privacy helpers --- .../src/tinybird.rs | 219 +++++++++++++++--- 1 file changed, 181 insertions(+), 38 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 66d00d3c..d36d3229 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -34,25 +34,47 @@ pub(crate) fn auction_sink_from_settings(settings: &Settings) -> Arc Self { - Self { config } +#[derive(Debug, Clone)] +struct TinybirdEventsTarget { + api_host: String, + dataset: String, + secret_store: StoreName, + token_secret: String, + uri: String, + backend_spec: PlatformBackendSpec, + max_body_bytes: usize, +} + +impl TinybirdEventsTarget { + fn from_config(config: TinybirdSettings) -> Self { + let uri = tinybird_events_uri(&config.api_host, &config.auction_dataset); + let backend_spec = tinybird_backend_spec(&config.api_host); + Self { + api_host: config.api_host, + dataset: config.auction_dataset, + secret_store: StoreName::from(config.secret_store), + token_secret: config.auction_token_secret, + uri, + backend_spec, + max_body_bytes: config.max_body_bytes, + } } } -#[async_trait::async_trait(?Send)] -impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { - async fn emit_auction_events( - &self, - services: &RuntimeServices, - batch: AuctionEventBatch, - ) -> Result<(), Report> { - if !self.config.enabled || batch.is_empty() { - return Ok(()); +impl FastlyTinybirdAuctionTelemetrySink { + fn new(config: TinybirdSettings) -> Self { + let enabled = config.enabled; + Self { + enabled, + target: TinybirdEventsTarget::from_config(config), } + } + + fn validate_batch(batch: &AuctionEventBatch) -> Result<(), Report> { if batch.row_count() > TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH { return Err(Report::new(TrustedServerError::Proxy { message: format!( @@ -62,57 +84,76 @@ impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { ), })); } + Ok(()) + } + + fn serialize_batch( + &self, + batch: &AuctionEventBatch, + ) -> Result> { + batch.to_ndjson(self.target.max_body_bytes) + } - let body = batch.to_ndjson(self.config.max_body_bytes)?; + fn load_append_token( + &self, + services: &RuntimeServices, + ) -> Result> { let token = services .secret_store() - .get_string( - &StoreName::from(self.config.secret_store.as_str()), - &self.config.auction_token_secret, - ) + .get_string(&self.target.secret_store, &self.target.token_secret) .change_context(TrustedServerError::Proxy { message: "Tinybird auction append token unavailable".to_owned(), })?; - let token = token.trim(); + let token = token.trim().to_owned(); if token.is_empty() { return Err(Report::new(TrustedServerError::Proxy { message: "Tinybird auction append token is empty".to_owned(), })); } + Ok(token) + } - let backend_name = services + fn ensure_backend( + &self, + services: &RuntimeServices, + ) -> Result> { + services .backend() - .ensure(&tinybird_backend_spec(&self.config.api_host)) + .ensure(&self.target.backend_spec) .change_context(TrustedServerError::Proxy { message: "Tinybird backend registration failed".to_owned(), - })?; + }) + } - let uri = tinybird_events_uri(&self.config.api_host, &self.config.auction_dataset); - let auth_header = HeaderValue::from_str(&format!("Bearer {token}")).change_context( + fn authorization_header(token: &str) -> Result> { + HeaderValue::from_str(&format!("Bearer {token}")).change_context( TrustedServerError::InvalidHeaderValue { message: "invalid Tinybird authorization header".to_owned(), }, - )?; - let body_len = body.len(); - let request = request_builder() + ) + } + + fn build_events_request( + &self, + body: String, + auth_header: HeaderValue, + ) -> Result> { + request_builder() .method(Method::POST) - .uri(uri) + .uri(self.target.uri.as_str()) .header(header::AUTHORIZATION, auth_header) .header(header::CONTENT_TYPE, TINYBIRD_NDJSON_CONTENT_TYPE) .body(Body::from(body)) .change_context(TrustedServerError::Proxy { message: "failed to build Tinybird Events API request".to_owned(), - })?; - - log::info!( - "sending auction telemetry to Tinybird dataset={} rows={} bytes={} host={} backend={}", - self.config.auction_dataset, - batch.row_count(), - body_len, - self.config.api_host, - backend_name - ); + }) + } + async fn send_fire_and_forget( + services: &RuntimeServices, + request: edgezero_core::http::Request, + backend_name: String, + ) -> Result<(), Report> { let pending = services .http_client() .send_async(PlatformHttpRequest::new(request, backend_name)) @@ -125,6 +166,38 @@ impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { } } +#[async_trait::async_trait(?Send)] +impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { + async fn emit_auction_events( + &self, + services: &RuntimeServices, + batch: AuctionEventBatch, + ) -> Result<(), Report> { + if !self.enabled || batch.is_empty() { + return Ok(()); + } + + Self::validate_batch(&batch)?; + let body = self.serialize_batch(&batch)?; + let body_len = body.len(); + let token = self.load_append_token(services)?; + let auth_header = Self::authorization_header(&token)?; + let backend_name = self.ensure_backend(services)?; + let request = self.build_events_request(body, auth_header)?; + + log::info!( + "sending auction telemetry to Tinybird dataset={} rows={} bytes={} host={} backend={}", + self.target.dataset, + batch.row_count(), + body_len, + self.target.api_host, + backend_name + ); + + Self::send_fire_and_forget(services, request, backend_name).await + } +} + fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { PlatformBackendSpec { scheme: "https".to_owned(), @@ -383,6 +456,14 @@ mod tests { ); } + #[test] + fn events_uri_urlencodes_dataset_name() { + assert_eq!( + tinybird_events_uri("api.us-east.aws.tinybird.co", "auction events/raw"), + "https://api.us-east.aws.tinybird.co/v0/events?name=auction%20events%2Fraw" + ); + } + #[test] fn backend_spec_uses_matching_tls_host() { let spec = tinybird_backend_spec("api.us-east.aws.tinybird.co"); @@ -451,6 +532,68 @@ mod tests { ); } + #[test] + fn disabled_sink_does_not_dispatch() { + let backend = Arc::new(RecordingBackend::default()); + let http_client = Arc::new(RecordingHttpClient::default()); + let services = services( + Arc::clone(&backend), + Arc::clone(&http_client), + HashMap::new(), + ); + let mut config = enabled_config(); + config.enabled = false; + let sink = FastlyTinybirdAuctionTelemetrySink::new(config); + + futures::executor::block_on( + sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), + ) + .expect("should ignore disabled sink"); + + assert!( + backend.specs.lock().expect("should lock specs").is_empty(), + "should not ensure backend when disabled" + ); + assert!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .is_empty(), + "should not send when disabled" + ); + } + + #[test] + fn empty_batch_does_not_dispatch() { + let backend = Arc::new(RecordingBackend::default()); + let http_client = Arc::new(RecordingHttpClient::default()); + let services = services( + Arc::clone(&backend), + Arc::clone(&http_client), + HashMap::new(), + ); + let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + + futures::executor::block_on( + sink.emit_auction_events(&services, AuctionEventBatch::new(Vec::new())), + ) + .expect("should ignore empty batch"); + + assert!( + backend.specs.lock().expect("should lock specs").is_empty(), + "should not ensure backend for empty batches" + ); + assert!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .is_empty(), + "should not send empty batches" + ); + } + #[test] fn sink_drops_missing_secret_as_setup_error() { let backend = Arc::new(RecordingBackend::default()); From e03ab989d3891c8ed64125f48d0dfd032b9a0a76 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 29 Jun 2026 10:26:14 -0500 Subject: [PATCH 12/14] Set Tinybird between-bytes backend timeout --- crates/trusted-server-adapter-fastly/src/tinybird.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index d36d3229..d5c2f400 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -206,6 +206,7 @@ fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { host_header_override: None, certificate_check: true, first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, } } From ba43cf4b6543aaba4b6bb3f5864554ee3917e262 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 6 Jul 2026 14:10:19 -0500 Subject: [PATCH 13/14] Address Tinybird telemetry review notes --- .../src/tinybird.rs | 7 +- .../src/auction/endpoints.rs | 60 +++--- crates/trusted-server-core/src/auction/mod.rs | 6 +- .../src/auction/orchestrator.rs | 3 +- .../src/auction/telemetry.rs | 54 +++++- crates/trusted-server-core/src/publisher.rs | 182 ++++++++++-------- crates/trusted-server-core/src/settings.rs | 37 +++- ...06-23-auction-telemetry-direct-tinybird.md | 4 +- ...-prebid-metrics-tinybird-grafana-design.md | 31 ++- 9 files changed, 237 insertions(+), 147 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index d5c2f400..b5d332a6 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -18,6 +18,7 @@ use trusted_server_core::settings::{Settings, TinybirdSettings}; const TINYBIRD_EVENTS_PATH: &str = "/v0/events"; const TINYBIRD_NDJSON_CONTENT_TYPE: &str = "application/x-ndjson"; const TINYBIRD_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(2); +const TINYBIRD_BETWEEN_BYTES_TIMEOUT: Duration = Duration::from_secs(2); const TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH: usize = 512; /// Build the configured auction telemetry sink. @@ -168,6 +169,10 @@ impl FastlyTinybirdAuctionTelemetrySink { #[async_trait::async_trait(?Send)] impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { + fn is_enabled(&self) -> bool { + self.enabled + } + async fn emit_auction_events( &self, services: &RuntimeServices, @@ -206,7 +211,7 @@ fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { host_header_override: None, certificate_check: true, first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, - between_bytes_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: TINYBIRD_BETWEEN_BYTES_TIMEOUT, } } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 836a3600..83c7df34 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -25,7 +25,7 @@ use crate::settings::Settings; use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request}; use super::telemetry::{ - build_auction_events, emit_auction_events_best_effort, AuctionObservationContext, + build_auction_events, emit_auction_events_best_effort_lazy, AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, }; use super::types::AuctionContext; @@ -197,14 +197,16 @@ pub async fn handle_auction( &auction_request, ec_context, ); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Skipped { - reason: "consent_denied", - elapsed_ms: 0, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Skipped { + reason: "consent_denied", + elapsed_ms: 0, + }, + ) + }) + .await; let empty_result = OrchestrationResult { provider_responses: Vec::new(), @@ -297,30 +299,34 @@ pub async fn handle_auction( Ok(result) => result, Err(err) => { let elapsed_ms = observation.elapsed_ms(); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::ExecutionFailed { - request: Some(&auction_request), - provider_responses: &[], - reason: "execution_failed", - elapsed_ms, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::ExecutionFailed { + request: Some(&auction_request), + provider_responses: &[], + reason: "execution_failed", + elapsed_ms, + }, + ) + }) + .await; return Err(err.change_context(TrustedServerError::Auction { message: "Auction orchestration failed".to_string(), })); } }; - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: &auction_request, - result: &result, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &auction_request, + result: &result, + }, + ) + }) + .await; log::info!( "Auction completed: {} providers, {} winning bids, {}ms total", diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index f7461b56..90c58dcb 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -29,9 +29,9 @@ pub use context::{build_url_with_context_params, ContextQueryParams, ContextValu pub use orchestrator::AuctionOrchestrator; pub use provider::AuctionProvider; pub use telemetry::{ - build_auction_events, emit_auction_events_best_effort, AbandonedProviderCall, - AuctionEventBatch, AuctionEventRow, AuctionObservationContext, AuctionSource, - AuctionTelemetrySink, AuctionTerminalOutcome, NoopAuctionTelemetrySink, + build_auction_events, emit_auction_events_best_effort, emit_auction_events_best_effort_lazy, + AbandonedProviderCall, AuctionEventBatch, AuctionEventRow, AuctionObservationContext, + AuctionSource, AuctionTelemetrySink, AuctionTerminalOutcome, NoopAuctionTelemetrySink, }; pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 589873e0..145059d9 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1319,6 +1319,7 @@ mod tests { use web_time::Instant; use crate::auction::config::AuctionConfig; + use crate::auction::orchestrator::DispatchAuctionOutcome; use crate::auction::provider::AuctionProvider; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ @@ -2126,7 +2127,7 @@ mod tests { // Assert: no dispatch and no provider request launched. assert!( - dispatched.is_none(), + matches!(dispatched, DispatchAuctionOutcome::NotStarted), "should skip initial-page dispatch on sequential platforms" ); assert!( diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index 3f5999a4..4819cef6 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -426,12 +426,20 @@ impl AuctionEventBatch { /// Sink for auction telemetry batches. #[async_trait::async_trait(?Send)] pub trait AuctionTelemetrySink: Send + Sync { + /// Return whether this sink emits telemetry. + /// + /// Callers use this as a cheap hot-path gate before allocating telemetry + /// rows. Enabled sinks may still drop individual empty or invalid batches. + fn is_enabled(&self) -> bool { + true + } + /// Emit an auction telemetry batch. /// /// # Errors /// /// Returns an error when the sink cannot start emission. Callers should use - /// [`emit_auction_events_best_effort`] on the hot path. + /// [`emit_auction_events_best_effort_lazy`] on the hot path. async fn emit_auction_events( &self, services: &RuntimeServices, @@ -444,6 +452,10 @@ pub struct NoopAuctionTelemetrySink; #[async_trait::async_trait(?Send)] impl AuctionTelemetrySink for NoopAuctionTelemetrySink { + fn is_enabled(&self) -> bool { + false + } + async fn emit_auction_events( &self, _services: &RuntimeServices, @@ -455,14 +467,27 @@ impl AuctionTelemetrySink for NoopAuctionTelemetrySink { /// Emit a telemetry batch without letting errors affect customer traffic. pub async fn emit_auction_events_best_effort(services: &RuntimeServices, batch: AuctionEventBatch) { + emit_auction_events_best_effort_lazy(services, || batch).await; +} + +/// Lazily build and emit a telemetry batch without affecting customer traffic. +/// +/// The batch builder is skipped when the configured sink is disabled, avoiding +/// per-auction row allocations on the default no-op telemetry path. +pub async fn emit_auction_events_best_effort_lazy( + services: &RuntimeServices, + build_batch: impl FnOnce() -> AuctionEventBatch, +) { + let sink = services.auction_telemetry_sink(); + if !sink.is_enabled() { + return; + } + + let batch = build_batch(); if batch.is_empty() { return; } - if let Err(err) = services - .auction_telemetry_sink() - .emit_auction_events(services, batch) - .await - { + if let Err(err) = sink.emit_auction_events(services, batch).await { log::warn!("auction telemetry emission skipped: {err:?}"); } } @@ -884,6 +909,7 @@ fn looks_like_uuid(value: &str) -> bool { #[cfg(test)] mod tests { + use std::cell::Cell; use std::collections::HashMap; use serde_json::json; @@ -941,6 +967,22 @@ mod tests { } } + #[test] + fn emit_lazy_skips_builder_when_sink_is_disabled() { + let services = crate::platform::test_support::noop_services(); + let builder_called = Cell::new(false); + + futures::executor::block_on(emit_auction_events_best_effort_lazy(&services, || { + builder_called.set(true); + AuctionEventBatch::default() + })); + + assert!( + !builder_called.get(), + "disabled telemetry sink should not build auction rows" + ); + } + #[test] fn normalize_page_path_strips_query_and_redacts_dynamic_segments() { assert_eq!( diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 9b724eaa..f93d518c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -34,7 +34,7 @@ use crate::auction::orchestrator::{ AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, }; use crate::auction::telemetry::{ - build_auction_events, emit_auction_events_best_effort, AuctionObservationContext, + build_auction_events, emit_auction_events_best_effort_lazy, AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, }; use crate::auction::types::{ @@ -676,14 +676,16 @@ pub async fn stream_publisher_body_async( if let (Some(observation), Some(auction_request)) = (telemetry.observation, telemetry.auction_request.as_ref()) { - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: auction_request, - result: &result, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ) + }) + .await; } write_bids_to_state( @@ -1184,17 +1186,19 @@ async fn emit_abandoned_auction( return; }; let (request, provider_responses, abandoned_providers, elapsed_ms) = dispatched.abandon(); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Abandoned { - request: &request, - provider_responses: &provider_responses, - abandoned_providers: &abandoned_providers, - reason, - elapsed_ms, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Abandoned { + request: &request, + provider_responses: &provider_responses, + abandoned_providers: &abandoned_providers, + reason, + elapsed_ms, + }, + ) + }) + .await; } async fn collect_stream_auction( @@ -1215,14 +1219,16 @@ async fn collect_stream_auction( if let (Some(observation), Some(auction_request)) = (telemetry.observation, telemetry.auction_request.as_ref()) { - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: auction_request, - result: &result, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ) + }) + .await; } log::info!( "body_close_hold_loop: collect complete - {} winning bid(s)", @@ -1510,30 +1516,34 @@ pub async fn handle_publisher_request( provider_responses, elapsed_ms, } => { - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::DispatchFailed { - request: &request, - provider_responses: &provider_responses, - reason: "dispatch_failed", - elapsed_ms, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::DispatchFailed { + request: &request, + provider_responses: &provider_responses, + reason: "dispatch_failed", + elapsed_ms, + }, + ) + }) + .await; None } DispatchAuctionOutcome::NotStarted => { let elapsed_ms = observation.elapsed_ms(); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::DispatchFailed { - request: &auction_request, - provider_responses: &[], - reason: "no_provider_dispatched", - elapsed_ms, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::DispatchFailed { + request: &auction_request, + provider_responses: &[], + reason: "no_provider_dispatched", + elapsed_ms, + }, + ) + }) + .await; None } } @@ -1550,14 +1560,16 @@ pub async fn handle_publisher_request( "not_ad_stack_eligible" }; let elapsed_ms = observation.elapsed_ms(); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Skipped { - reason: skip_reason, - elapsed_ms, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Skipped { + reason: skip_reason, + elapsed_ms, + }, + ) + }) + .await; None } }; @@ -2318,29 +2330,33 @@ pub async fn handle_page_bids( { Ok(result) => { let winning_bids = result.winning_bids.clone(); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: &auction_request, - result: &result, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &auction_request, + result: &result, + }, + ) + }) + .await; winning_bids } Err(e) => { log::warn!("page-bids auction failed: {e:?}"); let elapsed_ms = observation.elapsed_ms(); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::ExecutionFailed { - request: Some(&auction_request), - provider_responses: &[], - reason: "execution_failed", - elapsed_ms, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::ExecutionFailed { + request: Some(&auction_request), + provider_responses: &[], + reason: "execution_failed", + elapsed_ms, + }, + ) + }) + .await; std::collections::HashMap::new() } } @@ -2357,14 +2373,16 @@ pub async fn handle_page_bids( "not_ad_stack_eligible" }; let elapsed_ms = observation.elapsed_ms(); - let telemetry_batch = build_auction_events( - observation, - AuctionTerminalOutcome::Skipped { - reason: skip_reason, - elapsed_ms, - }, - ); - emit_auction_events_best_effort(services, telemetry_batch).await; + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Skipped { + reason: skip_reason, + elapsed_ms, + }, + ) + }) + .await; std::collections::HashMap::new() } }; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 7389ca47..9cbb2a54 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1739,16 +1739,19 @@ pub struct TinybirdSettings { /// Secret key containing the auction datasource APPEND token. #[serde(default = "default_tinybird_auction_token_secret")] pub auction_token_secret: String, - /// Enable optional access-log telemetry. + /// Reserved for future access-log telemetry. + /// + /// `true` is rejected until an access-log emitter is wired, so operators + /// cannot enable a setting that silently emits nothing. #[serde(default)] pub access_enabled: bool, - /// Access-log Events API datasource name. + /// Future access-log Events API datasource name. #[serde(default = "default_tinybird_access_dataset")] pub access_dataset: String, - /// Secret key containing the access-log datasource APPEND token. + /// Future Secret Store key containing the access-log datasource APPEND token. #[serde(default = "default_tinybird_access_token_secret")] pub access_token_secret: String, - /// Fraction of requests to emit for optional access telemetry. + /// Future fraction of requests to emit for optional access telemetry. #[serde(default)] pub access_sample_rate: f64, /// Defensive maximum NDJSON body size for one Events API request. @@ -1819,7 +1822,12 @@ impl TinybirdSettings { message: "tinybird.max_body_bytes must be at least 1024".to_owned(), })); } - if !self.enabled && !self.access_enabled { + if self.access_enabled { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird.access_enabled is reserved for future access-log telemetry; no emitter is currently wired".to_owned(), + })); + } + if !self.enabled { return Ok(()); } validate_tinybird_api_host(&self.api_host)?; @@ -1834,10 +1842,6 @@ impl TinybirdSettings { validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; validate_tinybird_secret(&self.auction_token_secret, "tinybird.auction_token_secret")?; } - if self.access_enabled { - validate_tinybird_dataset(&self.access_dataset, "tinybird.access_dataset")?; - validate_tinybird_secret(&self.access_token_secret, "tinybird.access_token_secret")?; - } Ok(()) } } @@ -2640,6 +2644,21 @@ mod tests { assert_eq!(settings.tinybird.api_host, "api.us-east.aws.tinybird.co"); } + #[test] + fn tinybird_access_enabled_is_rejected_until_emitter_is_wired() { + let toml = format!( + "{}\n[tinybird]\naccess_enabled = true\n", + crate_test_settings_str() + ); + + let err = Settings::from_toml(&toml) + .expect_err("should reject access telemetry before emitter exists"); + assert!( + format!("{err:?}").contains("tinybird.access_enabled"), + "should report unsupported tinybird.access_enabled setting: {err:?}" + ); + } + #[test] fn test_settings_from_valid_toml() { let toml_str = crate_test_settings_str(); diff --git a/docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md b/docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md index a03495b1..5cf0ecfa 100644 --- a/docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md +++ b/docs/superpowers/plans/2026-06-23-auction-telemetry-direct-tinybird.md @@ -265,7 +265,7 @@ max_body_bytes = 1048576 Notes: - The revised spec lists token secret keys but not a Secret Store name. The implementation needs a store name because `PlatformSecretStore` reads by `StoreName`; use `tinybird.secret_store` with a safe default such as `ts_secrets` unless the user prefers separate per-token store settings. -- Validate only when `enabled` or `access_enabled` is true: +- Validate only when `enabled` is true; `access_enabled = true` is rejected until Phase 9 wires an access-log emitter, so operators cannot enable a no-op path by mistake: - `api_host` must be non-empty, host-only, no scheme, no path, no control characters. - dataset names must be non-empty and bounded. - secret key names must be non-empty. @@ -494,7 +494,7 @@ Tinybird fixture tests must prove: ### Phase 9: Optional ops access telemetry -Do this only if requested for the implementation PR; auction telemetry alone satisfies phase 1. +Do this only if requested for a future implementation PR; auction telemetry alone satisfies phase 1. Until this phase is implemented, `tinybird.access_enabled = true` fails validation rather than silently emitting nothing. - [ ] Add `AccessTelemetrySink` or extend the Tinybird sink with `emit_access_log`. - [ ] Emit one sampled row at top-level response finalization where status and elapsed time are known. 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 af166260..c7f09cc8 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 @@ -89,11 +89,10 @@ unless the application deliberately waits, which it must not do on the hot path. 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 yield telemetry and optionally ops access telemetry.** Auction - telemetry is the primary requirement. Ops access telemetry uses the same - direct-ingest transport but must be volume-gated with configuration and may be - sampled or disabled if one async POST per request is too expensive for the - Tinybird plan. +3. **Phase 1 ships auction yield telemetry.** Auction telemetry is the primary + requirement. Ops access telemetry is a future phase: its config/schema can be + reserved, but enabling it must fail closed until an emitter exists so an + operator cannot turn on a no-op telemetry path by mistake. 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 @@ -475,17 +474,17 @@ reconciled revenue. The Fastly adapter needs runtime configuration for direct Tinybird ingestion: -| setting | purpose | -| ------------------------------- | ---------------------------------------------------------- | -| `tinybird.enabled` | master enable/disable switch | -| `tinybird.api_host` | regional Tinybird API host, without path | -| `tinybird.auction_dataset` | usually `auction_events_raw` | -| `tinybird.auction_token_secret` | Fastly Secret Store key containing the `ts_ingest` token | -| `tinybird.access_enabled` | enable/disable direct access-log telemetry | -| `tinybird.access_dataset` | usually `access_logs_raw` | -| `tinybird.access_token_secret` | Fastly Secret Store key containing the access append token | -| `tinybird.access_sample_rate` | fraction of requests to emit for ops telemetry | -| `tinybird.max_body_bytes` | defensive maximum body size for one direct ingest POST | +| setting | purpose | +| ------------------------------- | ----------------------------------------------------------------------------------- | +| `tinybird.enabled` | master enable/disable switch | +| `tinybird.api_host` | regional Tinybird API host, without path | +| `tinybird.auction_dataset` | usually `auction_events_raw` | +| `tinybird.auction_token_secret` | Fastly Secret Store key containing the `ts_ingest` token | +| `tinybird.access_enabled` | reserved for future direct access-log telemetry; rejected while no emitter is wired | +| `tinybird.access_dataset` | future access-log datasource, usually `access_logs_raw` | +| `tinybird.access_token_secret` | future Fastly Secret Store key for the access append token | +| `tinybird.access_sample_rate` | future fraction of requests to emit for ops telemetry | +| `tinybird.max_body_bytes` | defensive maximum body size for one direct ingest POST | The adapter must not log token values or request `Authorization` headers. Local and test environments may use disabled/no-op sinks or fixture secrets. From 89655c072bec47891536e2531b35cf808fb8bcee Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 7 Jul 2026 15:33:48 -0500 Subject: [PATCH 14/14] Remove duplicate auction format tests after rebase --- .../src/auction/formats.rs | 174 ------------------ 1 file changed, 174 deletions(-) diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index bb4bef53..691021d5 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -1331,177 +1331,3 @@ mod convert_tests { ); } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::consent::ConsentContext; - use crate::platform::test_support::noop_services; - use crate::test_support::tests::crate_test_settings_str; - use fastly::http::Method; - use fastly::Request; - - fn make_settings() -> Settings { - Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") - } - - fn make_req() -> Request { - Request::new(Method::POST, "https://test-publisher.com/auction") - } - - fn call_convert(body: &AdRequest) -> AuctionRequest { - let settings = make_settings(); - let services = noop_services(); - let req = make_req(); - convert_tsjs_to_auction_request( - body, - &settings, - &services, - &req, - ConsentContext::default(), - "test-ec-id", - None, - ) - .expect("should convert without error") - } - - #[test] - fn no_bids_produces_empty_bidders_map() { - // An ad unit with no `bids` array must produce an empty bidders map. - // An empty bidders map triggers the PBS stored-request fallback: - // the PBS provider sets imp.ext.prebid.storedrequest = { id: "" }. - let body = AdRequest { - ad_units: vec![AdUnit { - code: "atf_sidebar_ad".to_string(), - media_types: Some(MediaTypes { - banner: Some(BannerUnit { - sizes: vec![vec![300, 250]], - }), - }), - bids: None, - }], - config: None, - }; - - let auction_request = call_convert(&body); - - assert_eq!(auction_request.slots.len(), 1, "should have one slot"); - let slot = &auction_request.slots[0]; - assert_eq!(slot.id, "atf_sidebar_ad", "slot id should match unit code"); - assert!( - slot.bidders.is_empty(), - "absent bids array should yield empty bidders map (PBS stored-request path)" - ); - } - - #[test] - fn inline_bids_populate_bidders_map() { - // When bids are supplied, each bidder+params pair should appear in the - // slot's bidders map so PBS receives inline params. - let body = AdRequest { - ad_units: vec![AdUnit { - code: "homepage_header_ad".to_string(), - media_types: Some(MediaTypes { - banner: Some(BannerUnit { - sizes: vec![vec![970, 90]], - }), - }), - bids: Some(vec![BidConfig { - bidder: "kargo".to_string(), - params: serde_json::json!({ "placementId": "client_123" }), - }]), - }], - config: None, - }; - - let auction_request = call_convert(&body); - - let slot = &auction_request.slots[0]; - assert!( - slot.bidders.contains_key("kargo"), - "kargo bidder should be present in slot bidders map" - ); - assert_eq!( - slot.bidders["kargo"]["placementId"], "client_123", - "bidder params should be forwarded verbatim" - ); - } - - #[test] - fn config_allowed_key_passes_through() { - // Keys in auction.allowed_context_keys must reach the auction context. - // The test settings do not set allowed_context_keys so the default - // (empty) applies — verify a key is NOT present rather than IS. - // To test the allow-list, inject a key via a custom settings string. - let settings_str = format!( - "{}\n[auction]\nallowed_context_keys = [\"permutive_segments\"]\n", - crate_test_settings_str() - ); - let settings = Settings::from_toml(&settings_str).expect("should parse"); - let services = noop_services(); - let req = make_req(); - - let body = AdRequest { - ad_units: vec![], - config: Some(serde_json::json!({ - "permutive_segments": ["seg1", "seg2"], - "disallowed_key": "should be dropped", - })), - }; - - let auction_request = convert_tsjs_to_auction_request( - &body, - &settings, - &services, - &req, - ConsentContext::default(), - "test-ec-id", - None, - ) - .expect("should convert"); - - assert!( - auction_request.context.contains_key("permutive_segments"), - "allowed key should be in auction context" - ); - assert!( - !auction_request.context.contains_key("disallowed_key"), - "unlisted key should be dropped" - ); - } - - #[test] - fn invalid_banner_size_returns_error() { - // Banner sizes must be [width, height] pairs; a 3-element size is invalid. - let body = AdRequest { - ad_units: vec![AdUnit { - code: "bad_slot".to_string(), - media_types: Some(MediaTypes { - banner: Some(BannerUnit { - sizes: vec![vec![300, 250, 99]], // invalid — 3 elements - }), - }), - bids: None, - }], - config: None, - }; - - let settings = make_settings(); - let services = noop_services(); - let req = make_req(); - let result = convert_tsjs_to_auction_request( - &body, - &settings, - &services, - &req, - ConsentContext::default(), - "test-ec-id", - None, - ); - - assert!( - result.is_err(), - "3-element banner size should return an error" - ); - } -}