From f0e3e5b0b7a3f3cbedf5df22cc1c26a3c150d683 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 13 Jul 2026 18:10:14 +1200 Subject: [PATCH 1/2] spike: Sentry tracing API as a shim over Activities Validates part two of the Activity-as-single-source-of-truth plan: existing Sentry tracing API calls (SentrySdk.StartTransaction, ISpan.StartChild, hub.StartSpan) keep working unchanged while creating Activities under the hood, giving SDK users a long deprecation runway and effectively migrating every integration that funnels through the Hub to OTel instrumentation in one stroke. Design: lifecycle, timing and parenting go through the Activity (making it the single source of truth for the span tree); rich Sentry-only state (measurements, tags, data, contexts, breadcrumbs) delegates directly to the shadow tracer the SentryActivityListener creates - no lossy round-trip through Activity tags. Sentry concepts needed across the Activity lifecycle boundary travel as side-channel values fused onto the Activity: - an explicit rich SpanStatus passed to Finish (Activity only has Unset/Ok/Error), - the customSamplingContext dictionary (solving the TracesSampler gap that ActivityListener.Sample has no channel for), - an inbound DynamicSamplingContext. These are fused before Activity.Start() (via CreateActivity + Start), so the processor sees them at sampling time in OnStart. Mechanics: - ActivityTransactionShim/ActivitySpanShim implement ITransactionTracer/ISpan over an Activity plus the shadow tracer (resolved via a new reverse fused association set by the processor). - Hub.StartTransaction routes Sentry-API transactions through SentryOptions.ActivityShimFactory (a plain delegate, so core needs no Activity types - this is also the hook that works for legacy TFMs where the shim ships in Sentry.DiagnosticSource). Processor-created transactions arrive with Instrumenter.OpenTelemetry and fall through, avoiding recursion. A null factory result (no listener) falls back to classic tracing. - The shim replaces the shadow tracer with itself on the scope, so integrations using hub.GetSpan()/StartSpan also route through Activities. - The processor skips OTel semantic-convention parsing for shim-created activities (they are already Sentry-native and the parse would clobber descriptions/names set through the Sentry API). Tests (ActivityShimTests, 8 scenarios): transaction round-trip with rich state (incl. SpanStatus.Cancelled and a rename), Activity-backed children, hub.StartSpan routing, unsampled flows, classic-path fallback, customSamplingContext reaching the TracesSampler, and the headline scenario: an external ActivitySource (e.g. MongoDB driver) parenting naturally under a Sentry-API transaction via Activity.Current - one trace from two worlds. Verified: Sentry.DiagnosticSource.Tests 127/127 (net10.0), Sentry.Tests 2402 passed, Sentry.OpenTelemetry.Tests 42/42, core + standalone build clean on all TFMs (CreateActivity/SetStatus/IsStopped all present in the DiagnosticSource 8.0.1 legacy assets). #skip-changelog Co-Authored-By: Claude Opus 4.8 --- .../Internal/Tracing/ActivityShim.cs | 27 ++ .../Internal/Tracing/ActivitySpanShim.cs | 136 ++++++++++ .../Tracing/ActivityTransactionShim.cs | 181 +++++++++++++ .../Tracing/SentryActivityListener.cs | 19 +- .../Tracing/SentryActivityProcessor.cs | 38 ++- src/Sentry/Internal/Hub.cs | 12 + src/Sentry/SentryOptions.cs | 13 + .../Tracing/ActivityShimTests.cs | 249 ++++++++++++++++++ 8 files changed, 666 insertions(+), 9 deletions(-) create mode 100644 src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs create mode 100644 src/Sentry.DiagnosticSource/Internal/Tracing/ActivitySpanShim.cs create mode 100644 src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs create mode 100644 test/Sentry.DiagnosticSource.Tests/Tracing/ActivityShimTests.cs 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..28119226ca 100644 --- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs @@ -136,6 +136,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 +164,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 +181,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 +234,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 +292,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(); + } +} From 51c743031770cb6bef6498f1e3d93c33a70c25f2 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 13 Jul 2026 07:04:02 +0000 Subject: [PATCH 2/2] Format code --- .../Internal/Tracing/SentryActivityProcessor.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs index 28119226ca..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;