relay_event_schema/protocol/
span_v2.rs

1use relay_protocol::{Annotated, Array, Empty, Error, FromValue, Getter, IntoValue, Object, Value};
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::Serialize;
7
8use crate::processor::ProcessValue;
9use crate::protocol::{Attributes, OperationType, SpanId, Timestamp, TraceId};
10
11/// A version 2 (transactionless) span.
12#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
13pub struct SpanV2 {
14    /// The ID of the trace to which this span belongs.
15    #[metastructure(required = true, trim = false)]
16    pub trace_id: Annotated<TraceId>,
17
18    /// The ID of the span enclosing this span.
19    pub parent_span_id: Annotated<SpanId>,
20
21    /// The Span ID.
22    #[metastructure(required = true, trim = false)]
23    pub span_id: Annotated<SpanId>,
24
25    /// Span type (see `OperationType` docs).
26    #[metastructure(required = true)]
27    pub name: Annotated<OperationType>,
28
29    /// The span's status.
30    #[metastructure(required = true)]
31    pub status: Annotated<SpanV2Status>,
32
33    /// Indicates whether a span's parent is remote.
34    ///
35    /// For OpenTelemetry spans, this is derived from span flags bits 8 and 9. See
36    /// `SPAN_FLAGS_CONTEXT_HAS_IS_REMOTE_MASK` and `SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK`.
37    ///
38    /// The states are:
39    ///  - `false`: is not remote
40    ///  - `true`: is remote
41    #[metastructure(required = true)]
42    pub is_remote: Annotated<bool>,
43
44    /// Used to clarify the relationship between parents and children, or to distinguish between
45    /// spans, e.g. a `server` and `client` span with the same name.
46    ///
47    /// See <https://opentelemetry.io/docs/specs/otel/trace/api/#spankind>
48    #[metastructure(skip_serialization = "empty", trim = false)]
49    pub kind: Annotated<SpanV2Kind>,
50
51    /// Timestamp when the span started.
52    #[metastructure(required = true)]
53    pub start_timestamp: Annotated<Timestamp>,
54
55    /// Timestamp when the span was ended.
56    #[metastructure(required = true)]
57    pub end_timestamp: Annotated<Timestamp>,
58
59    /// Links from this span to other spans.
60    #[metastructure(pii = "maybe")]
61    pub links: Annotated<Array<SpanV2Link>>,
62
63    /// Arbitrary attributes on a span.
64    #[metastructure(pii = "true", trim = false)]
65    pub attributes: Annotated<Attributes>,
66
67    /// Additional arbitrary fields for forwards compatibility.
68    #[metastructure(additional_properties, pii = "maybe")]
69    pub other: Object<Value>,
70}
71
72impl Getter for SpanV2 {
73    fn get_value(&self, path: &str) -> Option<relay_protocol::Val<'_>> {
74        Some(match path.strip_prefix("span.")? {
75            "name" => self.name.value()?.as_str().into(),
76            "status" => self.status.value()?.as_str().into(),
77            "kind" => self.kind.value()?.as_str().into(),
78            path => {
79                if let Some(key) = path.strip_prefix("attributes.") {
80                    let key = key.strip_suffix(".value")?;
81                    self.attributes.value()?.get_value(key)?.into()
82                } else {
83                    return None;
84                }
85            }
86        })
87    }
88}
89
90/// Status of a V2 span.
91///
92/// This is a subset of OTEL's statuses (unset, ok, error), plus
93/// a catchall variant for forward compatibility.
94#[derive(Clone, Debug, PartialEq, Serialize)]
95#[serde(rename_all = "snake_case")]
96pub enum SpanV2Status {
97    /// The span completed successfully.
98    Ok,
99    /// The span contains an error.
100    Error,
101    /// Catchall variant for forward compatibility.
102    Other(String),
103}
104
105impl SpanV2Status {
106    /// Returns the string representation of the status.
107    pub fn as_str(&self) -> &str {
108        match self {
109            Self::Ok => "ok",
110            Self::Error => "error",
111            Self::Other(s) => s,
112        }
113    }
114}
115
116impl Empty for SpanV2Status {
117    #[inline]
118    fn is_empty(&self) -> bool {
119        false
120    }
121}
122
123impl ProcessValue for SpanV2Status {}
124
125impl AsRef<str> for SpanV2Status {
126    fn as_ref(&self) -> &str {
127        self.as_str()
128    }
129}
130
131impl fmt::Display for SpanV2Status {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137impl From<String> for SpanV2Status {
138    fn from(value: String) -> Self {
139        match value.as_str() {
140            "ok" => Self::Ok,
141            "error" => Self::Error,
142            _ => Self::Other(value),
143        }
144    }
145}
146
147impl FromValue for SpanV2Status {
148    fn from_value(value: Annotated<Value>) -> Annotated<Self>
149    where
150        Self: Sized,
151    {
152        String::from_value(value).map_value(|s| s.into())
153    }
154}
155
156impl IntoValue for SpanV2Status {
157    fn into_value(self) -> Value
158    where
159        Self: Sized,
160    {
161        Value::String(match self {
162            SpanV2Status::Other(s) => s,
163            _ => self.to_string(),
164        })
165    }
166
167    fn serialize_payload<S>(
168        &self,
169        s: S,
170        _behavior: relay_protocol::SkipSerialization,
171    ) -> Result<S::Ok, S::Error>
172    where
173        Self: Sized,
174        S: serde::Serializer,
175    {
176        s.serialize_str(self.as_str())
177    }
178}
179
180/// The kind of a V2 span.
181///
182/// This corresponds to OTEL's kind enum, plus a
183/// catchall variant for forward compatibility.
184#[derive(Clone, Debug, PartialEq, ProcessValue)]
185pub enum SpanV2Kind {
186    /// An operation internal to an application.
187    Internal,
188    /// Server-side processing requested by a client.
189    Server,
190    /// A request from a client to a server.
191    Client,
192    /// Scheduling of an operation.
193    Producer,
194    /// Processing of a scheduled operation.
195    Consumer,
196}
197
198impl SpanV2Kind {
199    pub fn as_str(&self) -> &'static str {
200        match self {
201            Self::Internal => "internal",
202            Self::Server => "server",
203            Self::Client => "client",
204            Self::Producer => "producer",
205            Self::Consumer => "consumer",
206        }
207    }
208}
209
210impl Empty for SpanV2Kind {
211    fn is_empty(&self) -> bool {
212        false
213    }
214}
215
216impl Default for SpanV2Kind {
217    fn default() -> Self {
218        Self::Internal
219    }
220}
221
222impl fmt::Display for SpanV2Kind {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        write!(f, "{}", self.as_str())
225    }
226}
227
228#[derive(Debug, Clone, Copy)]
229pub struct ParseSpanV2KindError;
230
231impl fmt::Display for ParseSpanV2KindError {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(f, "invalid span kind")
234    }
235}
236
237impl FromStr for SpanV2Kind {
238    type Err = ParseSpanV2KindError;
239
240    fn from_str(s: &str) -> Result<Self, Self::Err> {
241        let kind = match s {
242            "internal" => Self::Internal,
243            "server" => Self::Server,
244            "client" => Self::Client,
245            "producer" => Self::Producer,
246            "consumer" => Self::Consumer,
247            _ => return Err(ParseSpanV2KindError),
248        };
249        Ok(kind)
250    }
251}
252
253impl FromValue for SpanV2Kind {
254    fn from_value(Annotated(value, meta): Annotated<Value>) -> Annotated<Self>
255    where
256        Self: Sized,
257    {
258        match &value {
259            Some(Value::String(s)) => match s.parse() {
260                Ok(kind) => Annotated(Some(kind), meta),
261                Err(_) => Annotated::from_error(Error::expected("a span kind"), value),
262            },
263            Some(_) => Annotated::from_error(Error::expected("a span kind"), value),
264            None => Annotated::empty(),
265        }
266    }
267}
268
269impl IntoValue for SpanV2Kind {
270    fn into_value(self) -> Value
271    where
272        Self: Sized,
273    {
274        Value::String(self.to_string())
275    }
276
277    fn serialize_payload<S>(
278        &self,
279        s: S,
280        _behavior: relay_protocol::SkipSerialization,
281    ) -> Result<S::Ok, S::Error>
282    where
283        Self: Sized,
284        S: serde::Serializer,
285    {
286        s.serialize_str(self.as_str())
287    }
288}
289
290/// A link from a span to another span.
291#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
292#[metastructure(trim = false)]
293pub struct SpanV2Link {
294    /// The trace id of the linked span.
295    #[metastructure(required = true, trim = false)]
296    pub trace_id: Annotated<TraceId>,
297
298    /// The span id of the linked span.
299    #[metastructure(required = true, trim = false)]
300    pub span_id: Annotated<SpanId>,
301
302    /// Whether the linked span was positively/negatively sampled.
303    #[metastructure(trim = false)]
304    pub sampled: Annotated<bool>,
305
306    /// Span link attributes, similar to span attributes/data.
307    #[metastructure(pii = "maybe", trim = false)]
308    pub attributes: Annotated<Attributes>,
309
310    /// Additional arbitrary fields for forwards compatibility.
311    #[metastructure(additional_properties, pii = "maybe", trim = false)]
312    pub other: Object<Value>,
313}
314
315#[cfg(test)]
316mod tests {
317    use chrono::{TimeZone, Utc};
318    use similar_asserts::assert_eq;
319
320    use super::*;
321
322    #[test]
323    fn test_span_serialization() {
324        let json = r#"{
325  "trace_id": "6cf173d587eb48568a9b2e12dcfbea52",
326  "span_id": "438f40bd3b4a41ee",
327  "name": "GET http://app.test/",
328  "status": "ok",
329  "is_remote": true,
330  "kind": "server",
331  "start_timestamp": 1742921669.25,
332  "end_timestamp": 1742921669.75,
333  "links": [
334    {
335      "trace_id": "627a2885119dcc8184fae7eef09438cb",
336      "span_id": "6c71fc6b09b8b716",
337      "sampled": true,
338      "attributes": {
339        "sentry.link.type": {
340          "type": "string",
341          "value": "previous_trace"
342        }
343      }
344    }
345  ],
346  "attributes": {
347    "custom.error_rate": {
348      "type": "double",
349      "value": 0.5
350    },
351    "custom.is_green": {
352      "type": "boolean",
353      "value": true
354    },
355    "http.response.status_code": {
356      "type": "integer",
357      "value": 200
358    },
359    "sentry.environment": {
360      "type": "string",
361      "value": "local"
362    },
363    "sentry.origin": {
364      "type": "string",
365      "value": "manual"
366    },
367    "sentry.platform": {
368      "type": "string",
369      "value": "php"
370    },
371    "sentry.release": {
372      "type": "string",
373      "value": "1.0.0"
374    },
375    "sentry.sdk.name": {
376      "type": "string",
377      "value": "sentry.php"
378    },
379    "sentry.sdk.version": {
380      "type": "string",
381      "value": "4.10.0"
382    },
383    "sentry.transaction_info.source": {
384      "type": "string",
385      "value": "url"
386    },
387    "server.address": {
388      "type": "string",
389      "value": "DHWKN7KX6N.local"
390    }
391  }
392}"#;
393
394        let mut attributes = Attributes::new();
395
396        attributes.insert("custom.error_rate".to_owned(), 0.5);
397        attributes.insert("custom.is_green".to_owned(), true);
398        attributes.insert("sentry.release".to_owned(), "1.0.0".to_owned());
399        attributes.insert("sentry.environment".to_owned(), "local".to_owned());
400        attributes.insert("sentry.platform".to_owned(), "php".to_owned());
401        attributes.insert("sentry.sdk.name".to_owned(), "sentry.php".to_owned());
402        attributes.insert("sentry.sdk.version".to_owned(), "4.10.0".to_owned());
403        attributes.insert(
404            "sentry.transaction_info.source".to_owned(),
405            "url".to_owned(),
406        );
407        attributes.insert("sentry.origin".to_owned(), "manual".to_owned());
408        attributes.insert("server.address".to_owned(), "DHWKN7KX6N.local".to_owned());
409        attributes.insert("http.response.status_code".to_owned(), 200i64);
410
411        let mut link_attributes = Attributes::new();
412        link_attributes.insert("sentry.link.type".to_owned(), "previous_trace".to_owned());
413
414        let links = vec![Annotated::new(SpanV2Link {
415            trace_id: Annotated::new("627a2885119dcc8184fae7eef09438cb".parse().unwrap()),
416            span_id: Annotated::new("6c71fc6b09b8b716".parse().unwrap()),
417            sampled: Annotated::new(true),
418            attributes: Annotated::new(link_attributes),
419            ..Default::default()
420        })];
421        let span = Annotated::new(SpanV2 {
422            start_timestamp: Annotated::new(
423                Utc.timestamp_opt(1742921669, 250000000).unwrap().into(),
424            ),
425            end_timestamp: Annotated::new(Utc.timestamp_opt(1742921669, 750000000).unwrap().into()),
426            name: Annotated::new("GET http://app.test/".to_owned()),
427            trace_id: Annotated::new("6cf173d587eb48568a9b2e12dcfbea52".parse().unwrap()),
428            span_id: Annotated::new("438f40bd3b4a41ee".parse().unwrap()),
429            parent_span_id: Annotated::empty(),
430            status: Annotated::new(SpanV2Status::Ok),
431            kind: Annotated::new(SpanV2Kind::Server),
432            is_remote: Annotated::new(true),
433            links: Annotated::new(links),
434            attributes: Annotated::new(attributes),
435            ..Default::default()
436        });
437        assert_eq!(json, span.to_json_pretty().unwrap());
438
439        let span_from_string = Annotated::from_json(json).unwrap();
440        assert_eq!(span, span_from_string);
441    }
442}