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 .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ jobs:
-pn 'Trellis.ServiceLevelIndicators' `
-pv $packageVersion `
-ps 'Xavier John' `
-nsb 'https://github.com/xavierjohn/ServiceLevelIndicators/sbom' `
-nsb 'https://github.com/xavierjohn/Trellis.ServiceLevelIndicators/sbom' `
-m $sbomDirectory `
-mi SPDX:3.0 `
-D true `
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
<LangVersion>Latest</LangVersion>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/xavierjohn/ServiceLevelIndicators.git</RepositoryUrl>
<RepositoryUrl>https://github.com/xavierjohn/Trellis.ServiceLevelIndicators.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://github.com/xavierjohn/ServiceLevelIndicators</PackageProjectUrl>
<PackageProjectUrl>https://github.com/xavierjohn/Trellis.ServiceLevelIndicators</PackageProjectUrl>
<PackageIcon>icon.png</PackageIcon>
</PropertyGroup>

Expand Down
5 changes: 3 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,13 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.2.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageVersion Include="Asp.Versioning.OpenApi" Version="10.2.3" />
<PackageVersion Include="Azure.Core" Version="1.63.0" />
<PackageVersion Include="DotNet.ReproducibleBuilds" Version="2.0.5" />
<PackageVersion Include="Trellis.AgentDocs.Packaging" Version="0.1.0-preview.20" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.13" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.10.94" />
<PackageVersion Include="OpenTelemetry" Version="1.20.0" />
Expand All @@ -36,6 +34,9 @@
<PackageVersion Include="FluentAssertions" Version="7.2.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.1.1" />
<!-- FakeTimeProvider follows dotnet/extensions' independent 10.x release cadence,
not the runtime's 10.0.x servicing versions. -->
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" />
<!-- Pin SqlClient's test-only cryptography dependency to a patched release. -->
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.12.0" />
Expand Down
532 changes: 112 additions & 420 deletions README.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion ServiceLevelIndicators.slnx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<Solution>
<Folder Name="/Sample/">
<Project Path="sample/ConsoleApp/SampleConsoleSLI.csproj" />
<Project Path="sample/FocusedSamples.Tests/FocusedSamples.Tests.csproj" />
<Project Path="sample/GenerateSli.Tests/GenerateSli.Tests.csproj" />
<Project Path="sample/GenerateSli/GenerateSli.csproj" />
<Project Path="sample/MinApi/SampleMinimalApiSli.csproj" />
<Project Path="sample/WebApi/SampleWebApplicationSLI.csproj" />
<Project Path="sample/SliProductionDemo.Tests/SliProductionDemo.Tests.csproj" />
<Project Path="sample/SliProductionDemo/SliProductionDemo.csproj" />
<Project Path="sample/WebApiVersioned/SampleVersionedWebApplicationSLI.csproj" />
</Folder>
<Folder Name="/Trellis.ServiceLevelIndicators.Asp.ApiVersioning/">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This package extends [Trellis.ServiceLevelIndicators.Asp](https://www.nuget.org/
## Installation

```shell
dotnet add package Trellis.ServiceLevelIndicators.Asp.ApiVersioning
dotnet add package Trellis.ServiceLevelIndicators.Asp.ApiVersioning --prerelease
```

> Requires [Trellis.ServiceLevelIndicators.Asp](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp) (installed automatically as a dependency).
Expand Down Expand Up @@ -76,6 +76,6 @@ agent opens it when its task concerns SLI metrics. After a package upgrade, run

## Further Reading

- [Full documentation and samples](https://github.com/xavierjohn/ServiceLevelIndicators)
- [Package selection and usage reference](https://github.com/xavierjohn/ServiceLevelIndicators/blob/main/docs/usage-reference.md)
- [Full documentation and samples](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators)
- [Package selection and usage reference](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators/blob/main/docs/usage-reference.md)
- [ASP.NET API Versioning](https://github.com/dotnet/aspnet-api-versioning)
45 changes: 29 additions & 16 deletions Trellis.ServiceLevelIndicators.Asp/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ Use [Trellis.ServiceLevelIndicators](https://www.nuget.org/packages/Trellis.Serv
## Installation

```shell
dotnet add package Trellis.ServiceLevelIndicators.Asp
dotnet add package Trellis.ServiceLevelIndicators.Asp --prerelease
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
```

The OpenTelemetry hosting and OTLP exporter extensions are separate dependencies. Import `OpenTelemetry.Metrics` and `Trellis.ServiceLevelIndicators`. The repository README includes a complete copyable Minimal API setup.

## Quick Start — MVC Controllers

```csharp
Expand Down Expand Up @@ -60,6 +64,8 @@ app.MapGet("/hello", () => "Hello World!")

If you configure a custom `Meter` in `ServiceLevelIndicatorOptions`, register that same meter with OpenTelemetry by calling `AddServiceLevelIndicatorInstrumentation(meter)`.

Register `AddServiceLevelIndicator()` once per Minimal API endpoint, either on its group or on the handler. Applying it to both can duplicate parameter metadata and fail endpoint initialization.

`ServiceLevelIndicator` is a sealed `IDisposable` registered as a singleton; the DI container disposes it (and the `Meter` it created) at host shutdown — no manual cleanup needed. A `Meter` you supply via `Options.Meter` is owned by you and is never disposed by SLI.

Place `UseServiceLevelIndicator()` after routing and before endpoint execution so the middleware can read endpoint metadata and measure request handling.
Expand Down Expand Up @@ -91,6 +97,8 @@ The middleware measures routed endpoints, not genuine route misses. Matched endp

Configure `options.LatencyThresholds.Add(TimeSpan.FromSeconds(2))` to enable `operation.duration.threshold.count` (`Counter<long>`, unit `{operation}`) on the same meter. Each measured request contributes once per configured threshold, with `sli.latency.threshold_ms` and `sli.latency.result = within | exceeded`; equality is within. Distinct positive whole-millisecond values are validated and snapshotted at construction; the default is disabled.

Thresholds classify recorded duration; they do not enforce request timeouts, cancel work, or alter HTTP status/outcome.

The counter retains SLI, API-version, abort, and custom dimensions, but excludes HTTP status, HTTP method, and `sli.request.abort_exception`. The duration histogram is unchanged. A fast failure is not a good event merely because its timing result is `within`.

Abort-first availability can miss slow dependency outages when callers disconnect before the server fails. Pair availability with a successful-and-fast latency objective that includes slow aborts. The abort diagnostic helps investigation but is not a reliable standalone outage detector. Use the repository's usage reference for the full policy and Azure delta-counter query.
Expand All @@ -102,7 +110,7 @@ Abort-first availability can miss slow dependency outages when callers disconnec
For `GET /teams/{teamId}` made by user `xa1` for team `team1`:

- ✅ `CustomerResourceId = "team1"` (the resource being acted on)
- ❌ NOT `"xa1"` — that's the *caller*; put it in `AddAttribute("CallerTier", ...)` or your audit log
- ❌ NOT `"xa1"` — that's the *caller*; keep identities in audit logs/traces, not metric dimensions
- ❌ NOT a fresh `Guid.NewGuid()` per request — that explodes metric cardinality
- ❌ NOT the raw path `"/teams/team1"` — already covered by `Operation`

Expand All @@ -120,7 +128,7 @@ app.MapGet("/teams/{teamId}",
.AddServiceLevelIndicator("GetTeam");
```

Or set it imperatively from claims/headers via `Enrich` or `HttpContext.GetMeasuredOperation()` — but the value must still be stable and meaningful.
Or set it imperatively from resolved target metadata via `Enrich` or `HttpContext.GetMeasuredOperation()`. A caller's tenant claim is only a valid target when the application guarantees same-tenant operations; cross-tenant callers must not replace the actual target.

### How `Operation` is resolved

Expand Down Expand Up @@ -196,25 +204,27 @@ ASP uses the public `ServiceLevelIndicatorEventSource` contract and public outco

### Enrich with custom data

Use `Enrich` (or `EnrichAsync`) to set `CustomerResourceId` from a stable identifier on the request (a tenant/subscription claim, a header, etc.) or to add custom attributes. **Do not use the caller's identity (UPN, user ID) as `CustomerResourceId`** — that's the caller, not the resource being acted on. See [What `CustomerResourceId` is — and what it is NOT](#what-customerresourceid-is--and-what-it-is-not).
`Enrich` receives a `WebEnrichmentContext` exposing `HttpContext`, `SetCustomerResourceId`, and `AddAttribute`. `EnrichAsync` receives that context and a cancellation token and returns `ValueTask`. Add bounded dimensions without replacing the target resource:

```csharp
builder.Services.AddServiceLevelIndicator(options => { /* ... */ })
.AddMvc()
.Enrich(context =>
{
var tenantId = context.HttpContext.User.Claims
.FirstOrDefault(c => c.Type == "tid")?.Value ?? "unknown";
context.SetCustomerResourceId(tenantId);

// Caller identity belongs in a separate (still bounded) dimension, never in CustomerResourceId.
var tier = context.HttpContext.User.Claims
.FirstOrDefault(c => c.Type == "tier")?.Value ?? "free";
var tier = context.HttpContext.User.FindFirst("tier")?.Value switch
{
"free" => "free",
"standard" => "standard",
"premium" => "premium",
_ => "unknown"
};
context.AddAttribute("CallerTier", tier);
});
```

`Enrich` and `EnrichAsync` delegates are skipped once request cancellation is observed. To preserve already-resolved metadata on aborts, implement `IEnrichment<WebEnrichmentContext>` with `EnrichOnCancellation => true` and register it in DI. Only opt in for bounded, cancellation-safe metadata access without I/O; the canceled token is still passed. Skipped enrichments do not prevent later cancellation-safe enrichments from running. The API-version package already opts in.
Enrichment runs after endpoint handling and can overwrite a resource already extracted from route metadata. Only call `SetCustomerResourceId` with the resolved target, not the caller's tenant unless same-tenant semantics are guaranteed. See [What `CustomerResourceId` is — and what it is NOT](#what-customerresourceid-is--and-what-it-is-not).

`Enrich` and `EnrichAsync` delegates are skipped once request cancellation is observed. To preserve already-resolved metadata on aborts, implement `IEnrichment<WebEnrichmentContext>` with `EnrichOnCancellation => true` and register it in DI. Only opt in for bounded, cancellation-safe metadata access without I/O; the canceled token is still passed. Skipped enrichments do not prevent later cancellation-safe enrichments from running. The API-version package already opts in.

### Override the operation name

Expand Down Expand Up @@ -242,10 +252,13 @@ HttpContext.GetMeasuredOperation().CustomerResourceId = customerResourceId;
Parameters decorated with `[Measure]` are automatically added as metric dimensions:

```csharp
[HttpGet("name/{first}/{teamId}")]
public IActionResult Get([Measure] string first, [CustomerResourceId] string teamId) => Ok();
[HttpPost("projects/{projectId}/sync/{mode:regex(^(quick|standard|slow)$)}")]
public IActionResult Sync(
[CustomerResourceId] string projectId,
[Measure("SyncMode")] string mode) => Ok();
```

Use stable target identifiers and bounded categories such as these three sync modes, not personal names or arbitrary text.
### Add custom attributes manually

```csharp
Expand Down Expand Up @@ -299,5 +312,5 @@ agent opens it when its task concerns SLI metrics. After a package upgrade, run

## Further Reading

- [Full documentation and samples](https://github.com/xavierjohn/ServiceLevelIndicators)
- [Package selection and usage reference](https://github.com/xavierjohn/ServiceLevelIndicators/blob/main/docs/usage-reference.md)
- [Full documentation and samples](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators)
- [Package selection and usage reference](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators/blob/main/docs/usage-reference.md)
30 changes: 17 additions & 13 deletions Trellis.ServiceLevelIndicators/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ If you want automatic measurement of ASP.NET Core endpoints, use [Trellis.Servic
## Installation

```shell
dotnet add package Trellis.ServiceLevelIndicators
dotnet add package Trellis.ServiceLevelIndicators --prerelease
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
```

The OpenTelemetry hosting and OTLP exporter extensions are separate dependencies used by the examples below. Import `OpenTelemetry.Metrics` and `Trellis.ServiceLevelIndicators`; custom meters also need `System.Diagnostics.Metrics`.

## Quick Start

### 1. Register with OpenTelemetry
Expand All @@ -34,7 +38,7 @@ builder.Services.AddOpenTelemetry()
If you supply a custom `Meter` in `ServiceLevelIndicatorOptions`, register that same meter with OpenTelemetry:

```csharp
var sliMeter = new Meter("MyCompany.ServiceLevelIndicator");
using var sliMeter = new Meter("MyCompany.ServiceLevelIndicator");

builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
Expand Down Expand Up @@ -63,22 +67,20 @@ builder.Services.AddServiceLevelIndicator(options =>

### 3. Measure operations

Wrap any block of code in a `using StartMeasuring` scope:
Prefer `Measure` / `MeasureAsync` to infer outcomes from completion, exceptions, and cancellation:

```csharp
void DoWork(ServiceLevelIndicator sli)
{
using var op = sli.StartMeasuring("ProcessOrder");
// Do work...
op.SetOutcome(SliOutcome.Success);
}
Task DoWorkAsync(ServiceLevelIndicator sli, CancellationToken cancellationToken) =>
sli.MeasureAsync("ProcessOrder", () => Task.Delay(50, cancellationToken));
```

You can also pass custom attributes:
These helpers record `Success` on completion, `Failure` on exceptions, and `Ignored` for `OperationCanceledException`; exceptions still propagate. Raw `StartMeasuring(...)` scopes default to `Ignored` and do not detect thrown work. If you use one, explicitly classify every completion, failure, and cancellation path.

Helpers also accept custom attributes:

```csharp
var attribute = new KeyValuePair<string, object?>("Event", "OrderCreated");
using var op = sli.StartMeasuring("ProcessOrder", attribute);
await sli.MeasureAsync("ProcessOrder", () => Task.Delay(50), attribute);
```

Custom attribute names must be unique and must not reuse SLI-reserved tags: `CustomerResourceId`, `LocationId`, `Operation`, `Outcome`, `activity.status.code`, `http.request.method`, `http.response.status.code`, `sli.request.aborted`, `sli.request.abort_exception`, `sli.latency.threshold_ms`, or `sli.latency.result`.
Expand Down Expand Up @@ -136,6 +138,8 @@ builder.Services.AddServiceLevelIndicator(options =>

`LatencyThresholds` is an initially empty, get-only collection. Configure distinct, positive, whole-millisecond values at startup. Invalid or duplicate values fail before instruments are created; configuration is snapshotted when the singleton is constructed.

Thresholds classify recorded elapsed time; they do not enforce timeouts, cancel work, or decide the SLI outcome.

| Element | Contract |
|---|---|
| Instrument | `operation.duration.threshold.count` |
Expand Down Expand Up @@ -213,6 +217,6 @@ agent opens it when its task concerns SLI metrics. After a package upgrade, run

## Further Reading

- [Full documentation and samples](https://github.com/xavierjohn/ServiceLevelIndicators)
- [Package selection and usage reference](https://github.com/xavierjohn/ServiceLevelIndicators/blob/main/docs/usage-reference.md)
- [Full documentation and samples](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators)
- [Package selection and usage reference](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators/blob/main/docs/usage-reference.md)
- [OpenTelemetry .NET](https://opentelemetry.io/docs/languages/dotnet/)
Binary file removed assets/aspire.jpg
Binary file not shown.
Binary file added assets/sli-live-demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed assets/versioned.jpg
Binary file not shown.
28 changes: 23 additions & 5 deletions docs/api_reference/trellis-api-sli-apiversioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package: Trellis.ServiceLevelIndicators.Asp.ApiVersioning
namespaces: [Trellis.ServiceLevelIndicators]
types: [ServiceLevelIndicatorServiceCollectionExtensions, ApiVersionEnrichment]
version: v10
last_verified: 2026-10-15
last_verified: 2026-10-16
audience: [llm]
---

Expand All @@ -21,7 +21,7 @@ See also the `Trellis.ServiceLevelIndicators` and `Trellis.ServiceLevelIndicator

| Tag | Value |
|---|---|
| `http.api.version` | The single resolved API version (e.g. `2023-06-06`), `"Neutral"` if the endpoint is API-version-neutral, `"Unspecified"` if no version was requested and no default was assumed, or `""` if the request is invalid or ambiguous. |
| `http.api.version` | The single resolved API version (e.g. `1.0`, `2.0`, or `2023-06-06`), `"Neutral"` if the endpoint is API-version-neutral, `"Unspecified"` if no version was requested and no default was assumed, or `""` if the request is invalid or ambiguous. |

Resolved versions are retained on aborted MVC and Minimal API requests, and on the opt-in `operation.duration.threshold.count` counter. Date-based values remain date-only strings; Kusto may interpret date-like dynamic properties as `datetime`, which should be normalized in queries rather than by changing library formatting.

Expand Down Expand Up @@ -80,13 +80,26 @@ This enrichment is registered as a singleton via `TryAddEnumerable`, so calling

## Typical usage

For MVC, install the versioning implementation and separate telemetry dependencies as well as SLI:

```shell
dotnet add package Trellis.ServiceLevelIndicators.Asp.ApiVersioning --prerelease
dotnet add package Asp.Versioning.Mvc
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
```

Within an existing ASP.NET Core `builder`:

```csharp
using Asp.Versioning;
using OpenTelemetry.Metrics;
using Trellis.ServiceLevelIndicators;

builder.Services.AddControllers();
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(2023, 6, 6);
o.ApiVersionReader = new QueryStringApiVersionReader();
o.ReportApiVersions = true;
}).AddMvc();

Expand All @@ -96,13 +109,18 @@ builder.Services.AddOpenTelemetry()
builder.Services.AddServiceLevelIndicator(o =>
{
o.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
o.AutomaticallyEmitted = false;
})
.AddMvc()
.AddApiVersion(); // ← from Trellis.ServiceLevelIndicators.Asp.ApiVersioning
.AddApiVersion();

var app = builder.Build();
app.UseRouting();
app.UseServiceLevelIndicator();
app.MapControllers();
app.Run();
```

A request to a `[ApiVersion("2023-06-06")]` endpoint then emits the standard SLI tags plus `http.api.version=2023-06-06`. Requests to an `[ApiVersionNeutral]` endpoint emit `http.api.version=Neutral`.
Mark the measured controller/action with `[ServiceLevelIndicator]` and declare its supported versions with `[ApiVersion("1.0")]` and `[ApiVersion("2.0")]`. A request such as `GET /projects/project-blue?api-version=2.0` to that controller emits the standard SLI tags plus `http.api.version=2.0`. Date-based versions remain supported. Measured `[ApiVersionNeutral]` endpoints emit `http.api.version=Neutral`.

`AddApiVersion()` enriches existing SLI measurements; it does not configure version routing or enable measurement on an otherwise excluded/opt-out endpoint.
Loading
Loading