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
16pub trait Observable: Send + Sync + 'static {}
18
19impl<T> Observable for T where T: Send + Sync + 'static {}
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Observation<T>
24where
25 T: Observable,
26{
27 pub version: SchemaVersion,
29 pub timestamp: Timestamp,
31 pub service: ServiceName,
33 pub identity: ProcessIdentity,
35 pub trace: Option<TraceContext>,
37 pub payload: T,
39}
40
41impl<T> Observation<T>
42where
43 T: Observable,
44{
45 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct LogEvent {
76 pub version: SchemaVersion,
78 pub timestamp: Timestamp,
80 pub level: Level,
82 pub service: ServiceName,
84 pub target: TargetCategory,
86 pub action: ActionName,
88 pub message: Option<String>,
90 pub identity: ProcessIdentity,
92 pub trace: Option<TraceContext>,
94 pub request_id: Option<CorrelationId>,
96 pub correlation_id: Option<CorrelationId>,
98 pub outcome: Option<OutcomeLabel>,
100 pub diagnostic: Option<Diagnostic>,
102 pub state_transition: Option<StateTransition>,
104 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}