diff --git a/.gitignore b/.gitignore index 770aa83ef8..41dc3aaaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ src/*.html # Documentation proposal workflow scratch space /.docs-proposal/ +# Wrangler config rendered by the Cloudflare integration-test harness from +# wrangler.ci.toml for CI or test-specific configuration overrides. +wrangler.integration.generated.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5d0b9ea8..5ecfed01fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Fastly abandons unfinished requests; Axum, Cloudflare, and Spin cancel owned work. - Explicit TS Console activation with `?ts_console=1` now sets a 30-minute diagnostics cookie instead of a browser-session cookie, independently of the mobile trace flag. Ordinary requests do not refresh it; end and explicitly enable diagnostics again to adopt the lifetime for an existing session. - Publisher documents with diagnostics active now use private, no-store responses and strip conditional and range request headers before fetching the origin. An unexpected origin `304` becomes a private `502` because it cannot supply an instrumented document, including when mobile tracing is disabled. +- Access telemetry v2 now uses `auction_collected_ms` for collection return, sets `ts_version` to the TS build identifier matching `x-ts-version`, and adds `delivery_result` for local complete/partial/error transport handoff. It does not establish client receipt. Coordinate any deployed preview v2 schema before updating the producer. +- Access ingestion now dispatches without waiting for Tinybird's reply. Start failures are reported; later transport errors, non-2xx responses and remote ingestion acceptance are unobserved, with no retry. +- `/auction`, page-bids and HTML collectors share measured collection waits and outcome mapping. Real mediator-only work can have collection/wait/commit timing without a provider dispatch offset; routing and admission still decide whether any request is sent. +- Prebid diagnostics listener, targeting and correlation code now lives in the optional diagnostics bundle. Badge evidence passes clicks through, its small control sits outside observed creative bounds, and selecting the latest request no longer opens history. + +- Auction telemetry failure batches for `/auction` and page-bids now include provider launch-failure rows alongside the summary, so a failed dispatch can emit more than one row. `/auction` summary `elapsed_ms` now uses the orchestrator's elapsed time. +- GPT diagnostics JSON exports now include bounded server winner and Prebid candidate/win bidder codes and bucketed `hb_pb` values. These observations do not establish which creative GAM ultimately served; raw CPM and creative markup remain excluded. +- The Axum dev server now runs its own serve loop to apply terminal request-timing handling. +- The default `tinybird.access_dataset` is now `access_logs_v2`. Since this field is serialized, an upgrade from TOML that omits it may produce a one-time config-envelope diff; review and push once to settle it. Explicit legacy dataset values must be changed before enabling access telemetry. - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** `ts audit ad-templates generate` now previews the managed-field diff on stderr and asks before writing a changed config. Runs whose stdin or stderr is not a terminal fail without writing unless `--yes` is passed, so add `--yes` to scripts and CI jobs that apply generated slots. A declined prompt exits non-zero, `--yes` cannot be combined with `--dry-run`, and a candidate whose managed fields are unchanged is no longer rewritten. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. @@ -71,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `[rewrite] include_domains` limits creative asset rewriting to listed hosts. When non-empty, only asset URLs on matching hosts are proxied through `/first-party/proxy`, so only those hosts receive the EC ID on newly rewritten or signed asset URLs. The list is not enforced when a proxy URL is fetched, so previously issued proxy URLs can still forward the EC ID to hosts later removed from it. Other assets keep their original URL, and `/first-party/sign` returns `502` for them so the creative runtime loads them directly. Click-through links are not affected, and `exclude_domains` still wins. Entries must be host names, optionally prefixed with `*.`, dotted-quad IPv4 addresses, or bracketed IPv6 addresses; entries with a scheme, port, path, spaces or non-ASCII characters are rejected at load. Upgrading: deploy the binary first, then push a config that sets `include_domains`. Rolling back: remove `include_domains`, push the resulting config, then roll back the binary; older binaries reject the key because `[rewrite]` denies unknown fields. - Added `[auction].rewrite_clicks` to control creative click-through wrapping (``/`` → signed `/first-party/click`) independently of asset rewriting. Unset (the default) follows `rewrite_creatives` for `POST /auction` and SSAT/page-bids and keeps wrapping links in HTML fetched through `/first-party/proxy`, so existing configs keep their asset and link behavior; an explicit value applies to every path. `rewrite_creatives` now governs asset URLs only; bidder `` handling and, on `POST /auction`, creative TSJS injection run when either setting is on. Click wrapping is what forwards the visitor's EC ID (`ts-ec`) to landing pages, so `rewrite_clicks = false` stops that on every path and `rewrite_clicks = true` starts it for configs with `rewrite_creatives = false`. Also changed for every config with rewriting on, including the default, and for HTML fetched through `/first-party/proxy` under every config: a bidder `` is now kept with its `href` dropped, instead of removing the whole element, so raw links keep opening where the bidder intended. Upgrading: deploy the binary first, then push a config that sets `rewrite_clicks`. Rolling back: remove any explicit `rewrite_clicks` (and its environment override), push the resulting config, then roll back the binary. Older binaries tie click wrapping to `rewrite_creatives` in both directions, so rolling back turns clicks back on for `rewrite_creatives = true` with `rewrite_clicks = false`, and off for `rewrite_creatives = false` with `rewrite_clicks = true`; rolling back from any `rewrite_clicks = false` also turns click wrapping back on in HTML fetched through `/first-party/proxy`. - Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (asset URL conversion to `/first-party/proxy`; bidder `` handling, which drops the `href` and keeps a `target`, and creative TSJS injection on `POST /auction` only, both of which also run when `rewrite_clicks` is on). Click-through wrapping is controlled by `[auction].rewrite_clicks`, which follows `rewrite_creatives` when unset. Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. +- Added opt-in request-phase `Server-Timing` on private Fastly/Axum responses and sampled post-delivery Fastly access telemetry. Deploy the new `access_logs_v2` datasource and its scoped APPEND token before enabling access emission; the legacy `access_logs_raw` schema and data remain unchanged. Access rows retain bounded route templates and coarse operational dimensions for 30 days, with random UUID joins for active auction observations and null UUIDs for disabled auctions. Each row stores its sampling rate. Access ingestion starts after delivery without waiting for a reply. Before rolling back to an older binary, disable access emission, remove non-default `[observability]` config, and re-push; access-only deployments must also disable the Tinybird master switch. See the telemetry guide and Tinybird cutover runbook. - `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup rejects a blank `gam_network_id` only when an absent/default path or `{network_id}` template consumes it. Trusted Server conservatively caps whole rendered dynamic paths at 100 UTF-8 bytes, informed by Google's 100-character per-ad-unit-code limit; an over-limit request-specific path omits that slot without failing the response. During typed/startup finalization, every placeholder-bearing template that omits `section_segment` materializes `section_segment = 0`, so an older binary rejects the blob loudly. Static and absent paths remain legacy-schema compatible only when both `section_root` and `section_segment` are omitted. Before rolling back below this feature, replace or remove dynamic paths, remove both keys, re-push and finalize the config, then roll back the binary. - Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. - Added typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint. diff --git a/Cargo.lock b/Cargo.lock index 70316712ac..0401a9961b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "base16ct 1.0.0", "serde", @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "anyhow", "async-trait", @@ -1571,7 +1571,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "anyhow", "async-trait", @@ -1594,7 +1594,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "anyhow", "async-stream", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "anyhow", "async-trait", @@ -1651,7 +1651,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "chrono", "clap", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "anyhow", "async-compression", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3#31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" dependencies = [ "log", "proc-macro2", @@ -6600,6 +6600,7 @@ dependencies = [ "futures", "log", "log-fastly", + "rand 0.8.6", "serde", "serde_json", "toml", diff --git a/Cargo.toml b/Cargo.toml index 139cf4f610..b450f52dc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,12 +56,14 @@ cssparser = "0.36" derive_more = { version = "2.0", features = ["display", "error"] } directories = "5" ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11" } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } +# Temporary integration pin for EdgeZero #389, including main's lifecycle and ingress APIs. +# Replace all six revisions with an approved release tag before merging into main. +edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", rev = "31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "31b66d4b1c48dd0bf4a600837268d7eb7e3fb5b3", default-features = false } env_logger = "0.11" error-stack = "0.6" esi = "0.7.2" diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index af707aa24e..798d644440 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" [dependencies] async-trait = { workspace = true } +axum = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } edgezero-core = { workspace = true } error-stack = { workspace = true } @@ -27,7 +28,8 @@ futures = { workspace = true } log = { workspace = true } reqwest = { workspace = true } simple_logger = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] } +tower = { workspace = true, features = ["util"] } trusted-server-core = { workspace = true } web-time = { workspace = true } @@ -37,4 +39,3 @@ axum = { workspace = true } base64 = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } -tower = { workspace = true, features = ["util"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 0b1858e208..039a3867c9 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -2,6 +2,7 @@ use core::future::Future; use std::sync::Arc; use edgezero_adapter_axum::context::AxumRequestContext; +use edgezero_adapter_axum::service::EdgeZeroAxumService; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; @@ -41,6 +42,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::{ClientInfo, RuntimeServices}; use trusted_server_core::trace::{TraceMetadata, TracePreDispatchHook}; +use crate::ingress::HttpIngressHook; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; @@ -619,16 +621,10 @@ impl Hooks for TrustedServerApp { "TrustedServer" } + /// Returns the bare router without terminal timing; serve with + /// [`Self::dev_server_service`] to preserve `Server-Timing` emission. fn routes() -> RouterService { - let state = match build_state() { - Ok(s) => s, - Err(ref e) => { - log::error!("failed to build application state: {:?}", e); - return startup_error_router(e); - } - }; - - build_router(&state) + Self::routes_with_server_timing_flag().0 } } @@ -650,6 +646,43 @@ impl TrustedServerApp { Ok(build_router(&state)) } + /// The dev server's fully configured tower service: the application + /// router wrapped in the terminal timing layer + /// ([`crate::timing::TimingService`]), with `server_timing_enabled` + /// read from the same settings snapshot that built the router. + /// + /// This is the standard construction path for serving this adapter. + /// [`Hooks::routes`] satisfies the `Hooks` trait contract and returns + /// the bare router without the timing layer; callers who serve traffic + /// should use this instead so `server_timing_enabled` is never + /// silently discarded. + #[must_use] + pub fn dev_server_service() -> crate::timing::TimingService { + let (router, server_timing_enabled) = Self::routes_with_server_timing_flag(); + crate::timing::TimingService::new(EdgeZeroAxumService::new(router), server_timing_enabled) + } + + /// Build the router alongside whether `Server-Timing` emission is + /// enabled, read from the same settings snapshot used to build the + /// router. + /// + /// The Axum dev server's terminal timing layer ([`crate::timing`]) needs + /// this flag once at startup. The Axum dev server builds its application + /// state once and reuses the same [`RouterService`] for every request. + #[must_use] + fn routes_with_server_timing_flag() -> (RouterService, bool) { + let state = match build_state() { + Ok(s) => s, + Err(ref e) => { + log::error!("failed to build application state: {:?}", e); + return (startup_error_router(e), false); + } + }; + + let server_timing_enabled = state.settings.observability.server_timing_enabled; + (build_router(&state), server_timing_enabled) + } + /// Build the full router with explicit settings and runtime services. /// /// Each request receives a clone of the supplied services, allowing callers @@ -680,10 +713,10 @@ fn build_router(state: &Arc) -> RouterService { let trace_state = Arc::clone(state); let mut router = RouterService::builder() - .pre_dispatch_hook_arc(Arc::new(TracePreDispatchHook::new( + .pre_dispatch_hook_arc(Arc::new(HttpIngressHook(TracePreDispatchHook::new( Arc::clone(&state.settings), Arc::new(move |request| trace_metadata(&trace_state, request)), - ))) + )))) // Outermost middleware: strips the configured trusted-client-IP // headers before anything else sees the request. Must stay first — // any middleware registered ahead of it would observe the diff --git a/crates/trusted-server-adapter-axum/src/ingress.rs b/crates/trusted-server-adapter-axum/src/ingress.rs new file mode 100644 index 0000000000..7678eae8bf --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/ingress.rs @@ -0,0 +1,81 @@ +//! Ingress capture for the application-owned plain HTTP listener. +//! +//! EdgeZero's HTTP transport marker is private to its dev-server helper. Our +//! timed listener cannot install it, and SDK request conversion replaces any +//! preinstalled `RequestIngress`. Carry a separate private extension through +//! conversion and restore it before trace preflight instead. + +use async_trait::async_trait; +use axum::body::Body as AxumBody; +use axum::http::Request as AxumRequest; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Request, Response, header}; +use edgezero_core::ingress::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; +use edgezero_core::router::PreDispatchHook; + +#[derive(Clone)] +struct HttpIngress(RequestIngress); + +/// Capture ingress at the application-owned **plain HTTP** listener only. +/// +/// This must not be used for TLS listeners or arbitrary in-process requests. +/// Forwarded headers never establish the transport scheme or authority. +/// +/// # Examples +/// +/// ```ignore +/// capture_http_ingress(&mut request); +/// let response = service.oneshot(request).await; +/// ``` +pub fn capture_http_ingress(request: &mut AxumRequest) { + let uri = request.uri(); + let mut hosts = request.headers().get_all(header::HOST).iter(); + let origin = hosts.next().and_then(|host| { + if hosts.next().is_some() + || uri + .scheme_str() + .is_some_and(|scheme| !scheme.eq_ignore_ascii_case("http")) + { + return None; + } + let origin = + InboundOrigin::parse("http", host.to_str().ok()?, OriginSource::TransportBinding) + .ok()?; + if let Some(authority) = uri.authority() { + let supplied = + InboundOrigin::parse("http", authority.as_str(), OriginSource::TransportBinding) + .ok()?; + if !origin.same_authority(&supplied) { + return None; + } + } + Some(origin) + }); + let source = if uri.scheme().is_some() { + TargetSource::RuntimeUrl + } else { + TargetSource::RuntimePathAndQuery + }; + let ingress = RequestIngress::new( + CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown), + origin, + HeaderFidelity::RUNTIME_HEADER_MAP, + ); + request.extensions_mut().insert(HttpIngress(ingress)); +} + +/// Restore only facts captured by our listener, before the shared trace policy. +pub(crate) struct HttpIngressHook(pub(crate) H); + +#[async_trait(?Send)] +impl PreDispatchHook for HttpIngressHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + if let Some(ingress) = request.extensions_mut().remove::() { + request.extensions_mut().insert(ingress.0); + } + self.0.handle(request).await + } +} diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d2..d972023a74 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -6,7 +6,12 @@ /// Application routing and handler registration for the Axum dev server. pub mod app; +/// Validated ingress for the application-owned HTTP listener. +pub mod ingress; /// Request middleware (auth, response finalisation). pub mod middleware; /// Platform-trait implementations backed by env vars and `reqwest`. pub mod platform; +/// Terminal timing layer wrapping the Axum dev server's tower `Service` +/// boundary with the request-phase `Server-Timing` freeze point. +pub mod timing; diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 66d592e0b7..e08405a1a8 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,7 +1,19 @@ -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; -use edgezero_core::app::Hooks as _; +use std::convert::Infallible; +use std::net::SocketAddr; + +use axum::Router; +use axum::body::Body; +use axum::http::{Request, Response}; +use edgezero_adapter_axum::dev_server::AxumDevServerConfig; +use edgezero_adapter_axum::service::EdgeZeroAxumService; use log::LevelFilter; +use tokio::net::TcpListener; +use tokio::runtime::Builder as RuntimeBuilder; +use tokio::signal; +use tower::{Service, ServiceExt as _, service_fn}; use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::ingress::capture_http_ingress; +use trusted_server_adapter_axum::timing::TimingService; /// Environment variable that sets the maximum log level. const LOG_LEVEL_ENV: &str = "RUST_LOG"; @@ -37,14 +49,91 @@ fn main() { None => AxumDevServerConfig::default(), }; - log::info!("Listening on http://{}", config.addr); - let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { + let service = TrustedServerApp::dev_server_service(); + if let Err(err) = run(service, config) { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } } +/// Runs the Axum dev server with the request-phase timing terminal layer +/// ([`trusted_server_adapter_axum::timing::TimingService`]) wrapped around +/// `EdgeZeroAxumService`, ahead of `axum::serve`. +/// +/// This does not use `edgezero_adapter_axum::dev_server::AxumDevServer::run`: +/// that helper only accepts a bare [`RouterService`](edgezero_core::router::RouterService) and builds its own +/// `EdgeZeroAxumService` and `axum::Router` internally, with no seam for an +/// outer service wrapper. Router-generated 404/405 responses bypass +/// `RouterBuilder::middleware` (see `trusted_server_adapter_axum::timing`), +/// so the freeze point has to wrap the tower `Service` boundary itself. +/// Driving `axum::serve` directly here mirrors that helper's own internal +/// bind/wrap/serve/shutdown sequence closely enough to keep behavior +/// identical for callers (`PORT` env var, ctrl-c graceful shutdown). +/// +/// # Errors +/// +/// Returns an error if the Tokio runtime fails to start, the listener fails +/// to bind, or the underlying serve loop errors. +fn run( + service: TimingService, + config: AxumDevServerConfig, +) -> std::io::Result<()> { + let runtime = RuntimeBuilder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("failed to build tokio runtime: {error}"), + ) + })?; + runtime.block_on(serve(service, config)) +} + +async fn serve( + service: TimingService, + config: AxumDevServerConfig, +) -> std::io::Result<()> { + let listener = TcpListener::bind(config.addr).await.map_err(|error| { + std::io::Error::new( + error.kind(), + format!("failed to bind dev server to {}: {error}", config.addr), + ) + })?; + + log::info!("Listening on http://{}", listener.local_addr()?); + + let make_service = + dev_server_router(service).into_make_service_with_connect_info::(); + + let server = axum::serve(listener, make_service); + if config.enable_ctrl_c { + server + .with_graceful_shutdown(async { + let _ctrl_c = signal::ctrl_c().await; + }) + .await + } else { + server.await + } + .map_err(|error| std::io::Error::new(error.kind(), format!("axum server error: {error}"))) +} + +fn dev_server_router(service: S) -> Router +where + S: Service, Response = Response, Error = Infallible> + + Clone + + Send + + Sync + + 'static, + S::Future: Send + 'static, +{ + Router::new().fallback_service(service_fn(move |mut req| { + capture_http_ingress(&mut req); + service.clone().oneshot(req) + })) +} + /// Parses an optional `RUST_LOG` value into the logger's maximum level. /// /// Absent and blank values resolve to [`DEFAULT_LOG_LEVEL`]. Any other value @@ -84,10 +173,253 @@ fn port_from_env() -> Option { #[cfg(test)] mod tests { + use std::future::{Ready, ready}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; + use super::*; - #[test] - fn crate_compiles() {} + struct ReadinessSensitiveService { + polled: bool, + readied: bool, + pending_polls: Arc, + calls: Arc, + } + + impl Clone for ReadinessSensitiveService { + fn clone(&self) -> Self { + Self { + polled: false, + readied: false, + pending_polls: Arc::clone(&self.pending_polls), + calls: Arc::clone(&self.calls), + } + } + } + + impl Service> for ReadinessSensitiveService { + type Response = Response; + type Error = Infallible; + type Future = Ready>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + if !self.polled { + self.polled = true; + self.pending_polls.fetch_add(1, Ordering::Relaxed); + cx.waker().wake_by_ref(); + return Poll::Pending; + } + self.readied = true; + Poll::Ready(Ok(())) + } + + fn call(&mut self, _request: Request) -> Self::Future { + assert!( + self.readied, + "should wait for this instance to become ready" + ); + self.readied = false; + self.calls.fetch_add(1, Ordering::Relaxed); + ready(Ok(Response::builder() + .header("cache-control", "private") + .body(Body::empty()) + .expect("should build a private response"))) + } + } + + #[tokio::test] + async fn dev_server_bridge_waits_for_readiness_on_timed_and_health_paths() { + for path in ["/private", "/health"] { + for enabled in [true, false] { + let pending_polls = Arc::new(AtomicUsize::new(0)); + let calls = Arc::new(AtomicUsize::new(0)); + let router = dev_server_router(TimingService::new( + ReadinessSensitiveService { + polled: false, + readied: false, + pending_polls: Arc::clone(&pending_polls), + calls: Arc::clone(&calls), + }, + enabled, + )); + + for expected_calls in 1..=2 { + let response = router + .clone() + .oneshot( + Request::builder() + .uri(path) + .body(Body::empty()) + .expect("should build a bridge request"), + ) + .await + .expect("should serve the request after readiness"); + + assert_eq!(calls.load(Ordering::Relaxed), expected_calls); + assert_eq!(pending_polls.load(Ordering::Relaxed), expected_calls); + assert_eq!( + response.headers().contains_key("server-timing"), + enabled && path != "/health", + "should preserve timing configuration and health exclusion" + ); + } + } + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn http_listener_preserves_trace_action_origin_checks_through_sdk_conversion() { + let settings = trusted_server_core::settings::Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "example-user" + password = "example-password" + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.example.com" + proxy_secret = "fictional-proxy-secret" + [ec] + passphrase = "fictional-passphrase-at-least-32-bytes" + [request_signing] + enabled = false + config_store_id = "fictional-config" + secret_store_id = "fictional-secrets" + [integrations.gpt_diagnostics] + enabled = true + trace_page_enabled = true + "#, + ) + .expect("should parse trace settings"); + let service = TimingService::new( + EdgeZeroAxumService::new( + TrustedServerApp::routes_with_settings(settings) + .expect("should build trace routes"), + ), + true, + ); + let listener = dev_server_router(service.clone()); + for action in ["enable", "end"] { + for (target_origin, hosts, origin, site, through_listener, accepted) in [ + ( + "", + vec!["localhost:8787"], + "http://localhost:8787", + "same-origin", + true, + true, + ), + ( + "http://localhost:8787", + vec!["localhost:8787"], + "http://localhost:8787", + "same-origin", + true, + true, + ), + ( + "", + vec!["localhost:8787"], + "https://other.example.com", + "same-origin", + true, + false, + ), + ( + "", + vec!["localhost:8787"], + "http://localhost:8787", + "cross-site", + true, + false, + ), + ( + "http://other.example.com", + vec!["localhost:8787"], + "http://localhost:8787", + "same-origin", + true, + false, + ), + ( + "https://localhost:8787", + vec!["localhost:8787"], + "https://localhost:8787", + "same-origin", + true, + false, + ), + ( + "", + vec![], + "http://localhost:8787", + "same-origin", + true, + false, + ), + ( + "", + vec!["localhost:8787", "localhost:8787"], + "http://localhost:8787", + "same-origin", + true, + false, + ), + ( + "", + vec!["localhost:invalid"], + "http://localhost:8787", + "same-origin", + true, + false, + ), + ( + "", + vec!["localhost:8787"], + "http://localhost:8787", + "same-origin", + false, + false, + ), + ] { + let mut request = Request::builder() + .method("POST") + .uri(format!("{target_origin}/_ts/trace/{action}")) + .header("origin", origin) + .header("sec-fetch-site", site) + .header("x-ts-trace-action", action) + // These caller-controlled values must not override transport facts. + .header("forwarded", "proto=https;host=other.example.com") + .header("x-forwarded-proto", "https") + .header("x-forwarded-host", "other.example.com"); + for host in hosts { + request = request.header("host", host); + } + let request = request + .body(Body::empty()) + .expect("should build trace action"); + let response = if through_listener { + listener.clone().oneshot(request).await + } else { + service.clone().oneshot(request).await + } + .expect("should serve trace action"); + assert_eq!( + response.status().as_u16(), + if accepted { 200 } else { 403 }, + "should authorize only listener-bound same-origin {action} actions" + ); + assert_eq!(response.headers().contains_key("set-cookie"), accepted); + assert_eq!(response.headers()["cache-control"], "no-store, private"); + assert!( + !response.headers().contains_key("server-timing"), + "should preserve terminal trace hardening outside the timing layer" + ); + } + } + } #[test] fn parse_log_level_defaults_to_info_when_unset_or_blank() { diff --git a/crates/trusted-server-adapter-axum/src/timing.rs b/crates/trusted-server-adapter-axum/src/timing.rs new file mode 100644 index 0000000000..c920179ae9 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/timing.rs @@ -0,0 +1,567 @@ +//! Terminal timing layer for the Axum dev server. +//! +//! [`TimingService`](crate::timing::TimingService) wraps the tower `Service` +//! boundary the Axum dev server's router sits behind: it reuses the generic +//! handle in request extensions or creates one, exposing it through the +//! [`RequestTimings`](trusted_server_core::request_timing::RequestTimings) +//! facade. Downstream core handlers can record into it. The wrapper stamps +//! `mark_headers_ready` on the way back and appends the `Server-Timing` header via +//! [`append_server_timing_if_private`](trusted_server_core::request_timing::append_server_timing_if_private). +//! +//! This wraps *outside* `RouterService` rather than registering as +//! `RouterBuilder::middleware` because the tower boundary is the terminal +//! freeze point. Before a response reaches this layer, +//! `RouterService::oneshot` inside `EdgeZeroAxumService::call` has +//! converted any dispatch error into a plain response. Every response is +//! covered uniformly regardless of how routing produced it, and the +//! position survives future routing changes. (In this application's router +//! a catch-all fallback spans every path and publisher method, so +//! router-generated 404/405s that bypass middleware are close to +//! unreachable today; the outer position does not depend on them.) +//! +//! `/health` is excluded by path match before a +//! [`RequestTimings`](trusted_server_core::request_timing::RequestTimings) +//! collector is even created: this wrapper bypasses timing for every method +//! on that path, leaving any preinstalled handle unchanged. +//! +//! The Axum dev server builds its application state once at startup, so +//! `ts-appbuild` never appears in its per-request header. Fastly records +//! per-request setup and, on cold requests, application construction in +//! `Phase::AppBuild`. + +use std::convert::Infallible; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use axum::body::Body as AxumBody; +use axum::http::{Request, Response}; +use tower::Service; +use trusted_server_core::request_timing::{RequestTimings, append_server_timing_if_private}; +use trusted_server_core::trace::TraceTerminalResponse; + +/// Path excluded from timing collection and `Server-Timing` emission: health +/// checks bypass this wrapper for every method. +const HEALTH_PATH: &str = "/health"; + +/// Wraps an inner Axum tower service with the request-phase timing freeze +/// point described in the module docs. +#[derive(Clone)] +pub struct TimingService { + inner: S, + server_timing_enabled: bool, +} + +impl TimingService { + /// Wraps `inner`, appending `Server-Timing` when `server_timing_enabled` + /// is set and the response is conclusively private. + #[must_use] + pub fn new(inner: S, server_timing_enabled: bool) -> Self { + Self { + inner, + server_timing_enabled, + } + } +} + +impl Service> for TimingService +where + S: Service, Response = Response, Error = Infallible> + + Clone + + Send + + 'static, + S::Future: Send + 'static, +{ + type Error = Infallible; + type Future = Pin> + Send>>; + type Response = Response; + + fn call(&mut self, mut req: Request) -> Self::Future { + // Readiness belongs to the polled instance, not necessarily its clone. + let replacement = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, replacement); + + // Bypass installation and finalization for `/health`, regardless of method. + // A preinstalled collector remains available to the inner service. + if req.uri().path() == HEALTH_PATH { + return Box::pin(async move { inner.call(req).await }); + } + + let server_timing_enabled = self.server_timing_enabled; + let timings = RequestTimings::from_extensions(req.extensions()).unwrap_or_else(|| { + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + timings + }); + + Box::pin(async move { + let mut response = inner.call(req).await?; + if response + .extensions() + .get::() + .is_none() + { + append_server_timing_if_private(&mut response, &timings, server_timing_enabled); + } + Ok(response) + }) + } + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } +} + +#[cfg(test)] +mod tests { + use std::future::{Ready, ready}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use axum::http::header::CACHE_CONTROL; + use axum::http::{HeaderValue, StatusCode}; + use edgezero_adapter_axum::service::EdgeZeroAxumService; + use edgezero_core::body::Body as EdgeBody; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::response_builder; + use edgezero_core::router::RouterService; + use tower::{ServiceExt as _, service_fn}; + + struct ReadinessSensitiveService { + readied: bool, + calls: Arc, + } + + impl Clone for ReadinessSensitiveService { + fn clone(&self) -> Self { + Self { + readied: false, + calls: Arc::clone(&self.calls), + } + } + } + + impl Service> for ReadinessSensitiveService { + type Response = Response; + type Error = Infallible; + type Future = Ready>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.readied = true; + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request) -> Self::Future { + assert!( + self.readied, + "should call the instance whose readiness permit was reserved" + ); + self.readied = false; + self.calls.fetch_add(1, Ordering::Relaxed); + ready(Ok(Response::builder() + .header(CACHE_CONTROL, "private") + .body(AxumBody::empty()) + .expect("should build a private fixture response"))) + } + } + + #[tokio::test] + async fn axum_calls_the_readied_instance_on_timed_and_health_paths() { + for path in ["/private", "/health"] { + for enabled in [true, false] { + let calls = Arc::new(AtomicUsize::new(0)); + let inner = ReadinessSensitiveService { + readied: false, + calls: Arc::clone(&calls), + }; + let mut service = TimingService::new(inner, enabled); + + for expected_calls in 1..=2 { + let request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .expect("should build a request for readiness testing"); + let response = service + .ready() + .await + .expect("should reserve readiness") + .call(request) + .await + .expect("should call the readied service"); + + assert_eq!( + calls.load(Ordering::Relaxed), + expected_calls, + "should forward each request exactly once" + ); + assert_eq!( + header(&response, "server-timing").is_some(), + enabled && path != HEALTH_PATH, + "should preserve the timing flag and health bypass" + ); + } + } + } + } + + /// Builds a private (`cache-control: private, no-store`) response for a + /// handler under test. + fn private_ok_response() -> Result { + Ok(response_builder() + .status(StatusCode::OK) + .header("cache-control", "private, no-store") + .body(EdgeBody::from("ok")) + .expect("should build a private response fixture")) + } + + /// Reads a response header as a UTF-8 string, or `None` if absent. + fn header(response: &Response, name: &str) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_preserves_trace_response_without_terminal_timing() { + let router = RouterService::builder() + .get("/_ts/trace", |mut ctx: RequestContext| async move { + match trusted_server_core::trace::preflight( + &trusted_server_core::settings::Settings::default(), + ctx.request_mut(), + ) { + trusted_server_core::trace::TracePreflight::Response(response) => Ok(response), + _ => panic!("should reject disabled trace locally"), + } + }) + .build(); + let response = TimingService::new(EdgeZeroAxumService::new(router), true) + .oneshot( + Request::builder() + .uri("/_ts/trace") + .body(AxumBody::empty()) + .expect("should build trace request"), + ) + .await + .expect("should serve trace response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!( + response + .extensions() + .get::() + .is_some(), + "should preserve the terminal trace marker through conversion" + ); + assert_eq!( + header(&response, "cache-control").as_deref(), + Some("no-store, private"), + "should preserve hardened trace cache policy" + ); + assert!( + header(&response, "server-timing").is_none(), + "should bypass ordinary timing finalization even when enabled" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_preserves_preinstalled_collector_and_origin() { + let timings = RequestTimings::new(); + timings.record( + trusted_server_core::request_timing::Phase::Filter, + std::time::Duration::from_millis(7), + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let router = RouterService::builder() + .get("/private", |ctx: RequestContext| async move { + let timings = RequestTimings::from_extensions(ctx.request().extensions()) + .expect("should receive preinstalled collector"); + assert_eq!( + timings.snapshot().filter_ms, + Some(7), + "should preserve upstream phase" + ); + timings.mark_auction_dispatched(); + assert!( + timings + .snapshot() + .auction_dispatched_ms + .expect("should mark dispatch") + >= 20, + "should preserve the pre-aged origin" + ); + private_ok_response() + }) + .build(); + let terminal = TimingService::new(EdgeZeroAxumService::new(router), true); + let upstream_timings = timings.clone(); + let mut upstream = service_fn(move |mut req: Request| { + req.extensions_mut() + .insert(upstream_timings.handle().clone()); + let mut service = terminal.clone(); + async move { service.call(req).await } + }); + let request = Request::builder() + .uri("/private") + .body(AxumBody::empty()) + .expect("should build request"); + let response = upstream + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should handle request"); + let value = header(&response, "server-timing").expect("should render private timing"); + assert!( + value.contains("ts-filter;dur=7.0"), + "should render upstream phase: {value}" + ); + assert!( + timings + .snapshot() + .time_elapsed_ms + .expect("should stamp original handle") + >= 20, + "terminal rendering should use the original clock" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_emits_header_on_private_response() { + let router = RouterService::builder() + .get("/private", |_ctx: RequestContext| async { + private_ok_response() + }) + .build(); + let mut service = TimingService::new(EdgeZeroAxumService::new(router), true); + + let request = Request::builder() + .uri("/private") + .body(AxumBody::empty()) + .expect("should build request"); + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should not fail"); + + let server_timing = header(&response, "server-timing").expect("should emit header"); + assert!( + server_timing.contains("ts-total;dur="), + "should carry the collected total: {server_timing}" + ); + assert!( + !server_timing.contains("ts-appbuild"), + "the Axum dev server builds state once at startup, so there is no \ + per-request app-build interval to render: {server_timing}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_suppresses_timing_when_edge_cache_header_collides_with_private_cache_control() { + let router = RouterService::builder() + .get("/collision", |_ctx: RequestContext| async { + Ok(response_builder() + .status(StatusCode::OK) + .header("cache-control", "private, no-store") + .header("cdn-cache-control", "max-age=60") + .body(EdgeBody::from("ok")) + .expect("should build a response with conflicting cache directives")) + }) + .build(); + let mut service = TimingService::new(EdgeZeroAxumService::new(router), true); + + let request = Request::builder() + .uri("/collision") + .body(AxumBody::empty()) + .expect("should build request"); + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should not fail"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + header(&response, "cdn-cache-control").as_deref(), + Some("max-age=60") + ); + assert!( + header(&response, "server-timing").is_none(), + "must not expose timing when a CDN header can make the private response cacheable" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_suppresses_header_when_flag_is_off() { + let router = RouterService::builder() + .get("/private", |_ctx: RequestContext| async { + private_ok_response() + }) + .build(); + let mut service = TimingService::new(EdgeZeroAxumService::new(router), false); + + let request = Request::builder() + .uri("/private") + .body(AxumBody::empty()) + .expect("should build request"); + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should not fail"); + + assert!( + header(&response, "server-timing").is_none(), + "should not emit server-timing when server_timing_enabled is false" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_round_trips_phase_timings_recorded_in_the_handler() { + // The collector crosses the adapter boundary as a request extension; + // this pins the round trip end to end: a phase recorded inside a + // core-style handler must come back out in the rendered header, so + // a future adapter conversion that drops request extensions fails + // here instead of silently losing every phase. + let router = RouterService::builder() + .get("/private", |ctx: RequestContext| async move { + if let Some(timings) = RequestTimings::from_extensions(ctx.request().extensions()) { + timings.record( + trusted_server_core::request_timing::Phase::Filter, + std::time::Duration::from_millis(7), + ); + } + private_ok_response() + }) + .build(); + let mut service = TimingService::new(EdgeZeroAxumService::new(router), true); + + let request = Request::builder() + .uri("/private") + .body(AxumBody::empty()) + .expect("should build request"); + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should not fail"); + + let server_timing = header(&response, "server-timing").expect("should emit header"); + assert!( + server_timing.contains("ts-filter;dur=7.0"), + "a phase recorded in the handler should survive the adapter round trip: {server_timing}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_404_carries_header_when_private() { + // An empty router has no routes at all, so any path dispatches + // through `RouterInner::dispatch`'s `NotFound` branch, exactly the + // path that bypasses `RouterBuilder::middleware`. The router's own + // `EdgeError::into_response` does not attach `Cache-Control`, so a + // small wrapping service forces the response private here, standing + // in for whatever upstream layer would normally mark a genuinely + // private 404. This proves the freeze point still runs for a + // router-generated response without weakening + // `append_server_timing_if_private`'s real gating logic. + let empty_router = RouterService::builder().build(); + let inner = EdgeZeroAxumService::new(empty_router); + let force_private = service_fn(move |req: Request| { + let mut svc = inner.clone(); + async move { + let mut response = svc.call(req).await?; + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store")); + Ok::<_, Infallible>(response) + } + }); + let mut service = TimingService::new(force_private, true); + + let request = Request::builder() + .uri("/does-not-exist") + .body(AxumBody::empty()) + .expect("should build request"); + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should not fail"); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "should still be the router's own not-found response" + ); + let server_timing = header(&response, "server-timing") + .expect("a router-generated 404 must still carry the header when private"); + assert!( + server_timing.contains("ts-total;dur="), + "should carry the collected total: {server_timing}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn axum_health_is_excluded() { + for method in [axum::http::Method::GET, axum::http::Method::POST] { + for path in ["/health", "/health?probe=1"] { + for preinstalled in [false, true] { + let timings = RequestTimings::new(); + let inner = service_fn(move |req: Request| async move { + assert_eq!( + RequestTimings::from_extensions(req.extensions()).is_some(), + preinstalled, + "health bypass should not install or remove a handle" + ); + Ok::<_, Infallible>( + Response::builder() + .header("cache-control", "private") + .body(AxumBody::empty()) + .expect("should build private response"), + ) + }); + let mut service = TimingService::new(inner, true); + let mut request = Request::builder() + .method(method.clone()) + .uri(path) + .body(AxumBody::empty()) + .expect("should build request"); + if preinstalled { + request.extensions_mut().insert(timings.handle().clone()); + } + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should not fail"); + assert!( + header(&response, "server-timing").is_none(), + "health should not emit timing" + ); + assert_eq!( + timings.snapshot().time_elapsed_ms, + None, + "health bypass should not finalize timing" + ); + } + } + } + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 668b025ccb..9298722906 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -40,6 +40,7 @@ use trusted_server_core::publisher::{ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; +use trusted_server_core::request_timing::RequestTimingMiddleware; use trusted_server_core::settings::Settings; use trusted_server_core::trace::{TraceMetadata, TracePreDispatchHook}; @@ -609,6 +610,7 @@ fn build_router(state: &Arc) -> RouterService { // any middleware registered ahead of it would observe the // shared-secret authentication header. .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) + .middleware(RequestTimingMiddleware::default().with_excluded_paths(&["/health"])) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get( diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index af19241163..6bb7255c86 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -56,9 +56,9 @@ impl Middleware for SanitizeRequestMiddleware { /// (injected by the Cloudflare Workers runtime). On the native host target the /// header is absent, so `X-Geo-Info-Available: false` is emitted. /// -/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of -/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected -/// ones — carries a consistent set of headers. +/// Registered inside [`RequestTimingMiddleware`](trusted_server_core::request_timing::RequestTimingMiddleware) and ahead of [`AuthMiddleware`] +/// so that every outgoing response — including auth-rejected ones — carries a +/// consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -163,6 +163,8 @@ pub(crate) fn apply_finalize_headers( #[cfg(test)] mod tests { use super::*; + use edgezero_core::router::RouterService; + use trusted_server_core::request_timing::{RequestTimingMiddleware, RequestTimings}; use std::collections::HashMap; use std::sync::Mutex; @@ -182,10 +184,10 @@ mod tests { .expect("should build empty test response") } - fn empty_ctx() -> RequestContext { + fn ctx_for_path(path: &str) -> RequestContext { let req = request_builder() .method(Method::GET) - .uri("/test") + .uri(path) .header("x-reader-ip", "198.51.100.7") .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") .body(Body::empty()) @@ -193,6 +195,10 @@ mod tests { RequestContext::new(req, PathParams::new(HashMap::new())) } + fn empty_ctx() -> RequestContext { + ctx_for_path("/test") + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { // Build from explicit test settings: the settings baked into the // binary contain placeholder secrets that `get_settings()` rejects @@ -291,6 +297,77 @@ mod tests { ); } + #[test] + fn request_timing_middleware_preserves_shared_handle_and_health_policy() { + for method in [Method::GET, Method::POST] { + for path in ["/test", "/health", "/health?probe=1", "/health/child"] { + for preinstalled in [false, true] { + let route_path = path.split('?').next().expect("should have path"); + let expected = preinstalled || route_path != "/health"; + let timings = RequestTimings::new(); + timings.record( + trusted_server_core::request_timing::Phase::Filter, + std::time::Duration::from_millis(7), + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + let router = RouterService::builder() + .middleware( + RequestTimingMiddleware::default().with_excluded_paths(&["/health"]), + ) + .middleware( + RequestTimingMiddleware::default().with_excluded_paths(&["/health"]), + ) + .route(route_path, method.clone(), move |ctx: RequestContext| async move { + let installed = RequestTimings::from_extensions(ctx.request().extensions()); + assert_eq!(installed.is_some(), expected, "should preserve exact health policy"); + if let Some(installed) = installed { + if preinstalled { + assert_eq!(installed.snapshot().filter_ms, Some(7), "should retain upstream facts"); + installed.mark_auction_dispatched(); + assert!(installed.snapshot().auction_dispatched_ms.expect("should mark dispatch") >= 2, + "should retain upstream origin"); + } else { + assert_eq!(installed.snapshot(), trusted_server_core::request_timing::TimingSnapshot::default(), + "should install independent empty facts"); + } + installed.record_auction_wait( + trusted_server_core::request_timing::AuctionWaitPlacement::PreHeader, + std::time::Duration::from_millis(3)); + } + Ok::(empty_response()) + }) + .build(); + let mut request = request_builder() + .method(method.clone()) + .uri(path) + .body(Body::empty()) + .expect("should build request"); + if preinstalled { + request.extensions_mut().insert(timings.handle().clone()); + } + let response = + block_on(router.oneshot(request)).expect("should dispatch request"); + assert!( + response.headers().get("server-timing").is_none(), + "attachment should not expose timing" + ); + if preinstalled { + assert_eq!( + timings.snapshot().auction_wait_ms, + Some(3), + "should share handler updates" + ); + assert_eq!( + timings.snapshot().request_elapsed_ms, + None, + "should not fabricate completion" + ); + } + } + } + } + } + #[test] fn sanitize_middleware_strips_configured_trust_headers_before_routing() { let mut settings = settings_with_response_headers(vec![]); diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 6c7121d0d6..70d7b1c817 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -34,6 +34,7 @@ fern = { workspace = true } futures = { workspace = true } log = { workspace = true } log-fastly = { workspace = true } +rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ce06ce7e30..a90bb01b29 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -98,6 +98,7 @@ use edgezero_core::http::{ }; use edgezero_core::router::RouterService; use error_stack::Report; +use trusted_server_core::access_telemetry::{RouteClass, RouteMetadata, publisher_route_template}; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ @@ -117,6 +118,7 @@ use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::geo::GeoLookupState; use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, @@ -138,6 +140,7 @@ use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, handle_verify_signature, }; +use trusted_server_core::request_timing::{Phase, RequestTimings}; use trusted_server_core::settings::{ProxyAssetRoute, Settings}; use trusted_server_core::settings_data::{DEFAULT_CONFIG_STORE_ID, get_settings_from_config_store}; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; @@ -307,6 +310,12 @@ fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool { *method == Method::GET && path.starts_with("/static/tsjs=") } +/// Coarse route template for every `tsjs` bundle request, used as the +/// `route_template` in the [`RouteMetadata`] attached by the tsjs branch of +/// [`dispatch_fallback`]. Actual filenames vary by module/hash; the prefix +/// alone is the route identity that matters for access telemetry. +const TSJS_ROUTE_TEMPLATE: &str = "/static/tsjs=*"; + // --------------------------------------------------------------------------- // EC request state // --------------------------------------------------------------------------- @@ -368,6 +377,17 @@ impl EcRequestState { services: self.services, } } + + /// Derives the carried [`GeoLookupState`] from this request's geo lookup + /// outcome, so response-phase finalize can reuse it instead of repeating + /// the lookup. `build_ec_request_state` always attempts the lookup, so + /// `None` here means the lookup ran and failed, not that it was skipped. + fn geo_lookup_state(&self) -> GeoLookupState { + match &self.geo_info { + Some(info) => GeoLookupState::Resolved(info.clone()), + None => GeoLookupState::Attempted, + } + } } /// Derives device signals from the request's `User-Agent` header. @@ -423,13 +443,17 @@ fn build_ec_request_state( let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(req, COOKIE_SHAREDID); - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed during EC setup: {e}"); - None - }); + let timings = RequestTimings::from_extensions(req.extensions()).unwrap_or_default(); + let geo_info = { + let _span = timings.span(Phase::Geo); + services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed during EC setup: {e}"); + None + }) + }; let (ec_context, setup_error) = match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) { @@ -450,7 +474,7 @@ fn build_ec_request_state( // Bot gate: suppress KV-backed EC writes for unrecognized clients, except // consent withdrawals. Revocations keep the write path so tombstones stay // authoritative even for privacy-extension-heavy clients. - let kv_graph = crate::maybe_identity_graph(settings); + let kv_graph = crate::identity_graph_with_timing(settings, &timings); let finalize_kv_graph = if setup_error.is_none() && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) { @@ -502,6 +526,14 @@ async fn run_pre_route_filters( req: &mut Request, geo_info: Option<&GeoInfo>, ) -> PreRoute { + // Only recorded when a filter is actually registered, so unconfigured + // deployments omit ts-filter from the Server-Timing header entirely. + let timings = RequestTimings::from_extensions(req.extensions()).unwrap_or_default(); + let _span = state + .registry + .has_request_filters() + .then(|| timings.span(Phase::Filter)); + match state .registry .filter_request(RequestFilterRegistryInput { @@ -535,6 +567,7 @@ fn attach_dispatch_extensions( ec: EcRequestState, effects: RequestFilterEffects, ) -> Response { + response.extensions_mut().insert(ec.geo_lookup_state()); response.extensions_mut().insert(ec.into_finalize_state()); if !effects.response_headers.is_empty() { response.extensions_mut().insert(effects); @@ -585,7 +618,9 @@ async fn execute_named( // Deliberately do not use an EC request-state graph: that // copy is bot-gated, while operators use curl for this // authenticated diagnostic. - let kv = crate::maybe_identity_graph(&state.settings); + let timings = + RequestTimings::from_extensions(req.extensions()).unwrap_or_default(); + let kv = crate::identity_graph_with_timing(&state.settings, &timings); handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), @@ -659,7 +694,8 @@ async fn run_named_route( if req.method() == Method::OPTIONS { cors_preflight_identify(&state.settings, &req) } else { - let kv = crate::require_identity_graph(&state.settings)?; + let timings = RequestTimings::from_extensions(req.extensions()).unwrap_or_default(); + let kv = crate::require_identity_graph_with_timing(&state.settings, &timings)?; let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; handle_identify( &state.settings, @@ -743,12 +779,14 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> let is_real_browser = device_signals.looks_like_browser(); let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); + let timings = RequestTimings::from_extensions(req.extensions()).unwrap_or_default(); - let result = crate::require_identity_graph(&state.settings).and_then(|kv| { - let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; - let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, &partner_registry, &limiter, req) - }); + let result = + crate::require_identity_graph_with_timing(&state.settings, &timings).and_then(|kv| { + let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); + handle_batch_sync(&kv, &partner_registry, &limiter, req) + }); let mut response = result.unwrap_or_else(|e| http_error(&e)); // Legacy parity: batch-sync responses still pass through @@ -808,12 +846,32 @@ async fn dispatch_fallback( PreRoute::Continue { effects } => effects, }; + // Assigned exactly once, per branch below, alongside the routing + // decision itself, so the access-telemetry route identity always + // reflects which branch actually dispatched the request — including + // when that branch's handler errors. The asset-route sub-branch is an + // early return handled separately by `dispatch_asset_fallback`, so it + // never reaches (or needs to assign) this binding. + let route_metadata: Option; + let result = if uses_dynamic_tsjs_fallback(&method, &path) { + route_metadata = Some(RouteMetadata { + route_class: RouteClass::Tsjs, + route_template: TSJS_ROUTE_TEMPLATE.to_owned(), + }); handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SurrogateControl) - } else if state.registry.has_route(&method, &path) { + } else if let Some(pattern) = state.registry.matched_route_pattern(&method, &path) { // Integration-proxy responses are not bounded by // publisher.max_buffered_body_bytes. Publisher fallback below uses the // publisher-specific streaming finalizer instead. + // The matched route pattern is an integration-defined literal + // (bounded and content-free by construction), so telemetry keeps it + // verbatim instead of running the request path through the lossy + // publisher classifier. + route_metadata = Some(RouteMetadata { + route_class: RouteClass::IntegrationProxy, + route_template: pattern.to_owned(), + }); state .registry .handle_proxy(ProxyDispatchInput { @@ -841,9 +899,34 @@ async fn dispatch_fallback( .then(|| state.settings.asset_route_for_path(&path)) .flatten(); if let Some(asset_route) = matched_asset_route { - return dispatch_asset_fallback(state, services, req, asset_route, &effects).await; + // The template is the operator-configured route prefix, so it + // is bounded and content-free by construction (unlike request + // paths, which need `publisher_route_template`). + let asset_metadata = RouteMetadata { + route_class: RouteClass::Asset, + route_template: format!("{}/*", asset_route.prefix.trim_end_matches('/')), + }; + let mut response = dispatch_asset_fallback( + state, + services, + req, + asset_route, + &effects, + ec.geo_lookup_state(), + ) + .await; + response.extensions_mut().insert(asset_metadata); + return response; } + route_metadata = Some(RouteMetadata { + route_class: RouteClass::PublisherHtml, + route_template: publisher_route_template( + &path, + &state.settings.observability.route_sections, + ), + }); + // Generate an EC ID if needed — mirrors the legacy catch-all arm. // Only for document navigations by recognised browsers; subresource // requests may lack consent signals such as Sec-GPC. @@ -910,7 +993,10 @@ async fn dispatch_fallback( } }; - let response = result.unwrap_or_else(|e| http_error(&e)); + let mut response = result.unwrap_or_else(|e| http_error(&e)); + if let Some(metadata) = route_metadata { + response.extensions_mut().insert(metadata); + } attach_dispatch_extensions(response, ec, effects) } @@ -934,7 +1020,10 @@ fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool { /// [`AssetProxyCachePolicy`] out via response extensions so `edgezero_main` /// can reapply protected cache directives after finalization. EC finalization /// is intentionally skipped: no [`EcFinalizeState`] is attached, matching the -/// legacy `should_finalize_ec = false` behavior for asset responses. +/// legacy `should_finalize_ec = false` behavior for asset responses. The +/// caller's [`GeoLookupState`] is still attached, since `build_ec_request_state` +/// already attempted the lookup before the asset route was matched — this is +/// the one exit path that carries geo state without an `EcFinalizeState`. /// /// Like legacy `route_request`, asset bodies are streamed straight to the client /// with no cap: the origin stream is attached to the response and `edgezero_main` @@ -949,6 +1038,7 @@ async fn dispatch_asset_fallback( req: Request, asset_route: &ProxyAssetRoute, effects: &RequestFilterEffects, + geo_state: GeoLookupState, ) -> Response { log::info!("No explicit route matched; proxying via configured asset route"); @@ -970,6 +1060,7 @@ async fn dispatch_asset_fallback( } response.extensions_mut().insert(cache_policy); + response.extensions_mut().insert(geo_state); attach_request_filter_effects(&mut response, effects); response } @@ -978,6 +1069,7 @@ async fn dispatch_asset_fallback( response .extensions_mut() .insert(AssetProxyCachePolicy::NoStorePrivate); + response.extensions_mut().insert(geo_state); attach_request_filter_effects(&mut response, effects); response } @@ -1101,6 +1193,10 @@ struct NamedRoute { path: &'static str, primary_methods: &'static [Method], handler: NamedRouteHandler, + /// Access-telemetry traffic category for this row. Attached verbatim + /// alongside `path` (the route-table pattern) to every response this + /// route produces — see [`named_route_handler`]. + route_class: RouteClass, } /// Every method an admin route must claim to keep non-primary methods from falling @@ -1123,21 +1219,25 @@ const NAMED_ROUTES: &[NamedRoute] = &[ path: "/.well-known/trusted-server.json", primary_methods: &[Method::GET], handler: NamedRouteHandler::TrustedServerDiscovery, + route_class: RouteClass::Other, }, NamedRoute { path: "/verify-signature", primary_methods: &[Method::POST], handler: NamedRouteHandler::VerifySignature, + route_class: RouteClass::Ec, }, NamedRoute { path: "/_ts/admin/keys/rotate", primary_methods: &[Method::POST], handler: NamedRouteHandler::RotateKey, + route_class: RouteClass::Ec, }, NamedRoute { path: "/_ts/admin/keys/deactivate", primary_methods: &[Method::POST], handler: NamedRouteHandler::DeactivateKey, + route_class: RouteClass::Ec, }, // Every method is claimed, not just POST. A method this route did not claim would // fall through to the publisher, and `enforce_basic_auth` leaves the `Authorization` @@ -1147,6 +1247,7 @@ const NAMED_ROUTES: &[NamedRoute] = &[ path: "/_ts/admin/cache/purge", primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::AdminCachePurge, + route_class: RouteClass::Other, }, // Admin EC lookup: the bare route reads the EC ID from the caller's // `ts-ec` cookie; the parameterized route takes an explicit EC ID. @@ -1154,11 +1255,13 @@ const NAMED_ROUTES: &[NamedRoute] = &[ path: "/_ts/admin/ec", primary_methods: &[Method::GET], handler: NamedRouteHandler::AdminEcLookup, + route_class: RouteClass::Ec, }, NamedRoute { path: "/_ts/admin/ec/{id}", primary_methods: &[Method::GET], handler: NamedRouteHandler::AdminEcLookup, + route_class: RouteClass::Ec, }, // Admin EIDs echo: decodes the request's ts-eids/sharedId cookies with // an ingestion preview. Pure request inspection — no KV access. @@ -1166,6 +1269,7 @@ const NAMED_ROUTES: &[NamedRoute] = &[ path: "/_ts/admin/eids", primary_methods: &[Method::GET], handler: NamedRouteHandler::AdminEidsLookup, + route_class: RouteClass::Ec, }, // The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with a // 404 instead of executing key operations: the production basic-auth handler @@ -1177,36 +1281,43 @@ const NAMED_ROUTES: &[NamedRoute] = &[ path: "/admin/keys/rotate", primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, + route_class: RouteClass::Other, }, NamedRoute { path: "/admin/keys/deactivate", primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, + route_class: RouteClass::Other, }, NamedRoute { path: "/_ts/api/v1/batch-sync", primary_methods: &[Method::POST], handler: NamedRouteHandler::BatchSync, + route_class: RouteClass::Ec, }, NamedRoute { path: "/_ts/api/v1/identify", primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::Identify, + route_class: RouteClass::Ec, }, NamedRoute { path: "/_ts/set-tester", primary_methods: &[Method::GET], handler: NamedRouteHandler::SetTester, + route_class: RouteClass::Other, }, NamedRoute { path: "/_ts/clear-tester", primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, + route_class: RouteClass::Other, }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], handler: NamedRouteHandler::Auction, + route_class: RouteClass::AuctionApi, }, // GET runs the SPA re-auction; OPTIONS is denied in-handler as a CORS // preflight guard for this side-effecting endpoint. @@ -1214,6 +1325,7 @@ const NAMED_ROUTES: &[NamedRoute] = &[ path: PAGE_BIDS_PATH, primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, + route_class: RouteClass::AuctionApi, }, // Deprecated double-underscore alias. tsjs bundles served before the // `/_ts/page-bids` rename keep requesting this path from already-loaded @@ -1224,21 +1336,29 @@ const NAMED_ROUTES: &[NamedRoute] = &[ path: PAGE_BIDS_LEGACY_PATH, primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, + route_class: RouteClass::AuctionApi, }, + // Classified `Other` rather than `IntegrationProxy`: that class is + // reserved for `state.registry.handle_proxy` (the js-integration proxy + // dispatch in `dispatch_fallback`), which these first-party proxy routes + // do not go through. NamedRoute { path: "/first-party/proxy", primary_methods: &[Method::GET], handler: NamedRouteHandler::FirstPartyProxy, + route_class: RouteClass::Other, }, NamedRoute { path: "/first-party/click", primary_methods: &[Method::GET], handler: NamedRouteHandler::FirstPartyClick, + route_class: RouteClass::Other, }, NamedRoute { path: "/first-party/sign", primary_methods: &[Method::GET, Method::POST], handler: NamedRouteHandler::FirstPartySign, + route_class: RouteClass::Other, }, NamedRoute { path: "/first-party/proxy-rebuild", @@ -1247,16 +1367,35 @@ const NAMED_ROUTES: &[NamedRoute] = &[ // POST is blocked by CORS and the guard navigates here for a 302 instead. primary_methods: &[Method::GET, Method::POST], handler: NamedRouteHandler::FirstPartyProxyRebuild, + route_class: RouteClass::Other, }, ]; +/// Wraps [`execute_named`], attaching a [`RouteMetadata`] extension carrying +/// `route_class` and the route-table pattern (`route_template`, verbatim, +/// with parameters left as placeholders) to every response the handler +/// produces — including its early-return diagnostic and setup-error arms, +/// since the attachment happens once around the whole future rather than in +/// each branch. fn named_route_handler( state: Arc, handler: NamedRouteHandler, + route_class: RouteClass, + route_template: &'static str, ) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { move |ctx: RequestContext| { let state = Arc::clone(&state); - Box::pin(execute_named(state, ctx, handler)) + Box::pin(async move { + execute_named(state, ctx, handler) + .await + .map(|mut response| { + response.extensions_mut().insert(RouteMetadata { + route_class, + route_template: route_template.to_owned(), + }); + response + }) + }) } } @@ -1321,7 +1460,12 @@ impl TrustedServerApp { router = router.route( route.path, method.clone(), - named_route_handler(Arc::clone(state), route.handler), + named_route_handler( + Arc::clone(state), + route.handler, + route.route_class, + route.path, + ), ); } @@ -1400,10 +1544,11 @@ mod tests { use super::{ AppState, AuctionDispatch, EcContext, EdgeCacheHeader, EidSyncSource, HandlerFuture, - NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, RuntimeStoreConfig, - TrustedServerApp, build_orchestrator_with_plan, build_per_request_services, - build_state_from_settings, compile_auction_plan, handle_publisher_request, - publisher_response_into_streaming_response, startup_error_router, + NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, RouteClass, + RouteMetadata, RuntimeStoreConfig, TSJS_ROUTE_TEMPLATE, TrustedServerApp, + build_orchestrator_with_plan, build_per_request_services, build_state_from_settings, + compile_auction_plan, handle_publisher_request, publisher_response_into_streaming_response, + startup_error_router, }; use base64::Engine as _; use bytes::Bytes; @@ -1412,6 +1557,7 @@ mod tests { use edgezero_core::context::RequestContext; use edgezero_core::http::{ HeaderValue, Method, Request, Response, StatusCode, header, request_builder, + response_builder, }; use edgezero_core::key_value_store::NoopKvStore; use edgezero_core::params::PathParams; @@ -1421,20 +1567,23 @@ mod tests { use error_stack::Report; use futures::StreamExt as _; use futures::executor::block_on; - use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; + use trusted_server_core::constants::{HEADER_X_GEO_COUNTRY, HEADER_X_GEO_INFO_AVAILABLE}; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::error::TrustedServerError; + use trusted_server_core::geo::GeoLookupState; use trusted_server_core::integrations::{ HeaderMutation, IntegrationRegistry, IntegrationRequestFilter, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, }; use trusted_server_core::platform::{ - ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, PlatformHttpClient, - PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, PlatformTemplateCache, PlatformTemplateCacheReservation, - RuntimeServices, TemplateCacheError, TemplateCacheKey, TemplateCacheLookup, - TemplateCacheMiss, TemplateCacheReservation, TemplateEntry, TemplateMetadata, + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformError, PlatformGeo, + PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, + PlatformResponse, PlatformSelectResult, PlatformTemplateCache, + PlatformTemplateCacheReservation, RuntimeServices, TemplateCacheError, TemplateCacheKey, + TemplateCacheLookup, TemplateCacheMiss, TemplateCacheReservation, TemplateEntry, + TemplateMetadata, }; + use trusted_server_core::request_timing::RequestTimings; use trusted_server_core::settings::Settings; use trusted_server_core::test_support::nextjs_auction; @@ -1590,12 +1739,12 @@ mod tests { ); } - /// Builds a router whose `AppState` uses a registry containing the given - /// request filters (and no routes), so dispatch-level request-filter - /// behavior can be exercised without a real integration. - fn router_with_request_filters( + /// Builds an `AppState` whose registry contains the given request + /// filters (and no routes), so dispatch-level request-filter behavior can + /// be exercised without a real integration. + fn state_with_request_filters( filters: Vec>, - ) -> RouterService { + ) -> Arc { let settings = test_settings(); let plan = Arc::new( trusted_server_core::auction::compile_auction_plan(&settings) @@ -1607,7 +1756,7 @@ mod tests { let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; - let state = Arc::new(super::AppState { + Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, ), @@ -1615,8 +1764,15 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, - }); - TrustedServerApp::routes_for_state(&state) + }) + } + + /// Builds a router on top of [`state_with_request_filters`] so + /// dispatch-level request-filter behavior can be exercised end-to-end. + fn router_with_request_filters( + filters: Vec>, + ) -> RouterService { + TrustedServerApp::routes_for_state(&state_with_request_filters(filters)) } /// Continues routing while mutating the request and emitting a response @@ -2489,6 +2645,114 @@ mod tests { ); } + /// `Authorization: Basic` header value for `test_settings()`'s + /// `^/_ts/admin` handler (`admin` / `admin-pass`). + fn admin_basic_auth_header() -> edgezero_core::http::HeaderValue { + let credentials = base64::engine::general_purpose::STANDARD.encode("admin:admin-pass"); + format!("Basic {credentials}") + .parse() + .expect("should parse basic-auth header value") + } + + #[test] + fn named_route_attaches_the_table_pattern_verbatim_even_with_a_real_id_in_the_path() { + // A named-route response must carry the route-TABLE pattern + // (`{id}` left as a placeholder), never the caller's actual matched + // path segment — this is what keeps a real EC identifier out of + // access telemetry, independent of anything the row-serialization + // layer does. + let router = test_router(); + let ec_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01"; + let mut req = empty_request(Method::GET, &format!("/_ts/admin/ec/{ec_id}")); + req.headers_mut() + .insert(header::AUTHORIZATION, admin_basic_auth_header()); + let response = route(&router, req); + + let metadata = response + .extensions() + .get::() + .expect("named-route responses should carry RouteMetadata"); + assert_eq!(metadata.route_class, RouteClass::Ec); + assert_eq!(metadata.route_template, "/_ts/admin/ec/{id}"); + assert!( + !metadata.route_template.contains(ec_id), + "the attached template must never contain the matched id" + ); + } + + #[test] + fn named_route_attaches_metadata_even_on_a_read_only_diagnostic_early_return() { + // AdminEidsLookup is handled by an early-return arm inside + // execute_named, before the normal EC lifecycle runs (see the + // "read-only diagnostics" comment there). named_route_handler wraps + // the whole future, so the attachment must still happen here too. + let router = test_router(); + let mut req = empty_request(Method::GET, "/_ts/admin/eids"); + req.headers_mut() + .insert(header::AUTHORIZATION, admin_basic_auth_header()); + let response = route(&router, req); + + let metadata = response + .extensions() + .get::() + .expect("even a read-only diagnostic early-return response should carry RouteMetadata"); + assert_eq!(metadata.route_class, RouteClass::Ec); + assert_eq!(metadata.route_template, "/_ts/admin/eids"); + } + + #[test] + fn tsjs_fallback_attaches_tsjs_route_metadata() { + let router = test_router(); + let response = route( + &router, + empty_request(Method::GET, "/static/tsjs=tsjs-unified.min.js"), + ); + + let metadata = response + .extensions() + .get::() + .expect("tsjs fallback responses should carry RouteMetadata"); + assert_eq!(metadata.route_class, RouteClass::Tsjs); + assert_eq!(metadata.route_template, TSJS_ROUTE_TEMPLATE); + } + + #[test] + fn integration_proxy_fallback_attaches_integration_proxy_route_metadata() { + // test_settings() enables the prebid integration, which registers a + // proxy route at /integrations/prebid/bundle.js. + let router = test_router(); + let response = route( + &router, + empty_request(Method::GET, "/integrations/prebid/bundle.js"), + ); + + let metadata = response + .extensions() + .get::() + .expect("integration-proxy fallback responses should carry RouteMetadata"); + assert_eq!(metadata.route_class, RouteClass::IntegrationProxy); + assert_eq!( + metadata.route_template, "/integrations/prebid/bundle.js", + "should carry the registered route pattern verbatim, not a classifier output" + ); + } + + #[test] + fn publisher_fallback_attaches_publisher_html_route_metadata() { + let router = test_router(); + let response = route(&router, empty_request(Method::GET, "/news/some-article")); + + let metadata = response + .extensions() + .get::() + .expect("publisher fallback responses should carry RouteMetadata"); + assert_eq!(metadata.route_class, RouteClass::PublisherHtml); + assert_eq!( + metadata.route_template, "/other/*", + "the default empty section allowlist should collapse publisher paths" + ); + } + fn browser_request(method: Method, path: &str, fetch_destination: &str) -> Request { let mut request = empty_request(method, path); request.headers_mut().insert( @@ -2929,6 +3193,61 @@ mod tests { ); } + #[test] + fn asset_fallback_carries_geo_state_without_ec_finalize_state() { + // The asset-route fallback is the one exit path that skips + // EcFinalizeState but must still carry GeoLookupState, since + // build_ec_request_state (and its geo lookup) already ran before the + // asset route was matched. Without this, the finalize step would + // silently repeat the lookup for every asset request. + let settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [proxy] + + [[proxy.asset_routes]] + prefix = "/.image/" + origin_url = "https://assets.example.com" + "#, + ) + .expect("should parse asset-route settings"); + let state = build_state_from_settings(settings).expect("should build state"); + let router = TrustedServerApp::routes_for_state(&state); + + let response = route(&router, empty_request(Method::GET, "/.image/banner.png")); + + assert!( + response.extensions().get::().is_some(), + "asset-route responses should still carry GeoLookupState even though \ + EC finalization is skipped" + ); + assert!( + response + .extensions() + .get::() + .is_none(), + "asset-route responses must skip EC finalization (no EcFinalizeState)" + ); + } + struct FixedBackend; impl PlatformBackend for FixedBackend { @@ -3352,6 +3671,7 @@ mod tests { req, asset_route, &effects, + trusted_server_core::geo::GeoLookupState::NotAttempted, )); assert_eq!( @@ -3447,6 +3767,193 @@ mod tests { ); } + /// A [`PlatformGeo`] stub that counts every `lookup` call and always + /// returns the same canned result, used to prove the request-phase geo + /// lookup is never repeated during finalize. + struct CountingGeo { + calls: Arc, + result: Option, + } + + impl PlatformGeo for CountingGeo { + fn lookup(&self, _: Option) -> Result, Report> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.result.clone()) + } + } + + fn sample_geo_info() -> GeoInfo { + GeoInfo { + city: "Testville".to_string(), + country: "US".to_string(), + continent: "NorthAmerica".to_string(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + + fn runtime_services_with_geo(geo: Arc) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) + .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) + .kv_store(Arc::new(NoopKvStore) as Arc) + .backend(Arc::new(FixedBackend)) + .http_client(Arc::new(StreamingHttpClient)) + .geo(geo) + .client_info(ClientInfo::default()) + .build() + } + + #[test] + fn finalize_reuses_request_phase_geo_without_second_lookup() { + // Dispatching a publisher route runs build_ec_request_state, which + // attempts the geo lookup once and carries the result via + // GeoLookupState. The finalize step (resolve_geo_for_response) must + // reuse that carried value instead of calling the geo backend again. + let calls = Arc::new(AtomicUsize::new(0)); + let geo = Arc::new(CountingGeo { + calls: Arc::clone(&calls), + result: Some(sample_geo_info()), + }); + let state = app_state_for_settings(test_settings()); + let services = runtime_services_with_geo(geo); + let req = empty_request(Method::GET, "/some-page"); + + let response = block_on(super::dispatch_fallback(&state, &services, req)); + + let carried = response + .extensions() + .get::() + .cloned() + .expect("dispatch should attach GeoLookupState"); + assert!( + matches!(carried, GeoLookupState::Resolved(_)), + "a successful lookup should carry Resolved" + ); + + let geo_info = + crate::middleware::resolve_geo_for_response(&response, &carried, None, |_| { + panic!("finalize must not repeat a resolved geo lookup"); + }); + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "only the request-phase lookup should have run" + ); + + let mut response = response; + geo_info + .expect("geo info should have resolved") + .set_response_headers(&mut response); + assert!( + response.headers().get(HEADER_X_GEO_COUNTRY).is_some(), + "x-geo-country should still be set on the response after reusing the carried geo" + ); + } + + #[test] + fn failed_lookup_is_not_retried() { + // When the request-phase lookup fails (returns None), dispatch must + // carry GeoLookupState::Attempted rather than NotAttempted, and + // finalize must not retry it. + let calls = Arc::new(AtomicUsize::new(0)); + let geo = Arc::new(CountingGeo { + calls: Arc::clone(&calls), + result: None, + }); + let state = app_state_for_settings(test_settings()); + let services = runtime_services_with_geo(geo); + let req = empty_request(Method::GET, "/some-page"); + + let response = block_on(super::dispatch_fallback(&state, &services, req)); + + let carried = response + .extensions() + .get::() + .cloned() + .expect("dispatch should attach GeoLookupState even for a failed lookup"); + assert!( + matches!(carried, GeoLookupState::Attempted), + "a failed lookup should carry Attempted, not Resolved or NotAttempted" + ); + + let geo_info = + crate::middleware::resolve_geo_for_response(&response, &carried, None, |_| { + panic!("finalize must not retry a failed geo lookup"); + }); + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "only the request-phase lookup should have run" + ); + assert!( + geo_info.is_none(), + "no geo info should be available after a failed lookup" + ); + } + + #[test] + fn filter_span_recorded_when_request_filter_runs() { + // The Filter phase span should only be recorded when the registry + // actually has a request filter registered, so unconfigured + // deployments omit ts-filter from the Server-Timing header entirely. + let state = state_with_request_filters(vec![Arc::new(RecordingRequestFilter)]); + let services = RuntimeServices::builder() + .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) + .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) + .kv_store(Arc::new(NoopKvStore) as Arc) + .backend(Arc::new(FixedBackend)) + .http_client(Arc::new(StreamingHttpClient)) + .geo(Arc::new(crate::platform::FastlyPlatformGeo)) + .client_info(ClientInfo::default()) + .build(); + let mut req = empty_request(Method::GET, "/some-page"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + + let _ = block_on(super::run_pre_route_filters( + &state, &services, &mut req, None, + )); + + assert!( + timings.snapshot().filter_ms.is_some(), + "should record the Filter phase span when a request filter is registered and runs" + ); + } + + #[test] + fn filter_span_not_recorded_when_no_request_filters_registered() { + // Mirror test: an empty registry must never record the Filter span, + // even though run_pre_route_filters still runs (as a no-op loop). + let state = state_with_request_filters(Vec::new()); + let services = RuntimeServices::builder() + .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) + .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) + .kv_store(Arc::new(NoopKvStore) as Arc) + .backend(Arc::new(FixedBackend)) + .http_client(Arc::new(StreamingHttpClient)) + .geo(Arc::new(crate::platform::FastlyPlatformGeo)) + .client_info(ClientInfo::default()) + .build(); + let mut req = empty_request(Method::GET, "/some-page"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + + let _ = block_on(super::run_pre_route_filters( + &state, &services, &mut req, None, + )); + + assert!( + timings.snapshot().filter_ms.is_none(), + "should omit the Filter phase span when no request filters are registered" + ); + } + #[test] fn dispatch_runs_request_filter_and_threads_response_effects() { // Regression guard for the EdgeZero request-filter bypass: the publisher @@ -3506,6 +4013,61 @@ mod tests { ); } + /// Joins every instance of a response header into one comma-separated + /// string (mirroring how a client sees repeated header fields), or + /// `None` if the header is absent. + fn response_header(response: &Response, name: &str) -> Option { + let values: Vec<&str> = response + .headers() + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + if values.is_empty() { + None + } else { + Some(values.join(", ")) + } + } + + #[test] + fn server_timing_emitted_on_private_response_when_enabled() { + let mut response = response_builder() + .header("cache-control", "private, no-store") + .body(Body::empty()) + .expect("should build a private response fixture"); + let timings = RequestTimings::new(); + + crate::apply_server_timing_header(&mut response, &timings, true); + + let header = response_header(&response, "server-timing").expect("should emit header"); + assert!( + header.contains("ts-total;dur="), + "should carry the stored total: {header}" + ); + assert_eq!( + header.matches("ts-total").count(), + 1, + "should emit exactly one TS-owned metric set" + ); + } + + #[test] + fn server_timing_absent_when_flag_off() { + let mut response = response_builder() + .header("cache-control", "private, no-store") + .body(Body::empty()) + .expect("should build a private response fixture"); + let timings = RequestTimings::new(); + + crate::apply_server_timing_header(&mut response, &timings, false); + + assert!( + response_header(&response, "server-timing").is_none(), + "should not emit server-timing when the flag is off" + ); + } + fn recovery_eligible_of(response: &Response) -> bool { response .extensions() @@ -3549,6 +4111,32 @@ mod tests { ); } + #[test] + fn server_timing_absent_on_cacheable_responses() { + // tsjs route policy: public, long max-age, immutable. + let mut tsjs_response = response_builder() + .header("cache-control", "public, max-age=31536000, immutable") + .body(Body::empty()) + .expect("should build a tsjs-style response fixture"); + // A bare shared-cacheable response with no private/no-store directive. + let mut public_response = response_builder() + .header("cache-control", "max-age=60") + .body(Body::empty()) + .expect("should build a bare max-age response fixture"); + + crate::apply_server_timing_header(&mut tsjs_response, &RequestTimings::new(), true); + crate::apply_server_timing_header(&mut public_response, &RequestTimings::new(), true); + + assert!( + response_header(&tsjs_response, "server-timing").is_none(), + "should not emit on the public immutable tsjs cache policy" + ); + assert!( + response_header(&public_response, "server-timing").is_none(), + "should not emit on a bare shared-cacheable max-age response" + ); + } + #[test] fn filter_short_circuit_response_is_not_eligible_for_eid_persistence() { // A request-filter short circuit (e.g. a DataDome challenge/block) must @@ -3586,6 +4174,45 @@ mod tests { ); } + #[test] + fn server_timing_absent_when_no_cache_control_header_exists() { + // The fail-closed case: absence of Cache-Control is not evidence of + // privacy, so emission must be suppressed rather than defaulted on. + let mut response = response_builder() + .body(Body::empty()) + .expect("should build a response with no cache-control header"); + + crate::apply_server_timing_header(&mut response, &RequestTimings::new(), true); + + assert!( + response_header(&response, "server-timing").is_none(), + "should not emit when the response carries no Cache-Control at all" + ); + } + + #[test] + fn preexisting_server_timing_values_survive() { + let mut response = response_builder() + .header("cache-control", "private, no-store") + .header("server-timing", "upstream;dur=1") + .body(Body::empty()) + .expect("should build a private response fixture carrying an upstream Server-Timing"); + let timings = RequestTimings::new(); + + crate::apply_server_timing_header(&mut response, &timings, true); + + let header = + response_header(&response, "server-timing").expect("should still carry a header"); + assert!( + header.contains("upstream;dur=1"), + "should preserve the pre-existing entry: {header}" + ); + assert!( + header.contains("ts-total"), + "should append the TS-owned set: {header}" + ); + } + #[test] fn publisher_navigation_origin_start_failure_is_not_recovery_eligible() { // Recovery is authorized only after a successful origin start. With no diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 3cc13fdfa8..14479199c3 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use edgezero_adapter_fastly::config_store::FastlyConfigStore as EdgeZeroFastlyConfigStore; use edgezero_adapter_fastly::request::into_core_request; @@ -10,9 +11,15 @@ use edgezero_core::response::IntoResponse; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; +use rand::Rng as _; +use trusted_server_core::access_telemetry::{ + AccessTelemetrySnapshot, DeliveryResult, RouteClass, RouteMetadata, access_event_row, +}; use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive}; -use trusted_server_core::constants::HEADER_X_TS_VERSION; +use trusted_server_core::constants::{ + ENV_FASTLY_IS_STAGING, ENV_FASTLY_POP, ENV_FASTLY_SERVICE_ID, HEADER_X_TS_VERSION, +}; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -21,9 +28,13 @@ use trusted_server_core::ec::pull_sync::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::geo::GeoLookupState; use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::platform::PlatformGeo as _; +use trusted_server_core::platform::{PlatformHttpClient, TimedKvStore}; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; +use trusted_server_core::publisher::TemplateCacheResponseState; +use trusted_server_core::request_timing::{Phase, RequestTimings, append_server_timing_if_private}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; use trusted_server_core::trace::{TraceTerminalResponse, is_trace_path}; @@ -378,7 +389,10 @@ fn edgezero_main( return; } - let config_store = + let timings = RequestTimings::new(); + + let config_store = { + let _appbuild = timings.span(Phase::AppBuild); match open_trusted_server_config_store(runtime_stores.config_store_name.as_ref()) { Ok(cs) => cs, Err(e) => { @@ -388,8 +402,8 @@ fn edgezero_main( .send_to_client(); return; } - }; - + } + }; // Build lazily, once per sandbox. Reached only past the health, JA4, and // counters short-circuits, so none of those pays for construction. // @@ -400,6 +414,7 @@ fn edgezero_main( // permanent error mode, and the next callback retries construction. let failed_build = sandbox .initialize(|| { + let _appbuild = timings.span(Phase::AppBuild); let (app, state) = TrustedServerApp::build_app_with_state(&runtime_stores); match state { Some(state) => Ok(RetainedApp { app, state }), @@ -424,6 +439,18 @@ fn edgezero_main( let settings_snapshot = app_state.as_ref().map(|state| Arc::clone(&state.settings)); let counters = SandboxCounters::capture(sandbox, ordinal, request_id, settings_snapshot.as_deref()); + let server_timing_enabled = settings_snapshot + .as_deref() + .is_some_and(|settings| settings.observability.server_timing_enabled); + let access_sample_rate = settings_snapshot + .as_deref() + .map_or(0.0, |settings| settings.tinybird.access_sample_rate); + let access_telemetry_enabled = settings_snapshot + .as_deref() + .is_some_and(|settings| settings.tinybird.enabled && settings.tinybird.access_enabled); + let publisher_domain = settings_snapshot + .as_deref() + .map_or("unknown", |settings| settings.publisher.domain.as_str()); let trusted_client_ip = settings_snapshot .as_deref() .and_then(|settings| settings.trusted_client_ip.as_ref()); @@ -443,6 +470,9 @@ fn edgezero_main( compat::resolve_and_sanitize_client_ip(&mut req, trusted_client_ip) }; + // Retain the method before dispatch consumes the request, including trace paths. + let request_method = req.get_method().clone(); + if !trace_path { // Re-inject a trusted TLS scheme signal after sanitization has stripped any // client-sent fastly-ssl header. Setting it from Fastly's native TLS @@ -491,6 +521,7 @@ fn edgezero_main( core_req.extensions_mut().insert(device_signals); } core_req.extensions_mut().insert(client_info); + core_req.extensions_mut().insert(timings.handle().clone()); match futures::executor::block_on(app.router().oneshot(core_req)) { Ok(response) => response, Err(error) => edge_error_response(error), @@ -519,14 +550,34 @@ fn edgezero_main( let ec_state = response.extensions_mut().remove::(); let asset_cache_policy = response.extensions_mut().remove::(); let request_filter_effects = response.extensions_mut().remove::(); + // Read rather than pop: the access-telemetry snapshot built later in + // `send_edgezero_response` reads this same extension, so it must still + // be attached to `response` at that point. + let geo_lookup_state = response + .extensions() + .get::() + .cloned() + .unwrap_or(GeoLookupState::NotAttempted); if !take_finalize_sentinel(&mut response) { if let Some(settings) = settings_snapshot.as_deref() { - apply_entry_point_finalize_headers(settings, &mut response, client_ip); + apply_entry_point_finalize_headers( + settings, + &mut response, + client_ip, + &geo_lookup_state, + &timings, + ); } else { match load_settings_from_config_store(&runtime_stores) { Ok(settings) => { - apply_entry_point_finalize_headers(&settings, &mut response, client_ip); + apply_entry_point_finalize_headers( + &settings, + &mut response, + client_ip, + &geo_lookup_state, + &timings, + ); } Err(e) => { log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}"); @@ -542,16 +593,43 @@ fn edgezero_main( policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); } + let send_context = SendContext { + timings: timings.clone(), + server_timing_enabled, + method: request_method.as_str(), + publisher_domain, + access_sample_rate, + access_telemetry_enabled, + }; + let access_http_client = platform::FastlyPlatformHttpClient; + if let Some(mut ec_state) = ec_state { if let Some(settings) = settings_snapshot.as_deref() { - match apply_edgezero_ec_finalize(settings, &mut ec_state, &mut response) { + match apply_edgezero_ec_finalize(settings, &mut ec_state, &mut response, &timings) { Ok(partner_registry) => { - send_edgezero_response( + let outcome = send_edgezero_response( response, request_filter_effects.as_ref(), + &send_context, counters.as_ref(), ); - run_edgezero_pull_sync_after_send(settings, &partner_registry, &ec_state); + run_post_send_steps( + || { + run_edgezero_pull_sync_after_send( + settings, + &partner_registry, + &ec_state, + ) + }, + || { + emit_access_telemetry_after_send( + &access_http_client, + settings, + &outcome, + &timings, + ) + }, + ); return; } Err(e) => { @@ -563,17 +641,35 @@ fn edgezero_main( } else { match load_settings_from_config_store(&runtime_stores) { Ok(settings) => { - match apply_edgezero_ec_finalize(&settings, &mut ec_state, &mut response) { + match apply_edgezero_ec_finalize( + &settings, + &mut ec_state, + &mut response, + &timings, + ) { Ok(partner_registry) => { - send_edgezero_response( + let outcome = send_edgezero_response( response, request_filter_effects.as_ref(), + &send_context, counters.as_ref(), ); - run_edgezero_pull_sync_after_send( - &settings, - &partner_registry, - &ec_state, + run_post_send_steps( + || { + run_edgezero_pull_sync_after_send( + &settings, + &partner_registry, + &ec_state, + ); + }, + || { + emit_access_telemetry_after_send( + &access_http_client, + &settings, + &outcome, + &timings, + ) + }, ); return; } @@ -591,7 +687,21 @@ fn edgezero_main( } } - send_edgezero_response(response, request_filter_effects.as_ref(), counters.as_ref()); + let outcome = send_edgezero_response( + response, + request_filter_effects.as_ref(), + &send_context, + counters.as_ref(), + ); + // The asset/admin/error fallback path: no `EcFinalizeState` (or the ec + // finalize branch above failed), so there is no pull-sync dispatch here + // at all — telemetry is the only post-send step. When `app_state` never + // built there is nothing to emit either: `access_telemetry_enabled` was + // necessarily false without a settings snapshot, so the outcome carries + // no access snapshot, and reloading settings here could not change that. + if let Some(settings) = settings_snapshot.as_deref() { + emit_access_telemetry_after_send(&access_http_client, settings, &outcome, &timings); + } } fn edge_error_response(error: EdgeError) -> HttpResponse { @@ -619,6 +729,8 @@ fn apply_entry_point_finalize_headers( settings: &Settings, response: &mut HttpResponse, client_ip: Option, + geo_state: &GeoLookupState, + timings: &RequestTimings, ) { if response .extensions() @@ -627,23 +739,33 @@ fn apply_entry_point_finalize_headers( { return; } - let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { + let geo_info = resolve_geo_for_response(response, geo_state, client_ip, |client_ip| { + let _span = timings.span(Phase::Geo); FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); None }) }); apply_finalize_headers(settings, geo_info.as_ref(), response); + + // This path runs only when the middleware chain was bypassed (e.g. a + // router-level 404/405 for an unregistered method), so `geo_state` may + // still be `NotAttempted` even after a fresh lookup just ran above. + // Write the resolved outcome back so the access-telemetry snapshot built + // later in `send_edgezero_response` sees what was actually looked up; + // 401 handling lives in the shared helper. + middleware::write_back_geo_lookup_state(response, geo_info.as_ref()); } fn apply_edgezero_ec_finalize( settings: &Settings, ec_state: &mut EcFinalizeState, response: &mut HttpResponse, + timings: &RequestTimings, ) -> Result> { let partner_registry = PartnerRegistry::from_config(&settings.ec.partners)?; let finalize_kv_graph = if ec_state.use_finalize_kv { - maybe_identity_graph(settings) + identity_graph_with_timing(settings, timings) } else { None }; @@ -704,6 +826,218 @@ where Some((context, kv)) } +/// Runs the post-send steps in their contract order: EC identity pull-sync +/// first, then access-telemetry emission. +/// +/// Every `edgezero_main` site that has both steps routes through this +/// function, so the ordering is owned in exactly one place and the +/// sequence test can instrument it; `request_elapsed` is already stamped +/// before either step because `send_edgezero_response` stamps it before +/// returning. +fn run_post_send_steps(pull_sync: impl FnOnce(), emit_access_telemetry: impl FnOnce()) { + pull_sync(); + emit_access_telemetry(); +} + +/// Builds and dispatches the access row after a response's transport attempt, +/// when access telemetry is enabled and this request is sampled in. +/// +/// Called last at every `send_edgezero_response` call site in +/// [`edgezero_main`] — after `run_edgezero_pull_sync_after_send` on the two +/// EC-finalized paths, and directly after send on the asset/admin/error +/// fallback path, which never builds an [`EcFinalizeState`] or route-scoped +/// `RuntimeServices` at all. The Tinybird transport context is therefore +/// constructed fresh from `settings` here rather than threaded through +/// either of those per-route types, so every response class can emit. +/// +/// Sampling happens before snapshot construction, using the same configured +/// rate stored in the row. Disabled and sampled-out requests have no snapshot +/// and return silently. Row construction, setup and request-start failures log +/// one warning. The host-owned ingest request continues after this callback; +/// later transport errors, HTTP status and remote acceptance are unobserved. +/// Credentials were resolved during settings load. +fn emit_access_telemetry_after_send( + http_client: &dyn PlatformHttpClient, + settings: &Settings, + outcome: &DeliveryOutcome, + timings: &RequestTimings, +) { + if !settings.tinybird.enabled || !settings.tinybird.access_enabled { + return; + } + + // No snapshot means access telemetry was disabled or sampled out before + // the response was sent; there is nothing to emit. + let Some(snapshot) = &outcome.snapshot else { + return; + }; + + let since_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let epoch_ms = u64::try_from(since_epoch.as_millis()).unwrap_or(u64::MAX); + let row = access_event_row(snapshot, &timings.snapshot(), outcome.result, epoch_ms); + let result = futures::executor::block_on(async { + let target = tinybird::TinybirdEventsTarget::from_access_config(&settings.tinybird)?; + tinybird::emit_access_event(http_client, &target, row).await + }); + if let Err(error) = result { + log::warn!("access telemetry emission dropped: {error:?}"); + } +} + +/// Per-response context threaded into [`send_edgezero_response`] so the +/// function stays at or under seven parameters. +struct SendContext<'a> { + /// The request's phase-timing collector. + timings: RequestTimings, + /// Whether `observability.server_timing_enabled` is set. + server_timing_enabled: bool, + /// The request's HTTP method, captured before the request was consumed + /// by dispatch. + method: &'a str, + /// The configured publisher domain, borrowed from the settings snapshot. + publisher_domain: &'a str, + /// The configured access-telemetry sample rate. + access_sample_rate: f64, + /// Whether `tinybird.enabled` and `tinybird.access_enabled` were both + /// set when settings were first read. Gates building the + /// [`AccessTelemetrySnapshot`] at all: the snapshot costs env reads and + /// `String` allocations on the pre-send path, which a disabled + /// deployment (the default) should not pay. + access_telemetry_enabled: bool, +} + +/// Outcome of handing a finalized response to the client. +pub(crate) struct DeliveryOutcome { + /// Outcome of the local body handoff, emitted as `delivery_result`. + /// This does not establish confirmed client receipt. + pub result: DeliveryResult, + /// Access-telemetry dimensions captured for this response at the + /// freeze point. `None` when access telemetry was disabled or sampled out; + /// the emitter treats that as nothing to send. + pub snapshot: Option, +} + +/// Thin Fastly-adapter wrapper around +/// [`append_server_timing_if_private`], the freeze point shared with the +/// Axum adapter's terminal timing layer. See that function's doc for the +/// emission rules (always stamps `mark_headers_ready`; appends rather than +/// overwrites; never promotes a response to shared-cacheable). +pub(crate) fn apply_server_timing_header( + response: &mut HttpResponse, + timings: &RequestTimings, + server_timing_enabled: bool, +) { + append_server_timing_if_private(response, timings, server_timing_enabled); +} + +/// A [`Write`](std::io::Write) wrapper that tallies bytes successfully written +/// to the inner writer. +/// +/// Wraps the client transport during a streaming drive so a truncated or +/// failed drive still reports how many bytes actually reached it, instead of +/// the placeholder `0` a failed/aborted drive would otherwise report. +struct CountingWriter { + inner: W, + bytes: u64, +} + +impl CountingWriter { + fn new(inner: W) -> Self { + Self { inner, bytes: 0 } + } + + /// Bytes successfully written to the inner writer so far. + fn bytes(&self) -> u64 { + self.bytes + } + + fn into_inner(self) -> W { + self.inner + } +} + +impl std::io::Write for CountingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let written = self.inner.write(buf)?; + self.bytes = self.bytes.saturating_add(written as u64); + Ok(written) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +/// Drives a streaming `EdgeZero` body through `output`, tallying bytes written +/// and timing the drive into `timings`. +/// +/// Stamps `resp_bytes` and `request_elapsed` immediately once the drive +/// returns — before the caller does anything transport-specific (finishing +/// the streaming body, logging) — so `request_elapsed` never includes that +/// work. Returns the counting writer (so the caller can recover both the +/// tallied byte count and the wrapped transport) alongside the drive's +/// result. +fn drive_streaming_body( + body: EdgeBody, + output: W, + timings: &RequestTimings, +) -> (CountingWriter, Result<(), Report>) { + let mut counting = CountingWriter::new(output); + let drive_started = Instant::now(); + let result = futures::executor::block_on(stream_asset_body(body, &mut counting)); + timings.record(Phase::Stream, drive_started.elapsed()); + timings.set_resp_bytes(counting.bytes()); + timings.mark_request_elapsed(); + (counting, result) +} + +/// Classifies a completed streaming drive into a [`DeliveryResult`]. +/// +/// A drive that failed after writing at least one byte delivered a truncated +/// response rather than nothing at all, so it is [`DeliveryResult::Partial`], +/// not [`DeliveryResult::Error`]. +/// +/// The `Ok(())` arm exists for the classifier's totality, not for the +/// production caller: `send_edgezero_response` consumes this value only in +/// its `Err` branch and re-derives the success outcome from +/// `streaming_body.finish()`. +fn classify_stream_delivery( + drive_result: &Result<(), Report>, + bytes: u64, +) -> DeliveryResult { + match drive_result { + Ok(()) => DeliveryResult::Complete, + Err(_) if bytes > 0 => DeliveryResult::Partial, + Err(_) => DeliveryResult::Error, + } +} + +/// Classifies the transport's finish result after a successful body drive. +fn classify_stream_finish( + finish_result: std::io::Result<()>, + counter_context: &str, +) -> DeliveryResult { + match finish_result { + Ok(()) => DeliveryResult::Complete, + Err(error) => { + log::error!("failed to finish EdgeZero streaming body{counter_context}: {error}"); + DeliveryResult::Partial + } + } +} + +/// Stamps `resp_bytes`/`request_elapsed` for an already-materialized body, +/// immediately before it is handed to the Fastly client transport, and +/// returns its byte length. +fn record_buffered_delivery(body: &EdgeBody, timings: &RequestTimings) -> u64 { + let bytes = u64::try_from(body.as_bytes().map(<[u8]>::len).unwrap_or(0)).unwrap_or(u64::MAX); + timings.set_resp_bytes(bytes); + timings.mark_request_elapsed(); + bytes +} + /// Sends a finalized `EdgeZero` response to the client. /// /// Streaming `EdgeZero` bodies commit headers first, then pipe chunks to Fastly's @@ -712,15 +1046,43 @@ where fn send_edgezero_response( mut response: HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, + context: &SendContext, counters: Option<&SandboxCounters>, -) { - apply_terminal_response_effects(&mut response, request_filter_effects); +) -> DeliveryOutcome { + finalize_send_response_headers( + &mut response, + request_filter_effects, + &context.timings, + context.server_timing_enabled, + ); + + // Built right after the freeze point and before `into_parts()` + // consumes `response`: nothing else survives to post-send on every + // path (the request was consumed by dispatch, and `EcFinalizeState` + // is absent on asset, admin, and error paths). Skipped entirely when + // access telemetry is disabled, so the default configuration pays no + // env reads or allocations here. + let snapshot = sampled_access_snapshot( + context, + || rand::thread_rng().r#gen::(), + || build_access_telemetry_snapshot(&response, context), + ); + + send_response_with_delivery(response, counters, &context.timings, snapshot) +} - send_response_to_client(response, counters); +/// Sends an early response without access telemetry or terminal timing headers. +fn send_response_to_client(response: HttpResponse, counters: Option<&SandboxCounters>) { + send_response_with_delivery(response, counters, &RequestTimings::new(), None); } /// Sends a response, attaching sandbox counters first when supplied. -fn send_response_to_client(mut response: HttpResponse, counters: Option<&SandboxCounters>) { +fn send_response_with_delivery( + mut response: HttpResponse, + counters: Option<&SandboxCounters>, + timings: &RequestTimings, + snapshot: Option, +) -> DeliveryOutcome { // Captured before the body is consumed so post-commitment failures can be // matched back to the response that carried these counters. let counter_context = counters.map_or_else(String::new, |counters| { @@ -745,17 +1107,18 @@ fn send_response_to_client(mut response: HttpResponse, counters: Option<&Sandbox parts, EdgeBody::empty(), )); - let mut streaming_body = skeleton.stream_to_client(); - match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) { - Ok(()) => { - if let Err(e) = streaming_body.finish() { - // Also post-commitment: same attribution as the - // streaming failure below. - log::error!( - "failed to finish EdgeZero streaming body{counter_context}: {e}" - ); - } - } + let (counting, drive_result) = + drive_streaming_body(body, skeleton.stream_to_client(), timings); + let bytes = counting.bytes(); + let streaming_body = counting.into_inner(); + // Computed before `drive_result` is matched by value below, since + // the `Err` arm there moves its `Report` out. + let result = classify_stream_delivery(&drive_result, bytes); + match drive_result { + Ok(()) => DeliveryOutcome { + result: classify_stream_finish(streaming_body.finish(), &counter_context), + snapshot, + }, Err(e) => { // After commitment: log and stop. Returning an error here // would let the SDK attempt a second response. Counters @@ -763,16 +1126,124 @@ fn send_response_to_client(mut response: HttpResponse, counters: Option<&Sandbox // tagged with the same identity for reconciliation. log::error!("EdgeZero streaming failed{counter_context}: {e:?}"); drop(streaming_body); + DeliveryOutcome { result, snapshot } } } } once => { + record_buffered_delivery(&once, timings); compat::to_fastly_response(HttpResponse::from_parts(parts, once)).send_to_client(); + DeliveryOutcome { + result: DeliveryResult::Complete, + snapshot, + } } } } -/// Apply every late response mutation, then restore privacy invariants before headers commit. +/// Samples once before doing snapshot work. Neither closure runs when disabled, +/// and sampled-out requests never build dimensions or read environment values. +fn sampled_access_snapshot( + context: &SendContext<'_>, + roll: impl FnOnce() -> f64, + build: impl FnOnce() -> AccessTelemetrySnapshot, +) -> Option { + if !context.access_telemetry_enabled + || !tinybird::sampled_in(context.access_sample_rate, roll()) + { + return None; + } + Some(build()) +} + +/// Builds the [`AccessTelemetrySnapshot`] for `response` at the +/// `Server-Timing` freeze point. +/// +/// Reads route identity, geo country, and template-cache state from typed +/// response extensions rather than the headers those extensions back — +/// operator-configured response headers can override a managed header, so +/// reading a header here could silently drift from what actually happened. +/// Falls back to `"unknown"`/[`RouteClass::Other`] sentinels when an +/// extension was never attached (router-generated, asset, and other +/// responses that never passed through a `RouteMetadata`-attaching +/// wrapper). +fn build_access_telemetry_snapshot( + response: &HttpResponse, + context: &SendContext, +) -> AccessTelemetrySnapshot { + let (route_class, route_template) = match response.extensions().get::() { + Some(metadata) => (metadata.route_class, metadata.route_template.clone()), + None => (RouteClass::Other, "unknown".to_owned()), + }; + + let country = match response.extensions().get::() { + Some(GeoLookupState::Resolved(info)) => info.country.clone(), + Some(GeoLookupState::Attempted | GeoLookupState::NotAttempted) | None => { + "unknown".to_owned() + } + }; + + let template_cache_state = response + .extensions() + .get::() + .map_or_else(|| "unknown".to_owned(), |state| state.as_str().to_owned()); + + let body_mode = if matches!(response.body(), EdgeBody::Stream(_)) { + "streamed" + } else { + "buffered" + }; + + AccessTelemetrySnapshot { + method: context.method.to_owned(), + status: response.status().as_u16(), + route_class, + route_template, + publisher_domain: context.publisher_domain.to_owned(), + env: resolve_env_dimension(), + service_id: env_var_or_unknown(ENV_FASTLY_SERVICE_ID), + pop: env_var_or_unknown(ENV_FASTLY_POP), + country, + template_cache_state, + body_mode, + sample_rate: context.access_sample_rate, + } +} + +/// Derives the `env` access-telemetry dimension from the same +/// `FASTLY_IS_STAGING` input that drives the `x-ts-env` response header +/// (see [`apply_finalize_headers`]), never from [`Settings`] — `Settings` +/// has no environment field and does not gain one for this. +/// +/// `"unknown"` covers contexts where the variable is entirely absent (for +/// example native unit tests run outside Fastly Compute); on the Fastly +/// platform the variable is always present, as either `"1"` or not. +fn resolve_env_dimension() -> String { + match std::env::var(ENV_FASTLY_IS_STAGING) { + Ok(value) if value == "1" => "staging".to_owned(), + Ok(_) => "production".to_owned(), + Err(_) => "unknown".to_owned(), + } +} + +/// Reads a Fastly-provided environment variable, defaulting to `"unknown"` +/// when unset. +fn env_var_or_unknown(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| "unknown".to_owned()) +} + +/// Apply terminal response effects before evaluating Server-Timing at the send freeze point. +fn finalize_send_response_headers( + response: &mut HttpResponse, + request_filter_effects: Option<&RequestFilterEffects>, + timings: &RequestTimings, + server_timing_enabled: bool, +) { + apply_terminal_response_effects(response, request_filter_effects); + apply_server_timing_header(response, timings, server_timing_enabled); +} + +/// Apply late response effects and restore cache-privacy invariants before headers commit. fn apply_terminal_response_effects( response: &mut HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, @@ -850,16 +1321,31 @@ fn build_ja4_debug_response(req: &FastlyRequest) -> FastlyResponse { .with_body(body) } -pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option { - settings - .ec - .ec_store - .as_ref() - .map(|store_name| KvIdentityGraph::new(FastlyEcKvStore::new(store_name))) +/// Constructs a `KvIdentityGraph` wrapped in the [`Phase::EcKv`] timing +/// decorator, for request-path callers with a `RequestTimings` handle. +/// +/// Returns `None` when `ec.ec_store` is not configured, matching +/// [`require_identity_graph_with_timing`]'s contract on every other axis. +pub(crate) fn identity_graph_with_timing( + settings: &Settings, + timings: &RequestTimings, +) -> Option { + settings.ec.ec_store.as_ref().map(|store_name| { + KvIdentityGraph::new(TimedKvStore::new( + FastlyEcKvStore::new(store_name), + timings.clone(), + )) + }) } /// Constructs a `KvIdentityGraph` from settings, or returns an error if the /// `ec_store` config is not set. +/// +/// Deliberately untimed: pull-sync (this function's only caller) runs after +/// `send_edgezero_response`'s Server-Timing freeze point, so a decorated +/// store here would record into a handle nothing ever renders. +/// Request-path callers with a `RequestTimings` handle use +/// [`require_identity_graph_with_timing`] instead. pub(crate) fn require_identity_graph( settings: &Settings, ) -> Result> { @@ -872,6 +1358,27 @@ pub(crate) fn require_identity_graph( Ok(KvIdentityGraph::new(FastlyEcKvStore::new(store_name))) } +/// Constructs a `KvIdentityGraph` wrapped in the [`Phase::EcKv`] timing +/// decorator, or returns an error if the `ec_store` config is not set. +/// +/// Request-path sibling of [`require_identity_graph`], which pull-sync uses +/// unwrapped because pull-sync runs after the Server-Timing freeze point. +pub(crate) fn require_identity_graph_with_timing( + settings: &Settings, + timings: &RequestTimings, +) -> Result> { + let store_name = settings.ec.ec_store.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::KvStore { + store_name: "ec.ec_store".to_owned(), + message: "ec.ec_store is not configured".to_owned(), + }) + })?; + Ok(KvIdentityGraph::new(TimedKvStore::new( + FastlyEcKvStore::new(store_name), + timings.clone(), + ))) +} + /// Extracts a named cookie value from the request's `Cookie` header. pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option { let cookie_header = req.headers().get("cookie").and_then(|v| v.to_str().ok())?; @@ -900,6 +1407,8 @@ pub(crate) fn derive_device_signals(req: &FastlyRequest) -> DeviceSignals { #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; use base64::Engine as _; use edgezero_core::body::Body as EdgeBody; @@ -910,7 +1419,50 @@ mod tests { TargetUnavailable, }; use fastly::mime; + use std::time::Duration; use trusted_server_core::integrations::HeaderMutation; + use trusted_server_core::platform::{ + PlatformError, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + PlatformSelectResult, RuntimeServices, + }; + use trusted_server_core::request_timing::AuctionWaitPlacement; + + #[derive(Default)] + struct RecordingAccessHttpClient { + requests: Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for RecordingAccessHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.requests + .lock() + .expect("should lock recorded requests") + .push(request.request.uri().to_string()); + Err(Report::new(PlatformError::Unsupported)) + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.requests + .lock() + .expect("should lock recorded requests") + .push(request.request.uri().to_string()); + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + } fn test_settings() -> Settings { Settings::from_toml( @@ -938,6 +1490,25 @@ mod tests { .expect("should parse test settings") } + /// A minimal [`AccessTelemetrySnapshot`] fixture for tests that only + /// need a `DeliveryOutcome` to exist, not its telemetry content. + fn sample_access_snapshot() -> AccessTelemetrySnapshot { + AccessTelemetrySnapshot { + method: "GET".to_owned(), + status: 200, + route_class: RouteClass::Other, + route_template: "/other/*".to_owned(), + publisher_domain: "unknown".to_owned(), + env: "unknown".to_owned(), + service_id: "unknown".to_owned(), + pop: "unknown".to_owned(), + country: "unknown".to_owned(), + template_cache_state: "unknown".to_owned(), + body_mode: "buffered", + sample_rate: 0.0, + } + } + #[test] fn trace_dispatch_terminal_asset_bypasses_hostile_native_finalizers() { let mut settings = test_settings(); @@ -978,7 +1549,13 @@ mod tests { HeaderMutation::set("cache-control", "public, max-age=3600"), ], }; - apply_entry_point_finalize_headers(&settings, &mut response, None); + apply_entry_point_finalize_headers( + &settings, + &mut response, + None, + &GeoLookupState::NotAttempted, + &RequestTimings::new(), + ); apply_terminal_response_effects(&mut response, Some(&effects)); assert_eq!( response.headers(), @@ -1114,7 +1691,13 @@ mod tests { "should preserve action and preflight status" ); let original = response.headers().clone(); - apply_entry_point_finalize_headers(&settings, &mut response, None); + apply_entry_point_finalize_headers( + &settings, + &mut response, + None, + &GeoLookupState::NotAttempted, + &RequestTimings::new(), + ); apply_terminal_response_effects(&mut response, Some(&effects)); assert_eq!( response.headers(), @@ -1215,6 +1798,21 @@ mod tests { ); } + #[test] + fn health_short_circuit_is_get_only_and_ignores_query() { + for path in ["/health", "/health?probe=1"] { + let url = format!("https://example.com{path}"); + assert!( + health_response(&FastlyRequest::get(&url)).is_some(), + "should bypass GET health" + ); + assert!( + health_response(&FastlyRequest::post(&url)).is_none(), + "should time non-GET health normally" + ); + } + } + #[test] fn health_response_reports_git_version_only() { let req = FastlyRequest::get("https://example.com/health"); @@ -1357,6 +1955,31 @@ mod tests { assert!(response.headers().get("etag").is_none()); } + #[test] + fn send_freeze_applies_filter_effects_before_server_timing_privacy_gate() { + let mut response = response_builder() + .header("cache-control", "private, no-store") + .body(EdgeBody::empty()) + .expect("should build response"); + let effects = RequestFilterEffects { + request_headers: Vec::new(), + response_headers: vec![HeaderMutation::set( + "cache-control", + "public, s-maxage=3600", + )], + }; + let context = send_context_fixture(); + drop(context.timings.span(Phase::Origin)); + + finalize_send_response_headers(&mut response, Some(&effects), &context.timings, true); + + assert_eq!(response.headers()["cache-control"], "public, s-maxage=3600"); + assert!( + response.headers().get("server-timing").is_none(), + "late public cache policy must be visible to the Server-Timing privacy gate" + ); + } + #[test] fn late_filter_effects_cannot_make_a_page_bids_response_public() { let mut response = trusted_server_core::publisher::page_bids_preflight_denied(); @@ -1516,9 +2139,10 @@ mod tests { .body(EdgeBody::empty()) .expect("should build response"); - let geo_info = resolve_geo_for_response(&response, None, |_| { - panic!("should skip entry-point geo lookup for 401 responses"); - }); + let geo_info = + resolve_geo_for_response(&response, &GeoLookupState::NotAttempted, None, |_| { + panic!("should skip entry-point geo lookup for 401 responses"); + }); apply_finalize_headers(&settings, geo_info.as_ref(), &mut response); assert_eq!( @@ -1584,4 +2208,686 @@ mod tests { "should include sec-ch-ua-platform fallback" ); } + + fn ec_finalize_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + ec_store = "ec_identity_store" + + [[ec.partners]] + name = "Example Partner" + source_domain = "example.com" + api_token = "test-vendor-token-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + "#, + ) + .expect("should parse EC finalize test settings") + } + + /// Minimal `RuntimeServices` for `EcFinalizeState.services`. Real + /// `FastlyPlatform*` handles are used as inert placeholders: EC + /// finalization never calls through them, it only satisfies the field. + fn inert_runtime_services() -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) + .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore) + as Arc) + .backend(Arc::new(crate::platform::FastlyPlatformBackend)) + .http_client(Arc::new(crate::platform::FastlyPlatformHttpClient)) + .geo(Arc::new(crate::platform::FastlyPlatformGeo)) + .client_info(trusted_server_core::platform::ClientInfo::default()) + .build() + } + + #[test] + fn ec_finalize_kv_lands_before_freeze() { + // A pre-seeded EC entry (see fastly.toml's ec_identity_store fixture) + // for a returning user carrying an eids cookie that matches the + // configured partner. This drives ec_finalize_response into + // ingest_eid_cookies, which reads and writes the KV identity graph. + let settings = ec_finalize_settings(); + let ec_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01"; + let eids = serde_json::json!([{ + "source": "example.com", + "uids": [{ "id": "example-uid", "atype": 1 }] + }]); + let eids_cookie = base64::engine::general_purpose::STANDARD.encode(eids.to_string()); + let request = edgezero_core::http::request_builder() + .method(fastly::http::Method::GET) + .uri("https://publisher.example.com/article") + .header("cookie", format!("ts-ec={ec_id}; ts-eids={eids_cookie}")) + .body(EdgeBody::empty()) + .expect("should build EC finalize test request"); + + let services = inert_runtime_services(); + let geo_info = trusted_server_core::platform::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + let mut ec_context = trusted_server_core::ec::EcContext::read_from_request_with_geo( + &settings, + &request, + &services, + Some(&geo_info), + ) + .expect("should read EC context from a non-regulated request"); + assert!( + ec_context.ec_was_present(), + "the pre-seeded ts-ec cookie should be recognized" + ); + // Returning-user EID persistence is gated on the request source; the + // dispatcher marks document navigations this way before finalization. + ec_context.set_eid_sync_source(trusted_server_core::ec::EidSyncSource::Navigation); + + let mut ec_state = EcFinalizeState { + ec_context, + use_finalize_kv: true, + eids_cookie: Some(eids_cookie), + sharedid_cookie: None, + is_real_browser: true, + services, + }; + let mut response = response_builder() + .header("cache-control", "private, no-store") + .body(EdgeBody::empty()) + .expect("should build EC finalize response fixture"); + let timings = RequestTimings::new(); + + // Mirrors edgezero_main's ordering: EC finalize runs, then the freeze + // point (apply_server_timing_header, called just before + // response.into_parts() inside send_edgezero_response) renders the + // header. Calling both directly exercises exactly this order without + // requiring a live Fastly client connection. + apply_edgezero_ec_finalize(&settings, &mut ec_state, &mut response, &timings) + .expect("should finalize EC response"); + apply_server_timing_header(&mut response, &timings, true); + + let header = response + .headers() + .get("server-timing") + .and_then(|v| v.to_str().ok()) + .expect("should emit a Server-Timing header"); + assert!( + header.contains("ts-kv"), + "the freeze point must run after EC finalization recorded KV time: {header}" + ); + } + + #[test] + fn streamed_delivery_row_reports_bytes_and_request_elapsed() { + let timings = RequestTimings::new(); + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"hello "), + bytes::Bytes::from_static(b"world"), + ])); + + let (counting, drive_result) = drive_streaming_body(body, Vec::new(), &timings); + drive_result.expect("streaming a well-formed body should not fail"); + let bytes = counting.bytes(); + let outcome = DeliveryOutcome { + result: DeliveryResult::Complete, + snapshot: Some(AccessTelemetrySnapshot { + body_mode: "streamed", + ..sample_access_snapshot() + }), + }; + + assert_eq!( + counting.into_inner(), + b"hello world", + "should write every byte to the underlying transport" + ); + assert_eq!( + bytes, + "hello world".len() as u64, + "should count bytes accepted by the underlying transport" + ); + + let snapshot = timings.snapshot(); + let row: serde_json::Value = serde_json::from_str(&access_event_row( + outcome + .snapshot + .as_ref() + .expect("should capture access dimensions"), + &snapshot, + outcome.result, + 0, + )) + .expect("should parse the emitted delivery row"); + assert_eq!(row["delivery_result"], "complete"); + assert_eq!(row["resp_bytes"], bytes); + assert_eq!( + snapshot.resp_bytes, + Some("hello world".len() as u64), + "should stamp resp_bytes to the tallied byte count" + ); + assert!( + snapshot.request_elapsed_ms.is_some(), + "should stamp request_elapsed once the drive returns" + ); + } + + #[test] + fn buffered_delivery_stamps_bytes_and_request_elapsed() { + let timings = RequestTimings::new(); + let body = EdgeBody::from(b"a buffered body".to_vec()); + + let bytes = record_buffered_delivery(&body, &timings); + + assert_eq!( + bytes, + "a buffered body".len() as u64, + "should report the buffered body length" + ); + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.resp_bytes, + Some("a buffered body".len() as u64), + "should stamp resp_bytes for the buffered path too" + ); + assert!( + snapshot.request_elapsed_ms.is_some(), + "should stamp request_elapsed for the buffered path too" + ); + } + + fn send_context_fixture() -> SendContext<'static> { + SendContext { + timings: RequestTimings::new(), + server_timing_enabled: false, + method: "GET", + publisher_domain: "publisher.example.com", + access_sample_rate: 0.25, + access_telemetry_enabled: true, + } + } + + #[test] + fn disabled_access_telemetry_skips_entropy_and_snapshot_work() { + let mut context = send_context_fixture(); + context.access_telemetry_enabled = false; + + let snapshot = sampled_access_snapshot( + &context, + || panic!("disabled telemetry should not draw entropy"), + || panic!("disabled telemetry should not construct dimensions"), + ); + + assert!(snapshot.is_none(), "should not build a disabled snapshot"); + } + + #[test] + fn sampled_out_access_telemetry_skips_snapshot_and_transport() { + let context = send_context_fixture(); + let snapshot = sampled_access_snapshot( + &context, + || context.access_sample_rate, + || panic!("sampled-out telemetry should not construct dimensions"), + ); + assert!(snapshot.is_none(), "should exclude the sampling boundary"); + let mut settings = test_settings(); + settings.tinybird.api_host = "ingest.example.com".to_owned(); + settings.tinybird.enabled = true; + settings.tinybird.access_enabled = true; + settings.tinybird.access_token_secret = Some(trusted_server_core::redacted::Redacted::new( + "access-append-token".to_owned(), + )); + let http_client = RecordingAccessHttpClient::default(); + emit_access_telemetry_after_send( + &http_client, + &settings, + &DeliveryOutcome { + result: DeliveryResult::Complete, + snapshot, + }, + &context.timings, + ); + + assert!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .is_empty(), + "should not dispatch access telemetry for a sampled-out response" + ); + } + + #[test] + fn access_telemetry_setup_error_skips_transport_without_panicking() { + let mut settings = test_settings(); + settings.tinybird.api_host = "ingest.example.com".to_owned(); + settings.tinybird.enabled = true; + settings.tinybird.access_enabled = true; + settings.tinybird.access_token_secret = None; + let http_client = RecordingAccessHttpClient::default(); + + emit_access_telemetry_after_send( + &http_client, + &settings, + &DeliveryOutcome { + result: DeliveryResult::Complete, + snapshot: Some(sample_access_snapshot()), + }, + &RequestTimings::new(), + ); + + assert!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .is_empty(), + "should not dispatch access telemetry without a resolved token" + ); + } + + #[test] + fn access_telemetry_dispatch_is_gated_and_best_effort() { + for (enabled, access_enabled) in [(false, true), (true, false), (true, true)] { + let mut settings = test_settings(); + settings.tinybird.api_host = "ingest.example.com".to_owned(); + settings.tinybird.enabled = enabled; + settings.tinybird.access_enabled = access_enabled; + settings.tinybird.access_token_secret = Some( + trusted_server_core::redacted::Redacted::new("access-append-token".to_owned()), + ); + let http_client = RecordingAccessHttpClient::default(); + + emit_access_telemetry_after_send( + &http_client, + &settings, + &DeliveryOutcome { + result: DeliveryResult::Complete, + snapshot: Some(sample_access_snapshot()), + }, + &RequestTimings::new(), + ); + + assert_eq!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .len(), + usize::from(enabled && access_enabled), + "should dispatch once only when both toggles are enabled, without retrying errors" + ); + } + } + + #[test] + fn sampled_in_snapshot_records_the_exact_rate_and_draws_once() { + let context = send_context_fixture(); + let response = response_builder() + .body(EdgeBody::empty()) + .expect("should build response"); + let draws = std::cell::Cell::new(0); + let builds = std::cell::Cell::new(0); + + let snapshot = sampled_access_snapshot( + &context, + || { + draws.set(draws.get() + 1); + 0.0 + }, + || { + builds.set(builds.get() + 1); + build_access_telemetry_snapshot(&response, &context) + }, + ) + .expect("should include zero entropy at a positive rate"); + + assert_eq!(draws.get(), 1, "should sample exactly once"); + assert_eq!(builds.get(), 1, "should build one sampled snapshot"); + assert_eq!(snapshot.sample_rate, context.access_sample_rate); + } + + #[test] + fn access_snapshot_defaults_when_no_extensions_are_attached() { + // Router-generated 404/405 responses and other paths that never pass + // through a RouteMetadata-attaching wrapper must still produce a + // usable snapshot: RouteClass::Other and "unknown" sentinels, never + // a missing/panicking build. + let response = response_builder() + .status(404) + .body(EdgeBody::empty()) + .expect("should build response"); + let context = send_context_fixture(); + + let snapshot = build_access_telemetry_snapshot(&response, &context); + + assert_eq!(snapshot.status, 404); + assert_eq!(snapshot.method, "GET"); + assert!(matches!(snapshot.route_class, RouteClass::Other)); + assert_eq!(snapshot.route_template, "unknown"); + assert_eq!(snapshot.country, "unknown"); + assert_eq!(snapshot.template_cache_state, "unknown"); + assert_eq!(snapshot.body_mode, "buffered"); + assert_eq!(snapshot.publisher_domain, "publisher.example.com"); + assert_eq!(snapshot.sample_rate, 0.25); + } + + #[test] + fn access_snapshot_reads_route_geo_and_template_cache_extensions() { + let mut response = response_builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build response"); + response.extensions_mut().insert(RouteMetadata { + route_class: RouteClass::AuctionApi, + route_template: "/auction".to_owned(), + }); + response.extensions_mut().insert(GeoLookupState::Resolved( + trusted_server_core::platform::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }, + )); + response + .extensions_mut() + .insert(TemplateCacheResponseState::Hit); + let context = send_context_fixture(); + + let snapshot = build_access_telemetry_snapshot(&response, &context); + + assert!(matches!(snapshot.route_class, RouteClass::AuctionApi)); + assert_eq!(snapshot.route_template, "/auction"); + assert_eq!(snapshot.country, "US"); + assert_eq!(snapshot.template_cache_state, "hit"); + } + + #[test] + fn entry_point_404_finalization_writes_geo_state_before_access_snapshot() { + let settings = Settings::default(); + let timings = RequestTimings::new(); + let resolved_geo = trusted_server_core::platform::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + + for (geo_state, expected_country) in [ + (GeoLookupState::NotAttempted, "unknown"), + (GeoLookupState::Resolved(resolved_geo), "US"), + ] { + let mut response = response_builder() + .status(404) + .body(EdgeBody::empty()) + .expect("should build router 404"); + + apply_entry_point_finalize_headers( + &settings, + &mut response, + None, + &geo_state, + &timings, + ); + + match (geo_state, response.extensions().get::()) { + (GeoLookupState::NotAttempted, Some(GeoLookupState::Attempted)) => {} + (GeoLookupState::Resolved(expected), Some(GeoLookupState::Resolved(actual))) => { + assert_eq!(actual.country, expected.country) + } + (_, actual) => { + panic!("should write back the entry-point geo result, got {actual:?}") + } + } + let snapshot = build_access_telemetry_snapshot(&response, &send_context_fixture()); + assert_eq!( + snapshot.status, 404, + "should preserve the router 404 status" + ); + assert_eq!(snapshot.country, expected_country); + } + } + + #[test] + fn access_snapshot_treats_attempted_geo_lookup_as_unknown_country() { + let mut response = response_builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build response"); + response.extensions_mut().insert(GeoLookupState::Attempted); + let context = send_context_fixture(); + + let snapshot = build_access_telemetry_snapshot(&response, &context); + + assert_eq!( + snapshot.country, "unknown", + "an attempted-but-unresolved lookup must not surface a stale country" + ); + } + + #[test] + fn access_snapshot_body_mode_reflects_the_response_body_variant() { + let streamed = response_builder() + .status(200) + .body(EdgeBody::stream(futures::stream::empty())) + .expect("should build streaming response"); + let buffered = response_builder() + .status(200) + .body(EdgeBody::from(b"hi".to_vec())) + .expect("should build buffered response"); + let context = send_context_fixture(); + + assert_eq!( + build_access_telemetry_snapshot(&streamed, &context).body_mode, + "streamed" + ); + assert_eq!( + build_access_telemetry_snapshot(&buffered, &context).body_mode, + "buffered" + ); + } + + #[test] + fn stream_drive_records_stream_ms_covering_the_in_stream_auction_wait() { + // A streaming seam wait (Task 6, publisher.rs) records into the same + // `RequestTimings` handle the adapter drives with. `Phase::Stream` + // wraps the entire drive, so it must cover — and therefore be at + // least as large as — any `AuctionWait` recorded while the body was + // being polled. + let timings = RequestTimings::new(); + let wait_timings = timings.clone(); + let stream = futures::stream::once(async move { + let waited = Duration::from_millis(5); + std::thread::sleep(waited); + wait_timings.record_auction_wait(AuctionWaitPlacement::InStream, waited); + bytes::Bytes::from_static(b"") + }); + let body = EdgeBody::stream(stream); + + let (_counting, drive_result) = drive_streaming_body(body, Vec::new(), &timings); + drive_result.expect("streaming a well-formed body should not fail"); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::InStream), + "should preserve the placement recorded from inside the polled body" + ); + let auction_wait_ms = snapshot + .auction_wait_ms + .expect("should record the auction wait"); + let stream_ms = snapshot.stream_ms.expect("should record the stream drive"); + assert!( + stream_ms >= auction_wait_ms, + "the drive's Phase::Stream span must cover the in-stream auction wait: \ + stream_ms={stream_ms} auction_wait_ms={auction_wait_ms}" + ); + } + + #[test] + fn failed_stream_drives_emit_partial_or_error_rows_with_actual_bytes() { + for (prefix, expected) in [(&b"example"[..], "partial"), (&b""[..], "error")] { + let mut chunks = Vec::new(); + if !prefix.is_empty() { + chunks.push(Ok(bytes::Bytes::copy_from_slice(prefix))); + } + chunks.push(Err(std::io::Error::other("example stream failure"))); + let body = EdgeBody::from_stream(futures::stream::iter(chunks)); + let timings = RequestTimings::new(); + let (writer, drive_result) = drive_streaming_body(body, Vec::new(), &timings); + assert!(drive_result.is_err(), "should observe the stream failure"); + let result = classify_stream_delivery(&drive_result, writer.bytes()); + let snapshot = AccessTelemetrySnapshot { + body_mode: "streamed", + ..sample_access_snapshot() + }; + let row: serde_json::Value = + serde_json::from_str(&access_event_row(&snapshot, &timings.snapshot(), result, 0)) + .expect("should parse the failed delivery row"); + assert_eq!(row["delivery_result"], expected); + assert_eq!(row["resp_bytes"], prefix.len()); + assert_eq!(row["status"], 200, "should retain the already-sent status"); + } + } + + #[test] + fn stream_finish_results_reach_the_access_row() { + for (finish_result, expected) in [ + (Ok(()), "complete"), + ( + Err(std::io::Error::other("example finish failure")), + "partial", + ), + ] { + let result = classify_stream_finish(finish_result, ""); + let row: serde_json::Value = serde_json::from_str(&access_event_row( + &sample_access_snapshot(), + &Default::default(), + result, + 0, + )) + .expect("should parse the transport finish row"); + assert_eq!(row["delivery_result"], expected); + } + } + + #[test] + fn classify_stream_delivery_treats_bytes_written_before_an_error_as_partial() { + let err = Report::new(TrustedServerError::Proxy { + message: "boom".to_string(), + }); + assert_eq!( + classify_stream_delivery(&Err(err), 42), + DeliveryResult::Partial, + "bytes already on the wire before a stream error is a truncated delivery" + ); + } + + #[test] + fn classify_stream_delivery_treats_an_error_with_no_bytes_as_error() { + let err = Report::new(TrustedServerError::Proxy { + message: "boom".to_string(), + }); + assert_eq!( + classify_stream_delivery(&Err(err), 0), + DeliveryResult::Error, + "a failure before any byte reached the client is a clean failure, not a truncation" + ); + } + + #[test] + fn classify_stream_delivery_treats_ok_as_complete() { + assert_eq!( + classify_stream_delivery(&Ok(()), 123), + DeliveryResult::Complete + ); + } + + #[test] + fn post_send_order_is_elapsed_then_pull_sync_then_telemetry() { + // The full contract sequence, instrumented through the real seams: + // `send_edgezero_response` stamps `request_elapsed` before + // returning, and `run_post_send_steps` (which every production + // site with both steps routes through) owns pull-sync-then- + // telemetry ordering. + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let timings = RequestTimings::new(); + let response = response_builder() + .body(EdgeBody::from("ok")) + .expect("should build response"); + + let outcome = send_edgezero_response( + response, + None, + &SendContext { + timings: timings.clone(), + server_timing_enabled: false, + method: "GET", + publisher_domain: "publisher.example.com", + access_sample_rate: 1.0, + access_telemetry_enabled: true, + }, + None, + ); + assert!( + timings.snapshot().request_elapsed_ms.is_some(), + "request_elapsed should be stamped before any post-send step runs" + ); + assert!( + outcome.snapshot.is_some(), + "the access snapshot should exist for the enabled context" + ); + + let pull_log = Arc::clone(&log); + let emit_log = Arc::clone(&log); + run_post_send_steps( + move || { + pull_log + .lock() + .expect("should lock order log") + .push("pull_sync") + }, + move || { + emit_log + .lock() + .expect("should lock order log") + .push("telemetry") + }, + ); + + assert_eq!( + *log.lock().expect("should lock order log"), + vec!["pull_sync", "telemetry"], + "pull-sync must dispatch before telemetry emits" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 9bbc25d7ba..4188d01e73 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -24,8 +24,9 @@ use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_FASTLY_VERSION, }; -use trusted_server_core::geo::GeoInfo; +use trusted_server_core::geo::{GeoInfo, GeoLookupState}; use trusted_server_core::platform::{ClientInfo, PlatformGeo}; +use trusted_server_core::request_timing::{Phase, RequestTimings}; use trusted_server_core::settings::Settings; use trusted_server_core::version_header::apply_git_version_header; @@ -74,6 +75,8 @@ impl Middleware for FinalizeResponseMiddleware { || FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), |info| info.client_ip, ); + let timings = + RequestTimings::from_extensions(ctx.request().extensions()).unwrap_or_default(); let mut response = match next.run(ctx).await { Ok(r) => r, @@ -83,13 +86,24 @@ impl Middleware for FinalizeResponseMiddleware { } }; - let geo_info = resolve_geo_for_response(&response, client_ip, |ip| { + let carried = response + .extensions() + .get::() + .cloned() + .unwrap_or(GeoLookupState::NotAttempted); + let geo_info = resolve_geo_for_response(&response, &carried, client_ip, |ip| { + let _span = timings.span(Phase::Geo); self.geo.lookup(ip).unwrap_or_else(|e| { log::warn!("geo lookup failed: {e}"); None }) }); + // Mirrors the entry-point finalize site in `main.rs` + // (`apply_entry_point_finalize_headers`); 401 handling lives in the + // shared helper. + write_back_geo_lookup_state(&mut response, geo_info.as_ref()); + apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); response .headers_mut() @@ -148,14 +162,40 @@ impl Middleware for AuthMiddleware { // Shared geo resolution helper // --------------------------------------------------------------------------- -/// Resolves geo for a response, skipping the lookup for 401 responses. +/// Writes the resolved geo outcome back onto the response as a +/// [`GeoLookupState`] extension, so a downstream access-telemetry snapshot +/// sees what was actually looked up rather than the stale carried-in state. +/// +/// Skips the write on a 401: [`resolve_geo_for_response`] returns `None` +/// for unauthorized responses before consulting the carried state, so +/// writing `Attempted` there would overwrite a carried `Resolved` with a +/// value that was never looked up, and the row would lose a country it +/// legitimately had. +pub(crate) fn write_back_geo_lookup_state(response: &mut Response, geo_info: Option<&GeoInfo>) { + if response.status() == StatusCode::UNAUTHORIZED { + return; + } + let resolved_state = match geo_info { + Some(geo) => GeoLookupState::Resolved(geo.clone()), + None => GeoLookupState::Attempted, + }; + response.extensions_mut().insert(resolved_state); +} + +/// Resolves geo for a response, skipping the lookup for 401 responses and +/// reusing a request-phase lookup when one was already carried. /// -/// Returns `None` for authentication rejections (401) without calling `lookup_geo` -/// to avoid unnecessary work and exposing geo data to unauthenticated callers. -/// All other responses call `lookup_geo` and return its result. +/// Returns `None` for authentication rejections (401) without consulting +/// `carried` or calling `lookup_geo`, to avoid unnecessary work and exposing +/// geo data to unauthenticated callers. Otherwise dispatches on `carried`: +/// a [`GeoLookupState::Resolved`] value is reused as-is, a +/// [`GeoLookupState::Attempted`] value is treated as no geo info without +/// retrying the lookup, and [`GeoLookupState::NotAttempted`] falls back to +/// calling `lookup_geo`. /// /// Used by both [`FinalizeResponseMiddleware`] and the entry-point finalization -/// in `main.rs` so the 401-skip rule is defined in one place. +/// in `main.rs` so the 401-skip rule and the dedupe rule are each defined in +/// one place. /// /// # Parity note /// @@ -167,6 +207,7 @@ impl Middleware for AuthMiddleware { /// server or the upstream origin. pub(crate) fn resolve_geo_for_response( response: &Response, + carried: &GeoLookupState, client_ip: Option, lookup_geo: F, ) -> Option @@ -174,9 +215,12 @@ where F: FnOnce(Option) -> Option, { if response.status() == StatusCode::UNAUTHORIZED { - None - } else { - lookup_geo(client_ip) + return None; + } + match carried { + GeoLookupState::Resolved(geo) => Some(geo.clone()), + GeoLookupState::Attempted => None, + GeoLookupState::NotAttempted => lookup_geo(client_ip), } } @@ -273,6 +317,51 @@ fn apply_fastly_version_header(service_version: Option<&str>, response: &mut Res mod tests { use super::*; + #[test] + fn geo_write_back_preserves_resolved_state_on_401() { + // A 401 short-circuits geo resolution before the carried state is + // consulted, so the write-back must not downgrade a carried + // Resolved to Attempted (which would cost the row its country). + let mut response = response_builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::empty()) + .expect("should build a 401 response"); + response + .extensions_mut() + .insert(GeoLookupState::Resolved(sample_geo_info())); + + write_back_geo_lookup_state(&mut response, None); + + match response.extensions().get::() { + Some(GeoLookupState::Resolved(geo)) => { + assert_eq!( + geo.country, + sample_geo_info().country, + "should keep the carried country" + ); + } + other => panic!("should keep the Resolved state on a 401, got {other:?}"), + } + } + + #[test] + fn geo_write_back_records_attempted_on_non_401_miss() { + let mut response = response_builder() + .status(StatusCode::OK) + .body(Body::empty()) + .expect("should build a 200 response"); + + write_back_geo_lookup_state(&mut response, None); + + assert!( + matches!( + response.extensions().get::(), + Some(GeoLookupState::Attempted) + ), + "should record an attempted-but-missed lookup on ordinary responses" + ); + } + use std::collections::HashMap; use std::net::IpAddr; use std::sync::{Arc, Mutex}; @@ -303,6 +392,19 @@ mod tests { RequestContext::new(req, PathParams::new(HashMap::new())) } + fn sample_geo_info() -> GeoInfo { + GeoInfo { + city: "Testville".to_string(), + country: "US".to_string(), + continent: "NorthAmerica".to_string(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + struct FixedGeo(Option); impl PlatformGeo for FixedGeo { @@ -705,6 +807,67 @@ mod tests { ); } + #[test] + fn finalize_handle_writes_back_resolved_geo_state_after_fallback_lookup() { + // The request phase never attempted a geo lookup (no GeoLookupState + // extension on the handler's response), so the middleware resolves + // one via the fallback closure. That resolved outcome must be + // written back into response extensions -- mirroring + // apply_entry_point_finalize_headers in main.rs -- so a downstream + // access-telemetry snapshot sees the freshly resolved country + // instead of a stale/missing GeoLookupState. + let settings = settings_with_response_headers(vec![]); + let middleware = FinalizeResponseMiddleware::new( + Arc::new(settings), + Arc::new(FixedGeo(Some(sample_geo_info()))), + ); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + match response.extensions().get::() { + Some(GeoLookupState::Resolved(info)) => { + assert_eq!( + info.country, "US", + "should carry the fallback-resolved geo info" + ); + } + other => { + panic!("expected GeoLookupState::Resolved after a fallback lookup, got {other:?}") + } + } + } + + #[test] + fn finalize_handle_writes_back_attempted_geo_state_when_fallback_finds_nothing() { + // The fallback lookup ran but resolved no geo info. The middleware + // must still record that the lookup was attempted, so a later + // consumer of the extension does not mistake this for + // GeoLookupState::NotAttempted and retry the lookup. + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + assert!( + matches!( + response.extensions().get::(), + Some(GeoLookupState::Attempted) + ), + "should write back Attempted when the fallback lookup finds no geo info" + ); + } + #[test] fn finalize_handle_marks_response_as_finalized() { let settings = settings_with_response_headers(vec![]); @@ -786,6 +949,30 @@ mod tests { ); } + #[test] + #[allow(clippy::panic)] + fn geo_lookup_skipped_for_unauthorized_responses() { + // The 401 short-circuit in resolve_geo_for_response must win + // regardless of what state the request phase carried in, and must + // never invoke the fallback lookup closure. + let mut response = empty_response(); + *response.status_mut() = StatusCode::UNAUTHORIZED; + + for carried in [ + GeoLookupState::NotAttempted, + GeoLookupState::Attempted, + GeoLookupState::Resolved(sample_geo_info()), + ] { + let geo_info = resolve_geo_for_response(&response, &carried, None, |_| { + panic!("401 responses must never trigger a geo lookup"); + }); + assert!( + geo_info.is_none(), + "401 responses should never resolve geo info, regardless of carried state" + ); + } + } + // --------------------------------------------------------------------------- // AuthMiddleware::handle tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 504dbd9fba..16c1c6b5dc 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -10,10 +10,15 @@ use trusted_server_core::auction::telemetry::{ AuctionEventBatch, AuctionTelemetrySink, NoopAuctionTelemetrySink, }; use trusted_server_core::error::TrustedServerError; -use trusted_server_core::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; +use trusted_server_core::platform::{ + PlatformBackend as _, PlatformBackendSpec, PlatformHttpClient, PlatformHttpRequest, + RuntimeServices, +}; use trusted_server_core::redacted::Redacted; use trusted_server_core::settings::{Settings, TinybirdSettings}; +use crate::platform::FastlyPlatformBackend; + 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); @@ -21,9 +26,14 @@ 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. +/// +/// Auction emission requires both the Tinybird master toggle +/// (`tinybird.enabled`) and the auction-specific toggle +/// (`tinybird.auction_enabled`), so access-log telemetry can be enabled +/// independently without also emitting auction events. #[must_use] pub(crate) fn auction_sink_from_settings(settings: &Settings) -> Arc { - if settings.tinybird.enabled { + if settings.tinybird.enabled && settings.tinybird.auction_enabled { Arc::new(FastlyTinybirdAuctionTelemetrySink::new( settings.tinybird.clone(), )) @@ -39,7 +49,7 @@ struct FastlyTinybirdAuctionTelemetrySink { } #[derive(Debug, Clone)] -struct TinybirdEventsTarget { +pub(crate) struct TinybirdEventsTarget { api_host: String, dataset: String, append_token: Redacted, @@ -63,6 +73,37 @@ impl TinybirdEventsTarget { max_body_bytes: config.max_body_bytes, } } + + /// Builds the Events API target for the access-log datasource. + /// + /// Shares [`from_config`](Self::from_config)'s host and body-size-limit + /// derivation, but points at `access_dataset` and + /// `access_token_secret` instead of the auction pair, so access-log + /// emission never shares a datasource or token with auction telemetry + /// even though both configs come from the same [`TinybirdSettings`]. + /// + /// # Errors + /// + /// Returns an error if the access token has not been resolved. + pub(crate) fn from_access_config( + config: &TinybirdSettings, + ) -> Result> { + let append_token = config.access_token_secret.clone().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "Tinybird access token is not resolved".to_owned(), + }) + })?; + let uri = tinybird_events_uri(&config.api_host, &config.access_dataset); + let backend_spec = tinybird_backend_spec(&config.api_host); + Ok(Self { + api_host: config.api_host.clone(), + dataset: config.access_dataset.clone(), + append_token, + uri, + backend_spec, + max_body_bytes: config.max_body_bytes, + }) + } } impl FastlyTinybirdAuctionTelemetrySink { @@ -182,6 +223,116 @@ impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { } } +// --------------------------------------------------------------------------- +// Access telemetry: post-send emitter +// --------------------------------------------------------------------------- + +/// Decides whether one request's access-telemetry row should be emitted. +/// +/// `roll` is a uniform draw from `[0, 1)`; callers pass +/// `rand::thread_rng().r#gen::()`, which the wasm32-wasip1 guest backs with +/// real WASI randomness (the EC generation path already relies on this and +/// the CI wasm release build verifies it). Comparing the draw directly +/// against `rate` keeps the sampling probability exactly `rate` for every +/// positive value: there is no bucket quantization, so rates below one in a +/// million sample proportionally instead of never, and emitted rows' +/// `sample_rate` matches the probability they were sampled at, which the +/// `sum(1.0 / sample_rate)` volume estimator depends on. +/// +/// `rate <= 0.0` never samples and `rate >= 1.0` always samples, for any +/// `roll` in `[0, 1)`. `0.0` cannot actually occur while `access_enabled` +/// is `true` (`Settings` validation requires `access_sample_rate > 0.0` in +/// that case), but this function stays total rather than leaning on that +/// invariant. +#[must_use] +pub(crate) fn sampled_in(rate: f64, roll: f64) -> bool { + roll < rate +} + +/// Builds the Events API POST request for one access-log row. +fn build_access_events_request( + target: &TinybirdEventsTarget, + body: String, + auth_header: HeaderValue, +) -> Result> { + request_builder() + .method(Method::POST) + .uri(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(), + }) +} + +/// Starts a best-effort POST of one post-send access-log row to Tinybird. +/// +/// The pending handle is dropped without waiting for the ingest reply or +/// retrying. Fastly SDK 0.12.1 documents that `Request::send_async` continues +/// transmitting headers and body in the background even after the initiating +/// Compute program exits, and permits discarding `PendingRequest` for side +/// effects. This avoids holding a reused sandbox until the ingest reply. +/// Dispatch success does not confirm remote ingest acceptance: later transport +/// failures and non-2xx statuses are not observable here. Likewise, the earlier +/// client-response transport handoff does not confirm client receipt. +/// +/// `client` is the adapter's stateless platform HTTP client in production +/// ([`crate::platform::FastlyPlatformHttpClient`]); accepting it as `&dyn +/// PlatformHttpClient` lets tests substitute a recording double. +/// `target` is derived from settings once at the post-send call site. +/// +/// # Errors +/// +/// Returns `Err` when the row exceeds the configured request-body limit, the +/// authorization header is invalid, the backend cannot be registered, or the +/// request cannot be built or started. +pub(crate) async fn emit_access_event( + client: &dyn PlatformHttpClient, + target: &TinybirdEventsTarget, + mut row: String, +) -> Result<(), Report> { + // Match the auction sink's NDJSON framing: every row is + // newline-terminated, and the terminator counts toward the body limit. + if !row.ends_with('\n') { + row.push('\n'); + } + let body_len = row.len(); + if body_len > target.max_body_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "Tinybird access telemetry request body has {body_len} bytes, exceeding {} byte limit", + target.max_body_bytes + ), + })); + } + + let auth_header = + FastlyTinybirdAuctionTelemetrySink::authorization_header(target.append_token.expose())?; + let backend_name = FastlyPlatformBackend + .ensure(&target.backend_spec) + .change_context(TrustedServerError::Proxy { + message: "Tinybird backend registration failed".to_owned(), + })?; + let request = build_access_events_request(target, row, auth_header)?; + + log::debug!( + "sending access telemetry to Tinybird dataset={} host={} backend={}", + target.dataset, + target.api_host, + backend_name + ); + + let pending = client + .send_async(PlatformHttpRequest::new(request, backend_name)) + .await + .change_context(TrustedServerError::Proxy { + message: "failed to start Tinybird access telemetry request".to_owned(), + })?; + drop(pending); + Ok(()) +} + fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { PlatformBackendSpec { scheme: "https".to_owned(), @@ -205,6 +356,7 @@ fn tinybird_events_uri(api_host: &str, dataset: &str) -> String { #[cfg(test)] mod tests { use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use error_stack::Report; @@ -308,25 +460,45 @@ mod tests { body: Vec, } + struct RecordedPending(Arc); + + impl Drop for RecordedPending { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + #[derive(Default)] struct RecordingHttpClient { requests: Mutex>, + send_calls: AtomicUsize, + send_async_calls: AtomicUsize, + wait_calls: AtomicUsize, select_calls: Mutex, + fail_async_start: bool, + pending_drops: Arc, } - #[async_trait::async_trait(?Send)] - impl PlatformHttpClient for RecordingHttpClient { - async fn send( - &self, - _request: PlatformHttpRequest, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) + impl RecordingHttpClient { + fn assert_no_response_wait(&self) { + assert_eq!( + self.send_calls.load(Ordering::SeqCst), + 0, + "must not send synchronously" + ); + assert_eq!( + self.wait_calls.load(Ordering::SeqCst), + 0, + "must not wait on a pending request" + ); + assert_eq!( + *self.select_calls.lock().expect("should lock select calls"), + 0, + "must not select a response" + ); } - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { + fn record(&self, request: PlatformHttpRequest) { let backend_name = request.backend_name; let (parts, body) = request.request.into_parts(); let headers = parts @@ -350,7 +522,40 @@ mod tests { .lock() .expect("should lock recorded requests") .push(recorded); - Ok(PlatformPendingRequest::new(()).with_backend_name("tinybird-backend")) + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for RecordingHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + self.send_calls.fetch_add(1, Ordering::SeqCst); + Err(Report::new(PlatformError::Unsupported)) + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.send_async_calls.fetch_add(1, Ordering::SeqCst); + self.record(request); + if self.fail_async_start { + return Err(Report::new(PlatformError::HttpClient)); + } + Ok( + PlatformPendingRequest::new(RecordedPending(Arc::clone(&self.pending_drops))) + .with_backend_name("tinybird-backend"), + ) + } + + async fn wait( + &self, + _pending: PlatformPendingRequest, + ) -> Result> { + self.wait_calls.fetch_add(1, Ordering::SeqCst); + Err(Report::new(PlatformError::Unsupported)) } async fn select( @@ -431,18 +636,52 @@ mod tests { fn enabled_config() -> TinybirdSettings { TinybirdSettings { enabled: true, - api_host: "api.us-east.aws.tinybird.co".to_owned(), + auction_enabled: true, + api_host: "api.tinybird.example.com".to_owned(), secret_store: None, auction_dataset: "auction_events_raw".to_owned(), auction_token_secret: Some(Redacted::new("append-token".to_owned())), access_enabled: false, - access_dataset: "access_logs_raw".to_owned(), - access_token_secret: None, + access_dataset: "access_logs_v2".to_owned(), + access_token_secret: Some(Redacted::new("access-append-token".to_owned())), access_sample_rate: 0.0, max_body_bytes: 1024 * 1024, } } + #[test] + fn sink_from_settings_disables_when_auction_enabled_is_false() { + let settings = Settings { + tinybird: TinybirdSettings { + auction_enabled: false, + ..enabled_config() + }, + ..Settings::default() + }; + + let sink = auction_sink_from_settings(&settings); + + assert!( + !sink.is_enabled(), + "auction telemetry should stay off when auction_enabled is false, even if tinybird.enabled is true" + ); + } + + #[test] + fn sink_from_settings_enables_when_both_toggles_are_true() { + let settings = Settings { + tinybird: enabled_config(), + ..Settings::default() + }; + + let sink = auction_sink_from_settings(&settings); + + assert!( + sink.is_enabled(), + "auction telemetry should be on when both tinybird.enabled and tinybird.auction_enabled are true" + ); + } + #[test] fn events_uri_targets_dataset_on_region_host() { assert_eq!( @@ -486,7 +725,7 @@ mod tests { 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"); + assert_eq!(specs[0].host, "api.tinybird.example.com"); drop(specs); let requests = http_client @@ -498,7 +737,7 @@ mod tests { 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" + "https://api.tinybird.example.com/v0/events?name=auction_events_raw" ); assert_eq!( header_value(&requests[0].headers, header::CONTENT_TYPE.as_str()), @@ -619,6 +858,281 @@ mod tests { ); } + #[test] + fn access_target_rejects_unresolved_token_without_panicking() { + let mut config = enabled_config(); + config.access_token_secret = None; + + let error = TinybirdEventsTarget::from_access_config(&config) + .expect_err("should reject an unresolved access token"); + + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should report a configuration error" + ); + assert!( + error + .to_string() + .contains("Tinybird access token is not resolved"), + "should explain the missing resolved access token" + ); + } + + #[test] + fn access_target_borrows_settings_and_uses_only_access_fields() { + let mut config = enabled_config(); + config.auction_token_secret = None; + config.max_body_bytes = 2048; + + let target = TinybirdEventsTarget::from_access_config(&config) + .expect("should build access telemetry target"); + + assert_eq!(target.api_host, config.api_host); + assert_eq!(target.dataset, config.access_dataset); + assert_eq!(target.append_token.expose(), "access-append-token"); + assert_eq!(target.max_body_bytes, config.max_body_bytes); + assert_eq!( + config + .access_token_secret + .as_ref() + .expect("should retain the access token in settings") + .expose(), + "access-append-token" + ); + } + + #[test] + fn access_emitter_rejects_oversized_row_before_sending() { + let mut config = enabled_config(); + config.max_body_bytes = 1024; + let target = TinybirdEventsTarget::from_access_config(&config) + .expect("should build access telemetry target"); + let http_client = RecordingHttpClient::default(); + let row = "x".repeat(1025); + + let result = futures::executor::block_on(emit_access_event(&http_client, &target, row)); + + let error = result.expect_err("should reject a row above the configured body limit"); + assert!( + error.to_string().contains("1024"), + "error should name the configured body limit: {error}" + ); + assert!( + http_client + .requests + .lock() + .expect("should lock recorded requests") + .is_empty(), + "should not send an oversized access row" + ); + } + + #[test] + fn access_emitter_accepts_row_exactly_at_body_limit_including_newline() { + let mut config = enabled_config(); + config.max_body_bytes = 1024; + let target = TinybirdEventsTarget::from_access_config(&config) + .expect("should build access telemetry target"); + let http_client = RecordingHttpClient::default(); + let row = "x".repeat(1023); + + futures::executor::block_on(emit_access_event(&http_client, &target, row.clone())) + .expect("a newline-terminated row exactly at the limit should send"); + + let requests = http_client + .requests + .lock() + .expect("should lock recorded requests"); + assert_eq!(requests.len(), 1, "should send the boundary-sized row"); + assert_eq!(requests[0].body.len(), 1024, "newline counts toward limit"); + assert_eq!(requests[0].body.last(), Some(&b'\n')); + } + + #[test] + fn access_emitter_posts_ndjson_and_drops_pending_without_waiting() { + let target = TinybirdEventsTarget::from_access_config(&enabled_config()) + .expect("should build access telemetry target"); + let http_client = RecordingHttpClient::default(); + let row = r#"{"status":200}"#.to_owned(); + + futures::executor::block_on(emit_access_event(&http_client, &target, row.clone())) + .expect("should start the access telemetry request"); + + let requests = http_client + .requests + .lock() + .expect("should lock recorded requests"); + assert_eq!(requests.len(), 1, "should send exactly one request"); + assert_eq!( + requests[0].uri, + "https://api.tinybird.example.com/v0/events?name=access_logs_v2" + ); + assert_eq!(requests[0].method, Method::POST.to_string()); + 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 access-append-token") + ); + let body = std::str::from_utf8(&requests[0].body).expect("should record utf8 body"); + assert_eq!( + body, + format!("{row}\n"), + "should send newline-delimited JSON, matching the auction sink's framing" + ); + http_client.assert_no_response_wait(); + assert_eq!(http_client.send_async_calls.load(Ordering::SeqCst), 1); + assert_eq!( + http_client.pending_drops.load(Ordering::SeqCst), + 1, + "should drop the pending handle before returning" + ); + } + + #[test] + fn access_emitter_surfaces_async_start_failure_without_retrying() { + let target = TinybirdEventsTarget::from_access_config(&enabled_config()) + .expect("should build access telemetry target"); + let http_client = RecordingHttpClient { + fail_async_start: true, + ..RecordingHttpClient::default() + }; + + let error = futures::executor::block_on(emit_access_event( + &http_client, + &target, + r#"{"status":200}"#.to_owned(), + )) + .expect_err("should surface the dispatch failure"); + + assert!( + error + .to_string() + .contains("failed to start Tinybird access telemetry request") + ); + assert_eq!( + http_client.send_async_calls.load(Ordering::SeqCst), + 1, + "must not retry" + ); + assert_eq!( + http_client.pending_drops.load(Ordering::SeqCst), + 0, + "failed dispatch must not create a pending handle" + ); + http_client.assert_no_response_wait(); + } + + #[test] + fn access_emitter_preserves_existing_newline() { + let target = TinybirdEventsTarget::from_access_config(&enabled_config()) + .expect("should build access telemetry target"); + let http_client = RecordingHttpClient::default(); + let row = "{\"status\":200}\n".to_owned(); + + futures::executor::block_on(emit_access_event(&http_client, &target, row.clone())) + .expect("should start the access telemetry request"); + + let requests = http_client + .requests + .lock() + .expect("should lock recorded requests"); + assert_eq!(requests[0].body, row.as_bytes()); + } + + #[test] + fn access_emitter_counts_added_newline_against_body_limit() { + let mut config = enabled_config(); + config.max_body_bytes = 1024; + let target = TinybirdEventsTarget::from_access_config(&config) + .expect("should build access telemetry target"); + let http_client = RecordingHttpClient::default(); + + let error = + futures::executor::block_on(emit_access_event(&http_client, &target, "x".repeat(1024))) + .expect_err("should count the added newline toward the body limit"); + + assert!( + error + .to_string() + .contains("1025 bytes, exceeding 1024 byte limit"), + "error should count the added newline: {error}" + ); + assert_eq!(http_client.send_async_calls.load(Ordering::SeqCst), 0); + http_client.assert_no_response_wait(); + } + + #[test] + fn access_emitter_rejects_invalid_authorization_before_dispatch() { + let mut config = enabled_config(); + config.access_token_secret = Some(Redacted::new("invalid\ntoken".to_owned())); + let target = TinybirdEventsTarget::from_access_config(&config) + .expect("should build access telemetry target"); + let http_client = RecordingHttpClient::default(); + + let error = + futures::executor::block_on(emit_access_event(&http_client, &target, "{}".to_owned())) + .expect_err("should reject an invalid authorization header"); + + assert!( + error + .to_string() + .contains("invalid Tinybird authorization header"), + "error should name the invalid authorization header: {error}" + ); + assert_eq!(http_client.send_async_calls.load(Ordering::SeqCst), 0); + http_client.assert_no_response_wait(); + } + + #[test] + fn sampled_in_boundary_rates_are_unconditional() { + assert!( + sampled_in(1.0, 0.0), + "a 1.0 sample rate should always sample in" + ); + assert!( + sampled_in(1.0, 0.999_999), + "a 1.0 sample rate should sample in for the largest roll" + ); + assert!( + !sampled_in(0.0, 0.0), + "a 0.0 sample rate should never sample in, even on a zero roll" + ); + assert!( + !sampled_in(-1.0, 0.0), + "a negative rate should never sample in" + ); + } + + #[test] + fn sampled_in_keeps_exact_probability_for_tiny_rates() { + // The previous bucket-quantized sampler truncated rates below one + // in a million to a zero threshold, silently emitting nothing. + // Direct comparison keeps every positive rate proportional. + let rate = 0.000_000_1; + assert!( + sampled_in(rate, rate / 2.0), + "a roll below a tiny positive rate should sample in" + ); + assert!( + !sampled_in(rate, rate * 2.0), + "a roll above a tiny positive rate should sample out" + ); + assert!( + !sampled_in(0.000_001_9, 0.000_001_95), + "no downward quantization: the boundary sits exactly at the rate" + ); + assert!( + sampled_in(0.000_001_9, 0.000_001_85), + "rolls just under the rate should sample in" + ); + } + fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { headers .iter() diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 9edce69fa3..02459f575b 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -44,6 +44,7 @@ use trusted_server_core::publisher::{ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; +use trusted_server_core::request_timing::RequestTimingMiddleware; use trusted_server_core::settings::Settings; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::settings_data::{default_config_key, default_secret_store_name}; @@ -894,6 +895,7 @@ fn build_router(state: &Arc) -> RouterService { // any middleware registered ahead of it would observe the // shared-secret authentication header. .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) + .middleware(RequestTimingMiddleware::default().with_excluded_paths(&["/health"])) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) // Innermost middleware: normalize every routed request (strip diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 4dc3cbee47..0dd9ba45fa 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -55,9 +55,9 @@ impl Middleware for SanitizeRequestMiddleware { /// Spin does not expose geo headers to the application, so /// `X-Geo-Info-Available: false` is emitted for every response. /// -/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of -/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected -/// ones — carries a consistent set of headers. +/// Registered inside [`RequestTimingMiddleware`](trusted_server_core::request_timing::RequestTimingMiddleware) and ahead of [`AuthMiddleware`] +/// so that every outgoing response — including auth-rejected ones — carries a +/// consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -190,6 +190,8 @@ pub(crate) fn apply_finalize_headers( #[cfg(test)] mod tests { use super::*; + use edgezero_core::router::RouterService; + use trusted_server_core::request_timing::{RequestTimingMiddleware, RequestTimings}; use std::collections::HashMap; use std::sync::Mutex; @@ -209,10 +211,10 @@ mod tests { .expect("should build empty test response") } - fn empty_ctx() -> RequestContext { + fn ctx_for_path(path: &str) -> RequestContext { let req = request_builder() .method(Method::GET) - .uri("/test") + .uri(path) .header("x-reader-ip", "198.51.100.7") .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") .body(Body::empty()) @@ -220,6 +222,10 @@ mod tests { RequestContext::new(req, PathParams::new(HashMap::new())) } + fn empty_ctx() -> RequestContext { + ctx_for_path("/test") + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { // Build from explicit test settings: the settings baked into the // binary contain placeholder secrets that `get_settings()` rejects @@ -318,6 +324,77 @@ mod tests { ); } + #[test] + fn request_timing_middleware_preserves_shared_handle_and_health_policy() { + for method in [Method::GET, Method::POST] { + for path in ["/test", "/health", "/health?probe=1", "/health/child"] { + for preinstalled in [false, true] { + let route_path = path.split('?').next().expect("should have path"); + let expected = preinstalled || route_path != "/health"; + let timings = RequestTimings::new(); + timings.record( + trusted_server_core::request_timing::Phase::Filter, + std::time::Duration::from_millis(7), + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + let router = RouterService::builder() + .middleware( + RequestTimingMiddleware::default().with_excluded_paths(&["/health"]), + ) + .middleware( + RequestTimingMiddleware::default().with_excluded_paths(&["/health"]), + ) + .route(route_path, method.clone(), move |ctx: RequestContext| async move { + let installed = RequestTimings::from_extensions(ctx.request().extensions()); + assert_eq!(installed.is_some(), expected, "should preserve exact health policy"); + if let Some(installed) = installed { + if preinstalled { + assert_eq!(installed.snapshot().filter_ms, Some(7), "should retain upstream facts"); + installed.mark_auction_dispatched(); + assert!(installed.snapshot().auction_dispatched_ms.expect("should mark dispatch") >= 2, + "should retain upstream origin"); + } else { + assert_eq!(installed.snapshot(), trusted_server_core::request_timing::TimingSnapshot::default(), + "should install independent empty facts"); + } + installed.record_auction_wait( + trusted_server_core::request_timing::AuctionWaitPlacement::PreHeader, + std::time::Duration::from_millis(3)); + } + Ok::(empty_response()) + }) + .build(); + let mut request = request_builder() + .method(method.clone()) + .uri(path) + .body(Body::empty()) + .expect("should build request"); + if preinstalled { + request.extensions_mut().insert(timings.handle().clone()); + } + let response = + block_on(router.oneshot(request)).expect("should dispatch request"); + assert!( + response.headers().get("server-timing").is_none(), + "attachment should not expose timing" + ); + if preinstalled { + assert_eq!( + timings.snapshot().auction_wait_ms, + Some(3), + "should share handler updates" + ); + assert_eq!( + timings.snapshot().request_elapsed_ms, + None, + "should not fabricate completion" + ); + } + } + } + } + } + #[test] fn sanitize_middleware_strips_configured_trust_headers_before_routing() { let mut settings = settings_with_response_headers(vec![]); diff --git a/crates/trusted-server-core/src/access_telemetry.rs b/crates/trusted-server-core/src/access_telemetry.rs new file mode 100644 index 0000000000..a89162c2a8 --- /dev/null +++ b/crates/trusted-server-core/src/access_telemetry.rs @@ -0,0 +1,716 @@ +//! Access telemetry: route classification and the per-request access log row. +//! +//! Uses the versioned `access_logs_v2` Tinybird datasource with bounded, +//! content-free route identity (see [`RouteClass`] and +//! [`publisher_route_template`]) instead of the raw request path, which would +//! otherwise carry identifiers, search terms, and other user-generated +//! content into a 30-day dataset. See the design spec +//! `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md` +//! section 9. + +use serde_json::json; + +use crate::constants::TS_GIT_VERSION; +use crate::request_timing::{AuctionWaitPlacement, TimingSnapshot}; + +/// Normalizes an HTTP method token into the bounded set of values stored in +/// the `method` `LowCardinality` column. +/// +/// HTTP permits arbitrary extension-method tokens (`PROPFIND`, `MKCOL`, or +/// any client-supplied garbage), and the token on an inbound request is +/// entirely client controlled. Capturing one verbatim into a 30-day +/// `LowCardinality(String)` column would let a single caller inflate that +/// column's cardinality without bound and would violate this dataset's +/// bounded-dimension privacy rule (see the module doc). GET, HEAD, POST, PUT, +/// DELETE, PATCH and OPTIONS map to their uppercase form; all other methods, +/// including CONNECT and TRACE, map to `"other"`. Runs +/// inside [`access_event_row`] rather than at each capture site, so every +/// row-building path is covered regardless of how `method` was populated. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::access_telemetry::normalize_method; +/// +/// assert_eq!(normalize_method("get"), "GET"); +/// assert_eq!(normalize_method("PROPFIND"), "other"); +/// assert_eq!(normalize_method(""), + "other", + "an unbounded client-controlled token must not reach the row verbatim" + ); + } + + #[test] + fn row_normalizes_method_even_when_snapshot_carries_a_raw_token() { + // The normalizer runs inside `access_event_row` so every row-building + // path is covered, regardless of what the snapshot's `method` field + // holds — a caller-controlled extension method must never leak into + // the row unnormalized. + let mut snapshot = unknown_snapshot(RouteClass::Other, "/other/*"); + snapshot.method = "PROPFIND".to_owned(); + let row = access_event_row( + &snapshot, + &TimingSnapshot::default(), + DeliveryResult::Complete, + 0, + ); + let parsed: serde_json::Value = + serde_json::from_str(&row).expect("should serialize valid JSON"); + + assert_eq!(parsed["method"], "other"); + } +} diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 086515fa20..66013b7db3 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -22,6 +22,7 @@ use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::platform::RuntimeServices; +use crate::request_timing::RequestTimings; use crate::settings::Settings; use crate::trace::{ TraceAuctionCarry, TraceAuctionSource, TraceAuctionTerminalReason, TraceAuctionTerminalStatus, @@ -150,6 +151,12 @@ pub async fn handle_auction( } let (parts, body) = req.into_parts(); + // T0-anchored timeline (spec section 18). This route is the auction, so + // dispatch and resolve bracket `run_auction` rather than the origin + // fetch, and the commit mark lands once the OpenRTB response carrying the + // targeting has been built. A defaulted handle records into nothing that + // is ever read, so direct-handler tests are unaffected. + let timings = RequestTimings::from_extensions(&parts.extensions).unwrap_or_default(); let body_bytes = body.into_bytes().unwrap_or_default(); if body_bytes.len() > MAX_AUCTION_BODY_SIZE { return Response::builder() @@ -281,6 +288,7 @@ pub async fn handle_auction( &auction_request, ec_context, ); + timings.set_auction_id(observation.auction_id); emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -403,24 +411,27 @@ pub async fn handle_auction( ec_context, ); - // Run the auction - let result = match orchestrator.run_auction(&auction_request, &context).await { - Ok(result) => result, - Err(err) => { - let elapsed_ms = observation.elapsed_ms(); + timings.set_auction_id(observation.auction_id); + + let (result, auction_work_started) = match orchestrator + .run_auction_with_timings(&auction_request, &context, &timings) + .await + { + Ok(completed) => completed, + Err(failure) => { emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, AuctionTerminalOutcome::ExecutionFailed { request: Some(&auction_request), - provider_responses: &[], + provider_responses: &failure.provider_responses, reason: "execution_failed", - elapsed_ms, + elapsed_ms: failure.elapsed_ms, }, ) }) .await; - return Err(err.change_context(TrustedServerError::Auction { + return Err(failure.error.change_context(TrustedServerError::Auction { message: "Auction orchestration failed".to_string(), })); } @@ -452,6 +463,12 @@ pub async fn handle_auction( } }; + // For this route the response body is the commit, since there is no + // page state to write into. Routing-only results cannot commit an auction. + if auction_work_started { + timings.mark_auction_committed(); + } + emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -695,6 +712,7 @@ pub(crate) fn merge_auction_eids( mod tests { use super::*; use crate::auction::config::AuctionConfig; + use crate::auction::provider::test_support::LaunchEvidenceProvider as Mediator; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; use crate::auction::types::{AuctionRequest, AuctionResponse}; @@ -707,7 +725,10 @@ mod tests { NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubBackend, StubHttpClient, noop_services, }; - use crate::platform::{ClientInfo, PlatformHttpClient, PlatformHttpRequest, PlatformResponse}; + use crate::platform::{ + ClientInfo, PlatformBackend, PlatformHttpClient, PlatformHttpRequest, PlatformResponse, + }; + use crate::request_timing::AuctionWaitPlacement; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use crate::trace::TracePreDispatchHook; use base64::Engine as _; @@ -737,13 +758,21 @@ mod tests { } fn services_with_telemetry(sink: Arc) -> RuntimeServices { + services_with_http_and_telemetry(Arc::new(NoopHttpClient), Arc::new(NoopBackend), sink) + } + + fn services_with_http_and_telemetry( + http: Arc, + backend: Arc, + 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)) + .backend(backend) + .http_client(http) .geo(Arc::new(NoopGeo)) .auction_telemetry_sink(telemetry_sink) .client_info(ClientInfo::default()) @@ -1143,13 +1172,15 @@ mod tests { "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }] }); - let request = Request::builder() + let mut request = Request::builder() .method("POST") .uri("https://test-publisher.example/auction") .body(EdgeBody::from( serde_json::to_vec(&body).expect("should serialize disabled-auction body"), )) .expect("should build disabled-auction request"); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); let response = handle_auction( &settings, @@ -1168,6 +1199,10 @@ mod tests { StatusCode::OK, "disabled auction should return a 200 no-bid response" ); + assert!( + timings.snapshot().auction_id.is_none(), + "should keep the disabled /auction access join key null" + ); let batches = telemetry_sink .batches .lock() @@ -1184,6 +1219,262 @@ mod tests { ); } + #[tokio::test] + async fn planned_endpoint_preserves_installed_collector_across_terminal_states() { + for (provider_configured, transport_failure, metadata_missing) in [ + (true, false, false), + (true, true, false), + (false, false, false), + (true, false, true), + ] { + let provider = if provider_configured { + "[auction.providers.example]\nprotocol = \"openrtb-2.6\"\nprofile = \"standard\"\nendpoint = \"https://bidder.example.com/auction\"\nrouting = \"all_eligible\"\n" + } else { + "" + }; + let settings = Settings::from_toml(&format!( + "{}\n[auction]\nenabled = true\n{provider}", + crate_test_settings_str() + )) + .expect("should parse planned endpoint settings"); + let plan = Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile endpoint plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + if transport_failure { + http.push_select_error(); + } + if metadata_missing { + http.push_pending_backend_name_override(None); + } + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = + services_with_http_and_telemetry(http.clone(), Arc::new(StubBackend), sink.clone()); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let body = json!({ "adUnits": [{ + "code": "example-slot", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + }] }); + let mut request = Request::builder() + .method("POST") + .uri("https://publisher.example.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize endpoint request"), + )) + .expect("should build endpoint request"); + let timings = RequestTimings::new(); + timings.record( + crate::request_timing::Phase::Filter, + std::time::Duration::from_millis(11), + ); + request.extensions_mut().insert(timings.handle().clone()); + + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &mut ec_context, + &services, + request, + ) + .await; + + if metadata_missing { + assert_eq!( + response + .expect_err("should fail closed after losing pending metadata") + .current_context() + .status_code(), + StatusCode::BAD_GATEWAY + ); + } else { + assert_eq!( + response + .expect("should return a completed or no-bid response") + .status(), + StatusCode::OK, + "should preserve the no-bid HTTP contract" + ); + } + let snapshot = timings.snapshot(); + let auction_id = snapshot + .auction_id + .expect("should record the attempted auction UUID"); + let batches = sink.batches.lock().expect("should lock telemetry batches"); + assert!( + batches[0] + .rows() + .iter() + .all(|row| row.auction_id == auction_id.to_string()), + "should join the original collector to every emitted auction row" + ); + assert_eq!( + snapshot.filter_ms, + Some(11), + "should retain adapter-recorded phases" + ); + assert_eq!( + http.recorded_backend_names().len(), + usize::from(provider_configured), + "should distinguish a real request launch from the empty plan" + ); + assert_eq!( + snapshot.auction_wait_ms.is_some(), + provider_configured && !metadata_missing + ); + assert_eq!( + snapshot.auction_wait_placement, + (provider_configured && !metadata_missing) + .then_some(crate::request_timing::AuctionWaitPlacement::PreHeader) + ); + if metadata_missing { + assert!( + snapshot.auction_dispatched_ms.is_some(), + "should retain evidence of the request already launched" + ); + assert!( + snapshot.auction_resolved_ms.is_none(), + "should not fabricate collection after dispatch failure" + ); + assert!( + snapshot.auction_committed_ms.is_none(), + "should not commit a failed dispatch" + ); + } else if provider_configured { + let dispatched = snapshot + .auction_dispatched_ms + .expect("should mark a real launch"); + let resolved = snapshot + .auction_resolved_ms + .expect("should mark collection even after transport failure"); + let committed = snapshot + .auction_committed_ms + .expect("should commit the no-bid response"); + assert!( + dispatched <= resolved && resolved <= committed, + "should preserve milestone ordering" + ); + } else { + assert!( + snapshot.auction_dispatched_ms.is_none(), + "should omit zero-launch dispatch" + ); + assert!( + snapshot.auction_resolved_ms.is_none(), + "should omit zero-launch resolution" + ); + assert!( + snapshot.auction_committed_ms.is_none(), + "should omit zero-launch commit" + ); + } + timings.mark_headers_ready(); + timings.mark_request_elapsed(); + assert!( + timings + .handle() + .snapshot( + |inner| inner.headers_ready.is_some() && inner.request_complete.is_some() + ) + .expect("should snapshot original handle"), + "should retain the same collector through response commitment" + ); + } + } + + #[tokio::test] + async fn endpoint_mediator_collection_requires_launch_evidence_and_preserves_not_started() { + for configured_provider in [false, true] { + for (mediator, wait_failure) in [ + (Mediator::Immediate, false), + (Mediator::FailBeforeStart, false), + (Mediator::FailAfterStart, false), + (Mediator::Pending, false), + (Mediator::Pending, true), + ] { + // Explicit routing without bidders produces the existing routing-only + // Dispatched path. An empty provider set must remain NotStarted. + let provider = if configured_provider { + "[auction.providers.example]\nprotocol = \"openrtb-2.6\"\nprofile = \"standard\"\nendpoint = \"https://bidder.example.com/auction\"\nrouting = \"explicit\"\n" + } else { + "" + }; + let settings = Settings::from_toml(&format!( + "{}\n[auction]\nenabled = true\nmediator = \"adserver_mock\"\n{provider}", + crate_test_settings_str() + )) + .expect("should parse mediator endpoint settings"); + let orchestrator = AuctionOrchestrator::from_plan( + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile mediator plan"), + ), + Some(Arc::new(mediator)), + ); + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + if wait_failure { + http.push_wait_error(); + } + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = + services_with_http_and_telemetry(http.clone(), Arc::new(StubBackend), sink); + let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let mut request = Request::builder() + .method("POST") + .uri("https://publisher.example.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({"adUnits": [{ + "code": "slot", "mediaTypes": {"banner": {"sizes": [[300, 250]]}} + }]})) + .expect("should serialize mediator endpoint request"), + )) + .expect("should construct mediator endpoint request"); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &mut ec_context, + &services, + request, + ) + .await + .expect("should return no-bid response"); + assert_eq!(response.status(), StatusCode::OK); + let launched = configured_provider + && matches!(mediator, Mediator::Pending | Mediator::FailAfterStart); + assert_eq!( + http.recorded_backend_names().len(), + usize::from(launched), + "{configured_provider} {mediator:?}" + ); + let snapshot = timings.snapshot(); + assert!( + snapshot.auction_dispatched_ms.is_none(), + "mediator collection must not fabricate provider dispatch" + ); + assert_eq!( + snapshot.auction_wait_ms.is_some(), + launched, + "{configured_provider} {mediator:?}" + ); + assert_eq!( + snapshot.auction_wait_placement, + launched.then_some(AuctionWaitPlacement::PreHeader) + ); + assert_eq!(snapshot.auction_resolved_ms.is_some(), launched); + assert_eq!(snapshot.auction_committed_ms.is_some(), launched); + } + } + } + #[tokio::test] async fn trace_slot_conversion_api_keeps_refs_out_of_actual_bidder_and_mediator_payloads() { const SLOT_REF: &str = "ts-slot-00000000-0000-4000-8000-000000000001"; @@ -1539,13 +1830,15 @@ mod tests { "mediaTypes": { "banner": { "sizes": [[300, 250]] } } }] }); - let request = Request::builder() + let mut request = Request::builder() .method("POST") .uri("https://test-publisher.example/auction") .body(EdgeBody::from( serde_json::to_vec(&body).expect("should serialize launch-failure body"), )) .expect("should build launch-failure request"); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); let error = handle_auction( &settings, @@ -1569,10 +1862,25 @@ mod tests { .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, "should emit one execution-failure summary"); - assert_eq!(rows[0].event_kind, "summary"); - assert_eq!(rows[0].terminal_status.as_deref(), Some("execution_failed")); - assert_eq!(rows[0].terminal_reason.as_deref(), Some("execution_failed")); + assert_eq!( + rows.len(), + 2, + "should emit provider failure and summary rows" + ); + let summary = rows + .iter() + .find(|row| row.event_kind == "summary") + .expect("should emit an execution-failure summary"); + assert_eq!(summary.terminal_status.as_deref(), Some("execution_failed")); + assert_eq!(summary.terminal_reason.as_deref(), Some("execution_failed")); + let snapshot = timings.snapshot(); + assert_eq!( + Some(summary.auction_id.clone()), + snapshot.auction_id.map(|id| id.to_string()) + ); + assert!(snapshot.auction_dispatched_ms.is_none()); + assert!(snapshot.auction_resolved_ms.is_none()); + assert!(snapshot.auction_committed_ms.is_none()); } #[tokio::test] @@ -1604,13 +1912,15 @@ mod tests { } ] }); - let req = Request::builder() + let mut req = Request::builder() .method("POST") .uri("https://test-publisher.com/auction") .body(EdgeBody::from( serde_json::to_vec(&body).expect("should serialize body"), )) .expect("should build auction request"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); let response = handle_auction( &settings, @@ -1651,6 +1961,18 @@ mod tests { 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 snapshot = timings.snapshot(); + assert_eq!( + rows[0].auction_id, + snapshot + .auction_id + .expect("should record the consent-denied auction UUID") + .to_string(), + "should retain the skipped-summary join key" + ); + assert!(snapshot.auction_dispatched_ms.is_none()); + assert!(snapshot.auction_resolved_ms.is_none()); + assert!(snapshot.auction_committed_ms.is_none()); let ndjson = batches[0] .to_ndjson(16 * 1024) .expect("should serialize telemetry"); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index f665441723..81a7962db7 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -17,6 +17,7 @@ use super::openrtb::unused_bidder_params_count; use super::plan::AuctionPlan; use super::provider::{ AuctionProvider, GenericOpenRtbProvider, ProviderParseState, ProviderRequestOutcome, + ProviderRequestStarted, }; #[cfg(test)] use super::routing::RoutedAuction; @@ -24,6 +25,7 @@ use super::routing::route_auction; use super::telemetry::AbandonedProviderCall; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; use crate::request_signing::RequestSigner; +use crate::request_timing::{AuctionWaitPlacement, RequestTimings}; use crate::trace::{ TraceAuctionCarry, TraceAuctionTerminalReason, TraceAuctionTerminalStatus, TraceProviderObservation, TraceProviderRole, @@ -38,6 +40,8 @@ use crate::trace::{ /// the original auction budget. pub struct DispatchedAuction { pending_requests: Vec, + /// Whether dispatch started at least one provider request. + provider_launched: bool, backend_to_provider: HashMap, planned_backend_to_provider: HashMap, completed_responses: Vec, @@ -69,8 +73,10 @@ struct ProviderLaunchState { 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. + /// Dispatch failed before collection, possibly after starting a request. DispatchFailed { + /// Whether any provider started a request before dispatch failed. + provider_launched: bool, /// Original auction request. request: AuctionRequest, /// Provider launch-failure responses. @@ -89,7 +95,24 @@ pub enum DispatchAuctionOutcome { Dispatched(DispatchedAuction), } +/// Synchronous execution failure, including outcomes needed by route telemetry. +pub(crate) struct AuctionExecutionFailure { + /// Admission or dispatch error to propagate through the route's error boundary. + pub error: Report, + /// Provider outcomes materialized before failure, for terminal telemetry. + pub provider_responses: Vec, + /// Elapsed dispatch time, or execution time when dispatch returned `NotStarted`. + /// Excludes the caller's request parsing and targeting preparation. + pub elapsed_ms: u64, +} + impl DispatchedAuction { + /// Whether at least one provider request was started. + #[must_use] + pub fn has_provider_launch(&self) -> bool { + self.provider_launched + } + /// Consume the dispatch token without collecting provider responses. #[must_use] pub fn abandon( @@ -138,9 +161,20 @@ impl DispatchedAuction { #[cfg(test)] impl DispatchedAuction { + /// Creates a token modeling a started provider request that resolved to no bid. + pub(crate) fn launched_no_bid_for_test(request: AuctionRequest, timeout_ms: u32) -> Self { + let mut dispatched = Self::empty_for_test(request, timeout_ms); + dispatched.provider_launched = true; + dispatched + .completed_responses + .push(AuctionResponse::no_bid("example", 0)); + dispatched + } + pub(crate) fn empty_for_test(request: AuctionRequest, timeout_ms: u32) -> Self { Self { pending_requests: Vec::new(), + provider_launched: false, backend_to_provider: HashMap::new(), planned_backend_to_provider: HashMap::new(), completed_responses: Vec::new(), @@ -909,41 +943,119 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + self.execute_auction(request, context, None) + .await + .map(|(result, _)| result) + .map_err(|failure| failure.error) + } + + /// Execute a synchronous route auction with request-clock collection timing. + /// + /// Returns the result and whether any provider or mediator request started. + /// The bool stays false for Immediate, routing-only, and empty-plan results. + /// Only provider launches during dispatch stamp the dispatch offset; actual + /// upstream work during collection records wait and resolution timing. + /// + /// # Errors + /// Returns admission or dispatch failures with their provider outcomes and + /// elapsed time for route telemetry. Failed dispatch never records collection. + pub(crate) async fn run_auction_with_timings( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + timings: &RequestTimings, + ) -> Result<(OrchestrationResult, bool), AuctionExecutionFailure> { + self.execute_auction(request, context, Some(timings)).await + } + + async fn execute_auction( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + timings: Option<&RequestTimings>, + ) -> Result<(OrchestrationResult, bool), AuctionExecutionFailure> { let trace = context.request.extensions().get::(); let _trace_guard = trace.map(TraceAuctionCarry::cancellation_guard); - match self.dispatch_planned_auction(request, context).await { - DispatchAuctionOutcome::Dispatched(dispatched) => Ok(self - .collect_dispatched_auction(dispatched, context.services, context) - .await), + let started = Instant::now(); + #[cfg(test)] + let outcome = if self.plan_backed { + self.dispatch_planned_auction(request, context).await + } else { + self.dispatch_auction(request, context).await + }; + #[cfg(not(test))] + let outcome = self.dispatch_planned_auction(request, context).await; + match outcome { + DispatchAuctionOutcome::Dispatched(dispatched) => { + if dispatched.has_provider_launch() + && let Some(timings) = timings + { + timings.mark_auction_dispatched(); + } + Ok(self + .collect_with_timing( + dispatched, + context.services, + context, + timings.map(|timings| (timings, AuctionWaitPlacement::PreHeader)), + ) + .await) + } DispatchAuctionOutcome::DispatchFailed { + provider_launched, + provider_responses, fatal_admission_error, + elapsed_ms, .. } => { if let Some(trace) = trace { trace.finish_dispatch_failed(false); } - if let Some(error) = fatal_admission_error { - return Err(error.change_context(TrustedServerError::Auction { - message: "Planned auction admission failed".to_string(), - })); + if provider_launched && let Some(timings) = timings { + timings.mark_auction_dispatched(); } - Err(Report::new(TrustedServerError::Auction { - message: "All eligible planned provider requests failed to launch".to_string(), - })) + Err(AuctionExecutionFailure { + error: fatal_admission_error + .map(|error| { + error.change_context(TrustedServerError::Auction { + message: "Planned auction admission failed".to_string(), + }) + }) + .unwrap_or_else(|| { + Report::new(TrustedServerError::Auction { + message: "All eligible planned provider requests failed to launch" + .to_string(), + }) + }), + provider_responses, + elapsed_ms, + }) } DispatchAuctionOutcome::NotStarted => { - if self.planned_providers.is_empty() { + #[cfg(test)] + let has_providers = if self.plan_backed { + !self.planned_providers.is_empty() + } else { + !self.config.providers.is_empty() + }; + #[cfg(not(test))] + let has_providers = !self.planned_providers.is_empty(); + if has_providers { if let Some(trace) = trace { - trace.finish(TraceAuctionTerminalStatus::Completed, None); + trace.finish_dispatch_failed(true); } - Ok(OrchestrationResult::no_bid()) + Err(AuctionExecutionFailure { + error: Report::new(TrustedServerError::Auction { + message: "No planned provider request was started".to_string(), + }), + provider_responses: Vec::new(), + elapsed_ms: started.elapsed().as_millis() as u64, + }) } else { if let Some(trace) = trace { - trace.finish_dispatch_failed(true); + trace.finish(TraceAuctionTerminalStatus::Completed, None); } - Err(Report::new(TrustedServerError::Auction { - message: "No planned provider request was started".to_string(), - })) + Ok((OrchestrationResult::no_bid(), false)) } } } @@ -1775,6 +1887,7 @@ impl AuctionOrchestrator { .unwrap_or(usize::MAX) }); return DispatchAuctionOutcome::DispatchFailed { + provider_launched: false, request: request.clone(), provider_responses, fatal_admission_error: Some(error), @@ -1798,6 +1911,7 @@ impl AuctionOrchestrator { let mut planned_backend_to_provider = HashMap::new(); let mut reserved_backend_names = HashSet::new(); let mut immediate_response_count = 0usize; + let mut provider_launch_count = 0usize; let mut launch_failure_count = 0usize; for input in routed.inputs() { @@ -1856,6 +1970,7 @@ impl AuctionOrchestrator { request: pending, parse_state, }) => { + provider_launch_count += 1; let Some(backend_name) = pending.backend_name().map(str::to_string) else { if let (Some(trace), Some(observation)) = (&trace, trace_observation) { trace.observe_failure(observation); @@ -1893,6 +2008,8 @@ impl AuctionOrchestrator { if let (Some(trace), Some(observation)) = (&trace, trace_observation) { trace.observe_response(observation, &response); } + // Planned providers use Immediate for routing-only skips such + // as NoImpressions; no request left the edge. immediate_response_count += 1; completed_responses.push(response); } @@ -1900,6 +2017,8 @@ impl AuctionOrchestrator { if let (Some(trace), Some(observation)) = (&trace, trace_observation) { trace.observe_failure(observation); } + provider_launch_count += + usize::from(error.contains::()); log::warn!( "Planned provider '{}' failed to dispatch: {error:?}", provider.provider_name() @@ -1931,6 +2050,7 @@ impl AuctionOrchestrator { .unwrap_or(usize::MAX) }); return DispatchAuctionOutcome::DispatchFailed { + provider_launched: provider_launch_count > 0, request: request.clone(), provider_responses: completed_responses, fatal_admission_error: None, @@ -1944,6 +2064,7 @@ impl AuctionOrchestrator { } DispatchAuctionOutcome::Dispatched(DispatchedAuction { + provider_launched: provider_launch_count > 0, pending_requests, backend_to_provider: HashMap::new(), planned_backend_to_provider, @@ -1970,10 +2091,12 @@ impl AuctionOrchestrator { /// [`DispatchedAuction`] token. The Fastly host begins the SSP round-trips /// while WASM continues to `pending_origin.wait()`. /// - /// 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. + /// Returns [`DispatchAuctionOutcome::NotStarted`] when the auction is disabled + /// or has no providers to execute. Routing-only skips can instead yield a + /// [`DispatchAuctionOutcome::Dispatched`] token with immediate responses but + /// no provider launch. Admission or launch failures can yield + /// [`DispatchAuctionOutcome::DispatchFailed`]; its `provider_launched` flag + /// preserves evidence of requests that started before a later failure. #[must_use] pub async fn dispatch_auction( &self, @@ -2054,8 +2177,12 @@ impl AuctionOrchestrator { let completed_responses: Vec = Vec::new(); #[cfg(test)] let mut immediate_response_count = 0usize; + #[cfg(test)] + let mut provider_launch_count = 0usize; #[cfg(not(test))] let immediate_response_count = 0usize; + #[cfg(not(test))] + let provider_launch_count = 0usize; #[cfg(test)] for provider_name in &provider_names { @@ -2126,6 +2253,10 @@ impl AuctionOrchestrator { request: pending, parse_state, }) => { + #[cfg(test)] + { + provider_launch_count += 1; + } let backend_name = pending.backend_name().map(str::to_string).or_else(|| { if let Some(backend_name) = predicted_backend_name.as_ref() { log::warn!( @@ -2183,6 +2314,7 @@ impl AuctionOrchestrator { completed_responses.push(response); } Err(e) => { + provider_launch_count += usize::from(e.contains::()); let response_time_ms = start_time.elapsed().as_millis() as u64; log::warn!( "Provider '{}' failed to dispatch request: {:?}", @@ -2202,6 +2334,7 @@ impl AuctionOrchestrator { DispatchAuctionOutcome::NotStarted } else { DispatchAuctionOutcome::DispatchFailed { + provider_launched: provider_launch_count > 0, request: request.clone(), provider_responses: completed_responses, fatal_admission_error: None, @@ -2220,6 +2353,7 @@ impl AuctionOrchestrator { ); DispatchAuctionOutcome::Dispatched(DispatchedAuction { + provider_launched: provider_launch_count > 0, pending_requests, backend_to_provider, planned_backend_to_provider: HashMap::new(), @@ -2255,9 +2389,64 @@ impl AuctionOrchestrator { dispatched: DispatchedAuction, services: &RuntimeServices, context: &AuctionContext<'_>, + ) -> OrchestrationResult { + self.collect_with_timing(dispatched, services, context, None) + .await + .0 + } + + /// Collect with measured wait placement and explicit provider/mediator launch evidence. + /// + /// Returns the result and whether a provider or mediator request started, + /// including post-start failures. The bool stays false for Immediate and + /// routing-only outcomes. Wait and resolution are recorded only when true; + /// mediator-only work does not fabricate a provider dispatch offset. + pub(crate) async fn collect_dispatched_auction_with_timings( + &self, + dispatched: DispatchedAuction, + services: &RuntimeServices, + context: &AuctionContext<'_>, + timings: &RequestTimings, + placement: AuctionWaitPlacement, + ) -> (OrchestrationResult, bool) { + self.collect_with_timing(dispatched, services, context, Some((timings, placement))) + .await + } + + async fn collect_with_timing( + &self, + dispatched: DispatchedAuction, + services: &RuntimeServices, + context: &AuctionContext<'_>, + timing: Option<(&RequestTimings, AuctionWaitPlacement)>, + ) -> (OrchestrationResult, bool) { + let mut auction_work_started = dispatched.has_provider_launch(); + let wait_started = Instant::now(); + let result = self + .collect_dispatched_auction_inner( + dispatched, + services, + context, + &mut auction_work_started, + ) + .await; + if auction_work_started && let Some((timings, placement)) = timing { + timings.record_auction_wait(placement, wait_started.elapsed()); + timings.mark_auction_resolved(); + } + (result, auction_work_started) + } + + async fn collect_dispatched_auction_inner( + &self, + dispatched: DispatchedAuction, + services: &RuntimeServices, + context: &AuctionContext<'_>, + auction_work_started: &mut bool, ) -> OrchestrationResult { let DispatchedAuction { pending_requests, + provider_launched: _, mut backend_to_provider, mut planned_backend_to_provider, completed_responses, @@ -2596,7 +2785,9 @@ impl AuctionOrchestrator { .request_bids(&request, &mediator_context) .await { - Ok(_) if Instant::now() >= mediator_deadline => { + Ok(outcome) if Instant::now() >= mediator_deadline => { + *auction_work_started |= + matches!(outcome, ProviderRequestOutcome::Pending { .. }); log::debug!( "Mediator '{}' exhausted its response budget during launch; using local ranking", mediator.provider_name() @@ -2613,66 +2804,70 @@ impl AuctionOrchestrator { Ok(ProviderRequestOutcome::Pending { request: pending, parse_state, - }) => match services - .http_client() - .wait_until(pending, mediator_deadline) - .await - { - Ok(platform_resp) => { - let response_time_ms = mediator_start.elapsed().as_millis() as u64; - if Instant::now() >= mediator_deadline { - log::debug!( - "Mediator '{}' completed after its response deadline; using local ranking ({}ms)", - mediator.provider_name(), - response_time_ms - ); - None - } else { - match mediator - .parse_response_with_context_and_state( - platform_resp, - response_time_ms, - &request, - &mediator_context, - parse_state.as_deref(), - ) - .await - { - Ok(response) => { - if let (Some(trace), Some(observation)) = - (&trace, trace_observation.take()) - { - trace.observe_response(observation, &response); + }) => { + *auction_work_started = true; + match services + .http_client() + .wait_until(pending, mediator_deadline) + .await + { + Ok(platform_resp) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + if Instant::now() >= mediator_deadline { + log::debug!( + "Mediator '{}' completed after its response deadline; using local ranking ({}ms)", + mediator.provider_name(), + response_time_ms + ); + None + } else { + match mediator + .parse_response_with_context_and_state( + platform_resp, + response_time_ms, + &request, + &mediator_context, + parse_state.as_deref(), + ) + .await + { + Ok(response) => { + if let (Some(trace), Some(observation)) = + (&trace, trace_observation.take()) + { + trace.observe_response(observation, &response); + } + Some(response) + } + Err(error) => { + log::warn!( + "Mediator '{}' parse failed: {:?}", + mediator.provider_name(), + error + ); + None } - Some(response) - } - Err(error) => { - log::warn!( - "Mediator '{}' parse failed: {:?}", - mediator.provider_name(), - error - ); - None } } } - } - Err(error) => { - if matches!( - error.current_context(), - crate::platform::PlatformError::DeadlineExceeded - ) { - log::debug!( - "Mediator '{}' exhausted its response budget; using local ranking", - mediator.provider_name() - ); - } else { - log::warn!("Mediator request failed: {:?}", error); + Err(error) => { + if matches!( + error.current_context(), + crate::platform::PlatformError::DeadlineExceeded + ) { + log::debug!( + "Mediator '{}' exhausted its response budget; using local ranking", + mediator.provider_name() + ); + } else { + log::warn!("Mediator request failed: {:?}", error); + } + None } - None } - }, + } Err(error) => { + *auction_work_started |= error.contains::(); log::warn!( "Mediator '{}' failed to dispatch: {:?}", mediator.provider_name(), @@ -2959,6 +3154,7 @@ mod tests { mod trace_auction_live_tests { use super::*; + use crate::request_timing::RequestTimings; use crate::trace::{TraceAuctionCarry, TraceAuctionSource}; use edgezero_core::body::Body as EdgeBody; use http::Request; @@ -2996,7 +3192,7 @@ mod tests { let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); let settings = create_test_settings(); let request = planned_request(); - for split in [false, true] { + for (split, timed) in [(false, false), (false, true), (true, false)] { let (inbound, carry) = captured_request(&request); let token = carry.token(); let context = AuctionContext { @@ -3016,10 +3212,16 @@ mod tests { "should preserve the ordinary split outcome" ); } else { - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should preserve successful empty-plan execution"); + let result = if timed { + orchestrator + .run_auction_with_timings(&request, &context, &RequestTimings::new()) + .await + .map(|(result, _)| result) + .map_err(|failure| failure.error) + } else { + orchestrator.run_auction(&request, &context).await + } + .expect("should preserve successful empty-plan execution"); assert!( result.winning_bids.is_empty(), "should preserve an ordinary zero-bid result" @@ -3617,6 +3819,10 @@ mod tests { else { panic!("all-skipped auction should produce a completed dispatch token"); }; + assert!( + !dispatched.has_provider_launch(), + "routing-only skipped outcomes must not claim a provider launch" + ); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -5199,6 +5405,10 @@ mod tests { else { panic!("enabled immediate provider should dispatch"); }; + assert!( + !dispatched.has_provider_launch(), + "should not count an immediate no-bid response as a provider request" + ); let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; @@ -6247,6 +6457,108 @@ mod tests { ); } + #[tokio::test] + async fn planned_no_impressions_immediate_outcome_is_not_a_provider_launch() { + let http = Arc::new(StubHttpClient::new()); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let mut config = planned_prebid_config(&[( + "pbs-a", + serde_json::json!({}), + NotificationConfig::default(), + )]); + config.bidders.insert( + "alpha".parse().expect("should parse bidder ID"), + crate::auction::plan::BidderRouteConfig { + provider: "pbs-a".parse().expect("should parse provider ID"), + }, + ); + let orchestrator = AuctionOrchestrator::from_plan( + Arc::new(AuctionPlan::compile(config).expect("should compile PBS plan")), + None, + ); + let mut request = planned_prebid_request(); + request.slots[0].bidders.insert( + "trustedServer".to_string(), + serde_json::json!({"storedRequest":false,"bidderParams":{"alpha":{}}}), + ); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("should retain the routing-only provider response for collection"); + }; + assert_eq!( + http.recorded_backend_names().len(), + 0, + "should not start an outbound request for NoImpressions" + ); + assert!( + !dispatched.has_provider_launch(), + "should not establish launch evidence for a routing-only Immediate response" + ); + assert_eq!( + dispatched.completed_responses[0].metadata["routing"]["skipped_no_usable_demand"], true, + "should retain the routing skip response for collection" + ); + } + + #[tokio::test] + async fn planned_started_request_keeps_launch_evidence_after_backend_bookkeeping_failure() { + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + http.push_pending_backend_name_override(None); + let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); + let services = build_services_with_backend_and_http_client( + Arc::clone(&backend) as Arc<_>, + Arc::clone(&http) as Arc<_>, + ); + let plan = Arc::new( + AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile planned auction"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let request = planned_request(); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + + let DispatchAuctionOutcome::DispatchFailed { + provider_launched, .. + } = orchestrator.dispatch_auction(&request, &context).await + else { + panic!("missing backend bookkeeping should fail dispatch"); + }; + assert_eq!(http.recorded_backend_names().len(), 1); + assert!( + provider_launched, + "should retain launch evidence for a started request when bookkeeping fails" + ); + } + #[tokio::test] async fn planned_dispatch_does_not_snapshot_inbound_headers() { let http = Arc::new(StubHttpClient::new()); diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index d68ebde4d8..b8782ec944 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -29,6 +29,89 @@ use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; const MAX_PLANNED_RESPONSE_BYTES: usize = 1024 * 1024; +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + + /// Exercises launch evidence through real route dispatch and collection. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(crate) enum LaunchEvidenceProvider { + Immediate, + FailBeforeStart, + FailAfterStart, + Pending, + } + + #[async_trait(?Send)] + impl AuctionProvider for LaunchEvidenceProvider { + fn provider_name(&self) -> &str { + "launch-evidence" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + if *self == Self::Immediate { + return Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( + self.provider_name(), + 0, + ))); + } + let failure = || { + Report::new(TrustedServerError::Auction { + message: "Launch evidence test failure".to_string(), + }) + }; + if *self == Self::FailBeforeStart { + return Err(failure()); + } + let outbound = Request::builder() + .uri("https://mediator.example.com/auction") + .body(EdgeBody::empty()) + .expect("should construct mediator request"); + let pending = context + .services + .http_client() + .send_async(PlatformHttpRequest::new( + outbound, + "launch-evidence-backend", + )) + .await + .change_context(TrustedServerError::Auction { + message: "Launch evidence request failed".to_string(), + })?; + if *self == Self::FailAfterStart { + return Err(failure().attach_opaque(ProviderRequestStarted)); + } + Ok(ProviderRequestOutcome::pending(pending)) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::no_bid( + self.provider_name(), + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 1_000 + } + } +} + +/// Evidence attached to provider or mediator errors after a request started. +/// +/// `Immediate` never proves launch. Implementations that fail after a successful +/// `send_async` must attach this marker so collection retains that evidence. +#[derive(Debug)] +pub(crate) struct ProviderRequestStarted; + fn attach_provider_routing_metadata( response: &mut AuctionResponse, profile: &CompiledOpenRtbProfile, @@ -397,7 +480,8 @@ impl GenericOpenRtbProvider { "Provider {} pending request backend did not match registered backend", self.provider_name() ), - })); + }) + .attach_opaque(ProviderRequestStarted)); } let parse_state = match &self.plan.profile { CompiledOpenRtbProfile::Standard(_) => GenericOpenRtbParseState::Standard { diff --git a/crates/trusted-server-core/src/cache_policy.rs b/crates/trusted-server-core/src/cache_policy.rs index 4d920f54a6..cc1427beb3 100644 --- a/crates/trusted-server-core/src/cache_policy.rs +++ b/crates/trusted-server-core/src/cache_policy.rs @@ -289,15 +289,18 @@ pub fn is_edge_cache_header_name(name: &str) -> bool { /// as `not-private` or `no-storey` do not match `private` / `no-store`. #[must_use] pub fn cache_control_value_has_directive(value: &str, directive: &str) -> bool { - let part_has_directive = |part: &str| { + cache_control_value_matches(value, |part| { let part = part.trim(); let directive_name = part .find(['=', ';']) .map_or(part, |end| &part[..end]) .trim(); directive_name.eq_ignore_ascii_case(directive) - }; + }) +} +/// Tests comma-delimited directives without splitting quoted values. +fn cache_control_value_matches(value: &str, part_matches: impl Fn(&str) -> bool) -> bool { let mut quoted = false; let mut escaped = false; let mut part_start = 0; @@ -313,14 +316,14 @@ pub fn cache_control_value_has_directive(value: &str, directive: &str) -> bool { } else if character == '"' { quoted = true; } else if character == ',' { - if part_has_directive(&value[part_start..index]) { + if part_matches(&value[part_start..index]) { return true; } part_start = index + character.len_utf8(); } } - part_has_directive(&value[part_start..]) + part_matches(&value[part_start..]) } /// Return true when any `Cache-Control` header value contains `directive`. @@ -333,13 +336,33 @@ pub fn cache_control_headers_have_directive(headers: &HeaderMap, directive: &str .any(|value| cache_control_value_has_directive(value, directive)) } -/// Return true when response cache-control contains exact `private` or `no-store`. +/// Return true when response cache-control contains `private` or `no-store`, +/// including directives with arguments. #[must_use] pub fn cache_control_headers_are_private_or_no_store(headers: &HeaderMap) -> bool { cache_control_headers_have_directive(headers, "private") || cache_control_headers_have_directive(headers, "no-store") } +/// Return true when response cache-control contains a bare `private` or `no-store`. +/// +/// Field-qualified `private` restricts only named headers, not the whole response, +/// so it is insufficient permission to append request-specific timing headers. +/// Directive-shaped text inside quoted values is not a bare directive. +#[must_use] +pub(crate) fn cache_control_headers_have_bare_private_or_no_store(headers: &HeaderMap) -> bool { + headers + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| { + cache_control_value_matches(value, |part| { + let part = part.trim(); + part.eq_ignore_ascii_case("private") || part.eq_ignore_ascii_case("no-store") + }) + }) +} + /// Apply `Cache-Control: no-store, private` and strip all shared-cache headers. /// /// # Panics diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 059d500092..027042bddd 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -193,6 +193,10 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { vec![optional_object("tinybird"), object("auction_token_secret")], true, ), + field( + vec![optional_object("tinybird"), object("access_token_secret")], + true, + ), field( vec![ optional_object("integrations"), @@ -468,7 +472,7 @@ fn validate_secret_key_references(settings: &Settings) -> Result<(), Report Result<(), Report("datadome")? { if datadome.enable_protection { let key = datadome @@ -951,6 +964,7 @@ formats = [{ width = 300, height = 250 }] ("trusted_client_ip.shared_secret".to_owned(), false), ("trusted_forwarder.shared_secret".to_owned(), false), ("tinybird.auction_token_secret".to_owned(), true), + ("tinybird.access_token_secret".to_owned(), true), ( "integrations.datadome.server_side_key_secret_name".to_owned(), true, diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 34a4143cf2..1e07dc215a 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -72,16 +72,26 @@ pub fn settings_from_config_blob( } fn remove_inactive_secret_references(data: &mut serde_json::Value) { - if data - .pointer("/tinybird/enabled") - .and_then(serde_json::Value::as_bool) - != Some(true) - && let Some(tinybird) = data - .get_mut("tinybird") - .and_then(serde_json::Value::as_object_mut) + if let Some(tinybird) = data + .get_mut("tinybird") + .and_then(serde_json::Value::as_object_mut) { - tinybird.remove("auction_token_secret"); - tinybird.remove("access_token_secret"); + let master_enabled = + tinybird.get("enabled").and_then(serde_json::Value::as_bool) == Some(true); + let auction_enabled = tinybird + .get("auction_enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + let access_enabled = tinybird + .get("access_enabled") + .and_then(serde_json::Value::as_bool) + == Some(true); + if !master_enabled || !auction_enabled { + tinybird.remove("auction_token_secret"); + } + if !master_enabled || !access_enabled { + tinybird.remove("access_token_secret"); + } } if let Some(partners) = data @@ -677,6 +687,92 @@ mod tests { assert!(error.to_string().contains("ec.partners[0].ts_pull_token")); } + #[test] + fn inactive_tinybird_sinks_strip_stale_secret_references_before_resolution() { + let mut original = test_settings(); + let mut data = serde_json::to_value(&original).expect("should serialize settings"); + data["tinybird"] = serde_json::json!({ + "enabled": false, + "auction_token_secret": "unused-auction-token", + "access_token_secret": "unused-access-token", + }); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); + + let loaded = settings_from_config_blob( + &envelope_json, + &UnifiedSecretStore, + &StoreName::from("ts_secrets"), + ) + .expect("should ignore stale secrets for disabled sinks"); + assert!(!loaded.tinybird.enabled); + assert!(loaded.tinybird.auction_enabled, "auction defaults on"); + assert!(!loaded.tinybird.access_enabled, "access defaults off"); + assert!(loaded.tinybird.auction_token_secret.is_none()); + assert!(loaded.tinybird.access_token_secret.is_none()); + + let mut data = serde_json::to_value(&original).expect("should serialize settings"); + data["tinybird"] = serde_json::json!({ + "enabled": true, + "api_host": "api.example.com", + "auction_enabled": false, + "auction_token_secret": "unused-auction-token", + "access_enabled": true, + "access_token_secret": "tinybird-token-key", + "access_sample_rate": 1.0, + }); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); + let loaded = settings_from_config_blob( + &envelope_json, + &UnifiedSecretStore, + &StoreName::from("ts_secrets"), + ) + .expect("should load active access telemetry without the disabled auction token"); + assert!(!loaded.tinybird.auction_enabled); + assert!(loaded.tinybird.auction_token_secret.is_none()); + assert_eq!( + loaded + .tinybird + .access_token_secret + .as_ref() + .map(Redacted::expose) + .map(String::as_str), + Some("resolved-tinybird-token") + ); + + let mut data = serde_json::to_value(&original).expect("should serialize settings"); + data["tinybird"] = serde_json::json!({ + "enabled": true, + "api_host": "api.example.com", + "auction_token_secret": "tinybird-token-key", + "access_token_secret": "unused-access-token", + }); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); + let loaded = settings_from_config_blob( + &envelope_json, + &UnifiedSecretStore, + &StoreName::from("ts_secrets"), + ) + .expect("should load the default-on auction sink without the disabled access token"); + assert!(loaded.tinybird.auction_enabled, "auction defaults on"); + assert!(!loaded.tinybird.access_enabled, "access defaults off"); + assert!(loaded.tinybird.access_token_secret.is_none()); + assert!(loaded.tinybird.auction_token_secret.is_some()); + + original.tinybird.enabled = true; + original.tinybird.api_host = "api.example.com".to_owned(); + original.tinybird.auction_token_secret = None; + let error = settings_from_config_blob( + &self::envelope_json(&original), + &UnifiedSecretStore, + &StoreName::from("ts_secrets"), + ) + .expect_err("should reject a missing active default-on auction token"); + assert!(error.to_string().contains("tinybird.auction_token_secret")); + } + #[test] fn inactive_optional_features_do_not_resolve_stale_secret_references() { let mut original = test_settings(); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 1fd3c2382c..63472dd868 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -48,6 +48,8 @@ pub const TS_GIT_VERSION: Option<&str> = match option_env!("TRUSTED_SERVER__GIT_ // Fastly environment variables pub const ENV_FASTLY_SERVICE_VERSION: &str = "FASTLY_SERVICE_VERSION"; pub const ENV_FASTLY_IS_STAGING: &str = "FASTLY_IS_STAGING"; +pub const ENV_FASTLY_SERVICE_ID: &str = "FASTLY_SERVICE_ID"; +pub const ENV_FASTLY_POP: &str = "FASTLY_POP"; // Common standard header names used across modules pub const HEADER_USER_AGENT: HeaderName = HeaderName::from_static("user-agent"); diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index b0eb83d3fa..8653f2342a 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -1717,6 +1717,28 @@ mod tests { assert!(ts > 0, "should return a nonzero timestamp"); } + #[test] + fn kv_span_accumulates_across_graph_operations() { + let timings = crate::request_timing::RequestTimings::new(); + let graph = KvIdentityGraph::new(crate::platform::TimedKvStore::new( + crate::ec::kv_backend::test_support::InMemoryEcKv::new("test-store"), + timings.clone(), + )); + + graph + .create("ec-1", &live_entry()) + .expect("should create entry through the timed store"); + graph + .get("ec-1") + .expect("should read the entry back through the timed store"); + + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_some(), + "should accumulate Phase::EcKv across both graph operations, not just the last write" + ); + } + #[test] fn serialize_entry_produces_valid_json() { let entry = KvEntry::tombstone(1000); diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f53..fe5785d260 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -48,6 +48,24 @@ impl GeoInfo { } } +/// Carries the outcome of a request-phase geo lookup across to +/// response-phase finalization, so a finalize consumer can reuse it instead +/// of performing a second lookup for the same request. +/// +/// Attached as a response extension on every exit path that attempted a +/// lookup, including the asset-route fallback (which does not carry an EC +/// finalize state). +#[derive(Debug, Clone)] +pub enum GeoLookupState { + /// No lookup has been attempted for this request. + NotAttempted, + /// A lookup ran and failed (or returned no result). This must not be + /// retried: finalize treats it the same as no geo info being available. + Attempted, + /// A lookup ran and resolved geo info. + Resolved(GeoInfo), +} + fn insert_geo_header(headers: &mut http::HeaderMap, name: http::header::HeaderName, value: &str) { match HeaderValue::from_str(value) { Ok(header_value) => { diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index ef4893a41b..861bfa52b3 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -330,6 +330,7 @@ initialBids, initialSlots, traceAuctionTransport, + initialAuctionDiagnostics, ) { // The bundle may replace this scheduler after the fallback claims the initial // pass. Keep the latch on the shared document API so replacement cannot reset it. @@ -340,6 +341,9 @@ // would overwrite a committed SPA navigation's slots. if (initialSlots !== undefined) ts.adSlots = initialSlots; if (initialBids !== undefined) ts.bids = initialBids; + if (initialAuctionDiagnostics !== undefined) { + ts.auctionDiagnostics = initialAuctionDiagnostics; + } var fire = function () { if ((ts.navGeneration || 0) !== 0) return; try { @@ -400,6 +404,7 @@ opportunity, trace.auctionId, requestedSizes, + undefined, trace.identity, ); } catch (_) { diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index a5f708b7ec..d50dc51a54 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -90,6 +90,7 @@ impl GptDiagnosticsCookieAction { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GptDiagnosticsRequestDecision { active: bool, + browser_session_active: bool, clean_browser_path_and_query: Option, cookie_action: GptDiagnosticsCookieAction, } @@ -101,6 +102,16 @@ impl GptDiagnosticsRequestDecision { self.active } + /// Whether this request came from an activated diagnostics browser session. + /// + /// Unlike [`Self::active`], this remains true for non-document requests such + /// as the SPA page-bids fetch. It is captured before the private activation + /// cookie is stripped from the request. + #[must_use] + pub(crate) fn browser_session_active(&self) -> bool { + self.browser_session_active + } + /// Whether the response must be private and non-storeable. #[must_use] pub fn requires_private_no_store(&self) -> bool { @@ -149,6 +160,7 @@ impl GptDiagnosticsRequestDecision { pub(crate) fn active_for_tests() -> Self { Self { active: true, + browser_session_active: true, clean_browser_path_and_query: None, cookie_action: GptDiagnosticsCookieAction::None, } @@ -171,6 +183,7 @@ mod head_seam_invariant_tests { ] { out.push(GptDiagnosticsRequestDecision { active, + browser_session_active: active, clean_browser_path_and_query: clean.clone(), cookie_action, }); @@ -307,12 +320,19 @@ pub fn prepare_request( replace_path_and_query(request, &clean_path)?; } - let mut decision = GptDiagnosticsRequestDecision::default(); + let mut decision = GptDiagnosticsRequestDecision { + browser_session_active: integration_enabled + && directive == QueryDirective::Absent + && cookie_state.occurrences == 1 + && cookie_state.canonical, + ..GptDiagnosticsRequestDecision::default() + }; if integration_enabled && eligible_navigation && had_reserved_query { decision.clean_browser_path_and_query = Some(clean_path); match directive { QueryDirective::Enable => { decision.active = true; + decision.browser_session_active = true; decision.cookie_action = GptDiagnosticsCookieAction::SetSession; } QueryDirective::Disable => { @@ -566,6 +586,22 @@ mod tests { assert_eq!(duplicate.headers()[header::COOKIE], "other=value"); } + #[test] + fn active_cookie_marks_non_document_requests_without_activating_document_behavior() { + let mut request = Request::builder() + .method(Method::GET) + .uri("https://publisher.example/_ts/page-bids?path=/article") + .header(header::COOKIE, "__Host-ts-console=1; other=value") + .body(EdgeBody::empty()) + .expect("should build page-bids request"); + + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + + assert!(!decision.active()); + assert!(decision.browser_session_active()); + assert_eq!(request.headers()[header::COOKIE], "other=value"); + } + #[test] fn invalid_duplicate_and_disable_directives_fail_closed() { for query in [ diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 15a6486fd8..5295a26542 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -713,7 +713,17 @@ impl IntegrationRegistrationBuilder { } } -type RouteValue = (Arc, &'static str); +/// Proxy handler, integration id, and the registered route pattern (kept so +/// telemetry can label responses with the integration-defined literal, e.g. +/// `/integrations/prebid/*`, instead of deriving anything from the request +/// path). +type RouteValue = (Arc, &'static str, String); + +/// A test-constructor route entry: method, path pattern, and the proxy with +/// its integration id ([`IntegrationRegistry::from_routes`] fills the +/// pattern into [`RouteValue`] itself). +#[cfg(test)] +type RouteEntry<'a> = (Method, &'a str, (Arc, &'static str)); struct IntegrationRegistryInner { // Method-specific routers for O(log n) lookups @@ -859,7 +869,11 @@ impl IntegrationRegistry { for proxy in registration.proxies { for route in proxy.routes() { - let value = (proxy.clone(), registration.integration_id); + let value = ( + proxy.clone(), + registration.integration_id, + route.path.clone(), + ); // Convert /* wildcard to matchit's {*rest} syntax let matchit_path = if route.path.ends_with("/*") { @@ -958,6 +972,27 @@ impl IntegrationRegistry { self.find_route(method, path).is_some() } + /// The registered route pattern matched by `method` and `path`, if any. + /// + /// Patterns are integration-defined literals (for example + /// `/integrations/prebid/*`), so they are bounded and content-free and + /// safe to store as a telemetry dimension, unlike the request path. + #[must_use] + pub fn matched_route_pattern(&self, method: &Method, path: &str) -> Option<&str> { + self.find_route(method, path).map(|value| value.2.as_str()) + } + + /// Return true when at least one integration request filter is + /// registered. + /// + /// Adapters use this to decide whether to record a request-filter phase + /// timing span, so unconfigured deployments (no request filters) omit + /// that entry from observability output entirely. + #[must_use] + pub fn has_request_filters(&self) -> bool { + !self.inner.request_filters.is_empty() + } + /// Run pre-routing request filters. /// /// Request header mutations are applied immediately so later filters and @@ -1033,7 +1068,7 @@ impl IntegrationRegistry { services, mut req, } = input; - if let Some((proxy, _)) = self.find_route(method, path) { + if let Some((proxy, _, _)) = self.find_route(method, path) { // Organic proxy handler: generate if needed (best effort). // Only generate for document navigations — subresource requests // may lack consent signals such as the Sec-GPC header. @@ -1401,7 +1436,7 @@ impl IntegrationRegistry { /// # Panics /// /// Panics if route registration fails due to duplicate or invalid paths. - pub fn from_routes(routes: Vec<(Method, &str, RouteValue)>) -> Self { + pub fn from_routes(routes: Vec>) -> Self { let mut get_router = Router::new(); let mut post_router = Router::new(); let mut put_router = Router::new(); @@ -1410,7 +1445,8 @@ impl IntegrationRegistry { let mut head_router = Router::new(); let mut options_router = Router::new(); - for (method, path, value) in routes { + for (method, path, (proxy, integration_id)) in routes { + let value: RouteValue = (proxy, integration_id, path.to_owned()); // Convert /* wildcard to matchit's {*rest} syntax let matchit_path = if path.ends_with("/*") { format!( diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 5cdc1d1248..d026e5f6f8 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -31,6 +31,7 @@ ) )] +pub mod access_telemetry; pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; @@ -64,6 +65,7 @@ pub mod proxy; pub mod publisher; pub mod redacted; pub mod request_signing; +pub mod request_timing; pub(crate) mod response_header_rewrite; pub mod response_privacy; pub mod rsc_flight; diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index e4733106e3..f41370f748 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -44,6 +44,7 @@ mod template_assembly; mod template_cache; #[cfg(test)] pub(crate) mod test_support; +mod timed_kv; mod timer; mod traits; mod types; @@ -75,6 +76,7 @@ pub use template_cache::{ TemplateCookieValue, TemplateEntry, TemplateMetadata, TemplateMetadataEncodeError, UnavailableTemplateCache, VaryHeaderValues, VarySpec, reader_url_surrogate_key, }; +pub use timed_kv::TimedKvStore; pub use timer::PlatformTimer; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; pub use types::{ diff --git a/crates/trusted-server-core/src/platform/timed_kv.rs b/crates/trusted-server-core/src/platform/timed_kv.rs new file mode 100644 index 0000000000..376879cbf7 --- /dev/null +++ b/crates/trusted-server-core/src/platform/timed_kv.rs @@ -0,0 +1,266 @@ +//! Latency-only timing decorator for Edge Cookie KV store handles. +//! +//! [`TimedKvStore`] wraps an [`EcKvStore`] plus a [`RequestTimings`] handle and +//! records [`Phase::EcKv`] around every store call at +//! [`KvIdentityGraph`](crate::ec::kv::KvIdentityGraph) construction sites. +//! The decorator measures latency only: it never reads, parses, or logs values. + +use error_stack::Report; + +use crate::ec::kv_backend::{EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteOutcome}; +use crate::error::TrustedServerError; +use crate::request_timing::{Phase, RequestTimings}; + +/// Wraps `inner` plus a [`RequestTimings`] handle, recording [`Phase::EcKv`] +/// around every store call made through it. +pub struct TimedKvStore { + /// The wrapped store handle. + inner: S, + /// The request's phase-timing collector. + timings: RequestTimings, +} + +impl TimedKvStore { + /// Creates a decorator around `inner` that records into `timings`. + #[must_use] + pub fn new(inner: S, timings: RequestTimings) -> Self { + Self { inner, timings } + } +} + +impl EcKvStore for TimedKvStore { + fn store_name(&self) -> &str { + self.inner.store_name() + } + + fn lookup(&self, key: &str) -> Result, Report> { + let _span = self.timings.span(Phase::EcKv); + self.inner.lookup(key) + } + + fn key_exists(&self, key: &str) -> Result> { + let _span = self.timings.span(Phase::EcKv); + self.inner.key_exists(key) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + let _span = self.timings.span(Phase::EcKv); + self.inner.insert(key, write) + } + + fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + let _span = self.timings.span(Phase::EcKv); + self.inner.list_keys_with_prefix(prefix, limit) + } + + fn count_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result> { + let _span = self.timings.span(Phase::EcKv); + self.inner.count_keys_with_prefix(prefix, limit) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + let _span = self.timings.span(Phase::EcKv); + self.inner.delete(key) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::ec::kv_backend::test_support::InMemoryEcKv; + + struct DelayingCountingEcKv { + inner: InMemoryEcKv, + calls: std::sync::Arc>>, + } + + impl DelayingCountingEcKv { + fn record(&self, operation: &'static str) { + self.calls + .lock() + .expect("should lock KV operation calls") + .push(operation); + std::thread::sleep(Duration::from_millis(12)); + } + } + + impl EcKvStore for DelayingCountingEcKv { + fn store_name(&self) -> &str { + self.inner.store_name() + } + + fn lookup(&self, key: &str) -> Result, Report> { + self.record("lookup"); + self.inner.lookup(key) + } + + fn key_exists(&self, key: &str) -> Result> { + self.record("key_exists"); + self.inner.key_exists(key) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + self.record("insert"); + self.inner.insert(key, write) + } + + fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + self.record("list_keys_with_prefix"); + self.inner.list_keys_with_prefix(prefix, limit) + } + + fn count_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result> { + self.record("count_keys_with_prefix"); + self.inner.count_keys_with_prefix(prefix, limit) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + self.record("delete"); + self.inner.delete(key) + } + } + + #[test] + fn ec_kv_store_operations_accumulate_into_ec_kv_phase() { + let timings = RequestTimings::new(); + let store = TimedKvStore::new(InMemoryEcKv::new("test-store"), timings.clone()); + + store + .insert( + "key-a", + EcKvWrite { + body: "{}", + metadata: "{}", + ttl: Duration::from_secs(60), + mode: crate::ec::kv_backend::EcKvWriteMode::Add, + }, + ) + .expect("should insert into the in-memory store"); + store.lookup("key-a").expect("should read back the entry"); + assert!(store.key_exists("key-a").expect("should check key")); + assert_eq!( + store + .list_keys_with_prefix("key-", 10) + .expect("should list keys"), + vec!["key-a".to_string()] + ); + assert_eq!( + store + .count_keys_with_prefix("key-", 10) + .expect("should count keys"), + 1 + ); + store.delete("key-a").expect("should delete key"); + assert!(!store.key_exists("key-a").expect("should check deletion")); + + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_some(), + "should accumulate Phase::EcKv across lookup, key_exists, insert, list, count, and delete" + ); + } + + #[test] + fn every_store_operation_contributes_a_separate_timed_span() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let timings = RequestTimings::new(); + let store = TimedKvStore::new( + DelayingCountingEcKv { + inner: InMemoryEcKv::new("test-store"), + calls: std::sync::Arc::clone(&calls), + }, + timings.clone(), + ); + let mut previous_ms = 0; + let mut assert_span_added = |operation: &str| { + let current_ms = timings + .snapshot() + .kv_ms + .expect("each store operation should record an EcKv span"); + assert!( + current_ms > previous_ms, + "{operation} should add its own delayed span: previous={previous_ms}ms current={current_ms}ms" + ); + previous_ms = current_ms; + }; + + store + .insert( + "key-a", + EcKvWrite { + body: "{}", + metadata: "{}", + ttl: Duration::from_secs(60), + mode: crate::ec::kv_backend::EcKvWriteMode::Add, + }, + ) + .expect("should insert into the delaying store"); + assert_span_added("insert"); + store.lookup("key-a").expect("should look up key"); + assert_span_added("lookup"); + store.key_exists("key-a").expect("should check key"); + assert_span_added("key_exists"); + store + .list_keys_with_prefix("key-", 10) + .expect("should list keys"); + assert_span_added("list_keys_with_prefix"); + store + .count_keys_with_prefix("key-", 10) + .expect("should count keys"); + assert_span_added("count_keys_with_prefix"); + store.delete("key-a").expect("should delete key"); + assert_span_added("delete"); + + assert_eq!( + *calls.lock().expect("should lock KV operation calls"), + vec![ + "insert", + "lookup", + "key_exists", + "list_keys_with_prefix", + "count_keys_with_prefix", + "delete", + ], + "the wrapper should forward every operation exactly once" + ); + } + + #[test] + fn store_name_is_not_timed() { + let timings = RequestTimings::new(); + let store = TimedKvStore::new(InMemoryEcKv::new("test-store"), timings.clone()); + + assert_eq!(store.store_name(), "test-store"); + timings.mark_headers_ready(); + assert!( + timings.snapshot().kv_ms.is_none(), + "store_name is a metadata accessor, not a store operation" + ); + } +} diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a670d31416..9c027da854 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -83,6 +83,7 @@ use crate::platform::{ reader_url_surrogate_key, }; use crate::price_bucket::{PriceGranularity, price_bucket}; +use crate::request_timing::{AuctionWaitPlacement, Phase, RequestTimings}; use crate::response_header_rewrite::{OriginHeaderRewrite, rewrite_origin_urls_in_headers}; use crate::response_privacy::{ apply_inactive_ad_stack_browser_cache_policy, cache_control_forbids_shared_storage, @@ -111,21 +112,44 @@ const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); const HEADER_X_TS_TEMPLATE_CACHE: &str = "x-ts-template-cache"; const HEADER_X_TS_ASSEMBLY: &str = "x-ts-assembly"; -#[derive(Clone, Copy, PartialEq, Eq)] -enum TemplateCacheResponseState { +/// Outcome of a template-cache lookup/store attempt for one response. +/// +/// Set on every response that passes through the assembly pipeline via +/// [`set_template_cache_response_state`], which writes both the +/// `x-ts-template-cache` response header and this same value as a typed +/// response extension, so the two can never drift. Access telemetry reads +/// the extension rather than the header, since operator-configured response +/// headers can override a managed header but cannot touch extensions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateCacheResponseState { + /// The cached template was found and reused. Hit, + /// No cached template existed; the cache store is reserved for this + /// content type. MissReserved, + /// No cached template existed; one was stored after assembly. MissStored, + /// No cached template existed; storing the freshly assembled template + /// failed. MissStoreError, + /// The request bypassed the cache lookup. BypassRequest, + /// The response bypassed the cache store. BypassResponse, + /// The response's content type is not supported by the template cache. Unsupported, + /// The cached template entry was invalid and could not be reused. Invalid, + /// A backend error prevented the cache lookup or store. BackendError, } impl TemplateCacheResponseState { - const fn as_str(self) -> &'static str { + /// Renders this variant as the string written to the + /// `x-ts-template-cache` header and the `template_cache_state` access + /// telemetry column. + #[must_use] + pub const fn as_str(self) -> &'static str { match self { Self::Hit => "hit", Self::MissReserved => "miss-reserved", @@ -148,6 +172,7 @@ fn set_template_cache_response_state( HEADER_X_TS_TEMPLATE_CACHE, HeaderValue::from_static(state.as_str()), ); + response.extensions_mut().insert(state); } #[derive(Clone, Copy, PartialEq, Eq)] @@ -1796,6 +1821,12 @@ pub struct OwnedProcessResponseParams { /// rescanned from the output, which cannot tell a `nonce` attribute from the same /// word inside a script. pub(crate) csp_nonce_observed: Option>, + /// Per-request phase-timing handle, carried into the streaming/buffered + /// finalizers so the `` seam wait can be recorded with the right + /// [`AuctionWaitPlacement`]. Cheap to clone (an `Arc` handle); a request that + /// never attached one to its extensions gets a fresh, unattached collector + /// that nothing ever renders. + pub(crate) timings: RequestTimings, } impl OwnedProcessResponseParams { @@ -2068,6 +2099,8 @@ pub async fn buffer_publisher_response_async( ¶ms.request_scheme, ¶ms.request_host, ), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::PreHeader, }, ) .await; @@ -2234,6 +2267,7 @@ fn build_template_assembly_params( request_scheme: &str, price_granularity: PriceGranularity, ad_bids_state: AdBidsState, + timings: RequestTimings, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { response_status: StatusCode::OK, @@ -2259,6 +2293,7 @@ fn build_template_assembly_params( gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings, } } @@ -2611,6 +2646,8 @@ pub async fn publisher_response_into_streaming_response( ¶ms.request_scheme, ¶ms.request_host, ), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::InStream, }, ) .await; @@ -2710,6 +2747,8 @@ pub async fn publisher_response_into_streaming_response( ¶ms.request_scheme, ¶ms.request_host, ), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::InStream, }; while let Some(step) = driver .next_step(&deps) @@ -2786,6 +2825,7 @@ pub async fn publisher_response_into_streaming_response( &orchestrator, &services, &settings, + AuctionWaitPlacement::InStream, ) .await; // Collection reached a terminal result; disarm only now @@ -2811,6 +2851,8 @@ pub async fn publisher_response_into_streaming_response( ¶ms.request_scheme, ¶ms.request_host, ), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::InStream, }; while let Some(step) = hold_step_next_chunk( @@ -3186,6 +3228,8 @@ async fn stream_html_eof( services, settings, request_origin: request_origin(¶ms.request_scheme, ¶ms.request_host), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::PreHeader, }; while let Some(step) = driver.next_step(&deps).await? { for segment in step.ready { @@ -3321,6 +3365,7 @@ pub async fn stream_publisher_body_async( orchestrator, services, settings, + AuctionWaitPlacement::PreHeader, ) .await; if body.is_stream() { @@ -3396,6 +3441,8 @@ pub async fn stream_publisher_body_async( services, settings, request_origin: request_origin(¶ms.request_scheme, ¶ms.request_host), + timings: params.timings.clone(), + placement: AuctionWaitPlacement::PreHeader, }, }, ) @@ -3585,6 +3632,47 @@ fn request_origin(scheme: &str, host: &str) -> String { } } +/// Request-clock auction facts exposed only to an active browser diagnostics session. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserAuctionDiagnostics { + #[serde(skip_serializing_if = "Option::is_none")] + auction_dispatched_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_resolved_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_committed_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_wait_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_wait_placement: Option<&'static str>, +} + +const fn auction_wait_placement_wire(placement: AuctionWaitPlacement) -> &'static str { + match placement { + AuctionWaitPlacement::PreHeader => "pre_header", + AuctionWaitPlacement::InStream => "in_stream", + } +} + +impl BrowserAuctionDiagnostics { + fn from_request_timings(timings: &RequestTimings) -> Option { + let snapshot = timings.snapshot(); + if snapshot.auction_dispatched_ms.is_none() && snapshot.auction_wait_ms.is_none() { + return None; + } + Some(Self { + auction_dispatched_ms: snapshot.auction_dispatched_ms, + auction_resolved_ms: snapshot.auction_resolved_ms, + auction_committed_ms: snapshot.auction_committed_ms, + auction_wait_ms: snapshot.auction_wait_ms, + auction_wait_placement: snapshot + .auction_wait_placement + .map(auction_wait_placement_wire), + }) + } +} + /// This request's auction result, in both forms the seams need. /// /// The inline `` seam injects a rendered `` sequences inside the string. pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { - build_bids_script_with_trace(bid_map, None) + build_bids_script_with_context(bid_map, None, None) } -fn build_bids_script_with_trace( +fn build_bids_script_with_context( bid_map: &serde_json::Map, trace: Option<&TraceAuctionCarry>, + auction_diagnostics: Option<&BrowserAuctionDiagnostics>, ) -> String { - let json = serde_json::to_string(bid_map) - .expect("serde_json::to_string of Map should be infallible"); + let json = serde_json::to_string(bid_map).expect("should serialize the bids map"); let escaped = html_escape_for_script(&json); let transport = trace_transport_script(trace); - let (transport_script, scheduler_arguments) = - transport.as_ref().map_or((String::new(), "b"), |json| { - ( - format!("var x=JSON.parse(\"{}\");", html_escape_for_script(json)), - "b,undefined,x", - ) - }); + let transport_script = transport.as_ref().map_or_else(String::new, |json| { + format!("var x=JSON.parse(\"{}\");", html_escape_for_script(json)) + }); // adInit() defines GPT slots on the publisher's `-container` wrappers, which // mutates those ad-slot subtrees. Calling it synchronously here (this script // runs at body-parse time) lands those mutations inside React's hydration @@ -6630,16 +6836,34 @@ fn build_bids_script_with_trace( // payload. Only when no scheduler exists at all (GPT integration active // without its head bootstrap — not an expected deployment) does the script // fall back to a plain assignment, where no SPA hook exists to race with. + let diagnostics_script = auction_diagnostics.map_or_else(String::new, |diagnostics| { + let json = + serde_json::to_string(diagnostics).expect("should serialize BrowserAuctionDiagnostics"); + format!("var d=JSON.parse(\"{}\");", html_escape_for_script(&json)) + }); + // Keep trace in main's third scheduler argument. Timing diagnostics travel + // independently in the fourth argument behind the same generation guard. + let scheduler_arguments = match (transport.is_some(), auction_diagnostics.is_some()) { + (false, false) => "b", + (true, false) => "b,undefined,x", + (false, true) => "b,void 0,void 0,d", + (true, true) => "b,void 0,x,d", + }; + let fallback = if auction_diagnostics.is_some() { + "else{t.bids=b;t.auctionDiagnostics=d;}" + } else { + "else t.bids=b;" + }; format!( "", - escaped, transport_script, scheduler_arguments + escaped, transport_script, diagnostics_script, scheduler_arguments, fallback ) } @@ -6662,11 +6886,12 @@ else t.bids=b;\ /// /// Slots are applied before bids inside the scheduler, because the scheduler may fire /// `adInit` and `adInit` reads `ts.adSlots`. +#[cfg(test)] pub(crate) fn build_seam_script( slots_json: &str, bid_map: &serde_json::Map, ) -> String { - build_seam_script_with_trace(slots_json, bid_map, None) + build_seam_script_with_context(slots_json, bid_map, None, None) } fn trace_transport_script(trace: Option<&TraceAuctionCarry>) -> Option { @@ -6707,38 +6932,52 @@ fn trace_serialization_unavailable() -> serde_json::Value { serde_json::json!({"schema_version":1,"unavailable_reason":"evidence_projection_failed"}) } -fn build_seam_script_with_trace( +fn build_seam_script_with_context( slots_json: &str, bid_map: &serde_json::Map, trace: Option<&TraceAuctionCarry>, + auction_diagnostics: Option<&BrowserAuctionDiagnostics>, ) -> String { // The local test script probes the minified `var a=JSON.parse`, // `var b=JSON.parse`, and `s(b,a)` literals below. Update the harness with any // semantically equivalent rewrite so its black-box checks keep matching output. - let bids = serde_json::to_string(bid_map) - .expect("serde_json::to_string of Map should be infallible"); + let bids = serde_json::to_string(bid_map).expect("should serialize the bids map"); let transport = trace_transport_script(trace); - let (transport_script, scheduler_arguments) = - transport.as_ref().map_or((String::new(), "b,a"), |json| { - ( - format!("var x=JSON.parse(\"{}\");", html_escape_for_script(json)), - "b,a,x", - ) - }); + let transport_script = transport.as_ref().map_or_else(String::new, |json| { + format!("var x=JSON.parse(\"{}\");", html_escape_for_script(json)) + }); + let diagnostics_script = auction_diagnostics.map_or_else(String::new, |diagnostics| { + let json = + serde_json::to_string(diagnostics).expect("should serialize BrowserAuctionDiagnostics"); + format!("var d=JSON.parse(\"{}\");", html_escape_for_script(&json)) + }); + let scheduler_arguments = match (transport.is_some(), auction_diagnostics.is_some()) { + (false, false) => "b,a", + (true, false) => "b,a,x", + (false, true) => "b,a,void 0,d", + (true, true) => "b,a,x,d", + }; + let fallback_diagnostics = if auction_diagnostics.is_some() { + "t.auctionDiagnostics=d;" + } else { + "" + }; format!( "", html_escape_for_script(slots_json), html_escape_for_script(&bids), transport_script, - scheduler_arguments + diagnostics_script, + scheduler_arguments, + fallback_diagnostics ) } @@ -7639,9 +7878,9 @@ pub async fn handle_page_bids( ec_context: &mut EcContext, mut req: Request, ) -> Result, Report> { - // CSRF-style gate: refuse cross-site invocations before any other work — - // including the not-configured 404 below, which would otherwise tell a - // cross-site caller whether this deployment has creative opportunities. + // Reject cross-site invocations before diagnostics preparation or the + // not-configured 404 below, which would otherwise tell a cross-site caller + // whether this deployment has creative opportunities. if !page_bids_request_allowed(&req) { log::debug!( "page-bids: rejecting request (sec-fetch-site={:?}, tsjs header present={})", @@ -7652,6 +7891,21 @@ pub async fn handle_page_bids( ); return Ok(page_bids_preflight_denied()); } + + // Keep a local collector for direct-handler callers, but only expose + // browser timings from an adapter-attached request clock. Otherwise an + // adapter that forgets the extension would silently report a different + // handler-entry timing origin. + let request_timings = RequestTimings::from_extensions(req.extensions()); + let request_timing_collector_attached = request_timings.is_some(); + let timings = request_timings.unwrap_or_default(); + + // Adapter fallbacks prepare this before routing. Keep this idempotent call as + // a direct-handler safety net and retain the session decision after the + // private activation cookie is stripped. + let gpt_diagnostics = + crate::integrations::gpt_diagnostics::prepare_request(settings, &mut req)?; + ec_context.set_eid_sync_source(EidSyncSource::PageBids); // Deprecation signal for the transition alias. Evaluated after the @@ -7815,6 +8069,7 @@ pub async fn handle_page_bids( .as_ref() .map(TraceAuctionCarry::cancellation_guard); + let mut auction_diagnostics = None; let (winning_bids, prebuilt_bid_map) = if matched_slots.is_empty() { (std::collections::HashMap::new(), None) } else { @@ -7832,6 +8087,12 @@ pub async fn handle_page_bids( user_agent, ec_context, ); + // Same T0-anchored timeline as the navigation path (spec section 18). + // This route uses the dispatch/collect split without an intervening + // origin fetch. Only a started provider request stamps the milestones. + if auction.orchestrator.is_enabled() { + timings.set_auction_id(observation.auction_id); + } if ad_stack_enabled && !is_bot && !is_prefetch { let slots_ctx = MatchedSlotsContext { matched_slots: &matched_slots, @@ -7887,14 +8148,19 @@ pub async fn handle_page_bids( provider_responses: None, services, }; - match auction + let mut dispatch_failure_responses = Vec::new(); + let result = auction .orchestrator - .run_auction(&auction_request, &auction_context) + .run_auction_with_timings(&auction_request, &auction_context, &timings) .await - { - Ok(result) => { + .map_err(|failure| { + dispatch_failure_responses = failure.provider_responses; + failure.error + }); + match result { + Ok((result, auction_work_started)) => { let winning_bids = result.winning_bids.clone(); - let auction_id = trace_auction + let diagnostics_request_id = trace_auction .as_ref() .map(|trace| trace.token().to_string()) .or_else(|| diagnostics_auction_id(settings)); @@ -7904,8 +8170,17 @@ pub async fn handle_page_bids( settings, &page_bids_request_origin, settings.debug.inject_adm_for_testing, - auction_id.as_deref(), + diagnostics_request_id.as_deref(), ); + // Targeting is available to the response pipeline. + if auction_work_started { + timings.mark_auction_committed(); + } + if gpt_diagnostics.browser_session_active() && request_timing_collector_attached + { + auction_diagnostics = + BrowserAuctionDiagnostics::from_request_timings(&timings); + } let delivered_winner_slots = bid_map.keys().cloned().collect(); if let Some(trace) = &trace_auction { trace.observe_delivery(&winning_bids, &delivered_winner_slots); @@ -7931,7 +8206,7 @@ pub async fn handle_page_bids( observation, AuctionTerminalOutcome::ExecutionFailed { request: Some(&auction_request), - provider_responses: &[], + provider_responses: &dispatch_failure_responses, reason: "execution_failed", elapsed_ms, }, @@ -8006,6 +8281,12 @@ pub async fn handle_page_bids( .unwrap_or_else(crate::trace::TraceAuctionTransportV1::unavailable), ); } + if let Some(diagnostics) = auction_diagnostics { + body["auctionDiagnostics"] = + serde_json::to_value(diagnostics).change_context(TrustedServerError::Proxy { + message: "Failed to serialize page-bids diagnostics".to_string(), + })?; + } let body = serde_json::to_string(&body).change_context(TrustedServerError::Proxy { message: "Failed to serialize page-bids response".to_string(), })?; @@ -9950,6 +10231,7 @@ mod tests { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } } @@ -10858,6 +11140,83 @@ mod tests { .expect("should proxy publisher request") } + #[tokio::test] + async fn origin_span_covers_the_publisher_fetch() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![(header::CONTENT_TYPE.as_str(), "text/html; charset=utf-8")], + ); + let services = + build_services_with_http_client(stub as Arc); + let mut request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/some-page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + + let _response = run_publisher_proxy(&settings, &services, request).await; + + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.is_some(), + "should record the Origin phase span around the publisher fetch" + ); + } + + #[tokio::test] + async fn origin_span_is_recorded_when_the_origin_send_fails() { + // Regression guard for the review finding that the origin span + // guard stayed alive through the error branch (and its + // abandonment-telemetry await): the span must close when the send + // resolves, so a failed fetch still records `origin_ms` and the + // error branch's own work is excluded from it. + let settings = create_test_settings(); + // No queued response: the stub client fails the origin send. + let stub = Arc::new(StubHttpClient::new()); + let services = + build_services_with_http_client(stub as Arc); + let mut request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/some-page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let mut ec_context = EcContext::read_from_request(&settings, &request, &services) + .expect("should read EC context"); + let result = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + request, + EdgeCacheHeader::SurrogateControl, + ) + .await; + + assert!(result.is_err(), "should surface the origin failure"); + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.is_some(), + "should record the Origin span even when the origin send fails" + ); + } + mod rendered_template_identity_tests { //! The gate the plan's Task 3 Step 2 actually asks for. //! @@ -11532,6 +11891,7 @@ mod tests { //! link; this tests that they connect. use super::*; + use crate::auction::provider::test_support::LaunchEvidenceProvider as Mediator; use crate::platform::ClientInfo; use crate::platform::test_support::{ NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, StubHttpClient, @@ -11990,6 +12350,47 @@ mod tests { price: f64, } + struct FailingNavigationProvider; + + #[async_trait::async_trait(?Send)] + impl crate::auction::provider::AuctionProvider for FailingNavigationProvider { + fn provider_name(&self) -> &'static str { + STUB_BIDDER + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + _context: &crate::auction::types::AuctionContext<'_>, + ) -> Result> + { + Err(Report::new(TrustedServerError::Auction { + message: "navigation provider admission failed".to_string(), + })) + } + + async fn parse_response( + &self, + _response: crate::platform::PlatformResponse, + _response_time_ms: u64, + ) -> Result> + { + panic!("should not parse a response after navigation dispatch failure"); + } + + fn timeout_ms(&self) -> u32 { + 2_000 + } + + fn backend_name( + &self, + _services: &RuntimeServices, + _timeout_ms: u32, + ) -> Option { + Some("stub-bidder-backend".to_string()) + } + } + #[async_trait::async_trait(?Send)] impl crate::auction::provider::AuctionProvider for WinningBidProvider { fn provider_name(&self) -> &'static str { @@ -12742,110 +13143,354 @@ mod tests { } #[tokio::test] - async fn harness_emits_a_summary_row_for_an_ad_serving_navigation() { + async fn disabled_navigation_keeps_access_auction_id_null() { let stub = Arc::new(StubHttpClient::new()); - let sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_cache_and_telemetry( Arc::clone(&stub), Arc::new(MemoryTemplateCache::default()), - Arc::clone(&sink), + Arc::new(RecordingTelemetrySink::default()), ); - let settings = Arc::new(settings_with_mode("esi")); + let mut disabled_settings = settings_with_mode("esi"); + disabled_settings.auction.enabled = false; + let settings = Arc::new(disabled_settings); queue_shareable_html(&stub); + let mut request = navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); - let _ = run(&settings, &services, navigation_request()).await; + let response = run(&settings, &services, request).await; + let _ = body_of(response).await; assert!( - last_summary_row(&sink).is_some(), - "the harness must emit a summary row, or every assertion built on it is vacuous" + timings.snapshot().auction_id.is_none(), + "should keep the disabled navigation access join key null" ); } - /// Drive `handle_publisher_request` through the **EC-preload** fetch path. - /// - /// `run_with_orchestrator` passes `kv: None` and no EC id, so - /// `should_preload_ec_snapshot` is false for every other test in this file and the - /// first of the two origin-fetch call sites is never exercised. Supplying both - /// reaches it. - async fn run_through_ec_preload( - settings: &Arc, - services: &RuntimeServices, - request: Request, - ) { - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); - let kv = crate::ec::kv::KvIdentityGraph::in_memory("test-store"); - let consent = crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }; - let mut ec_context = EcContext::new_for_test(Some("test-ec-id".to_owned()), consent); - - let _ = handle_publisher_request( - settings, - services, - Some(&kv), - &mut ec_context, - AuctionDispatch { - integration_registry: &IntegrationRegistry::empty_for_tests(), - orchestrator: &orchestrator, - slots: &[article_slot()], - registry: None, - }, - request, - EdgeCacheHeader::SMaxageFallback, - ) - .await - .expect("should proxy publisher request"); - } - #[tokio::test] - async fn the_ec_preload_fetch_path_applies_the_same_cache_gate() { - // The call site a naive revert missed. Reverting only this one previously - // passed all 2,697 tests; this is the behavioral cover for it. + async fn skipped_navigation_joins_summary_without_milestones() { let stub = Arc::new(StubHttpClient::new()); + let sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_cache_and_telemetry( Arc::clone(&stub), Arc::new(MemoryTemplateCache::default()), - Arc::new(RecordingTelemetrySink::default()), + Arc::clone(&sink), ); - stub.set_pending_streaming_responses_supported(true); - let settings = Arc::new(settings_with_readthrough_enabled("inline")); + let settings = Arc::new(settings_with_mode("esi")); queue_shareable_html(&stub); + let mut request = prefetch_navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); - run_through_ec_preload(&settings, &services, navigation_request()).await; - + let response = run(&settings, &services, request).await; + let _ = body_of(response).await; + let summary = + last_summary_row(&sink).expect("should emit a skipped summary for prefetch"); + assert_eq!(summary.terminal_reason.as_deref(), Some("prefetch")); assert_eq!( - stub.recorded_cache_intents(), - vec![PlatformCacheIntent::Shared { - surrogate_key: format!( - "ts-template {}", - crate::platform::reader_url_surrogate_key("http://ts.example.com/article") - ), - }], - "the EC-preload path must honor the gate, not decide for itself" + timings + .snapshot() + .auction_id + .map(|id| id.to_string()) + .as_deref(), + Some(summary.auction_id.as_str()), + "should join the enabled skipped navigation to its summary" ); + assert!(timings.snapshot().auction_dispatched_ms.is_none()); + assert!(timings.snapshot().auction_resolved_ms.is_none()); + assert!(timings.snapshot().auction_committed_ms.is_none()); } #[tokio::test] - async fn the_ec_preload_fetch_path_still_bypasses_when_readthrough_is_off() { - let stub = Arc::new(StubHttpClient::new()); - let services = services_with_cache_and_telemetry( - Arc::clone(&stub), - Arc::new(MemoryTemplateCache::default()), - Arc::new(RecordingTelemetrySink::default()), - ); - stub.set_pending_streaming_responses_supported(true); - let settings = Arc::new(settings_with_mode("inline")); - queue_shareable_html(&stub); - - run_through_ec_preload(&settings, &services, navigation_request()).await; - - assert_eq!( - stub.recorded_cache_intents(), - vec![PlatformCacheIntent::Bypass], - "the opt-in must gate both fetch paths, not only the non-preload one" + async fn routing_only_navigation_times_only_mediator_requests_that_started() { + // Shared-template cache misses intentionally buffer even with a streaming + // finalizer. Inline assembly exercises the actual lazy collection path. + let toml = format!( + "{}\n[auction]\nenabled = true\n\n\ + [auction.providers.skipped]\nprotocol = \"openrtb-2.6\"\nprofile = \"standard\"\nendpoint = \"https://bidder.example.com/auction\"\nrouting = \"explicit\"\n\n\ + [creative_opportunities]\ngam_network_id = \"99999\"\nassembly_mode = \"inline\"\n\n\ + [[creative_opportunities.slot]]\nid = \"test-slot\"\npage_patterns = [\"/article\"]\nformats = [{{ width = 728, height = 90 }}]\n", + crate_test_settings_str() ); - } + let mut parsed_settings = Settings::from_toml(&toml) + .expect("should parse explicit-routing navigation settings"); + parsed_settings.publisher.origin_url = + "https://origin.publisher.example.com".to_owned(); + parsed_settings.proxy.allowed_domains = + vec!["*.example".to_string(), "*.example.com".to_string()]; + let settings = Arc::new(parsed_settings); + let plan = Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile explicit-routing navigation plan"), + ); + for (finalizer, placement) in [ + (Finalizer::Buffered, AuctionWaitPlacement::PreHeader), + (Finalizer::Streaming, AuctionWaitPlacement::InStream), + ] { + for mediator in [ + None, + Some(Mediator::Immediate), + Some(Mediator::FailBeforeStart), + Some(Mediator::FailAfterStart), + Some(Mediator::Pending), + ] { + let launched = + matches!(mediator, Some(Mediator::FailAfterStart | Mediator::Pending)); + let orchestrator = AuctionOrchestrator::from_plan( + Arc::clone(&plan), + mediator.map(|mediator| Arc::new(mediator) as Arc), + ); + let stub = Arc::new(StubHttpClient::new()); + stub.set_streaming_responses_supported(true); + queue_shareable_html(&stub); + stub.push_response(204, Vec::new()); + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with_cache_and_telemetry( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + Arc::clone(&sink), + ); + let mut request = navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + + let response = run_with_orchestrator( + &settings, + &services, + request, + orchestrator, + finalizer, + ) + .await; + if matches!(finalizer, Finalizer::Streaming) { + assert!( + response.body().is_stream(), + "should return a lazy response body" + ); + let before_poll = timings.snapshot(); + assert_eq!( + before_poll.auction_wait_ms, None, + "should not collect before the lazy body is polled" + ); + assert_eq!( + before_poll.auction_resolved_ms, None, + "should not resolve before the lazy body is polled" + ); + assert_eq!( + stub.recorded_request_uris(), + vec!["https://origin.publisher.example.com/article"], + "should defer the mediator request until the lazy body is polled" + ); + } + let _ = body_of(response).await; + let summary = last_summary_row(&sink) + .expect("should emit a summary for the routing-only dispatch"); + assert_eq!(summary.terminal_status.as_deref(), Some("completed")); + assert_eq!( + timings + .snapshot() + .auction_id + .map(|id| id.to_string()) + .as_deref(), + Some(summary.auction_id.as_str()), + "should join the routing-only dispatch to its navigation summary" + ); + let snapshot = timings.snapshot(); + assert!( + snapshot.auction_dispatched_ms.is_none(), + "must not fabricate provider dispatch for a mediator launched during collection" + ); + assert_eq!( + snapshot.auction_resolved_ms.is_some(), + launched, + "{mediator:?}" + ); + assert_eq!( + snapshot.auction_committed_ms.is_some(), + launched, + "{mediator:?}" + ); + assert_eq!(snapshot.auction_wait_ms.is_some(), launched, "{mediator:?}"); + assert_eq!( + snapshot.auction_wait_placement, + launched.then_some(placement) + ); + let expected = if launched { + vec![ + "https://origin.publisher.example.com/article", + "https://mediator.example.com/auction", + ] + } else { + vec!["https://origin.publisher.example.com/article"] + }; + assert_eq!(stub.recorded_request_uris(), expected, "{mediator:?}"); + } + } + } + + #[tokio::test] + async fn failed_navigation_joins_summary_without_launch_milestones() { + let stub = Arc::new(StubHttpClient::new()); + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with_cache_and_telemetry( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + Arc::clone(&sink), + ); + let settings = Arc::new(settings_with_bidder("esi")); + queue_shareable_html(&stub); + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(FailingNavigationProvider)); + let mut request = navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + + let response = run_with_orchestrator( + &settings, + &services, + request, + orchestrator, + Finalizer::Streaming, + ) + .await; + let _ = body_of(response).await; + let summary = last_summary_row(&sink) + .expect("should emit a dispatch-failed summary for failed navigation"); + assert_eq!(summary.terminal_reason.as_deref(), Some("dispatch_failed")); + assert_eq!( + timings + .snapshot() + .auction_id + .map(|id| id.to_string()) + .as_deref(), + Some(summary.auction_id.as_str()), + "should join the enabled failed navigation to its summary" + ); + assert!(timings.snapshot().auction_dispatched_ms.is_none()); + assert!(timings.snapshot().auction_resolved_ms.is_none()); + assert!(timings.snapshot().auction_committed_ms.is_none()); + } + + #[tokio::test] + async fn harness_emits_a_summary_row_for_an_ad_serving_navigation() { + let stub = Arc::new(StubHttpClient::new()); + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with_cache_and_telemetry( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + Arc::clone(&sink), + ); + let settings = Arc::new(settings_with_bidder("esi")); + queue_bid_response(&stub); + queue_shareable_html(&stub); + let mut request = navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + + let response = run_bidding(&settings, &services, request).await; + let _ = body_of(response).await; + let summary = last_summary_row(&sink) + .expect("should emit a summary row for the navigation handler"); + assert_eq!( + timings + .snapshot() + .auction_id + .map(|id| id.to_string()) + .as_deref(), + Some(summary.auction_id.as_str()), + "the installed navigation collector must join its summary row" + ); + let snapshot = timings.snapshot(); + assert!(snapshot.auction_dispatched_ms.is_some()); + assert!(snapshot.auction_resolved_ms.is_some()); + assert!(snapshot.auction_committed_ms.is_some()); + } + + /// Drive `handle_publisher_request` through the **EC-preload** fetch path. + /// + /// `run_with_orchestrator` passes `kv: None` and no EC id, so + /// `should_preload_ec_snapshot` is false for every other test in this file and the + /// first of the two origin-fetch call sites is never exercised. Supplying both + /// reaches it. + async fn run_through_ec_preload( + settings: &Arc, + services: &RuntimeServices, + request: Request, + ) { + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let kv = crate::ec::kv::KvIdentityGraph::in_memory("test-store"); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(Some("test-ec-id".to_owned()), consent); + + let _ = handle_publisher_request( + settings, + services, + Some(&kv), + &mut ec_context, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &orchestrator, + slots: &[article_slot()], + registry: None, + }, + request, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy publisher request"); + } + + #[tokio::test] + async fn the_ec_preload_fetch_path_applies_the_same_cache_gate() { + // The call site a naive revert missed. Reverting only this one previously + // passed all 2,697 tests; this is the behavioral cover for it. + let stub = Arc::new(StubHttpClient::new()); + let services = services_with_cache_and_telemetry( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + Arc::new(RecordingTelemetrySink::default()), + ); + stub.set_pending_streaming_responses_supported(true); + let settings = Arc::new(settings_with_readthrough_enabled("inline")); + queue_shareable_html(&stub); + + run_through_ec_preload(&settings, &services, navigation_request()).await; + + assert_eq!( + stub.recorded_cache_intents(), + vec![PlatformCacheIntent::Shared { + surrogate_key: format!( + "ts-template {}", + crate::platform::reader_url_surrogate_key("http://ts.example.com/article") + ), + }], + "the EC-preload path must honor the gate, not decide for itself" + ); + } + + #[tokio::test] + async fn the_ec_preload_fetch_path_still_bypasses_when_readthrough_is_off() { + let stub = Arc::new(StubHttpClient::new()); + let services = services_with_cache_and_telemetry( + Arc::clone(&stub), + Arc::new(MemoryTemplateCache::default()), + Arc::new(RecordingTelemetrySink::default()), + ); + stub.set_pending_streaming_responses_supported(true); + let settings = Arc::new(settings_with_mode("inline")); + queue_shareable_html(&stub); + + run_through_ec_preload(&settings, &services, navigation_request()).await; + + assert_eq!( + stub.recorded_cache_intents(), + vec![PlatformCacheIntent::Bypass], + "the opt-in must gate both fetch paths, not only the non-preload one" + ); + } #[tokio::test] async fn disabled_readthrough_preserves_non_ad_origin_caching() { @@ -13454,6 +14099,108 @@ mod tests { ); } + #[tokio::test] + async fn template_cache_response_extension_matches_the_header_on_every_transition() { + // The typed extension and the `x-ts-template-cache` header are written + // together by a single setter, so they must always agree — access + // telemetry reads the extension precisely because it cannot drift from + // what an operator-configured header override might otherwise show. + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + + queue_shareable_html(&stub); + + let cold = run(&settings, &services, navigation_request()).await; + let cold_header = cold + .headers() + .get(HEADER_X_TS_TEMPLATE_CACHE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let cold_extension = cold + .extensions() + .get::() + .map(|state| state.as_str()); + assert_eq!( + cold_extension, + cold_header.as_deref(), + "the cold-fill extension must match the header" + ); + assert_eq!(cold_extension, Some("miss-stored")); + + let warm = run(&settings, &services, navigation_request()).await; + let warm_header = warm + .headers() + .get(HEADER_X_TS_TEMPLATE_CACHE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let warm_extension = warm + .extensions() + .get::() + .map(|state| state.as_str()); + assert_eq!( + warm_extension, + warm_header.as_deref(), + "the warm-hit extension must match the header" + ); + assert_eq!(warm_extension, Some("hit")); + } + + #[tokio::test] + async fn template_cache_span_recorded_only_when_lookup_runs() { + // Inline mode: no shared-cache key is ever computed, so the lookup + // never runs and the span is never recorded. + let inline_settings = create_test_settings(); + let inline_stub = Arc::new(StubHttpClient::new()); + inline_stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![(header::CONTENT_TYPE.as_str(), "text/html; charset=utf-8")], + ); + let inline_services = build_services_with_http_client( + inline_stub as Arc, + ); + let mut inline_request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/some-page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + let inline_timings = RequestTimings::new(); + inline_request + .extensions_mut() + .insert(inline_timings.handle().clone()); + + let _inline_response = + run_publisher_proxy(&inline_settings, &inline_services, inline_request).await; + + inline_timings.mark_headers_ready(); + assert!( + inline_timings.snapshot().template_cache_ms.is_none(), + "inline mode should never run the template-cache lookup" + ); + + // Shared-mode eligible: a matched slot plus a template-cache-eligible + // assembly mode compute a cache key, so the lookup runs and is timed. + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + queue_shareable_html(&stub); + let mut request = navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + + let _response = run(&settings, &services, request).await; + + timings.mark_headers_ready(); + assert!( + timings.snapshot().template_cache_ms.is_some(), + "a shared-cache-eligible request should record the TemplateCacheLookup span" + ); + } + #[tokio::test] async fn a_pass_through_authorization_never_reaches_the_shared_template() { let stub = Arc::new(StubHttpClient::new()); @@ -19810,6 +20557,16 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { #[derive(Default)] struct RecordingTelemetrySink { batches: Mutex>, + delay: std::time::Duration, + } + + impl RecordingTelemetrySink { + fn with_delay(delay: std::time::Duration) -> Self { + Self { + batches: Mutex::new(Vec::new()), + delay, + } + } } #[async_trait::async_trait(?Send)] @@ -19819,6 +20576,9 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { _services: &RuntimeServices, batch: AuctionEventBatch, ) -> Result<(), Report> { + if !self.delay.is_zero() { + tokio::time::sleep(self.delay).await; + } self.batches .lock() .expect("should lock telemetry batches") @@ -20057,7 +20817,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let settings = settings_with_dispatching_provider(); let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); orchestrator.register_provider(Arc::new(DispatchingTestProvider)); - let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let telemetry_delay = std::time::Duration::from_millis(250); + let telemetry_sink = Arc::new(RecordingTelemetrySink::with_delay(telemetry_delay)); let stub = Arc::new(StubHttpClient::new()); stub.set_streaming_responses_supported(true); stub.set_pending_streaming_responses_supported(true); @@ -20071,6 +20832,10 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let graph = KvIdentityGraph::new(InMemoryEcKv::new("wait-error-store")); let mut ec_context = active_ec_context(); let slots = [article_slot()]; + let mut request = conditional_navigation_request(); + let timings = RequestTimings::new(); + request.extensions_mut().insert(timings.handle().clone()); + let started = std::time::Instant::now(); let error = match handle_publisher_request( &settings, @@ -20083,7 +20848,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { slots: &slots, registry: None, }, - conditional_navigation_request(), + request, EdgeCacheHeader::SurrogateControl, ) .await @@ -20117,7 +20882,21 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { ); assert_eq!( summaries[0].publisher_domain, settings.publisher.domain, - "reordered path should retain configured publisher attribution" + "should retain configured publisher attribution" + ); + drop(batches); + let elapsed_ms = started.elapsed().as_millis() as u64; + assert!(elapsed_ms >= telemetry_delay.as_millis() as u64); + timings.mark_headers_ready(); + let origin_ms = u64::from( + timings + .snapshot() + .origin_ms + .expect("should record origin timing before abandonment telemetry"), + ); + assert!( + origin_ms + telemetry_delay.as_millis() as u64 / 2 < elapsed_ms, + "origin span must exclude the deliberately slow abandonment sink: origin={origin_ms}ms total={elapsed_ms}ms" ); } @@ -21788,6 +22567,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::PreHeader, }, }; let mut output = Vec::new(); @@ -21864,6 +22645,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::InStream, }, }; let mut processor = RecordingProcessor { @@ -21944,6 +22727,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::InStream, }, }; let mut processor = RecordingProcessor { @@ -22038,6 +22823,8 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { services: &services, settings: &settings, request_origin: String::new(), + timings: RequestTimings::new(), + placement: AuctionWaitPlacement::PreHeader, }; // Passthrough processor: the ordering contract is about collection, not // HTML rewriting, so keep the emitted bytes verbatim. @@ -23069,6 +23856,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -23131,6 +23919,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -23182,6 +23971,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), @@ -23311,6 +24101,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), @@ -23378,6 +24169,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -23448,6 +24240,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -23518,6 +24311,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -23588,6 +24382,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -23640,6 +24435,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } } @@ -23866,6 +24662,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"hello"), @@ -23944,6 +24741,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; // The `` that triggers bid injection lives in the SECOND gzip // member. `flate2::read::GzDecoder` decodes only the first member, so @@ -24021,6 +24819,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( b"body{background:url('https://origin.example.com/asset.png')}", @@ -24092,6 +24891,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let publisher_response = PublisherResponse::Stream { response, @@ -24529,10 +25329,10 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { None, &mut EcContext::default(), AuctionDispatch { + integration_registry: ®istry, orchestrator: &orchestrator, slots: &[], registry: None, - integration_registry: ®istry, }, request, EdgeCacheHeader::SurrogateControl, @@ -26059,6 +26859,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } } @@ -26782,6 +27583,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), } }; let make_stream_response = || PublisherResponse::Stream { @@ -26845,19 +27647,360 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { } #[test] - fn publisher_response_streaming_finalize_processes_gzip_stream() { - let compressed = - gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); - let split_at = compressed.len() / 2; - let output = drain_streaming_finalize_body( - "gzip", - EdgeBody::stream(futures::stream::iter(vec![ - bytes::Bytes::copy_from_slice(&compressed[..split_at]), - bytes::Bytes::copy_from_slice(&compressed[split_at..]), - ])), - ); - - let css = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + fn streaming_seam_wait_records_in_stream_placement() { + // The true Fastly streaming path: `publisher_response_into_streaming_response` + // hands back a lazy body after headers have already been committed by + // `stream_to_client()`. The `` seam wait polled from inside that body + // must therefore be attributed `InStream`, never `PreHeader`. + let settings = Arc::new(create_test_settings()); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let timings = RequestTimings::new(); + + let mut params = make_stream_params(&settings, ""); + params.content_type = "text/html; charset=utf-8".to_string(); + params.dispatched_auction = Some(DispatchedAuction::launched_no_bid_for_test( + test_auction_request(), + 500, + )); + params.auction_request = Some(test_auction_request()); + params.timings = timings.clone(); + + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(EdgeBody::empty()) + .expect("should build response"); + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"hello"), + bytes::Bytes::from_static(b""), + ])); + + let response = futures::executor::block_on(publisher_response_into_streaming_response( + PublisherResponse::Stream { + response, + body, + params: Box::new(params), + }, + &Method::GET, + Arc::clone(&settings), + ®istry, + Arc::clone(&orchestrator), + noop_services(), + )) + .expect("streaming finalize should succeed"); + + // The wait is only recorded once the lazy body is actually polled — the + // finalizer call above only constructs it. + let drained = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("body should drain"); + assert!( + String::from_utf8_lossy(&drained).contains("hello"), + "should still stream the document" + ); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::InStream), + "the streaming seam wait must be attributed InStream" + ); + assert!( + snapshot.auction_wait_ms.is_some(), + "should record an auction wait duration" + ); + // The T0 marks are recorded at this same collect site, so a collect + // path that stops calling them fails here rather than silently + // emitting null columns. + assert!( + snapshot.auction_resolved_ms.is_some(), + "should mark the auction resolved at the streaming collect site" + ); + assert!( + snapshot.auction_committed_ms.is_some(), + "should mark the auction committed at the streaming collect site" + ); + } + + #[test] + fn template_hit_streaming_finalizer_records_in_stream_placement() { + let settings = Arc::new(create_test_settings()); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let timings = RequestTimings::new(); + let mut params = make_stream_params(&settings, ""); + params.dispatched_auction = Some(DispatchedAuction::launched_no_bid_for_test( + test_auction_request(), + 500, + )); + params.auction_request = Some(test_auction_request()); + params.timings = timings.clone(); + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(EdgeBody::empty()) + .expect("should build template-hit response"); + let template = format!("cached{}", AD_ASSEMBLY_SEAM).into_bytes(); + let response = futures::executor::block_on(publisher_response_into_streaming_response( + PublisherResponse::AssembleTemplate { + response, + template, + params: Box::new(params), + }, + &Method::GET, + Arc::clone(&settings), + ®istry, + Arc::clone(&orchestrator), + noop_services(), + )) + .expect("should build lazy template-hit response"); + let body = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("should drain the template-hit body"); + assert!( + !String::from_utf8_lossy(&body).contains(AD_ASSEMBLY_SEAM), + "should replace the cached template seam while streaming" + ); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::InStream), + "should attribute the template-hit wait to the committed stream" + ); + assert!(snapshot.auction_resolved_ms.is_some()); + assert!(snapshot.auction_committed_ms.is_some()); + } + + #[test] + fn streamed_non_html_auction_wait_records_in_stream_placement() { + let settings = Arc::new(create_test_settings()); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let timings = RequestTimings::new(); + let mut params = make_stream_params(&settings, ""); + params.dispatched_auction = Some(DispatchedAuction::launched_no_bid_for_test( + test_auction_request(), + 500, + )); + params.auction_request = Some(test_auction_request()); + params.timings = timings.clone(); + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::empty()) + .expect("should build response"); + let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"{\"ok\":true}", + )])); + + let response = futures::executor::block_on(publisher_response_into_streaming_response( + PublisherResponse::Stream { + response, + body, + params: Box::new(params), + }, + &Method::GET, + Arc::clone(&settings), + ®istry, + Arc::clone(&orchestrator), + noop_services(), + )) + .expect("streaming finalize should succeed"); + let _ = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("non-HTML body should drain"); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::InStream) + ); + assert!(snapshot.auction_resolved_ms.is_some()); + assert!(snapshot.auction_committed_ms.is_some()); + } + + #[test] + fn empty_collector_tokens_do_not_mark_resolved_or_committed() { + let settings = create_test_settings(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let request = test_auction_request(); + let streamed_timings = RequestTimings::new(); + let state = AdBidsState::default(); + let deps = AuctionCollectDeps { + price_granularity: PriceGranularity::default(), + ad_bids_state: &state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + request_origin: String::new(), + timings: streamed_timings.clone(), + placement: AuctionWaitPlacement::InStream, + }; + futures::executor::block_on(collect_stream_auction( + DispatchedAuction::empty_for_test(request.clone(), 10), + AuctionTelemetryCarry { + observation: None, + auction_request: None, + }, + &deps, + )); + let streamed = streamed_timings.snapshot(); + assert!(streamed.auction_resolved_ms.is_none()); + assert!(streamed.auction_committed_ms.is_none()); + assert_eq!( + streamed.auction_wait_ms, None, + "should leave empty stream collection untimed" + ); + assert_eq!( + streamed.auction_wait_placement, None, + "should omit empty stream wait placement" + ); + + for placement in [ + AuctionWaitPlacement::PreHeader, + AuctionWaitPlacement::InStream, + ] { + let mut params = make_stream_params(&settings, ""); + params.timings = RequestTimings::new(); + let buffered_timings = params.timings.clone(); + futures::executor::block_on(collect_non_html_auction( + DispatchedAuction::empty_for_test(request.clone(), 10), + AuctionTelemetryCarry { + observation: None, + auction_request: None, + }, + ¶ms, + &orchestrator, + &services, + &settings, + placement, + )); + let buffered = buffered_timings.snapshot(); + assert!(buffered.auction_resolved_ms.is_none()); + assert!(buffered.auction_committed_ms.is_none()); + assert_eq!( + buffered.auction_wait_ms, None, + "should leave empty non-HTML collection untimed" + ); + assert_eq!( + buffered.auction_wait_placement, None, + "should omit empty non-HTML wait placement" + ); + } + } + + #[test] + fn buffered_template_miss_records_pre_header_placement() { + // The buffered finalizer materializes the entire response — headers and + // body — before any of it reaches the client. Even though the wait runs + // through the same `` seam code path as the streaming finalizer + // above, headers have not committed here, so it must be attributed + // `PreHeader`. (This exercises the same collect step a shared-template + // authorized miss rides through: `template_cache_key`'s presence only + // changes what happens *after* collection — whether the transformed bytes + // are stored — not where the wait itself is measured.) + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let timings = RequestTimings::new(); + + let mut params = make_stream_params(&settings, ""); + params.content_type = "text/html; charset=utf-8".to_string(); + params.dispatched_auction = Some(DispatchedAuction::launched_no_bid_for_test( + test_auction_request(), + 500, + )); + params.auction_request = Some(test_auction_request()); + params.timings = timings.clone(); + + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(EdgeBody::empty()) + .expect("should build response"); + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"hello"), + bytes::Bytes::from_static(b""), + ])); + + let response = futures::executor::block_on(buffer_publisher_response_async( + PublisherResponse::Stream { + response, + body, + params: Box::new(params), + }, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + )) + .expect("buffered finalize should succeed"); + + let html = String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("should be valid UTF-8"); + assert!(html.contains("hello"), "should still assemble the document"); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::PreHeader), + "the buffered finalizer's wait must be attributed PreHeader even though \ + it shares the seam code path with the streaming finalizer" + ); + assert!( + snapshot.auction_wait_ms.is_some(), + "should record an auction wait duration" + ); + // Same guard as the streaming test: both collect sites must mark, or + // one body mode quietly reports null offsets. + assert!( + snapshot.auction_resolved_ms.is_some(), + "should mark the auction resolved at the buffered collect site" + ); + assert!( + snapshot.auction_committed_ms.is_some(), + "should mark the auction committed at the buffered collect site" + ); + } + + #[test] + fn publisher_response_streaming_finalize_processes_gzip_stream() { + let compressed = + gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let output = drain_streaming_finalize_body( + "gzip", + EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])), + ); + + let css = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); assert!( css.contains("proxy.example.com"), "streaming response finalize should rewrite gzip body. Got: {css}" @@ -26976,6 +28119,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let publisher_response = PublisherResponse::Stream { response, @@ -27057,6 +28201,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -27121,6 +28266,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -27242,6 +28388,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -27312,6 +28459,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { gpt_diagnostics: None, trace_bootstrap: None, suppress_datadome_client_side_tag: false, + timings: RequestTimings::new(), }; let mut output = Vec::new(); @@ -27342,10 +28490,11 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { #[cfg(test)] mod creative_opportunities_tests { use super::super::{ - AdBidsState, MatchedSlotsContext, build_ad_slots_script, build_auction_request, - build_bid_map, build_bids_script, diagnostics_auction_id, html_escape_for_script, - write_bids_to_state, + AdBidsState, BrowserAuctionDiagnostics, MatchedSlotsContext, build_ad_slots_script, + build_auction_request, build_bid_map, build_bids_script, diagnostics_auction_id, + html_escape_for_script, write_bids_to_state, }; + use super::test_auction_request; use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderer, MediaType}; use crate::consent::ConsentContext; use crate::creative_opportunities::{ @@ -27353,8 +28502,11 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { }; use crate::http_util::RequestInfo; use crate::price_bucket::PriceGranularity; + use crate::request_timing::{AuctionWaitPlacement, RequestTimings}; use crate::settings::Settings; + use crate::trace::{TraceAuctionCarry, TraceAuctionSource, TraceAuctionTerminalStatus}; use std::collections::HashMap; + use std::time::Duration; // Default settings are enough for the creative boundary: the sanitize // pass needs no config, and `rewrite_creative_html` only signs URLs it @@ -28737,51 +29889,213 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { } #[test] - fn bids_script_defers_ad_init_until_after_hydration() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + fn active_diagnostics_hands_auction_timing_to_the_generation_guarded_scheduler() { + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.record_auction_wait(AuctionWaitPlacement::InStream, Duration::from_millis(12)); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + let state = AdBidsState::with_diagnostics(true, true); + state.set(serde_json::Map::new()); - let script = build_bids_script(&map); + state.set_auction_diagnostics(&timings); - // adInit() mutates ad-slot subtrees (GPT defineSlot on the - // `-container` wrapper). Running it synchronously at body-parse time - // lands those mutations inside React's hydration window and trips a - // #418 hydration mismatch. The deferral lifecycle (window `load`, - // double `requestAnimationFrame`, generation-0 pinning via - // `tsjs.navGeneration`) lives in the GPT bundle module (with a - // head-injected fallback in gpt_bootstrap.js) where it is executable - // under Vitest (schedule_initial_ad_init.test.ts); this inline - // script must only delegate to that scheduler. - assert!( - script.contains("var s=t.scheduleInitialAdInit"), - "should delegate deferral to the installed scheduler" - ); - // The bids payload is handed to the scheduler (which applies it only - // while the page is still on navigation generation 0) instead of - // being assigned unconditionally, so a faster SPA navigation's live - // bids cannot be clobbered by the stale SSR payload. - assert!( - script.contains("if(typeof s===\"function\")s(b)"), - "should pass the SSR bids payload to the scheduler" - ); - assert!( - script.contains("else t.bids=b"), - "should fall back to a plain bids assignment without a scheduler" - ); - assert!( - !script.contains(".bids=JSON.parse"), - "should not assign the SSR payload unconditionally" - ); - // The one hydration-unsafe thing this script could do is invoke - // adInit synchronously at body-parse time — it must not. - assert!( - !script.contains("adInit()"), - "should not invoke adInit synchronously at parse time" - ); - assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" - ); + let script = state + .script_cell() + .lock() + .expect("should lock bid script") + .clone() + .expect("should render bid script"); + assert!(script.contains("s(b,void 0,void 0,d)")); + assert!(script.contains("auctionDispatchedMs")); + assert!(script.contains("auctionResolvedMs")); + assert!(script.contains("auctionCommittedMs")); + assert!(script.contains("auctionWaitMs")); + assert!(script.contains("in_stream")); + } + + #[test] + fn timing_updates_preserve_trace_transport_in_both_scheduler_seams() { + let trace = TraceAuctionCarry::capture_if_enabled( + true, + TraceAuctionSource::InitialNavigationSsat, + &test_auction_request().slots, + ) + .expect("should capture the enabled auction"); + trace.finish(TraceAuctionTerminalStatus::Completed, None); + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.record_auction_wait(AuctionWaitPlacement::InStream, Duration::ZERO); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + let mut state = AdBidsState::with_diagnostics(true, true); + state.attach_trace(trace); + state.set_auction_diagnostics(&timings); + state.prepend_to_script(""); + + let inline = state + .script_cell() + .lock() + .expect("should lock bid script") + .clone() + .expect("should render bid script"); + let shared = state.build_seam_script("[]"); + for (script, scheduler) in [(&inline, "s(b,void 0,x,d)"), (&shared, "s(b,a,x,d)")] { + assert!( + script.contains(scheduler), + "should carry both scheduler payloads" + ); + assert!( + script.contains("terminal_status"), + "should retain trace transport" + ); + assert!( + script.contains("auctionWaitMs"), + "should retain measured timing" + ); + assert!(script.starts_with("")); + } + } + + #[test] + fn timing_updates_cannot_reenable_withheld_ad_initialization() { + let trace = TraceAuctionCarry::capture_if_enabled( + true, + TraceAuctionSource::InitialNavigationSsat, + &test_auction_request().slots, + ) + .expect("should capture the enabled auction"); + trace.finish(TraceAuctionTerminalStatus::Completed, None); + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + let mut state = AdBidsState::with_diagnostics(true, true); + state.attach_trace(trace); + state.withhold_ad_initialization(); + + state.set_auction_diagnostics(&timings); + + let script = state + .script_cell() + .lock() + .expect("should lock bid script") + .clone() + .expect("should render trace-only script"); + assert!( + script.contains("observeTransport"), + "should keep trace observation" + ); + assert!( + !script.contains("scheduleInitialAdInit"), + "should not initialize withheld ads" + ); + assert!( + !script.contains("auctionDiagnostics"), + "should keep the trace-only seam" + ); + } + + #[test] + fn browser_auction_diagnostics_preserve_measured_zero_wait_instead_of_offset_difference() { + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + std::thread::sleep(Duration::from_millis(5)); + timings.record_auction_wait(AuctionWaitPlacement::PreHeader, Duration::ZERO); + timings.mark_auction_resolved(); + let diagnostics = BrowserAuctionDiagnostics::from_request_timings(&timings) + .expect("should expose measured auction timing"); + assert_eq!(diagnostics.auction_wait_ms, Some(0)); + assert_eq!(diagnostics.auction_wait_placement, Some("pre_header")); + assert!(diagnostics.auction_resolved_ms > diagnostics.auction_dispatched_ms); + } + + #[test] + fn active_diagnostics_without_attached_collector_omits_auction_timing() { + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + let state = AdBidsState::with_diagnostics(true, false); + state.set(serde_json::Map::new()); + + state.set_auction_diagnostics(&timings); + + let script = state + .script_cell() + .lock() + .expect("should lock bid script") + .clone() + .expect("should render bid script"); + assert!(!script.contains("auctionDispatchedMs")); + assert!(!script.contains("auctionResolvedMs")); + } + + #[test] + fn inactive_diagnostics_omits_auction_timing_from_the_bid_script() { + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + let state = AdBidsState::default(); + state.set(serde_json::Map::new()); + + state.set_auction_diagnostics(&timings); + + let script = state + .script_cell() + .lock() + .expect("should lock bid script") + .clone() + .expect("should render bid script"); + assert!(!script.contains("auctionDiagnostics")); + assert!(!script.contains("auctionDispatchedMs")); + } + + #[test] + fn bids_script_defers_ad_init_until_after_hydration() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + + let script = build_bids_script(&map); + + // adInit() mutates ad-slot subtrees (GPT defineSlot on the + // `-container` wrapper). Running it synchronously at body-parse time + // lands those mutations inside React's hydration window and trips a + // #418 hydration mismatch. The deferral lifecycle (window `load`, + // double `requestAnimationFrame`, generation-0 pinning via + // `tsjs.navGeneration`) lives in the GPT bundle module (with a + // head-injected fallback in gpt_bootstrap.js) where it is executable + // under Vitest (schedule_initial_ad_init.test.ts); this inline + // script must only delegate to that scheduler. + assert!( + script.contains("var s=t.scheduleInitialAdInit"), + "should delegate deferral to the installed scheduler" + ); + // The bids payload is handed to the scheduler (which applies it only + // while the page is still on navigation generation 0) instead of + // being assigned unconditionally, so a faster SPA navigation's live + // bids cannot be clobbered by the stale SSR payload. + assert!( + script.contains("if(typeof s===\"function\")s(b)"), + "should pass the SSR bids payload to the scheduler" + ); + assert!( + script.contains("else t.bids=b"), + "should fall back to a plain bids assignment without a scheduler" + ); + assert!( + !script.contains(".bids=JSON.parse"), + "should not assign the SSR payload unconditionally" + ); + // The one hydration-unsafe thing this script could do is invoke + // adInit synchronously at body-parse time — it must not. + assert!( + !script.contains("adInit()"), + "should not invoke adInit synchronously at parse time" + ); + assert!( + !script.contains("setTimeout"), + "should not retry adInit on a timer" + ); } #[test] @@ -28988,6 +30302,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { use super::super::*; use super::build_services_with_http_client; use crate::auction::AuctionOrchestrator; + use crate::auction::provider::test_support::LaunchEvidenceProvider as Mediator; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::types::{AuctionRequest, AuctionResponse, Bid}; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; @@ -29186,6 +30501,12 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { make_page_bids_request_on(PAGE_BIDS_PATH, path) } + fn make_active_page_bids_request(path: &str) -> Request { + let mut req = make_page_bids_request(path); + set_test_header(&mut req, "cookie", "__Host-ts-console=1"); + req + } + #[tokio::test] async fn page_bids_format_absent_or_json_returns_json() { let settings = settings_with_co(); @@ -29607,6 +30928,383 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { } } + #[tokio::test] + async fn active_page_bids_omits_timings_when_no_provider_dispatches() { + let mut settings = settings_with_co(); + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&["missing-provider"]); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + let mut req = make_active_page_bids_request("/2024/01/my-article/"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + let mut ec_context = consent_allowing_ec_context(); + let response = handle_page_bids( + &settings, + &noop_services(), + None, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + req, + ) + .await + .expect("should produce a no-bid response"); + let body_bytes = response + .into_body() + .into_bytes() + .expect("should read page-bids response body"); + let body: serde_json::Value = + serde_json::from_slice(&body_bytes).expect("should parse page-bids response"); + + assert!( + body.get("auctionDiagnostics").is_none(), + "should omit diagnostics when no provider dispatched" + ); + let snapshot = timings.snapshot(); + assert!(snapshot.auction_id.is_some()); + assert!(snapshot.auction_dispatched_ms.is_none()); + assert!(snapshot.auction_resolved_ms.is_none()); + assert!(snapshot.auction_committed_ms.is_none()); + } + + #[tokio::test] + async fn active_page_bids_zero_provider_path_leaves_handler_collector_unresolved() { + let mut settings = settings_with_co(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let orchestrator = AuctionOrchestrator::from_plan( + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile empty planned auction"), + ), + None, + ); + let slots = article_slot(); + let mut req = make_active_page_bids_request("/2024/01/my-article/"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + let mut ec_context = consent_allowing_ec_context(); + + let response = handle_page_bids( + &settings, + &noop_services(), + None, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + req, + ) + .await + .expect("should produce a no-bid response without providers"); + + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = response + .into_body() + .into_bytes() + .expect("should read zero-provider page-bids body"); + let body: serde_json::Value = serde_json::from_slice(&body_bytes) + .expect("should parse zero-provider page-bids body"); + assert!( + body.get("auctionDiagnostics").is_none(), + "should omit diagnostics when no provider launched" + ); + let snapshot = timings.snapshot(); + assert!(snapshot.auction_dispatched_ms.is_none()); + assert!(snapshot.auction_resolved_ms.is_none()); + assert!(snapshot.auction_committed_ms.is_none()); + } + + #[tokio::test] + async fn page_bids_mediator_collection_requires_actual_launch_evidence() { + for configured_provider in [false, true] { + for (mediator, wait_failure) in [ + (Mediator::Immediate, false), + (Mediator::FailBeforeStart, false), + (Mediator::FailAfterStart, false), + (Mediator::Pending, false), + (Mediator::Pending, true), + ] { + let mut settings = settings_with_co(); + let provider = if configured_provider { + "[providers.skipped]\nprotocol = \"openrtb-2.6\"\nprofile = \"standard\"\nendpoint = \"https://bidder.example.com/auction\"\nrouting = \"explicit\"\n" + } else { + "" + }; + settings.auction = toml::from_str(&format!( + "enabled = true\nmediator = \"adserver_mock\"\n{provider}" + )) + .expect("should parse mediator-only collection settings"); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({"enabled": true})) + .expect("should enable diagnostics"); + let orchestrator = AuctionOrchestrator::from_plan( + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile mediator collection plan"), + ), + Some(Arc::new(mediator)), + ); + let http = Arc::new(StubHttpClient::new()); + http.push_response(204, Vec::new()); + if wait_failure { + http.push_wait_error(); + } + let services = build_services_with_http_client(http.clone()); + let slots = article_slot(); + let mut req = make_active_page_bids_request("/2024/01/my-article/"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + let mut ec_context = consent_allowing_ec_context(); + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + req, + ) + .await + .expect("should return mediator page-bids response"); + let body: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should read page-bids response"), + ) + .expect("should parse mediator page-bids response"); + let launched = configured_provider + && matches!(mediator, Mediator::FailAfterStart | Mediator::Pending); + assert_eq!( + http.recorded_backend_names().len(), + usize::from(launched), + "{configured_provider} {mediator:?}" + ); + let snapshot = timings.snapshot(); + assert!(snapshot.auction_dispatched_ms.is_none()); + assert_eq!(snapshot.auction_resolved_ms.is_some(), launched); + assert_eq!(snapshot.auction_committed_ms.is_some(), launched); + assert_eq!(snapshot.auction_wait_ms.is_some(), launched); + assert_eq!( + snapshot.auction_wait_placement, + launched.then_some(AuctionWaitPlacement::PreHeader) + ); + assert_eq!( + body.get("auctionDiagnostics").is_some(), + launched, + "{configured_provider} {mediator:?}" + ); + if launched { + let diagnostics = &body["auctionDiagnostics"]; + assert!(diagnostics.get("auctionDispatchedMs").is_none()); + assert_eq!( + diagnostics["auctionWaitMs"].as_u64(), + snapshot.auction_wait_ms.map(u64::from) + ); + assert_eq!(diagnostics["auctionWaitPlacement"], "pre_header"); + } + } + } + } + + #[tokio::test] + async fn planned_page_bids_keeps_installed_collector_after_launch_and_transport_failure() { + for (transport_failure, metadata_missing) in + [(false, false), (true, false), (false, true)] + { + let mut settings = settings_with_co(); + settings.auction = toml::from_str( + r#" + enabled = true + [providers.example] + protocol = "openrtb-2.6" + profile = "standard" + endpoint = "https://bidder.example.com/auction" + routing = "all_eligible" + "#, + ) + .expect("should parse a production provider plan"); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let orchestrator = AuctionOrchestrator::from_plan( + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile page-bids provider plan"), + ), + None, + ); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(204, Vec::new()); + if transport_failure { + stub.push_select_error(); + } + if metadata_missing { + stub.push_pending_backend_name_override(None); + } + let services = build_services_with_http_client(stub.clone()); + let slots = article_slot(); + let mut req = make_active_page_bids_request("/2024/01/my-article/"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + let mut ec_context = consent_allowing_ec_context(); + + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + req, + ) + .await + .expect("should complete page-bids after a real launch"); + + assert_eq!( + response.status(), + StatusCode::OK, + "should retain a no-bid response after transport failure" + ); + assert_eq!( + stub.recorded_backend_names().len(), + 1, + "should launch the planned provider" + ); + let snapshot = timings.snapshot(); + assert!( + snapshot.auction_id.is_some(), + "should stamp the observation UUID in the original handle" + ); + assert_eq!(snapshot.auction_wait_ms.is_some(), !metadata_missing); + assert_eq!( + snapshot.auction_wait_placement, + (!metadata_missing).then_some(AuctionWaitPlacement::PreHeader) + ); + let dispatched = snapshot + .auction_dispatched_ms + .expect("should mark dispatch"); + let body: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should read page-bids response body"), + ) + .expect("should parse page-bids response"); + if metadata_missing { + assert!( + snapshot.auction_resolved_ms.is_none(), + "should not fabricate collection after losing the pending handle" + ); + assert!( + snapshot.auction_committed_ms.is_none(), + "should not commit a failed dispatch" + ); + assert!( + body.get("auctionDiagnostics").is_none(), + "should omit completed-auction diagnostics after dispatch failure" + ); + } else { + let resolved = snapshot + .auction_resolved_ms + .expect("should mark collection"); + let committed = snapshot + .auction_committed_ms + .expect("should commit targeting"); + assert!( + dispatched <= resolved && resolved <= committed, + "should preserve ordering after success or transport failure" + ); + assert_eq!( + body["auctionDiagnostics"]["auctionDispatchedMs"].as_u64(), + Some(u64::from(dispatched)), + "should expose the installed collector rather than a handler-local clock" + ); + assert_eq!( + body["auctionDiagnostics"]["auctionWaitMs"].as_u64(), + snapshot.auction_wait_ms.map(u64::from) + ); + assert_eq!( + body["auctionDiagnostics"]["auctionWaitPlacement"], + "pre_header" + ); + } + } + } + + #[tokio::test] + async fn active_page_bids_omits_timings_without_adapter_collector() { + let mut settings = settings_with_co(); + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&[AUCTION_ID_TEST_PROVIDER]); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let slots = article_slot(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"winner".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let orchestrator = + auction_id_test_orchestrator(&settings, Arc::new(Mutex::new(None)), true); + let mut ec_context = consent_allowing_ec_context(); + + let response = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + make_active_page_bids_request("/2024/01/my-article/"), + ) + .await + .expect("should return page-bids response"); + let body: serde_json::Value = serde_json::from_slice( + &response + .into_body() + .into_bytes() + .expect("should read page-bids response body"), + ) + .expect("should serialize page-bids response as JSON"); + + assert!( + body.get("auctionDiagnostics").is_none(), + "missing adapter timing context must not emit handler-local timing facts" + ); + } + #[tokio::test] async fn page_bids_response_includes_auction_id_only_for_winning_bids() { let mut settings = settings_with_co(); @@ -29619,6 +31317,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let slots = article_slot(); let winning_stub = Arc::new(StubHttpClient::new()); winning_stub.push_response(200, b"winner".to_vec()); + winning_stub.push_response(200, b"winner".to_vec()); let winning_services = build_services_with_http_client( Arc::clone(&winning_stub) as Arc ); @@ -29633,6 +31332,14 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { }, ); + let mut winning_page_bids_request = + make_active_page_bids_request("/2024/01/my-article/"); + let request_timings = RequestTimings::new(); + winning_page_bids_request + .extensions_mut() + .insert(request_timings.handle().clone()); + std::thread::sleep(std::time::Duration::from_millis(2)); + let winning_response = handle_page_bids( &settings, &winning_services, @@ -29644,7 +31351,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { registry: None, }, &mut ec_context, - make_page_bids_request("/2024/01/my-article/"), + winning_page_bids_request, ) .await .expect("should return winning page-bids response"); @@ -29665,10 +31372,45 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { auction_request.id, "ts-page-auction-example-123", "test EC ID should produce a deterministic auction request ID" ); + let timing_snapshot = request_timings.snapshot(); + assert!(timing_snapshot.auction_dispatched_ms.is_some()); + assert!(timing_snapshot.auction_resolved_ms.is_some()); + assert!(timing_snapshot.auction_committed_ms.is_some()); let winning_auction_id = winning_body["bids"]["atf"]["hb_auction_id"] .as_str() .expect("page-bids should expose an auction ID on the winner") .to_string(); + let auction_diagnostics = winning_body["auctionDiagnostics"] + .as_object() + .expect("an active session should expose page-bids auction diagnostics"); + let timing = |field| { + auction_diagnostics + .get(field) + .and_then(serde_json::Value::as_u64) + .unwrap_or_else(|| panic!("page-bids diagnostics should include {field}")) + }; + let dispatched_ms = timing("auctionDispatchedMs"); + let resolved_ms = timing("auctionResolvedMs"); + let committed_ms = timing("auctionCommittedMs"); + let wait_ms = timing("auctionWaitMs"); + assert!( + dispatched_ms > 0, + "page-bids dispatch should include time since adapter request entry" + ); + assert!( + dispatched_ms <= resolved_ms && resolved_ms <= committed_ms, + "page-bids auction milestones should be monotonic" + ); + assert_eq!( + wait_ms, + u64::from( + timing_snapshot + .auction_wait_ms + .expect("should measure collection wait") + ), + "browser wait should use the measured collection interval" + ); + assert_eq!(auction_diagnostics["auctionWaitPlacement"], "pre_header"); assert!( winning_auction_id.starts_with("ts-auc-"), "page-bids should expose a freshly minted diagnostics token, got `{winning_auction_id}`" @@ -29682,6 +31424,33 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { "browser-visible auction ID must not embed the EC ID" ); + let inactive_winning_response = handle_page_bids( + &settings, + &winning_services, + None, + AuctionDispatch { + integration_registry: &IntegrationRegistry::empty_for_tests(), + orchestrator: &winning_orchestrator, + slots: &slots, + registry: None, + }, + &mut ec_context, + make_page_bids_request("/2024/01/my-article/"), + ) + .await + .expect("should return inactive winning page-bids response"); + let inactive_winning_body: serde_json::Value = serde_json::from_slice( + &inactive_winning_response + .into_body() + .into_bytes() + .expect("should read inactive winning page-bids response body"), + ) + .expect("should serialize inactive winning page-bids response as JSON"); + assert!( + inactive_winning_body.get("auctionDiagnostics").is_none(), + "an inactive session should not receive successful auction diagnostics" + ); + let no_winner_stub = Arc::new(StubHttpClient::new()); no_winner_stub.push_response(200, b"no-bid".to_vec()); let no_winner_services = build_services_with_http_client( @@ -29719,6 +31488,10 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .is_empty(), "page-bids should not fabricate auction metadata without a winner" ); + assert!( + no_winner_body.get("auctionDiagnostics").is_none(), + "an inactive session should not receive page-bids auction diagnostics" + ); } /// The browser-visible auction ID is minted per auction and only for @@ -29935,6 +31708,39 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { ); } + #[tokio::test] + async fn cross_site_request_is_denied_before_diagnostics_configuration_errors() { + let mut settings = settings_with_co(); + settings + .integrations + .insert_config( + "gpt_diagnostics", + &serde_json::json!({ "enabled": true, "unexpected": true }), + ) + .expect("should insert invalid diagnostics configuration"); + let mut probe = make_page_bids_request("/2024/01/my-article/"); + assert!( + crate::integrations::gpt_diagnostics::prepare_request(&settings, &mut probe) + .is_err(), + "should exercise a diagnostics configuration error" + ); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + + for endpoint in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { + let mut req = make_page_bids_request_on(endpoint, "/2024/01/my-article/"); + set_test_header(&mut req, "sec-fetch-site", "cross-site"); + + let response = + run_page_bids_response(&settings, &orchestrator, &article_slot(), req).await; + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "should deny cross-site requests before diagnostics preparation on {endpoint}" + ); + } + } + #[tokio::test] async fn cross_site_fetch_metadata_is_rejected() { let settings = settings_with_co(); @@ -30207,9 +32013,28 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let settings = settings_with_co_auction_disabled(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let slots = article_slot(); - let req = make_page_bids_request("/2024/01/my-article/"); - - let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + let mut req = make_page_bids_request("/2024/01/my-article/"); + let timings = RequestTimings::new(); + req.extensions_mut().insert(timings.handle().clone()); + let mut ec_context = consent_allowing_ec_context(); + let response = run_page_bids_response_with_ec( + &settings, + &orchestrator, + &slots, + &mut ec_context, + req, + ) + .await; + let body_bytes = response + .into_body() + .into_bytes() + .expect("should read disabled page-bids response body"); + let body: serde_json::Value = serde_json::from_slice(&body_bytes) + .expect("should parse disabled page-bids response"); + assert!( + timings.snapshot().auction_id.is_none(), + "should keep the disabled page-bids access join key null" + ); assert_eq!( body["slots"] diff --git a/crates/trusted-server-core/src/request_timing.rs b/crates/trusted-server-core/src/request_timing.rs new file mode 100644 index 0000000000..6c8d2f99eb --- /dev/null +++ b/crates/trusted-server-core/src/request_timing.rs @@ -0,0 +1,974 @@ +//! Per-request phase timing collection and Server-Timing rendering. +//! +//! Collection delegates to `EdgeZero` with saturating math and nonblocking +//! updates. Contention drops the whole sample; poisoned locks recover +//! best-effort without rollback. TS callbacks only assign plain optional facts +//! under the shared clock and mutex. Rendering and exposure remain TS policy. +//! See the design spec +//! `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md`. + +use std::time::Duration; + +use http::{Extensions, HeaderName, HeaderValue, Response}; +use uuid::Uuid; + +use crate::cache_policy::{ + EDGE_CACHE_HEADER_NAMES, cache_control_headers_have_bare_private_or_no_store, +}; + +/// `Server-Timing` header name. Not present in the `http` crate's `header` +/// module (unlike `CACHE_CONTROL` etc.), so declared locally following the +/// same `HeaderName::from_static` pattern used in +/// `trusted_server_core::constants`. +const HEADER_SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing"); + +/// Number of [`Phase`] variants; sizes the fixed-slot duration array in +/// the shared `EdgeZero` collector. +const PHASE_COUNT: usize = 8; + +/// A distinct stage of request handling that duration can be attributed to. +/// +/// Variants map to fixed slots in [`RequestTimings`], in declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + /// Time spent constructing the app/handler before request processing + /// begins. + AppBuild, + /// Time spent in request/response filtering (e.g. HTML rewriting). + Filter, + /// Time spent resolving geographic signals for the request. + Geo, + /// Time spent reading or writing the Edge Cookie key-value store. + EcKv, + /// Time spent waiting on the origin fetch. + Origin, + /// Time spent looking up a cached template. + TemplateCacheLookup, + /// Time spent waiting on the auction. Row-only: never rendered as a + /// `Server-Timing` header entry. + AuctionWait, + /// Time spent streaming the response body. Row-only: never rendered as + /// a `Server-Timing` header entry. + Stream, +} + +impl Phase { + /// Maps this variant to its fixed slot in the generic phase array. + fn index(self) -> usize { + match self { + Self::AppBuild => 0, + Self::Filter => 1, + Self::Geo => 2, + Self::EcKv => 3, + Self::Origin => 4, + Self::TemplateCacheLookup => 5, + Self::AuctionWait => 6, + Self::Stream => 7, + } + } + + /// `Server-Timing` header entry name; row-only phases return `None`. + fn header_name(self) -> Option<&'static str> { + match self { + Self::AppBuild => Some("ts-appbuild"), + Self::Filter => Some("ts-filter"), + Self::Geo => Some("ts-geo"), + Self::EcKv => Some("ts-kv"), + Self::Origin => Some("ts-origin"), + Self::TemplateCacheLookup => Some("ts-template-cache"), + Self::AuctionWait | Self::Stream => None, + } + } +} + +/// Where in the response the auction wait occurred. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuctionWaitPlacement { + /// The auction was awaited before response headers were sent. + PreHeader, + /// The auction was awaited while streaming the response body. + InStream, +} + +/// Auction facts sharing the generic collector's origin and mutex. +/// +/// Updates only assign plain optional facts, preserving validity on unwind. +#[derive(Default)] +pub struct AuctionTimingData { + auction_wait_placement: Option, + auction_dispatched: Option, + auction_resolved: Option, + auction_committed: Option, + auction_id: Option, +} + +/// The sole timing type installed in request extensions on every adapter. +pub type RequestTimingHandle = + edgezero_core::request_timing::RequestTimings; + +/// Shared attachment middleware specialized to Trusted Server's timing payload. +/// Exclusion policy belongs at each adapter's registration site. +pub type RequestTimingMiddleware = + edgezero_core::middleware::RequestTimingMiddleware; + +/// Typed application facade over the installed `EdgeZero` handle, not an extension. +/// +/// Clones share one origin and mutex. Contention drops samples or yields missing +/// facts. `EdgeZero` recovers poisoned locks best-effort, without rollback; the +/// callbacks here only update plain optional facts and cannot invalidate payloads. +#[derive(Clone)] +pub struct RequestTimings(RequestTimingHandle); + +impl RequestTimings { + /// Starts a new collector with its clock reference (`t0`) set to now. + #[must_use] + pub fn new() -> Self { + Self(RequestTimingHandle::new()) + } + + /// Wraps the installed generic handle without creating a clock or copying facts. + #[must_use] + pub fn from_extensions(extensions: &Extensions) -> Option { + extensions.get::().cloned().map(Self) + } + + /// Returns the shared generic handle for insertion into request extensions. + #[must_use] + pub fn handle(&self) -> &RequestTimingHandle { + &self.0 + } + + /// Accumulates `dur` into `phase`'s running total. + /// + /// Repeated calls for the same phase saturate-add rather than overwrite. + /// Drops the sample silently on lock contention; a poisoned lock is recovered. + pub fn record(&self, phase: Phase, dur: Duration) { + let _ = self.0.record(phase.index(), dur); + } + + /// Records an auction wait duration under [`Phase::AuctionWait`] and + /// stores its placement. + /// + /// Callers record only when at least one provider request actually started. + /// A measured zero duration is retained; no-launch collection leaves the + /// duration and placement unset. Publisher collectors and the `/auction` + /// and `/_ts/page-bids` endpoints measure the collection interval here, + /// including mediator-only collection when an upstream request started. + /// Drops the sample silently on lock contention; a poisoned lock is recovered. + pub fn record_auction_wait(&self, placement: AuctionWaitPlacement, dur: Duration) { + let _ = self + .0 + .record_with(Phase::AuctionWait.index(), dur, |data, _| { + data.auction_wait_placement = Some(placement); + }); + } + + /// Starts a guard that records elapsed time into `phase` when dropped. + /// + /// # Panics + /// Panics only if the internal exhaustive phase-to-slot mapping is invalid. + pub fn span(&self, phase: Phase) -> PhaseSpan { + self.0 + .span(phase.index()) + .expect("should map every Phase to a valid slot") + } + + /// Stamps the elapsed time since `t0` as `headers_ready_total`, the + /// first time this is called. + /// + /// Subsequent calls are no-ops (first call wins). Drops the sample + /// silently on lock contention; a poisoned lock is recovered. + pub fn mark_headers_ready(&self) { + let _ = self.0.mark_headers_ready(); + } + + /// Stamps the elapsed time since `t0` as `request_elapsed`, the first + /// time this is called. + /// + /// Subsequent calls are no-ops (first call wins). Drops the sample + /// silently on lock contention; a poisoned lock is recovered. + pub fn mark_request_elapsed(&self) { + let _ = self.0.mark_request_complete(); + } + + /// Records the telemetry auction id, the first time this is called. + /// + /// Called where an enabled auction's observation is built, before any skip, + /// dispatch-failure or launch outcome, so the access row can be joined to the + /// `auction_events_raw` row even when the auction was skipped or failed to + /// dispatch. Disabled auctions are not stamped, so their `auction_disabled` + /// summary rows have no access join key. Kept separate from + /// [`RequestTimings::mark_auction_dispatched`] so a dropped dispatch + /// sample cannot also lose the join key. Subsequent calls are no-ops + /// (first call wins). Drops the sample silently on lock contention; a + /// poisoned lock is recovered. + pub fn set_auction_id(&self, auction_id: Uuid) { + let _ = self.0.update_data(|data, _| { + data.auction_id.get_or_insert(auction_id); + }); + } + + /// Stamps the elapsed time since `t0` as the auction dispatch offset, + /// the first time this is called. + /// + /// Called after at least one provider starts a request. + /// Routing-only skipped outcomes and zero-launch + /// failures do not stamp it. A null offset alongside a non-null + /// `auction_id` means no provider launch was observed during dispatch. + /// A mediator started later during collection does not stamp this offset. + /// Subsequent calls + /// are no-ops (first call wins). + /// Drops the sample silently on lock contention; a poisoned lock is + /// recovered. + pub fn mark_auction_dispatched(&self) { + let _ = self.0.update_data(|data, elapsed| { + data.auction_dispatched.get_or_insert(elapsed); + }); + } + + /// Stamps the elapsed time since `t0` when auction collection returns, + /// the first time this is called. + /// + /// Collection may follow transport completion on streaming paths. This + /// measures the caller's collection point, not the final network reply. + /// + /// Stays `None` when a dispatched auction is abandoned before collection + /// (origin error, bodiless response, reader disconnect), so a non-null + /// dispatch offset with a null resolve offset is the "dispatched, never + /// collected" case rather than "no auction ran". Subsequent calls are + /// no-ops (first call wins). Drops the sample silently on lock + /// contention; a poisoned lock is recovered. + pub fn mark_auction_resolved(&self) { + let _ = self.0.update_data(|data, elapsed| { + data.auction_resolved.get_or_insert(elapsed); + }); + } + + /// Stamps the elapsed time since `t0` as the auction commit offset + /// (winning bids available to the response pipeline), the first time + /// this is called. + /// + /// Subsequent calls are no-ops (first call wins). Drops the sample + /// silently on lock contention; a poisoned lock is recovered. + pub fn mark_auction_committed(&self) { + let _ = self.0.update_data(|data, elapsed| { + data.auction_committed.get_or_insert(elapsed); + }); + } + + /// Records the response body size in bytes. + /// + /// Drops the sample silently on lock contention; a poisoned lock is recovered. + pub fn set_resp_bytes(&self, bytes: u64) { + let _ = self.0.set_response_bytes(bytes); + } + + /// Renders a `Server-Timing` header value, or `None` before + /// [`RequestTimings::mark_headers_ready`] has been called. + /// + /// `ts-total` is rendered first from `headers_ready_total`, followed by + /// the recorded header-bearing phases (`ts-appbuild`, `ts-filter`, + /// `ts-geo`, `ts-kv`, `ts-origin`, `ts-template-cache`) in enum + /// declaration order. Unrecorded phases are omitted. Durations are + /// rendered as milliseconds with one decimal place. Drops the sample + /// silently (returning `None`) on lock contention; poison is recovered. + #[must_use] + pub fn server_timing_value(&self) -> Option { + self.0 + .snapshot(|inner| { + let total = inner.headers_ready?; + let mut entries = vec![format_entry("ts-total", total)]; + for phase in HEADER_PHASES { + let Some(name) = phase.header_name() else { + continue; + }; + if let Some(dur) = inner.phases[phase.index()] { + entries.push(format_entry(name, dur)); + } + } + Some(entries.join(", ")) + }) + .ok() + .flatten() + } + + /// Captures the current state as a [`TimingSnapshot`]. + /// + /// Returns an all-`None` snapshot on lock contention, + /// consistent with the infallibility of every other method. + #[must_use] + pub fn snapshot(&self) -> TimingSnapshot { + self.0 + .snapshot(|inner| TimingSnapshot { + time_elapsed_ms: duration_ms(inner.headers_ready), + request_elapsed_ms: duration_ms(inner.request_complete), + appbuild_ms: duration_ms(inner.phases[Phase::AppBuild.index()]), + filter_ms: duration_ms(inner.phases[Phase::Filter.index()]), + geo_ms: duration_ms(inner.phases[Phase::Geo.index()]), + kv_ms: duration_ms(inner.phases[Phase::EcKv.index()]), + origin_ms: duration_ms(inner.phases[Phase::Origin.index()]), + template_cache_ms: duration_ms(inner.phases[Phase::TemplateCacheLookup.index()]), + auction_wait_ms: duration_ms(inner.phases[Phase::AuctionWait.index()]), + stream_ms: duration_ms(inner.phases[Phase::Stream.index()]), + auction_wait_placement: inner.data.auction_wait_placement, + resp_bytes: inner.response_bytes, + auction_dispatched_ms: duration_ms(inner.data.auction_dispatched), + auction_resolved_ms: duration_ms(inner.data.auction_resolved), + auction_committed_ms: duration_ms(inner.data.auction_committed), + auction_id: inner.data.auction_id, + }) + .unwrap_or_default() + } +} + +impl Default for RequestTimings { + fn default() -> Self { + Self::new() + } +} + +/// Stamps [`RequestTimings::mark_headers_ready`] and, when `enabled` and the +/// response is conclusively private, appends the rendered `Server-Timing` +/// header. +/// +/// Always stamps `mark_headers_ready` regardless of whether the header is +/// rendered, so the collector's `ts-total` reflects the moment headers +/// commit. Appends rather than overwrites so a pre-existing `Server-Timing` +/// value set upstream survives alongside the TS-owned set. A response is +/// never promoted to shared-cacheable just because the header would +/// otherwise be omitted: this only gates emission, it does not touch +/// `Cache-Control`. Emission is also suppressed when a recognized CDN or +/// surrogate cache-control header is present, since it may allow shared +/// caching despite a private `Cache-Control` value. Privacy requires a bare +/// `private` or `no-store` directive; field-qualified `private="..."` protects +/// only named headers and does not authorize request-specific timing emission. +/// +/// Generic over the response body type so every adapter's terminal layer can +/// call the same emission logic regardless of which body type its HTTP stack +/// uses. +pub fn append_server_timing_if_private( + response: &mut Response, + timings: &RequestTimings, + enabled: bool, +) { + timings.mark_headers_ready(); + if !enabled { + return; + } + + let headers = response.headers(); + let conclusively_private = cache_control_headers_have_bare_private_or_no_store(headers) + && !EDGE_CACHE_HEADER_NAMES + .iter() + .any(|name| headers.contains_key(*name)); + if !conclusively_private { + return; + } + + let Some(value) = timings.server_timing_value() else { + return; + }; + match HeaderValue::from_str(&value) { + Ok(header_value) => { + response + .headers_mut() + .append(HEADER_SERVER_TIMING, header_value); + } + Err(error) => log::warn!("skipping server-timing header: {error}"), + } +} + +/// The header-bearing phases (see [`Phase::header_name`]), in the enum +/// declaration order [`RequestTimings::server_timing_value`] renders them in. +const HEADER_PHASES: [Phase; 6] = [ + Phase::AppBuild, + Phase::Filter, + Phase::Geo, + Phase::EcKv, + Phase::Origin, + Phase::TemplateCacheLookup, +]; + +/// Formats one `Server-Timing` entry as `name;dur=`. +fn format_entry(name: &str, dur: Duration) -> String { + format!("{name};dur={:.1}", dur.as_secs_f64() * 1000.0) +} + +/// Converts a recorded [`Duration`] to whole milliseconds, saturating to +/// [`u32::MAX`] instead of overflowing. +fn duration_ms(dur: Option) -> Option { + dur.map(|dur| u32::try_from(dur.as_millis()).unwrap_or(u32::MAX)) +} + +/// RAII guard returned by [`RequestTimings::span`] that records its own +/// elapsed lifetime into the originating phase when dropped. +pub type PhaseSpan = edgezero_core::request_timing::PhaseSpan; + +/// A point-in-time, plain-data view of a [`RequestTimings`] collector. +/// +/// All durations are whole milliseconds; unrecorded phases are `None`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TimingSnapshot { + /// Elapsed time from request start to + /// [`RequestTimings::mark_headers_ready`], in milliseconds. + pub time_elapsed_ms: Option, + /// Elapsed time from request start to + /// [`RequestTimings::mark_request_elapsed`], in milliseconds. + pub request_elapsed_ms: Option, + /// Accumulated [`Phase::AppBuild`] duration, in milliseconds. + pub appbuild_ms: Option, + /// Accumulated [`Phase::Filter`] duration, in milliseconds. + pub filter_ms: Option, + /// Accumulated [`Phase::Geo`] duration, in milliseconds. + pub geo_ms: Option, + /// Accumulated [`Phase::EcKv`] duration, in milliseconds. + pub kv_ms: Option, + /// Accumulated [`Phase::Origin`] duration, in milliseconds. + pub origin_ms: Option, + /// Accumulated [`Phase::TemplateCacheLookup`] duration, in milliseconds. + pub template_cache_ms: Option, + /// Accumulated [`Phase::AuctionWait`] duration, in milliseconds. + pub auction_wait_ms: Option, + /// Accumulated [`Phase::Stream`] duration, in milliseconds. + pub stream_ms: Option, + /// Placement recorded by the most recent + /// [`RequestTimings::record_auction_wait`] call. + pub auction_wait_placement: Option, + /// Response body size in bytes, set via + /// [`RequestTimings::set_resp_bytes`]. + pub resp_bytes: Option, + /// T0 offset at which the auction dispatched (bid requests left the + /// edge). `None` when no bid request went out, which covers both "no + /// auction was attempted" and "the auction was skipped or failed to + /// dispatch" — `auction_id` separates the two. + pub auction_dispatched_ms: Option, + /// T0 offset at which the auction resolved (final bid or timeout). + /// `None` when the milestone was never reached, including a dispatched + /// auction abandoned before collection. + pub auction_resolved_ms: Option, + /// T0 offset at which winning bids became available to the response + /// pipeline. `None` when the milestone was never reached. + pub auction_committed_ms: Option, + /// Telemetry auction UUID joining this row to the auction dataset. + /// `None` only when no auction was attempted at all; an attempted + /// auction carries the id whatever its outcome, matching the row it + /// emits to `auction_events_raw`. + pub auction_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_and_header_compatibility_fixture() { + let timings = RequestTimings::new(); + let clone = timings.clone(); + clone.record(Phase::AppBuild, Duration::ZERO); + clone.record(Phase::Filter, Duration::from_micros(9_100)); + clone.record(Phase::Geo, Duration::MAX); + clone.record(Phase::Geo, Duration::from_secs(1)); + clone.record(Phase::EcKv, Duration::from_millis(3)); + clone.record(Phase::Origin, Duration::from_millis(4)); + clone.record(Phase::TemplateCacheLookup, Duration::from_millis(5)); + clone.record_auction_wait(AuctionWaitPlacement::PreHeader, Duration::from_millis(6)); + clone.record_auction_wait(AuctionWaitPlacement::InStream, Duration::from_millis(7)); + clone.record(Phase::Stream, Duration::from_millis(8)); + clone.set_resp_bytes(42); + clone.set_resp_bytes(99); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.appbuild_ms, Some(0)); + assert_eq!(snapshot.filter_ms, Some(9)); + assert_eq!(snapshot.geo_ms, Some(u32::MAX)); + assert_eq!(snapshot.auction_wait_ms, Some(13)); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::InStream) + ); + assert_eq!(snapshot.stream_ms, Some(8)); + assert_eq!(snapshot.resp_bytes, Some(99)); + assert_eq!(snapshot.request_elapsed_ms, None); + assert_eq!(RequestTimings::new().snapshot(), TimingSnapshot::default()); + timings.mark_headers_ready(); + let value = timings + .server_timing_value() + .expect("should render fixture"); + let entries: Vec<_> = value.split(", ").collect(); + assert_eq!(entries.len(), 7, "should omit row-only phases"); + assert!(entries[0].starts_with("ts-total;dur=")); + assert_eq!(entries[1], "ts-appbuild;dur=0.0"); + assert_eq!(entries[2], "ts-filter;dur=9.1"); + assert_eq!(entries[3], format_entry("ts-geo", Duration::MAX)); + assert_eq!( + &entries[4..], + &[ + "ts-kv;dur=3.0", + "ts-origin;dur=4.0", + "ts-template-cache;dur=5.0" + ] + ); + } + + #[test] + fn contention_drops_compound_update_and_omits_reads() { + let timings = RequestTimings::new(); + timings.record_auction_wait(AuctionWaitPlacement::PreHeader, Duration::from_millis(3)); + timings.mark_headers_ready(); + assert!(timings.server_timing_value().is_some()); + let before = timings.snapshot(); + // Deliberately hold the lock while probing unavailable operations in this + // test. Production callbacks only assign facts and never reenter. + timings + .0 + .update_data(|_, _| { + timings + .record_auction_wait(AuctionWaitPlacement::InStream, Duration::from_millis(7)); + timings.mark_request_elapsed(); + assert_eq!(timings.snapshot(), TimingSnapshot::default()); + assert_eq!(timings.server_timing_value(), None); + }) + .expect("should hold collector lock"); + assert_eq!( + timings.snapshot(), + before, + "should drop entire compound update" + ); + } + + #[test] + fn request_completion_is_first_write_and_header_append_preserves_upstream() { + let timings = RequestTimings::new(); + timings.mark_request_elapsed(); + let first = timings.snapshot().request_elapsed_ms; + std::thread::sleep(Duration::from_millis(5)); + timings.mark_request_elapsed(); + assert_eq!(timings.snapshot().request_elapsed_ms, first); + let mut response = Response::builder() + .header("cache-control", "private") + .header("server-timing", "upstream;dur=1.0") + .body(()) + .expect("should build private response"); + append_server_timing_if_private(&mut response, &timings, false); + assert!( + timings.snapshot().time_elapsed_ms.is_some(), + "should mark even when disabled" + ); + assert_eq!( + response.headers().get_all("server-timing").iter().count(), + 1 + ); + append_server_timing_if_private(&mut response, &timings, true); + let values: Vec<_> = response.headers().get_all("server-timing").iter().collect(); + assert_eq!(values.len(), 2); + assert_eq!(values[0], "upstream;dur=1.0"); + assert_eq!(response.headers()["cache-control"], "private"); + } + + #[test] + fn every_phase_index_is_unique_and_in_bounds() { + // `PHASE_COUNT` and `Phase::index()` are hand-synced; nothing at + // compile time ties them together. A new variant whose `index()` + // returns `PHASE_COUNT` would silently lose records or panic when + // starting a span, so this test fails first instead. (A variant missing from this list is a compile + // error via the exhaustive `match` in `index()` once added there.) + let phases = [ + Phase::AppBuild, + Phase::Filter, + Phase::Geo, + Phase::EcKv, + Phase::Origin, + Phase::TemplateCacheLookup, + Phase::AuctionWait, + Phase::Stream, + ]; + let mut seen = [false; PHASE_COUNT]; + for phase in phases { + let index = phase.index(); + assert!( + index < PHASE_COUNT, + "should be in bounds: {phase:?} -> {index}" + ); + assert!(!seen[index], "should be unique: {phase:?} -> {index}"); + seen[index] = true; + } + assert!( + seen.iter().all(|slot| *slot), + "should cover every phases-array slot" + ); + + // `HEADER_PHASES` is a second hand-synced list: a phase that gains + // a `header_name()` but is never added there silently renders + // nothing in the `Server-Timing` value. + let header_bearing: Vec = phases + .into_iter() + .filter(|phase| phase.header_name().is_some()) + .collect(); + assert_eq!( + header_bearing, + HEADER_PHASES.to_vec(), + "should render every header-bearing phase, in declaration order" + ); + } + + #[test] + fn auction_marks_are_first_call_wins_and_snapshot_maps_them() { + let timings = RequestTimings::new(); + timings.set_auction_id(uuid::uuid!("11111111-1111-1111-1111-111111111111")); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + let first = timings.snapshot(); + + // Sleep so a restamp would land on a different millisecond: without + // it both stamps fall in the same millisecond and an equality + // assertion would hold even under last-call-wins. + std::thread::sleep(Duration::from_millis(5)); + timings.set_auction_id(uuid::uuid!("22222222-2222-2222-2222-222222222222")); + timings.mark_auction_dispatched(); + timings.mark_auction_resolved(); + timings.mark_auction_committed(); + + let second = timings.snapshot(); + assert_eq!( + second.auction_dispatched_ms, first.auction_dispatched_ms, + "should not restamp the dispatch offset" + ); + assert_eq!( + second.auction_resolved_ms, first.auction_resolved_ms, + "should not restamp the resolve offset" + ); + assert_eq!( + second.auction_committed_ms, first.auction_committed_ms, + "should not restamp the commit offset" + ); + assert_eq!( + second.auction_id, + Some(uuid::uuid!("11111111-1111-1111-1111-111111111111")), + "should keep the first-recorded auction id" + ); + } + + #[test] + fn auction_id_survives_a_dropped_dispatch_mark() { + // The join key is stamped where the observation is built, so an + // auction that is skipped or fails to dispatch still carries the id + // that its `auction_events_raw` row was emitted under. + let timings = RequestTimings::new(); + timings.set_auction_id(uuid::uuid!("44444444-4444-4444-4444-444444444444")); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_id, + Some(uuid::uuid!("44444444-4444-4444-4444-444444444444")), + "should carry the join key without a dispatch mark" + ); + assert_eq!( + snapshot.auction_dispatched_ms, None, + "should leave the dispatch offset null when nothing was sent" + ); + } + + #[test] + fn snapshot_without_auction_marks_yields_none_for_all_offsets() { + let timings = RequestTimings::new(); + timings.mark_headers_ready(); + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_dispatched_ms, None, + "should stay None when no auction dispatched" + ); + assert_eq!( + snapshot.auction_resolved_ms, None, + "should stay None when no auction resolved" + ); + assert_eq!( + snapshot.auction_committed_ms, None, + "should stay None when no auction committed" + ); + assert_eq!( + snapshot.auction_id, None, + "should carry no auction id when no auction ran" + ); + } + + #[test] + fn render_omits_unrecorded_phases_and_orders_total_first() { + let timings = RequestTimings::new(); + timings.record(Phase::Filter, Duration::from_micros(9_100)); + timings.mark_headers_ready(); + let value = timings + .server_timing_value() + .expect("should render after mark_headers_ready"); + assert!( + value.starts_with("ts-total;dur="), + "should lead with ts-total: {value}" + ); + assert!( + value.contains("ts-filter;dur=9.1"), + "should render one decimal: {value}" + ); + assert!( + !value.contains("ts-geo"), + "should omit unrecorded phases: {value}" + ); + } + + #[test] + fn render_returns_none_before_headers_ready() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(1)); + assert!( + timings.server_timing_value().is_none(), + "should require the snapshot" + ); + } + + #[test] + fn repeated_phases_accumulate_saturating() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(2)); + timings.record(Phase::Geo, Duration::from_millis(3)); + timings.mark_headers_ready(); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.geo_ms, Some(5), "should accumulate repeats"); + } + + #[test] + fn mark_headers_ready_is_first_call_wins() { + let timings = RequestTimings::new(); + timings.mark_headers_ready(); + let first = timings.snapshot().time_elapsed_ms; + std::thread::sleep(Duration::from_millis(5)); + timings.mark_headers_ready(); + assert_eq!( + timings.snapshot().time_elapsed_ms, + first, + "should not restamp" + ); + } + + #[test] + fn span_guard_records_on_drop() { + let timings = RequestTimings::new(); + { + let _span = timings.span(Phase::Origin); + std::thread::sleep(Duration::from_millis(2)); + } + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.expect("should record on drop") >= 1, + "should measure elapsed span time" + ); + } + + #[test] + fn auction_wait_keeps_zero_duration_and_placement() { + for placement in [ + AuctionWaitPlacement::PreHeader, + AuctionWaitPlacement::InStream, + ] { + let timings = RequestTimings::new(); + timings.mark_auction_dispatched(); + timings.record_auction_wait(placement, Duration::ZERO); + + let snapshot = timings.snapshot(); + assert_eq!( + snapshot.auction_wait_ms, + Some(0), + "should preserve a measured zero wait" + ); + assert_eq!( + snapshot.auction_wait_placement, + Some(placement), + "should retain placement for a measured zero wait" + ); + } + } + + #[test] + fn auction_wait_records_placement() { + let timings = RequestTimings::new(); + timings.record_auction_wait(AuctionWaitPlacement::PreHeader, Duration::from_millis(40)); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.auction_wait_ms, Some(40), "should record wait"); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::PreHeader), + "should record placement" + ); + } + + #[test] + fn rendered_names_never_include_vendor_terms() { + let timings = RequestTimings::new(); + for phase in [ + Phase::AppBuild, + Phase::Filter, + Phase::Geo, + Phase::EcKv, + Phase::Origin, + Phase::TemplateCacheLookup, + ] { + timings.record(phase, Duration::from_millis(1)); + } + timings.mark_headers_ready(); + let value = timings.server_timing_value().expect("should render"); + assert!( + !value.to_ascii_lowercase().contains("datadome"), + "should mask vendors" + ); + } + + #[test] + fn append_server_timing_emits_on_private_response_when_enabled() { + let mut response = Response::builder() + .header("cache-control", "private, no-store") + .body(()) + .expect("should build a private response fixture"); + let timings = RequestTimings::new(); + + append_server_timing_if_private(&mut response, &timings, true); + + let header = response + .headers() + .get("server-timing") + .and_then(|value| value.to_str().ok()) + .expect("should emit a Server-Timing header"); + assert!( + header.starts_with("ts-total;dur="), + "should lead with the stored total: {header}" + ); + } + + #[test] + fn append_server_timing_rejects_edge_cache_header_collisions() { + for cache_control in ["private", "no-store"] { + for edge_header in EDGE_CACHE_HEADER_NAMES { + let mut response = Response::builder() + .header("cache-control", cache_control) + .header(*edge_header, "max-age=60") + .body(()) + .expect("should build a response with conflicting cache directives"); + let timings = RequestTimings::new(); + + append_server_timing_if_private(&mut response, &timings, true); + + assert!( + response.headers().get("server-timing").is_none(), + "should suppress Server-Timing with {edge_header} and Cache-Control: {cache_control}" + ); + assert!( + timings.snapshot().time_elapsed_ms.is_some(), + "should still stamp headers-ready" + ); + } + } + + for (cache_control, enabled) in [(None, true), (Some("public, max-age=60"), true)] { + let mut builder = Response::builder(); + if let Some(cache_control) = cache_control { + builder = builder.header("cache-control", cache_control); + } + let mut response = builder.body(()).expect("should build response"); + append_server_timing_if_private(&mut response, &RequestTimings::new(), enabled); + assert!( + response.headers().get("server-timing").is_none(), + "should suppress Server-Timing without private/no-store Cache-Control" + ); + } + } + + #[test] + fn append_server_timing_requires_a_bare_privacy_directive() { + for cache_control in [ + "private=\"Set-Cookie\", max-age=600", + "public, s-maxage=600, private=\"Set-Cookie\"", + "private = \"Set-Cookie\"", + "private=Set-Cookie", + "private=\"cache-control, no-store\"", + "private=\"\"", + "private; extension", + "no-store=\"field\"", + "not-private, no-storey", + "ext=\"private,no-store\", max-age=600", + r#"ext="escaped\",private,no-store", max-age=600"#, + ] { + let mut response = Response::builder() + .header("cache-control", cache_control) + .header("server-timing", "upstream;dur=1.0") + .body(()) + .expect("should build a response without whole-response privacy"); + let timings = RequestTimings::new(); + + append_server_timing_if_private(&mut response, &timings, true); + + assert_eq!( + response.headers().get_all("server-timing").iter().count(), + 1, + "should not append timing for Cache-Control: {cache_control}" + ); + assert_eq!( + response.headers()["server-timing"], + "upstream;dur=1.0", + "should preserve upstream timing" + ); + assert_eq!( + response.headers()["cache-control"], + cache_control, + "should not rewrite the cache policy" + ); + assert!( + timings.snapshot().time_elapsed_ms.is_some(), + "should freeze timing even when the policy suppresses emission" + ); + } + } + + #[test] + fn append_server_timing_accepts_bare_privacy_among_qualified_directives() { + for cache_control in [ + "private", + " No-Store ", + "PRIVATE, max-age=60", + "private=\"Set-Cookie\", no-store", + "ext=\"private,no-store\", private", + r#"ext="escaped\",private,no-store", PRIVATE"#, + ] { + let mut response = Response::builder() + .header("cache-control", "private=\"Set-Cookie\"") + .body(()) + .expect("should build a field-qualified response"); + response.headers_mut().append( + http::header::CACHE_CONTROL, + HeaderValue::from_str(cache_control).expect("should build a cache-control value"), + ); + + append_server_timing_if_private(&mut response, &RequestTimings::new(), true); + + assert!( + response.headers().get("server-timing").is_some(), + "should recognize whole-response privacy across all header values: {cache_control}" + ); + } + } + + #[test] + fn append_server_timing_marks_headers_ready_even_when_not_emitted() { + let mut response = Response::builder() + .header("cache-control", "max-age=60") + .body(()) + .expect("should build a shared-cacheable response fixture"); + let timings = RequestTimings::new(); + + append_server_timing_if_private(&mut response, &timings, true); + + assert!( + response.headers().get("server-timing").is_none(), + "should not emit on a shared-cacheable response" + ); + assert!( + timings.server_timing_value().is_some(), + "should still stamp mark_headers_ready so ts-total reflects the freeze point" + ); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 754acfc262..b04efb646a 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2049,9 +2049,19 @@ impl Proxy { /// Direct Tinybird Events API telemetry configuration. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TinybirdSettings { - /// Master enablement for auction telemetry ingestion. + /// Master enablement for Tinybird telemetry. Required by both auction and + /// access-log emission; each is independently toggled below. #[serde(default)] pub enabled: bool, + /// Emit auction telemetry when `enabled`. Defaults to `true` so existing + /// configs preserve their current auction-emission behavior after + /// upgrading; set `false` to silence auction events while keeping + /// `enabled` on for other Tinybird telemetry (e.g. `access_enabled`). + /// The default `true` is omitted from serialized config; `false` is an + /// explicit opt-out for binaries that support this field. Older binaries + /// may ignore that opt-out and resume auction telemetry on rollback. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub auction_enabled: bool, /// Regional Tinybird API host, without scheme or path. #[serde(default)] pub api_host: String, @@ -2064,19 +2074,26 @@ pub struct TinybirdSettings { /// Secret reference containing the auction datasource APPEND token. #[serde(default, skip_serializing_if = "Option::is_none")] pub auction_token_secret: Option>, - /// Reserved for future access-log telemetry. + /// Emit access-log telemetry when `enabled`, independent of + /// `auction_enabled`. /// - /// `true` is rejected until an access-log emitter is wired, so operators - /// cannot enable a setting that silently emits nothing. + /// `true` requires `enabled`, non-empty `api_host`/`access_dataset`, a + /// resolved `access_token_secret`, `max_body_bytes >= 1024`, and + /// `access_sample_rate > 0.0`. This prevents an armed-but-silent sampler + /// that enables the flag but emits nothing. #[serde(default)] pub access_enabled: bool, - /// Future access-log Events API datasource name. + /// Access-log Events API datasource name. Required non-empty when + /// `access_enabled`. #[serde(default = "default_tinybird_access_dataset")] pub access_dataset: String, - /// Deprecated placeholder for the unwired access-log APPEND token. - #[serde(default, skip_serializing)] + /// Secret reference containing the access-log datasource APPEND token. + /// Required when `access_enabled`. + #[serde(default, skip_serializing_if = "Option::is_none")] pub access_token_secret: Option>, - /// Future fraction of requests to emit for optional access telemetry. + /// Fraction of requests to emit for access telemetry. Must be greater + /// than `0.0` when `access_enabled`, so an operator cannot enable access + /// telemetry while sampling it away entirely. #[serde(default)] pub access_sample_rate: f64, /// Defensive maximum NDJSON body size for one Events API request. @@ -2089,7 +2106,7 @@ fn default_tinybird_auction_dataset() -> String { } fn default_tinybird_access_dataset() -> String { - "access_logs_raw".to_owned() + "access_logs_v2".to_owned() } fn default_tinybird_max_body_bytes() -> usize { @@ -2100,6 +2117,7 @@ impl Default for TinybirdSettings { fn default() -> Self { Self { enabled: false, + auction_enabled: default_true(), api_host: String::new(), secret_store: None, auction_dataset: default_tinybird_auction_dataset(), @@ -2122,14 +2140,17 @@ impl TinybirdSettings { ); } self.auction_dataset = self.auction_dataset.trim().to_owned(); - self.auction_token_secret = self.auction_token_secret.take().and_then(|value| { - let value = value.expose().trim().to_owned(); - (!value.is_empty()).then(|| Redacted::new(value)) - }); + self.auction_token_secret = normalize_secret(self.auction_token_secret.take()); self.access_dataset = self.access_dataset.trim().to_owned(); - self.access_token_secret = None; + self.access_token_secret = normalize_secret(self.access_token_secret.take()); } + /// Validate this settings block, including the access-telemetry matrix: + /// `access_enabled` requires `enabled`, a non-empty `api_host` and + /// `access_dataset`, a resolved `access_token_secret`, a `max_body_bytes` + /// above the defensive floor enforced below, and an `access_sample_rate` + /// greater than `0.0`. Auction emission is independently gated by + /// `auction_enabled` and validated the same way. fn prepare_runtime(&mut self) -> Result<(), Report> { self.normalize(); if !(0.0..=1.0).contains(&self.access_sample_rate) { @@ -2142,27 +2163,59 @@ impl TinybirdSettings { message: "tinybird.max_body_bytes must be at least 1024".to_owned(), })); } - if self.access_enabled { + if self.access_enabled && !self.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(), + message: "tinybird.access_enabled requires tinybird.enabled".to_owned(), })); } if !self.enabled { return Ok(()); } validate_tinybird_api_host(&self.api_host)?; - validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; - let token = self.auction_token_secret.as_ref().ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: - "tinybird.auction_token_secret is required when Tinybird telemetry is enabled" - .to_owned(), - }) - })?; - validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret") + if self.auction_enabled { + validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; + let token = self.auction_token_secret.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: + "tinybird.auction_token_secret is required when tinybird.auction_enabled is true" + .to_owned(), + }) + })?; + validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret")?; + } + if self.access_enabled { + validate_tinybird_dataset(&self.access_dataset, "tinybird.access_dataset")?; + if self.access_dataset == "access_logs_raw" { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird.access_dataset must not be access_logs_raw when access telemetry is enabled; use access_logs_v2 or a datasource with the v2 schema".to_owned(), + })); + } + let token = self.access_token_secret.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: + "tinybird.access_token_secret is required when tinybird.access_enabled is true" + .to_owned(), + }) + })?; + validate_tinybird_secret(token.expose(), "tinybird.access_token_secret")?; + if self.access_sample_rate <= 0.0 { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird.access_sample_rate must be > 0 when tinybird.access_enabled is true".to_owned(), + })); + } + } + Ok(()) } } +/// Trim a resolved secret, dropping it entirely when nothing is left. +fn normalize_secret(value: Option>) -> Option> { + value.and_then(|value| { + let value = value.expose().trim().to_owned(); + (!value.is_empty()).then(|| Redacted::new(value)) + }) +} + fn validate_tinybird_api_host(host: &str) -> Result<(), Report> { if host.is_empty() || host.contains('/') @@ -2183,6 +2236,17 @@ fn validate_tinybird_api_host(host: &str) -> Result<(), Report Result<(), Report> { + if value.is_empty() || value.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("{setting} must be non-empty after secret resolution"), + })); + } + Ok(()) +} + fn validate_tinybird_dataset(value: &str, setting: &str) -> Result<(), Report> { if value.is_empty() || value.len() > 128 @@ -2197,15 +2261,6 @@ fn validate_tinybird_dataset(value: &str, setting: &str) -> Result<(), Report Result<(), Report> { - if value.is_empty() || value.chars().any(char::is_control) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("{setting} must be non-empty after secret resolution"), - })); - } - Ok(()) -} - /// Cache behavior configuration. #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -2805,6 +2860,14 @@ pub(crate) const AUCTION_DEBUG_UPSTREAM_METADATA_KEYS: &[&str] = &[ "upstream_message_truncated", ]; +/// `skip_serializing_if` helper: true is the serde default for the fields +/// that use it, so serializing it would only widen the pushed config's +/// rollback surface. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_true(value: &bool) -> bool { + *value +} + fn default_true() -> bool { true } @@ -2952,6 +3015,81 @@ pub enum AuctionDebugCommentFormat { Pretty, } +/// Request-observability toggles exposed to operators. +/// +/// The default table must stay omitted from serialized config blobs. Older +/// binaries deny unknown fields on [`Settings`], so a blob carrying the new +/// `[observability]` table would fail to load and break rollback. See +/// [`Settings::observability`]. +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ObservabilitySettings { + /// Emit the `Server-Timing` response header with per-phase request + /// timing. Defaults to `false` (off). + #[serde(default)] + pub server_timing_enabled: bool, + /// Section names whose publisher paths keep a named route template in + /// access telemetry (`/{section}/*`); everything else collapses to + /// `/other/*`. Matching is ASCII case-insensitive on the first path + /// segment, and a match requires at least one further segment. Defaults + /// to empty, which collapses every publisher path. Names are trimmed and + /// must be non-empty, contain no `/` or control characters, fit within 128 + /// UTF-8 bytes, and number at most 32. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub route_sections: Vec, +} + +impl ObservabilitySettings { + const MAX_ROUTE_SECTIONS: usize = 32; + const MAX_ROUTE_SECTION_BYTES: usize = 128; + + fn normalize(&mut self) { + self.route_sections = self + .route_sections + .iter() + .map(|section| { + section + .trim_matches(|character: char| { + character.is_whitespace() && !character.is_control() + }) + .to_owned() + }) + .collect(); + } + + fn prepare_runtime(&self) -> Result<(), Report> { + if self.route_sections.len() > Self::MAX_ROUTE_SECTIONS { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "observability.route_sections must contain at most {} entries", + Self::MAX_ROUTE_SECTIONS + ), + })); + } + for section in &self.route_sections { + if section.is_empty() + || section.len() > Self::MAX_ROUTE_SECTION_BYTES + || section.contains('/') + || section.chars().any(char::is_control) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "observability.route_sections entries must be non-empty, contain no slash or control characters, and fit within {} UTF-8 bytes", + Self::MAX_ROUTE_SECTION_BYTES + ), + })); + } + } + Ok(()) + } + + /// True when every field is at its default, i.e. observability is fully + /// disabled and the table can be omitted from serialized output. + fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// Tester-cookie endpoint configuration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct TesterCookieConfig { @@ -3274,6 +3412,12 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + /// Request-observability toggles. The default table is omitted from + /// serialized config blobs so a config round-tripped without change + /// still parses under a prior binary's schema; see + /// [`ObservabilitySettings`]. + #[serde(default, skip_serializing_if = "ObservabilitySettings::is_default")] + pub observability: ObservabilitySettings, } impl Settings { @@ -3364,6 +3508,7 @@ impl Settings { self.rewrite.normalize(); self.image_optimizer.normalize()?; self.debug.auction_html_comment_options.normalize(); + self.observability.normalize(); self.tinybird.normalize(); self.integrations .remove_legacy_static_secret_store_selectors(); @@ -3406,6 +3551,7 @@ impl Settings { self.cache.prepare_runtime()?; self.proxy.prepare_runtime()?; self.tinybird.prepare_runtime()?; + self.observability.prepare_runtime()?; self.debug .auction_html_comment_options .validate_metadata_keys()?; @@ -4562,6 +4708,14 @@ mod tests { serde_json::from_value::(value) .expect("should preserve backward compatibility without forwarder configuration"); } + /// Parses `extra` appended to the shared test fixture TOML, mirroring the + /// `format!("{}\n...", crate_test_settings_str())` pattern used throughout + /// this module's other tests. + fn settings_from_toml_with(extra: &str) -> Result> { + let toml = format!("{}\n{extra}", crate_test_settings_str()); + Settings::from_toml(&toml) + } + fn trusted_client_ip_toml(ip_header: &str, auth_header: &str, shared_secret: &str) -> String { format!( "{}\n[trusted_client_ip]\nip_header = \"{ip_header}\"\nauth_header = \"{auth_header}\"\nshared_secret = \"{shared_secret}\"\n", @@ -4761,6 +4915,7 @@ mod tests { auction_token_secret: Some(Redacted::new(CANARY_TINYBIRD_AUCTION_TOKEN.to_string())), access_token_secret: Some(Redacted::new(CANARY_TINYBIRD_ACCESS_TOKEN.to_string())), enabled: false, + auction_enabled: true, api_host: String::new(), secret_store: None, auction_dataset: String::new(), @@ -5481,6 +5636,17 @@ mod tests { ); } + #[test] + fn tinybird_deployment_validation_requires_default_on_auction_secret() { + let error = + settings_from_toml_with("[tinybird]\nenabled = true\napi_host = \"api.example.com\"\n") + .expect_err("should reject a missing default-on auction token"); + assert!( + error.to_string().contains("tinybird.auction_token_secret"), + "should match runtime blob validation: {error:?}" + ); + } + #[test] fn tinybird_accepts_region_host_without_scheme() { let toml = format!( @@ -5494,17 +5660,224 @@ mod tests { } #[test] - fn tinybird_access_enabled_is_rejected_until_emitter_is_wired() { - let toml = format!( - "{}\n[tinybird]\naccess_enabled = true\n", - crate_test_settings_str() + fn tinybird_access_enabled_with_full_config_is_accepted() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_token_secret = \"test-auction-token\"\naccess_enabled = true\naccess_token_secret = \"test-access-token\"\naccess_sample_rate = 1.0\n", + ) + .expect("should accept a fully-specified access telemetry config"); + assert!( + settings.tinybird.access_enabled, + "should enable access emission" ); + } - let err = Settings::from_toml(&toml) - .expect_err("should reject access telemetry before emitter exists"); + #[test] + fn access_enabled_rejects_legacy_dataset_after_normalization() { + for dataset in ["access_logs_raw", " access_logs_raw "] { + let config = format!( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_enabled = false\naccess_enabled = true\naccess_dataset = \"{dataset}\"\naccess_token_secret = \"test-access-token\"\naccess_sample_rate = 1.0\n" + ); + let error = settings_from_toml_with(&config) + .expect_err("should reject v2 access rows targeting the legacy datasource"); + assert!( + error.to_string().contains("tinybird.access_dataset"), + "should identify the invalid access dataset: {error:?}" + ); + } + } + + #[test] + fn disabled_access_accepts_legacy_dataset() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_token_secret = \"test-auction-token\"\naccess_dataset = \"access_logs_raw\"\n", + ) + .expect("should retain legacy dataset settings while access emission is disabled"); + assert_eq!(settings.tinybird.access_dataset, "access_logs_raw"); + assert!(!settings.tinybird.access_enabled); + } + + #[test] + fn access_enabled_accepts_custom_versioned_dataset() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_enabled = false\naccess_enabled = true\naccess_dataset = \"custom_access_v2\"\naccess_token_secret = \"test-access-token\"\naccess_sample_rate = 1.0\n", + ) + .expect("should allow custom access datasource names"); + assert_eq!(settings.tinybird.access_dataset, "custom_access_v2"); + } + + #[test] + fn access_enabled_requires_tinybird_enabled() { + // access_enabled = true with tinybird.enabled omitted (defaults + // false) must be rejected: access telemetry cannot run without the + // master toggle on. + let err = settings_from_toml_with( + "[tinybird]\napi_host = \"api.example.com\"\naccess_enabled = true\naccess_sample_rate = 1.0\n", + ) + .expect_err("should reject access telemetry without tinybird.enabled"); assert!( format!("{err:?}").contains("tinybird.access_enabled"), - "should report unsupported tinybird.access_enabled setting: {err:?}" + "should name the field: {err:?}" + ); + } + + #[test] + fn access_enabled_requires_positive_sample_rate() { + // access_enabled = true with access_sample_rate = 0 is armed-but-silent: an error. + let err = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_token_secret = \"test-auction-token\"\naccess_enabled = true\naccess_token_secret = \"test-access-token\"\naccess_sample_rate = 0.0\n", + ) + .expect_err("should reject armed-but-silent access telemetry"); + assert!( + format!("{err:?}").contains("access_sample_rate"), + "should name the field" + ); + } + + #[test] + fn access_and_auction_emission_are_independent() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_enabled = false\naccess_enabled = true\naccess_token_secret = \"test-access-token\"\naccess_sample_rate = 1.0\n", + ) + .expect("should accept access without auction"); + assert!( + !settings.tinybird.auction_enabled, + "should disable auction emission" + ); + assert!( + settings.tinybird.access_enabled, + "should enable access emission" + ); + } + + #[test] + fn access_dataset_defaults_to_versioned_schema() { + assert_eq!( + TinybirdSettings::default().access_dataset, + "access_logs_v2", + "should target the versioned access schema rather than the reserved legacy datasource" + ); + let settings = settings_from_toml_with("[tinybird]\nenabled = false\n") + .expect("should parse settings without an explicit access dataset"); + assert_eq!( + settings.tinybird.access_dataset, "access_logs_v2", + "should apply the same versioned default during deserialization" + ); + } + + #[test] + fn auction_enabled_defaults_true_for_existing_configs() { + let settings = + settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_token_secret = \"test-auction-token\"\n", + ) + .expect("should parse a pre-decoupling config"); + assert!( + settings.tinybird.auction_enabled, + "should preserve current behavior" + ); + } + + #[test] + fn observability_defaults_off_and_serializes_away() { + let settings = create_test_settings(); + assert!( + !settings.observability.server_timing_enabled, + "should default off" + ); + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("[observability]"), + "should omit the default table so a prior binary can parse the config" + ); + } + + #[test] + fn auction_enabled_serializes_only_when_disabled() { + let mut settings = create_test_settings(); + assert!(settings.tinybird.auction_enabled, "should default on"); + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("auction_enabled"), + "should omit the default-true key: a rollback must not silently \ + re-enable auction telemetry an operator disabled" + ); + + settings.tinybird.auction_enabled = false; + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + toml.contains("auction_enabled = false"), + "should serialize the operator's explicit disable" + ); + } + + #[test] + fn route_sections_normalize_and_enforce_finite_limits() { + let mut settings = create_test_settings(); + settings.observability.route_sections = vec![" news ".to_owned()]; + settings + .normalize_deserialized() + .expect("should normalize a valid route section"); + settings + .prepare_runtime() + .expect("should accept and normalize a valid route section"); + assert_eq!(settings.observability.route_sections, ["news"]); + + for invalid in ["", " ", "news/archive", "news\narchive", "news\n"] { + let mut settings = create_test_settings(); + settings.observability.route_sections = vec![invalid.to_owned()]; + settings + .normalize_deserialized() + .expect("should normalize settings before validating route sections"); + let error = settings + .prepare_runtime() + .expect_err("should reject an invalid route section"); + assert!(error.to_string().contains("observability.route_sections")); + } + + let mut boundary = create_test_settings(); + boundary.observability.route_sections = vec!["x".repeat(128)]; + settings_normalize_and_prepare(&mut boundary); + boundary.observability.route_sections = vec!["x".repeat(129)]; + assert!(boundary.prepare_runtime().is_err(), "129 bytes must fail"); + + let mut boundary = create_test_settings(); + boundary.observability.route_sections = (0..32).map(|index| format!("s{index}")).collect(); + settings_normalize_and_prepare(&mut boundary); + boundary + .observability + .route_sections + .push("extra".to_owned()); + assert!(boundary.prepare_runtime().is_err(), "33 entries must fail"); + } + + fn settings_normalize_and_prepare(settings: &mut Settings) { + settings + .normalize_deserialized() + .expect("should normalize route-section limits at the boundary"); + settings + .prepare_runtime() + .expect("should accept route-section limits at the boundary"); + } + + #[test] + fn route_sections_serialize_only_when_configured() { + let mut settings = create_test_settings(); + assert!( + settings.observability.route_sections.is_empty(), + "should default to the collapse-everything allowlist" + ); + settings.observability.server_timing_enabled = true; + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("route_sections"), + "should omit the empty allowlist so a prior binary can parse the config" + ); + + settings.observability.route_sections = vec!["news".to_owned()]; + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + toml.contains("route_sections"), + "should serialize a configured allowlist" ); } diff --git a/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts index 55996306aa..6797ffff75 100644 --- a/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts @@ -24,6 +24,32 @@ async function waitForApi(page: Page): Promise { ); } +async function captureClosedShadowRoots(page: Page): Promise { + await page.addInitScript(() => { + const roots = new WeakMap(); + const attachShadow = Element.prototype.attachShadow; + Object.defineProperty(Element.prototype, "attachShadow", { + configurable: true, + value(this: Element, init: ShadowRootInit): ShadowRoot { + const root = attachShadow.call(this, init); + roots.set(this, root); + return root; + }, + }); + (window as any).__gptDiagnosticsClosedRoots = roots; + }); +} + +async function diagnosticsText(page: Page): Promise { + return page.evaluate((hostId) => { + const host = document.getElementById(hostId); + const roots = (window as any).__gptDiagnosticsClosedRoots as + | WeakMap + | undefined; + return host && roots ? (roots.get(host)?.textContent ?? "") : ""; + }, HOST_ID); +} + async function emit( page: Page, name: string, @@ -188,6 +214,20 @@ test.describe("GPT runtime diagnostics", () => { }, testInfo) => { const pageErrors: string[] = []; const diagnosticNetworkRequests: string[] = []; + await captureClosedShadowRoots(page); + await page.addInitScript(() => { + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (init: ShadowRootInit) { + const root = originalAttachShadow.call(this, init); + if ( + (this as HTMLElement).id === + "trusted-server-gpt-diagnostics" + ) { + (window as any).__gptDiagnosticsTestRoot = root; + } + return root; + }; + }); page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("request", (request) => { if ( @@ -224,6 +264,9 @@ test.describe("GPT runtime diagnostics", () => { isBackfill: true, slotContentChanged: true, }); + await expect + .poll(() => diagnosticsText(page)) + .toContain("Fill 300×250"); await emit(page, "slotOnload", "gpt-diagnostics-slot-primary"); await emit(page, "impressionViewable", "gpt-diagnostics-slot-primary"); await emit( @@ -271,6 +314,9 @@ test.describe("GPT runtime diagnostics", () => { slot.slotElementId === "gpt-diagnostics-slot-secondary", ); expect(primary.binding).toEqual({ status: "bound" }); + expect(snapshot.slots.map((slot: any) => slot.runtimeSlotNumber)).toEqual([ + 1, 2, + ]); expect( primary.requests.map((cycle: any) => cycle.requestNumber), ).toEqual([1, 2, 3]); @@ -331,17 +377,113 @@ test.describe("GPT runtime diagnostics", () => { await page.evaluate(() => (window as any).tsjs.gptDiagnostics.hide()); await expect(page.locator(`#${HOST_ID}`)).toHaveCount(0); await emit(page, "slotRequested", "gpt-diagnostics-slot-secondary"); + await emit( + page, + "slotResponseReceived", + "gpt-diagnostics-slot-secondary", + ); + await emit(page, "slotRenderEnded", "gpt-diagnostics-slot-secondary", { + isEmpty: false, + size: [1, 1], + }); await page.evaluate(() => (window as any).tsjs.gptDiagnostics.show()); await expect(page.locator(`#${HOST_ID}`)).toHaveCount(1); const hiddenPeriodSnapshot = await page.evaluate(() => (window as any).tsjs.gptDiagnostics.snapshot(), ); - expect( - hiddenPeriodSnapshot.slots.find( - (slot: any) => - slot.slotElementId === "gpt-diagnostics-slot-secondary", - ).requests, - ).toHaveLength(2); + await page.waitForFunction(() => + Boolean( + (window as any).__gptDiagnosticsTestRoot?.querySelector( + ".tsgd-badge-handle:not([hidden])", + ), + ), + ); + const badgeIdentity = await page.evaluate(() => { + const badge = ( + window as any + ).__gptDiagnosticsTestRoot.querySelector( + ".tsgd-badge-handle:not([hidden])", + ) as HTMLButtonElement; + const container = badge.closest(".tsgd-badge")!; + badge.focus(); + return { + tagName: badge.tagName, + text: badge.textContent, + ariaLabel: badge.getAttribute("aria-label"), + evidence: container.querySelector(".tsgd-badge-evidence") + ?.textContent, + runtimeSlotNumber: container.dataset.runtimeSlot, + requestNumber: container.dataset.requestNumber, + }; + }); + expect(badgeIdentity).toMatchObject({ + tagName: "BUTTON", + text: expect.stringMatching(/^Ad #\d+$/), + evidence: expect.stringMatching(/^Request #\d+ · /), + ariaLabel: expect.stringMatching(/Ad #\d+, Request #\d+/), + }); + await page.keyboard.press("Enter"); + await page.waitForFunction( + ({ runtimeSlotNumber, requestNumber }) => { + const root = (window as any) + .__gptDiagnosticsTestRoot as ShadowRoot; + const selected = root?.querySelector( + `[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`, + ); + return selected !== null && root.activeElement === selected; + }, + { + runtimeSlotNumber: badgeIdentity.runtimeSlotNumber, + requestNumber: badgeIdentity.requestNumber, + }, + ); + await page.evaluate( + ({ runtimeSlotNumber, requestNumber }) => { + const root = (window as any) + .__gptDiagnosticsTestRoot as ShadowRoot; + const selected = root.querySelector( + `[aria-current="true"][data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]`, + ); + const locate = Array.from( + selected + ?.closest(".tsgd-slot") + ?.querySelectorAll("button") ?? [], + ).find( + (candidate) => + candidate.textContent === "Locate on page", + ); + locate?.click(); + }, + { + runtimeSlotNumber: badgeIdentity.runtimeSlotNumber, + requestNumber: badgeIdentity.requestNumber, + }, + ); + await page.waitForFunction(() => + Boolean( + (window as any).__gptDiagnosticsTestRoot?.querySelector( + ".tsgd-highlight", + ), + ), + ); + const secondaryRequests = hiddenPeriodSnapshot.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-secondary", + ).requests; + expect(secondaryRequests).toHaveLength(2); + expect(secondaryRequests[1]).toMatchObject({ + isEmpty: false, + size: [1, 1], + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()), + ), + ), + ); + expect(await diagnosticsText(page)).not.toContain("1×1"); const downloadPromise = page.waitForEvent("download"); await page.evaluate(() => (window as any).tsjs.gptDiagnostics.export()); @@ -355,6 +497,16 @@ test.describe("GPT runtime diagnostics", () => { const exported = JSON.parse(Buffer.concat(chunks).toString("utf8")); expect(exported.version).toBe(1); expect(exported.slots).toHaveLength(hiddenPeriodSnapshot.slots.length); + const exportedPrimary = exported.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-primary", + ); + const exportedSecondary = exported.slots.find( + (slot: any) => + slot.slotElementId === "gpt-diagnostics-slot-secondary", + ); + expect(exportedPrimary.requests[0].size).toEqual([300, 250]); + expect(exportedSecondary.requests[1].size).toEqual([1, 1]); expect(exported.callbackIssues).toHaveLength( hiddenPeriodSnapshot.callbackIssues.length, ); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 9d29f96622..48c632f6e8 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -95,10 +95,6 @@ export interface AuctionBidData { hb_cache_path?: string; /** Opaque server-auction correlation ID used only by GPT diagnostics. */ hb_auction_id?: string; - /** Winning creative width; the bridge sizes the inline render from this. */ - w?: number; - /** Winning creative height; the bridge sizes the inline render from this. */ - h?: number; nurl?: string; burl?: string; /** Typed winning-bid renderer capability. */ @@ -116,6 +112,43 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } +/** Server-measured auction timings relative to their documented server-side origin. */ +export interface AuctionDiagnosticsData { + auctionDispatchedMs?: number; + auctionResolvedMs?: number; + auctionCommittedMs?: number; + auctionWaitMs?: number; + auctionWaitPlacement?: 'pre_header' | 'in_stream'; +} + +/** Auction path presented by GPT diagnostics. */ +export type GptDiagnosticsAuctionType = 'ssat' | 'trusted_server' | 'client_side' | 'competing'; + +/** Clock origin for server auction timings, independent of aggregate auction classification. */ +export type GptDiagnosticsServerAuctionTimingOrigin = 'navigation' | 'spa_auction'; + +/** Sanitized bid facts already exposed as bucketed ad-server targeting. */ +export interface GptDiagnosticsAuctionWinner { + bidder: string; + priceBucket: string; + /** ISO currency supplied by the evidence source; absent means not supplied. */ + currency?: string; +} + +/** A completed, exactly correlated client-side Prebid auction. */ +export interface GptDiagnosticsPrebidAuctionEvidence { + auctionId: string; + targetingCandidate?: GptDiagnosticsAuctionWinner; + win?: GptDiagnosticsAuctionWinner; +} + +/** Internal Trusted Server auction evidence attached to the next GPT request. */ +export interface GptDiagnosticsAuctionFacts { + auctionType?: Extract; + winner?: GptDiagnosticsAuctionWinner; + serverTimings?: AuctionDiagnosticsData; +} + export type GptDiagnosticsCallbackKind = | 'slotRequested' | 'slotResponseReceived' @@ -262,6 +295,14 @@ export interface GptDiagnosticsRequestCycle { requestPath?: GptDiagnosticsRequestPath; requestIntentId?: number; trustedServerAuctionId?: string; + auctionType?: GptDiagnosticsAuctionType; + /** Compatibility field: winner of the observed server auction, not necessarily the served creative. */ + auctionWinner?: GptDiagnosticsAuctionWinner; + /** Completed Prebid facts, correlated to this exact slot, request, and auction attempt. */ + prebidAuction?: GptDiagnosticsPrebidAuctionEvidence; + serverAuctionTimings?: AuctionDiagnosticsData; + /** Retained separately because `auctionType` can become `competing`. */ + serverAuctionTimingOrigin?: GptDiagnosticsServerAuctionTimingOrigin; opportunityToRequestMs?: number; replacedRequestNumber?: number; previousRenderToRequestMs?: number; @@ -371,10 +412,33 @@ export interface GptDiagnosticsRecorder { opportunity: GptDiagnosticsTrustedServerOpportunity, trustedServerAuctionId?: string, requestedSlotSizes?: ReadonlyArray, + auctionFacts?: GptDiagnosticsAuctionFacts, traceIdentity?: TraceGptIdentity ): void; /** Mark slots whose next observed GPT request follows the Prebid refresh path. */ recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; + /** Observe the exact Prebid instance and slots after its targeting succeeds. */ + recordCompletedPrebidAuction?( + prebid: { + onEvent?: (event: 'bidWon', listener: (bid: unknown) => void) => void; + offEvent?: (event: 'bidWon', listener: (bid: unknown) => void) => void; + }, + rawAuctionId: unknown, + slots: Array string[] }>, + adUnitCodes: string[] + ): void; + /** Record a completed Prebid attempt at its targeting boundary for one exact GPT slot. */ + recordPrebidAuction( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void; + /** Record Prebid's documented `bidWon` observation for that exact attempt. */ + recordPrebidWin( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void; /** Record a creative markup request and return its opaque attempt ID. */ recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; /** Record that a creative attempt successfully posted markup. */ @@ -473,6 +537,8 @@ export interface TsjsApi { adSlots?: AuctionSlot[]; /** Winning bid targeting data injected before . */ bids?: Record; + /** Server-measured timing evidence for the auction that populated `bids`. */ + auctionDiagnostics?: AuctionDiagnosticsData; /** * Bounded client-side Prebid APS renderer capabilities keyed by Prebid's generated * `hb_adid`. The Universal Creative bridge consumes each entry at most once. @@ -559,7 +625,8 @@ export interface TsjsApi { scheduleInitialAdInit?: ( initialBids?: Record, initialSlots?: AuctionSlot[], - traceAuctionTransport?: unknown + traceAuctionTransport?: unknown, + initialAuctionDiagnostics?: AuctionDiagnosticsData ) => void; /** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */ gptDiagnostics?: GptDiagnosticsApi; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index e4b46fcba2..39bcee6026 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -13,6 +13,8 @@ import type { TraceGptOpportunity } from '../../trace/gpt'; import type { AuctionSlot, AuctionBidData, + AuctionDiagnosticsData, + GptDiagnosticsAuctionFacts, GptDiagnosticsCreativeFailure, GptDiagnosticsTrustedServerOpportunity, GptSlotHandoff, @@ -79,6 +81,25 @@ function trustedServerOpportunity(bid: AuctionBidData): GptDiagnosticsTrustedSer return hasAdId && (hasInline || hasCache) ? 'renderable_candidate' : 'unrenderable_candidate'; } +function diagnosticsAuctionFacts( + generation: number, + auctionDiagnostics: AuctionDiagnosticsData | undefined, + bid: AuctionBidData +): GptDiagnosticsAuctionFacts | undefined { + const isSpaAuction = generation > 0; + const winner = + isNonEmptyString(bid.hb_bidder) && isNonEmptyString(bid.hb_pb) + ? { bidder: bid.hb_bidder, priceBucket: bid.hb_pb } + : undefined; + if (!winner && auctionDiagnostics === undefined) return undefined; + + return { + auctionType: isSpaAuction ? 'trusted_server' : 'ssat', + ...(winner ? { winner } : {}), + ...(auctionDiagnostics ? { serverTimings: auctionDiagnostics } : {}), + }; +} + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -816,12 +837,16 @@ function installScheduleInitialAdInit(ts: TsjsApi): void { ts.scheduleInitialAdInit = function ( initialBids?: Record, initialSlots?: AuctionSlot[], - traceAuctionTransport?: unknown + traceAuctionTransport?: unknown, + initialAuctionDiagnostics?: AuctionDiagnosticsData ) { if ((ts.navGeneration ?? 0) !== 0 || ts.initialAdInitScheduled) return; ts.initialAdInitScheduled = true; if (initialSlots !== undefined) ts.adSlots = initialSlots; if (initialBids !== undefined) ts.bids = initialBids; + if (initialAuctionDiagnostics !== undefined) { + ts.auctionDiagnostics = initialAuctionDiagnostics; + } const runUnlessNavigated = (): void => { if ((ts.navGeneration ?? 0) !== 0) return; try { @@ -1137,7 +1162,8 @@ function schedulePublisherFirstImpressionFallback( slot: AuctionSlot, bid: AuctionBidData, element: HTMLElement, - generation: number + generation: number, + auctionDiagnostics: AuctionDiagnosticsData | undefined ): void { if (!reservePublisherFirstImpressionFallback(ts, element)) return; @@ -1211,12 +1237,14 @@ function schedulePublisherFirstImpressionFallback( try { const trace = traceOpportunity(slot, bid.hb_auction_id); + const auctionFacts = diagnosticsAuctionFacts(generation, auctionDiagnostics, bid); ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( gptSlot, slot.id, trustedServerOpportunity(bid), trace.auctionId, slot.formats, + auctionFacts, ...(trace.identity ? ([trace.identity] as const) : []) ); } catch { @@ -1265,6 +1293,7 @@ export function installTsAdInit(): void { // first act and stands down rather than applying this invocation's // slots/bids to the newer route's DOM and double-requesting it. const generation = ts.navGeneration ?? 0; + const auctionDiagnostics = ts.auctionDiagnostics ? { ...ts.auctionDiagnostics } : undefined; const g = (window as GptWindow).googletag; if (!g) return; installFirstImpressionLifecycleObservers(ts, g); @@ -1360,7 +1389,15 @@ export function installTsAdInit(): void { if (!firstImpression) { const claim = firstImpressionClaim(ts, el); if (claim?.owner === 'publisher') { - schedulePublisherFirstImpressionFallback(ts, g, slot, bid, el, generation); + schedulePublisherFirstImpressionFallback( + ts, + g, + slot, + bid, + el, + generation, + auctionDiagnostics + ); } return; } @@ -1408,12 +1445,14 @@ export function installTsAdInit(): void { const requestedSlotSizes = ts.gptSlotHandoffs?.[slotDivId2]?.formats; const opportunity = trustedServerOpportunity(bid); const trace = traceOpportunity(slot, bid.hb_auction_id); + const auctionFacts = diagnosticsAuctionFacts(generation, auctionDiagnostics, bid); ts.gptDiagnosticsRecorder?.recordTrustedServerOpportunity( gptSlot, slot.id, opportunity, trace.auctionId, requestedSlotSizes, + auctionFacts, ...(trace.identity ? ([trace.identity] as const) : []) ); } catch { @@ -1518,6 +1557,7 @@ interface PageBidsResponse { slots: AuctionSlot[]; bids: Record; trace_auction?: unknown; + auctionDiagnostics?: AuctionDiagnosticsData; } /** Canonical SPA re-auction endpoint. Mirrors `PAGE_BIDS_PATH` in Rust. */ @@ -1726,6 +1766,9 @@ export function installSpaAuctionHook(): void { if (g) clearPreviousNavigationTargeting(ts, g); ts.navGeneration = (ts.navGeneration ?? 0) + 1; delete ts.firstImpression; + // Server timings belong to the route that produced them. Clear them before + // page-bids starts so failure or supersession cannot relabel stale offsets. + ts.auctionDiagnostics = undefined; // A route change invalidates hydration aliases before the new route's // publisher can define a same-prefix slot while page-bids is in flight. for (const [elementId, handoff] of Object.entries(ts.gptSlotHandoffs ?? {})) { @@ -1757,6 +1800,7 @@ export function installSpaAuctionHook(): void { } catch { // A diagnostic bridge cannot block the accepted navigation. } + ts.auctionDiagnostics = data.auctionDiagnostics; // This route is now the committed, loaded state — a later failed // navigation rolls back here, and a return trip no-ops correctly. lastAppliedPath = path; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index 1388b8f6b0..8076a7e7eb 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -1,5 +1,7 @@ import type { GptDiagnosticsApi, + GptDiagnosticsAuctionFacts, + GptDiagnosticsAuctionWinner, GptDiagnosticsCreativeFailure, GptDiagnosticsExportV1, GptDiagnosticsRecorder, @@ -9,6 +11,7 @@ import type { import type { TraceGptIdentity } from '../../trace/types'; import type { GptDiagnosticsBindingManager } from './binding'; +import { clonePrebidAuctionEvidence } from './evidence'; import type { GptDiagnosticsStoreSnapshot } from './store'; interface ApiStore { @@ -20,9 +23,20 @@ interface ApiStore { opportunity: GptDiagnosticsTrustedServerOpportunity, trustedServerAuctionId?: string, requestedSlotSizes?: ReadonlyArray, + auctionFacts?: GptDiagnosticsAuctionFacts, traceIdentity?: TraceGptIdentity ): void; recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; + recordPrebidAuction( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void; + recordPrebidWin( + slot: GptDiagnosticsSlotHandle, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void; recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; recordTrustedServerCreativeResponse(attemptId: number): void; recordTrustedServerCreativeFailure( @@ -69,6 +83,13 @@ function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsEx requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size]), size: cycle.size ? [...cycle.size] : undefined, observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, + ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } + : {}), + ...(cycle.serverAuctionTimings + ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } + : {}), adManager: cycle.adManager ? { ...cycle.adManager, @@ -160,6 +181,7 @@ export class GptDiagnosticsApiController { opportunity, trustedServerAuctionId, requestedSlotSizes, + auctionFacts, traceIdentity ) => safelyRecord(() => { @@ -169,10 +191,15 @@ export class GptDiagnosticsApiController { opportunity, trustedServerAuctionId, requestedSlotSizes, + auctionFacts, ...(traceIdentity ? ([traceIdentity] as const) : []) ); }), recordPrebidRefresh: (slots) => safelyRecord(() => this.store.recordPrebidRefresh(slots)), + recordPrebidAuction: (slot, auctionId, targetingCandidate) => + safelyRecord(() => this.store.recordPrebidAuction(slot, auctionId, targetingCandidate)), + recordPrebidWin: (slot, auctionId, winner) => + safelyRecord(() => this.store.recordPrebidWin(slot, auctionId, winner)), recordTrustedServerCreativeRequest: (auctionSlotId) => safelyCreateAttempt(() => this.store.recordTrustedServerCreativeRequest(auctionSlotId)), recordTrustedServerCreativeResponse: (attemptId) => @@ -204,6 +231,13 @@ export class GptDiagnosticsApiController { requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size]), size: cycle.size ? [...cycle.size] : undefined, observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, + ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } + : {}), + ...(cycle.serverAuctionTimings + ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } + : {}), adManager: cycle.adManager ? { ...cycle.adManager, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts index 643dc355a3..fbc370ff93 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -2,7 +2,12 @@ import type { GptDiagnosticsRequestCycle } from '../../core/types'; import type { GptDiagnosticsBindingManager } from './binding'; import { unhandledCase } from './exhaustive'; -import { formatSizes, scheduleFrame } from './presentation_helpers'; +import { + auctionTypeBadgeLabel, + displayableGptFillSize, + formatSizes, + scheduleFrame, +} from './presentation_helpers'; import type { GptDiagnosticsBindingInput, GptDiagnosticsStoreSlotSnapshot, @@ -27,12 +32,15 @@ type BadgeWindow = Window & { const BADGE_MAX_WIDTH_PX = 260; const BADGE_EDGE_GUTTER_PX = 4; +const HANDLE_WIDTH_PX = 72; +const HANDLE_HEIGHT_PX = 26; const MAX_BADGE_REQUESTED_SLOT_SIZES = 3; interface BadgeOptions { window?: BadgeWindow; document?: Document; scheduleFrame?: (callback: () => void) => void; + onActivate?: (runtimeSlotNumber: number, requestNumber: number) => void; } function intersectsViewport(rectangle: DOMRect, window: Window): boolean { @@ -106,9 +114,12 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string { else if (cycle.isEmpty === false) firstLine.push('Filled'); else if (cycle.renderAtMs !== undefined) firstLine.push('Rendered (fill unknown)'); else firstLine.push('Pending'); + if (cycle.auctionType) firstLine.push(auctionTypeBadgeLabel(cycle.auctionType)); const delivery = deliveryLabel(cycle); if (delivery) firstLine.push(delivery); - if (cycle.requestPath === 'competing') firstLine.push('Competing paths'); + if (cycle.requestPath === 'competing' && cycle.auctionType !== 'competing') { + firstLine.push('Competing paths'); + } if (cycle.requestedSlotSizes) { const displayedSizes = cycle.requestedSlotSizes.slice(0, MAX_BADGE_REQUESTED_SLOT_SIZES); const remainingSizeCount = cycle.requestedSlotSizes.length - displayedSizes.length; @@ -116,16 +127,17 @@ function badgeText(cycle: GptDiagnosticsRequestCycle): string { `Req ${formatSizes(displayedSizes)}${remainingSizeCount > 0 ? ` +${remainingSizeCount}` : ''}` ); } - if (cycle.size) firstLine.push(`Fill ${cycle.size[0]}×${cycle.size[1]}`); + const fillSize = displayableGptFillSize(cycle.size); + if (fillSize) firstLine.push(`Fill ${fillSize[0]}×${fillSize[1]}`); if (cycle.observedSlotSize) { - firstLine.push(`Box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); + firstLine.push(`Size filled ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); } const timingLine: string[] = []; const response = formatMilliseconds(cycle.durations.requestToResponseMs); const render = formatMilliseconds(cycle.durations.responseToRenderMs); - if (response) timingLine.push(`Response ${response}`); - if (render) timingLine.push(`Render ${render}`); + if (response) timingLine.push(`GAM request → response ${response}`); + if (render) timingLine.push(`GAM response → render ${render}`); const lines = [firstLine.join(' · ')]; if (timingLine.length > 0) lines.push(timingLine.join(' · ')); @@ -148,6 +160,7 @@ export class GptDiagnosticsBadgeManager { private readonly window: BadgeWindow; private readonly document: Document; private readonly scheduleFrame: (callback: () => void) => void; + private readonly onActivate: (runtimeSlotNumber: number, requestNumber: number) => void; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; private readonly slotElementIds = new Set(); @@ -165,6 +178,7 @@ export class GptDiagnosticsBadgeManager { this.document = options.document ?? document; this.scheduleFrame = options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); + this.onActivate = options.onActivate ?? (() => undefined); this.refreshSlotElementIds(); this.unsubscribeStore = this.store.subscribe(() => { this.refreshSlotElementIds(); @@ -188,7 +202,18 @@ export class GptDiagnosticsBadgeManager { update(): void { if (this.destroyed || !this.layer?.isConnected) return; const observedElements: HTMLElement[] = []; - const badges: HTMLElement[] = []; + const existingBadges = new Map( + Array.from(this.layer.querySelectorAll('.tsgd-badge')).map((badge) => [ + badge.dataset.runtimeSlot, + badge, + ]) + ); + const badges = new Set(); + const creativeRectangles = this.slots.flatMap((slot) => { + const binding = this.bindings.get(slot.runtimeSlotNumber); + if (binding.binding.status !== 'bound' || !binding.element?.isConnected) return []; + return [binding.element.getBoundingClientRect()]; + }); for (const slot of this.slots) { const cycle = latestCycle(slot); @@ -201,28 +226,72 @@ export class GptDiagnosticsBadgeManager { if (!intersectsViewport(rectangle, this.window)) continue; observedElements.push(element); - const badge = this.document.createElement('div'); - badge.className = 'tsgd-badge'; - badge.dataset.runtimeSlot = String(slot.runtimeSlotNumber); - badge.textContent = badgeText(cycle); - badge.style.maxWidth = `${BADGE_MAX_WIDTH_PX}px`; + const runtimeSlot = String(slot.runtimeSlotNumber); + let badge = existingBadges.get(runtimeSlot); + if (!badge) { + const createdBadge = this.document.createElement('div'); + createdBadge.className = 'tsgd-badge'; + const evidence = this.document.createElement('span'); + evidence.className = 'tsgd-badge-evidence'; + const handle = this.document.createElement('button'); + handle.type = 'button'; + handle.className = 'tsgd-badge-handle'; + createdBadge.append(evidence, handle); + handle.addEventListener('click', () => { + const runtimeSlotNumber = Number(createdBadge.dataset.runtimeSlot); + const requestNumber = Number(createdBadge.dataset.requestNumber); + if (!Number.isSafeInteger(runtimeSlotNumber) || !Number.isSafeInteger(requestNumber)) { + return; + } + this.onActivate(runtimeSlotNumber, requestNumber); + }); + badge = createdBadge; + } + const text = badgeText(cycle); + badge.dataset.runtimeSlot = runtimeSlot; + badge.dataset.requestNumber = String(cycle.requestNumber); + const evidence = badge.querySelector('.tsgd-badge-evidence')!; + evidence.textContent = `Request #${cycle.requestNumber} · ${text}`; + const handle = badge.querySelector('.tsgd-badge-handle')!; + handle.textContent = `Ad #${slot.runtimeSlotNumber}`; + handle.setAttribute( + 'aria-label', + `Open diagnostics for Ad #${slot.runtimeSlotNumber}, Request #${cycle.requestNumber}: ${text}` + ); + badge.style.maxWidth = `${Math.max( + 0, + Math.min(BADGE_MAX_WIDTH_PX, this.window.innerWidth - 2 * BADGE_EDGE_GUTTER_PX) + )}px`; badge.style.left = `${Math.max( BADGE_EDGE_GUTTER_PX, Math.min(rectangle.left, this.window.innerWidth - BADGE_MAX_WIDTH_PX - BADGE_EDGE_GUTTER_PX) )}px`; - if (rectangle.top >= 56) { - badge.style.top = `${rectangle.top - 8}px`; - badge.style.transform = 'translateY(-100%)'; - } else { - badge.style.top = `${Math.max( - BADGE_EDGE_GUTTER_PX, - rectangle.top + BADGE_EDGE_GUTTER_PX - )}px`; + if (!badge.isConnected) this.layer.append(badge); + const evidenceHeight = evidence.getBoundingClientRect().height; + badge.style.top = `${Math.max( + BADGE_EDGE_GUTTER_PX, + Math.min( + rectangle.top - evidenceHeight - BADGE_EDGE_GUTTER_PX, + this.window.innerHeight - evidenceHeight - BADGE_EDGE_GUTTER_PX + ) + )}px`; + const handlePosition = this.handlePosition(rectangle, creativeRectangles); + handle.hidden = !handlePosition; + handle.style.width = `${HANDLE_WIDTH_PX}px`; + handle.style.height = `${HANDLE_HEIGHT_PX}px`; + if (handlePosition) { + handle.style.left = `${handlePosition.left}px`; + handle.style.top = `${handlePosition.top}px`; } - badges.push(badge); + badges.add(badge); } - this.layer.replaceChildren(...badges); + for (const badge of Array.from(this.layer.querySelectorAll('.tsgd-badge'))) { + if (!badges.has(badge)) badge.remove(); + } + for (const badge of badges) { + if (!badge.isConnected) this.layer.append(badge); + } this.resizeObserver?.disconnect(); for (const element of observedElements) this.resizeObserver?.observe(element); } @@ -240,6 +309,37 @@ export class GptDiagnosticsBadgeManager { this.layer = undefined; } + private handlePosition( + rectangle: DOMRect, + creativeRectangles: DOMRect[] + ): { left: number; top: number } | undefined { + const gutter = BADGE_EDGE_GUTTER_PX; + const maxLeft = this.window.innerWidth - HANDLE_WIDTH_PX - gutter; + const maxTop = this.window.innerHeight - HANDLE_HEIGHT_PX - gutter; + const left = Math.max(gutter, Math.min(rectangle.left, maxLeft)); + const top = Math.max(gutter, Math.min(rectangle.top, maxTop)); + const candidates = [ + { left, top: rectangle.top - HANDLE_HEIGHT_PX - gutter }, + { left, top: rectangle.bottom + gutter }, + { left: rectangle.right + gutter, top }, + { left: rectangle.left - HANDLE_WIDTH_PX - gutter, top }, + ]; + return candidates.find( + (candidate) => + candidate.left >= gutter && + candidate.top >= gutter && + candidate.left <= maxLeft && + candidate.top <= maxTop && + creativeRectangles.every( + (creative) => + candidate.left + HANDLE_WIDTH_PX <= creative.left || + candidate.left >= creative.right || + candidate.top + HANDLE_HEIGHT_PX <= creative.top || + candidate.top >= creative.bottom + ) + ); + } + private readonly scheduleUpdate = (): void => { if (this.destroyed || this.scheduled) return; this.scheduled = true; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts new file mode 100644 index 0000000000..af9779597a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/evidence.ts @@ -0,0 +1,14 @@ +import type { GptDiagnosticsPrebidAuctionEvidence } from '../../core/types'; + +/** Return a mutable copy of retained Prebid auction evidence. */ +export function clonePrebidAuctionEvidence( + evidence: GptDiagnosticsPrebidAuctionEvidence +): GptDiagnosticsPrebidAuctionEvidence { + return { + ...evidence, + ...(evidence.targetingCandidate + ? { targetingCandidate: { ...evidence.targetingCandidate } } + : {}), + ...(evidence.win ? { win: { ...evidence.win } } : {}), + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index ac8a0de4cd..d1f7f605e3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -10,6 +10,7 @@ import { GptDiagnosticsBindingManager } from './binding'; import { GptDiagnosticsObserver } from './observer'; import type { GptObserverWindow } from './observer'; import { GptDiagnosticsOverlay } from './overlay'; +import { GptDiagnosticsPrebidRecorder } from './prebid'; import { GptDiagnosticsSlotSizeObserver } from './slot_size_observer'; import { GptDiagnosticsStore } from './store'; @@ -51,6 +52,7 @@ export function installGptDiagnosticsRuntime( let slotSizeObserver: GptDiagnosticsSlotSizeObserver | undefined; let apiController: GptDiagnosticsApiController | undefined; let traceHandoff: TraceHandoff | undefined; + let prebidRecorder: GptDiagnosticsPrebidRecorder | undefined; try { if (!target.tsjs) throw new Error('TSJS core API unavailable'); @@ -71,6 +73,8 @@ export function installGptDiagnosticsRuntime( badges = new GptDiagnosticsBadgeManager(store, bindings, { window: target, document: target.document, + onActivate: (runtimeSlotNumber, requestNumber) => + overlay?.selectRequest(runtimeSlotNumber, requestNumber), }); slotSizeObserver = new GptDiagnosticsSlotSizeObserver(store, bindings, { window: target }); overlay = new GptDiagnosticsOverlay(store, bindings, { @@ -93,6 +97,8 @@ export function installGptDiagnosticsRuntime( observer.install(); const api = apiController.api; const recorder = apiController.recorder; + prebidRecorder = new GptDiagnosticsPrebidRecorder(target, recorder); + recorder.recordCompletedPrebidAuction = prebidRecorder.recordCompletedPrebidAuction; const runtime: GptDiagnosticsRuntime = { api, destroy: () => { @@ -102,6 +108,7 @@ export function installGptDiagnosticsRuntime( if (target.tsjs?.gptDiagnosticsRecorder === recorder) { delete target.tsjs.gptDiagnosticsRecorder; } + prebidRecorder?.destroy(); apiController?.destroy(); overlay?.destroy(); badges?.destroy(); @@ -123,6 +130,7 @@ export function installGptDiagnosticsRuntime( } catch (error) { traceHandoff?.destroy(); traceHandoff = undefined; + prebidRecorder?.destroy(); apiController?.destroy(); overlay?.destroy(); badges?.destroy(); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index 8512bfe128..72e23005ee 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -4,7 +4,12 @@ import type { TraceHandoffState } from '../../trace/handoff'; import type { GptDiagnosticsBindingManager } from './binding'; import { unhandledCase } from './exhaustive'; -import { formatSizes, scheduleFrame } from './presentation_helpers'; +import { + auctionTypeLabel, + displayableGptFillSize, + formatSizes, + scheduleFrame, +} from './presentation_helpers'; import type { GptDiagnosticsStoreSlotSnapshot, GptDiagnosticsStoreSnapshot } from './store'; export const GPT_DIAGNOSTICS_HOST_ID = 'trusted-server-gpt-diagnostics'; @@ -72,7 +77,7 @@ const PANEL_STYLES = ` font: inherit; } button { cursor: pointer; } - button:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } + button:focus-visible, select:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } .tsgd-toolbar { display: flex; gap: 8px; align-items: center; border-bottom: 1px solid #334155; } .tsgd-toolbar { flex-wrap: wrap; } .tsgd-trace-action { min-height: 44px; background: #1d4ed8; font-weight: 700; } @@ -84,6 +89,13 @@ const PANEL_STYLES = ` .tsgd-empty { padding: 18px 12px; color: #94a3b8; } .tsgd-slot { border-bottom: 1px solid #334155; } .tsgd-slot:last-child { border-bottom: 0; } + .tsgd-slot[aria-current="true"], .tsgd-cycle[aria-current="true"] { outline: 2px solid #60a5fa; outline-offset: -2px; } + .tsgd-group { margin-top: 8px; } + .tsgd-group h3 { margin: 0; color: #e2e8f0; font-size: 12px; } + .tsgd-help { padding: 0 12px 8px; color: #cbd5e1; } + .tsgd-help p { margin: 6px 0 0; } + .tsgd-locate { margin-top: 8px; } + .tsgd-selection-note { padding: 8px 12px; color: #fde68a; border-bottom: 1px solid #334155; } .tsgd-slot-title { display: flex; gap: 8px; align-items: baseline; } .tsgd-slot-title strong { overflow-wrap: anywhere; } .tsgd-state { margin-left: auto; color: #fde68a; white-space: nowrap; } @@ -93,8 +105,12 @@ const PANEL_STYLES = ` summary { cursor: pointer; color: #93c5fd; } .tsgd-cycle { margin: 6px 0 0; padding: 6px 8px; background: #0f172a; border-radius: 5px; } .tsgd-badge-layer { position: fixed; z-index: 2147483646; inset: 0; pointer-events: none; } - .tsgd-badge { - position: fixed; + .tsgd-badge { position: fixed; pointer-events: none; } + .tsgd-badge-evidence { + display: block; + max-height: calc(100vh - 8px); + overflow: hidden; + pointer-events: none; padding: 5px 7px; color: #fff; background: rgb(15 23 42 / 94%); @@ -103,6 +119,25 @@ const PANEL_STYLES = ` box-shadow: 0 2px 8px rgb(0 0 0 / 35%); font: 11px/1.35 ui-sans-serif, system-ui, sans-serif; white-space: pre-line; + text-align: left; + } + .tsgd-badge-handle { + position: fixed; + pointer-events: auto; + min-height: 0; + padding: 2px 4px; + overflow: hidden; + white-space: nowrap; + font: 11px/1.35 ui-sans-serif, system-ui, sans-serif; + border-color: #60a5fa; + } + .tsgd-badge-handle[hidden] { display: none; } + .tsgd-highlight { + position: fixed; + border: 3px solid #fbbf24; + background: rgb(251 191 36 / 18%); + box-shadow: 0 0 0 3px rgb(15 23 42 / 75%); + pointer-events: none; } `; @@ -126,28 +161,34 @@ function formatMilliseconds(value: number | undefined): string | undefined { return `${Math.round(value * 10) / 10} ms`; } -function deliveryFact(cycle: GptDiagnosticsRequestCycle): string | undefined { +function deliveryFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.delivery) { case 'trusted_server_response_sent': - return 'Trusted Server selected; markup response sent to PUC'; + return 'Creative markup sent; execution not confirmed'; case 'trusted_server_selected': - return 'Trusted Server selected; no markup response confirmed'; + return 'Server bid selected by the creative bridge; response not confirmed'; case 'candidate_unconfirmed': - return 'Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible'; + return 'Server bid available; selection not confirmed'; case 'no_candidate': - return 'adInit observed no direct Trusted Server candidate for this request'; + return 'No direct Trusted Server candidate'; case 'unknown': - return 'Delivery status unknown — required GPT or direct-candidate evidence was not observed'; + return 'Delivery status unknown — required evidence was not observed'; case 'pending': return 'Waiting for Trusted Server creative evidence'; case 'not_applicable': + return 'Delivery evidence: Not applicable'; case undefined: - return undefined; + return 'Delivery evidence: Not observed'; default: - return unhandledCase(cycle.delivery); + unhandledCase(cycle.delivery); + return 'Delivery evidence: Not observed'; } } +function servedBidderFact(cycle: GptDiagnosticsRequestCycle): string | undefined { + return cycle.isEmpty === false ? 'Served bidder not confirmed' : undefined; +} + function requestPathFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.requestPath) { case 'trusted_server_direct': @@ -157,24 +198,24 @@ function requestPathFact(cycle: GptDiagnosticsRequestCycle): string { case 'publisher_refresh': return 'Request path: Publisher refresh'; case 'competing': - return 'Request path: Competing paths'; + return 'Request path: Multiple paths observed'; case 'unattributed': - return 'Request path: Unattributed'; + return 'Request path: Not observed'; case undefined: - return 'Request path: Unknown (not observed)'; + return 'Request path: Not observed'; } } function trustedServerOpportunityFact(cycle: GptDiagnosticsRequestCycle): string { switch (cycle.trustedServerOpportunity) { case 'renderable_candidate': - return 'Direct opportunity: Renderable candidate'; + return 'Server bid available; creative source present'; case 'unrenderable_candidate': - return 'Direct opportunity: Unrenderable candidate'; + return 'Server bid available; creative source incomplete'; case 'no_candidate': return 'Direct opportunity: No candidate'; case undefined: - return 'Direct opportunity: Unknown (not observed)'; + return 'Direct opportunity: Not observed'; } } @@ -247,16 +288,126 @@ function responseClassFact(cycle: GptDiagnosticsRequestCycle): string | undefine } } -function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { - const facts: string[] = [requestPathFact(cycle), trustedServerOpportunityFact(cycle)]; +function priceBucket(value: { priceBucket: string; currency?: string }): string { + return `${value.priceBucket}${value.currency ? ` ${value.currency}` : ''}`; +} + +function auctionFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [ + requestPathFact(cycle), + `Auction evidence: ${cycle.auctionType ? auctionTypeLabel(cycle.auctionType) : 'Auction not observed'}`, + ]; + if (cycle.auctionWinner) { + facts.push(`Server auction winner: ${cycle.auctionWinner.bidder}`); + facts.push(`Server bid price bucket: ${priceBucket(cycle.auctionWinner)}`); + } + if (cycle.prebidAuction?.targetingCandidate) { + const candidate = cycle.prebidAuction.targetingCandidate; + facts.push(`Prebid targeting candidate: ${candidate.bidder}`); + facts.push(`Prebid candidate price bucket: ${priceBucket(candidate)}`); + } + if (cycle.prebidAuction?.win) { + const win = cycle.prebidAuction.win; + facts.push(`Prebid bidWon observation: ${win.bidder}`); + facts.push(`Prebid win price bucket: ${priceBucket(win)}`); + } + const servedBidder = servedBidderFact(cycle); + if (servedBidder) facts.push(servedBidder); + return facts; +} + +function timingFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const serverTimingApplies = + cycle.auctionType === 'ssat' || + cycle.auctionType === 'trusted_server' || + cycle.auctionType === 'competing'; + const missingServerTiming = serverTimingApplies ? 'Unavailable' : 'Not applicable'; + const timingAnchor = + cycle.serverAuctionTimingOrigin === 'spa_auction' + ? 'SPA page-bids request T0' + : cycle.serverAuctionTimingOrigin === 'navigation' + ? 'Initial document request T0' + : 'Server request T0'; + const serverTimings = [ + [`${timingAnchor} → auction dispatched`, cycle.serverAuctionTimings?.auctionDispatchedMs], + [`${timingAnchor} → auction collected`, cycle.serverAuctionTimings?.auctionResolvedMs], + [`${timingAnchor} → bids ready`, cycle.serverAuctionTimings?.auctionCommittedMs], + ] as const; + const facts = serverTimings.map( + ([label, timing]) => `${label} ${formatMilliseconds(timing) ?? missingServerTiming}` + ); + const wait = formatMilliseconds(cycle.serverAuctionTimings?.auctionWaitMs); + if (wait) { + const placement = + cycle.serverAuctionTimings?.auctionWaitPlacement === 'pre_header' + ? 'pre-header' + : cycle.serverAuctionTimings?.auctionWaitPlacement === 'in_stream' + ? 'in stream' + : 'placement unknown'; + facts.push(`Auction collection wait (${placement}) ${wait}`); + } else { + facts.push(`Auction collection wait ${missingServerTiming}`); + } + facts.push( + `Opportunity → request ${formatMilliseconds(cycle.opportunityToRequestMs) ?? 'Unavailable'}` + ); + const durations = [ + ['GAM request → response', cycle.durations.requestToResponseMs], + ['GAM response → render', cycle.durations.responseToRenderMs], + ['GAM request → render', cycle.durations.requestToRenderMs], + ['Render → load', cycle.durations.renderToLoadMs], + ['Render → viewable', cycle.durations.renderToViewableMs], + ] as const; + for (const [label, duration] of durations) { + facts.push(`${label} ${formatMilliseconds(duration) ?? 'Unavailable'}`); + } + return facts; +} + +function deliveryFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [ + deliveryFact(cycle), + responseClassFact(cycle) ?? 'Ad Manager response class: Not observed', + adManagerFact(cycle) ?? 'Ad Manager fields: Not observed', + ]; + if (cycle.loadAtMs !== undefined) facts.push('GPT slot onload observed'); + if (cycle.viewableAtMs !== undefined) facts.push('GPT impressionViewable observed'); + if (cycle.incompleteSequence) facts.push('Incomplete sequence'); + if (cycle.isBackfill !== undefined) facts.push(`Backfill ${cycle.isBackfill ? 'yes' : 'no'}`); + if (cycle.slotContentChanged !== undefined) { + facts.push(`Slot content changed ${cycle.slotContentChanged ? 'yes' : 'no'}`); + } + return facts; +} + +function sizeFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const fillSize = displayableGptFillSize(cycle.size); + return [ + cycle.requestedSlotSizes + ? `Requested sizes ${formatSizes(cycle.requestedSlotSizes)}` + : 'Requested sizes: Not observed', + fillSize + ? `GPT-reported size ${formatSizes([fillSize])}` + : cycle.size?.[0] === 1 && cycle.size[1] === 1 + ? 'GPT-reported size: placeholder hidden' + : 'GPT-reported size: Not observed', + cycle.observedSlotSize + ? `Size filled ${formatSizes([cycle.observedSlotSize])} · Measured outer slot size` + : 'Size filled: Not observed · Measured outer slot size', + ]; +} + +function technicalCycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { + const facts = [trustedServerOpportunityFact(cycle)]; if (Number.isSafeInteger(cycle.requestIntentId) && cycle.requestIntentId! > 0) { facts.push(`Request intent: ${cycle.requestIntentId}`); } if (typeof cycle.trustedServerAuctionId === 'string' && cycle.trustedServerAuctionId.length > 0) { facts.push(`Trusted Server auction: ${cycle.trustedServerAuctionId}`); } - const opportunityToRequest = formatMilliseconds(cycle.opportunityToRequestMs); - if (opportunityToRequest) facts.push(`Opportunity → request ${opportunityToRequest}`); + if (cycle.prebidAuction?.auctionId) { + facts.push(`Prebid auction: ${cycle.prebidAuction.auctionId}`); + } const previousRenderToRequest = formatMilliseconds(cycle.previousRenderToRequestMs); if (cycle.replacedRequestNumber !== undefined && previousRenderToRequest) { facts.push( @@ -287,41 +438,19 @@ function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { for (const failure of new Set(cycle.trustedServerCreativeFailures ?? [])) { facts.push(creativeFailureFact(failure)); } - const deliveryLine = deliveryFact(cycle); - if (deliveryLine) facts.push(deliveryLine); - const responseClassLine = responseClassFact(cycle); - if (responseClassLine) facts.push(responseClassLine); - const adManagerLine = adManagerFact(cycle); - if (adManagerLine) facts.push(adManagerLine); - if (cycle.loadAtMs !== undefined) facts.push('GPT slot onload observed'); - if (cycle.viewableAtMs !== undefined) facts.push('GPT impressionViewable observed'); - if (cycle.incompleteSequence) facts.push('Incomplete sequence'); - if (cycle.requestedSlotSizes) { - facts.push(`Requested slot sizes ${formatSizes(cycle.requestedSlotSizes)}`); - } - if (cycle.size) facts.push(`GPT-reported fill size ${cycle.size[0]}×${cycle.size[1]}`); - if (cycle.observedSlotSize) { - facts.push(`Observed outer slot box ${cycle.observedSlotSize[0]}×${cycle.observedSlotSize[1]}`); - } - if (cycle.isBackfill !== undefined) facts.push(`Backfill ${cycle.isBackfill ? 'yes' : 'no'}`); - if (cycle.slotContentChanged !== undefined) { - facts.push(`Slot content changed ${cycle.slotContentChanged ? 'yes' : 'no'}`); - } - - const durations = [ - ['Request → response', cycle.durations.requestToResponseMs], - ['Response → render', cycle.durations.responseToRenderMs], - ['Request → render', cycle.durations.requestToRenderMs], - ['Render → load', cycle.durations.renderToLoadMs], - ['Render → viewable', cycle.durations.renderToViewableMs], - ] as const; - for (const [label, duration] of durations) { - const formatted = formatMilliseconds(duration); - if (formatted) facts.push(`${label} ${formatted}`); - } return facts; } +function cycleFacts(cycle: GptDiagnosticsRequestCycle): string[] { + return [ + ...auctionFacts(cycle), + ...deliveryFacts(cycle), + ...timingFacts(cycle), + ...sizeFacts(cycle), + ...technicalCycleFacts(cycle), + ]; +} + function cycleLabel(cycle: GptDiagnosticsRequestCycle): string { return cycle.requestNumber === 1 ? 'Initial request' : `Refresh ${cycle.requestNumber - 1}`; } @@ -353,6 +482,21 @@ function appendFacts(document: Document, parent: HTMLElement, facts: string[]): parent.append(list); } +function appendGroup( + document: Document, + parent: HTMLElement, + heading: string, + facts: string[] +): void { + const section = document.createElement('section'); + section.className = 'tsgd-group'; + const title = document.createElement('h3'); + title.textContent = heading; + section.append(title); + appendFacts(document, section, facts); + parent.append(section); +} + /** Owns hydration-safe mounting and the closed-shadow diagnostics panel. */ export class GptDiagnosticsOverlay { private readonly store: OverlayStore; @@ -370,6 +514,7 @@ export class GptDiagnosticsOverlay { private readonly unsubscribeBindings: () => void; private host?: HTMLElement; private panel?: HTMLElement; + private badgeLayer?: HTMLElement; private lifecycleObserver?: MutationObserver; private visualReady = false; private mountWaitStarted = false; @@ -380,6 +525,9 @@ export class GptDiagnosticsOverlay { private dismissed = false; private destroyed = false; private filter: GptDiagnosticsFilter = 'all'; + private selectedRequest?: { runtimeSlotNumber: number; requestNumber: number }; + private historySlotToOpenOnce?: number; + private selectedRequestHasFocus = false; constructor(store: OverlayStore, bindings: OverlayBindings, options: OverlayOptions = {}) { this.store = store; @@ -419,6 +567,28 @@ export class GptDiagnosticsOverlay { this.scheduleRender(); } + /** Open and focus the exact retained request selected from an on-page badge. */ + selectRequest(runtimeSlotNumber: number, requestNumber: number): void { + if (this.destroyed) return; + this.selectedRequest = { runtimeSlotNumber, requestNumber }; + const slot = this.store + .snapshot() + .slots.find((candidate) => candidate.runtimeSlotNumber === runtimeSlotNumber); + this.historySlotToOpenOnce = + slot && latestCycle(slot)?.requestNumber !== requestNumber ? runtimeSlotNumber : undefined; + this.filter = 'all'; + this.collapsed = false; + this.show(); + this.render(); + this.scheduleFrame(() => { + const selected = this.panel?.querySelector( + `[data-runtime-slot="${runtimeSlotNumber}"][data-request-number="${requestNumber}"]` + ); + selected?.focus(); + selected?.scrollIntoView?.({ block: 'nearest' }); + }); + } + destroy(): void { if (this.destroyed) return; this.destroyed = true; @@ -477,11 +647,11 @@ export class GptDiagnosticsOverlay { panel.setAttribute('aria-label', 'GPT runtime diagnostics'); const badgeLayer = this.document.createElement('div'); badgeLayer.className = 'tsgd-badge-layer'; - badgeLayer.setAttribute('aria-hidden', 'true'); root.append(style, badgeLayer, panel); this.host = host; this.panel = panel; + this.badgeLayer = badgeLayer; (this.document.body ?? this.document.documentElement).append(host); this.onShadowRoot?.(root); this.onBadgeLayerChange?.(badgeLayer); @@ -493,6 +663,7 @@ export class GptDiagnosticsOverlay { const host = this.host; this.host = undefined; this.panel = undefined; + this.badgeLayer = undefined; host?.remove(); } @@ -540,11 +711,20 @@ export class GptDiagnosticsOverlay { const panel = this.panel; const previousContent = panel.querySelector('.tsgd-content'); const previousScrollTop = previousContent?.scrollTop ?? 0; + const selectedRequestWasFocused = this.selectedRequestHasFocus; + const helpOpen = panel.querySelector('.tsgd-help')?.open ?? false; const openHistorySlots = new Set( - Array.from(panel.querySelectorAll('.tsgd-slot details[open]')) + Array.from(panel.querySelectorAll('.tsgd-history[open]')) .map((details) => details.closest('.tsgd-slot')?.dataset.runtimeSlot) .filter((runtimeSlot): runtimeSlot is string => runtimeSlot !== undefined) ); + const openTechnicalSlots = new Set( + Array.from(panel.querySelectorAll('.tsgd-technical[open]')) + .map((details) => details.closest('.tsgd-slot')?.dataset.runtimeSlot) + .filter((runtimeSlot): runtimeSlot is string => runtimeSlot !== undefined) + ); + const historySlotToOpenOnce = this.historySlotToOpenOnce; + this.historySlotToOpenOnce = undefined; const retainedToolbar = this.onViewTrace && !this.collapsed ? panel.querySelector('.tsgd-toolbar') @@ -577,6 +757,8 @@ export class GptDiagnosticsOverlay { const toolbar = retainedToolbar ?? this.document.createElement('div'); toolbar.className = 'tsgd-toolbar'; + const retainedFilter = retainedToolbar?.querySelector('select'); + if (retainedFilter) retainedFilter.value = this.filter; if (!retainedToolbar) { const filterLabel = this.document.createElement('label'); filterLabel.textContent = 'Filter'; @@ -603,7 +785,14 @@ export class GptDiagnosticsOverlay { }); const exportButton = this.button('Export JSON', () => this.onExport()); filterLabel.append(select); - toolbar.append(filterLabel, exportButton); + const dictionaryLink = this.document.createElement('a'); + dictionaryLink.href = + 'https://iabtechlab.github.io/trusted-server/guide/integrations/gpt-diagnostics-dictionary'; + dictionaryLink.target = '_blank'; + dictionaryLink.rel = 'noopener noreferrer'; + dictionaryLink.textContent = 'Label dictionary'; + dictionaryLink.setAttribute('aria-label', 'Open GPT diagnostics label dictionary'); + toolbar.append(filterLabel, exportButton, dictionaryLink); } if (this.onViewTrace) { const viewTrace = @@ -650,6 +839,17 @@ export class GptDiagnosticsOverlay { if (!retainedTraceStatus) panel.append(traceStatus); } + const help = this.document.createElement('details'); + help.className = 'tsgd-help'; + help.open = helpOpen; + const helpSummary = this.document.createElement('summary'); + helpSummary.textContent = 'How to read this evidence'; + const helpText = this.document.createElement('p'); + helpText.textContent = + 'Auction winners, Prebid candidates, and GPT render results are separate observations. “Filled” does not identify the served bidder. Browser and server timings use separate clocks.'; + help.append(helpSummary, helpText); + panel.append(help); + const summary = this.document.createElement('div'); summary.className = 'tsgd-summary'; summary.textContent = `${snapshot.slots.length} slots · ${snapshot.callbackIssues.length} callback issues · ${snapshot.attributionIssues?.length ?? 0} attribution issues`; @@ -664,6 +864,23 @@ export class GptDiagnosticsOverlay { summary.append(coverage); panel.append(summary); + if ( + this.selectedRequest && + !snapshot.slots.some( + (slot) => + slot.runtimeSlotNumber === this.selectedRequest?.runtimeSlotNumber && + slot.requests.some((cycle) => cycle.requestNumber === this.selectedRequest?.requestNumber) + ) + ) { + const note = this.document.createElement('div'); + note.className = 'tsgd-selection-note'; + note.setAttribute('role', 'status'); + note.textContent = `Ad #${this.selectedRequest.runtimeSlotNumber}, Request #${this.selectedRequest.requestNumber} is no longer retained.`; + panel.append(note); + this.selectedRequest = undefined; + this.selectedRequestHasFocus = false; + } + const content = this.document.createElement('div'); content.className = 'tsgd-content'; const filteredSlots = snapshot.slots.filter((slot) => @@ -677,22 +894,47 @@ export class GptDiagnosticsOverlay { content.append(empty); } else { for (const slot of filteredSlots) { - content.append(this.renderSlot(slot, openHistorySlots.has(String(slot.runtimeSlotNumber)))); + const runtimeSlot = String(slot.runtimeSlotNumber); + content.append( + this.renderSlot( + slot, + openHistorySlots.has(runtimeSlot) || historySlotToOpenOnce === slot.runtimeSlotNumber, + openTechnicalSlots.has(runtimeSlot) + ) + ); } } panel.append(content); content.scrollTop = previousScrollTop; + if (selectedRequestWasFocused) { + panel.querySelector('[aria-current="true"]')?.focus({ preventScroll: true }); + } } - private renderSlot(slot: GptDiagnosticsStoreSlotSnapshot, historyOpen: boolean): HTMLElement { + private renderSlot( + slot: GptDiagnosticsStoreSlotSnapshot, + historyOpen: boolean, + technicalOpen: boolean + ): HTMLElement { const container = this.document.createElement('article'); container.className = 'tsgd-slot'; + const latest = latestCycle(slot); container.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + if (latest) container.dataset.requestNumber = String(latest.requestNumber); + const latestSelected = + latest !== undefined && + this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && + this.selectedRequest.requestNumber === latest.requestNumber; + if (latestSelected) { + container.tabIndex = -1; + container.setAttribute('aria-current', 'true'); + this.trackSelectedRequestFocus(container); + } + const title = this.document.createElement('div'); title.className = 'tsgd-slot-title'; const name = this.document.createElement('strong'); - name.textContent = slot.slotElementId ?? `Unbound GPT slot ${slot.runtimeSlotNumber}`; - const latest = latestCycle(slot); + name.textContent = `Ad #${slot.runtimeSlotNumber}${latest ? ` · Request #${latest.requestNumber}` : ''} · ${slot.slotElementId ?? 'Unbound GPT slot'}`; const state = this.document.createElement('span'); state.className = 'tsgd-state'; state.textContent = primaryState(latest); @@ -700,41 +942,117 @@ export class GptDiagnosticsOverlay { container.append(title); const binding = this.bindings.get(slot.runtimeSlotNumber); - const facts = [ - slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : undefined, - binding.binding.status === 'bound' - ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` - : binding.binding.status === 'ambiguous' - ? `Ambiguous binding · ${binding.binding.reason ?? 'unknown'}` - : `Unbound · ${binding.binding.reason ?? 'unknown'}`, - slot.currentVisibilityPercentage !== undefined - ? `GPT visibility ${slot.currentVisibilityPercentage}% (maximum ${slot.maximumVisibilityPercentage ?? slot.currentVisibilityPercentage}%)` - : undefined, - latest ? cycleLabel(latest) : undefined, - ...(latest ? cycleFacts(latest) : []), - ].filter((fact): fact is string => fact !== undefined); - appendFacts(this.document, container, facts); + if (binding.binding.status === 'bound' && binding.element?.isConnected) { + const locate = this.button('Locate on page', () => this.locateOnPage(slot.runtimeSlotNumber)); + locate.className = 'tsgd-locate'; + container.append(locate); + } + + if (latest) { + const summaryFacts = [ + `Ad #${slot.runtimeSlotNumber} · Request #${latest.requestNumber}`, + `GPT result: ${primaryState(latest)}`, + `Observed auction path: ${latest.auctionType ? auctionTypeLabel(latest.auctionType) : 'Auction not observed'}`, + deliveryFact(latest), + ]; + const servedBidder = servedBidderFact(latest); + if (servedBidder) summaryFacts.push(servedBidder); + appendGroup(this.document, container, 'Summary', summaryFacts); + appendGroup(this.document, container, 'Auction evidence', auctionFacts(latest)); + appendGroup(this.document, container, 'Delivery evidence', deliveryFacts(latest)); + appendGroup(this.document, container, 'Timing', timingFacts(latest)); + appendGroup(this.document, container, 'Size and visibility', [ + ...sizeFacts(latest), + binding.binding.status === 'bound' + ? `Binding: Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : `Binding: ${binding.binding.status} · ${binding.binding.reason ?? 'reason unavailable'}`, + slot.currentVisibilityPercentage !== undefined + ? `GPT visibility ${slot.currentVisibilityPercentage}% (maximum ${slot.maximumVisibilityPercentage ?? slot.currentVisibilityPercentage}%)` + : 'GPT visibility: Not observed', + ]); + } if (slot.requests.length > 1) { const history = this.document.createElement('details'); + history.className = 'tsgd-history'; history.open = historyOpen; const summary = this.document.createElement('summary'); - summary.textContent = `Previous requests (${slot.requests.length - 1})`; + summary.textContent = `Request history (${slot.requests.length - 1} previous)`; history.append(summary); for (const cycle of slot.requests.slice(0, -1).reverse()) { const previous = this.document.createElement('div'); previous.className = 'tsgd-cycle'; + previous.dataset.runtimeSlot = String(slot.runtimeSlotNumber); + previous.dataset.requestNumber = String(cycle.requestNumber); + const selected = + this.selectedRequest?.runtimeSlotNumber === slot.runtimeSlotNumber && + this.selectedRequest.requestNumber === cycle.requestNumber; + if (selected) { + previous.tabIndex = -1; + previous.setAttribute('aria-current', 'true'); + this.trackSelectedRequestFocus(previous); + } const heading = this.document.createElement('strong'); - heading.textContent = `${cycleLabel(cycle)} · ${primaryState(cycle)}`; + heading.textContent = `Request #${cycle.requestNumber} · ${cycleLabel(cycle)} · ${primaryState(cycle)}`; previous.append(heading); appendFacts(this.document, previous, cycleFacts(cycle)); history.append(previous); } container.append(history); } + + const technical = this.document.createElement('details'); + technical.className = 'tsgd-technical'; + technical.open = technicalOpen; + const technicalSummary = this.document.createElement('summary'); + technicalSummary.textContent = 'Technical details'; + technical.append(technicalSummary); + appendFacts(this.document, technical, [ + slot.adUnitPath ? `Ad unit ${slot.adUnitPath}` : 'Ad unit: Unavailable', + ...(latest + ? [] + : [ + binding.binding.status === 'bound' + ? `Bound · ${binding.visible ? 'Visible' : 'Outside viewport'}` + : binding.binding.status === 'ambiguous' + ? `Ambiguous binding · ${binding.binding.reason ?? 'reason unavailable'}` + : `Unbound · ${binding.binding.reason ?? 'reason unavailable'}`, + ]), + ...(latest ? technicalCycleFacts(latest) : []), + ]); + container.append(technical); return container; } + private trackSelectedRequestFocus(element: HTMLElement): void { + element.addEventListener('focus', () => { + this.selectedRequestHasFocus = true; + }); + element.addEventListener('blur', () => { + this.selectedRequestHasFocus = false; + }); + } + + private locateOnPage(runtimeSlotNumber: number): void { + const binding = this.bindings.get(runtimeSlotNumber); + const element = binding.element; + if (binding.binding.status !== 'bound' || !element?.isConnected) return; + element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); + this.scheduleFrame(() => { + if (!this.badgeLayer?.isConnected || !element.isConnected) return; + const rectangle = element.getBoundingClientRect(); + const highlight = this.document.createElement('div'); + highlight.className = 'tsgd-highlight'; + highlight.setAttribute('aria-hidden', 'true'); + highlight.style.left = `${rectangle.left}px`; + highlight.style.top = `${rectangle.top}px`; + highlight.style.width = `${rectangle.width}px`; + highlight.style.height = `${rectangle.height}px`; + this.badgeLayer.append(highlight); + this.window.setTimeout(() => highlight.remove(), 1500); + }); + } + private button(label: string, action: () => void): HTMLButtonElement { const button = this.document.createElement('button'); button.type = 'button'; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/prebid.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/prebid.ts new file mode 100644 index 0000000000..8db150dbb6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/prebid.ts @@ -0,0 +1,179 @@ +import type { + GptDiagnosticsAuctionWinner, + GptDiagnosticsRecorder, + GptDiagnosticsSlotHandle, + TsjsApi, +} from '../../core/types'; + +type DiagnosticSlot = GptDiagnosticsSlotHandle & { + getTargeting?: (key: string) => string[]; +}; +type DiagnosticPrebid = Parameters< + NonNullable +>[0]; +type DiagnosticWindow = { + performance: Pick; + tsjs?: Pick; +}; + +const MAX_PREBID_DIAGNOSTIC_ATTEMPTS = 128; +const PREBID_DIAGNOSTIC_WINDOW_MS = 30_000; + +interface PrebidDiagnosticAttempt { + prebid: DiagnosticPrebid; + slot: DiagnosticSlot; + generation: number; + expiresAtMs: number; +} + +function prebidDiagnosticKey(auctionId: string, adUnitCode: string): string { + return `${auctionId}\u0000${adUnitCode}`; +} + +function boundedTargetingValue( + slot: DiagnosticSlot, + key: string, + maxBytes: number +): string | undefined { + let values: string[] | undefined; + try { + values = slot.getTargeting?.(key); + } catch { + return undefined; + } + if (!Array.isArray(values) || values.length !== 1) return undefined; + const value = values[0]?.trim(); + if (!value || new TextEncoder().encode(value).length > maxBytes) return undefined; + return value; +} + +function targetingCandidate(slot: DiagnosticSlot): GptDiagnosticsAuctionWinner | undefined { + const bidder = boundedTargetingValue(slot, 'hb_bidder', 128); + const priceBucket = boundedTargetingValue(slot, 'hb_pb', 64); + if (!bidder || !priceBucket || !/^\d+(?:\.\d+)?$/.test(priceBucket)) return undefined; + return { bidder, priceBucket }; +} + +/** Correlates targeting and bidWon only in the optional diagnostics bundle. */ +export class GptDiagnosticsPrebidRecorder { + private readonly attempts = new Map(); + private readonly installed = new Map void>(); + private destroyed = false; + + constructor( + private readonly target: DiagnosticWindow, + private recorder: + | Pick + | undefined + ) {} + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.recorder = undefined; + this.attempts.clear(); + for (const [prebid, listener] of this.installed) { + try { + prebid.offEvent?.('bidWon', listener); + } catch { + // A missing or failing unsubscribe leaves an inert listener, not the store. + } + } + this.installed.clear(); + } + + recordCompletedPrebidAuction = ( + pbjs: DiagnosticPrebid, + rawAuctionId: unknown, + auctionSlots: DiagnosticSlot[], + adUnitCodes: string[] + ): void => { + if (this.destroyed) return; + const recorder = this.recorder; + if (!recorder || typeof rawAuctionId !== 'string') return; + const auctionId = rawAuctionId; + if ( + !auctionId || + auctionId !== auctionId.trim() || + new TextEncoder().encode(auctionId).length > 256 + ) + return; + if (auctionSlots.length !== adUnitCodes.length) return; + this.installWinListener(pbjs); + const counts = new Map(); + for (const code of adUnitCodes) counts.set(code, (counts.get(code) ?? 0) + 1); + const nowMs = this.target.performance.now(); + for (const [key, attempt] of this.attempts) { + if (nowMs > attempt.expiresAtMs) this.attempts.delete(key); + } + const generation = this.target.tsjs?.navGeneration ?? 0; + for (let index = 0; index < auctionSlots.length; index += 1) { + const slot = auctionSlots[index]; + const code = adUnitCodes[index]; + if (!slot || !code || counts.get(code) !== 1) continue; + try { + recorder.recordPrebidAuction(slot, auctionId, targetingCandidate(slot)); + } catch { + // Diagnostics must not suppress the GAM request. + } + const key = prebidDiagnosticKey(auctionId, code); + this.attempts.delete(key); + this.attempts.set(key, { + prebid: pbjs, + slot, + generation, + expiresAtMs: nowMs + PREBID_DIAGNOSTIC_WINDOW_MS, + }); + while (this.attempts.size > MAX_PREBID_DIAGNOSTIC_ATTEMPTS) { + const oldest = this.attempts.keys().next().value; + if (oldest === undefined) break; + this.attempts.delete(oldest); + } + } + }; + + private installWinListener(pbjs: DiagnosticPrebid): void { + if (this.installed.has(pbjs) || typeof pbjs.onEvent !== 'function') return; + const listener = (rawBid: unknown): void => { + if (this.destroyed) return; + if (typeof rawBid !== 'object' || rawBid === null) return; + const bid = rawBid as Record; + const auctionId = typeof bid.auctionId === 'string' ? bid.auctionId : undefined; + const adUnitCode = typeof bid.adUnitCode === 'string' ? bid.adUnitCode : undefined; + if ( + !auctionId || + !adUnitCode || + (bid.latestTargetedAuctionId !== undefined && bid.latestTargetedAuctionId !== auctionId) + ) + return; + const key = prebidDiagnosticKey(auctionId, adUnitCode); + const attempt = this.attempts.get(key); + if (attempt?.prebid !== pbjs) return; + this.attempts.delete(key); + if ( + this.target.performance.now() > attempt.expiresAtMs || + (this.target.tsjs?.navGeneration ?? 0) !== attempt.generation + ) { + return; + } + const adserverTargeting = + typeof bid.adserverTargeting === 'object' && bid.adserverTargeting !== null + ? (bid.adserverTargeting as Record) + : {}; + const winner = targetingCandidate({ + getTargeting: (targetingKey) => { + const value = adserverTargeting[targetingKey]; + return typeof value === 'string' ? [value] : []; + }, + }); + if (!winner) return; + try { + this.recorder?.recordPrebidWin(attempt.slot, auctionId, winner); + } catch { + // Diagnostics must not alter delivery. + } + }; + pbjs.onEvent('bidWon', listener); + this.installed.set(pbjs, listener); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts index b601833b8f..42ce496c66 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts @@ -1,10 +1,43 @@ -import type { Size } from '../../core/types'; +import type { GptDiagnosticsAuctionType, Size } from '../../core/types'; /** Formats CSS sizes consistently across diagnostics presentation surfaces. */ export function formatSizes(sizes: ReadonlyArray): string { return sizes.map((size) => `${size[0]}×${size[1]}`).join(', '); } +/** Hide GPT's ubiquitous 1×1 placeholder from presentation while retaining it in exports. */ +export function displayableGptFillSize(size: Size | undefined): Size | undefined { + return size?.[0] === 1 && size[1] === 1 ? undefined : size; +} + +/** Human-readable auction classification shared by the badge and side panel. */ +export function auctionTypeLabel(type: GptDiagnosticsAuctionType): string { + switch (type) { + case 'ssat': + return 'SSAT: initial-page server auction'; + case 'trusted_server': + return 'TS auction: SPA server auction'; + case 'client_side': + return 'Client-side Prebid auction'; + case 'competing': + return 'Multiple auction paths observed'; + } +} + +/** Compact auction classification shared by on-page badges. */ +export function auctionTypeBadgeLabel(type: GptDiagnosticsAuctionType): string { + switch (type) { + case 'ssat': + return 'SSAT'; + case 'trusted_server': + return 'TS auction'; + case 'client_side': + return 'Prebid auction'; + case 'competing': + return 'Multiple paths'; + } +} + /** Schedules presentation work in the target window's next animation frame. */ export function scheduleFrame( window: Pick, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index 3f6fff45a8..4e4dc7347d 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -1,4 +1,5 @@ import type { + AuctionDiagnosticsData, GptDiagnosticsAdManagerIdentity, GptDiagnosticsAttributionIssue, GptDiagnosticsAttributionIssueReason, @@ -8,10 +9,15 @@ import type { GptDiagnosticsCoverageCounters, GptDiagnosticsCreativeFailure, GptDiagnosticsDelivery, + GptDiagnosticsAuctionFacts, + GptDiagnosticsAuctionType, + GptDiagnosticsAuctionWinner, + GptDiagnosticsPrebidAuctionEvidence, GptDiagnosticsDurations, GptDiagnosticsRequestCycle, GptDiagnosticsRequestPath, GptDiagnosticsResponseClass, + GptDiagnosticsServerAuctionTimingOrigin, GptDiagnosticsSlotHandle, GptDiagnosticsTrustedServerOpportunity, Size, @@ -20,6 +26,8 @@ import { GPT_DIAGNOSTICS_CREATIVE_FAILURES } from '../../core/types'; import type { TraceGptIdentity, TraceSlotCorrelationV1 } from '../../trace/types'; import { validDiagnosticAuctionId, validTraceSlotRef } from '../../trace/validation'; +import { clonePrebidAuctionEvidence } from './evidence'; + function normalizedTraceIdentity( value: TraceGptIdentity | undefined ): TraceGptIdentity | undefined { @@ -135,6 +143,10 @@ interface PendingSourceEvidence { trustedServerAuctionId?: string; requestedSlotSizes?: ReadonlyArray; traceIdentity?: TraceGptIdentity; + auctionType?: Extract; + auctionWinner?: GptDiagnosticsAuctionWinner; + prebidAuction?: GptDiagnosticsPrebidAuctionEvidence; + serverAuctionTimings?: AuctionDiagnosticsData; } interface PendingRequestIntent { @@ -254,6 +266,78 @@ function normalizedRequestedSlotSizes(value: unknown): ReadonlyArray | und return requestedSlotSizes.length > 0 ? Object.freeze(requestedSlotSizes) : undefined; } +function normalizedBoundedString(value: unknown, maxBytes: number): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0 || new TextEncoder().encode(trimmed).length > maxBytes) return undefined; + return trimmed; +} + +function normalizedAuctionWinner(value: unknown): GptDiagnosticsAuctionWinner | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Partial; + const bidder = normalizedBoundedString(candidate.bidder, 128); + const priceBucket = normalizedBoundedString(candidate.priceBucket, 64); + if (!bidder || !priceBucket || !/^\d+(?:\.\d+)?$/.test(priceBucket)) return undefined; + const currency = normalizedBoundedString(candidate.currency, 3)?.toUpperCase(); + return Object.freeze({ + bidder, + priceBucket, + ...(currency && /^[A-Z]{3}$/.test(currency) ? { currency } : {}), + }); +} + +const MAX_SERVER_AUCTION_TIMING_MS = 0xffffffff; + +function normalizedTiming(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + value <= MAX_SERVER_AUCTION_TIMING_MS + ? value + : undefined; +} + +function normalizedServerAuctionTimings(value: unknown): AuctionDiagnosticsData | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as AuctionDiagnosticsData; + const timings: AuctionDiagnosticsData = { + auctionDispatchedMs: normalizedTiming(candidate.auctionDispatchedMs), + auctionResolvedMs: normalizedTiming(candidate.auctionResolvedMs), + auctionCommittedMs: normalizedTiming(candidate.auctionCommittedMs), + auctionWaitMs: normalizedTiming(candidate.auctionWaitMs), + auctionWaitPlacement: + candidate.auctionWaitPlacement === 'pre_header' || + candidate.auctionWaitPlacement === 'in_stream' + ? candidate.auctionWaitPlacement + : undefined, + }; + return Object.values(timings).some((entry) => entry !== undefined) + ? Object.freeze(timings) + : undefined; +} + +function normalizedAuctionFacts( + value: unknown +): Omit< + PendingSourceEvidence, + 'observedAtMs' | 'trustedServerOpportunity' | 'trustedServerAuctionId' | 'requestedSlotSizes' +> { + if (typeof value !== 'object' || value === null) return {}; + const candidate = value as GptDiagnosticsAuctionFacts; + const auctionType = + candidate.auctionType === 'ssat' || candidate.auctionType === 'trusted_server' + ? candidate.auctionType + : undefined; + const auctionWinner = normalizedAuctionWinner(candidate.winner); + const serverAuctionTimings = normalizedServerAuctionTimings(candidate.serverTimings); + return { + ...(auctionType ? { auctionType } : {}), + ...(auctionWinner ? { auctionWinner } : {}), + ...(serverAuctionTimings ? { serverAuctionTimings } : {}), + }; +} + function responseClass(cycle: MutableRequestCycle): GptDiagnosticsResponseClass | undefined { if (cycle.renderAtMs === undefined) return undefined; if (cycle.isEmpty === true) return 'empty'; @@ -309,6 +393,13 @@ function copyCycle(cycle: MutableRequestCycle, nowMs: number): GptDiagnosticsReq companyIds: cycle.adManager.companyIds ? [...cycle.adManager.companyIds] : undefined, } : undefined, + ...(cycle.auctionWinner ? { auctionWinner: { ...cycle.auctionWinner } } : {}), + ...(cycle.prebidAuction + ? { prebidAuction: clonePrebidAuctionEvidence(cycle.prebidAuction) } + : {}), + ...(cycle.serverAuctionTimings + ? { serverAuctionTimings: { ...cycle.serverAuctionTimings } } + : {}), trustedServerCreativeFailures: cycle.trustedServerCreativeFailures ? [...cycle.trustedServerCreativeFailures] : undefined, @@ -364,6 +455,7 @@ export class GptDiagnosticsStore { opportunity: GptDiagnosticsTrustedServerOpportunity, trustedServerAuctionId?: string, requestedSlotSizes?: ReadonlyArray, + auctionFacts?: GptDiagnosticsAuctionFacts, traceIdentity?: TraceGptIdentity ): void { if ( @@ -396,6 +488,7 @@ export class GptDiagnosticsStore { trustedServerAuctionId: auctionId, requestedSlotSizes: normalizedRequestedSlotSizes(requestedSlotSizes), ...(correlatedIdentity ? { traceIdentity: correlatedIdentity } : {}), + ...normalizedAuctionFacts(auctionFacts), }); } @@ -409,6 +502,47 @@ export class GptDiagnosticsStore { } } + /** Record a completed Prebid attempt for one exact slot's next request. */ + recordPrebidAuction( + slot: GptDiagnosticsSlotLike, + auctionId: string, + targetingCandidate?: GptDiagnosticsAuctionWinner + ): void { + if (!isSlotObject(slot)) return; + const normalizedId = normalizedAuctionId(auctionId); + if (!normalizedId) return; + const candidate = normalizedAuctionWinner(targetingCandidate); + this.recordRequestIntentSource(slot, 'prebid_refresh', { + prebidAuction: Object.freeze({ + auctionId: normalizedId, + ...(candidate ? { targetingCandidate: candidate } : {}), + }), + }); + } + + /** Attach a documented Prebid win only to its retained exact slot and attempt. */ + recordPrebidWin( + slot: GptDiagnosticsSlotLike, + auctionId: string, + winner: GptDiagnosticsAuctionWinner + ): void { + if (!isSlotObject(slot)) return; + const normalizedId = normalizedAuctionId(auctionId); + const normalizedWinner = normalizedAuctionWinner(winner); + if (!normalizedId || !normalizedWinner) return; + const runtimeSlotNumber = this.slotNumbers.get(slot); + const record = runtimeSlotNumber === undefined ? undefined : this.slots.get(runtimeSlotNumber); + const matches = record?.requests.filter( + (cycle) => cycle.prebidAuction?.auctionId === normalizedId + ); + if (!matches || matches.length !== 1 || !matches[0]?.prebidAuction) return; + matches[0].prebidAuction = Object.freeze({ + ...matches[0].prebidAuction, + win: normalizedWinner, + }); + this.notify(); + } + /** Record publisher refresh observation from the private GPT diagnostics observer. */ recordPublisherRefresh(slots: GptDiagnosticsSlotLike[]): void { if (!Array.isArray(slots)) return; @@ -630,6 +764,13 @@ export class GptDiagnosticsStore { const intent = this.consumeRequestIntent(slot, timestampMs); const trustedServerEvidence = intent?.sources.get('trusted_server_direct'); const requestPath = this.requestPath(intent); + const auctionType = this.auctionType(intent, trustedServerEvidence); + const serverAuctionTimingOrigin: GptDiagnosticsServerAuctionTimingOrigin | undefined = + trustedServerEvidence?.auctionType === 'trusted_server' + ? 'spa_auction' + : trustedServerEvidence?.auctionType === 'ssat' + ? 'navigation' + : undefined; record.requests.push({ requestNumber, requestedAtMs: timestampMs, @@ -643,6 +784,17 @@ export class GptDiagnosticsStore { ...(trustedServerEvidence?.trustedServerAuctionId !== undefined ? { trustedServerAuctionId: trustedServerEvidence.trustedServerAuctionId } : {}), + ...(auctionType !== undefined ? { auctionType } : {}), + ...(trustedServerEvidence?.auctionWinner !== undefined + ? { auctionWinner: trustedServerEvidence.auctionWinner } + : {}), + ...(intent?.sources.get('prebid_refresh')?.prebidAuction !== undefined + ? { prebidAuction: intent.sources.get('prebid_refresh')?.prebidAuction } + : {}), + ...(trustedServerEvidence?.serverAuctionTimings !== undefined + ? { serverAuctionTimings: trustedServerEvidence.serverAuctionTimings } + : {}), + ...(serverAuctionTimingOrigin !== undefined ? { serverAuctionTimingOrigin } : {}), ...(trustedServerEvidence?.requestedSlotSizes !== undefined ? { requestedSlotSizes: trustedServerEvidence.requestedSlotSizes } : {}), @@ -987,10 +1139,7 @@ export class GptDiagnosticsStore { private recordRequestIntentSource( slot: object, source: RequestIntentSource, - facts: Pick< - PendingSourceEvidence, - 'trustedServerOpportunity' | 'trustedServerAuctionId' | 'requestedSlotSizes' | 'traceIdentity' - > = {} + facts: Omit = {} ): void { const observedAtMs = this.now(); let intent = this.pendingRequestIntents.get(slot); @@ -1005,6 +1154,8 @@ export class GptDiagnosticsStore { this.nextRequestIntentId += 1; this.pendingRequestIntents.set(slot, intent); } + // Replace rather than merge: a later bare refresh must not carry an + // earlier auction's evidence into the next GPT request. intent.sources.set(source, { observedAtMs, ...facts }); } @@ -1078,6 +1229,17 @@ export class GptDiagnosticsStore { return source ?? 'unattributed'; } + private auctionType( + intent: PendingRequestIntent | undefined, + trustedServerEvidence: PendingSourceEvidence | undefined + ): GptDiagnosticsAuctionType | undefined { + const trustedServerAuction = trustedServerEvidence?.auctionType; + const hasClientSideAuction = intent?.sources.get('prebid_refresh')?.prebidAuction !== undefined; + if (trustedServerAuction && hasClientSideAuction) return 'competing'; + if (hasClientSideAuction) return 'client_side'; + return trustedServerAuction; + } + private recordReplacement(record: MutableSlotRecord, cycle: MutableRequestCycle): void { const currentIndex = record.requests.indexOf(cycle); if (currentIndex <= 0) return; @@ -1146,13 +1308,15 @@ export class GptDiagnosticsStore { } } - const runtimeSlotNumber = this.nextRuntimeSlotNumber; - this.nextRuntimeSlotNumber += 1; + const runtimeSlotNumber = existingNumber ?? this.nextRuntimeSlotNumber; + if (existingNumber === undefined) { + this.nextRuntimeSlotNumber += 1; + this.slotNumbers.set(slot, runtimeSlotNumber); + } const record: MutableSlotRecord = { runtimeSlotNumber, requests: [], }; - this.slotNumbers.set(slot, runtimeSlotNumber); this.refreshSlotMetadata(record, slot); this.slots.set(runtimeSlotNumber, record); this.slotOrder.push(runtimeSlotNumber); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 288653c1bb..3981e48b79 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -2882,11 +2882,10 @@ export function installRefreshHandler(timeoutMs = 1500): void { // slots, and a late callback cannot issue a second GAM request. let completed = false; let fallbackTimer: ReturnType | undefined; - function completeRefresh(applyTargeting: boolean): void { + function completeRefresh(applyTargeting: boolean, completedAuctionId?: string): void { if (completed) return; completed = true; if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); - // The publisher refresh itself started before this asynchronous auction. // Reconcile its per-slot token only when the callback is ready to issue // GPT: TS may have won an already-overlapping first impression while the @@ -2921,15 +2920,32 @@ export function installRefreshHandler(timeoutMs = 1500): void { const completedAdUnitCodes = refreshAdUnitCodes.filter( (_code, index) => !callbackFilteredSlots.has(auctionSlots[index]) ); - if (applyTargeting) { + const completedAuctionSlots = auctionSlots.filter( + (slot) => !callbackFilteredSlots.has(slot) + ); + let targetingApplied = false; + if (applyTargeting && typeof pbjs.setTargetingForGPTAsync === 'function') { try { - pbjs.setTargetingForGPTAsync?.(completedAdUnitCodes); + pbjs.setTargetingForGPTAsync(completedAdUnitCodes); + targetingApplied = true; } catch (error) { log.error('[tsjs-prebid] refresh targeting failed', error); } } completedSlots.forEach(consumeGptPublisherRefreshSuppression); recordPrebidRefreshForDiagnostics(completedSlots); + if (targetingApplied) { + try { + window.tsjs?.gptDiagnosticsRecorder?.recordCompletedPrebidAuction?.( + pbjs, + completedAuctionId, + completedAuctionSlots, + completedAdUnitCodes + ); + } catch { + // Auction diagnostics must not suppress the publisher's GAM refresh. + } + } // Preserve the publisher's original refresh form unless one losing // first-impression slot was filtered. A delayed bare call must also // become explicit so slots added after the auction snapshot cannot join. @@ -2941,7 +2957,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { try { pbjs.requestBids({ adUnits, - bidsBackHandler: () => completeRefresh(true), + bidsBackHandler: (_bids, _timedOut, auctionId) => completeRefresh(true, auctionId), timeout: timeoutMs, }); // A one-shot watchdog completes the GAM request even if Prebid never diff --git a/crates/trusted-server-js/lib/src/trace/projection.ts b/crates/trusted-server-js/lib/src/trace/projection.ts index 862973d359..1c99163e44 100644 --- a/crates/trusted-server-js/lib/src/trace/projection.ts +++ b/crates/trusted-server-js/lib/src/trace/projection.ts @@ -86,7 +86,17 @@ function cycleProjection(value: unknown, omissions: { value: number }): Record = { requestNumber: data(source, 'requestNumber'), diff --git a/crates/trusted-server-js/lib/test/gpt-diagnostics-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/gpt-diagnostics-artifact-integration.test.mjs new file mode 100644 index 0000000000..31e6f183e9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/gpt-diagnostics-artifact-integration.test.mjs @@ -0,0 +1,225 @@ +// @vitest-environment node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { JSDOM } from 'jsdom'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { moduleBuildOptions } from '../build-module-options.mjs'; + +const libDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +let outputDirectory; +const bundles = {}; + +beforeAll(async () => { + outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tsjs-diagnostics-artifacts-')); + const { build } = await import('vite'); + for (const name of ['prebid', 'gpt_diagnostics']) { + await build( + moduleBuildOptions({ + name, + entryPath: path.join(libDir, 'src', 'integrations', name, 'index.ts'), + outDir: outputDirectory, + }) + ); + bundles[name] = fs.readFileSync(path.join(outputDirectory, `tsjs-${name}.js`), 'utf8'); + } +}, 60_000); + +afterAll(() => fs.rmSync(outputDirectory, { recursive: true, force: true })); + +function page() { + const dom = new JSDOM('
', { + url: 'https://publisher.example/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const target = dom.window; + target.TextEncoder = TextEncoder; + target.CSS = { escape: (value) => value }; + target.tsjs = { navGeneration: 1 }; + target.__tsjs_gpt_diagnostics_active = true; + target.__tsjs_prebid = { serverSideBidders: [] }; + target.__tsjs_prebid_bundle = { + schemaVersion: 1, + modules: { bidder: [], userId: [], analytics: [] }, + runtimeCodes: { bidder: [], analytics: [] }, + }; + const events = new Map(); + const targeting = new Map(); + const slot = { + getSlotElementId: () => 'ad-slot', + getAdUnitPath: () => '/example/ad-slot', + getSizes: () => [[300, 250]], + getTargeting: vi.fn((key) => targeting.get(key) ?? []), + clearTargeting: (key) => targeting.delete(key), + }; + const refresh = vi.fn(() => { + for (const callback of events.get('slotRequested') ?? []) callback({ slot }); + }); + const pubads = { + getSlots: () => [slot], + refresh, + addEventListener: (name, callback) => { + events.set(name, [...(events.get(name) ?? []), callback]); + }, + }; + target.googletag = { cmd: { push: (callback) => callback() }, pubads: () => pubads }; + let auctionSequence = 0; + const prebidEvents = new Map(); + const prebid = (target.pbjs = { + que: [], + cmd: [], + adUnits: [], + registerBidAdapter: vi.fn(), + setConfig: vi.fn(), + getConfig: () => ({}), + getBidderCodes: () => ['trustedServer'], + processQueue: vi.fn(), + requestBids: vi.fn((options) => + options.bidsBackHandler({}, false, `auction-${++auctionSequence}`) + ), + setTargetingForGPTAsync: vi.fn(() => { + targeting.set('hb_bidder', ['client']); + targeting.set('hb_pb', ['2.40']); + }), + onEvent: vi.fn((name, callback) => { + const listeners = prebidEvents.get(name) ?? new Set(); + listeners.add(callback); + prebidEvents.set(name, listeners); + }), + offEvent: vi.fn((name, callback) => { + prebidEvents.get(name)?.delete(callback); + }), + }); + return { + dom, + target, + prebid, + prebidEvents, + pubads, + refresh, + slot, + emitPrebid: (name, bid) => { + for (const callback of prebidEvents.get(name) ?? []) callback(bid); + }, + }; +} + +describe('separately built Prebid shim and diagnostics artifacts', () => { + it('does not subscribe or read diagnostic targeting when only the shim is loaded', () => { + const p = page(); + try { + p.target.eval(bundles.prebid); + p.pubads.refresh([p.slot]); + expect(p.refresh).toHaveBeenCalledOnce(); + expect(p.prebid.onEvent).not.toHaveBeenCalled(); + expect(p.slot.getTargeting).not.toHaveBeenCalledWith('hb_bidder'); + expect(p.slot.getTargeting).not.toHaveBeenCalledWith('hb_pb'); + expect(p.target.tsjs.gptDiagnosticsRecorder).toBeUndefined(); + } finally { + p.dom.window.close(); + } + }); + + it.each([ + ['gpt_diagnostics', 'prebid'], + ['prebid', 'gpt_diagnostics'], + ])('records targeting and bidWon with %s loaded before %s', (first, second) => { + const p = page(); + try { + p.target.eval(bundles[first]); + p.target.eval(bundles[second]); + // The shim captured this instance before a publisher replaced its global. + const decoy = { onEvent: vi.fn() }; + p.target.pbjs = decoy; + p.pubads.refresh([p.slot]); + expect(p.refresh).toHaveBeenCalledExactlyOnceWith([p.slot], undefined); + expect(p.prebid.setTargetingForGPTAsync).toHaveBeenCalledWith(['ad-slot']); + expect(decoy.onEvent).not.toHaveBeenCalled(); + expect(p.prebid.onEvent).toHaveBeenCalledExactlyOnceWith('bidWon', expect.any(Function)); + let cycle = p.target.tsjs.gptDiagnostics.snapshot().slots[0].requests[0]; + expect(cycle.prebidAuction).toMatchObject({ + auctionId: 'auction-1', + targetingCandidate: { bidder: 'client', priceBucket: '2.40' }, + }); + p.emitPrebid('bidWon', { + auctionId: 'auction-1', + adUnitCode: 'ad-slot', + latestTargetedAuctionId: 'auction-1', + adserverTargeting: { hb_bidder: 'client', hb_pb: '2.40' }, + }); + cycle = p.target.tsjs.gptDiagnostics.snapshot().slots[0].requests[0]; + expect(cycle.prebidAuction.win).toMatchObject({ bidder: 'client', priceBucket: '2.40' }); + } finally { + p.target.__tsjs_gpt_diagnostics_runtime?.destroy(); + p.dom.window.close(); + } + }); + + it('removes only its own listener across runtime destroy and reinstall', () => { + const p = page(); + try { + const publisher = vi.fn(); + p.prebid.onEvent('bidWon', publisher); + p.target.eval(bundles.gpt_diagnostics); + p.target.eval(bundles.prebid); + const previousRecorder = p.target.tsjs.gptDiagnosticsRecorder; + const previousWin = vi.spyOn(previousRecorder, 'recordPrebidWin'); + p.pubads.refresh([p.slot]); + const previousListener = p.prebid.onEvent.mock.calls[1][1]; + expect(p.prebidEvents.get('bidWon').size).toBe(2); + p.target.__tsjs_gpt_diagnostics_runtime.destroy(); + expect(p.prebid.offEvent).toHaveBeenCalledExactlyOnceWith('bidWon', previousListener); + expect([...p.prebidEvents.get('bidWon')]).toEqual([publisher]); + + p.target.eval(bundles.gpt_diagnostics); + const currentWin = vi.spyOn(p.target.tsjs.gptDiagnosticsRecorder, 'recordPrebidWin'); + p.pubads.refresh([p.slot]); + expect(p.prebidEvents.get('bidWon').size).toBe(2); + const bid = { + auctionId: 'auction-2', + adUnitCode: 'ad-slot', + adserverTargeting: { hb_bidder: 'client', hb_pb: '2.40' }, + }; + previousListener(bid); + p.emitPrebid('bidWon', bid); + expect(previousWin).not.toHaveBeenCalled(); + expect(currentWin).toHaveBeenCalledExactlyOnceWith(p.slot, 'auction-2', { + bidder: 'client', + priceBucket: '2.40', + }); + expect(publisher).toHaveBeenCalledExactlyOnceWith(bid); + } finally { + p.target.__tsjs_gpt_diagnostics_runtime?.destroy(); + p.dom.window.close(); + } + }); + + it('keeps targeting and GAM refresh fail-open when recorder or subscription throws', () => { + const p = page(); + try { + p.target.eval(bundles.gpt_diagnostics); + p.target.eval(bundles.prebid); + p.prebid.onEvent.mockImplementationOnce(() => { + throw new Error('registration failed'); + }); + p.pubads.refresh([p.slot]); + expect(p.refresh).toHaveBeenCalledTimes(1); + p.pubads.refresh([p.slot]); + expect(p.prebid.onEvent).toHaveBeenCalledTimes(2); + p.target.tsjs.gptDiagnosticsRecorder.recordCompletedPrebidAuction = () => { + throw new Error('diagnostics failed'); + }; + p.pubads.refresh([p.slot]); + expect(p.prebid.setTargetingForGPTAsync).toHaveBeenCalledTimes(3); + expect(p.refresh).toHaveBeenCalledTimes(3); + } finally { + p.target.__tsjs_gpt_diagnostics_runtime?.destroy(); + p.dom.window.close(); + } + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 7b818229a2..d3c744b2df 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -11,7 +11,6 @@ import { } from '../../../src/core/first_impression'; import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; import { installTraceRuntime } from '../../../src/trace/runtime'; -import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; import { gptTransport, joinedGptStore } from '../../trace/gpt-fixtures'; import { AUCTION_TOKEN, SLOT_TOKEN } from '../../trace/fixtures'; import { @@ -238,6 +237,7 @@ describe('installTsAdInit', () => { async (implementation) => { const { mockSlot } = configureOpportunityDiagnostics(undefined, vi.fn()); const ts = (window as TestWindow).tsjs as TsjsApi; + ts.auctionDiagnostics = { auctionResolvedMs: 84 }; window.__tsjs_trace_active = true; const collector = installTraceRuntime(ts)!; ts.adSlots![0]!.ext = { trusted_server: { trace_slot_ref: SLOT_TOKEN } }; @@ -263,6 +263,9 @@ describe('installTsAdInit', () => { requestPath: 'trusted_server_direct', trustedServerOpportunity: 'no_candidate', trustedServerAuctionId: AUCTION_TOKEN, + ...(implementation === 'bundle' + ? { auctionType: 'ssat', serverAuctionTimings: { auctionResolvedMs: 84 } } + : {}), }); expect(joinedGptStore(store, collector)?.auctions[0]?.slots[0]?.correlation).toBe('matched'); expect(JSON.stringify(store.snapshot())).not.toContain(SLOT_TOKEN); @@ -410,22 +413,72 @@ describe('installTsAdInit', () => { try { const recordTrustedServerOpportunity = vi.fn(); const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - { hb_pb: '1.10', hb_adid: 'example-fallback-ad', adm: '
Fallback
' }, + { + hb_pb: '1.10', + hb_bidder: 'example-bidder', + hb_adid: 'example-fallback-ad', + hb_auction_id: 'example-fallback-auction', + adm: '
Fallback
', + }, recordTrustedServerOpportunity ); const ts = (window as TestWindow).tsjs as TsjsApi; + ts.navGeneration = 1; + ts.auctionDiagnostics = { auctionDispatchedMs: 17, auctionResolvedMs: 93 }; registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); ts.adInit!(); expect(mockPubads.refresh).not.toHaveBeenCalled(); + ts.auctionDiagnostics = { auctionDispatchedMs: 31, auctionResolvedMs: 144 }; vi.advanceTimersByTime(5001); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); expect(mockPubads.refresh).toHaveBeenCalledOnce(); expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); + expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( + mockSlot, + 'atf_sidebar_ad', + 'renderable_candidate', + 'example-fallback-auction', + [[300, 250]], + { + auctionType: 'trusted_server', + winner: { bidder: 'example-bidder', priceBucket: '1.10' }, + serverTimings: { auctionDispatchedMs: 17, auctionResolvedMs: 93 }, + } + ); + + vi.advanceTimersByTime(10_000); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps fallback delivery when the diagnostics recorder throws', async () => { + vi.useFakeTimers(); + try { + const recordTrustedServerOpportunity = vi.fn(() => { + throw new Error('fallback diagnostics unavailable'); + }); + const { mockPubads, mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '1.10', hb_adid: 'example-fallback-ad', adm: '
Fallback
' }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs as TsjsApi; + registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ts.adInit!(); + + expect(() => vi.advanceTimersByTime(5001)).not.toThrow(); + expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalledOnce(); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); vi.advanceTimersByTime(10_000); expect(mockPubads.refresh).toHaveBeenCalledOnce(); @@ -534,6 +587,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', expectedOpportunity, undefined, + undefined, undefined ); } @@ -560,6 +614,99 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'unrenderable_candidate', 'auction-123', + undefined, + { + auctionType: 'ssat', + winner: { bidder: 'example', priceBucket: '1.00' }, + } + ); + }); + + it('classifies a winning SPA auction and snapshots its server timings', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockSlot } = configureOpportunityDiagnostics( + { + hb_pb: '1.00', + hb_bidder: 'example', + hb_adid: 'creative-1', + hb_auction_id: 'auction-spa-123', + }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs!; + ts.navGeneration = 1; + ts.auctionDiagnostics = { auctionDispatchedMs: 12, auctionResolvedMs: 84 }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + ts.adInit!(); + + expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( + mockSlot, + 'atf_sidebar_ad', + 'unrenderable_candidate', + 'auction-spa-123', + undefined, + { + auctionType: 'trusted_server', + winner: { bidder: 'example', priceBucket: '1.00' }, + serverTimings: { auctionDispatchedMs: 12, auctionResolvedMs: 84 }, + } + ); + }); + + it('snapshots auction timing with the bids before queued GPT work runs', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockSlot } = configureOpportunityDiagnostics( + { hb_pb: '1.00', hb_bidder: 'example', hb_adid: 'creative-1' }, + recordTrustedServerOpportunity + ); + const ts = (window as TestWindow).tsjs!; + ts.auctionDiagnostics = { auctionResolvedMs: 84 }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + let queued: (() => void) | undefined; + const googletag = (window as TestWindow).googletag as { + cmd: { push(callback: () => void): void }; + }; + googletag.cmd.push = (callback) => { + queued = callback; + }; + + ts.adInit!(); + ts.auctionDiagnostics.auctionResolvedMs = 99; + queued?.(); + + expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( + mockSlot, + 'atf_sidebar_ad', + 'unrenderable_candidate', + undefined, + undefined, + { + auctionType: 'ssat', + winner: { bidder: 'example', priceBucket: '1.00' }, + serverTimings: { auctionResolvedMs: 84 }, + } + ); + }); + + it('does not infer an SPA auction from navigation generation alone', async () => { + const recordTrustedServerOpportunity = vi.fn(); + const { mockSlot } = configureOpportunityDiagnostics(undefined, recordTrustedServerOpportunity); + (window as TestWindow).tsjs!.navGeneration = 1; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( + mockSlot, + 'atf_sidebar_ad', + 'no_candidate', + undefined, + undefined, undefined ); }); @@ -597,7 +744,8 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, - formats + formats, + undefined ); }); @@ -623,7 +771,8 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, - formats + formats, + undefined ); }); @@ -663,6 +812,7 @@ describe('installTsAdInit', () => { 'atf_sidebar_ad', 'no_candidate', undefined, + undefined, undefined ); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 46ba778dc9..e82a211c93 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -781,10 +781,14 @@ describe('gpt_bootstrap.js fallback', () => { const adInit = vi.fn(); ts.adInit = adInit; ts.bids = { live_slot: { hb_pb: '2.50' } }; + ts.auctionDiagnostics = { auctionResolvedMs: 10 }; ts.navGeneration = 1; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); + ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, undefined, undefined, { + auctionResolvedMs: 99, + }); expect(ts.bids).toEqual({ live_slot: { hb_pb: '2.50' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 10 }); window.dispatchEvent(new Event('load')); flushFrame(); @@ -813,8 +817,15 @@ describe('gpt_bootstrap.js fallback', () => { formats: [[728, 90]] as Array<[number, number]>, }; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot]); + const auctionDiagnostics = { auctionResolvedMs: 84 }; + ts.scheduleInitialAdInit!( + { ssr_slot: { hb_pb: '1.00' } }, + [ssrSlot], + undefined, + auctionDiagnostics + ); expect(ts.adSlots).toEqual([ssrSlot]); + expect(ts.auctionDiagnostics).toEqual(auctionDiagnostics); ts.adSlots = [liveSlot]; ts.navGeneration = 1; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts index a68b41ffe6..10142bd1ad 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts @@ -139,8 +139,14 @@ describe('scheduleInitialAdInit', () => { expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([evidence]); expect(ts.adSlots).toBe(slots); expect(ts.bids).toBe(bids); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 84 }); }); - ts.scheduleInitialAdInit!(bids, slots, { schema_version: 1, evidence }); + ts.scheduleInitialAdInit!( + bids, + slots, + { schema_version: 1, evidence }, + { auctionResolvedMs: 84 } + ); expect(ts.traceEvidence!.snapshot().value?.serverAuctions).toEqual([]); flushFrame(); flushFrame(); @@ -445,9 +451,13 @@ describe('scheduleInitialAdInit', () => { await flushAsync(); expect(ts.navGeneration).toBe(1); ts.bids = { live_slot: { hb_pb: '2.50' } }; + ts.auctionDiagnostics = { auctionResolvedMs: 10 }; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); + ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, undefined, undefined, { + auctionResolvedMs: 99, + }); expect(ts.bids).toEqual({ live_slot: { hb_pb: '2.50' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 10 }); window.dispatchEvent(new Event('load')); flushFrame(); @@ -466,6 +476,7 @@ describe('scheduleInitialAdInit', () => { json: async () => ({ slots: [{ id: 's1', div_id: 'div-s1' }], bids: { s1: { hb_pb: '3.00' } }, + auctionDiagnostics: { auctionResolvedMs: 84 }, }), }); await importGptModule(); @@ -476,6 +487,7 @@ describe('scheduleInitialAdInit', () => { history.pushState({}, '', '/b'); await flushAsync(); expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 84 }); expect(adInit).toHaveBeenCalledTimes(1); ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); @@ -484,6 +496,7 @@ describe('scheduleInitialAdInit', () => { flushFrame(); expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); + expect(ts.auctionDiagnostics).toEqual({ auctionResolvedMs: 84 }); expect(adInit).toHaveBeenCalledTimes(1); }); @@ -501,10 +514,23 @@ describe('scheduleInitialAdInit', () => { formats: [[728, 90]] as Array<[number, number]>, }; - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot]); + const auctionDiagnostics = { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'pre_header' as const, + }; + ts.scheduleInitialAdInit!( + { ssr_slot: { hb_pb: '1.00' } }, + [ssrSlot], + undefined, + auctionDiagnostics + ); expect(ts.adSlots).toEqual([ssrSlot]); expect(ts.bids).toEqual({ ssr_slot: { hb_pb: '1.00' } }); + expect(ts.auctionDiagnostics).toEqual(auctionDiagnostics); }); it('preserves head-injected slots when initialSlots is omitted', async () => { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 3474be84b8..fba638cb53 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -816,9 +816,13 @@ describe('installSpaAuctionHook', () => { const ts = (window as TestWindow).tsjs!; const adInit = vi.fn(); ts.adInit = adInit; + ts.auctionDiagnostics = { auctionResolvedMs: 84 }; history.pushState({}, '', '/first'); + expect(ts.auctionDiagnostics).toBeUndefined(); + ts.auctionDiagnostics = { auctionResolvedMs: 40 }; history.pushState({}, '', '/second'); + expect(ts.auctionDiagnostics).toBeUndefined(); await flushAsync(); expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); @@ -842,13 +846,16 @@ describe('installSpaAuctionHook', () => { installSpaAuctionHook(); const ts = (window as TestWindow).tsjs!; ts.adSlots = [{ id: 'existing' } as never]; + ts.auctionDiagnostics = { auctionResolvedMs: 84 }; const adInit = vi.fn(); ts.adInit = adInit; history.pushState({}, '', '/error-page'); + expect(ts.auctionDiagnostics).toBeUndefined(); await flushAsync(); expect(ts.adSlots).toEqual([{ id: 'existing' }]); + expect(ts.auctionDiagnostics).toBeUndefined(); expect(adInit).not.toHaveBeenCalled(); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index abbdeca692..f831d6fa3f 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -66,6 +66,8 @@ function fakeApiStore() { subscribe: vi.fn(() => () => undefined), recordTrustedServerOpportunity: vi.fn(), recordPrebidRefresh: vi.fn(), + recordPrebidAuction: vi.fn(), + recordPrebidWin: vi.fn(), recordTrustedServerCreativeRequest: vi.fn((_auctionSlotId: string) => 41), recordTrustedServerCreativeResponse: vi.fn(), recordTrustedServerCreativeFailure: vi.fn(), @@ -92,7 +94,9 @@ describe('GptDiagnosticsApiController', () => { 'subscribe', ]); expect(Object.keys(controller.recorder).sort()).toEqual([ + 'recordPrebidAuction', 'recordPrebidRefresh', + 'recordPrebidWin', 'recordTrustedServerCreativeFailure', 'recordTrustedServerCreativeRequest', 'recordTrustedServerCreativeResponse', @@ -112,7 +116,11 @@ describe('GptDiagnosticsApiController', () => { controller.recorder.recordTrustedServerOpportunity( slot, 'auction-slot-example', - 'renderable_candidate' + 'renderable_candidate', + undefined, + undefined, + { auctionType: 'ssat' }, + { diagnostic_auction_id: 'ts-auc-example', slot_ref: 'ts-slot-example' } ); controller.recorder.recordPrebidRefresh(slots); const attemptId = @@ -126,7 +134,9 @@ describe('GptDiagnosticsApiController', () => { 'auction-slot-example', 'renderable_candidate', undefined, - undefined + undefined, + { auctionType: 'ssat' }, + { diagnostic_auction_id: 'ts-auc-example', slot_ref: 'ts-slot-example' } ); expect(store.recordPrebidRefresh).toHaveBeenCalledTimes(1); expect(store.recordPrebidRefresh).toHaveBeenCalledWith(slots); @@ -159,6 +169,7 @@ describe('GptDiagnosticsApiController', () => { 'auction-slot-example', 'renderable_candidate', 'auction-123', + undefined, undefined ); }); @@ -225,6 +236,9 @@ describe('GptDiagnosticsApiController', () => { yieldGroupIds: [10], companyIds: [20], }, + auctionWinner: { bidder: 'example', priceBucket: '1.20' }, + serverAuctionTimings: { auctionResolvedMs: 84 }, + serverAuctionTimingOrigin: 'spa_auction' as const, trustedServerCreativeFailures: ['cache_fetch_failed' as const], }, ], @@ -280,6 +294,13 @@ describe('GptDiagnosticsApiController', () => { expect(cycle?.adManager?.companyIds).not.toBe( source.slots[0]?.requests[0]?.adManager.companyIds ); + expect(cycle?.auctionWinner).toEqual({ bidder: 'example', priceBucket: '1.20' }); + expect(cycle?.auctionWinner).not.toBe(source.slots[0]?.requests[0]?.auctionWinner); + expect(cycle?.serverAuctionTimings).toEqual({ auctionResolvedMs: 84 }); + expect(cycle?.serverAuctionTimings).not.toBe( + source.slots[0]?.requests[0]?.serverAuctionTimings + ); + expect(cycle?.serverAuctionTimingOrigin).toBe('spa_auction'); expect(snapshot.metadata).not.toBe(source.metadata); expect(snapshot.metadata.droppedAttributionIssues).toBe(2); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts index 329c4d2990..7cdb8a01c2 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts @@ -112,6 +112,138 @@ describe('GptDiagnosticsBadgeManager', () => { expect(layer.querySelectorAll('.tsgd-badge')).toHaveLength(1); expect(layer.querySelector('.tsgd-badge')?.dataset.runtimeSlot).toBe('1'); + expect(layer.querySelector('.tsgd-badge')?.textContent).toContain('Ad #1'); + manager.destroy(); + }); + + it('renders an accessible request-scoped control and activates its exact request', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const element = document.createElement('div'); + document.body.append(element); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rectangle(10, 100, 300, 250)); + const observedSlot = slot('accessible'); + store.recordSlotRequested(observedSlot); + store.recordSlotRequested(observedSlot); + bindings.set(1, { status: 'bound' }, element, true); + const activate = vi.fn(); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + onActivate: activate, + }); + manager.setLayer(layer); + runFrame(frames); + + const badge = layer.querySelector('.tsgd-badge-handle'); + expect(badge).toBeInstanceOf(HTMLButtonElement); + expect(badge?.textContent).toBe('Ad #1'); + expect(layer.querySelector('.tsgd-badge-evidence')?.textContent).toBe('Request #2 · Pending'); + layer.querySelector('.tsgd-badge')?.click(); + expect(activate).not.toHaveBeenCalled(); + expect(badge?.getAttribute('aria-label')).toBe( + 'Open diagnostics for Ad #1, Request #2: Pending' + ); + badge?.click(); + expect(activate).toHaveBeenCalledWith(1, 2); + + badge?.focus(); + store.recordSlotRequested(observedSlot); + runFrame(frames); + const updatedBadge = layer.querySelector('.tsgd-badge-handle'); + expect(updatedBadge).toBe(badge); + expect(document.activeElement).toBe(badge); + expect(updatedBadge?.textContent).toBe('Ad #1'); + expect(layer.querySelector('.tsgd-badge-evidence')?.textContent).toBe('Request #3 · Pending'); + updatedBadge?.click(); + expect(activate).toHaveBeenLastCalledWith(1, 3); + + const highlight = document.createElement('div'); + highlight.className = 'tsgd-highlight'; + layer.append(highlight); + manager.update(); + expect(layer.querySelector('.tsgd-highlight')).toBe(highlight); + manager.destroy(); + }); + + it.each([ + { name: 'top-left clipping', bounds: rectangle(-100, -20, 500, 500), hidden: false }, + { name: 'bottom-right clipping', bounds: rectangle(900, 700, 300, 250), hidden: false }, + { name: 'full viewport creative', bounds: rectangle(0, 0, 1024, 768), hidden: true }, + { name: 'creative larger than viewport', bounds: rectangle(-10, -10, 1044, 788), hidden: true }, + { name: 'viewport narrower than handle', bounds: rectangle(10, 100, 30, 250), hidden: true }, + ])('keeps the handle outside creative bounds with $name', ({ name, bounds, hidden }) => { + if (name === 'viewport narrower than handle') { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 60 }); + } + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const creative = document.createElement('div'); + const originalMarkup = creative.outerHTML; + document.body.append(creative); + vi.spyOn(creative, 'getBoundingClientRect').mockReturnValue(bounds); + store.recordSlotRequested(slot('geometry')); + bindings.set(1, { status: 'bound' }, creative, true); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + }); + manager.setLayer(layer); + runFrame(frames); + const handle = layer.querySelector('.tsgd-badge-handle')!; + expect(handle.hidden).toBe(hidden); + if (!hidden) { + const left = Number.parseFloat(handle.style.left); + const top = Number.parseFloat(handle.style.top); + const right = left + Number.parseFloat(handle.style.width); + const bottom = top + Number.parseFloat(handle.style.height); + expect(left).toBeGreaterThanOrEqual(4); + expect(top).toBeGreaterThanOrEqual(4); + expect(right).toBeLessThanOrEqual(window.innerWidth - 4); + expect(bottom).toBeLessThanOrEqual(window.innerHeight - 4); + expect( + right <= bounds.left || left >= bounds.right || bottom <= bounds.top || top >= bounds.bottom + ).toBe(true); + handle.focus(); + expect(document.activeElement).toBe(handle); + } + expect(layer.querySelector('.tsgd-badge-evidence')).not.toBeNull(); + expect(creative.outerHTML).toBe(originalMarkup); + manager.destroy(); + }); + + it('avoids neighboring creatives and clamps tall evidence to the viewport', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const creative = document.createElement('div'); + const neighbor = document.createElement('div'); + document.body.append(creative, neighbor); + vi.spyOn(creative, 'getBoundingClientRect').mockReturnValue(rectangle(100, 100, 300, 250)); + vi.spyOn(neighbor, 'getBoundingClientRect').mockReturnValue(rectangle(0, 0, 1024, 100)); + store.recordSlotRequested(slot('geometry')); + store.recordSlotRequested(slot('neighbor')); + bindings.set(1, { status: 'bound' }, creative, true); + bindings.set(2, { status: 'bound' }, neighbor, true); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + }); + manager.setLayer(layer); + runFrame(frames); + const badge = layer.querySelector('.tsgd-badge')!; + const evidence = badge.querySelector('.tsgd-badge-evidence')!; + vi.spyOn(evidence, 'getBoundingClientRect').mockReturnValue(rectangle(0, 0, 260, 700)); + manager.update(); + expect(badge.style.top).toBe('4px'); + const handle = badge.querySelector('.tsgd-badge-handle')!; + expect(handle.style.top).toBe('354px'); + expect(handle.hidden).toBe(false); manager.destroy(); }); @@ -155,6 +287,36 @@ describe('GptDiagnosticsBadgeManager', () => { expect(emptyBadge, 'an empty cycle must not claim a Trusted Server response').toBe('Empty'); }); + it('shows Multiple paths for a competing SPA auction without a redundant path badge', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const observedSlot = slot('competing-spa-badge'); + + store.recordTrustedServerOpportunity( + observedSlot, + 'auction-slot', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'trusted_server', + serverTimings: { auctionDispatchedMs: 12, auctionResolvedMs: 84 }, + } + ); + store.recordPrebidRefresh([observedSlot]); + store.recordPrebidAuction(observedSlot, 'client-auction'); + store.recordSlotRequested(observedSlot); + + const cycle = store.snapshot().slots[0].requests[0]; + expect(cycle).toMatchObject({ + auctionType: 'competing', + serverAuctionTimingOrigin: 'spa_auction', + serverAuctionTimings: { auctionDispatchedMs: 12, auctionResolvedMs: 84 }, + }); + const badge = gptDiagnosticsBadgeTextForTest(cycle); + expect(badge).toContain('Multiple paths'); + expect(badge).not.toContain('Competing paths'); + }); + it('renders compact evidence-safe labels for every delivery state', () => { const responseSentBadge = gptDiagnosticsBadgeTextForTest({ requestNumber: 1, @@ -266,7 +428,7 @@ describe('GptDiagnosticsBadgeManager', () => { }, }) ).toBe( - 'Filled · Req 728×90, 970×250 · Fill 728×90 · Box 980×270\nResponse 276 ms · Render 42 ms\nViewable after 1 s' + 'Filled · Req 728×90, 970×250 · Fill 728×90 · Size filled 980×270\nGAM request → response 276 ms · GAM response → render 42 ms\nViewable after 1 s' ); expect( gptDiagnosticsBadgeTextForTest({ @@ -282,6 +444,17 @@ describe('GptDiagnosticsBadgeManager', () => { durations: {}, }) ).toBe('Filled · Req 300×250, 320×50, 728×90 +1'); + expect( + gptDiagnosticsBadgeTextForTest({ + requestNumber: 1, + isEmpty: false, + size: [1, 1], + observedSlotSize: [728, 90], + auctionType: 'ssat', + incompleteSequence: false, + durations: {}, + }) + ).toBe('Filled · SSAT · Size filled 728×90'); expect( gptDiagnosticsBadgeTextForTest({ requestNumber: 1, @@ -364,8 +537,11 @@ describe('GptDiagnosticsBadgeManager', () => { const firstBadge = layer.querySelector('.tsgd-badge')!; expect(firstBadge.style.left).toBe('100px'); - expect(firstBadge.style.top).toBe('112px'); + expect(firstBadge.style.top).toBe('116px'); expect(firstBadge.style.maxWidth).toBe('260px'); + const handle = firstBadge.querySelector('button')!; + handle.focus(); + expect(document.activeElement).toBe(handle); currentRectangle = rectangle(220, 260, 300, 250); window.dispatchEvent(new Event('scroll')); @@ -373,8 +549,10 @@ describe('GptDiagnosticsBadgeManager', () => { expect(frames).toHaveLength(1); runFrame(frames); const movedBadge = layer.querySelector('.tsgd-badge')!; + expect(movedBadge).toBe(firstBadge); + expect(document.activeElement).toBe(handle); expect(movedBadge.style.left).toBe('220px'); - expect(movedBadge.style.top).toBe('252px'); + expect(movedBadge.style.top).toBe('256px'); expect(element.getAttributeNames().map((name) => [name, element.getAttribute(name)])).toEqual( originalAttributes ); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index 7775f306bc..f97b1ac2eb 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -177,7 +177,10 @@ describe('GPT diagnostics integration composition', () => { // Evidence writers live on their own channel; the operator API stays read-only. expect(Object.keys(first!).sort()).toEqual(['export', 'hide', 'show', 'snapshot', 'subscribe']); expect(Object.keys(target.tsjs!.gptDiagnosticsRecorder!).sort()).toEqual([ + 'recordCompletedPrebidAuction', + 'recordPrebidAuction', 'recordPrebidRefresh', + 'recordPrebidWin', 'recordTrustedServerCreativeFailure', 'recordTrustedServerCreativeRequest', 'recordTrustedServerCreativeResponse', diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts index 9f24c7dfad..1f89a3de3b 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts @@ -207,7 +207,7 @@ describe('GptDiagnosticsOverlay', () => { overlay.destroy(); }); - it('presents the request path and Trusted Server delivery evidence without winner claims', () => { + it('presents request-path, auction, winner, timing, and delivery evidence', () => { const frames: Array<() => void> = []; let now = 10; const store = new GptDiagnosticsStore({ @@ -222,7 +222,19 @@ describe('GptDiagnosticsOverlay', () => { responseSentSlot, 'auction-response-sent', 'renderable_candidate', - 'auction-123' + 'auction-123', + undefined, + { + auctionType: 'ssat', + winner: { bidder: 'example-bidder', priceBucket: '1.20', currency: 'usd' }, + serverTimings: { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'in_stream', + }, + } ); store.recordSlotRequested(responseSentSlot); now = 11; @@ -259,7 +271,19 @@ describe('GptDiagnosticsOverlay', () => { store.recordTrustedServerOpportunity( selectedSlot, 'auction-selected', - 'unrenderable_candidate' + 'unrenderable_candidate', + undefined, + undefined, + { + auctionType: 'trusted_server', + serverTimings: { + auctionDispatchedMs: 0, + auctionResolvedMs: 40, + auctionCommittedMs: 41, + auctionWaitMs: 40, + auctionWaitPlacement: 'pre_header', + }, + } ); store.recordPrebidRefresh([selectedSlot]); store.recordSlotRequested(selectedSlot); @@ -290,6 +314,10 @@ describe('GptDiagnosticsOverlay', () => { const prebidSlot = slot('prebid-slot'); now = 50; store.recordPrebidRefresh([prebidSlot]); + store.recordPrebidAuction(prebidSlot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); store.recordSlotRequested(prebidSlot); now = 51; store.recordSlotResponseReceived(prebidSlot); @@ -345,8 +373,16 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).toContain('Request path: Trusted Server direct'); expect(responseSentArticle).toContain('Request intent: 1'); expect(responseSentArticle).toContain('Trusted Server auction: auction-123'); + expect(responseSentArticle).toContain('Auction evidence: SSAT: initial-page server auction'); + expect(responseSentArticle).toContain('Server auction winner: example-bidder'); + expect(responseSentArticle).toContain('Server bid price bucket: 1.20 USD'); + expect(responseSentArticle).not.toContain('(currency not supplied)'); + expect(responseSentArticle).toContain('Initial document request T0 → auction dispatched 4 ms'); + expect(responseSentArticle).toContain('Initial document request T0 → auction collected 84 ms'); + expect(responseSentArticle).toContain('Initial document request T0 → bids ready 85 ms'); + expect(responseSentArticle).toContain('Auction collection wait (in stream) 80 ms'); expect(responseSentArticle).toContain('Opportunity → request 0 ms'); - expect(responseSentArticle).toContain('Direct opportunity: Renderable candidate'); + expect(responseSentArticle).toContain('Server bid available; creative source present'); expect(responseSentArticle).toContain('Trusted Server creative request observed at 13 ms'); expect(responseSentArticle).toContain('Trusted Server markup response sent at 14 ms'); expect( @@ -355,7 +391,7 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).toContain('Creative bridge failure: cache fetch failed'); expect(responseSentArticle).toContain('Creative bridge failure: invalid cache payload'); expect(responseSentArticle).toContain('Creative bridge failure: response post failed'); - expect(responseSentArticle).toContain('Trusted Server selected; markup response sent to PUC'); + expect(responseSentArticle).toContain('Creative markup sent; execution not confirmed'); expect(responseSentArticle).toContain( 'Ad Manager reported line item 6543210987 · order 2345678901' ); @@ -367,48 +403,49 @@ describe('GptDiagnosticsOverlay', () => { expect(responseSentArticle).not.toMatch(/creative rendered|ad visible|pixels confirmed/i); const selectedArticle = slotArticle(root!, 'selected-slot').textContent ?? ''; - expect(selectedArticle).toContain('Request path: Competing paths'); - expect(selectedArticle).toContain('Direct opportunity: Unrenderable candidate'); + expect(selectedArticle).toContain('Request path: Multiple paths observed'); + expect(selectedArticle).toContain('Auction evidence: TS auction: SPA server auction'); + expect(selectedArticle).toContain('SPA page-bids request T0 → auction dispatched 0 ms'); + expect(selectedArticle).toContain('SPA page-bids request T0 → auction collected 40 ms'); + expect(selectedArticle).toContain('Server bid available; creative source incomplete'); expect(selectedArticle).toContain('Trusted Server creative request observed at 23 ms'); expect(selectedArticle).not.toContain('Trusted Server markup response sent'); - expect(selectedArticle).toContain('Trusted Server selected; no markup response confirmed'); + expect(selectedArticle).toContain( + 'Server bid selected by the creative bridge; response not confirmed' + ); const noCandidateArticle = slotArticle(root!, 'no-candidate-slot').textContent ?? ''; expect(noCandidateArticle).toContain('Request path: Trusted Server direct'); expect(noCandidateArticle).toContain('Direct opportunity: No candidate'); - expect(noCandidateArticle).toContain( - 'adInit observed no direct Trusted Server candidate for this request' - ); + expect(noCandidateArticle).toContain('No direct Trusted Server candidate'); const unattributedArticle = slotArticle(root!, 'unattributed-slot').textContent ?? ''; - expect(unattributedArticle).toContain('Request path: Unattributed'); - expect(unattributedArticle).toContain('Direct opportunity: Unknown (not observed)'); + expect(unattributedArticle).toContain('Request path: Not observed'); + expect(unattributedArticle).toContain('Direct opportunity: Not observed'); expect(unattributedArticle).toContain( - 'Delivery status unknown — required GPT or direct-candidate evidence was not observed' + 'Delivery status unknown — required evidence was not observed' ); const prebidArticle = slotArticle(root!, 'prebid-slot').textContent ?? ''; expect(prebidArticle).toContain('Request path: Prebid refresh'); - expect(prebidArticle).toContain('Direct opportunity: Unknown (not observed)'); - expect(prebidArticle).toContain( - 'Delivery status unknown — required GPT or direct-candidate evidence was not observed' - ); + expect(prebidArticle).toContain('Prebid candidate price bucket: 2.40'); + expect(prebidArticle).not.toContain('(currency not supplied)'); + expect(prebidArticle).toContain('Direct opportunity: Not observed'); + expect(prebidArticle).toContain('Delivery status unknown — required evidence was not observed'); const unconfirmedArticle = slotArticle(root!, 'unconfirmed-slot').textContent ?? ''; expect(unconfirmedArticle).toContain('Request path: Trusted Server direct'); - expect(unconfirmedArticle).toContain('Direct opportunity: Renderable candidate'); - expect(unconfirmedArticle).toContain( - 'Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible' - ); + expect(unconfirmedArticle).toContain('Server bid available; creative source present'); + expect(unconfirmedArticle).toContain('Server bid available; selection not confirmed'); const pendingArticle = slotArticle(root!, 'candidate-pending-slot').textContent ?? ''; expect(pendingArticle).toContain('Request path: Trusted Server direct'); - expect(pendingArticle).toContain('Direct opportunity: Renderable candidate'); + expect(pendingArticle).toContain('Server bid available; creative source present'); expect(pendingArticle).toContain('Waiting for Trusted Server creative evidence'); const notApplicableArticle = slotArticle(root!, 'not-applicable-slot').textContent ?? ''; expect(notApplicableArticle).toContain('Request path: Trusted Server direct'); - expect(notApplicableArticle).toContain('Direct opportunity: Renderable candidate'); + expect(notApplicableArticle).toContain('Server bid available; creative source present'); expect(notApplicableArticle).not.toMatch( /Trusted Server selected|candidate unconfirmed|no direct Trusted Server candidate|Delivery status unknown|Waiting for Trusted Server creative evidence/ ); @@ -563,22 +600,41 @@ describe('GptDiagnosticsOverlay', () => { expect(root!.textContent).toContain('GPT observed'); expect(root!.textContent).toContain('callback issues'); expect(root!.textContent).toContain('attribution issues'); - expect(root!.textContent).toContain('filled-slot'); + expect(root!.textContent).toContain('Ad #1 · Request #2 · filled-slot'); expect(root!.textContent).toContain('/example/site/filled-slot'); expect(root!.textContent).toContain('Empty'); - expect(root!.textContent).toContain('Previous requests (1)'); - expect(root!.textContent).toContain('Requested slot sizes 300×250, 728×90, 320×50, 970×250'); - expect(root!.textContent).toContain('GPT-reported fill size 300×250'); - expect(root!.textContent).toContain('Observed outer slot box 320×270'); + expect(root!.textContent).toContain('Request history (1 previous)'); + expect(root!.textContent).toContain('Requested sizes 300×250, 728×90, 320×50, 970×250'); + expect(root!.textContent).toContain('GPT-reported size 300×250'); + expect(root!.textContent).toContain('Size filled 320×270'); expect(root!.textContent).toContain('Backfill yes'); expect(root!.textContent).toContain('GPT slot onload observed'); expect(root!.textContent).toContain('GPT impressionViewable observed'); - expect(root!.textContent).toContain('Request → response 10 ms'); + expect(root!.textContent).toContain('GAM request → response 10 ms'); expect(root!.textContent).toContain('GPT visibility 60%'); expect(root!.textContent).toContain('Requesting'); - expect(root!.textContent).toContain('Ambiguous binding'); + expect(root!.textContent).toContain('Ambiguous binding · duplicate_dom_id'); + expect(slotArticle(root!, 'filled-slot').textContent?.match(/Bound · Visible/g)).toHaveLength( + 1 + ); expect(root!.textContent).toContain('Incomplete sequence'); - + expect(slotArticle(root!, 'pending-slot').textContent).toContain( + 'Delivery evidence: Not applicable' + ); + expect(slotArticle(root!, 'pending-slot').textContent).not.toContain( + 'Served bidder not confirmed' + ); + const emptySummary = slotArticle(root!, 'filled-slot').querySelector('.tsgd-group'); + expect(emptySummary?.textContent).toContain('Delivery evidence: Not applicable'); + expect(emptySummary?.textContent).not.toContain('Served bidder not confirmed'); + + expect(root!.textContent).toContain('How to read this evidence'); + const dictionaryLink = root!.querySelector('a'); + expect(dictionaryLink?.href).toBe( + 'https://iabtechlab.github.io/trusted-server/guide/integrations/gpt-diagnostics-dictionary' + ); + expect(dictionaryLink?.relList.contains('noopener')).toBe(true); + expect(dictionaryLink?.relList.contains('noreferrer')).toBe(true); button(root!, 'Export JSON').click(); expect(exportSnapshot).toHaveBeenCalledTimes(1); @@ -619,6 +675,177 @@ describe('GptDiagnosticsOverlay', () => { overlay.destroy(); }); + it.each(['prebid', 'unattributed'])( + 'uses neutral server timing labels for a %s request without a server origin', + (requestKind) => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const diagnosticSlot = slot(`${requestKind}-without-server-origin`); + if (requestKind === 'prebid') { + store.recordPrebidRefresh([diagnosticSlot]); + store.recordPrebidAuction(diagnosticSlot, 'example-prebid-auction'); + } + store.recordSlotRequested(diagnosticSlot); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + const text = slotArticle(root!, `${requestKind}-without-server-origin`).textContent ?? ''; + expect(text).toContain( + `Auction evidence: ${requestKind === 'prebid' ? 'Client-side Prebid auction' : 'Auction not observed'}` + ); + for (const boundary of ['auction dispatched', 'auction collected', 'bids ready']) { + expect(text).toContain(`Server request T0 → ${boundary} Not applicable`); + } + expect(text).not.toContain('Initial document request T0'); + expect(text).not.toContain('SPA page-bids request T0'); + overlay.destroy(); + } + ); + + it('labels unavailable SPA auction timing with the SPA request clock', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const diagnosticSlot = slot('spa-without-timings'); + store.recordTrustedServerOpportunity( + diagnosticSlot, + 'auction-spa-without-timings', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'trusted_server', + winner: { bidder: 'example-bidder', priceBucket: '1.20' }, + } + ); + store.recordSlotRequested(diagnosticSlot); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + expect(root?.textContent).toContain( + 'SPA page-bids request T0 → auction dispatched Unavailable' + ); + expect(root?.textContent).not.toContain( + 'Initial document request T0 → auction dispatched Unavailable' + ); + overlay.destroy(); + }); + + it('announces an evicted request selection once and then clears it', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const diagnosticSlot = slot('evicted-selection'); + store.recordSlotRequested(diagnosticSlot); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + overlay.selectRequest(1, 1); + runNextFrame(frames); + let requestsToRecord = 10; + while (requestsToRecord > 0) { + store.recordSlotRequested(diagnosticSlot); + requestsToRecord -= 1; + } + runNextFrame(frames); + + expect(root?.textContent).toContain('Ad #1, Request #1 is no longer retained.'); + store.recordSlotResponseReceived(diagnosticSlot); + runNextFrame(frames); + expect(root?.textContent).not.toContain('Ad #1, Request #1 is no longer retained.'); + overlay.destroy(); + }); + + it('reveals an exact request and locates it without mutating publisher markup', () => { + const frames: Array<() => void> = []; + const store = new GptDiagnosticsStore({ schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const publisherSlot = document.createElement('div'); + publisherSlot.id = 'locatable-slot'; + publisherSlot.className = 'publisher-class'; + publisherSlot.style.minHeight = '250px'; + document.body.append(publisherSlot); + const originalMarkup = publisherSlot.outerHTML; + const scrollIntoView = vi.fn(); + publisherSlot.scrollIntoView = scrollIntoView; + vi.spyOn(publisherSlot, 'getBoundingClientRect').mockReturnValue({ + left: 10, + top: 20, + width: 300, + height: 250, + } as DOMRect); + const observedSlot = slot('locatable-slot'); + store.recordSlotRequested(observedSlot); + store.recordSlotRequested(observedSlot); + bindings.set(1, { status: 'bound' }, publisherSlot, true); + let root: ShadowRoot | undefined; + const overlay = new GptDiagnosticsOverlay(store, bindings, { + scheduleFrame: (callback) => frames.push(callback), + onShadowRoot: (createdRoot) => { + root = createdRoot; + }, + }); + runNextFrame(frames); + runNextFrame(frames); + + overlay.selectRequest(1, 2); + runNextFrame(frames); + const current = root?.querySelector('.tsgd-slot'); + expect(current?.getAttribute('aria-current')).toBe('true'); + expect(root?.activeElement).toBe(current); + expect(root?.querySelector('.tsgd-history')?.open).toBe(false); + + overlay.selectRequest(1, 1); + runNextFrame(frames); + const selected = root?.querySelector( + '[data-runtime-slot="1"][data-request-number="1"]' + ); + expect(selected?.getAttribute('aria-current')).toBe('true'); + expect(root?.activeElement).toBe(selected); + expect(root?.textContent).toContain('Request history (1 previous)'); + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + selected?.focus(); + const focusCallsBeforeUpdate = focus.mock.calls.length; + const history = root?.querySelector('.tsgd-history'); + expect(history?.open).toBe(true); + if (history) history.open = false; + + store.recordSlotResponseReceived(observedSlot); + runNextFrame(frames); + expect(focus.mock.calls.length).toBeGreaterThan(focusCallsBeforeUpdate); + expect(root?.querySelector('.tsgd-history')?.open).toBe(false); + + button(root!, 'Locate on page').click(); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'center', + inline: 'nearest', + }); + runNextFrame(frames); + expect(root?.querySelector('.tsgd-highlight')).not.toBeNull(); + expect(publisherSlot.outerHTML).toBe(originalMarkup); + overlay.destroy(); + }); + it('does not remove a publisher element that collides with the host ID', async () => { const frames: Array<() => void> = []; const publisherElement = document.createElement('div'); @@ -665,14 +892,20 @@ describe('GptDiagnosticsOverlay', () => { runNextFrame(frames); const content = root!.querySelector('.tsgd-content')!; - const history = root!.querySelector('details')!; + const history = root!.querySelector('.tsgd-history')!; + const help = root!.querySelector('.tsgd-help')!; + const technical = root!.querySelector('.tsgd-technical')!; history.open = true; + help.open = true; + technical.open = true; content.scrollTop = 42; store.recordSlotResponseReceived(diagnosticSlot); runNextFrame(frames); expect(root!.textContent).toContain('Rendered (fill unknown)'); - expect(root!.querySelector('details')?.open).toBe(true); + expect(root!.querySelector('.tsgd-history')?.open).toBe(true); + expect(root!.querySelector('.tsgd-help')?.open).toBe(true); + expect(root!.querySelector('.tsgd-technical')?.open).toBe(true); expect(root!.querySelector('.tsgd-content')?.scrollTop).toBe(42); overlay.destroy(); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/prebid.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/prebid.test.ts new file mode 100644 index 0000000000..0e4d6d25f6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/prebid.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GptDiagnosticsPrebidRecorder } from '../../../src/integrations/gpt_diagnostics/prebid'; + +function fixture() { + let now = 0; + const target = { performance: { now: () => now }, tsjs: { navGeneration: 1 } }; + const recorder = { recordPrebidAuction: vi.fn(), recordPrebidWin: vi.fn() }; + const diagnostics = new GptDiagnosticsPrebidRecorder(target, recorder); + let listener: ((bid: unknown) => void) | undefined; + const prebid = { + onEvent: vi.fn((_event: 'bidWon', callback: (bid: unknown) => void) => { + listener = callback; + }), + }; + const slot = { + getTargeting: (key: string) => (key === 'hb_bidder' ? ['client'] : ['1.20']), + }; + return { + diagnostics, + recorder, + prebid, + slot, + target, + setNow: (value: number) => { + now = value; + }, + win: (auctionId: string, adUnitCode = 'slot') => + listener?.({ + auctionId, + adUnitCode, + adserverTargeting: { hb_bidder: 'client', hb_pb: '1.20' }, + }), + }; +} + +describe('optional Prebid diagnostics recorder', () => { + it('evicts the oldest attempt after 128 entries and consumes wins once', () => { + const f = fixture(); + for (let index = 0; index < 129; index += 1) { + f.diagnostics.recordCompletedPrebidAuction(f.prebid, `auction-${index}`, [f.slot], ['slot']); + } + expect(f.prebid.onEvent).toHaveBeenCalledOnce(); + f.win('auction-0'); + expect(f.recorder.recordPrebidWin).not.toHaveBeenCalled(); + f.win('auction-1'); + f.win('auction-1'); + expect(f.recorder.recordPrebidWin).toHaveBeenCalledExactlyOnceWith(f.slot, 'auction-1', { + bidder: 'client', + priceBucket: '1.20', + }); + }); + + it('accepts the 30-second boundary, rejects expired and destroyed attempts', () => { + const f = fixture(); + f.diagnostics.recordCompletedPrebidAuction(f.prebid, 'boundary', [f.slot], ['slot']); + f.diagnostics.recordCompletedPrebidAuction(f.prebid, 'expired', [f.slot], ['slot']); + f.setNow(30_000); + f.win('boundary'); + expect(f.recorder.recordPrebidWin).toHaveBeenCalledOnce(); + f.setNow(30_001); + f.win('expired'); + expect(f.recorder.recordPrebidWin).toHaveBeenCalledOnce(); + f.diagnostics.recordCompletedPrebidAuction(f.prebid, 'destroyed', [f.slot], ['slot']); + f.diagnostics.destroy(); + f.win('destroyed'); + f.diagnostics.recordCompletedPrebidAuction(f.prebid, 'later', [f.slot], ['slot']); + expect(f.recorder.recordPrebidWin).toHaveBeenCalledOnce(); + expect(f.recorder.recordPrebidAuction).toHaveBeenCalledTimes(3); + }); + + it.each(['available', 'absent', 'throws'] as const)( + 'releases its recorder on destroy and reinstalls with offEvent %s', + (unsubscribe) => { + const f = fixture(); + const publisher = vi.fn(); + const listeners = new Set<(bid: unknown) => void>([publisher]); + const offEvent = vi.fn((_event: 'bidWon', listener: (bid: unknown) => void) => { + if (unsubscribe === 'throws') throw new Error('unsubscribe failed'); + listeners.delete(listener); + }); + const prebid = { + onEvent: (_event: 'bidWon', listener: (bid: unknown) => void) => listeners.add(listener), + offEvent: unsubscribe === 'absent' ? undefined : offEvent, + }; + f.diagnostics.recordCompletedPrebidAuction(prebid, 'old-auction', [f.slot], ['slot']); + const oldListener = [...listeners].find((listener) => listener !== publisher)!; + f.diagnostics.destroy(); + f.diagnostics.destroy(); + expect(Reflect.get(f.diagnostics, 'recorder')).toBeUndefined(); + expect(listeners.has(publisher)).toBe(true); + expect(listeners.has(oldListener)).toBe(unsubscribe !== 'available'); + if (unsubscribe !== 'absent') { + expect(offEvent).toHaveBeenCalledExactlyOnceWith('bidWon', oldListener); + } + oldListener({ + auctionId: 'old-auction', + adUnitCode: 'slot', + adserverTargeting: { hb_bidder: 'client', hb_pb: '1.20' }, + }); + expect(f.recorder.recordPrebidWin).not.toHaveBeenCalled(); + + const currentRecorder = { recordPrebidAuction: vi.fn(), recordPrebidWin: vi.fn() }; + const current = new GptDiagnosticsPrebidRecorder(f.target, currentRecorder); + current.recordCompletedPrebidAuction(prebid, 'new-auction', [f.slot], ['slot']); + for (const listener of listeners) { + listener({ + auctionId: 'new-auction', + adUnitCode: 'slot', + adserverTargeting: { hb_bidder: 'client', hb_pb: '1.20' }, + }); + } + expect(publisher).toHaveBeenCalledOnce(); + expect(f.recorder.recordPrebidWin).not.toHaveBeenCalled(); + expect(currentRecorder.recordPrebidWin).toHaveBeenCalledExactlyOnceWith( + f.slot, + 'new-auction', + { + bidder: 'client', + priceBucket: '1.20', + } + ); + expect(listeners.size).toBe(unsubscribe === 'available' ? 2 : 3); + current.destroy(); + } + ); + + it('continues cleanup on other exact instances after one offEvent throws', () => { + const f = fixture(); + const failed = { + onEvent: vi.fn(), + offEvent: vi.fn(() => { + throw new Error('failed'); + }), + }; + const working = { onEvent: vi.fn(), offEvent: vi.fn() }; + f.diagnostics.recordCompletedPrebidAuction(failed, 'failed', [f.slot], ['slot']); + f.diagnostics.recordCompletedPrebidAuction(working, 'working', [f.slot], ['slot']); + expect(() => f.diagnostics.destroy()).not.toThrow(); + expect(failed.offEvent).toHaveBeenCalledExactlyOnceWith( + 'bidWon', + failed.onEvent.mock.calls[0][1] + ); + expect(working.offEvent).toHaveBeenCalledExactlyOnceWith( + 'bidWon', + working.onEvent.mock.calls[0][1] + ); + }); + + it('retries when the API arrives later and subscribes to the passed instance only', () => { + const f = fixture(); + const delayed: { onEvent?: typeof f.prebid.onEvent } = {}; + f.diagnostics.recordCompletedPrebidAuction(delayed, 'stub', [f.slot], ['slot']); + delayed.onEvent = f.prebid.onEvent; + f.diagnostics.recordCompletedPrebidAuction(delayed, 'ready', [f.slot], ['slot']); + f.diagnostics.recordCompletedPrebidAuction(delayed, 'ready-again', [f.slot], ['slot']); + expect(delayed.onEvent).toHaveBeenCalledOnce(); + f.win('ready'); + expect(f.recorder.recordPrebidWin).toHaveBeenCalledOnce(); + }); + + it("does not let a different subscribed instance consume another instance's attempt", () => { + const f = fixture(); + let otherWin: ((bid: unknown) => void) | undefined; + const other = { + onEvent: (_event: 'bidWon', listener: (bid: unknown) => void) => { + otherWin = listener; + }, + }; + f.diagnostics.recordCompletedPrebidAuction(f.prebid, 'shared-id', [f.slot], ['slot']); + f.diagnostics.recordCompletedPrebidAuction(other, 'shared-id', [f.slot], ['slot']); + f.win('shared-id'); + expect(f.recorder.recordPrebidWin).not.toHaveBeenCalled(); + otherWin?.({ + auctionId: 'shared-id', + adUnitCode: 'slot', + adserverTargeting: { hb_bidder: 'client', hb_pb: '1.20' }, + }); + expect(f.recorder.recordPrebidWin).toHaveBeenCalledOnce(); + }); + + it.each([undefined, '', ' padded ', 'é'.repeat(129)])( + 'rejects invalid auction ID %s', + (auctionId) => { + const f = fixture(); + f.diagnostics.recordCompletedPrebidAuction(f.prebid, auctionId, [f.slot], ['slot']); + expect(f.recorder.recordPrebidAuction).not.toHaveBeenCalled(); + expect(f.prebid.onEvent).not.toHaveBeenCalled(); + } + ); + + it.each([ + { bidder: ['é'.repeat(65)], bucket: ['1.20'] }, + { bidder: ['one', 'two'], bucket: ['1.20'] }, + { bidder: ['client'], bucket: ['1'.repeat(65)] }, + { bidder: ['client'], bucket: ['NaN'] }, + ])('omits invalid targeting candidates without dropping the attempt', ({ bidder, bucket }) => { + const f = fixture(); + const slot = { getTargeting: (key: string) => (key === 'hb_bidder' ? bidder : bucket) }; + f.diagnostics.recordCompletedPrebidAuction(f.prebid, 'valid-id', [slot], ['slot']); + expect(f.recorder.recordPrebidAuction).toHaveBeenCalledWith(slot, 'valid-id', undefined); + f.win('valid-id'); + expect(f.recorder.recordPrebidWin).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index a357ecfc48..eb0c2c9aeb 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -81,6 +81,7 @@ describe('GptDiagnosticsStore', () => { 'no_candidate', marker, undefined, + undefined, { diagnostic_auction_id: AUCTION_TOKEN, slot_ref: SLOT_TOKEN, @@ -105,6 +106,7 @@ describe('GptDiagnosticsStore', () => { 'no_candidate', ` ${AUCTION_TOKEN} `, undefined, + undefined, { diagnostic_auction_id: AUCTION_TOKEN, slot_ref: SLOT_TOKEN, @@ -126,6 +128,11 @@ describe('GptDiagnosticsStore', () => { opportunity, AUCTION_TOKEN, [[300, 250]], + { + auctionType: 'ssat', + serverTimings: { auctionResolvedMs: 84 }, + winner: { bidder: 'example', priceBucket: '1.00' }, + }, { diagnostic_auction_id: AUCTION_TOKEN, slot_ref: SLOT_TOKEN } ); expect(onTraceCorrelation).not.toHaveBeenCalled(); @@ -144,6 +151,9 @@ describe('GptDiagnosticsStore', () => { requestPath: 'trusted_server_direct', trustedServerOpportunity: opportunity, trustedServerAuctionId: AUCTION_TOKEN, + auctionType: 'ssat', + serverAuctionTimings: { auctionResolvedMs: 84 }, + auctionWinner: { bidder: 'example', priceBucket: '1.00' }, }); expect(cycles[1]).toMatchObject({ requestPath: 'unattributed' }); expect(JSON.stringify(store.snapshot())).not.toContain(SLOT_TOKEN); @@ -168,6 +178,7 @@ describe('GptDiagnosticsStore', () => { 'no_candidate', AUCTION_TOKEN, undefined, + undefined, identity ); store.recordPrebidRefresh([slot]); @@ -181,6 +192,7 @@ describe('GptDiagnosticsStore', () => { 'no_candidate', AUCTION_TOKEN, undefined, + undefined, identity ); now += REQUEST_PATH_ATTRIBUTION_WINDOW_MS; @@ -202,6 +214,7 @@ describe('GptDiagnosticsStore', () => { 'no_candidate', AUCTION_TOKEN, undefined, + undefined, identity ); identity.slot_ref = 'private-mutated'; @@ -232,6 +245,7 @@ describe('GptDiagnosticsStore', () => { 'no_candidate', undefined, undefined, + undefined, identity ); store.recordSlotRequested(slot); @@ -587,7 +601,7 @@ describe('GptDiagnosticsStore', () => { assertCoverageEquation(store); }); - it('evicts least-recently-active slots and re-enters only on a new request', () => { + it('evicts least-recently-active slots and preserves their number on re-entry', () => { let now = 0; const store = new GptDiagnosticsStore({ now: () => ++now }); const slots = Array.from({ length: MAX_DIAGNOSTIC_SLOTS + 1 }, (_, index) => @@ -611,7 +625,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slots[1]); store.recordSlotResponseReceived(slots[1]); const reentered = store.snapshot().slots.find((slot) => slot.slotElementId === 'lru-1'); - expect(reentered).toMatchObject({ runtimeSlotNumber: 66 }); + expect(reentered).toMatchObject({ runtimeSlotNumber: 2 }); expect(reentered?.requests[0]).toMatchObject({ requestNumber: 2 }); expect(reentered?.requests[0].responseAtMs).toBeDefined(); expect(store.snapshot().slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); @@ -737,8 +751,10 @@ describe('GptDiagnosticsStore', () => { direct: 'renderable_candidate', prebid: false, publisher: false, + auctionFacts: { auctionType: 'trusted_server' as const }, expectedPath: 'trusted_server_direct', expectedOpportunity: 'renderable_candidate', + expectedAuctionType: 'trusted_server' as const, }, { name: 'a direct unrenderable candidate', @@ -747,6 +763,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'unrenderable_candidate', + expectedAuctionType: undefined, }, { name: 'a direct request without a candidate', @@ -755,22 +772,28 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'trusted_server_direct', expectedOpportunity: 'no_candidate', + expectedAuctionType: undefined, }, { - name: 'a Prebid refresh', + name: 'a completed Prebid auction', direct: undefined, prebid: true, + completedPrebid: true, publisher: false, expectedPath: 'prebid_refresh', expectedOpportunity: undefined, + expectedAuctionType: 'client_side' as const, }, { - name: 'competing direct and Prebid evidence', + name: 'competing Trusted Server and Prebid auctions', direct: 'renderable_candidate', prebid: true, + completedPrebid: true, publisher: false, + auctionFacts: { auctionType: 'trusted_server' as const }, expectedPath: 'competing', expectedOpportunity: 'renderable_candidate', + expectedAuctionType: 'competing' as const, }, { name: 'an unattributed request', @@ -779,6 +802,7 @@ describe('GptDiagnosticsStore', () => { publisher: false, expectedPath: 'unattributed', expectedOpportunity: undefined, + expectedAuctionType: undefined, }, { name: 'a publisher refresh', @@ -787,14 +811,25 @@ describe('GptDiagnosticsStore', () => { publisher: true, expectedPath: 'publisher_refresh', expectedOpportunity: undefined, + expectedAuctionType: undefined, + }, + { + name: 'direct auction with publisher refresh evidence', + direct: 'renderable_candidate', + prebid: false, + publisher: true, + expectedPath: 'competing', + expectedOpportunity: 'renderable_candidate', + expectedAuctionType: undefined, }, { - name: 'competing Prebid and publisher evidence', + name: 'client-side auction with publisher refresh evidence', direct: undefined, prebid: true, publisher: true, expectedPath: 'competing', expectedOpportunity: undefined, + expectedAuctionType: undefined, }, { name: 'competing all source evidence', @@ -803,26 +838,222 @@ describe('GptDiagnosticsStore', () => { publisher: true, expectedPath: 'competing', expectedOpportunity: 'renderable_candidate', + expectedAuctionType: undefined, }, ] as const)( 'attributes $name without inferring demand ownership', - ({ direct, prebid, publisher, expectedPath, expectedOpportunity }) => { + ({ + direct, + prebid, + completedPrebid, + publisher, + auctionFacts, + expectedPath, + expectedOpportunity, + expectedAuctionType, + }) => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); const slot = fakeSlot('path-slot'); if (direct !== undefined) { - store.recordTrustedServerOpportunity(slot, 'auction-slot', direct); + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + direct, + undefined, + undefined, + auctionFacts + ); } if (prebid) store.recordPrebidRefresh([slot]); + if (completedPrebid) store.recordPrebidAuction(slot, 'path-auction'); if (publisher) store.recordPublisherRefresh([slot]); store.recordSlotRequested(slot); const cycle = store.snapshot().slots[0].requests[0]; expect(cycle.requestPath).toBe(expectedPath); expect(cycle.trustedServerOpportunity).toBe(expectedOpportunity); + expect(cycle.auctionType).toBe(expectedAuctionType); } ); + it('retains a completed Prebid candidate and only an exact correlated win', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('prebid-facts'); + + store.recordPrebidRefresh([slot]); + store.recordPrebidAuction(slot, 'auction-client-1', { + bidder: ' example-client ', + priceBucket: '2.40', + currency: 'eur', + }); + store.recordSlotRequested(slot); + store.recordPrebidWin(slot, 'other-auction', { + bidder: 'wrong', + priceBucket: '9.99', + }); + store.recordPrebidWin(slot, 'auction-client-1', { + bidder: ' example-client ', + priceBucket: '2.40', + currency: 'eur', + }); + + expect(store.snapshot().slots[0].requests[0]).toMatchObject({ + auctionType: 'client_side', + prebidAuction: { + auctionId: 'auction-client-1', + targetingCandidate: { + bidder: 'example-client', + priceBucket: '2.40', + currency: 'EUR', + }, + win: { bidder: 'example-client', priceBucket: '2.40', currency: 'EUR' }, + }, + }); + }); + + it('drops a Prebid win when the same auction ID matches multiple retained cycles', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('prebid-ambiguous-auction'); + + store.recordPrebidAuction(slot, 'reused-auction-id', { + bidder: 'example-client', + priceBucket: '2.40', + }); + store.recordSlotRequested(slot); + store.recordPrebidAuction(slot, 'reused-auction-id', { + bidder: 'example-client', + priceBucket: '2.40', + }); + store.recordSlotRequested(slot); + + store.recordPrebidWin(slot, 'reused-auction-id', { + bidder: 'example-client', + priceBucket: '2.40', + }); + + expect(store.snapshot().slots[0].requests).toHaveLength(2); + expect(store.snapshot().slots[0].requests.map((cycle) => cycle.prebidAuction?.win)).toEqual([ + undefined, + undefined, + ]); + }); + + it('does not carry an earlier auction into a later bare Prebid refresh', () => { + let now = 10; + const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); + const slot = fakeSlot('prebid-replaced-intent'); + store.recordPrebidRefresh([slot]); + store.recordPrebidAuction(slot, 'auction-client-1', { + bidder: 'example-client', + priceBucket: '2.40', + }); + + now = 20; + store.recordPrebidRefresh([slot]); + store.recordSlotRequested(slot); + store.recordPrebidWin(slot, 'auction-client-1', { + bidder: 'example-client', + priceBucket: '2.40', + }); + + const cycle = store.snapshot().slots[0].requests[0]; + expect(cycle.requestPath).toBe('prebid_refresh'); + expect(cycle.auctionType).toBeUndefined(); + expect(cycle.prebidAuction).toBeUndefined(); + }); + + it('retains bounded winner and server timing facts for a Trusted Server auction', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('auction-facts'); + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + 'auction-123', + [[300, 250]], + { + auctionType: 'trusted_server', + winner: { bidder: ' example-bidder ', priceBucket: '1.20' }, + serverTimings: { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'pre_header', + }, + } + ); + store.recordSlotRequested(slot); + + expect(store.snapshot().slots[0].requests[0]).toMatchObject({ + auctionType: 'trusted_server', + auctionWinner: { bidder: 'example-bidder', priceBucket: '1.20' }, + serverAuctionTimings: { + auctionDispatchedMs: 4, + auctionResolvedMs: 84, + auctionCommittedMs: 85, + auctionWaitMs: 80, + auctionWaitPlacement: 'pre_header', + }, + }); + }); + + it('does not treat a Prebid refresh route as client-auction evidence', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('competing-spa-auction'); + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'trusted_server', + serverTimings: { auctionDispatchedMs: 0, auctionResolvedMs: 84 }, + } + ); + store.recordPrebidRefresh([slot]); + store.recordSlotRequested(slot); + + expect(store.snapshot().slots[0].requests[0]).toMatchObject({ + auctionType: 'trusted_server', + serverAuctionTimingOrigin: 'spa_auction', + serverAuctionTimings: { auctionDispatchedMs: 0, auctionResolvedMs: 84 }, + }); + }); + + it('drops malformed winner and auction timing fields at the diagnostics boundary', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const slot = fakeSlot('invalid-auction-facts'); + + store.recordTrustedServerOpportunity( + slot, + 'auction-slot', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'invalid' as never, + winner: { bidder: 'example-bidder', priceBucket: 'not-a-price' }, + serverTimings: { + auctionDispatchedMs: -1, + auctionResolvedMs: Number.NaN, + auctionCommittedMs: 0x1_0000_0000, + auctionWaitPlacement: 'elsewhere' as never, + }, + } + ); + store.recordSlotRequested(slot); + + const cycle = store.snapshot().slots[0].requests[0]; + expect(cycle.auctionType).toBeUndefined(); + expect(cycle.auctionWinner).toBeUndefined(); + expect(cycle.serverAuctionTimings).toBeUndefined(); + }); + it('consumes direct and Prebid markers exactly once', () => { let now = 10; const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts index f4f4c74864..5ac0044570 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts @@ -81,6 +81,9 @@ describe('GPT diagnostics public types', () => { expectTypeOf().toEqualTypeOf< | 'recordTrustedServerOpportunity' | 'recordPrebidRefresh' + | 'recordCompletedPrebidAuction' + | 'recordPrebidAuction' + | 'recordPrebidWin' | 'recordTrustedServerCreativeRequest' | 'recordTrustedServerCreativeResponse' | 'recordTrustedServerCreativeFailure' diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 8f1e1126c8..35da3ab59a 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -367,6 +367,7 @@ import { import { log } from '../../../src/core/log'; import type { TsjsApi } from '../../../src/core/types'; import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; +import { GptDiagnosticsPrebidRecorder } from '../../../src/integrations/gpt_diagnostics/prebid'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; import envelope from '../../fixtures/aps-renderer-v1.json'; import { installTraceRuntime } from '../../../src/trace/runtime'; @@ -4469,6 +4470,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockPbjs.requestBids = mockRequestBids; mockPbjs.removeAdUnit = mockRemoveAdUnit; delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; + delete (mockPbjs as unknown as Record).__tsDiagnosticsBidWonInstalled; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -4488,10 +4490,22 @@ describe('prebid publisher snapshots and delivery refreshes', () => { delete testWindow.__tsjs_prebid; testWindow.tsjs = undefined; delete testWindow.googletag; + vi.restoreAllMocks(); document.body.replaceChildren(); }); + function attachPrebidDiagnostics() { + const recorder = testWindow.tsjs?.gptDiagnosticsRecorder; + if (recorder) { + recorder.recordCompletedPrebidAuction = new GptDiagnosticsPrebidRecorder( + window, + recorder + ).recordCompletedPrebidAuction; + } + } + function installGpt(slots: Array>) { + attachPrebidDiagnostics(); installedGptSlots = slots; for (const slot of slots) { if (!slot || typeof slot !== 'object') continue; @@ -4522,6 +4536,68 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return { originalRefresh, pubads }; } + it.each(['onEvent registration', 'diagnostics recorder access'])( + 'issues one GAM refresh when %s throws during auction diagnostics setup', + (failure) => { + vi.useFakeTimers(); + mockOnEvent.mockReset(); + try { + const code = `example-diagnostics-${failure.replaceAll(' ', '-')}`; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + const element = document.createElement('div'); + element.id = code; + document.body.appendChild(element); + installPrebidNpm(); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + let bidsBackHandler: ((...args: unknown[]) => void) | undefined; + mockRequestBids.mockImplementation((options) => { + bidsBackHandler = options.bidsBackHandler; + }); + if (failure === 'onEvent registration') { + testWindow.tsjs = { + gptDiagnosticsRecorder: { + recordPrebidAuction: vi.fn(), + } as unknown as TsjsApi['gptDiagnosticsRecorder'], + }; + attachPrebidDiagnostics(); + mockOnEvent.mockImplementation(() => { + throw new Error('publisher onEvent failed'); + }); + } else { + testWindow.tsjs = {}; + Object.defineProperty(testWindow.tsjs, 'gptDiagnosticsRecorder', { + configurable: true, + get: () => { + throw new Error('diagnostics recorder unavailable'); + }, + }); + } + + pubads.refresh([slot]); + expect(bidsBackHandler).toBeTypeOf('function'); + + bidsBackHandler!({}, false, 'example-diagnostics-auction'); + if (failure === 'onEvent registration') { + expect(mockOnEvent).toHaveBeenCalledWith('bidWon', expect.any(Function)); + } + vi.advanceTimersByTime(640); + bidsBackHandler!({}, false, 'example-diagnostics-auction'); + + expect(originalRefresh).toHaveBeenCalledOnce(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + mockOnEvent.mockReset(); + vi.useRealTimers(); + } + } + ); + function refreshAdUnitFromLastRequest(): | (Record & { code?: string; bids?: TestBid[] }) | undefined { @@ -5701,6 +5777,220 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return recordPrebidRefresh; } + it('leaves Prebid auction identity and event listeners unchanged when diagnostics is inactive', () => { + const getTargeting = vi.fn(() => []); + const slot = { + getSlotElementId: () => 'example-inactive-slot', + getTargeting, + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-inactive-auction'); + }); + + pubads.refresh([slot]); + + const request = mockRequestBids.mock.calls[0][0]; + expect(request).not.toHaveProperty('auctionId'); + expect(mockOnEvent).not.toHaveBeenCalledWith('bidWon', expect.any(Function)); + expect(getTargeting).not.toHaveBeenCalledWith('hb_bidder'); + expect(getTargeting).not.toHaveBeenCalledWith('hb_pb'); + expect(getTargeting).not.toHaveBeenCalledWith('hb_cur'); + }); + + it('records completed client auction evidence only for the exact Prebid attempt', () => { + const getTargeting = vi.fn((key: string) => + key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [] + ); + const slot = { + getSlotElementId: () => 'example-client-slot', + getTargeting, + clearTargeting: vi.fn(), + }; + const recordPrebidRefresh = vi.fn(); + const recordPrebidAuction = vi.fn(); + const recordPrebidWin = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { + recordPrebidRefresh, + recordPrebidAuction, + recordPrebidWin, + }, + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-client-auction'); + }); + + pubads.refresh([slot]); + + expect(mockRequestBids.mock.calls[0][0]).not.toHaveProperty('auctionId'); + expect(mockOnEvent).toHaveBeenCalledWith('bidWon', expect.any(Function)); + expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); + expect(recordPrebidAuction).toHaveBeenCalledWith(slot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); + expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( + recordPrebidAuction.mock.invocationCallOrder[0] + ); + expect(getTargeting).not.toHaveBeenCalledWith('hb_cur'); + const bidWon = mockOnEvent.mock.calls.find(([event]) => event === 'bidWon')?.[1]; + expect(bidWon).toBeTypeOf('function'); + bidWon?.({ + auctionId: 'other-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'wrong-client', hb_pb: '9.99' }, + }); + expect(recordPrebidWin).not.toHaveBeenCalled(); + bidWon?.({ + auctionId: 'example-client-auction', + latestTargetedAuctionId: 'later-client-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'stale-client', hb_pb: '9.99' }, + }); + expect(recordPrebidWin).not.toHaveBeenCalled(); + bidWon?.({ + auctionId: 'example-client-auction', + latestTargetedAuctionId: 'example-client-auction', + adUnitCode: 'example-client-slot', + adserverTargeting: { hb_bidder: 'example-client', hb_pb: '2.40' }, + }); + expect(recordPrebidWin).toHaveBeenCalledWith(slot, 'example-client-auction', { + bidder: 'example-client', + priceBucket: '2.40', + }); + }); + + it('retries failed bidWon registration without duplicating a successful listener', () => { + const slot = { + getSlotElementId: () => 'example-retry-slot', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const recordPrebidWin = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { + recordPrebidRefresh: vi.fn(), + recordPrebidAuction: vi.fn(), + recordPrebidWin, + }, + }; + const { originalRefresh, pubads } = installGpt([slot]); + installPrebidNpm(); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + let auctionSequence = 0; + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, `example-retry-auction-${++auctionSequence}`); + }); + mockOnEvent.mockClear(); + mockOnEvent.mockImplementationOnce(() => { + throw new Error('publisher onEvent failed once'); + }); + + try { + pubads.refresh([slot]); + expect(mockOnEvent).toHaveBeenCalledExactlyOnceWith('bidWon', expect.any(Function)); + expect(mockOnEvent.mock.results[0].type).toBe('throw'); + + pubads.refresh([slot]); + expect(mockOnEvent).toHaveBeenCalledTimes(2); + expect(mockOnEvent.mock.results[1].type).toBe('return'); + const bidWon = mockOnEvent.mock.calls[1][1]; + bidWon({ + auctionId: 'example-retry-auction-2', + adUnitCode: 'example-retry-slot', + adserverTargeting: { hb_bidder: 'example-client', hb_pb: '2.40' }, + }); + expect(recordPrebidWin).toHaveBeenCalledExactlyOnceWith(slot, 'example-retry-auction-2', { + bidder: 'example-client', + priceBucket: '2.40', + }); + + pubads.refresh([slot]); + expect(mockOnEvent).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledTimes(3); + } finally { + mockOnEvent.mockReset(); + } + }); + + it('does not record a completed auction for duplicate ad-unit element IDs', () => { + const code = 'duplicate-client-slot'; + const firstSlot = { + getSlotElementId: () => code, + getTargeting: () => ['example-client'], + clearTargeting: vi.fn(), + }; + const secondSlot = { + getSlotElementId: () => code, + getTargeting: () => ['example-client'], + clearTargeting: vi.fn(), + }; + const recordPrebidAuction = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { + recordPrebidRefresh: vi.fn(), + recordPrebidAuction, + }, + }; + const { originalRefresh, pubads } = installGpt([firstSlot, secondSlot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'duplicate-client-auction'); + }); + installPrebidNpm(); + + pubads.refresh([firstSlot, secondSlot]); + + expect(recordPrebidAuction).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([firstSlot, secondSlot], undefined); + }); + + it.each(['expiry', 'navigation change'] as const)( + 'rejects a Prebid bidWon observation after %s', + (reason) => { + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const adUnitCode = `example-${reason.replace(' ', '-')}`; + const slot = { + getSlotElementId: () => adUnitCode, + getTargeting: (key: string) => + key === 'hb_bidder' ? ['example-client'] : key === 'hb_pb' ? ['2.40'] : [], + clearTargeting: vi.fn(), + }; + const recordPrebidWin = vi.fn(); + testWindow.tsjs = { + navGeneration: 1, + gptDiagnosticsRecorder: { + recordPrebidRefresh: vi.fn(), + recordPrebidAuction: vi.fn(), + recordPrebidWin, + }, + }; + const { pubads } = installGpt([slot]); + mockPbjs.setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts) => { + opts.bidsBackHandler?.({}, false, 'example-client-auction'); + }); + + pubads.refresh([slot]); + const bidWon = mockOnEvent.mock.calls.find(([event]) => event === 'bidWon')?.[1]; + expect(bidWon).toBeTypeOf('function'); + if (reason === 'expiry') now = 30_001; + else testWindow.tsjs!.navGeneration = 2; + bidWon?.({ + auctionId: 'example-client-auction', + adUnitCode, + adserverTargeting: { hb_bidder: 'example-client', hb_pb: '2.40' }, + }); + + expect(recordPrebidWin).not.toHaveBeenCalled(); + } + ); it('suppresses one publisher delivery after TS claims first and allows a later refresh', () => { const code = 'example-ts-first-slot'; const element = document.createElement('div'); @@ -7432,12 +7722,17 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } }); - it('completes a synthetic refresh when targeting throws', () => { + it('completes a synthetic refresh without recording auction evidence when targeting throws', () => { const slot = { getSlotElementId: () => 'example-throwing-targeting', getTargeting: () => [], clearTargeting: vi.fn(), }; + const recordPrebidRefresh = vi.fn(); + const recordPrebidAuction = vi.fn(); + testWindow.tsjs = { + gptDiagnosticsRecorder: { recordPrebidRefresh, recordPrebidAuction }, + }; const { originalRefresh, pubads } = installGpt([slot]); mockPbjs.setTargetingForGPTAsync = vi.fn(() => { throw new Error('example targeting failure'); @@ -7447,6 +7742,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { pubads.refresh([slot]); + expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); + expect(recordPrebidAuction).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 80ba90580c..103e26b98d 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -331,11 +331,17 @@ describe('tsjs-prebid production artifacts', () => { expect(shimCode).not.toContain(analyticsArtifact.manifest.prebidVersion); expect(shimCode).not.toContain('_pbjsGlobals'); expect(analyticsArtifact.bundleCode.length).toBeGreaterThan(200_000); - // The original shim measures 40,354 characters. Shared strict trace JSON - // validation, request-ID hook association, and bounded pending lifecycle add - // 11,676 characters (52,030 total). Keep a narrow 52.5 KB guard: Prebid remains a separate artifact above 200 KB, and importing - // its runtime or private rendering helpers must still fail this regression. - expect(shimCode.length).toBeLessThan(52_500); + // Main's trace-enabled shim measures 52,464 bytes with these production + // options; the completed-auction bridge adds 188 bytes. Keep a narrow 53 KB + // budget while the optional diagnostics bundle owns Prebid evidence listeners. + expect(Buffer.byteLength(shimCode)).toBeLessThan(53_000); + // The optional diagnostics bundle owns subscription, correlation, and + // candidate reads. Only its completed-auction bridge remains in the shim. + expect(shimCode).not.toContain('bidWon'); + expect(shimCode).not.toContain('latestTargetedAuctionId'); + expect(shimCode).not.toContain('recordPrebidAuction'); + expect(shimCode).not.toContain('recordPrebidWin'); + expect(shimCode).toContain('recordCompletedPrebidAuction'); expect(shimCode).toContain('markWinningBidAsUsed'); }); diff --git a/crates/trusted-server-js/lib/test/trace/projection.test.ts b/crates/trusted-server-js/lib/test/trace/projection.test.ts index 0cde5b2e1f..41087eba98 100644 --- a/crates/trusted-server-js/lib/test/trace/projection.test.ts +++ b/crates/trusted-server-js/lib/test/trace/projection.test.ts @@ -77,6 +77,48 @@ describe('explicit GPT public projection', () => { ).toBe(false); } ); + it('projects Console auction facts without expanding the v1 public trace schema', () => { + const store = new GptDiagnosticsStore({ now: () => 1 }); + const slot = { getSlotElementId: () => 'example' }; + store.recordTrustedServerOpportunity( + slot, + 'example', + 'renderable_candidate', + undefined, + undefined, + { + auctionType: 'ssat', + winner: { bidder: 'private-server-bidder', priceBucket: '1.00' }, + serverTimings: { auctionResolvedMs: 84 }, + } + ); + store.recordPrebidAuction(slot, 'private-prebid-auction', { + bidder: 'private-client-bidder', + priceBucket: '2.00', + }); + store.recordSlotRequested(slot); + const source = observedGptSource(store); + expect(source.slots[0]!.requests[0]!).toMatchObject({ + auctionType: 'competing', + serverAuctionTimingOrigin: 'navigation', + serverAuctionTimings: { auctionResolvedMs: 84 }, + prebidAuction: { auctionId: 'private-prebid-auction' }, + }); + const projected = projectTraceGptDiagnostics(source, TRACE_ORIGIN); + expect(projected.ok).toBe(true); + if (!projected.ok) throw new Error('should project Console auction facts'); + const cycle = projected.value.slots[0]!.requests[0]!; + expect(cycle.requestPath).toBe('competing'); + for (const key of [ + 'auctionType', + 'auctionWinner', + 'prebidAuction', + 'serverAuctionTimings', + 'serverAuctionTimingOrigin', + ]) + expect(cycle).not.toHaveProperty(key); + expect(JSON.stringify(projected.value)).not.toContain('private-'); + }); it('classifies inspection failures without reading caller-controlled errors', () => { let reads = 0; const failure = Object.defineProperty(new Error('private-message'), 'message', { diff --git a/crates/trusted-server-js/lib/test/trace/types.test.ts b/crates/trusted-server-js/lib/test/trace/types.test.ts index 97a9c2ccc0..9eca4e9c2c 100644 --- a/crates/trusted-server-js/lib/test/trace/types.test.ts +++ b/crates/trusted-server-js/lib/test/trace/types.test.ts @@ -57,6 +57,11 @@ const cycle = { requestPath: 'copy', requestIntentId: 'copy', trustedServerAuctionId: 'copy', + auctionType: 'exclude', + auctionWinner: 'exclude', + prebidAuction: 'exclude', + serverAuctionTimings: 'exclude', + serverAuctionTimingOrigin: 'exclude', opportunityToRequestMs: 'copy', replacedRequestNumber: 'copy', previousRenderToRequestMs: 'copy', diff --git a/crates/trusted-server-js/lib/trace-assets-manifest.json b/crates/trusted-server-js/lib/trace-assets-manifest.json index 8450d65363..c7de726e7d 100644 --- a/crates/trusted-server-js/lib/trace-assets-manifest.json +++ b/crates/trusted-server-js/lib/trace-assets-manifest.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_sha256": "e188da9359876286f6488882a1093f8b9ed6d0471df112f817623612ec18ae1c", + "source_sha256": "4e49d17fe2ec718c3507d666c9351a24e6fd67a3c2f40a7b79bc26d02d66b110", "assets": [ { "path": "/_ts/trace/assets/v1.js", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 13c3c3c984..941262a03f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -169,6 +169,10 @@ export default withMermaid( text: 'GPT Runtime Diagnostics', link: '/guide/integrations/gpt-diagnostics', }, + { + text: 'GPT Diagnostics Label Dictionary', + link: '/guide/integrations/gpt-diagnostics-dictionary', + }, { text: 'Lockr', link: '/guide/integrations/lockr' }, { text: 'Next.js', link: '/guide/integrations/nextjs' }, { text: 'Osano', link: '/guide/integrations/osano' }, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 8413e77d09..3818337c33 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -61,6 +61,7 @@ publisher, trusted-client-IP, EC, handler, Tinybird, DataDome, and S3 fields: - `ec.partners[*].ts_pull_token`, when pull sync is enabled - `handlers[*].password` - `tinybird.auction_token_secret`, when Tinybird auction telemetry is enabled +- `tinybird.access_token_secret`, when Tinybird access telemetry is enabled - `integrations.datadome.server_side_key_secret_name`, when protection is enabled - `integrations.datadome.protection_test_bypass.credential_secret_name`, when the bypass is enabled - `proxy.asset_routes[*].auth.access_key_id`, `secret_access_key`, and optional `session_token` @@ -70,11 +71,8 @@ resolved only while an instance builds runtime settings. An adapter can map the logical ID to a different physical name. For example, Fastly commonly maps `trusted_server_secrets` to physical store `ts_secrets`. -One accepted secret-shaped field is deliberately different: - -- `tinybird.access_token_secret` is deprecated input. It is accepted for - migration, then discarded and omitted from serialized config; use - `tinybird.auction_token_secret` as the store key name instead. +Both Tinybird token fields are active secret-store key references when their +respective sinks are enabled. The four deprecated `secret_store` selectors under Tinybird, DataDome, its protection-test bypass, and S3 route authentication are also accepted and @@ -90,26 +88,26 @@ diff; it does not move inline values to a secret store. The following table records the independent lifecycle, key-identity, serialization, runtime, and secret axes for every exceptional field: -| Path | Lifecycle | Key identity | Serialization | Runtime | Secret handling | -| ------------------------------------------------------------ | ---------- | ----------------------------------- | ------------- | -------------------- | ------------------------ | -| `AssetOriginAuth.s3_sig_v4` | deprecated | alias of `AssetOriginAuth.s3_sigv4` | skipped | deserialization only | none | -| `DataDomeConfig.server_side_key_secret_name` | canonical | canonical | serialized | active | store resolved | -| `DataDomeConfig.server_side_key_secret_store` | deprecated | canonical | skipped | normalized away | none | -| `DataDomeProtectionTestBypassConfig.credential_secret_name` | canonical | canonical | serialized | active | store resolved | -| `DataDomeProtectionTestBypassConfig.credential_secret_store` | deprecated | canonical | skipped | normalized away | none | -| `Ec.passphrase` | canonical | canonical | serialized | active | store resolved | -| `EcPartner.api_token` | canonical | canonical | serialized | active | store resolved | -| `EcPartner.ts_pull_token` | canonical | canonical | serialized | active | store resolved | -| `Handler.password` | canonical | canonical | serialized | active | store resolved | -| `Publisher.proxy_secret` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.access_key_id` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.secret_access_key` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.secret_store` | deprecated | canonical | skipped | normalized away | none | -| `S3SigV4AuthConfig.session_token` | canonical | canonical | serialized | active | store resolved | -| `TinybirdSettings.access_token_secret` | deprecated | canonical | skipped | normalized away | accepted, then discarded | -| `TinybirdSettings.auction_token_secret` | canonical | canonical | serialized | active | store resolved | -| `TinybirdSettings.secret_store` | deprecated | canonical | skipped | normalized away | none | -| `TrustedClientIpConfig.shared_secret` | canonical | canonical | serialized | active | store resolved | +| Path | Lifecycle | Key identity | Serialization | Runtime | Secret handling | +| ------------------------------------------------------------ | ---------- | ----------------------------------- | ----------------------- | -------------------- | --------------- | +| `AssetOriginAuth.s3_sig_v4` | deprecated | alias of `AssetOriginAuth.s3_sigv4` | skipped | deserialization only | none | +| `DataDomeConfig.server_side_key_secret_name` | canonical | canonical | serialized | active | store resolved | +| `DataDomeConfig.server_side_key_secret_store` | deprecated | canonical | skipped | normalized away | none | +| `DataDomeProtectionTestBypassConfig.credential_secret_name` | canonical | canonical | serialized | active | store resolved | +| `DataDomeProtectionTestBypassConfig.credential_secret_store` | deprecated | canonical | skipped | normalized away | none | +| `Ec.passphrase` | canonical | canonical | serialized | active | store resolved | +| `EcPartner.api_token` | canonical | canonical | serialized | active | store resolved | +| `EcPartner.ts_pull_token` | canonical | canonical | serialized | active | store resolved | +| `Handler.password` | canonical | canonical | serialized | active | store resolved | +| `Publisher.proxy_secret` | canonical | canonical | serialized | active | store resolved | +| `S3SigV4AuthConfig.access_key_id` | canonical | canonical | serialized | active | store resolved | +| `S3SigV4AuthConfig.secret_access_key` | canonical | canonical | serialized | active | store resolved | +| `S3SigV4AuthConfig.secret_store` | deprecated | canonical | skipped | normalized away | none | +| `S3SigV4AuthConfig.session_token` | canonical | canonical | serialized | active | store resolved | +| `TinybirdSettings.access_token_secret` | canonical | canonical | serialized when present | active sink only | store resolved | +| `TinybirdSettings.auction_token_secret` | canonical | canonical | serialized | active | store resolved | +| `TinybirdSettings.secret_store` | deprecated | canonical | skipped | normalized away | none | +| `TrustedClientIpConfig.shared_secret` | canonical | canonical | serialized | active | store resolved | Prepare an initial reference-based deployment in this order: @@ -163,6 +161,7 @@ auction_token_secret = "tinybird_auction_append_token" Store the APPEND token value under `tinybird_auction_append_token` in the physical store mapped from `trusted_server_secrets`. The token is resolved once at startup. Disabled Tinybird telemetry does not require or resolve the token. +`access_token_secret` follows the same path when `access_enabled` is on. The legacy `tinybird.secret_store` field is accepted for one migration release, but it is ignored and omitted from newly pushed config. @@ -202,13 +201,14 @@ fail and the service will return its startup-error response. | `[[handlers]]` | Ordered HTTP Basic-auth rules | | `[image_optimizer]` | Reusable Fastly Image Optimizer profiles | | `[integrations.*]` | Typed partner and browser integration settings | +| `[observability]` | Server-Timing header emission and bounded publisher route sections | | `[proxy]` | Proxy allowlist, TLS policy, and asset routes | | `[publisher]` | Publisher domain, origin, and proxy signing key | | `[request_signing]` | Outbound Ed25519 request signing and management-store IDs | | `[response_headers]` | Headers added to Trusted Server responses | | `[rewrite]` | First-party URL rewrite exclusions | | `[tester_cookie]` | Optional tester-cookie endpoints | -| `[tinybird]` | Direct Tinybird auction telemetry | +| `[tinybird]` | Direct Tinybird auction and access telemetry | | `[trusted_forwarder]` | Authenticated browser-facing host and scheme forwarding | | `[trusted_client_ip]` | Authenticated front-door client-IP forwarding | @@ -1541,26 +1541,30 @@ See [Asset Routes](/guide/asset-routes) for request flow, S3 auth details, and I ## Tinybird Configuration -`[tinybird]` sends auction events directly to the Tinybird Events API. The -emitter is active only when `enabled = true`; access-log emission is not wired. +`[tinybird]` sends auction events and sampled access rows directly to the +Tinybird Events API. Fastly wires both emitters; each sink requires +`enabled = true` and its own enable flag. Other adapters do not emit Tinybird +rows. See [Telemetry](/guide/telemetry) for sampling, privacy, and retention. ### `[tinybird]` -| Field | Type | Default | Contract | -| ---------------------- | -------------- | ---------------------- | ------------------------------------------------------------------ | -| `enabled` | Boolean | `false` | Enable auction telemetry | -| `api_host` | String | `""` | Required when enabled; regional host without scheme, port, or path | -| `auction_dataset` | String | `"auction_events_raw"` | 1–128 ASCII letters, digits, or `_` | -| `auction_token_secret` | String or null | `null` | Store key required when enabled; resolved value must be nonempty | -| `access_enabled` | Boolean | `false` | Reserved; `true` fails startup because no emitter is wired | -| `access_dataset` | String | `"access_logs_raw"` | Reserved access-log dataset name | -| `access_sample_rate` | Number | `0.0` | `0.0..=1.0`; reserved while access emission is disabled | -| `max_body_bytes` | Integer | `1048576` | At least `1024` bytes | - -`secret_store` and `access_token_secret` are deprecated compatibility inputs. -Both are removed during normalization; neither reaches runtime or serialized -output. New configurations use only `auction_token_secret`, whose value is -resolved through `trusted_server_secrets`. +| Field | Type | Default | Contract | +| ---------------------- | -------------- | ---------------------- | --------------------------------------------------------------------------------- | +| `enabled` | Boolean | `false` | Master switch for Tinybird telemetry | +| `auction_enabled` | Boolean | `true` | Enable auction telemetry when the master switch is on | +| `api_host` | String | `""` | Required when enabled; regional host without scheme, port, or path | +| `auction_dataset` | String | `"auction_events_raw"` | 1–128 ASCII letters, digits, or `_` | +| `auction_token_secret` | String or null | `null` | Required for an active auction sink; resolves through the default secret store | +| `access_enabled` | Boolean | `false` | Enable sampled post-delivery access telemetry | +| `access_dataset` | String | `"access_logs_v2"` | Versioned access datasource; `access_logs_raw` is rejected when access is enabled | +| `access_token_secret` | String or null | `null` | Required for an active access sink; resolves through the default secret store | +| `access_sample_rate` | Number | `0.0` | `0.0..=1.0`; must be positive when access telemetry is enabled | +| `max_body_bytes` | Integer | `1048576` | At least `1024` bytes | + +`secret_store` is a deprecated compatibility input. It is ignored and omitted +from newly pushed config. Both sink tokens resolve through +`trusted_server_secrets`. Disabled sinks neither require nor resolve their token +references, even when stale keys remain in a stored config. The complete enabled example appears in [Tinybird auction telemetry](#tinybird-auction-telemetry). @@ -3149,6 +3153,183 @@ Rollback to the legacy entry point is no longer controlled by runtime config keys. Use the normal deployment rollback path to restore a pre-cleanup service version if that is required. +## Observability and Access Telemetry Configuration + +Settings for the `Server-Timing` response header and the sampled +access-telemetry sink. Both are off by default and are independent switches: +enabling one does not enable the other. + +### `[observability]` + +| Field | Type | Required | Default | Description | +| ----------------------- | -------- | -------- | ------- | --------------------------------------------------------------------------------------------------------- | +| `server_timing_enabled` | Boolean | No | `false` | Append request-phase timings to the `Server-Timing` response header | +| `route_sections` | String[] | No | `[]` | Section names kept as publisher route templates (`/{section}/*`) in access telemetry; empty collapses all | + +Route sections are matched ASCII-case-insensitively against the first path segment, +with at least one further segment required. Leading and trailing non-control +whitespace is trimmed; tabs, newlines, and other control characters are rejected, +not trimmed. Names are limited to 32 entries of at most 128 UTF-8 bytes each and +must be nonempty with no slash or control characters. For example, +`route_sections = [" news "]` normalizes to `["news"]`, preserving `/news/*` but not +`/news`. Matching uses the raw path without percent-decoding, so literal non-ASCII +section names do not match their percent-encoded URLs. Use ASCII section names. +The name `other`, in any case, shares the fallback `/other/*` bucket rather than +creating a distinct section. Configure this array in TOML, for example +`route_sections = ["news"]`; the environment overlay cannot set arrays. + +**Purpose**: Surfaces per-phase request timing (`ts-total` plus recorded +phases such as `ts-appbuild`, `ts-filter`, `ts-geo`, `ts-kv`, `ts-origin`, and +`ts-template-cache`) as a standard `Server-Timing` header, in milliseconds +with one decimal place. An unrecorded phase is omitted from the header +rather than rendered as zero. When origin dispatch overlaps auction dispatch or +EC preload, `ts-origin` measures the remaining origin wait after overlap, not full +origin latency. Fastly `ts-appbuild` measures per-request setup and includes application +construction only on cold requests; a non-null warm value is not a cold-start signal. +Axum builds state at startup and has no per-request app-build phase. + +**Emission is conservative**: the header is appended only on responses that +are conclusively private, meaning `Cache-Control` contains a bare `private` or +`no-store` directive and no recognized CDN/surrogate cache-control header is present. +A field-qualified directive such as `private="Set-Cookie"` does not qualify. +An edge-cache header suppresses timing even alongside private/no-store, since +shared-cache overrides can replay request-specific timings. A response that is +heuristically cacheable, carries a bare +`max-age`, or has no cache header at all never receives the header, because a +shared-cache object would otherwise replay one request's timings for its +entire stored lifetime. The long-lived, shared-cacheable `tsjs` asset route is +the concrete case this excludes. The header is appended, never inserted, so +an origin-supplied `Server-Timing` value and any entries the fronting +delivery layer adds are preserved alongside the TS entries. + +The Axum adapter applies the same private-response rule at its own terminal +point before serializing the response, and emits the header only; it does not +send access-telemetry rows. Cloudflare and Spin collect timing for GPT diagnostics +only; neither emits `Server-Timing` or access rows, so these emission flags have +no effect there. + +**Example**: + +```toml +[observability] +server_timing_enabled = true +``` + +::: tip The Axum dev server reads this flag once at startup +The Axum dev server bakes `server_timing_enabled` into its service when it starts. +Flipping the flag there requires a restart to take effect. +::: + +::: warning Client-visible latency disclosure +The `Server-Timing` header is sent to every client on eligible responses, +not only to operators: browsers expose the values to same-origin JavaScript +via `PerformanceResourceTiming.serverTiming`, and any caller can read the +raw header. Enabling it publishes measured per-phase server latency, +including KV read timing on the public identity endpoints (`ts-kv`) and +origin/cache behaviour on publisher pages (`ts-origin`, +`ts-template-cache`). This is standard `Server-Timing` practice and the +values are durations only, but treat the flag as a diagnostic aid to enable +deliberately, not a general always-on toggle, unless disclosing those +timings to all clients is acceptable for the deployment. +::: + +**Environment Override**: + +```bash +TRUSTED_SERVER__OBSERVABILITY__SERVER_TIMING_ENABLED=true +``` + +::: tip Present-but-false by default +`server_timing_enabled` ships as `false` in the base operator config rather +than being left out, even though `false` is also its default. The +environment-variable overlay can only override a leaf that already exists in +the parsed TOML; it cannot create a missing one. Keeping the leaf present lets +`TRUSTED_SERVER__OBSERVABILITY__SERVER_TIMING_ENABLED` take effect without an +extra edit to add the table first. +::: + +### `[tinybird]` access telemetry keys + +The authoritative field reference is [Tinybird Configuration](#tinybird). +The examples below cover access-sink enablement and rollout. + +**Purpose**: `access_enabled` and `auction_enabled` gate the two Tinybird +sinks separately so that turning on one does not silently turn on (or leave +off) the other; a settings test locks this decoupling in both directions. +Setting `access_enabled = true` with `access_sample_rate = 0.0` is rejected at +config load as an armed-but-silent configuration; use `access_enabled` itself +to turn the sink off, not the sample rate. Enabling `access_enabled` also +requires the shared transport fields (`enabled`, non-empty `api_host`, +`access_dataset`, a resolvable `access_token_secret`, and a +`max_body_bytes` of at least 1024) to already be set. + +**Example**: + +```toml +[tinybird] +enabled = true +api_host = "api.tinybird.example.com" +auction_enabled = false + +# Access-only telemetry: no auction APPEND token is required. +access_enabled = true +access_dataset = "access_logs_v2" +access_token_secret = "tinybird_access_append_token" +access_sample_rate = 0.05 +max_body_bytes = 1048576 +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__TINYBIRD__ACCESS_ENABLED=true +TRUSTED_SERVER__TINYBIRD__ACCESS_SAMPLE_RATE=0.05 +TRUSTED_SERVER__TINYBIRD__AUCTION_ENABLED=false +``` + +These overrides require the corresponding leaves to exist in the parsed TOML. +The example's Tinybird block is commented out; uncomment and configure it first. +Keep `access_sample_rate` as a TOML float, such as `0.05` or `0.0`, since overlay +coercion uses the existing leaf's type. + +A sampled request dispatches one access-telemetry row to `access_dataset` after +client delivery. Fastly starts the host-owned request and drops its pending handle, +without awaiting the ingest reply or retrying. Local serialization/setup/start +failures are logged; later transport errors, non-2xx responses and ingestion +acceptance are not observed. The row records the configured sample rate and has a +30-day TTL. See [Telemetry](/guide/telemetry#access-data-path) for limits, the +collection-time and delivery-result contracts, and privacy. + +### Deploy and rollback ordering + +::: warning Push a compatibility config before rolling back +The compatibility boundary is uneven. The top-level `Settings` schema uses +`deny_unknown_fields`, so an older binary rejects a config carrying the +`[observability]` table. The nested `[tinybird]` table does not: an older +binary accepts unknown keys there, rejects `access_enabled = true` through +validation, ignores `auction_enabled` entirely, and reads `enabled = true` +as "auction telemetry on". + +**Deploying**: create the versioned `access_logs_v2` datasource and its scoped +`ts_access_v2_ingest` APPEND token before deploying the emitting binary. Preserve +`access_logs_raw`; it is the legacy reserved schema, not the target for these +rows. Upgrade every serving binary before pushing a config containing the new +fields. An existing explicit `access_dataset = "access_logs_raw"` must be changed +to `access_logs_v2`, with the matching token key. When access is enabled, +validation rejects exactly the normalized legacy name `access_logs_raw`; custom +dataset names remain allowed and disabled legacy configurations remain accepted. +Follow the workspace cutover in +[`tinybird/README.md`](https://github.com/IABTechLab/trusted-server/blob/main/tinybird/README.md). + +**Rolling back**: push a compatibility config first, then roll the binary +back. The compatibility config removes the `[observability]` table, sets +`access_enabled = false`, and, for a deployment that only used the access +sink, sets `enabled = false` as well. Otherwise the older binary tries to enable +auction telemetry, or fails startup if no auction token is present. Keep both +Tinybird datasources during rollback. Do not redeploy an older Tinybird project +that omits `access_logs_v2`; inspect the deployment dry-run for proposed removals. +::: + ## Validation ### Automatic Validation diff --git a/docs/guide/integrations/gpt-diagnostics-dictionary.md b/docs/guide/integrations/gpt-diagnostics-dictionary.md new file mode 100644 index 0000000000..9458b07924 --- /dev/null +++ b/docs/guide/integrations/gpt-diagnostics-dictionary.md @@ -0,0 +1,171 @@ +# GPT Diagnostics Label Dictionary + +This dictionary defines the exact fixed labels and dynamic label prefixes in the GPT Runtime Diagnostics panel and badges. Text after a prefix such as `Server auction winner:` is the bounded observed value. The console reports observations; it does not identify the creative ultimately served unless a listed evidence source explicitly establishes that fact. Browser times use `performance.now()` and server times use the server request's `RequestTimings` clock. The clocks are never subtracted. + +See [GPT Runtime Diagnostics](./gpt-diagnostics.md) for activation and operational details. + +## Identity and controls + +| Label | Badge | Source | Meaning and limits | +| ----------------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `Ad #N` | `Ad #N` | `runtimeSlotNumber` | Stable number assigned to a GPT slot object in this page. It is not a creative ID. | +| `Request #M` | `Request #M` | `requestNumber` | Monotonic request cycle for that slot, including refreshes. It does not imply fill. | +| `Filter` | — | panel state | Limits displayed rows. Activating a badge resets a hiding filter to `All`. | +| `Export JSON` | — | `gptDiagnostics.export()` | Downloads the allowlisted V1 snapshot. It issues no ad request. | +| `Label dictionary` | — | documentation link | Opens this keyboard-accessible help page. | +| `How to read this evidence` | — | inline help | Expands a concise explanation that winner, candidate, render, and timing observations have separate meanings and clocks. | +| `Locate on page` | — | exact unique DOM binding | Scrolls only on activation and briefly draws a diagnostics-layer highlight. It never changes publisher attributes, classes, or styles. | +| `Collapse`, `Expand`, `Close` | — | panel state | Presentation controls only. `show()` can reopen a closed panel. | +| `Request history` | — | retained cycles | Earlier retained requests. At most ten cycles are retained per slot. An evicted selection is reported as no longer retained. | +| `Technical details` | — | allowlisted snapshot fields | Expands correlation IDs, coverage, failures, and other non-summary evidence. | + +Badge evidence passes pointer input through. A small keyboard-accessible `Ad #N` +button opens the panel from outside the observed bound creative rectangles. If no +outside position fits inside the viewport, only the button is hidden. Selecting +the latest request focuses its current details; an earlier request opens history. + +The per-request panel groups are `Summary`, `Auction evidence`, `Delivery evidence`, `Timing`, and `Size and visibility`. Summary prefixes are `GPT result:` and `Observed auction path:`. Technical prefixes include `Ad unit`, `Request intent:`, `Trusted Server auction:`, and `Prebid auction:`. A fact appears in one detailed group; Technical details does not repeat those grouped facts. + +Panel status is `GPT observed` or `Waiting for GPT`. Filter values are `All`, `Visible`, `Filled`, `Empty`, `Pending/Incomplete`, and `Unbound/Ambiguous`. Empty results are `No GPT slots observed yet.` or `No slots match.` The retained-cycle labels are `Initial request` and `Refresh N`; an evicted selection is announced as `Ad #N, Request #M is no longer retained.` The overview counts use the prefixes `slots`, `callback issues`, and `attribution issues`. Callback coverage uses the suffixes `observed`, `matched`, `unmatched`, and `ambiguous`. + +## GPT lifecycle and delivery + +| Label | Badge | Raw value / source | Meaning and what it does not prove | +| -------------------------------------------------------------------- | ------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Waiting for request` | — | no request cycle | GPT has not emitted `slotRequested` for the slot. | +| `Requesting` | `Pending` | `slotRequested` | A GPT request callback was observed. Elapsed time alone does not mean failure. | +| `Response received` | — | `slotResponseReceived` | GPT emitted its response callback. It does not prove fill. | +| `GPT result: Filled` / `Filled` | `Filled` | `slotRenderEnded.isEmpty === false` | GPT reported non-empty. It does not prove which bidder or pixels were served. | +| `GPT result: Empty` / `Empty` | `Empty` | `slotRenderEnded.isEmpty === true` | GPT explicitly reported empty. | +| `GPT result: Rendered (fill unknown)` / `Rendered (fill unknown)` | `Rendered (fill unknown)` | `slotRenderEnded` without `isEmpty` | Render callback observed; fill remains unavailable. | +| `Creative markup sent; execution not confirmed` | `TS response sent` | `delivery=trusted_server_response_sent` | The bridge posted markup to PUC. It does not prove execution, visibility, or final served source. | +| `Server bid selected by the creative bridge; response not confirmed` | `TS selected` | `delivery=trusted_server_selected` | A matched PUC request selected the server bid; no successful response post was observed. | +| `Server bid available; selection not confirmed` | `TS unconfirmed` | `delivery=candidate_unconfirmed` | A server candidate existed but no matched selection appeared in the observation window. | +| `Waiting for Trusted Server creative evidence` | `TS candidate (pending)` | `delivery=pending` | The five-second positive-evidence window is open. | +| `No direct Trusted Server candidate` | `No TS candidate` | `delivery=no_candidate` | `adInit` explicitly found no direct candidate for this request. | +| `Delivery status unknown — required evidence was not observed` | `Delivery unknown` | `delivery=unknown` | Evidence exists but cannot establish a more specific delivery state. | +| `Delivery evidence: Not applicable` | — | `delivery=not_applicable` | No positive bridge evidence exists and a delivery conclusion does not apply before render or for an explicitly empty result. | +| `Delivery evidence: Not observed` | — | missing delivery state | No delivery state was captured. | +| `Served bidder not confirmed` | — | a non-empty GPT render without served proof | Server winner, targeting candidate, `bidWon`, and GPT render facts remain separate; none alone confirms the final served creative. This label is not shown for pending or empty requests. | +| `GPT slot onload observed` | — | `slotOnload` | GPT emitted onload. It is not pixel-level or demand-source proof. | +| `GPT impressionViewable observed` | — | `impressionViewable` | GPT emitted its viewability callback. | +| `Incomplete sequence` | `Incomplete sequence` | `incompleteSequence` | An observed callback proves a missing or invalid predecessor. Time alone never sets it. | + +## Auction evidence + +| Label | Badge | Raw value / source | Meaning and limits | +| ----------------------------------- | ---------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SSAT: initial-page server auction` | `SSAT` | `auctionType=ssat` | Explicit completed server-auction evidence from the initial page request. | +| `TS auction: SPA server auction` | `TS auction` | `auctionType=trusted_server` | Explicit completed `/_ts/page-bids` server-auction evidence. | +| `Client-side Prebid auction` | `Prebid auction` | `auctionType=client_side`, exact Prebid callback correlation | A completed Prebid attempt was correlated to this slot and next GPT request. A refresh wrapper call alone does not establish it. | +| `Multiple auction paths observed` | `Multiple paths` | `auctionType=competing` | Completed server and Prebid auction evidence both exist for the request. This does not prove a race, overwrite, or winner. | +| `Auction not observed` | no auction badge | missing or malformed explicit facts | Diagnostics has no qualifying completed-auction evidence. It never defaults to SSAT. | +| `Server auction winner` | — | compatibility `auctionWinner`, server `hb_bidder` | Winner selected by the server auction. It is not the final served bidder. | +| `Server bid price bucket:` | — | server `hb_pb` | Already-bucketed price; raw CPM is never exported. A supplied currency follows the bucket value; an absent currency adds no suffix. | +| `Prebid targeting candidate:` | — | `prebidAuction.targetingCandidate` after exact `setTargetingForGPTAsync` boundary | Candidate targeting observed on the exact GPT slot for the exact Prebid callback auction ID. It is not a final win. | +| `Prebid candidate price bucket:` | — | candidate `hb_pb` | Bucketed targeting value for the candidate; it is not a winning-price claim. Diagnostics do not infer or read a Prebid currency targeting key. | +| `Prebid bidWon observation:` | — | `prebidAuction.win`, documented `bidWon` payload | A bounded event joined by exact auction ID, ad-unit code, slot object, navigation generation, and retention window. It remains distinct from GPT render and served-source proof. | +| `Prebid win price bucket:` | — | `bidWon.adserverTargeting.hb_pb` | Bucketed value observed on the correlated `bidWon` event; it is not proof of the creative GAM served. Diagnostics do not infer a currency. | + +Bidder names are limited to 128 UTF-8 bytes, numeric bucket strings to 64 bytes, currencies to three ASCII letters, and auction IDs to 256 UTF-8 bytes. Prebid supplies its own auction ID through `bidsBackHandler`; diagnostics does not override Prebid auction identity. Duplicate, ambiguous, expired, late, prior-navigation, and malformed observations are rejected. Only an active diagnostics recorder installs the `bidWon` listener or retains candidate/win state, which is bounded to 128 pending attempts and 30 seconds. No raw CPM, creative markup, targeting dump, or losing bids are retained. + +First-party GPT and Prebid integrations never populate `currency`. The optional field +accepts currency supplied through the internal `gptDiagnosticsRecorder` channel; that +channel is not a supported operator API. Diagnostics does not infer a currency or read +`hb_cur`, and missing currency adds no suffix to the price bucket. + +`Auction not observed` also covers the Prebid watchdog fallback: targeting can be +applied even when Prebid never invokes `bidsBackHandler`, so no completed-auction ID +is available to correlate with the GPT request. Targeting alone is not proof of a +completed auction. + +## Request paths and opportunities + +| Label | Raw value | Meaning | +| -------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `Request path: Trusted Server direct` | `trusted_server_direct` | The direct `adInit` route preceded the request. Route evidence alone is not auction evidence. | +| `Request path: Prebid refresh` | `prebid_refresh` | The installed wrapper delegated the GPT refresh. Timeout and throw fallbacks retain this route label but do not claim a completed auction. | +| `Request path: Publisher refresh` | `publisher_refresh` | Publisher refresh boundary observed. | +| `Request path: Multiple paths observed` | `competing` | More than one route marker preceded the request; competition is possible but unproven. | +| `Request path: Not observed` | `unattributed` | No eligible route marker was consumed. | +| `Server bid available; creative source present` | `renderable_candidate` | Bid targeting plus ad ID and inline markup or complete cache coordinates were present. | +| `Server bid available; creative source incomplete` | `unrenderable_candidate` | Bid targeting existed but the bridge lacked a complete render source. | +| `Direct opportunity: No candidate` | `no_candidate` | `adInit` explicitly observed no direct bid targeting. | +| `Direct opportunity: Not observed` | missing opportunity | No bounded direct-opportunity evidence was captured. It is not negative demand-source evidence. | +| `Request intent:` | `requestIntentId` | Opaque local correlation sequence, not an auction or user ID. | +| `Trusted Server auction:` | `trustedServerAuctionId` | Opaque per-auction correlation token, not a GAM key or visitor identifier. | +| `Prebid auction:` | `prebidAuction.auctionId` | Opaque Prebid-supplied auction correlation token. Diagnostics does not create or replace it. | + +Path markers live for five seconds, are consumed once, and are keyed by GPT slot object identity. + +## Timing + +All values are milliseconds. Missing timing that should apply is `Unavailable`, never zero. Server timing is `Not applicable` when no completed server auction was observed. A displayed zero is a valid immediate observation. The three server rows below are rendered with their T0 anchor substituted: `Initial document request T0` for a navigation-origin auction and `SPA page-bids request T0` for an SPA-origin one. When no server timing origin was observed, the rows use the neutral `Server request T0` prefix with `Not applicable` values; this does not imply that a server clock or auction was observed. + +| Label | Origin and boundaries | Raw field | +| ---------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ | +| ` → auction dispatched` | Server request `RequestTimings` T0 to successful `dispatch_auction` outcome | `serverAuctionTimings.auctionDispatchedMs` | +| ` → auction collected` | Same T0 to completion of `collect_dispatched_auction` | `auctionResolvedMs` | +| ` → bids ready` | Same T0 to winning-bid map commit | `auctionCommittedMs` | +| `Auction collection wait` | Actual duration blocked in collect; placement is `pre-header` or `in stream` | `auctionWaitMs`, `auctionWaitPlacement` | +| `Opportunity → request` | Browser `performance.now()`: recorder observation to matched GPT `slotRequested` | `opportunityToRequestMs` | +| `GAM request → response` | Browser `slotRequested` to `slotResponseReceived` | `durations.requestToResponseMs` | +| `GAM response → render` | Browser `slotResponseReceived` to `slotRenderEnded` | `responseToRenderMs` | +| `GAM request → render` | Browser `slotRequested` to `slotRenderEnded` | `requestToRenderMs` | +| `Render → load` | Browser `slotRenderEnded` to `slotOnload` | `renderToLoadMs` | +| `Render → viewable` | Browser `slotRenderEnded` to `impressionViewable` | `renderToViewableMs` | +| `Replaced rendered request` | Earlier browser render callback to later request callback | `previousRenderToRequestMs` | + +Server offsets are not browser timestamps. `auctionResolvedMs` means collection completed (including timeout handling), not that a network byte arrived at that exact instant. + +## Size, visibility, binding, and GAM fields + +| Label | Raw field / source | Meaning and limits | +| ------------------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `Requested sizes` | `requestedSlotSizes` | Configured sizes supplied to GPT; ordinary sizes such as 300×250 remain visible. Missing data is shown as `Requested sizes: Not observed`. | +| `GPT-reported size` | `size` | Exact `slotRenderEnded.size`. A 1×1 placeholder is shown as `GPT-reported size: placeholder hidden` and retained unchanged in V1 JSON. | +| `Size filled` / `Measured outer slot size` | `observedSlotSize` | CSS outer box of the exact uniquely bound slot after fill. Missing data is shown as `Size filled: Not observed · Measured outer slot size`. | +| `GPT visibility` | current/maximum visibility percentage | Values from GPT visibility callbacks; absence is `GPT visibility: Not observed`. | +| `Binding: Bound` / `Bound` | `binding.status=bound` | Exactly one connected publisher element matched. | +| `Binding: unbound` / `Unbound` | `binding.status=unbound` | No safe element binding; reason is shown. | +| `Binding: ambiguous` / `Ambiguous binding` | `binding.status=ambiguous` | Duplicate ID or GPT-slot evidence prevented a safe choice. No badge or locate action is invented. | +| `Outside viewport` | binding geometry | The exact element does not intersect the viewport; it remains in the panel. | +| `Ad Manager response class: empty/backfill/reservation/unclassified non-empty` | `responseClass` | Source-neutral classification derived from GPT render facts. | +| `Ad Manager reported …` | `adManager` identifiers | GAM-reported line item, order, advertiser, creative, yield-group, and company IDs. They do not identify the demand source. | +| `Ad Manager fields: Not observed` | missing `adManager` | No allowlisted GAM identifier was captured. | +| `Ad Manager response class: Not observed` | missing `responseClass` | GPT did not provide enough render evidence to classify the response. | +| `Backfill yes/no` | `isBackfill` | GPT's callback value. | +| `Slot content changed yes/no` | `slotContentChanged` | GPT's callback value; not proof pixels changed. | +| `Creative changed/unchanged` | retained GAM creative IDs | Comparison only when both cycles supplied an ID. | + +The unprefixed `Bound`, `Unbound`, and `Ambiguous binding` variants appear in Technical details only when a slot has no request cycle. Once request groups exist, binding appears only under Size and visibility. + +Binding reasons are `missing_slot_element_id`, `missing_element`, `duplicate_dom_id`, `dom_uniqueness_unverifiable`, and `duplicate_gpt_slot_id`. Other exact technical fact prefixes are `Replaced rendered request`, `Creative changed`, `Creative unchanged`, `Trusted Server creative request observed at`, and `Trusted Server markup response sent at`. + +## Failures, attribution, coverage, and retention + +Creative bridge labels are `Creative bridge failure: missing render source`, `Creative bridge failure: cache fetch failed`, `Creative bridge failure: invalid cache payload`, and `Creative bridge failure: response post failed`. They map directly to `missing_render_source`, `cache_fetch_failed`, `invalid_cache_payload`, and `response_post_failed`; they describe only the observed bridge step and contain no URL, markup, payload, or stack trace. + +Attribution issue labels map to `creative_request_without_slot`, `creative_request_without_cycle`, `creative_request_ambiguous_cycle`, `creative_request_on_empty_cycle`, `creative_attempt_capacity`, `creative_attempt_unknown`, `creative_attempt_expired`, and `creative_attempt_evicted`. Callback coverage separately counts `observed`, `matched`, `unmatched`, and `ambiguous` for `slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, `impressionViewable`, and `slotVisibilityChanged`. + +The V1 export remains additive and compatible: optional Prebid evidence and optional currency fields are new; existing fields keep their meanings. Bounds are 64 slots, ten request cycles per slot, 128 callback issues, 128 attribution issues, 64 direct associations, 16 requested sizes, and 128 creative attempts. Metadata reports dropped callbacks/issues and evicted slots/cycles. + +## Missing-value vocabulary + +- **Not observed**: the relevant callback or explicit evidence was not captured. +- **Unavailable**: the value cannot be calculated or safely retained. +- **Not applicable**: the fact does not apply, for example delivery conclusions before render or for an explicitly empty result. +- **Unknown**: evidence exists but is insufficient to select a more specific state. + +## Acronyms + +- **TS**: Trusted Server. +- **SSAT**: server-side ad targeting on the initial page request. +- **SPA**: single-page application. +- **GPT**: Google Publisher Tag. +- **GAM**: Google Ad Manager. +- **Prebid**: the client-side header bidding library observed here. +- **PBS**: Prebid Server. +- **PUC**: Prebid Universal Creative. +- **CPM**: cost per thousand impressions; diagnostics retains only a bucketed targeting value, never raw CPM. +- **T0**: the start of one server request's `RequestTimings` clock. diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 445eda7873..2cb77e23c7 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -9,9 +9,8 @@ GPT Runtime Diagnostics is an opt-in browser console for documented Google Publisher Tag (GPT) lifecycle callbacks and Trusted Server integration evidence. -It groups observations into per-slot request cycles, shows timings and source-neutral -GAM facts, binds slots to exact DOM elements, and downloads the same allowlisted data -as versioned JSON. +It groups observations into per-slot request cycles, shows auction and GPT timings, +links each accessible page badge to the same stable `Ad #N` and `Request #M` in the side panel, binds slots to exact DOM elements, and downloads the same allowlisted data as versioned JSON. See the complete [label dictionary](./gpt-diagnostics-dictionary.md) for every operator-facing term. The console reports positive observations, not inferred ownership. A filled result means only that GPT emitted `slotRenderEnded` with `isEmpty === false`. A Trusted @@ -322,8 +321,12 @@ Visible, Filled, Empty, Pending/Incomplete, and Unbound/Ambiguous slots. Each request cycle can show: +- The same stable `Ad #N` and `Request #M` used by the request's page badge. - The observed request path, request-intent ID, and direct Trusted Server opportunity. +- Explicit auction classification: initial-page server, SPA server, completed client-side Prebid, multiple observed paths, or not observed. - Opaque Trusted Server auction-ID correlation and opportunity-to-request latency when available. +- Source-scoped server-auction winner, Prebid targeting candidate, and documented Prebid win observations. None is presented as the final served creative. +- Server-measured auction dispatch, resolution, commit, and wait timing when available. - Observed replacement of an earlier retained filled render, including GPT creative-ID transitions. - Requesting, Response received, Filled, Empty, or Rendered (fill unknown) GPT lifecycle state. @@ -331,9 +334,11 @@ Each request cycle can show: - Safe, deduplicated creative-bridge failure categories. - Source-neutral GAM response class and identifiers reported by GPT. - GPT slot-onload, impression-viewable, and visibility observations. -- Non-negative request-to-response, response-to-render, render-to-load, and +- Non-negative GAM request-to-response, GAM response-to-render, render-to-load, and render-to-viewable durations. -- GPT-reported rendered size, a separately labelled observed outer slot box when safely bound, backfill, and slot-content-change facts. +- GPT-reported rendered size, a separately labelled `Size filled` outer-slot measurement + when safely bound, backfill, and slot-content-change facts. GPT's ubiquitous `1×1` + placeholder stays in the JSON evidence but is hidden from the panel and badge. - Current DOM binding status and viewport intersection. Elapsed time alone never changes a pending GPT request to Incomplete. Incomplete @@ -342,6 +347,68 @@ step. When `slotRenderEnded` omits `isEmpty`, the result stays Rendered (fill un and `responseClass` remains absent. `unclassified_non_empty` requires an explicit `isEmpty === false` observation. +## Auction Classification and Timing + +Auction labels describe the path observed for that GPT request cycle: + +| Label | Meaning | +| --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| SSAT: initial-page server auction | Explicit completed initial-document server-auction evidence populated the direct request. | +| TS auction: SPA server auction | Explicit completed `/_ts/page-bids` server-auction evidence populated the direct request. | +| Client-side Prebid auction | A completed Prebid attempt was joined by exact auction ID, ad-unit code, GPT slot, and next request. | +| Multiple auction paths observed | Server and completed client-side evidence were both observed. This does not prove a race or winner. | +| Auction not observed | Explicit completed-auction evidence was absent or malformed. It never defaults to SSAT. | + +Publisher refresh evidence does not by itself establish another auction. It remains +visible in the request-path classification but does not turn an SSAT, TS auction, or +client-side auction label into `Multiple auction paths observed`. Publisher-only refreshes and +unattributed requests have no auction label because the available evidence does not +establish an auction implementation. + +For synthetic refresh auctions, diagnostics correlates the auction ID supplied by +Prebid's own `bidsBackHandler`; it does not provide or override Prebid auction IDs. The +`bidWon` listener, targeting reads, and bounded correlation state are installed only +when an active diagnostics recorder exists, so an inactive console does not change +Prebid-visible behavior. + +Server auction timing and browser GPT timing use separate clocks and are never +subtracted from each other. Both initial SSAT and SPA TS auction offsets use the +adapter's server-request clock. Initial offsets belong to the document request, while +SPA offsets belong to the later `/_ts/page-bids` request. Diagnostics retain that +request origin separately from the aggregate auction classification, so a request marked +`competing` still labels SPA offsets from the SPA page-bids request T0. Neither server +clock is the browser's navigation clock. The server facts are: + +- `auctionDispatchedMs`: bid dispatch offset from that timing origin. +- `auctionResolvedMs`: offset when auction collection completed, including timeout handling. +- `auctionCommittedMs`: offset when winning bids were available to page state. +- `auctionWaitMs`: actual time blocked in auction collection. +- `auctionWaitPlacement`: whether that wait occurred before response headers or while + the document response was streaming. + +The SPA page-bids response includes these server timings only for an activated +console session and a successfully dispatched auction. Failures before any provider +dispatch do not emit timing evidence. The activation cookie is still removed before +auction and publisher processing; the server retains only the request-scoped activation +decision needed to gate this diagnostics field. + +Browser timings have different boundaries. **GAM request → response** starts at GPT's +`slotRequested` callback and ends at `slotResponseReceived`. **GAM response → render** +starts at `slotResponseReceived` and ends at `slotRenderEnded`. “Render” therefore +means that GPT emitted its render-ended callback; it does not prove inner-iframe pixels +executed or became visible. + +```mermaid +flowchart TB + A[Server timing origin] --> B[Auction dispatched] + B --> C[Auction collected] + A -->|Mediator-only collection| C + C --> D[Bids committed to page state] + E[GPT slotRequested] --> F[GPT slotResponseReceived] + F --> G[GPT slotRenderEnded] + G --> H[GPT slotOnload / impressionViewable] +``` + ## Request Paths Request-path labels describe integration paths observed immediately before one GPT @@ -374,9 +441,19 @@ synchronous diagnostics context while delegating their own refresh, so a nested context even when the delegated refresh throws, and the Prebid wrapper restores the exact prior value. Diagnostics never suppresses or changes a GPT request. +The always-served Prebid shim calls one optional completed-auction recorder hook +with the exact Prebid instance and targeted slots. Targeting reads, `bidWon` +subscription and bounded correlation state live in the separate diagnostics bundle. +Without that runtime, the shim does not install diagnostics listeners or read +candidate targeting. Runtime destruction removes its own listener when `offEvent` +is available; otherwise its listener becomes inert and releases retained recorder +state. + For a direct observation, the optional opaque auction ID is retained only after -trimming to a non-empty value no longer than 256 UTF-8 bytes. No auction payload, -targeting map, bid price, markup, network body, or stack trace is exported. The +trimming to a non-empty value no longer than 256 UTF-8 bytes. Winning bidder names are +limited to 128 UTF-8 bytes, and numeric price-bucket strings to 64 bytes. The console +exports only those winning-bid fields and the bounded timing object—not a raw auction +payload, targeting map, exact unbucketed CPM, markup, network body, or stack trace. The reported opportunity-to-request duration is browser-observed only and is omitted for invalid or negative timing. @@ -394,8 +471,8 @@ For the direct path, `adInit` records one opportunity: | `unrenderable_candidate` | Bid targeting was applied, but the current bridge lacked the complete ID/render-source combination needed to serve markup. | | `no_candidate` | `adInit` explicitly observed no direct Trusted Server bid targeting for that configured slot. | -An absent opportunity is displayed as unknown. It must not be converted into a -negative demand-source conclusion. +An absent opportunity is displayed as `Not observed`. It must not be converted into +a negative demand-source conclusion. ## Trusted Server Evidence Ladder @@ -415,15 +492,15 @@ that acknowledgement is outside the zero-publisher-change design. The derived `delivery` value uses these evidence-safe meanings: -| Delivery state | Panel wording | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `trusted_server_response_sent` | Trusted Server selected; markup response sent to PUC | -| `trusted_server_selected` | Trusted Server selected; no markup response confirmed | -| `candidate_unconfirmed` | Trusted Server candidate unconfirmed — another GAM result or a creative/bridge failure is possible | -| `no_candidate` | adInit observed no direct Trusted Server candidate for this request | -| `unknown` | Delivery status unknown — required GPT or direct-candidate evidence was not observed | -| `pending` | Waiting for Trusted Server creative evidence | -| `not_applicable` | No delivery conclusion is displayed before render or for an explicitly empty result, provided no Trusted Server creative evidence was stamped on the cycle. | +| Delivery state | Panel wording | +| ------------------------------ | ------------------------------------------------------------------ | +| `trusted_server_response_sent` | Creative markup sent; execution not confirmed | +| `trusted_server_selected` | Server bid selected by the creative bridge; response not confirmed | +| `candidate_unconfirmed` | Server bid available; selection not confirmed | +| `no_candidate` | No direct Trusted Server candidate | +| `unknown` | Delivery status unknown — required evidence was not observed | +| `pending` | Waiting for Trusted Server creative evidence | +| `not_applicable` | Delivery evidence: Not applicable | For an explicit non-empty candidate, diagnostics wait five seconds from `slotRenderEnded` for positive creative evidence. If no matched PUC request arrives, @@ -548,11 +625,13 @@ A concise viewport badge appears only when a slot: - Has a unique, connected exact binding. - Has a non-zero rectangle intersecting the viewport. -A badge summarizes the slot's most recent request cycle: the GPT result (Filled, Empty, -Rendered (fill unknown), or Pending), a short delivery label, a `Competing paths` -marker when the request path is `competing`, the rendered size, and the request-to- -response, response-to-render, and render-to-viewable durations that are available. It -adds `Incomplete sequence` when a callback proved a missing or invalid earlier step. +A badge starts with the slot's stable `Ad #N`, matching the side-panel heading. It then +summarizes the most recent request cycle: the GPT result (Filled, Empty, Rendered (fill +unknown), or Pending), auction type, a short delivery label, a `Competing paths` marker +when the request path is `competing` and the auction type is not already `Multiple paths`, +sizes, and the GAM request-to-response, GAM +response-to-render, and render-to-viewable durations that are available. It adds +`Incomplete sequence` when a callback proved a missing or invalid earlier step. Badge delivery labels are the same derived states the panel and export report, shortened to fit: @@ -593,10 +672,11 @@ becomes unbound or ambiguous, the last successful sample remains as request-cycl evidence while the binding status reports the current DOM state. It is displayed separately from `size`, which remains the exact GPT-reported `slotRenderEnded.size` fill-size fact. The panel labels the three separate facts as requested slot sizes, -GPT-reported fill size, and observed outer slot box; the badge abbreviates them as -`Req`, `Fill`, and `Box`. The observed box may differ from GPT's reported size (for -example, a flexible APS creative can report `1×1` while its allocated outer slot box -is larger). The measurement describes publisher-page layout, not universal internal +GPT-reported fill size, and `Size filled`; the badge abbreviates the first two as `Req` +and `Fill` and uses `Size filled` for the observed outer box. The observed box may +differ from GPT's reported size. A flexible APS creative, for example, can report GPT's +uninformative `1×1` placeholder while its allocated outer slot box is larger; the UI +suppresses that `1×1` text but the export retains the original `size: [1, 1]` evidence. The measurement describes publisher-page layout, not universal internal creative-pixel dimensions. A collapsed or hidden bound element can report `0×0`, which records the page layout state rather than an invalid measurement. Empty, unbound, missing, or ambiguous slots do not report an observed box; delayed measurements from an older cycle @@ -607,14 +687,19 @@ content or altering the APS sandbox, so this field cannot prove the inner creati pixels. Badges and the panel live in a closed Shadow DOM. Diagnostics do not add attributes, -classes, or inline styles to publisher slot elements. +classes, or inline styles to publisher slot elements. Badge evidence is noninteractive +and passes pointer input through. Only the small native `Ad #N` button is clickable. +It is positioned outside the bound creative rectangles observed by diagnostics and +inside the viewport. If no such position fits, the button is hidden while the panel +remains available. The button supports normal keyboard focus and activation. ## Presentation Lifecycle - **Collapse** reduces the panel while preserving capture. - **Close** dismisses the presentation for the current document. - External removal by hydration or DOM reconciliation triggers a debounced remount. -- Live re-renders preserve open request-history disclosures and panel scroll position. +- Live re-renders preserve open request-history, help, and technical disclosures plus panel scroll position. +- Selecting the latest request focuses its current details without opening history. Selecting an earlier retained request opens its history once. A later user collapse remains closed across live updates. - Explicit Close or `hide()` prevents remount until `show()` is called. - Capture continues while the panel is hidden. @@ -658,8 +743,17 @@ The existing GPT-only `snapshot()` and `export()` model contains: - Retained slots, binding facts, visibility, and request cycles. - `requestedSlotSizes` when Trusted Server supplied configured formats for that exact request, plus GPT-reported fill `size` and an optional observed outer `observedSlotSize`. -- Request path, request intent ID, opportunity, creative-progress timestamps, and - safe failure enums. +- Request path, auction type, request intent ID, opportunity, creative-progress + timestamps, and safe failure enums. +- The bounded winning bidder and bucketed price plus server auction timing fields and + their `navigation` or `spa_auction` origin when direct auction evidence was observed. +- Completed client-side Prebid evidence (`prebidAuction`): Prebid's opaque `auctionId`, + plus bounded bidder and bucketed price fields in `targetingCandidate` and `win`, + when a completed attempt and any documented `bidWon` observation were correlated + to that exact request. Neither candidate nor win evidence proves the final served bidder. +- Optional `currency` on server winner, Prebid candidate, and Prebid win evidence, + when supplied through the internal recorder. Values are normalized to three uppercase + ASCII letters; first-party integrations do not populate this field. - The per-auction diagnostics token (`trustedServerAuctionId`) and the opportunity-to-request duration, when a direct opportunity was observed. - Replacement facts for a re-rendered slot: `replacedRequestNumber`, @@ -670,9 +764,11 @@ The existing GPT-only `snapshot()` and `export()` model contains: - Separate callback issues, attribution issues, coverage counters, and retention counters. -It does not contain raw targeting, bid IDs, bid prices, bidder identity, creative -markup, cache URLs, cache payloads, cache or bridge error details, cookies, user -identifiers, query strings, or URL fragments. The exported `trustedServerAuctionId` +It does not contain raw targeting, bid IDs, exact unbucketed bid prices, losing bid +lists, creative markup, cache URLs, cache payloads, cache or bridge error details, +cookies, user identifiers, query strings, or URL fragments. It does contain the server +winner and the Prebid candidate and win bidder names with their bucketed `hb_pb` values +as described above, even when the final served bidder is unconfirmed. The exported `trustedServerAuctionId` is the `hb_auction_id` value described in [Auction correlation token](#auction-correlation-token): minted fresh for each server-side auction, not derived from the Edge Cookie ID or any other visitor @@ -680,6 +776,12 @@ identifier, and never repeated across auctions, so it cannot be joined back to a visitor. Diagnostics retain it only after trimming to a non-empty value of at most 256 UTF-8 bytes. +The exported `prebidAuction.auctionId` is supplied by Prebid for auction correlation, +not visitor identity. Diagnostics does not generate or replace it and retains only +non-empty values of at most 256 UTF-8 bytes. Bidder names are limited to 128 UTF-8 +bytes and numeric price bucket strings to 64 bytes. These bounds limit retained data; +they do not establish how a publisher's Prebid configuration generated its auction ID. + The GPT-only observation store remains memory-only. Its capture and `export()` do not add an upload, diagnostics network request, `localStorage`, `sessionStorage`, IndexedDB, or other persistence. `export()` creates only the user-requested local JSON download; it sends nothing to a @@ -689,6 +791,8 @@ inaccessible to JavaScript. The explicit mobile handoff separately writes the bounded combined trace report to `sessionStorage`. Its stricter GPT projection excludes the exact pathname, GAM identifiers and `previousCreativeId`, as described under [Mobile Trace Page](#mobile-trace-page). +It also excludes the GPT-only auction classification, winner, Prebid candidate and win, +and server timing fields. Server auction trace evidence retains its separate v1 schema. ## Timing and Retention Bounds @@ -706,8 +810,9 @@ GAM identifiers and `previousCreativeId`, as described under [Mobile Trace Page] - Retained attribution issues: 128. The least-recently-active slot is evicted when the slot bound is exceeded. An evicted -GPT slot can re-enter retention only after a future `slotRequested`; request numbers -remain monotonic. The oldest request cycle or issue is removed at its own bound. +GPT slot can re-enter retention only after a future `slotRequested`; its `Ad #N` identity +and request numbers remain stable and monotonic for the console lifetime. The oldest +request cycle or issue is removed at its own bound. Export metadata reports `evictedSlots`, `evictedRequestCycles`, `droppedCallbacks`, and `droppedAttributionIssues`. diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index a4ad08b93a..d7ae4aa344 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -1,10 +1,13 @@ -# Auction Telemetry +# Auction and access telemetry -Trusted Server can emit bounded auction observations directly to Tinybird. -Emission is currently wired only by the Fastly adapter. Other adapters use the -core no-op sink even if they can parse the shared settings. +Trusted Server can emit bounded auction observations and sampled request-timing +rows directly to Tinybird. Both sinks are opt-in and currently wired only by the +Fastly adapter. Axum can emit the `Server-Timing` response header, but not Tinybird +rows. Cloudflare and Spin collect timings for GPT diagnostics only; their +`server_timing_enabled` and `tinybird.access_enabled` settings have no effect on +header or access-row emission. -## Data path +## Auction data path The request entry point derives coarse `DeviceSignals`. Auction execution combines those signals with request and navigation facts in an @@ -19,7 +22,7 @@ after dispatch; it does not wait for Tinybird's response. Each batch is limited to 512 rows and `tinybird.max_body_bytes` bytes. The Fastly backend uses two-second first-byte and between-byte timeouts. -## Privacy boundary +## Auction privacy boundary Telemetry records operational auction facts, not the EC identifier. The event model includes coarse device classification, auction source and outcome, @@ -33,19 +36,133 @@ clients produce no value. Edge, Opera, and iOS-specific tokens are matched before generic Chrome or Safari tokens so embedded UA tokens do not misclassify them. -## Configuration +## Access data path -Set `tinybird.enabled = true`, provide the regional API host, select the -auction datasource, and set `auction_token_secret` to the key holding that -datasource's APPEND token in the default app-config secret store. The default -datasource is `auction_events_raw`; the default body limit is 1 MiB and the -minimum accepted limit is 1 KiB. +Fastly attaches one request clock, records phases, and stamps the headers-ready +boundary after applying terminal response effects. Access sampling is uniform per +response: one random draw is made before constructing the snapshot, using the +configured `access_sample_rate`. Each selected response emits one row after its +buffered response has been handed to the client transport or its stream has been +finished. The row stores the same sample rate; joins to auction rows therefore +represent only the sampled access population. -`tinybird.access_enabled = true` is rejected because no access-log emitter is -wired. `access_dataset` and `access_sample_rate` are reserved. -`access_token_secret` is deprecated input and is normalized away; it cannot -enable access telemetry. +```mermaid +flowchart TB + A[Request clock and recorded phases] --> B[Terminal response effects] + B --> C[Headers-ready boundary and sampling] + C --> D[Capture selected response dimensions] + D --> E[Send or finish client response] + E --> F[Dispatch one access row] + F --> G[Fastly host continues ingest after callback return] +``` + +Auction-wait duration measures the actual collection interval after a provider or +mediator request started. `/auction` and `/_ts/page-bids` record this interval with +`pre_header` placement. Publisher HTML records `pre_header` or `in_stream` according +to where collection runs. A measured wait under one millisecond is `0`, with its +placement retained. Empty plans, routing-only or immediate outcomes with no network +work, and failures before collection leave wait timing null with `none` placement. + +`auction_dispatched_ms` records provider dispatch only. Mediator-only collection +can therefore have a wait, `auction_collected_ms` and commit offset while provider +dispatch is null. A configured mediator alone is not launch evidence; a pending +request or explicit started-request error is required. Instrumentation does not +start new mediator requests on paths that otherwise return without dispatch. +Page-bids browser diagnostics use the same measured wait, not R minus D. Browser +wire fields retain the name `auctionResolvedMs`; the persisted access column is +`auction_collected_ms` to describe the collection-return point accurately. + +Emission is best effort. Serialization, body-size, backend-registration and request +start failures are logged without changing the delivered response. Like the auction +sink, access telemetry starts `send_async` and drops the pending handle. Fastly's +host continues transmission after the callback returns, so a reusable sandbox does +not wait for the ingest reply. Dispatch preparation still costs work. Ingest is not +included in `request_elapsed_ms`. + +Successful dispatch is not confirmed ingestion. Later transport/TLS failures, +non-2xx responses and quarantined rows are unobserved by the emitter; there is no +retry or durability guarantee. Check workspace ingestion and quarantine during +rollout. The SDK lifetime contract was exercised with delayed local Viceroy backends, +including sandbox reuse, not a production Tinybird deployment. + +## Access privacy boundary + +The new `access_logs_v2` datasource has a configured 30-day TTL. Its 31 columns +contain: + +- Event timestamp, normalized HTTP method, response status, and sampling rate. +- Fastly service ID, publisher domain, deployment environment, POP, country, and + `ts_version`, which matches the Trusted Server build identifier in `x-ts-version`. + It uses the build-pipeline override, exact Git tag, branch name, then six-character + commit SHA, in that order; absent metadata is `unknown`. It is not the numeric + Fastly activation version from `x-ts-fastly-version`. A branch name is not an + immutable source fingerprint. +- Coarse route class and bounded route template, body mode, and template-cache + state. Publisher paths collapse to `/`, `/other/*`, or an operator-allowlisted + section such as `/news/*`; named routes retain parameter placeholders. Asset + routes report `/*`; responses without route metadata report + `unknown`. +- App-build, filter, geo, KV, origin, template-cache, auction-wait, and stream + durations, plus headers-ready and request-completion offsets and response bytes + accepted by the transport. When origin dispatch overlaps other work, `origin_ms` + measures only the remaining origin wait after overlap, not full origin latency. + A template-cache hit skips the origin span. Unrecorded timings are null. +- `delivery_result` is `complete`, `partial`, or `error`. It describes local body + handoff to the transport, not confirmed client receipt. `partial` means bytes + were accepted before a drive error, or transport finish failed; `error` means a + drive failed before any body byte was accepted. A status of 200 can accompany + either failure result. +- Auction wait placement, dispatch/collection/commit offsets, and a random auction + UUID. The UUID joins to `auction_events_raw.auction_id`, where coarse device + class and browser family can also be present. Disabled auctions have no access + UUID; active consent-denied observations retain their join key without launch + milestones. + +`appbuild_ms` measures Fastly per-request setup, including config-store opening and, +on cold requests, application construction. A warm sample can be non-null; this is +not a cold-start flag. Axum builds its state at startup and records no per-request +app-build phase. Adapter entry points start their clocks at different boundaries, +so compare timings within one adapter rather than treating their T0 values as equal. + +Access rows contain no raw path, query string, IP address, User-Agent, cookie, +EC ID, or creative content. Configuring a route section preserves that operator +value, so allowlist only content categories, not usernames or other identifiers. +The legacy `access_logs_raw` dataset is preserved separately, without backfilling +its raw paths into the new privacy-bounded dataset. + +## Configuration and rollout + +Both sinks require `tinybird.enabled = true` and the regional API host. +`auction_enabled` defaults to true for existing configurations. Set it to false +for access-only telemetry. The auction sink defaults to `auction_events_raw` and +requires that datasource's APPEND token key in `auction_token_secret`. + +To enable access telemetry, deploy `access_logs_v2` first, create its +`ts_access_v2_ingest` APPEND token, and store the token value in the platform's +default app-config secret store. Set `access_enabled = true`, +`access_dataset = "access_logs_v2"`, `access_token_secret` to the stored key name, +and a positive `access_sample_rate`, such as `0.05`. Token values are resolved +when runtime settings load; only key names belong in config. Existing configs +that explicitly name `access_logs_raw` must change their dataset and token before +enabling this sink. Validation rejects exactly the normalized `access_logs_raw` +name when access is enabled. Custom dataset names remain allowed, and disabled +legacy configurations remain accepted. The default body limit is 1 MiB and the +minimum accepted limit is 1 KiB; an individual row exceeding the configured limit +is dropped. + +Deploy the datasource before the emitting binary, then push the opt-in config +only after every serving binary understands it. Check ingestion freshness and +workspace quarantine before switching dashboards to the new datasource. Remote +deployment checks are required; source/schema alignment alone does not validate +a workspace cutover. + +Before rolling back to a pre-observability binary, disable access telemetry, +remove the non-default `[observability]` table, and re-push compatible config. An +access-only deployment must also disable the Tinybird master switch. Keep both +datasources during rollback; do not drop or recreate the legacy dataset or redeploy +an older Tinybird project that omits `access_logs_v2`. The exact settings and secret-handling dispositions are in -[Configuration](/guide/configuration#tinybird). The tracked Tinybird project is -described in [`tinybird/README.md`](https://github.com/IABTechLab/trusted-server/blob/main/tinybird/README.md). +[Configuration](/guide/configuration#tinybird). Token provisioning and the +versioned cutover are described in +[`tinybird/README.md`](https://github.com/IABTechLab/trusted-server/blob/main/tinybird/README.md). diff --git a/docs/superpowers/plans/2026-08-24-request-phase-timing.md b/docs/superpowers/plans/2026-08-24-request-phase-timing.md new file mode 100644 index 0000000000..2537801457 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-request-phase-timing.md @@ -0,0 +1,1044 @@ +# Request Phase Timing Implementation Plan + +> PR #1261 contract update: access v2 has 31 columns, including `delivery_result` +> and `auction_collected_ms`. `ts_version` is the compiled TS build identifier. +> Access ingestion is best-effort dispatch/drop, not a confirmed-ingest wait. +> The guide/spec and current tests define these contracts; historical unchecked +> steps do not prove deployment or committed-pin validation. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every application response attributes its own server time by phase via a +Server-Timing header and a sampled Tinybird access-telemetry row. + +**Architecture:** A core `RequestTimings` handle (Arc-shared, infallible recording) +collects phase spans always-on; the Fastly adapter freezes and emits at +`send_edgezero_response` immediately before `into_parts()`; a post-send emitter ships +one NDJSON row to the Tinybird Events API with a bounded, 2xx-validated await. + +**Tech Stack:** Rust 2024, `edgezero` HTTP types, Fastly Compute (wasm32-wasip1, +Viceroy tests), Axum (native tests), Tinybird Events API. + +**Spec:** `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md`: the +plan argues from the spec; executors read both. Spec section numbers are cited per +task. + +## Global Constraints + +- Errors use `error-stack` (`Report`); errors defined with + `derive_more::Display`; never thiserror, never anyhow (except the Spin entry point). +- No `unwrap()` in production code; `expect("should ...")` only. Assertion messages + `"should ..."`. Tests use Arrange-Act-Assert. +- No inline comments; comments on their own line above the code. +- Functions never exceed 7 arguments; use a struct instead (this bit + `ec_finalize_response` in review; the timings handle travels inside existing state). +- No local imports inside functions; `use super::*` only in `#[cfg(test)]`. +- Only example/fictional data in tests and docs (`example.com` domains). +- Recording is infallible: saturating math, lock failure drops the sample, no panics + (spec 5, 13). +- Vendor identity never appears in emitted surfaces: the filter span is `ts-filter` + (spec 3). +- Test commands: `cargo test-axum` (native, fast inner loop), `cargo test-fastly` + (Viceroy) for adapter tasks. Before PR handoff: the full CI gate list in + `CLAUDE.md`. +- Commit style: sentence case, imperative, no prefixes, no trailers. + +## File Structure + +| File | Responsibility | +| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/request_timing.rs` (new) | `Phase`, `AuctionWaitPlacement`, `RequestTimings`, `PhaseSpan`, `TimingSnapshot`, header rendering | +| `crates/trusted-server-core/src/access_telemetry.rs` (new) | `RouteClass`, `publisher_route_template`, `AccessTelemetrySnapshot`, `AccessEventRow` NDJSON | +| `crates/trusted-server-core/src/geo.rs` (modify) | `GeoLookupState` response-extension type | +| `crates/trusted-server-core/src/settings.rs` (modify) | `ObservabilitySettings`, tinybird flag decoupling, access validation | +| `crates/trusted-server-core/src/publisher.rs` (modify) | `ts-origin`, `ts-template-cache`, auction-wait spans | +| `crates/trusted-server-core/src/ec/kv.rs` (modify) | `ts-kv` at the graph abstraction | +| `crates/trusted-server-adapter-fastly/src/main.rs` (modify) | T0, appbuild span, freeze point, `DeliveryOutcome`, post-send emission ordering | +| `crates/trusted-server-adapter-fastly/src/app.rs` (modify) | filter span, geo span + `GeoLookupState` attach, route class assignment | +| `crates/trusted-server-adapter-fastly/src/middleware.rs` (modify) | finalize consumes `GeoLookupState` | +| `crates/trusted-server-adapter-fastly/src/tinybird.rs` (modify) | best-effort post-send access dispatch | +| `crates/trusted-server-adapter-axum/src/` (modify) | terminal freeze layer, header emission | +| `tinybird/datasources/access_logs_v2.datasource` (create) | versioned phase-column schema; preserve the legacy reserved datasource | +| `trusted-server.example.toml` (modify) | `[observability]`, tinybird keys | + +Out of scope for this plan: the Grafana dashboard JSON (separate telemetry repo, +spec 11) and Cloudflare/Spin emission wiring (spec non-goal). + +--- + +### Task 1: Core `RequestTimings` + +**Files:** + +- Create: `crates/trusted-server-core/src/request_timing.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` (add `pub mod request_timing;`) +- Test: same file, `#[cfg(test)]` + +**Interfaces:** + +- Consumes: nothing (leaf module; `std::time`, `std::sync`). +- Produces (later tasks rely on these exact names): + - `pub enum Phase { AppBuild, Filter, Geo, EcKv, Origin, TemplateCacheLookup, AuctionWait, Stream }` + - `pub enum AuctionWaitPlacement { PreHeader, InStream }` + - `#[derive(Clone)] pub struct RequestTimings` with: + - `pub fn new() -> Self` + - `pub fn record(&self, phase: Phase, dur: Duration)` (saturating accumulate) + - `pub fn record_auction_wait(&self, placement: AuctionWaitPlacement, dur: Duration)` + - `pub fn span(&self, phase: Phase) -> PhaseSpan` (records on drop) + - `pub fn mark_headers_ready(&self)` (first call wins) + - `pub fn mark_request_elapsed(&self)` (first call wins) + - `pub fn set_resp_bytes(&self, bytes: u64)` + - `pub fn server_timing_value(&self) -> Option` + - `pub fn snapshot(&self) -> TimingSnapshot` + - `pub struct TimingSnapshot { pub time_elapsed_ms: Option, pub request_elapsed_ms: Option, pub appbuild_ms: Option, pub filter_ms: Option, pub geo_ms: Option, pub kv_ms: Option, pub origin_ms: Option, pub template_cache_ms: Option, pub auction_wait_ms: Option, pub stream_ms: Option, pub auction_wait_placement: Option, pub resp_bytes: Option }` + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_omits_unrecorded_phases_and_orders_total_first() { + let timings = RequestTimings::new(); + timings.record(Phase::Filter, Duration::from_micros(9_100)); + timings.mark_headers_ready(); + let value = timings + .server_timing_value() + .expect("should render after mark_headers_ready"); + assert!( + value.starts_with("ts-total;dur="), + "should lead with ts-total: {value}" + ); + assert!(value.contains("ts-filter;dur=9.1"), "should render one decimal: {value}"); + assert!(!value.contains("ts-geo"), "should omit unrecorded phases: {value}"); + } + + #[test] + fn render_returns_none_before_headers_ready() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(1)); + assert!(timings.server_timing_value().is_none(), "should require the snapshot"); + } + + #[test] + fn repeated_phases_accumulate_saturating() { + let timings = RequestTimings::new(); + timings.record(Phase::Geo, Duration::from_millis(2)); + timings.record(Phase::Geo, Duration::from_millis(3)); + timings.mark_headers_ready(); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.geo_ms, Some(5), "should accumulate repeats"); + } + + #[test] + fn mark_headers_ready_is_first_call_wins() { + let timings = RequestTimings::new(); + timings.mark_headers_ready(); + let first = timings.snapshot().time_elapsed_ms; + std::thread::sleep(Duration::from_millis(5)); + timings.mark_headers_ready(); + assert_eq!(timings.snapshot().time_elapsed_ms, first, "should not restamp"); + } + + #[test] + fn span_guard_records_on_drop() { + let timings = RequestTimings::new(); + { + let _span = timings.span(Phase::Origin); + std::thread::sleep(Duration::from_millis(2)); + } + timings.mark_headers_ready(); + assert!( + timings.snapshot().origin_ms.expect("should record on drop") >= 1, + "should measure elapsed span time" + ); + } + + #[test] + fn auction_wait_records_placement() { + let timings = RequestTimings::new(); + timings.record_auction_wait(AuctionWaitPlacement::PreHeader, Duration::from_millis(40)); + let snapshot = timings.snapshot(); + assert_eq!(snapshot.auction_wait_ms, Some(40), "should record wait"); + assert_eq!( + snapshot.auction_wait_placement, + Some(AuctionWaitPlacement::PreHeader), + "should record placement" + ); + } + + #[test] + fn rendered_names_never_include_vendor_terms() { + let timings = RequestTimings::new(); + for phase in [Phase::AppBuild, Phase::Filter, Phase::Geo, Phase::EcKv, Phase::Origin, Phase::TemplateCacheLookup] { + timings.record(phase, Duration::from_millis(1)); + } + timings.mark_headers_ready(); + let value = timings.server_timing_value().expect("should render"); + assert!(!value.to_ascii_lowercase().contains("datadome"), "should mask vendors"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core request_timing` +Expected: compile FAIL, module does not exist. + +- [ ] **Step 3: Implement** + +```rust +//! Per-request phase timing collection and Server-Timing rendering. +//! +//! Collection is always-on and infallible: saturating math, lock failure +//! drops the sample, no panics. See the design spec +//! `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md`. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const PHASE_COUNT: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + AppBuild, + Filter, + Geo, + EcKv, + Origin, + TemplateCacheLookup, + AuctionWait, + Stream, +} + +impl Phase { + fn index(self) -> usize { /* match self -> 0..=7 */ } + + /// Header entry name; row-only phases return None. + fn header_name(self) -> Option<&'static str> { + match self { + Self::AppBuild => Some("ts-appbuild"), + Self::Filter => Some("ts-filter"), + Self::Geo => Some("ts-geo"), + Self::EcKv => Some("ts-kv"), + Self::Origin => Some("ts-origin"), + Self::TemplateCacheLookup => Some("ts-template-cache"), + Self::AuctionWait | Self::Stream => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuctionWaitPlacement { + PreHeader, + InStream, +} + +struct Inner { + t0: Instant, + phases: [Option; PHASE_COUNT], + headers_ready_total: Option, + request_elapsed: Option, + auction_wait_placement: Option, + resp_bytes: Option, +} + +#[derive(Clone)] +pub struct RequestTimings(Arc>); +``` + +Implementation notes (all bodies in this task, none deferred): + +- Every method takes `if let Ok(mut inner) = self.0.try_lock()` and silently + returns otherwise: contention and poison both drop the sample instead of waiting, + per the infallibility constraint. +- `record` accumulates with `saturating_add` semantics + (`Some(existing.saturating_add(dur))`). +- `mark_headers_ready` and `mark_request_elapsed` write `t0.elapsed()` only when the + slot is `None`. +- `server_timing_value` returns `None` unless `headers_ready_total` is set; renders + `ts-total` first from the stored snapshot, then the six header phases in enum order + with `{:.1}` millisecond formatting (`dur.as_secs_f64() * 1000.0`). +- `PhaseSpan { timings: RequestTimings, phase: Phase, started: Instant }`; `Drop` + calls `record(self.phase, self.started.elapsed())`. +- `TimingSnapshot` converts each `Duration` with + `u32::try_from(dur.as_millis()).unwrap_or(u32::MAX)`. +- `impl Default for RequestTimings` delegates to `new()`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum -p trusted-server-core request_timing` +Expected: all 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/request_timing.rs crates/trusted-server-core/src/lib.rs +git commit -m "Add RequestTimings phase collection and Server-Timing rendering" +``` + +--- + +### Task 2: Settings: `[observability]`, tinybird decoupling, access validation + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `trusted-server.example.toml` + +**Interfaces:** + +- Produces: + - `pub struct ObservabilitySettings { pub server_timing_enabled: bool }` as + `settings.observability`, `#[serde(default)]` on the field and + `#[serde(skip_serializing_if = "ObservabilitySettings::is_default")]`. + - `TinybirdSettings.auction_enabled: bool` (`#[serde(default = "default_true")]`). + - `prepare_runtime` validation: `access_enabled` requires `enabled`, non-empty + `api_host`, `secret_store`, `access_dataset`, `access_token_secret`, + `max_body_bytes >= 1024`, and `access_sample_rate > 0.0`. + +- [ ] **Step 1: Write the failing tests** (in `settings.rs` tests module) + +```rust +#[test] +fn observability_defaults_off_and_serializes_away() { + let settings = create_test_settings(); + assert!(!settings.observability.server_timing_enabled, "should default off"); + let toml = toml::to_string(&settings).expect("should serialize settings"); + assert!( + !toml.contains("[observability]"), + "should omit the default table so a prior binary can parse the config" + ); +} + +#[test] +fn access_enabled_requires_positive_sample_rate() { + // access_enabled = true with access_sample_rate = 0 is armed-but-silent: an error. + let err = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\naccess_enabled = true\naccess_sample_rate = 0.0\n", + ) + .expect_err("should reject armed-but-silent access telemetry"); + assert!(format!("{err:?}").contains("access_sample_rate"), "should name the field"); +} + +#[test] +fn access_and_auction_emission_are_independent() { + let settings = settings_from_toml_with( + "[tinybird]\nenabled = true\napi_host = \"api.example.com\"\nauction_enabled = false\naccess_enabled = true\naccess_sample_rate = 1.0\n", + ) + .expect("should accept access without auction"); + assert!(!settings.tinybird.auction_enabled, "should disable auction emission"); + assert!(settings.tinybird.access_enabled, "should enable access emission"); +} + +#[test] +fn auction_enabled_defaults_true_for_existing_configs() { + let settings = settings_from_toml_with("[tinybird]\nenabled = true\napi_host = \"api.example.com\"\n") + .expect("should parse a pre-decoupling config"); + assert!(settings.tinybird.auction_enabled, "should preserve current behavior"); +} +``` + +Also REPLACE the existing rejection test +(`tinybird_access_enabled_is_rejected_until_emitter_is_wired`, `settings.rs:4123`) +with a wiring test asserting a fully-specified access config is accepted. + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core observability access_enabled auction_enabled` +Expected: compile FAIL (`observability` field missing). + +- [ ] **Step 3: Implement** + +- Add `ObservabilitySettings` (derive `Debug, Clone, Default, PartialEq, Deserialize, +Serialize`, `#[serde(deny_unknown_fields)]`), with + `fn is_default(&self) -> bool { *self == Self::default() }`. +- Add the `observability` field to `Settings` with the serde attributes above. +- Add `auction_enabled` to `TinybirdSettings` with `default_true()`; update + `Default for TinybirdSettings`. +- Extend `TinybirdSettings::prepare_runtime` with the access validation matrix; error + messages name the failing field (`"tinybird.access_sample_rate must be > 0 when +access_enabled"` and so on). +- `trusted-server.example.toml`: add a commented `[observability]` block with + `server_timing_enabled = false` present-but-false and the env-override note (the + overlay cannot create a missing leaf), plus `auction_enabled`/access keys in the + tinybird section comments. +- Gate the auction sink: in `crates/trusted-server-adapter-fastly/src/app.rs`, + `auction_sink_from_settings` condition becomes + `settings.tinybird.enabled && settings.tinybird.auction_enabled`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum -p trusted-server-core` then `cargo test-fastly` (the sink gate +touches the Fastly adapter). +Expected: PASS, including the replaced wiring test. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/settings.rs trusted-server.example.toml crates/trusted-server-adapter-fastly/src/tinybird.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Add observability settings and decouple tinybird access and auction emission" +``` + +--- + +### Task 3: Fastly freeze point, header emission, `DeliveryOutcome` + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (entry T0, appbuild span, + `send_edgezero_response`) +- Test: `crates/trusted-server-adapter-fastly/src/app.rs` tests module (route-level + tests run under Viceroy) + +**Interfaces:** + +- Consumes: `RequestTimings`, `Phase` (Task 1); + `trusted_server_core::cache_policy::cache_control_headers_are_private_or_no_store`. +- Produces: + - `RequestTimings` inserted into request extensions at dispatch + (`core_req.extensions_mut().insert(timings.clone())`), alongside the existing + `config_store`/`device_signals`/`client_info` inserts. + - `send_edgezero_response(response, effects, timings) -> DeliveryOutcome` where + `pub(crate) struct DeliveryOutcome { pub bytes: u64, pub result: DeliveryResult }` + and `pub(crate) enum DeliveryResult { Complete, Error }` (streaming partial + detection lands in Task 6). + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn server_timing_emitted_on_private_response_when_enabled() { + // Arrange: settings with observability.server_timing_enabled = true; publisher + // route fixture whose response is Cache-Control: private, no-store. + // Act: dispatch through the full adapter path. + // Assert: + let header = response_header(&response, "server-timing").expect("should emit header"); + assert!(header.contains("ts-total;dur="), "should carry the stored total"); + assert_eq!( + header.matches("ts-total").count(), 1, + "should emit exactly one TS-owned metric set" + ); +} + +#[test] +fn server_timing_absent_when_flag_off() { /* same fixture, flag false: no ts-total */ } + +#[test] +fn server_timing_absent_on_cacheable_responses() { + // tsjs route (public, max-age=31536000, immutable) and a bare max-age=60 response: + // both must carry no ts-total even with the flag on. +} + +#[test] +fn preexisting_server_timing_values_survive() { + // Fixture response already carrying Server-Timing: upstream;dur=1 stays present + // alongside the appended TS set. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly server_timing` +Expected: FAIL, no header emitted. + +- [ ] **Step 3: Implement** + +In `edgezero_main` (`main.rs`): + +```rust +let timings = RequestTimings::new(); +{ + let _appbuild = timings.span(Phase::AppBuild); + // existing: open_trusted_server_config_store() + build_app_with_state() +} +``` + +Move the config-store open inside the span scope. Insert `timings.clone()` into +request extensions before dispatch. Thread the handle into both send sites and the +error paths by value (it is a cheap clone). + +In `send_edgezero_response`, immediately before `response.into_parts()`: + +```rust +timings.mark_headers_ready(); +let conclusively_private = + cache_control_headers_are_private_or_no_store(response.headers()); +if settings_enabled_server_timing && conclusively_private { + if let Some(value) = timings.server_timing_value() { + match HeaderValue::from_str(&value) { + Ok(header_value) => { + response.headers_mut().append(header::SERVER_TIMING, header_value); + } + Err(error) => log::warn!("skipping server-timing header: {error}"), + } + } +} +``` + +`settings_enabled_server_timing` arrives inside a small +`SendContext { timings: RequestTimings, server_timing_enabled: bool }` so the +function stays at or under seven parameters. Return `DeliveryOutcome` with per-mode +semantics: buffered bodies capture the byte count from the body length before +`send_to_client()` (which returns no delivery result) and report complete-on-return; +the streaming branch gains a counting writer in Task 6. Existing callers ignore the +outcome in this task (Task 8 consumes it). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo clippy-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/main.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Emit Server-Timing at the send freeze point on conclusively private responses" +``` + +--- + +### Task 4: Filter span and geo span with `GeoLookupState` dedupe + +**Files:** + +- Modify: `crates/trusted-server-core/src/geo.rs` (add `GeoLookupState`) +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` + (`run_pre_route_filters` wrapper, `build_ec_request_state` geo span + state attach) +- Modify: `crates/trusted-server-adapter-fastly/src/middleware.rs` and `main.rs` + (`resolve_geo_for_response` consumes carried state) + +**Interfaces:** + +- Consumes: `RequestTimings` from request extensions (Task 3). +- Produces: + - `pub enum GeoLookupState { NotAttempted, Attempted, Resolved(GeoInfo) }` in + `trusted_server_core::geo`, attached as a response extension on every exit path + that attempted a lookup (including the asset fallback). + - `resolve_geo_for_response` gains the carried state as input: live lookup only on + `NotAttempted`; `Attempted` is never retried; fallback lookups are wrapped in + `timings.span(Phase::Geo)`. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn finalize_reuses_request_phase_geo_without_second_lookup() { + // Counting geo stub: dispatch a publisher route; assert lookup count == 1 and + // x-geo-country still set on the response. +} + +#[test] +fn failed_lookup_is_not_retried() { + // Stub returns None once; assert GeoLookupState::Attempted carried and the + // finalize path performs zero further lookups. +} + +#[test] +fn asset_fallback_carries_geo_state_without_ec_finalize_state() { + // Asset route: response extension holds GeoLookupState, EcFinalizeState absent. +} + +#[test] +fn filter_span_recorded_when_request_filter_runs() { + // Registry fixture with a test request filter; assert snapshot().filter_ms is Some. +} + +#[test] +fn geo_lookup_skipped_for_unauthorized_responses() { + // Existing 401 rule preserved: no lookup, state NotAttempted. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly geo_ filter_span` +Expected: FAIL (lookup count 2; no `GeoLookupState`). + +- [ ] **Step 3: Implement** + +- `GeoLookupState` derives `Debug, Clone`; store in response extensions from the + dispatch layer right after `build_ec_request_state` resolves (or fails) its lookup. +- Wrap the `build_ec_request_state` lookup and any finalize fallback lookup in + `timings.span(Phase::Geo)` (accumulating slot handles the repeat case). +- Wrap `run_pre_route_filters` (`app.rs:751`) in `timings.span(Phase::Filter)`, + recording only when at least one filter is registered (skip the span when the + registry has no request filters, so the header omits `ts-filter` on unconfigured + deployments). +- `resolve_geo_for_response(response, carried: &GeoLookupState, client_ip, lookup)` + keeps the 401 short-circuit first, then matches the carried state. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo test-axum` +Expected: PASS including untouched existing geo header tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/geo.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/middleware.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Record filter and geo spans and dedupe the per-request geo lookup" +``` + +--- + +### Task 5: Core spans: origin, template cache, KV abstraction + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` (origin send ~4496, template + cache lookup ~4391 on current main) +- Modify: `crates/trusted-server-core/src/ec/kv.rs` (graph-level `ts-kv`) +- Test: `publisher.rs` and `ec/kv.rs` test modules + +**Interfaces:** + +- Consumes: `RequestTimings` read from request extensions inside + `handle_publisher_request`; `KvIdentityGraph` gains + `pub fn with_timings(self, timings: RequestTimings) -> Self` (builder-style, + optional field), set where the graph is constructed in `main.rs`. +- Produces: `origin_ms`, `template_cache_ms`, `kv_ms` populated in snapshots. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn origin_span_covers_the_publisher_fetch() { + // Stubbed origin with a small injected delay; assert snapshot().origin_ms is Some. +} + +#[test] +fn template_cache_span_recorded_only_when_lookup_runs() { + // Inline mode fixture: template_cache_ms None. Shared-mode eligible fixture: + // template_cache_ms Some. +} + +#[test] +fn kv_span_accumulates_across_graph_operations() { + // Stub KV recording two operations through a TimedKvStore-wrapped graph; assert + // kv_ms Some and covers both (accumulated, not last-write). +} + +#[test] +fn consent_store_reads_are_timed_and_pull_sync_is_not() { + // Consent read through the decorated RuntimeServices store: kv_ms Some. + // Pull-sync graph built from the untimed store: records nothing. +} + +#[test] +fn ec_finalize_kv_lands_before_freeze() { + // Adapter-level (test-fastly): EC-enabled fixture with eids cookies; assert the + // emitted header contains ts-kv, proving the freeze point sits after finalize. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core origin_span template_cache_span kv_span` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- `handle_publisher_request` reads the handle: + `let timings = req.extensions().get::().cloned().unwrap_or_default();` + (a defaulted handle records into nothing that ever renders, keeping non-adapter + tests unchanged). +- Origin: `let origin_span = timings.span(Phase::Origin);` immediately before + `services.http_client().send(platform_request).await`; `drop(origin_span)` when the + response headers are available (directly after the `match` arm binds the response). +- Template cache: same guard pattern around + `services.template_cache().lookup_or_reserve(key).await`. +- KV: add `TimedKvStore` (new type in `crates/trusted-server-core/src/platform/`), + a decorator implementing `PlatformKvStore` that wraps `Arc` + plus a `RequestTimings` handle and records `Phase::EcKv` around every trait + method. Every request-path `KvIdentityGraph` construction site (request setup, + identify, admin lookup, batch sync, finalization) receives the timed store; + consent-store access through `RuntimeServices` uses the same decorator; pull-sync + constructs its graph from the untimed store explicitly (add a test asserting the + pull-sync store records nothing). `ec_finalize_response` keeps seven arguments: + the handle rides inside the store the graph already receives. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum` then `cargo test-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/ec/kv.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Record origin, template cache, and KV phase spans in core" +``` + +--- + +### Task 6: Body-phase capture: stream, auction wait placement, bytes + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` (seam wait + buffered wait) +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (stream drive timing, + `DeliveryOutcome.bytes`) +- Test: `publisher.rs` tests + adapter tests + +**Interfaces:** + +- Consumes: `record_auction_wait` (Task 1), `DeliveryOutcome` (Task 3). +- Produces: `stream_ms`, `auction_wait_ms` + placement, `resp_bytes`, + `mark_request_elapsed()` called by the adapter immediately after the stream drive + returns. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn streaming_seam_wait_records_in_stream_placement() { + // Streaming fixture with a delayed auction: placement InStream, and + // stream_ms >= auction_wait_ms. +} + +#[test] +fn buffered_template_miss_records_pre_header_placement() { + // Shared-template authorized miss (buffered finalizer): placement PreHeader; the + // wait is recorded even though headers had not committed. +} + +#[test] +fn delivery_outcome_reports_bytes_and_request_elapsed_set() { + // Adapter: after send, snapshot has resp_bytes Some(body_len) and + // request_elapsed_ms Some; request_elapsed excludes post-send emitter time by + // construction (asserted by ordering test in Task 8). +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly seam_wait buffered_template delivery_outcome` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- Streaming path: around the `collect_stream_auction(...)` await inside the body + stream, measure with `Instant::now()` and call + `timings.record_auction_wait(AuctionWaitPlacement::InStream, waited)`. The handle + reaches the stream closure through `OwnedProcessResponseParams`/assembly params (it + is `Clone`; add a field). +- Buffered path (`buffer_publisher_response_async` and the shared-template miss + finalizer): same measurement with `AuctionWaitPlacement::PreHeader`. +- Adapter stream drive: wrap the `block_on(stream_asset_body(...))` region with a + counting writer that tallies bytes and observes truncation/error, record + `Phase::Stream` with the elapsed drive time, populate `DeliveryOutcome` with + bytes and Complete/Partial/Error, call `timings.set_resp_bytes(bytes)` and + `timings.mark_request_elapsed()` immediately after the drive returns, before + anything else post-send. Buffered responses keep the Task 3 complete-on-return + semantics; `body_mode` distinguishes the regimes in the row. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo test-axum` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Capture stream duration, auction wait placement, and response bytes" +``` + +--- + +### Task 7: `AccessTelemetrySnapshot`, route class, route template + +**Files:** + +- Create: `crates/trusted-server-core/src/access_telemetry.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` (RouteMetadata attach at + handler wrappers), `main.rs` (snapshot build at freeze point), + `crates/trusted-server-core/src/publisher.rs` (typed template-cache state + extension) + +**Interfaces:** + +- Consumes: `TimingSnapshot` (Task 1), `GeoLookupState` (Task 4). +- Produces: + - `pub enum RouteClass { PublisherHtml, Tsjs, IntegrationProxy, Ec, AuctionApi, Other }` + with `pub fn as_str(&self) -> &'static str` (snake_case values from the spec). + - `pub fn publisher_route_template(path: &str) -> String`: `/` plus first segment + filtered to `[a-z0-9_-]`, truncated to 32 chars, plus `/*` when deeper; empty or + disallowed first segments render `/other/*`. + - `pub struct AccessTelemetrySnapshot { pub method: String, pub status: u16, pub route_class: RouteClass, pub route_template: String, pub publisher_domain: String, pub env: String, pub service_id: String, pub pop: String, pub country: String, pub template_cache_state: String, pub body_mode: &'static str, pub sample_rate: f64 }` + - `pub fn access_event_row(snapshot: &AccessTelemetrySnapshot, timings: &TimingSnapshot, delivery_result: DeliveryResult, event_ts_epoch_ms: u64) -> String` (one NDJSON line). + +- [ ] **Step 1: Write the failing tests** (adversarial, per spec 9) + +```rust +#[test] +fn admin_ec_route_template_never_contains_the_identifier() { + // Named-route template comes from the route table: "/_ts/admin/ec/{id}". + // Assert a row built for that route never contains a 64-hex EC id fixture. +} + +#[test] +fn publisher_paths_normalize_to_coarse_templates() { + assert_eq!(publisher_route_template("/news/some-article-slug"), "/news/*"); + assert_eq!(publisher_route_template("/"), "/"); + assert_eq!( + publisher_route_template("/user@example.com/profile"), + "/other/*", + "should reject non-allowlisted characters" + ); + assert_eq!( + publisher_route_template(&format!("/{}", "a".repeat(500))), + format!("/{}", "a".repeat(32)), + "should bound segment length" + ); + assert_eq!(publisher_route_template("/search terms here"), "/other/*"); +} + +#[test] +fn row_serializes_nulls_for_missing_phases() { + // Sparse TimingSnapshot: absent phases serialize as JSON null, dimension fields + // never null (unknown sentinel). +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum -p trusted-server-core access_telemetry route_template` +Expected: compile FAIL. + +- [ ] **Step 3: Implement** + +Row serialization via `serde_json::json!` mapping spec section 9 column names exactly +(`time_elapsed_ms`, `appbuild_ms`, ..., `auction_wait_placement` as +`pre_header|in_stream|none`). `pop`/`service_id` read from Fastly env +(`FASTLY_SERVICE_ID`, `FASTLY_POP`), defaulting `"unknown"`; `env` derived by the +adapter from `FASTLY_IS_STAGING` (the `x-ts-env` input), never from `Settings`. +Route identity travels as a typed `RouteMetadata` response extension +(`pub struct RouteMetadata { pub route_class: RouteClass, pub route_template: String }` +in `access_telemetry.rs`): each named-route handler wrapper attaches its matched +route-table pattern verbatim, and the fallback and tsjs handlers attach their class +plus the coarse template; the freeze point consumes the extension (no `RouteClass` +column in `NAMED_ROUTES`, no reconstruction from a handler enum). Also in this task: +make `TemplateCacheResponseState` a typed response extension in `publisher.rs`, set +at every point that writes `x-ts-template-cache` so header and extension cannot +drift; the row reads the extension. The snapshot is built (when access +telemetry is enabled) in +`send_edgezero_response` right after `mark_headers_ready()` and returned inside +`DeliveryOutcome` (add field `pub snapshot: AccessTelemetrySnapshot`). + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum` and `cargo test-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/access_telemetry.rs crates/trusted-server-core/src/lib.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Add access telemetry snapshot, route classes, and coarse route templates" +``` + +--- + +### Task 8: Best-effort access dispatch and post-send ordering + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/tinybird.rs` (access sink) +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (post-send ordering) + +**Interfaces:** + +- Consumes: `AccessTelemetrySnapshot` + `access_event_row` (Task 7), settings flags + (Task 2), `DeliveryOutcome` (Tasks 3/6). +- Produces: `pub(crate) async fn emit_access_event(client: &FastlyPlatformHttpClient, target: &TinybirdEventsTarget, row: String) -> Result<(), Report>`, + starting the host-owned request with `send_async` after client delivery, + then dropping its pending handle. Serialization/setup/start errors are returned; + later transport, HTTP status and ingestion acceptance are unobserved. The transport context is adapter-owned and route-independent (target + derived from settings once at entry), so asset, admin, and error responses emit + without `RuntimeServices` or `EcFinalizeState`. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn access_emitter_dispatches_ndjson_and_drops_without_waiting() { + // Assert exact URI/body/auth, one async start, no synchronous send or wait, + // and destruction of the returned pending handle. +} + +#[test] +fn access_emitter_reports_start_failure_without_retry() { + // An async-start failure returns Err; no retry or wait is attempted. +} + +#[test] +fn sampled_out_requests_emit_nothing() { + // access_sample_rate stub decision false: RecordingHttpClient sees zero requests. +} + +#[test] +fn post_send_order_is_elapsed_then_pull_sync_then_telemetry() { + // Instrumented stubs record call order; assert request_elapsed snapshot precedes + // pull-sync dispatch which precedes the telemetry send. +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-fastly access_emitter post_send_order` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- Reuse `TinybirdEventsTarget` with a second constructor + `from_access_config(config: &TinybirdSettings)` using `access_dataset` and + `access_token_secret`. +- Sampling decision: `fn sampled_in(rate: f64, roll: f64) -> bool` compares the + configured rate to one uniform random draw before snapshot construction. +- `main.rs` post-send, in order: `timings.mark_request_elapsed()` (already placed in + Task 6), existing pull-sync dispatch unchanged, then when + `settings.tinybird.enabled && settings.tinybird.access_enabled` and sampled in: + build the row from `outcome.snapshot` + `timings.snapshot()`, call + `emit_access_event`, log one warning on `Err`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-fastly` and `cargo clippy-fastly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/tinybird.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Dispatch access telemetry after pull-sync post-send" +``` + +--- + +### Task 9: Tinybird datasource schema + +**Files:** + +- Create: `tinybird/datasources/access_logs_v2.datasource` +- Preserve: `tinybird/datasources/access_logs_raw.datasource` + +**Interfaces:** + +- Consumes: column names exactly as serialized by `access_event_row` (Task 7). +- Produces: the deployed schema contract for the dashboard (separate repo). + +- [ ] **Step 1: Create the versioned schema** per spec section 9, with the + 31 producer fields and JSONPaths, non-nullable dimension columns, nullable + phase/milestone columns, and a 30-day TTL. Use + `ENGINE_SORTING_KEY "toDate(event_ts), service_id, publisher_domain, env, route_class, pop, status"`. + Keep the legacy nine-column datasource unchanged. No `FORWARD_QUERY` or + historical raw-path backfill is part of this cutover. + +- [ ] **Step 2: Validate** with the tinybird toolchain if available locally + (`tb check` / project tests under `tinybird/tests`); otherwise assert the file + parses by review and rely on rollout step 4's remote verification. Add a fixture row + in `tinybird/fixtures` matching `access_event_row` output. + +- [ ] **Step 3: Commit** + +```bash +git add tinybird/datasources/access_logs_v2.datasource tinybird/fixtures +git commit -m "Add the versioned access timing datasource" +``` + +--- + +### Task 10: Axum adapter emission + +**Files:** + +- Modify: `crates/trusted-server-adapter-axum/src/` (terminal layer at the response + serialization boundary; locate the equivalent of the Fastly send path) +- Test: axum adapter tests (`cargo test-axum`) + +**Interfaces:** + +- Consumes: `RequestTimings`, header emission helper. Extract the emission block from + Task 3 into a shared core helper so both adapters call one function: + `pub fn append_server_timing_if_private(response: &mut Response, timings: &RequestTimings, enabled: bool)` + in `request_timing.rs` (move the Fastly inline logic here and re-point Task 3's call + site). +- Produces: Axum responses carry the header under the same conservative predicate; + `ts-appbuild` absent by construction (state built at startup); router-generated + 404/405 covered by the terminal layer; `/health` excluded by route match. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn axum_emits_header_on_private_response() { /* flag on, private response: ts-total present, ts-appbuild absent */ } + +#[test] +fn axum_404_carries_header_when_private() { /* router-generated 404 passes through the terminal layer */ } + +#[test] +fn axum_health_is_excluded() { /* /health: no ts-total */ } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test-axum axum_emits axum_404 axum_health` +Expected: FAIL. + +- [ ] **Step 3: Implement** an outer service wrapper around the `RouterService` + inside `AxumDevServer` (not router middleware, which router-generated 404/405 + responses bypass and which returns before body serialization): create + `RequestTimings::new()` per request in the wrapper, insert into request + extensions, and on the wrapper's response side call `mark_headers_ready()` + + `append_server_timing_if_private(...)`, skipping the `/health` path by match. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test-axum` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-axum/src crates/trusted-server-core/src/request_timing.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Emit Server-Timing from the Axum terminal layer with adapter-specific semantics" +``` + +--- + +### Task 11: Full gate, docs, and PR + +- [ ] **Step 1: Docs.** Add a short operator section to `docs/guide/configuration.md`: + the `[observability]` flag, the tinybird access keys, the deploy/rollback ordering + from spec section 12 (binary first, config second; config first on rollback), and + the conservative emission rule. Run `cd docs && npm run format`. + +- [ ] **Step 2: Full CI gate list** from `CLAUDE.md`: + `cargo fmt --all -- --check`; all six clippy aliases; `test-fastly`, `test-axum`, + `test-cloudflare`, `test-spin`; the integration-tests parity suite; JS build/test + and formats. Cloudflare/Spin compile the new core modules (collection only), which + is exactly what the non-goal requires. + +- [ ] **Step 3: Commit docs, push the branch, open the implementation PR** referencing + the spec PR #1069 and issue #1068, with the rollout section of the spec quoted as + the deployment checklist (staging pass-through + MISS/HIT replay before production + flag-on). + +--- + +## Self-Review + +- Spec coverage: sections 5 (Task 1), 12 (Task 2), 7 (Tasks 3, 10), 8/8a (Tasks 4, + 10), 6 (Tasks 4-6), 9 (Tasks 7, 9), 10 (Task 8), 13 (Tasks 1, 3, 8), 14 (test + steps throughout), 15 steps 1-4 (Task 11 + deployment checklist). Section 11 + (dashboard) is explicitly out of scope for this repo's plan. +- Type consistency: `RequestTimings`/`TimingSnapshot`/`RouteClass`/ + `AccessTelemetrySnapshot`/`DeliveryOutcome` names and signatures match across + Tasks 1, 3, 6, 7, 8, 10. +- Known intentional deferral: `DeliveryResult::Partial` detection is named in Task 3 + and wired when the stream drive reports bytes in Task 6; no other deferrals. diff --git a/docs/superpowers/plans/2026-08-26-auction-timeline-offsets.md b/docs/superpowers/plans/2026-08-26-auction-timeline-offsets.md new file mode 100644 index 0000000000..4b5b518c48 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-auction-timeline-offsets.md @@ -0,0 +1,89 @@ +# Auction Timeline Offsets Implementation Plan + +> PR #1261 contract update: the persisted collection field is `auction_collected_ms`. +> Internal Rust and browser `resolved` names remain unchanged. Collection wait uses +> actual provider or mediator launch evidence, while provider dispatch stays null +> for mediator-only work. Historical checkboxes below are not evidence that the +> current committed Fastly dependency or a live deployment has been validated. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Record three T0-anchored auction milestones (dispatched, resolved, committed) plus the auction id on `RequestTimings`, and emit them as four auction columns on the versioned `access_logs_v2` row. + +**Architecture:** Follows spec section 18. All state lives in the existing `RequestTimings` inner (same `try_lock`/first-call-wins/saturating model as `mark_headers_ready`); the row builder reads the values from `TimingSnapshot`, so no new emission path and no adapter changes. + +**Tech Stack:** Rust (core crate only), Tinybird datasource and fixture files. + +**Spec:** `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md` section 18. + +## Global Constraints + +- Marks are first-call-wins; `try_lock` only; a contended lock drops the sample, a poisoned lock is recovered (matching every other write on the collector). +- A null offset means "this milestone was not reached", not "no auction ran". `auction_id` is what separates the two: null id means no auction was attempted; a non-null id with a null dispatch offset means no provider launch, but mediator-only collection may still have occurred; a non-null dispatch offset with a null resolve offset means dispatched and never collected. +- The id is stamped where an active-auction observation is built, not on dispatch, so active skipped and dispatch-failed auctions stay joinable. Disabled auctions have null access UUIDs on all three routes. +- Column names: `auction_dispatched_ms`, `auction_collected_ms`, `auction_committed_ms`, `auction_id`; JSONPaths `json:$.`. The new versioned datasource has no `FORWARD_QUERY`; the legacy dataset is preserved. +- `auction_id` is `Nullable(UUID)`, matching `auction_events_raw.auction_id` so the join needs no cast and no sentinel. +- All three `AuctionSource` variants are instrumented: `InitialNavigation`, `SpaNavigation` (`/_ts/page-bids`), and `AuctionApi` (`POST /auction`). +- No header emission and no config surface. Changes are confined to `trusted-server-core`, `tinybird/`, and the spec and plan documents. + +--- + +### Task 1: RequestTimings marks and snapshot fields + +**Files:** + +- Modify: `crates/trusted-server-core/src/request_timing.rs` + +**Interfaces:** + +- Produces: `set_auction_id(&self, auction_id: Uuid)`, `mark_auction_dispatched(&self)`, `mark_auction_resolved(&self)`, `mark_auction_committed(&self)`; `TimingSnapshot { auction_dispatched_ms, auction_resolved_ms, auction_committed_ms: Option, auction_id: Option, .. }` + +- [x] Add `auction_dispatched`, `auction_resolved`, `auction_committed: Option` and `auction_id: Option` to `Inner`; initialize `None`. +- [x] Add `set_auction_id` plus the three no-arg mark methods, first-call-wins on their own field, storing `inner.t0.elapsed()`. +- [x] Recover a poisoned lock in all four, matching the other lock-taking methods on the collector. +- [x] Map all four into `TimingSnapshot` via `duration_ms`; `Uuid` is `Copy`, so the id needs no clone. +- [x] Tests: first-call-wins per mark, proved by sleeping between the two calls and asserting the value is unchanged; the id survives a request with no dispatch mark; unmarked snapshot yields all `None`. +- [x] `cargo test-fastly request_timing`, commit. + +### Task 2: Auction call sites + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` + +**Interfaces:** + +- Consumes: Task 1 methods; `observation.auction_id` (`AuctionObservationContext`), in scope wherever an observation is built. + +- [x] Navigation path: `set_auction_id` at observation construction; `mark_auction_dispatched()` in the `DispatchAuctionOutcome::Dispatched` arm; `mark_auction_resolved()` after both `record_auction_wait` calls; `mark_auction_committed()` after both `write_bids_to_state` calls. +- [x] `/_ts/page-bids`: preserve the adapter collector, stamp the enabled observation UUID, use the orchestrator-owned dispatch/collect mapping and measured pre-header wait, then commit after the bid map is built. No-work outcomes keep timing null. +- [x] `POST /auction`: preserve the adapter collector and use the same observed orchestration path; commit once the OpenRTB response is converted. Mediator work does not synthesize provider dispatch. +- [x] Tests: extend the two existing collect-site tests to assert the resolve and commit offsets alongside `auction_wait_ms`. +- [x] `cargo test-fastly`, commit. + +### Task 3: Row columns, datasource, fixture + +**Files:** + +- Modify: `crates/trusted-server-core/src/access_telemetry.rs` +- Create: `tinybird/datasources/access_logs_v2.datasource` +- Create: `tinybird/fixtures/access_logs_v2.ndjson` + +- [x] `access_event_row`: add the three offset keys and `auction_id`, all nullable, after the existing phase keys. +- [x] Extend `row_serializes_nulls_for_missing_phases` (including `auction_id` in the nullable-column loop) and `row_serializes_recorded_phases_as_numbers` for the new keys. +- [x] Datasource: four schema columns with JSONPaths (`Nullable(UInt32)` ×3, `Nullable(UUID)`) on the new `access_logs_v2` datasource. Preserve the legacy nine-column datasource without a forward query or backfill. +- [x] Fixture: extend the existing row with a full timeline reusing the `auction_events_raw` fixture's UUID so the pair demonstrates the join, and add a second row covering the no-auction case. +- [x] Full gates: fmt, clippy (all six), test-fastly/axum/cloudflare/spin, parity. Commit. + +### Task 4: Spec amendment + +**Files:** + +- Modify: `docs/superpowers/specs/2026-08-24-request-phase-timing-design.md` + +- [x] Section 18: mark table including `set_auction_id`, the per-source instrumentation table, the null-semantics table, and the `Nullable(UUID)` rationale. +- [x] Section 18: correct the interpretation ladder so `time_elapsed_ms` is not shown last, since on a streamed body the headers commit before the seam collect. +- [x] Section 18: state the `R - D` versus `total_time_ms` reconciliation caveat and give the join query. +- [x] Section 9: add the four columns so the canonical column list the row builder's doc comment points at stays complete. +- [x] Docs format (`cd docs && npm run format`). diff --git a/docs/superpowers/specs/2026-08-24-request-phase-timing-design.md b/docs/superpowers/specs/2026-08-24-request-phase-timing-design.md new file mode 100644 index 0000000000..30ae4ce553 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-request-phase-timing-design.md @@ -0,0 +1,787 @@ +# Request phase timing: Server-Timing subtimings and access telemetry + +**Date:** 2026-08-24 +**Status:** Approved design, revised for review rounds 1 and 2, pending implementation +plan. +**Scope:** `trusted-server-core`, Fastly and Axum adapters, `tinybird/` schema, +performance dashboard (separate repo). + +--- + +## 1. Problem + +On 2026-08-21 a production deployment (publisher redacted, `prospect-a.example`) showed +an episodic stall: for a window of roughly 40 minutes, every request that reached the +application path carried a uniform extra ~600 ms of Fastly `time-elapsed`, and then +recovered to 20-50 ms with no deploy or config change we could observe. `/health` +(2-4 ms, short-circuits before app construction) and `/_ts/debug/ja4` (6-9 ms, settings +load only) stayed fast throughout, so the stall lived between app construction and +response send. + +Attributing that window required a live probing session: route-by-route bisection, +cookie-deletion experiments, and an eight-agent code trace. The trace found no +unconditional await on the path that could cost 570 ms, and exactly two +config-conditional candidates (the pre-route request filter's synchronous verification +POST, and EC identity KV writes before send), plus one dependency shared by every +application route (two geo hostcalls per request). We could not tell which one stalled, +because nothing in the response says where server time went. + +The Compute CPU budget is ~50 ms per request, so a large `time-elapsed` strongly +suggests wall-clock time outside active guest CPU: dependency awaits are the leading +explanation, with platform scheduling and hostcall queueing as the residual ones. The +comparison figure here is the fronting delivery layer's `time-elapsed` Server-Timing +entry, observed at its deliver phase. Either way, these are exactly the numbers a +response can carry about itself. + +## 2. Goals + +1. Every normal application response attributes its own server time by phase in a + standard header. Browsers expose the values to same-origin JavaScript via + `PerformanceResourceTiming.serverTiming`, so RUM tooling that reads that API can + surface the breakdown. Whether a given vendor or the publisher's own monitoring + extension actually collects it is verified separately in rollout; the publisher + extension needs a small change to render it. +2. The same numbers flow to Tinybird so we hold p50/p95/p99 per phase, per route class, + per PoP, per deployed version, and a future stall window self-diagnoses in one query. +3. No additional awaited I/O before first byte. The pre-send cost is a handful of + monotonic clock reads, one small allocation at entry, and rendering one header; + telemetry emission happens strictly after the last body byte. + +Scope note: phases cover the application lifecycle after T0. The `/health` and +`/_ts/debug/ja4` short-circuits, config-store open failures, and request-conversion +failures bypass the lifecycle and emit nothing. Requests served entirely by the +fronting cache never reach the guest and produce neither header entries nor rows. + +## 3. Non-goals + +- No trailer-based Server-Timing for body-phase spans (browsers do not expose trailer + values to JavaScript). +- No per-filter naming in any emitted surface. The request-filter span is `ts-filter` + regardless of which filter runs; vendor identity stays out of headers and telemetry. +- No Cloudflare or Spin emission wiring in v1. Core collection is adapter-neutral; those + adapters can wire emission later without core changes. +- No Tinybird endpoint pipe and no rollup materialized views in v1. Grafana queries the + datasource through the ClickHouse connector, matching the auction dashboards; rollups + only if panel latency demands them. +- No sampling of the header. The header is all-traffic when enabled; only Tinybird rows + sample. +- No cross-request circuit breaker for telemetry emission. Compute runs one isolate per + request; there is no shared mutable state to hold breaker state. The controls are the + bounded per-request cost and the `access_sample_rate` lever (section 10). + +## 4. Design overview + +``` +adapter entry (T0) + | RequestTimings::new() -> shared handle + v +app construction ................ ts-appbuild (adapter) +pre-route request filters ....... ts-filter (adapter wrapper) +geo lookup (single, deduped) .... ts-geo (adapter; result carried forward) +template cache lookup ........... ts-template-cache (core: publisher.rs) +origin fetch to resp headers .... ts-origin (core: publisher.rs) +EC identity KV, pre-send ........ ts-kv (core: KV abstraction) +auction wait, buffered mode ..... auction_wait_ms (row only; pre-header in this mode) + | +send_edgezero_response, immediately before into_parts(): + mark_headers_ready() snapshot (unconditional) + build AccessTelemetrySnapshot (only when tinybird.access_enabled) + append Server-Timing header (flag-gated, only on conclusively private responses) + | +headers committed; body streams + auction hold at seam .......... auction_wait_ms (row only; in-stream in this mode) + stream duration, bytes ........ stream_ms, resp_bytes (row only) + | +post-send (adapter main): + request_elapsed snapshot, then existing pull-sync, then: + sample gate -> one NDJSON row -> Tinybird Events API + bounded response await, 2xx validated +``` + +Collection is always-on and flag-free, including the `mark_headers_ready()` snapshot. +Two independent flags gate emission: the header (`observability.server_timing_enabled`) +and the telemetry row (`tinybird.access_enabled`). + +## 5. `RequestTimings` (core) + +New module `crates/trusted-server-core/src/request_timing.rs`. + +- `Phase`: a closed enum: `AppBuild`, `Filter`, `Geo`, `EcKv`, `Origin`, + `TemplateCacheLookup`, `AuctionWait`, `Stream`. Header rendering covers the first six + plus the stored total; the last two are row-only. +- Inner state: one fixed-size array of `Option` slots indexed by phase, + `t0: Instant`, `headers_ready_total: Option`, + `auction_wait_placement: Option` (`PreHeader` or `InStream`), + and `resp_bytes: Option`. Phases that repeat within a request (geo, KV) + accumulate by saturating addition into the same slot. +- `mark_headers_ready()`: stores `t0.elapsed()` once at the response-commit boundary, + unconditionally, before either emission flag is consulted. The header renders this + stored value as `ts-total`; the telemetry row reads the same stored value as + `time_elapsed_ms`. The two surfaces cannot disagree, and the row stays correct when + the header flag is off. Full request duration is captured separately as + `request_elapsed_ms`, snapshotted immediately after the body-stream drive returns and + before any other post-send work, so pull-sync and telemetry emission are never + included in it. +- Sharing: `RequestTimings` is a cheap-clone handle, `Arc>`. It crosses + three boundaries: adapter entry to core handlers, the streaming body closure (records + body-phase spans after the response object has been handed off), and the adapter's + post-send emission read. Access is exclusively `try_lock()`: a contended lock + drops the one sample rather than waiting, so recording can never delay a + request, and a poisoned lock is recovered (the guarded data are plain counters + with no invariant a panic can break) so one panic cannot silence the rest of + the request's timing. +- Recording API: `timings.record(Phase::Geo, dur)` and a scope guard + `timings.span(Phase::Origin)` that records on drop. Guards use saturating duration + math; a non-monotonic reading records zero rather than panicking. The auction-wait + recorder takes the placement explicitly so the two modes cannot be conflated. +- Rendering: `server_timing_value(&self) -> Option` produces + `ts-total;dur=41.2, ts-appbuild;dur=18.4, ts-filter;dur=9.1` with durations in + milliseconds at one decimal. Phases never recorded are omitted. Returns `None` when + `mark_headers_ready()` has not run. + +`Instant` is already used freely in the guest (`publisher.rs`, `auction/telemetry.rs`), +so no new clock abstraction is needed. + +## 6. Span taxonomy and recording sites + +| Entry | Measures | Site | +| ------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `ts-total` | T0 to `mark_headers_ready()` at the response-commit boundary | stored snapshot | +| `ts-appbuild` | config-store open, Settings parse, orchestrator + registry + router build | Fastly `main.rs` around `open_trusted_server_config_store()` + `build_app_with_state()` | +| `ts-filter` | pre-route request filters, end to end (backend ensure, secret read, POST) | around `run_pre_route_filters` (`app.rs:751`) | +| `ts-geo` | geo hostcall (single after dedupe; accumulates if any path still repeats) | `build_ec_request_state` (`app.rs:410`) and timed finalizer fallback lookups | +| `ts-kv` | EC identity KV operations before response send (see enumeration below) | the shared KV abstraction | +| `ts-origin` | publisher backend send to response headers available (read-through cache hit or miss) | `publisher.rs` around the origin `send` | +| `ts-template-cache` | template cache `lookup_or_reserve`, including the hit-path full-body read | `publisher.rs` around the lookup | + +Naming follows the completed template-cache terminology migration (`x-ts-template-cache` +is the emitted header on `main`; `c2` naming is retired). + +`ts-kv` is instrumented by a timing decorator implementing both `PlatformKvStore` and +the EC store trait, wrapping the store handed to request-scoped consumers, because EC +graph operations go through `KvIdentityGraph` and graphs are constructed independently +in request setup, identify, admin lookup, batch sync, and finalization. Every +request-path graph construction receives the timed store; pull-sync explicitly +constructs its graph from an untimed store. Included pre-send operations: EC +generation `create_or_revive`, identify-path graph reads and evaluation, finalize-path +`ingest_eid_cookies`/`upsert_partner_ids` and withdrawal tombstones, and batch-sync +graph access when it runs before send. The legacy consent KV store was removed on +`main` (#903), so there is no separate consent-store read to time. +Explicitly excluded: pull-sync work, which runs strictly after `send_to_client` and is +invisible to both surfaces. The decorator measures store-call latency only: no value +passing through it is read, parsed, or recorded, and the emitted surfaces carry no +consent or identity payloads. This feature therefore needs no consent gate; it is the +site measuring its own infrastructure, not processing user data. The timings handle +reaches `ec_finalize_response` inside the graph it already receives; that function +keeps the repository maximum of seven arguments and does not gain an eighth. + +Row-only fields: + +| Field | Measures | Site | +| -------------------- | --------------------------------------------------------- | ------------------------------------------------ | +| `auction_wait_ms` | wait on the dispatched auction (placement varies by mode) | seam hold (streaming) or buffered finalizer wait | +| `body_mode` | `streamed` or `buffered` response assembly | set where the response body is built | +| `stream_ms` | headers committed to last body byte | adapter around the body-stream drive | +| `resp_bytes` | bytes written to the client body | same | +| `request_elapsed_ms` | T0 to immediately after the body-stream drive returns | post-send snapshot, before pull-sync | + +Auction-wait placement is not universal. On the ordinary streaming path the wait +happens at the `` seam inside the body stream and nests inside `stream_ms`. On +buffered paths (the Fastly shared-template authorized miss, which buffers the full +transform and auction before returning a response, and every Axum response) the wait +completes before headers commit. The row therefore carries `body_mode` plus +`auction_wait_placement` (`pre_header` or `in_stream`), and derivations are +conditional: + +- `in_stream`: `stream_other_ms = greatest(coalesce(stream_ms, 0) - coalesce(auction_wait_ms, 0), 0)`. +- `pre_header`: `auction_wait_ms` joins the pre-header phase set, and `stream_other_ms = coalesce(stream_ms, 0)`. + +`unattributed_ms = greatest(coalesce(time_elapsed_ms, 0) - (coalesce(appbuild_ms, 0) + +coalesce(filter_ms, 0) + coalesce(geo_ms, 0) + coalesce(kv_ms, 0) + +coalesce(origin_ms, 0) + coalesce(template_cache_ms, 0) + pre-header auction wait), 0)`. +Every phase column is nullable, so every query-time formula wraps each term in +`coalesce(column, 0)` and every subtraction in `greatest(..., 0)`; query tests cover +sparse phase combinations. + +## 7. Freeze point and header emission + +The freeze-and-emit point is `send_edgezero_response` (Fastly `main.rs`), immediately +before `response.into_parts()`. This is the single choke point every send path shares, +and it runs after everything that can still mutate the response: the router middleware, +entry-point finalize (`apply_finalize_headers`, asset-policy reapplication), EC +finalization and its KV work, and terminal filter/privacy effects. +`apply_finalize_headers` itself does not emit; the `HEADER_X_TS_FINALIZED` sentinel +marks middleware finalization, not header commitment, and must not be treated as the +timing boundary. + +At the freeze point, in order: `mark_headers_ready()` (unconditional), the +`AccessTelemetrySnapshot` build (gated on `tinybird.access_enabled`, +section 10), then, gated on +`observability.server_timing_enabled`, append one `Server-Timing` header from +`server_timing_value()`. Append semantics, never insert: an origin-supplied +Server-Timing survives, and the fronting delivery layer's own entries (`time-elapsed`, +`hit-state`) are additive per the header's list semantics. + +Header emission is conservative: it happens only when the response is conclusively +non-storable by any shared cache, meaning `Cache-Control` contains `private` or +`no-store` (the existing `cache_control_headers_are_private_or_no_store` predicate). +Anything else, including bare `max-age`, `s-maxage` without `private`, +heuristically-cacheable responses with no cache header at all, and anything a fronting +cache override might store, emits no header, because a stored object would replay one +request's timings for its full lifetime. The long-lived immutable `tsjs` asset route +is the concrete excluded case. The snapshot and the telemetry row are unaffected by +this skip, so excluded routes still report through Tinybird. + +The Axum adapter applies the same emission rule at its terminal point before response +serialization, with adapter-specific phase semantics (section 8a). + +## 8. Geo lookup dedupe (rider) + +Today every dispatched request pays two geo hostcalls for one answer: request-phase in +`build_ec_request_state` (`app.rs:410`) and response-phase in +`FinalizeResponseMiddleware` (`middleware.rs:83`, alternate site `main.rs:285`). + +Plain request extensions cannot carry the result out: the middleware moves the request +context into `next.run(ctx)` and holds only the response afterward. The resolved geo +travels on a dedicated `GeoLookupState` response extension, attached on every exit +path that attempted a lookup, including the asset fallback, which runs +`build_ec_request_state` and then returns without `EcFinalizeState` (which is why +`EcFinalizeState` is not an acceptable carrier). States: `NotAttempted`, +`Attempted(None)` (lookup ran and failed, do not retry), and `Resolved(GeoInfo)`. The +finalize path consumes the carried value and performs a live lookup only in the +`NotAttempted` state; those legitimate fallback lookups (admin, batch, error paths) +are themselves timed into `ts-geo` so degraded geo cannot hide inside +`unattributed_ms`. The 401 rule (`resolve_geo_for_response` skips lookup for +unauthorized responses) is preserved. + +## 8a. Adapter phase semantics + +The Fastly adapter is the reference implementation of the taxonomy. Axum differs +structurally and its emissions are defined accordingly rather than pretending parity: + +- `ts-appbuild` is absent: Axum builds application state once at startup. +- Axum records no `Phase::Stream`; `stream_ms` is not a buffered-body write-out + measurement there. Core auction collection occurs before headers are sent. +- The freeze point is `TimingService` around `EdgeZeroAxumService`, driven by + the adapter's own `axum::serve` loop rather than `AxumDevServer`. It sees + every response before serialization, independently of router middleware and + router-generated error handling. `/health` is excluded by path match. +- Axum emits the header only; no Tinybird rows in v1 (unchanged). + +Cloudflare and Spin attach the per-request collector so core request-scoped consumers +share the adapter clock, but have no header or telemetry emission wiring in v1. + +The request-clock origins differ: + +| Adapter | T0 boundary | App-build meaning | +| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| Fastly | Before per-request config-store opening and setup | Per-request setup, plus cold application construction when required; warm values can be non-null | +| Axum | Entry to `TimingService::call` | State is built once at startup; no per-request app-build phase | +| Cloudflare / Spin | Router timing middleware after per-request application setup | Setup precedes the collector; not comparable to Fastly app-build | + +`origin_ms` is caller-blocking wait. An early-dispatched origin request overlaps +other work, so its span measures only the remaining wait. Neither this phase nor +`appbuild_ms` is a cross-adapter transport-latency or cold-start indicator. + +## 9. Access telemetry row + +Creates `tinybird/datasources/access_logs_v2.datasource`, preserving the reserved +nine-column `access_logs_raw` datasource unchanged. There is no backfill or +`FORWARD_QUERY`; historical raw paths do not enter the new dataset. + +Retained concepts: `event_ts`, `method`, `status`, `time_elapsed_ms` (defined as the +`mark_headers_ready()` snapshot; nullable because a contended lock drop can lose the +snapshot), `sample_rate`, 30-day TTL. The new sorting key uses `toDate(event_ts)` +rather than the legacy `event_date` column. + +Excluded from the new row: raw `path`. Route identifiers like `/_ts/admin/ec/{id}` would otherwise put +EC identifiers into a 30-day dataset, and publisher paths carry unbounded cardinality +and user-generated content (search terms, usernames, emails in slugs). Replaced by +`route_template`: + +- Named routes: the matched route-table pattern verbatim, parameters left as + placeholders. +- Asset routes: the configured prefix followed by `/*`. Responses without route + metadata use `unknown`. +- Publisher templates come from an operator-configured allowlist of section names + (`observability.route_sections`, default empty), not from the request: a path + whose first segment matches an allowlist entry and that has at least one further + segment emits `/{section}/*` (the lowercased allowlist entry itself); everything + else emits `/other/*`, and the root path emits `/`. This replaces the earlier + shape heuristics (charset, length, digit bounds), which review showed cannot + bound identity: depth-2 first segments are usernames on `/{username}/posts` + shapes, and single-segment paths are documents under `/%postname%/` permalinks. + With the allowlist, the emitted value set is fixed by configuration, so no + request-derived byte ever reaches the row. +- Tests are adversarial, not just the happy path: a literal EC identifier on the admin + route, an email address in a path segment, search-term-shaped segments, overlong + segments, UUIDs, hex ids, reset tokens, and full article slugs must all normalize + to bounded, content-free templates. + +Added columns (all dimension columns non-nullable with an `unknown` sentinel, because +ClickHouse sorting keys cannot contain nullable columns): + +``` +`service_id` LowCardinality(String), -- FASTLY_SERVICE_ID; immutable deployment identity +`publisher_domain` LowCardinality(String), -- matches auction schema +`env` LowCardinality(String), -- adapter-derived: production | staging | unknown +`route_class` LowCardinality(String), -- publisher_html | tsjs | integration_proxy | ec | auction_api | asset | other +`route_template` String, -- bounded, normalized; replaces path +`body_mode` LowCardinality(String), -- streamed | buffered +`delivery_result` LowCardinality(String), -- complete | partial | error; transport handoff only +`auction_wait_placement` LowCardinality(String), -- pre_header | in_stream | none +`appbuild_ms` Nullable(UInt32), +`filter_ms` Nullable(UInt32), +`geo_ms` Nullable(UInt32), +`kv_ms` Nullable(UInt32), +`origin_ms` Nullable(UInt32), +`template_cache_ms` Nullable(UInt32), +`auction_wait_ms` Nullable(UInt32), +`stream_ms` Nullable(UInt32), +`request_elapsed_ms` Nullable(UInt32), +`resp_bytes` Nullable(UInt64), +`template_cache_state` LowCardinality(String), -- from the typed response extension, not the public header +`country` LowCardinality(String), +`ts_version` LowCardinality(String), -- compiled TS build identifier, matching x-ts-version +`pop` LowCardinality(String), -- FASTLY_POP, 'unknown' when absent +`auction_dispatched_ms` Nullable(UInt32), -- section 18 +`auction_collected_ms` Nullable(UInt32), -- section 18 +`auction_committed_ms` Nullable(UInt32), -- section 18 +`auction_id` Nullable(UUID) -- section 18; join key to auction_events_raw +``` + +The four auction columns are specified in section 18; they are listed here so +this block stays the single canonical column list. + +The matched route pattern does not survive dispatch today, so a typed +`RouteMetadata` response extension carries `route_class` and `route_template`: each +named-route handler wrapper attaches its route-table pattern verbatim (handlers +serving multiple patterns attach the one that matched), and the fallback and tsjs +handlers attach their class plus the coarse template. The freeze point consumes the +extension; nothing reconstructs routes from a handler enum or path regex. + +Typed sources only: `env` is adapter-owned, derived from the same Fastly +`FASTLY_IS_STAGING` input that drives `x-ts-env` (`Settings` has no environment +field and does not gain one). `template_cache_state` comes from a typed response +extension, not the `x-ts-template-cache` header (operator-configured response +headers can override managed headers): the currently private +`TemplateCacheResponseState` in `publisher.rs` becomes a typed response extension, +and every state transition sets the managed header and the extension together so +the two can never drift. `service_id` and `pop` come from the Fastly environment. `cache_state` +from the reserved schema is dropped: the guest cannot observe the fronting cache, and +guest-visible cache behavior is already carried by `template_cache_state` and +`origin_ms`. Rows exist only for guest-handled requests; fronting-cache hits are +invisible by construction and the dashboard documentation says so. + +Sorting key: `(toDate(event_ts), service_id, publisher_domain, env, route_class, +pop, status)`. Every column carries a `json:$.` path (the Events API rejects +NDJSON into a datasource without JSONPaths, discovered live); `event_date` was +dropped in favor of the sorting-key expression because a DEFAULT column cannot +carry a JSONPath the producer never sends. Grafana time filtering uses `$__timeFilter(event_ts)` and every panel query +also carries a `toDate(event_ts)` predicate so the primary index prunes; rollout validates +the panel queries with `EXPLAIN` before the dashboard is committed. This replaces the +reserved key `(event_date, path, status, method)` only in the new datasource. +The legacy datasource and its key remain unchanged. Rollout step 4 creates the +versioned replacement and cuts consumers over after verifying ingestion. + +## 10. Emission mechanics + +- `AccessTelemetrySnapshot`: built at the freeze point when + `tinybird.access_enabled` is set (revised in review from the original + unconditional build, so a disabled deployment pays nothing here), before + `into_parts()` consumes the response. It captures method, status, route metadata + (from the `RouteMetadata` extension), and typed dimension states (`env`, + `template_cache_state`, geo country). It exists because nothing else survives to + post-send on every path: the request is consumed by dispatch, the response by + `into_parts()`, and `EcFinalizeState` is absent on asset, admin, and error paths. +- The emitter's transport context is adapter-owned and route-independent: the + Events API target (backend spec, secret store name, dataset, token secret, sample + rate) derives from settings once at entry in `main.rs`, and the HTTP client is the + adapter's stateless platform client. Asset, admin, and error responses therefore + emit without `RuntimeServices` or `EcFinalizeState`. +- `send_edgezero_response` returns a delivery outcome instead of `()`, with + per-mode semantics because the two body paths observe different things. Streamed + bodies: a counting writer reports bytes written and distinguishes complete, + partial (truncated), and error outcomes. Buffered bodies: `send_to_client()` + returns no delivery result, so the byte count is captured from the body length + before the send and the outcome is complete-on-return with no partial detection; + `body_mode` in the row keeps the two regimes distinguishable in analysis. +- Ordering after the body-stream drive returns: snapshot `request_elapsed_ms` first, + run the existing pull-sync dispatch unchanged, then telemetry emission last, so + pull-sync is never delayed behind the ingest await and never included in + `request_elapsed_ms`. +- Sampling: uniform per-request decision against `tinybird.access_sample_rate`. No + client stickiness. Sampled-out requests are silent; every other drop (row build + failure, send failure, non-2xx) logs one warning naming the reason. There is no + cross-request warning suppression (per-request isolates hold no shared state); the + overload controls are the 2 s bounded await, the single-warning-per-request cap, and + `access_sample_rate` pushed down by config as the operational abort lever. Ingest + health is monitored from the Tinybird side via ingestion freshness on the + datasource, which catches quarantine and schema rejection that per-request warnings + cannot. +- Transport: one NDJSON row to the Tinybird Events API: same `api_host`, reserved + `access_dataset` and `access_token_secret`, 2 s first-byte and between-bytes + timeouts, `max_body_bytes` guard, no retry. +- Delivery confirmation: unlike the auction sink, which starts `send_async` and drops + the pending response (it runs before delivery completes and cannot afford to wait), + the access emitter runs after the client has the full response and therefore awaits + the bounded ingest response and validates 2xx. A non-2xx or timeout logs a warning + with the status. +- Budget: at `access_sample_rate = 1.0` this adds one backend request per request to + the service, after delivery; during a Tinybird outage each such request holds its + sandbox for up to the bounded timeout. The sample rate is the budget control; 1.0 is + a diagnosis setting, not a steady state, and rollout treats sustained emission + warnings as the signal to dial it down. +- Axum adapter: emits the header only; no Tinybird rows in v1. + +## 11. Dashboard and query model + +No endpoint pipe in v1. Grafana queries `access_logs_v2` directly through the +ClickHouse connector with `$__timeFilter(event_ts)` plus a `toDate(event_ts)` +predicate, matching the auction dashboards. + +Dashboard: a new standalone `grafana/dashboards/edge-performance.json` in the +telemetry repo (`trusted-server-tinybird`), performance only, no panels shared with +the revenue and auction dashboards. Panels: + +- Phase percentiles (p50/p95/p99) by `route_class`, per phase column. +- Stacked phase breakdown over time using the non-overlapping set: `appbuild_ms`, + `filter_ms`, `geo_ms`, `kv_ms`, `origin_ms`, `template_cache_ms`, pre-header + auction wait (where `auction_wait_placement = 'pre_header'`), and derived + `unattributed_ms`. In-stream auction wait and derived `stream_other_ms` chart in a + separate body-phase panel and never stack with pre-header phases. +- PoP split, `ts_version` overlay, template-cache state rates. +- Stall panel: rows with `request_elapsed_ms > 500` (post-body total, so body-only + stalls are caught) grouped by dominant phase, where `unattributed_ms` competes as a + phase so the panel cannot confidently blame a small measured span while most time is + uninstrumented. + +All derivations use the `coalesce`/`greatest` forms from section 6; query tests cover +sparse phase combinations and both `auction_wait_placement` modes. + +Sampling semantics for every aggregate: `sample_rate` must be operationally stable +within any queried window. Quantile panels filter strictly to a single `sample_rate` +value. Volume panels weight each row by `1.0 / sample_rate` (the inverse-probability +estimator is `sum(1.0 / sample_rate)` over emitted rows; `count() / rate` is valid +only when the query is already filtered to one rate). Pooled unweighted quantiles +across a rate change are documented as invalid. + +## 12. Config surface + +```toml +[observability] +# Append TS phase timings to the Server-Timing response header. +server_timing_enabled = false # example default +``` + +New `ObservabilitySettings` struct with the single boolean, default off, standard +environment override (`TRUSTED_SERVER__OBSERVABILITY__SERVER_TIMING_ENABLED`). +Collection has no flag: the flags gate the two emission surfaces independently. + +Tinybird flag structure: `tinybird.enabled` today arms the auction sink by itself, so +"enable Tinybird for access telemetry" would silently enable auction emission too. The +master flag is demoted to transport-only (host, store, credentials), and each emitter +gets its own switch: a new `tinybird.auction_enabled` defaulting to `true` (preserving +current behavior for existing configs) and the reserved `tinybird.access_enabled` +defaulting to `false`. A settings test locks the decoupling in both directions. + +Validation when `access_enabled = true`: `tinybird.enabled`, non-empty `api_host`, +non-empty `secret_store`, `access_dataset`, and `access_token_secret`, a positive +`max_body_bytes`, and `access_sample_rate > 0`. An armed-but-silent configuration +(`access_enabled = true`, `access_sample_rate = 0`) is a configuration error, not a +valid state; disabling is done with the flag, not the rate. + +Rollback and compatibility, because `Settings` is `deny_unknown_fields`: + +- Deployment order is binary first, config second. Rollback order is config first + (remove the `[observability]` table and any new tinybird keys), binary second. A + config containing the new fields must never be pushed while a pre-observability + binary can still run. +- Config serialization omits the table when it equals the default, so round-tripping a + config through tooling does not inject a field an older binary rejects. A + compatibility test asserts the serialized default config parses under the previous + schema. +- The environment-variable overlay cannot create a missing leaf, so the key ships + present-but-false in the base operator TOML (the same pattern the GPT integration + documents in `trusted-server.example.toml`) and is flipped by config push. + +## 13. Error handling + +- Recording is infallible: saturating math, lock-failure drops the sample, no panics. +- Header rendering failure (defensive `HeaderValue::from_str` error) logs and skips + the header. +- Row emission failure logs one warning naming the reason and drops the row. The + response has already been delivered; there is nothing to degrade. + +## 14. Testing + +- Core unit tests: phase accumulation, saturating math, `mark_headers_ready()` + idempotence and both-surface consistency, render format (one decimal, omission of + unrecorded phases), row serialization shape, auction-wait placement recording. +- Adapter tests (Fastly via Viceroy, Axum native): header present and well-formed on a + conclusively-private publisher route with the flag on; absent with the flag off; + absent on the shared-cacheable tsjs route and on a bare `max-age` response with the + flag on; exactly one TS-owned metric set (a single `ts-total`) with every + pre-existing Server-Timing value preserved, across all send paths; `ts-kv` captures + EC finalize work (proving the freeze point sits after it). +- Body-mode tests: ordinary streaming (in-stream wait nested in `stream_ms`), + Fastly shared-template authorized miss (buffered, pre-header wait), and Axum + (always buffered), each asserting placement and non-negative derivations. +- Geo dedupe: finalize consumes `Resolved`; no retry on `Attempted(None)`; live + lookup only on `NotAttempted`; fallback lookups timed into `ts-geo`; asset-fallback + path carries `GeoLookupState` without `EcFinalizeState`; 401 skip preserved. +- Route template: adversarial normalization tests (literal EC identifier on the admin + route, email address in a segment, search-term segments, overlong segments) all + producing bounded content-free templates. +- Settings: the access validation matrix including the armed-but-silent rejection; + auction/access flag decoupling in both directions; the former rejection test becomes + the wiring test; the serialized-default-config compatibility test against the + previous schema. +- Sink tests: `RecordingHttpClient` pattern; assert URI, NDJSON body shape, token + header, 2xx validation and warning on non-2xx, skip when sampled out, ordering after + pull-sync. +- Query tests: derivation formulas against sparse rows and both placements. + +## 15. Rollout and verification + +1. Land collection + freeze point + header emission behind the flag, off everywhere. + Full CI gate. +2. Staging deploy with the flag on. Delivery-layer verification is two-sided: a + pass-through request confirming the appended Server-Timing survives the fronting + VCL, and a MISS-then-HIT replay against a cacheable route confirming no stale + timing header is ever served from cache. Fallback if the VCL clobbers the header: a + one-line VCL change on the delivery service, or mirroring the value to + `x-ts-timing` while that lands. +3. Production flag on. Confirm + `performance.getEntriesByType('navigation')[0].serverTiming` shows `ts-*` entries + in a real browser session, and separately confirm what the publisher's RUM tooling + actually collects; the publisher monitoring extension renders it only after a small + change on their side. +4. Inspect the deployed workspace, preserving any `access_logs_raw` datasource. + Deploy `access_logs_v2` and its `ts_access_v2_ingest` APPEND token before the + emitting binary. Update explicit dataset names and token references, then + enable sampling and verify ingestion/quarantine before switching dashboards. + Validate panel queries with `EXPLAIN` against the new sorting key. Sample at + 1.0 only deliberately during stall diagnosis, accounting for the post-send + ingest wait. Follow the cutover and rollback steps in `tinybird/README.md`. +5. Success criterion: the next stall window is attributable from one response header + or one dashboard query, with no live probing session. + +## 16. Overhead + +Roughly ten monotonic clock reads, two stored snapshots, and one ~130-byte header per +request; one sampled HTTP POST with a bounded await after the response has fully +streamed. No allocation in the hot path beyond the one `Arc` at entry, the +`AccessTelemetrySnapshot` at the freeze point, and the rendered header string. + +## 17. Decisions and open questions + +- **Public exposure is a decision, not an open question.** The header is all-traffic + when enabled. Rationale: values are durations only; the delivery layer already + exposes `hit-state` and `time-elapsed` publicly on every response; filter vendor + identity is masked; emission is restricted to conclusively-private responses so no + cache can replay stale timings. Revisit (quantization or gating) only if a concrete + abuse surfaces. +- The fronting delivery layer's Server-Timing pass-through is unverified until the + first staging deploy (step 2). This is the only known external dependency. +- Body-phase capture threads the timings handle into the streaming closure in + `publisher.rs`; the exact seam is an implementation-plan detail, with the constraint + that a dropped handle (error paths, early client disconnect) must still yield a + valid row with null body-phase fields and a recorded delivery outcome. +- The stall window itself remains unattributed until this ships. If it recurs first, + the bisection runbook from 2026-08-21 (cookie-free curl UA request, static-asset + path versus HTML path) is the fallback. + +## 18. Auction timeline offsets (follow-up increment) + +Status: spec amendment written ahead of implementation, then implemented in the +same PR on top of the initial implementation (#1074). Builds only on machinery +that spec sections 5, 9, and 10 already define. + +### Problem + +The pipeline has two clocks that never meet. The auction dataset +(`auction_events_raw`, PR #813) measures the auction internally: `total_time_ms` +from auction start to terminal, `provider_response_time_ms` per bidder call. Its +clock starts when the auction observation is created, so nothing places those +numbers on the request timeline. The access row is T0-anchored but records only +`auction_wait_ms`: time the handler was blocked at collect, deliberately not the +auction's own timeline. + +That leaves three questions unanswerable today: + +1. At what request-relative time did the auction start (dispatch leave the edge)? +2. At what request-relative time did the auction resolve (final bid or timeout)? +3. At what request-relative time were the results committed toward GAM? + +These are the overlap-proof questions. A client-side wrapper cannot dispatch until +the browser boots (t~3000ms on measured prospect pages); the server-side auction +dispatches while the origin fetch is in flight. Proving that requires all +milestones on one clock. + +### Design + +One first-call-wins id setter and three first-call-wins marks on +`RequestTimings`, in the style of `mark_headers_ready()`, each mark storing +`Option` since T0: + +| Call | Recorded at | Meaning | +| --------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- | +| `set_auction_id()` | where the `AuctionObservationContext` is built, on all three auction sources | the join key to `auction_events_raw` for this request | +| `mark_auction_dispatched()` | where the bid requests leave the edge | bid requests have left the edge | +| `mark_auction_resolved()` | where the auction returns, terminal on success, failure, or timeout | final bid returned or auction timed out | +| `mark_auction_committed()` | where winning bids become available to the response pipeline | targeting is committed | + +All three auction sources are instrumented, because all three emit rows to +`auction_events_raw` and all three emit an access row: + +| Source | Route | Dispatch / resolve bracket | Commit | +| ------------------- | ---------------- | ---------------------------- | -------------------------- | +| `InitialNavigation` | publisher HTML | `dispatch_auction` / collect | `write_bids_to_state` | +| `SpaNavigation` | `/_ts/page-bids` | `run_auction` | bid map built | +| `AuctionApi` | `POST /auction` | `run_auction` | OpenRTB response converted | + +Notes on the definitions: + +- "Committed toward GAM" is defined as the point where targeting becomes part of + the response: `write_bids_to_state` returning on the navigation path, the bid + map on page-bids, the converted OpenRTB response on `/auction`. TS never calls + GAM server-side; the browser's GPT call carries the targeting, and that half of + the timeline belongs to client-side measurement. The edge proves when targeting + was available; the client proves when GAM saw it. +- The id is set separately from the dispatch mark, and earlier. Every auction + outcome emits an `auction_events_raw` row under that id, including skipped and + dispatch-failed auctions, so stamping the id at dispatch would leave exactly + those rows unjoinable. It also means a dropped dispatch sample cannot take the + join key with it. +- First-call-wins on the id and all three marks. A request produces at most one + auction today; if a second ever occurs in one request, the row describes the + first and the auction dataset still carries both in full. +- Same locking and failure model as every other `RequestTimings` write: + `try_lock`, drop on contention, poisoned lock recovered, saturating conversion + at serialization. + +### Row changes + +Four auction columns on the new `access_logs_v2` row, all populated from the +`TimingSnapshot` at the existing freeze/emission points (no new emission path): + +``` +`auction_dispatched_ms` Nullable(UInt32), `json:$.auction_dispatched_ms` +`auction_collected_ms` Nullable(UInt32), `json:$.auction_collected_ms` +`auction_committed_ms` Nullable(UInt32), `json:$.auction_committed_ms` +`auction_id` Nullable(UUID), `json:$.auction_id` +``` + +Null on an offset means "this milestone was not reached", not "no auction ran". +`auction_id` is what separates the cases, and the two read together: + +| `auction_id` | `dispatched` | `collected` | `committed` | Meaning | +| ------------ | ------------ | ----------- | ----------- | ------------------------------------------------------------------------- | +| null | null | null | null | no auction observation, including disabled auctions | +| set | null | null | null | observed, then skipped or failed before network work | +| set | set | null | null | provider dispatched, never collected, such as origin error or disconnect | +| set | set | set | null | collected, targeting not committed | +| set | set | set | set | provider work collected and targeting committed, including no-bid/timeout | +| set | null | set | null | mediator-only network work collected, targeting not committed | +| set | null | set | set | mediator-only network work collected and targeting committed | + +Dispatch evidence means at least one provider started a request; routing-only +outcomes do not qualify. Collection can observe a mediator Pending result or a +started-request error without provider dispatch. A configured mediator or an +Immediate result alone is not launch evidence. Existing dispatch/admission choices +are unchanged. The internal and browser fields retain their resolved names; +`auction_collected_ms` names the persisted milestone, not transport completion. +Whenever all three milestones exist, `dispatched <= collected <= committed`. +Headers-ready can fall before or after these milestones, as described below. + +The third row is the case worth watching: bid requests went out and the response +they were for never used them. It is distinguishable now, where before it was +indistinguishable from "no auction". + +- `auction_id` is the telemetry auction UUID already present on every + `auction_events_raw` row, carried onto the access row as the join key between + the T0 timeline and per-bidder detail. It is `Nullable(UUID)` rather than a + String with a sentinel so it joins natively against `auction_events_raw` + .`auction_id`, which is `UUID`: a String column would make the join a type + error, and casting a `'none'` sentinel through `toUUID` throws. It is a random + UUID, not identity-bearing; unbounded cardinality is accepted for the same + reason it is accepted in the auction dataset. It is not in the sorting key, so + the section 9 non-nullable-dimension rule (which exists because ClickHouse + sorting keys cannot contain nullable columns) does not apply to it. +- The versioned `access_logs_v2` datasource uses JSONPaths on every column and + no `FORWARD_QUERY`. The nine-column legacy datasource remains intact. Validate + deployment against the intended workspace before cutover; local field + alignment is not evidence of a successful remote deployment. + +### Interpretation model + +Combined with existing columns, one access row now reads as a timeline: + +``` +t=0 ......... request entry +t=D ......... auction_dispatched_ms (bids out; origin fetch typically in flight) +t=R ......... auction_collected_ms (collection returned, not transport completion) +t=C ......... auction_committed_ms (targeting available to the response) +``` + +`time_elapsed_ms` (t=H, headers committed) does **not** belong at the end of that +ladder, and where it lands depends on `body_mode`: + +- Buffered (`auction_wait_placement = 'pre_header'`): H comes after C. The auction + is collected before the response headers are built, so D < R < C < H. +- Streamed (`auction_wait_placement = 'in_stream'`): H comes **before** R and C. + `mark_headers_ready` is stamped at the terminal layer before the lazy body is + polled, while the collect happens at the `` seam inside that body. So + D < H < R < C is the normal ordering for a streamed HTML page. + +Requiring `auction_committed_ms <= time_elapsed_ms` can discard valid streamed +rows. Slice by wait placement before comparing auction marks against H. The +illustrated ordering assumes distinct millisecond values; adjacent marks can be +equal after whole-millisecond conversion. + +`C - R` measures whole-millisecond commit latency. On the navigation path it +brackets `write_bids_to_state`, including per-winning-bid creative processing. +Expect 0 on light auctions and non-zero as creative count and size grow. + +`R - D` is the dispatch-to-collection window, not auction duration. On streamed +pages, collection can wait until the `` seam even if provider transport +finished earlier. Neither `R - D` nor its difference from `auction_wait_ms` +establishes auction/origin overlap. `origin_ms` can itself measure only the +remaining origin wait after early dispatch. Transport-completion timestamps would +be needed to derive that overlap. + +`auction_wait_ms` measures actual collection after provider or mediator launch. +`/auction` and `/_ts/page-bids` record `pre_header`; HTML uses its actual pre-header +or in-stream placement. No-work outcomes leave wait null with `none` placement. +A measured zero retains its placement, and mediator-only collection may have wait +and collected/committed offsets while provider dispatch remains null. + +Joining to per-bidder detail, now that both sides are `UUID`: + +```sql +SELECT a.auction_id, a.auction_collected_ms - a.auction_dispatched_ms AS edge_window, + e.provider, e.provider_response_time_ms +FROM access_logs_v2 a +INNER JOIN auction_events_raw e ON a.auction_id = e.auction_id +WHERE a.auction_id IS NOT NULL AND e.event_kind = 'provider_call' +``` + +D is stamped where dispatch returns to the caller, while the auction dataset's +`total_time_ms` and `provider_response_time_ms` start inside the orchestrator's +launch loop. R is stamped when collection returns and includes any delay before +the caller starts collection. Use the auction dataset for per-bidder duration; +the access-row window is not a transport-duration measurement. + +### Scope + +- Fastly emits. Axum, Cloudflare, and Spin attach a collector and record the marks but + do not emit them. Cloudflare and Spin added collection when GPT diagnostics began + exposing request-scoped auction offsets; without an adapter collector those offsets + silently used a different handler-entry clock. This matches section 8a adapter + semantics: collection is portable, while emission remains adapter-specific. +- No header emission for any of these values: they are post-hoc analysis fields, + and two of the three are typically unknown at the header freeze point in + streaming mode. +- No config surface: the marks are always-on collection like every other phase, + gated at emission by the existing `tinybird.access_enabled`. diff --git a/tinybird/README.md b/tinybird/README.md index c0a0e976ce..6a305dcdec 100644 --- a/tinybird/README.md +++ b/tinybird/README.md @@ -1,7 +1,7 @@ # Tinybird project This directory is the tracked Tinybird resource project for Trusted Server -auction telemetry. +auction and access telemetry. - `datasources/auction_events_raw.datasource` is the runtime ingest target. - The auction overview, provider, and bid-stat rollups are materialized @@ -10,8 +10,10 @@ auction telemetry. yield endpoints or materialized views. - `fixtures/auction_events_raw.ndjson` is test data only. - `tests/` checks auction summary, provider health, and seat-yield results. -- `datasources/access_logs_raw.datasource` is reserved; Trusted Server does - not currently emit access logs. +- `datasources/access_logs_v2.datasource` is the sampled access-telemetry ingest + target, with 31 columns and a 30-day TTL. `fixtures/access_logs_v2.ndjson` is test data only. +- `datasources/access_logs_raw.datasource` preserves the reserved legacy schema. + It is not the target for the new emitter and is not replaced or backfilled. Authenticate the Tinybird CLI against the intended workspace, change to this directory, run `tb test`, inspect `tb deploy --dry-run`, and deploy with @@ -22,9 +24,18 @@ platform secret store, and put only its key name in `tinybird.auction_token_secret`. The runtime sends directly to the regional Events API host configured by `tinybird.api_host`. -For runtime behavior, limits, privacy properties, and the Fastly-only emission -boundary, see [Auction Telemetry](../docs/guide/telemetry.md). For exact -configuration fields, see [Configuration](../docs/guide/configuration.md). +For access telemetry, use the distinct `ts_access_v2_ingest` APPEND token scoped +only to `access_logs_v2`. Store its value in the default app-config secret store +under the key named by `tinybird.access_token_secret`. Set +`tinybird.access_dataset = "access_logs_v2"` explicitly when updating a config +that still carries the legacy dataset name. Keep access disabled until this +schema and token are ready. With access enabled, validation rejects exactly the +normalized legacy name `access_logs_raw`; custom dataset names remain allowed, +and disabled legacy configurations remain accepted. + +For runtime behavior, sampling, privacy properties, and the Fastly-only emission +boundary, see [Telemetry](../docs/guide/telemetry.md). For exact configuration +fields, see [Configuration](../docs/guide/configuration.md). ## Deploy ordering @@ -37,10 +48,60 @@ is an unconfigured placeholder returning `NULL`, not a working monitor. Connect workspace quarantine source before relying on its counts. A code-first deploy can lose rows silently until the schema catches up. -Adding a field means changing three things together: the struct in +Adding an auction field means changing three things together: the struct in `crates/trusted-server-core/src/auction/telemetry.rs`, the `SCHEMA` block in `datasources/auction_events_raw.datasource`, and every row in -`fixtures/auction_events_raw.ndjson`. +`fixtures/auction_events_raw.ndjson`. Access fields likewise must agree between +`crates/trusted-server-core/src/access_telemetry.rs`, +`datasources/access_logs_v2.datasource`, and `fixtures/access_logs_v2.ndjson`, +including the JSONPaths and nullable types. + +The access v2 contract uses `auction_collected_ms` for collection return, not +provider transport completion. `ts_version` identifies the TS build like the +`x-ts-version` header, not a Fastly activation number. `delivery_result` reports +local transport handoff as `complete`, `partial`, or `error`, not client receipt. +Access ingestion is fire-and-forget; HTTP success and quarantined rows are not +observed by the emitter. The core producer/schema/fixture test checks field and +JSONPath agreement, but cannot establish live ingestion success. + +These are pre-rollout changes to the new v2 datasource. If a preview v2 schema +has already been deployed, inspect and coordinate its migration before deploying +this producer; do not assume the new name and column appear automatically. + +### Access datasource cutover + +The new emitter uses a versioned datasource rather than evolving +`access_logs_raw`. The legacy datasource has nine columns, including raw +`path` and `cache_state`, and no phase columns. `access_logs_v2` has no +`FORWARD_QUERY` and does not copy historical raw paths into bounded route templates. + +1. Inspect the deployed workspace resources and retention requirements. Preserve + any existing `access_logs_raw` and its token; do not drop or recreate it. +2. Run the Tinybird deployment check against that workspace and confirm the diff + creates `access_logs_v2` and `ts_access_v2_ingest` without changing the legacy + datasource. Deploy the new resources before the emitting binary. +3. Provision the new APPEND token in the platform secret store. Deploy the new + binary with access telemetry still disabled, then push a config with + `enabled = true`, `access_enabled = true`, `access_dataset = "access_logs_v2"`, + the new token key, and a positive sample rate. Use separate + token keys for the access and auction sinks. +4. Verify accepted rows, their stored `sample_rate`, ingestion freshness, and + workspace quarantine. Confirm the UUID join against `auction_events_raw` for + active auction observations. Disabled auctions have null access UUIDs. +5. Switch dashboards and queries to `access_logs_v2`. Leave legacy resources in + place for retention and rollback; any later removal is a separate operator + decision. + +Rollback disables access emission before restoring an older binary. Re-push a +compatible config with no non-default `[observability]` table; for access-only +telemetry also set `tinybird.enabled = false`, since older binaries interpret it +as auction enablement. Keep both datasets and tokens intact during rollback. +Do not redeploy an older `tinybird/` project that omits `access_logs_v2`; +its deployment may propose removing the new datasource. Inspect the dry-run +rather than treating a binary rollback as a Tinybird project rollback. + +These steps are a rollout requirement, not evidence that a live workspace +migration has already been tested. ## Reading `origin_cache_shareable` diff --git a/tinybird/datasources/access_logs_v2.datasource b/tinybird/datasources/access_logs_v2.datasource new file mode 100644 index 0000000000..951284c48d --- /dev/null +++ b/tinybird/datasources/access_logs_v2.datasource @@ -0,0 +1,41 @@ +DESCRIPTION > + Per-request phase-timing telemetry rows, sampled and emitted post-send by the edge service. + +SCHEMA > + `event_ts` DateTime64(3) `json:$.event_ts`, + `method` LowCardinality(String) `json:$.method`, + `status` UInt16 `json:$.status`, + `time_elapsed_ms` Nullable(UInt32) `json:$.time_elapsed_ms`, + `sample_rate` Float64 `json:$.sample_rate`, + `service_id` LowCardinality(String) `json:$.service_id`, + `publisher_domain` LowCardinality(String) `json:$.publisher_domain`, + `env` LowCardinality(String) `json:$.env`, + `route_class` LowCardinality(String) `json:$.route_class`, + `route_template` String `json:$.route_template`, + `body_mode` LowCardinality(String) `json:$.body_mode`, + `delivery_result` LowCardinality(String) `json:$.delivery_result`, + `auction_wait_placement` LowCardinality(String) `json:$.auction_wait_placement`, + `appbuild_ms` Nullable(UInt32) `json:$.appbuild_ms`, + `filter_ms` Nullable(UInt32) `json:$.filter_ms`, + `geo_ms` Nullable(UInt32) `json:$.geo_ms`, + `kv_ms` Nullable(UInt32) `json:$.kv_ms`, + `origin_ms` Nullable(UInt32) `json:$.origin_ms`, + `template_cache_ms` Nullable(UInt32) `json:$.template_cache_ms`, + `auction_wait_ms` Nullable(UInt32) `json:$.auction_wait_ms`, + `stream_ms` Nullable(UInt32) `json:$.stream_ms`, + `request_elapsed_ms` Nullable(UInt32) `json:$.request_elapsed_ms`, + `resp_bytes` Nullable(UInt64) `json:$.resp_bytes`, + `template_cache_state` LowCardinality(String) `json:$.template_cache_state`, + `country` LowCardinality(String) `json:$.country`, + `ts_version` LowCardinality(String) `json:$.ts_version`, + `pop` LowCardinality(String) `json:$.pop`, + `auction_dispatched_ms` Nullable(UInt32) `json:$.auction_dispatched_ms`, + `auction_collected_ms` Nullable(UInt32) `json:$.auction_collected_ms`, + `auction_committed_ms` Nullable(UInt32) `json:$.auction_committed_ms`, + `auction_id` Nullable(UUID) `json:$.auction_id` + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "toDate(event_ts), service_id, publisher_domain, env, route_class, pop, status" +TTL "toDate(event_ts) + INTERVAL 30 DAY" + +TOKEN ts_access_v2_ingest APPEND diff --git a/tinybird/fixtures/access_logs_v2.ndjson b/tinybird/fixtures/access_logs_v2.ndjson new file mode 100644 index 0000000000..a28bfeae68 --- /dev/null +++ b/tinybird/fixtures/access_logs_v2.ndjson @@ -0,0 +1,2 @@ +{"event_ts":"2026-06-23 12:00:00.000","method":"GET","status":200,"time_elapsed_ms":60,"sample_rate":0.1,"service_id":"abc123","publisher_domain":"publisher.example.com","env":"production","route_class":"publisher_html","route_template":"/news/*","body_mode":"streamed","delivery_result":"complete","auction_wait_placement":"in_stream","appbuild_ms":12,"filter_ms":5,"geo_ms":3,"kv_ms":8,"origin_ms":null,"template_cache_ms":10,"auction_wait_ms":22,"stream_ms":40,"request_elapsed_ms":100,"resp_bytes":8192,"template_cache_state":"hit","country":"US","ts_version":"v1.2.3","pop":"SFO","auction_dispatched_ms":30,"auction_collected_ms":92,"auction_committed_ms":94,"auction_id":"550e8400-e29b-41d4-a716-446655440000"} +{"event_ts":"2026-06-23 12:00:01.000","method":"GET","status":200,"time_elapsed_ms":12,"sample_rate":0.1,"service_id":"abc123","publisher_domain":"publisher.example.com","env":"production","route_class":"asset","route_template":"/static/*","body_mode":"streamed","delivery_result":"partial","auction_wait_placement":"none","appbuild_ms":2,"filter_ms":1,"geo_ms":1,"kv_ms":null,"origin_ms":null,"template_cache_ms":null,"auction_wait_ms":null,"stream_ms":3,"request_elapsed_ms":15,"resp_bytes":2048,"template_cache_state":"unknown","country":"US","ts_version":"v1.2.3","pop":"SFO","auction_dispatched_ms":null,"auction_collected_ms":null,"auction_committed_ms":null,"auction_id":null} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 99afa869dd..028e54f0d3 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -353,6 +353,17 @@ provider = "pbs-main" # inventory_domain = "publisher.example" # inventory_page_origin = "https://www.publisher.example" +[observability] +# Keep this leaf present so the environment override can apply; the overlay +# cannot create a missing configuration leaf. The Server-Timing header stays +# off until enabled. +server_timing_enabled = false +# Keep selected first path segments in access-telemetry route templates. +# Set this array in TOML; the environment overlay cannot set arrays. +# Use ASCII names; "other" shares the fallback bucket. Matching uses the raw, +# percent-encoded path, so literal non-ASCII names do not match encoded URLs. +# route_sections = ["news"] + # Server-side ad slot templates + creative-opportunity auction. Kept active. [creative_opportunities] # Set false to disable server-side ad templates while keeping slot definitions @@ -466,13 +477,19 @@ auction_timeout_ms = 500 # "/news/x" -> /123456789/example/news # "/reviews/y" -> /123456789/example/reviews -# Direct Tinybird auction telemetry (off by default). When enabled, `api_host` +# Direct Tinybird auction/access telemetry (off by default). When enabled, `api_host` # is required and must be a bare host (no scheme or path). # [tinybird] # enabled = true # api_host = "api.us-east.tinybird.example" # required when enabled; host only -# auction_dataset = "auction_events" # Events API datasource name -# auction_token_secret = "tinybird_auction_append_token" # Key in trusted_server_secrets +# auction_dataset = "auction_events_raw" # auction Events API datasource +# auction_token_secret = "tinybird_auction_append_token" # key in the default secret store +# auction_enabled = true # emit auction telemetry +# access_enabled = true # positive sample rate required +# access_dataset = "access_logs_v2" # deploy this schema before enabling access +# access_token_secret = "tinybird_access_append_token" # key in the default secret store +# access_sample_rate = 0.05 # sample 5%; sink defaults off +# max_body_bytes = 1048576 # maximum NDJSON body, at least 1024 # Debug endpoints (all default false — never enable in production). # [debug]