diff --git a/sentry-core/Cargo.toml b/sentry-core/Cargo.toml index aaf41313b..cc82f456e 100644 --- a/sentry-core/Cargo.toml +++ b/sentry-core/Cargo.toml @@ -24,6 +24,7 @@ test = ["client"] [dependencies] sentry-types = { version = "0.19.1", path = "../sentry-types" } +serde = { version = "1.0.104", features = ["derive"] } lazy_static = "1.4.0" im = { version = "14.2.0", optional = true } rand = { version = "0.7.3", optional = true } diff --git a/sentry-core/src/api.rs b/sentry-core/src/api.rs index 10c21df9b..af05cf2f7 100644 --- a/sentry-core/src/api.rs +++ b/sentry-core/src/api.rs @@ -261,3 +261,27 @@ pub fn last_event_id() -> Option { Hub::with(|hub| hub.last_event_id()) }} } + +/// Start a new session for Release Health. +/// +/// This is still **experimental** for the moment and is not recommended to be +/// used with a very high volume of sessions (_request-mode_ sessions). +/// +/// # Examples +/// +/// ``` +/// sentry::start_session(); +/// +/// // capturing any event / error here will update the sessions `errors` count, +/// // up until we call `sentry::end_session`. +/// +/// sentry::end_session(); +/// ``` +pub fn start_session() { + Hub::with_active(|hub| hub.start_session()) +} + +/// End the current Release Health Session. +pub fn end_session() { + Hub::with_active(|hub| hub.end_session()) +} diff --git a/sentry-core/src/client.rs b/sentry-core/src/client.rs index 6dcb4e09d..8d2b3878c 100644 --- a/sentry-core/src/client.rs +++ b/sentry-core/src/client.rs @@ -11,7 +11,7 @@ use rand::random; use crate::constants::SDK_INFO; use crate::protocol::{ClientSdkInfo, Event}; use crate::types::{Dsn, Uuid}; -use crate::{ClientOptions, Hub, Integration, Scope, Transport}; +use crate::{ClientOptions, Envelope, Hub, Integration, Scope, Transport}; impl> From for Client { fn from(o: T) -> Client { @@ -142,6 +142,14 @@ impl Client { mut event: Event<'static>, scope: Option<&Scope>, ) -> Option> { + if let Some(scope) = scope { + scope.update_session_from_event(&event); + } + + if !self.sample_should_send() { + return None; + } + // event_id and sdk_info are set before the processors run so that the // processors can poke around in that data. if event.event_id.is_nil() { @@ -236,17 +244,33 @@ impl Client { /// Captures an event and sends it to sentry. pub fn capture_event(&self, event: Event<'static>, scope: Option<&Scope>) -> Uuid { if let Some(ref transport) = *self.transport.read().unwrap() { - if self.sample_should_send() { - if let Some(event) = self.prepare_event(event, scope) { - let event_id = event.event_id; - transport.send_envelope(event.into()); - return event_id; + if let Some(event) = self.prepare_event(event, scope) { + let event_id = event.event_id; + let mut envelope: Envelope = event.into(); + let session_item = scope.and_then(|scope| { + scope + .session + .lock() + .unwrap() + .as_mut() + .and_then(|session| session.create_envelope_item()) + }); + if let Some(session_item) = session_item { + envelope.add(session_item); } + transport.send_envelope(envelope); + return event_id; } } Default::default() } + pub(crate) fn capture_envelope(&self, envelope: Envelope) { + if let Some(ref transport) = *self.transport.read().unwrap() { + transport.send_envelope(envelope); + } + } + /// Drains all pending events and shuts down the transport behind the /// client. After shutting down the transport is removed. /// diff --git a/sentry-core/src/clientoptions.rs b/sentry-core/src/clientoptions.rs index 48aa5f161..c53e3ee0c 100644 --- a/sentry-core/src/clientoptions.rs +++ b/sentry-core/src/clientoptions.rs @@ -82,6 +82,12 @@ pub struct ClientOptions { /// The timeout on client drop for draining events on shutdown. pub shutdown_timeout: Duration, // Other options not documented in Unified API + /// Enable Release Health Session tracking. + /// + /// When automatic session tracking is enabled, a new "user-mode" session + /// is started at the time of `sentry::init`, and will persist for the + /// application lifetime. + pub auto_session_tracking: bool, /// Border frames which indicate a border from a backtrace to /// useless internals. Some are automatically included. pub extra_border_frames: Vec<&'static str>, @@ -147,6 +153,7 @@ impl fmt::Debug for ClientOptions { .field("http_proxy", &self.http_proxy) .field("https_proxy", &self.https_proxy) .field("shutdown_timeout", &self.shutdown_timeout) + .field("auto_session_tracking", &self.auto_session_tracking) .field("extra_border_frames", &self.extra_border_frames) .field("trim_backtraces", &self.trim_backtraces) .field("user_agent", &self.user_agent) @@ -176,6 +183,7 @@ impl Default for ClientOptions { http_proxy: None, https_proxy: None, shutdown_timeout: Duration::from_secs(2), + auto_session_tracking: false, extra_border_frames: vec![], trim_backtraces: true, user_agent: Cow::Borrowed(&USER_AGENT), diff --git a/sentry-core/src/envelope.rs b/sentry-core/src/envelope.rs index 9d4ad3059..5d74fd000 100644 --- a/sentry-core/src/envelope.rs +++ b/sentry-core/src/envelope.rs @@ -1,14 +1,15 @@ use std::io::Write; use crate::protocol::Event; +use crate::session::Session; use crate::types::Uuid; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] #[non_exhaustive] -enum EnvelopeItem { +pub(crate) enum EnvelopeItem { Event(Event<'static>), + Session(Session), // TODO: - // * Session, // * Attachment, // etc… } @@ -27,7 +28,7 @@ impl From> for EnvelopeItem { /// /// See the [documentation on Envelopes](https://develop.sentry.dev/sdk/envelopes/) /// for more details. -#[derive(Clone, Default, Debug, PartialEq)] +#[derive(Clone, Default, Debug)] pub struct Envelope { event_id: Option, items: Vec, @@ -39,6 +40,13 @@ impl Envelope { Default::default() } + pub(crate) fn add(&mut self, item: I) + where + I: Into, + { + self.items.push(item.into()); + } + /// Returns the Envelopes Uuid, if any. pub fn uuid(&self) -> Option<&Uuid> { self.event_id.as_ref() @@ -48,12 +56,11 @@ impl Envelope { /// /// [`Event`]: protocol/struct.Event.html pub fn event(&self) -> Option<&Event<'static>> { - // until we actually add more items: - #[allow(clippy::unnecessary_filter_map)] self.items .iter() .filter_map(|item| match item { EnvelopeItem::Event(event) => Some(event), + _ => None, }) .next() } @@ -77,14 +84,13 @@ impl Envelope { // write each item: for item in &self.items { // we write them to a temporary buffer first, since we need their length - serde_json::to_writer( - &mut item_buf, - match item { - EnvelopeItem::Event(event) => event, - }, - )?; + match item { + EnvelopeItem::Event(event) => serde_json::to_writer(&mut item_buf, event)?, + EnvelopeItem::Session(session) => serde_json::to_writer(&mut item_buf, session)?, + } let item_type = match item { EnvelopeItem::Event(_) => "event", + EnvelopeItem::Session(_) => "session", }; writeln!( writer, @@ -113,6 +119,7 @@ impl From> for Envelope { #[cfg(test)] mod test { use super::*; + fn to_buf(envelope: Envelope) -> Vec { let mut vec = Vec::new(); envelope.to_writer(&mut vec).unwrap(); diff --git a/sentry-core/src/hub.rs b/sentry-core/src/hub.rs index 2c0018c79..9317e8e2a 100644 --- a/sentry-core/src/hub.rs +++ b/sentry-core/src/hub.rs @@ -9,10 +9,11 @@ use std::thread; use std::time::Duration; use crate::protocol::{Breadcrumb, Event, Level}; +use crate::session::{Session, SessionStatus}; use crate::types::Uuid; use crate::{event_from_error, Integration, IntoBreadcrumbs, Scope, ScopeGuard}; #[cfg(feature = "client")] -use crate::{scope::Stack, Client}; +use crate::{scope::Stack, Client, Envelope}; #[cfg(feature = "client")] lazy_static::lazy_static! { @@ -311,6 +312,46 @@ impl Hub { }) } + /// Start a new session for Release Health. + /// + /// See the global [`start_session`](fn.start_session.html) + /// for more documentation. + pub fn start_session(&self) { + with_client_impl! {{ + self.inner.with_mut(|stack| { + let top = stack.top_mut(); + if let Some(session) = Session::from_stack(top) { + // When creating a *new* session, we make sure it is unique, + // as to no inherit *backwards* to any parents. + let mut scope = Arc::make_mut(&mut top.scope); + scope.session = Arc::new(Mutex::new(Some(session))); + } + }) + }} + } + + /// End the current Release Health Session. + /// + /// See the global [`end_session`](fn.end_session.html) + /// for more documentation. + pub fn end_session(&self) { + with_client_impl! {{ + self.inner.with_mut(|stack| { + let top = stack.top_mut(); + if let Some(mut session) = top.scope.session.lock().unwrap().take() { + session.close(); + if let Some(item) = session.create_envelope_item() { + let mut envelope = Envelope::new(); + envelope.add(item); + if let Some(ref client) = top.client { + client.capture_envelope(envelope); + } + } + } + }) + }} + } + /// Pushes a new scope. /// /// This returns a guard that when dropped will pop the scope again. diff --git a/sentry-core/src/lib.rs b/sentry-core/src/lib.rs index c2a9d50bb..67060b962 100644 --- a/sentry-core/src/lib.rs +++ b/sentry-core/src/lib.rs @@ -64,6 +64,7 @@ mod hub; mod integration; mod intodsn; mod scope; +mod session; mod transport; // public api or exports from this crate diff --git a/sentry-core/src/scope/noop.rs b/sentry-core/src/scope/noop.rs index 0695a3b32..82c426a30 100644 --- a/sentry-core/src/scope/noop.rs +++ b/sentry-core/src/scope/noop.rs @@ -2,13 +2,6 @@ use std::fmt; use crate::protocol::{Context, Event, Level, User, Value}; -/// The minimal scope. -/// -/// In minimal API mode all modification functions are available as normally -/// just that generally calling them is impossible. -#[derive(Debug, Clone)] -pub struct Scope; - /// A minimal API scope guard. /// /// Doesn't do anything but can be debug formatted. @@ -21,6 +14,13 @@ impl fmt::Debug for ScopeGuard { } } +/// The minimal scope. +/// +/// In minimal API mode all modification functions are available as normally +/// just that generally calling them is impossible. +#[derive(Debug, Clone)] +pub struct Scope; + impl Scope { /// Clear the scope. /// diff --git a/sentry-core/src/scope/real.rs b/sentry-core/src/scope/real.rs index 26929f766..d322cfc11 100644 --- a/sentry-core/src/scope/real.rs +++ b/sentry-core/src/scope/real.rs @@ -1,8 +1,9 @@ use std::borrow::Cow; use std::fmt; -use std::sync::{Arc, PoisonError, RwLock}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; use crate::protocol::{Breadcrumb, Context, Event, Level, User, Value}; +use crate::session::Session; use crate::Client; #[derive(Debug)] @@ -41,6 +42,7 @@ pub struct Scope { pub(crate) tags: im::HashMap, pub(crate) contexts: im::HashMap, pub(crate) event_processors: im::Vector>, + pub(crate) session: Arc>>, } impl fmt::Debug for Scope { @@ -55,6 +57,7 @@ impl fmt::Debug for Scope { .field("tags", &self.tags) .field("contexts", &self.contexts) .field("event_processors", &self.event_processors.len()) + .field("session", &self.session) .finish() } } @@ -71,6 +74,7 @@ impl Default for Scope { tags: Default::default(), contexts: Default::default(), event_processors: Default::default(), + session: Default::default(), } } } @@ -89,8 +93,8 @@ impl Stack { } pub fn push(&mut self) { - let scope = self.layers[self.layers.len() - 1].clone(); - self.layers.push(scope); + let layer = self.layers[self.layers.len() - 1].clone(); + self.layers.push(layer); } pub fn pop(&mut self) { @@ -257,4 +261,10 @@ impl Scope { Some(event) } + + pub(crate) fn update_session_from_event(&self, event: &Event<'static>) { + if let Some(session) = self.session.lock().unwrap().as_mut() { + session.update_from_event(event); + } + } } diff --git a/sentry-core/src/session.rs b/sentry-core/src/session.rs new file mode 100644 index 000000000..849909e63 --- /dev/null +++ b/sentry-core/src/session.rs @@ -0,0 +1,354 @@ +//! Release Health Sessions +//! +//! https://develop.sentry.dev/sdk/sessions/ + +use std::borrow::Cow; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::envelope::EnvelopeItem; +use crate::protocol::{Event, Level, User}; +use crate::scope::StackLayer; +use crate::types::{DateTime, Utc, Uuid}; +use crate::{Client, Envelope}; + +/// Represents the status of a session. +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum SessionStatus { + Ok, + Crashed, + #[allow(dead_code)] + Abnormal, + Exited, +} + +// TODO: make this a true POD type and move it to `sentry-types`, +// and split out the client, user, and dirty flag into a separate guard struct +// that lives on the scope. +#[derive(Clone, Debug)] +pub struct Session { + client: Arc, + session_id: Uuid, + status: SessionStatus, + errors: usize, + user: Option>, + release: Cow<'static, str>, + environment: Option>, + started: Instant, + started_utc: DateTime, + duration: Option, + init: bool, + dirty: bool, +} + +impl Drop for Session { + fn drop(&mut self) { + self.close(); + if let Some(item) = self.create_envelope_item() { + let mut envelope = Envelope::new(); + envelope.add(item); + self.client.capture_envelope(envelope); + } + } +} + +impl Session { + pub fn from_stack(stack: &StackLayer) -> Option { + let client = stack.client.as_ref()?; + let options = client.options(); + Some(Self { + client: client.clone(), + session_id: Uuid::new_v4(), + status: SessionStatus::Ok, + errors: 0, + user: stack.scope.user.clone(), + release: options.release.clone()?, + environment: options.environment.clone(), + started: Instant::now(), + started_utc: Utc::now(), + duration: None, + init: true, + dirty: true, + }) + } + + pub(crate) fn update_from_event(&mut self, event: &Event<'static>) { + if self.status != SessionStatus::Ok { + // a session that has already transitioned to a "terminal" state + // should not receive any more updates + return; + } + let mut has_error = event.level >= Level::Error; + let mut is_crash = false; + for exc in &event.exception.values { + has_error = true; + if let Some(mechanism) = &exc.mechanism { + if let Some(false) = mechanism.handled { + is_crash = true; + break; + } + } + } + + if is_crash { + self.status = SessionStatus::Crashed; + } + if has_error { + self.errors += 1; + self.dirty = true; + } + } + + pub(crate) fn close(&mut self) { + if self.status == SessionStatus::Ok { + self.duration = Some(self.started.elapsed()); + self.status = SessionStatus::Exited; + self.dirty = true; + } + } + + pub(crate) fn create_envelope_item(&mut self) -> Option { + if self.dirty { + let item = EnvelopeItem::Session(self.clone()); + self.init = false; + self.dirty = false; + return Some(item); + } + None + } +} + +#[derive(serde::Serialize)] +struct Attrs { + release: Cow<'static, str>, + #[serde(skip_serializing_if = "Option::is_none")] + environment: Option>, +} + +impl serde::Serialize for Session { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut session = serializer.serialize_struct("Session", 8)?; + session.serialize_field("sid", &self.session_id)?; + let did = self.user.as_ref().and_then(|user| { + user.id + .as_ref() + .or_else(|| user.email.as_ref()) + .or_else(|| user.username.as_ref()) + }); + if let Some(did) = did { + session.serialize_field("did", &did)?; + } else { + session.skip_field("did")?; + } + + session.serialize_field( + "status", + match self.status { + SessionStatus::Ok => "ok", + SessionStatus::Crashed => "crashed", + SessionStatus::Abnormal => "abnormal", + SessionStatus::Exited => "exited", + }, + )?; + session.serialize_field("errors", &self.errors)?; + session.serialize_field("started", &self.started_utc)?; + + if let Some(duration) = self.duration { + session.serialize_field("duration", &duration.as_secs_f64())?; + } else { + session.skip_field("duration")?; + } + if self.init { + session.serialize_field("init", &true)?; + } else { + session.skip_field("init")?; + } + + session.serialize_field( + "attrs", + &Attrs { + release: self.release.clone(), + environment: self.environment.clone(), + }, + )?; + + session.end() + } +} + +#[cfg(all(test, feature = "test"))] +mod tests { + use crate as sentry; + use crate::Envelope; + + fn to_buf(envelope: &Envelope) -> Vec { + let mut vec = Vec::new(); + envelope.to_writer(&mut vec).unwrap(); + vec + } + fn to_str(envelope: &Envelope) -> String { + String::from_utf8(to_buf(envelope)).unwrap() + } + fn capture_envelopes(f: F) -> Vec + where + F: FnOnce(), + { + crate::test::with_captured_envelopes_options( + f, + crate::ClientOptions { + release: Some("some-release".into()), + ..Default::default() + }, + ) + } + + #[test] + fn test_session_startstop() { + let envelopes = capture_envelopes(|| { + sentry::start_session(); + std::thread::sleep(std::time::Duration::from_millis(10)); + }); + assert_eq!(envelopes.len(), 1); + + let body = to_str(&envelopes[0]); + assert!(body.starts_with("{}\n{\"type\":\"session\",")); + assert!(body.contains(r#""attrs":{"release":"some-release"}"#)); + assert!(body.contains(r#""status":"exited","errors":0"#)); + assert!(body.contains(r#""init":true"#)); + } + + #[test] + fn test_session_error() { + let envelopes = capture_envelopes(|| { + sentry::start_session(); + + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + }); + assert_eq!(envelopes.len(), 2); + + let body = to_str(&envelopes[0]); + assert!(body.contains(r#"{"type":"session","#)); + assert!(body.contains(r#""attrs":{"release":"some-release"}"#)); + assert!(body.contains(r#""status":"ok","errors":1"#)); + assert!(body.contains(r#""init":true"#)); + + let body = to_str(&envelopes[1]); + assert!(body.contains(r#"{"type":"session","#)); + assert!(body.contains(r#""status":"exited","errors":1"#)); + assert!(!body.contains(r#""init":true"#)); + } + + #[test] + fn test_session_sampled_errors() { + let mut envelopes = crate::test::with_captured_envelopes_options( + || { + sentry::start_session(); + + for _ in 0..100 { + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + } + }, + crate::ClientOptions { + release: Some("some-release".into()), + sample_rate: 0.5, + ..Default::default() + }, + ); + assert!(envelopes.len() > 25); + assert!(envelopes.len() < 75); + + let body = to_str(&envelopes.pop().unwrap()); + assert!(body.contains(r#"{"type":"session","#)); + assert!(body.contains(r#""status":"exited","errors":100"#)); + } + + /// For _user-mode_ sessions, we want to inherit the session for any _new_ + /// Hub that is spawned from the main thread Hub which already has a session + /// attached + #[test] + fn test_inherit_session_from_top() { + let envelopes = capture_envelopes(|| { + sentry::start_session(); + + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + + // create a new Hub which should have the same session + let hub = std::sync::Arc::new(sentry::Hub::new_from_top(sentry::Hub::current())); + + sentry::Hub::run(hub, || { + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + + sentry::with_scope( + |_| {}, + || { + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + }, + ); + }); + }); + + assert_eq!(envelopes.len(), 4); // 3 errors and one session end + + let body = to_str(&envelopes[3]); + assert!(body.contains(r#"{"type":"session","#)); + assert!(body.contains(r#""status":"exited","errors":3"#)); + assert!(!body.contains(r#""init":true"#)); + } + + /// We want to forward-inherit sessions as the previous test asserted, but + /// not *backwards*. So any new session created in a derived Hub and scope + /// will only get updates from that particular scope. + #[test] + fn test_dont_inherit_session_backwards() { + let envelopes = capture_envelopes(|| { + let hub = std::sync::Arc::new(sentry::Hub::new_from_top(sentry::Hub::current())); + + sentry::Hub::run(hub, || { + sentry::with_scope( + |_| {}, + || { + sentry::start_session(); + + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + }, + ); + + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + }); + + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + }); + + assert_eq!(envelopes.len(), 4); // 3 errors and one session end + + let body = to_str(&envelopes[0]); + assert!(body.contains(r#"{"type":"session","#)); + assert!(body.contains(r#""attrs":{"release":"some-release"}"#)); + assert!(body.contains(r#""status":"ok","errors":1"#)); + assert!(body.contains(r#""init":true"#)); + + let body = to_str(&envelopes[1]); + assert!(body.starts_with("{}\n{\"type\":\"session\",")); + assert!(body.contains(r#""status":"exited","errors":1"#)); + assert!(!body.contains(r#""init":true"#)); + + // the other two events should not have session updates + let body = to_str(&envelopes[2]); + assert!(!body.contains(r#"{"type":"session","#)); + let body = to_str(&envelopes[3]); + assert!(!body.contains(r#"{"type":"session","#)); + } +} diff --git a/sentry/examples/health.rs b/sentry/examples/health.rs new file mode 100644 index 000000000..a88a79f45 --- /dev/null +++ b/sentry/examples/health.rs @@ -0,0 +1,37 @@ +fn main() { + let _sentry = sentry::init(sentry::ClientOptions { + // release health requires a release to be set + release: sentry::release_name!(), + debug: true, + // session tracking is enabled by default, but we want to explicitly + // create the session + auto_session_tracking: false, + ..Default::default() + }); + + let handle = std::thread::spawn(|| { + // this session will be set to crashed + sentry::start_session(); + std::thread::sleep(std::time::Duration::from_secs(3)); + panic!("oh no!"); + }); + + sentry::start_session(); + + sentry::capture_message( + "anything with a level >= Error will increase the error count", + sentry::Level::Error, + ); + + // or any error that has an explicit exception attached + let err = "NaN".parse::().unwrap_err(); + sentry::capture_error(&err); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + // this session will have an error count of 2, but otherwise have + // a clean exit. + sentry::end_session(); + + handle.join().ok(); +} diff --git a/sentry/src/init.rs b/sentry/src/init.rs index 40ef93dce..8925a34b1 100644 --- a/sentry/src/init.rs +++ b/sentry/src/init.rs @@ -2,7 +2,8 @@ use std::sync::Arc; use sentry_core::sentry_debug; -use crate::{defaults::apply_defaults, Client, ClientOptions, Hub}; +use crate::defaults::apply_defaults; +use crate::{Client, ClientOptions, Hub}; /// Helper struct that is returned from `init`. /// @@ -32,6 +33,8 @@ impl Drop for ClientInitGuard { } else { sentry_debug!("dropping client guard (no client to dispose)"); } + // end any session that might be open before closing the client + crate::end_session(); self.0.close(None); } } @@ -88,6 +91,7 @@ where C: Into, { let opts = apply_defaults(opts.into()); + let auto_session_tracking = opts.auto_session_tracking; let client = Arc::new(Client::from(opts)); Hub::with(|hub| hub.bind_client(Some(client.clone()))); @@ -96,5 +100,8 @@ where } else { sentry_debug!("initialized disabled sentry client due to disabled or invalid DSN"); } + if auto_session_tracking { + crate::start_session() + } ClientInitGuard(client) }