diff --git a/docs/spec/features/monitoring.md b/docs/spec/features/monitoring.md index 45381ad..704cb0f 100644 --- a/docs/spec/features/monitoring.md +++ b/docs/spec/features/monitoring.md @@ -530,6 +530,40 @@ these needs a write. domains' traces is wanted (MON-31). 12. Size of INBUXA's `o` and `x` subspaces, to check MON-19's estimate. +## Implementation status + +Built 2026-09-19 from this spec, clean-room, under the multi-tenancy hand-off +brief's rules. Metric history is in `crates/common/src/telemetry/metrics/store.rs`, +trace history in `crates/common/src/telemetry/tracers/store.rs`, alerts in +`crates/common/src/telemetry/alerts.rs`, the live streams in +`crates/http/src/live.rs`, and `x:Metric` and `x:Trace` in +`crates/jmap/src/inbuxa/telemetry.rs`, at the paths the shared tests name; +upstream files carry hooks marked `inbuxa:`. All four suites in +`tests/src/telemetry` are un-gated and pass, so the table above is history: +they cover tests 10 and 12 (`tracing.rs`), 13 (`metrics.rs`) and 18 +(`alerts.rs`). Tests 1, 3, 5 to 9, 11, 14 to 17, 19 to 21, 23 and 24 pass as +`tests/src/system/monitoring.rs`. + +- **MON-1 to MON-39:** built, with the limits below. +- **Test 26 (compat)** is written as `monitoring_compat`, ignored, and unrun + until a copy of INBUXA's data is provided. It checks observed 1, reads the + old history without an error, and purges it. It deletes history, so only + ever run it against a copy. +- **Not exercised by a test:** 2 (an unreachable PostgreSQL tracing store), + 4 (`queue.count` after a restart; the gauge is set from the queue on each + calculation), 22 (a webhook for `telemetry.alert-event`; webhooks are the + shared, unchanged path), 25 (a full buffer; the subscriber is lossy by + construction), and test 17's 30-minute close. +- **Known limits, not requirements of this spec:** + - A trace cut at 1000 events (MON-15) carries the number cut as a `total` + value on its closing event, since no event type fits a separate marker. + - The `inbuxa:admin` scope check on live tokens (MON-23) waits for the auth + contract; until then the permission and server-level checks apply. + - A live token can be reused within its 60 seconds (Decision, MON-23). + - The alert edge trigger (MON-26) is held in memory, so an alert whose + condition holds across a restart fires once more. + - The stream limit (MON-24) is per node. + ## Observed Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart diff --git a/tests/src/system/monitoring.rs b/tests/src/system/monitoring.rs index 3ff7e2e..e21b465 100644 --- a/tests/src/system/monitoring.rs +++ b/tests/src/system/monitoring.rs @@ -9,6 +9,7 @@ //! names the test number or requirement. use crate::utils::{ + account::Account, server::{TestServer, TestServerBuilder}, smtp::SmtpConnection, }; @@ -24,6 +25,7 @@ use registry::schema::{ use serde_json::{Value, json}; use std::time::Duration; use trc::{ClusterEvent, Collector, EventType, MetricType}; +use types::id::Id; const SECRET: &str = "monitoring test user passphrase"; @@ -382,6 +384,86 @@ pub async fn test(test: &mut TestServer) { test.wait_for_tasks().await; } +/// Acceptance test 26 (compat): INBUXA's settings read back as observed 1, +/// its stored history in the stripped encoding is skipped, not an error, and +/// one purge past its age removes it. Run against a copy of its data with +/// `INBUXA_COMPAT_ADMIN` (`name:password`), `NO_INSERT=1`, and the store's +/// `TMPDIR`/`STORE` pointing at the copy. The purge deletes history, so never +/// point it at the live store. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn monitoring_compat() { + let admin = std::env::var("INBUXA_COMPAT_ADMIN").expect("INBUXA_COMPAT_ADMIN"); + assert!(std::env::var("NO_INSERT").is_ok(), "NO_INSERT must be set"); + let test = TestServerBuilder::new("monitoring_compat") + .await + .with_default_listeners() + .await + .build_with_opts(false) + .await; + let (name, secret) = admin.split_once(':').expect("name:password"); + let admin = Account::new( + Box::leak(name.to_string().into_boxed_str()), + Box::leak(secret.to_string().into_boxed_str()), + &[], + "Compat admin", + Id::from(u32::MAX), + ); + + // Observed 1: 30 and 90 days, hourly, both stores Default, no alerts + let retention = admin + .jmap_method_call("x:DataRetention/get", json!({"ids": ["singleton"]})) + .await; + let retention = &retention.list()[0]; + assert_eq!(retention["holdTracesFor"], 30 * 86_400_000u64, "{retention}"); + assert_eq!(retention["holdMetricsFor"], 90 * 86_400_000u64, "{retention}"); + assert_eq!(retention["metricsCollectionInterval"]["@type"], "Hourly"); + for object in ["x:TracingStore/get", "x:MetricsStore/get"] { + let store = admin + .jmap_method_call(object, json!({"ids": ["singleton"]})) + .await; + assert_eq!(store.list()[0]["@type"], "Default", "{object}"); + } + let search = admin + .jmap_method_call("x:Search/get", json!({"ids": ["singleton"]})) + .await; + let search = &search.list()[0]; + assert_eq!(search["indexTelemetry"], true, "{search}"); + let alerts = admin + .jmap_method_call("x:Alert/get", json!({"ids": null})) + .await; + assert!(alerts.list().is_empty(), "{alerts:?}"); + + // Old history: read without an error, whatever can't be decoded skipped + for method in ["x:Trace/get", "x:Metric/get"] { + let response = admin.jmap_method_call(method, json!({"ids": null})).await; + assert!( + response.0.pointer("/methodResponses/0/1/list").is_some(), + "{method}: {response:?}" + ); + } + + // One purge past its age, and nothing old is left + test.server + .tracing_store() + .purge_spans(Duration::ZERO, Some(test.server.search_store())) + .await + .unwrap(); + test.server + .metrics_store() + .purge_metrics(Duration::ZERO) + .await + .unwrap(); + for method in ["x:Trace/query", "x:Metric/query"] { + let response = admin.jmap_method_call(method, json!({})).await; + assert_eq!( + response.0.pointer("/methodResponses/0/1/ids"), + Some(&json!([])), + "{method}: {response:?}" + ); + } +} + /// The first `event:` frame of that name on an event stream, its data. async fn first_frame(http: &reqwest::Client, url: &str, event: &str) -> String { let mut response = http.get(url).send().await.unwrap();