From bf9a06c75d4aaae67cc61b419291ddc397765f04 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 13 Jul 2026 17:07:27 +1200 Subject: [PATCH 1/4] spike: port SentrySpanProcessor to a raw ActivityListener in core Experiment for the Activity-as-single-source-of-truth discussion (see #3238, #5350, #4859): proves that the Activity->Sentry conversion logic in Sentry.OpenTelemetry's SentrySpanProcessor can live in the core Sentry package, driven by a System.Diagnostics.ActivityListener, with zero dependency on the OpenTelemetry SDK. - Sentry.Internal.Tracing.SentryActivityProcessor: near-verbatim port of SentrySpanProcessor's OnStart/OnEnd state machine. Only two changes: no BaseProcessor base class, and OTel Resource detection replaced by an injectable resourceAttributeResolver (the only two OTel SDK touch points in the original). - Sentry.Internal.Tracing.SentryActivityListener: replaces the OTel TracerProvider registration glue with a raw ActivityListener (ShouldListenTo / Sample / ActivityStarted / ActivityStopped). - HAS_ACTIVITY_LISTENER constant gates everything to modern TFMs; netstandard2.0/2.1/net462 compile the new files out, so no new package dependency is introduced anywhere. - Tests: all 33 SentrySpanProcessorTests ported 1:1 and passing against the core port, plus 4 new end-to-end SentryActivityListener tests (no OTel SDK involved). Full Sentry.Tests suite green (2439 passed). #skip-changelog Co-Authored-By: Claude Opus 4.8 --- .../Tracing/ActivityAttributeExtensions.cs | 59 + .../Internal/Tracing/ActivityIdExtensions.cs | 30 + .../Tracing/ISentryActivityEnricher.cs | 12 + .../Tracing/SentryActivityListener.cs | 52 + .../Tracing/SentryActivityProcessor.cs | 525 +++++++++ src/Sentry/Sentry.csproj | 3 + .../Internals/Tracing/ActivitySourceTests.cs | 37 + .../Tracing/SentryActivityListenerTests.cs | 123 ++ .../Tracing/SentryActivityProcessorTests.cs | 1023 +++++++++++++++++ 9 files changed, 1864 insertions(+) create mode 100644 src/Sentry/Internal/Tracing/ActivityAttributeExtensions.cs create mode 100644 src/Sentry/Internal/Tracing/ActivityIdExtensions.cs create mode 100644 src/Sentry/Internal/Tracing/ISentryActivityEnricher.cs create mode 100644 src/Sentry/Internal/Tracing/SentryActivityListener.cs create mode 100644 src/Sentry/Internal/Tracing/SentryActivityProcessor.cs create mode 100644 test/Sentry.Tests/Internals/Tracing/ActivitySourceTests.cs create mode 100644 test/Sentry.Tests/Internals/Tracing/SentryActivityListenerTests.cs create mode 100644 test/Sentry.Tests/Internals/Tracing/SentryActivityProcessorTests.cs diff --git a/src/Sentry/Internal/Tracing/ActivityAttributeExtensions.cs b/src/Sentry/Internal/Tracing/ActivityAttributeExtensions.cs new file mode 100644 index 0000000000..f43e111e37 --- /dev/null +++ b/src/Sentry/Internal/Tracing/ActivityAttributeExtensions.cs @@ -0,0 +1,59 @@ +#if HAS_ACTIVITY_LISTENER +using Sentry.Internal.Extensions; +using Sentry.Internal.OpenTelemetry; + +namespace Sentry.Internal.Tracing; + +// Ported from Sentry.OpenTelemetry.OpenTelemetryExtensions so that the core Activity processor has no +// dependency on the OpenTelemetry SDK package. +internal static class ActivityAttributeExtensions +{ + public static BaggageHeader AsBaggageHeader(this IEnumerable> baggage, + bool useSentryPrefix = false) => + BaggageHeader.Create( + baggage.Where(member => member.Value != null) + .Select(kvp => (KeyValuePair)kvp!), + useSentryPrefix + ); + + /// + /// The names that OpenTelemetry gives to attributes, by convention, have changed over time so we often need to + /// check for both the new attribute and any obsolete ones. + /// + private static T? GetFirstMatchingAttribute(this IDictionary attributes, + params string[] attributeNames) + { + foreach (var name in attributeNames) + { + if (attributes.TryGetTypedValue(name, out T value)) + { + return value; + } + } + + return default; + } + + public static string? HttpMethodAttribute(this IDictionary attributes) => + attributes.GetFirstMatchingAttribute( + OtelSemanticConventions.AttributeHttpRequestMethod, + OtelSemanticConventions.AttributeHttpMethod // Fallback pre-1.5.0 + ); + + public static string? UrlFullAttribute(this IDictionary attributes) => + attributes.GetFirstMatchingAttribute( + OtelSemanticConventions.AttributeUrlFull, + OtelSemanticConventions.AttributeHttpUrl // Fallback pre-1.5.0 + ); + + public static short? HttpResponseStatusCodeAttribute(this IDictionary attributes) + { + var statusCode = attributes.GetFirstMatchingAttribute( + OtelSemanticConventions.AttributeHttpResponseStatusCode + ); + return statusCode is >= short.MinValue and <= short.MaxValue + ? (short)statusCode.Value + : null; + } +} +#endif diff --git a/src/Sentry/Internal/Tracing/ActivityIdExtensions.cs b/src/Sentry/Internal/Tracing/ActivityIdExtensions.cs new file mode 100644 index 0000000000..2ecdabd4a2 --- /dev/null +++ b/src/Sentry/Internal/Tracing/ActivityIdExtensions.cs @@ -0,0 +1,30 @@ +#if HAS_ACTIVITY_LISTENER +namespace Sentry.Internal.Tracing; + +// Note: a copy of these conversions ships in Sentry.OpenTelemetry(.Exporter) as Sentry.Internal.ActivityExtensions. +// This copy lives in a distinct namespace to avoid CS0436 conflicts in the OTel packages, which can see Sentry's +// internals via InternalsVisibleTo. The copies converge when the OTel packages are rebased onto core (see spike notes). +internal static class ActivityIdExtensions +{ + private const int SpanIdByteCount = sizeof(long); + private static readonly int TraceIdByteCount = Unsafe.SizeOf(); + + public static SpanId AsSentrySpanId(this ActivitySpanId id) => SpanId.Parse(id.ToHexString()); + + public static ActivitySpanId AsActivitySpanId(this SpanId id) + { + Span buffer = stackalloc byte[SpanIdByteCount]; + id.TryWriteBytes(buffer); + return ActivitySpanId.CreateFromBytes(buffer); + } + + public static SentryId AsSentryId(this ActivityTraceId id) => SentryId.Parse(id.ToHexString()); + + public static ActivityTraceId AsActivityTraceId(this SentryId id) + { + Span buffer = stackalloc byte[TraceIdByteCount]; + id.TryWriteBytes(buffer); + return ActivityTraceId.CreateFromBytes(buffer); + } +} +#endif diff --git a/src/Sentry/Internal/Tracing/ISentryActivityEnricher.cs b/src/Sentry/Internal/Tracing/ISentryActivityEnricher.cs new file mode 100644 index 0000000000..7b087466d2 --- /dev/null +++ b/src/Sentry/Internal/Tracing/ISentryActivityEnricher.cs @@ -0,0 +1,12 @@ +#if HAS_ACTIVITY_LISTENER +namespace Sentry.Internal.Tracing; + +/// +/// Enriches Sentry spans with additional information from the that produced them, +/// just before the span is finished. Core equivalent of Sentry.OpenTelemetry.IOpenTelemetryEnricher. +/// +internal interface ISentryActivityEnricher +{ + public void Enrich(ISpan span, Activity activity, IHub hub, SentryOptions? options); +} +#endif diff --git a/src/Sentry/Internal/Tracing/SentryActivityListener.cs b/src/Sentry/Internal/Tracing/SentryActivityListener.cs new file mode 100644 index 0000000000..6bb17dd8e1 --- /dev/null +++ b/src/Sentry/Internal/Tracing/SentryActivityListener.cs @@ -0,0 +1,52 @@ +#if HAS_ACTIVITY_LISTENER +namespace Sentry.Internal.Tracing; + +/// +/// Subscribes to instrumentation via (part of the .NET +/// runtime — no OpenTelemetry SDK dependency) and forwards activity lifecycle events to a +/// , which converts them into Sentry transactions and spans. +/// +/// +/// This replaces the registration glue that Sentry.OpenTelemetry gets from the OpenTelemetry SDK +/// (TracerProviderBuilder.AddProcessor): where the OTel SDK decides which ActivitySources to listen to and +/// which activities to sample, here those decisions are made by the shouldListenTo predicate and the +/// Sample callback respectively. +/// +/// Spike note on sampling: for parity with the current POTEL behaviour (where the OTel SDK records everything +/// and Sentry re-runs its own sampling when the root span is converted in +/// SentryActivityProcessor.CreateRootSpan), the Sample callback returns AllDataAndRecorded for +/// every activity. A production implementation could instead invoke Sentry's sampling logic here — at activity +/// creation time — to avoid paying for recording of activities that Sentry will discard. That requires solving +/// the TracesSampler customSamplingContext gap (ActivityCreationOptions has no channel for it) and reconciling +/// with any other ActivityListeners present (the runtime takes the most permissive sampling result). +/// +internal sealed class SentryActivityListener : IDisposable +{ + private readonly ActivityListener _listener; + + internal SentryActivityProcessor Processor { get; } + + public SentryActivityListener( + IHub hub, + Func? shouldListenTo = null, + IEnumerable? enrichers = null, + IReplaySession? replaySession = null, + Func>? resourceAttributeResolver = null) + { + Processor = new SentryActivityProcessor(hub, enrichers, replaySession, resourceAttributeResolver); + _listener = new ActivityListener + { + ShouldListenTo = source => shouldListenTo?.Invoke(source) ?? true, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = Processor.OnStart, + ActivityStopped = Processor.OnEnd + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() => _listener.Dispose(); +} +#endif diff --git a/src/Sentry/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry/Internal/Tracing/SentryActivityProcessor.cs new file mode 100644 index 0000000000..1ae2d601ea --- /dev/null +++ b/src/Sentry/Internal/Tracing/SentryActivityProcessor.cs @@ -0,0 +1,525 @@ +#if HAS_ACTIVITY_LISTENER +using Sentry.Extensibility; +using Sentry.Internal.Extensions; +using Sentry.Internal.OpenTelemetry; + +namespace Sentry.Internal.Tracing; + +/// +/// Converts lifecycle events into Sentry transactions and spans. +/// +/// +/// This is a port of Sentry.OpenTelemetry.SentrySpanProcessor with the OpenTelemetry SDK dependency removed: +/// instead of deriving from OpenTelemetry's BaseProcessor<Activity>, it is driven by an +/// (see ). The only functionality lost in +/// the port is OTel Resource attribute detection (ParentProvider.GetResource() has no System.Diagnostics +/// equivalent) — resource attributes can instead be supplied via the constructor. +/// +internal class SentryActivityProcessor +{ + private readonly IHub _hub; + internal readonly IEnumerable _enrichers; + private readonly IReplaySession _replaySession; + internal const string OpenTelemetryOrigin = "auto.otel"; + + // ReSharper disable once MemberCanBePrivate.Global - Used by tests + internal readonly ConcurrentDictionary _map = new(); + private readonly SentryOptions? _options; + private readonly Lazy> _resourceAttributes; + + private static readonly long PruningInterval = TimeSpan.FromSeconds(5).Ticks; + internal long _lastPruned = 0; + private readonly Lazy _realHub; + + internal SentryActivityProcessor( + IHub hub, + IEnumerable? enrichers = null, + IReplaySession? replaySession = null, + Func>? resourceAttributeResolver = null) + { + _hub = hub; + _realHub = new Lazy(() => + _hub switch + { + Hub thisHub => thisHub, + HubAdapter when SentrySdk.CurrentHub is Hub sdkHub => sdkHub, + _ => null + }); + + if (_hub is DisabledHub) + { + // This would only happen if someone tried to create a SentryActivityProcessor manually + throw new InvalidOperationException( + "Attempted to create a SentryActivityProcessor for a Disabled hub. " + + "The Sentry SDK should be initialized before the activity processor is created."); + } + + _enrichers = enrichers ?? []; + _replaySession = replaySession ?? ReplaySession.Instance; + _options = hub.GetSentryOptions(); + + if (_options is null) + { + throw new InvalidOperationException( + "The Sentry SDK has not been initialised. To capture tracing instrumentation from Activities " + + "you need to initialize the Sentry SDK."); + } + + // Spike note: retained for behavioural parity with SentrySpanProcessor. In a real Activity-based core + // this guard inverts — Instrumenter.OpenTelemetry marks spans created from Activities so that parent + // inference (see OnStart) can distinguish them from Sentry-native spans. + if (_options.Instrumenter != Instrumenter.OpenTelemetry) + { + throw new InvalidOperationException( + "Activity-based tracing has not been configured on the Sentry SDK. You need " + + "to initialize the Sentry SDK with options.Instrumenter = Instrumenter.OpenTelemetry"); + } + + // OTel Resource attributes have no System.Diagnostics equivalent; callers may supply them instead. + // Resolved lazily (once) as they are consistent between spans. + _resourceAttributes = new Lazy>( + resourceAttributeResolver ?? (static () => new Dictionary(0))); + } + + public void OnStart(Activity data) + { + if (!_hub.IsEnabled) + { + // This would be unusual... it might happen if the SDK is closed while the processor is still running and + // we receive new telemetry. In this case, we can't log anything because our logger is disabled, so we just + // swallow it + return; + } + + if (data.ParentSpanId != default && _map.TryGetValue(data.ParentSpanId, out var mappedParent)) + { + // Explicit ParentSpanId of another activity that we have already mapped + CreateChildSpan(data, mappedParent, data.ParentSpanId); + } + // Note if the current span on the hub is OTel instrumented and is not the parent of `data` then this may be + // intentional (see https://opentelemetry.io/docs/languages/net/instrumentation/#creating-new-root-activities) + // so we explicitly exclude OTel instrumented spans from the following check. + else if (_hub.GetSpan() is IBaseTracer { IsOtelInstrumenter: false } inferredParent) + { + // When mixing Sentry and Activity instrumentation and we infer that the currently active span is the parent. + var inferredParentSpan = (ISpan)inferredParent; + CreateChildSpan(data, inferredParentSpan, inferredParentSpan.SpanId); + } + else + { + CreateRootSpan(data); + } + + // Housekeeping + PruneFilteredSpans(); + } + + private void CreateChildSpan(Activity data, ISpan parentSpan, ActivitySpanId? parentSpanId = null) + => CreateChildSpan(data, parentSpan, parentSpanId?.AsSentrySpanId()); + + private void CreateChildSpan(Activity data, ISpan parentSpan, SpanId? parentSpanId = null) + { + // We can find the parent span - start a child span. + var context = new SpanContext( + data.OperationName, + data.SpanId.AsSentrySpanId(), + parentSpanId, + description: data.DisplayName + ) + { + Instrumenter = Instrumenter.OpenTelemetry + }; + + var span = parentSpan.StartChild(context); + // Used to filter out spans that are not recorded when finishing a transaction + span.SetFused(data); + if (span is SpanTracer spanTracer) + { + spanTracer.Origin = OpenTelemetryOrigin; + spanTracer.StartTimestamp = data.StartTimeUtc; + spanTracer.IsFiltered = () => spanTracer.GetFused() is { IsAllDataRequested: false, Recorded: false }; + } + _map[data.SpanId] = span; + } + + private void CreateRootSpan(Activity data) + { + // If a parent exists at all, then copy its sampling decision. + bool? isSampled = data.HasRemoteParent ? data.Recorded : null; + + // No parent span found - start a new transaction + var transactionContext = new TransactionContext( + data.DisplayName, + data.OperationName, + data.SpanId.AsSentrySpanId(), + data.ParentSpanId.AsSentrySpanId(), + data.TraceId.AsSentryId(), + data.DisplayName, null, isSampled, isSampled) + { + Instrumenter = Instrumenter.OpenTelemetry + }; + + var baggageHeader = data.Baggage.AsBaggageHeader(); + var dynamicSamplingContext = baggageHeader.CreateDynamicSamplingContext(_replaySession); + var transaction = _hub.StartTransaction( + transactionContext, new Dictionary(), dynamicSamplingContext + ); + if (transaction is TransactionTracer tracer) + { + tracer.Contexts.Trace.Origin = OpenTelemetryOrigin; + tracer.StartTimestamp = data.StartTimeUtc; + } + _hub.ConfigureScope(static (scope, transaction) => scope.Transaction = transaction, transaction); + transaction.SetFused(data); + _map[data.SpanId] = transaction; + } + + public void OnEnd(Activity data) + { + if (!_hub.IsEnabled) + { + // This would be unusual... it might happen if the SDK is closed while the processor is still running and + // we receive new telemetry. In this case, we can't log anything because our logger is disabled, so we just + // swallow it + return; + } + + // Skip any activities that are not recorded. + if (data is { Recorded: false }) + { + _options?.DiagnosticLogger?.LogDebug("Ignoring unrecorded Activity {0}.", data.SpanId); + _map.TryRemove(data.SpanId, out _); + return; + } + + // Make a dictionary of the attributes (aka "tags") for faster lookup when used throughout the processor. + var attributes = data.TagObjects.ToDict(); + + var url = attributes.UrlFullAttribute(); + if (!string.IsNullOrEmpty(url) && (_options?.IsSentryRequest(url) ?? false)) + { + _options?.DiagnosticLogger?.LogDebug($"Ignoring Activity {data.SpanId} for Sentry request."); + + if (_map.TryRemove(data.SpanId, out var removed)) + { + if (removed is SpanTracer spanTracerToRemove) + { + spanTracerToRemove.IsSentryRequest = true; + } + + if (removed is TransactionTracer transactionTracer) + { + transactionTracer.IsSentryRequest = true; + } + } + + return; + } + + if (!_map.TryGetValue(data.SpanId, out var span)) + { + _options?.DiagnosticLogger?.LogError($"Span not found for SpanId: {data.SpanId}. Did OnStart run? We might have a bug in the SDK."); + return; + } + + var (operation, description, source) = ParseOtelSpanDescription(data, attributes); + span.Operation = operation; + span.Description = description; + + // 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; + transaction.SetData(OtelSemanticConventions.AttributeHttpResponseStatusCode, responseStatusCode); + } + + // Use the end timestamp from the activity data. + transaction.EndTimestamp = data.StartTimeUtc + data.Duration; + + // Transactions set otel attributes (and resource attributes) as context. + transaction.Contexts["otel"] = GetOtelContext(attributes); + } + else if (span is SpanTracer spanTracer) + { + // Use the end timestamp from the activity data. + spanTracer.EndTimestamp = data.StartTimeUtc + data.Duration; + + // Spans set otel attributes in extras (passed to Sentry as "data" on the span). + // Resource attributes do not need to be set, as they would be identical as those set on the transaction. + spanTracer.SetExtras(attributes); + spanTracer.SetExtra("otel.kind", data.Kind); + if (statusCode is { } responseStatusCode) + { + // Set this as a tag so that it's searchable in Sentry + span.SetTag(OtelSemanticConventions.AttributeHttpResponseStatusCode, responseStatusCode.ToString()); + } + } + + // In ASP.NET Core the middleware finishes up (and the scope gets popped) before the activity is ended. So we + // need to restore the scope here (it's saved by our middleware when the request starts) + var activityScope = GetSavedScope(data); + if (activityScope is { } savedScope) + { + var hub = _realHub.Value; + hub?.RestoreScope(savedScope); + } + GenerateSentryErrorsFromOtelSpan(data, attributes); + + var status = GetSpanStatus(data.Status, attributes); + foreach (var enricher in _enrichers) + { + enricher.Enrich(span, data, _hub, _options); + } + span.Finish(status); + + _map.TryRemove(data.SpanId, out _); + + // Housekeeping + PruneFilteredSpans(); + } + + /// + /// Clean up items that may have been filtered out. + /// See https://github.com/getsentry/sentry-dotnet/pull/3198 + /// + internal void PruneFilteredSpans(bool force = false) + { + if (!force && !NeedsPruning()) + { + return; + } + + foreach (var mappedItem in _map) + { + var (spanId, span) = mappedItem; + var activity = span.GetFused(); + // Also prune when the activity has been GC'd (weak ref returns null): the activity is gone, so it + // can never call OnEnd, and the span will never be removed otherwise — causing a memory leak. + if (activity is null or { Recorded: false, IsAllDataRequested: false }) + { + _map.TryRemove(spanId, out _); + } + } + } + + private bool NeedsPruning() + { + var lastPruned = Interlocked.Read(ref _lastPruned); + if (lastPruned > DateTime.UtcNow.Ticks - PruningInterval) + { + return false; + } + + var thisPruned = DateTime.UtcNow.Ticks; + Interlocked.CompareExchange(ref _lastPruned, thisPruned, lastPruned); + // May be false if another thread gets there first + return Interlocked.Read(ref _lastPruned) == thisPruned; + } + + private static Scope? GetSavedScope(Activity? activity) + { + while (activity is not null) + { + if (activity.GetFused() is { } savedScope) + { + return savedScope; + } + activity = activity.Parent; + } + return null; + } + + internal static SpanStatus GetSpanStatus(ActivityStatusCode status, IDictionary attributes) + { + // See https://github.com/open-telemetry/opentelemetry-dotnet/discussions/4703 + if (attributes.TryGetValue(OtelSpanAttributeConstants.StatusCodeKey, out var statusCode) + && statusCode is OtelStatusTags.ErrorStatusCodeTagValue + ) + { + return GetErrorSpanStatus(attributes); + } + return status switch + { + ActivityStatusCode.Unset => SpanStatus.Ok, + ActivityStatusCode.Ok => SpanStatus.Ok, + ActivityStatusCode.Error => GetErrorSpanStatus(attributes), + _ => SpanStatus.UnknownError + }; + } + + private static SpanStatus GetErrorSpanStatus(IDictionary attributes) + { + if (attributes.TryGetTypedValue("http.status_code", out int httpCode)) + { + return SpanStatusConverter.FromHttpStatusCode(httpCode); + } + + if (attributes.TryGetTypedValue("rpc.grpc.status_code", out int grpcCode)) + { + return SpanStatusConverter.FromGrpcStatusCode(grpcCode); + } + + return SpanStatus.UnknownError; + } + + internal static (string operation, string description, TransactionNameSource source) ParseOtelSpanDescription( + Activity activity, + IDictionary attributes) + { + // This function should loosely match the JavaScript implementation at: + // https://github.com/getsentry/sentry-javascript/blob/3487fa3af7aa72ac7fdb0439047cb7367c591e77/packages/opentelemetry-node/src/utils/parseOtelSpanDescription.ts + // However, it should also follow the OpenTelemetry semantic conventions specification, as indicated. + + // HTTP span + // https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/http/ + if (attributes.HttpMethodAttribute() is { } httpMethod) + { + if (activity.Kind == ActivityKind.Client) + { + // Per OpenTelemetry spec, client spans use only the method. + var description = (attributes.UrlFullAttribute() is { } fullUrl) + ? $"{httpMethod} {fullUrl}" + : httpMethod; + return ("http.client", description, TransactionNameSource.Custom); + } + + if (attributes.TryGetTypedValue(OtelSemanticConventions.AttributeHttpRoute, out string httpRoute)) + { + // A route exists. Use the method and route. + return ("http.server", $"{httpMethod} {httpRoute}", TransactionNameSource.Route); + } + + if (attributes.TryGetTypedValue(OtelSemanticConventions.AttributeHttpTarget, out string httpTarget)) + { + // A target exists. Use the method and target. If the target is "/" we can treat it like a route. + var source = httpTarget == "/" ? TransactionNameSource.Route : TransactionNameSource.Url; + return ("http.server", $"{httpMethod} {httpTarget}", source); + } + + // Some other type of HTTP server span. Pass it through with the original name. + return ("http.server", activity.DisplayName, TransactionNameSource.Custom); + } + + // DB span + // https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/database/ + if (attributes.ContainsKey(OtelSemanticConventions.AttributeDbSystem)) + { + if (attributes.TryGetTypedValue(OtelSemanticConventions.AttributeDbStatement, out string dbStatement)) + { + // We have a database statement. Use it. + return ("db", dbStatement, TransactionNameSource.Task); + } + + // Some other type of DB span. Pass it through with the original name. + return ("db", activity.DisplayName, TransactionNameSource.Task); + } + + // RPC span + // https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/rpc/ + if (attributes.ContainsKey(OtelSemanticConventions.AttributeRpcService)) + { + return ("rpc", activity.DisplayName, TransactionNameSource.Route); + } + + // Messaging span + // https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/messaging/ + if (attributes.ContainsKey(OtelSemanticConventions.AttributeMessagingSystem)) + { + return ("message", activity.DisplayName, TransactionNameSource.Route); + } + + // FaaS (Functions/Lambda) span + // https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/faas/ + if (attributes.TryGetTypedValue(OtelSemanticConventions.AttributeFaasTrigger, out string faasTrigger)) + { + return (faasTrigger, activity.DisplayName, TransactionNameSource.Route); + } + + // Default - pass through unmodified. + return (activity.OperationName, activity.DisplayName, TransactionNameSource.Custom); + } + + private Dictionary GetOtelContext(IDictionary attributes) + { + var otelContext = new Dictionary(); + if (attributes.Count > 0) + { + otelContext.Add("attributes", attributes); + } + + var resourceAttributes = _resourceAttributes.Value; + if (resourceAttributes.Count > 0) + { + otelContext.Add("resource", resourceAttributes); + } + + return otelContext; + } + + private void GenerateSentryErrorsFromOtelSpan(Activity activity, IDictionary spanAttributes) + { + // https://develop.sentry.dev/sdk/performance/opentelemetry/#step-7-define-generatesentryerrorsfromotelspan + // https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/exceptions/ + foreach (var @event in activity.Events.Where(e => e.Name == OtelSemanticConventions.AttributeExceptionEventName)) + { + var eventAttributes = @event.Tags.ToDict(); + // This would be where we would ideally implement full exception capture. That's not possible at the + // moment since the full exception isn't yet available via the OpenTelemetry API. + // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/2439#issuecomment-1577314568 + if (!eventAttributes.TryGetTypedValue(OtelSemanticConventions.AttributeExceptionType, out string exceptionType)) + { + continue; + } + eventAttributes.TryGetTypedValue(OtelSemanticConventions.AttributeExceptionMessage, out string message); + eventAttributes.TryGetTypedValue(OtelSemanticConventions.AttributeExceptionStacktrace, out string stackTrace); + + Exception exception; + try + { + if (CreatePoorMansException(exceptionType, message) is not { } poorMansException) + { + _options?.DiagnosticLogger?.LogWarning($"Unable to create poor man's exception with trimming enabled : {exceptionType}"); + continue; + } + exception = poorMansException; + } + catch + { + _options?.DiagnosticLogger?.LogError($"Failed to create poor man's exception for type : {exceptionType}"); + continue; + } + + var sentryEvent = new SentryEvent(exception, @event.Timestamp); + var otelContext = GetOtelContext(spanAttributes); + otelContext.Add("stack_trace", stackTrace); + sentryEvent.Contexts["otel"] = otelContext; + _hub.CaptureEvent(sentryEvent, scope => + { + var trace = scope.Contexts.Trace; + trace.SpanId = activity.SpanId.AsSentrySpanId(); + trace.ParentSpanId = activity.ParentSpanId.AsSentrySpanId(); + trace.TraceId = activity.TraceId.AsSentryId(); + }); + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2057", Justification = AotHelper.AvoidAtRuntime)] + private static Exception? CreatePoorMansException(string exceptionType, string message) + { + if (AotHelper.IsTrimmed) + { + return null; + } + + var type = Type.GetType(exceptionType)!; + var exception = (Exception)Activator.CreateInstance(type, message)!; + exception.SetSentryMechanism("SentryActivityProcessor.ErrorSpan"); + return exception; + } +} +#endif diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 3ff7453ca3..1b84824b11 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -53,6 +53,9 @@ $(DefineConstants);HAS_DIAGNOSTIC_INTEGRATION + + $(DefineConstants);HAS_ACTIVITY_LISTENER diff --git a/test/Sentry.Tests/Internals/Tracing/ActivitySourceTests.cs b/test/Sentry.Tests/Internals/Tracing/ActivitySourceTests.cs new file mode 100644 index 0000000000..269e792d4f --- /dev/null +++ b/test/Sentry.Tests/Internals/Tracing/ActivitySourceTests.cs @@ -0,0 +1,37 @@ +#if NET8_0_OR_GREATER +namespace Sentry.Tests.Internals.Tracing; + +/// +/// Base class for tests that need a recording ActivitySource. This is the core equivalent of +/// Sentry.OpenTelemetry.Tests.ActivitySourceTests, with the OpenTelemetry SDK's TracerProvider replaced by a +/// raw configured to record everything (matching the OTel default sampler used +/// in those tests). +/// +public abstract class ActivitySourceTests : IDisposable +{ + protected readonly ActivitySource Tracer; + private readonly ActivityListener _listener; + + protected ActivitySourceTests() + { + // Use a unique name per test class instance so parallel tests don't listen to each other's sources. + Tracer = new ActivitySource($"SentryActivityProcessorTests-{Guid.NewGuid()}"); + _listener = new ActivityListener + { + ShouldListenTo = source => source == Tracer, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() + { + _listener.Dispose(); + Tracer.Dispose(); + GC.SuppressFinalize(this); + } +} +#endif diff --git a/test/Sentry.Tests/Internals/Tracing/SentryActivityListenerTests.cs b/test/Sentry.Tests/Internals/Tracing/SentryActivityListenerTests.cs new file mode 100644 index 0000000000..f658fd42f3 --- /dev/null +++ b/test/Sentry.Tests/Internals/Tracing/SentryActivityListenerTests.cs @@ -0,0 +1,123 @@ +#if NET8_0_OR_GREATER +using Sentry.Internal.Tracing; + +namespace Sentry.Tests.Internals.Tracing; + +/// +/// End-to-end tests for : Activities created via ActivitySource are +/// captured as Sentry transactions/spans with no OpenTelemetry SDK involved — the listener's own +/// Sample/ActivityStarted/ActivityStopped callbacks drive the whole pipeline. +/// +public class SentryActivityListenerTests +{ + private class Fixture + { + public SentryOptions Options { get; } + public ISentryClient Client { get; } + + public Fixture() + { + Options = new SentryOptions + { + Dsn = ValidDsn, + TracesSampleRate = 1.0, + AutoSessionTracking = false, + Instrumenter = Instrumenter.OpenTelemetry + }; + Client = Substitute.For(); + } + + public Hub Hub { get; private set; } + + public Hub GetHub() => Hub ??= new Hub(Options, Client); + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void ActivityStopped_RootActivity_CapturesTransaction() + { + // Arrange + var hub = _fixture.GetHub(); + using var source = new ActivitySource($"{nameof(SentryActivityListenerTests)}-{Guid.NewGuid()}"); + using var listener = new SentryActivityListener(hub, s => s == source); + + // Act + var activity = source.StartActivity("test operation")!; + activity.DisplayName = "test display name"; + activity.Stop(); + + // Assert + _fixture.Client.Received(1).CaptureTransaction( + Arg.Is(t => + t.Name == "test display name" && + t.Operation == "test operation" && + t.IsSampled == true), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void ActivityStopped_ChildActivity_CapturesSpanOnTransaction() + { + // Arrange + var hub = _fixture.GetHub(); + using var source = new ActivitySource($"{nameof(SentryActivityListenerTests)}-{Guid.NewGuid()}"); + using var listener = new SentryActivityListener(hub, s => s == source); + + // Act + var parent = source.StartActivity("parent operation")!; + var child = source.StartActivity("child operation")!; + child.Stop(); + parent.Stop(); + + // Assert + _fixture.Client.Received(1).CaptureTransaction( + Arg.Is(t => + t.Operation == "parent operation" && + t.Spans.Count == 1 && + t.Spans.Single().Operation == "child operation" && + t.Spans.Single().ParentSpanId == t.Contexts.Trace.SpanId), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void ShouldListenTo_OtherSource_IsIgnored() + { + // Arrange + var hub = _fixture.GetHub(); + using var source = new ActivitySource($"{nameof(SentryActivityListenerTests)}-{Guid.NewGuid()}"); + using var otherSource = new ActivitySource($"other-{Guid.NewGuid()}"); + using var listener = new SentryActivityListener(hub, s => s == source); + + // Act + // No other listener is subscribed to otherSource, so StartActivity returns null — exactly the + // ActivitySource.HasListeners() zero-cost property the migration relies on. + var activity = otherSource.StartActivity("ignored operation"); + + // Assert + activity.Should().BeNull(); + _fixture.Client.DidNotReceive().CaptureTransaction( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public void ActivityStopped_UnsampledActivity_DoesNotCaptureTransaction() + { + // Arrange + _fixture.Options.TracesSampleRate = 0.0; + var hub = _fixture.GetHub(); + using var source = new ActivitySource($"{nameof(SentryActivityListenerTests)}-{Guid.NewGuid()}"); + using var listener = new SentryActivityListener(hub, s => s == source); + + // Act + var activity = source.StartActivity("test operation")!; + activity.Stop(); + + // Assert + _fixture.Client.DidNotReceive().CaptureTransaction( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} +#endif diff --git a/test/Sentry.Tests/Internals/Tracing/SentryActivityProcessorTests.cs b/test/Sentry.Tests/Internals/Tracing/SentryActivityProcessorTests.cs new file mode 100644 index 0000000000..892224e850 --- /dev/null +++ b/test/Sentry.Tests/Internals/Tracing/SentryActivityProcessorTests.cs @@ -0,0 +1,1023 @@ +#if NET8_0_OR_GREATER +using Sentry.Internal.OpenTelemetry; +using Sentry.Internal.Tracing; + +namespace Sentry.Tests.Internals.Tracing; + +/// +/// Port of Sentry.OpenTelemetry.Tests.SentrySpanProcessorTests, running the same scenarios against the core +/// (driven by ActivityListener callbacks rather than the OTel SDK's +/// BaseProcessor). Test bodies are kept as close to the originals as possible so the two suites can be diffed. +/// +public class SentryActivityProcessorTests : ActivitySourceTests +{ + private class Fixture + { + public SentryOptions Options { get; } + + public ISentryClient Client { get; } + + public ISessionManager SessionManager { get; set; } + + public IInternalScopeManager ScopeManager { get; set; } + + public ISystemClock Clock { get; set; } + + public List Enrichers { get; set; } = new(); + + private IReplaySession ReplaySession { get; } = Substitute.For(); + + public Fixture() + { + Options = new SentryOptions + { + Dsn = ValidDsn, + TracesSampleRate = 1.0, + AutoSessionTracking = false + }; + + Client = Substitute.For(); + } + + public Hub Hub { get; private set; } + + private Hub GetHub() => Hub ??= new Hub(Options, Client, SessionManager, Clock, ScopeManager, replaySession: ReplaySession); + + public SentryActivityProcessor GetSut(IHub hub = null) + { + return new SentryActivityProcessor(hub ?? GetHub(), Enrichers, ReplaySession); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_Instrumenter_OpenTelemetry_DoesNotThrowException() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + + // Act + var sut = _fixture.GetSut(); + + // Assert + Assert.NotNull(sut); + } + + [Fact] + public void Ctor_Instrumenter_Not_OpenTelemetry_Throws() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.Sentry; + + // Act & Assert + Assert.Throws(() => _fixture.GetSut()); + } + + [Fact] + public void GetSpanStatus() + { + using (new AssertionScope()) + { + var noAttributes = new Dictionary(); + + // Unset and OK -> OK + SentryActivityProcessor.GetSpanStatus(ActivityStatusCode.Unset, noAttributes).Should().Be(SpanStatus.Ok); + SentryActivityProcessor.GetSpanStatus(ActivityStatusCode.Ok, noAttributes).Should().Be(SpanStatus.Ok); + + // Error (no attributes) -> UnknownError + SentryActivityProcessor.GetSpanStatus(ActivityStatusCode.Error, noAttributes) + .Should().Be(SpanStatus.UnknownError); + + // Unknown status code -> UnknownError + SentryActivityProcessor.GetSpanStatus((ActivityStatusCode)42, noAttributes) + .Should().Be(SpanStatus.UnknownError); + + // We only test one http scenario, just to make sure the SpanStatusConverter is called for these headers. + // Tests for SpanStatusConverter ensure other http status codes would also work though + var notFoundAttributes = new Dictionary { ["http.status_code"] = 404 }; + SentryActivityProcessor.GetSpanStatus(ActivityStatusCode.Error, notFoundAttributes) + .Should().Be(SpanStatus.NotFound); + + // We only test one grpc scenario, just to make sure the SpanStatusConverter is called for these headers. + // Tests for SpanStatusConverter ensure other grpc status codes would also work though + var grpcAttributes = new Dictionary { ["rpc.grpc.status_code"] = 7 }; + SentryActivityProcessor.GetSpanStatus(ActivityStatusCode.Error, grpcAttributes) + .Should().Be(SpanStatus.PermissionDenied); + + var errorAttributes = new Dictionary { [OtelSpanAttributeConstants.StatusCodeKey] = OtelStatusTags.ErrorStatusCodeTagValue }; + SentryActivityProcessor.GetSpanStatus(ActivityStatusCode.Ok, errorAttributes).Should().Be(SpanStatus.UnknownError); + SentryActivityProcessor.GetSpanStatus(ActivityStatusCode.Unset, errorAttributes).Should().Be(SpanStatus.UnknownError); + } + } + + [Fact] + public void OnStart_Transaction_With_DynamicSamplingContext() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var expected = new Dictionary() + { + { "trace_id", SentryId.Create().ToString() }, + { "public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff" }, + { "sample_rate", "0.5" }, + }; + var data = Tracer.StartActivity("test op")!; + data.AddBaggage($"{BaggageHeader.SentryKeyPrefix}trace_id", expected["trace_id"]); + data.AddBaggage($"{BaggageHeader.SentryKeyPrefix}public_key", expected["public_key"]); + data.AddBaggage($"{BaggageHeader.SentryKeyPrefix}sample_rate", expected["sample_rate"]); + + // Act + sut.OnStart(data!); + + // Assert + Assert.True(sut._map.TryGetValue(data.SpanId, out var span)); + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + if (transaction.DynamicSamplingContext is not { } actual) + { + Assert.Fail("Transaction does not have a dynamic sampling context"); + return; + } + using (new AssertionScope()) + { + actual.Items["trace_id"].Should().Be(expected["trace_id"]); + actual.Items["public_key"].Should().Be(expected["public_key"]); + actual.Items["sample_rate"].Should().NotBe(expected["sample_rate"]); + actual.Items["sample_rate"].Should().Be(_fixture.Options.TracesSampleRate.ToString()); + } + } + + [Fact] + public void OnStart_SampledWithParentSpanId_StartsChildSpan() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.Options.TracesSampleRate = 1.0; + var sut = _fixture.GetSut(); + + using var parent = Tracer.StartActivity("Parent"); + sut.OnStart(parent); + + using var data = Tracer.StartActivity("TestActivity"); + + // Act + sut.OnStart(data!); + + // Assert + Assert.True(sut._map.TryGetValue(data.SpanId, out var span)); + using (new AssertionScope()) + { + span.IsSampled.Should().Be(true); + span.SpanId.Should().Be(data.SpanId.AsSentrySpanId()); + + if (span is not SpanTracer spanTracer) + { + Assert.Fail("Span is not a span tracer"); + return; + } + + span.SpanId.Should().Be(data.SpanId.AsSentrySpanId()); + using (new AssertionScope()) + { + spanTracer.ParentSpanId.Should().Be(data.ParentSpanId.AsSentrySpanId()); + spanTracer.TraceId.Should().Be(data.TraceId.AsSentryId()); + spanTracer.Operation.Should().Be(data.OperationName); + spanTracer.Description.Should().Be(data.DisplayName); + spanTracer.Status.Should().BeNull(); + spanTracer.StartTimestamp.Should().Be(data.StartTimeUtc); + } + } + } + + [Fact] + public void OnStart_NotSampledWithParentSpanId_StartsChildSpan() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.Options.TracesSampleRate = 0.0; + var sut = _fixture.GetSut(); + + using var parent = Tracer.StartActivity("Parent"); + sut.OnStart(parent); + + using var data = Tracer.StartActivity("TestActivity"); + + // Act + sut.OnStart(data!); + + // Assert + Assert.True(sut._map.TryGetValue(data.SpanId, out var span)); + using (new AssertionScope()) + { + span.IsSampled.Should().Be(false); + span.SpanId.Should().Be(data.SpanId.AsSentrySpanId()); + span.Should().BeOfType(); + } + } + + [Fact] + public void OnStart_WithSentryParentSpanId_StartsChildSpan() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var parent = _fixture.Hub.StartTransaction("Program", "Main"); + _fixture.Hub.ConfigureScope(scope => scope.Transaction = parent); + + using var data = Tracer.StartActivity("TestActivity"); + + // Act + sut.OnStart(data!); + + // Assert + ((IBaseTracer)parent).IsOtelInstrumenter.Should().BeFalse(); + Assert.True(sut._map.TryGetValue(data.SpanId, out var span)); + using (new AssertionScope()) + { + span.Should().BeOfType(); + span.SpanId.Should().Be(data.SpanId.AsSentrySpanId()); + if (span is not SpanTracer spanTracer) + { + Assert.Fail("Span is not a span tracer"); + return; + } + using (new AssertionScope()) + { + ((IBaseTracer)spanTracer).IsOtelInstrumenter.Should().BeTrue(); + spanTracer.SpanId.Should().Be(data.SpanId.AsSentrySpanId()); + spanTracer.ParentSpanId.Should().Be(parent.SpanId); + spanTracer.TraceId.Should().Be(parent.TraceId); + spanTracer.Operation.Should().Be(data.OperationName); + spanTracer.Description.Should().Be(data.DisplayName); + spanTracer.Status.Should().BeNull(); + spanTracer.StartTimestamp.Should().Be(data.StartTimeUtc); + } + } + } + + + [Fact] + public void StartSpan_UsingSentryTracing_StartsChildSpan() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + using var parent = Tracer.StartActivity("Parent"); + sut.OnStart(parent); + + // Act + var span = _fixture.Hub.StartSpan("foo", "bar"); + + // Assert + Assert.True(sut._map.TryGetValue(parent.SpanId, out var transaction)); + using (new AssertionScope()) + { + if (span is not SpanTracer spanTracer) + { + Assert.Fail("Span is not a span tracer"); + return; + } + spanTracer.ParentSpanId.Should().Be(transaction.SpanId); + spanTracer.TraceId.Should().Be(transaction.TraceId); + spanTracer.Operation.Should().Be("foo"); + spanTracer.Description.Should().Be("bar"); + spanTracer.Status.Should().BeNull(); + } + } + + [Fact] + public void OnStart_SampledWithoutParentSpanId_StartsNewTransaction() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.Options.TracesSampleRate = 1.0; + _fixture.ScopeManager = Substitute.For(); + var scope = new Scope(); + var clientScope = new KeyValuePair(scope, _fixture.Client); + _fixture.ScopeManager.GetCurrent().Returns(clientScope); + var sut = _fixture.GetSut(); + + var data = Tracer.StartActivity("test op"); + + // Act + sut.OnStart(data!); + + // Assert + Assert.True(sut._map.TryGetValue(data.SpanId, out var span)); + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) + { + transaction.SpanId.Should().Be(data.SpanId.AsSentrySpanId()); + transaction.ParentSpanId.Should().Be(new ActivitySpanId().AsSentrySpanId()); + transaction.TraceId.Should().Be(data.TraceId.AsSentryId()); + transaction.Name.Should().Be(data.DisplayName); + transaction.Operation.Should().Be(data.OperationName); + transaction.Description.Should().Be(data.DisplayName); + transaction.Status.Should().BeNull(); + transaction.StartTimestamp.Should().Be(data.StartTimeUtc); + } + } + + [Fact] + public void OnStart_NotSampledWithoutParentSpanId_StartsNewTransaction() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.Options.TracesSampleRate = 0.0; + _fixture.ScopeManager = Substitute.For(); + var scope = new Scope(); + var clientScope = new KeyValuePair(scope, _fixture.Client); + _fixture.ScopeManager.GetCurrent().Returns(clientScope); + var sut = _fixture.GetSut(); + + var data = Tracer.StartActivity("test op"); + + // Act + sut.OnStart(data!); + + // Assert + Assert.True(sut._map.TryGetValue(data.SpanId, out var span)); + if (span is not UnsampledTransaction transaction) + { + Assert.Fail("Span is not an unsampled transaction"); + return; + } + + using (new AssertionScope()) + { + transaction.SpanId.Should().Be(data.SpanId.AsSentrySpanId()); + transaction.TraceId.Should().Be(data.TraceId.AsSentryId()); + } + } + + [Fact] + public void OnEnd_Sampled_Span_FinishesSpan() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var parent = Tracer.StartActivity(name: "transaction")!; + sut.OnStart(parent); + + var tags = new Dictionary { + { "foo", "bar" } + }; + var data = Tracer.StartActivity(name: "test operation", kind: ActivityKind.Internal, parentContext: default, tags)!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not SpanTracer spanTracer) + { + Assert.Fail("Span is not a span tracer"); + return; + } + + using (new AssertionScope()) + { + spanTracer.ParentSpanId.Should().Be(parent.SpanId.AsSentrySpanId()); + spanTracer.Operation.Should().Be(data.OperationName); + spanTracer.Description.Should().Be(data.DisplayName); + spanTracer.EndTimestamp.Should().NotBeNull(); + spanTracer.Extra["otel.kind"].Should().Be(data.Kind); + foreach (var keyValuePair in tags) + { + span.Extra[keyValuePair.Key].Should().Be(keyValuePair.Value); + } + + spanTracer.Status.Should().Be(SpanStatus.Ok); + spanTracer.Origin.Should().Be(SentryActivityProcessor.OpenTelemetryOrigin); + } + } + + [Fact] + public void OnEnd_Unsampled_Span_DoesNotThrow() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.Options.TracesSampleRate = 0.0; + var sut = _fixture.GetSut(); + + var parent = Tracer.StartActivity(name: "transaction")!; + sut.OnStart(parent); + + Dictionary tags = []; + var data = Tracer.StartActivity(name: "test operation", kind: ActivityKind.Internal, parentContext: default, tags)!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + span.Should().BeOfType(); + + // There's nothing else to assert here, as long as calling OnEnd does not throw an exception, + // UnsampledSpan.Finish() is basically a no-op. + } + + [Fact] + public void OnEnd_Transaction_SetsResponseStatusCode() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var tags = new Dictionary { + { OtelSemanticConventions.AttributeHttpResponseStatusCode, 404 } + }; + var data = Tracer.StartActivity( + name: "test operation", + kind: ActivityKind.Server, + parentContext: default, + tags + ); + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) + { + transaction.Contexts.Response.StatusCode.Should().Be(404); + transaction.Data.Should().Contain(OtelSemanticConventions.AttributeHttpResponseStatusCode, 404); + } + } + + [Fact] + public void OnEnd_Transaction_DoesNotClearResponseStatusCode() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var data = Tracer.StartActivity( + name: "test operation", + kind: ActivityKind.Server, + parentContext: default, + new Dictionary() + ); + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + (span as TransactionTracer)!.Contexts.Response.StatusCode = 200; + + // Act + sut.OnEnd(data); + + // Assert + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) + { + transaction.Contexts.Response.StatusCode.Should().Be(200); + } + } + + [Fact] + public void OnEnd_Span_SetsResponseStatusCode() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var parent = Tracer.StartActivity(name: "transaction")!; + sut.OnStart(parent); + + var tags = new Dictionary { + { OtelSemanticConventions.AttributeHttpResponseStatusCode, 404 } + }; + var data = Tracer.StartActivity( + name: "test operation", + kind: ActivityKind.Server, + parentContext: default, + tags + ); + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not SpanTracer spanTracer) + { + Assert.Fail("Span is not a span tracer"); + return; + } + + using (new AssertionScope()) + { + spanTracer.Tags.TryGetValue(OtelSemanticConventions.AttributeHttpResponseStatusCode, + out var responseStatusCode).Should().BeTrue(); + responseStatusCode.Should().Be("404"); + } + } + + [Fact] + public void OnEnd_Transaction_RestoresSavedScope() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.ScopeManager = Substitute.For(); + var dummyScope = new Scope(); + var clientScope = new KeyValuePair(dummyScope, _fixture.Client); + _fixture.ScopeManager.GetCurrent().Returns(clientScope); + var sut = _fixture.GetSut(); + + var scope = new Scope(); + var data = Tracer.StartActivity("transaction")!; + data.SetFused(scope); + sut.OnStart(data); + + // Act + sut.OnEnd(data); + + // Assert + _fixture.ScopeManager.Received(1).RestoreScope(scope); + } + + [Fact] + public void OnEnd_Span_RestoresSavedScope() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.ScopeManager = Substitute.For(); + var dummyScope = new Scope(); + var clientScope = new KeyValuePair(dummyScope, _fixture.Client); + _fixture.ScopeManager.GetCurrent().Returns(clientScope); + var sut = _fixture.GetSut(); + + var scope = new Scope(); + var parent = Tracer.StartActivity("transaction")!; + parent.SetFused(scope); + sut.OnStart(parent); + + var data = Tracer.StartActivity("test operation")!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + // Act + sut.OnEnd(data); + + // Assert + _fixture.ScopeManager.Received(1).RestoreScope(scope); + } + + [Fact] + public void OnEnd_SpansEnriched() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var mockEnricher = Substitute.For(); + mockEnricher.Enrich(Arg.Do(s => s.SetTag("foo", "bar")), Arg.Any(), Arg.Any(), Arg.Any()); + _fixture.Enrichers.Add(mockEnricher); + var sut = _fixture.GetSut(); + + var parent = Tracer.StartActivity(name: "transaction")!; + sut.OnStart(parent); + + sut._map.TryGetValue(parent.SpanId, out var span); + + // Act + sut.OnEnd(parent); + + // Assert + if (span is not TransactionTracer transactionTracer) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + transactionTracer.Tags.TryGetValue("foo", out var foo).Should().BeTrue(); + foo.Should().Be("bar"); + } + + [Fact] + public void OnEnd_Sampled_FinishesTransaction() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var tags = new Dictionary { + { "foo", "bar" } + }; + var data = Tracer.StartActivity(name: "test operation", kind: ActivityKind.Internal, parentContext: default, tags)!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) + { + transaction.ParentSpanId.Should().Be(new ActivitySpanId().AsSentrySpanId()); + transaction.Operation.Should().Be(data.OperationName); + transaction.Description.Should().Be(data.DisplayName); + transaction.Name.Should().Be(data.DisplayName); + transaction.NameSource.Should().Be(TransactionNameSource.Custom); + transaction.EndTimestamp.Should().NotBeNull(); + transaction.Contexts["otel"].Should().BeEquivalentTo(new Dictionary + { + { "attributes", tags } + }); + transaction.Contexts.Trace.Origin.Should().Be(SentryActivityProcessor.OpenTelemetryOrigin); + transaction.Status.Should().Be(SpanStatus.Ok); + } + } + + [Fact] + public void OnEnd_NotSampled_FinishesTransaction() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.Options.TracesSampleRate = 0.0; + var sut = _fixture.GetSut(); + + Dictionary tags = []; + var data = Tracer.StartActivity(name: "test operation", kind: ActivityKind.Internal, parentContext: default, tags)!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not UnsampledTransaction transaction) + { + Assert.Fail("Span is not an unsampled transaction"); + return; + } + transaction.IsFinished.Should().BeTrue(); + } + + [Fact] + public void OnEnd_FilteredTransaction_DoesNotFinishTransaction() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var parent = Tracer.StartActivity("transaction")!; + sut.OnStart(parent); + + var data = Tracer.StartActivity("test operation", kind: ActivityKind.Internal)!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + FilterActivity(parent); + + sut._map.TryGetValue(parent.SpanId, out var span); + + // Act + sut.OnEnd(data); + sut.OnEnd(parent); + + // Assert + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) + { + transaction.EndTimestamp.Should().BeNull(); + transaction.Status.Should().BeNull(); + } + } + + [Fact] + public void OnEnd_FilteredSpan_RemovesSpan() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var parent = Tracer.StartActivity("transaction")!; + sut.OnStart(parent); + + var data = Tracer.StartActivity("test operation", kind: ActivityKind.Internal)!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + FilterActivity(data); + + sut._map.TryGetValue(parent.SpanId, out var parentSpan); + sut._map.TryGetValue(data.SpanId, out var childSpan); + + // Act + sut.OnEnd(data); + sut.OnEnd(parent); + + // Assert + if (parentSpan is not TransactionTracer transaction) + { + Assert.Fail("parentSpan is not a transaction tracer"); + return; + } + if (childSpan is not SpanTracer span) + { + Assert.Fail("span is not a span tracer"); + return; + } + + using (new AssertionScope()) + { + span.EndTimestamp.Should().BeNull(); + span.Status.Should().BeNull(); + + transaction.EndTimestamp.Should().NotBeNull(); + transaction.Status.Should().Be(SpanStatus.Ok); + transaction.Spans.Should().BeEmpty(); + } + } + + [Theory] + [InlineData(OtelSemanticConventions.AttributeUrlFull)] + [InlineData(OtelSemanticConventions.AttributeHttpUrl)] + public void OnEnd_IsSentryRequest_DoesNotFinishTransaction(string urlKey) + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var tags = new Dictionary { { "foo", "bar" }, { urlKey, _fixture.Options.Dsn } }; + var data = Tracer.StartActivity(name: "test operation", kind: ActivityKind.Internal, parentContext: default, tags)!; + data.DisplayName = "test display name"; + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + transaction.IsSentryRequest.Should().BeTrue(); + } + + [Fact] + public void OnStart_DisabledHub_DoesNothing() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + SentryClientExtensions.SentryOptionsForTestingOnly = _fixture.Options; + var hub = Substitute.For(); + hub.IsEnabled.Returns(false); + var sut = _fixture.GetSut(hub); + + var data = Tracer.StartActivity()!; + + // Act + sut.OnStart(data); + + // Assert + sut._map.Should().BeEmpty(); + hub.Received(0).GetSpan(); + hub.Received(0).StartTransaction( + Arg.Any(), + Arg.Any>() + ); + } + + [Fact] + public void OnEnd_DisabledHub_DoesNothing() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + SentryClientExtensions.SentryOptionsForTestingOnly = _fixture.Options; + var hub = Substitute.For(); + hub.IsEnabled.Returns(true, false); + var sut = _fixture.GetSut(hub); + + var data = Tracer.StartActivity()!; + + // Act + sut.OnEnd(data); + + // Assert + sut._map.Should().BeEmpty(); + hub.Received(0).GetSpan(); + hub.Received(0).StartTransaction( + Arg.Any(), + Arg.Any>() + ); + } + + private static void FilterActivity(Activity activity) + { + // Simulates filtering an activity - see https://github.com/getsentry/sentry-dotnet/pull/3198 + activity.IsAllDataRequested = false; + activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + } + + [Fact] + public void PruneFilteredSpans_FilteredTransactions_Pruned() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + using var parent = Tracer.StartActivity(); + sut.OnStart(parent!); + + FilterActivity(parent); + + // Act + sut.PruneFilteredSpans(true); + + // Assert + Assert.False(sut._map.TryGetValue(parent.SpanId, out var _)); + } + + [Fact] + public void PruneFilteredSpans_UnFilteredTransactions_NotPruned() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + using var parent = Tracer.StartActivity(); + sut.OnStart(parent!); + + // Act + sut.PruneFilteredSpans(true); + + // Assert + Assert.True(sut._map.TryGetValue(parent.SpanId, out var _)); + } + + [Fact] + public void PruneFilteredSpans_FilteredSpans_Pruned() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + using var parent = Tracer.StartActivity(); + sut.OnStart(parent!); + + using var activity1 = Tracer.StartActivity(); + sut.OnStart(activity1!); + + using var activity2 = Tracer.StartActivity(); + sut.OnStart(activity2!); + + FilterActivity(activity2); + + // Act + sut.PruneFilteredSpans(true); + + // Assert + Assert.True(sut._map.TryGetValue(activity1.SpanId, out var _)); + Assert.False(sut._map.TryGetValue(activity2.SpanId, out var _)); + } + + [Fact] + public void PruneFilteredSpans_UnsampledSpanWithRecordedActivity_NotPruned() + { + // Arrange — Sentry drops the transaction (TracesSampleRate = 0), but the listener still records the Activity. + // CreateChildSpan produces an UnsampledSpan. PruneFilteredSpans must not remove it prematurely, + // otherwise OnEnd (which fires because Recorded = true) logs a "Span not found" error. + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + _fixture.Options.TracesSampleRate = 0.0; + var sut = _fixture.GetSut(); + + using var parent = Tracer.StartActivity("Parent"); + sut.OnStart(parent!); + + using var child = Tracer.StartActivity("Child"); + sut.OnStart(child!); + + sut._map.TryGetValue(child!.SpanId, out var span).Should().BeTrue(); + span.Should().BeOfType(); + + // Act + sut.PruneFilteredSpans(true); + + // Assert — the UnsampledSpan is still live (Recorded = true), so it must not be pruned. + sut._map.TryGetValue(child.SpanId, out _).Should().BeTrue(); + } + + [Fact] + public void PruneFilteredSpans_GarbageCollectedActivity_Pruned() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + // Simulate a span whose fused activity has been GC'd (GetFused() returns null). + // This can happen when a filtered activity is short-lived and collected before PruneFilteredSpans runs. + var spanId = ActivitySpanId.CreateRandom(); + var orphanedSpan = Substitute.For(); + sut._map[spanId] = orphanedSpan; + + // Act + sut.PruneFilteredSpans(true); + + // Assert + Assert.False(sut._map.TryGetValue(spanId, out _)); + } + + [Fact] + public void PruneFilteredSpans_RecentlyPruned_DoesNothing() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + sut._lastPruned = DateTimeOffset.MaxValue.Ticks; // fake a recent prune + + using var parent = Tracer.StartActivity(); + sut.OnStart(parent!); + + using var activity1 = Tracer.StartActivity(); + sut.OnStart(activity1!); + + using var activity2 = Tracer.StartActivity(); + sut.OnStart(activity2!); + + FilterActivity(activity2); + + // Act + sut.PruneFilteredSpans(); + + // Assert + Assert.True(sut._map.TryGetValue(activity1.SpanId, out var _)); + Assert.True(sut._map.TryGetValue(activity2.SpanId, out var _)); + } + + [Fact] + public void ParseOtelSpanDescription_HttpClient() + { + // Arrange + var data = Tracer.StartActivity("test op", ActivityKind.Client)!; + var attributes = new Dictionary() + { + [OtelSemanticConventions.AttributeHttpRequestMethod] = "POST", + [OtelSemanticConventions.AttributeUrlFull] = "https://example.com/foo", + }; + + // Act + var (operation, description, source) = SentryActivityProcessor.ParseOtelSpanDescription(data, attributes); + + // Assert + operation.Should().Be("http.client"); + description.Should().Be("POST https://example.com/foo"); + source.Should().Be(TransactionNameSource.Custom); + } +} +#endif From 9997cc31620be3de72066e63f07db4047e829bd7 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 13 Jul 2026 17:22:23 +1200 Subject: [PATCH 2/4] spike: move Activity tracing into Sentry.DiagnosticSource for all-TFM support Validates the packaging model for supporting Activity-based tracing on every TFM without adding a dependency to the core Sentry package: - The tracing sources move to src/Sentry.DiagnosticSource/Internal/Tracing/, so the existing compile-include in Sentry.csproj ships them inside core for modern TFMs (where ActivityListener is part of the shared framework), while legacy TFMs (netstandard2.0/2.1/net462) get them by referencing the Sentry.DiagnosticSource package - the same split the EF/SqlClient DiagnosticSource integration uses today. - Sentry.DiagnosticSource bumps System.Diagnostics.DiagnosticSource from 4.5.0 to 8.0.1. ActivityListener needs 5.0 and ActivityStatusCode needs 6.0, but the netstandard2.0/net462 assets of 6.0 lag the modern API surface (no Activity.HasRemoteParent), making 8.0.1 the effective minimum. - ActivityIdExtensions regains the non-NET8 hex-string conversion branches and the processor avoids KeyValuePair deconstruction, so the sources compile on the legacy TFMs. - Tests move to Sentry.DiagnosticSource.Tests, where modern TFMs exercise the core-compiled path and net48 (Windows CI) exercises the standalone package path: one suite covers both packagings. Verified locally (macOS): Sentry builds on all TFMs; Sentry.DiagnosticSource builds on netstandard2.0/2.1/net462; Sentry.DiagnosticSource.Tests 119/119 on net10.0; Sentry.Tests 2402 passed; OTel packages build unchanged. #skip-changelog Co-Authored-By: Claude Opus 4.8 --- .../Tracing/ActivityAttributeExtensions.cs | 0 .../Internal/Tracing/ActivityIdExtensions.cs | 15 +++++++++++++++ .../Internal/Tracing/ISentryActivityEnricher.cs | 0 .../Internal/Tracing/SentryActivityListener.cs | 0 .../Internal/Tracing/SentryActivityProcessor.cs | 4 +++- .../Sentry.DiagnosticSource.csproj | 11 ++++++++++- src/Sentry/Sentry.csproj | 6 ++++-- .../Tracing/ActivitySourceTests.cs | 4 +--- .../Tracing/SentryActivityListenerTests.cs | 4 +--- .../Tracing/SentryActivityProcessorTests.cs | 4 +--- 10 files changed, 35 insertions(+), 13 deletions(-) rename src/{Sentry => Sentry.DiagnosticSource}/Internal/Tracing/ActivityAttributeExtensions.cs (100%) rename src/{Sentry => Sentry.DiagnosticSource}/Internal/Tracing/ActivityIdExtensions.cs (68%) rename src/{Sentry => Sentry.DiagnosticSource}/Internal/Tracing/ISentryActivityEnricher.cs (100%) rename src/{Sentry => Sentry.DiagnosticSource}/Internal/Tracing/SentryActivityListener.cs (100%) rename src/{Sentry => Sentry.DiagnosticSource}/Internal/Tracing/SentryActivityProcessor.cs (99%) rename test/{Sentry.Tests/Internals => Sentry.DiagnosticSource.Tests}/Tracing/ActivitySourceTests.cs (95%) rename test/{Sentry.Tests/Internals => Sentry.DiagnosticSource.Tests}/Tracing/SentryActivityListenerTests.cs (98%) rename test/{Sentry.Tests/Internals => Sentry.DiagnosticSource.Tests}/Tracing/SentryActivityProcessorTests.cs (99%) diff --git a/src/Sentry/Internal/Tracing/ActivityAttributeExtensions.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs similarity index 100% rename from src/Sentry/Internal/Tracing/ActivityAttributeExtensions.cs rename to src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs diff --git a/src/Sentry/Internal/Tracing/ActivityIdExtensions.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityIdExtensions.cs similarity index 68% rename from src/Sentry/Internal/Tracing/ActivityIdExtensions.cs rename to src/Sentry.DiagnosticSource/Internal/Tracing/ActivityIdExtensions.cs index 2ecdabd4a2..194b0d807f 100644 --- a/src/Sentry/Internal/Tracing/ActivityIdExtensions.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityIdExtensions.cs @@ -6,25 +6,40 @@ namespace Sentry.Internal.Tracing; // internals via InternalsVisibleTo. The copies converge when the OTel packages are rebased onto core (see spike notes). internal static class ActivityIdExtensions { + private const int HexCharsPerByte = 2; private const int SpanIdByteCount = sizeof(long); + private const int SpanIdHexCharCount = SpanIdByteCount * HexCharsPerByte; private static readonly int TraceIdByteCount = Unsafe.SizeOf(); + internal static readonly int TraceIdHexCharCount = TraceIdByteCount * HexCharsPerByte; public static SpanId AsSentrySpanId(this ActivitySpanId id) => SpanId.Parse(id.ToHexString()); public static ActivitySpanId AsActivitySpanId(this SpanId id) { +#if NET8_0_OR_GREATER Span buffer = stackalloc byte[SpanIdByteCount]; id.TryWriteBytes(buffer); return ActivitySpanId.CreateFromBytes(buffer); +#else + Span buffer = stackalloc char[SpanIdHexCharCount]; + id.TryFormat(buffer); + return ActivitySpanId.CreateFromString(buffer); +#endif } public static SentryId AsSentryId(this ActivityTraceId id) => SentryId.Parse(id.ToHexString()); public static ActivityTraceId AsActivityTraceId(this SentryId id) { +#if NET8_0_OR_GREATER Span buffer = stackalloc byte[TraceIdByteCount]; id.TryWriteBytes(buffer); return ActivityTraceId.CreateFromBytes(buffer); +#else + Span buffer = stackalloc char[TraceIdHexCharCount]; + id.TryFormat(buffer); + return ActivityTraceId.CreateFromString(buffer); +#endif } } #endif diff --git a/src/Sentry/Internal/Tracing/ISentryActivityEnricher.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs similarity index 100% rename from src/Sentry/Internal/Tracing/ISentryActivityEnricher.cs rename to src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs diff --git a/src/Sentry/Internal/Tracing/SentryActivityListener.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs similarity index 100% rename from src/Sentry/Internal/Tracing/SentryActivityListener.cs rename to src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs diff --git a/src/Sentry/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs similarity index 99% rename from src/Sentry/Internal/Tracing/SentryActivityProcessor.cs rename to src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs index 1ae2d601ea..4dc7ef0769 100644 --- a/src/Sentry/Internal/Tracing/SentryActivityProcessor.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs @@ -296,7 +296,9 @@ internal void PruneFilteredSpans(bool force = false) foreach (var mappedItem in _map) { - var (spanId, span) = mappedItem; + // Note: no KeyValuePair deconstruction here — it isn't available on netstandard2.0/net462. + var spanId = mappedItem.Key; + var span = mappedItem.Value; var activity = span.GetFused(); // Also prune when the activity has been GC'd (weak ref returns null): the activity is gone, so it // can never call OnEnd, and the span will never be removed otherwise — causing a memory leak. diff --git a/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj b/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj index 2bb043f837..05e0684572 100644 --- a/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj +++ b/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj @@ -7,6 +7,11 @@ Official Diagnostic.Listener integration for Sentry - Open-source error tracking that helps developers monitor and fix crashes in real time. Note: This package is not needed when using Sentry with .NET Core 3 or higher. Sentry + + $(DefineConstants);HAS_ACTIVITY_LISTENER @@ -14,7 +19,11 @@ - + + diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 1b84824b11..2a4b057892 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -53,8 +53,10 @@ $(DefineConstants);HAS_DIAGNOSTIC_INTEGRATION - + $(DefineConstants);HAS_ACTIVITY_LISTENER diff --git a/test/Sentry.Tests/Internals/Tracing/ActivitySourceTests.cs b/test/Sentry.DiagnosticSource.Tests/Tracing/ActivitySourceTests.cs similarity index 95% rename from test/Sentry.Tests/Internals/Tracing/ActivitySourceTests.cs rename to test/Sentry.DiagnosticSource.Tests/Tracing/ActivitySourceTests.cs index 269e792d4f..e29b7c7ae6 100644 --- a/test/Sentry.Tests/Internals/Tracing/ActivitySourceTests.cs +++ b/test/Sentry.DiagnosticSource.Tests/Tracing/ActivitySourceTests.cs @@ -1,5 +1,4 @@ -#if NET8_0_OR_GREATER -namespace Sentry.Tests.Internals.Tracing; +namespace Sentry.DiagnosticSource.Tests.Tracing; /// /// Base class for tests that need a recording ActivitySource. This is the core equivalent of @@ -34,4 +33,3 @@ public void Dispose() GC.SuppressFinalize(this); } } -#endif diff --git a/test/Sentry.Tests/Internals/Tracing/SentryActivityListenerTests.cs b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityListenerTests.cs similarity index 98% rename from test/Sentry.Tests/Internals/Tracing/SentryActivityListenerTests.cs rename to test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityListenerTests.cs index f658fd42f3..fabc5db7b1 100644 --- a/test/Sentry.Tests/Internals/Tracing/SentryActivityListenerTests.cs +++ b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityListenerTests.cs @@ -1,7 +1,6 @@ -#if NET8_0_OR_GREATER using Sentry.Internal.Tracing; -namespace Sentry.Tests.Internals.Tracing; +namespace Sentry.DiagnosticSource.Tests.Tracing; /// /// End-to-end tests for : Activities created via ActivitySource are @@ -120,4 +119,3 @@ public void ActivityStopped_UnsampledActivity_DoesNotCaptureTransaction() Arg.Any(), Arg.Any(), Arg.Any()); } } -#endif diff --git a/test/Sentry.Tests/Internals/Tracing/SentryActivityProcessorTests.cs b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityProcessorTests.cs similarity index 99% rename from test/Sentry.Tests/Internals/Tracing/SentryActivityProcessorTests.cs rename to test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityProcessorTests.cs index 892224e850..9c47ceb8b6 100644 --- a/test/Sentry.Tests/Internals/Tracing/SentryActivityProcessorTests.cs +++ b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityProcessorTests.cs @@ -1,8 +1,7 @@ -#if NET8_0_OR_GREATER using Sentry.Internal.OpenTelemetry; using Sentry.Internal.Tracing; -namespace Sentry.Tests.Internals.Tracing; +namespace Sentry.DiagnosticSource.Tests.Tracing; /// /// Port of Sentry.OpenTelemetry.Tests.SentrySpanProcessorTests, running the same scenarios against the core @@ -1020,4 +1019,3 @@ public void ParseOtelSpanDescription_HttpClient() source.Should().Be(TransactionNameSource.Custom); } } -#endif From 904fef22187447a4c6ab82ac2d96f84e2d0f5897 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 13 Jul 2026 17:27:25 +1200 Subject: [PATCH 3/4] spike: drop the redundant HAS_ACTIVITY_LISTENER guards Addresses review feedback on the PR: now that the tracing sources live in Sentry.DiagnosticSource, ActivityListener is available in every compilation context that sees them - the standalone package always references System.Diagnostics.DiagnosticSource 8.0.1, and the compile-include into core Sentry only fires on modern TFMs where the APIs ship in the framework. This also matches the existing convention: the EF/SqlClient DiagnosticSource sources carry no availability guards either; HAS_DIAGNOSTIC_INTEGRATION is only used by core files (e.g. SentryOptions.cs) that are compiled on all TFMs and reference those types. If/when core gains wiring that references the Activity tracing types (e.g. SDK init), it will need an equivalent constant - reintroduce it then, on the referencing side. #skip-changelog Co-Authored-By: Claude Opus 4.8 --- .../Internal/Tracing/ActivityAttributeExtensions.cs | 2 -- .../Internal/Tracing/ActivityIdExtensions.cs | 2 -- .../Internal/Tracing/ISentryActivityEnricher.cs | 2 -- .../Internal/Tracing/SentryActivityListener.cs | 2 -- .../Internal/Tracing/SentryActivityProcessor.cs | 2 -- src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj | 5 ----- src/Sentry/Sentry.csproj | 5 ----- 7 files changed, 20 deletions(-) diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs index f43e111e37..df89b42761 100644 --- a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs @@ -1,4 +1,3 @@ -#if HAS_ACTIVITY_LISTENER using Sentry.Internal.Extensions; using Sentry.Internal.OpenTelemetry; @@ -56,4 +55,3 @@ public static BaggageHeader AsBaggageHeader(this IEnumerable @@ -9,4 +8,3 @@ internal interface ISentryActivityEnricher { public void Enrich(ISpan span, Activity activity, IHub hub, SentryOptions? options); } -#endif diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs index 6bb17dd8e1..3c60613ee6 100644 --- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs @@ -1,4 +1,3 @@ -#if HAS_ACTIVITY_LISTENER namespace Sentry.Internal.Tracing; /// @@ -49,4 +48,3 @@ public SentryActivityListener( public void Dispose() => _listener.Dispose(); } -#endif diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs index 4dc7ef0769..ca224241b2 100644 --- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs @@ -1,4 +1,3 @@ -#if HAS_ACTIVITY_LISTENER using Sentry.Extensibility; using Sentry.Internal.Extensions; using Sentry.Internal.OpenTelemetry; @@ -524,4 +523,3 @@ private void GenerateSentryErrorsFromOtelSpan(Activity activity, IDictionary Sentry - - $(DefineConstants);HAS_ACTIVITY_LISTENER diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 2a4b057892..3ff7453ca3 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -53,11 +53,6 @@ $(DefineConstants);HAS_DIAGNOSTIC_INTEGRATION - - $(DefineConstants);HAS_ACTIVITY_LISTENER From 5d22058dcd1dea3694082f608aac941d6168058f Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 13 Jul 2026 17:50:46 +1200 Subject: [PATCH 4/4] spike: alias Activity to fix ambiguity on Android TFMs On net9.0/net10.0-android, the Android implicit usings bring Android.App into scope, making the bare `Activity` identifier ambiguous with System.Diagnostics.Activity (which the repo-wide global usings import). Alias it explicitly in the three tracing files that use the bare type name. First time core has compiled System.Diagnostics.Activity usage on mobile TFMs, so no precedent existed. Verified locally: src/Sentry builds for net9.0-android35.0 (the TFM CI failed on). #skip-changelog Co-Authored-By: Claude Opus 4.8 --- .../Internal/Tracing/ISentryActivityEnricher.cs | 4 ++++ .../Internal/Tracing/SentryActivityListener.cs | 4 ++++ .../Internal/Tracing/SentryActivityProcessor.cs | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs index 99234c38e4..0315a0b65c 100644 --- a/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs @@ -1,3 +1,7 @@ +// 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; /// diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs index 3c60613ee6..65a907caa3 100644 --- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs @@ -1,3 +1,7 @@ +// 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; /// diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs index ca224241b2..78e1a4a140 100644 --- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs @@ -1,3 +1,7 @@ +// 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;