diff --git a/README.md b/README.md index 2fcd926..159539f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ By default, a meter named `Trellis.SLI` with instrument name `operation.duration **Trellis.ServiceLevelIndicators.Asp** adds the following dimensions. -- Operation - For ASP.NET endpoints, the operation name is the HTTP method plus the route template, resolved in this order: (1) `[ServiceLevelIndicator(Operation = "...")]` attribute or `.AddServiceLevelIndicator("op")` override, (2) MVC `AttributeRouteInfo.Template`, (3) the endpoint's `RouteEndpoint.RoutePattern.RawText` (Minimal APIs / conventional routing). Route placeholders such as `{id}` are preserved, never substituted with the concrete request value. If no bounded template is available, the middleware emits the sentinel `" "` and logs a warning — see that value in your metrics as a signal to add a route template. +- Operation - For ASP.NET endpoints, the operation name is the HTTP method plus the route template, resolved in this order: (1) `[ServiceLevelIndicator(Operation = "...")]` attribute or `.AddServiceLevelIndicator("op")` override, (2) MVC `AttributeRouteInfo.Template`, (3) the endpoint's `RouteEndpoint.RoutePattern.RawText` (Minimal APIs / conventional routing). Route placeholders such as `{id}` are preserved, never substituted with the concrete request value. A trailing slash is trimmed (the literal root `/` is kept), so a route group's root and its slashless form share one series. If no bounded template is available, the middleware emits the sentinel `" "` and logs a warning — see that value in your metrics as a signal to add a route template. - Outcome - By default, 2xx and 3xx responses are `Success`, common caller errors such as 400/401/403/404/409/412/422 are `ClientError`, 429 and 5xx responses are `Failure`, and request-aborted cancellations are `Ignored`. - http.response.status.code - The http status code. - http.request.method - The http request method (GET, POST, etc). diff --git a/Trellis.ServiceLevelIndicators.Asp/src/README.md b/Trellis.ServiceLevelIndicators.Asp/src/README.md index 6e1fcdf..25f8743 100644 --- a/Trellis.ServiceLevelIndicators.Asp/src/README.md +++ b/Trellis.ServiceLevelIndicators.Asp/src/README.md @@ -114,6 +114,8 @@ Or set it imperatively from claims/headers via `Enrich` or `HttpContext.GetMeasu 2. MVC attribute route template (`AttributeRouteInfo.Template`). 3. The endpoint's route pattern (`RouteEndpoint.RoutePattern.RawText`) — covers Minimal APIs and conventional MVC routing. Placeholders such as `{id}` are preserved, **never** substituted with the concrete request value. +A trailing slash is trimmed from the resolved template (the literal root `/` is preserved), so a route group's root endpoint — where the `/orders` group prefix combines with the `/` pattern to produce `/orders/` — shares one series with `/orders` instead of emitting a separate `POST /orders/`. + If none of those yield a bounded template (e.g. a synthetic problem-details endpoint emitted by Asp.Versioning when the API version is invalid), the middleware emits the sentinel `" "` and logs a one-time warning per endpoint name. **If you see `` in your metrics, an endpoint is missing a route template — fix it by adding an attribute route or moving to a routed endpoint.** ## Customizations diff --git a/Trellis.ServiceLevelIndicators.Asp/src/ServiceLevelIndicatorMiddleware.cs b/Trellis.ServiceLevelIndicators.Asp/src/ServiceLevelIndicatorMiddleware.cs index 7d85de1..b235a61 100644 --- a/Trellis.ServiceLevelIndicators.Asp/src/ServiceLevelIndicatorMiddleware.cs +++ b/Trellis.ServiceLevelIndicators.Asp/src/ServiceLevelIndicatorMiddleware.cs @@ -169,6 +169,16 @@ private string GetOperation(HttpContext context, EndpointMetadataCollection meta return context.Request.Method.ToUpperInvariant() + " "; } + // A route group's root endpoint yields a trailing slash in the raw template (e.g. a "/orders" + // group combined with the "/" pattern => "/orders/"). Trim it so "/orders/" and "/orders" + // share a single Operation series, while preserving the literal root path "/". + if (template.Length > 1) + { + var normalized = template.TrimEnd('/'); + if (normalized.Length > 0) + template = normalized; + } + return context.Request.Method.ToUpperInvariant() + " " + template; } diff --git a/Trellis.ServiceLevelIndicators.Asp/tests/ServiceLevelIndicatorMinimalApiTests.cs b/Trellis.ServiceLevelIndicators.Asp/tests/ServiceLevelIndicatorMinimalApiTests.cs index 9809e27..55882f8 100644 --- a/Trellis.ServiceLevelIndicators.Asp/tests/ServiceLevelIndicatorMinimalApiTests.cs +++ b/Trellis.ServiceLevelIndicators.Asp/tests/ServiceLevelIndicatorMinimalApiTests.cs @@ -160,6 +160,85 @@ public async Task SLI_Metrics_emits_route_template_not_concrete_path_for_minimal operations.Should().AllBe("GET /resource/{id}"); } + [Fact] + public async Task SLI_Metrics_trims_trailing_slash_from_route_group_root_operation() + { + // Regression: a route group's root endpoint ("/grouped" prefix + "/") produces a trailing + // slash in the raw route template. The Operation tag must drop it so "/grouped/" and + // "/grouped" map to a single bounded series rather than two. + // Arrange + using var host = await CreateMinimalApiHost(); + + // Act + var response = await host.GetTestClient().PostAsync("grouped/", content: null, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var expectedTags = new KeyValuePair[] + { + new("CustomerResourceId", "TestCustomerResourceId"), + new("LocationId", "ms-loc://az/public/West US 3"), + new("Operation", "POST /grouped"), + new("Outcome", "Success"), + new("http.request.method", "POST"), + new("http.response.status.code", 200), + }; + + ValidateMetrics(expectedTags); + } + + [Fact] + public async Task SLI_Metrics_trims_trailing_slash_from_explicitly_authored_route() + { + // The trailing-slash trim is deliberate and not limited to route-group roots: an explicitly + // authored "/explicit-trailing/" route emits "GET /explicit-trailing" as well. + // Arrange + using var host = await CreateMinimalApiHost(); + + // Act + var response = await host.GetTestClient().GetAsync("explicit-trailing/", TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var expectedTags = new KeyValuePair[] + { + new("CustomerResourceId", "TestCustomerResourceId"), + new("LocationId", "ms-loc://az/public/West US 3"), + new("Operation", "GET /explicit-trailing"), + new("Outcome", "Success"), + new("http.response.status.code", 200), + }; + + ValidateMetrics(expectedTags); + } + + [Fact] + public async Task SLI_Metrics_preserves_literal_root_path_operation() + { + // Guard: the trim must keep the literal root path "/" intact, not reduce it to the empty string. + // Arrange + using var host = await CreateMinimalApiHost(); + + // Act + var response = await host.GetTestClient().GetAsync("/", TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var expectedTags = new KeyValuePair[] + { + new("CustomerResourceId", "TestCustomerResourceId"), + new("LocationId", "ms-loc://az/public/West US 3"), + new("Operation", "GET /"), + new("Outcome", "Success"), + new("http.response.status.code", 200), + }; + + ValidateMetrics(expectedTags); + } + [Fact] public async Task SLI_Metrics_not_emitted_when_AddServiceLevelIndicator_not_called() { @@ -262,6 +341,21 @@ private async Task CreateMinimalApiHost() => .AddServiceLevelIndicator(); endpoints.MapGet("/no-sli", () => "No SLI"); + + // A route group's root endpoint: the "/grouped" prefix combined with the + // "/" pattern yields the raw template "/grouped/" (with a trailing slash). + var grouped = endpoints.MapGroup("/grouped") + .AddServiceLevelIndicator(); + grouped.MapPost("/", () => "Created"); + + // An explicitly authored trailing-slash route normalizes the same way as a + // route-group root — the trim is deliberate and applies to any route template. + endpoints.MapGet("/explicit-trailing/", () => "Trailing") + .AddServiceLevelIndicator(); + + // The literal root path must keep its single slash (it must not be trimmed away). + endpoints.MapGet("/", () => "Root") + .AddServiceLevelIndicator(); }); })) .StartAsync();