Skip to main content

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}