diff --git a/sentry-core/Cargo.toml b/sentry-core/Cargo.toml index 49e00a5d..5d7e4ff4 100644 --- a/sentry-core/Cargo.toml +++ b/sentry-core/Cargo.toml @@ -39,7 +39,7 @@ log_ = { package = "log", version = "0.4.8", optional = true, features = ["std"] # Because we re-export all the public API in `sentry`, we actually run all the # doctests using the `sentry` crate. This also takes care of the doctest # limitation documented in https://github.com/rust-lang/rust/issues/45599. -sentry = { path = "../sentry", default-features = false, features = ["test"] } +sentry = { path = "../sentry", default-features = false, features = ["test", "transport"] } thiserror = "1.0.15" anyhow = "1.0.30" tokio = { version = "1.0", features = ["rt", "rt-multi-thread", "macros"] } diff --git a/sentry-core/src/lib.rs b/sentry-core/src/lib.rs index e16a9cb2..93a7cff8 100644 --- a/sentry-core/src/lib.rs +++ b/sentry-core/src/lib.rs @@ -63,6 +63,7 @@ mod futures; mod hub; mod integration; mod intodsn; +mod performance; mod scope; mod transport; @@ -75,6 +76,7 @@ pub use crate::futures::{SentryFuture, SentryFutureExt}; pub use crate::hub::Hub; pub use crate::integration::Integration; pub use crate::intodsn::IntoDsn; +pub use crate::performance::*; pub use crate::scope::{Scope, ScopeGuard}; pub use crate::transport::{Transport, TransportFactory}; diff --git a/sentry-core/src/performance.rs b/sentry-core/src/performance.rs new file mode 100644 index 00000000..b1cfdfda --- /dev/null +++ b/sentry-core/src/performance.rs @@ -0,0 +1,452 @@ +use std::sync::Arc; +use std::sync::Mutex; + +use crate::protocol; +use crate::{Client, Hub}; + +const MAX_SPANS: usize = 1_000; + +// global API: + +/// Start a new Performance Monitoring Transaction. +/// +/// The transaction needs to be explicitly finished via [`Transaction::finish`], +/// otherwise it will be discarded. +/// The transaction itself also represents the root span in the span hierarchy. +/// Child spans can be started with the [`Transaction::start_child`] method. +pub fn start_transaction(ctx: TransactionContext) -> Transaction { + let client = Hub::with_active(|hub| hub.client()); + Transaction::new(client, ctx) +} + +// Hub API: + +impl Hub { + /// Start a new Performance Monitoring Transaction. + /// + /// See the global [`start_transaction`] for more documentation. + pub fn start_transaction(&self, ctx: TransactionContext) -> Transaction { + Transaction::new(self.client(), ctx) + } +} + +// "Context" Types: + +/// The Transaction Context used to start a new Performance Monitoring Transaction. +/// +/// The Transaction Context defines the metadata for a Performance Monitoring +/// Transaction, and also the connection point for distributed tracing. +#[derive(Debug)] +pub struct TransactionContext { + name: String, + op: String, + trace_id: protocol::TraceId, + parent_span_id: Option, + sampled: Option, +} + +impl TransactionContext { + /// Creates a new Transaction Context with the given `name` and `op`. + /// + /// See + /// for an explanation of a Transaction's `name`, and + /// for conventions + /// around an `operation`'s value. + /// + /// See also the [`TransactionContext::continue_from_headers`] function that + /// can be used for distributed tracing. + #[must_use = "this must be used with `start_transaction`"] + pub fn new(name: &str, op: &str) -> Self { + Self::continue_from_headers(name, op, vec![]) + } + + /// Creates a new Transaction Context based on the distributed tracing `headers`. + /// + /// The `headers` in particular need to include the `sentry-trace` header, + /// which is used to associate the transaction with a distributed trace. + #[must_use = "this must be used with `start_transaction`"] + pub fn continue_from_headers<'a, I: IntoIterator>( + name: &str, + op: &str, + headers: I, + ) -> Self { + let mut trace = None; + for (k, v) in headers.into_iter() { + if k == "sentry-trace" { + trace = parse_sentry_trace(v); + } + } + + let (trace_id, parent_span_id, sampled) = match trace { + Some(trace) => (trace.0, Some(trace.1), trace.2), + None => (protocol::TraceId::default(), None, None), + }; + + Self { + name: name.into(), + op: op.into(), + trace_id, + parent_span_id, + sampled, + } + } + + /// Creates a new Transaction Context based on an existing Span. + /// + /// This should be used when an independent computation is spawned on another + /// thread and should be connected to the calling thread via a distributed + /// tracing transaction. + pub fn continue_from_span(name: &str, op: &str, span: Option) -> Self { + let span = match span { + Some(span) => span, + None => return Self::new(name, op), + }; + + let (trace_id, parent_span_id, sampled) = match span { + TransactionOrSpan::Transaction(transaction) => { + let inner = transaction.inner.lock().unwrap(); + ( + inner.context.trace_id, + inner.context.span_id, + Some(inner.sampled), + ) + } + TransactionOrSpan::Span(span) => { + (span.span.trace_id, span.span.span_id, Some(span.sampled)) + } + }; + + Self { + name: name.into(), + op: op.into(), + trace_id, + parent_span_id: Some(parent_span_id), + sampled, + } + } + + /// Set the sampling decision for this Transaction. + /// + /// This can be either an explicit boolean flag, or [`None`], which will fall + /// back to use the configured `traces_sample_rate` option. + pub fn set_sampled(&mut self, sampled: impl Into>) { + self.sampled = sampled.into(); + } +} + +// global API types: + +/// A wrapper that groups a [`Transaction`] and a [`Span`] together. +#[derive(Clone, Debug)] +pub enum TransactionOrSpan { + /// A [`Transaction`]. + Transaction(Transaction), + /// A [`Span`]. + Span(Span), +} + +impl From for TransactionOrSpan { + fn from(transaction: Transaction) -> Self { + Self::Transaction(transaction) + } +} + +impl From for TransactionOrSpan { + fn from(span: Span) -> Self { + Self::Span(span) + } +} + +impl TransactionOrSpan { + /// Returns the headers needed for distributed tracing. + pub fn iter_headers(&self) -> TraceHeadersIter { + match self { + TransactionOrSpan::Transaction(transaction) => transaction.iter_headers(), + TransactionOrSpan::Span(span) => span.iter_headers(), + } + } + + /// Starts a new child Span with the given `op` and `description`. + /// + /// The span must be explicitly finished via [`Span::finish`], as it will + /// otherwise not be sent to Sentry. + #[must_use = "a span must be explicitly closed via `finish()`"] + pub fn start_child(&self, op: &str, description: &str) -> Span { + match self { + TransactionOrSpan::Transaction(transaction) => transaction.start_child(op, description), + TransactionOrSpan::Span(span) => span.start_child(op, description), + } + } + + pub(crate) fn apply_to_event(&self, event: &mut protocol::Event<'_>) { + if event.contexts.contains_key("trace") { + return; + } + + let context = match self { + TransactionOrSpan::Transaction(transaction) => { + transaction.inner.lock().unwrap().context.clone() + } + TransactionOrSpan::Span(span) => protocol::TraceContext { + span_id: span.span.span_id, + trace_id: span.span.trace_id, + ..Default::default() + }, + }; + event.contexts.insert("trace".into(), context.into()); + } + + /// Finishes the Transaction/Span. + /// + /// This records the end timestamp and either sends the inner [`Transaction`] + /// directly to Sentry, or adds the [`Span`] to its transaction. + pub fn finish(self) { + match self { + TransactionOrSpan::Transaction(transaction) => transaction.finish(), + TransactionOrSpan::Span(span) => span.finish(), + } + } +} + +#[derive(Debug)] +struct TransactionInner { + client: Option>, + sampled: bool, + context: protocol::TraceContext, + transaction: Option>, +} + +type TransactionArc = Arc>; + +/// A running Performance Monitoring Transaction. +/// +/// The transaction needs to be explicitly finished via [`Transaction::finish`], +/// otherwise neither the transaction nor any of its child spans will be sent +/// to Sentry. +#[derive(Clone, Debug)] +pub struct Transaction { + inner: TransactionArc, +} + +impl Transaction { + fn new(mut client: Option>, ctx: TransactionContext) -> Self { + let context = protocol::TraceContext { + trace_id: ctx.trace_id, + parent_span_id: ctx.parent_span_id, + op: Some(ctx.op), + ..Default::default() + }; + + let (sampled, mut transaction) = match client.as_ref() { + Some(client) => ( + ctx.sampled + .unwrap_or_else(|| client.sample_traces_should_send()), + Some(protocol::Transaction { + name: Some(ctx.name), + ..Default::default() + }), + ), + None => (ctx.sampled.unwrap_or(false), None), + }; + + // throw away the transaction here, which means there is nothing to send + // on `finish`. + if !sampled { + transaction = None; + client = None; + } + + Self { + inner: Arc::new(Mutex::new(TransactionInner { + client, + sampled, + context, + transaction, + })), + } + } + + /// Returns the headers needed for distributed tracing. + pub fn iter_headers(&self) -> TraceHeadersIter { + let inner = self.inner.lock().unwrap(); + let trace = SentryTrace( + inner.context.trace_id, + inner.context.span_id, + Some(inner.sampled), + ); + TraceHeadersIter { + sentry_trace: Some(trace.to_string()), + } + } + + /// Finishes the Transaction. + /// + /// This records the end timestamp and sends the transaction together with + /// all finished child spans to Sentry. + pub fn finish(self) { + let mut inner = self.inner.lock().unwrap(); + if let Some(mut transaction) = inner.transaction.take() { + if let Some(client) = inner.client.take() { + transaction.finish(); + transaction + .contexts + .insert("trace".into(), inner.context.clone().into()); + + // TODO: apply the scope to the transaction, whatever that means + + let mut envelope = protocol::Envelope::new(); + envelope.add_item(transaction); + + client.send_envelope(envelope) + } + } + } + + /// Starts a new child Span with the given `op` and `description`. + /// + /// The span must be explicitly finished via [`Span::finish`]. + #[must_use = "a span must be explicitly closed via `finish()`"] + pub fn start_child(&self, op: &str, description: &str) -> Span { + let inner = self.inner.lock().unwrap(); + let span = protocol::Span { + trace_id: inner.context.trace_id, + parent_span_id: Some(inner.context.span_id), + op: Some(op.into()), + description: if description.is_empty() { + None + } else { + Some(description.into()) + }, + ..Default::default() + }; + Span { + transaction: Arc::clone(&self.inner), + sampled: inner.sampled, + span, + } + } +} + +/// A running Performance Monitoring Span. +/// +/// The span needs to be explicitly finished via [`Span::finish`], otherwise it +/// will not be sent to Sentry. +#[derive(Clone, Debug)] +pub struct Span { + transaction: TransactionArc, + sampled: bool, + span: protocol::Span, +} + +impl Span { + /// Returns the headers needed for distributed tracing. + pub fn iter_headers(&self) -> TraceHeadersIter { + let trace = SentryTrace(self.span.trace_id, self.span.span_id, Some(self.sampled)); + TraceHeadersIter { + sentry_trace: Some(trace.to_string()), + } + } + + /// Finishes the Span. + /// + /// This will record the end timestamp and add the span to the transaction + /// in which it was started. + pub fn finish(mut self) { + self.span.finish(); + let mut inner = self.transaction.lock().unwrap(); + if let Some(transaction) = inner.transaction.as_mut() { + if transaction.spans.len() <= MAX_SPANS { + transaction.spans.push(self.span); + } + } + } + + /// Starts a new child Span with the given `op` and `description`. + /// + /// The span must be explicitly finished via [`Span::finish`]. + #[must_use = "a span must be explicitly closed via `finish()`"] + pub fn start_child(&self, op: &str, description: &str) -> Span { + let span = protocol::Span { + trace_id: self.span.trace_id, + parent_span_id: Some(self.span.span_id), + op: Some(op.into()), + description: if description.is_empty() { + None + } else { + Some(description.into()) + }, + ..Default::default() + }; + Span { + transaction: self.transaction.clone(), + sampled: self.sampled, + span, + } + } +} + +/// An Iterator over HTTP header names and values needed for distributed tracing. +/// +/// This currently only yields the `sentry-trace` header, but other headers +/// may be added in the future. +pub struct TraceHeadersIter { + sentry_trace: Option, +} + +impl Iterator for TraceHeadersIter { + type Item = (&'static str, String); + + fn next(&mut self) -> Option { + self.sentry_trace.take().map(|st| ("sentry-trace", st)) + } +} + +#[derive(Debug, PartialEq)] +struct SentryTrace(protocol::TraceId, protocol::SpanId, Option); + +fn parse_sentry_trace(header: &str) -> Option { + let header = header.trim(); + let mut parts = header.splitn(3, '-'); + + let trace_id = parts.next()?.parse().ok()?; + let parent_span_id = parts.next()?.parse().ok()?; + let parent_sampled = parts.next().and_then(|sampled| match sampled { + "1" => Some(true), + "0" => Some(false), + _ => None, + }); + + Some(SentryTrace(trace_id, parent_span_id, parent_sampled)) +} + +impl std::fmt::Display for SentryTrace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}-{}", self.0, self.1)?; + if let Some(sampled) = self.2 { + write!(f, "-{}", if sampled { '1' } else { '0' })?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_sentry_trace() { + use std::str::FromStr; + let trace_id = protocol::TraceId::from_str("09e04486820349518ac7b5d2adbf6ba5").unwrap(); + let parent_trace_id = protocol::SpanId::from_str("9cf635fa5b870b3a").unwrap(); + + let trace = parse_sentry_trace("09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0"); + assert_eq!( + trace, + Some(SentryTrace(trace_id, parent_trace_id, Some(false))) + ); + + let trace = SentryTrace(Default::default(), Default::default(), None); + let parsed = parse_sentry_trace(&format!("{}", trace)); + assert_eq!(parsed, Some(trace)); + } +} diff --git a/sentry-core/src/scope/noop.rs b/sentry-core/src/scope/noop.rs index 629ad80e..f621a7e0 100644 --- a/sentry-core/src/scope/noop.rs +++ b/sentry-core/src/scope/noop.rs @@ -108,4 +108,14 @@ impl Scope { let _event = event; minimal_unreachable!(); } + + /// Set the given [`TransactionOrSpan`] as the active span for this scope. + pub fn set_span(&mut self, span: Option) { + minimal_unreachable!(); + } + + /// Returns the currently active span. + pub fn get_span(&self) -> Option { + None + } } diff --git a/sentry-core/src/scope/real.rs b/sentry-core/src/scope/real.rs index f1affec5..c424f1a9 100644 --- a/sentry-core/src/scope/real.rs +++ b/sentry-core/src/scope/real.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, VecDeque}; use std::fmt; use std::sync::{Arc, Mutex, PoisonError, RwLock}; +use crate::performance::TransactionOrSpan; use crate::protocol::{Breadcrumb, Context, Event, Level, User, Value}; use crate::session::Session; use crate::Client; @@ -44,6 +45,7 @@ pub struct Scope { pub(crate) contexts: Arc>, pub(crate) event_processors: Arc>, pub(crate) session: Arc>>, + pub(crate) span: Arc>, } impl fmt::Debug for Scope { @@ -231,6 +233,10 @@ impl Scope { .map(|(k, v)| (k.to_owned(), v.to_owned())), ); + if let Some(span) = self.span.as_ref() { + span.apply_to_event(&mut event); + } + if event.transaction.is_none() { if let Some(txn) = self.transaction.as_deref() { event.transaction = Some(txn.to_owned()); @@ -259,6 +265,16 @@ impl Scope { Some(event) } + /// Set the given [`TransactionOrSpan`] as the active span for this scope. + pub fn set_span(&mut self, span: Option) { + self.span = Arc::new(span); + } + + /// Returns the currently active span. + pub fn get_span(&self) -> Option { + self.span.as_ref().clone() + } + 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/examples/performance-demo.rs b/sentry/examples/performance-demo.rs new file mode 100644 index 00000000..12436c84 --- /dev/null +++ b/sentry/examples/performance-demo.rs @@ -0,0 +1,87 @@ +use std::thread; +use std::time::Duration; + +// cargo run --example performance-demo +fn main() { + let _sentry = sentry::init(sentry::ClientOptions { + release: sentry::release_name!(), + traces_sample_rate: 1.0, + debug: true, + ..Default::default() + }); + + let transaction = + sentry::start_transaction(sentry::TransactionContext::new("transaction", "root span")); + sentry::configure_scope(|scope| scope.set_span(Some(transaction.clone().into()))); + + main_span1(); + + thread::sleep(Duration::from_millis(100)); + + transaction.finish(); + sentry::configure_scope(|scope| scope.set_span(None)); +} + +fn main_span1() { + wrap_in_span("span1", "", || { + thread::sleep(Duration::from_millis(50)); + + let transaction_ctx = sentry::TransactionContext::continue_from_span( + "background transaction", + "root span", + sentry::configure_scope(|scope| scope.get_span()), + ); + thread::spawn(move || { + let transaction = sentry::start_transaction(transaction_ctx); + sentry::configure_scope(|scope| scope.set_span(Some(transaction.clone().into()))); + + thread::sleep(Duration::from_millis(50)); + + thread_span1(); + + transaction.finish(); + sentry::configure_scope(|scope| scope.set_span(None)); + }); + thread::sleep(Duration::from_millis(100)); + + main_span2() + }); +} + +fn thread_span1() { + wrap_in_span("span1", "", || { + thread::sleep(Duration::from_millis(200)); + }) +} + +fn main_span2() { + wrap_in_span("span2", "", || { + sentry::capture_message( + "A message that should have a trace context", + sentry::Level::Info, + ); + thread::sleep(Duration::from_millis(200)); + }) +} + +fn wrap_in_span(op: &str, description: &str, f: F) -> R +where + F: FnOnce() -> R, +{ + let parent = sentry::configure_scope(|scope| scope.get_span()); + let span1: sentry::TransactionOrSpan = match &parent { + Some(parent) => parent.start_child(op, description).into(), + None => { + let ctx = sentry::TransactionContext::new(description, op); + sentry::start_transaction(ctx).into() + } + }; + sentry::configure_scope(|scope| scope.set_span(Some(span1.clone()))); + + let rv = f(); + + span1.finish(); + sentry::configure_scope(|scope| scope.set_span(parent)); + + rv +}