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    /// The status of the session.
214    #[serde(default)]
215    pub status: SessionStatus,
216    /// The number of errors that ocurred.
217    #[serde(default)]
218    pub errors: u64,
219    /// The session event attributes.
220    #[serde(rename = "attrs")]
221    pub attributes: SessionAttributes,
222    /// The abnormal mechanism.
223    #[serde(
224        default,
225        deserialize_with = "null_to_default",
226        skip_serializing_if = "AbnormalMechanism::is_none"
227    )]
228    pub abnormal_mechanism: AbnormalMechanism,
229}
230
231impl SessionUpdate {
232    /// Parses a session update from JSON.
233    pub fn parse(payload: &[u8]) -> Result<Self, serde_json::Error> {
234        serde_json::from_slice(payload)
235    }
236
237    /// Serializes a session update back into JSON.
238    pub fn serialize(&self) -> Result<Vec<u8>, serde_json::Error> {
239        serde_json::to_vec(self)
240    }
241}
242
243impl SessionLike for SessionUpdate {
244    fn started(&self) -> DateTime<Utc> {
245        self.started
246    }
247
248    fn distinct_id(&self) -> Option<&String> {
249        self.distinct_id.as_ref()
250    }
251
252    fn total_count(&self) -> u32 {
253        u32::from(self.init)
254    }
255
256    fn abnormal_count(&self) -> u32 {
257        match self.status {
258            SessionStatus::Abnormal => 1,
259            _ => 0,
260        }
261    }
262
263    fn unhandled_count(&self) -> u32 {
264        match self.status {
265            SessionStatus::Unhandled => 1,
266            _ => 0,
267        }
268    }
269
270    fn crashed_count(&self) -> u32 {
271        match self.status {
272            SessionStatus::Crashed => 1,
273            _ => 0,
274        }
275    }
276
277    fn all_errors(&self) -> Option<SessionErrored> {
278        if self.errors > 0 || self.status.is_error() {
279            Some(SessionErrored::Individual(self.session_id))
280        } else {
281            None
282        }
283    }
284
285    fn abnormal_mechanism(&self) -> AbnormalMechanism {
286        self.abnormal_mechanism
287    }
288}
289
290impl Getter for SessionUpdate {
291    fn get_value(&self, path: &str) -> Option<Val<'_>> {
292        self.attributes.get_value(path)
293    }
294}
295
296#[allow(clippy::trivially_copy_pass_by_ref)]
297fn is_zero(val: &u32) -> bool {
298    *val == 0
299}
300
301#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
302pub struct SessionAggregateItem {
303    /// The timestamp of when the session itself started.
304    pub started: DateTime<Utc>,
305    /// The distinct identifier.
306    #[serde(rename = "did", default, skip_serializing_if = "Option::is_none")]
307    pub distinct_id: Option<String>,
308    /// The number of exited sessions that ocurred.
309    #[serde(default, skip_serializing_if = "is_zero")]
310    pub exited: u32,
311    /// The number of errored sessions that ocurred, not including the abnormal and crashed ones.
312    #[serde(default, skip_serializing_if = "is_zero")]
313    pub errored: u32,
314    /// The number of abnormal sessions that ocurred.
315    #[serde(default, skip_serializing_if = "is_zero")]
316    pub abnormal: u32,
317    /// The number of unhandled sessions that ocurred.
318    #[serde(default, skip_serializing_if = "is_zero")]
319    pub unhandled: u32,
320    /// The number of crashed sessions that ocurred.
321    #[serde(default, skip_serializing_if = "is_zero")]
322    pub crashed: u32,
323    // If you add a new variant here, bump the session metrics extraction version
324    // to prevent outdated extraction in external Relays.
325}
326
327impl SessionLike for SessionAggregateItem {
328    fn started(&self) -> DateTime<Utc> {
329        self.started
330    }
331
332    fn distinct_id(&self) -> Option<&String> {
333        self.distinct_id.as_ref()
334    }
335
336    fn total_count(&self) -> u32 {
337        self.exited + self.abnormal + self.errored + self.unhandled + self.crashed
338    }
339
340    fn abnormal_count(&self) -> u32 {
341        self.abnormal
342    }
343
344    fn unhandled_count(&self) -> u32 {
345        self.unhandled
346    }
347
348    fn crashed_count(&self) -> u32 {
349        self.crashed
350    }
351
352    fn all_errors(&self) -> Option<SessionErrored> {
353        // Errors contain all of: abnormal, unhandled, and crashed.
354        // See https://github.com/getsentry/snuba/blob/c45f2a8636f9ea3dfada4e2d0ae5efef6c6248de/snuba/migrations/snuba_migrations/sessions/0003_sessions_matview.py#L80-L81
355        let all_errored = self.abnormal + self.errored + self.unhandled + self.crashed;
356        if all_errored > 0 {
357            Some(SessionErrored::Aggregated(all_errored))
358        } else {
359            None
360        }
361    }
362    fn abnormal_mechanism(&self) -> AbnormalMechanism {
363        AbnormalMechanism::None
364    }
365}
366
367#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
368pub struct SessionAggregates {
369    /// A batch of sessions that were started.
370    #[serde(default)]
371    pub aggregates: Vec<SessionAggregateItem>,
372    /// The shared session event attributes.
373    #[serde(rename = "attrs")]
374    pub attributes: SessionAttributes,
375}
376
377impl SessionAggregates {
378    /// Parses a session batch from JSON.
379    pub fn parse(payload: &[u8]) -> Result<Self, serde_json::Error> {
380        serde_json::from_slice(payload)
381    }
382
383    /// Serializes a session batch back into JSON.
384    pub fn serialize(&self) -> Result<Vec<u8>, serde_json::Error> {
385        serde_json::to_vec(self)
386    }
387}
388
389impl Getter for SessionAggregates {
390    fn get_value(&self, path: &str) -> Option<Val<'_>> {
391        self.attributes.get_value(path)
392    }
393}
394
395#[cfg(test)]
396mod tests {
397
398    use std::str::FromStr;
399
400    use similar_asserts::assert_eq;
401
402    use super::*;
403
404    #[test]
405    fn test_did_you_bump_session_metrics_extraction_version() {
406        fn _assert_status(status: SessionStatus) {
407            match status {
408                SessionStatus::Ok => todo!(),
409                SessionStatus::Exited => todo!(),
410                SessionStatus::Crashed => todo!(),
411                SessionStatus::Abnormal => todo!(),
412                SessionStatus::Errored => todo!(),
413                SessionStatus::Unhandled => todo!(),
414                SessionStatus::Unknown(_) => todo!(),
415                // If you have to make changes here, you also need to bump the session extraction
416                // metrics version in Sentry and Relay.
417            }
418        }
419        fn _assert_aggregate_item(item: SessionAggregateItem) {
420            let SessionAggregateItem {
421                started: _,
422                distinct_id: _,
423                exited: _,
424                errored: _,
425                abnormal: _,
426                unhandled: _,
427                crashed: _,
428                // If you have to make changes here, you also need to bump the session extraction
429                // metrics version in Sentry and Relay.
430            } = item;
431        }
432    }
433
434    #[test]
435    fn test_sessionstatus_unknown() {
436        let unknown = SessionStatus::from_str("invalid status").unwrap();
437        if let SessionStatus::Unknown(inner) = unknown {
438            assert_eq!(inner, "invalid status".to_owned());
439        } else {
440            panic!();
441        }
442    }
443
444    #[test]
445    fn test_session_default_values() {
446        let json = r#"{
447  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
448  "timestamp": "2020-02-07T15:17:00Z",
449  "started": "2020-02-07T14:16:00Z",
450  "attrs": {
451    "release": "sentry-test@1.0.0"
452  }
453}"#;
454
455        let output = r#"{
456  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
457  "did": null,
458  "seq": 4711,
459  "timestamp": "2020-02-07T15:17:00Z",
460  "started": "2020-02-07T14:16:00Z",
461  "status": "ok",
462  "errors": 0,
463  "attrs": {
464    "release": "sentry-test@1.0.0"
465  }
466}"#;
467
468        let update = SessionUpdate {
469            session_id: "8333339f-5675-4f89-a9a0-1c935255ab58".parse().unwrap(),
470            distinct_id: None,
471            sequence: 4711, // this would be a timestamp instead
472            timestamp: "2020-02-07T15:17:00Z".parse().unwrap(),
473            started: "2020-02-07T14:16:00Z".parse().unwrap(),
474            init: false,
475            status: SessionStatus::Ok,
476            abnormal_mechanism: AbnormalMechanism::None,
477            errors: 0,
478            attributes: SessionAttributes {
479                release: "sentry-test@1.0.0".to_owned(),
480                environment: None,
481                ip_address: None,
482                user_agent: None,
483            },
484        };
485
486        let mut parsed = SessionUpdate::parse(json.as_bytes()).unwrap();
487
488        // Sequence is defaulted to the current timestamp. Override for snapshot.
489        assert!((default_sequence() - parsed.sequence) <= 1);
490        parsed.sequence = 4711;
491
492        assert_eq!(update, parsed);
493        assert_eq!(output, serde_json::to_string_pretty(&update).unwrap());
494    }
495
496    #[test]
497    fn test_session_default_timestamp_and_sid() {
498        let json = r#"{
499  "started": "2020-02-07T14:16:00Z",
500  "attrs": {
501      "release": "sentry-test@1.0.0"
502  }
503}"#;
504
505        let parsed = SessionUpdate::parse(json.as_bytes()).unwrap();
506        assert!(!parsed.session_id.is_nil());
507    }
508
509    #[test]
510    fn test_session_roundtrip() {
511        let json = r#"{
512  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
513  "did": "foobarbaz",
514  "seq": 42,
515  "init": true,
516  "timestamp": "2020-02-07T15:17:00Z",
517  "started": "2020-02-07T14:16:00Z",
518  "status": "exited",
519  "errors": 0,
520  "attrs": {
521    "release": "sentry-test@1.0.0",
522    "environment": "production",
523    "ip_address": "::1",
524    "user_agent": "Firefox/72.0"
525  }
526}"#;
527
528        let update = SessionUpdate {
529            session_id: "8333339f-5675-4f89-a9a0-1c935255ab58".parse().unwrap(),
530            distinct_id: Some("foobarbaz".into()),
531            sequence: 42,
532            timestamp: "2020-02-07T15:17:00Z".parse().unwrap(),
533            started: "2020-02-07T14:16:00Z".parse().unwrap(),
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
614    #[test]
615    fn test_session_update_get_value() {
616        let json = r#"{
617  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
618  "started": "2020-02-07T14:16:00Z",
619  "attrs": {
620    "release": "sentry-test@1.0.0",
621    "environment": "production"
622  }
623}"#;
624
625        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
626        assert_eq!(
627            update.get_value("event.release"),
628            Some(Val::String("sentry-test@1.0.0"))
629        );
630        assert_eq!(
631            update.get_value("event.environment"),
632            Some(Val::String("production"))
633        );
634        assert_eq!(update.get_value("event.transaction"), None);
635        assert_eq!(
636            update.get_value("log.attributes.sentry.release.value"),
637            None
638        );
639        assert_eq!(update.get_value("release"), None);
640    }
641
642    #[test]
643    fn test_session_update_get_value_without_environment() {
644        let json = r#"{
645  "sid": "8333339f-5675-4f89-a9a0-1c935255ab58",
646  "started": "2020-02-07T14:16:00Z",
647  "attrs": {
648    "release": "sentry-test@1.0.0"
649  }
650}"#;
651
652        let update = SessionUpdate::parse(json.as_bytes()).unwrap();
653        assert_eq!(
654            update.get_value("event.release"),
655            Some(Val::String("sentry-test@1.0.0"))
656        );
657        assert_eq!(update.get_value("event.environment"), None);
658    }
659}