diff --git a/.gitignore b/.gitignore
index 24b9e06aa1..4340873735 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,7 @@ src/*.html
# leftover local build artifacts (node_modules, target, dist) that remain on disk.
/crates/js/
/crates/integration-tests/
+
+# 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 c7a0eb1ecd..eb3def6744 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- 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 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.
- The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave.
@@ -43,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- 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 waits after delivery and can delay the next request in a reused Fastly sandbox. 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.
- 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 (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery.
- `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.
diff --git a/Cargo.lock b/Cargo.lock
index e915a0ea8b..a60f1d4eca 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -832,7 +832,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
@@ -1498,7 +1498,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"toml",
]
@@ -1506,7 +1506,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-axum"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"anyhow",
"async-trait",
@@ -1534,7 +1534,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-cloudflare"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"anyhow",
"async-trait",
@@ -1557,7 +1557,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-fastly"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"anyhow",
"async-stream",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-spin"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"anyhow",
"async-trait",
@@ -1613,7 +1613,7 @@ dependencies = [
[[package]]
name = "edgezero-cli"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"chrono",
"clap",
@@ -1638,7 +1638,7 @@ dependencies = [
[[package]]
name = "edgezero-core"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"anyhow",
"async-compression",
@@ -1669,7 +1669,7 @@ dependencies = [
[[package]]
name = "edgezero-macros"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b"
+source = "git+https://github.com/stackpop/edgezero?rev=ab444946fcacb1d44c020c6079abb6ba29231402#ab444946fcacb1d44c020c6079abb6ba29231402"
dependencies = [
"log",
"proc-macro2",
@@ -2557,7 +2557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595"
dependencies = [
"bstr",
- "hashbrown 0.17.1",
+ "hashbrown 0.16.1",
]
[[package]]
@@ -6546,6 +6546,7 @@ dependencies = [
"futures",
"log",
"log-fastly",
+ "rand 0.8.6",
"serde",
"serde_json",
"toml",
@@ -7210,7 +7211,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index f5c81ecf4b..c00b3a2659 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/stackpop/edgezero", rev = "683202c66948146ec360f490126f1d60f920288b", default-features = false }
-edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "683202c66948146ec360f490126f1d60f920288b", default-features = false }
-edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "683202c66948146ec360f490126f1d60f920288b", default-features = false }
-edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "683202c66948146ec360f490126f1d60f920288b", default-features = false }
-edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "683202c66948146ec360f490126f1d60f920288b" }
-edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "683202c66948146ec360f490126f1d60f920288b", default-features = false }
+# Temporary integration pin for EdgeZero PR #389. Before merging TS into main,
+# replace all six pins with the approved EdgeZero release tag and revalidate.
+edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
+edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
+edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
+edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
+edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402" }
+edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", 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 94ad02170c..1c0158e6fd 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 }
[dev-dependencies]
@@ -36,4 +38,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 caba714d68..7bd46b12e4 100644
--- a/crates/trusted-server-adapter-axum/src/app.rs
+++ b/crates/trusted-server-adapter-axum/src/app.rs
@@ -1,6 +1,7 @@
use core::future::Future;
use std::sync::Arc;
+use edgezero_adapter_axum::service::EdgeZeroAxumService;
use edgezero_core::app::Hooks;
use edgezero_core::context::RequestContext;
use edgezero_core::error::EdgeError;
@@ -615,16 +616,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
}
}
@@ -646,6 +641,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
diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs
index 2f15e566d2..b1d4c3dd8f 100644
--- a/crates/trusted-server-adapter-axum/src/lib.rs
+++ b/crates/trusted-server-adapter-axum/src/lib.rs
@@ -10,3 +10,6 @@ pub mod app;
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..db21ab3d86 100644
--- a/crates/trusted-server-adapter-axum/src/main.rs
+++ b/crates/trusted-server-adapter-axum/src/main.rs
@@ -1,7 +1,18 @@
-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::timing::TimingService;
/// Environment variable that sets the maximum log level.
const LOG_LEVEL_ENV: &str = "RUST_LOG";
@@ -37,14 +48,88 @@ 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 |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 +169,100 @@ 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"
+ );
+ }
+ }
+ }
+ }
#[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..16da24d126
--- /dev/null
+++ b/crates/trusted-server-adapter-axum/src/timing.rs
@@ -0,0 +1,518 @@
+//! 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};
+
+/// 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?;
+ 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_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 87b9567e7f..3a7445e059 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 crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware};
@@ -602,6 +603,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 a22b51982e..b6f049bd50 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 710523766e..ed66130f72 100644
--- a/crates/trusted-server-adapter-fastly/Cargo.toml
+++ b/crates/trusted-server-adapter-fastly/Cargo.toml
@@ -32,6 +32,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 d1da9b61fb..b87bdb38e3 100644
--- a/crates/trusted-server-adapter-fastly/src/app.rs
+++ b/crates/trusted-server-adapter-fastly/src/app.rs
@@ -100,6 +100,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::{
@@ -119,6 +120,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,
@@ -140,6 +142,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};
@@ -298,6 +301,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
// ---------------------------------------------------------------------------
@@ -359,6 +368,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.
@@ -414,13 +434,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()) {
@@ -441,7 +465,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()))
{
@@ -493,6 +517,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 {
@@ -526,6 +558,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);
@@ -576,7 +609,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),
@@ -650,7 +685,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,
@@ -733,12 +769,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
@@ -798,12 +836,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 {
@@ -831,9 +889,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.
@@ -899,7 +982,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)
}
@@ -923,7 +1009,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`
@@ -938,6 +1027,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");
@@ -959,6 +1049,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
}
@@ -967,6 +1058,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
}
@@ -1090,6 +1182,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
@@ -1112,21 +1208,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`
@@ -1136,6 +1236,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.
@@ -1143,11 +1244,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.
@@ -1155,6 +1258,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
@@ -1166,36 +1270,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.
@@ -1203,6 +1314,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
@@ -1213,21 +1325,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",
@@ -1236,16 +1356,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
+ })
+ })
}
}
@@ -1306,7 +1445,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,
+ ),
);
}
@@ -1366,10 +1510,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;
@@ -1379,6 +1524,7 @@ mod tests {
use edgezero_core::env_config::EnvConfig;
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;
@@ -1387,20 +1533,23 @@ mod tests {
use error_stack::Report;
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;
#[test]
@@ -1579,12 +1728,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)
@@ -1596,7 +1745,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,
),
@@ -1604,8 +1753,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
@@ -2292,6 +2448,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(
@@ -2732,6 +2996,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.test-publisher.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 {
@@ -3154,6 +3473,7 @@ mod tests {
req,
asset_route,
&effects,
+ trusted_server_core::geo::GeoLookupState::NotAttempted,
));
assert_eq!(
@@ -3197,6 +3517,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