Skip to main content

zeroclaw_log/
event.rs

1//! Canonical event schema. OTel logs data model + ECS attribute
2//! conventions, with a `zeroclaw.*` namespace for the alias-bound
3//! domain attribution fields.
4//!
5//! On-disk JSON shape is the canonical contract — third-party tail
6//! consumers parse `serde_json::Value` and walk the keys. This struct is
7//! `pub(crate)` to keep external consumers off the typed surface.
8
9use 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/// OTel severity buckets. Stored alongside `severity_number` so consumers
18/// can range-compare numerically and pattern-match textually.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Severity {
21    Trace,
22    Debug,
23    Info,
24    Warn,
25    Error,
26}
27
28impl Severity {
29    // SCREAMING_SNAKE_CASE aliases so the `record!` macro can mirror
30    // `tracing::Level::INFO` syntax at the call site (and so the macro
31    // body's `$crate::Severity::$level` token forwarding works).
32    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    /// OTel severity_number for the bucket's "primary" sub-level.
39    #[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    /// Convert from a `tracing::Level`.
62    #[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/// ECS-style event.category coarse axis.
75#[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/// ECS event.outcome. Default unknown.
102#[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/// ECS-style nested event descriptor.
123#[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/// Service-identifier block. Constant for the daemon's lifetime.
136#[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
151/// Plain alias-bound attribution fields. Adding to this list is the ONLY
152/// per-field change needed — Layer/reader/gateway/UI all read this list
153/// at runtime instead of hardcoding the per-field plumbing.
154pub 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
172/// Composite alias-bound prefixes. Each prefix gets three on-disk keys:
173/// `<prefix>` (full `<type>.<alias>`), `<prefix>_type`, `<prefix>_alias`.
174/// Adding to this list propagates to every consumer the same way as
175/// [`ATTRIBUTION_FIELDS`].
176pub const COMPOSITE_PREFIXES: &[&str] = &[
177    "channel",
178    "model_provider",
179    "tts_provider",
180    "transcription_provider",
181    "tunnel_provider",
182];
183
184/// Derive the `_type` decomposed key for a composite prefix. Single source
185/// of the `_type` suffix — every reader/writer routes through this.
186#[must_use]
187pub fn type_field(prefix: &str) -> String {
188    format!("{prefix}_type")
189}
190
191/// Derive the `_alias` decomposed key for a composite prefix. Single source
192/// of the `_alias` suffix.
193#[must_use]
194pub fn alias_field(prefix: &str) -> String {
195    format!("{prefix}_alias")
196}
197
198/// True when `name` matches a known plain attribution field, a composite
199/// prefix, or a composite's decomposed `_type` / `_alias` suffix.
200#[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/// ZeroClaw-domain attribution. Every field is alias-bound where
217/// applicable: `channel` is the `<type>.<alias>` composite, `model_provider`
218/// is the `<type>.<alias>` composite, etc. Composites are stored as three
219/// keys (`<prefix>`, `<prefix>_type`, `<prefix>_alias`) so filters can
220/// match either coarse or precise.
221///
222/// The shape is a flat string map flattened into the parent on-disk JSON,
223/// driven by [`ATTRIBUTION_FIELDS`] + [`COMPOSITE_PREFIXES`]. Adding a new
224/// attribution key requires extending those constants — nothing else.
225#[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    /// Per-event duration when applicable. Kept off `fields` so JSON
231    /// readers see a number, not a stringified number.
232    #[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    /// Set a composite-prefixed attribution by splitting `composite` at
247    /// the first `.` — populates `<prefix>`, `<prefix>_type`, and
248    /// (when the dotted form is present) `<prefix>_alias` in one call.
249    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    /// Fill any `key` absent on `self` from `other`. The flat-map shape
260    /// means composite groups move as a unit naturally (all three keys
261    /// merge independently, but the composite-prefix setter always
262    /// writes all three together, so the parent's set is consistent).
263    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    /// True when every plain field in [`ATTRIBUTION_FIELDS`] and every
273    /// composite in [`COMPOSITE_PREFIXES`] has been populated — the
274    /// span-walk uses this as a "no point looking further up" check.
275    #[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/// One row in the canonical log stream.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct LogEvent {
289    /// Persistent event id. UUID v4.
290    pub id: String,
291
292    /// RFC 3339 UTC timestamp with milliseconds. Keyed `@timestamp` to
293    /// match ECS conventions; consumers (and our paginated reader) sort
294    /// by this lexicographically, which works because RFC 3339 is sortable
295    /// as a string.
296    #[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    /// Per-turn trace identifier so multiple events from one agent
308    /// turn group together in the UI. Hex string; populated by the
309    /// agent loop at run() entry.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub trace_id: Option<String>,
312
313    /// Sub-span within a turn (e.g. one tool call inside a multi-tool
314    /// iteration).
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub span_id: Option<String>,
317
318    /// All the alias-bound attribution fields live here.
319    #[serde(default)]
320    pub zeroclaw: ZeroclawAttribution,
321
322    /// Human-readable short message. The structured fields above carry the
323    /// machine-readable detail; `message` is what a terminal-formatter
324    /// prints as the line body.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub message: Option<String>,
327
328    /// Free-form structured payload. Per-action contributors put extra
329    /// data here (tokens used, iteration counter, tool input/output
330    /// payloads when `log_tool_io` is enabled, anyhow error chain when
331    /// the event is an error, …).
332    #[serde(default, skip_serializing_if = "Value::is_null")]
333    pub attributes: Value,
334
335    /// Schema version. `2` = this struct. Older files containing version-1
336    /// rows get migrated in place at daemon startup.
337    #[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    /// Build a fresh event with the given level + action + category.
349    /// Caller fills in attribution and message before emission.
350    #[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/// Lookup helper used by callers that already have an OTel-style severity
378/// number and want the text bucket.
379#[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// ---------------------------------------------------------------------------
397// Call-site Event surface
398// ---------------------------------------------------------------------------
399
400/// Closed `event.action` taxonomy. Adding a verb requires extending
401/// this enum — no `Other(&str)` escape hatch on purpose. The snake_case
402/// form of each variant is the on-disk `event.action` string, derived
403/// via `strum::IntoStaticStr`.
404#[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/// One emission's call-site descriptor. Built by the `record!` macro
454/// from the Event constructor + builder calls and consumed by the layer.
455/// Everything is by-value; the macro takes `&Event` to keep call sites
456/// non-moving.
457#[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    /// JSON-encode the attrs payload as a string for tracing::event!
515    /// transport. Layer parses back to `Value`.
516    #[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}