diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs new file mode 100644 index 0000000000..df89b42761 --- /dev/null +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityAttributeExtensions.cs @@ -0,0 +1,57 @@ +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; + } +} diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityIdExtensions.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityIdExtensions.cs new file mode 100644 index 0000000000..bddc7fcc43 --- /dev/null +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityIdExtensions.cs @@ -0,0 +1,43 @@ +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 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 + } +} diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs new file mode 100644 index 0000000000..0315a0b65c --- /dev/null +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ISentryActivityEnricher.cs @@ -0,0 +1,14 @@ +// 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; + +/// +/// 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); +} diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs new file mode 100644 index 0000000000..65a907caa3 --- /dev/null +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityListener.cs @@ -0,0 +1,54 @@ +// 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; + +/// +/// 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(); +} diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs new file mode 100644 index 0000000000..78e1a4a140 --- /dev/null +++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs @@ -0,0 +1,529 @@ +// 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; + +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) + { + // 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. + 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; + } +} diff --git a/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj b/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj index 2bb043f837..bd5b17e048 100644 --- a/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj +++ b/src/Sentry.DiagnosticSource/Sentry.DiagnosticSource.csproj @@ -14,7 +14,11 @@ - + + diff --git a/test/Sentry.DiagnosticSource.Tests/Tracing/ActivitySourceTests.cs b/test/Sentry.DiagnosticSource.Tests/Tracing/ActivitySourceTests.cs new file mode 100644 index 0000000000..e29b7c7ae6 --- /dev/null +++ b/test/Sentry.DiagnosticSource.Tests/Tracing/ActivitySourceTests.cs @@ -0,0 +1,35 @@ +namespace Sentry.DiagnosticSource.Tests.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); + } +} diff --git a/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityListenerTests.cs b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityListenerTests.cs new file mode 100644 index 0000000000..fabc5db7b1 --- /dev/null +++ b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityListenerTests.cs @@ -0,0 +1,121 @@ +using Sentry.Internal.Tracing; + +namespace Sentry.DiagnosticSource.Tests.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()); + } +} diff --git a/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityProcessorTests.cs b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityProcessorTests.cs new file mode 100644 index 0000000000..9c47ceb8b6 --- /dev/null +++ b/test/Sentry.DiagnosticSource.Tests/Tracing/SentryActivityProcessorTests.cs @@ -0,0 +1,1021 @@ +using Sentry.Internal.OpenTelemetry; +using Sentry.Internal.Tracing; + +namespace Sentry.DiagnosticSource.Tests.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); + } +}