diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs
new file mode 100644
index 0000000000..ba60bc907f
--- /dev/null
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs
@@ -0,0 +1,27 @@
+// Alias required because Android TFMs of the core Sentry package otherwise see an ambiguous reference
+// between System.Diagnostics.Activity (global using) and Android.App.Activity (Android implicit usings).
+using Activity = System.Diagnostics.Activity;
+
+namespace Sentry.Internal.Tracing;
+
+///
+/// The ActivitySource used for Activities created by the Sentry tracing shim (i.e. when users call
+/// SentrySdk.StartTransaction / ISpan.StartChild and Activity-based tracing is enabled).
+///
+internal static class SentryActivitySources
+{
+ public const string ShimSourceName = "Sentry";
+
+ public static readonly ActivitySource Shim = new(ShimSourceName);
+}
+
+///
+/// Keys for side-channel values fused onto an by the shim, read back by
+/// . These carry Sentry concepts that have no native representation
+/// on an Activity (rich span status, custom sampling context, an inbound dynamic sampling context).
+///
+internal static class ShimKeys
+{
+ public const string SpanStatus = "sentry.shim.status";
+ public const string CustomSamplingContext = "sentry.shim.custom_sampling_context";
+}
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivitySpanShim.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivitySpanShim.cs
new file mode 100644
index 0000000000..141255d650
--- /dev/null
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivitySpanShim.cs
@@ -0,0 +1,136 @@
+using Sentry.Protocol;
+
+// Alias required because Android TFMs of the core Sentry package otherwise see an ambiguous reference
+// between System.Diagnostics.Activity (global using) and Android.App.Activity (Android implicit usings).
+using Activity = System.Diagnostics.Activity;
+
+namespace Sentry.Internal.Tracing;
+
+///
+/// An facade over an .
+///
+///
+/// Design principle: lifecycle, timing and parenting go through the Activity (so the Activity is the
+/// single source of truth for the span tree, and pure-OTel instrumentation interleaves naturally), while
+/// rich Sentry-only state delegates directly to the shadow tracer that
+/// created for the Activity (so nothing is lost squeezing Sentry
+/// concepts through Activity tags). The only Sentry-native concepts that must survive the Activity lifecycle
+/// boundary (an explicit passed to Finish) travel as side-channel values fused onto
+/// the Activity, where the processor picks them up in OnEnd.
+///
+internal class ActivitySpanShim : ISpan
+{
+ protected internal Activity Activity { get; }
+ private readonly ISpan _inner;
+ protected readonly IHub Hub;
+
+ public ActivitySpanShim(Activity activity, ISpan inner, IHub hub)
+ {
+ Activity = activity;
+ _inner = inner;
+ Hub = hub;
+ }
+
+ internal ISpan Inner => _inner;
+
+ // ---- Lifecycle: routed through the Activity ----
+
+ public ISpan StartChild(string operation)
+ {
+ // Parent explicitly to this shim's Activity (not Activity.Current) to preserve Sentry's
+ // StartChild-on-a-specific-span semantics.
+ var childActivity = SentryActivitySources.Shim.StartActivity(
+ operation, ActivityKind.Internal, Activity.Context);
+
+ if (childActivity?.GetFused() is { } childSpan)
+ {
+ return new ActivitySpanShim(childActivity, childSpan, Hub);
+ }
+
+ // No listener is running (or the activity was suppressed) - fall back to the classic path.
+ childActivity?.Dispose();
+ return _inner.StartChild(operation);
+ }
+
+ public void Finish()
+ {
+ // An explicit status set via the property (rather than Finish(status)) must survive OnEnd,
+ // which would otherwise derive the status from the Activity.
+ if (_inner.Status is { } status)
+ {
+ FuseStatus(status);
+ }
+ StopActivity();
+ }
+
+ public void Finish(SpanStatus status)
+ {
+ FuseStatus(status);
+ Activity.SetStatus(status == SpanStatus.Ok ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
+ StopActivity();
+ }
+
+ public void Finish(Exception exception, SpanStatus status)
+ {
+ Hub.BindException(exception, _inner);
+ Finish(status);
+ }
+
+ public void Finish(Exception exception) => Finish(exception, SpanStatusConverter.FromException(exception));
+
+ public void Dispose() => Finish();
+
+ private void FuseStatus(SpanStatus status) => Activity.SetFused(ShimKeys.SpanStatus, status);
+
+ private void StopActivity()
+ {
+ if (!Activity.IsStopped)
+ {
+ Activity.Stop();
+ }
+ }
+
+ // ---- Rich state: delegated to the shadow tracer ----
+
+ public string? Description
+ {
+ get => _inner.Description;
+ set => _inner.Description = value;
+ }
+
+ public string Operation
+ {
+ get => _inner.Operation;
+ set => _inner.Operation = value;
+ }
+
+ public SpanStatus? Status
+ {
+ get => _inner.Status;
+ set => _inner.Status = value;
+ }
+
+ public SpanId SpanId => _inner.SpanId;
+ public SpanId? ParentSpanId => _inner.ParentSpanId;
+ public SentryId TraceId => _inner.TraceId;
+ public string? Origin => _inner.Origin;
+ public bool? IsSampled => _inner.IsSampled;
+
+ public DateTimeOffset StartTimestamp => _inner.StartTimestamp;
+ public DateTimeOffset? EndTimestamp => _inner.EndTimestamp;
+ public bool IsFinished => _inner.IsFinished;
+ public SentryTraceHeader GetTraceHeader() => _inner.GetTraceHeader();
+
+ public IReadOnlyDictionary Measurements => _inner.Measurements;
+ public void SetMeasurement(string name, Measurement measurement) => _inner.SetMeasurement(name, measurement);
+
+ public IReadOnlyDictionary Data => _inner.Data;
+ public void SetData(string key, object? value) => _inner.SetData(key, value);
+
+ public IReadOnlyDictionary Tags => _inner.Tags;
+ public void SetTag(string key, string value) => _inner.SetTag(key, value);
+ public void UnsetTag(string key) => _inner.UnsetTag(key);
+
+ public IReadOnlyDictionary Extra => _inner.Extra;
+ public void SetExtra(string key, object? value) => _inner.SetExtra(key, value);
+}
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs
new file mode 100644
index 0000000000..1e926e4d74
--- /dev/null
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs
@@ -0,0 +1,181 @@
+// Alias required because Android TFMs of the core Sentry package otherwise see an ambiguous reference
+// between System.Diagnostics.Activity (global using) and Android.App.Activity (Android implicit usings).
+using Activity = System.Diagnostics.Activity;
+
+namespace Sentry.Internal.Tracing;
+
+///
+/// An facade over an . See
+/// for the design principle. Created by , which
+/// Hub.StartTransaction invokes (via SentryOptions.ActivityShimFactory) for Sentry-API
+/// transactions when Activity-based tracing is enabled.
+///
+internal sealed class ActivityTransactionShim : ActivitySpanShim, ITransactionTracer
+{
+ private readonly ITransactionTracer _inner;
+
+ private ActivityTransactionShim(Activity activity, ITransactionTracer inner, IHub hub)
+ : base(activity, inner, hub)
+ {
+ _inner = inner;
+ }
+
+ ///
+ /// Starts an Activity for a Sentry-API transaction and returns a shim wrapping it, or null when no
+ /// activity listener is running (callers should fall back to the classic tracing path).
+ ///
+ ///
+ /// Uses CreateActivity + Start (rather than StartActivity) so that side-channel values the
+ /// needs at OnStart time - the custom sampling context, an inbound
+ /// dynamic sampling context and the transaction name - are all in place before OnStart runs sampling.
+ ///
+ public static ITransactionTracer? Create(
+ IHub hub,
+ ITransactionContext context,
+ IReadOnlyDictionary customSamplingContext,
+ DynamicSamplingContext? dynamicSamplingContext)
+ {
+ // Continue an inbound (remote) trace, propagating the upstream sampling decision.
+ ActivityContext parentContext = default;
+ if (context.ParentSpanId is { } parentSpanId && parentSpanId != default(SpanId))
+ {
+ var flags = context.IsParentSampled == true ? ActivityTraceFlags.Recorded : ActivityTraceFlags.None;
+ parentContext = new ActivityContext(
+ context.TraceId.AsActivityTraceId(), parentSpanId.AsActivitySpanId(), flags, isRemote: true);
+ }
+
+ var activity = SentryActivitySources.Shim.CreateActivity(
+ context.Operation, ActivityKind.Internal, parentContext);
+ if (activity is null)
+ {
+ // No listener is subscribed to the Sentry ActivitySource.
+ return null;
+ }
+
+ activity.DisplayName = context.Name;
+ activity.SetFused(ShimKeys.CustomSamplingContext, customSamplingContext);
+ if (dynamicSamplingContext is not null)
+ {
+ activity.SetFused(dynamicSamplingContext);
+ }
+
+ activity.Start();
+
+ if (activity.GetFused() is not ITransactionTracer inner)
+ {
+ // The listener saw the activity but did not map it (e.g. the hub was disabled mid-flight).
+ activity.Dispose();
+ return null;
+ }
+
+ var shim = new ActivityTransactionShim(activity, inner, hub);
+ // The processor put the shadow tracer on the scope; replace it with the shim so that
+ // integrations calling hub.GetSpan()/scope.Transaction route child spans through Activities.
+ hub.ConfigureScope(static (scope, s) => scope.Transaction = s, shim);
+ return shim;
+ }
+
+ // ---- ITransactionTracer ----
+
+ public string Name
+ {
+ get => _inner.Name;
+ set
+ {
+ // The DisplayName must be kept in sync because the processor derives the transaction name
+ // from the Activity in OnEnd (which would otherwise clobber a rename back to the old name).
+ Activity.DisplayName = value;
+ _inner.Name = value;
+ }
+ }
+
+ public TransactionNameSource NameSource => _inner.NameSource;
+
+ public bool? IsParentSampled
+ {
+ get => _inner.IsParentSampled;
+ set => _inner.IsParentSampled = value;
+ }
+
+ public IReadOnlyCollection Spans => _inner.Spans;
+
+ public ISpan? GetLastActiveSpan()
+ {
+ if (_inner.GetLastActiveSpan() is not { } last)
+ {
+ return null;
+ }
+ // Every span the processor creates has its Activity fused on; wrap it so that callers
+ // (e.g. integrations creating children of the "current" span) route through Activities.
+ return last.GetFused() is { } activity
+ ? new ActivitySpanShim(activity, last, Hub)
+ : last;
+ }
+
+ // ---- IEventLike (delegated) ----
+
+ public IReadOnlyCollection Breadcrumbs => _inner.Breadcrumbs;
+ public void AddBreadcrumb(Breadcrumb breadcrumb) => _inner.AddBreadcrumb(breadcrumb);
+
+ public string? Distribution
+ {
+ get => _inner.Distribution;
+ set => _inner.Distribution = value;
+ }
+
+ public SentryLevel? Level
+ {
+ get => _inner.Level;
+ set => _inner.Level = value;
+ }
+
+ public SentryRequest Request
+ {
+ get => _inner.Request;
+ set => _inner.Request = value;
+ }
+
+ public SentryContexts Contexts
+ {
+ get => _inner.Contexts;
+ set => _inner.Contexts = value;
+ }
+
+ public SentryUser User
+ {
+ get => _inner.User;
+ set => _inner.User = value;
+ }
+
+ public string? Release
+ {
+ get => _inner.Release;
+ set => _inner.Release = value;
+ }
+
+ public string? Environment
+ {
+ get => _inner.Environment;
+ set => _inner.Environment = value;
+ }
+
+ public string? TransactionName
+ {
+ get => _inner.TransactionName;
+ set => _inner.TransactionName = value;
+ }
+
+ public SdkVersion Sdk => _inner.Sdk;
+
+ public string? Platform
+ {
+ get => _inner.Platform;
+ set => _inner.Platform = value;
+ }
+
+ public IReadOnlyList Fingerprint
+ {
+ get => _inner.Fingerprint;
+ set => _inner.Fingerprint = value;
+ }
+}
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs
index 65a907caa3..91b1bb74ad 100644
--- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs
@@ -26,6 +26,7 @@ namespace Sentry.Internal.Tracing;
internal sealed class SentryActivityListener : IDisposable
{
private readonly ActivityListener _listener;
+ private readonly SentryOptions? _options;
internal SentryActivityProcessor Processor { get; }
@@ -48,7 +49,23 @@ public SentryActivityListener(
ActivityStopped = Processor.OnEnd
};
ActivitySource.AddActivityListener(_listener);
+
+ // Install the tracing shim: Sentry-API transactions (SentrySdk.StartTransaction et al.) get routed
+ // through Activities on the Sentry ActivitySource, making the Activity the single source of truth
+ // for spans regardless of which API created them.
+ _options = hub.GetSentryOptions();
+ if (_options is not null)
+ {
+ _options.ActivityShimFactory = ActivityTransactionShim.Create;
+ }
}
- public void Dispose() => _listener.Dispose();
+ public void Dispose()
+ {
+ if (_options is { ActivityShimFactory: { } factory } && factory == ActivityTransactionShim.Create)
+ {
+ _options.ActivityShimFactory = null;
+ }
+ _listener.Dispose();
+ }
}
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs
index 78e1a4a140..c944e202c9 100644
--- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs
@@ -1,10 +1,9 @@
// Alias required because Android TFMs of the core Sentry package otherwise see an ambiguous reference
// between System.Diagnostics.Activity (global using) and Android.App.Activity (Android implicit usings).
-using Activity = System.Diagnostics.Activity;
-
using Sentry.Extensibility;
using Sentry.Internal.Extensions;
using Sentry.Internal.OpenTelemetry;
+using Activity = System.Diagnostics.Activity;
namespace Sentry.Internal.Tracing;
@@ -136,6 +135,8 @@ private void CreateChildSpan(Activity data, ISpan parentSpan, SpanId? parentSpan
var span = parentSpan.StartChild(context);
// Used to filter out spans that are not recorded when finishing a transaction
span.SetFused(data);
+ // The reverse association lets the tracing shim resolve the span backing an Activity it started.
+ data.SetFused(span);
if (span is SpanTracer spanTracer)
{
spanTracer.Origin = OpenTelemetryOrigin;
@@ -162,10 +163,15 @@ private void CreateRootSpan(Activity data)
Instrumenter = Instrumenter.OpenTelemetry
};
+ // The tracing shim can supply Sentry concepts that have no Activity representation as side-channel
+ // values fused onto the Activity (set before Start, so they are available here at sampling time).
+ var customSamplingContext = data.GetFused>(ShimKeys.CustomSamplingContext)
+ ?? new Dictionary();
var baggageHeader = data.Baggage.AsBaggageHeader();
- var dynamicSamplingContext = baggageHeader.CreateDynamicSamplingContext(_replaySession);
+ var dynamicSamplingContext = data.GetFused()
+ ?? baggageHeader.CreateDynamicSamplingContext(_replaySession);
var transaction = _hub.StartTransaction(
- transactionContext, new Dictionary(), dynamicSamplingContext
+ transactionContext, customSamplingContext, dynamicSamplingContext
);
if (transaction is TransactionTracer tracer)
{
@@ -174,6 +180,8 @@ private void CreateRootSpan(Activity data)
}
_hub.ConfigureScope(static (scope, transaction) => scope.Transaction = transaction, transaction);
transaction.SetFused(data);
+ // The reverse association lets the tracing shim resolve the transaction backing an Activity it started.
+ data.SetFused(transaction);
_map[data.SpanId] = transaction;
}
@@ -225,16 +233,26 @@ public void OnEnd(Activity data)
return;
}
- var (operation, description, source) = ParseOtelSpanDescription(data, attributes);
- span.Operation = operation;
- span.Description = description;
+ // Activities created by the Sentry tracing shim are already Sentry-native: operation, description
+ // and name are maintained directly on the shadow tracer through the shim, so the OTel
+ // semantic-convention parsing below would only clobber them.
+ var isShimActivity = data.Source.Name == SentryActivitySources.ShimSourceName;
+ if (!isShimActivity)
+ {
+ var (operation, description, source) = ParseOtelSpanDescription(data, attributes);
+ span.Operation = operation;
+ span.Description = description;
+ if (span is TransactionTracer transactionToName)
+ {
+ transactionToName.Name = description;
+ transactionToName.NameSource = source;
+ }
+ }
// Handle HTTP response status code specially
var statusCode = attributes.HttpResponseStatusCodeAttribute();
if (span is TransactionTracer transaction)
{
- transaction.Name = description;
- transaction.NameSource = source;
if (statusCode is { } responseStatusCode)
{
transaction.Contexts.Response.StatusCode = responseStatusCode;
@@ -273,7 +291,10 @@ public void OnEnd(Activity data)
}
GenerateSentryErrorsFromOtelSpan(data, attributes);
- var status = GetSpanStatus(data.Status, attributes);
+ // A rich SpanStatus set explicitly through the tracing shim wins over the (lossy) status derived
+ // from the Activity, which only distinguishes Unset/Ok/Error.
+ var status = data.GetFused(ShimKeys.SpanStatus)
+ ?? GetSpanStatus(data.Status, attributes);
foreach (var enricher in _enrichers)
{
enricher.Enrich(span, data, _hub, _options);
diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs
index dade93b89f..bffc26cc4c 100644
--- a/src/Sentry/Internal/Hub.cs
+++ b/src/Sentry/Internal/Hub.cs
@@ -191,6 +191,18 @@ internal ITransactionTracer StartTransaction(
return NoOpTransaction.Instance;
}
+ // Spike: when Activity-based tracing is enabled, Sentry-API transactions are routed through the
+ // Activity shim - the Activity becomes the source of truth and the SentryActivityListener converts
+ // it back into the transaction that gets captured. Transactions created by that conversion arrive
+ // here with Instrumenter.OpenTelemetry and fall through to the classic path below (which is how the
+ // shim avoids infinite recursion). A null return means no listener is running - fall through too.
+ if (_options.ActivityShimFactory is { } shimFactory
+ && context is not TransactionContext { Instrumenter: Instrumenter.OpenTelemetry }
+ && shimFactory(this, context, customSamplingContext, dynamicSamplingContext) is { } shimmedTransaction)
+ {
+ return shimmedTransaction;
+ }
+
bool? isSampled = null;
double? sampleRate = null;
DiscardReason? discardReason = null;
diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs
index 187da749e0..5b6e6b459a 100644
--- a/src/Sentry/SentryOptions.cs
+++ b/src/Sentry/SentryOptions.cs
@@ -1237,6 +1237,19 @@ public StackTraceMode StackTraceMode
///
internal Instrumenter Instrumenter { get; set; } = Instrumenter.Sentry;
+ ///
+ /// Spike: when Activity-based tracing is enabled, this factory routes Sentry-API transactions
+ /// (SentrySdk.StartTransaction et al.) through a System.Diagnostics.Activity so that the Activity is the
+ /// single source of truth for spans. Set by SentryActivityListener. The factory returns null when no
+ /// activity listener is running, in which case Hub.StartTransaction falls back to the classic path.
+ ///
+ ///
+ /// This is a plain delegate (no System.Diagnostics.Activity types in the signature) so that it can be
+ /// declared here in core - which compiles for TFMs without ActivityListener support - while the shim
+ /// implementation ships in Sentry.DiagnosticSource for those TFMs.
+ ///
+ internal Func, DynamicSamplingContext?, ITransactionTracer?>? ActivityShimFactory { get; set; }
+
///
/// During the transition period to OTLP we give SDK users the option to keep using Sentry's tracing in conjunction
/// with OTEL instrumentation. Setting this to true will disable Sentry's tracing entirely, which is the recommended
diff --git a/test/Sentry.DiagnosticSource.Tests/Tracing/ActivityShimTests.cs b/test/Sentry.DiagnosticSource.Tests/Tracing/ActivityShimTests.cs
new file mode 100644
index 0000000000..1c92d299ef
--- /dev/null
+++ b/test/Sentry.DiagnosticSource.Tests/Tracing/ActivityShimTests.cs
@@ -0,0 +1,249 @@
+using Sentry.Internal.Tracing;
+
+namespace Sentry.DiagnosticSource.Tests.Tracing;
+
+///
+/// End-to-end tests for the Activity tracing shim: Sentry-API calls (StartTransaction/StartChild/StartSpan)
+/// create Activities under the hood, the SentryActivityListener converts them back into the transactions
+/// that get captured, and pure-Activity instrumentation interleaves into the same trace.
+///
+public class ActivityShimTests : IDisposable
+{
+ private readonly SentryOptions _options;
+ private readonly ISentryClient _client;
+ private readonly Hub _hub;
+ private readonly ActivitySource _externalSource;
+ private readonly SentryActivityListener _listener;
+
+ public ActivityShimTests()
+ {
+ _options = new SentryOptions
+ {
+ Dsn = ValidDsn,
+ TracesSampleRate = 1.0,
+ AutoSessionTracking = false,
+ Instrumenter = Instrumenter.OpenTelemetry
+ };
+ _client = Substitute.For();
+ _hub = new Hub(_options, _client);
+ _externalSource = new ActivitySource($"external-{Guid.NewGuid()}");
+ _listener = new SentryActivityListener(_hub,
+ s => s.Name == SentryActivitySources.ShimSourceName || s == _externalSource);
+ }
+
+ public void Dispose()
+ {
+ _listener.Dispose();
+ _externalSource.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public void StartTransaction_ShimEnabled_ReturnsActivityBackedTransaction()
+ {
+ // Act
+ var transaction = _hub.StartTransaction("my name", "my operation");
+
+ // Assert
+ using (new AssertionScope())
+ {
+ transaction.Should().BeOfType();
+
+ var activity = Activity.Current;
+ activity.Should().NotBeNull();
+ activity!.OperationName.Should().Be("my operation");
+ activity.DisplayName.Should().Be("my name");
+ activity.Source.Name.Should().Be(SentryActivitySources.ShimSourceName);
+
+ transaction.TraceId.Should().Be(activity.TraceId.AsSentryId());
+ transaction.SpanId.Should().Be(activity.SpanId.AsSentrySpanId());
+
+ // The shim (not the shadow tracer) is on the scope, so integrations route through Activities.
+ _hub.GetTransaction().Should().BeSameAs(transaction);
+ }
+
+ transaction.Finish();
+ }
+
+ [Fact]
+ public void Finish_RichState_CapturedOnTransaction()
+ {
+ // Arrange
+ var transaction = _hub.StartTransaction("original name", "some operation");
+ transaction.SetTag("my.tag", "tag value");
+ transaction.SetExtra("my.extra", "extra value");
+ transaction.SetMeasurement("frames.dropped", 42);
+ transaction.Name = "renamed";
+
+ // Act: Cancelled has no ActivityStatusCode equivalent - it must survive via the side-channel.
+ transaction.Finish(SpanStatus.Cancelled);
+
+ // Assert
+ transaction.IsFinished.Should().BeTrue();
+ _client.Received(1).CaptureTransaction(
+ Arg.Is(t =>
+ t.Name == "renamed" &&
+ t.Operation == "some operation" &&
+ t.Status == SpanStatus.Cancelled &&
+ t.Tags["my.tag"] == "tag value" &&
+ Equals(t.Data["my.extra"], "extra value") &&
+ t.Measurements.ContainsKey("frames.dropped")),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public void StartChild_OnShim_CreatesActivityBackedChild()
+ {
+ // Arrange
+ var transaction = _hub.StartTransaction("parent", "http.server");
+
+ // Act
+ var child = transaction.StartChild("db.query", "SELECT 1");
+ var childActivity = Activity.Current;
+ child.Finish();
+ transaction.Finish();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ child.Should().BeOfType();
+ childActivity!.OperationName.Should().Be("db.query");
+
+ _client.Received(1).CaptureTransaction(
+ Arg.Is(t =>
+ t.Spans.Count == 1 &&
+ t.Spans.Single().Operation == "db.query" &&
+ t.Spans.Single().Description == "SELECT 1" &&
+ t.Spans.Single().ParentSpanId == t.Contexts.Trace.SpanId),
+ Arg.Any(),
+ Arg.Any());
+ }
+ }
+
+ [Fact]
+ public void ExternalActivity_ParentsUnderShimTransaction()
+ {
+ // This is the interleaving scenario the shim exists for: Sentry-API instrumentation and pure
+ // Activity/OTel instrumentation (e.g. the MongoDB driver) land in one trace.
+
+ // Arrange
+ var transaction = _hub.StartTransaction("checkout", "http.server");
+ var transactionSpanId = Activity.Current!.SpanId;
+
+ // Act: an external library starts an Activity while the shim transaction is current.
+ var externalActivity = _externalSource.StartActivity("mongodb.find")!;
+ externalActivity.Stop();
+ transaction.Finish();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ externalActivity.ParentSpanId.Should().Be(transactionSpanId);
+ _client.Received(1).CaptureTransaction(
+ Arg.Is(t =>
+ t.Name == "checkout" &&
+ t.Spans.Count == 1 &&
+ t.Spans.Single().Operation == "mongodb.find" &&
+ t.Spans.Single().ParentSpanId == t.Contexts.Trace.SpanId),
+ Arg.Any(),
+ Arg.Any());
+ }
+ }
+
+ [Fact]
+ public void HubStartSpan_RoutesThroughActivity()
+ {
+ // Integrations use hub.GetSpan()/hub.StartSpan rather than holding transaction references -
+ // this validates that the scope hand-off routes those through Activities too.
+
+ // Arrange
+ var transaction = _hub.StartTransaction("parent", "http.server");
+
+ // Act
+ var span = _hub.StartSpan("child.op", "child description");
+ span.Finish();
+ transaction.Finish();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ span.Should().BeOfType();
+ _client.Received(1).CaptureTransaction(
+ Arg.Is(t =>
+ t.Spans.Count == 1 &&
+ t.Spans.Single().Operation == "child.op"),
+ Arg.Any(),
+ Arg.Any());
+ }
+ }
+
+ [Fact]
+ public void StartTransaction_Unsampled_NothingCaptured()
+ {
+ // Arrange
+ _options.TracesSampleRate = 0.0;
+
+ // Act
+ var transaction = _hub.StartTransaction("unsampled", "op");
+ var child = transaction.StartChild("child.op");
+ child.Finish();
+ transaction.Finish();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ transaction.Should().BeOfType();
+ transaction.IsSampled.Should().Be(false);
+ _client.DidNotReceive().CaptureTransaction(
+ Arg.Any(), Arg.Any(), Arg.Any());
+ }
+ }
+
+ [Fact]
+ public void CustomSamplingContext_ReachesTracesSampler()
+ {
+ // The customSamplingContext dictionary has no channel through ActivityListener.Sample - the shim
+ // carries it as a side-channel on the Activity, and the processor hands it back to Sentry's
+ // sampling pipeline. This test proves that round-trip.
+
+ // Arrange
+ _options.TracesSampleRate = null;
+ _options.TracesSampler = context =>
+ context.CustomSamplingContext.TryGetValue("vip", out var vip) && Equals(vip, true) ? 1.0 : 0.0;
+
+ // Act
+ var vipTransaction = _hub.StartTransaction(
+ new TransactionContext("vip request", "http.server"),
+ new Dictionary { ["vip"] = true });
+ var regularTransaction = _hub.StartTransaction(
+ new TransactionContext("regular request", "http.server"),
+ new Dictionary { ["vip"] = false });
+
+ // Assert
+ using (new AssertionScope())
+ {
+ vipTransaction.IsSampled.Should().Be(true);
+ regularTransaction.IsSampled.Should().Be(false);
+ }
+
+ vipTransaction.Finish();
+ regularTransaction.Finish();
+ }
+
+ [Fact]
+ public void StartTransaction_NoListener_FallsBackToClassicTracing()
+ {
+ // Arrange: the factory is installed but no listener is subscribed to the Sentry ActivitySource
+ // (e.g. it was disposed) - CreateActivity returns null and Hub falls through to the classic path.
+ _listener.Dispose();
+ _options.ActivityShimFactory = ActivityTransactionShim.Create;
+
+ // Act
+ var transaction = _hub.StartTransaction("classic", "op");
+
+ // Assert
+ transaction.Should().BeOfType();
+ transaction.Finish();
+ }
+}