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 "owner_tui_id",
170];
171
172pub const COMPOSITE_PREFIXES: &[&str] = &[
177 "channel",
178 "model_provider",
179 "tts_provider",
180 "transcription_provider",
181 "tunnel_provider",
182];
183
184#[must_use]
187pub fn type_field(prefix: &str) -> String {
188 format!("{prefix}_type")
189}
190
191#[must_use]
194pub fn alias_field(prefix: &str) -> String {
195 format!("{prefix}_alias")
196}
197
198#[must_use]
201pub fn is_attribution_field(name: &str) -> bool {
202 if ATTRIBUTION_FIELDS.contains(&name) {
203 return true;
204 }
205 for prefix in COMPOSITE_PREFIXES {
206 if name == *prefix {
207 return true;
208 }
209 if name == type_field(prefix) || name == alias_field(prefix) {
210 return true;
211 }
212 }
213 false
214}
215
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226pub struct ZeroclawAttribution {
227 #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
228 pub fields: BTreeMap<String, String>,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub duration_ms: Option<u64>,
234}
235
236impl ZeroclawAttribution {
237 #[must_use]
238 pub fn get(&self, key: &str) -> Option<&str> {
239 self.fields.get(key).map(String::as_str)
240 }
241
242 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
243 self.fields.insert(key.into(), value.into());
244 }
245
246 pub fn set_composite(&mut self, prefix: &str, composite: &str) {
250 self.set(prefix.to_string(), composite.to_string());
251 if let Some((ty, alias)) = composite.split_once('.') {
252 self.set(type_field(prefix), ty.to_string());
253 self.set(alias_field(prefix), alias.to_string());
254 } else {
255 self.set(type_field(prefix), composite.to_string());
256 }
257 }
258
259 pub fn merge_from(&mut self, other: &Self) {
264 for (k, v) in &other.fields {
265 self.fields.entry(k.clone()).or_insert_with(|| v.clone());
266 }
267 if self.duration_ms.is_none() {
268 self.duration_ms = other.duration_ms;
269 }
270 }
271
272 #[must_use]
276 pub fn is_fully_populated(&self) -> bool {
277 ATTRIBUTION_FIELDS
278 .iter()
279 .all(|k| self.fields.contains_key(*k))
280 && COMPOSITE_PREFIXES
281 .iter()
282 .all(|p| self.fields.contains_key(*p))
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct LogEvent {
289 pub id: String,
291
292 #[serde(rename = "@timestamp")]
297 pub timestamp: String,
298
299 pub severity_number: u8,
300 pub severity_text: String,
301
302 pub event: EventDescriptor,
303
304 #[serde(default)]
305 pub service: ServiceDescriptor,
306
307 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub trace_id: Option<String>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub span_id: Option<String>,
317
318 #[serde(default)]
320 pub zeroclaw: ZeroclawAttribution,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub message: Option<String>,
327
328 #[serde(default, skip_serializing_if = "Value::is_null")]
333 pub attributes: Value,
334
335 #[serde(default = "default_schema_version")]
338 pub schema_version: u8,
339}
340
341fn default_schema_version() -> u8 {
342 LogEvent::SCHEMA_VERSION
343}
344
345impl LogEvent {
346 pub const SCHEMA_VERSION: u8 = 2;
347
348 #[must_use]
351 pub fn new(severity: Severity, action: &str, category: EventCategory) -> Self {
352 Self {
353 id: uuid::Uuid::new_v4().to_string(),
354 timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
355 severity_number: severity.number(),
356 severity_text: severity.text().to_string(),
357 event: EventDescriptor {
358 category: category.as_str().to_string(),
359 action: action.to_string(),
360 outcome: EventOutcome::Unknown.as_str().to_string(),
361 },
362 service: ServiceDescriptor::default(),
363 trace_id: None,
364 span_id: None,
365 zeroclaw: ZeroclawAttribution::default(),
366 message: None,
367 attributes: Value::Null,
368 schema_version: LogEvent::SCHEMA_VERSION,
369 }
370 }
371
372 pub fn set_outcome(&mut self, outcome: EventOutcome) {
373 self.event.outcome = outcome.as_str().to_string();
374 }
375}
376
377#[must_use]
380pub fn severity_text_from_number(n: u8) -> &'static str {
381 match n {
382 0..=4 => "TRACE",
383 5..=8 => "DEBUG",
384 9..=12 => "INFO",
385 13..=16 => "WARN",
386 17..=20 => "ERROR",
387 _ => "FATAL",
388 }
389}
390
391#[must_use]
392pub fn severity_text_from_tracing_level(level: tracing::Level) -> &'static str {
393 Severity::from_tracing_level(level).text()
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
405#[strum(serialize_all = "snake_case")]
406pub enum Action {
407 Start,
408 Complete,
409 Fail,
410 Cancel,
411 Skip,
412 Timeout,
413 Retry,
414 Inbound,
415 Outbound,
416 Send,
417 Receive,
418 Connect,
419 Disconnect,
420 Reconnect,
421 Spawn,
422 Kill,
423 Tick,
424 Trigger,
425 Schedule,
426 Approve,
427 Reject,
428 Defer,
429 Read,
430 Write,
431 Delete,
432 List,
433 Query,
434 Invoke,
435 Dispatch,
436 Resolve,
437 Register,
438 Unregister,
439 Load,
440 Save,
441 Migrate,
442 Validate,
443 Note,
444}
445
446impl Action {
447 #[must_use]
448 pub fn as_str(self) -> &'static str {
449 self.into()
450 }
451}
452
453#[derive(Debug, Clone)]
458pub struct Event {
459 pub name: &'static str,
460 pub action: Action,
461 pub category: Option<EventCategory>,
462 pub outcome: EventOutcome,
463 pub duration_ms: Option<u64>,
464 pub attrs: Option<Value>,
465}
466
467impl Event {
468 #[must_use]
469 pub fn new(name: &'static str, action: Action) -> Self {
470 Self {
471 name,
472 action,
473 category: None,
474 outcome: EventOutcome::Unknown,
475 duration_ms: None,
476 attrs: None,
477 }
478 }
479
480 #[must_use]
481 pub fn with_category(mut self, category: EventCategory) -> Self {
482 self.category = Some(category);
483 self
484 }
485
486 #[must_use]
487 pub fn with_outcome(mut self, outcome: EventOutcome) -> Self {
488 self.outcome = outcome;
489 self
490 }
491
492 #[must_use]
493 pub fn with_duration(mut self, duration_ms: u64) -> Self {
494 self.duration_ms = Some(duration_ms);
495 self
496 }
497
498 #[must_use]
499 pub fn with_attrs(mut self, attrs: Value) -> Self {
500 self.attrs = Some(attrs);
501 self
502 }
503
504 #[must_use]
505 pub fn category_str(&self) -> &'static str {
506 self.category.map_or("", EventCategory::as_str)
507 }
508
509 #[must_use]
510 pub fn outcome_str(&self) -> &'static str {
511 self.outcome.as_str()
512 }
513
514 #[must_use]
517 pub fn attrs_str(&self) -> String {
518 match &self.attrs {
519 Some(v) => serde_json::to_string(v).unwrap_or_default(),
520 None => String::new(),
521 }
522 }
523
524 #[must_use]
525 pub fn duration_ms_or_zero(&self) -> u64 {
526 self.duration_ms.unwrap_or(0)
527 }
528
529 #[must_use]
530 pub fn has_duration(&self) -> bool {
531 self.duration_ms.is_some()
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn severity_round_trip_through_tracing() {
541 for (level, severity) in [
542 (tracing::Level::TRACE, Severity::Trace),
543 (tracing::Level::DEBUG, Severity::Debug),
544 (tracing::Level::INFO, Severity::Info),
545 (tracing::Level::WARN, Severity::Warn),
546 (tracing::Level::ERROR, Severity::Error),
547 ] {
548 assert_eq!(Severity::from_tracing_level(level), severity);
549 }
550 }
551
552 #[test]
553 fn severity_text_buckets_match_number() {
554 assert_eq!(severity_text_from_number(1), "TRACE");
555 assert_eq!(severity_text_from_number(5), "DEBUG");
556 assert_eq!(severity_text_from_number(9), "INFO");
557 assert_eq!(severity_text_from_number(13), "WARN");
558 assert_eq!(severity_text_from_number(17), "ERROR");
559 assert_eq!(severity_text_from_number(22), "FATAL");
560 }
561
562 #[test]
563 fn set_composite_splits_channel() {
564 let mut attribution = ZeroclawAttribution::default();
565 attribution.set_composite("channel", "discord.clamps");
566 assert_eq!(attribution.get("channel"), Some("discord.clamps"));
567 assert_eq!(attribution.get("channel_type"), Some("discord"));
568 assert_eq!(attribution.get("channel_alias"), Some("clamps"));
569 }
570
571 #[test]
572 fn set_composite_bare_type() {
573 let mut attribution = ZeroclawAttribution::default();
574 attribution.set_composite("channel", "webhook");
575 assert_eq!(attribution.get("channel_type"), Some("webhook"));
576 assert!(attribution.get("channel_alias").is_none());
577 }
578
579 #[test]
580 fn set_composite_splits_model_provider() {
581 let mut attribution = ZeroclawAttribution::default();
582 attribution.set_composite("model_provider", "anthropic.clamps");
583 assert_eq!(attribution.get("model_provider"), Some("anthropic.clamps"));
584 assert_eq!(attribution.get("model_provider_type"), Some("anthropic"));
585 assert_eq!(attribution.get("model_provider_alias"), Some("clamps"));
586 }
587
588 #[test]
589 fn merge_from_fills_missing_only() {
590 let mut child = ZeroclawAttribution::default();
591 child.set("agent_alias", "clamps");
592 let mut parent = ZeroclawAttribution::default();
593 parent.set("agent_alias", "glados");
594 parent.set("risk_profile", "strict");
595 child.merge_from(&parent);
596 assert_eq!(child.get("agent_alias"), Some("clamps"));
597 assert_eq!(child.get("risk_profile"), Some("strict"));
598 }
599
600 #[test]
601 fn is_attribution_field_recognises_composites() {
602 assert!(is_attribution_field("channel"));
603 assert!(is_attribution_field("channel_type"));
604 assert!(is_attribution_field("channel_alias"));
605 assert!(is_attribution_field("model_provider_alias"));
606 assert!(is_attribution_field("agent_alias"));
607 assert!(!is_attribution_field("not_a_real_field"));
608 }
609
610 #[test]
611 fn event_serializes_with_at_timestamp_key() {
612 let event = LogEvent::new(Severity::Info, "test", EventCategory::Agent);
613 let serialized = serde_json::to_value(&event).unwrap();
614 assert!(serialized.get("@timestamp").is_some());
615 assert!(serialized.get("timestamp").is_none());
616 assert_eq!(serialized["severity_text"], "INFO");
617 assert_eq!(serialized["severity_number"], 9);
618 assert_eq!(serialized["event"]["category"], "agent");
619 assert_eq!(serialized["event"]["action"], "test");
620 assert_eq!(serialized["schema_version"], LogEvent::SCHEMA_VERSION);
621 }
622
623 #[test]
624 fn unknown_outcome_omitted_from_serialization() {
625 let event = LogEvent::new(Severity::Info, "test", EventCategory::Agent);
626 let serialized = serde_json::to_value(&event).unwrap();
627 assert!(serialized["event"].get("outcome").is_none());
628 }
629}