Repository: getsentry/sentry-rust
Pull request #779 (2025-04-30 12:48:22)

feat(otel): add OpenTelemetry SpanProcessor, Propagator, Extractor
Creates a new crate `sentry-opentelemetry` for integration with OTEL.

The implementation is based on a SpanProcessor that creates and finishes Sentry spans on OTEL span start/end.
There's also a Propagator/Extractor that injects the metadata (e.g. HTTP headers) required for distributed tracing to work.
It's based on our implementation for JS and other SDKs (https://develop.sentry.dev/sdk/telemetry/traces/opentelemetry/).

The goal with this initial version is:
- support OTEL <> Sentry span conversion and capturing
- capture OTEL span attributes
- link any messages/errors captured through the Sentry API to the correct spans, assuming that only the OTEL API is used to manage spans

Feedback appreciated on:
- `SPAN_MAP`, general Rust stuff, additions to performance API (@Swatinem)
- OTEL/SDKs things (@adinauer & @sl0thentr0py)

There are some todo items which will be addressed in follow-up PRs (not necessarily in this release).


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 5640f5d..655d6a9 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 740cd0b..43ccd6a 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 <https://docs.sentry.io/platforms/native/enriching-events/transaction-name/>
+    /// for an explanation of a Transaction's `name`, and
+    /// <https://develop.sentry.dev/sdk/performance/span-operations/> 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<protocol::SpanId>,
+        parent_span_id: Option<protocol::SpanId>,
+    ) -> 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<bool>);
+pub struct SentryTrace(protocol::TraceId, protocol::SpanId, Option<bool>);
+
+impl SentryTrace {
+    /// Creates a new [`SentryTrace`] from the provided parameters
+    pub fn new(
+        trace_id: protocol::TraceId,
+        span_id: protocol::SpanId,
+        sampled: Option<bool>,
+    ) -> Self {
+        SentryTrace(trace_id, span_id, sampled)
+    }
+}
 
 fn parse_sentry_trace(header: &str) -> Option<SentryTrace> {
     let header = header.trim();
@@ -998,6 +1184,25 @@ fn parse_w3c_traceparent(header: &str) -> Option<SentryTrace> {
     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<Item = (&'a str, &'a str)>>(
+    headers: I,
+) -> Option<SentryTrace> {
+    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 0000000..9f889dc
--- /dev/null
+++ b/sentry-opentelemetry/Cargo.toml
@@ -0,0 +1,37 @@
+[package]
+name = "sentry-opentelemetry"
+version = "0.37.0"
+authors = ["Sentry <hello@sentry.io>"]
+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 0000000..0f66451
--- /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 0000000..d9de713
--- /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 0000000..d1a2733
--- /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<Mutex<HashMap<sentry_core::protocol::SpanId, TransactionOrSpan>>>;
+
+/// 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::<SentryTrace>() {
+                        // 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 0000000..396f95f
--- /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-types/src/protocol/v7.rs b/sentry-types/src/protocol/v7.rs
index badeaa0..bb2008f 100644
--- a/sentry-types/src/protocol/v7.rs
+++ b/sentry-types/src/protocol/v7.rs
@@ -1096,8 +1096,10 @@ pub enum Context {
     Browser(Box<BrowserContext>),
     /// Tracing data.
     Trace(Box<TraceContext>),
-    /// GPU data
+    /// GPU data.
     Gpu(Box<GpuContext>),
+    /// OpenTelemetry data.
+    Otel(Box<OtelContext>),
     /// Generic other context data.
     #[serde(rename = "unknown")]
     Other(Map<String, Value>),
@@ -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<String, Value>,
 }
 
+/// 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<String, Value>,
+    /// 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<String, Value>,
+    /// Additional arbitrary fields for forwards compatibility.
+    #[serde(flatten)]
+    pub other: Map<String, Value>,
+}
+
 /// 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<String> 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<String> 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 5d77a62..03a8f1b 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 be98796..352094d 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)]


Test patch:
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 0000000..2e7b411
--- /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<Transaction> = None;
+    let mut span_id: Option<String> = None;
+
+    let mut trace_id_from_event: Option<String> = None;
+    let mut span_id_from_event: Option<String> = 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 0000000..c8ca55d
--- /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 0000000..4b47efa
--- /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 0000000..e57fb5b
--- /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<String, String>);
+
+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<String, String>);
+
+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 0000000..5189da0
--- /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<TestTransport> {
+    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
+}