From 7505ff7046dbf7c448b5fcde090b969fbbed9120 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 16:57:03 +0100 Subject: [PATCH 01/10] Auction telemetry is a module [analytics] selects `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the table's `enabled` line. Every other thing a deployment can switch on is selected that way, and telemetry was the one section named after its vendor and switched on by a flag. - `Settings::telemetry()` returns the module's settings only when the section selects it, and the Fastly sink is built from that, so the sink has no enabled flag of its own. - A section that selects nothing, or a module this build does not have, is refused when the settings load, and the table refuses a key it does not know. - The secret reference is `analytics.tinybird.auction_token_secret`. The references of a table nothing selects are cleared before secrets are resolved, so the refusal is what an operator sees and not a failed lookup. - A configuration that still carries `[tinybird]` is refused with directions. An earlier version wrote the table into every stored configuration, on or off, so the message says to push the configuration again with this version. - The messages about the table name it through one constant. - An empty or absent token reference is refused as the settings load, where an empty one becomes absent as they normalize, and at deploy by the declared secret field, so the deploy validation's own check of the token is removed. Both refusals are tested. The example configuration carries the new block, which a test uncomments and loads. --- .../src/tinybird.rs | 50 ++- crates/trusted-server-core/src/config.rs | 97 +++- .../trusted-server-core/src/config_payload.rs | 70 ++- .../src/inspect/config/tests.rs | 10 +- crates/trusted-server-core/src/settings.rs | 422 +++++++++++++++--- trusted-server.example.toml | 19 +- 6 files changed, 532 insertions(+), 136 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 7d9dd25c29..3737699e14 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -20,21 +20,18 @@ const TINYBIRD_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(2); const TINYBIRD_BETWEEN_BYTES_TIMEOUT: Duration = Duration::from_secs(2); const TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH: usize = 512; -/// Build the configured auction telemetry sink. +/// Build the auction telemetry sink, which sends only when `[analytics]` +/// selects the telemetry module. #[must_use] pub(crate) fn auction_sink_from_settings(settings: &Settings) -> Arc { - if settings.tinybird.enabled { - Arc::new(FastlyTinybirdAuctionTelemetrySink::new( - settings.tinybird.clone(), - )) - } else { - Arc::new(NoopAuctionTelemetrySink) + match settings.telemetry() { + Some(telemetry) => Arc::new(FastlyTinybirdAuctionTelemetrySink::new(telemetry.clone())), + None => Arc::new(NoopAuctionTelemetrySink), } } #[derive(Debug, Clone)] struct FastlyTinybirdAuctionTelemetrySink { - enabled: bool, target: TinybirdEventsTarget, } @@ -57,7 +54,7 @@ impl TinybirdEventsTarget { dataset: config.auction_dataset, append_token: config .auction_token_secret - .expect("should contain a resolved Tinybird auction token when enabled"), + .expect("should contain a resolved Tinybird auction token when selected"), uri, backend_spec, max_body_bytes: config.max_body_bytes, @@ -67,9 +64,7 @@ impl TinybirdEventsTarget { impl FastlyTinybirdAuctionTelemetrySink { fn new(config: TinybirdSettings) -> Self { - let enabled = config.enabled; Self { - enabled, target: TinybirdEventsTarget::from_config(config), } } @@ -149,16 +144,12 @@ impl FastlyTinybirdAuctionTelemetrySink { #[async_trait::async_trait(?Send)] impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { - fn is_enabled(&self) -> bool { - self.enabled - } - async fn emit_auction_events( &self, services: &RuntimeServices, batch: AuctionEventBatch, ) -> Result<(), Report> { - if !self.enabled || batch.is_empty() { + if batch.is_empty() { return Ok(()); } @@ -432,7 +423,6 @@ mod tests { fn enabled_config() -> TinybirdSettings { TinybirdSettings { - enabled: true, api_host: "api.us-east.aws.tinybird.co".to_owned(), secret_store: None, auction_dataset: "auction_events_raw".to_owned(), @@ -527,7 +517,7 @@ mod tests { } #[test] - fn disabled_sink_does_not_dispatch() { + fn settings_selecting_no_telemetry_build_a_sink_that_sends_nothing() { let backend = Arc::new(RecordingBackend::default()); let http_client = Arc::new(RecordingHttpClient::default()); let services = services( @@ -535,18 +525,17 @@ mod tests { Arc::clone(&http_client), HashMap::new(), ); - let mut config = enabled_config(); - config.enabled = false; - let sink = FastlyTinybirdAuctionTelemetrySink::new(config); + let sink = auction_sink_from_settings(&Settings::default()); + assert!(!sink.is_enabled(), "should build the no-op sink"); futures::executor::block_on( sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), ) - .expect("should ignore disabled sink"); + .expect("the no-op sink should accept the batch"); assert!( backend.specs.lock().expect("should lock specs").is_empty(), - "should not ensure backend when disabled" + "should not ensure a backend when nothing is selected" ); assert!( http_client @@ -554,10 +543,23 @@ mod tests { .lock() .expect("should lock recorded requests") .is_empty(), - "should not send when disabled" + "should not send when nothing is selected" ); } + #[test] + fn settings_selecting_the_telemetry_module_build_a_sink_that_sends() { + let mut settings = Settings::default(); + settings.analytics = Some(trusted_server_core::settings::AnalyticsConfig { + module: Some("tinybird".to_owned()), + tinybird: Some(enabled_config()), + }); + + let sink = auction_sink_from_settings(&settings); + + assert!(sink.is_enabled(), "should build the sink that sends"); + } + #[test] fn empty_batch_does_not_dispatch() { let backend = Arc::new(RecordingBackend::default()); diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index b4741cf148..f569874fc8 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -270,7 +270,11 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { false, ), field( - vec![optional_object("tinybird"), object("auction_token_secret")], + vec![ + optional_object("analytics"), + optional_object("tinybird"), + object("auction_token_secret"), + ], true, ), field( @@ -496,15 +500,6 @@ fn validate_secret_key_references( )?; } - if settings.tinybird.enabled { - let token = settings - .tinybird - .auction_token_secret - .as_ref() - .ok_or_else(|| missing_secret_key_reference("tinybird.auction_token_secret"))?; - validate_secret_key_reference("tinybird.auction_token_secret", token.expose())?; - } - // Each setting a selected module declares as naming a secret, where the // module's table puts it to use. for builder in crate::integrations::all_builders(extra_integrations) { @@ -582,7 +577,10 @@ mod tests { use crate::integrations::IntegrationRegistration; use crate::integrations::js_asset_proxy::JS_ASSET_PROXY_INTEGRATION_ID; use crate::redacted::Redacted; - use crate::settings::{ProxyAssetRoute, S3SigV4AuthConfig, TrustedClientIpConfig}; + use crate::settings::{ + AnalyticsConfig, ProxyAssetRoute, S3SigV4AuthConfig, TinybirdSettings, + TrustedClientIpConfig, + }; use crate::test_support::template::{template_with_resolved_required_secrets, uncomment_block}; use crate::test_support::tests::{ crate_test_settings_str, crate_test_settings_str_with_ec_section, select_hmac_module, @@ -728,13 +726,15 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn documented_tinybird_block_validates_when_uncommented() { - let toml = uncomment_block(&template_with_resolved_required_secrets(), "[tinybird]"); + fn documented_analytics_block_validates_when_uncommented() { + let toml = uncomment_block(&template_with_resolved_required_secrets(), "[analytics]"); let settings = Settings::from_toml(&toml) - .expect("uncommented [tinybird] with documented api_host should parse and validate"); + .expect("uncommented [analytics] with documented api_host should parse and validate"); assert!( - settings.tinybird.enabled && !settings.tinybird.api_host.is_empty(), - "tinybird should be enabled with a non-empty api_host" + settings + .telemetry() + .is_some_and(|telemetry| !telemetry.api_host.is_empty()), + "the selected telemetry module should have a non-empty api_host" ); } @@ -935,7 +935,7 @@ formats = [{ width = 300, height = 250 }] ("ec.partners[*].api_token".to_owned(), true), ("ec.partners[*].ts_pull_token".to_owned(), true), ("trusted_client_ip.shared_secret".to_owned(), false), - ("tinybird.auction_token_secret".to_owned(), true), + ("analytics.tinybird.auction_token_secret".to_owned(), true), ("proxy.asset_routes[*].auth.access_key_id".to_owned(), true), ( "proxy.asset_routes[*].auth.secret_access_key".to_owned(), @@ -984,10 +984,21 @@ formats = [{ width = 300, height = 250 }] assert_eq!(serialized["secret_access_key"], "secret_access_key"); } + /// An `[analytics]` section selecting the telemetry module with `settings`. + fn telemetry_selecting(settings: TinybirdSettings) -> AnalyticsConfig { + AnalyticsConfig { + module: Some("analytics.tinybird".to_string()), + tinybird: Some(settings), + } + } + #[test] fn legacy_static_secret_store_selectors_are_accepted_but_not_serialized() { let mut settings = valid_settings(); - settings.tinybird.secret_store = Some("legacy-tinybird-store".to_string()); + settings.analytics = Some(telemetry_selecting(TinybirdSettings { + secret_store: Some("legacy-tinybird-store".to_string()), + ..TinybirdSettings::default() + })); let mut route = ProxyAssetRoute::new( "/assets/", "https://examplebucket.s3.us-east-1.amazonaws.com", @@ -1016,8 +1027,10 @@ formats = [{ width = 300, height = 250 }] #[test] fn settings_debug_redacts_resolved_static_credentials() { let mut settings = valid_settings(); - settings.tinybird.auction_token_secret = - Some(Redacted::new("resolved-tinybird-secret".to_string())); + settings.analytics = Some(telemetry_selecting(TinybirdSettings { + auction_token_secret: Some(Redacted::new("resolved-tinybird-secret".to_string())), + ..TinybirdSettings::default() + })); settings .insert_module_config( "testing", @@ -1158,6 +1171,50 @@ gam_network_id = "99999" ); } + /// The telemetry module's token is a secret reference like every other, + /// so an empty or absent one is refused for deploy, by its path. + #[test] + fn app_config_new_rejects_a_telemetry_secret_key_reference_that_is_empty_or_absent() { + for (case, reference) in [ + ("an empty reference", Some(Redacted::new(String::new()))), + ( + "a whitespace reference", + Some(Redacted::new(" ".to_owned())), + ), + ("no reference", None), + ] { + let mut settings = valid_settings(); + settings.analytics = Some(telemetry_selecting(TinybirdSettings { + api_host: "api.tinybird.co".to_owned(), + auction_token_secret: reference, + ..TinybirdSettings::default() + })); + + let err = TrustedServerAppConfig::new(settings) + .err() + .unwrap_or_else(|| panic!("{case}: should refuse the telemetry secret reference")); + + assert!( + err.to_string() + .contains("analytics.tinybird.auction_token_secret"), + "{case}: the error should identify the telemetry secret reference: {err:?}" + ); + } + } + + #[test] + fn app_config_new_accepts_a_telemetry_secret_key_reference() { + let mut settings = valid_settings(); + settings.analytics = Some(telemetry_selecting(TinybirdSettings { + api_host: "api.tinybird.co".to_owned(), + auction_token_secret: Some(Redacted::new("tinybird_auction_token".to_owned())), + ..TinybirdSettings::default() + })); + + TrustedServerAppConfig::new(settings) + .expect("should validate the token's key name without treating it as the value"); + } + #[test] fn app_config_new_rejects_a_resolve_allowed_origin_that_is_not_bare() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 0fc7a317da..7bf58a78cc 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -105,16 +105,17 @@ pub fn settings_from_config_blob_with( } 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") + // The telemetry module runs when [analytics] selects it. A table the + // section does not select is refused once the settings are read, and + // clearing its references here means that refusal is what an operator + // sees rather than a secret lookup failing first. + if !crate::settings::document_selects_telemetry(data) + && let Some(table) = data + .pointer_mut("/analytics/tinybird") .and_then(serde_json::Value::as_object_mut) { - tinybird.remove("auction_token_secret"); - tinybird.remove("access_token_secret"); + table.remove("auction_token_secret"); + table.remove("access_token_secret"); } if let Some(partners) = data @@ -144,7 +145,8 @@ mod tests { use crate::platform::{PlatformError, StoreId}; use crate::redacted::Redacted; use crate::settings::{ - AssetOriginAuth, EcPartner, ProxyAssetRoute, S3SigV4AuthConfig, TrustedClientIpConfig, + AnalyticsConfig, AssetOriginAuth, EcPartner, ProxyAssetRoute, S3SigV4AuthConfig, + TinybirdSettings, TrustedClientIpConfig, }; use crate::test_support::tests::{ crate_test_settings_str, hmac_passphrase, select_hmac_module, select_host_signals_module, @@ -510,10 +512,14 @@ mod tests { #[test] fn resolves_all_static_credentials_from_the_mapped_default_store() { let mut original = test_settings(); - original.tinybird.enabled = true; - original.tinybird.api_host = "api.example.com".to_string(); - original.tinybird.auction_token_secret = - Some(Redacted::new("tinybird-token-key".to_string())); + original.analytics = Some(AnalyticsConfig { + module: Some("analytics.tinybird".to_string()), + tinybird: Some(TinybirdSettings { + api_host: "api.example.com".to_string(), + auction_token_secret: Some(Redacted::new("tinybird-token-key".to_string())), + ..TinybirdSettings::default() + }), + }); let mut route = ProxyAssetRoute::new( "/assets/", "https://examplebucket.s3.us-east-1.amazonaws.com", @@ -541,9 +547,8 @@ mod tests { assert_eq!( reconstructed - .tinybird - .auction_token_secret - .as_ref() + .telemetry() + .and_then(|telemetry| telemetry.auction_token_secret.as_ref()) .map(Redacted::expose) .map(String::as_str), Some("resolved-tinybird-token") @@ -875,8 +880,6 @@ mod tests { #[test] fn inactive_optional_features_do_not_resolve_stale_secret_references() { let mut original = test_settings(); - original.tinybird.auction_token_secret = - Some(Redacted::new("unused-tinybird-key".to_string())); original .ec .partners @@ -889,10 +892,39 @@ mod tests { ) .expect("should skip inactive optional feature references"); - assert!(reconstructed.tinybird.auction_token_secret.is_none()); assert!(reconstructed.ec.partners[0].ts_pull_token.is_none()); } + #[test] + fn an_unselected_telemetry_table_is_refused_without_resolving_its_secret() { + let mut original = test_settings(); + original.analytics = Some(AnalyticsConfig { + module: None, + tinybird: Some(TinybirdSettings { + api_host: "api.example.com".to_string(), + auction_token_secret: Some(Redacted::new("unused-telemetry-key".to_string())), + ..TinybirdSettings::default() + }), + }); + + let error = settings_from_config_blob( + &envelope_json(&original), + &UnifiedSecretStore, + &StoreName::from("ts_secrets"), + ) + .expect_err("should refuse a table nothing selects"); + let rendered = format!("{error:?}"); + + assert!( + rendered.contains("[analytics] selects no module"), + "should name the section and why: {rendered}" + ); + assert!( + !rendered.contains("unused-telemetry-key") && !rendered.contains("secret reference"), + "should not have tried to resolve the stale secret reference: {rendered}" + ); + } + /// A table for a module its section does not select is refused, and its /// secret references are dropped before resolution, so the operator reads /// the table's own fault rather than a secret-store failure that follows diff --git a/crates/trusted-server-core/src/inspect/config/tests.rs b/crates/trusted-server-core/src/inspect/config/tests.rs index 33c8f5a1ec..106d665eb3 100644 --- a/crates/trusted-server-core/src/inspect/config/tests.rs +++ b/crates/trusted-server-core/src/inspect/config/tests.rs @@ -234,10 +234,12 @@ fn documents_holding_every_declared_secret() -> Vec<(&'static str, Value)> { "session_token": "route-session", }, }]); - main["tinybird"] = json!({ - "enabled": true, - "api_host": "api.tinybird.example.com", - "auction_token_secret": "telemetry", + main["analytics"] = json!({ + "module": "tinybird", + "tinybird": { + "api_host": "api.tinybird.example.com", + "auction_token_secret": "telemetry", + }, }); let mut host_signals = document(""); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index fdee0e50c5..7b5f11251c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2608,12 +2608,15 @@ impl Proxy { } } -/// Direct Tinybird Events API telemetry configuration. +/// The table holding the auction telemetry module's settings, which every +/// message about them names. +const TELEMETRY_TABLE: &str = "analytics.tinybird"; + +/// Direct Tinybird Events API telemetry configuration, the settings of the +/// module `[analytics]` selects. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct TinybirdSettings { - /// Master enablement for auction telemetry ingestion. - #[serde(default)] - pub enabled: bool, /// Regional Tinybird API host, without scheme or path. #[serde(default)] pub api_host: String, @@ -2661,7 +2664,6 @@ fn default_tinybird_max_body_bytes() -> usize { impl Default for TinybirdSettings { fn default() -> Self { Self { - enabled: false, api_host: String::new(), secret_store: None, auction_dataset: default_tinybird_auction_dataset(), @@ -2680,7 +2682,7 @@ impl TinybirdSettings { self.api_host = self.api_host.trim().to_ascii_lowercase(); if self.secret_store.take().is_some() { log::warn!( - "tinybird.secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" + "{TELEMETRY_TABLE}.secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" ); } self.auction_dataset = self.auction_dataset.trim().to_owned(); @@ -2696,32 +2698,39 @@ impl TinybirdSettings { self.normalize(); if !(0.0..=1.0).contains(&self.access_sample_rate) { return Err(Report::new(TrustedServerError::Configuration { - message: "tinybird.access_sample_rate must be between 0.0 and 1.0".to_owned(), + message: format!( + "{TELEMETRY_TABLE}.access_sample_rate must be between 0.0 and 1.0" + ), })); } if self.max_body_bytes < 1024 { return Err(Report::new(TrustedServerError::Configuration { - message: "tinybird.max_body_bytes must be at least 1024".to_owned(), + message: format!("{TELEMETRY_TABLE}.max_body_bytes must be at least 1024"), })); } if self.access_enabled { return Err(Report::new(TrustedServerError::Configuration { - message: "tinybird.access_enabled is reserved for future access-log telemetry; no emitter is currently wired".to_owned(), + message: format!( + "{TELEMETRY_TABLE}.access_enabled is reserved for future access-log telemetry; no emitter is currently wired" + ), })); } - if !self.enabled { - return Ok(()); - } validate_tinybird_api_host(&self.api_host)?; - validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; + validate_tinybird_dataset( + &self.auction_dataset, + &format!("{TELEMETRY_TABLE}.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(), + message: format!( + "{TELEMETRY_TABLE}.auction_token_secret is required when [analytics] selects the module" + ), }) })?; - validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret") + validate_tinybird_secret( + token.expose(), + &format!("{TELEMETRY_TABLE}.auction_token_secret"), + ) } } @@ -2734,13 +2743,14 @@ fn validate_tinybird_api_host(host: &str) -> Result<(), Report Result<(), Report, + /// The telemetry module's settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tinybird: Option, +} + +/// Whether `written`, selected in `[analytics]`, names the telemetry module, +/// in full or by its name within the section. +fn names_telemetry(written: &str) -> bool { + written == TELEMETRY_TABLE + || written == crate::module_name::short_form("analytics", TELEMETRY_TABLE) +} + +/// Whether the document `data` selects the telemetry module in `[analytics]`, +/// read before the settings are, to tell which secret references are live. +pub(crate) fn document_selects_telemetry(data: &JsonValue) -> bool { + data.pointer("/analytics/module") + .and_then(JsonValue::as_str) + .is_some_and(names_telemetry) +} + +impl AnalyticsConfig { + /// Whether `module` selects the telemetry module. + fn selects_telemetry(&self) -> bool { + self.module.as_deref().is_some_and(names_telemetry) + } + + /// The telemetry module's settings, when `module` selects it. + #[must_use] + pub fn telemetry(&self) -> Option<&TinybirdSettings> { + if self.selects_telemetry() { + self.tinybird.as_ref() + } else { + None + } + } + + fn normalize(&mut self) { + if let Some(settings) = &mut self.tinybird { + settings.normalize(); + } + } + + /// Refuses a section that selects nothing or a module this build does + /// not have, and checks the selected module's settings. A table the + /// selection does not name cannot pass, because the one table there is + /// can only be named by selecting its module. + fn prepare_runtime(&mut self) -> Result<(), Report> { + let short = crate::module_name::short_form("analytics", TELEMETRY_TABLE); + match self.module.as_deref() { + None => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[analytics] selects no module. Select one with [analytics] module = \ + \"\", or remove the section. The analytics modules this build \ + has are: {short}" + ), + })); + } + Some(written) if !self.selects_telemetry() => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[analytics] module names `{written}`, which this build does not have. \ + The analytics modules it has are: {short}" + ), + })); + } + Some(_) => {} + } + match &mut self.tinybird { + Some(settings) => settings.prepare_runtime(), + // Checking the defaults reports the first setting the module + // needs and the table does not have. + None => TinybirdSettings::default().prepare_runtime(), + } + } +} + /// Cache behavior configuration. #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -3485,6 +3584,15 @@ refused_table! { `--secret-store-id`" } +refused_table! { + /// The `[tinybird]` table, now `[analytics.tinybird]`. + MovedTinybirdTable => "Configuration table `[tinybird]` is now `[analytics.tinybird]`. \ + Select it with `[analytics] module = \"tinybird\"`, which replaces its `enabled` \ + line, and move its other settings there unchanged. A stored configuration written by \ + an earlier version carries the table even where telemetry was off, so push the \ + configuration again with this version" +} + refused_table! { /// The `[permission_signal]` table, renamed `[permission-signal]`. RenamedPermissionSignalTable => "Configuration table `[permission_signal]` is now \ @@ -3843,8 +3951,19 @@ pub struct Settings { pub creative_opportunities: Option, #[serde(default)] pub image_optimizer: ImageOptimizerSettings, - #[serde(default)] - pub tinybird: TinybirdSettings, + /// Analytics. `[analytics] module` selects the module that runs, with its + /// settings in the table at its name. Omitted from a serialized + /// configuration when absent, for the reason given on `trusted_client_ip`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub analytics: Option, + /// The `[tinybird]` table, kept so a configuration carrying it is told + /// where it moved. + #[serde(rename = "tinybird", default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the moved table fails with directions" + )] + moved_tinybird: MovedTinybirdTable, #[serde(default)] pub debug: DebugConfig, #[serde(default, skip_serializing_if = "is_default_device_config")] @@ -3978,12 +4097,20 @@ impl Settings { Self::finalize_deserialized(settings, "Build-time configuration") } + /// The auction telemetry module's settings, when `[analytics]` selects it. + #[must_use] + pub fn telemetry(&self) -> Option<&TinybirdSettings> { + self.analytics.as_ref().and_then(AnalyticsConfig::telemetry) + } + pub(crate) fn normalize_deserialized(&mut self) { self.cache.normalize(); self.proxy.normalize(); self.image_optimizer.normalize(); self.debug.auction_html_comment_options.normalize(); - self.tinybird.normalize(); + if let Some(analytics) = &mut self.analytics { + analytics.normalize(); + } self.consent.validate(); } @@ -4046,7 +4173,9 @@ impl Settings { self.image_optimizer.prepare_runtime()?; self.cache.prepare_runtime()?; self.proxy.prepare_runtime()?; - self.tinybird.prepare_runtime()?; + if let Some(analytics) = &mut self.analytics { + analytics.prepare_runtime()?; + } self.debug .auction_html_comment_options .validate_metadata_keys()?; @@ -5158,18 +5287,22 @@ module = \"none\"", })); settings.proxy.asset_routes = vec![asset_route]; - settings.tinybird = TinybirdSettings { - 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, - api_host: String::new(), - secret_store: None, - auction_dataset: String::new(), - access_enabled: false, - access_dataset: String::new(), - access_sample_rate: 0.0f64, - max_body_bytes: 0, - }; + settings.analytics = Some(AnalyticsConfig { + module: Some(TELEMETRY_TABLE.to_string()), + tinybird: Some(TinybirdSettings { + auction_token_secret: Some(Redacted::new( + CANARY_TINYBIRD_AUCTION_TOKEN.to_string(), + )), + access_token_secret: Some(Redacted::new(CANARY_TINYBIRD_ACCESS_TOKEN.to_string())), + api_host: String::new(), + secret_store: None, + auction_dataset: String::new(), + access_enabled: false, + access_dataset: String::new(), + access_sample_rate: 0.0f64, + max_body_bytes: 0, + }), + }); // A module's table is opaque JSON, and the section's hand-written // `Debug` impl is the only thing keeping a secret a module's table @@ -5220,10 +5353,13 @@ module = \"none\"", CANARY_S3_SESSION_TOKEN, ), ( - "tinybird.auction_token_secret", + "analytics.tinybird.auction_token_secret", CANARY_TINYBIRD_AUCTION_TOKEN, ), - ("tinybird.access_token_secret", CANARY_TINYBIRD_ACCESS_TOKEN), + ( + "analytics.tinybird.access_token_secret", + CANARY_TINYBIRD_ACCESS_TOKEN, + ), ("testing.example.key_name", CANARY_MODULE_KEY), ]; @@ -5978,61 +6114,225 @@ module = \"none\"", ); } + /// `[analytics]` selecting the telemetry module, with `table` as the + /// lines of its settings table. + fn settings_str_with_telemetry(table: &str) -> String { + format!( + "{}\n[analytics]\nmodule = \"tinybird\"\n\n[analytics.tinybird]\n{table}\n", + crate_test_settings_str() + ) + } + + /// The lines of a telemetry table that passes every check. + const TELEMETRY_LINES: &str = + "api_host = \"api.us-east.aws.tinybird.co\"\nauction_token_secret = \"test-auction-token\""; + #[test] - fn tinybird_defaults_to_disabled_placeholders() { + fn settings_without_an_analytics_section_select_no_telemetry() { let settings = Settings::from_toml(&crate_test_settings_str()) - .expect("should parse settings without tinybird block"); + .expect("should parse settings without an analytics section"); + + assert!(settings.analytics.is_none(), "should hold no section"); + assert!(settings.telemetry().is_none(), "should select no telemetry"); + let value = serde_json::to_value(&settings).expect("should serialize the settings"); + assert!( + value.get("analytics").is_none() && value.get("tinybird").is_none(), + "should write neither the section nor the table it replaced: {value}" + ); + } + + #[test] + fn the_telemetry_module_is_selected_by_its_short_or_full_name() { + for name in ["tinybird", "analytics.tinybird"] { + let toml = format!( + "{}\n[analytics]\nmodule = \"{name}\"\n\n[analytics.tinybird]\n{TELEMETRY_LINES}\n", + crate_test_settings_str() + ); + + let settings = Settings::from_toml(&toml).expect("should accept the selection"); + + let telemetry = settings + .telemetry() + .expect("should select the telemetry module"); + assert_eq!(telemetry.api_host, "api.us-east.aws.tinybird.co"); + assert_eq!(telemetry.auction_dataset, "auction_events_raw"); + } + } + #[test] + fn a_selected_telemetry_module_survives_the_stored_form() { + let settings = Settings::from_toml(&settings_str_with_telemetry(TELEMETRY_LINES)) + .expect("should accept the selection"); + + let stored = serde_json::to_value(&settings).expect("should serialize the settings"); + + assert_eq!(stored["analytics"]["module"], "tinybird"); + assert_eq!( + stored["analytics"]["tinybird"]["api_host"], + "api.us-east.aws.tinybird.co" + ); assert!( - !settings.tinybird.enabled, - "Tinybird should default disabled" + stored.get("tinybird").is_none(), + "should not write the table that moved: {stored}" ); - assert_eq!(settings.tinybird.secret_store, None); - assert_eq!(settings.tinybird.auction_dataset, "auction_events_raw"); - assert!(settings.tinybird.auction_token_secret.is_none()); } #[test] - fn tinybird_enabled_requires_host_dataset_and_token() { + fn the_telemetry_module_needs_a_regional_host() { + let toml = settings_str_with_telemetry("api_host = \"https://api.example.com/path\""); + + let err = Settings::from_toml(&toml).expect_err("should reject invalid api host"); + + assert!( + format!("{err:?}").contains("analytics.tinybird.api_host"), + "should name the setting by its table: {err:?}" + ); + } + + #[test] + fn the_telemetry_module_needs_its_token() { + let toml = settings_str_with_telemetry("api_host = \"api.us-east.aws.tinybird.co\""); + + let err = Settings::from_toml(&toml).expect_err("should require the token"); + + assert!( + format!("{err:?}").contains( + "analytics.tinybird.auction_token_secret is required when [analytics] selects \ + the module" + ), + "should say what is missing and why: {err:?}" + ); + } + + #[test] + fn a_selected_telemetry_module_with_no_table_is_told_what_it_needs() { let toml = format!( - "{}\n[tinybird]\nenabled = true\napi_host = \"https://api.example.com/path\"\n", + "{}\n[analytics]\nmodule = \"tinybird\"\n", crate_test_settings_str() ); - let err = Settings::from_toml(&toml).expect_err("should reject invalid api host"); + let err = Settings::from_toml(&toml).expect_err("should need the table's settings"); + + assert!( + format!("{err:?}").contains("analytics.tinybird.api_host"), + "should name the first setting the module needs: {err:?}" + ); + } + + #[test] + fn access_telemetry_is_rejected_until_an_emitter_is_wired() { + let toml = + settings_str_with_telemetry(&format!("{TELEMETRY_LINES}\naccess_enabled = true")); + + let err = Settings::from_toml(&toml) + .expect_err("should reject access telemetry before an emitter exists"); + assert!( - format!("{err:?}").contains("tinybird.api_host"), - "should report tinybird.api_host validation error: {err:?}" + format!("{err:?}").contains("analytics.tinybird.access_enabled"), + "should name the unsupported setting: {err:?}" ); } #[test] - fn tinybird_accepts_region_host_without_scheme() { + fn an_analytics_section_selecting_nothing_is_refused() { let toml = format!( - "{}\n[tinybird]\nenabled = true\napi_host = \"api.us-east.aws.tinybird.co\"\nauction_token_secret = \"test-auction-token\"\n", + "{}\n[analytics.tinybird]\n{TELEMETRY_LINES}\n", crate_test_settings_str() ); - let settings = Settings::from_toml(&toml).expect("should accept Tinybird region host"); - assert!(settings.tinybird.enabled); - assert_eq!(settings.tinybird.api_host, "api.us-east.aws.tinybird.co"); + let err = Settings::from_toml(&toml).expect_err("nothing would read the table"); + + assert!( + format!("{err:?}").contains("[analytics] selects no module"), + "should say the section selects nothing: {err:?}" + ); } #[test] - fn tinybird_access_enabled_is_rejected_until_emitter_is_wired() { + fn an_analytics_module_this_build_does_not_have_is_refused() { let toml = format!( - "{}\n[tinybird]\naccess_enabled = true\n", + "{}\n[analytics]\nmodule = \"another\"\n", crate_test_settings_str() ); - let err = Settings::from_toml(&toml) - .expect_err("should reject access telemetry before emitter exists"); + let err = Settings::from_toml(&toml).expect_err("no such module"); + let message = format!("{err:?}"); + + assert!( + message.contains("[analytics] module names `another`, which this build does not have"), + "should name what was written: {message}" + ); + assert!( + message.contains("has are: tinybird"), + "should list what can be selected: {message}" + ); + } + + /// The token is a secret reference, and an empty or absent one is refused + /// as the settings load, before any deploy step sees the document. + #[test] + fn a_telemetry_token_reference_that_is_empty_or_absent_is_refused_when_the_settings_load() { + for (case, table) in [ + ( + "an empty token", + "api_host = \"api.us-east.aws.tinybird.co\"\nauction_token_secret = \"\"", + ), + ("no token", "api_host = \"api.us-east.aws.tinybird.co\""), + ] { + let err = Settings::from_toml(&settings_str_with_telemetry(table)) + .err() + .unwrap_or_else(|| panic!("{case}: the settings should not load")); + + assert!( + format!("{err:?}").contains("auction_token_secret"), + "{case}: should name the token: {err:?}" + ); + } + } + + #[test] + fn a_telemetry_table_refuses_a_key_it_does_not_know() { + let toml = settings_str_with_telemetry(&format!("{TELEMETRY_LINES}\nenabled = true")); + + let err = Settings::from_toml(&toml).expect_err("the enabled line is gone"); + + assert!( + format!("{err:?}").contains("enabled"), + "should name the key it does not know: {err:?}" + ); + } + + #[test] + fn a_table_the_section_does_not_select_gives_no_telemetry() { + let analytics = AnalyticsConfig { + module: None, + tinybird: Some(TinybirdSettings::default()), + }; + assert!( - format!("{err:?}").contains("tinybird.access_enabled"), - "should report unsupported tinybird.access_enabled setting: {err:?}" + analytics.telemetry().is_none(), + "a table nothing selects should give no settings" ); } + #[test] + fn the_tinybird_table_is_refused_with_directions() { + for table in [ + "enabled = true\napi_host = \"api.us-east.aws.tinybird.co\"", + "enabled = false", + ] { + let toml = format!("{}\n[tinybird]\n{table}\n", crate_test_settings_str()); + + let err = Settings::from_toml(&toml).expect_err("the table has moved"); + + assert!( + format!("{err:?}") + .contains("Configuration table `[tinybird]` is now `[analytics.tinybird]`"), + "should say where the table went: {err:?}" + ); + } + } + #[test] fn settings_rejects_removed_consent_store_toml() { let toml = format!( diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 5b5101ed84..dc419b14da 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -28,9 +28,10 @@ # setting = "value" # # The types are ec, geo, device, permission-signal, demand, ad-server, -# robots-txt and integration. A settings table its type's selector does not -# name refuses startup, so there is no stale block to misread. The complete -# rules, and what is checked when, are in docs/guide/configuration-rules.md. +# robots-txt, analytics and integration. A settings table its type's selector +# does not name refuses startup, so there is no stale block to misread. The +# complete rules, and what is checked when, are in +# docs/guide/configuration-rules.md. # # `TRUSTED_SERVER__` env vars can override values when the `ts` CLI builds and # validates this config for push (e.g. TRUSTED_SERVER__PUBLISHER__DOMAIN=...; @@ -634,11 +635,13 @@ 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` -# 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 +# Direct Tinybird auction telemetry, sent only when [analytics] selects it. +# `api_host` is required and must be a bare host (no scheme or path). +# [analytics] +# module = "tinybird" +# +# [analytics.tinybird] +# api_host = "api.us-east.tinybird.example" # required; host only # auction_dataset = "auction_events" # Events API datasource name # auction_token_secret = "tinybird_auction_append_token" # Key in trusted_server_secrets From 75fc6977e16bfdaadcdc38346ab600e88f7df911 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 16:57:04 +0100 Subject: [PATCH 02/10] The guides describe [analytics] The configuration guide's Tinybird section becomes the analytics section, with the selector, what is refused and where `[tinybird]` went. The telemetry guide, the configuration rules and the Tinybird project's README name the settings by their new table, and the changelog records the change as breaking. --- CHANGELOG.md | 1 + docs/guide/configuration-rules.md | 10 +++-- docs/guide/configuration.md | 72 ++++++++++++++++++++----------- docs/guide/telemetry.md | 17 ++++---- tinybird/README.md | 5 ++- 5 files changed, 65 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c8b67ccb..47e8abb511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. - **Breaking:** `ts cache purge` asks the hosting platform to purge instead of calling the service. It takes `--service-id ` where it took `--service `, reads a Fastly API token from `FASTLY_API_TOKEN` where it read `TRUSTED_SERVER_ADMIN_PASSWORD`, and no longer takes `--username`. It runs `fastly service purge --key`, which needs Fastly CLI 14.0.0 or later, with the surrogate key it derives by the function the service tags its cached objects with, so `--all` and `--page ` purge exactly what they purged before. - **Breaking:** A module's change to a page runs only on the pages where a `[[fetch]]` or `[[serve]]` entry names its middleware, and no longer on every page of a deployment that selects the module. A deployment adds the entries when it upgrades. Until it does, a module changes no page, and startup logs a warning naming each middleware that no entry names. "Placing page changes" in the configuration guide lists the middleware each module supplies and the phase each runs in. To keep a page as it was, name the middleware of the modules a deployment selects in one `[[fetch]]` entry and one `[[serve]]` entry for `text/html`, in the order `trusted-server.example.toml` shows them, which is the order the modules' hooks ran in. diff --git a/docs/guide/configuration-rules.md b/docs/guide/configuration-rules.md index cc821a2a4b..236f06e1d4 100644 --- a/docs/guide/configuration-rules.md +++ b/docs/guide/configuration-rules.md @@ -10,7 +10,8 @@ request, and why the file is built this way. Everything Trusted Server can switch on is a module, selected the same way: the Edge Cookie identity, the location and device lookups, the permission signals, the demand sources in an auction, the ad server that picks the -winner, the rules in `robots.txt`, and the page integrations. +winner, the rules in `robots.txt`, where auction telemetry is sent, and the +page integrations. ```toml [] @@ -22,8 +23,8 @@ setting = "value" 1. **The type is the job.** Each type is one top-level table, named for what its modules do: `ec`, `geo`, `device`, `permission-signal`, `demand`, - `ad-server` and `robots-txt`, and the section of each module type, such as - `cmp` or `tag`. + `ad-server`, `robots-txt` and `analytics`, and the section of each module + type, such as `cmp` or `tag`. 2. **The selector chooses what runs.** A type that runs one module takes `module`, a string. A type that runs several takes `modules`, a list. 3. **`[.]` holds the settings.** A name with nothing to set @@ -53,6 +54,7 @@ setting = "value" | `demand` | several | `modules`, a list | `auction-protocol.openrtb`, `auction.prebid-server`, `auction.aps` | | `ad-server` | one | `module`, a string | `mock` | | `robots-txt` | several, in order | `modules`, a list | `refuse_all`, `allow_all`, or an integration that supplies robots.txt rules | +| `analytics` | one | `module`, a string | `tinybird` | | `cmp` | one | `module`, a string | `didomi`, `sourcepoint`, `osano` | | `tag` | several | `modules`, a list | `google-tag-manager` | | `ad-tag` | several | `modules`, a list | `google`, `google.diagnostics` | @@ -168,7 +170,7 @@ They keep their own keys and have no selector. | `[creative_opportunities]` | server-rendered ad slots | | `[proxy]`, `[cache]`, `[rewrite]` | first-party proxying, caching and URL rewriting | | `[request_signing]`, `[trusted_client_ip]` | signing and client addresses | -| `[debug]`, `[tinybird]` | diagnostics and telemetry | +| `[debug]` | diagnostics | ## Why the file works this way diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index fdb0dd3408..76efd5302a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -76,7 +76,7 @@ publisher, trusted-client-IP, EC, Tinybird, DataDome, and S3 fields: - `ec.host_signals.passphrase`, when `[ec] module = "host_signals"` - `ec.partners[*].api_token`, when inbound identify or batch sync is used - `ec.partners[*].ts_pull_token`, when pull sync is enabled -- `tinybird.auction_token_secret`, when Tinybird auction telemetry is enabled +- `analytics.tinybird.auction_token_secret`, when `[analytics] module = "tinybird"` - `bot-protection.datadome.server_side_key_secret_name`, when protection is enabled - `bot-protection.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` @@ -90,9 +90,9 @@ Two accepted secret-shaped fields are deliberately different: - `trusted_client_ip.shared_secret` is an inline value in the app-config blob; it is redacted by debug formatting but is not resolved from a secret store. -- `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. +- `analytics.tinybird.access_token_secret` is deprecated input. It is accepted + for migration, then discarded and omitted from serialized config; use + `analytics.tinybird.auction_token_secret` as the store key name instead. The four deprecated `secret_store` selectors under Tinybird, DataDome, its protection-test bypass, and S3 route authentication are also accepted and @@ -171,8 +171,10 @@ Tinybird uses the same typed secret-reference path as the other static credentials. Do not configure a feature-specific store: ```toml -[tinybird] -enabled = true +[analytics] +module = "tinybird" + +[analytics.tinybird] api_host = "api.example.com" auction_dataset = "auction_events_raw" auction_token_secret = "tinybird_auction_append_token" @@ -180,9 +182,9 @@ 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. -The legacy `tinybird.secret_store` field is accepted for one migration release, -but it is ignored and omitted from newly pushed config. +at startup. Settings that do not select the module neither require nor resolve +the token. The legacy `analytics.tinybird.secret_store` field is accepted for +one migration release, but it is ignored and omitted from newly pushed config. ### Generate Secure Secrets @@ -210,7 +212,7 @@ fail and the service will return its startup-error response. ## Key Sections -10 of these sections select what runs, with `module` where one runs and +11 of these sections select what runs, with `module` where one runs and `modules` where several run, and each gives every selected name its own `[.]` settings table, as [Configuration Rules](/guide/configuration-rules) describes. @@ -218,6 +220,7 @@ fail and the service will return its startup-error response. | Section | Selects | Purpose | | ----------------------------------------------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------- | | `[ad-server]` | one module | The ad server that picks the winner | +| `[analytics]` | one module | The module that receives auction telemetry | | `[auction]` | several modules | Auction orchestration, bidder routes, and the modules the auction runs, `prebid` among them | | `[cache]` | nothing | Static and rehosted asset cache policy | | `[consent]` | nothing | Consent interpretation, forwarding, and conflict resolution | @@ -239,7 +242,6 @@ fail and the service will return its startup-error response. | `[robots-txt]` | several modules | What `/robots.txt` answers, and `X-Robots-Tag` on its responses when every crawler is refused | | `[[serve]]` | nothing | Which page changes run on each reader's copy of a page, and in what order | | `[tester_cookie]` | nothing | Optional tester-cookie endpoints | -| `[tinybird]` | nothing | Direct Tinybird auction telemetry | | `[trusted_client_ip]` | nothing | Authenticated front-door client-IP forwarding | | `[]`, such as `[cmp]`, `[tag]`, `[ad-tag]`, `[bot-protection]`, `[identity]`, `[audience]` or `[framework]` | one module or several | The section of a module type, selecting the modules of that type that run | @@ -1453,30 +1455,48 @@ when_missing = "smart" See [Asset Routes](/guide/asset-routes) for request flow, S3 auth details, and Image Optimizer behavior. -## Tinybird Configuration +## Analytics Configuration + +`[analytics]` selects the module that receives auction telemetry, the way every +other section selects its module. `module = "tinybird"` sends auction events +directly to the Tinybird Events API, with its settings in +`[analytics.tinybird]`. Without the section nothing is sent. Access-log +emission is not wired. + +### `[analytics]` + +| Field | Type | Default | Contract | +| -------- | ------ | ------- | ------------------------------------------------------------------------------------------------ | +| `module` | String | none | Required when the section is present. `tinybird`, or the same name in full, `analytics.tinybird` | + +**Refused when the settings load**: a section that selects no module, a module +this build does not have, and a key `[analytics.tinybird]` does not know. -`[tinybird]` sends auction events directly to the Tinybird Events API. The -emitter is active only when `enabled = true`; access-log emission is not wired. +A configuration that still carries `[tinybird]` is refused with directions. +Move its settings to `[analytics.tinybird]` unchanged, and select the module +with `[analytics] module = "tinybird"`, which replaces the `enabled` line. A +stored configuration written by an earlier version carries the `[tinybird]` +table even where telemetry was off, so push the configuration again with this +version. -### `[tinybird]` +### `[analytics.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 | +| Field | Type | Default | Contract | +| ---------------------- | -------------- | ---------------------- | ---------------------------------------------------------- | +| `api_host` | String | `""` | Required; 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; 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`. -The complete enabled example appears in +The complete example appears in [Tinybird auction telemetry](#tinybird-auction-telemetry). ## Cache Configuration diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index a4ad08b93a..dfe762406e 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -16,8 +16,8 @@ request to the configured auction datasource. Emission is best effort. Failure to construct or start telemetry is logged and must not change the customer response. The sink drops the in-flight handle 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. +to 512 rows and `analytics.tinybird.max_body_bytes` bytes. The Fastly backend +uses two-second first-byte and between-byte timeouts. ## Privacy boundary @@ -35,17 +35,18 @@ misclassify them. ## Configuration -Set `tinybird.enabled = true`, provide the regional API host, select the -auction datasource, and set `auction_token_secret` to the key holding that +Select the module with `[analytics] module = "tinybird"`. In +`[analytics.tinybird]` 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. -`tinybird.access_enabled = true` is rejected because no access-log emitter is -wired. `access_dataset` and `access_sample_rate` are reserved. +`analytics.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. 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#analytics-configuration). The tracked +Tinybird project is described in [`tinybird/README.md`](https://github.com/IABTechLab/trusted-server/blob/main/tinybird/README.md). diff --git a/tinybird/README.md b/tinybird/README.md index c0a0e976ce..5b19c9b851 100644 --- a/tinybird/README.md +++ b/tinybird/README.md @@ -19,8 +19,9 @@ directory, run `tb test`, inspect `tb deploy --dry-run`, and deploy with Create an APPEND token scoped to `auction_events_raw`, store its value in the 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`. +`analytics.tinybird.auction_token_secret`. The runtime sends directly to the +regional Events API host configured by `analytics.tinybird.api_host`, when +`[analytics] module = "tinybird"` selects the module. For runtime behavior, limits, privacy properties, and the Fastly-only emission boundary, see [Auction Telemetry](../docs/guide/telemetry.md). For exact From 7de3882f1fe81294d7953b17af14a3b054034146 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:18:22 +0100 Subject: [PATCH 03/10] The analytics module is named analytics in every identifier The constant, accessors, helpers and tests this pull request added say analytics where they said telemetry, and the Tinybird test helper no longer carries the removed enabled key. The two [analytics] refusals use the words of the generic section and the registry. Comments that restated a rule, described a crates folder that does not exist yet, or gave upgrade advice the changelog already gives are cut. --- .../src/tinybird.rs | 18 +- crates/trusted-server-core/src/config.rs | 24 +-- .../trusted-server-core/src/config_payload.rs | 13 +- crates/trusted-server-core/src/settings.rs | 160 +++++++++--------- 4 files changed, 107 insertions(+), 108 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 3737699e14..31363181cb 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -21,10 +21,10 @@ const TINYBIRD_BETWEEN_BYTES_TIMEOUT: Duration = Duration::from_secs(2); const TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH: usize = 512; /// Build the auction telemetry sink, which sends only when `[analytics]` -/// selects the telemetry module. +/// selects the analytics module. #[must_use] pub(crate) fn auction_sink_from_settings(settings: &Settings) -> Arc { - match settings.telemetry() { + match settings.analytics_module() { Some(telemetry) => Arc::new(FastlyTinybirdAuctionTelemetrySink::new(telemetry.clone())), None => Arc::new(NoopAuctionTelemetrySink), } @@ -421,7 +421,7 @@ mod tests { .build() } - fn enabled_config() -> TinybirdSettings { + fn tinybird_settings() -> TinybirdSettings { TinybirdSettings { api_host: "api.us-east.aws.tinybird.co".to_owned(), secret_store: None, @@ -469,7 +469,7 @@ mod tests { Arc::clone(&http_client), HashMap::new(), ); - let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + let sink = FastlyTinybirdAuctionTelemetrySink::new(tinybird_settings()); futures::executor::block_on( sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), @@ -517,7 +517,7 @@ mod tests { } #[test] - fn settings_selecting_no_telemetry_build_a_sink_that_sends_nothing() { + fn settings_selecting_no_analytics_module_build_a_sink_that_sends_nothing() { let backend = Arc::new(RecordingBackend::default()); let http_client = Arc::new(RecordingHttpClient::default()); let services = services( @@ -548,11 +548,11 @@ mod tests { } #[test] - fn settings_selecting_the_telemetry_module_build_a_sink_that_sends() { + fn settings_selecting_the_analytics_module_build_a_sink_that_sends() { let mut settings = Settings::default(); settings.analytics = Some(trusted_server_core::settings::AnalyticsConfig { module: Some("tinybird".to_owned()), - tinybird: Some(enabled_config()), + tinybird: Some(tinybird_settings()), }); let sink = auction_sink_from_settings(&settings); @@ -569,7 +569,7 @@ mod tests { Arc::clone(&http_client), HashMap::new(), ); - let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + let sink = FastlyTinybirdAuctionTelemetrySink::new(tinybird_settings()); futures::executor::block_on( sink.emit_auction_events(&services, AuctionEventBatch::new(Vec::new())), @@ -602,7 +602,7 @@ mod tests { b"append-token".to_vec(), )]), ); - let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + let sink = FastlyTinybirdAuctionTelemetrySink::new(tinybird_settings()); let rows = vec![test_row(); TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH + 1]; let result = futures::executor::block_on( diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index c9d99beb86..69eacabce6 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -732,9 +732,9 @@ formats = [{ width = 300, height = 250 }] .expect("uncommented [analytics] with documented api_host should parse and validate"); assert!( settings - .telemetry() - .is_some_and(|telemetry| !telemetry.api_host.is_empty()), - "the selected telemetry module should have a non-empty api_host" + .analytics_module() + .is_some_and(|module| !module.api_host.is_empty()), + "the selected analytics module should have a non-empty api_host" ); } @@ -984,8 +984,8 @@ formats = [{ width = 300, height = 250 }] assert_eq!(serialized["secret_access_key"], "secret_access_key"); } - /// An `[analytics]` section selecting the telemetry module with `settings`. - fn telemetry_selecting(settings: TinybirdSettings) -> AnalyticsConfig { + /// An `[analytics]` section selecting the analytics module with `settings`. + fn analytics_selecting(settings: TinybirdSettings) -> AnalyticsConfig { AnalyticsConfig { module: Some("analytics.tinybird".to_string()), tinybird: Some(settings), @@ -995,7 +995,7 @@ formats = [{ width = 300, height = 250 }] #[test] fn legacy_static_secret_store_selectors_are_accepted_but_not_serialized() { let mut settings = valid_settings(); - settings.analytics = Some(telemetry_selecting(TinybirdSettings { + settings.analytics = Some(analytics_selecting(TinybirdSettings { secret_store: Some("legacy-tinybird-store".to_string()), ..TinybirdSettings::default() })); @@ -1027,7 +1027,7 @@ formats = [{ width = 300, height = 250 }] #[test] fn settings_debug_redacts_resolved_static_credentials() { let mut settings = valid_settings(); - settings.analytics = Some(telemetry_selecting(TinybirdSettings { + settings.analytics = Some(analytics_selecting(TinybirdSettings { auction_token_secret: Some(Redacted::new("resolved-tinybird-secret".to_string())), ..TinybirdSettings::default() })); @@ -1171,10 +1171,10 @@ gam_network_id = "99999" ); } - /// The telemetry module's token is a secret reference like every other, + /// The analytics module's token is a secret reference like every other, /// so an empty or absent one is refused for deploy, by its path. #[test] - fn app_config_new_rejects_a_telemetry_secret_key_reference_that_is_empty_or_absent() { + fn app_config_new_rejects_an_analytics_secret_key_reference_that_is_empty_or_absent() { for (case, reference) in [ ("an empty reference", Some(Redacted::new(String::new()))), ( @@ -1184,7 +1184,7 @@ gam_network_id = "99999" ("no reference", None), ] { let mut settings = valid_settings(); - settings.analytics = Some(telemetry_selecting(TinybirdSettings { + settings.analytics = Some(analytics_selecting(TinybirdSettings { api_host: "api.tinybird.co".to_owned(), auction_token_secret: reference, ..TinybirdSettings::default() @@ -1203,9 +1203,9 @@ gam_network_id = "99999" } #[test] - fn app_config_new_accepts_a_telemetry_secret_key_reference() { + fn app_config_new_accepts_an_analytics_secret_key_reference() { let mut settings = valid_settings(); - settings.analytics = Some(telemetry_selecting(TinybirdSettings { + settings.analytics = Some(analytics_selecting(TinybirdSettings { api_host: "api.tinybird.co".to_owned(), auction_token_secret: Some(Redacted::new("tinybird_auction_token".to_owned())), ..TinybirdSettings::default() diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 7bf58a78cc..e26bc765c9 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -105,11 +105,8 @@ pub fn settings_from_config_blob_with( } fn remove_inactive_secret_references(data: &mut serde_json::Value) { - // The telemetry module runs when [analytics] selects it. A table the - // section does not select is refused once the settings are read, and - // clearing its references here means that refusal is what an operator - // sees rather than a secret lookup failing first. - if !crate::settings::document_selects_telemetry(data) + // References of a table `[analytics]` does not select are cleared before resolution. + if !crate::settings::document_selects_analytics_module(data) && let Some(table) = data .pointer_mut("/analytics/tinybird") .and_then(serde_json::Value::as_object_mut) @@ -547,8 +544,8 @@ mod tests { assert_eq!( reconstructed - .telemetry() - .and_then(|telemetry| telemetry.auction_token_secret.as_ref()) + .analytics_module() + .and_then(|module| module.auction_token_secret.as_ref()) .map(Redacted::expose) .map(String::as_str), Some("resolved-tinybird-token") @@ -896,7 +893,7 @@ mod tests { } #[test] - fn an_unselected_telemetry_table_is_refused_without_resolving_its_secret() { + fn an_unselected_analytics_module_table_is_refused_without_resolving_its_secret() { let mut original = test_settings(); original.analytics = Some(AnalyticsConfig { module: None, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 7b5f11251c..be6ec6746f 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2608,9 +2608,9 @@ impl Proxy { } } -/// The table holding the auction telemetry module's settings, which every -/// message about them names. -const TELEMETRY_TABLE: &str = "analytics.tinybird"; +/// The analytics module's name in full, which is also the table holding its +/// settings and which every message about them names. +const ANALYTICS_MODULE: &str = "analytics.tinybird"; /// Direct Tinybird Events API telemetry configuration, the settings of the /// module `[analytics]` selects. @@ -2682,7 +2682,7 @@ impl TinybirdSettings { self.api_host = self.api_host.trim().to_ascii_lowercase(); if self.secret_store.take().is_some() { log::warn!( - "{TELEMETRY_TABLE}.secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" + "{ANALYTICS_MODULE}.secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" ); } self.auction_dataset = self.auction_dataset.trim().to_owned(); @@ -2699,37 +2699,37 @@ impl TinybirdSettings { if !(0.0..=1.0).contains(&self.access_sample_rate) { return Err(Report::new(TrustedServerError::Configuration { message: format!( - "{TELEMETRY_TABLE}.access_sample_rate must be between 0.0 and 1.0" + "{ANALYTICS_MODULE}.access_sample_rate must be between 0.0 and 1.0" ), })); } if self.max_body_bytes < 1024 { return Err(Report::new(TrustedServerError::Configuration { - message: format!("{TELEMETRY_TABLE}.max_body_bytes must be at least 1024"), + message: format!("{ANALYTICS_MODULE}.max_body_bytes must be at least 1024"), })); } if self.access_enabled { return Err(Report::new(TrustedServerError::Configuration { message: format!( - "{TELEMETRY_TABLE}.access_enabled is reserved for future access-log telemetry; no emitter is currently wired" + "{ANALYTICS_MODULE}.access_enabled is reserved for future access-log telemetry; no emitter is currently wired" ), })); } validate_tinybird_api_host(&self.api_host)?; validate_tinybird_dataset( &self.auction_dataset, - &format!("{TELEMETRY_TABLE}.auction_dataset"), + &format!("{ANALYTICS_MODULE}.auction_dataset"), )?; let token = self.auction_token_secret.as_ref().ok_or_else(|| { Report::new(TrustedServerError::Configuration { message: format!( - "{TELEMETRY_TABLE}.auction_token_secret is required when [analytics] selects the module" + "{ANALYTICS_MODULE}.auction_token_secret is required when [analytics] selects the module" ), }) })?; validate_tinybird_secret( token.expose(), - &format!("{TELEMETRY_TABLE}.auction_token_secret"), + &format!("{ANALYTICS_MODULE}.auction_token_secret"), ) } } @@ -2744,13 +2744,13 @@ fn validate_tinybird_api_host(host: &str) -> Result<(), Report Result<(), Report, - /// The telemetry module's settings. + /// The analytics module's settings. #[serde(default, skip_serializing_if = "Option::is_none")] pub tinybird: Option, } -/// Whether `written`, selected in `[analytics]`, names the telemetry module, +/// Whether `written`, selected in `[analytics]`, names the analytics module, /// in full or by its name within the section. -fn names_telemetry(written: &str) -> bool { - written == TELEMETRY_TABLE - || written == crate::module_name::short_form("analytics", TELEMETRY_TABLE) +fn names_analytics_module(written: &str) -> bool { + written == ANALYTICS_MODULE + || written == crate::module_name::short_form("analytics", ANALYTICS_MODULE) } -/// Whether the document `data` selects the telemetry module in `[analytics]`, +/// Whether the document `data` selects the analytics module in `[analytics]`, /// read before the settings are, to tell which secret references are live. -pub(crate) fn document_selects_telemetry(data: &JsonValue) -> bool { +pub(crate) fn document_selects_analytics_module(data: &JsonValue) -> bool { data.pointer("/analytics/module") .and_then(JsonValue::as_str) - .is_some_and(names_telemetry) + .is_some_and(names_analytics_module) } impl AnalyticsConfig { - /// Whether `module` selects the telemetry module. - fn selects_telemetry(&self) -> bool { - self.module.as_deref().is_some_and(names_telemetry) + /// Whether `module` selects the analytics module. + fn selects_analytics_module(&self) -> bool { + self.module.as_deref().is_some_and(names_analytics_module) } - /// The telemetry module's settings, when `module` selects it. + /// The analytics module's settings, when `module` selects it. #[must_use] - pub fn telemetry(&self) -> Option<&TinybirdSettings> { - if self.selects_telemetry() { + pub fn analytics_module(&self) -> Option<&TinybirdSettings> { + if self.selects_analytics_module() { self.tinybird.as_ref() } else { None @@ -2833,26 +2828,23 @@ impl AnalyticsConfig { } /// Refuses a section that selects nothing or a module this build does - /// not have, and checks the selected module's settings. A table the - /// selection does not name cannot pass, because the one table there is - /// can only be named by selecting its module. + /// not have, and checks the selected module's settings. fn prepare_runtime(&mut self) -> Result<(), Report> { - let short = crate::module_name::short_form("analytics", TELEMETRY_TABLE); + let short = crate::module_name::short_form("analytics", ANALYTICS_MODULE); match self.module.as_deref() { None => { return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "[analytics] selects no module. Select one with [analytics] module = \ - \"\", or remove the section. The analytics modules this build \ - has are: {short}" - ), + message: "[analytics] selects no module. A module type's section names \ + what runs with `module` or `modules`, so select one or remove \ + the section" + .to_owned(), })); } - Some(written) if !self.selects_telemetry() => { + Some(written) if !self.selects_analytics_module() => { return Err(Report::new(TrustedServerError::Configuration { message: format!( - "[analytics] module names `{written}`, which this build does not have. \ - The analytics modules it has are: {short}" + "[analytics] selects `{written}`, which no module this deployment \ + supplies. The analytics modules it supplies are [{short}]" ), })); } @@ -3588,9 +3580,7 @@ refused_table! { /// The `[tinybird]` table, now `[analytics.tinybird]`. MovedTinybirdTable => "Configuration table `[tinybird]` is now `[analytics.tinybird]`. \ Select it with `[analytics] module = \"tinybird\"`, which replaces its `enabled` \ - line, and move its other settings there unchanged. A stored configuration written by \ - an earlier version carries the table even where telemetry was off, so push the \ - configuration again with this version" + line, and move its other settings there unchanged" } refused_table! { @@ -4097,10 +4087,12 @@ impl Settings { Self::finalize_deserialized(settings, "Build-time configuration") } - /// The auction telemetry module's settings, when `[analytics]` selects it. + /// The analytics module's settings, when `[analytics]` selects it. #[must_use] - pub fn telemetry(&self) -> Option<&TinybirdSettings> { - self.analytics.as_ref().and_then(AnalyticsConfig::telemetry) + pub fn analytics_module(&self) -> Option<&TinybirdSettings> { + self.analytics + .as_ref() + .and_then(AnalyticsConfig::analytics_module) } pub(crate) fn normalize_deserialized(&mut self) { @@ -5288,7 +5280,7 @@ module = \"none\"", settings.proxy.asset_routes = vec![asset_route]; settings.analytics = Some(AnalyticsConfig { - module: Some(TELEMETRY_TABLE.to_string()), + module: Some(ANALYTICS_MODULE.to_string()), tinybird: Some(TinybirdSettings { auction_token_secret: Some(Redacted::new( CANARY_TINYBIRD_AUCTION_TOKEN.to_string(), @@ -6114,9 +6106,9 @@ module = \"none\"", ); } - /// `[analytics]` selecting the telemetry module, with `table` as the + /// `[analytics]` selecting the analytics module, with `table` as the /// lines of its settings table. - fn settings_str_with_telemetry(table: &str) -> String { + fn settings_str_with_analytics_module(table: &str) -> String { format!( "{}\n[analytics]\nmodule = \"tinybird\"\n\n[analytics.tinybird]\n{table}\n", crate_test_settings_str() @@ -6124,16 +6116,19 @@ module = \"none\"", } /// The lines of a telemetry table that passes every check. - const TELEMETRY_LINES: &str = + const ANALYTICS_MODULE_LINES: &str = "api_host = \"api.us-east.aws.tinybird.co\"\nauction_token_secret = \"test-auction-token\""; #[test] - fn settings_without_an_analytics_section_select_no_telemetry() { + fn settings_without_an_analytics_section_select_no_module() { let settings = Settings::from_toml(&crate_test_settings_str()) .expect("should parse settings without an analytics section"); assert!(settings.analytics.is_none(), "should hold no section"); - assert!(settings.telemetry().is_none(), "should select no telemetry"); + assert!( + settings.analytics_module().is_none(), + "should select no module" + ); let value = serde_json::to_value(&settings).expect("should serialize the settings"); assert!( value.get("analytics").is_none() && value.get("tinybird").is_none(), @@ -6142,27 +6137,28 @@ module = \"none\"", } #[test] - fn the_telemetry_module_is_selected_by_its_short_or_full_name() { + fn the_analytics_module_is_selected_by_its_short_or_full_name() { for name in ["tinybird", "analytics.tinybird"] { let toml = format!( - "{}\n[analytics]\nmodule = \"{name}\"\n\n[analytics.tinybird]\n{TELEMETRY_LINES}\n", + "{}\n[analytics]\nmodule = \"{name}\"\n\n[analytics.tinybird]\n{ANALYTICS_MODULE_LINES}\n", crate_test_settings_str() ); let settings = Settings::from_toml(&toml).expect("should accept the selection"); let telemetry = settings - .telemetry() - .expect("should select the telemetry module"); + .analytics_module() + .expect("should select the analytics module"); assert_eq!(telemetry.api_host, "api.us-east.aws.tinybird.co"); assert_eq!(telemetry.auction_dataset, "auction_events_raw"); } } #[test] - fn a_selected_telemetry_module_survives_the_stored_form() { - let settings = Settings::from_toml(&settings_str_with_telemetry(TELEMETRY_LINES)) - .expect("should accept the selection"); + fn a_selected_analytics_module_survives_the_stored_form() { + let settings = + Settings::from_toml(&settings_str_with_analytics_module(ANALYTICS_MODULE_LINES)) + .expect("should accept the selection"); let stored = serde_json::to_value(&settings).expect("should serialize the settings"); @@ -6178,8 +6174,9 @@ module = \"none\"", } #[test] - fn the_telemetry_module_needs_a_regional_host() { - let toml = settings_str_with_telemetry("api_host = \"https://api.example.com/path\""); + fn the_analytics_module_needs_a_regional_host() { + let toml = + settings_str_with_analytics_module("api_host = \"https://api.example.com/path\""); let err = Settings::from_toml(&toml).expect_err("should reject invalid api host"); @@ -6190,8 +6187,8 @@ module = \"none\"", } #[test] - fn the_telemetry_module_needs_its_token() { - let toml = settings_str_with_telemetry("api_host = \"api.us-east.aws.tinybird.co\""); + fn the_analytics_module_needs_its_token() { + let toml = settings_str_with_analytics_module("api_host = \"api.us-east.aws.tinybird.co\""); let err = Settings::from_toml(&toml).expect_err("should require the token"); @@ -6205,7 +6202,7 @@ module = \"none\"", } #[test] - fn a_selected_telemetry_module_with_no_table_is_told_what_it_needs() { + fn a_selected_analytics_module_with_no_table_is_told_what_it_needs() { let toml = format!( "{}\n[analytics]\nmodule = \"tinybird\"\n", crate_test_settings_str() @@ -6221,8 +6218,9 @@ module = \"none\"", #[test] fn access_telemetry_is_rejected_until_an_emitter_is_wired() { - let toml = - settings_str_with_telemetry(&format!("{TELEMETRY_LINES}\naccess_enabled = true")); + let toml = settings_str_with_analytics_module(&format!( + "{ANALYTICS_MODULE_LINES}\naccess_enabled = true" + )); let err = Settings::from_toml(&toml) .expect_err("should reject access telemetry before an emitter exists"); @@ -6236,7 +6234,7 @@ module = \"none\"", #[test] fn an_analytics_section_selecting_nothing_is_refused() { let toml = format!( - "{}\n[analytics.tinybird]\n{TELEMETRY_LINES}\n", + "{}\n[analytics.tinybird]\n{ANALYTICS_MODULE_LINES}\n", crate_test_settings_str() ); @@ -6259,11 +6257,13 @@ module = \"none\"", let message = format!("{err:?}"); assert!( - message.contains("[analytics] module names `another`, which this build does not have"), + message.contains( + "[analytics] selects `another`, which no module this deployment supplies" + ), "should name what was written: {message}" ); assert!( - message.contains("has are: tinybird"), + message.contains("The analytics modules it supplies are [tinybird]"), "should list what can be selected: {message}" ); } @@ -6271,7 +6271,7 @@ module = \"none\"", /// The token is a secret reference, and an empty or absent one is refused /// as the settings load, before any deploy step sees the document. #[test] - fn a_telemetry_token_reference_that_is_empty_or_absent_is_refused_when_the_settings_load() { + fn an_analytics_token_reference_that_is_empty_or_absent_is_refused_when_the_settings_load() { for (case, table) in [ ( "an empty token", @@ -6279,7 +6279,7 @@ module = \"none\"", ), ("no token", "api_host = \"api.us-east.aws.tinybird.co\""), ] { - let err = Settings::from_toml(&settings_str_with_telemetry(table)) + let err = Settings::from_toml(&settings_str_with_analytics_module(table)) .err() .unwrap_or_else(|| panic!("{case}: the settings should not load")); @@ -6291,8 +6291,10 @@ module = \"none\"", } #[test] - fn a_telemetry_table_refuses_a_key_it_does_not_know() { - let toml = settings_str_with_telemetry(&format!("{TELEMETRY_LINES}\nenabled = true")); + fn the_analytics_module_table_refuses_a_key_it_does_not_know() { + let toml = settings_str_with_analytics_module(&format!( + "{ANALYTICS_MODULE_LINES}\nenabled = true" + )); let err = Settings::from_toml(&toml).expect_err("the enabled line is gone"); @@ -6303,14 +6305,14 @@ module = \"none\"", } #[test] - fn a_table_the_section_does_not_select_gives_no_telemetry() { + fn a_table_the_section_does_not_select_gives_no_analytics_module() { let analytics = AnalyticsConfig { module: None, tinybird: Some(TinybirdSettings::default()), }; assert!( - analytics.telemetry().is_none(), + analytics.analytics_module().is_none(), "a table nothing selects should give no settings" ); } From cdb0ab1223c3ead1d29240ddbd1e3f0024663b0e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 08:54:48 +0100 Subject: [PATCH 04/10] Send no telemetry row about a reader who has not permitted ad measurement Every auction telemetry row describes the reader, with the complete User-Agent, the country, the region and the page path, and rows went out on every path the sink was on, including an auction the consent gate skipped as consent_denied. The observation now records whether the reader's permissions include MeasureAdPerformance, the Data Use TCF Purpose 7 maps to, and build_auction_events returns no row without it. Every path builds its rows there, so the /auction endpoint, the navigation and page-bids paths and the abandoned and collected auctions all check it. No new policy is needed, because the permission model already resolves the permission from each place's rules and the reader's signals. New tests drive the /auction endpoint, a navigation and page-bids with a reader the consent gate refuses and who has set no permission, and each sends no row. A unit test shows the builder gives no row for a skipped or a completed auction without the permission and a row with it alone, and the /auction consent test now gives its reader that permission, so a refused reader who permits measurement is still recorded. The test contexts with the gate open also permit measurement, so the existing telemetry tests keep their rows. --- .../src/auction/endpoints.rs | 61 +++++++++- .../src/auction/telemetry.rs | 74 +++++++++++- crates/trusted-server-core/src/ec/mod.rs | 15 ++- crates/trusted-server-core/src/publisher.rs | 112 +++++++++++++++++- 4 files changed, 254 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 56d858cb54..83291f1740 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -646,6 +646,7 @@ mod tests { use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingModule}; use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; + use crate::permissions::{Permission, PermissionSet, PermissionState}; use crate::platform::test_support::{ NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient, noop_services, @@ -1312,7 +1313,11 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); // The default consent context keeps the jurisdiction unknown, so the // server-side auction gate fails closed; the EC gate is off to match. - let mut ec_context = make_ec_context(false, Some(&ec_id)); + // The reader permits ad performance measurement alone, so the skip is + // still recorded. + let mut ec_context = make_ec_context(false, Some(&ec_id)).with_permissions_for_test( + PermissionState::new(PermissionSet::none().with(Permission::MeasureAdPerformance)), + ); let body = json!({ "adUnits": [ @@ -1378,6 +1383,60 @@ mod tests { ); } + #[tokio::test] + async fn a_consent_denied_auction_sends_no_row_without_the_ad_measurement_permission() { + // The server-side auction gate refuses this reader, who has set no + // permission at all. Every row describes the reader, so none is sent. + let settings = create_test_settings(); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + provider_names: vec!["panic_provider".to_string()], + timeout_ms: 2000, + adserver_name: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(PanicOnBidProvider)); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with_telemetry(Arc::clone(&telemetry_sink)); + let mut ec_context = make_ec_context(false, None); + let body = json!({ + "adUnits": [{ + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + }] + }); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .header(header::USER_AGENT, "FictionalBrowser/1.0 (FictionalOS)") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build auction request"); + + handle_auction( + &settings, + &orchestrator, + None, + None, + &mut ec_context, + &services, + req, + ) + .await + .expect("gated auction should still return a valid response"); + + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + assert!( + batches.is_empty(), + "should send no row about a reader who has not permitted ad measurement, got {:?}", + *batches + ); + } + /// Provider that records whether the auction request it received carried /// EIDs, then fails its launch so no real transport handle is needed. struct EidCapturingProvider { diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index 0beb1508e4..b9638dd3ad 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -1,7 +1,8 @@ //! Auction telemetry row construction and sink abstraction. //! //! Core owns the auction observation model and pure row builder. Platform -//! adapters provide the concrete sink implementation. +//! adapters provide the concrete sink implementation. Rows are built only for +//! a reader who permits measuring ad performance. use std::collections::HashSet; @@ -15,6 +16,7 @@ use crate::auction::orchestrator::OrchestrationResult; use crate::auction::types::{AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType}; use crate::ec::EcContext; use crate::error::TrustedServerError; +use crate::permissions::Permission; use crate::platform::RuntimeServices; const MAX_PAGE_PATH_BYTES: usize = 256; @@ -131,6 +133,9 @@ pub struct AuctionObservationContext { /// `Some(false)` — a dashboard that reads absence as "not shareable" will be wrong for /// every `/auction` row. pub origin_cache_shareable: Option, + /// Whether the reader permits measuring ad performance, which is what + /// every row does. See [`build_auction_events`]. + ad_measurement_permitted: bool, started_at: Instant, } @@ -198,6 +203,9 @@ impl AuctionObservationContext { consent_present: !consent.is_empty(), slot_count, origin_cache_shareable: None, + ad_measurement_permitted: ec_context + .permissions() + .is_set(Permission::MeasureAdPerformance), started_at: Instant::now(), } } @@ -237,6 +245,7 @@ impl AuctionObservationContext { consent_present: false, slot_count, origin_cache_shareable: None, + ad_measurement_permitted: true, started_at: Instant::now(), } } @@ -542,6 +551,10 @@ pub async fn emit_auction_events_best_effort_lazy( } /// Build auction telemetry rows for one terminal observation. +/// +/// Every row measures ad performance and describes the reader, so there is no +/// row at all for a reader whose permissions do not include +/// [`Permission::MeasureAdPerformance`], whatever the outcome. #[allow( clippy::needless_pass_by_value, reason = "call sites hand off terminal observations by value to keep ownership explicit" @@ -551,6 +564,9 @@ pub fn build_auction_events( observation: AuctionObservationContext, terminal: AuctionTerminalOutcome<'_>, ) -> AuctionEventBatch { + if !observation.ad_measurement_permitted { + return AuctionEventBatch::default(); + } let event_ts = current_event_timestamp(); let mut rows = Vec::new(); @@ -1166,6 +1182,62 @@ mod tests { ); } + #[test] + fn no_row_is_built_for_a_reader_who_has_not_permitted_ad_measurement() { + use crate::consent::types::ConsentContext; + use crate::permissions::{PermissionSet, PermissionState}; + + let refused = EcContext::new_for_test_gated(None, ConsentContext::default(), false); + let permitted = EcContext::new_for_test_gated(None, ConsentContext::default(), false) + .with_permissions_for_test(PermissionState::new( + PermissionSet::none().with(Permission::MeasureAdPerformance), + )); + let observation = |ec_context: &EcContext| { + AuctionObservationContext::from_parts( + AuctionSource::InitialNavigation, + "test-publisher.example", + "/article", + 1, + Some(TEST_USER_AGENT), + ec_context, + ) + }; + let request = test_request("request-id"); + let result = OrchestrationResult { + provider_responses: vec![AuctionResponse::no_bid("provider", 10)], + adserver_response: None, + winning_bids: HashMap::new(), + total_time_ms: 10, + metadata: HashMap::new(), + }; + let skipped = || AuctionTerminalOutcome::Skipped { + reason: "consent_denied", + elapsed_ms: 0, + }; + + assert!( + build_auction_events(observation(&refused), skipped()).is_empty(), + "a skipped auction should give no row without the permission" + ); + assert!( + build_auction_events( + observation(&refused), + AuctionTerminalOutcome::Completed { + request: &request, + result: &result, + delivered_winner_slots: None, + }, + ) + .is_empty(), + "a completed auction should give no row without the permission" + ); + assert_eq!( + build_auction_events(observation(&permitted), skipped()).row_count(), + 1, + "the permission alone should let the summary row through" + ); + } + #[test] fn normalize_page_path_strips_query_and_redacts_dynamic_segments() { assert_eq!( diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 0f77ad619c..5e8cf489ac 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1297,15 +1297,20 @@ impl EcContext { } } -/// The permissions a test context stands in for: storage and personalised-ad -/// selection when the gate is open, none when it is closed. +/// The permissions a test context stands in for: storage, personalised-ad +/// selection and ad performance measurement when the gate is open, none when +/// it is closed. #[cfg(test)] fn permissions_for_test(ec_allowed: bool) -> PermissionState { if ec_allowed { PermissionState::new( - [Permission::StoreOnDevice, Permission::SelectPersonalisedAds] - .into_iter() - .collect(), + [ + Permission::StoreOnDevice, + Permission::SelectPersonalisedAds, + Permission::MeasureAdPerformance, + ] + .into_iter() + .collect(), ) } else { PermissionState::default() diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 01eccb3895..cdf66fa27e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -26702,7 +26702,9 @@ mod tests { /// The same capturing setup also covers how both paths key the identity /// graph. A module whose canonical form differs from the cookie value has /// its row read under the canonical key, so the partner ID stored there - /// reaches the dispatched auction request. + /// reaches the dispatched auction request. It also covers that neither + /// path sends a telemetry row about a reader the consent gate refuses and + /// who has not permitted ad performance measurement. mod navigation_publisher_domain_tests { use super::*; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; @@ -26861,6 +26863,26 @@ mod tests { EcContext::new_for_test(None, consent) } + /// [`EcContext`] in an unknown jurisdiction with no consent record, + /// which the server-side auction gate refuses, for a reader who has + /// set no permission, ad performance measurement included. + fn consent_denying_ec_context() -> EcContext { + EcContext::new_for_test_gated(None, crate::consent::ConsentContext::default(), false) + } + + /// Asserts the sink received no telemetry row at all. + fn assert_no_telemetry(telemetry_sink: &RecordingTelemetrySink) { + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + assert!( + batches.is_empty(), + "should send no row about a reader who has not permitted ad measurement, got {:?}", + *batches + ); + } + fn services_with( http_client: Arc, telemetry_sink: Arc, @@ -27034,6 +27056,94 @@ mod tests { assert_configured_domain(&captured, &telemetry_sink); } + #[tokio::test] + async fn navigation_sends_no_row_when_consent_denies_without_ad_measurement() { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = services_with( + Arc::clone(&stub) as Arc, + Arc::clone(&telemetry_sink), + ); + let mut ec_context = consent_denying_ec_context(); + let req = HttpRequest::builder() + .method(Method::GET) + .uri(format!("https://{EDGE_HOST}/2024/01/my-article/")) + .header(header::HOST, EDGE_HOST) + .header(header::USER_AGENT, "FictionalBrowser/1.0 (FictionalOS)") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build test request"); + + let registry = test_registry(&settings); + let _ = handle_publisher_request( + AppContext { + settings: &settings, + integration_registry: ®istry, + }, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: None, + }, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy publisher request"); + + assert_no_telemetry(&telemetry_sink); + } + + #[tokio::test] + async fn page_bids_sends_no_row_when_consent_denies_without_ad_measurement() { + let settings = settings_with_capturing_provider(); + let captured = Arc::new(Mutex::new(None)); + let orchestrator = orchestrator_capturing_request(&settings, &captured); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let services = services_with( + Arc::new(crate::platform::test_support::NoopHttpClient), + Arc::clone(&telemetry_sink), + ); + let mut ec_context = consent_denying_ec_context(); + let mut req = HttpRequest::builder() + .method(Method::GET) + .uri(format!( + "https://{EDGE_HOST}/_ts/page-bids?path=/2024/01/my-article/" + )) + .header(header::HOST, EDGE_HOST) + .header(header::USER_AGENT, "FictionalBrowser/1.0 (FictionalOS)") + .body(EdgeBody::empty()) + .expect("should build test request"); + req.headers_mut().insert( + header::HeaderName::from_static("sec-fetch-site"), + HeaderValue::from_static("same-origin"), + ); + + let _ = handle_page_bids( + &settings, + &services, + None, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &article_slot(), + registry: None, + }, + &mut ec_context, + req, + ) + .await + .expect("should return ok response"); + + assert_no_telemetry(&telemetry_sink); + } + #[tokio::test] async fn initial_navigation_auctions_only_renderable_slots() { let settings = settings_with_capturing_provider(); From 71fce6dbfaf2fe5bbd409e3cbc26ed741a5be559 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:05:39 +0100 Subject: [PATCH 05/10] Warn at startup when an adapter sends no telemetry for [analytics] The Axum, Cloudflare and Spin adapters build no telemetry sink, so a module [analytics] selects loaded, resolved its token and sent nothing, with nothing logged. Each now calls warn_once_when_analytics_is_unsent as it builds its state, which logs one warning naming the adapter when the section selects a module, unless the services a caller supplied carry a sink that sends. The adapter's own flag keeps it to one line however often the state is built. The behaviour is otherwise unchanged. Core tests capture the log on their own thread and show one warning naming the adapter after two calls, and none when nothing is selected or a supplied sink sends. Each adapter's test builds its state from settings with the documented [analytics] block and shows startup logged. The documented_block test helper gives a test that block on its own, so no adapter names the module. --- crates/trusted-server-adapter-axum/src/app.rs | 32 ++++ .../src/app.rs | 34 +++++ crates/trusted-server-adapter-spin/src/app.rs | 34 +++++ .../src/auction/telemetry.rs | 138 ++++++++++++++++++ .../trusted-server-core/src/test_support.rs | 25 ++++ 5 files changed, 263 insertions(+) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 7eee210bc7..8779d36e52 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -1,5 +1,6 @@ use core::future::Future; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; @@ -11,6 +12,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::telemetry::warn_once_when_analytics_is_unsent; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, }; @@ -74,6 +76,9 @@ pub struct AppState { services: Option, } +/// Set once startup has logged that this adapter sends no auction telemetry. +static UNSENT_ANALYTICS_LOGGED: AtomicBool = AtomicBool::new(false); + /// The permission signal modules this adapter links, in the order they run /// when configuration names none. Global Privacy Control is first because it /// is a browser setting with no interface of its own, and the three that @@ -208,6 +213,14 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + // This adapter builds no telemetry sink, so a module `[analytics]` selects + // sends nothing unless the caller's services carry a sink. + warn_once_when_analytics_is_unsent( + &settings, + services.as_ref(), + "Axum", + &UNSENT_ANALYTICS_LOGGED, + ); Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -936,4 +949,23 @@ mod tests { "the error should name the selected module, got: {error}" ); } + + /// `[analytics]` selecting a module still loads on this adapter, which + /// sends no auction telemetry, and startup says so. + #[test] + fn startup_logs_that_this_adapter_sends_no_auction_telemetry() { + let settings = Settings::from_toml(&format!( + "{}\n{}", + trusted_server_core::test_support::tests::crate_test_settings_str(), + trusted_server_core::test_support::template::documented_block("[analytics]") + )) + .expect("should load settings selecting the documented analytics module"); + + build_state_with_settings(settings).expect("should accept a selected analytics module"); + + assert!( + UNSENT_ANALYTICS_LOGGED.load(std::sync::atomic::Ordering::Relaxed), + "startup should log that this adapter sends no auction telemetry" + ); + } } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 3dbe434d64..14208cc770 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -1,6 +1,7 @@ use core::future::Future; use core::pin::Pin; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; @@ -10,6 +11,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::telemetry::warn_once_when_analytics_is_unsent; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, }; @@ -309,6 +311,14 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + // This adapter builds no telemetry sink, so a module `[analytics]` selects + // sends nothing unless the caller's services carry a sink. + warn_once_when_analytics_is_unsent( + &settings, + services.as_ref(), + "Cloudflare", + &UNSENT_ANALYTICS_LOGGED, + ); Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -320,6 +330,11 @@ fn build_state_with_registrations_and_services( })) } +/// Set once startup has logged that this adapter sends no auction telemetry. +/// The state is built for every request, so the flag keeps the warning to +/// one for each instance. +static UNSENT_ANALYTICS_LOGGED: AtomicBool = AtomicBool::new(false); + /// The permission signal modules this adapter links, in the order they run /// when configuration names none. Global Privacy Control is first because it /// is a browser setting with no interface of its own, and the three that @@ -1209,4 +1224,23 @@ mod tests { "configuration error should name the malformed legacy key" ); } + + /// `[analytics]` selecting a module still loads on this adapter, which + /// sends no auction telemetry, and startup says so. + #[test] + fn startup_logs_that_this_adapter_sends_no_auction_telemetry() { + let settings = Settings::from_toml(&format!( + "{}\n{}", + trusted_server_core::test_support::tests::crate_test_settings_str(), + trusted_server_core::test_support::template::documented_block("[analytics]") + )) + .expect("should load settings selecting the documented analytics module"); + + build_state_with_settings(settings).expect("should accept a selected analytics module"); + + assert!( + UNSENT_ANALYTICS_LOGGED.load(std::sync::atomic::Ordering::Relaxed), + "startup should log that this adapter sends no auction telemetry" + ); + } } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 49de4c0381..01b9337aef 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1,5 +1,6 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; +use std::sync::atomic::AtomicBool; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use edgezero_adapter_spin::config_store::SpinConfigStore; @@ -14,6 +15,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::telemetry::warn_once_when_analytics_is_unsent; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, }; @@ -238,6 +240,14 @@ fn build_state_with_registrations_and_services( &settings, &shipped_signal_modules(), )?; + // This adapter builds no telemetry sink, so a module `[analytics]` selects + // sends nothing unless the caller's services carry a sink. + warn_once_when_analytics_is_unsent( + &settings, + services.as_ref(), + "Spin", + &UNSENT_ANALYTICS_LOGGED, + ); Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -249,6 +259,11 @@ fn build_state_with_registrations_and_services( })) } +/// Set once startup has logged that this adapter sends no auction telemetry. +/// The state is built for every request, so the flag keeps the warning to +/// one for each instance. +static UNSENT_ANALYTICS_LOGGED: AtomicBool = AtomicBool::new(false); + /// The permission signal modules this adapter links, in the order they run /// when configuration names none. Global Privacy Control is first because it /// is a browser setting with no interface of its own, and the three that @@ -1658,4 +1673,23 @@ mod tests { "startup-fallback health body should be `ok`" ); } + + /// `[analytics]` selecting a module still loads on this adapter, which + /// sends no auction telemetry, and startup says so. + #[test] + fn startup_logs_that_this_adapter_sends_no_auction_telemetry() { + let settings = Settings::from_toml(&format!( + "{}\n{}", + trusted_server_core::test_support::tests::crate_test_settings_str(), + trusted_server_core::test_support::template::documented_block("[analytics]") + )) + .expect("should load settings selecting the documented analytics module"); + + build_state_with_settings(settings).expect("should accept a selected analytics module"); + + assert!( + UNSENT_ANALYTICS_LOGGED.load(std::sync::atomic::Ordering::Relaxed), + "startup should log that this adapter sends no auction telemetry" + ); + } } diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index b9638dd3ad..e97ccb687c 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -5,6 +5,7 @@ //! a reader who permits measuring ad performance. use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, Ordering}; use chrono::Utc; use error_stack::Report; @@ -18,6 +19,7 @@ use crate::ec::EcContext; use crate::error::TrustedServerError; use crate::permissions::Permission; use crate::platform::RuntimeServices; +use crate::settings::Settings; const MAX_PAGE_PATH_BYTES: usize = 256; const DYNAMIC_SEGMENT_REPLACEMENT: &str = ":id"; @@ -523,6 +525,32 @@ impl AuctionTelemetrySink for NoopAuctionTelemetrySink { } } +/// Logs once, at warning level, that `adapter` sends no auction telemetry +/// when `[analytics]` selects a module. +/// +/// An adapter that builds no sink of its own calls this as it builds its +/// state, so the selection is not ignored without a word. `supplied` is the +/// services a caller handed the adapter, whose sink its requests use, and +/// nothing is logged when that sink sends. `logged` is the adapter's own flag, +/// so the line is written once however often the state is built. +pub fn warn_once_when_analytics_is_unsent( + settings: &Settings, + supplied: Option<&RuntimeServices>, + adapter: &str, + logged: &AtomicBool, +) { + if settings.analytics_module().is_none() + || supplied.is_some_and(|services| services.auction_telemetry_sink().is_enabled()) + || logged.swap(true, Ordering::Relaxed) + { + return; + } + log::warn!( + "[analytics] selects a module, but the {adapter} adapter sends no auction \ + telemetry, so nothing is sent" + ); +} + /// Emit a telemetry batch without letting errors affect customer traffic. pub async fn emit_auction_events_best_effort(services: &RuntimeServices, batch: AuctionEventBatch) { emit_auction_events_best_effort_lazy(services, || batch).await; @@ -1238,6 +1266,116 @@ mod tests { ); } + thread_local! { + /// The lines logged on this thread while a test is capturing them. + static CAPTURED_LOG: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + } + + /// A logger that keeps the lines logged on a thread that asked for them, + /// so a test reads its own lines and none of another test's. + struct ThreadLog; + + impl log::Log for ThreadLog { + fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { + true + } + + fn log(&self, record: &log::Record<'_>) { + CAPTURED_LOG.with(|captured| { + if let Some(lines) = captured.borrow_mut().as_mut() { + lines.push((record.level(), record.args().to_string())); + } + }); + } + + fn flush(&self) {} + } + + /// The lines at warning level or above that `run` logs on this thread. + fn warnings_logged_by(run: impl FnOnce()) -> Vec<(log::Level, String)> { + static LOGGER: ThreadLog = ThreadLog; + static INSTALL: std::sync::Once = std::sync::Once::new(); + INSTALL.call_once(|| { + log::set_logger(&LOGGER).expect("no other test should install a logger"); + log::set_max_level(log::LevelFilter::Warn); + }); + CAPTURED_LOG.with(|captured| *captured.borrow_mut() = Some(Vec::new())); + run(); + CAPTURED_LOG.with(|captured| captured.borrow_mut().take().unwrap_or_default()) + } + + /// Settings whose `[analytics]` selects the module the example documents. + fn settings_selecting_analytics() -> Settings { + Settings::from_toml(&format!( + "{}\n{}", + crate::test_support::tests::crate_test_settings_str(), + crate::test_support::template::documented_block("[analytics]") + )) + .expect("should load settings selecting the documented analytics module") + } + + /// A sink that sends, standing in for one a caller supplies. + struct SendingSink; + + #[async_trait::async_trait(?Send)] + impl AuctionTelemetrySink for SendingSink { + async fn emit_auction_events( + &self, + _services: &RuntimeServices, + _batch: AuctionEventBatch, + ) -> Result<(), Report> { + Ok(()) + } + } + + #[test] + fn an_adapter_that_sends_nothing_warns_once_when_analytics_selects_a_module() { + let settings = settings_selecting_analytics(); + let logged = AtomicBool::new(false); + + let lines = warnings_logged_by(|| { + warn_once_when_analytics_is_unsent(&settings, None, "Example", &logged); + warn_once_when_analytics_is_unsent(&settings, None, "Example", &logged); + }); + + assert_eq!(lines.len(), 1, "should log one line, got {lines:?}"); + assert_eq!(lines[0].0, log::Level::Warn, "should log a warning"); + assert!( + lines[0] + .1 + .contains("the Example adapter sends no auction telemetry"), + "should name the adapter, got {:?}", + lines[0].1 + ); + } + + #[test] + fn no_warning_when_analytics_selects_nothing_or_a_supplied_sink_sends() { + let selecting_nothing = crate::test_support::tests::create_test_settings(); + let selecting = settings_selecting_analytics(); + let sending = crate::platform::test_support::noop_services_with_telemetry_sink( + std::sync::Arc::new(SendingSink), + ); + + let lines = warnings_logged_by(|| { + warn_once_when_analytics_is_unsent( + &selecting_nothing, + None, + "Example", + &AtomicBool::new(false), + ); + warn_once_when_analytics_is_unsent( + &selecting, + Some(&sending), + "Example", + &AtomicBool::new(false), + ); + }); + + assert!(lines.is_empty(), "should log nothing, got {lines:?}"); + } + #[test] fn normalize_page_path_strips_query_and_redacts_dynamic_segments() { assert_eq!( diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 9fea391024..53e55f90de 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -252,6 +252,31 @@ pub mod template { out.join("\n") } + + /// Only the block of [`EXAMPLE_TEMPLATE`] that begins at the line + /// `# {header}`, uncommented, for a test that adds a documented section to + /// settings of its own. The block ends at the first line that is not a + /// comment. + /// + /// # Panics + /// + /// Panics if the template documents no such block. + #[must_use] + pub fn documented_block(header: &str) -> String { + let header_line = format!("# {header}"); + let block: Vec<&str> = EXAMPLE_TEMPLATE + .lines() + .skip_while(|line| *line != header_line) + .take_while(|line| line.trim_start().starts_with('#')) + .map(|line| { + line.strip_prefix("# ") + .or_else(|| line.strip_prefix('#')) + .unwrap_or(line) + }) + .collect(); + assert!(!block.is_empty(), "the template should document {header}"); + block.join("\n") + } } /// Shared Next.js + auction origin fixture. From 4bf0d8a0ec3ccde7ecd290a2b94e4623fb86c489 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:10:10 +0100 Subject: [PATCH 06/10] Say when telemetry rows are sent and which adapters send none The changelog entry, the telemetry guide and the Tinybird project's README say that a row is sent only for a reader whose permissions include analytics.ad_reporting.measure_ad_performance, whatever the auction's outcome, and the telemetry guide says that every row carries the reader's complete User-Agent, country, region and page path. The changelog and the guide say that the Axum, Cloudflare and Spin adapters log a warning at startup when [analytics] selects a module. --- CHANGELOG.md | 2 +- docs/guide/telemetry.md | 8 +++++++- tinybird/README.md | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f0861eca..747fcf0540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. +- **Breaking:** auction telemetry is selected the way every other module is. `[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module = "tinybird"` selects it, which replaces the `enabled` line. A configuration that still carries `[tinybird]` is refused with directions. A stored configuration written by an earlier version carries the table even where telemetry was off, so push the configuration again with this version. The secret reference is `analytics.tinybird.auction_token_secret`, the table refuses a key it does not know, and a section that selects nothing, or a module this build does not have, is refused. A row is sent only for a reader whose permissions include `analytics.ad_reporting.measure_ad_performance`, so none is sent for a reader the consent gate refuses unless they permit it. The Axum, Cloudflare and Spin adapters send no telemetry, and log a warning at startup when the section selects a module. - The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. - **Breaking:** `ts cache purge` asks the hosting platform to purge instead of calling the service. It takes `--service-id ` where it took `--service `, reads a Fastly API token from `FASTLY_API_TOKEN` where it read `TRUSTED_SERVER_ADMIN_PASSWORD`, and no longer takes `--username`. It runs `fastly service purge --key`, which needs Fastly CLI 14.0.0 or later, with the surrogate key it derives by the function the service tags its cached objects with, so `--all` and `--page ` purge exactly what they purged before. - **Breaking:** A module's change to a page runs only on the pages where a `[[fetch]]` or `[[serve]]` entry names its middleware. A deployment adds the entries when it upgrades. Until it does, a module changes no page, and startup logs a warning naming each middleware that no entry names. "Placing page changes" in the configuration guide lists the middleware each module supplies and the phase each runs in. To keep a page as it was, name the middleware of the modules a deployment selects in one `[[fetch]]` entry and one `[[serve]]` entry for `text/html`, in the order `trusted-server.example.toml` shows them, which is the order the modules' hooks ran in. diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index dfe762406e..a8974464c5 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -2,7 +2,8 @@ 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. +core no-op sink even if they can parse the shared settings, and log a warning +at startup when `[analytics]` selects a module. ## Data path @@ -27,6 +28,11 @@ provider status and timing, slot dimensions, media type, seat, price, currency, win state, and selected creative identifiers. Tests reject the request's derived EC ID from serialized NDJSON. +Every row also carries the reader's complete User-Agent, country, region and +page path, so rows are built only when the reader's permissions include +`analytics.ad_reporting.measure_ad_performance` (TCF Purpose 7). A reader +without it gets no row, whatever the outcome, a skip for consent included. + `browser_family` is a coarse User-Agent classification. Recognized values are `chrome`, `safari`, `firefox`, `edge`, and `opera`; unrecognized clients produce no value. Edge, Opera, and iOS-specific tokens are matched diff --git a/tinybird/README.md b/tinybird/README.md index 5b19c9b851..57571e3172 100644 --- a/tinybird/README.md +++ b/tinybird/README.md @@ -62,7 +62,9 @@ Publisher summary rows cover requests with matching renderable slots while creat opportunities are enabled. They include completed auctions **and skipped candidates**: bots, prefetches, consent-denied readers, and requests with auctions disabled can emit a `Skipped` summary when slots match. A page with no matching slot, a non-GET request, or -traffic with creative opportunities disabled produces no publisher summary. +traffic with creative opportunities disabled produces no publisher summary, and neither +does a reader whose permissions do not include measuring ad performance, whatever the +outcome. The query below therefore measures shareability among matching-slot candidates, including skipped summaries. It is neither an ad-serving-pageview rate nor a site-wide rate. The From a2048583739e216f0e805153eacbec1d32964832 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:18:48 +0100 Subject: [PATCH 07/10] The rules guide maps `[tinybird]` to the analytics module The configuration rules guide's table of old names to new had no row for the `[tinybird]` table, which this branch refuses with directions to `[analytics] module = "tinybird"` and `[analytics.tinybird]`. The table now has that row, beside the ad server's. --- docs/guide/configuration-rules.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guide/configuration-rules.md b/docs/guide/configuration-rules.md index 12bbf82380..b36dc2778d 100644 --- a/docs/guide/configuration-rules.md +++ b/docs/guide/configuration-rules.md @@ -298,6 +298,7 @@ endpoint = "https://house.example.com/openrtb2/auction" | `profile = "standard"` | `implementation = "auction-protocol.openrtb"` | | `profile = "prebid-server"` and `profile = "aps"` | `implementation = "auction.prebid-server"` and `implementation = "auction.aps"` | | `[auction] mediator = "adserver_mock"` and `[integrations.adserver_mock]` | `[ad-server] module = "mock"` and `[ad-server.mock]` | +| `[tinybird]` with `enabled = true` | `[analytics] module = "tinybird"` and `[analytics.tinybird]` | | `[integrations.aps] rendering_mode` | `rendering_mode` in the `[demand.]` table of the `auction.aps` implementation | | `[debug.auction_html_comment_options] include_mediator_response` | `include_adserver_response` | From 99abf9f4fac1bbab50c885c4c62752aa72db41fa Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 10:28:23 +0100 Subject: [PATCH 08/10] The guides say which adapters send auction and access telemetry --- docs/guide/axum-dev.md | 3 +++ docs/guide/cloudflare.md | 3 +++ docs/guide/spin.md | 3 +++ docs/guide/telemetry.md | 9 +++++++++ 4 files changed, 18 insertions(+) diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index b5eb0e701c..45c14afd59 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -65,5 +65,8 @@ Axum sanitizes forwarded client-IP headers but does not resolve `[trusted_client_ip]`; see the [adapter matrix](./api-reference#adapter-and-startup-support). +Axum sends no [auction telemetry](./telemetry#adapter-support), and warns at +startup when `[analytics]` selects a module. + For an edge deployment, use the [Fastly](./fastly), [Cloudflare](./cloudflare), or [Spin](./spin) guide. diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index d9ca66a543..7e9fa981a5 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -59,5 +59,8 @@ store registries are also unwired, so EC KV lookup is not available. Forwarded client-IP headers are sanitized, but `[trusted_client_ip]` is not resolved. See the [API reference](./api-reference) for route-level behavior. +The Worker sends no [auction telemetry](./telemetry#adapter-support), and warns +at startup when `[analytics]` selects a module. + Compare the [Fastly](./fastly), [Spin](./spin), and [Axum](./axum-dev) adapter journeys. diff --git a/docs/guide/spin.md b/docs/guide/spin.md index 8b84421d5c..8feec039e1 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -55,6 +55,9 @@ unavailable. Forwarded client-IP headers are sanitized, and Spin derives its runtime authority, scheme, and client-address headers in the innermost normalization layer. +Spin sends no [auction telemetry](./telemetry#adapter-support), and warns at +startup when `[analytics]` selects a module. + Spin is not installed in the integration CI environment. The recurring manual smoke therefore needs a named owner, tested commit, exact tool versions, and an expiry; those details are recorded with each run in the documentation-refresh diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index a8974464c5..cb55984879 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -5,6 +5,15 @@ 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, and log a warning at startup when `[analytics]` selects a module. +## Adapter support + +| Adapter | Auction telemetry | Access telemetry | +| ------------ | ---------------------------- | ---------------- | +| `axum` | not sent, warning at startup | refused | +| `cloudflare` | not sent, warning at startup | refused | +| `fastly` | sent | refused | +| `spin` | not sent, warning at startup | refused | + ## Data path The request entry point derives coarse `DeviceSignals`. Auction execution From b1a47b8fcf421fdc53296cca04a0807dec8e63db Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 10:28:24 +0100 Subject: [PATCH 09/10] A Fastly test shows each request carries the sink [analytics] selects --- .../trusted-server-adapter-fastly/src/app.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index cf5d23632b..8a28d380ab 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1806,6 +1806,36 @@ mod tests { ); } + /// The sink startup builds from `[analytics]` is the one each request's + /// services carry, so a selected module sends from the request path and a + /// deployment that selects none sends nothing. + #[test] + fn per_request_services_carry_the_sink_analytics_selects() { + let base = trusted_server_core::test_support::tests::crate_test_settings_str(); + let analytics = + trusted_server_core::test_support::template::documented_block("[analytics]"); + let context = RequestContext::new( + empty_request(Method::GET, "/article"), + PathParams::default(), + ); + + for (document, sends) in [ + (base.clone(), false), + (format!("{base}\n{analytics}"), true), + ] { + let settings = Settings::from_toml(&document).expect("should load the settings"); + let state = build_state_from_settings(settings).expect("should build state"); + + let services = build_per_request_services(&state, &context); + + assert_eq!( + services.auction_telemetry_sink().is_enabled(), + sends, + "a request's sink should send exactly when [analytics] selects a module" + ); + } + } + /// 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. From 82f97b34f2012665333b03e8f89fdf8202ec27e8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:49 +0000 Subject: [PATCH 10/10] The analytics startup tests carry the sample policy --- crates/trusted-server-adapter-axum/src/app.rs | 4 +++- crates/trusted-server-adapter-cloudflare/src/app.rs | 4 +++- crates/trusted-server-adapter-fastly/src/app.rs | 7 ++++++- crates/trusted-server-adapter-spin/src/app.rs | 4 +++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index fab91192cf..31752fb4a1 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -951,7 +951,9 @@ mod tests { trusted_server_core::test_support::tests::crate_test_settings_str(), trusted_server_core::test_support::template::documented_block("[analytics]") )) - .expect("should load settings selecting the documented analytics module"); + .expect("should load settings selecting the documented analytics module") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); build_state_with_settings(settings).expect("should accept a selected analytics module"); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 9d9271387b..a98a9d5ef5 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -1153,7 +1153,9 @@ mod tests { trusted_server_core::test_support::tests::crate_test_settings_str(), trusted_server_core::test_support::template::documented_block("[analytics]") )) - .expect("should load settings selecting the documented analytics module"); + .expect("should load settings selecting the documented analytics module") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); build_state_with_settings(settings).expect("should accept a selected analytics module"); diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d3cd0f54e5..d092b5585d 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1850,7 +1850,12 @@ mod tests { (base.clone(), false), (format!("{base}\n{analytics}"), true), ] { - let settings = Settings::from_toml(&document).expect("should load the settings"); + let settings = Settings::from_toml(&document) + .expect("should load the settings") + // A loaded domain carries its policy. + .with_permission_policy( + trusted_server_core::permissions::PermissionPolicy::sample(), + ); let state = build_state_from_settings(settings).expect("should build state"); let services = build_per_request_services(&state, &context); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index ab38cd93b5..2129b8e6cc 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1750,7 +1750,9 @@ mod tests { trusted_server_core::test_support::tests::crate_test_settings_str(), trusted_server_core::test_support::template::documented_block("[analytics]") )) - .expect("should load settings selecting the documented analytics module"); + .expect("should load settings selecting the documented analytics module") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); build_state_with_settings(settings).expect("should accept a selected analytics module");