harn_vm/observability/
audit.rs1use serde_json::Value;
29
30use super::vocabulary;
31
32#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct AuditFinding {
36 pub kind: AuditFindingKind,
37 pub key: String,
40 pub surface: String,
43 pub context: String,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum AuditFindingKind {
50 UnknownVocabKey,
55 MetricMissingInstrument,
61 OrphanSpan,
66}
67
68impl AuditFinding {
69 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
92pub 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 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}