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