diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index 6815aa0d95..5f2ede7844 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -11,6 +11,7 @@ internal partial class BindableSentryOptions public bool? EnableScopeSync { get; set; } public bool? EnableBackpressureHandling { get; set; } public List? TagFilters { get; set; } + public List? IgnoreTransactions { get; set; } public bool? SendDefaultPii { get; set; } public bool? IsEnvironmentUser { get; set; } public string? ServerName { get; set; } @@ -66,6 +67,7 @@ public void ApplyTo(SentryOptions options) options.EnableScopeSync = EnableScopeSync ?? options.EnableScopeSync; options.EnableBackpressureHandling = EnableBackpressureHandling ?? options.EnableBackpressureHandling; options.TagFilters = TagFilters?.Select(s => new StringOrRegex(s)).ToList() ?? options.TagFilters; + options.IgnoreTransactions = IgnoreTransactions?.Select(s => new StringOrRegex(s)).ToList() ?? options.IgnoreTransactions; options.SendDefaultPii = SendDefaultPii ?? options.SendDefaultPii; options.IsEnvironmentUser = IsEnvironmentUser ?? options.IsEnvironmentUser; options.ServerName = ServerName ?? options.ServerName; diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 7bbd403aef..e84ca1eb5f 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -185,6 +185,19 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent return; } + // Applied after the sampling check so that a transaction which is sampled out is + // still attributed to sampling, not to this filter. IgnoreTransactions is a built-in + // filter, so its discards are recorded under EventProcessor (matching the exception + // filter path and the JS inbound filters), not BeforeSend, which is reserved for the + // user's BeforeSendTransaction callback. + if (_options.IgnoreTransactions.MatchesSubstringOrRegex(transaction.Name)) + { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Transaction); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Span, spanCount); + _options.LogInfo("Transaction dropped by IgnoreTransactions option."); + return; + } + scope ??= new Scope(_options); hint ??= new SentryHint(); hint.AddAttachmentsFromScope(scope); diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 8000c6fa99..0dbfd7c99b 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -243,6 +243,18 @@ internal IEnumerable Integrations /// public IList TagFilters { get; set; } = new List(); + /// + /// A list of transaction names to be ignored. A transaction whose name matches any of the + /// given substrings or regular expression patterns will not be sent to Sentry. + /// + /// + /// Matching transactions are dropped before the BeforeSendTransaction callback runs, so that + /// callback is not invoked for them. This mirrors the behavior of the ignoreTransactions + /// option in the Sentry JavaScript and Python SDKs, where the built-in filter runs ahead of the + /// user's before_send_transaction hook. + /// + public IList IgnoreTransactions { get; set; } = new List(); + /// /// The worker used by the client to pass envelopes. /// diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 5bc79de2f1..35d2d30f47 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -822,6 +822,7 @@ namespace Sentry public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } public System.Net.IWebProxy? HttpProxy { get; set; } + public System.Collections.Generic.IList IgnoreTransactions { get; set; } public System.TimeSpan InitCacheFlushTimeout { get; set; } public bool IsEnvironmentUser { get; set; } public bool IsGlobalModeEnabled { 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 5bc79de2f1..35d2d30f47 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -822,6 +822,7 @@ namespace Sentry public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } public System.Net.IWebProxy? HttpProxy { get; set; } + public System.Collections.Generic.IList IgnoreTransactions { get; set; } public System.TimeSpan InitCacheFlushTimeout { get; set; } public bool IsEnvironmentUser { get; set; } public bool IsGlobalModeEnabled { 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 5bc79de2f1..35d2d30f47 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -822,6 +822,7 @@ namespace Sentry public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } public System.Net.IWebProxy? HttpProxy { get; set; } + public System.Collections.Generic.IList IgnoreTransactions { get; set; } public System.TimeSpan InitCacheFlushTimeout { get; set; } public bool IsEnvironmentUser { get; set; } public bool IsGlobalModeEnabled { 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 6c44d57159..fe7b7cab37 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -809,6 +809,7 @@ namespace Sentry public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } public System.Net.IWebProxy? HttpProxy { get; set; } + public System.Collections.Generic.IList IgnoreTransactions { get; set; } public System.TimeSpan InitCacheFlushTimeout { get; set; } public bool IsEnvironmentUser { get; set; } public bool IsGlobalModeEnabled { get; set; } diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 1accc76921..ab57b04227 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -1296,6 +1296,119 @@ public void CaptureTransaction_NoOperation_Ignored() _ = client.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); } + [Fact] + public void CaptureTransaction_MatchesIgnoreTransactions_Dropped() + { + // Arrange + _fixture.SentryOptions.IgnoreTransactions = new List { "GET /health" }; + var client = _fixture.GetSut(); + + var sentryTransaction = new SentryTransaction("GET /health", "http.server") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + }; + + // Act + client.CaptureTransaction(sentryTransaction); + + // Assert + _ = client.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + + var expectedSpanCount = sentryTransaction.Spans.Count + 1; // 1 for each span + one for the root transaction + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Transaction); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Span, expectedSpanCount); + } + + [Fact] + public void CaptureTransaction_MatchesIgnoreTransactionsRegex_Dropped() + { + // Arrange + _fixture.SentryOptions.IgnoreTransactions = new List { new(new Regex(@"^GET /health/\d+$")) }; + var client = _fixture.GetSut(); + + // Act + client.CaptureTransaction( + new SentryTransaction("GET /health/123", "http.server") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + }); + + // Assert + _ = client.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureTransaction_DoesNotMatchIgnoreTransactions_Sent() + { + // Arrange + _fixture.SentryOptions.IgnoreTransactions = new List { "GET /health" }; + var client = _fixture.GetSut(); + + // Act + client.CaptureTransaction( + new SentryTransaction("GET /api/users", "http.server") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + }); + + // Assert + _ = client.Worker.Received(1).EnqueueEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureTransaction_MatchesIgnoreTransactions_BeforeSendTransactionNotInvoked() + { + // Arrange: IgnoreTransactions is applied before the BeforeSendTransaction + // callback, so the callback must not observe an ignored transaction. + _fixture.SentryOptions.IgnoreTransactions = new List { "GET /health" }; + var beforeSendTransactionInvoked = false; + _fixture.SentryOptions.SetBeforeSendTransaction((tx, _) => + { + beforeSendTransactionInvoked = true; + return tx; + }); + var client = _fixture.GetSut(); + + // Act + client.CaptureTransaction( + new SentryTransaction("GET /health", "http.server") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + }); + + // Assert + beforeSendTransactionInvoked.Should().BeFalse(); + _ = client.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureTransaction_SampledOutAndMatchesIgnoreTransactions_RecordedAsSampleRate() + { + // Arrange: a sampled-out transaction whose name also matches an ignore pattern must be + // attributed to sampling (the earlier, primary drop reason), not to IgnoreTransactions. + _fixture.SentryOptions.IgnoreTransactions = new List { "GET /health" }; + var client = _fixture.GetSut(); + + var hub = Substitute.For(); + var transaction = new UnsampledTransaction(hub, new TransactionContext("GET /health", "http.server")); + transaction.StartChild("span1"); + + // Act + client.CaptureTransaction(new SentryTransaction(transaction)); + + // Assert + _ = client.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + + var expectedSpanCount = transaction.Spans.Count + 1; // 1 for each span + one for the root transaction + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Transaction); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Span, expectedSpanCount); + _fixture.ClientReportRecorder.DidNotReceive().RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Transaction); + } + [Fact] public void CaptureTransaction_NotFinished_Sent() {