plecto_control/manifest/observability.rs
1//! Operational observability config (`[observability]`, ADR 000009 Stage A).
2
3use serde::{Deserialize, Serialize};
4
5/// Operational observability config (`[observability]`, ADR 000009 Stage A): a separate admin
6/// listener exposing Prometheus metrics + liveness/readiness, and an opt-in structured access log.
7/// Off by default — Plecto stays quiet and exposes nothing extra unless asked (operational
8/// simplicity). Captured at construction; a reload does not re-bind the admin listener.
9#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema, Serialize)]
10#[serde(deny_unknown_fields)]
11pub struct Observability {
12 /// `host:port` the admin endpoint binds (e.g. `127.0.0.1:9090`). `None` = no admin listener
13 /// (the default). Serves `/metrics`, `/healthz`, `/readyz` — never on the data-plane port, so
14 /// proxied routes never collide with it and the metrics surface is not exposed to clients.
15 #[serde(default)]
16 pub admin_addr: Option<String>,
17 /// Emit one structured access-log event per request (the `plecto::access` tracing target,
18 /// rendered as JSON by the binary's subscriber). `false` by default.
19 #[serde(default)]
20 pub access_log: bool,
21 /// OTLP/HTTP collector base URL (e.g. `http://localhost:4318`) — the exporter appends
22 /// `/v1/traces`, mirroring `OTEL_EXPORTER_OTLP_ENDPOINT` semantics (ADR 000040). `None` = no
23 /// trace export (the default). Captured at construction, like `admin_addr`: changing it
24 /// requires a restart, not a reload.
25 #[serde(default)]
26 pub otlp_endpoint: Option<String>,
27}
28
29#[cfg(test)]
30mod tests {
31 use crate::manifest::Manifest;
32
33 #[test]
34 fn observability_defaults_off_and_parses_when_present() {
35 // Absent `[observability]` → admin endpoint off, access log off, no OTLP export
36 // (operational simplicity).
37 let bare = Manifest::from_toml("").unwrap();
38 assert_eq!(bare.observability.admin_addr, None);
39 assert!(!bare.observability.access_log);
40 assert_eq!(bare.observability.otlp_endpoint, None);
41
42 // Present → the knobs are read.
43 let m = Manifest::from_toml(
44 r#"
45[observability]
46admin_addr = "127.0.0.1:9090"
47access_log = true
48otlp_endpoint = "http://localhost:4318"
49"#,
50 )
51 .unwrap();
52 assert_eq!(
53 m.observability.admin_addr.as_deref(),
54 Some("127.0.0.1:9090")
55 );
56 assert!(m.observability.access_log);
57 assert_eq!(
58 m.observability.otlp_endpoint.as_deref(),
59 Some("http://localhost:4318")
60 );
61 }
62
63 #[test]
64 fn observability_is_not_part_of_the_content_hash() {
65 // `[observability]` is operational, not config identity (`skip_serializing`): toggling it
66 // must NOT change the `content_hash` / config version, so an admin-only edit is a reload
67 // no-op rather than a spurious "config changed".
68 let without = Manifest::from_toml("").unwrap();
69 let with = Manifest::from_toml(
70 r#"
71[observability]
72admin_addr = "127.0.0.1:9090"
73access_log = true
74otlp_endpoint = "http://localhost:4318"
75"#,
76 )
77 .unwrap();
78 assert_eq!(
79 without.content_hash().unwrap(),
80 with.content_hash().unwrap(),
81 "observability config must not affect the semantic content hash"
82 );
83 }
84}