tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 13c9b4ab6973f28e13fefcf4bd5e23f2a6fdf211
parent b0318dafece6648b8986afefe9a91ed629a93c44
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 12:30:13 -0700

observability: add tracing setup

- add runtime tracing config with compact and JSON formats
- initialize CLI tracing subscribers from runtime config
- emit command and server lifecycle trace events
- assert relay trace output in the integration run path

Diffstat:
MCargo.lock | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/tangle/Cargo.toml | 2++
Mcrates/tangle/src/lib.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/tangle/tests/run_integration.rs | 41++++++++++++++++++++++++++++++++++-------
Mcrates/tangle_runtime/Cargo.toml | 1+
Mcrates/tangle_runtime/src/lib.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
6 files changed, 428 insertions(+), 16 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -359,7 +359,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -2208,6 +2208,15 @@ dependencies = [ ] [[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] name = "matchit" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2336,6 +2345,15 @@ dependencies = [ ] [[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3523,6 +3541,15 @@ dependencies = [ ] [[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4037,6 +4064,8 @@ dependencies = [ "tangle_test_support", "tokio", "tokio-tungstenite 0.29.0", + "tracing", + "tracing-subscriber", ] [[package]] @@ -4106,6 +4135,7 @@ dependencies = [ "tangle_test_support", "tokio", "tower", + "tracing", "url", ] @@ -4485,6 +4515,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -4633,6 +4706,12 @@ dependencies = [ ] [[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] name = "vart" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/crates/tangle/Cargo.toml b/crates/tangle/Cargo.toml @@ -11,6 +11,8 @@ readme = "../../README" [dependencies] tangle_runtime = { path = "../tangle_runtime" } tokio = { version = "1", features = ["rt", "signal"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } [dev-dependencies] futures-util = "0.3" diff --git a/crates/tangle/src/lib.rs b/crates/tangle/src/lib.rs @@ -296,6 +296,7 @@ pub fn projection_rebuild_output(report: tangle_runtime::RuntimeProjectionRebuil pub async fn migrate_with_config(path: &str) -> Result<String, String> { let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?; + initialize_tracing(config.tracing_config())?; let report = tangle_runtime::migrate_runtime_database(&config) .await .map_err(|error| error.to_string())?; @@ -308,6 +309,7 @@ pub async fn event_import_with_config( ) -> Result<String, String> { let config = tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; + initialize_tracing(config.tracing_config())?; let report = tangle_runtime::import_events_from_path(&config, input_path) .await .map_err(|error| error.to_string())?; @@ -320,6 +322,7 @@ pub async fn event_export_with_config( ) -> Result<String, String> { let config = tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; + initialize_tracing(config.tracing_config())?; let report = tangle_runtime::export_events_to_path(&config, output_path) .await .map_err(|error| error.to_string())?; @@ -329,6 +332,7 @@ pub async fn event_export_with_config( pub async fn projection_rebuild_with_config(config_path: &str) -> Result<String, String> { let config = tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; + initialize_tracing(config.tracing_config())?; let report = tangle_runtime::rebuild_projections(&config) .await .map_err(|error| error.to_string())?; @@ -337,6 +341,7 @@ pub async fn projection_rebuild_with_config(config_path: &str) -> Result<String, pub async fn run_with_config(path: &str) -> Result<(), String> { let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?; + initialize_tracing(config.tracing_config())?; let (shutdown, _) = tangle_runtime::GracefulShutdownSignal::new(); let signal = shutdown.clone(); tokio::spawn(async move { @@ -350,17 +355,46 @@ pub async fn run_with_config(path: &str) -> Result<(), String> { .map_err(|error| error.to_string()) } +fn initialize_tracing(config: &tangle_runtime::RuntimeTracingConfig) -> Result<(), String> { + if !config.enabled() { + return Ok(()); + } + let filter = tracing_subscriber::EnvFilter::try_new(config.filter()) + .map_err(|error| format!("tracing filter is invalid: {error}"))?; + match config.format() { + tangle_runtime::RuntimeTracingFormat::Compact => { + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .try_init(); + } + tangle_runtime::RuntimeTracingFormat::Json => { + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .json() + .try_init(); + } + } + tracing::info!( + filter = config.filter(), + format = config.format().as_str(), + "tracing initialized" + ); + Ok(()) +} + #[cfg(test)] mod tests { use super::{ PACKAGE_NAME, PACKAGE_VERSION, TangleCliError, TangleCommand, TangleInvocation, - event_export_output, event_import_output, migrate_output, parse_tangle_command, - parse_tangle_invocation, projection_rebuild_output, require_config_path, - require_input_path, require_output_path, usage_output, version_output, + event_export_output, event_import_output, initialize_tracing, migrate_output, + parse_tangle_command, parse_tangle_invocation, projection_rebuild_output, + require_config_path, require_input_path, require_output_path, usage_output, version_output, }; use tangle_runtime::{ RuntimeEventExportReport, RuntimeEventImportReport, RuntimeMigrationReport, - RuntimeProjectionRebuildReport, + RuntimeProjectionRebuildReport, RuntimeTracingConfig, RuntimeTracingFormat, }; #[test] @@ -379,6 +413,21 @@ mod tests { } #[test] + fn tracing_setup_ignores_disabled_config_and_rejects_bad_filters() { + assert_eq!( + initialize_tracing(&RuntimeTracingConfig::disabled()), + Ok(()) + ); + let invalid = + RuntimeTracingConfig::new(true, "bad[", RuntimeTracingFormat::Compact).expect("config"); + assert!( + initialize_tracing(&invalid) + .expect_err("invalid filter") + .starts_with("tracing filter is invalid:") + ); + } + + #[test] fn usage_output_lists_supported_command_model() { assert_eq!( usage_output(), diff --git a/crates/tangle/tests/run_integration.rs b/crates/tangle/tests/run_integration.rs @@ -41,6 +41,13 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { "window_seconds": 60 } }), + Some(serde_json::json!({ + "tracing": { + "enabled": true, + "filter": "info,tangle=info,tangle_runtime=info", + "format": "json" + } + })), ); let mut relay = Command::new(env!("CARGO_BIN_EXE_tangle")) @@ -404,7 +411,10 @@ async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { assert!(seller_detail.contains("\"display_name\":\"Radroots Market\"")); assert!(seller_detail.contains("\"regions\":[\"cascadia\",\"pnw\"]")); - stop_relay(relay); + let trace_output = stop_relay_with_stderr(relay); + assert!(trace_output.contains("tracing initialized")); + assert!(trace_output.contains("starting runtime server")); + assert!(trace_output.contains("\"format\":\"json\"")); let store_config = SurrealConnectionConfig::rocksdb(db_path.to_str().expect("db path"), "tangle_it", "relay") @@ -638,6 +648,7 @@ async fn tangle_run_persists_durable_write_rate_limits() { "window_seconds": 60 } }), + None, ); let mut relay = spawn_relay(&config_path); wait_for_http(port, &mut relay); @@ -735,6 +746,7 @@ async fn tangle_run_serves_admin_policy_api() { serde_json::json!({ "admin_pubkeys": [admin.as_str()] }), + None, ); let mut relay = spawn_relay(&config_path); wait_for_http(port, &mut relay); @@ -887,7 +899,7 @@ async fn run_policy_write_scenario( let db_path = root.join("surrealdb"); let config_path = root.join("runtime.json"); fs::create_dir_all(&root).expect("runtime root"); - write_runtime_config(&config_path, &db_path, port, namespace, policy); + write_runtime_config(&config_path, &db_path, port, namespace, policy, None); let mut relay = spawn_relay(&config_path); wait_for_http(port, &mut relay); let (mut client, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) @@ -935,8 +947,15 @@ fn spawn_relay(config_path: &Path) -> Child { .expect("spawn tangle run") } -fn write_runtime_config(path: &Path, db_path: &Path, port: u16, namespace: &str, policy: Value) { - let config = serde_json::json!({ +fn write_runtime_config( + path: &Path, + db_path: &Path, + port: u16, + namespace: &str, + policy: Value, + observability: Option<Value>, +) { + let mut config = serde_json::json!({ "server": { "listen_addr": format!("127.0.0.1:{port}"), "relay_url": "wss://relay.radroots.test" @@ -958,6 +977,9 @@ fn write_runtime_config(path: &Path, db_path: &Path, port: u16, namespace: &str, }, "policy": policy }); + if let Some(observability) = observability { + config["observability"] = observability; + } fs::write( path, serde_json::to_string_pretty(&config).expect("config JSON"), @@ -1292,10 +1314,15 @@ fn forum_thread_comment( .expect("forum comment event") } -fn stop_relay(mut relay: Child) { +fn stop_relay(relay: Child) { + let _ = stop_relay_with_stderr(relay); +} + +fn stop_relay_with_stderr(mut relay: Child) -> String { stop_child(&mut relay); - let status = relay.wait().expect("relay exit"); - assert!(status.success()); + let output = relay.wait_with_output().expect("relay exit"); + assert!(output.status.success()); + String::from_utf8_lossy(&output.stderr).to_string() } #[cfg(unix)] diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml @@ -18,6 +18,7 @@ tangle_protocol = { path = "../tangle_protocol" } tangle_store = { path = "../tangle_store" } tangle_store_surreal = { path = "../tangle_store_surreal" } tokio = { version = "1", features = ["net", "sync"] } +tracing = "0.1" url = "2" [dev-dependencies] diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -243,6 +243,7 @@ pub struct TangleRuntimeConfig { durable_write_rate_limit: Option<RateLimitConfig>, admin_pubkeys: BTreeSet<PublicKeyHex>, limits: RuntimeLimits, + tracing: RuntimeTracingConfig, } impl TangleRuntimeConfig { @@ -274,6 +275,10 @@ impl TangleRuntimeConfig { self.limits } + pub fn tracing_config(&self) -> &RuntimeTracingConfig { + &self.tracing + } + pub fn websocket_state(&self, shutdown_signal: GracefulShutdownSignal) -> WebSocketHttpState { WebSocketHttpState::with_shutdown(self.relay_connection.clone(), shutdown_signal) } @@ -284,6 +289,68 @@ impl TangleRuntimeConfig { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeTracingFormat { + Compact, + Json, +} + +impl RuntimeTracingFormat { + pub fn as_str(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Json => "json", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeTracingConfig { + enabled: bool, + filter: String, + format: RuntimeTracingFormat, +} + +impl RuntimeTracingConfig { + pub fn new( + enabled: bool, + filter: impl Into<String>, + format: RuntimeTracingFormat, + ) -> Result<Self, RuntimeConfigError> { + let filter = filter.into(); + if filter.trim().is_empty() { + return Err(RuntimeConfigError::invalid( + "observability.tracing.filter must not be empty", + )); + } + Ok(Self { + enabled, + filter: filter.trim().to_owned(), + format, + }) + } + + pub fn disabled() -> Self { + Self { + enabled: false, + filter: "info,tangle=info,tangle_runtime=info".to_owned(), + format: RuntimeTracingFormat::Compact, + } + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn filter(&self) -> &str { + &self.filter + } + + pub fn format(&self) -> RuntimeTracingFormat { + self.format + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuntimeConfigErrorKind { Read, Parse, @@ -435,6 +502,12 @@ impl std::error::Error for RuntimeCommandError {} pub async fn migrate_runtime_database( config: &TangleRuntimeConfig, ) -> Result<RuntimeMigrationReport, RuntimeCommandError> { + tracing::info!( + command = "migrate", + namespace = config.database_config().namespace(), + database = config.database_config().database(), + "starting runtime database migration" + ); let store = connect_runtime_store(config).await?; let outcomes = store .apply_plan(&base_migration_plan()) @@ -448,6 +521,13 @@ pub async fn migrate_runtime_database( .iter() .filter(|outcome| **outcome == MigrationApplyOutcome::AlreadyApplied) .count() as u64; + tracing::info!( + command = "migrate", + applied, + already_applied, + total = outcomes.len() as u64, + "finished runtime database migration" + ); Ok(RuntimeMigrationReport::new( applied, already_applied, @@ -526,6 +606,11 @@ pub async fn import_events_from_path( path: impl AsRef<FsPath>, ) -> Result<RuntimeEventImportReport, RuntimeCommandError> { let path = path.as_ref(); + tracing::info!( + command = "event import", + input_path = path.display().to_string(), + "starting event import" + ); let raw = fs::read_to_string(path).map_err(|error| { RuntimeCommandError::input(format!( "failed to read event import file `{}`: {error}", @@ -551,6 +636,15 @@ pub async fn import_events_from_path( let outcome = import_single_event(&store, &validator, event, now).await?; report.record(outcome); } + tracing::info!( + command = "event import", + total = report.total(), + inserted = report.inserted(), + duplicate = report.duplicate(), + projected = report.projected(), + skipped = report.skipped(), + "finished event import" + ); Ok(report) } @@ -724,6 +818,12 @@ pub async fn export_events_to_path( config: &TangleRuntimeConfig, path: impl AsRef<FsPath>, ) -> Result<RuntimeEventExportReport, RuntimeCommandError> { + let path = path.as_ref(); + tracing::info!( + command = "event export", + output_path = path.display().to_string(), + "starting event export" + ); let store = connect_runtime_store(config).await?; store .apply_plan(&base_migration_plan()) @@ -738,13 +838,17 @@ pub async fn export_events_to_path( output.push_str(&runtime_row_string(row, "raw_json")?); output.push('\n'); } - let path = path.as_ref(); fs::write(path, output).map_err(|error| { RuntimeCommandError::input(format!( "failed to write event export file `{}`: {error}", path.display() )) })?; + tracing::info!( + command = "event export", + exported = rows.len() as u64, + "finished event export" + ); Ok(RuntimeEventExportReport::new(rows.len() as u64)) } @@ -823,6 +927,10 @@ enum RuntimeProjectionRebuildOutcome { pub async fn rebuild_projections( config: &TangleRuntimeConfig, ) -> Result<RuntimeProjectionRebuildReport, RuntimeCommandError> { + tracing::info!( + command = "projection rebuild", + "starting projection rebuild" + ); let store = connect_runtime_store(config).await?; store .apply_plan(&base_migration_plan()) @@ -849,6 +957,14 @@ pub async fn rebuild_projections( let outcome = rebuild_single_event_projection(&store, &validator, event, now).await?; report.record(outcome); } + tracing::info!( + command = "projection rebuild", + scanned = report.scanned(), + rebuilt = report.rebuilt(), + projected = report.projected(), + skipped = report.skipped(), + "finished projection rebuild" + ); Ok(report) } @@ -903,6 +1019,13 @@ impl RuntimeServer { } pub async fn run(&self) -> Result<RuntimeServerReport, RuntimeCommandError> { + tracing::info!( + command = "run", + listen_addr = self.config.listen_addr().to_string(), + namespace = self.config.database_config().namespace(), + database = self.config.database_config().database(), + "starting runtime server" + ); let store = connect_runtime_store(&self.config).await?; store .apply_plan(&base_migration_plan()) @@ -922,6 +1045,11 @@ impl RuntimeServer { }) .await .map_err(|error| RuntimeCommandError::store(format!("server failed: {error}")))?; + tracing::info!( + command = "run", + listen_addr = listen_addr.to_string(), + "runtime server stopped" + ); Ok(RuntimeServerReport::new(listen_addr)) } } @@ -1522,6 +1650,8 @@ struct RuntimeConfigDocument { limits: RuntimeLimitsConfigDocument, #[serde(default)] policy: RuntimePolicyConfigDocument, + #[serde(default)] + observability: RuntimeObservabilityConfigDocument, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -1597,6 +1727,26 @@ struct RuntimePolicyConfigDocument { blocked_pubkeys: Vec<String>, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +struct RuntimeObservabilityConfigDocument { + #[serde(default)] + tracing: RuntimeTracingConfigDocument, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +struct RuntimeTracingConfigDocument { + enabled: Option<bool>, + filter: Option<String>, + format: Option<RuntimeTracingFormatDocument>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +enum RuntimeTracingFormatDocument { + Compact, + Json, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] enum RuntimeUnapprovedSellerActionDocument { @@ -1626,6 +1776,7 @@ fn runtime_config_from_document( let durable_write_rate_limit = durable_write_rate_limit_from_document(&document.policy)?; let admin_pubkeys = admin_pubkeys_from_document(&document.policy)?; let admission_policy = admission_policy_from_document(&document.policy)?; + let tracing = tracing_config_from_document(document.observability.tracing)?; Ok(TangleRuntimeConfig { listen_addr, relay_connection, @@ -1634,6 +1785,7 @@ fn runtime_config_from_document( durable_write_rate_limit, admin_pubkeys, limits: limits.runtime, + tracing, }) } @@ -1727,6 +1879,24 @@ fn durable_write_rate_limit_from_document( .transpose() } +fn tracing_config_from_document( + document: RuntimeTracingConfigDocument, +) -> Result<RuntimeTracingConfig, RuntimeConfigError> { + let default = RuntimeTracingConfig::disabled(); + let format = match document.format { + Some(RuntimeTracingFormatDocument::Compact) => RuntimeTracingFormat::Compact, + Some(RuntimeTracingFormatDocument::Json) => RuntimeTracingFormat::Json, + None => default.format(), + }; + RuntimeTracingConfig::new( + document.enabled.unwrap_or(default.enabled()), + document + .filter + .unwrap_or_else(|| default.filter().to_owned()), + format, + ) +} + fn admin_pubkeys_from_document( document: &RuntimePolicyConfigDocument, ) -> Result<BTreeSet<PublicKeyHex>, RuntimeConfigError> { @@ -4201,9 +4371,9 @@ mod tests { EventMessageHandler, GracefulShutdownSignal, ListingsHttpState, LiveEventFanout, ReadinessCheckStatus, ReadinessState, RelayConnection, RelayConnectionConfig, RelayConnectionId, RelayInfoDocument, ReqMessageHandler, RuntimeCommandErrorKind, - RuntimeConfigErrorKind, TANGLE_RELAY_SOFTWARE, TANGLE_SUPPORTED_NIPS, WebSocketHttpState, - health_router, listing_item_document, listing_projection_query, listings_router, - load_runtime_config, migrate_runtime_database, parse_listing_query, + RuntimeConfigErrorKind, RuntimeTracingFormat, TANGLE_RELAY_SOFTWARE, TANGLE_SUPPORTED_NIPS, + WebSocketHttpState, health_router, listing_item_document, listing_projection_query, + listings_router, load_runtime_config, migrate_runtime_database, parse_listing_query, parse_marketplace_search_query, parse_runtime_config_json, relay_info_router, search_document_query, websocket_router, }; @@ -4506,6 +4676,15 @@ mod tests { websocket_state.connection_config(), config.relay_connection_config() ); + assert!(!config.tracing_config().enabled()); + assert_eq!( + config.tracing_config().filter(), + "info,tangle=info,tangle_runtime=info" + ); + assert_eq!( + config.tracing_config().format(), + RuntimeTracingFormat::Compact + ); } #[test] @@ -4545,6 +4724,47 @@ mod tests { } #[test] + fn runtime_config_loader_parses_observability_tracing_config() { + let config = parse_runtime_config_json( + r#"{ + "server": { + "listen_addr": "127.0.0.1:7101", + "relay_url": "wss://relay.radroots.test" + }, + "database": { + "mode": "memory", + "namespace": "tangle", + "database": "relay" + }, + "auth": { + "challenge_ttl_seconds": 300 + }, + "limits": { + "message_rate_limit": { + "limit": 120, + "window_seconds": 60 + } + }, + "observability": { + "tracing": { + "enabled": true, + "filter": "info,tangle=debug,tangle_runtime=debug", + "format": "json" + } + } + }"#, + ) + .expect("runtime config"); + + assert!(config.tracing_config().enabled()); + assert_eq!( + config.tracing_config().filter(), + "info,tangle=debug,tangle_runtime=debug" + ); + assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json); + } + + #[test] fn runtime_config_loader_rejects_invalid_documents() { let parse_error = parse_runtime_config_json("{").expect_err("parse"); let invalid_listen = parse_runtime_config_json( @@ -4593,6 +4813,35 @@ mod tests { }"#, ) .expect_err("endpoint"); + let empty_tracing_filter = parse_runtime_config_json( + r#"{ + "server": { + "listen_addr": "127.0.0.1:7000", + "relay_url": "ws://127.0.0.1:7000" + }, + "database": { + "mode": "memory", + "namespace": "tangle", + "database": "relay" + }, + "auth": { + "challenge_ttl_seconds": 300 + }, + "limits": { + "message_rate_limit": { + "limit": 120, + "window_seconds": 60 + } + }, + "observability": { + "tracing": { + "enabled": true, + "filter": " " + } + } + }"#, + ) + .expect_err("tracing filter"); assert_eq!(parse_error.kind(), RuntimeConfigErrorKind::Parse); assert!( @@ -4611,6 +4860,11 @@ mod tests { missing_endpoint.message(), "database.endpoint is required for http mode" ); + assert_eq!(empty_tracing_filter.kind(), RuntimeConfigErrorKind::Invalid); + assert_eq!( + empty_tracing_filter.message(), + "observability.tracing.filter must not be empty" + ); } #[test]