1use std::collections::BTreeMap;
10use std::str::FromStr;
11
12use chrono::{SecondsFormat, Utc};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use strum_macros::{EnumString, IntoStaticStr};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Severity {
21 Trace,
22 Debug,
23 Info,
24 Warn,
25 Error,
26}
27
28impl Severity {
29 pub const TRACE: Self = Self::Trace;
33 pub const DEBUG: Self = Self::Debug;
34 pub const INFO: Self = Self::Info;
35 pub const WARN: Self = Self::Warn;
36 pub const ERROR: Self = Self::Error;
37
38 #[must_use]
40 pub fn number(self) -> u8 {
41 match self {
42 Self::Trace => 1,
43 Self::Debug => 5,
44 Self::Info => 9,
45 Self::Warn => 13,
46 Self::Error => 17,
47 }
48 }
49
50 #[must_use]
51 pub fn text(self) -> &'static str {
52 match self {
53 Self::Trace => "TRACE",
54 Self::Debug => "DEBUG",
55 Self::Info => "INFO",
56 Self::Warn => "WARN",
57 Self::Error => "ERROR",
58 }
59 }
60
61 #[must_use]
63 pub fn from_tracing_level(level: tracing::Level) -> Self {
64 match level {
65 tracing::Level::TRACE => Self::Trace,
66 tracing::Level::DEBUG => Self::Debug,
67 tracing::Level::INFO => Self::Info,
68 tracing::Level::WARN => Self::Warn,
69 tracing::Level::ERROR => Self::Error,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr, EnumString)]
76#[strum(serialize_all = "snake_case")]
77pub enum EventCategory {
78 Agent,
79 Channel,
80 Cron,
81 Memory,
82 Tool,
83 Provider,
84 Session,
85 System,
86 Internal,
87}
88
89impl EventCategory {
90 #[must_use]
91 pub fn as_str(self) -> &'static str {
92 self.into()
93 }
94
95 #[must_use]
96 pub fn parse(raw: &str) -> Option<Self> {
97 Self::from_str(raw).ok()
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr, EnumString)]
103#[strum(serialize_all = "snake_case")]
104pub enum EventOutcome {
105 Success,
106 Failure,
107 Unknown,
108}
109
110impl EventOutcome {
111 #[must_use]
112 pub fn as_str(self) -> &'static str {
113 self.into()
114 }
115
116 #[must_use]
117 pub fn parse(raw: &str) -> Option<Self> {
118 Self::from_str(raw).ok()
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct EventDescriptor {
125 pub category: String,
126 pub action: String,
127 #[serde(default, skip_serializing_if = "is_unknown_outcome")]
128 pub outcome: String,
129}
130
131fn is_unknown_outcome(s: &String) -> bool {
132 s == "unknown" || s.is_empty()
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ServiceDescriptor {
138 pub name: String,
139 pub version: String,
140}
141
142impl Default for ServiceDescriptor {
143 fn default() -> Self {
144 Self {
145 name: "zeroclaw".to_string(),
146 version: env!("CARGO_PKG_VERSION").to_string(),
147 }
148 }
149}
150
151pub const ATTRIBUTION_FIELDS: &[&str] = &[
155 "agent_alias",
156 "tool",
157 "session_key",
158 "cron_job_id",
159 "risk_profile",
160 "runtime_profile",
161 "memory_namespace",
162 "skill_bundle",
163 "knowledge_bundle",
164 "mcp_bundle",
165 "peer_group",
166 "sop_name",
167 "model",
168 "embedding_provider",
169];
170
171pub const COMPOSITE_PREFIXES: &[&str] = &[
176 "channel",
177 "model_provider",
178 "tts_provider",
179 "transcription_provider",
180 "tunnel_provider",
181];
182
183#[must_use]
186pub fn type_field(prefix: &str) -> String {
187 format!("{prefix}_type")
188}
189
190#[must_use]
193pub fn alias_field(prefix: &str) -> String {
194 format!("{prefix}_alias")
195}
196
197#[must_use]
200pub fn is_attribution_field(name: &str) -> bool {
201 if ATTRIBUTION_FIELDS.contains(&name) {
202 return true;
203 }
204 for prefix in COMPOSITE_PREFIXES {
205 if name == *prefix {
206 return true;
207 }
208 if name == type_field(prefix) || name == alias_field(prefix) {
209 return true;
210 }
211 }
212 false
213}
214
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
225pub struct ZeroclawAttribution {
226 #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
227 pub fields: BTreeMap<String, String>,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub duration_ms: Option<u64>,
233}
234
235impl ZeroclawAttribution {
236 #[must_use]
237 pub fn get(&self, key: &str) -> Option<&str> {
238 self.fields.get(key).map(String::as_str)
239 }
240
241 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
242 self.fields.insert(key.into(), value.into());
243 }
244
245 pub fn set_composite(&mut self, prefix: &str, composite: &str) {
249 self.set(prefix.to_string(), composite.to_string());
250 if let Some((ty, alias)) = composite.split_once('.') {
251 self.set(type_field(prefix), ty.to_string());
252 self.set(alias_field(prefix), alias.to_string());
253 } else {
254 self.set(type_field(prefix), composite.to_string());
255 }
256 }
257
258 pub fn merge_from(&mut self, other: &Self) {
263 for (k, v) in &other.fields {
264 self.fields.entry(k.clone()).or_insert_with(|| v.clone());
265 }
266 if self.duration_ms.is_none() {
267 self.duration_ms = other.duration_ms;
268 }
269 }
270
271 #[must_use]
275 pub fn is_fully_populated(&self) -> bool {
276 ATTRIBUTION_FIELDS
277 .iter()
278 .all(|k| self.fields.contains_key(*k))
279 && COMPOSITE_PREFIXES
280 .iter()
281 .all(|p| self.fields.contains_key(*p))
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct LogEvent {
288 pub id: String,
290
291 #[serde(rename = "@timestamp")]
296 pub timestamp: String,
297
298 pub severity_number: u8,
299 pub severity_text: String,
300
301 pub event: EventDescriptor,
302
303 #[serde(default)]
304 pub service: ServiceDescriptor,
305
306 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub trace_id: Option<String>,
311
312 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub span_id: Option<String>,
316
317 #[serde(default)]
319 pub zeroclaw: ZeroclawAttribution,
320
321 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub message: Option<String>,
326
327 #[serde(default, skip_serializing_if = "Value::is_null")]
332 pub attributes: Value,
333
334 #[serde(default = "default_schema_version")]
337 pub schema_version: u8,
338}
339
340fn default_schema_version() -> u8 {
341 LogEvent::SCHEMA_VERSION
342}
343
344impl LogEvent {
345 pub const SCHEMA_VERSION: u8 = 2;
346
347 #[must_use]
350 pub fn new(severity: Severity, action: &str, category: EventCategory) -> Self {
351 Self {
352 id: uuid::Uuid::new_v4().to_string(),
353 timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
354 severity_number: severity.number(),
355 severity_text: severity.text().to_string(),
356 event: EventDescriptor {
357 category: category.as_str().to_string(),
358 action: action.to_string(),
359 outcome: EventOutcome::Unknown.as_str().to_string(),
360 },
361 service: ServiceDescriptor::default(),
362 trace_id: None,
363 span_id: None,
364 zeroclaw: ZeroclawAttribution::default(),
365 message: None,
366 attributes: Value::Null,
367 schema_version: LogEvent::SCHEMA_VERSION,
368 }
369 }
370
371 pub fn set_outcome(&mut self, outcome: EventOutcome) {
372 self.event.outcome = outcome.as_str().to_string();
373 }
374}
375
376#[must_use]
379pub fn severity_text_from_number(n: u8) -> &'static str {
380 match n {
381 0..=4 => "TRACE",
382 5..=8 => "DEBUG",
383 9..=12 => "INFO",
384 13..=16 => "WARN",
385 17..=20 => "ERROR",
386 _ => "FATAL",
387 }
388}
389
390#[must_use]
391pub fn severity_text_from_tracing_level(level: tracing::Level) -> &'static str {
392 Severity::from_tracing_level(level).text()
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
404#[strum(serialize_all = "snake_case")]
405pub enum Action {
406 Start,
407 Complete,
408 Fail,
409 Cancel,
410 Skip,
411 Timeout,
412 Retry,
413 Inbound,
414 Outbound,
415 Send,
416 Receive,
417 Connect,
418 Disconnect,
419 Reconnect,
420 Spawn,
421 Kill,
422 Tick,
423 Trigger,
424 Schedule,
425 Approve,
426 Reject,
427 Defer,
428 Read,
429 Write,
430 Delete,
431 List,
432 Query,
433 Invoke,
434 Dispatch,
435 Resolve,
436 Register,
437 Unregister,
438 Load,
439 Save,
440 Migrate,
441 Validate,
442 Note,
443}
444
445impl Action {
446 #[must_use]
447 pub fn as_str(self) -> &'static str {
448 self.into()
449 }
450}
451
452#[derive(Debug, Clone)]
457pub struct Event {
458 pub name: &'static str,
459 pub action: Action,
460 pub category: Option<EventCategory>,
461 pub outcome: EventOutcome,
462 pub duration_ms: Option<u64>,
463 pub attrs: Option<Value>,
464}
465
466impl Event {
467 #[must_use]
468 pub fn new(name: &'static str, action: Action) -> Self {
469 Self {
470 name,
471 action,
472 category: None,
473 outcome: EventOutcome::Unknown,
474 duration_ms: None,
475 attrs: None,
476 }
477 }
478
479 #[must_use]
480 pub fn with_category(mut self, category: EventCategory) -> Self {
481 self.category = Some(category);
482 self
483 }
484
485 #[must_use]
486 pub fn with_outcome(mut self, outcome: EventOutcome) -> Self {
487 self.outcome = outcome;
488 self
489 }
490
491 #[must_use]
492 pub fn with_duration(mut self, duration_ms: u64) -> Self {
493 self.duration_ms = Some(duration_ms);
494 self
495 }
496
497 #[must_use]
498 pub fn with_attrs(mut self, attrs: Value) -> Self {
499 self.attrs = Some(attrs);
500 self
501 }
502
503 #[must_use]
504 pub fn category_str(&self) -> &'static str {
505 self.category.map_or("", EventCategory::as_str)
506 }
507
508 #[must_use]
509 pub fn outcome_str(&self) -> &'static str {
510 self.outcome.as_str()
511 }
512
513 #[must_use]
516 pub fn attrs_str(&self) -> String {
517 match &self.attrs {
518 Some(v) => serde_json::to_string(v).unwrap_or_default(),
519 None => String::new(),
520 }
521 }
522
523 #[must_use]
524 pub fn duration_ms_or_zero(&self) -> u64 {
525 self.duration_ms.unwrap_or(0)
526 }
527
528 #[must_use]
529 pub fn has_duration(&self) -> bool {
530 self.duration_ms.is_some()
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
539 fn severity_round_trip_through_tracing() {
540 for (level, severity) in [
541 (tracing::Level::TRACE, Severity::Trace),
542 (tracing::Level::DEBUG, Severity::Debug),
543 (tracing::Level::INFO, Severity::Info),
544 (tracing::Level::WARN, Severity::Warn),
545 (tracing::Level::ERROR, Severity::Error),
546 ] {
547 assert_eq!(Severity::from_tracing_level(level), severity);
548 }
549 }
550
551 #[test]
552 fn severity_text_buckets_match_number() {
553 assert_eq!(severity_text_from_number(1), "TRACE");
554 assert_eq!(severity_text_from_number(5), "DEBUG");
555 assert_eq!(severity_text_from_number(9), "INFO");
556 assert_eq!(severity_text_from_number(13), "WARN");
557 assert_eq!(severity_text_from_number(17), "ERROR");
558 assert_eq!(severity_text_from_number(22), "FATAL");
559 }
560
561 #[test]
562 fn set_composite_splits_channel() {
563 let mut attribution = ZeroclawAttribution::default();
564 attribution.set_composite("channel", "discord.clamps");
565 assert_eq!(attribution.get("channel"), Some("discord.clamps"));
566 assert_eq!(attribution.get("channel_type"), Some("discord"));
567 assert_eq!(attribution.get("channel_alias"), Some("clamps"));
568 }
569
570 #[test]
571 fn set_composite_bare_type() {
572 let mut attribution = ZeroclawAttribution::default();
573 attribution.set_composite("channel", "webhook");
574 assert_eq!(attribution.get("channel_type"), Some("webhook"));
575 assert!(attribution.get("channel_alias").is_none());
576 }
577
578 #[test]
579 fn set_composite_splits_model_provider() {
580 let mut attribution = ZeroclawAttribution::default();
581 attribution.set_composite("model_provider", "anthropic.clamps");
582 assert_eq!(attribution.get("model_provider"), Some("anthropic.clamps"));
583 assert_eq!(attribution.get("model_provider_type"), Some("anthropic"));
584 assert_eq!(attribution.get("model_provider_alias"), Some("clamps"));
585 }
586
587 #[test]
588 fn merge_from_fills_missing_only() {
589 let mut child = ZeroclawAttribution::default();
590 child.set("agent_alias", "clamps");
591 let mut parent = ZeroclawAttribution::default();
592 parent.set("agent_alias", "glados");
593 parent.set("risk_profile", "strict");
594 child.merge_from(&parent);
595 assert_eq!(child.get("agent_alias"), Some("clamps"));
596 assert_eq!(child.get("risk_profile"), Some("strict"));
597 }
598
599 #[test]
600 fn is_attribution_field_recognises_composites() {
601 assert!(is_attribution_field("channel"));
602 assert!(is_attribution_field("channel_type"));
603 assert!(is_attribution_field("channel_alias"));
604 assert!(is_attribution_field("model_provider_alias"));
605 assert!(is_attribution_field("agent_alias"));
606 assert!(!is_attribution_field("not_a_real_field"));
607 }
608
609 #[test]
610 fn event_serializes_with_at_timestamp_key() {
611 let event = LogEvent::new(Severity::Info, "test", EventCategory::Agent);
612 let serialized = serde_json::to_value(&event).unwrap();
613 assert!(serialized.get("@timestamp").is_some());
614 assert!(serialized.get("timestamp").is_none());
615 assert_eq!(serialized["severity_text"], "INFO");
616 assert_eq!(serialized["severity_number"], 9);
617 assert_eq!(serialized["event"]["category"], "agent");
618 assert_eq!(serialized["event"]["action"], "test");
619 assert_eq!(serialized["schema_version"], LogEvent::SCHEMA_VERSION);
620 }
621
622 #[test]
623 fn unknown_outcome_omitted_from_serialization() {
624 let event = LogEvent::new(Severity::Info, "test", EventCategory::Agent);
625 let serialized = serde_json::to_value(&event).unwrap();
626 assert!(serialized["event"].get("outcome").is_none());
627 }
628}