Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ Public API diffs are stored as Verify snapshot files. After any public API chang
## Changelog

- If the change is not user-facing, add `#skip-changelog` to the PR description
- Otherwise generated automatically from [Commit message conventions](https://develop.sentry.dev/engineering-practices/commit-messages/)
- Otherwise generated automatically from [Commit message conventions](https://develop.sentry.dev/engineering-practices/commit-messages/).
- Do **NOT** add changelogs manually.
Comment thread
jamescrosswell marked this conversation as resolved.

```sh
# Get PR number for current branch
Expand Down
2 changes: 2 additions & 0 deletions src/Sentry.AspNetCore/BindableSentryAspNetCoreOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal class BindableSentryAspNetCoreOptions : BindableSentryLoggingOptions
public bool? AdjustStandardEnvironmentNameCasing { get; set; }
public bool? AutoRegisterTracing { get; set; }
public bool? CaptureBlockingCalls { get; set; }
public bool? PreferTransactionNameProvider { get; set; }

public void ApplyTo(SentryAspNetCoreOptions options)
{
Expand All @@ -28,5 +29,6 @@ public void ApplyTo(SentryAspNetCoreOptions options)
options.AdjustStandardEnvironmentNameCasing = AdjustStandardEnvironmentNameCasing ?? options.AdjustStandardEnvironmentNameCasing;
options.AutoRegisterTracing = AutoRegisterTracing ?? options.AutoRegisterTracing;
options.CaptureBlockingCalls = CaptureBlockingCalls ?? options.CaptureBlockingCalls;
options.PreferTransactionNameProvider = PreferTransactionNameProvider ?? options.PreferTransactionNameProvider;
}
}
7 changes: 5 additions & 2 deletions src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ internal static class HttpContextExtensions
: null;
}

internal static string? TryGetCustomTransactionName(this HttpContext context) =>
context.Features.Get<TransactionNameProvider>()?.Invoke(context);
internal static string? TryGetCustomTransactionName(this HttpContext context)
{
var customName = context.Features.Get<TransactionNameProvider>()?.Invoke(context);
return !string.IsNullOrEmpty(customName) ? customName : null;
}

public static string? TryGetTransactionName(this HttpContext context)
{
Expand Down
13 changes: 13 additions & 0 deletions src/Sentry.AspNetCore/SentryAspNetCoreOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ public class SentryAspNetCoreOptions : SentryLoggingOptions
/// </remarks>
public TransactionNameProvider? TransactionNameProvider { get; set; }

/// <summary>
/// Controls whether <see cref="TransactionNameProvider"/> runs on every request, or only
/// when routing fails to resolve a template.
/// </summary>
/// <remarks>
/// When <c>false</c> (default), the provider is only invoked as a fallback for unresolved routes —
/// matching the legacy behaviour. When <c>true</c>, the provider is invoked for every request
/// after routing; a non-empty return value overrides the route-derived name. If the provider
/// returns <c>null</c> or <see cref="string.Empty"/>, the routed name is preserved (or the URL-path
/// fallback applies when no route was resolved).
/// </remarks>
public bool PreferTransactionNameProvider { get; set; }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added follow up issue to complete documentation:


/// <summary>
/// Controls whether the casing of the standard (Production, Development and Staging) environment name supplied by <see cref="Microsoft.AspNetCore.Hosting.IHostingEnvironment" />
/// is adjusted when setting the Sentry environment. Defaults to true.
Expand Down
8 changes: 5 additions & 3 deletions src/Sentry.AspNetCore/SentryTracingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,15 @@ public async Task InvokeAsync(HttpContext context)
var status = SpanStatusConverter.FromHttpStatusCode(context.Response.StatusCode);

// If no Name was found for Transaction, then we don't have the route.
if (transaction.Name == string.Empty)
// Also, run this block if the caller has opted into always calling the TransactionNameProvider.
var customName = new Lazy<string?>(context.TryGetCustomTransactionName, LazyThreadSafetyMode.None);
var forceCustomName = _options.PreferTransactionNameProvider && customName.Value is not null;
if (transaction.Name == string.Empty || forceCustomName)
{
var method = context.Request.Method.ToUpperInvariant();

// If we've set a TransactionNameProvider, use that here
var customTransactionName = context.TryGetCustomTransactionName();
if (!string.IsNullOrEmpty(customTransactionName))
if (customName.Value is { } customTransactionName)
{
transaction.Name = $"{method} {customTransactionName}";
tracer.NameSource = TransactionNameSource.Custom;
Comment thread
Flash0ver marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ namespace Sentry.AspNetCore
public bool FlushOnCompletedRequest { get; set; }
public bool IncludeActivityData { get; set; }
public Sentry.Extensibility.RequestSize MaxRequestBodySize { get; set; }
public bool PreferTransactionNameProvider { get; set; }
public Sentry.AspNetCore.TransactionNameProvider? TransactionNameProvider { get; set; }
}
public static class SentryBuilderExtensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ namespace Sentry.AspNetCore
public bool FlushOnCompletedRequest { get; set; }
public bool IncludeActivityData { get; set; }
public Sentry.Extensibility.RequestSize MaxRequestBodySize { get; set; }
public bool PreferTransactionNameProvider { get; set; }
public Sentry.AspNetCore.TransactionNameProvider? TransactionNameProvider { get; set; }
}
public static class SentryBuilderExtensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ namespace Sentry.AspNetCore
public bool FlushOnCompletedRequest { get; set; }
public bool IncludeActivityData { get; set; }
public Sentry.Extensibility.RequestSize MaxRequestBodySize { get; set; }
public bool PreferTransactionNameProvider { get; set; }
public Sentry.AspNetCore.TransactionNameProvider? TransactionNameProvider { get; set; }
}
public static class SentryBuilderExtensions
Expand Down
130 changes: 130 additions & 0 deletions test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -752,4 +752,134 @@ public async Task Transaction_TransactionNameProviderSetUnset_TransactionNameSet
transaction.Name.Should().Be("GET /person/13.bmp");
transaction.NameSource.Should().Be(TransactionNameSource.Url);
}

[Fact]
public async Task Transaction_TransactionNameProviderSetForRoutedRequest_ProviderOverridesRouteName()
{
// Arrange
SentryTransaction transaction = null;

const string expectedName = "My custom name";

var sentryClient = Substitute.For<ISentryClient>();
sentryClient.When(x => x.CaptureTransaction(Arg.Any<SentryTransaction>(), Arg.Any<Scope>(), Arg.Any<SentryHint>()))
.Do(callback => transaction = callback.Arg<SentryTransaction>());

var hub = new Hub(new SentryOptions { Dsn = ValidDsn, TracesSampleRate = 1 }, sentryClient);

var server = new TestServer(new WebHostBuilder()
.UseDefaultServiceProvider(di => di.EnableValidation())
.UseSentry(aspNetOptions =>
{
aspNetOptions.TransactionNameProvider = _ => expectedName;
aspNetOptions.PreferTransactionNameProvider = true;
})
.ConfigureServices(services =>
{
services.AddRouting();

services.RemoveAll(typeof(Func<IHub>));
services.AddSingleton<Func<IHub>>(() => hub);
})
.Configure(app =>
{
app.UseRouting();

app.UseEndpoints(routes => routes.Map("/person/{id}", _ => Task.CompletedTask));
}));

var client = server.CreateClient();

// Act
await client.GetStringAsync("/person/13");

// Assert
transaction.Should().NotBeNull();
transaction.Name.Should().Be($"GET {expectedName}");
transaction.NameSource.Should().Be(TransactionNameSource.Custom);
}

[Fact]
public async Task Transaction_TransactionNameProviderSetForRoutedRequest_DefaultsToRouteBehaviour()
{
// Arrange
SentryTransaction transaction = null;

var sentryClient = Substitute.For<ISentryClient>();
sentryClient.When(x => x.CaptureTransaction(Arg.Any<SentryTransaction>(), Arg.Any<Scope>(), Arg.Any<SentryHint>()))
.Do(callback => transaction = callback.Arg<SentryTransaction>());

var hub = new Hub(new SentryOptions { Dsn = ValidDsn, TracesSampleRate = 1 }, sentryClient);

var server = new TestServer(new WebHostBuilder()
.UseDefaultServiceProvider(di => di.EnableValidation())
.UseSentry(aspNetOptions => aspNetOptions.TransactionNameProvider = _ => "My custom name")
.ConfigureServices(services =>
{
services.AddRouting();

services.RemoveAll(typeof(Func<IHub>));
services.AddSingleton<Func<IHub>>(() => hub);
})
.Configure(app =>
{
app.UseRouting();

app.UseEndpoints(routes => routes.Map("/person/{id}", _ => Task.CompletedTask));
}));

var client = server.CreateClient();

// Act
await client.GetStringAsync("/person/13");

// Assert
transaction.Should().NotBeNull();
transaction.Name.Should().Be("GET /person/{id}");
transaction.NameSource.Should().Be(TransactionNameSource.Route);
}

[Fact]
public async Task Transaction_PreferTransactionNameProviderWithNullReturn_PreservesRouteName()
{
// Arrange
SentryTransaction transaction = null;

var sentryClient = Substitute.For<ISentryClient>();
sentryClient.When(x => x.CaptureTransaction(Arg.Any<SentryTransaction>(), Arg.Any<Scope>(), Arg.Any<SentryHint>()))
.Do(callback => transaction = callback.Arg<SentryTransaction>());

var hub = new Hub(new SentryOptions { Dsn = ValidDsn, TracesSampleRate = 1 }, sentryClient);

var server = new TestServer(new WebHostBuilder()
.UseDefaultServiceProvider(di => di.EnableValidation())
.UseSentry(aspNetOptions =>
{
aspNetOptions.TransactionNameProvider = _ => null;
aspNetOptions.PreferTransactionNameProvider = true;
})
.ConfigureServices(services =>
{
services.AddRouting();

services.RemoveAll(typeof(Func<IHub>));
services.AddSingleton<Func<IHub>>(() => hub);
})
.Configure(app =>
{
app.UseRouting();

app.UseEndpoints(routes => routes.Map("/person/{id}", _ => Task.CompletedTask));
}));

var client = server.CreateClient();

// Act
await client.GetStringAsync("/person/13");

// Assert
transaction.Should().NotBeNull();
transaction.Name.Should().Be("GET /person/{id}");
transaction.NameSource.Should().Be(TransactionNameSource.Route);
}
}
Loading