Skip to main content

sc_observability_types/
span.rs

1use std::marker::PhantomData;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5
6use crate::{ActionName, Diagnostic, DurationMs, ServiceName, Timestamp, TraceContext};
7
8/// Final span status for a completed span record.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum SpanStatus {
11    /// The span completed successfully.
12    Ok,
13    /// The span completed with an error.
14    Error,
15    /// The span completed without an explicit outcome.
16    Unset,
17}
18
19/// Typestate marker for a started-but-not-yet-ended span.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub struct SpanStarted;
22
23/// Typestate marker for a completed span.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub struct SpanEnded;
26
27/// Producer-facing span record whose lifecycle is encoded via typestate.
28#[derive(Debug, Clone, PartialEq, Serialize)]
29pub struct SpanRecord<S> {
30    timestamp: Timestamp,
31    service: ServiceName,
32    name: ActionName,
33    trace: TraceContext,
34    status: SpanStatus,
35    diagnostic: Option<Diagnostic>,
36    attributes: Map<String, Value>,
37    duration_ms: Option<DurationMs>,
38    marker: PhantomData<S>,
39}
40
41impl SpanRecord<SpanStarted> {
42    /// Creates a new started span record.
43    ///
44    /// # Examples
45    ///
46    /// ```
47    /// use sc_observability_types::{
48    ///     ActionName, ServiceName, SpanId, SpanRecord, SpanStarted, Timestamp, TraceContext, TraceId,
49    /// };
50    ///
51    /// let record = SpanRecord::<SpanStarted>::new(
52    ///     Timestamp::UNIX_EPOCH,
53    ///     ServiceName::new("demo").expect("valid service"),
54    ///     ActionName::new("demo.run").expect("valid action"),
55    ///     TraceContext {
56    ///         trace_id: TraceId::new("0123456789abcdef0123456789abcdef").expect("valid trace"),
57    ///         span_id: SpanId::new("0123456789abcdef").expect("valid span"),
58    ///         parent_span_id: None,
59    ///     },
60    ///     Default::default(),
61    /// );
62    ///
63    /// assert_eq!(record.name().as_str(), "demo.run");
64    /// ```
65    #[must_use]
66    pub fn new(
67        timestamp: Timestamp,
68        service: ServiceName,
69        name: ActionName,
70        trace: TraceContext,
71        attributes: Map<String, Value>,
72    ) -> Self {
73        Self {
74            timestamp,
75            service,
76            name,
77            trace,
78            status: SpanStatus::Unset,
79            diagnostic: None,
80            attributes,
81            duration_ms: None,
82            marker: PhantomData,
83        }
84    }
85
86    /// Attaches a diagnostic to the started span before completion.
87    #[must_use]
88    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
89        self.diagnostic = Some(diagnostic);
90        self
91    }
92
93    /// Consumes the started span and returns the only valid completed span form.
94    #[must_use]
95    pub fn end(self, status: SpanStatus, duration: DurationMs) -> SpanRecord<SpanEnded> {
96        SpanRecord {
97            timestamp: self.timestamp,
98            service: self.service,
99            name: self.name,
100            trace: self.trace,
101            status,
102            diagnostic: self.diagnostic,
103            attributes: self.attributes,
104            duration_ms: Some(duration),
105            marker: PhantomData,
106        }
107    }
108}
109
110impl<S> SpanRecord<S> {
111    /// Returns the timestamp recorded for the span lifecycle event.
112    #[must_use]
113    pub fn timestamp(&self) -> Timestamp {
114        self.timestamp
115    }
116
117    /// Returns the service that emitted the span.
118    #[must_use]
119    pub fn service(&self) -> &ServiceName {
120        &self.service
121    }
122
123    /// Returns the stable action/name associated with the span.
124    #[must_use]
125    pub fn name(&self) -> &ActionName {
126        &self.name
127    }
128
129    /// Returns the trace context for the span.
130    #[must_use]
131    pub fn trace(&self) -> &TraceContext {
132        &self.trace
133    }
134
135    /// Returns the current typestate-derived span status.
136    #[must_use]
137    pub fn status(&self) -> SpanStatus {
138        self.status
139    }
140
141    /// Returns the optional diagnostic attached to the span.
142    #[must_use]
143    pub fn diagnostic(&self) -> Option<&Diagnostic> {
144        self.diagnostic.as_ref()
145    }
146
147    /// Returns immutable span attributes.
148    #[must_use]
149    pub fn attributes(&self) -> &Map<String, Value> {
150        &self.attributes
151    }
152}
153
154impl SpanRecord<SpanEnded> {
155    /// Returns the final duration recorded for the completed span.
156    ///
157    /// When the record was created through `SpanRecord::end`, this returns
158    /// `Some(duration)`. Deserializing malformed external input can still
159    /// produce a completed span without a duration, so the accessor remains
160    /// fallible by returning `None`.
161    #[must_use]
162    pub fn duration_ms(&self) -> Option<DurationMs> {
163        self.duration_ms
164    }
165}
166
167/// Event attached to a span timeline without creating a child span.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct SpanEvent {
170    /// UTC event timestamp.
171    pub timestamp: Timestamp,
172    /// Trace/span correlation for the event.
173    pub trace: TraceContext,
174    /// Stable event name.
175    pub name: ActionName,
176    /// Structured event attributes.
177    pub attributes: Map<String, Value>,
178    /// Optional diagnostic attached to the event.
179    pub diagnostic: Option<Diagnostic>,
180}
181
182/// Generic span lifecycle signal used by projectors and telemetry assembly.
183#[derive(Debug, Clone, PartialEq, Serialize)]
184pub enum SpanSignal {
185    /// Started span record.
186    Started(SpanRecord<SpanStarted>),
187    /// Point-in-time event on an existing span.
188    Event(SpanEvent),
189    /// Completed span record.
190    Ended(SpanRecord<SpanEnded>),
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde_json::json;
197
198    use crate::{Remediation, error_codes};
199    use crate::{SpanId, TraceId};
200
201    fn service_name() -> ServiceName {
202        ServiceName::new("sc-observability").expect("valid service name")
203    }
204
205    fn action_name() -> ActionName {
206        ActionName::new("observation.received").expect("valid action name")
207    }
208
209    fn trace_context() -> TraceContext {
210        TraceContext {
211            trace_id: TraceId::new("0123456789abcdef0123456789abcdef").expect("valid trace id"),
212            span_id: SpanId::new("0123456789abcdef").expect("valid span id"),
213            parent_span_id: Some(SpanId::new("fedcba9876543210").expect("valid parent span id")),
214        }
215    }
216
217    fn diagnostic() -> Diagnostic {
218        Diagnostic {
219            timestamp: Timestamp::UNIX_EPOCH,
220            code: error_codes::DIAGNOSTIC_INVALID,
221            message: "diagnostic invalid".to_string(),
222            cause: Some("invalid example".to_string()),
223            remediation: Remediation::recoverable(
224                "fix the input",
225                ["rerun the command", "review the docs"],
226            ),
227            docs: Some("https://example.test/docs".to_string()),
228            details: Map::from_iter([("key".to_string(), json!("value"))]),
229        }
230    }
231
232    #[test]
233    fn span_signal_round_trips_through_serde() {
234        let mut attributes = Map::new();
235        attributes.insert("tool".to_string(), json!("rg"));
236
237        let started = SpanRecord::<SpanStarted>::new(
238            Timestamp::UNIX_EPOCH,
239            service_name(),
240            action_name(),
241            trace_context(),
242            attributes.clone(),
243        )
244        .with_diagnostic(diagnostic());
245
246        let ended = started.clone().end(SpanStatus::Ok, DurationMs::from(123));
247        let signal = SpanSignal::Ended(ended);
248        let encoded = serde_json::to_value(&signal).expect("serialize span signal");
249
250        assert_eq!(encoded["Ended"]["status"], "Ok");
251        assert_eq!(encoded["Ended"]["duration_ms"], 123);
252    }
253
254    #[test]
255    fn span_record_end_transitions_to_span_ended() {
256        let span = SpanRecord::<SpanStarted>::new(
257            Timestamp::UNIX_EPOCH,
258            service_name(),
259            action_name(),
260            trace_context(),
261            Map::new(),
262        );
263
264        let ended = span.end(SpanStatus::Error, DurationMs::from(88));
265
266        assert_eq!(ended.status(), SpanStatus::Error);
267        assert_eq!(ended.duration_ms(), Some(DurationMs::from(88)));
268        assert_eq!(ended.service().as_str(), "sc-observability");
269    }
270
271    #[test]
272    fn span_record_accessors_preserve_started_values() {
273        let mut attributes = Map::new();
274        attributes.insert("count".to_string(), json!(2));
275        let span = SpanRecord::<SpanStarted>::new(
276            Timestamp::UNIX_EPOCH,
277            service_name(),
278            action_name(),
279            trace_context(),
280            attributes.clone(),
281        )
282        .with_diagnostic(diagnostic());
283
284        assert_eq!(span.timestamp(), Timestamp::UNIX_EPOCH);
285        assert_eq!(span.name().as_str(), "observation.received");
286        assert_eq!(
287            span.trace().trace_id.as_str(),
288            "0123456789abcdef0123456789abcdef"
289        );
290        assert_eq!(span.status(), SpanStatus::Unset);
291        assert_eq!(span.diagnostic(), Some(&diagnostic()));
292        assert_eq!(span.attributes(), &attributes);
293    }
294}