diff --git a/src/Sentry.AspNet/HttpContextExtensions.cs b/src/Sentry.AspNet/HttpContextExtensions.cs index 54aa715379..6ebadd582c 100644 --- a/src/Sentry.AspNet/HttpContextExtensions.cs +++ b/src/Sentry.AspNet/HttpContextExtensions.cs @@ -1,5 +1,6 @@ using Sentry.Extensibility; using Sentry.Internal; +using Sentry.Internal.OpenTelemetry; using Sentry.Protocol; namespace Sentry.AspNet; @@ -145,7 +146,8 @@ public static void FinishSentryTransaction(this HttpContext httpContext) return; } - var status = SpanStatusConverter.FromHttpStatusCode(httpContext.Response.StatusCode); - transaction.Finish(status); + var statusCode = httpContext.Response.StatusCode; + transaction.SetData(OtelSemanticConventions.AttributeHttpResponseStatusCode, statusCode); + transaction.Finish(SpanStatusConverter.FromHttpStatusCode(statusCode)); } } diff --git a/src/Sentry.AspNet/SentryAspNetOptionsExtensions.cs b/src/Sentry.AspNet/SentryAspNetOptionsExtensions.cs index 11a21cc9a7..14f9aa1154 100644 --- a/src/Sentry.AspNet/SentryAspNetOptionsExtensions.cs +++ b/src/Sentry.AspNet/SentryAspNetOptionsExtensions.cs @@ -1,6 +1,7 @@ using Sentry.AspNet.Internal; using Sentry.Extensibility; using Sentry.Infrastructure; +using Sentry.Internal; namespace Sentry.AspNet; @@ -36,6 +37,7 @@ public static SentryOptions AddAspNet(this SentryOptions options, RequestSize ma options.Release ??= SystemWebVersionLocator.Resolve(options, HttpContext.Current); options.AddEventProcessor(eventProcessor); options.AddDiagnosticSourceIntegration(); + options.AddTransactionProcessor(new TraceIgnoreStatusCodeTransactionProcessor(options)); return options; } diff --git a/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs b/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs index 8985e9cc5e..25c3070e68 100644 --- a/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs +++ b/src/Sentry.AspNetCore.Blazor.WebAssembly/WebAssemblyHostBuilderExtensions.cs @@ -4,6 +4,7 @@ using Sentry; using Sentry.AspNetCore.Blazor.WebAssembly.Internal; using Sentry.Extensions.Logging; +using Sentry.Internal; // ReSharper disable once CheckNamespace - Discoverability namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting; @@ -31,6 +32,7 @@ public static WebAssemblyHostBuilder UseSentry(this WebAssemblyHostBuilder build blazorOptions.RequestBodyCompressionLevel = CompressionLevel.NoCompression; // Since the WebAssemblyHost is a client-side application blazorOptions.IsGlobalModeEnabled = true; + blazorOptions.AddTransactionProcessor(new TraceIgnoreStatusCodeTransactionProcessor(blazorOptions)); }); builder.Services.AddSingleton, BlazorWasmOptionsSetup>(); diff --git a/src/Sentry.AspNetCore/SentryAspNetCoreOptionsSetup.cs b/src/Sentry.AspNetCore/SentryAspNetCoreOptionsSetup.cs index 3f428f58f3..60f364e5e4 100644 --- a/src/Sentry.AspNetCore/SentryAspNetCoreOptionsSetup.cs +++ b/src/Sentry.AspNetCore/SentryAspNetCoreOptionsSetup.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Configuration; using Microsoft.Extensions.Options; using Sentry.Extensions.Logging; +using Sentry.Internal; namespace Sentry.AspNetCore; @@ -28,6 +29,7 @@ public override void Configure(SentryAspNetCoreOptions options) base.Configure(options); options.AddDiagnosticSourceIntegration(); options.DeduplicateUnhandledException(); + options.AddTransactionProcessor(new TraceIgnoreStatusCodeTransactionProcessor(options)); } } @@ -65,6 +67,7 @@ public void Configure(SentryAspNetCoreOptions options) bindable.ApplyTo(options); options.DeduplicateUnhandledException(); + options.AddTransactionProcessor(new TraceIgnoreStatusCodeTransactionProcessor(options)); } } #endif diff --git a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs index 5aa9758c47..461dfd2038 100644 --- a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs +++ b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs @@ -239,6 +239,7 @@ public override void OnEnd(Activity data) if (statusCode is { } responseStatusCode) { transaction.Contexts.Response.StatusCode = responseStatusCode; + transaction.SetData(OtelSemanticConventions.AttributeHttpResponseStatusCode, responseStatusCode); } // Use the end timestamp from the activity data. diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index ebfba5c55a..e1210878d4 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -38,6 +38,7 @@ internal partial class BindableSentryOptions public string? CacheDirectoryPath { get; set; } public bool? CaptureFailedRequests { get; set; } public List? FailedRequestTargets { get; set; } + public List? TraceIgnoreStatusCodes { get; set; } public bool? DisableFileWrite { get; set; } public TimeSpan? InitCacheFlushTimeout { get; set; } public Dictionary? DefaultTags { get; set; } @@ -90,6 +91,7 @@ public void ApplyTo(SentryOptions options) options.CacheDirectoryPath = CacheDirectoryPath ?? options.CacheDirectoryPath; options.CaptureFailedRequests = CaptureFailedRequests ?? options.CaptureFailedRequests; options.FailedRequestTargets = FailedRequestTargets?.Select(s => new StringOrRegex(s)).ToList() ?? options.FailedRequestTargets; + options.TraceIgnoreStatusCodes = TraceIgnoreStatusCodes?.Select(code => new HttpStatusCodeRange(code)).ToList() ?? options.TraceIgnoreStatusCodes; options.DisableFileWrite = DisableFileWrite ?? options.DisableFileWrite; options.InitCacheFlushTimeout = InitCacheFlushTimeout ?? options.InitCacheFlushTimeout; options.DefaultTags = DefaultTags ?? options.DefaultTags; diff --git a/src/Sentry/Internal/TraceIgnoreStatusCodeTransactionProcessor.cs b/src/Sentry/Internal/TraceIgnoreStatusCodeTransactionProcessor.cs new file mode 100644 index 0000000000..f80847877a --- /dev/null +++ b/src/Sentry/Internal/TraceIgnoreStatusCodeTransactionProcessor.cs @@ -0,0 +1,31 @@ +using Sentry.Extensibility; +using Sentry.Internal.OpenTelemetry; + +namespace Sentry.Internal; + +internal class TraceIgnoreStatusCodeTransactionProcessor : ISentryTransactionProcessor +{ + private readonly SentryOptions _options; + + public TraceIgnoreStatusCodeTransactionProcessor(SentryOptions options) + { + _options = options; + } + + public SentryTransaction? Process(SentryTransaction transaction) + { + if (_options.TraceIgnoreStatusCodes.Count == 0) + { + return transaction; + } + + if (transaction.Data.TryGetValue(OtelSemanticConventions.AttributeHttpResponseStatusCode, out var statusCodeObj) + && statusCodeObj is IConvertible convertible + && _options.TraceIgnoreStatusCodes.ContainsStatusCode(convertible.ToInt32(null))) + { + return null; + } + + return transaction; + } +} diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 25a6eb3183..b6ccbb66bf 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -857,6 +857,12 @@ public IDiagnosticLogger? DiagnosticLogger (500, 599) }; + /// + /// Transactions will be dropped if the HTTP Response status code matches any of the configured ranges. + /// Defaults to an empty collection (all transactions are captured regardless of status code). + /// + public IList TraceIgnoreStatusCodes { get; set; } = []; + // The default failed request target list will match anything, but adding to the list should clear that. private Lazy> _failedRequestTargets = new(() => new AutoClearingList( @@ -1311,9 +1317,7 @@ public SentryOptions() SettingLocator = new SettingLocator(this); _lazyInstallationId = new(() => new InstallationIdHelper(this).TryGetInstallationId()); - TransactionProcessorsProviders = new() { - () => TransactionProcessors ?? Enumerable.Empty() - }; + TransactionProcessorsProviders = [() => TransactionProcessors ?? []]; _clientReportRecorder = new Lazy(() => new ClientReportRecorder(this)); diff --git a/test/Sentry.AspNet.Tests/HttpContextExtensionsTests.cs b/test/Sentry.AspNet.Tests/HttpContextExtensionsTests.cs index 5cf274b260..7c78c01b84 100644 --- a/test/Sentry.AspNet.Tests/HttpContextExtensionsTests.cs +++ b/test/Sentry.AspNet.Tests/HttpContextExtensionsTests.cs @@ -1,3 +1,4 @@ +using Sentry.Internal.OpenTelemetry; using HttpCookie = System.Web.HttpCookie; namespace Sentry.AspNet.Tests; @@ -73,6 +74,7 @@ public void FinishSentryTransaction_FinishesTransaction() // Assert transaction.IsFinished.Should().BeTrue(); transaction.Status.Should().Be(SpanStatus.NotFound); + transaction.Data.Should().Contain(OtelSemanticConventions.AttributeHttpResponseStatusCode, 404); } [Fact] diff --git a/test/Sentry.AspNet.Tests/SentryAspNetOptionsExtensionsTests.cs b/test/Sentry.AspNet.Tests/SentryAspNetOptionsExtensionsTests.cs index f0cf5bc5ca..7aa24a1018 100644 --- a/test/Sentry.AspNet.Tests/SentryAspNetOptionsExtensionsTests.cs +++ b/test/Sentry.AspNet.Tests/SentryAspNetOptionsExtensionsTests.cs @@ -1,4 +1,5 @@ using Sentry.AspNet.Internal; +using Sentry.Internal; namespace Sentry.AspNet.Tests; @@ -25,6 +26,14 @@ public void AddAspNet_EventProcessorsContainBodyExtractor() Assert.Contains(extractor.Extractors, p => p.GetType() == typeof(DefaultRequestPayloadExtractor)); } + [Fact] + public void AddAspNet_RegistersTraceIgnoreStatusCodeTransactionProcessor() + { + var options = new SentryOptions(); + options.AddAspNet(); + Assert.Contains(options.GetAllTransactionProcessors(), p => p is TraceIgnoreStatusCodeTransactionProcessor); + } + [Fact] public void AddAspNet_UsedMoreThanOnce_RegisterOnce() { diff --git a/test/Sentry.AspNetCore.Tests/SentryAspNetCoreOptionsSetupTests.cs b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreOptionsSetupTests.cs index 2f24d05416..916b2a615d 100644 --- a/test/Sentry.AspNetCore.Tests/SentryAspNetCoreOptionsSetupTests.cs +++ b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreOptionsSetupTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Configuration; +using Sentry.Internal; #if NETCOREAPP3_1_OR_GREATER using IHostingEnvironment = Microsoft.AspNetCore.Hosting.IWebHostEnvironment; @@ -30,6 +31,14 @@ public SentryAspNetCoreOptionsSetup GetSut() private readonly Fixture _fixture = new(); private readonly SentryAspNetCoreOptions _target = new(); + [Fact] + public void Configure_RegistersTraceIgnoreStatusCodeTransactionProcessor() + { + var sut = _fixture.GetSut(); + sut.Configure(_target); + Assert.Contains(_target.GetAllTransactionProcessors(), p => p is TraceIgnoreStatusCodeTransactionProcessor); + } + [Fact] public void Filters_KestrelApplicationEvent_NoException_Filtered() { diff --git a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs index c97e15f6a6..a26baac5ce 100644 --- a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs +++ b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs @@ -463,6 +463,7 @@ public void OnEnd_Transaction_SetsResponseStatusCode() using (new AssertionScope()) { transaction.Contexts.Response.StatusCode.Should().Be(404); + transaction.Data.Should().Contain(OtelSemanticConventions.AttributeHttpResponseStatusCode, 404); } } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 86aa068b07..ccf866768e 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -841,6 +841,7 @@ namespace Sentry public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } + public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } public System.Func? TracesSampler { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 86aa068b07..ccf866768e 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -841,6 +841,7 @@ namespace Sentry public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } + public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } public System.Func? TracesSampler { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 86aa068b07..ccf866768e 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -841,6 +841,7 @@ namespace Sentry public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } + public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } public System.Func? TracesSampler { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 58020d548b..1fc1e68f4b 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -828,6 +828,7 @@ namespace Sentry public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } + public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } public System.Func? TracesSampler { get; set; } diff --git a/test/Sentry.Tests/SentryOptionsTests.cs b/test/Sentry.Tests/SentryOptionsTests.cs index d563665bb4..270f8a2ab3 100644 --- a/test/Sentry.Tests/SentryOptionsTests.cs +++ b/test/Sentry.Tests/SentryOptionsTests.cs @@ -489,6 +489,13 @@ public void AddEventProcessorProvider_StoredInOptions() Assert.Contains(sut.GetAllEventProcessors(), actual => actual == second); } + [Fact] + public void GetAllTransactionProcessors_ByDefault_DoesNotIncludeTraceIgnoreStatusCodeTransactionProcessor() + { + var sut = new SentryOptions(); + Assert.DoesNotContain(sut.GetAllTransactionProcessors(), p => p is TraceIgnoreStatusCodeTransactionProcessor); + } + [Fact] public void AddTransactionProcessor_StoredInOptions() { diff --git a/test/Sentry.Tests/TraceIgnoreStatusCodeTransactionProcessorTests.cs b/test/Sentry.Tests/TraceIgnoreStatusCodeTransactionProcessorTests.cs new file mode 100644 index 0000000000..052e948b47 --- /dev/null +++ b/test/Sentry.Tests/TraceIgnoreStatusCodeTransactionProcessorTests.cs @@ -0,0 +1,143 @@ +using Sentry.Internal.OpenTelemetry; + +namespace Sentry.Tests; + +public class TraceIgnoreStatusCodeTransactionProcessorTests +{ + private static SentryOptions OptionsWithIgnoredCodes(params HttpStatusCodeRange[] ranges) + { + var options = new SentryOptions(); + foreach (var range in ranges) + { + options.TraceIgnoreStatusCodes.Add(range); + } + return options; + } + + private static SentryTransaction TransactionWithStatusCode(int statusCode) + { + var transaction = new SentryTransaction("name", "operation"); + transaction.SetData(OtelSemanticConventions.AttributeHttpResponseStatusCode, statusCode); + return transaction; + } + + [Fact] + public void Process_EmptyIgnoreList_ReturnsTransaction() + { + // Arrange + var options = new SentryOptions(); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + var transaction = TransactionWithStatusCode(404); + + // Act + var result = processor.Process(transaction); + + // Assert + result.Should().BeSameAs(transaction); + } + + [Fact] + public void Process_StatusCodeNotInIgnoreList_ReturnsTransaction() + { + // Arrange + var options = OptionsWithIgnoredCodes(404); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + var transaction = TransactionWithStatusCode(200); + + // Act + var result = processor.Process(transaction); + + // Assert + result.Should().BeSameAs(transaction); + } + + [Fact] + public void Process_StatusCodeInIgnoreList_ReturnsNull() + { + // Arrange + var options = OptionsWithIgnoredCodes(404); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + var transaction = TransactionWithStatusCode(404); + + // Act + var result = processor.Process(transaction); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Process_StatusCodeInIgnoredRange_ReturnsNull() + { + // Arrange + var options = OptionsWithIgnoredCodes((400, 499)); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + var transaction = TransactionWithStatusCode(404); + + // Act + var result = processor.Process(transaction); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Process_StatusCodeOutsideIgnoredRange_ReturnsTransaction() + { + // Arrange + var options = OptionsWithIgnoredCodes((400, 499)); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + var transaction = TransactionWithStatusCode(500); + + // Act + var result = processor.Process(transaction); + + // Assert + result.Should().BeSameAs(transaction); + } + + [Fact] + public void Process_NoStatusCodeExtra_ReturnsTransaction() + { + // Arrange + var options = OptionsWithIgnoredCodes((100, 599)); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + var transaction = new SentryTransaction("name", "operation"); + + // Act + var result = processor.Process(transaction); + + // Assert + result.Should().BeSameAs(transaction); + } + + [Fact] + public void Process_StatusCodeStoredAsShort_IsDropped() + { + // Regression test: OTel stores the status code as short, not int. + // Arrange + var options = OptionsWithIgnoredCodes(404); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + var transaction = new SentryTransaction("name", "operation"); + transaction.SetData(OtelSemanticConventions.AttributeHttpResponseStatusCode, (short)404); + + // Act + var result = processor.Process(transaction); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Process_MultipleIgnoredCodes_MatchesAny() + { + // Arrange + var options = OptionsWithIgnoredCodes(404, 429); + var processor = new TraceIgnoreStatusCodeTransactionProcessor(options); + + // Act & Assert + processor.Process(TransactionWithStatusCode(404)).Should().BeNull(); + processor.Process(TransactionWithStatusCode(429)).Should().BeNull(); + processor.Process(TransactionWithStatusCode(200)).Should().NotBeNull(); + } +}