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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<METHOD> <unrouted>"` 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 `"<METHOD> <unrouted>"` 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).
Expand Down
2 changes: 2 additions & 0 deletions Trellis.ServiceLevelIndicators.Asp/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<METHOD> <unrouted>"` and logs a one-time warning per endpoint name. **If you see `<unrouted>` in your metrics, an endpoint is missing a route template — fix it by adding an attribute route or moving to a routed endpoint.**

## Customizations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ private string GetOperation(HttpContext context, EndpointMetadataCollection meta
return context.Request.Method.ToUpperInvariant() + " <unrouted>";
}

// 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>[]
{
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<string, object?>[]
{
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<string, object?>[]
{
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()
{
Expand Down Expand Up @@ -262,6 +341,21 @@ private async Task<IHost> 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();
Expand Down
Loading