Skip to main content

sc_observability_types/
events.rs

1use std::sync::LazyLock;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5
6use crate::{
7    ActionName, CorrelationId, Diagnostic, Level, OutcomeLabel, ProcessIdentity, SchemaVersion,
8    ServiceName, StateTransition, TargetCategory, Timestamp, TraceContext, constants,
9};
10
11static OBSERVATION_SCHEMA_VERSION: LazyLock<SchemaVersion> = LazyLock::new(|| {
12    SchemaVersion::new(constants::OBSERVATION_ENVELOPE_VERSION)
13        .expect("shared schema version constant is valid")
14});
15
16/// Marker trait for consumer-owned observation payloads.
17pub trait Observable: Send + Sync + 'static {}
18
19impl<T> Observable for T where T: Send + Sync + 'static {}
20
21/// Shared envelope around a typed observation payload.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Observation<T>
24where
25    T: Observable,
26{
27    /// Envelope schema version.
28    pub version: SchemaVersion,
29    /// UTC observation timestamp.
30    pub timestamp: Timestamp,
31    /// Service that emitted the observation.
32    pub service: ServiceName,
33    /// Process identity attached to the observation.
34    pub identity: ProcessIdentity,
35    /// Optional trace context for correlation.
36    pub trace: Option<TraceContext>,
37    /// Caller-owned typed payload.
38    pub payload: T,
39}
40
41impl<T> Observation<T>
42where
43    T: Observable,
44{
45    /// Creates a new observation envelope using the current UTC timestamp.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use sc_observability_types::{Observation, ServiceName};
51    ///
52    /// let observation = Observation::new(
53    ///     ServiceName::new("demo").expect("valid service"),
54    ///     "payload".to_string(),
55    /// );
56    ///
57    /// assert_eq!(observation.service.as_str(), "demo");
58    /// assert_eq!(observation.version.as_str(), "v1");
59    /// ```
60    #[must_use]
61    pub fn new(service: ServiceName, payload: T) -> Self {
62        Self {
63            version: OBSERVATION_SCHEMA_VERSION.clone(),
64            timestamp: Timestamp::now_utc(),
65            service,
66            identity: ProcessIdentity::default(),
67            trace: None,
68            payload,
69        }
70    }
71}
72
73/// Structured log record emitted by the logging and routing layers.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct LogEvent {
76    /// Log schema version.
77    pub version: SchemaVersion,
78    /// UTC event timestamp.
79    pub timestamp: Timestamp,
80    /// Event severity.
81    pub level: Level,
82    /// Service that emitted the event.
83    pub service: ServiceName,
84    /// Stable target/category label.
85    pub target: TargetCategory,
86    /// Stable action label.
87    pub action: ActionName,
88    /// Optional human-readable message.
89    pub message: Option<String>,
90    /// Process identity attached to the event.
91    pub identity: ProcessIdentity,
92    /// Optional trace context for correlation.
93    pub trace: Option<TraceContext>,
94    /// Optional request identifier.
95    pub request_id: Option<CorrelationId>,
96    /// Optional correlation identifier.
97    pub correlation_id: Option<CorrelationId>,
98    /// Optional stable outcome label.
99    pub outcome: Option<OutcomeLabel>,
100    /// Optional structured diagnostic payload.
101    pub diagnostic: Option<Diagnostic>,
102    /// Optional state transition payload.
103    pub state_transition: Option<StateTransition>,
104    /// Arbitrary structured event fields.
105    pub fields: Map<String, Value>,
106}
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use serde::{Deserialize, Serialize};
111    use serde_json::json;
112
113    use crate::{SpanId, TraceId};
114
115    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116    struct FixturePayload {
117        name: String,
118        count: u32,
119    }
120
121    fn service_name() -> ServiceName {
122        ServiceName::new("sc-observability").expect("valid service name")
123    }
124
125    fn target_category() -> TargetCategory {
126        TargetCategory::new("routing.core").expect("valid target category")
127    }
128
129    fn action_name() -> ActionName {
130        ActionName::new("observation.received").expect("valid action name")
131    }
132
133    fn trace_context() -> TraceContext {
134        TraceContext {
135            trace_id: TraceId::new("0123456789abcdef0123456789abcdef").expect("valid trace id"),
136            span_id: SpanId::new("0123456789abcdef").expect("valid span id"),
137            parent_span_id: Some(SpanId::new("fedcba9876543210").expect("valid parent span id")),
138        }
139    }
140
141    fn diagnostic() -> Diagnostic {
142        Diagnostic {
143            timestamp: Timestamp::UNIX_EPOCH,
144            code: crate::error_codes::DIAGNOSTIC_INVALID,
145            message: "diagnostic invalid".to_string(),
146            cause: Some("invalid example".to_string()),
147            remediation: crate::Remediation::recoverable(
148                "fix the input",
149                ["rerun the command", "review the docs"],
150            ),
151            docs: Some("https://example.test/docs".to_string()),
152            details: Map::from_iter([("key".to_string(), json!("value"))]),
153        }
154    }
155
156    #[test]
157    fn observation_round_trips_through_serde() {
158        let mut observation = Observation::new(
159            service_name(),
160            FixturePayload {
161                name: "agent-info".to_string(),
162                count: 2,
163            },
164        );
165        observation.identity = ProcessIdentity {
166            hostname: Some("host-1".to_string()),
167            pid: Some(42),
168        };
169        observation.trace = Some(trace_context());
170
171        let encoded = serde_json::to_string(&observation).expect("serialize observation");
172        let decoded: Observation<FixturePayload> =
173            serde_json::from_str(&encoded).expect("deserialize observation");
174        assert_eq!(decoded, observation);
175    }
176
177    #[test]
178    fn log_event_round_trips_through_serde() {
179        let event = LogEvent {
180            version: OBSERVATION_SCHEMA_VERSION.clone(),
181            timestamp: Timestamp::UNIX_EPOCH,
182            level: Level::Info,
183            service: service_name(),
184            target: target_category(),
185            action: action_name(),
186            message: Some("observation accepted".to_string()),
187            identity: ProcessIdentity {
188                hostname: Some("host-1".to_string()),
189                pid: Some(7),
190            },
191            trace: Some(trace_context()),
192            request_id: Some(CorrelationId::new("req-1").expect("valid request id")),
193            correlation_id: Some(CorrelationId::new("corr-1").expect("valid correlation id")),
194            outcome: Some(OutcomeLabel::new("success").expect("valid outcome label")),
195            diagnostic: Some(diagnostic()),
196            state_transition: Some(StateTransition {
197                entity_kind: TargetCategory::new("subagent").expect("valid target category"),
198                entity_id: Some("agent-1".to_string()),
199                from_state: crate::StateName::new("started").expect("valid state"),
200                to_state: crate::StateName::new("running").expect("valid state"),
201                reason: Some("hook received".to_string()),
202                trigger: Some(ActionName::new("subagent-start").expect("valid action")),
203            }),
204            fields: Map::from_iter([("attempt".to_string(), json!(1))]),
205        };
206
207        let encoded = serde_json::to_string(&event).expect("serialize log event");
208        let decoded: LogEvent = serde_json::from_str(&encoded).expect("deserialize log event");
209        assert_eq!(decoded, event);
210    }
211
212    #[test]
213    fn observation_new_sets_defaults() {
214        let observation = Observation::new(
215            service_name(),
216            FixturePayload {
217                name: "payload".to_string(),
218                count: 1,
219            },
220        );
221
222        assert_eq!(
223            observation.version.as_str(),
224            constants::OBSERVATION_ENVELOPE_VERSION
225        );
226        assert_eq!(observation.identity, ProcessIdentity::default());
227        assert!(observation.trace.is_none());
228    }
229}