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];
170
171/// Composite alias-bound prefixes. Each prefix gets three on-disk keys:
172/// `<prefix>` (full `<type>.<alias>`), `<prefix>_type`, `<prefix>_alias`.
173/// Adding to this list propagates to every consumer the same way as
174/// [`ATTRIBUTION_FIELDS`].
175pub const COMPOSITE_PREFIXES: &[&str] = &[
176    "channel",
177    "model_provider",
178    "tts_provider",
179    "transcription_provider",
180    "tunnel_provider",
181];
182
183/// Derive the `_type` decomposed key for a composite prefix. Single source
184/// of the `_type` suffix — every reader/writer routes through this.
185#[must_use]
186pub fn type_field(prefix: &str) -> String {
187    format!("{prefix}_type")
188}
189
190/// Derive the `_alias` decomposed key for a composite prefix. Single source
191/// of the `_alias` suffix.
192#[must_use]
193pub fn alias_field(prefix: &str) -> String {
194    format!("{prefix}_alias")
195}
196
197/// True when `name` matches a known plain attribution field, a composite
198/// prefix, or a composite's decomposed `_type` / `_alias` suffix.
199#[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/// ZeroClaw-domain attribution. Every field is alias-bound where
216/// applicable: `channel` is the `<type>.<alias>` composite, `model_provider`
217/// is the `<type>.<alias>` composite, etc. Composites are stored as three
218/// keys (`<prefix>`, `<prefix>_type`, `<prefix>_alias`) so filters can
219/// match either coarse or precise.
220///
221/// The shape is a flat string map flattened into the parent on-disk JSON,
222/// driven by [`ATTRIBUTION_FIELDS`] + [`COMPOSITE_PREFIXES`]. Adding a new
223/// attribution key requires extending those constants — nothing else.
224#[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    /// Per-event duration when applicable. Kept off `fields` so JSON
230    /// readers see a number, not a stringified number.
231    #[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    /// Set a composite-prefixed attribution by splitting `composite` at
246    /// the first `.` — populates `<prefix>`, `<prefix>_type`, and
247    /// (when the dotted form is present) `<prefix>_alias` in one call.
248    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    /// Fill any `key` absent on `self` from `other`. The flat-map shape
259    /// means composite groups move as a unit naturally (all three keys
260    /// merge independently, but the composite-prefix setter always
261    /// writes all three together, so the parent's set is consistent).
262    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    /// True when every plain field in [`ATTRIBUTION_FIELDS`] and every
272    /// composite in [`COMPOSITE_PREFIXES`] has been populated — the
273    /// span-walk uses this as a "no point looking further up" check.
274    #[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/// One row in the canonical log stream.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct LogEvent {
288    /// Persistent event id. UUID v4.
289    pub id: String,
290
291    /// RFC 3339 UTC timestamp with milliseconds. Keyed `@timestamp` to
292    /// match ECS conventions; consumers (and our paginated reader) sort
293    /// by this lexicographically, which works because RFC 3339 is sortable
294    /// as a string.
295    #[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    /// Per-turn trace identifier so multiple events from one agent
307    /// turn group together in the UI. Hex string; populated by the
308    /// agent loop at run() entry.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub trace_id: Option<String>,
311
312    /// Sub-span within a turn (e.g. one tool call inside a multi-tool
313    /// iteration).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub span_id: Option<String>,
316
317    /// All the alias-bound attribution fields live here.
318    #[serde(default)]
319    pub zeroclaw: ZeroclawAttribution,
320
321    /// Human-readable short message. The structured fields above carry the
322    /// machine-readable detail; `message` is what a terminal-formatter
323    /// prints as the line body.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub message: Option<String>,
326
327    /// Free-form structured payload. Per-action contributors put extra
328    /// data here (tokens used, iteration counter, tool input/output
329    /// payloads when `log_tool_io` is enabled, anyhow error chain when
330    /// the event is an error, …).
331    #[serde(default, skip_serializing_if = "Value::is_null")]
332    pub attributes: Value,
333
334    /// Schema version. `2` = this struct. Older files containing version-1
335    /// rows get migrated in place at daemon startup.
336    #[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    /// Build a fresh event with the given level + action + category.
348    /// Caller fills in attribution and message before emission.
349    #[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/// Lookup helper used by callers that already have an OTel-style severity
377/// number and want the text bucket.
378#[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// ---------------------------------------------------------------------------
396// Call-site Event surface
397// ---------------------------------------------------------------------------
398
399/// Closed `event.action` taxonomy. Adding a verb requires extending
400/// this enum — no `Other(&str)` escape hatch on purpose. The snake_case
401/// form of each variant is the on-disk `event.action` string, derived
402/// via `strum::IntoStaticStr`.
403#[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/// One emission's call-site descriptor. Built by the `record!` macro
453/// from the Event constructor + builder calls and consumed by the layer.
454/// Everything is by-value; the macro takes `&Event` to keep call sites
455/// non-moving.
456#[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    /// JSON-encode the attrs payload as a string for tracing::event!
514    /// transport. Layer parses back to `Value`.
515    #[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}