Skip to main content

relay_event_schema/protocol/
session.rs

1use std::fmt::{self, Display};
2use std::time::SystemTime;
3
4use chrono::{DateTime, Utc};
5use relay_protocol::{Getter, Val};
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
151impl Getter for SessionAttributes {
152    fn get_value(&self, path: &str) -> Option<Val<'_>> {
153        Some(match path.strip_prefix("event.")? {
154            "release" => self.release.as_str().into(),
155            "environment" => self.environment.as_deref()?.into(),
156            _ => return None,
157        })
158    }
159}
160
161fn default_sequence() -> u64 {
162    SystemTime::now()
163        .duration_since(SystemTime::UNIX_EPOCH)
164        .unwrap_or_default()
165        .as_millis() as u64
166}
167
168#[allow(clippy::trivially_copy_pass_by_ref)]
169fn is_false(val: &bool) -> bool {
170    !val
171}
172
173/// Contains information about errored sessions. See [`SessionLike`].
174pub enum SessionErrored {
175    /// Contains the UUID for a single errored session.
176    Individual(Uuid),
177    /// Contains the number of all errored sessions in an aggregate.
178    /// errored, crashed, abnormal all count towards errored sessions.
179    Aggregated(u32),
180}
181
182/// Common interface for [`SessionUpdate`] and [`SessionAggregateItem`].
183pub trait SessionLike {
184    fn started(&self) -> DateTime<Utc>;
185    fn distinct_id(&self) -> Option<&String>;
186    fn total_count(&self) -> u32;
187    fn abnormal_count(&self) -> u32;
188    fn unhandled_count(&self) -> u32;
189    fn crashed_count(&self) -> u32;
190    fn all_errors(&self) -> Option<SessionErrored>;
191    fn abnormal_mechanism(&self) -> AbnormalMechanism;
192}
193
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195pub struct SessionUpdate {
196    /// The session identifier.
197    #[serde(rename = "sid", default = "Uuid::new_v4")]
198    pub session_id: Uuid,
199    /// The distinct identifier.
200    #[serde(rename = "did", default)]
201    pub distinct_id: Option<String>,
202    /// An optional logical clock.
203    #[serde(rename = "seq", default = "default_sequence")]
204    pub sequence: u64,
205    /// A flag that indicates that this is the initial transmission of the session.
206    #[serde(default, skip_serializing_if = "is_false")]
207    pub init: bool,
208    /// The timestamp of when the session change event was created.
209    #[serde(default = "Utc::now")]
210    pub timestamp: DateTime<Utc>,
211    /// The timestamp of when the session itself started.
212    pub started: DateTime<Utc>,
213    /// An optional duration of the session in seconds.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub duration: Option<f64>,
216    /// The status of the session.
217    #[serde(default)]
218    pub status: SessionStatus,
219    /// The number of errors that ocurred.
220    #[serde(default)]
221    pub errors: u64,
222    /// The session event attributes.
223    #[serde(rename = "attrs")]
224    pub attributes: SessionAttributes,
225    /// The abnormal mechanism.
226    #[serde(
227        default,
228        deserialize_with = "null_to_default",
229        skip_serializing_if = "AbnormalMechanism::is_none"
230    )]
231    pub abnormal_mechanism: AbnormalMechanism,
232}
233
234impl SessionUpdate {
235    /// Parses a session update from JSON.
236    pub fn parse(payload: &[u8]) -> Result<Self, serde_json::Error> {
237        serde_json::from_slice(payload)
238    }
239
240    /// Serializes a session update back into JSON.
241    pub fn serialize(&self) -> Result<Vec<u8>, serde_json::Error> {
242        serde_json::to_vec(self)
243    }
244}
245
246impl SessionLike for SessionUpdate {
247    fn started(&self) -> DateTime<Utc> {
248        self.started
249    }
250
251    fn distinct_id(&self) -> Option<&String> {
252        self.distinct_id.as_ref()
253    }
254
255    fn total_count(&self) -> u32 {
256        u32::from(self.init)
257    }
258
259    fn abnormal_count(&self) -> u32 {
260        match self.status {
261            SessionStatus::Abnormal => 1,
262            _ => 0,
263        }
264    }
265
266    fn unhandled_count(&self) -> u32 {
267        match self.status {
268            SessionStatus::Unhandled => 1,
269            _ => 0,
270        }
271    }
272
273    fn crashed_count(&self) -> u32 {
274        match self.status {
275            SessionStatus::Crashed => 1,
276            _ => 0,
277        }
278    }
279
280    fn all_errors(&self) -> Option<SessionErrored> {
281        if self.errors > 0 || self.status.is_error() {
282            Some(SessionErrored::Individual(self.session_id))
283        } else {
284            None
285        }
286    }
287
288    fn abnormal_mechanism(&self) -> AbnormalMechanism {
289        self.abnormal_mechanism
290    }
291}
292
293impl Getter for SessionUpdate {
294    fn get_value(&self, path: &str) -> Option<Val<'_>> {
295        self.attributes.get_value(path)
296    }
297}
298
299#[allow(clippy::trivially_copy_pass_by_ref)]
300fn is_zero(val: &u32) -> bool {
301    *val == 0
302}
303
304#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
305pub struct SessionAggregateItem {
306    /// The timestamp of when the session itself started.
307    pub started: DateTime<Utc>,
308    /// The distinct identifier.
309    #[serde(rename = "did", default, skip_serializing_if = "Option::is_none")]
310    pub distinct_id: Option<String>,
311    /// The number of exited sessions that ocurred.
312    #[serde(default, skip_serializing_if = "is_zero")]
313    pub exited: u32,
314    /// The number of errored sessions that ocurred, not including the abnormal and crashed ones.
315    #[serde(default, skip_serializing_if = "is_zero")]
316    pub errored: u32,
317    /// The number of abnormal sessions that ocurred.
318    #[serde(default, skip_serializing_if = "is_zero")]
319    pub abnormal: u32,
320    /// The number of unhandled sessions that ocurred.
321    #[serde(default, skip_serializing_if = "is_zero")]
322    pub unhandled: u32,
323    /// The number of crashed sessions that ocurred.
324    #[serde(default, skip_serializing_if = "is_zero")]
325    pub crashed: u32,
326    // If you add a new variant here, bump the session metrics extraction version
327    // to prevent outdated extraction in external Relays.
328}
329
330impl SessionLike for SessionAggregateItem {
331    fn started(&self) -> DateTime<Utc> {
332        self.started
333    }
334
335    fn distinct_id(&self) -> Option<&String> {
336        self.distinct_id.as_ref()
337    }
338
339    fn total_count(&self) -> u32 {
340        self.exited + self.abnormal + self.errored + self.unhandled + self.crashed
341    }
342
343    fn abnormal_count(&self) -> u32 {
344        self.abnormal
345    }
346
347    fn unhandled_count(&self) -> u32 {
348        self.unhandled
349    }
350
351    fn crashed_count(&self) -> u32 {
352        self.crashed
353    }
354
355    fn all_errors(&self) -> Option<SessionErrored> {
356        // Errors contain all of: abnormal, unhandled, and crashed.
357        // See https://github.com/getsentry/snuba/blob/c45f2a8636f9ea3dfada4e2d0ae5efef6c6248de/snuba/migrations/snuba_migrations/sessions/0003_sessions_matview.py#L80-L81
358        let all_errored = self.abnormal + self.errored + self.unhandled + self.crashed;
359        if all_errored > 0 {
360            Some(SessionErrored::Aggregated(all_errored))
361        } else {
362            None
363        }
364    }
365    fn abnormal_mechanism(&self) -> AbnormalMechanism {
366        AbnormalMechanism::None
367    }
368}
369
370#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
371pub struct SessionAggregates {
372    /// A batch of sessions that were started.
373    #[serde(default)]
374    pub aggregates: Vec<SessionAggregateItem>,
375    /// The shared session event attributes.
376    #[serde(rename = "attrs")]
377    pub attributes: SessionAttributes,
378}
379
380impl SessionAggregates {
381    /// Parses a session batch from JSON.
382    pub fn parse(payload: &[u8]) -> Result<Self, serde_json::Error> {
383        serde_json::from_slice(payload)
384    }
385
386    /// Serializes a session batch back into JSON.
387    pub fn serialize(&self) -> Result<Vec<u8>, serde_json::Error> {
388        serde_json::to_vec(self)
389    }
390}
391
392impl Getter for SessionAggregates {
393    fn get_value(&self, path: &str) -> Option<Val<'_>> {
394        self.attributes.get_value(path)
395    }
396}
397
398#[cfg(test)]
399mod tests {
400
401    use std::str::FromStr;
402
403    use similar_asserts::assert_eq;
404
405    use super::*;
406
407    #[test]
408    fn test_did_you_bump_session_metrics_extraction_version() {
409        fn _assert_status(status: SessionStatus) {
410            match status {
411                SessionStatus::Ok => todo!(),
412                SessionStatus::Exited => todo!(),
413                SessionStatus::Crashed => todo!(),
414                SessionStatus::Abnormal => todo!(),
415                SessionStatus::Errored => todo!(),
416                SessionStatus::Unhandled => todo!(),
417                SessionStatus::Unknown(_) => todo!(),
418                // If you have to make changes here, you also need to bump the session extraction
419                // metrics version in Sentry and Relay.
420            }
421        }
422        fn _assert_aggregate_item(item: SessionAggregateItem) {
423            let SessionAggregateItem {
424                started: _,
425                distinct_id: _,
426                exited: _,
427                errored: _,
428                abnormal: _,
429                unhandled: _,
430                crashed: _,
431                // If you have to make changes here, you also need to bump the session extraction
432                // metrics version in Sentry and Relay.
433            } = item;
434        }
435    }
436
437    #[test]
438    fn test_sessionstatus_unknown() {
439        let unknown = SessionStatus::from_str("invalid status").unwrap();
440        if let SessionStatus::Unknown(inner) = unknown {
441            assert_eq!(inner, "invalid status".to_owned());
442        } else {
443            panic!();
444        }
445    }
446
447    #[test]
448    fn test_session_default_values() {
449        let json = r#"{
450  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
451  "timestamp": "2020-02-07T15:17:00Z",
452  "started": "2020-02-07T14:16:00Z",
453  "attrs": {
454    "release": "sentry-test@1.0.0"
455  }
456}"#;
457
458        let output = r#"{
459  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
460  "did": null,
461  "seq": 4711,
462  "timestamp": "2020-02-07T15:17:00Z",
463  "started": "2020-02-07T14:16:00Z",
464  "status": "ok",
465  "errors": 0,
466  "attrs": {
467    "release": "sentry-test@1.0.0"
468  }
469}"#;
470
471        let update = SessionUpdate {
472            session_id: "8333339f-5675-4f89-a9a0-1c935255ab58".parse().unwrap(),
473            distinct_id: None,
474            sequence: 4711, // this would be a timestamp instead
475            timestamp: "2020-02-07T15:17:00Z".parse().unwrap(),
476            started: "2020-02-07T14:16:00Z".parse().unwrap(),
477            duration: None,
478            init: false,
479            status: SessionStatus::Ok,
480            abnormal_mechanism: AbnormalMechanism::None,
481            errors: 0,
482            attributes: SessionAttributes {
483                release: "sentry-test@1.0.0".to_owned(),
484                environment: None,
485                ip_address: None,
486                user_agent: None,
487            },
488        };
489
490        let mut parsed = SessionUpdate::parse(json.as_bytes()).unwrap();
491
492        // Sequence is defaulted to the current timestamp. Override for snapshot.
493        assert!((default_sequence() - parsed.sequence) <= 1);
494        parsed.sequence = 4711;
495
496        assert_eq!(update, parsed);
497        assert_eq!(output, serde_json::to_string_pretty(&update).unwrap());
498    }
499
500    #[test]
501    fn test_session_default_timestamp_and_sid() {
502        let json = r#"{
503  "started": "2020-02-07T14:16:00Z",
504  "attrs": {
505      "release": "sentry-test@1.0.0"
506  }
507}"#;
508
509        let parsed = SessionUpdate::parse(json.as_bytes()).unwrap();
510        assert!(!parsed.session_id.is_nil());
511    }
512
513    #[test]
514    fn test_session_roundtrip() {
515        let json = r#"{
516  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
517  "did": "foobarbaz",
518  "seq": 42,
519  "init": true,
520  "timestamp": "2020-02-07T15:17:00Z",
521  "started": "2020-02-07T14:16:00Z",
522  "duration": 1947.49,
523  "status": "exited",
524  "errors": 0,
525  "attrs": {
526    "release": "sentry-test@1.0.0",
527    "environment": "production",
528    "ip_address": "::1",
529    "user_agent": "Firefox/72.0"
530  }
531}"#;
532
533        let update = SessionUpdate {
534            session_id: "8333339f-5675-4f89-a9a0-1c935255ab58".parse().unwrap(),
535            distinct_id: Some("foobarbaz".into()),
536            sequence: 42,
537            timestamp: "2020-02-07T15:17:00Z".parse().unwrap(),
538            started: "2020-02-07T14:16:00Z".parse().unwrap(),
539            duration: Some(1947.49),
540            status: SessionStatus::Exited,
541            abnormal_mechanism: AbnormalMechanism::None,
542            errors: 0,
543            init: true,
544            attributes: SessionAttributes {
545                release: "sentry-test@1.0.0".to_owned(),
546                environment: Some("production".to_owned()),
547                ip_address: Some(IpAddr::parse("::1").unwrap()),
548                user_agent: Some("Firefox/72.0".to_owned()),
549            },
550        };
551
552        assert_eq!(update, SessionUpdate::parse(json.as_bytes()).unwrap());
553        assert_eq!(json, serde_json::to_string_pretty(&update).unwrap());
554    }
555
556    #[test]
557    fn test_session_ip_addr_auto() {
558        let json = r#"{
559  "started": "2020-02-07T14:16:00Z",
560  "attrs": {
561    "release": "sentry-test@1.0.0",
562    "ip_address": "{{auto}}"
563  }
564}"#;
565
566        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
567        assert_eq!(update.attributes.ip_address, Some(IpAddr::auto()));
568    }
569    #[test]
570    fn test_session_abnormal_mechanism() {
571        let json = r#"{
572    "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
573    "started": "2020-02-07T14:16:00Z",
574    "status": "abnormal",
575    "abnormal_mechanism": "anr_background",
576    "attrs": {
577    "release": "sentry-test@1.0.0",
578    "environment": "production"
579    }
580    }"#;
581
582        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
583        assert_eq!(update.abnormal_mechanism, AbnormalMechanism::AnrBackground);
584    }
585
586    #[test]
587    fn test_session_invalid_abnormal_mechanism() {
588        let json = r#"{
589  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
590  "started": "2020-02-07T14:16:00Z",
591  "status": "abnormal",
592  "abnormal_mechanism": "invalid_mechanism",
593  "attrs": {
594    "release": "sentry-test@1.0.0",
595    "environment": "production"
596  }
597}"#;
598
599        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
600        assert_eq!(update.abnormal_mechanism, AbnormalMechanism::None);
601    }
602
603    #[test]
604    fn test_session_null_abnormal_mechanism() {
605        let json = r#"{
606  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
607  "started": "2020-02-07T14:16:00Z",
608  "status": "abnormal",
609  "abnormal_mechanism": null,
610  "attrs": {
611    "release": "sentry-test@1.0.0",
612    "environment": "production"
613  }
614}"#;
615
616        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
617        assert_eq!(update.abnormal_mechanism, AbnormalMechanism::None);
618    }
619
620    #[test]
621    fn test_session_update_get_value() {
622        let json = r#"{
623  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
624  "started": "2020-02-07T14:16:00Z",
625  "attrs": {
626    "release": "sentry-test@1.0.0",
627    "environment": "production"
628  }
629}"#;
630
631        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
632        assert_eq!(
633            update.get_value("event.release"),
634            Some(Val::String("sentry-test@1.0.0"))
635        );
636        assert_eq!(
637            update.get_value("event.environment"),
638            Some(Val::String("production"))
639        );
640        assert_eq!(update.get_value("event.transaction"), None);
641        assert_eq!(
642            update.get_value("log.attributes.sentry.release.value"),
643            None
644        );
645        assert_eq!(update.get_value("release"), None);
646    }
647
648    #[test]
649    fn test_session_update_get_value_without_environment() {
650        let json = r#"{
651  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
652  "started": "2020-02-07T14:16:00Z",
653  "attrs": {
654    "release": "sentry-test@1.0.0"
655  }
656}"#;
657
658        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
659        assert_eq!(
660            update.get_value("event.release"),
661            Some(Val::String("sentry-test@1.0.0"))
662        );
663        assert_eq!(update.get_value("event.environment"), None);
664    }
665}