Skip to main content

harn_vm/observability/
audit.rs

1//! `harn-obs-audit` conformance gate.
2//!
3//! Scans a sequence of observability events emitted by a `.harn` handler
4//! (typically captured via `obs.events()` from a test harness) and
5//! reports violations of the published `harn.*` schema. Run from
6//! conformance tests so every primitive that lands in epic A (and every
7//! cloud endpoint port in epic E) gets gated on:
8//!
9//! * **Vocabulary:** any attribute key under a known `harn.<ns>.*`
10//!   prefix is declared in [`crate::observability::vocabulary`] —
11//!   already enforced at emit time, but the audit doubles as a
12//!   structural check for events that bypass the typed
13//!   `harness.obs.*` surface.
14//! * **Instrument tagging:** every metric event carries an
15//!   `instrument` field (`counter` / `histogram` / `gauge`), so
16//!   exporters can map it to the right OTel instrument without
17//!   guessing.
18//! * **Orphan spans:** every `span_end` event names a `trace_id` (set
19//!   automatically by [`crate::stdlib::observability`]) — a missing
20//!   id means the span never opened through the standard primitive.
21//!
22//! The audit is intentionally event-shape-based rather than AST-based:
23//! tests run the handler under the `test` backend and feed the captured
24//! events back through [`audit_events`]. Adding a new event class is
25//! one function in this module plus the namespace edit in
26//! [`crate::observability::vocabulary`].
27
28use serde_json::Value;
29
30use super::vocabulary;
31
32/// One audit violation. Carries enough context for the harness to
33/// surface a clickable, actionable error string in test output.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct AuditFinding {
36    pub kind: AuditFindingKind,
37    /// The offending key (for attribute violations), span name, or
38    /// metric name — whichever identifies the event.
39    pub key: String,
40    /// Which surface the offending event came from (`span`, `metric`,
41    /// `log`, ...). Mirrors the `kind` field on the event.
42    pub surface: String,
43    /// Auxiliary context — the event's `name` or the
44    /// `harn.<namespace>` that the key would be expected to live under.
45    pub context: String,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum AuditFindingKind {
50    /// An attribute key under a known `harn.<ns>.*` prefix that isn't
51    /// declared in the published vocabulary. Catches typos in
52    /// primitive emit sites that would otherwise drift into
53    /// dashboards.
54    UnknownVocabKey,
55    /// A metric event without an `instrument` field. Metrics emitted
56    /// through the typed `harness.obs.{counter,histogram,gauge}` set
57    /// it automatically; raw `obs.metric(...)` calls don't, so
58    /// primitives that want OTel-compatible metrics must migrate to
59    /// the instrument variants.
60    MetricMissingInstrument,
61    /// A `span_end` event with no `trace_id`. Spans opened through
62    /// [`crate::stdlib::observability::start_span_typed`] always carry
63    /// one — missing means the event was hand-crafted and skipped the
64    /// span helpers.
65    OrphanSpan,
66}
67
68impl AuditFinding {
69    /// Render the finding as a single line suitable for test output or
70    /// CI logs. Always starts with `HARN-OBS-AUDIT` so log scanners can
71    /// pick it out without re-parsing.
72    pub fn line(&self) -> String {
73        match self.kind {
74            AuditFindingKind::UnknownVocabKey => format!(
75                "HARN-OBS-AUDIT: {surface} `{ctx}` attribute `{key}` is not declared in the harn.* vocabulary",
76                surface = self.surface,
77                ctx = self.context,
78                key = self.key,
79            ),
80            AuditFindingKind::MetricMissingInstrument => format!(
81                "HARN-OBS-AUDIT: metric `{key}` lacks an `instrument` field (use harness.obs.{{counter,histogram,gauge}}; raw obs.metric() is not OTel-compatible)",
82                key = self.key,
83            ),
84            AuditFindingKind::OrphanSpan => format!(
85                "HARN-OBS-AUDIT: span `{key}` has no trace_id (span must open through harness.obs.span/start_span)",
86                key = self.key,
87            ),
88        }
89    }
90}
91
92/// Audit a sequence of obs events. The events are the payload values
93/// returned by `obs.events()` / `obs.events_take()` from `.harn`.
94///
95/// `events` typically wraps each payload as
96/// `{ backend, format, payload: {...} }` — we drill into `payload` for
97/// the structural fields. Any non-object entry is skipped silently
98/// (compose backends emit nested arrays).
99pub fn audit_events(events: &[Value]) -> Vec<AuditFinding> {
100    let mut findings = Vec::new();
101    for entry in events {
102        audit_one(entry, &mut findings);
103    }
104    findings
105}
106
107fn audit_one(entry: &Value, findings: &mut Vec<AuditFinding>) {
108    let payload = entry.get("payload").unwrap_or(entry);
109    let Some(map) = payload.as_object() else {
110        return;
111    };
112    let kind = map.get("kind").and_then(Value::as_str).unwrap_or("");
113    let name = map
114        .get("name")
115        .and_then(Value::as_str)
116        .or_else(|| map.get("message").and_then(Value::as_str))
117        .unwrap_or("")
118        .to_string();
119
120    if kind == "metric" && !map.contains_key("instrument") {
121        findings.push(AuditFinding {
122            kind: AuditFindingKind::MetricMissingInstrument,
123            key: name.clone(),
124            surface: "metric".to_string(),
125            context: String::new(),
126        });
127    }
128
129    if kind == "span_end" {
130        let has_trace_id = map
131            .get("trace_id")
132            .and_then(Value::as_str)
133            .is_some_and(|id| !id.is_empty());
134        if !has_trace_id {
135            findings.push(AuditFinding {
136                kind: AuditFindingKind::OrphanSpan,
137                key: name.clone(),
138                surface: "span".to_string(),
139                context: String::new(),
140            });
141        }
142    }
143
144    if let Some(Value::Object(fields)) = map.get("fields") {
145        for key in fields.keys() {
146            if vocabulary::is_violation(key) {
147                findings.push(AuditFinding {
148                    kind: AuditFindingKind::UnknownVocabKey,
149                    key: key.clone(),
150                    surface: kind.to_string(),
151                    context: name.clone(),
152                });
153            }
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use serde_json::json;
162
163    #[test]
164    fn metric_without_instrument_is_flagged() {
165        let events = vec![json!({"payload": {
166            "kind": "metric",
167            "name": "harn.mcp.calls",
168            "value": 1,
169            "fields": {},
170        }})];
171        let findings = audit_events(&events);
172        assert_eq!(findings.len(), 1);
173        assert_eq!(findings[0].kind, AuditFindingKind::MetricMissingInstrument);
174        assert!(findings[0].line().contains("harn.mcp.calls"));
175    }
176
177    #[test]
178    fn metric_with_instrument_passes() {
179        let events = vec![json!({"payload": {
180            "kind": "metric",
181            "name": "harn.mcp.calls",
182            "value": 1,
183            "instrument": "counter",
184            "fields": {"harn.mcp.server": "fs"},
185        }})];
186        assert!(audit_events(&events).is_empty());
187    }
188
189    #[test]
190    fn unknown_vocab_attribute_is_flagged() {
191        let events = vec![json!({"payload": {
192            "kind": "log",
193            "message": "boop",
194            "fields": {"harn.mcp.boops": "wat"},
195        }})];
196        let findings = audit_events(&events);
197        assert_eq!(findings.len(), 1);
198        assert_eq!(findings[0].kind, AuditFindingKind::UnknownVocabKey);
199        assert_eq!(findings[0].key, "harn.mcp.boops");
200    }
201
202    #[test]
203    fn span_end_without_trace_id_is_flagged() {
204        let events = vec![json!({"payload": {
205            "kind": "span_end",
206            "name": "raw_span",
207            "fields": {},
208        }})];
209        let findings = audit_events(&events);
210        assert_eq!(findings.len(), 1);
211        assert_eq!(findings[0].kind, AuditFindingKind::OrphanSpan);
212    }
213
214    #[test]
215    fn user_attributes_outside_harn_prefix_pass() {
216        let events = vec![json!({"payload": {
217            "kind": "log",
218            "message": "user log",
219            "fields": {"user.id": 7, "custom.tag": "ok"},
220        }})];
221        assert!(audit_events(&events).is_empty());
222    }
223
224    #[test]
225    fn compose_payload_arrays_descend_into_inner_entries() {
226        // Events wrapped by the compose backend land as nested arrays
227        // — the audit should still surface their inner findings rather
228        // than treating the wrapper as opaque.
229        let events = vec![json!({"payload": {
230            "kind": "metric",
231            "name": "harn.pg.queries",
232            "instrument": "counter",
233            "fields": {"harn.pg.bogus": "nope"},
234        }})];
235        let findings = audit_events(&events);
236        assert_eq!(findings.len(), 1);
237        assert_eq!(findings[0].kind, AuditFindingKind::UnknownVocabKey);
238        assert_eq!(findings[0].key, "harn.pg.bogus");
239    }
240}