Skip to main content

relay_event_schema/protocol/
replay.rs

1//! Replay processing and normalization module.
2//!
3//! Replays are multi-part values sent from Sentry integrations spanning arbitrary time-periods.
4//! They are ingested incrementally.
5//!
6//! # Protocol
7//!
8//! Relay is expecting a JSON object with some mandatory metadata.  However, environment and user
9//! metadata is usually sent in addition to the minimal payload.
10//!
11//! ```json
12//! {
13//!     "type": "replay_event",
14//!     "replay_id": "d2132d31b39445f1938d7e21b6bf0ec4",
15//!     "event_id": "63c5b0f895441a94340183c5f1e74cd4",
16//!     "segment_id": 0,
17//!     "timestamp": 1597976392.6542819,
18//!     "replay_start_timestamp": 1597976392.6542819,
19//!     "urls": ["https://sentry.io"],
20//!     "error_ids": ["d2132d31b39445f1938d7e21b6bf0ec4"],
21//!     "trace_ids": ["63c5b0f895441a94340183c5f1e74cd4"],
22//!     "request": {
23//!         "headers": {"User-Agent": "Mozilla/5.0..."}
24//!     },
25//! }
26//! ```
27
28use relay_protocol::{Annotated, Array, Empty, FromValue, Getter, IntoValue, Val};
29
30use crate::processor::ProcessValue;
31use crate::protocol::{
32    AppContext, BrowserContext, ClientSdkInfo, Contexts, DefaultContext, DeviceContext, EventId,
33    LenientString, OTAUpdatesContext, OsContext, ProfileContext, Request, ResponseContext, Tags,
34    Timestamp, TraceContext, User,
35};
36use uuid::Uuid;
37
38#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
39#[metastructure(process_func = "process_replay", value_type = "Replay")]
40pub struct Replay {
41    /// Unique identifier of this event.
42    ///
43    /// Hexadecimal string representing a uuid4 value. The length is exactly 32 characters. Dashes
44    /// are not allowed. Has to be lowercase.
45    ///
46    /// Even though this field is backfilled on the server with a new uuid4, it is strongly
47    /// recommended to generate that uuid4 clientside. There are some features like user feedback
48    /// which are easier to implement that way, and debugging in case events get lost in your
49    /// Sentry installation is also easier.
50    ///
51    /// Example:
52    ///
53    /// ```json
54    /// {
55    ///   "event_id": "fc6d8c0c43fc4630ad850ee518f1b9d0"
56    /// }
57    /// ```
58    pub event_id: Annotated<EventId>,
59
60    /// Replay identifier.
61    ///
62    /// Hexadecimal string representing a uuid4 value. The length is exactly 32 characters. Dashes
63    /// are not allowed. Has to be lowercase.
64    ///
65    /// Example:
66    ///
67    /// ```json
68    /// {
69    ///   "replay_id": "fc6d8c0c43fc4630ad850ee518f1b9d0"
70    /// }
71    /// ```
72    pub replay_id: Annotated<EventId>,
73
74    /// The type of sampling that captured the replay.
75    ///
76    /// A string enumeration.  One of "session" or "error".
77    ///
78    /// Example:
79    ///
80    /// ```json
81    /// {
82    ///   "replay_type": "session"
83    /// }
84    /// ```
85    #[metastructure(max_chars = 64)]
86    pub replay_type: Annotated<String>,
87
88    /// Segment identifier.
89    ///
90    /// A number representing a unique segment identifier in the chain of replay segments.
91    /// Segment identifiers are temporally ordered but can be received by the Relay service in any
92    /// order.
93    ///
94    /// Example:
95    ///
96    /// ```json
97    /// {
98    ///   "segment_id": 10
99    /// }
100    /// ```
101    pub segment_id: Annotated<u64>,
102
103    /// Timestamp when the event was created.
104    ///
105    /// Indicates when the segment was created in the Sentry SDK. The format is either a string as
106    /// defined in [RFC 3339](https://tools.ietf.org/html/rfc3339) or a numeric (integer or float)
107    /// value representing the number of seconds that have elapsed since the [Unix
108    /// epoch](https://en.wikipedia.org/wiki/Unix_time).
109    ///
110    /// Timezone is assumed to be UTC if missing.
111    ///
112    /// Sub-microsecond precision is not preserved with numeric values due to precision
113    /// limitations with floats (at least in our systems). With that caveat in mind, just send
114    /// whatever is easiest to produce.
115    ///
116    /// All timestamps in the event protocol are formatted this way.
117    ///
118    /// # Example
119    ///
120    /// All of these are the same date:
121    ///
122    /// ```json
123    /// { "timestamp": "2011-05-02T17:41:36Z" }
124    /// { "timestamp": "2011-05-02T17:41:36" }
125    /// { "timestamp": "2011-05-02T17:41:36.000" }
126    /// { "timestamp": 1304358096.0 }
127    /// ```
128    pub timestamp: Annotated<Timestamp>,
129
130    /// Timestamp when the replay was created.  Typically only specified on the initial segment.
131    pub replay_start_timestamp: Annotated<Timestamp>,
132
133    /// A list of URLs visted during the lifetime of the segment.
134    #[metastructure(pii = "true", max_depth = 7, max_bytes = 8192)]
135    pub urls: Annotated<Array<String>>,
136
137    /// A list of error-ids discovered during the lifetime of the segment.
138    #[metastructure(max_depth = 5, max_bytes = 2048)]
139    pub error_ids: Annotated<Array<Uuid>>,
140
141    /// A list of trace-ids discovered during the lifetime of the segment.
142    #[metastructure(max_depth = 5, max_bytes = 2048)]
143    pub trace_ids: Annotated<Array<Uuid>>,
144
145    /// A list of segment names discovered during the lifetime of the segment.
146    #[metastructure(pii = "true", max_depth = 5, max_bytes = 2048)]
147    pub segment_names: Annotated<Array<String>>,
148
149    /// Contexts describing the environment (e.g. device, os or browser).
150    #[metastructure(skip_serialization = "empty")]
151    pub contexts: Annotated<Contexts>,
152
153    /// Platform identifier of this event (defaults to "other").
154    ///
155    /// A string representing the platform the SDK is submitting from. This will be used by the
156    /// Sentry interface to customize various components in the interface.
157    #[metastructure(max_chars = 64)]
158    pub platform: Annotated<String>,
159
160    /// The release version of the application.
161    ///
162    /// **Release versions must be unique across all projects in your organization.** This value
163    /// can be the git SHA for the given project, or a product identifier with a semantic version.
164    #[metastructure(
165        max_chars = 200,
166        required = false,
167        trim_whitespace = true,
168        nonempty = true,
169        skip_serialization = "empty"
170    )]
171    pub release: Annotated<LenientString>,
172
173    /// Program's distribution identifier.
174    ///
175    /// The distribution of the application.
176    ///
177    /// Distributions are used to disambiguate build or deployment variants of the same release of
178    /// an application. For example, the dist can be the build number of an XCode build or the
179    /// version code of an Android build.
180    #[metastructure(
181        allow_chars = "a-zA-Z0-9_.-",
182        trim_whitespace = true,
183        required = false,
184        nonempty = true,
185        max_chars = 64
186    )]
187    pub dist: Annotated<String>,
188
189    /// The environment name, such as `production` or `staging`.
190    ///
191    /// ```json
192    /// { "environment": "production" }
193    /// ```
194    #[metastructure(
195        max_chars = 64,
196        nonempty = true,
197        required = false,
198        trim_whitespace = true
199    )]
200    pub environment: Annotated<String>,
201
202    /// Custom tags for this event.
203    ///
204    /// A map or list of tags for this event. Each tag must be less than 200 characters.
205    #[metastructure(skip_serialization = "empty", pii = "true")]
206    pub tags: Annotated<Tags>,
207
208    /// Static value. Should always be "replay_event".
209    #[metastructure(field = "type", max_chars = 64)]
210    pub ty: Annotated<String>,
211
212    /// Information about the user who triggered this event.
213    #[metastructure(skip_serialization = "empty")]
214    pub user: Annotated<User>,
215
216    /// Information about a web request that occurred during the event.
217    #[metastructure(skip_serialization = "empty")]
218    pub request: Annotated<Request>,
219
220    /// Information about the Sentry SDK that generated this event.
221    #[metastructure(field = "sdk")]
222    #[metastructure(skip_serialization = "empty")]
223    pub sdk: Annotated<ClientSdkInfo>,
224}
225
226impl Replay {
227    /// Returns a reference to the context if it exists in its default key.
228    pub fn context<C: DefaultContext>(&self) -> Option<&C> {
229        self.contexts.value()?.get()
230    }
231
232    /// Returns the raw user agent string.
233    ///
234    /// Returns `Some` if the event's request interface contains a `user-agent` header. Returns
235    /// `None` otherwise.
236    pub fn user_agent(&self) -> Option<&str> {
237        let headers = self.request.value()?.headers.value()?;
238
239        for item in headers.iter() {
240            if let Some((o_k, v)) = item.value()
241                && let Some(k) = o_k.as_str()
242                && k.eq_ignore_ascii_case("user-agent")
243            {
244                return v.as_str();
245            }
246        }
247
248        None
249    }
250}
251
252impl Getter for Replay {
253    fn get_value(&self, path: &str) -> Option<Val<'_>> {
254        Some(match path.strip_prefix("event.")? {
255            // Simple fields
256            "release" => self.release.as_str()?.into(),
257            "dist" => self.dist.as_str()?.into(),
258            "environment" => self.environment.as_str()?.into(),
259            "platform" => self.platform.as_str().unwrap_or("other").into(),
260
261            // Fields in top level structures (called "interfaces" in Sentry)
262            "user.email" => or_none(&self.user.value()?.email)?.into(),
263            "user.id" => or_none(&self.user.value()?.id)?.into(),
264            "user.ip_address" => self.user.value()?.ip_address.as_str()?.into(),
265            "user.name" => self.user.value()?.name.as_str()?.into(),
266            "user.segment" => or_none(&self.user.value()?.segment)?.into(),
267            "user.geo.city" => self.user.value()?.geo.value()?.city.as_str()?.into(),
268            "user.geo.country_code" => self
269                .user
270                .value()?
271                .geo
272                .value()?
273                .country_code
274                .as_str()?
275                .into(),
276            "user.geo.region" => self.user.value()?.geo.value()?.region.as_str()?.into(),
277            "user.geo.subdivision" => self.user.value()?.geo.value()?.subdivision.as_str()?.into(),
278            "request.method" => self.request.value()?.method.as_str()?.into(),
279            "request.url" => self.request.value()?.url.as_str()?.into(),
280            "sdk.name" => self.sdk.value()?.name.as_str()?.into(),
281            "sdk.version" => self.sdk.value()?.version.as_str()?.into(),
282
283            // Computed fields (after normalization).
284            "sentry_user" => self.user.value()?.sentry_user.as_str()?.into(),
285
286            // Partial implementation of contexts.
287            "contexts.app.in_foreground" => {
288                self.context::<AppContext>()?.in_foreground.value()?.into()
289            }
290            "contexts.device.arch" => self.context::<DeviceContext>()?.arch.as_str()?.into(),
291            "contexts.device.battery_level" => self
292                .context::<DeviceContext>()?
293                .battery_level
294                .value()?
295                .into(),
296            "contexts.device.brand" => self.context::<DeviceContext>()?.brand.as_str()?.into(),
297            "contexts.device.charging" => self.context::<DeviceContext>()?.charging.value()?.into(),
298            "contexts.device.family" => self.context::<DeviceContext>()?.family.as_str()?.into(),
299            "contexts.device.model" => self.context::<DeviceContext>()?.model.as_str()?.into(),
300            "contexts.device.locale" => self.context::<DeviceContext>()?.locale.as_str()?.into(),
301            "contexts.device.online" => self.context::<DeviceContext>()?.online.value()?.into(),
302            "contexts.device.orientation" => self
303                .context::<DeviceContext>()?
304                .orientation
305                .as_str()?
306                .into(),
307            "contexts.device.name" => self.context::<DeviceContext>()?.name.as_str()?.into(),
308            "contexts.device.screen_density" => self
309                .context::<DeviceContext>()?
310                .screen_density
311                .value()?
312                .into(),
313            "contexts.device.screen_dpi" => {
314                self.context::<DeviceContext>()?.screen_dpi.value()?.into()
315            }
316            "contexts.device.screen_width_pixels" => self
317                .context::<DeviceContext>()?
318                .screen_width_pixels
319                .value()?
320                .into(),
321            "contexts.device.screen_height_pixels" => self
322                .context::<DeviceContext>()?
323                .screen_height_pixels
324                .value()?
325                .into(),
326            "contexts.device.simulator" => {
327                self.context::<DeviceContext>()?.simulator.value()?.into()
328            }
329            "contexts.os.build" => self.context::<OsContext>()?.build.as_str()?.into(),
330            "contexts.os.kernel_version" => {
331                self.context::<OsContext>()?.kernel_version.as_str()?.into()
332            }
333            "contexts.os.name" => self.context::<OsContext>()?.name.as_str()?.into(),
334            "contexts.os.version" => self.context::<OsContext>()?.version.as_str()?.into(),
335            "contexts.browser.name" => self.context::<BrowserContext>()?.name.as_str()?.into(),
336            "contexts.browser.version" => {
337                self.context::<BrowserContext>()?.version.as_str()?.into()
338            }
339            "contexts.profile.profile_id" => {
340                (&self.context::<ProfileContext>()?.profile_id.value()?.0).into()
341            }
342            "contexts.device.uuid" => self.context::<DeviceContext>()?.uuid.value()?.into(),
343            "contexts.trace.status" => self
344                .context::<TraceContext>()?
345                .status
346                .value()?
347                .as_str()
348                .into(),
349            "contexts.trace.op" => self.context::<TraceContext>()?.op.as_str()?.into(),
350            "contexts.response.status_code" => self
351                .context::<ResponseContext>()?
352                .status_code
353                .value()?
354                .into(),
355            "contexts.unreal.crash_type" => match self.contexts.value()?.get_key("unreal")? {
356                super::Context::Other(context) => context.get("crash_type")?.value()?.into(),
357                _ => return None,
358            },
359            "contexts.ota_updates.channel" => self
360                .context::<OTAUpdatesContext>()?
361                .channel
362                .as_str()?
363                .into(),
364            "contexts.ota_updates.runtime_version" => self
365                .context::<OTAUpdatesContext>()?
366                .runtime_version
367                .as_str()?
368                .into(),
369            "contexts.ota_updates.update_id" => self
370                .context::<OTAUpdatesContext>()?
371                .update_id
372                .as_str()?
373                .into(),
374
375            // Dynamic access to certain data bags
376            path => {
377                if let Some(rest) = path.strip_prefix("tags.") {
378                    self.tags.value()?.get(rest)?.into()
379                } else {
380                    let rest = path.strip_prefix("request.headers.")?;
381                    self.request
382                        .value()?
383                        .headers
384                        .value()?
385                        .get_header(rest)?
386                        .into()
387                }
388            }
389        })
390    }
391}
392
393fn or_none(string: &Annotated<impl AsRef<str>>) -> Option<&str> {
394    match string.as_str() {
395        None | Some("") => None,
396        Some(other) => Some(other),
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use chrono::{TimeZone, Utc};
403
404    use crate::protocol::TagEntry;
405
406    use super::*;
407
408    #[test]
409    fn test_event_roundtrip() {
410        // NOTE: Interfaces will be tested separately.
411        let json = r#"{
412  "event_id": "52df9022835246eeb317dbd739ccd059",
413  "replay_id": "52df9022835246eeb317dbd739ccd059",
414  "segment_id": 0,
415  "replay_type": "session",
416  "error_sample_rate": 0.5,
417  "session_sample_rate": 0.5,
418  "timestamp": 946684800.0,
419  "replay_start_timestamp": 946684800.0,
420  "urls": ["localhost:9000"],
421  "error_ids": ["52df9022835246eeb317dbd739ccd059"],
422  "trace_ids": ["52df9022835246eeb317dbd739ccd059"],
423  "platform": "myplatform",
424  "release": "myrelease",
425  "dist": "mydist",
426  "environment": "myenv",
427  "tags": [
428    [
429      "tag",
430      "value"
431    ]
432  ]
433}"#;
434
435        let replay = Annotated::new(Replay {
436            event_id: Annotated::new(EventId("52df9022835246eeb317dbd739ccd059".parse().unwrap())),
437            replay_id: Annotated::new(EventId("52df9022835246eeb317dbd739ccd059".parse().unwrap())),
438            replay_type: Annotated::new("session".to_owned()),
439            segment_id: Annotated::new(0),
440            timestamp: Annotated::new(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().into()),
441            replay_start_timestamp: Annotated::new(
442                Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().into(),
443            ),
444            urls: Annotated::new(vec![Annotated::new("localhost:9000".to_owned())]),
445            error_ids: Annotated::new(vec![Annotated::new(
446                Uuid::parse_str("52df9022835246eeb317dbd739ccd059").unwrap(),
447            )]),
448            trace_ids: Annotated::new(vec![Annotated::new(
449                Uuid::parse_str("52df9022835246eeb317dbd739ccd059").unwrap(),
450            )]),
451            platform: Annotated::new("myplatform".to_owned()),
452            release: Annotated::new("myrelease".to_owned().into()),
453            dist: Annotated::new("mydist".to_owned()),
454            environment: Annotated::new("myenv".to_owned()),
455            tags: {
456                let items = vec![Annotated::new(TagEntry(
457                    Annotated::new("tag".to_owned()),
458                    Annotated::new("value".to_owned()),
459                ))];
460                Annotated::new(Tags(items.into()))
461            },
462            ..Default::default()
463        });
464
465        assert_eq!(replay, Annotated::from_json(json).unwrap());
466    }
467
468    #[test]
469    fn test_lenient_release() {
470        let input = r#"{"release":42}"#;
471        let output = r#"{"release":"42"}"#;
472        let event = Annotated::new(Replay {
473            release: Annotated::new("42".to_owned().into()),
474            ..Default::default()
475        });
476
477        assert_eq!(event, Annotated::from_json(input).unwrap());
478        assert_eq!(output, event.to_json().unwrap());
479    }
480
481    #[test]
482    fn test_ota_updates_context_getter() {
483        let mut contexts = Contexts::new();
484        contexts.add(OTAUpdatesContext {
485            channel: Annotated::new("production".to_owned()),
486            runtime_version: Annotated::new("1.0.0".to_owned()),
487            update_id: Annotated::new("12345678-1234-1234-1234-1234567890ab".to_owned()),
488            ..OTAUpdatesContext::default()
489        });
490
491        let replay = Replay {
492            contexts: Annotated::new(contexts),
493            ..Default::default()
494        };
495
496        assert_eq!(
497            Some(Val::String("production")),
498            replay.get_value("event.contexts.ota_updates.channel")
499        );
500        assert_eq!(
501            Some(Val::String("1.0.0")),
502            replay.get_value("event.contexts.ota_updates.runtime_version")
503        );
504        assert_eq!(
505            Some(Val::String("12345678-1234-1234-1234-1234567890ab")),
506            replay.get_value("event.contexts.ota_updates.update_id")
507        );
508    }
509}