diff --git a/Cargo.lock b/Cargo.lock index 5640f5d7..655d6a95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2447,6 +2447,45 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.12", + "tracing", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b29a9f89f1a954936d5aa92f19b2feec3c8f3971d3e96206640db7f9706ae3" + +[[package]] +name = "opentelemetry_sdk" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "opentelemetry", + "percent-encoding", + "rand 0.9.0", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-stream", +] + [[package]] name = "os_info" version = "3.10.0" @@ -3141,6 +3180,7 @@ dependencies = [ "sentry-core", "sentry-debug-images", "sentry-log", + "sentry-opentelemetry", "sentry-panic", "sentry-slog", "sentry-tower", @@ -3241,6 +3281,17 @@ dependencies = [ "sentry-core", ] +[[package]] +name = "sentry-opentelemetry" +version = "0.37.0" +dependencies = [ + "opentelemetry", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "sentry", + "sentry-core", +] + [[package]] name = "sentry-panic" version = "0.37.0" diff --git a/sentry-core/src/performance.rs b/sentry-core/src/performance.rs index 740cd0ba..43ccd6a4 100644 --- a/sentry-core/src/performance.rs +++ b/sentry-core/src/performance.rs @@ -2,6 +2,9 @@ use std::borrow::Cow; use std::collections::BTreeMap; use std::ops::{Deref, DerefMut}; use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::SystemTime; + +use sentry_types::protocol::v7::SpanId; use crate::{protocol, Hub}; @@ -31,6 +34,23 @@ pub fn start_transaction(ctx: TransactionContext) -> Transaction { } } +/// Start a new Performance Monitoring Transaction with the provided start timestamp. +/// +/// 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_with_timestamp( + ctx: TransactionContext, + timestamp: SystemTime, +) -> Transaction { + let transaction = start_transaction(ctx); + if let Some(tx) = transaction.inner.lock().unwrap().transaction.as_mut() { + tx.start_timestamp = timestamp; + } + transaction +} + // Hub API: impl Hub { @@ -47,6 +67,21 @@ impl Hub { Transaction::new_noop(ctx) } } + + /// Start a new Performance Monitoring Transaction with the provided start timestamp. + /// + /// See the global [`start_transaction_with_timestamp`] for more documentation. + pub fn start_transaction_with_timestamp( + &self, + ctx: TransactionContext, + timestamp: SystemTime, + ) -> Transaction { + let transaction = start_transaction(ctx); + if let Some(tx) = transaction.inner.lock().unwrap().transaction.as_mut() { + tx.start_timestamp = timestamp; + } + transaction + } } // "Context" Types: @@ -110,6 +145,29 @@ impl TransactionContext { } } + /// Creates a new Transaction Context with the given `name`, `op`, `trace_id`, and + /// possibly the given `span_id` and `parent_span_id`. + /// + /// See + /// for an explanation of a Transaction's `name`, and + /// for conventions + /// around an `operation`'s value. + #[must_use = "this must be used with `start_transaction`"] + pub fn new_with_details( + name: &str, + op: &str, + trace_id: protocol::TraceId, + span_id: Option, + parent_span_id: Option, + ) -> Self { + let mut slf = Self::new_with_trace_id(name, op, trace_id); + if let Some(span_id) = span_id { + slf.span_id = span_id; + } + slf.parent_span_id = parent_span_id; + slf + } + /// Creates a new Transaction Context based on the distributed tracing `headers`. /// /// The `headers` in particular need to include either the `sentry-trace` or W3C @@ -121,23 +179,23 @@ impl TransactionContext { op: &str, headers: I, ) -> Self { - let mut trace = None; - for (k, v) in headers.into_iter() { - if k.eq_ignore_ascii_case("sentry-trace") { - trace = parse_sentry_trace(v); - break; - } - - if k.eq_ignore_ascii_case("traceparent") { - trace = parse_w3c_traceparent(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), - }; - + parse_headers(headers) + .map(|sentry_trace| Self::continue_from_sentry_trace(name, op, &sentry_trace)) + .unwrap_or_else(|| Self { + name: name.into(), + op: op.into(), + trace_id: Default::default(), + parent_span_id: None, + span_id: Default::default(), + sampled: None, + custom: None, + }) + } + + /// Creates a new Transaction Context based on the provided distributed tracing data. + pub fn continue_from_sentry_trace(name: &str, op: &str, sentry_trace: &SentryTrace) -> Self { + let (trace_id, parent_span_id, sampled) = + (sentry_trace.0, Some(sentry_trace.1), sentry_trace.2); Self { name: name.into(), op: op.into(), @@ -440,6 +498,28 @@ impl TransactionOrSpan { } } + /// Starts a new child Span with the given `op`, `description` and `id`. + /// + /// 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_with_details( + &self, + op: &str, + description: &str, + id: SpanId, + timestamp: SystemTime, + ) -> Span { + match self { + TransactionOrSpan::Transaction(transaction) => { + transaction.start_child_with_details(op, description, id, timestamp) + } + TransactionOrSpan::Span(span) => { + span.start_child_with_details(op, description, id, timestamp) + } + } + } + #[cfg(feature = "client")] pub(crate) fn apply_to_event(&self, event: &mut protocol::Event<'_>) { if event.contexts.contains_key("trace") { @@ -462,10 +542,23 @@ impl TransactionOrSpan { event.contexts.insert("trace".into(), context.into()); } - /// Finishes the Transaction/Span. + /// Finishes the Transaction/Span with the provided end timestamp. /// /// This records the end timestamp and either sends the inner [`Transaction`] /// directly to Sentry, or adds the [`Span`] to its transaction. + pub fn finish_with_timestamp(self, timestamp: SystemTime) { + match self { + TransactionOrSpan::Transaction(transaction) => { + transaction.finish_with_timestamp(timestamp) + } + TransactionOrSpan::Span(span) => span.finish_with_timestamp(timestamp), + } + } + + /// Finishes the Transaction/Span. + /// + /// This records the current timestamp as 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(), @@ -693,11 +786,11 @@ impl Transaction { self.inner.lock().unwrap().sampled } - /// Finishes the Transaction. + /// Finishes the Transaction with the provided end timestamp. /// /// This records the end timestamp and sends the transaction together with /// all finished child spans to Sentry. - pub fn finish(self) { + pub fn finish_with_timestamp(self, _timestamp: SystemTime) { with_client_impl! {{ let mut inner = self.inner.lock().unwrap(); @@ -708,7 +801,7 @@ impl Transaction { if let Some(mut transaction) = inner.transaction.take() { if let Some(client) = inner.client.take() { - transaction.finish(); + transaction.finish_with_timestamp(_timestamp); transaction .contexts .insert("trace".into(), inner.context.clone().into()); @@ -731,6 +824,14 @@ impl Transaction { }} } + /// Finishes the Transaction. + /// + /// This records the current timestamp as the end timestamp and sends the transaction together with + /// all finished child spans to Sentry. + pub fn finish(self) { + self.finish_with_timestamp(SystemTime::now()); + } + /// Starts a new child Span with the given `op` and `description`. /// /// The span must be explicitly finished via [`Span::finish`]. @@ -754,6 +855,38 @@ impl Transaction { span: Arc::new(Mutex::new(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_with_details( + &self, + op: &str, + description: &str, + id: SpanId, + timestamp: SystemTime, + ) -> 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()) + }, + span_id: id, + start_timestamp: timestamp, + ..Default::default() + }; + Span { + transaction: Arc::clone(&self.inner), + sampled: inner.sampled, + span: Arc::new(Mutex::new(span)), + } + } } /// A smart pointer to a span's [`data` field](protocol::Span::data). @@ -901,18 +1034,18 @@ impl Span { self.sampled } - /// Finishes the Span. + /// Finishes the Span with the provided end timestamp. /// /// This will record the end timestamp and add the span to the transaction /// in which it was started. - pub fn finish(self) { + pub fn finish_with_timestamp(self, _timestamp: SystemTime) { with_client_impl! {{ let mut span = self.span.lock().unwrap(); if span.timestamp.is_some() { // the span was already finished return; } - span.finish(); + span.finish_with_timestamp(_timestamp); let mut inner = self.transaction.lock().unwrap(); if let Some(transaction) = inner.transaction.as_mut() { if transaction.spans.len() <= MAX_SPANS { @@ -922,6 +1055,14 @@ impl Span { }} } + /// Finishes the Span. + /// + /// This will record the current timestamp as the end timestamp and add the span to the + /// transaction in which it was started. + pub fn finish(self) { + self.finish_with_timestamp(SystemTime::now()); + } + /// Starts a new child Span with the given `op` and `description`. /// /// The span must be explicitly finished via [`Span::finish`]. @@ -945,6 +1086,38 @@ impl Span { span: Arc::new(Mutex::new(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()`"] + fn start_child_with_details( + &self, + op: &str, + description: &str, + id: SpanId, + timestamp: SystemTime, + ) -> Span { + let span = self.span.lock().unwrap(); + let span = protocol::Span { + trace_id: span.trace_id, + parent_span_id: Some(span.span_id), + op: Some(op.into()), + description: if description.is_empty() { + None + } else { + Some(description.into()) + }, + span_id: id, + start_timestamp: timestamp, + ..Default::default() + }; + Span { + transaction: self.transaction.clone(), + sampled: self.sampled, + span: Arc::new(Mutex::new(span)), + } + } } /// An Iterator over HTTP header names and values needed for distributed tracing. @@ -963,8 +1136,21 @@ impl Iterator for TraceHeadersIter { } } +/// A container for distributed tracing metadata that can be extracted from e.g. HTTP headers such as +/// `sentry-trace` and `traceparent`. #[derive(Debug, PartialEq)] -struct SentryTrace(protocol::TraceId, protocol::SpanId, Option); +pub struct SentryTrace(protocol::TraceId, protocol::SpanId, Option); + +impl SentryTrace { + /// Creates a new [`SentryTrace`] from the provided parameters + pub fn new( + trace_id: protocol::TraceId, + span_id: protocol::SpanId, + sampled: Option, + ) -> Self { + SentryTrace(trace_id, span_id, sampled) + } +} fn parse_sentry_trace(header: &str) -> Option { let header = header.trim(); @@ -998,6 +1184,25 @@ fn parse_w3c_traceparent(header: &str) -> Option { Some(SentryTrace(trace_id, parent_span_id, parent_sampled)) } +/// Extracts distributed tracing metadata from headers (or, generally, key-value pairs), +/// considering the values for both `sentry-trace` (prioritized) and `traceparent`. +pub fn parse_headers<'a, I: IntoIterator>( + headers: I, +) -> Option { + let mut trace = None; + for (k, v) in headers.into_iter() { + if k.eq_ignore_ascii_case("sentry-trace") { + trace = parse_sentry_trace(v); + break; + } + + if k.eq_ignore_ascii_case("traceparent") { + trace = parse_w3c_traceparent(v); + } + } + trace +} + impl std::fmt::Display for SentryTrace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}-{}", self.0, self.1)?; diff --git a/sentry-opentelemetry/Cargo.toml b/sentry-opentelemetry/Cargo.toml new file mode 100644 index 00000000..9f889dc2 --- /dev/null +++ b/sentry-opentelemetry/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "sentry-opentelemetry" +version = "0.37.0" +authors = ["Sentry "] +license = "MIT" +readme = "README.md" +repository = "https://github.com/getsentry/sentry-rust" +homepage = "https://sentry.io/welcome/" +description = """ +Sentry integration for OpenTelemetry. +""" +edition = "2021" +rust-version = "1.81" + +[package.metadata.docs.rs] +all-features = true + +[dependencies] +sentry-core = { version = "0.37.0", path = "../sentry-core", features = [ + "client", + "release-health" +] } +opentelemetry = { version = "0.29.0", default-features = false } +opentelemetry_sdk = { version = "0.29.0", default-features = false, features = [ + "trace", +] } +opentelemetry-semantic-conventions = "0.29.0" + +[dev-dependencies] +sentry = { version = "0.37.0", path = "../sentry", features = ["test"] } +sentry-core = { version = "0.37.0", path = "../sentry-core", features = [ + "test", +] } +opentelemetry_sdk = { version = "0.29.0", default-features = false, features = [ + "trace", + "testing", +] } diff --git a/sentry-opentelemetry/src/converters.rs b/sentry-opentelemetry/src/converters.rs new file mode 100644 index 00000000..0f664514 --- /dev/null +++ b/sentry-opentelemetry/src/converters.rs @@ -0,0 +1,56 @@ +use sentry_core::protocol::{value::Number, SpanId, SpanStatus, TraceId, Value}; + +pub(crate) fn convert_span_id(span_id: &opentelemetry::SpanId) -> SpanId { + span_id.to_bytes().into() +} + +pub(crate) fn convert_trace_id(trace_id: &opentelemetry::TraceId) -> TraceId { + trace_id.to_bytes().into() +} + +pub(crate) fn convert_span_status(status: &opentelemetry::trace::Status) -> SpanStatus { + match status { + opentelemetry::trace::Status::Unset | opentelemetry::trace::Status::Ok => SpanStatus::Ok, + opentelemetry::trace::Status::Error { description } => { + description.parse().unwrap_or(SpanStatus::UnknownError) + } + } +} + +pub(crate) fn convert_span_kind(kind: opentelemetry::trace::SpanKind) -> Value { + format!("{:?}", kind).to_lowercase().into() +} + +pub(crate) fn convert_value(value: opentelemetry::Value) -> Value { + match value { + opentelemetry::Value::Bool(x) => Value::Bool(x), + opentelemetry::Value::I64(x) => Value::Number(x.into()), + opentelemetry::Value::F64(x) => Number::from_f64(x) + .map(Value::Number) + .unwrap_or(Value::Null), + opentelemetry::Value::String(x) => Value::String(x.into()), + opentelemetry::Value::Array(arr) => match arr { + opentelemetry::Array::Bool(items) => { + Value::Array(items.iter().map(|x| Value::Bool(*x)).collect()) + } + opentelemetry::Array::I64(items) => Value::Array( + items + .iter() + .map(|x| Value::Number(Number::from(*x))) + .collect(), + ), + opentelemetry::Array::F64(items) => Value::Array( + items + .iter() + .filter_map(|x| Number::from_f64(*x)) + .map(Value::Number) + .collect(), + ), + opentelemetry::Array::String(items) => { + Value::Array(items.iter().map(|x| x.as_str().into()).collect()) + } + _ => Value::Null, // non-exhaustive + }, + _ => Value::Null, // non-exhaustive + } +} diff --git a/sentry-opentelemetry/src/lib.rs b/sentry-opentelemetry/src/lib.rs new file mode 100644 index 00000000..d9de713b --- /dev/null +++ b/sentry-opentelemetry/src/lib.rs @@ -0,0 +1,51 @@ +//! OpenTelemetry support for Sentry. +//! +//! This integration allows you to capture spans from your existing OpenTelemetry setup and send +//! them to Sentry, with support for distributed tracing. +//! It's assumed that only the [OpenTelemetry tracing +//! API](https://opentelemetry.io/docs/specs/otel/trace/api/) is used to start/end/modify Spans. +//! Mixing it with the Sentry tracing API (e.g. `sentry_core::start_transaction(ctx)`) will not +//! work, as the spans created with the two methods will not be nested properly. +//! Capturing events (either manually with e.g. `sentry::capture_event`, or automatically with e.g. the +//! `sentry-panic` integration) will send them to Sentry with the correct trace and span +//! association. +//! +//! # Configuration +//! +//! Initialize Sentry, then register the [`SentryPropagator`] and the [`SentrySpanProcessor`]: +//! +//! ``` +//! use opentelemetry::{global}; +//! use opentelemetry_sdk::{ +//! propagation::TraceContextPropagator, trace::SdkTracerProvider, +//! }; +//! +//! // Initialize the Sentry SDK +//! let _guard = sentry::init(sentry::ClientOptions { +//! // Enable capturing of traces; set this a to lower value in production. +//! // For more sophisticated behavior use a custom +//! // [`sentry::ClientOptions::traces_sampler`] instead. +//! // That's the equivalent of a tail sampling processor in OpenTelemetry. +//! // These options will only affect sampling of the spans that are sent to Sentry, +//! // not of the underlying OpenTelemetry spans. +//! traces_sample_rate: 1.0, +//! ..sentry::ClientOptions::default() +//! }); +//! +//! // Register the Sentry propagator to enable distributed tracing +//! global::set_text_map_propagator(sentry_opentelemetry::SentryPropagator::new()); +//! +//! let tracer_provider = SdkTracerProvider::builder() +//! // Register the Sentry span processor to send OpenTelemetry spans to Sentry +//! .with_span_processor(sentry_opentelemetry::SentrySpanProcessor::new()) +//! .build(); +//! +//! global::set_tracer_provider(tracer_provider); +//! ``` + +mod converters; +mod processor; +mod propagator; + +pub use processor::*; +pub use propagator::*; diff --git a/sentry-opentelemetry/src/processor.rs b/sentry-opentelemetry/src/processor.rs new file mode 100644 index 00000000..d1a27338 --- /dev/null +++ b/sentry-opentelemetry/src/processor.rs @@ -0,0 +1,187 @@ +//! An OpenTelemetry [SpanProcessor](https://opentelemetry.io/docs/specs/otel/trace/sdk/#span-processor) for Sentry. +//! +//! [`SentrySpanProcessor`] allows the Sentry Rust SDK to integrate with OpenTelemetry. +//! It transforms OpenTelemetry spans into Sentry transactions/spans and sends them to Sentry. +//! +//! # Configuration +//! +//! Unless you have no need for distributed tracing, this should be used together with [`crate::propagator::SentryPropagator`]. An example of +//! setting up both is provided in the [crate-level documentation](../). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use opentelemetry::global::ObjectSafeSpan; +use opentelemetry::trace::{get_active_span, SpanId}; +use opentelemetry::Context; +use opentelemetry_sdk::error::OTelSdkResult; +use opentelemetry_sdk::trace::{Span, SpanData, SpanProcessor}; + +use opentelemetry_sdk::Resource; +use sentry_core::SentryTrace; +use sentry_core::{TransactionContext, TransactionOrSpan}; + +use crate::converters::{ + convert_span_id, convert_span_kind, convert_span_status, convert_trace_id, convert_value, +}; + +/// A mapping from Sentry span IDs to Sentry spans/transactions. +/// Sentry spans are created with the same SpanId as the corresponding OTEL span, so this is used +/// to track OTEL spans across start/end calls. +type SpanMap = Arc>>; + +/// An OpenTelemetry SpanProcessor that converts OTEL spans to Sentry spans/transactions and sends +/// them to Sentry. +#[derive(Debug, Clone)] +pub struct SentrySpanProcessor { + span_map: SpanMap, +} + +impl SentrySpanProcessor { + /// Creates a new `SentrySpanProcessor`. + pub fn new() -> Self { + sentry_core::configure_scope(|scope| { + // Associate Sentry events with the correct span and trace. + // This works as long as all Sentry spans/transactions are managed exclusively through OTEL APIs. + scope.add_event_processor(|mut event| { + get_active_span(|otel_span| { + let (span_id, trace_id) = ( + convert_span_id(&otel_span.span_context().span_id()), + convert_trace_id(&otel_span.span_context().trace_id()), + ); + + if let Some(sentry_core::protocol::Context::Trace(trace_context)) = + event.contexts.get_mut("trace") + { + trace_context.trace_id = trace_id; + trace_context.span_id = span_id; + } else { + event.contexts.insert( + "trace".into(), + sentry_core::protocol::TraceContext { + span_id, + trace_id, + ..Default::default() + } + .into(), + ); + } + }); + Some(event) + }); + }); + Self { + span_map: Default::default(), + } + } +} + +impl Default for SentrySpanProcessor { + /// Creates a default `SentrySpanProcessor`. + fn default() -> Self { + Self::new() + } +} + +impl SpanProcessor for SentrySpanProcessor { + fn on_start(&self, span: &mut Span, ctx: &Context) { + let span_id = span.span_context().span_id(); + let trace_id = span.span_context().trace_id(); + + let mut span_map = self.span_map.lock().unwrap(); + + let mut span_description = String::new(); + let mut span_op = String::new(); + let mut span_start_timestamp = SystemTime::now(); + let mut parent_sentry_span = None; + if let Some(data) = span.exported_data() { + span_description = data.name.to_string(); + span_op = span_description.clone(); // TODO: infer this from OTEL span attributes + span_start_timestamp = data.start_time; + if data.parent_span_id != SpanId::INVALID { + parent_sentry_span = span_map.get(&convert_span_id(&data.parent_span_id)); + }; + } + let span_description = span_description.as_str(); + let span_op = span_op.as_str(); + + let sentry_span = { + if let Some(parent_sentry_span) = parent_sentry_span { + // continue local trace + TransactionOrSpan::Span(parent_sentry_span.start_child_with_details( + span_op, + span_description, + convert_span_id(&span_id), + span_start_timestamp, + )) + } else { + let sentry_ctx = { + if let Some(sentry_trace) = ctx.get::() { + // continue remote trace + TransactionContext::continue_from_sentry_trace( + span_description, + span_op, + sentry_trace, + ) + } else { + // start a new trace + TransactionContext::new_with_details( + span_description, + span_op, + convert_trace_id(&trace_id), + Some(convert_span_id(&span_id)), + None, + ) + } + }; + let tx = + sentry_core::start_transaction_with_timestamp(sentry_ctx, span_start_timestamp); + TransactionOrSpan::Transaction(tx) + } + }; + span_map.insert(convert_span_id(&span_id), sentry_span); + } + + fn on_end(&self, data: SpanData) { + let span_id = data.span_context.span_id(); + + let mut span_map = self.span_map.lock().unwrap(); + + let Some(sentry_span) = span_map.remove(&convert_span_id(&span_id)) else { + return; + }; + + // TODO: read OTEL span events and convert them to Sentry breadcrumbs/events + + sentry_span.set_data("otel.kind", convert_span_kind(data.span_kind)); + for attribute in data.attributes { + sentry_span.set_data(attribute.key.as_str(), convert_value(attribute.value)); + } + // TODO: read OTEL semantic convention span attributes and map them to the appropriate + // Sentry span attributes/context values + sentry_span.set_status(convert_span_status(&data.status)); + sentry_span.finish_with_timestamp(data.end_time); + } + + fn force_flush(&self) -> OTelSdkResult { + Ok(()) + } + + fn shutdown(&self) -> OTelSdkResult { + Ok(()) + } + + fn set_resource(&mut self, resource: &Resource) { + sentry_core::configure_scope(|scope| { + let otel_context = sentry_core::protocol::OtelContext { + resource: resource + .iter() + .map(|(key, value)| (key.as_str().into(), convert_value(value.clone()))) + .collect(), + ..Default::default() + }; + scope.set_context("otel", sentry_core::protocol::Context::from(otel_context)); + }); + } +} diff --git a/sentry-opentelemetry/src/propagator.rs b/sentry-opentelemetry/src/propagator.rs new file mode 100644 index 00000000..396f95f7 --- /dev/null +++ b/sentry-opentelemetry/src/propagator.rs @@ -0,0 +1,82 @@ +//! An OpenTelemetry [Propagator](https://opentelemetry.io/docs/specs/otel/context/api-propagators/) for Sentry. +//! +//! [`SentryPropagator`] serves two purposes: +//! - extracts incoming Sentry tracing metadata from incoming traces, and stores it in +//! [`opentelemetry::baggage::Baggage`]. This information can then be used by +//! [`crate::processor::SentrySpanProcessor`] to achieve distributed tracing. +//! - injects Sentry tracing metadata in outgoing traces. This information can be used by +//! downstream Sentry SDKs to achieve distributed tracing. +//! +//! # Configuration +//! +//! This should be used together with [`crate::processor::SentrySpanProcessor`]. An example of +//! setting up both is provided in the [crate-level documentation](../). + +use std::sync::LazyLock; + +use opentelemetry::{ + propagation::{text_map_propagator::FieldIter, Extractor, Injector, TextMapPropagator}, + trace::TraceContextExt, + Context, SpanId, TraceId, +}; +use sentry_core::parse_headers; +use sentry_core::SentryTrace; + +use crate::converters::{convert_span_id, convert_trace_id}; + +const SENTRY_TRACE_KEY: &str = "sentry-trace"; + +// list of headers used in the inject operation +static SENTRY_PROPAGATOR_FIELDS: LazyLock<[String; 1]> = + LazyLock::new(|| [SENTRY_TRACE_KEY.to_owned()]); + +/// An OpenTelemetry Propagator that injects and extracts Sentry's tracing headers to achieve +/// distributed tracing. +#[derive(Debug, Copy, Clone)] +pub struct SentryPropagator {} + +impl SentryPropagator { + /// Creates a new `SentryPropagator` + pub fn new() -> Self { + Self {} + } +} + +impl Default for SentryPropagator { + /// Creates a default `SentryPropagator`. + fn default() -> Self { + Self::new() + } +} + +impl TextMapPropagator for SentryPropagator { + fn inject_context(&self, ctx: &Context, injector: &mut dyn Injector) { + let trace_id = ctx.span().span_context().trace_id(); + let span_id = ctx.span().span_context().span_id(); + let sampled = ctx.span().span_context().is_sampled(); + if trace_id == TraceId::INVALID || span_id == SpanId::INVALID { + return; + } + let sentry_trace = SentryTrace::new( + convert_trace_id(&trace_id), + convert_span_id(&span_id), + Some(sampled), + ); + injector.set(SENTRY_TRACE_KEY, sentry_trace.to_string()); + } + + fn extract_with_context(&self, ctx: &Context, extractor: &dyn Extractor) -> Context { + let keys = extractor.keys(); + let pairs = keys + .iter() + .filter_map(|&key| extractor.get(key).map(|value| (key, value))); + if let Some(sentry_trace) = parse_headers(pairs) { + return ctx.with_value(sentry_trace); + } + ctx.clone() + } + + fn fields(&self) -> FieldIter<'_> { + FieldIter::new(&*SENTRY_PROPAGATOR_FIELDS) + } +} diff --git a/sentry-opentelemetry/tests/associates_event_with_span.rs b/sentry-opentelemetry/tests/associates_event_with_span.rs new file mode 100644 index 00000000..2e7b411f --- /dev/null +++ b/sentry-opentelemetry/tests/associates_event_with_span.rs @@ -0,0 +1,83 @@ +mod shared; + +use opentelemetry::{ + global, + trace::{Tracer, TracerProvider}, +}; +use opentelemetry_sdk::trace::SdkTracerProvider; +use sentry_core::protocol::Transaction; +use sentry_opentelemetry::{SentryPropagator, SentrySpanProcessor}; + +#[test] +fn test_associates_event_with_span() { + let transport = shared::init_sentry(1.0); // Sample all spans + + // Set up OpenTelemetry + global::set_text_map_propagator(SentryPropagator::new()); + let tracer_provider = SdkTracerProvider::builder() + .with_span_processor(SentrySpanProcessor::new()) + .build(); + let tracer = tracer_provider.tracer("test".to_string()); + + // Create root span and execute test within it + tracer.in_span("root_span", |_| { + // Create child span and execute within it + tracer.in_span("child_span", |_| { + // Capture an event while the child span is active + sentry::capture_message("Test message", sentry::Level::Error); + }); + }); + + // Capture the event and spans + let envelopes = transport.fetch_and_clear_envelopes(); + + // Find event and transaction + let mut transaction: Option = None; + let mut span_id: Option = None; + + let mut trace_id_from_event: Option = None; + let mut span_id_from_event: Option = None; + + for envelope in &envelopes { + for item in envelope.items() { + match item { + sentry::protocol::EnvelopeItem::Event(event) => { + trace_id_from_event = event.contexts.get("trace").and_then(|c| match c { + sentry::protocol::Context::Trace(trace) => Some(trace.trace_id.to_string()), + _ => unreachable!(), + }); + span_id_from_event = event.contexts.get("trace").and_then(|c| match c { + sentry::protocol::Context::Trace(trace) => Some(trace.span_id.to_string()), + _ => unreachable!(), + }); + } + sentry::protocol::EnvelopeItem::Transaction(tx) => { + transaction = Some(tx.clone()); + tx.spans.iter().for_each(|span| { + span_id = Some(span.span_id.to_string()); + }); + } + _ => (), + } + } + } + + let transaction = transaction.expect("Should have a transaction"); + let span_id = span_id.expect("Transaction should have a child span"); + + let trace_id_from_event = trace_id_from_event.expect("Event should have a trace ID"); + let span_id_from_event = span_id_from_event.expect("Event should have a span ID"); + + // Verify that the transaction ID and span ID in the event match with the transaction and span + assert_eq!( + { + let context = transaction.contexts.get("trace").unwrap().clone(); + match context { + sentry::protocol::Context::Trace(context) => context.trace_id.to_string(), + _ => unreachable!(), + } + }, + trace_id_from_event + ); + assert_eq!(span_id, span_id_from_event); +} diff --git a/sentry-opentelemetry/tests/captures_transaction.rs b/sentry-opentelemetry/tests/captures_transaction.rs new file mode 100644 index 00000000..c8ca55d0 --- /dev/null +++ b/sentry-opentelemetry/tests/captures_transaction.rs @@ -0,0 +1,42 @@ +mod shared; + +use opentelemetry::{ + global, + trace::{Tracer, TracerProvider}, +}; +use opentelemetry_sdk::trace::SdkTracerProvider; +use sentry_opentelemetry::{SentryPropagator, SentrySpanProcessor}; + +#[test] +fn test_captures_transaction() { + // Initialize Sentry + let transport = shared::init_sentry(1.0); // Sample all spans + + // Set up OpenTelemetry + global::set_text_map_propagator(SentryPropagator::new()); + let tracer_provider = SdkTracerProvider::builder() + .with_span_processor(SentrySpanProcessor::new()) + .build(); + let tracer = tracer_provider.tracer("test".to_string()); + + // Create and end a root span + tracer.in_span("root_span", |_| { + // Span body is empty, just creating the span + }); + + // Check that data was sent to Sentry + let envelopes = transport.fetch_and_clear_envelopes(); + assert_eq!( + envelopes.len(), + 1, + "Expected one transaction to be sent to Sentry" + ); + + let transaction = envelopes[0].items().next().unwrap(); + match transaction { + sentry::protocol::EnvelopeItem::Transaction(tx) => { + assert_eq!(tx.name.as_deref(), Some("root_span")); + } + unexpected => panic!("Expected transaction, but got {:#?}", unexpected), + } +} diff --git a/sentry-opentelemetry/tests/captures_transaction_with_nested_spans.rs b/sentry-opentelemetry/tests/captures_transaction_with_nested_spans.rs new file mode 100644 index 00000000..4b47efa6 --- /dev/null +++ b/sentry-opentelemetry/tests/captures_transaction_with_nested_spans.rs @@ -0,0 +1,72 @@ +mod shared; + +use opentelemetry::{ + global, + trace::{Status, TraceContextExt, Tracer, TracerProvider}, + KeyValue, +}; +use opentelemetry_sdk::trace::SdkTracerProvider; +use sentry_opentelemetry::{SentryPropagator, SentrySpanProcessor}; + +#[test] +fn test_captures_transaction_with_nested_spans() { + // Initialize Sentry + let transport = shared::init_sentry(1.0); // Sample all spans + + // Set up OpenTelemetry + global::set_text_map_propagator(SentryPropagator::new()); + let tracer_provider = SdkTracerProvider::builder() + .with_span_processor(SentrySpanProcessor::new()) + .build(); + let tracer = tracer_provider.tracer("test".to_string()); + + // Create nested spans using in_span + tracer.in_span("root_span", |_| { + tracer.in_span("child_span", |_| { + tracer.in_span("grandchild_span", |cx| { + // Add some attributes to the grandchild + cx.span() + .set_attribute(KeyValue::new("test.key", "test.value")); + cx.span().set_status(Status::Ok); + }); + }); + }); + + // Check that data was sent to Sentry + let envelopes = transport.fetch_and_clear_envelopes(); + assert_eq!( + envelopes.len(), + 1, + "Expected one transaction to be sent to Sentry" + ); + + let transaction = envelopes[0].items().next().unwrap(); + match transaction { + sentry::protocol::EnvelopeItem::Transaction(tx) => { + assert_eq!(tx.name.as_deref(), Some("root_span")); + assert_eq!(tx.spans.len(), 2); // Should have 2 child spans + + let child_span = tx + .spans + .iter() + .find(|s| s.description.as_deref() == Some("child_span")) + .expect("Child span should exist"); + let grandchild_span = tx + .spans + .iter() + .find(|s| s.description.as_deref() == Some("grandchild_span")) + .expect("Grandchild span should exist"); + + // Get transaction span ID from trace context + let tx_span_id = match &tx.contexts.get("trace") { + Some(sentry::protocol::Context::Trace(trace)) => trace.span_id, + _ => panic!("Missing trace context in transaction"), + }; + + // Check parent-child relationship + assert_eq!(grandchild_span.parent_span_id, Some(child_span.span_id)); + assert_eq!(child_span.parent_span_id, Some(tx_span_id)); + } + unexpected => panic!("Expected transaction, but got {:#?}", unexpected), + } +} diff --git a/sentry-opentelemetry/tests/creates_distributed_trace.rs b/sentry-opentelemetry/tests/creates_distributed_trace.rs new file mode 100644 index 00000000..e57fb5b4 --- /dev/null +++ b/sentry-opentelemetry/tests/creates_distributed_trace.rs @@ -0,0 +1,121 @@ +mod shared; + +use opentelemetry::{ + global, + propagation::TextMapPropagator, + trace::{TraceContextExt, Tracer, TracerProvider}, + Context, +}; +use opentelemetry_sdk::trace::SdkTracerProvider; +use sentry_opentelemetry::{SentryPropagator, SentrySpanProcessor}; +use std::collections::HashMap; + +#[test] +fn test_creates_distributed_trace() { + let transport = shared::init_sentry(1.0); // Sample all spans + + // Set up OpenTelemetry + global::set_text_map_propagator(SentryPropagator::new()); + let tracer_provider = SdkTracerProvider::builder() + .with_span_processor(SentrySpanProcessor::new()) + .build(); + let tracer = tracer_provider.tracer("test".to_string()); + + // We need to store the context to pass between services, so we'll use a mutable variable + let mut headers = HashMap::new(); + let propagator = SentryPropagator::new(); + + // Create a "first service" span and store context in headers + tracer.in_span("first_service", |first_service_ctx| { + // Simulate passing the context to another service by extracting and injecting e.g. HTTP headers + propagator.inject_context(&first_service_ctx, &mut TestInjector(&mut headers)); + }); + + // Now simulate the second service receiving the headers and continuing the trace + let second_service_ctx = + propagator.extract_with_context(&Context::current(), &TestExtractor(&headers)); + + // Create a second service span that continues the trace + // We need to use start_with_context here to connect with the previous context + let second_service_span = tracer.start_with_context("second_service", &second_service_ctx); + let second_service_ctx = second_service_ctx.with_span(second_service_span); + + // End the second service span + second_service_ctx.span().end(); + + // Get both transactions at once + let envelopes = transport.fetch_and_clear_envelopes(); + assert_eq!( + envelopes.len(), + 2, + "Expected two transactions to be sent to Sentry" + ); + + // Find transactions for first and second services + let mut first_tx = None; + let mut second_tx = None; + + for envelope in &envelopes { + let tx = match envelope.items().next().unwrap() { + sentry::protocol::EnvelopeItem::Transaction(tx) => tx.clone(), + unexpected => panic!("Expected transaction, but got {:#?}", unexpected), + }; + + // Determine which service this transaction belongs to based on name + match tx.name.as_deref() { + Some("first_service") => first_tx = Some(tx), + Some("second_service") => second_tx = Some(tx), + name => panic!("Unexpected transaction name: {:?}", name), + } + } + + let first_tx = first_tx.expect("Missing first service transaction"); + let second_tx = second_tx.expect("Missing second service transaction"); + + // Get first service trace ID and span ID + let (first_trace_id, first_span_id) = match &first_tx.contexts.get("trace") { + Some(sentry::protocol::Context::Trace(trace)) => (trace.trace_id, trace.span_id), + _ => panic!("Missing trace context in first transaction"), + }; + + // Get second service trace ID and span ID + let (second_trace_id, second_span_id, second_parent_span_id) = + match &second_tx.contexts.get("trace") { + Some(sentry::protocol::Context::Trace(trace)) => { + (trace.trace_id, trace.span_id, trace.parent_span_id) + } + _ => panic!("Missing trace context in second transaction"), + }; + + // Verify the distributed trace - same trace ID, different span IDs + assert_eq!(first_trace_id, second_trace_id, "Trace IDs should match"); + assert_ne!( + first_span_id, second_span_id, + "Span IDs should be different" + ); + assert_eq!( + second_parent_span_id, + Some(first_span_id), + "Second service's parent span ID should match first service's span ID" + ); +} + +struct TestInjector<'a>(&'a mut HashMap); + +impl opentelemetry::propagation::Injector for TestInjector<'_> { + fn set(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } +} + +struct TestExtractor<'a>(&'a HashMap); + +impl opentelemetry::propagation::Extractor for TestExtractor<'_> { + fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).map(|s| s.as_str()) + } + + fn keys(&self) -> Vec<&str> { + self.0.keys().map(|k| k.as_str()).collect() + } +} diff --git a/sentry-opentelemetry/tests/shared.rs b/sentry-opentelemetry/tests/shared.rs new file mode 100644 index 00000000..5189da05 --- /dev/null +++ b/sentry-opentelemetry/tests/shared.rs @@ -0,0 +1,21 @@ +use sentry::{ClientOptions, Hub}; +use sentry_core::test::TestTransport; + +use std::sync::Arc; + +pub fn init_sentry(traces_sample_rate: f32) -> Arc { + let transport = TestTransport::new(); + let options = ClientOptions { + dsn: Some( + "https://test@sentry-opentelemetry.com/test" + .parse() + .unwrap(), + ), + transport: Some(Arc::new(transport.clone())), + sample_rate: 1.0, + traces_sample_rate, + ..ClientOptions::default() + }; + Hub::current().bind_client(Some(Arc::new(options.into()))); + transport +} diff --git a/sentry-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs index badeaa0b..bb2008f6 100644 --- a/sentry-types/src/protocol/v7.rs +++ b/sentry-types/src/protocol/v7.rs @@ -1096,8 +1096,10 @@ pub enum Context { Browser(Box), /// Tracing data. Trace(Box), - /// GPU data + /// GPU data. Gpu(Box), + /// OpenTelemetry data. + Otel(Box), /// Generic other context data. #[serde(rename = "unknown")] Other(Map), @@ -1114,6 +1116,7 @@ impl Context { Context::Browser(..) => "browser", Context::Trace(..) => "trace", Context::Gpu(..) => "gpu", + Context::Otel(..) => "otel", Context::Other(..) => "unknown", } } @@ -1332,6 +1335,22 @@ pub struct GpuContext { pub other: Map, } +/// OpenTelemetry context +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct OtelContext { + /// OpenTelemetry [general + /// attributes](https://opentelemetry.io/docs/specs/semconv/general/attributes/). + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub attributes: Map, + /// OpenTelemetry [resource attributes](https://opentelemetry.io/docs/specs/semconv/resource/), + /// describing the entity producing telemetry. + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub resource: Map, + /// Additional arbitrary fields for forwards compatibility. + #[serde(flatten)] + pub other: Map, +} + /// Holds the identifier for a Span #[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq, Hash)] #[serde(try_from = "String", into = "String")] @@ -1373,6 +1392,12 @@ impl TryFrom for SpanId { } } +impl From<[u8; 8]> for SpanId { + fn from(value: [u8; 8]) -> Self { + Self(value) + } +} + /// Holds the identifier for a Trace #[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq, Hash)] #[serde(try_from = "String", into = "String")] @@ -1414,6 +1439,12 @@ impl TryFrom for TraceId { } } +impl From<[u8; 16]> for TraceId { + fn from(value: [u8; 16]) -> Self { + Self(value) + } +} + /// Holds information about a tracing event. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct TraceContext { @@ -1457,8 +1488,11 @@ into_context!(Runtime, RuntimeContext); into_context!(Browser, BrowserContext); into_context!(Trace, TraceContext); into_context!(Gpu, GpuContext); +into_context!(Otel, OtelContext); -const INFERABLE_CONTEXTS: &[&str] = &["device", "os", "runtime", "app", "browser", "trace", "gpu"]; +const INFERABLE_CONTEXTS: &[&str] = &[ + "device", "os", "runtime", "app", "browser", "trace", "gpu", "otel", +]; struct ContextsVisitor; @@ -1803,6 +1837,11 @@ impl Span { Default::default() } + /// Finalizes the span with the provided timestamp. + pub fn finish_with_timestamp(&mut self, timestamp: SystemTime) { + self.timestamp = Some(timestamp); + } + /// Finalizes the span. pub fn finish(&mut self) { self.timestamp = Some(SystemTime::now()); @@ -2050,6 +2089,11 @@ impl<'a> Transaction<'a> { pub fn finish(&mut self) { self.timestamp = Some(SystemTime::now()); } + + /// Finalizes the transaction to be dispatched with the given end timestamp. + pub fn finish_with_timestamp(&mut self, timestamp: SystemTime) { + self.timestamp = Some(timestamp); + } } impl fmt::Display for Transaction<'_> { diff --git a/sentry/Cargo.toml b/sentry/Cargo.toml index 5d77a629..03a8f1bf 100644 --- a/sentry/Cargo.toml +++ b/sentry/Cargo.toml @@ -43,6 +43,7 @@ tower = ["sentry-tower"] tower-http = ["tower", "sentry-tower/http"] tower-axum-matched-path = ["tower-http", "sentry-tower/axum-matched-path"] tracing = ["sentry-tracing"] +opentelemetry = ["sentry-opentelemetry"] # other features test = ["sentry-core/test"] debug-logs = ["dep:log", "sentry-core/debug-logs"] @@ -70,6 +71,7 @@ sentry-panic = { version = "0.37.0", path = "../sentry-panic", optional = true } sentry-slog = { version = "0.37.0", path = "../sentry-slog", optional = true } sentry-tower = { version = "0.37.0", path = "../sentry-tower", optional = true } sentry-tracing = { version = "0.37.0", path = "../sentry-tracing", optional = true } +sentry-opentelemetry = { version = "0.37.0", path = "../sentry-opentelemetry", optional = true } log = { version = "0.4.8", optional = true, features = ["std"] } reqwest = { version = "0.12", optional = true, features = [ "blocking", diff --git a/sentry/src/lib.rs b/sentry/src/lib.rs index be98796f..352094d7 100644 --- a/sentry/src/lib.rs +++ b/sentry/src/lib.rs @@ -204,6 +204,10 @@ pub mod integrations { #[cfg_attr(doc_cfg, doc(cfg(feature = "log")))] #[doc(inline)] pub use sentry_log as log; + #[cfg(feature = "opentelemetry")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "opentelemetry")))] + #[doc(inline)] + pub use sentry_opentelemetry as opentelemetry; #[cfg(feature = "panic")] #[cfg_attr(doc_cfg, doc(cfg(feature = "panic")))] #[doc(inline)]