diff --git a/CHANGELOG.md b/CHANGELOG.md index ec29865dcd..df8b1f233b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Features +- Add the delegate TransactionNameProvider to allow the name definition from Unknown transactions on ASP.NET Core ([#1421](https://github.com/getsentry/sentry-dotnet/pull/1421)) - SentrySDK.WithScope is now obsolete in favour of overloads of CaptureEvent, CaptureMessage, CaptureException ([#1412](https://github.com/getsentry/sentry-dotnet/pull/1412)) - Add Sentry to global usings when ImplicitUsings is enabled (`true`) ([#1398](https://github.com/getsentry/sentry-dotnet/pull/1398)) diff --git a/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs index 251d43c8d9..541c55535e 100644 --- a/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs +++ b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs @@ -28,9 +28,9 @@ internal static class HttpContextExtensions return legacyFormat; } - var sentryRouteName = context.Features.Get(); + var sentryRouteName = context.Features.Get(); - return sentryRouteName?.GetRouteName(); + return sentryRouteName?.Invoke(context); } // Internal for testing. diff --git a/src/Sentry.AspNetCore/ISentryRouteName.cs b/src/Sentry.AspNetCore/ISentryRouteName.cs deleted file mode 100644 index 79c99b259d..0000000000 --- a/src/Sentry.AspNetCore/ISentryRouteName.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Sentry.AspNetCore; - -internal interface ISentryRouteName -{ - string? GetRouteName(); -} diff --git a/src/Sentry.AspNetCore/SentryAspNetCoreOptions.cs b/src/Sentry.AspNetCore/SentryAspNetCoreOptions.cs index d6300b03ca..346761d703 100644 --- a/src/Sentry.AspNetCore/SentryAspNetCoreOptions.cs +++ b/src/Sentry.AspNetCore/SentryAspNetCoreOptions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Sentry.Extensibility; using Sentry.Extensions.Logging; @@ -43,6 +44,14 @@ public class SentryAspNetCoreOptions : SentryLoggingOptions /// public TimeSpan FlushTimeout { get; set; } = TimeSpan.FromSeconds(2); + /// + /// The strategy to define the name of a transaction based on the . + /// + /// + /// The SDK can name transactions automatically when using MVC or Endpoint Routing. In other cases, like when serving static files, it will fallback to Unknown Route. This hook allows custom code to define a transaction name given a . + /// + public TransactionNameProvider? TransactionNameProvider { get; set; } + /// /// Controls whether the casing of the standard (Production, Development and Staging) environment name supplied by /// is adjusted when setting the Sentry environment. Defaults to true. diff --git a/src/Sentry.AspNetCore/SentryTracingMiddleware.cs b/src/Sentry.AspNetCore/SentryTracingMiddleware.cs index a0f7761caf..f1f5849e7f 100644 --- a/src/Sentry.AspNetCore/SentryTracingMiddleware.cs +++ b/src/Sentry.AspNetCore/SentryTracingMiddleware.cs @@ -107,6 +107,11 @@ public async Task InvokeAsync(HttpContext context) return; } + if (_options.TransactionNameProvider is { } route) + { + context.Features.Set(route); + } + var transaction = TryStartTransaction(context); var initialName = transaction?.Name; diff --git a/src/Sentry.AspNetCore/TransactionNameProvider.cs b/src/Sentry.AspNetCore/TransactionNameProvider.cs new file mode 100644 index 0000000000..969ef14a72 --- /dev/null +++ b/src/Sentry.AspNetCore/TransactionNameProvider.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http; + +namespace Sentry.AspNetCore; + +/// +/// Provides the strategy to define the name of a transaction based on the . +/// +/// +/// The SDK can name transactions automatically when using MVC or Endpoint Routing. In other cases, like when serving static files, it fallback to Unknown Route. This hook allows custom code to define a transaction name given a . +/// +public delegate string? TransactionNameProvider(HttpContext context); diff --git a/src/Sentry.Google.Cloud.Functions/SentryStartup.cs b/src/Sentry.Google.Cloud.Functions/SentryStartup.cs index 2450af9b53..2f1233f443 100644 --- a/src/Sentry.Google.Cloud.Functions/SentryStartup.cs +++ b/src/Sentry.Google.Cloud.Functions/SentryStartup.cs @@ -47,6 +47,10 @@ public override void ConfigureLogging(WebHostBuilderContext context, ILoggingBui { // Make sure all events are flushed out options.FlushBeforeRequestCompleted = true; + // K_SERVICE is where the name of the FAAS is stored. + // It'll return null. if GCP Function is running locally. + var serviceName = Environment.GetEnvironmentVariable("K_SERVICE"); + options.TransactionNameProvider = _ => serviceName; }); logging.Services.AddSingleton, SentryAspNetCoreOptionsSetup>(); @@ -114,17 +118,7 @@ private class SentryGoogleCloudFunctionsMiddleware /// public async Task InvokeAsync(HttpContext httpContext) { - httpContext.Features.Set(new SentryGoogleCloudFunctionsRouteName()); await _next(httpContext).ConfigureAwait(false); } } - - private class SentryGoogleCloudFunctionsRouteName : ISentryRouteName - { - private static readonly Lazy RouteName = new(() => Environment.GetEnvironmentVariable("K_SERVICE")); - - // K_SERVICE is where the name of the FAAS is stored. - // It'll return null. if GCP Function is running locally. - public string? GetRouteName() => RouteName.Value; - } } diff --git a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core2_1.verified.txt b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core2_1.verified.txt index 9283a6f808..a3cc825650 100644 --- a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core2_1.verified.txt +++ b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core2_1.verified.txt @@ -45,6 +45,7 @@ namespace Sentry.AspNetCore public System.TimeSpan FlushTimeout { get; set; } public bool IncludeActivityData { get; set; } public Sentry.Extensibility.RequestSize MaxRequestBodySize { get; set; } + public Sentry.AspNetCore.TransactionNameProvider? TransactionNameProvider { get; set; } } public class SentryAspNetCoreOptionsSetup : Microsoft.Extensions.Options.ConfigureFromConfigurationOptions { @@ -60,4 +61,5 @@ namespace Sentry.AspNetCore public SentryStartupFilter() { } public System.Action Configure(System.Action next) { } } + public delegate string? TransactionNameProvider(Microsoft.AspNetCore.Http.HttpContext context); } \ No newline at end of file diff --git a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core3_1.verified.txt b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core3_1.verified.txt index 3392cc73a0..3a379f853e 100644 --- a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core3_1.verified.txt +++ b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.Core3_1.verified.txt @@ -45,6 +45,7 @@ namespace Sentry.AspNetCore public System.TimeSpan FlushTimeout { get; set; } public bool IncludeActivityData { get; set; } public Sentry.Extensibility.RequestSize MaxRequestBodySize { get; set; } + public Sentry.AspNetCore.TransactionNameProvider? TransactionNameProvider { get; set; } } public class SentryAspNetCoreOptionsSetup : Microsoft.Extensions.Options.ConfigureFromConfigurationOptions { @@ -60,4 +61,5 @@ namespace Sentry.AspNetCore public SentryStartupFilter() { } public System.Action Configure(System.Action next) { } } + public delegate string? TransactionNameProvider(Microsoft.AspNetCore.Http.HttpContext context); } \ No newline at end of file diff --git a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet5_0.verified.txt b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet5_0.verified.txt index 5a875a03e5..f49034f977 100644 --- a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet5_0.verified.txt +++ b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet5_0.verified.txt @@ -45,6 +45,7 @@ namespace Sentry.AspNetCore public System.TimeSpan FlushTimeout { get; set; } public bool IncludeActivityData { get; set; } public Sentry.Extensibility.RequestSize MaxRequestBodySize { get; set; } + public Sentry.AspNetCore.TransactionNameProvider? TransactionNameProvider { get; set; } } public class SentryAspNetCoreOptionsSetup : Microsoft.Extensions.Options.ConfigureFromConfigurationOptions { @@ -60,4 +61,5 @@ namespace Sentry.AspNetCore public SentryStartupFilter() { } public System.Action Configure(System.Action next) { } } + public delegate string? TransactionNameProvider(Microsoft.AspNetCore.Http.HttpContext context); } \ No newline at end of file diff --git a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt index 0f7175ffb0..f36c1991ce 100644 --- a/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt +++ b/test/Sentry.AspNetCore.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt @@ -45,6 +45,7 @@ namespace Sentry.AspNetCore public System.TimeSpan FlushTimeout { get; set; } public bool IncludeActivityData { get; set; } public Sentry.Extensibility.RequestSize MaxRequestBodySize { get; set; } + public Sentry.AspNetCore.TransactionNameProvider? TransactionNameProvider { get; set; } } public class SentryAspNetCoreOptionsSetup : Microsoft.Extensions.Options.ConfigureFromConfigurationOptions { @@ -60,4 +61,5 @@ namespace Sentry.AspNetCore public SentryStartupFilter() { } public System.Action Configure(System.Action next) { } } + public delegate string? TransactionNameProvider(Microsoft.AspNetCore.Http.HttpContext context); } \ No newline at end of file diff --git a/test/Sentry.AspNetCore.Tests/Extensions/HttpContextExtensionsTests.cs b/test/Sentry.AspNetCore.Tests/Extensions/HttpContextExtensionsTests.cs index 5d6a46713e..9d36a32740 100644 --- a/test/Sentry.AspNetCore.Tests/Extensions/HttpContextExtensionsTests.cs +++ b/test/Sentry.AspNetCore.Tests/Extensions/HttpContextExtensionsTests.cs @@ -207,10 +207,9 @@ public void TryGetRouteTemplate_NoRoute_NullOutput() public void TryGetRouteTemplate_WithSentryRouteName_RouteName() { // Arrange - var sentryRouteName = Substitute.For(); - var httpContext = Fixture.GetSut(); var expectedName = "abc"; - sentryRouteName.GetRouteName().Returns(expectedName); + TransactionNameProvider sentryRouteName = _ => expectedName; + var httpContext = Fixture.GetSut(); httpContext.Features.Set(sentryRouteName); // Act diff --git a/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs b/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs index 54580e6c74..574500eab0 100644 --- a/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs +++ b/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs @@ -307,6 +307,93 @@ public async Task Transaction_binds_exception_thrown() Assert.True(hub.ExceptionToSpanMap.TryGetValue(exception, out var span)); Assert.Equal(SpanStatus.InternalError, span.Status); } + + [Fact] + public async Task Transaction_TransactionNameProviderSetSet_TransactionNameSet() + { + // Arrange + Transaction transaction = null; + + var expectedName = "My custom name"; + + var sentryClient = Substitute.For(); + sentryClient.When(x => x.CaptureTransaction(Arg.Any())) + .Do(callback => transaction = callback.Arg()); + var options = new SentryAspNetCoreOptions() + { + Dsn = DsnSamples.ValidDsnWithoutSecret, + TracesSampleRate = 1 + }; + + var hub = new Hub(options, sentryClient); + + var server = new TestServer(new WebHostBuilder() + .UseSentry(aspNewOptions => + { + aspNewOptions.TransactionNameProvider = _ => expectedName; + }) + .ConfigureServices(services => + { + services.RemoveAll(typeof(Func)); + services.AddSingleton>(() => hub); + }).Configure(app => app.UseSentryTracing())); + + var client = server.CreateClient(); + + // Act + try + { + await client.GetStringAsync("/person/13.bmp"); + } + // Expected error. + catch (HttpRequestException ex) when (ex.Message.Contains("404")) + { } + + // Assert + transaction.Should().NotBeNull(); + transaction?.Name.Should().Be($"GET {expectedName}"); + } + + [Fact] + public async Task Transaction_TransactionNameProviderSetUnset_UnknownTransactionNameSet() + { + // Arrange + Transaction transaction = null; + + var sentryClient = Substitute.For(); + sentryClient.When(x => x.CaptureTransaction(Arg.Any())) + .Do(callback => transaction = callback.Arg()); + var options = new SentryAspNetCoreOptions + { + Dsn = DsnSamples.ValidDsnWithoutSecret, + TracesSampleRate = 1 + }; + + var hub = new Hub(options, sentryClient); + + var server = new TestServer(new WebHostBuilder() + .UseSentry() + .ConfigureServices(services => + { + services.RemoveAll(typeof(Func)); + services.AddSingleton>(() => hub); + }).Configure(app => app.UseSentryTracing())); + + var client = server.CreateClient(); + + // Act + try + { + await client.GetStringAsync("/person/13.bmp"); + } + // Expected error. + catch (HttpRequestException ex) when (ex.Message.Contains("404")) + { } + + // Assert + transaction.Should().NotBeNull(); + transaction?.Name.Should().Be("Unknown Route"); + } } #endif