relay_event_schema/protocol/
session.rs

1use std::fmt::{self, Display};
2use std::time::SystemTime;
3
4use chrono::{DateTime, Utc};
5use relay_protocol::Getter;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::protocol::IpAddr;
10use crate::protocol::utils::null_to_default;
11
12/// The type of session event we're dealing with.
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Default)]
14pub enum SessionStatus {
15    /// The session is healthy.
16    ///
17    /// This does not necessarily indicate that the session is still active.
18    #[default]
19    Ok,
20    /// The session terminated normally.
21    Exited,
22    /// The session resulted in an application crash.
23    Crashed,
24    /// The session had an unexpected abrupt termination (not crashing).
25    Abnormal,
26    /// The session exited cleanly but experienced some errors during its run.
27    Errored,
28    /// The session had an unhandled error, but did not crash.
29    Unhandled,
30    /// Unknown status, for forward compatibility.
31    ///
32    /// If you add a new variant here, bump the session metrics extraction version
33    /// to prevent outdated extraction in external Relays.
34    Unknown(String),
35}
36
37impl SessionStatus {
38    /// Returns `true` if the status indicates an ended session.
39    pub fn is_terminal(&self) -> bool {
40        !matches!(self, SessionStatus::Ok)
41    }
42
43    /// Returns `true` if the status indicates a session with any kind of error or crash.
44    pub fn is_error(&self) -> bool {
45        !matches!(self, SessionStatus::Ok | SessionStatus::Exited)
46    }
47
48    /// Returns `true` if the status indicates a fatal session.
49    pub fn is_fatal(&self) -> bool {
50        matches!(self, SessionStatus::Crashed | SessionStatus::Abnormal)
51    }
52    fn as_str(&self) -> &str {
53        match self {
54            SessionStatus::Ok => "ok",
55            SessionStatus::Crashed => "crashed",
56            SessionStatus::Abnormal => "abnormal",
57            SessionStatus::Exited => "exited",
58            SessionStatus::Errored => "errored",
59            SessionStatus::Unhandled => "unhandled",
60            SessionStatus::Unknown(s) => s.as_str(),
61        }
62    }
63}
64
65impl Display for SessionStatus {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(f, "{}", self.as_str())
68    }
69}
70
71relay_common::impl_str_serde!(SessionStatus, "A session status");
72
73impl std::str::FromStr for SessionStatus {
74    type Err = ParseSessionStatusError;
75
76    fn from_str(s: &str) -> Result<Self, Self::Err> {
77        Ok(match s {
78            "ok" => SessionStatus::Ok,
79            "crashed" => SessionStatus::Crashed,
80            "abnormal" => SessionStatus::Abnormal,
81            "exited" => SessionStatus::Exited,
82            "errored" => SessionStatus::Errored,
83            "unhandled" => SessionStatus::Unhandled,
84            other => SessionStatus::Unknown(other.to_owned()),
85        })
86    }
87}
88
89/// An error used when parsing `SessionStatus`.
90#[derive(Debug)]
91pub struct ParseSessionStatusError;
92
93impl fmt::Display for ParseSessionStatusError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(f, "invalid session status")
96    }
97}
98
99impl std::error::Error for ParseSessionStatusError {}
100
101#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "snake_case")]
103pub enum AbnormalMechanism {
104    AnrForeground,
105    AnrBackground,
106    #[serde(other)]
107    #[default]
108    None,
109}
110
111#[derive(Debug)]
112pub struct ParseAbnormalMechanismError;
113
114impl fmt::Display for ParseAbnormalMechanismError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "invalid abnormal mechanism")
117    }
118}
119
120relay_common::derive_fromstr_and_display!(AbnormalMechanism, ParseAbnormalMechanismError, {
121    AbnormalMechanism::AnrForeground => "anr_foreground",
122    AbnormalMechanism::AnrBackground => "anr_background",
123    AbnormalMechanism::None => "none",
124});
125
126impl AbnormalMechanism {
127    fn is_none(&self) -> bool {
128        *self == Self::None
129    }
130}
131
132/// Additional attributes for Sessions.
133#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
134pub struct SessionAttributes {
135    /// The release version string.
136    pub release: String,
137
138    /// The environment identifier.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub environment: Option<String>,
141
142    /// The ip address of the user.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub ip_address: Option<IpAddr>,
145
146    /// The user agent of the user.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub user_agent: Option<String>,
149}
150
151fn default_sequence() -> u64 {
152    SystemTime::now()
153        .duration_since(SystemTime::UNIX_EPOCH)
154        .unwrap_or_default()
155        .as_millis() as u64
156}
157
158#[allow(clippy::trivially_copy_pass_by_ref)]
159fn is_false(val: &bool) -> bool {
160    !val
161}
162
163/// Contains information about errored sessions. See [`SessionLike`].
164pub enum SessionErrored {
165    /// Contains the UUID for a single errored session.
166    Individual(Uuid),
167    /// Contains the number of all errored sessions in an aggregate.
168    /// errored, crashed, abnormal all count towards errored sessions.
169    Aggregated(u32),
170}
171
172/// Common interface for [`SessionUpdate`] and [`SessionAggregateItem`].
173pub trait SessionLike {
174    fn started(&self) -> DateTime<Utc>;
175    fn distinct_id(&self) -> Option<&String>;
176    fn total_count(&self) -> u32;
177    fn abnormal_count(&self) -> u32;
178    fn unhandled_count(&self) -> u32;
179    fn crashed_count(&self) -> u32;
180    fn all_errors(&self) -> Option<SessionErrored>;
181    fn abnormal_mechanism(&self) -> AbnormalMechanism;
182}
183
184#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
185pub struct SessionUpdate {
186    /// The session identifier.
187    #[serde(rename = "sid", default = "Uuid::new_v4")]
188    pub session_id: Uuid,
189    /// The distinct identifier.
190    #[serde(rename = "did", default)]
191    pub distinct_id: Option<String>,
192    /// An optional logical clock.
193    #[serde(rename = "seq", default = "default_sequence")]
194    pub sequence: u64,
195    /// A flag that indicates that this is the initial transmission of the session.
196    #[serde(default, skip_serializing_if = "is_false")]
197    pub init: bool,
198    /// The timestamp of when the session change event was created.
199    #[serde(default = "Utc::now")]
200    pub timestamp: DateTime<Utc>,
201    /// The timestamp of when the session itself started.
202    pub started: DateTime<Utc>,
203    /// An optional duration of the session in seconds.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub duration: Option<f64>,
206    /// The status of the session.
207    #[serde(default)]
208    pub status: SessionStatus,
209    /// The number of errors that ocurred.
210    #[serde(default)]
211    pub errors: u64,
212    /// The session event attributes.
213    #[serde(rename = "attrs")]
214    pub attributes: SessionAttributes,
215    /// The abnormal mechanism.
216    #[serde(
217        default,
218        deserialize_with = "null_to_default",
219        skip_serializing_if = "AbnormalMechanism::is_none"
220    )]
221    pub abnormal_mechanism: AbnormalMechanism,
222}
223
224impl SessionUpdate {
225    /// Parses a session update from JSON.
226    pub fn parse(payload: &[u8]) -> Result<Self, serde_json::Error> {
227        serde_json::from_slice(payload)
228    }
229
230    /// Serializes a session update back into JSON.
231    pub fn serialize(&self) -> Result<Vec<u8>, serde_json::Error> {
232        serde_json::to_vec(self)
233    }
234}
235
236impl SessionLike for SessionUpdate {
237    fn started(&self) -> DateTime<Utc> {
238        self.started
239    }
240
241    fn distinct_id(&self) -> Option<&String> {
242        self.distinct_id.as_ref()
243    }
244
245    fn total_count(&self) -> u32 {
246        u32::from(self.init)
247    }
248
249    fn abnormal_count(&self) -> u32 {
250        match self.status {
251            SessionStatus::Abnormal => 1,
252            _ => 0,
253        }
254    }
255
256    fn unhandled_count(&self) -> u32 {
257        match self.status {
258            SessionStatus::Unhandled => 1,
259            _ => 0,
260        }
261    }
262
263    fn crashed_count(&self) -> u32 {
264        match self.status {
265            SessionStatus::Crashed => 1,
266            _ => 0,
267        }
268    }
269
270    fn all_errors(&self) -> Option<SessionErrored> {
271        if self.errors > 0 || self.status.is_error() {
272            Some(SessionErrored::Individual(self.session_id))
273        } else {
274            None
275        }
276    }
277
278    fn abnormal_mechanism(&self) -> AbnormalMechanism {
279        self.abnormal_mechanism
280    }
281}
282
283// Dummy implementation of `Getter` to satisfy the bound of `should_filter`.
284// We don't actually want to use `get_value` at this time.`
285impl Getter for SessionUpdate {
286    fn get_value(&self, _path: &str) -> Option<relay_protocol::Val<'_>> {
287        None
288    }
289}
290
291#[allow(clippy::trivially_copy_pass_by_ref)]
292fn is_zero(val: &u32) -> bool {
293    *val == 0
294}
295
296#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
297pub struct SessionAggregateItem {
298    /// The timestamp of when the session itself started.
299    pub started: DateTime<Utc>,
300    /// The distinct identifier.
301    #[serde(rename = "did", default, skip_serializing_if = "Option::is_none")]
302    pub distinct_id: Option<String>,
303    /// The number of exited sessions that ocurred.
304    #[serde(default, skip_serializing_if = "is_zero")]
305    pub exited: u32,
306    /// The number of errored sessions that ocurred, not including the abnormal and crashed ones.
307    #[serde(default, skip_serializing_if = "is_zero")]
308    pub errored: u32,
309    /// The number of abnormal sessions that ocurred.
310    #[serde(default, skip_serializing_if = "is_zero")]
311    pub abnormal: u32,
312    /// The number of unhandled sessions that ocurred.
313    #[serde(default, skip_serializing_if = "is_zero")]
314    pub unhandled: u32,
315    /// The number of crashed sessions that ocurred.
316    #[serde(default, skip_serializing_if = "is_zero")]
317    pub crashed: u32,
318    // If you add a new variant here, bump the session metrics extraction version
319    // to prevent outdated extraction in external Relays.
320}
321
322impl SessionLike for SessionAggregateItem {
323    fn started(&self) -> DateTime<Utc> {
324        self.started
325    }
326
327    fn distinct_id(&self) -> Option<&String> {
328        self.distinct_id.as_ref()
329    }
330
331    fn total_count(&self) -> u32 {
332        self.exited + self.abnormal + self.errored + self.unhandled + self.crashed
333    }
334
335    fn abnormal_count(&self) -> u32 {
336        self.abnormal
337    }
338
339    fn unhandled_count(&self) -> u32 {
340        self.unhandled
341    }
342
343    fn crashed_count(&self) -> u32 {
344        self.crashed
345    }
346
347    fn all_errors(&self) -> Option<SessionErrored> {
348        // Errors contain all of: abnormal, unhandled, and crashed.
349        // See https://github.com/getsentry/snuba/blob/c45f2a8636f9ea3dfada4e2d0ae5efef6c6248de/snuba/migrations/snuba_migrations/sessions/0003_sessions_matview.py#L80-L81
350        let all_errored = self.abnormal + self.errored + self.unhandled + self.crashed;
351        if all_errored > 0 {
352            Some(SessionErrored::Aggregated(all_errored))
353        } else {
354            None
355        }
356    }
357    fn abnormal_mechanism(&self) -> AbnormalMechanism {
358        AbnormalMechanism::None
359    }
360}
361
362#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
363pub struct SessionAggregates {
364    /// A batch of sessions that were started.
365    #[serde(default)]
366    pub aggregates: Vec<SessionAggregateItem>,
367    /// The shared session event attributes.
368    #[serde(rename = "attrs")]
369    pub attributes: SessionAttributes,
370}
371
372impl SessionAggregates {
373    /// Parses a session batch from JSON.
374    pub fn parse(payload: &[u8]) -> Result<Self, serde_json::Error> {
375        serde_json::from_slice(payload)
376    }
377
378    /// Serializes a session batch back into JSON.
379    pub fn serialize(&self) -> Result<Vec<u8>, serde_json::Error> {
380        serde_json::to_vec(self)
381    }
382}
383
384// Dummy implementation of `Getter` to satisfy the bound of `should_filter`.
385// We don't actually want to use `get_value` at this time.`
386impl Getter for SessionAggregates {
387    fn get_value(&self, _path: &str) -> Option<relay_protocol::Val<'_>> {
388        None
389    }
390}
391
392#[cfg(test)]
393mod tests {
394
395    use std::str::FromStr;
396
397    use similar_asserts::assert_eq;
398
399    use super::*;
400
401    #[test]
402    fn test_did_you_bump_session_metrics_extraction_version() {
403        fn _assert_status(status: SessionStatus) {
404            match status {
405                SessionStatus::Ok => todo!(),
406                SessionStatus::Exited => todo!(),
407                SessionStatus::Crashed => todo!(),
408                SessionStatus::Abnormal => todo!(),
409                SessionStatus::Errored => todo!(),
410                SessionStatus::Unhandled => todo!(),
411                SessionStatus::Unknown(_) => todo!(),
412                // If you have to make changes here, you also need to bump the session extraction
413                // metrics version in Sentry and Relay.
414            }
415        }
416        fn _assert_aggregate_item(item: SessionAggregateItem) {
417            let SessionAggregateItem {
418                started: _,
419                distinct_id: _,
420                exited: _,
421                errored: _,
422                abnormal: _,
423                unhandled: _,
424                crashed: _,
425                // If you have to make changes here, you also need to bump the session extraction
426                // metrics version in Sentry and Relay.
427            } = item;
428        }
429    }
430
431    #[test]
432    fn test_sessionstatus_unknown() {
433        let unknown = SessionStatus::from_str("invalid status").unwrap();
434        if let SessionStatus::Unknown(inner) = unknown {
435            assert_eq!(inner, "invalid status".to_owned());
436        } else {
437            panic!();
438        }
439    }
440
441    #[test]
442    fn test_session_default_values() {
443        let json = r#"{
444  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
445  "timestamp": "2020-02-07T15:17:00Z",
446  "started": "2020-02-07T14:16:00Z",
447  "attrs": {
448    "release": "sentry-test@1.0.0"
449  }
450}"#;
451
452        let output = r#"{
453  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
454  "did": null,
455  "seq": 4711,
456  "timestamp": "2020-02-07T15:17:00Z",
457  "started": "2020-02-07T14:16:00Z",
458  "status": "ok",
459  "errors": 0,
460  "attrs": {
461    "release": "sentry-test@1.0.0"
462  }
463}"#;
464
465        let update = SessionUpdate {
466            session_id: "8333339f-5675-4f89-a9a0-1c935255ab58".parse().unwrap(),
467            distinct_id: None,
468            sequence: 4711, // this would be a timestamp instead
469            timestamp: "2020-02-07T15:17:00Z".parse().unwrap(),
470            started: "2020-02-07T14:16:00Z".parse().unwrap(),
471            duration: None,
472            init: false,
473            status: SessionStatus::Ok,
474            abnormal_mechanism: AbnormalMechanism::None,
475            errors: 0,
476            attributes: SessionAttributes {
477                release: "sentry-test@1.0.0".to_owned(),
478                environment: None,
479                ip_address: None,
480                user_agent: None,
481            },
482        };
483
484        let mut parsed = SessionUpdate::parse(json.as_bytes()).unwrap();
485
486        // Sequence is defaulted to the current timestamp. Override for snapshot.
487        assert!((default_sequence() - parsed.sequence) <= 1);
488        parsed.sequence = 4711;
489
490        assert_eq!(update, parsed);
491        assert_eq!(output, serde_json::to_string_pretty(&update).unwrap());
492    }
493
494    #[test]
495    fn test_session_default_timestamp_and_sid() {
496        let json = r#"{
497  "started": "2020-02-07T14:16:00Z",
498  "attrs": {
499      "release": "sentry-test@1.0.0"
500  }
501}"#;
502
503        let parsed = SessionUpdate::parse(json.as_bytes()).unwrap();
504        assert!(!parsed.session_id.is_nil());
505    }
506
507    #[test]
508    fn test_session_roundtrip() {
509        let json = r#"{
510  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
511  "did": "foobarbaz",
512  "seq": 42,
513  "init": true,
514  "timestamp": "2020-02-07T15:17:00Z",
515  "started": "2020-02-07T14:16:00Z",
516  "duration": 1947.49,
517  "status": "exited",
518  "errors": 0,
519  "attrs": {
520    "release": "sentry-test@1.0.0",
521    "environment": "production",
522    "ip_address": "::1",
523    "user_agent": "Firefox/72.0"
524  }
525}"#;
526
527        let update = SessionUpdate {
528            session_id: "8333339f-5675-4f89-a9a0-1c935255ab58".parse().unwrap(),
529            distinct_id: Some("foobarbaz".into()),
530            sequence: 42,
531            timestamp: "2020-02-07T15:17:00Z".parse().unwrap(),
532            started: "2020-02-07T14:16:00Z".parse().unwrap(),
533            duration: Some(1947.49),
534            status: SessionStatus::Exited,
535            abnormal_mechanism: AbnormalMechanism::None,
536            errors: 0,
537            init: true,
538            attributes: SessionAttributes {
539                release: "sentry-test@1.0.0".to_owned(),
540                environment: Some("production".to_owned()),
541                ip_address: Some(IpAddr::parse("::1").unwrap()),
542                user_agent: Some("Firefox/72.0".to_owned()),
543            },
544        };
545
546        assert_eq!(update, SessionUpdate::parse(json.as_bytes()).unwrap());
547        assert_eq!(json, serde_json::to_string_pretty(&update).unwrap());
548    }
549
550    #[test]
551    fn test_session_ip_addr_auto() {
552        let json = r#"{
553  "started": "2020-02-07T14:16:00Z",
554  "attrs": {
555    "release": "sentry-test@1.0.0",
556    "ip_address": "{{auto}}"
557  }
558}"#;
559
560        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
561        assert_eq!(update.attributes.ip_address, Some(IpAddr::auto()));
562    }
563    #[test]
564    fn test_session_abnormal_mechanism() {
565        let json = r#"{
566    "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
567    "started": "2020-02-07T14:16:00Z",
568    "status": "abnormal",
569    "abnormal_mechanism": "anr_background",
570    "attrs": {
571    "release": "sentry-test@1.0.0",
572    "environment": "production"
573    }
574    }"#;
575
576        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
577        assert_eq!(update.abnormal_mechanism, AbnormalMechanism::AnrBackground);
578    }
579
580    #[test]
581    fn test_session_invalid_abnormal_mechanism() {
582        let json = r#"{
583  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
584  "started": "2020-02-07T14:16:00Z",
585  "status": "abnormal",
586  "abnormal_mechanism": "invalid_mechanism",
587  "attrs": {
588    "release": "sentry-test@1.0.0",
589    "environment": "production"
590  }
591}"#;
592
593        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
594        assert_eq!(update.abnormal_mechanism, AbnormalMechanism::None);
595    }
596
597    #[test]
598    fn test_session_null_abnormal_mechanism() {
599        let json = r#"{
600  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
601  "started": "2020-02-07T14:16:00Z",
602  "status": "abnormal",
603  "abnormal_mechanism": null,
604  "attrs": {
605    "release": "sentry-test@1.0.0",
606    "environment": "production"
607  }
608}"#;
609
610        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
611        assert_eq!(update.abnormal_mechanism, AbnormalMechanism::None);
612    }
613}