relay_event_schema/protocol/
session.rs

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