diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 885fe17..de1ce84 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -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 `
diff --git a/Directory.Build.props b/Directory.Build.props
index a2c2d0d..998ebf6 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,9 +14,9 @@
Latest
latest-Recommended
MIT
- https://github.com/xavierjohn/ServiceLevelIndicators.git
+ https://github.com/xavierjohn/Trellis.ServiceLevelIndicators.git
git
- https://github.com/xavierjohn/ServiceLevelIndicators
+ https://github.com/xavierjohn/Trellis.ServiceLevelIndicators
icon.png
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 51f7165..c4b6ebe 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,7 +10,6 @@
-
@@ -18,7 +17,6 @@
-
@@ -36,6 +34,9 @@
+
+
diff --git a/README.md b/README.md
index 5afbf71..116d132 100644
--- a/README.md
+++ b/README.md
@@ -1,494 +1,186 @@
# Trellis.ServiceLevelIndicators
-[](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators/actions/workflows/build.yml)
-[](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators)
-[](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp)
-[](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp.ApiVersioning)
-[](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators)
-[](LICENSE)
-[](https://dotnet.microsoft.com/download)
-[](https://docs.microsoft.com/en-us/dotnet/csharp/)
-[](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators/stargazers)
-
-> Latency SLI library for .NET: emits operation-duration histograms via `System.Diagnostics.Metrics` + OpenTelemetry, with rich dimensions (CustomerResourceId, LocationId, Operation, Outcome) and ASP.NET Core + API-versioning integrations.
+> **Your service can look healthy while one customer's operations are failing.**
-`Trellis.ServiceLevelIndicators` is a .NET library for emitting service-level latency metrics in milliseconds using the standard [System.Diagnostics.Metrics](https://learn.microsoft.com/dotnet/api/system.diagnostics.metrics) and OpenTelemetry pipeline.
+**Measure customer impact, not just HTTP response times.** Trellis SLI emits business-operation latency metrics in milliseconds, with the customer resource, location, operation, and outcome attached. Build availability and latency objectives that reveal problems aggregate request metrics can hide.
-It is designed for teams that need more than generic request timing. The library helps measure meaningful operations, attach service-specific dimensions such as customer, location, operation name, and SLI outcome, and build SLO or SLA-oriented dashboards and alerts from those metrics.
-
-Service level indicators (SLIs) are metrics used to track how a service is performing against expected reliability and responsiveness goals. Common examples include availability, response time, throughput, and error rate. This library focuses on latency SLIs so you can consistently measure operation duration across background work, ASP.NET Core APIs, and versioned endpoints.
+[](https://github.com/xavierjohn/Trellis.ServiceLevelIndicators/actions/workflows/build.yml)
+[](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators)
+[](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp)
+[](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp.ApiVersioning)
+[](https://dotnet.microsoft.com/download)
+[](LICENSE)
-### Video guide
+**[Try the live demo](#try-the-live-demo)** | [Get started](#installation) | [Focused examples](#focused-examples) | [Integration guide](docs/usage-reference.md) | [Watch the video](https://youtu.be/jpReYDjaEH0)
-[](https://youtu.be/jpReYDjaEH0)
+
+*Real metrics from the included demo, with simulated incidents. Compare the service-wide cards with the per-project charts: one struggling project tells a different story.*
-## Service Level Indicator Library
+## Why Trellis SLI?
-**Trellis.ServiceLevelIndicators** emits operation latency metrics in milliseconds so service owners can monitor performance over time using dimensions that matter to their system.
-The metrics are emitted via the standard [.NET Meter Class](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.meter).
+| The problem | What becomes visible |
+|---|---|
+| **Averages hide customer-specific incidents.** | Break down operations by `CustomerResourceId`, `LocationId`, and `Operation` instead of treating every request as the same experience. |
+| **200 OK does not mean fast enough.** | Opt into exact within/exceeded latency-threshold counts. Measure compliance directly rather than estimating it from p95. |
+| **A fast failure is still a failure.** | Track availability separately from latency. A fast 429 can meet the latency objective while failing availability. |
+| **Caller mistakes look like service outages.** | Distinguish `ClientError` from `Failure`, keeping caller errors visible without including them in the default SLO population. |
-By default, a meter named `Trellis.SLI` with instrument name `operation.duration` is added to the service metrics. If you configure `ServiceLevelIndicatorOptions.Meter`, metrics are emitted from that meter instead. Metrics recorded with `StartMeasuring(...)` emit the following [attributes](https://opentelemetry.io/docs/specs/otel/common/#attribute).
+Use the same SLI model in **ASP.NET Core MVC, Minimal APIs, versioned APIs, workers, and console apps**. Built on [`System.Diagnostics.Metrics`](https://learn.microsoft.com/dotnet/api/system.diagnostics.metrics) and **OpenTelemetry**; use your existing metrics backend.
-- CustomerResourceId - The **target resource** of the operation — the noun in the URL path being read or modified, normalized to a stable identifier (tenant, subscription, account, work item). **NOT** the caller, **NOT** a per-request GUID, **NOT** a user ID or email. Example: for `GET /teams/{teamId}` called by user `xa1` for team `team1`, the value is `"team1"`, not `"xa1"`. See the [ASP.NET Core package README](Trellis.ServiceLevelIndicators.Asp/src/README.md#what-customerresourceid-is--and-what-it-is-not) for the full mental model.
-- LocationId - The location where the service is running, such as public cloud in the West US 3 region. [Azure Core](https://learn.microsoft.com/en-us/dotnet/api/azure.core.azurelocation?view=azure-dotnet)
-- Operation - The name of the operation.
-- Outcome - The SLI outcome. Exact values are `Success`, `Failure`, `ClientError`, and `Ignored`. Default success-rate queries should use `Success / (Success + Failure)`.
+## Try the live demo
-**Trellis.ServiceLevelIndicators.Asp** adds the following dimensions.
+**One command. Docker only. No local .NET SDK required.** With Docker running Linux containers, start the complete demo from the repository root:
-- 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`, and 429 and 5xx responses are `Failure`. An observed request abort takes precedence over escaping exceptions and remains `Ignored`.
-- http.response.status.code - The HTTP status code. Omitted on an aborted request when response headers have not started; otherwise the actual status is retained.
-- http.request.method - The http request method (GET, POST, etc).
-- sli.request.aborted - `true` when the request-abort token is canceled; absent otherwise.
-- sli.request.abort_exception - Duration-only diagnostic on an aborted request with an escaping exception: `cancellation` for a top-level `OperationCanceledException`, `other` for every other exception shape. This describes shape, not cause.
+```powershell
+docker compose -f .\sample\Observability\Grafana\docker-compose.yml --profile demo up --build -d
+```
-Difference between ServiceLevelIndicator and http.server.request.duration
+Open the [live SLI dashboard](http://localhost:3000/d/trellis-sli?var-operation=SyncProject). The Project Tracker API, continuous traffic generator, Collector, Prometheus, and Grafana all run locally.
-| | ServiceLevelIndicator | http.server.request.duration
-| ---------- | ------- | ------
-| Resolution | milliseconds | seconds
-| Customer | CustomerResourceId | N/A
-| Error check | `Outcome` and HTTP status code | HTTP status code
+Healthy background traffic continues while slowdowns, failures, caller errors, throttling, and recovery repeat automatically. Start with **SyncProject / All projects**, then isolate **project-red** and compare it with the healthy projects.
-This makes the library useful when generic HTTP server metrics are not enough, especially for multi-tenant services, APIs with customer-specific objectives, or workloads that need the same SLI model outside HTTP request handling.
+**[Follow the guided walkthrough](sample/Observability/Grafana/README.md)** for what to investigate, how the SLIs are calculated, source-code pointers, and shutdown instructions. This is a local learning environment, not a production deployment.
-**Trellis.ServiceLevelIndicators.Asp.ApiVersioning** adds the following dimensions.
-- http.api.version - The resolved API version when used in conjunction with the [API Versioning package](https://github.com/dotnet/aspnet-api-versioning), including on aborted requests. The value can be a version string, `Neutral`, `Unspecified`, or an empty string for invalid or ambiguous requests.
+### Video guide
+
+Watch the complete NuGet guide
-## NuGet Packages
+[](https://youtu.be/jpReYDjaEH0)
-**Source-tree additions after `10.0.0-preview.37`:** result selectors, endpoint exclusions, and the instrumentation failure policy below require a package containing these changes; they are not in preview.37.
+
-- **Trellis.ServiceLevelIndicators**
+## What gets measured
- This library can be used to emit SLI for all .net core applications, where each operation is measured.
+The default meter is `Trellis.SLI`. Its `operation.duration` histogram records elapsed time in **milliseconds**, with these stable dimensions:
- [](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators)
+| Dimension | Meaning |
+|---|---|
+| `CustomerResourceId` | The **target resource**, such as the project, tenant, or subscription being operated on. Not the caller's identity or a request ID. |
+| `LocationId` | Where the service runs, set once from deployment configuration. |
+| `Operation` | A named business operation or HTTP method plus route template. Route values are never substituted into the default operation name. |
+| `Outcome` | `Success`, `Failure`, `ClientError`, or `Ignored`. |
-- **Trellis.ServiceLevelIndicators.Asp**
+ASP.NET Core adds HTTP method/status and request-abort diagnostics. The API-versioning package adds `http.api.version`.
- For measuring SLI for ASP.NET Core applications use this library that will automatically measure each API operation.
+Built-in `http.server.request.duration` metrics remain useful: their unit is seconds, not a limit on timing precision, and they include status/error diagnostics. Trellis adds an explicit SLI outcome policy and service-specific dimensions, with the same model available outside HTTP.
- [](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp)
-
-- **Trellis.ServiceLevelIndicators.Asp.ApiVersioning**
+For default availability, use **`Success / (Success + Failure)`**. Common caller errors are `ClientError`; 429 and 5xx are `Failure`. Observed request aborts are `Ignored`. For latency, opt into `operation.duration.threshold.count` to count operations within/exceeding configured millisecond boundaries; the duration histogram remains unchanged.
- If [API Versioning package](https://github.com/dotnet/aspnet-api-versioning) is used, this library will add the API version as a metric dimension.
+Latency thresholds classify elapsed time; they do not enforce timeouts or determine success/failure.
- [](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp.ApiVersioning)
+Choose the eligible population deliberately. The demo treats availability and latency as separate objectives; a successful-and-fast objective can instead require both. See [status semantics](docs/usage-reference.md#status-semantics) and [latency SLO recipes](docs/usage-reference.md#threshold-latency-objectives), including slow-abort and no-data handling.
## Installation
-```shell
-dotnet add package Trellis.ServiceLevelIndicators
-```
+Requires **.NET 10**. Choose the package for your application:
-For a concise package-selection and integration guide, see [docs/usage-reference.md](docs/usage-reference.md).
+| Application | Package |
+|---|---|
+| Console, worker, or shared library | [`Trellis.ServiceLevelIndicators`](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators) |
+| ASP.NET Core MVC or Minimal APIs | [`Trellis.ServiceLevelIndicators.Asp`](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp) |
+| ASP.NET Core with Asp.Versioning | [`Trellis.ServiceLevelIndicators.Asp.ApiVersioning`](https://www.nuget.org/packages/Trellis.ServiceLevelIndicators.Asp.ApiVersioning) |
-API references:
+The ASP.NET Core package includes core; the API-versioning package includes ASP.NET Core integration.
-- [`docs/api_reference/trellis-api-sli.md`](docs/api_reference/trellis-api-sli.md) — `Trellis.ServiceLevelIndicators` (core)
-- [`docs/api_reference/trellis-api-sli-asp.md`](docs/api_reference/trellis-api-sli-asp.md) — `Trellis.ServiceLevelIndicators.Asp` (middleware + attributes)
-- [`docs/api_reference/trellis-api-sli-apiversioning.md`](docs/api_reference/trellis-api-sli-apiversioning.md) — `Trellis.ServiceLevelIndicators.Asp.ApiVersioning`
+**Source-tree availability:** result selectors, endpoint exclusions, and the instrumentation failure policy were added after `10.0.0-preview.37`. Use a package containing those changes for those APIs. The Docker demo builds the current source directly.
-For ASP.NET Core:
+### A complete Minimal API setup
-```shell
-dotnet add package Trellis.ServiceLevelIndicators.Asp
-```
-
-For API Versioning support:
+From an ASP.NET Core project, install SLI and the OpenTelemetry hosting/exporter packages:
```shell
-dotnet add package Trellis.ServiceLevelIndicators.Asp.ApiVersioning
+dotnet add package Trellis.ServiceLevelIndicators.Asp --prerelease
+dotnet add package OpenTelemetry.Extensions.Hosting
+dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
```
-## Usage for ASP.NET Core MVC
-
-1. Register SLI with open telemetry by calling `AddServiceLevelIndicatorInstrumentation`.
-
- Example:
-
- ```csharp
- builder.Services.AddOpenTelemetry()
- .ConfigureResource(configureResource)
- .WithMetrics(builder =>
- {
- builder.AddServiceLevelIndicatorInstrumentation();
- builder.AddOtlpExporter();
- });
- ```
-
- If you configure `ServiceLevelIndicatorOptions.Meter` with a custom meter, register that same meter with OpenTelemetry:
-
- ```csharp
- var sliMeter = new Meter("MyCompany.ServiceLevelIndicator");
+Replace `Program.cs` with:
- builder.Services.AddOpenTelemetry()
- .ConfigureResource(configureResource)
- .WithMetrics(metrics =>
- {
- metrics.AddServiceLevelIndicatorInstrumentation(sliMeter);
- metrics.AddOtlpExporter();
- });
-
- builder.Services.AddServiceLevelIndicator(options =>
- {
- options.Meter = sliMeter;
- options.LocationId = ServiceLevelIndicator.CreateLocationId("public", AzureLocation.WestUS3.Name);
- });
- ```
-
-2. Add ServiceLevelIndicator into the dependency injection. `AddMvc()` is required for overrides present in MVC SLI attributes to take effect.
-
- Example:
-
- ```csharp
- builder.Services.AddServiceLevelIndicator(options =>
- {
- options.LocationId = ServiceLevelIndicator.CreateLocationId("public", AzureLocation.WestUS3.Name);
- })
- .AddMvc();
- ```
-
-3. Add the middleware to the pipeline.
-
- ```csharp
- app.UseServiceLevelIndicator();
- ```
-
-## Usage for Minimal APIs
-
-1. Register SLI with open telemetry by calling `AddServiceLevelIndicatorInstrumentation`.
-
- Example:
-
- ```csharp
- builder.Services.AddOpenTelemetry()
- .ConfigureResource(configureResource)
- .WithMetrics(builder =>
- {
- builder.AddServiceLevelIndicatorInstrumentation();
- builder.AddOtlpExporter();
- });
- ```
-
-2. Add ServiceLevelIndicator into the dependency injection. By default, SLI is emitted for every routed endpoint when the middleware is present. Set `AutomaticallyEmitted = false` if you want Minimal APIs to opt in endpoint-by-endpoint with `.AddServiceLevelIndicator()`.
+```csharp
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using Trellis.ServiceLevelIndicators;
- Example:
+var builder = WebApplication.CreateBuilder(args);
- ```csharp
- builder.Services.AddServiceLevelIndicator(options =>
+builder.Services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService(builder.Environment.ApplicationName))
+ .WithMetrics(metrics =>
{
- options.LocationId = ServiceLevelIndicator.CreateLocationId("public", AzureLocation.WestUS3.Name);
+ metrics.AddServiceLevelIndicatorInstrumentation();
+ metrics.AddOtlpExporter();
});
- ```
-
-3. Add the middleware to the ASP.NET Core pipeline.
-
- Example:
-
- ```csharp
- app.UseServiceLevelIndicator();
- ```
-
-4. Optional: when `AutomaticallyEmitted = false`, add `AddServiceLevelIndicator()` to each route mapping that should emit SLI metrics.
-
- Example:
-
- ```csharp
- app.MapGet("/hello", () => "Hello World!")
- .AddServiceLevelIndicator();
- ```
-
-## Usage for Background Jobs
-
-You can measure a block of code by wrapping it in a `using` clause of `MeasuredOperation`.
-
-Example:
-
-```csharp
-void MeasureCodeBlock(ServiceLevelIndicator serviceLevelIndicator)
-{
- using var measuredOperation = serviceLevelIndicator.StartMeasuring("OperationName");
- // Do Work.
- measuredOperation.SetOutcome(SliOutcome.Success);
-}
-```
-
-Classify failures returned as values without changing the returned value:
-
-```csharp
-var validation = sli.Measure(
- "ValidateOrder",
- () => ValidateOrder(order),
- result => result.IsValid ? SliOutcome.Success : SliOutcome.ClientError);
-
-var saved = await sli.MeasureAsync(
- "SaveOrder",
- () => SaveOrderAsync(order, cancellationToken),
- result => result.IsSuccess ? SliOutcome.Success : SliOutcome.Failure);
-```
-
-The functions and result types are application-defined; the core has no Trellis Results dependency. Selectors run once after successful completion, including for null results. The original result is returned. Action or selector exceptions propagate and record `Failure`; `OperationCanceledException` records `Ignored`. Undefined selector outcomes are rejected and record `Failure`. Existing overloads without a selector retain their success-on-completion behavior.
-
-## Operational Guidance
-
-### Threshold latency objectives
-
-Opt in to threshold counts without changing the duration histogram:
-```csharp
builder.Services.AddServiceLevelIndicator(options =>
{
options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
- options.LatencyThresholds.Add(TimeSpan.FromSeconds(2));
- options.LatencyThresholds.Add(TimeSpan.FromSeconds(10));
+ options.AutomaticallyEmitted = false;
+ options.LatencyThresholds.Add(TimeSpan.FromMilliseconds(250));
});
-```
-
-The configured meter emits `operation.duration.threshold.count` (`Counter`, unit `{operation}`): one increment per operation per threshold, with integer `sli.latency.threshold_ms` and `sli.latency.result = within | exceeded`. Equality is within. Thresholds must be distinct, positive whole milliseconds and are snapshotted at construction; none are enabled by default.
-
-The counter retains SLI, API-version, abort, and custom dimensions, but omits HTTP status, HTTP method, and the abort-exception diagnostic. Timing does not determine success: a fast failure is still a failure. See the [threshold SLO recipes](docs/usage-reference.md#threshold-latency-objectives) for eligibility rules, no-data handling, and Azure delta-counter queries. In Azure, sum the counter's `AppMetrics.Sum` increments over the window, not its exported rows or `ItemCount`.
-
-### Aborts and availability
-
-Abort-first availability can miss slow dependency outages when the caller gives up before the server fails. Pair availability with a successful-and-fast latency objective that explicitly includes slow aborts. The abort-exception diagnostic supports investigation but cannot prove an independent fault.
-
-### Cardinality Guidance
-
-Required tags must be stable and meaningful. The library bounds `Operation` for you via the route-template resolver and the `` sentinel; you are responsible for `LocationId` (set once from configuration) and `CustomerResourceId` (stable tenant / subscription / resource identifier). `CustomerResourceId` may be high-cardinality when the backend is designed for it, but it must not be a per-request generated value.
-
-The same discipline applies to `[Measure]` parameters and any custom attributes added via `AddAttribute(...)`. Avoid email addresses, request IDs, timestamps, or unconstrained free text unless your metrics backend is explicitly designed for high-cardinality telemetry.
-
-### Disposal
-
-`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 `ServiceLevelIndicatorOptions.Meter` is owned by you and is never disposed by SLI.
-
-Configuration is copied per instance, including thresholds, without modifying caller options. Reusing options without a supplied meter creates independently owned meters. The instance's `ServiceLevelIndicatorOptions` property retains its effective meter; later changes to the original configuration do not update an existing instance.
-
-`MeasuredOperation.Dispose()` attempts recording once and reports errors rather than replacing an application result or exception. Invalid raw-list mutations drop the measurement; provider failures may leave partial data but are not retried. Public `Record`, `AddAttribute`, and `SetOutcome` inputs remain strictly validated, and action/result-selector errors still propagate.
-
-Histogram/threshold reconciliation assumes successful recording: a provider failure after histogram delivery can leave corresponding threshold counts behind.
-
-Recording warnings use `ILogger` when available and EventSource provider `Trellis-ServiceLevelIndicators` (`MeasurementRecordingFailed`, event 1). A throwing diagnostic logger is isolated and reported through `DiagnosticLoggingFailed` (event 2).
-
-The provider is the public `ServiceLevelIndicatorEventSource.Log` contract, avoiding core/ASP internal coupling. Classifier fallback and enrichment failures emit events 3 and 4 independently of logging filters; enable EventSource collection to observe them. Exception-text fields are capped at 2048 UTF-16 code units, not redacted, and may still contain request data.
-## ASP.NET Core Customizations
+var app = builder.Build();
+app.UseServiceLevelIndicator();
-Once the prerequisites are done, eligible controllers/actions emit SLI information unless excluded.
-The default operation name is the HTTP method plus the route template (placeholders such as `{id}` are preserved). The full resolution order is described under **Trellis.ServiceLevelIndicators.Asp** above.
+app.MapGet("/projects/{projectId:regex(^project-(blue|green|red)$)}",
+ ([CustomerResourceId] string projectId) => Results.Ok(new { projectId }))
+ .AddServiceLevelIndicator("GetProject");
-### Exclude endpoints from automatic measurement
-
-```csharp
-app.MapHealthChecks("/health")
- .ExcludeFromServiceLevelIndicator();
-
-app.MapGet("/diagnostics", () => Results.Ok())
- .ExcludeFromServiceLevelIndicator();
-
-var internalApi = app.MapGroup("/internal")
- .ExcludeFromServiceLevelIndicator();
-internalApi.MapGet("/status", () => Results.Ok());
+app.Run();
```
-For MVC, put `[ExcludeFromServiceLevelIndicator]` on an action or controller. Controller and route-group exclusions apply to their child endpoints and win over automatic emission and explicit SLI inclusion metadata, regardless of registration order.
+Run with `dotnet run --urls http://localhost:5200` and request `http://localhost:5200/projects/project-blue`. The sample bounds project IDs deliberately; adapt these to your application's stable resource identifiers and cardinality budget.
-Exclusion happens before starting a scope, attaching the SLI feature, classification, or enrichment. The request emits no automatic duration, threshold, or measurement-related diagnostic counts, including on exception and abort paths. This is stronger than classifying a normal response as `Ignored`. Endpoint behavior and other ASP.NET Core/Application Insights telemetry are unchanged; explicit core measurements remain available.
+The OTLP exporter defaults to `http://localhost:4317`. Set `OTEL_EXPORTER_OTLP_ENDPOINT` for your collector. The [included observability stack](sample/Observability/Grafana/README.md) can receive these metrics. Use your actual deployment location instead of the example region; no Azure SDK dependency is needed.
-### Customize HTTP classification
+For MVC, register controllers and chain `.AddMvc()` after `AddServiceLevelIndicator(...)` so SLI attributes are applied. See the [MVC integration guide](docs/usage-reference.md#aspnet-core-mvc) or [versioned MVC example](sample/WebApiVersioned/Program.cs).
-Change one normal-response mapping without copying the defaults:
+## Background jobs and workers
+
+Use the core package without any ASP.NET Core dependency. Prefer `Measure` / `MeasureAsync` for automatic outcome handling:
```csharp
-builder.Services.AddServiceLevelIndicator(options => options.LocationId = locationId)
- .ClassifyHttpOutcome((context, defaultOutcome) =>
- context.Response.StatusCode == 429 && IsCallerQuotaRejection(context)
- ? SliOutcome.ClientError
- : defaultOutcome);
+async Task SyncProjectAsync(ServiceLevelIndicator sli, CancellationToken cancellationToken)
+{
+ await sli.MeasureAsync("SyncProject", () => Task.Delay(75, cancellationToken));
+}
```
-`IsCallerQuotaRejection` represents application-specific limiter metadata; capacity rejection should remain `Failure`. The original one-argument replacement classifier remains supported, and the last fluent registration wins. `HttpOutcomeClassifier.ClassifyStatusCode(int)` exposes the built-in mapping. Neither callback overrides abort or escaping-exception handling.
+Configure a stable `CustomerResourceId` for the workload. Successful completion records `Success`; exceptions record `Failure`; `OperationCanceledException` records `Ignored`. Exceptions still propagate to the application. Result selectors can classify failures returned as values.
-HTTP callback exceptions or undefined outcomes are warning-logged and use the built-in status outcome, without trying a lower-priority callback. Enrichment continues and handler response/exception behavior is preserved, even if diagnostic logging throws. This differs from explicit core result selectors, whose errors still propagate.
+Raw `StartMeasuring(...)` scopes default to **`Ignored`**, not success or failure. If you use one, explicitly classify every completion, exception, and cancellation path. See the [core guide](docs/usage-reference.md#core-package) and [runnable console example](sample/ConsoleApp/Program.cs).
-**Fallback changes the SLO population:** custom-ignored `/health` can become `Success`, and a custom-client-error 429 can become `Failure`. Monitor `HttpOutcomeClassificationFailed` (EventSource event 3), which includes the fallback outcome even when warning logs are filtered.
+## Focused examples
-- To add API versioning as a dimension use package `Trellis.ServiceLevelIndicators.Asp.ApiVersioning` and enrich the metrics with `AddApiVersion`.
+Start with the always-on demo for the full experience. These smaller examples isolate individual integration APIs without maintaining competing demo stacks:
- Example:
+| Example | What it teaches |
+|---|---|
+| [Always-on Project Tracker](sample/Observability/Grafana/README.md) | MVC instrumentation, continuous incidents, exact latency counts, and customer-specific Grafana SLIs |
+| [Console](sample/ConsoleApp/Program.cs) | Core-only measurement, custom meter ownership/registration, and console export |
+| [Minimal API](sample/MinApi/Program.cs) | Endpoint opt-in, named operations, target-resource metadata, and bounded dimensions; health and docs remain unmeasured |
+| [API-versioned MVC](sample/WebApiVersioned/Program.cs) | The same project operation separated by `http.api.version` |
- ```csharp
- builder.Services.AddServiceLevelIndicator(options =>
- {
- /// Options
- })
- .AddMvc()
- .AddApiVersion();
- ```
-
-- `http.request.method` is emitted by default by the ASP.NET Core middleware. `AddHttpMethod()` remains available as a no-op for older setup code.
-
-- Enrich SLI with the `Enrich` callback. The callback receives a `MeasuredOperation` as context that can be used to set `CustomerResourceId` or additional attributes.
- An async version `EnrichAsync` is also available.
-
- Example:
-
- ```csharp
- builder.Services.AddServiceLevelIndicator(options =>
- {
- options.LocationId = ServiceLevelIndicator.CreateLocationId(Cloud, Region);
- })
- .AddMvc()
- .Enrich(context =>
- {
- // Pull a STABLE tenant/subscription identifier — NOT the caller's UPN/user ID.
- 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, not in CustomerResourceId.
- var tier = context.HttpContext.User.Claims
- .FirstOrDefault(c => c.Type == "tier")?.Value ?? "free";
- context.AddAttribute("CallerTier", tier);
- });
- ```
+**[Run the focused examples](docs/usage-reference.md#running-the-focused-examples)** for commands, URLs, and expected metrics. The web examples share the demo's Collector/Prometheus/Grafana stack; their `Api.http` files contain ready-to-send requests.
-- To override the default operation name, add the attribute `[ServiceLevelIndicator]` and specify the operation name.
+## Integration and operational reference
- Example:
+Keep metric dimensions stable: no personal names, emails, request IDs, timestamps, or arbitrary free text. `CustomerResourceId` identifies the target, even when a caller acts across tenants; enrichment must not silently replace it with the caller's tenant.
- ```csharp
- [HttpGet("MyAction2")]
- [ServiceLevelIndicator(Operation = "MyNewOperationName")]
- public IEnumerable GetOperation() => GetWeather();
- ```
-
-- To set the `CustomerResourceId` within an API method, mark the parameter with the attribute `[CustomerResourceId]`
-
- ```csharp
- [HttpGet("teams/{teamId}")]
- public IEnumerable GetByTeam([CustomerResourceId] string teamId)
- => GetWeather();
- ```
-
- Or use `GetMeasuredOperation` extension method.
-
- ``` csharp
- [HttpGet("{customerResourceId}")]
- public IEnumerable Get(string customerResourceId)
- {
- HttpContext.GetMeasuredOperation().CustomerResourceId = customerResourceId;
- return GetWeather();
- }
- ```
-
-- To add custom Open Telemetry attributes.
-
- ``` csharp
- HttpContext.GetMeasuredOperation().AddAttribute(attribute, value);
- ```
-
- GetMeasuredOperation will **throw** if the route is not configured to emit SLI.
-
- When used in a middleware or scenarios where a route may not be configured to emit SLI.
-
- ``` csharp
- if (HttpContext.TryGetMeasuredOperation(out var measuredOperation))
- measuredOperation.AddAttribute("CustomAttribute", value);
- ```
-
- You can add additional dimensions to the SLI data by using the `Measure` attribute. Parameters decorated with `[Measure]` are automatically added as metric attributes (dimensions) using the parameter name as the attribute key.
-
- ```csharp
- [HttpGet("name/{first}/{surname}")]
- public IActionResult GetCustomerResourceId(
- [Measure] string first,
- [CustomerResourceId] string surname)
- => Ok(first + " " + surname);
- ```
-
-- To prevent automatically emitting SLI information on all controllers, set the option,
-
- ``` csharp
- builder.Services.AddServiceLevelIndicator(options =>
- {
- options.AutomaticallyEmitted = false;
- })
- .AddMvc();
- ```
-
- In this case, add the attribute `[ServiceLevelIndicator]` on the controllers that should emit SLI.
-
-## Sample
-
-Try out the sample weather forecast Web API.
-
-For a local Grafana/Prometheus/OpenTelemetry Collector experience, run the provisioned dashboard in [`sample\Observability\Grafana`](sample/Observability/Grafana/README.md). It shows SLI latency percentiles, success rate, failures, client errors, unknown customer diagnostics, and `` detection.
-
-To view the metrics locally using the [.NET Aspire Dashboard](https://aspire.dev/dashboard/standalone/):
-
-1. Start the Aspire dashboard:
- ```
- docker run --rm -it -d -p 18888:18888 -p 4317:18889 -e DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true -e DASHBOARD__OTLP__AUTHMODE=Unsecured --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest
- ```
-2. Run the sample web API project and call the `GET WeatherForecast` using the Open API UI.
-3. Open `http://localhost:18888` to view the dashboard. You should see the SLI metrics under the instrument `operation.duration` where `Operation = "GET WeatherForecast"`, `Outcome = "Success"`, `http.response.status.code = 200`, and `LocationId = "ms-loc://az/public/westus3"`.
-
-4. If you run the sample with API Versioning, you will see something similar to the following.
-
-
-## Optional API guidance for coding assistants
-
-Each ServiceLevelIndicators NuGet package now carries its own versioned API reference
-at the package root (`trellis-api-sli*.md`) and guidance manifest. The packages
-remain independent of `Trellis.Core`.
-Ordinary restore/build do not write `.github/`, `AGENTS.md`, or `.agentdocs/`
-unless the consuming repository explicitly opts into the separate local
-`Trellis.AgentDocs` tool. After restoring the consuming project or solution,
-run once from its Git root:
-
-```powershell
-dotnet new tool-manifest --output .config
-dotnet tool install Trellis.AgentDocs --version 0.1.0-preview.20 --tool-manifest .config/dotnet-tools.json
-dotnet tool run agentdocs init
-```
-
-Reuse an existing root `.config/dotnet-tools.json` instead of creating another.
-Restoring a package never activates its guide. `init` lists each package that
-publishes guidance as pending and prints the package IDs to add to
-`approvedPackages` in `.agentdocs/policy.json`. Add the SLI packages you trust,
-then run `dotnet tool run agentdocs sync` to install their guides into Git-root
-`.agentdocs/`; `init` also adds the managed instruction pointers. The SLI guides
-are on-demand: the generated index lists each with a description, and an agent
-opens one when its task concerns SLI metrics. After a package upgrade, run
-`dotnet restore` and then `dotnet tool run agentdocs sync`. The helper and tool
-are preview packages published on NuGet.org.
-
-For package maintainers, the three publishing projects use a pinned, private
-`Trellis.AgentDocs.Packaging` build dependency to generate their manifests at
-pack time. Each project sets `TrellisApiRefDescription`, the one-line "Open
-when..." topic consumers see in their index, and the guides refer to sibling
-packages by ID rather than by link because every package installs into its own
-directory. Its build target is not included in the published SLI
-packages. Run `pwsh build/test-guidance-packages.ps1` after packing to verify
-the payload and absence of legacy consumer targets.
-
-## Provider cancellation integration
-
-The ASP.NET tests include real mid-flight `HttpClient` cancellation. Real `SqlClient` cancellation requires a dedicated SQL fixture; without `TRELLIS_SLI_SQL_CONNECTION_STRING`, those cases are explicitly skipped. On Windows with SQL Server Express LocalDB installed, run:
-
-```powershell
-dotnet restore Trellis.ServiceLevelIndicators.Asp\tests\Trellis.ServiceLevelIndicators.Asp.Tests.csproj
-pwsh -NoProfile -File build\test-sql-cancellation.ps1
-```
+| Topic | Guide |
+|---|---|
+| Integration and configuration | [Usage reference](docs/usage-reference.md) |
+| Outcome policies and aborts | [Status semantics](docs/usage-reference.md#status-semantics) |
+| Exact latency objectives and queries | [Threshold latency objectives](docs/usage-reference.md#threshold-latency-objectives) |
+| Custom meters and disposal | [Custom meter registration](docs/usage-reference.md#custom-meter-registration) |
+| Endpoint exclusions | [Exclude endpoints](docs/usage-reference.md#exclude-endpoints-from-automatic-sli-measurement) |
+| Additional dimensions | [Enrichment and bounded attributes](docs/usage-reference.md#enrichment-and-bounded-attributes) |
+| Recording/classifier/enrichment failures | [Instrumentation failure policy](docs/usage-reference.md#instrumentation-failure-policy) |
+| Coding-assistant package guides | [Optional API guidance](docs/usage-reference.md#optional-api-guidance-for-coding-assistants) |
+| SQL cancellation fixture | [Provider cancellation integration](docs/usage-reference.md#provider-cancellation-integration) |
-The script creates, starts, stops, and deletes its own uniquely named LocalDB instance. It does not use or modify shared instances. The Windows CI job runs this fixture; provider dependencies are test-only. For another dedicated SQL Server, supply the environment variable and run the ASP.NET tests; the fixture needs permission to inspect `sys.dm_exec_requests` so cancellation occurs after the command is genuinely in flight.
+API references: [Core](docs/api_reference/trellis-api-sli.md) | [ASP.NET Core](docs/api_reference/trellis-api-sli-asp.md) | [API Versioning](docs/api_reference/trellis-api-sli-apiversioning.md).
## Related repositories
diff --git a/ServiceLevelIndicators.slnx b/ServiceLevelIndicators.slnx
index 280caa0..fef448a 100644
--- a/ServiceLevelIndicators.slnx
+++ b/ServiceLevelIndicators.slnx
@@ -1,9 +1,12 @@
+
+
-
+
+
diff --git a/Trellis.ServiceLevelIndicators.Asp.ApiVersioning/src/README.md b/Trellis.ServiceLevelIndicators.Asp.ApiVersioning/src/README.md
index 334ba2f..b84f148 100644
--- a/Trellis.ServiceLevelIndicators.Asp.ApiVersioning/src/README.md
+++ b/Trellis.ServiceLevelIndicators.Asp.ApiVersioning/src/README.md
@@ -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).
@@ -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)
diff --git a/Trellis.ServiceLevelIndicators.Asp/src/README.md b/Trellis.ServiceLevelIndicators.Asp/src/README.md
index 81924c2..9a70962 100644
--- a/Trellis.ServiceLevelIndicators.Asp/src/README.md
+++ b/Trellis.ServiceLevelIndicators.Asp/src/README.md
@@ -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
@@ -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.
@@ -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`, 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.
@@ -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`
@@ -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
@@ -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` 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` 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
@@ -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
@@ -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)
diff --git a/Trellis.ServiceLevelIndicators/src/README.md b/Trellis.ServiceLevelIndicators/src/README.md
index 842291e..1c74a01 100644
--- a/Trellis.ServiceLevelIndicators/src/README.md
+++ b/Trellis.ServiceLevelIndicators/src/README.md
@@ -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
@@ -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 =>
@@ -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("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`.
@@ -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` |
@@ -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/)
diff --git a/assets/aspire.jpg b/assets/aspire.jpg
deleted file mode 100644
index 1b36bee..0000000
Binary files a/assets/aspire.jpg and /dev/null differ
diff --git a/assets/sli-live-demo.png b/assets/sli-live-demo.png
new file mode 100644
index 0000000..4e2ea0e
Binary files /dev/null and b/assets/sli-live-demo.png differ
diff --git a/assets/versioned.jpg b/assets/versioned.jpg
deleted file mode 100644
index 6809fe5..0000000
Binary files a/assets/versioned.jpg and /dev/null differ
diff --git a/docs/api_reference/trellis-api-sli-apiversioning.md b/docs/api_reference/trellis-api-sli-apiversioning.md
index a932d89..a2ff177 100644
--- a/docs/api_reference/trellis-api-sli-apiversioning.md
+++ b/docs/api_reference/trellis-api-sli-apiversioning.md
@@ -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]
---
@@ -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.
@@ -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();
@@ -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.
diff --git a/docs/api_reference/trellis-api-sli-asp.md b/docs/api_reference/trellis-api-sli-asp.md
index da89ec5..58e2d36 100644
--- a/docs/api_reference/trellis-api-sli-asp.md
+++ b/docs/api_reference/trellis-api-sli-asp.md
@@ -3,7 +3,7 @@ package: Trellis.ServiceLevelIndicators.Asp
namespaces: [Trellis.ServiceLevelIndicators]
types: [IServiceLevelIndicatorBuilder, ServiceLevelIndicatorCoreServiceCollectionExtensions, ServiceLevelIndicatorServiceCollectionExtensions, ServiceLevelIndicatorHttpOptions, HttpOutcomeClassifier, ServiceLevelIndicatorApplicationBuilderExtensions, EndpointBuilderExtensions, ServiceLevelIndicatorAttribute, ExcludeFromServiceLevelIndicatorAttribute, CustomerResourceIdAttribute, MeasureAttribute, CustomerResourceIdMetadata, MeasureMetadata, WebEnrichmentContext, IServiceLevelIndicatorFeature, HttpContextExtensions]
version: v10
-last_verified: 2026-10-15
+last_verified: 2026-10-16
audience: [llm]
---
@@ -11,7 +11,7 @@ audience: [llm]
**Package:** `Trellis.ServiceLevelIndicators.Asp`
**Namespace:** `Trellis.ServiceLevelIndicators`
-**Purpose:** ASP.NET Core integration for `Trellis.ServiceLevelIndicators`. Provides middleware that automatically emits SLI latency metrics for eligible routed requests, endpoint exclusions, MVC and Minimal API attribute conventions for tagging the customer resource id and additional measured route values, and an enrichment pipeline for adding custom attributes (e.g. HTTP method, API version).
+**Purpose:** ASP.NET Core integration for `Trellis.ServiceLevelIndicators`. Provides middleware that automatically emits SLI latency metrics for eligible routed requests, endpoint exclusions, MVC and Minimal API attribute conventions for tagging the target resource and bounded route values, and an enrichment pipeline for additional service dimensions. HTTP method is emitted by default.
For API-versioning-specific enrichment, see the `Trellis.ServiceLevelIndicators.Asp.ApiVersioning` reference, when installed.
@@ -25,7 +25,7 @@ When the middleware is registered, every measured request emits these tags on to
| Tag | Source |
|---|---|
-| `Operation` | HTTP method plus route template (e.g. `GET WeatherForecast` or `GET /teams/{teamId}`) — derived from `ControllerActionDescriptor.AttributeRouteInfo.Template` for MVC, or from the route pattern for Minimal APIs. Overridable via `[ServiceLevelIndicator(operation)]` or `AddServiceLevelIndicator("operation")`. |
+| `Operation` | HTTP method plus route template (e.g. `GET /projects/{projectId}`) — derived from `ControllerActionDescriptor.AttributeRouteInfo.Template` for MVC, or from the route pattern for Minimal APIs. Overridable via `[ServiceLevelIndicator(operation)]` or `AddServiceLevelIndicator("operation")`. |
| `Outcome` | `Success` for 2xx/3xx, `ClientError` for 400/401/403/404/409/412/422, `Failure` for 429/5xx and escaping exceptions without an abort. Observed request aborts take precedence and are `Ignored`. |
| `http.response.status.code` | `HttpContext.Response.StatusCode`; omitted on abort before headers start, otherwise retained. |
| `http.request.method` | `HttpContext.Request.Method`; emitted by default. |
@@ -169,6 +169,8 @@ Endpoint and route-group integration.
| `public static TBuilder AddServiceLevelIndicator(this TBuilder builder, string? operation = default) where TBuilder : notnull, IEndpointConventionBuilder` | `TBuilder` | Marks a Minimal API endpoint for SLI emission. Scans the handler delegate's parameters for `[CustomerResourceId]` and `[Measure]` and projects them into endpoint metadata. When `operation` is `null`, the route template is used. |
| `public static TBuilder ExcludeFromServiceLevelIndicator(this TBuilder builder) where TBuilder : notnull, IEndpointConventionBuilder` | `TBuilder` | Excludes an endpoint, health-check mapping, or route group. Returns the original builder type for chaining; throws for a null builder. |
+Register SLI once for each endpoint: either on its route group or on the handler. Applying `AddServiceLevelIndicator()` to both re-scans the same parameters and can fail with duplicate customer-resource metadata.
+
---
### ServiceLevelIndicatorAttribute
@@ -298,6 +300,8 @@ public class WebEnrichmentContext : IEnrichmentContext
Concrete enrichment context for ASP.NET Core. Passed to every registered `IEnrichment` after the response status code is known but before the metric is recorded.
+**Constructor:** `public WebEnrichmentContext(MeasuredOperation operation, HttpContext httpContext)`.
+
**Properties**
| Name | Type | Description |
@@ -312,6 +316,8 @@ Concrete enrichment context for ASP.NET Core. Passed to every registered `IEnric
| `public void AddAttribute(string name, object? value)` | `void` | Append an attribute to the in-flight measurement. |
| `public void SetCustomerResourceId(string id)` | `void` | Override the customer resource id for this measurement. |
+Enrichments run after endpoint handling and may overwrite the resource extracted from route metadata. Supply the resolved target, not the caller's tenant unless same-tenant semantics are guaranteed. Map custom dimensions to bounded categories; do not emit caller identities or arbitrary request values.
+
---
### IServiceLevelIndicatorFeature
@@ -378,42 +384,58 @@ Genuine unmatched routes are not measured. Abort-first availability can miss slo
## Typical usage (MVC)
+Install the SLI integration and the separate hosting/exporter dependencies:
+
+```shell
+dotnet add package Trellis.ServiceLevelIndicators.Asp --prerelease
+dotnet add package OpenTelemetry.Extensions.Hosting
+dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
+```
+
+Within an existing ASP.NET Core `builder`:
+
```csharp
+using Microsoft.AspNetCore.Mvc;
+using OpenTelemetry.Metrics;
using Trellis.ServiceLevelIndicators;
+builder.Services.AddControllers();
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddServiceLevelIndicatorInstrumentation().AddOtlpExporter());
builder.Services.AddServiceLevelIndicator(o =>
{
o.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
- // Automatic emission is enabled by default. Set AutomaticallyEmitted = false
- // to opt in endpoint-by-endpoint with AddServiceLevelIndicator().
+ o.AutomaticallyEmitted = false;
})
.AddMvc();
var app = builder.Build();
app.UseRouting();
app.UseServiceLevelIndicator();
+app.MapControllers();
+app.Run();
```
```csharp
[ApiController]
-[Route("subscriptions/{subscriptionId}/widgets")]
-public class WidgetsController : ControllerBase
+[Route("projects/{projectId:regex(^project-(blue|green|red)$)}")]
+[ServiceLevelIndicator(Operation = "SyncProject")]
+public sealed class ProjectsController : ControllerBase
{
- [HttpGet("{widgetId}")]
- public IActionResult Get(
- [CustomerResourceId] Guid subscriptionId,
- [Measure("widget.id")] Guid widgetId) => Ok();
+ [HttpPost("sync/{mode:regex(^(quick|standard|slow)$)}")]
+ public IActionResult Sync(
+ [CustomerResourceId] string projectId,
+ [Measure("SyncMode")] string mode) => Ok();
}
```
## Typical usage (Minimal API)
```csharp
-// Required only when AutomaticallyEmitted = false.
-app.MapGet("/subs/{subscriptionId}/widgets/{widgetId}",
- ([CustomerResourceId] Guid subscriptionId, [Measure("widget.id")] Guid widgetId) => Results.Ok())
- .AddServiceLevelIndicator();
+app.MapPost("/projects/{projectId:regex(^project-(blue|green|red)$)}/sync/{mode:regex(^(quick|standard|slow)$)}",
+ ([CustomerResourceId] string projectId, [Measure("SyncMode")] string mode) => Results.Ok())
+ .AddServiceLevelIndicator("SyncProject");
```
+
+Use the same telemetry and SLI registration, but omit controller registration and `.AddMvc()` for a Minimal API-only app. Map endpoints before `app.Run()`. With `AutomaticallyEmitted = false`, only explicitly included endpoints emit automatic SLI metrics. The example constrains both target resources and mode values instead of measuring arbitrary widget IDs.
diff --git a/docs/api_reference/trellis-api-sli.md b/docs/api_reference/trellis-api-sli.md
index 4f9c22a..b4b6db3 100644
--- a/docs/api_reference/trellis-api-sli.md
+++ b/docs/api_reference/trellis-api-sli.md
@@ -1,9 +1,9 @@
---
package: Trellis.ServiceLevelIndicators
namespaces: [Trellis.ServiceLevelIndicators]
-types: [ServiceLevelIndicator, ServiceLevelIndicatorOptions, MeasuredOperation, IEnrichmentContext, "IEnrichment", ServiceLevelIndicatorMeterProviderBuilderExtensions]
+types: [ServiceLevelIndicator, ServiceLevelIndicatorOptions, SliOutcome, MeasuredOperation, ServiceLevelIndicatorEventSource, IServiceLevelIndicatorBuilder, ServiceLevelIndicatorCoreServiceCollectionExtensions, IEnrichmentContext, "IEnrichment", ServiceLevelIndicatorMeterProviderBuilderExtensions]
version: v10
-last_verified: 2026-10-15
+last_verified: 2026-10-16
audience: [llm]
---
@@ -45,6 +45,8 @@ Configure `options.LatencyThresholds.Add(TimeSpan.FromSeconds(2))` during startu
The duration histogram keeps its original tags and unit. Both instruments use the configured meter; no threshold instrument is created when the collection is empty. Generated threshold-tag names are also rejected on direct `MeasuredOperation.Attributes` mutation.
+Thresholds classify recorded duration; they do not enforce timeouts, cancel work, or change an operation's outcome.
+
Timing is independent of outcome: a fast failure is `within` but not successful-and-fast. Select one threshold and an explicit eligibility policy for each ratio. No eligible traffic is no data; eligible traffic with zero good events is 0. The counter supports configured boundaries, not arbitrary percentiles.
For a fixed meter/resource scope, `N` thresholds and `P` projected base-tag combinations add at most `2 * N * P` series; `P` includes outcome and retained optional/custom dimensions. Backend resource/instance dimensions may multiply this further. On Azure Monitor's delta-export path, sum each interval's `AppMetrics.Sum` increment over the window, not rows, `ItemCount`, latest/max, or cumulative differences. Prometheus cumulative scrapes need `increase`/`rate`.
@@ -72,6 +74,7 @@ Configuration is copied at construction, including the threshold list, without m
| Name | Type | Value | Description |
|---|---|---|---|
| `DefaultMeterName` | `const string` | `"Trellis.SLI"` | Default meter name used when no `Meter` is supplied in options. |
+| `UnknownCustomerResourceId` | `const string` | `"Unknown"` | Fallback when the target resource has not been configured or resolved. |
**Properties**
@@ -159,6 +162,22 @@ Registers `ServiceLevelIndicator` as a singleton and configures `ServiceLevelInd
---
+### SliOutcome
+
+```csharp
+public enum SliOutcome
+{
+ Success,
+ Failure,
+ ClientError,
+ Ignored
+}
+```
+
+Metrics use these exact, case-sensitive names. Default availability includes `Success` and `Failure`; `ClientError` and `Ignored` are excluded. Timing thresholds are independent of outcome. Raw scopes explicitly initialize their outcome to `Ignored`, not the enum's zero value.
+
+---
+
### MeasuredOperation
**Declaration**
@@ -181,15 +200,15 @@ Represents an in-flight measurement. The stopwatch starts in the constructor; di
| Signature | Description |
|---|---|
-| `public MeasuredOperation(ServiceLevelIndicator sli, string operation, params KeyValuePair[] attributes)` | Uses the default `CustomerResourceId` from the indicator's options. |
-| `public MeasuredOperation(ServiceLevelIndicator sli, string operation, string customerResourceId, params KeyValuePair[] attributes)` | Uses an explicit `CustomerResourceId`. |
+| `public MeasuredOperation(ServiceLevelIndicator serviceLevelIndicator, string operation, params KeyValuePair[] attributes)` | Uses the default `CustomerResourceId` from the indicator's options. |
+| `public MeasuredOperation(ServiceLevelIndicator serviceLevelIndicator, string operation, string customerResourceId, params KeyValuePair[] attributes)` | Uses an explicit `CustomerResourceId`. |
**Methods**
| Signature | Returns | Description |
|---|---|---|
| `public void SetOutcome(SliOutcome outcome)` | `void` | Sets the SLI outcome recorded with the measurement. Default is `Ignored`. Throws immediately for an undefined outcome without changing the previous outcome. |
-| `public void SetActivityStatusCode(ActivityStatusCode code)` | `void` | Obsolete compatibility shim that maps `Ok` to `Success`, `Error` to `Failure`, and other values to `Ignored`. |
+| `public void SetActivityStatusCode(ActivityStatusCode activityStatusCode)` | `void` | Obsolete compatibility shim that maps `Ok` to `Success`, `Error` to `Failure`, and other values to `Ignored`. |
| `public void AddAttribute(string attribute, object? value)` | `void` | Appends a custom attribute to be emitted with the measurement. Throws if the name collides with a reserved SLI tag. |
| `public void Dispose()` | `void` | Stops the stopwatch and attempts recording once. Terminal even if recording fails; reports recording errors instead of throwing them or retrying. |
| `protected virtual void Dispose(bool disposing)` | `void` | Standard dispose pattern hook. |
@@ -238,7 +257,7 @@ Helpers reject null failure exceptions and guard exception formatting. The corre
public interface IEnrichmentContext
```
-Marker contract implemented by enrichment contexts (e.g. `WebEnrichmentContext` in the ASP package). Lets enrichments add attributes and override the customer id without coupling to a specific host.
+Host-neutral contract implemented by enrichment contexts (e.g. `WebEnrichmentContext` in the ASP package). Lets enrichments add attributes and override the target resource without coupling to a specific host.
**Properties**
@@ -302,7 +321,18 @@ Helpers for wiring the SLI meter into an OpenTelemetry pipeline.
## Typical usage
+For a hosted OTLP pipeline, install:
+
+```shell
+dotnet add package Trellis.ServiceLevelIndicators --prerelease
+dotnet add package OpenTelemetry.Extensions.Hosting
+dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
+```
+
+These fragments assume an existing host `builder`. The simulated work uses `Task.Delay`; replace it with the application's work.
+
```csharp
+using OpenTelemetry.Metrics;
using Trellis.ServiceLevelIndicators;
builder.Services.AddOpenTelemetry()
@@ -313,21 +343,12 @@ builder.Services.AddOpenTelemetry()
builder.Services.AddServiceLevelIndicator(o =>
{
o.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
+ o.CustomerResourceId = "project-blue";
});
// In a worker / job:
-async Task DoWorkAsync(ServiceLevelIndicator sli, CancellationToken ct)
-{
- using var op = sli.StartMeasuring("ProcessBatch");
- try
- {
- await ProcessAsync(ct);
- op.SetOutcome(SliOutcome.Success);
- }
- catch
- {
- op.SetOutcome(SliOutcome.Failure);
- throw;
- }
-}
+Task DoWorkAsync(ServiceLevelIndicator sli, CancellationToken ct) =>
+ sli.MeasureAsync("SyncProject", () => Task.Delay(75, ct));
```
+
+`MeasureAsync` records `Success` on completion, `Failure` on exceptions, and `Ignored` for `OperationCanceledException`; exceptions still propagate. Raw scopes cannot detect escaping exceptions and must classify every path explicitly.
diff --git a/docs/design/sli-metric-contract.md b/docs/design/sli-metric-contract.md
index 86c6f93..8d99993 100644
--- a/docs/design/sli-metric-contract.md
+++ b/docs/design/sli-metric-contract.md
@@ -6,6 +6,8 @@ Implemented in the pre-1.0 metric contract finalization.
The first-preview additions for TFR-26/27/29 shipped in `10.0.0-preview.37` and completed consumer acceptance. TFR-28 result selectors and TFR-30 endpoint exclusion are implemented in the source tree but are not part of preview.37.
+This is a design record, not the getting-started guide. The metric and instrumentation-failure contracts below are implemented; the Results adapter, analyzers, testing package, and bucket helper remain future work. Use the [live demo](../../README.md#try-the-live-demo) and [integration reference](../usage-reference.md) for current setup and runnable examples.
+
## Context
`Trellis.ServiceLevelIndicators` is part of the Trellis AI-first .NET framework. Its role is to provide structural observability guardrails for generated and hand-written services by emitting service-level latency metrics with stable, meaningful dimensions.
@@ -42,6 +44,8 @@ The primary metric remains:
Each operation increments once per threshold, tagged with integer `sli.latency.threshold_ms` and `sli.latency.result = within | exceeded`; equality with the recorded millisecond duration is within. All recording paths share the central emission logic. Timing is independent of outcome.
+Threshold configuration classifies recorded durations only; it does not impose a deadline, cancel work, or change HTTP status/outcome.
+
The counter retains required dimensions, API version, the abort indicator, and custom dimensions when present. It omits `http.response.status.code`, `http.request.method`, and `sli.request.abort_exception`. It does not add threshold tags to the duration histogram.
For a fixed meter/resource scope, `N` thresholds and `P` projected base-tag combinations add at most `2 * N * P` series. Backend resource/instance dimensions may multiply this further. Per-operation threshold overrides remain deferred.
diff --git a/docs/usage-reference.md b/docs/usage-reference.md
index 77c3aed..73ad095 100644
--- a/docs/usage-reference.md
+++ b/docs/usage-reference.md
@@ -1,8 +1,8 @@
# Trellis.ServiceLevelIndicators Usage Reference
-This document is a compact reference for choosing the correct package and wiring it into a .NET application.
+Choose the correct package, wire it into a .NET application, and define the population and dimensions behind your SLIs. For a complete copyable Minimal API setup, start with the [root README](../README.md#installation).
-**Source-tree availability:** result selectors and endpoint exclusion are additions after `10.0.0-preview.37`; use a package containing these changes. They are not available in preview.37.
+**Source-tree availability:** result selectors, endpoint exclusions, and the instrumentation failure policy are additions after `10.0.0-preview.37`; use a package containing these changes. They are not available in preview.37. The Docker demo builds the current source.
## Package Matrix
@@ -12,6 +12,19 @@ This document is a compact reference for choosing the correct package and wiring
| Automatically measure ASP.NET Core endpoints | `Trellis.ServiceLevelIndicators.Asp` | Middleware, MVC, and Minimal API integration |
| Add API version as a metric dimension | `Trellis.ServiceLevelIndicators.Asp.ApiVersioning` | Adds `http.api.version` enrichment for apps using Asp.Versioning |
+## OpenTelemetry Setup
+
+The hosting and OTLP exporter extensions used below are separate packages, not dependencies supplied by SLI:
+
+```shell
+dotnet add package OpenTelemetry.Extensions.Hosting
+dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
+```
+
+The examples assume a host/application `builder`. Import `OpenTelemetry.Metrics`, `OpenTelemetry.Resources`, and `Trellis.ServiceLevelIndicators`. Custom-meter code also needs `System.Diagnostics.Metrics`.
+
+The OTLP exporter defaults to `http://localhost:4317`; set `OTEL_EXPORTER_OTLP_ENDPOINT` for your collector. The location examples use a plain region string and do not require Azure.Core. Use `--prerelease` when installing preview SLI packages.
+
## Metric Contract
These values are part of the library contract and should be treated as stable unless you are intentionally making a breaking change.
@@ -35,7 +48,7 @@ The opt-in `operation.duration.threshold.count` counter has unit `{operation}`.
Install:
```shell
-dotnet add package Trellis.ServiceLevelIndicators
+dotnet add package Trellis.ServiceLevelIndicators --prerelease
```
Register with OpenTelemetry:
@@ -62,21 +75,19 @@ builder.Services.AddServiceLevelIndicator(options =>
Measure work:
```csharp
-async Task ProcessOrder(ServiceLevelIndicator sli)
-{
- using var op = sli.StartMeasuring("ProcessOrder");
- op.AddAttribute("OrderType", "Standard");
-
- await Task.Delay(50);
-
- op.SetOutcome(SliOutcome.Success);
-}
+Task ProcessOrderAsync(ServiceLevelIndicator sli, CancellationToken cancellationToken) =>
+ sli.MeasureAsync(
+ "ProcessOrder",
+ () => Task.Delay(50, cancellationToken),
+ new KeyValuePair("OrderType", "Standard"));
```
-Direct recording is also available when you already know the elapsed time. `Record(...)` emits `CustomerResourceId`, `LocationId`, `Operation`, `Outcome`, and any custom attributes supplied to the call. Manual measurements default to `Ignored` unless you set an outcome.
+`Measure` / `MeasureAsync` infer `Success` on normal completion, `Failure` on exceptions, and `Ignored` for `OperationCanceledException`. Exceptions still propagate. Raw `StartMeasuring(...)` scopes start as `Ignored`: disposal does not inspect an exception escaping the scope. If you use raw scopes, explicitly classify normal completion, failure, and cancellation.
+
+Direct recording is also available when you already know the elapsed time. `Record(...)` emits `CustomerResourceId`, `LocationId`, `Operation`, `Outcome`, and any custom attributes supplied to the call. Calls without an explicit outcome also default to `Ignored`.
```csharp
-sli.Record("ProcessOrder", elapsedTime: 42);
+sli.Record("ProcessOrder", elapsedTime: 42, outcome: SliOutcome.Success);
```
### Classify returned values
@@ -115,6 +126,8 @@ builder.Services.AddServiceLevelIndicator(options =>
`LatencyThresholds` is a get-only collection, empty by default. Values must be distinct, positive whole milliseconds; invalid configuration fails before instrument creation. The collection is snapshotted at construction. All direct recordings, scopes, helpers, and measured HTTP requests contribute once per threshold.
+These are measurement boundaries, not timeouts: they do not cancel work, change HTTP status, or determine success/failure.
+
| Counter element | Contract |
|---|---|
| Instrument / type / unit | `operation.duration.threshold.count` / `Counter` / `{operation}` |
@@ -172,7 +185,7 @@ Options are copied at construction, including thresholds; creating an internal m
If you provide 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 =>
@@ -197,12 +210,14 @@ metrics.AddServiceLevelIndicatorInstrumentation("MyCompany.ServiceLevelIndicator
metrics.AddServiceLevelIndicatorInstrumentation(sliMeter);
```
+The DI container owns the singleton `ServiceLevelIndicator` and disposes any meter SLI creates at shutdown. A supplied meter remains application-owned; keep it alive until recording and export finish, then dispose it. The top-level `using` above assumes the host runs within that scope. The [console example](../sample/ConsoleApp/Program.cs) demonstrates complete meter, service-provider, and exporter lifetimes.
+
## ASP.NET Core MVC
Install:
```shell
-dotnet add package Trellis.ServiceLevelIndicators.Asp
+dotnet add package Trellis.ServiceLevelIndicators.Asp --prerelease
```
Register services:
@@ -237,24 +252,22 @@ builder.Services.AddServiceLevelIndicator(options =>
options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
})
.AddMvc()
-.Enrich(context =>
-{
- context.SetCustomerResourceId("tenant-a");
- context.AddAttribute("ProductTier", "Premium");
-});
+.Enrich(context => context.AddAttribute("ProductTier", "Premium"));
```
Action-level attributes:
```csharp
-[HttpGet("orders/{customerId}/{orderType}")]
-[ServiceLevelIndicator(Operation = "GetOrder")]
-public IActionResult Get(
- [CustomerResourceId] string customerId,
- [Measure(Name = "OrderType")] string orderType)
+[HttpPost("projects/{projectId}/sync/{mode:regex(^(quick|standard|slow)$)}")]
+[ServiceLevelIndicator(Operation = "SyncProject")]
+public IActionResult Sync(
+ [CustomerResourceId] string projectId,
+ [Measure("SyncMode")] string mode)
=> Ok();
```
+Register controllers with `builder.Services.AddControllers()` and map them with `app.MapControllers()`. `AddMvc()` above installs SLI conventions; it does not replace ASP.NET Core controller registration. Automatic measurement is enabled by default. To opt in, set `AutomaticallyEmitted = false` and mark controllers/actions with `[ServiceLevelIndicator]`.
+
## ASP.NET Core Minimal APIs
Register services and middleware:
@@ -280,11 +293,15 @@ app.UseServiceLevelIndicator();
Mark each endpoint that should emit SLI data:
```csharp
-app.MapGet("/orders/{customerId}/{orderType}",
- ([CustomerResourceId] string customerId, [Measure(Name = "OrderType")] string orderType) => Results.Ok())
- .AddServiceLevelIndicator("GetOrder");
+app.MapPost("/projects/{projectId}/sync/{mode:regex(^(quick|standard|slow)$)}",
+ ([CustomerResourceId] string projectId, [Measure("SyncMode")] string mode) => Results.Ok())
+ .AddServiceLevelIndicator("SyncProject");
```
+The named target must be a stable, validated resource identifier. The route constraint bounds `SyncMode` to three values. See the [runnable Minimal API example](../sample/MinApi/Program.cs) for endpoint opt-in and bounded project IDs. Its unmarked health and documentation endpoints remain unmeasured because automatic emission is disabled.
+
+Register `AddServiceLevelIndicator()` once per endpoint, either on its group or on the handler. Applying it to both can duplicate parameter metadata and fail endpoint initialization.
+
## Exclude Endpoints from Automatic SLI Measurement
```csharp
@@ -309,6 +326,8 @@ public IActionResult Diagnostics() => Ok();
The attribute also works on controller classes, including inherited controllers/actions. Route-group exclusions apply to nested groups and child endpoints. Exclusion takes precedence over automatic emission and explicit SLI inclusion, regardless of registration order.
+With opt-in emission, unmarked endpoints are already unmeasured and need no exclusion annotation. The [always-on demo](../sample/SliProductionDemo/Program.cs) enables automatic emission and explicitly excludes `/health`.
+
The exclusion check occurs before starting a scope, attaching the SLI feature, classification, and enrichment. No automatic SLI histogram, threshold, or measurement-related diagnostic counts are emitted, even if the handler fails or aborts. A classifier returning `Ignored` only changes a normal completion's outcome and is not equivalent to exclusion.
The endpoint still executes and other ASP.NET Core/Application Insights telemetry is unchanged. Manual core measurements are unaffected. Use `TryGetMeasuredOperation(...)` in shared code because excluded endpoints do not have an automatically attached feature.
@@ -318,7 +337,7 @@ The endpoint still executes and other ASP.NET Core/Application Insights telemetr
Install:
```shell
-dotnet add package Trellis.ServiceLevelIndicators.Asp.ApiVersioning
+dotnet add package Trellis.ServiceLevelIndicators.Asp.ApiVersioning --prerelease
```
Register enrichment:
@@ -334,6 +353,8 @@ builder.Services.AddServiceLevelIndicator(options =>
This adds the `http.api.version` metric dimension when Asp.Versioning is present. The value is the single resolved API version, `Neutral`, `Unspecified`, or an empty string when the requested version is invalid or ambiguous.
+`AddApiVersion()` enriches SLI; it does not configure Asp.Versioning routing or version discovery. See the [versioned MVC example](../sample/WebApiVersioned/Program.cs) for the complete registration and query-string version reader.
+
## ASP.NET Runtime Helpers
These APIs are useful inside controllers, middleware, and endpoint handlers:
@@ -351,6 +372,34 @@ if (HttpContext.TryGetMeasuredOperation(out var measuredOperation))
Use `GetMeasuredOperation()` when the route is guaranteed to emit SLI metrics. Use `TryGetMeasuredOperation()` in shared middleware or filters.
+## Enrichment and Bounded Attributes
+
+`Enrich` receives a `WebEnrichmentContext`, not a `MeasuredOperation`. It wraps the measurement and exposes `HttpContext`, `SetCustomerResourceId(...)`, and `AddAttribute(...)`. `EnrichAsync` receives the same context plus a cancellation token and returns `ValueTask`.
+
+For example, map a caller-tier claim to an explicitly bounded set without changing the target resource:
+
+```csharp
+builder.Services.AddServiceLevelIndicator(options =>
+{
+ options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
+})
+.Enrich(context =>
+{
+ var tier = context.HttpContext.User.FindFirst("tier")?.Value switch
+ {
+ "free" => "free",
+ "standard" => "standard",
+ "premium" => "premium",
+ _ => "unknown"
+ };
+ context.AddAttribute("CallerTier", tier);
+});
+```
+
+Enrichment runs after endpoint handling and can overwrite a target already extracted from `[CustomerResourceId]`. Only call `SetCustomerResourceId` with the resolved target of the operation. A caller's `tid` claim is not necessarily that target, especially for cross-tenant administration; using it requires an explicit same-tenant guarantee. Caller identity belongs in audit logs/traces, not SLI dimensions.
+
+`[Measure]` adds a parameter value as an attribute using the parameter name (or its configured override). Use bounded categories such as the `SyncMode` route above, never personal names or arbitrary text.
+
## Status Semantics
For non-HTTP code, set the outcome explicitly or use `Measure(...)` / `MeasureAsync(...)` helpers to infer it:
@@ -437,6 +486,82 @@ Avoid values that can explode cardinality unless your backend is designed for th
4. Forgetting `.AddServiceLevelIndicator()` on Minimal API endpoints when `AutomaticallyEmitted` is `false`.
5. Renaming `CustomerResourceId` or `LocationId` even though downstream systems depend on those exact names.
6. Reusing reserved tags: required SLI dimensions, `activity.status.code`, HTTP method/status, `sli.request.aborted`, `sli.request.abort_exception`, `sli.latency.threshold_ms`, or `sli.latency.result`.
+7. Assuming a raw `StartMeasuring(...)` scope detects thrown work: without explicit classification it records `Ignored`, not `Failure`.
+8. Replacing the target resource with the caller's tenant or emitting personal names as `[Measure]` dimensions.
+
+## Running the Focused Examples
+
+The [always-on Docker demo](../sample/Observability/Grafana/README.md) is the main end-to-end experience. These .NET 10 examples use the same Project Tracker vocabulary but isolate one integration each. Run commands from the repository root.
+
+### Console: core only and custom meters
+
+```powershell
+dotnet run --project .\sample\ConsoleApp\SampleConsoleSLI.csproj
+```
+
+No Docker or collector is required. The process measures `SyncProject` for `project-blue` and exports duration and 250 ms threshold metrics to the console when the exporter is disposed. It supplies its own meter and registers that same instance with OpenTelemetry. The sample disposes the exporter, service provider, and application-owned meter.
+
+### Web examples: reuse the observability stack
+
+If the live demo is already running, reuse its Collector, Prometheus, and Grafana. Otherwise start only the observability services:
+
+```powershell
+docker compose -f .\sample\Observability\Grafana\docker-compose.yml up -d
+```
+
+In separate terminals, run either or both examples:
+
+```powershell
+$env:OTEL_METRIC_EXPORT_INTERVAL = "1000"
+dotnet run --project .\sample\MinApi\SampleMinimalApiSli.csproj
+```
+
+```powershell
+$env:OTEL_METRIC_EXPORT_INTERVAL = "1000"
+dotnet run --project .\sample\WebApiVersioned\SampleVersionedWebApplicationSLI.csproj
+```
+
+Both export to `http://localhost:4317` by default and use HTTP-only loopback launch profiles; no development HTTPS certificate is needed.
+
+| Example | Requests | What to inspect |
+|---|---|---|
+| Minimal API, http://localhost:5201 | `GET /projects/project-blue`; `POST /projects/project-red/sync/slow` | Named operations, project ID, bounded `SyncMode`, and a slow `Success` exceeding 250 ms |
+| Versioned MVC, http://localhost:5202 | `GET /projects/project-blue?api-version=1.0` and `?api-version=2.0` | Same `GetProject` operation and project ID with distinct `http.api.version` values |
+
+Each web sample's `Api.http` file contains ready-to-send requests. Scalar is available at http://localhost:5201/scalar/v1 and http://localhost:5202/scalar/v2 in Development.
+
+All measured project IDs are bounded to `project-blue`, `project-green`, and `project-red`; the Minimal API also constrains sync modes. These examples disable automatic emission and opt in only their project endpoints. Unmarked health/docs endpoints and route misses therefore stay outside their SLO populations without exclusion annotations. The versioned sample is deterministic, not a second random-incident generator. Use the continuous demo for incidents and recovery.
+
+The dashboard can display these metrics too, but it aggregates services with matching SLI dimensions. If you want the continuous demo's charts in isolation, stop the focused web examples with Ctrl+C. Their service names remain distinct for custom Prometheus queries. The console example exports only to the console.
+
+## Optional API Guidance for Coding Assistants
+
+Each SLI NuGet package carries its own versioned API reference at the package root (`trellis-api-sli*.md`) and a guidance manifest. The packages remain independent of `Trellis.Core`.
+
+Ordinary restore/build does not write `.github/`, `AGENTS.md`, or `.agentdocs/` unless the consuming repository opts into the separate local `Trellis.AgentDocs` tool. After restoring the consuming project or solution, run once from its Git root:
+
+```powershell
+dotnet new tool-manifest --output .config
+dotnet tool install Trellis.AgentDocs --version 0.1.0-preview.20 --tool-manifest .config\dotnet-tools.json
+dotnet tool run agentdocs init
+```
+
+Reuse an existing root `.config\dotnet-tools.json` instead of creating another. Restoring a package never activates its guide. `init` lists packages that publish guidance as pending and prints the package IDs to add to `approvedPackages` in `.agentdocs/policy.json`. Add the SLI packages you trust, then run `dotnet tool run agentdocs sync` to install their guides into Git-root `.agentdocs/`; `init` also adds managed instruction pointers.
+
+Guides are on-demand: the generated index lists each with a description, and an agent opens one when its task concerns SLI metrics. After a package upgrade, run `dotnet restore` and `dotnet tool run agentdocs sync`. The helper and tool are preview packages published on NuGet.org.
+
+For maintainers, the three publishing projects use a pinned, private `Trellis.AgentDocs.Packaging` build dependency to generate manifests at pack time. Each project sets `TrellisApiRefDescription`, the one-line "Open when..." topic consumers see. Guides refer to sibling packages by ID rather than by link because every package installs into its own directory. The packaging build target is not included in published SLI packages. Run `pwsh -NoProfile -File build\test-guidance-packages.ps1` after packing to verify payloads and the absence of legacy consumer targets.
+
+## Provider Cancellation Integration
+
+The ASP.NET tests include real mid-flight `HttpClient` cancellation. Real `SqlClient` cancellation requires a dedicated SQL fixture; without `TRELLIS_SLI_SQL_CONNECTION_STRING`, those cases are explicitly skipped. On Windows with SQL Server Express LocalDB installed, run:
+
+```powershell
+dotnet restore Trellis.ServiceLevelIndicators.Asp\tests\Trellis.ServiceLevelIndicators.Asp.Tests.csproj
+pwsh -NoProfile -File build\test-sql-cancellation.ps1
+```
+
+The script creates, starts, stops, and deletes its own uniquely named LocalDB instance. It does not use or modify shared instances. The Windows CI job runs this fixture; provider dependencies are test-only. For another dedicated SQL Server, supply the environment variable and run the ASP.NET tests; the fixture needs permission to inspect `sys.dm_exec_requests` so cancellation occurs after the command is genuinely in flight.
## Public API Cheat Sheet
diff --git a/sample/ConsoleApp/Program.cs b/sample/ConsoleApp/Program.cs
index 0c01ad7..966bdb5 100644
--- a/sample/ConsoleApp/Program.cs
+++ b/sample/ConsoleApp/Program.cs
@@ -1,60 +1,27 @@
-using System.Reflection;
-using Azure.Core;
+using System.Diagnostics.Metrics;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
using OpenTelemetry;
-using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using Trellis.ServiceLevelIndicators;
-#pragma warning disable CA1848 // Use the LoggerMessage delegates
-
-const string ProgramName = "SampleConsoleSLI";
-string version = Assembly
- .GetExecutingAssembly()
- .GetCustomAttribute()!
- .Version;
-ResourceBuilder resourceBuilder = ResourceBuilder
- .CreateEmpty()
- .AddService(ProgramName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
-
-ServiceCollection services = new();
-services.AddServiceLevelIndicator(r =>
+using var meter = new Meter("SampleConsoleSLI");
+var services = new ServiceCollection();
+services.AddServiceLevelIndicator(options =>
{
- r.CustomerResourceId = ProgramName; // Stable resource this background workload is measuring.
- r.LocationId = ServiceLevelIndicator.CreateLocationId("public", AzureLocation.WestUS3.Name);
+ options.Meter = meter;
+ options.CustomerResourceId = "project-blue";
+ options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
+ options.LatencyThresholds.Add(TimeSpan.FromMilliseconds(250));
});
-services
- .AddLogging(builder => builder.AddOpenTelemetry(options =>
- {
- options.SetResourceBuilder(resourceBuilder);
- options.AddConsoleExporter();
- }));
-
-var serviceProvider = services.BuildServiceProvider();
-
-var logger = serviceProvider.GetRequiredService>();
-
+using ServiceProvider serviceProvider = services.BuildServiceProvider();
using MeterProvider meterProvider = Sdk.CreateMeterProviderBuilder()
- .SetResourceBuilder(resourceBuilder)
- .AddConsoleExporter()
- .AddServiceLevelIndicatorInstrumentation()
- .Build();
-
-var serviceLevelIndicator = serviceProvider.GetRequiredService();
-
-try
-{
- await serviceLevelIndicator.MeasureAsync("OperationWork", async () =>
- {
- logger.LogInformation("Starting to do some work...");
- await Task.Delay(1000); // Simulate some work
- logger.LogInformation("Work done.");
- });
-}
-catch (Exception ex)
-{
- logger.LogError(ex, "An error occurred doing work.");
-}
+ .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("SampleConsoleSLI"))
+ .AddServiceLevelIndicatorInstrumentation(meter)
+ .AddConsoleExporter()
+ .Build();
+
+ServiceLevelIndicator sli = serviceProvider.GetRequiredService();
+Console.WriteLine("Measuring SyncProject for project-blue. Metrics are exported at shutdown.");
+await sli.MeasureAsync("SyncProject", () => Task.Delay(75));
diff --git a/sample/ConsoleApp/SampleConsoleSLI.csproj b/sample/ConsoleApp/SampleConsoleSLI.csproj
index 0d14d0e..09ffcd7 100644
--- a/sample/ConsoleApp/SampleConsoleSLI.csproj
+++ b/sample/ConsoleApp/SampleConsoleSLI.csproj
@@ -9,8 +9,6 @@
-
-
diff --git a/sample/DockerOpenTelemetry/run.cmd b/sample/DockerOpenTelemetry/run.cmd
deleted file mode 100644
index 17f1360..0000000
--- a/sample/DockerOpenTelemetry/run.cmd
+++ /dev/null
@@ -1 +0,0 @@
-docker run --rm -it -d -p 18888:18888 -p 4317:18889 --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest
\ No newline at end of file
diff --git a/sample/FocusedSamples.Tests/FocusedSampleTests.cs b/sample/FocusedSamples.Tests/FocusedSampleTests.cs
new file mode 100644
index 0000000..aeae32d
--- /dev/null
+++ b/sample/FocusedSamples.Tests/FocusedSampleTests.cs
@@ -0,0 +1,154 @@
+extern alias minimal;
+extern alias versioned;
+
+namespace FocusedSamples.Tests;
+
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Trellis.ServiceLevelIndicators;
+
+public sealed class FocusedSampleTests
+{
+ [Theory]
+ [InlineData("project-blue")]
+ [InlineData("project-green")]
+ [InlineData("project-red")]
+ public async Task Minimal_api_records_the_named_operation_and_target_resource(string projectId)
+ {
+ using var capture = new MetricCapture();
+ using var application = new SampleApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.GetAsync(
+ $"/projects/{projectId}", TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var duration = capture.For("operation.duration").Should().ContainSingle().Subject;
+ duration.Tags["CustomerResourceId"].Should().Be(projectId);
+ duration.Tags["Operation"].Should().Be("GetProject");
+ duration.Tags["Outcome"].Should().Be("Success");
+ }
+
+ [Theory]
+ [InlineData("quick")]
+ [InlineData("standard")]
+ [InlineData("slow")]
+ public async Task Minimal_api_records_bounded_modes_and_exact_threshold_counts(string mode)
+ {
+ using var capture = new MetricCapture();
+ using var application = new SampleApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.PostAsync(
+ $"/projects/project-red/sync/{mode}",
+ content: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var duration = capture.For("operation.duration").Should().ContainSingle().Subject;
+ var threshold = capture.For("operation.duration.threshold.count").Should().ContainSingle().Subject;
+ duration.Tags["Operation"].Should().Be("SyncProject");
+ duration.Tags["CustomerResourceId"].Should().Be("project-red");
+ duration.Tags["SyncMode"].Should().Be(mode);
+ duration.Tags["Outcome"].Should().Be("Success");
+ threshold.Value.Should().Be(1);
+ threshold.Tags["CustomerResourceId"].Should().Be("project-red");
+ threshold.Tags["SyncMode"].Should().Be(mode);
+ threshold.Tags["sli.latency.threshold_ms"].Should().Be(250L);
+ if (mode == "slow")
+ threshold.Tags["sli.latency.result"].Should().Be("exceeded");
+ }
+
+ [Theory]
+ [InlineData("/health", HttpStatusCode.OK)]
+ [InlineData("/openapi/v1.json", HttpStatusCode.OK)]
+ [InlineData("/scalar/v1", HttpStatusCode.OK)]
+ [InlineData("/projects/arbitrary-rejected-id", HttpStatusCode.NotFound)]
+ public async Task Minimal_api_does_not_measure_health_docs_or_rejected_resources(
+ string path, HttpStatusCode expectedStatus)
+ {
+ using var capture = new MetricCapture();
+ using var application = new SampleApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(expectedStatus);
+ capture.Measurements.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Minimal_api_rejects_unbounded_sync_modes_without_measuring_them()
+ {
+ using var capture = new MetricCapture();
+ using var application = new SampleApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.PostAsync(
+ "/projects/project-red/sync/arbitrary-mode",
+ content: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ capture.Measurements.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData("1.0")]
+ [InlineData("2.0")]
+ public async Task Versioned_mvc_retains_the_same_operation_and_resource_across_versions(string version)
+ {
+ using var capture = new MetricCapture();
+ using var application = new SampleApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.GetAsync(
+ $"/projects/project-blue?api-version={version}", TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var project = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ project.Should().Be(new ProjectResponse("project-blue", version));
+ var duration = capture.For("operation.duration").Should().ContainSingle().Subject;
+ var threshold = capture.For("operation.duration.threshold.count").Should().ContainSingle().Subject;
+ duration.Tags["Operation"].Should().Be("GetProject");
+ duration.Tags["CustomerResourceId"].Should().Be("project-blue");
+ duration.Tags["Outcome"].Should().Be("Success");
+ duration.Tags["http.api.version"].Should().Be(version);
+ threshold.Tags["http.api.version"].Should().Be(version);
+ }
+
+ [Theory]
+ [InlineData("/health", HttpStatusCode.OK)]
+ [InlineData("/openapi/v1.json", HttpStatusCode.OK)]
+ [InlineData("/openapi/v2.json", HttpStatusCode.OK)]
+ [InlineData("/scalar/v2", HttpStatusCode.OK)]
+ [InlineData("/projects/arbitrary-rejected-id?api-version=1.0", HttpStatusCode.NotFound)]
+ public async Task Versioned_mvc_does_not_measure_health_docs_or_rejected_resources(
+ string path, HttpStatusCode expectedStatus)
+ {
+ using var capture = new MetricCapture();
+ using var application = new SampleApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(expectedStatus);
+ capture.Measurements.Should().BeEmpty();
+ }
+
+ private sealed record ProjectResponse(string ProjectId, string ApiVersion);
+
+ private sealed class SampleApplicationFactory(MetricCapture capture)
+ : WebApplicationFactory where TEntryPoint : class
+ {
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Development");
+ builder.ConfigureServices(services =>
+ services.Configure(options => options.Meter = capture.Meter));
+ }
+ }
+}
diff --git a/sample/FocusedSamples.Tests/FocusedSamples.Tests.csproj b/sample/FocusedSamples.Tests/FocusedSamples.Tests.csproj
new file mode 100644
index 0000000..169f618
--- /dev/null
+++ b/sample/FocusedSamples.Tests/FocusedSamples.Tests.csproj
@@ -0,0 +1,10 @@
+
+
+ false
+
+
+
+
+
+
+
diff --git a/sample/GenerateSli.Tests/GenerateSli.Tests.csproj b/sample/GenerateSli.Tests/GenerateSli.Tests.csproj
new file mode 100644
index 0000000..796d568
--- /dev/null
+++ b/sample/GenerateSli.Tests/GenerateSli.Tests.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/GenerateSli.Tests/ScenarioTests.cs b/sample/GenerateSli.Tests/ScenarioTests.cs
new file mode 100644
index 0000000..d6595f7
--- /dev/null
+++ b/sample/GenerateSli.Tests/ScenarioTests.cs
@@ -0,0 +1,902 @@
+namespace GenerateSli.Tests;
+
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Net;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Time.Testing;
+
+public sealed class ScenarioTests
+{
+ [Fact]
+ public void Continuous_mode_is_explicit_and_preserves_finite_defaults()
+ {
+ var defaults = GeneratorOptions.Parse([]);
+ defaults.Continuous.Should().BeFalse();
+ defaults.WaitForReady.Should().BeFalse();
+ defaults.PhaseSeconds.Should().Be(30);
+ defaults.RequestsPerSecond.Should().Be(12);
+
+ var continuous = GeneratorOptions.Parse(
+ ["--continuous", "--wait-for-ready", "--base-url", "http://demo-api:8080"]);
+ continuous.Continuous.Should().BeTrue();
+ continuous.WaitForReady.Should().BeTrue();
+ continuous.BaseAddress.Should().Be(new Uri("http://demo-api:8080/"));
+ }
+
+ [Theory]
+ [InlineData("--base-url", "ftp://localhost")]
+ [InlineData("--base-url", "/relative")]
+ [InlineData("--phase-seconds", "0")]
+ [InlineData("--requests-per-second", "-1")]
+ [InlineData("--unknown", "value")]
+ public void Invalid_options_fail_explicitly(string option, string value)
+ {
+ var act = () => GeneratorOptions.Parse([option, value]);
+ act.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData("+1")]
+ [InlineData(" 1")]
+ [InlineData("1 ")]
+ [InlineData("\t1")]
+ [InlineData("1.0")]
+ [InlineData("1,000")]
+ public void Numeric_options_require_invariant_digits(string value)
+ {
+ foreach (var option in new[] { "--phase-seconds", "--requests-per-second" })
+ {
+ var act = () => GeneratorOptions.Parse([option, value]);
+ act.Should().Throw().WithMessage("*only digits*");
+ }
+ }
+
+ [Theory]
+ [InlineData("--phase-seconds", "3601")]
+ [InlineData("--phase-seconds", "2147483647")]
+ [InlineData("--requests-per-second", "1001")]
+ [InlineData("--requests-per-second", "2000000000")]
+ public void Numeric_options_reject_values_beyond_the_demo_limits(string option, string value)
+ {
+ var act = () => GeneratorOptions.Parse([option, value]);
+ act.Should().Throw().WithMessage("*between 1 and*");
+ }
+
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(30, 12)]
+ [InlineData(3600, 1000)]
+ public void Accepted_numeric_limits_keep_the_pacing_interval_positive(int phaseSeconds, int requestsPerSecond)
+ {
+ var options = GeneratorOptions.Parse(
+ ["--phase-seconds", phaseSeconds.ToString(CultureInfo.InvariantCulture),
+ "--requests-per-second", requestsPerSecond.ToString(CultureInfo.InvariantCulture)]);
+
+ options.PhaseSeconds.Should().Be(phaseSeconds);
+ options.RequestsPerSecond.Should().Be(requestsPerSecond);
+ TimeSpan.FromSeconds(1d / options.RequestsPerSecond).Ticks.Should().BeGreaterThan(0);
+ }
+
+ [Theory]
+ [InlineData("--base-url")]
+ [InlineData("--phase-seconds")]
+ [InlineData("--requests-per-second")]
+ public void Value_options_require_a_value_instead_of_consuming_the_next_flag(string option)
+ {
+ var missing = () => GeneratorOptions.Parse([option]);
+ var nextFlag = () => GeneratorOptions.Parse([option, "--continuous"]);
+
+ missing.Should().Throw().WithMessage($"Missing value for '{option}'.");
+ nextFlag.Should().Throw().WithMessage($"Missing value for '{option}'.");
+ }
+
+ [Theory]
+ [InlineData("Healthy")]
+ [InlineData("Incident")]
+ [InlineData("ClientError")]
+ [InlineData("Throttled")]
+ [InlineData("Recovery")]
+ public void Every_phase_keeps_healthy_reads_and_writes_for_each_project(string phaseName)
+ {
+ var phase = Enum.Parse(phaseName);
+ var reads = new HashSet(StringComparer.Ordinal);
+ var healthyWrites = new HashSet(StringComparer.Ordinal);
+ for (var number = 1; number <= 600; number++)
+ {
+ using var request = ScenarioTraffic.CreateRequest(phase, number);
+ var path = request.RequestUri!.OriginalString;
+ var project = path.Split('/')[2];
+ if (request.Method == HttpMethod.Get)
+ {
+ path.Should().NotContain("?");
+ reads.Add(project);
+ }
+ else if (path.EndsWith("mode=healthy", StringComparison.Ordinal))
+ {
+ healthyWrites.Add(project);
+ }
+ }
+
+ string[] projects = ["project-blue", "project-green", "project-red"];
+ reads.Should().BeEquivalentTo(projects);
+ healthyWrites.Should().BeEquivalentTo(projects);
+ }
+
+ [Theory]
+ [InlineData("Incident", "project-red", "failure,healthy,slow")]
+ [InlineData("Incident", "project-green", "healthy")]
+ [InlineData("ClientError", "project-blue", "client-error,healthy")]
+ [InlineData("ClientError", "project-red", "healthy")]
+ [InlineData("Throttled", "project-red", "healthy,throttled")]
+ [InlineData("Throttled", "project-blue", "healthy")]
+ public void Incidents_affect_only_the_intended_project(string phaseName, string project, string expectedModes)
+ {
+ var phase = Enum.Parse(phaseName);
+ var modes = new HashSet(StringComparer.Ordinal);
+ for (var number = 1; number <= 600; number++)
+ {
+ using var request = ScenarioTraffic.CreateRequest(phase, number);
+ var path = request.RequestUri!.OriginalString;
+ if (request.Method == HttpMethod.Post && path.StartsWith($"api/projects/{project}/", StringComparison.Ordinal))
+ modes.Add(path[(path.IndexOf("mode=", StringComparison.Ordinal) + 5)..]);
+ }
+
+ modes.Should().BeEquivalentTo(expectedModes.Split(','));
+ }
+
+ [Fact]
+ public async Task Finite_mode_completes_exactly_one_cycle()
+ {
+ using var client = CreateClient(_ => new(HttpStatusCode.OK));
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ await AdvanceUntilCompleteAsync(
+ ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2), output, error, TestContext.Current.CancellationToken, time),
+ time);
+
+ output.ToString().Should().Contain("Cycle 1").And.NotContain("Cycle 2").And.Contain("Scenario complete.");
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Continuous_mode_starts_another_cycle_and_honors_cancellation()
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var client = CreateClient(_ => new(HttpStatusCode.OK));
+ using var output = new CycleCancellingWriter(cancellation, "Cycle 2");
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ var act = () => AdvanceUntilCompleteAsync(
+ ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2, Continuous: true), output, error, cancellation.Token, time),
+ time);
+
+ await act.Should().ThrowAsync();
+ output.ToString().Should().Contain("Cycle 1").And.Contain("Cycle 2").And.NotContain("Scenario complete.");
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData(false, "transport-failures", "failed")]
+ [InlineData(true, "timeouts", "timed out")]
+ public async Task Continuous_mode_reports_transient_failures_immediately_and_recovers(
+ bool timeout, string countName, string diagnostic)
+ {
+ var requests = 0;
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var client = CreateClient(request =>
+ {
+ if (request.RequestUri!.AbsolutePath != "/health" && Interlocked.Increment(ref requests) == 1)
+ {
+ if (timeout)
+ throw new TaskCanceledException("Simulated request timeout.");
+
+ throw new HttpRequestException("Simulated API restart.");
+ }
+
+ return new(HttpStatusCode.OK);
+ });
+ using var output = new CycleCancellingWriter(cancellation, "Cycle 2");
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ var run = ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2, Continuous: true), output, error, cancellation.Token, time);
+
+ run.IsCompleted.Should().BeFalse();
+ error.ToString().Should().Contain(diagnostic).And.Contain("Continuing traffic.");
+
+ var act = () => AdvanceUntilCompleteAsync(run, time);
+ await act.Should().ThrowAsync();
+
+ requests.Should().BeGreaterThan(1);
+ output.ToString().Should().Contain($"{countName}=1").And.Contain("200=").And.Contain("Cycle 2");
+ output.ToString().Should().NotContain(timeout ? "transport-failures=" : "timeouts=");
+ }
+
+ [Theory]
+ [InlineData(false, "transport-failures", "failed")]
+ [InlineData(true, "timeouts", "timed out")]
+ public async Task Repeated_continuous_failures_are_counted_without_per_request_log_flooding(
+ bool timeout, string countName, string diagnostic)
+ {
+ var requests = 0;
+ var time = new FakeTimeProvider();
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var client = CreateClient(request =>
+ {
+ if (request.RequestUri!.AbsolutePath == "/health")
+ return new(HttpStatusCode.OK);
+
+ Interlocked.Increment(ref requests);
+ time.Advance(TimeSpan.FromMilliseconds(100));
+ if (timeout)
+ throw new TaskCanceledException("Simulated request timeout.");
+
+ throw new HttpRequestException("Simulated API outage.");
+ });
+ using var output = new CycleCancellingWriter(cancellation, "Cycle 2");
+ using var error = new StringWriter();
+
+ var act = () => ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 1_000, Continuous: true), output, error, cancellation.Token, time);
+
+ await act.Should().ThrowAsync();
+
+ var diagnostics = error.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+ diagnostics.Should().HaveCount(5, "each phase logs only its first failure of each kind");
+ diagnostics.Should().OnlyContain(line => line.Contains(diagnostic, StringComparison.Ordinal));
+ var summaries = Regex.Matches(output.ToString(), countName + @"=(\d+)");
+ summaries.Should().HaveCount(5);
+ summaries.Sum(match => int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture))
+ .Should().Be(requests);
+ requests.Should().Be(50, "each of five phases contains ten failures with 100 ms of simulated send overhead");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Finite_mode_stops_sending_immediately_after_a_transport_failure_or_timeout(bool timeout)
+ {
+ var requests = 0;
+ using var client = CreateClient(request =>
+ {
+ if (request.RequestUri!.AbsolutePath == "/health")
+ return new(HttpStatusCode.OK);
+
+ Interlocked.Increment(ref requests);
+ if (timeout)
+ throw new TaskCanceledException("Simulated request timeout.");
+
+ throw new HttpRequestException("Simulated API restart.");
+ });
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ var act = () => AdvanceUntilCompleteAsync(
+ ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 30, 1), output, error, TestContext.Current.CancellationToken, time),
+ time);
+
+ await act.Should().ThrowAsync()
+ .Where(ex => timeout ? ex is OperationCanceledException : ex is HttpRequestException);
+ requests.Should().Be(1, "a failed finite run must not keep sending for the rest of its phase");
+ output.ToString().Should().NotContain("Scenario complete.");
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task User_cancellation_takes_precedence_over_an_earlier_failure_while_draining()
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var second = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var requests = 0;
+ using var client = new PayloadObservingClient(new ResponseHandler((request, _) =>
+ {
+ if (request.RequestUri!.AbsolutePath == "/health")
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
+
+ return Interlocked.Increment(ref requests) == 1 ? first.Task : second.Task;
+ }))
+ { BaseAddress = new Uri("http://demo.test/") };
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+ var run = ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 30, 10), output, error, cancellation.Token, time);
+
+ try
+ {
+ await AdvanceUntilAsync(() => Volatile.Read(ref requests) >= 2, time);
+ first.SetException(new HttpRequestException("An earlier request failed."));
+ await AdvanceUntilAsync(() => client.FirstPayload?.IsFaulted == true, time, TimeSpan.Zero);
+ cancellation.Cancel();
+ second.SetCanceled(cancellation.Token);
+
+ var act = () => AdvanceUntilCompleteAsync(run, time);
+ await act.Should().ThrowAsync();
+ output.ToString().Should().NotContain("Scenario complete.");
+ }
+ finally
+ {
+ cancellation.Cancel();
+ first.TrySetCanceled(cancellation.Token);
+ second.TrySetCanceled(cancellation.Token);
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task An_unexpected_drain_failure_cannot_replace_the_original_failure_or_user_shutdown(bool shutdown)
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var second = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondToken = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var requests = 0;
+ using var client = CreateClient((request, token) =>
+ {
+ if (request.RequestUri!.AbsolutePath == "/health")
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
+
+ if (Interlocked.Increment(ref requests) == 1)
+ return first.Task;
+
+ secondToken.TrySetResult(token);
+ return second.Task;
+ });
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+ var original = new HttpRequestException("The initiating request failure.");
+ var run = ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 30, 10), output, error, cancellation.Token, time);
+
+ try
+ {
+ await AdvanceUntilAsync(() => secondToken.Task.IsCompleted, time);
+ first.SetException(original);
+ await AdvanceUntilAsync(() => secondToken.Task.Result.IsCancellationRequested, time, TimeSpan.Zero);
+ if (shutdown)
+ cancellation.Cancel();
+ second.SetException(new InvalidOperationException("An unexpected secondary failure."));
+
+ var act = () => AdvanceUntilCompleteAsync(run, time);
+ if (shutdown)
+ {
+ await act.Should().ThrowAsync();
+ }
+ else
+ {
+ var exception = await act.Should().ThrowAsync();
+ exception.Which.Should().BeSameAs(original);
+ }
+
+ error.ToString().Should().Contain("Additional failure while stopping")
+ .And.Contain("InvalidOperationException").And.Contain("unexpected secondary failure");
+ }
+ finally
+ {
+ cancellation.Cancel();
+ first.TrySetCanceled(cancellation.Token);
+ second.TrySetCanceled(cancellation.Token);
+ await run.ConfigureAwait(
+ ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.SuppressThrowing);
+ }
+ }
+
+ [Fact]
+ public async Task A_failure_after_the_phase_window_cancels_other_in_flight_requests()
+ {
+ var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var drained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var requests = 0;
+ using var client = CreateClient(async (request, token) =>
+ {
+ if (request.RequestUri!.AbsolutePath == "/health")
+ return new(HttpStatusCode.OK);
+
+ if (Interlocked.Increment(ref requests) == 1)
+ return await first.Task;
+
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, token);
+ return new(HttpStatusCode.OK);
+ }
+ finally
+ {
+ drained.TrySetResult();
+ }
+ });
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+ var run = ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2), output, error, TestContext.Current.CancellationToken, time);
+
+ await AdvanceUntilAsync(() => Volatile.Read(ref requests) >= 2, time);
+ time.Advance(TimeSpan.FromSeconds(1));
+ first.SetException(new HttpRequestException("A request failed while draining."));
+
+ var act = () => run.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await act.Should().ThrowAsync().WithMessage("*while draining*");
+ requests.Should().Be(2);
+ drained.Task.IsCompletedSuccessfully.Should().BeTrue();
+ output.ToString().Should().NotContain("Scenario complete.");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Hanging_requests_apply_backpressure_at_the_documented_in_flight_limit(bool continuous)
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var requests = 0;
+ var active = 0;
+ var concurrency = new ConcurrentQueue();
+ using var client = CreateClient(async (request, token) =>
+ {
+ if (request.RequestUri!.AbsolutePath == "/health")
+ return new(HttpStatusCode.OK);
+
+ Interlocked.Increment(ref requests);
+ concurrency.Enqueue(Interlocked.Increment(ref active));
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, token);
+ return new(HttpStatusCode.OK);
+ }
+ finally
+ {
+ Interlocked.Decrement(ref active);
+ }
+ });
+ using var output = new StringWriter();
+ using var error = new BackpressureObservingWriter();
+ var time = new FakeTimeProvider();
+ var run = ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 30, 1_000, Continuous: continuous),
+ output, error, cancellation.Token, time);
+
+ try
+ {
+ await AdvanceUntilAsync(() => error.LimitReached.Task.IsCompleted, time);
+ time.Advance(TimeSpan.FromSeconds(5));
+ await Task.Yield();
+
+ Volatile.Read(ref requests).Should().Be(32);
+ concurrency.Max().Should().Be(32);
+ Volatile.Read(ref active).Should().Be(32);
+ error.ToString().Should().Contain("In-flight limit (32) reached");
+ }
+ finally
+ {
+ cancellation.Cancel();
+ var stop = () => run.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await stop.Should().ThrowAsync();
+ }
+
+ Volatile.Read(ref active).Should().Be(0);
+ }
+
+ [Fact]
+ public async Task Resuming_after_backpressure_does_not_burst_to_catch_up_missed_requests()
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var requests = new ConcurrentQueue>();
+ using var client = CreateClient((request, token) =>
+ {
+ if (request.RequestUri!.AbsolutePath == "/health")
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
+
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ requests.Enqueue(completion);
+ return completion.Task.WaitAsync(token);
+ });
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+ var run = ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 30, 100, Continuous: true),
+ output, error, cancellation.Token, time);
+
+ try
+ {
+ await AdvanceUntilAsync(() => requests.Count >= 32, time);
+ time.Advance(TimeSpan.FromSeconds(5));
+ requests.Count.Should().Be(32);
+
+ foreach (var completion in requests.ToArray())
+ completion.SetResult(new(HttpStatusCode.OK));
+
+ await AdvanceUntilAsync(() => requests.Count > 32, time, TimeSpan.Zero);
+ await Task.Yield();
+ requests.Count.Should().Be(33);
+
+ time.Advance(TimeSpan.FromMilliseconds(9));
+ await Task.Yield();
+ requests.Count.Should().Be(33);
+
+ time.Advance(TimeSpan.FromMilliseconds(1));
+ await AdvanceUntilAsync(() => requests.Count > 33, time, TimeSpan.Zero);
+ requests.Count.Should().Be(34);
+ }
+ finally
+ {
+ cancellation.Cancel();
+ var stop = () => run.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await stop.Should().ThrowAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Continuous_mode_does_not_treat_shutdown_as_a_request_timeout()
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var client = CreateClient(request =>
+ {
+ if (request.RequestUri!.AbsolutePath != "/health")
+ {
+ cancellation.Cancel();
+ cancellation.Token.ThrowIfCancellationRequested();
+ }
+
+ return new(HttpStatusCode.OK);
+ });
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ var act = () => ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2, Continuous: true), output, error, cancellation.Token, time);
+
+ await act.Should().ThrowAsync();
+ output.ToString().Should().NotContain("timeouts=").And.NotContain("transport-failures=");
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Request_pacing_accounts_for_synchronous_send_overhead()
+ {
+ var time = new FakeTimeProvider();
+ var requests = 0;
+ using var client = CreateClient(request =>
+ {
+ if (request.RequestUri!.AbsolutePath != "/health")
+ {
+ Interlocked.Increment(ref requests);
+ time.Advance(TimeSpan.FromMilliseconds(100));
+ }
+
+ return new(HttpStatusCode.OK);
+ });
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ await ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 10), output, error, TestContext.Current.CancellationToken, time);
+
+ requests.Should().Be(50, "each of five one-second phases should schedule ten requests");
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Container_startup_waits_for_readiness_before_sending_traffic()
+ {
+ var healthChecks = 0;
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var client = CreateClient(request =>
+ {
+ request.RequestUri!.AbsolutePath.Should().Be("/health");
+ return new(Interlocked.Increment(ref healthChecks) < 3 ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
+ });
+ using var output = new CycleCancellingWriter(cancellation, "Cycle 1");
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ var act = () => AdvanceUntilCompleteAsync(
+ ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2, WaitForReady: true), output, error, cancellation.Token, time),
+ time);
+
+ await act.Should().ThrowAsync();
+ healthChecks.Should().Be(3);
+ error.ToString().Should().Contain("Waiting for SliProductionDemo").And.Contain("503");
+ }
+
+ [Fact]
+ public async Task Container_startup_retries_a_readiness_request_timeout()
+ {
+ var healthChecks = 0;
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var client = CreateClient(request =>
+ {
+ request.RequestUri!.AbsolutePath.Should().Be("/health");
+ if (Interlocked.Increment(ref healthChecks) == 1)
+ throw new TaskCanceledException("Simulated readiness timeout.");
+
+ return new(HttpStatusCode.OK);
+ });
+ using var output = new CycleCancellingWriter(cancellation, "Cycle 1");
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ var act = () => AdvanceUntilCompleteAsync(
+ ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2, WaitForReady: true), output, error, cancellation.Token, time),
+ time);
+
+ await act.Should().ThrowAsync();
+ healthChecks.Should().Be(2);
+ error.ToString().Should().Contain("GET /health timed out.");
+ }
+
+ [Fact]
+ public async Task Container_startup_has_a_bounded_readiness_deadline()
+ {
+ using var client = CreateClient(_ => new(HttpStatusCode.ServiceUnavailable));
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+
+ var act = () => AdvanceUntilCompleteAsync(
+ ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2, WaitForReady: true),
+ output, error, TestContext.Current.CancellationToken, time),
+ time);
+
+ await act.Should().ThrowAsync().WithMessage("*within 30 seconds*");
+ output.ToString().Should().BeEmpty();
+ error.ToString().Should().Contain("Waiting for SliProductionDemo");
+ }
+
+ [Fact]
+ public async Task Unsuccessful_readiness_fails_without_counting_service_outcomes()
+ {
+ using var client = CreateClient(request =>
+ {
+ request.RequestUri!.AbsolutePath.Should().Be("/health");
+ return new(HttpStatusCode.ServiceUnavailable);
+ });
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ var act = () => ScenarioRunner.RunAsync(
+ client, new(client.BaseAddress!, 1, 2), output, error, TestContext.Current.CancellationToken);
+
+ await act.Should().ThrowAsync().WithMessage("*503*");
+ output.ToString().Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData("transport", "an HTTP request")]
+ [InlineData("timeout", "timed out")]
+ [InlineData("unexpected", "InvalidOperationException")]
+ public async Task Console_failures_are_reported_without_an_unhandled_stack_trace(string kind, string diagnostic)
+ {
+ Exception failure = kind switch
+ {
+ "transport" => new HttpRequestException("Simulated transport failure."),
+ "timeout" => new TaskCanceledException("Simulated request timeout."),
+ _ => new InvalidOperationException("Simulated unexpected request failure."),
+ };
+ using var client = CreateClient(request =>
+ request.RequestUri!.AbsolutePath == "/health" ? new(HttpStatusCode.OK) : throw failure);
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var time = new FakeTimeProvider();
+ var run = ScenarioRunner.RunConsoleAsync(
+ client, new(client.BaseAddress!, 30, 10), output, error, TestContext.Current.CancellationToken, time);
+
+ await AdvanceUntilCompleteAsync(run, time);
+
+ (await run).Should().Be(1);
+ error.ToString().Should().Contain("Scenario stopped:").And.Contain(diagnostic)
+ .And.NotContain("Unhandled exception").And.NotContain(" at ");
+ output.ToString().Should().NotContain("Scenario complete.");
+ }
+
+ [Fact]
+ public async Task Console_shutdown_returns_zero_without_reporting_a_timeout()
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ using var client = CreateClient(_ => new(HttpStatusCode.OK));
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ var exitCode = await ScenarioRunner.RunConsoleAsync(
+ client, new(client.BaseAddress!, 30, 10), output, error, cancellation.Token);
+
+ exitCode.Should().Be(0);
+ output.ToString().Should().Contain("Scenario stopped.");
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Only_the_first_stop_signal_suppresses_process_termination()
+ {
+ using var cancellation = new CancellationTokenSource();
+ using var error = new StringWriter();
+ var shutdown = new GeneratorShutdown(cancellation, error);
+ var suppression = new List();
+
+ shutdown.RequestCancellation(suppression.Add);
+ cancellation.IsCancellationRequested.Should().BeTrue();
+ shutdown.RequestCancellation(suppression.Add);
+ shutdown.RequestCancellation(suppression.Add);
+
+ suppression.Should().Equal(true, false, false);
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Concurrent_stop_signals_only_suppress_one_termination()
+ {
+ using var cancellation = new CancellationTokenSource();
+ using var error = new StringWriter();
+ var shutdown = new GeneratorShutdown(cancellation, error);
+ var results = new ConcurrentBag();
+
+ await Task.WhenAll(Enumerable.Range(0, 32)
+ .Select(_ => Task.Run(() => shutdown.RequestCancellation(results.Add), TestContext.Current.CancellationToken)));
+
+ results.Count(suppressed => suppressed).Should().Be(1);
+ cancellation.IsCancellationRequested.Should().BeTrue();
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Signal_suppression_is_set_before_cancellation_callbacks_run()
+ {
+ using var cancellation = new CancellationTokenSource();
+ using var error = new StringWriter();
+ var shutdown = new GeneratorShutdown(cancellation, error);
+ var suppressed = false;
+ bool? observedSuppression = null;
+ using var registration = cancellation.Token.Register(() => observedSuppression = suppressed);
+
+ shutdown.RequestCancellation(value => suppressed = value);
+
+ observedSuppression.Should().BeTrue();
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Cancellation_callback_failures_are_reported_and_do_not_suppress_a_second_signal()
+ {
+ using var cancellation = new CancellationTokenSource();
+ using var error = new StringWriter();
+ var shutdown = new GeneratorShutdown(cancellation, error);
+ var suppressed = false;
+ using var registration = cancellation.Token.Register(() => throw new InvalidOperationException("Broken callback."));
+
+ var first = () => shutdown.RequestCancellation(value => suppressed = value);
+ first.Should().NotThrow();
+ suppressed.Should().BeTrue();
+ cancellation.IsCancellationRequested.Should().BeTrue();
+ error.ToString().Should().Contain("Shutdown cancellation callback failed:")
+ .And.Contain("InvalidOperationException").And.Contain("Broken callback.");
+
+ shutdown.RequestCancellation(value => suppressed = value);
+
+ suppressed.Should().BeFalse();
+ error.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle();
+ }
+
+ [Fact]
+ public async Task A_second_stop_signal_does_not_wait_for_stalled_request_draining()
+ {
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var client = CreateClient((request, _) =>
+ request.RequestUri!.AbsolutePath == "/health"
+ ? Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))
+ : completion.Task);
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ var shutdown = new GeneratorShutdown(cancellation, error);
+ var suppressed = false;
+ var run = ScenarioRunner.RunConsoleAsync(
+ client, new(client.BaseAddress!, 30, 10), output, error, cancellation.Token, new FakeTimeProvider());
+
+ try
+ {
+ shutdown.RequestCancellation(value => suppressed = value);
+ suppressed.Should().BeTrue();
+ run.IsCompleted.Should().BeFalse("the pending handler deliberately ignores cancellation");
+ shutdown.RequestCancellation(value => suppressed = value);
+ suppressed.Should().BeFalse("another signal must permit immediate process termination");
+ }
+ finally
+ {
+ completion.TrySetCanceled(cancellation.Token);
+ }
+
+ (await run.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)).Should().Be(0);
+ error.ToString().Should().BeEmpty();
+ }
+
+ private static async Task AdvanceUntilCompleteAsync(Task run, FakeTimeProvider time)
+ {
+ await AdvanceUntilAsync(() => run.IsCompleted, time);
+ await run;
+ }
+
+ private static async Task AdvanceUntilAsync(Func completed, FakeTimeProvider time, TimeSpan? step = null)
+ {
+ for (var i = 0; i < 6_000 && !completed(); i++)
+ {
+ time.Advance(step ?? TimeSpan.FromMilliseconds(10));
+ // Let timer continuations run before advancing virtual time again.
+ await Task.Factory.StartNew(
+ static () => { },
+ TestContext.Current.CancellationToken,
+ TaskCreationOptions.PreferFairness,
+ TaskScheduler.Default).ConfigureAwait(
+ ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.ForceYielding);
+ }
+
+ completed().Should().BeTrue("the scenario should make progress within bounded virtual-time steps");
+ }
+
+ private static HttpClient CreateClient(Func respond) =>
+ CreateClient((request, _) => Task.FromResult(respond(request)));
+
+ private static HttpClient CreateClient(Func> respond) =>
+ new(new ResponseHandler(respond)) { BaseAddress = new Uri("http://demo.test/") };
+
+ private sealed class ResponseHandler(
+ Func> respond) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return respond(request, cancellationToken);
+ }
+ }
+
+ private sealed class PayloadObservingClient(HttpMessageHandler handler) : HttpClient(handler)
+ {
+ public Task? FirstPayload { get; private set; }
+
+ public override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var send = base.SendAsync(request, cancellationToken);
+ if (request.RequestUri!.AbsolutePath != "/health")
+ FirstPayload ??= send;
+ return send;
+ }
+ }
+
+ private sealed class BackpressureObservingWriter : StringWriter
+ {
+ public TaskCompletionSource LimitReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public override void WriteLine(string? value)
+ {
+ base.WriteLine(value);
+ if (value?.StartsWith("In-flight limit", StringComparison.Ordinal) == true)
+ LimitReached.TrySetResult();
+ }
+ }
+
+ private sealed class CycleCancellingWriter(CancellationTokenSource cancellation, string stopAt) : StringWriter
+ {
+ public override void WriteLine(string? value)
+ {
+ base.WriteLine(value);
+ if (value == stopAt)
+ cancellation.Cancel();
+ }
+ }
+}
diff --git a/sample/GenerateSli/Program.cs b/sample/GenerateSli/Program.cs
index 8601779..bfcd496 100644
--- a/sample/GenerateSli/Program.cs
+++ b/sample/GenerateSli/Program.cs
@@ -1,38 +1,460 @@
-
-Console.WriteLine("Generating SLI started.");
-var t1 = ClientRequests();
-var t2 = ClientRequests();
-var t3 = ClientRequests();
-await Task.WhenAll(t1, t2, t3);
-Console.WriteLine("Generating SLI done.");
-
-static async Task ClientRequests()
-{
- string[] apiUrl = [
- "https://localhost:63936/hello-world?api-version=2023-08-06",
- "https://localhost:63936/hello-world/xavier?api-version=2023-08-06",
- "https://localhost:63936/hello-world/micheal?api-version=2023-08-06",
- "https://localhost:63936/hello-world/xavier?api-version=1996-06-06",
- "https://localhost:63936/hello-world/micheal?api-version=1996-06-06",
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Runtime.ExceptionServices;
+using System.Runtime.InteropServices;
+
+GeneratorOptions options;
+try
+{
+ options = GeneratorOptions.Parse(args);
+}
+catch (ArgumentException ex)
+{
+ Console.Error.WriteLine($"Invalid arguments: {ex.Message}");
+ Console.Error.WriteLine(
+ "Usage: GenerateSli [--base-url URL] " +
+ $"[--phase-seconds 1-{GeneratorOptions.MaximumPhaseSeconds}] " +
+ $"[--requests-per-second 1-{GeneratorOptions.MaximumRequestsPerSecond}] " +
+ "[--continuous] [--wait-for-ready]");
+ return 2;
+}
+
+using var cancellation = new CancellationTokenSource();
+var shutdown = new GeneratorShutdown(cancellation, Console.Error);
+ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
+ shutdown.RequestCancellation(suppress => eventArgs.Cancel = suppress);
+Console.CancelKeyPress += cancelHandler;
+using var termination = PosixSignalRegistration.Create(
+ PosixSignal.SIGTERM, context => shutdown.RequestCancellation(suppress => context.Cancel = suppress));
+
+using var client = new HttpClient
+{
+ BaseAddress = options.BaseAddress,
+ Timeout = TimeSpan.FromSeconds(10),
+};
+
+Console.WriteLine($"SLI scenario -> {client.BaseAddress}");
+Console.WriteLine($"Target: {options.RequestsPerSecond} requests/second, {options.PhaseSeconds} seconds/phase");
+Console.WriteLine(options.Continuous ? "Continuous mode: repeat until stopped." : "Single-cycle mode.");
+
+try
+{
+ return await ScenarioRunner.RunConsoleAsync(client, options, Console.Out, Console.Error, cancellation.Token);
+}
+finally
+{
+ Console.CancelKeyPress -= cancelHandler;
+}
+
+internal sealed class GeneratorShutdown(CancellationTokenSource cancellation, TextWriter error)
+{
+ private int requested;
+
+ public void RequestCancellation(Action setSuppression)
+ {
+ var firstSignal = Interlocked.Exchange(ref requested, 1) == 0;
+ setSuppression(firstSignal);
+ if (!firstSignal)
+ return;
+
+ try
+ {
+ cancellation.Cancel();
+ }
+ catch (AggregateException ex)
+ {
+ foreach (var failure in ex.Flatten().InnerExceptions)
+ error.WriteLine($"Shutdown cancellation callback failed: {failure.GetType().Name}: {failure.Message}");
+ }
+ }
+}
+
+internal static class ScenarioRunner
+{
+ private const int MaximumInFlightRequests = 32;
+ private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromSeconds(30);
+
+ private static readonly Phase[] Phases =
+ [
+ new("Healthy baseline", ScenarioPhase.Healthy),
+ new("Targeted project-red incident", ScenarioPhase.Incident),
+ new("Client-error burst", ScenarioPhase.ClientError),
+ new("Capacity pressure", ScenarioPhase.Throttled),
+ new("Recovery", ScenarioPhase.Recovery),
];
- Random rnd = new Random();
- using var httpClient = new HttpClient();
- for (var i = 1; i <= 200; i++)
+ public static async Task RunConsoleAsync(
+ HttpClient client,
+ GeneratorOptions options,
+ TextWriter output,
+ TextWriter error,
+ CancellationToken cancellationToken,
+ TimeProvider? timeProvider = null)
+ {
+ var run = RunAsync(client, options, output, error, cancellationToken, timeProvider);
+ await run.ConfigureAwait(
+ ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.SuppressThrowing);
+
+ if (run.IsCompletedSuccessfully)
+ return 0;
+
+ if (run.Exception is null && cancellationToken.IsCancellationRequested)
+ {
+ output.WriteLine("Scenario stopped.");
+ return 0;
+ }
+
+ if (run.IsCanceled)
+ {
+ error.WriteLine($"Scenario stopped: an HTTP request to {client.BaseAddress} timed out.");
+ return 1;
+ }
+
+ if (run.Exception is { } fault)
+ {
+ var failure = fault.InnerExceptions[0];
+ error.WriteLine(failure is HttpRequestException
+ ? $"Scenario stopped: an HTTP request to {client.BaseAddress} failed. " +
+ $"Start SliProductionDemo, check --base-url, and try again. {failure.Message}"
+ : $"Scenario stopped: {failure.GetType().Name}: {failure.Message}");
+ return 1;
+ }
+
+ return 0;
+ }
+
+ public static async Task RunAsync(
+ HttpClient client,
+ GeneratorOptions options,
+ TextWriter output,
+ TextWriter error,
+ CancellationToken cancellationToken,
+ TimeProvider? timeProvider = null)
+ {
+ timeProvider ??= TimeProvider.System;
+ error = TextWriter.Synchronized(error);
+ await EnsureReadyAsync(client, options.WaitForReady, error, timeProvider, cancellationToken);
+
+ long sequence = 0;
+ long cycle = 0;
+ do
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ output.WriteLine($"Cycle {++cycle}");
+ foreach (var phase in Phases)
+ {
+ await RunPhaseAsync(
+ client, phase, options, () => Interlocked.Increment(ref sequence),
+ output, error, timeProvider, cancellationToken);
+ }
+ }
+ while (options.Continuous);
+
+ output.WriteLine("Scenario complete.");
+ }
+
+ private static async Task EnsureReadyAsync(
+ HttpClient client,
+ bool waitForReady,
+ TextWriter error,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken)
+ {
+ using var timeout = waitForReady
+ ? new CancellationTokenSource(ReadinessTimeout, timeProvider)
+ : null;
+ using var readiness = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, timeout?.Token ?? CancellationToken.None);
+
+ try
+ {
+ while (true)
+ {
+ try
+ {
+ using var response = await client.GetAsync("health", readiness.Token);
+ if (response.IsSuccessStatusCode)
+ return;
+
+ throw new HttpRequestException(
+ $"Demo readiness failed: GET /health returned HTTP {(int)response.StatusCode}.");
+ }
+ catch (HttpRequestException ex) when (waitForReady && !readiness.IsCancellationRequested)
+ {
+ error.WriteLine($"Waiting for SliProductionDemo: {ex.Message}");
+ }
+ catch (OperationCanceledException) when (waitForReady && !readiness.IsCancellationRequested)
+ {
+ error.WriteLine("Waiting for SliProductionDemo: GET /health timed out.");
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(1), timeProvider, readiness.Token);
+ }
+ }
+ catch (OperationCanceledException ex) when (
+ !cancellationToken.IsCancellationRequested && readiness.IsCancellationRequested)
+ {
+ throw new HttpRequestException($"Demo readiness did not succeed within {ReadinessTimeout.TotalSeconds:0} seconds.", ex);
+ }
+ }
+
+ private static async Task RunPhaseAsync(
+ HttpClient client,
+ Phase phase,
+ GeneratorOptions options,
+ Func nextSequence,
+ TextWriter output,
+ TextWriter error,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken)
{
+ using var phaseCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var requestCounts = new ConcurrentDictionary(StringComparer.Ordinal);
+ var pending = new List(MaximumInFlightRequests);
+ var duration = TimeSpan.FromSeconds(options.PhaseSeconds);
+ var interval = TimeSpan.FromSeconds(1d / options.RequestsPerSecond);
+ var started = timeProvider.GetTimestamp();
+ var nextRequest = TimeSpan.Zero;
+ Task? pacing = null;
+ var reportedBackpressure = false;
+ ExceptionDispatchInfo? failure = null;
+
+ output.WriteLine();
+ output.WriteLine($"[{timeProvider.GetLocalNow():HH:mm:ss}] {phase.Name}");
+
try
{
- await Task.Delay(rnd.Next(500, 3000));
- var response = await httpClient.GetAsync(apiUrl[rnd.Next(apiUrl.Length)]);
+ while (timeProvider.GetElapsedTime(started) < duration || pending.Count > 0)
+ {
+ phaseCancellation.Token.ThrowIfCancellationRequested();
+ for (var i = pending.Count - 1; i >= 0; i--)
+ {
+ if (!pending[i].IsCompleted)
+ continue;
+
+ var completed = pending[i];
+ pending.RemoveAt(i);
+ if (completed.Exception is { } fault)
+ {
+ failure = ExceptionDispatchInfo.Capture(fault.InnerExceptions[0]);
+ break;
+ }
+
+ await completed;
+ }
+
+ if (failure is not null)
+ break;
+
+ var elapsed = timeProvider.GetElapsedTime(started);
+ if (elapsed >= duration || pending.Count == MaximumInFlightRequests)
+ {
+ if (elapsed < duration && !reportedBackpressure)
+ {
+ error.WriteLine(
+ $"In-flight limit ({MaximumInFlightRequests}) reached; slowing traffic until requests complete.");
+ reportedBackpressure = true;
+ }
+
+ if (pending.Count > 0)
+ await Task.WhenAny(pending).WaitAsync(phaseCancellation.Token);
+ continue;
+ }
+
+ if (elapsed < nextRequest)
+ {
+ var remaining = duration - elapsed;
+ var delay = nextRequest - elapsed;
+ pacing ??= Task.Delay(delay < remaining ? delay : remaining, timeProvider, phaseCancellation.Token);
+ if (pending.Count == 0)
+ await pacing;
+ else
+ await Task.WhenAny(pending.Append(pacing));
+ if (pacing.IsCompleted)
+ pacing = null;
+ continue;
+ }
+
+ pacing = null;
+ pending.Add(SendRequestAsync(
+ client, phase.Kind, nextSequence(), options.Continuous, requestCounts, error, phaseCancellation.Token));
+
+ nextRequest += interval;
+ if (nextRequest < elapsed)
+ nextRequest = elapsed + interval;
+ }
+ }
+ catch (HttpRequestException ex)
+ {
+ failure = ExceptionDispatchInfo.Capture(ex);
+ }
+ catch (OperationCanceledException ex)
+ {
+ failure = ExceptionDispatchInfo.Capture(ex);
+ }
+ finally
+ {
+ var stopping = Task.WhenAll(phaseCancellation.CancelAsync(), Task.WhenAll(pending));
+ await stopping.ConfigureAwait(
+ ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.SuppressThrowing);
+ if (stopping.Exception is { } faults)
+ {
+ foreach (var exception in faults.Flatten().InnerExceptions)
+ {
+ if (failure is null)
+ failure = ExceptionDispatchInfo.Capture(exception);
+ else
+ error.WriteLine($"Additional failure while stopping: {exception.GetType().Name}: {exception.Message}");
+ }
+ }
+ else if (stopping.IsCanceled && failure is null)
+ {
+ failure = ExceptionDispatchInfo.Capture(new TaskCanceledException(stopping));
+ }
+ }
- if (response.IsSuccessStatusCode)
- await response.Content.ReadAsStringAsync();
- else
- Console.WriteLine($"Request {i}: Failed with status code {response.StatusCode}");
+ // Shutdown wins over request failures discovered while draining.
+ cancellationToken.ThrowIfCancellationRequested();
+ failure?.Throw();
+
+ var summary = string.Join(
+ ", ",
+ requestCounts.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
+ output.WriteLine($"[{timeProvider.GetLocalNow():HH:mm:ss}] {phase.Name} complete: {summary}");
+ }
+
+ private static async Task SendRequestAsync(
+ HttpClient client,
+ ScenarioPhase phase,
+ long requestNumber,
+ bool continuous,
+ ConcurrentDictionary requestCounts,
+ TextWriter error,
+ CancellationToken cancellationToken)
+ {
+ using var request = ScenarioTraffic.CreateRequest(phase, requestNumber);
+ try
+ {
+ using var response = await client.SendAsync(request, cancellationToken);
+ var status = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
+ requestCounts.AddOrUpdate(status, 1, static (_, count) => count + 1);
+ }
+ catch (HttpRequestException ex) when (continuous && !cancellationToken.IsCancellationRequested)
+ {
+ var count = requestCounts.AddOrUpdate("transport-failures", 1, static (_, count) => count + 1);
+ if (count == 1)
+ error.WriteLine(
+ $"Request {request.RequestUri} failed: {ex.Message} Continuing traffic. " +
+ "Further transport failures are included in this phase's summary.");
}
- catch (Exception ex)
+ catch (OperationCanceledException ex) when (continuous && !cancellationToken.IsCancellationRequested)
{
- Console.WriteLine($"Request {i}: Exception occurred: {ex.Message}");
+ var count = requestCounts.AddOrUpdate("timeouts", 1, static (_, count) => count + 1);
+ if (count == 1)
+ error.WriteLine(
+ $"Request {request.RequestUri} timed out: {ex.Message} Continuing traffic. " +
+ "Further timeouts are included in this phase's summary.");
}
}
}
+
+internal static class ScenarioTraffic
+{
+ private static readonly string[] Projects = ["project-blue", "project-green", "project-red"];
+
+ public static HttpRequestMessage CreateRequest(ScenarioPhase phase, long requestNumber)
+ {
+ var projectId = Projects[requestNumber % Projects.Length];
+ return requestNumber % 4 == 0
+ ? new(HttpMethod.Get, $"api/projects/{projectId}")
+ : new(HttpMethod.Post, $"api/projects/{projectId}/sync?mode={SelectMode(phase, projectId, requestNumber)}");
+ }
+
+ private static string SelectMode(ScenarioPhase phase, string projectId, long requestNumber)
+ {
+ if (requestNumber % 5 == 0)
+ return "healthy";
+
+ return phase switch
+ {
+ ScenarioPhase.Incident when projectId == "project-red" =>
+ requestNumber % 7 == 0 ? "failure" : "slow",
+ ScenarioPhase.ClientError when projectId == "project-blue" && requestNumber % 2 == 0 => "client-error",
+ ScenarioPhase.Throttled when projectId == "project-red" => "throttled",
+ _ => "healthy",
+ };
+ }
+}
+
+internal sealed record GeneratorOptions(
+ Uri BaseAddress, int PhaseSeconds, int RequestsPerSecond, bool Continuous = false, bool WaitForReady = false)
+{
+ public const int MaximumPhaseSeconds = 3_600;
+ public const int MaximumRequestsPerSecond = 1_000;
+
+ public static GeneratorOptions Parse(string[] args)
+ {
+ var baseUrl = "http://localhost:5199/";
+ var phaseSeconds = 30;
+ var requestsPerSecond = 12;
+ var continuous = false;
+ var waitForReady = false;
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--base-url":
+ baseUrl = ReadValue(args, ref i);
+ break;
+ case "--phase-seconds":
+ phaseSeconds = ParsePositiveInt("--phase-seconds", ReadValue(args, ref i), MaximumPhaseSeconds);
+ break;
+ case "--requests-per-second":
+ requestsPerSecond = ParsePositiveInt("--requests-per-second", ReadValue(args, ref i), MaximumRequestsPerSecond);
+ break;
+ case "--continuous":
+ continuous = true;
+ break;
+ case "--wait-for-ready":
+ waitForReady = true;
+ break;
+ default:
+ throw new ArgumentException($"Unknown argument '{args[i]}'.");
+ }
+ }
+
+ if (!baseUrl.EndsWith('/'))
+ baseUrl += "/";
+
+ if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var address) ||
+ (address.Scheme != Uri.UriSchemeHttp && address.Scheme != Uri.UriSchemeHttps))
+ throw new ArgumentException("--base-url must be an absolute HTTP or HTTPS URL.");
+
+ return new GeneratorOptions(address, phaseSeconds, requestsPerSecond, continuous, waitForReady);
+ }
+
+ private static string ReadValue(string[] args, ref int index)
+ {
+ if (++index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal))
+ throw new ArgumentException($"Missing value for '{args[index - 1]}'.");
+
+ return args[index];
+ }
+
+ private static int ParsePositiveInt(string name, string value, int maximum) =>
+ int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed > 0 && parsed <= maximum
+ ? parsed
+ : throw new ArgumentException($"{name} must contain only digits and be between 1 and {maximum}.");
+}
+
+internal sealed record Phase(string Name, ScenarioPhase Kind);
+
+internal enum ScenarioPhase
+{
+ Healthy,
+ Incident,
+ ClientError,
+ Throttled,
+ Recovery,
+}
diff --git a/sample/MinApi/Api.http b/sample/MinApi/Api.http
index 135fb83..a779bbc 100644
--- a/sample/MinApi/Api.http
+++ b/sample/MinApi/Api.http
@@ -1,21 +1,16 @@
-@HostAddress = https://localhost:6001
+@HostAddress = http://localhost:5201
-//Weather
-GET {{HostAddress}}/weatherforecast/Redmond
+### Named operation with a stable target resource
+GET {{HostAddress}}/projects/project-blue
Accept: application/json
-###
-
-// User
-
-// Hello World
-GET {{HostAddress}}/users/
+### Bounded SyncMode dimension; quick and standard usually meet the 250 ms threshold
+POST {{HostAddress}}/projects/project-blue/sync/quick
Accept: application/json
-###
-
-// Hello World Xavier
-GET {{HostAddress}}/users/Xavier
+### Slow success: availability succeeds, latency threshold is exceeded
+POST {{HostAddress}}/projects/project-red/sync/slow
Accept: application/json
-###
+### Health is not measured
+GET {{HostAddress}}/health
diff --git a/sample/MinApi/Program.cs b/sample/MinApi/Program.cs
index 9a168e8..a42ab3b 100644
--- a/sample/MinApi/Program.cs
+++ b/sample/MinApi/Program.cs
@@ -1,83 +1,57 @@
-using Azure.Core;
-using OpenTelemetry.Metrics;
+using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
-using SampleMinimalApiSli;
using Scalar.AspNetCore;
using Trellis.ServiceLevelIndicators;
var builder = WebApplication.CreateBuilder(args);
-// Configure the HTTP request pipeline.
-
-var summaries = new[]
-{
- "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
-};
-
builder.Services.AddOpenApi();
-
-// Build a resource configuration action to set service information.
-
-Action configureResource = r => r.AddService(
- serviceName: "SampleMinimalApiSli",
- serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown");
-
builder.Services.AddOpenTelemetry()
- .ConfigureResource(configureResource)
- .WithMetrics(builder =>
+ .ConfigureResource(resource => resource.AddService("SampleMinimalApiSli"))
+ .WithMetrics(metrics =>
{
- builder.AddServiceLevelIndicatorInstrumentation();
- builder.AddOtlpExporter();
+ metrics.AddServiceLevelIndicatorInstrumentation();
+ metrics.AddOtlpExporter();
});
builder.Services.AddServiceLevelIndicator(options =>
{
- options.CustomerResourceId = "SampleCustomerResourceId";
- options.LocationId = ServiceLevelIndicator.CreateLocationId("public", AzureLocation.WestUS3.Name);
+ options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
+ options.AutomaticallyEmitted = false;
+ options.LatencyThresholds.Add(TimeSpan.FromMilliseconds(250));
});
-// Add services to the container.
-
var app = builder.Build();
+app.UseServiceLevelIndicator();
+
+var projects = app.MapGroup("/projects/{projectId:regex(^project-(blue|green|red)$)}");
-app.MapGet(
- "/weatherforecast/{cid}",
- ([CustomerResourceId] string cid) =>
- Enumerable.Range(1, 5).Select(index =>
- new WeatherForecast
- (
- DateTime.Now.AddDays(index),
- Random.Shared.Next(-20, 55),
- summaries[Random.Shared.Next(summaries.Length)]
- )))
- .AddServiceLevelIndicator();
+projects.MapGet("/", ([CustomerResourceId] string projectId) => Results.Ok(new { projectId }))
+ .AddServiceLevelIndicator("GetProject");
-app.MapGet(
- "/background/{workType:regex(^(Quick|Standard|Slow)$)}",
- ([Measure("WorkType")] string workType) => Task.Delay(workType switch
+projects.MapPost("/sync/{mode:regex(^(quick|standard|slow)$)}",
+ async ([CustomerResourceId] string projectId, [Measure("SyncMode")] string mode,
+ CancellationToken cancellationToken) =>
+ {
+ var delay = mode switch
{
- nameof(WorkType.Quick) => TimeSpan.FromMilliseconds(250),
- nameof(WorkType.Standard) => TimeSpan.FromSeconds(1),
- nameof(WorkType.Slow) => TimeSpan.FromSeconds(2),
- _ => TimeSpan.FromSeconds(1)
- }))
- .AddServiceLevelIndicator("background_work");
+ "quick" => 50,
+ "standard" => 150,
+ "slow" => 400,
+ _ => throw new ArgumentOutOfRangeException(nameof(mode))
+ };
+ await Task.Delay(delay, cancellationToken);
+ return Results.Ok(new { projectId, mode });
+ })
+ .AddServiceLevelIndicator("SyncProject");
+
+app.MapGet("/health", () => Results.Ok());
-app.UseUserRoute();
app.MapOpenApi();
app.MapScalarApiReference();
-app.UseHttpsRedirection();
-app.UseServiceLevelIndicator();
app.Run();
-internal record WeatherForecast(DateTime Date, int TemperatureC, string? Summary)
-{
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
-}
-
-internal enum WorkType
-{
- Quick,
- Standard,
- Slow
-}
+///
+/// Application entry point for integration tests.
+///
+public partial class Program;
diff --git a/sample/MinApi/Properties/launchSettings.json b/sample/MinApi/Properties/launchSettings.json
index a940696..c320ab0 100644
--- a/sample/MinApi/Properties/launchSettings.json
+++ b/sample/MinApi/Properties/launchSettings.json
@@ -2,12 +2,12 @@
"profiles": {
"SampleMinimalApiSli": {
"commandName": "Project",
- "launchBrowser": true,
+ "launchBrowser": false,
"launchUrl": "scalar/v1",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
- "applicationUrl": "https://localhost:6001;http://localhost:6000"
+ "applicationUrl": "http://localhost:5201"
}
}
}
\ No newline at end of file
diff --git a/sample/MinApi/SampleMinimalApiSli.csproj b/sample/MinApi/SampleMinimalApiSli.csproj
index f0091bd..fe49b6b 100644
--- a/sample/MinApi/SampleMinimalApiSli.csproj
+++ b/sample/MinApi/SampleMinimalApiSli.csproj
@@ -4,7 +4,6 @@
false
-
diff --git a/sample/MinApi/UserExt.cs b/sample/MinApi/UserExt.cs
deleted file mode 100644
index 1354835..0000000
--- a/sample/MinApi/UserExt.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace SampleMinimalApiSli;
-
-using Trellis.ServiceLevelIndicators;
-
-///
-/// User API routes
-///
-public static class UserExt
-{
- ///
- /// Use User Route
- ///
- ///
- public static void UseUserRoute(this WebApplication app)
- {
- var userApi = app.MapGroup("/users")
- .AddServiceLevelIndicator();
-
- userApi.MapGet("/", () => "Hello Users");
-
- userApi.MapGet("/{name}", (string name) => $"Hello {name}").WithName("GetUserById");
- }
-
-}
diff --git a/sample/Observability/Grafana/Dockerfile b/sample/Observability/Grafana/Dockerfile
new file mode 100644
index 0000000..bd991ea
--- /dev/null
+++ b/sample/Observability/Grafana/Dockerfile
@@ -0,0 +1,37 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS source
+WORKDIR /src
+COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config version.json ./
+
+FROM source AS publish-api
+COPY Trellis.ServiceLevelIndicators/src/Trellis.ServiceLevelIndicators.csproj Trellis.ServiceLevelIndicators/src/
+COPY Trellis.ServiceLevelIndicators.Asp/src/Trellis.ServiceLevelIndicators.Asp.csproj Trellis.ServiceLevelIndicators.Asp/src/
+COPY sample/SliProductionDemo/SliProductionDemo.csproj sample/SliProductionDemo/
+RUN dotnet restore sample/SliProductionDemo/SliProductionDemo.csproj
+COPY .editorconfig icon.png ./
+COPY docs/api_reference/trellis-api-sli.md docs/api_reference/trellis-api-sli-asp.md docs/api_reference/
+COPY Trellis.ServiceLevelIndicators/src/ Trellis.ServiceLevelIndicators/src/
+COPY Trellis.ServiceLevelIndicators.Asp/src/ Trellis.ServiceLevelIndicators.Asp/src/
+COPY sample/SliProductionDemo/ sample/SliProductionDemo/
+RUN dotnet publish sample/SliProductionDemo/SliProductionDemo.csproj \
+ --configuration Release --no-restore --output /out/api -p:UseAppHost=false
+
+FROM source AS publish-traffic
+COPY sample/GenerateSli/GenerateSli.csproj sample/GenerateSli/
+RUN dotnet restore sample/GenerateSli/GenerateSli.csproj
+COPY .editorconfig icon.png ./
+COPY sample/GenerateSli/ sample/GenerateSli/
+RUN dotnet publish sample/GenerateSli/GenerateSli.csproj \
+ --configuration Release --no-restore --output /out/traffic -p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS api
+WORKDIR /app
+COPY --from=publish-api /out/api .
+USER $APP_UID
+EXPOSE 8080
+ENTRYPOINT ["dotnet", "SliProductionDemo.dll"]
+
+FROM mcr.microsoft.com/dotnet/runtime:10.0 AS traffic
+WORKDIR /app
+COPY --from=publish-traffic /out/traffic .
+USER $APP_UID
+ENTRYPOINT ["dotnet", "GenerateSli.dll"]
diff --git a/sample/Observability/Grafana/Dockerfile.dockerignore b/sample/Observability/Grafana/Dockerfile.dockerignore
new file mode 100644
index 0000000..1144ea7
--- /dev/null
+++ b/sample/Observability/Grafana/Dockerfile.dockerignore
@@ -0,0 +1,17 @@
+.git
+.github
+.worktrees
+**/.worktrees
+.vs
+**/bin
+**/obj
+**/TestResults
+**/artifacts
+**/*.user
+**/*.nupkg
+**/*.snupkg
+docs/**
+!docs/api_reference
+docs/api_reference/**
+!docs/api_reference/trellis-api-sli.md
+!docs/api_reference/trellis-api-sli-asp.md
diff --git a/sample/Observability/Grafana/README.md b/sample/Observability/Grafana/README.md
index d763b3f..6992dd6 100644
--- a/sample/Observability/Grafana/README.md
+++ b/sample/Observability/Grafana/README.md
@@ -1,64 +1,192 @@
-# Local SLI Grafana dashboard
+# Always-on SLI demo
-This sample runs a local OpenTelemetry Collector, Prometheus, and Grafana stack so you can see the value of the SLI library while running one of the sample applications.
+See what Trellis SLI adds beyond generic HTTP request metrics: identify an unhealthy business operation, isolate the affected customer resource, and distinguish availability failures from slow successful operations.
-## Start the dashboard stack
+The demo behaves like a small running service. It sends real HTTP traffic to a Project Tracker API continuously, keeps healthy background traffic flowing for every project, and automatically repeats localized incidents and recovery. Measurements pass through the real pipeline:
-From this directory:
+```text
+HTTP traffic -> Project Tracker API + Trellis SLI -> OTLP Collector -> Prometheus -> Grafana
+```
+
+There is no external application or downstream service to configure. The operating conditions are simulated; the emitted SLI measurements are real.
+
+## Start everything with one command
+
+You need Docker Engine 25 or newer with Linux containers and Docker Compose v2.20.2 or newer. No local .NET SDK is required. The first build downloads the .NET images and restores the sample's NuGet dependencies; subsequent starts use cached images. Restores are cached separately, and each image copies only its own project graph so unrelated source edits do not rebuild both images.
+
+From `sample\Observability\Grafana`:
```powershell
-docker compose up -d
+docker compose --profile demo up --build -d
```
-Grafana starts at http://localhost:3000 with anonymous admin access enabled for local development. The SLI dashboard is provisioned automatically under **Dashboards > Trellis > Service Level Indicators**.
+Open the [live SLI dashboard](http://localhost:3000/d/trellis-sli?var-operation=SyncProject). Allow a few seconds after startup for the first metric exports and scrapes.
+
+
+
+*Actual emitted metrics from simulated incidents: red degrades while blue and green remain healthy.*
+
+The command starts five services:
+
+| Service | Purpose |
+|---|---|
+| `demo-api` | Project Tracker API at http://localhost:5199, instrumented with Trellis SLI |
+| `demo-traffic` | Continuous HTTP traffic and recurring incidents |
+| `otel-collector` | Receives the API's OTLP metrics |
+| `prometheus` | Scrapes and stores the exported metrics |
+| `grafana` | Provisioned dashboard at http://localhost:3000 |
+
+Compose starts `demo-traffic` only after the API's HTTP `/health` probe succeeds. The probe receives an explicit URL; it never starts a host or tries to interpret Kestrel configuration. The `SLI_DEMO_PORT` environment variable defaults to `8080` and supplies the API binding, probe URL, published-port target, and generator URL together. Set it before running Compose to change the internal port; the public API address remains http://localhost:5199. Probes use the existing .NET runtime, not an extra curl dependency, and run every five seconds during startup and every 30 seconds afterward.
+
+The Docker `demo` profile is optional: plain `docker compose up -d` still starts only the observability stack for use with other samples. All published ports are bound to loopback. Grafana permits anonymous admin access for this local demo; do not expose this configuration as a production deployment.
+
+All five services rotate their JSON logs at 10 MB and retain at most three files per service. This bounds container log storage during a prolonged incident.
+
+Prometheus retention is configured for 24 hours or 256 MB, whichever triggers first, rather than its default 15 days. Retention deletes persisted blocks in the background; the active head, write-ahead log, and compaction can require additional space, so this is not a hard disk quota.
+
+## Explore it like a service owner
+
+Start with **Operation = SyncProject**, **CustomerResourceId = All**, and a recent five-minute time range.
-
+| Question | What to inspect | What the library makes visible |
+|---|---|---|
+| Is every customer affected? | **Availability by project** and **Within 250 ms by project** | `project-red` degrades while the other projects remain healthy. Aggregate service metrics can dilute this difference. |
+| Are successful requests still too slow? | **Within 250 ms** versus **Success rate** | Slow 200 responses hurt latency compliance without being availability failures. Histogram percentiles are useful diagnostics, not the exact threshold SLI. |
+| Are caller mistakes being counted as outages? | Blue project's 400/`ClientError` volume alongside its SLIs | Caller errors are visible but excluded from both SLO populations; healthy blue requests continue. |
+| Is a fast response necessarily good service? | Red project's capacity-pressure phase | Fast 429 responses hurt availability even when they meet the latency threshold. |
+| Is the problem specific to one operation? | Compare **SyncProject** with **GetProject** | Named operations expose the slow sync path while project reads remain healthy. |
-## Run a sample app
+Then select **CustomerResourceId = project-red** to inspect its current availability and latency cards. Switch to `project-green` for a healthy control, or `project-blue` to investigate caller errors. Returning the customer filter to **All** restores the side-by-side comparison.
-In another terminal, run the Web API sample and point its OTLP exporter at the local collector:
+### The recurring workload
+
+Each phase lasts approximately 30 seconds. The five-phase cycle takes roughly two and a half minutes, plus time to drain in-flight requests, and repeats until you stop it. It does not leave the dashboard idle after recovery.
+
+| Phase | Traffic | Expected SLI effect |
+|---|---|---|
+| Healthy baseline | Fast 200 responses across all three projects | Availability and latency compliance settle near 100%. |
+| Targeted project-red incident | Slow 200s and delayed 503s mixed with healthy red traffic | Red's latency compliance and availability fall; other projects stay healthy. |
+| Client-error burst | Some blue sync requests return 400; healthy blue traffic continues | Blue's `ClientError` volume rises, but its SLOs remain healthy. |
+| Capacity pressure | Some red sync requests return fast 429s; healthy red traffic continues | Red's availability falls while latency can remain within 250 ms. |
+| Recovery | Fast 200 responses across all three projects | Both SLIs recover before the next cycle. |
+
+Reads continue throughout. Every phase also retains healthy sync requests for every project. Operating modes and affected projects are repeatable; the API independently selects each request's delay from that mode's fixed palette. The generator schedules against elapsed time for a target rate of 12 requests/second, not an exact throughput guarantee or a load benchmark.
+
+At most 32 requests can be in flight. If the API hangs or a higher configured rate reaches that limit, the generator logs backpressure and slows down until requests complete. Completed tasks are retired, and missed send slots are skipped rather than queued for a catch-up burst.
+
+To see the current phase and HTTP response counts:
```powershell
-$env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4317"
-dotnet run --project ..\..\WebApi\SampleWebApplicationSLI.csproj
+docker compose --profile demo logs -f demo-traffic
```
-Generate traffic:
+Ctrl+C exits the log viewer; it does not stop the running demo.
+
+In continuous mode, the first transport failure and first timeout in each phase are logged immediately. Further failures are counted without repeating individual diagnostics; phase summaries retain every `transport-failures` and `timeouts` count. The generator keeps running and resumes successful traffic when the API recovers. It adds no application-level retries or circuit breakers and leaves the intentional 429/503 responses unchanged. These client-side counts do not fabricate server SLI observations; use outside-in checks to detect an unreachable service.
+
+### What the two SLIs mean
+
+All availability and latency-compliance panels use **only `Success` and `Failure`** operations. `ClientError` and `Ignored` do not enter either denominator, regardless of the **Outcome** dropdown:
+
+- **Availability:** `Success / (Success + Failure)`, with an illustrative objective of at least 99%.
+- **Latency:** operations completed in **250 ms or less** divided by all eligible operations, also with an illustrative 99% objective. A fast failure can meet the latency objective while still hurting availability.
+
+The latency panels use the opt-in threshold counter's `within` and `exceeded` counts, not estimated histogram quantiles. The comparison to 250 ms is inclusive. The displayed recent percentages use Prometheus `rate`; p50/p95/p99 remain diagnostic estimates across all outcomes.
+
+The **Outcome** dropdown filters request volume, not the SLO population. Percentage cards use the current rate window, not an older healthy value. If traffic stops or a selection has only excluded outcomes, they show **No data**, not 100% compliance.
+
+The API exports every second, the Collector batches for up to one second, and Prometheus scrapes and Grafana refreshes every two seconds. Percentages also use a rolling rate window, so previous-phase traffic must age out before a phase settles. Keep the five-minute dashboard range while learning the demo; longer ranges can blend phases.
+
+Timing includes real processing and scheduling, so startup warm-up and local machine load can affect the readings. These objectives are illustrative, not guarantees from the library.
+
+## Connect the dashboard to the code
+
+The demo uses three stable project IDs: `project-blue`, `project-green`, and `project-red`. Unknown IDs return 404 under the library's fixed `Unknown` dimension, never the arbitrary rejected ID. These requests also increment the unknown-customer diagnostic counter. `/health` is explicitly excluded from SLI measurement.
+
+The default workload uses only known projects, so the **Unknown customer diagnostics** panel normally has no activity. To exercise it, send `GET http://localhost:5199/api/projects/missing-project` twice, at least five seconds apart so Prometheus can observe a counter increase. The requests remain `ClientError` operations and are excluded from both SLO populations.
+
+| Source | What to look for |
+|---|---|
+| [`Program.cs`](../../SliProductionDemo/Program.cs) | `AddServiceLevelIndicator`, OpenTelemetry registration, a 250 ms threshold, location, and health exclusion |
+| [`ProjectsController.cs`](../../SliProductionDemo/Controllers/ProjectsController.cs) | Named `GetProject`/`SyncProject` operations and `[CustomerResourceId]` on the target project |
+| [`GenerateSli/Program.cs`](../../GenerateSli/Program.cs) | Deterministic traffic, recurring incidents, readiness, and graceful cancellation |
+
+The API opts into exact threshold counting with:
+
+```csharp
+options.LatencyThresholds.Add(TimeSpan.FromMilliseconds(250));
+```
+
+Grafana queries the library's existing metric contract:
+
+- `operation.duration`, exported as `operation_duration_milliseconds_*`
+- `operation.duration.threshold.count`, exported as `operation_duration_threshold_count_total`
+- dimensions `CustomerResourceId`, `LocationId`, `Operation`, and `Outcome`
+- threshold labels `sli_latency_threshold_ms` and `sli_latency_result`
+- histogram-only HTTP method and response-status dimensions
+
+No request IDs, timestamps, raw error messages, or other unbounded values are used as SLI dimensions.
+
+## Stop or reset the demo
+
+```powershell
+docker compose --profile demo down
+```
+
+This stops the API, generator, and observability services. Prometheus and Grafana data volumes are retained. For a clean start that **deletes this Compose project's saved metrics and Grafana state**, use:
```powershell
-Invoke-RestMethod https://localhost:63936/WeatherForecast -SkipCertificateCheck
-Invoke-RestMethod https://localhost:63936/WeatherForecast/MyAction1 -SkipCertificateCheck
-Invoke-RestMethod https://localhost:63936/WeatherForecast/MyAction2 -SkipCertificateCheck
-Invoke-RestMethod https://localhost:63936/WeatherForecast/my-customer-resource-id -SkipCertificateCheck
+docker compose --profile demo down --volumes
```
-You can also run the Minimal API or API-versioned samples with the same `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable.
+All services, including the standalone observability stack, restart after an unexpected exit or Docker/host restart. Stopping them with Compose prevents automatic restart until you start them again.
+
+## Troubleshooting
+
+- **A port is already in use:** stop an earlier copy of the stack or a locally running sample API. The demo uses localhost ports 3000, 4317, 4318, 5199, 9090, and 9464.
+- **No metrics after startup:** check `docker compose --profile demo ps` and `docker compose --profile demo logs demo-api demo-traffic otel-collector`. Compose waits for `demo-api` to become healthy before starting traffic. The generator also keeps a bounded 30-second readiness check for local runs and startup races after an API restart.
+- **The API stays unhealthy:** inspect `docker compose --profile demo logs demo-api` and its healthcheck output with `docker inspect`. Compose starts `demo-traffic` only after `demo-api` is healthy. If the API stays unhealthy after its 30-second start period and three 30-second retries, `docker compose --profile demo up` stops with `dependency failed to start`, and `demo-traffic` is not started. Fix the API, then run the command again.
+- **The API restarts during a run:** continuous traffic logs and counts transport failures and timeouts without exiting. Successful requests resume when the API is reachable again; no generator restart is needed.
+- **No data after changing filters:** select `SyncProject` and one of the three known projects, or reset customer/location/outcome filters to **All**. Check that `demo-traffic` is running and keep a recent time range.
+- **The first build fails to download dependencies:** confirm Docker can reach Microsoft's container registry and NuGet.org.
+
+## Optional: run the API and generator locally
-## What the dashboard shows
+For debugging, install the .NET 10 SDK, start only the observability stack with `docker compose up -d`, and use two additional terminals in this directory. Do not also start the Docker `demo` profile.
-The dashboard uses the SLI metric contract:
+API:
+
+```powershell
+$env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4317"
+$env:OTEL_METRIC_EXPORT_INTERVAL = "1000"
+$env:ASPNETCORE_URLS = "http://localhost:5199"
+dotnet run --no-launch-profile --project ..\..\SliProductionDemo\SliProductionDemo.csproj
+```
-- `operation.duration` histogram exported to Prometheus as `operation_duration_milliseconds_*`
-- `CustomerResourceId`
-- `LocationId`
-- `Operation`
-- `Outcome`
-- `http.request.method`
-- `http.response.status.code`
-- optional `http.api.version`
+To check the same API, pass its reachable health URL explicitly:
-Panels include:
+```powershell
+dotnet run --no-launch-profile --project ..\..\SliProductionDemo\SliProductionDemo.csproj -- `
+ --health-check http://localhost:5199/health
+```
-- request volume by operation and outcome
-- p50/p95/p99 latency
-- success rate using `Success / (Success + Failure)`
-- failure and client-error rates
-- HTTP status-code breakdown
-- unknown customer diagnostics
-- `` operation detection
+Probe mode accepts only `--health-check ` and never loads host configuration. If you customize Kestrel, point the probe at a reachable HTTP or HTTPS listener instead of passing host arguments. HTTPS uses normal certificate validation. A successful response exits 0; HTTP failures, connection errors, and timeouts exit 1; invalid probe arguments exit 2.
-## Stop the stack
+Traffic:
```powershell
-docker compose down
+dotnet run --project ..\..\GenerateSli\GenerateSli.csproj -- `
+ --base-url http://localhost:5199 `
+ --phase-seconds 30 `
+ --requests-per-second 12 `
+ --continuous `
+ --wait-for-ready
```
+
+Omit `--continuous` for a single five-phase run. Its first request transport failure or timeout stops further sends, cancels/drains outstanding requests, and exits with code 1. Requested shutdown takes precedence over request errors while draining. Secondary cleanup failures are reported without replacing the initiating failure; unexpected request errors also produce a concise diagnostic and exit code 1. Invalid options print a concise error and usage with exit code 2.
+
+Numeric arguments accept digits only: `--requests-per-second` is 1-1000 and `--phase-seconds` is 1-3600. Their defaults remain 12 and 30; continuous mode can repeat indefinitely.
+
+Ctrl+C stops either local process gracefully. A second Ctrl+C forces the generator to exit if graceful request draining is stuck; a second SIGTERM also permits termination on Linux and macOS. Windows SIGTERM cannot be canceled by .NET; use Ctrl+C for graceful native Windows shutdown. Stop both processes before running `docker compose down`.
+
+The [focused Minimal API and API-versioned examples](../../../docs/usage-reference.md#running-the-focused-examples) can reuse this stack and already configure the 250 ms threshold. Their `Api.http` files contain example requests; this traffic generator targets only the continuous demo's Project Tracker API. The dashboard aggregates matching SLI dimensions across services, so stop the focused examples when exploring the continuous demo in isolation.
diff --git a/sample/Observability/Grafana/docker-compose.yml b/sample/Observability/Grafana/docker-compose.yml
index 2754088..f3fc2da 100644
--- a/sample/Observability/Grafana/docker-compose.yml
+++ b/sample/Observability/Grafana/docker-compose.yml
@@ -1,44 +1,100 @@
+x-logging: &bounded-logging
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.123.0
+ restart: unless-stopped
+ logging: *bounded-logging
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro
ports:
- - "4317:4317"
- - "4318:4318"
- - "9464:9464"
+ - "127.0.0.1:4317:4317"
+ - "127.0.0.1:4318:4318"
+ - "127.0.0.1:9464:9464"
prometheus:
image: prom/prometheus:v3.3.0
+ restart: unless-stopped
+ logging: *bounded-logging
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
+ - "--storage.tsdb.retention.time=24h"
+ - "--storage.tsdb.retention.size=256MB"
- "--web.enable-lifecycle"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
ports:
- - "9090:9090"
+ - "127.0.0.1:9090:9090"
depends_on:
- otel-collector
grafana:
image: grafana/grafana:11.6.0
+ restart: unless-stopped
+ logging: *bounded-logging
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin"
GF_AUTH_DISABLE_LOGIN_FORM: "true"
- GF_USERS_DEFAULT_THEME: "light"
+ GF_DASHBOARDS_MIN_REFRESH_INTERVAL: "2s"
+ GF_USERS_DEFAULT_THEME: "dark"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
ports:
- - "3000:3000"
+ - "127.0.0.1:3000:3000"
depends_on:
- prometheus
+ demo-api:
+ profiles: ["demo"]
+ build:
+ context: ../../..
+ dockerfile: sample/Observability/Grafana/Dockerfile
+ target: api
+ restart: unless-stopped
+ logging: *bounded-logging
+ environment:
+ ASPNETCORE_URLS: "http://+:${SLI_DEMO_PORT:-8080}"
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317"
+ OTEL_METRIC_EXPORT_INTERVAL: "1000"
+ ports:
+ - "127.0.0.1:5199:${SLI_DEMO_PORT:-8080}"
+ healthcheck:
+ test: ["CMD", "dotnet", "SliProductionDemo.dll", "--health-check", "http://127.0.0.1:${SLI_DEMO_PORT:-8080}/health"]
+ interval: 30s
+ start_interval: 5s
+ timeout: 5s
+ start_period: 30s
+ retries: 3
+ depends_on:
+ - otel-collector
+
+ demo-traffic:
+ profiles: ["demo"]
+ build:
+ context: ../../..
+ dockerfile: sample/Observability/Grafana/Dockerfile
+ target: traffic
+ restart: unless-stopped
+ logging: *bounded-logging
+ command:
+ - "--base-url"
+ - "http://demo-api:${SLI_DEMO_PORT:-8080}"
+ - "--continuous"
+ - "--wait-for-ready"
+ depends_on:
+ demo-api:
+ condition: service_healthy
+
volumes:
prometheus-data:
grafana-data:
diff --git a/sample/Observability/Grafana/grafana/dashboards/sli-dashboard.json b/sample/Observability/Grafana/grafana/dashboards/sli-dashboard.json
index 51a5af4..6c6c7af 100644
--- a/sample/Observability/Grafana/grafana/dashboards/sli-dashboard.json
+++ b/sample/Observability/Grafana/grafana/dashboards/sli-dashboard.json
@@ -21,6 +21,22 @@
"id": null,
"links": [],
"panels": [
+ {
+ "gridPos": {
+ "h": 4,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 9,
+ "options": {
+ "content": "### Which operation and customer are actually unhealthy?\nCompare **SyncProject** across **All** customers. Select **project-red** to investigate, or **project-green** as a healthy control.\n\nContinuous traffic. Recurring incidents. Slow 200s hurt latency; fast 429s hurt availability. SLOs use **Success + Failure**, regardless of the Outcome filter. Objectives are illustrative.",
+ "mode": "markdown"
+ },
+ "pluginVersion": "11.6.0",
+ "title": "Read the demo like a service owner",
+ "type": "text"
+ },
{
"datasource": {
"type": "prometheus",
@@ -59,12 +75,13 @@
"h": 8,
"w": 6,
"x": 0,
- "y": 0
+ "y": 4
},
"id": 1,
+ "description": "Availability: Success / (Success + Failure). ClientError and Ignored are excluded, regardless of the Outcome filter.",
"options": {
"colorMode": "value",
- "graphMode": "area",
+ "graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
@@ -87,13 +104,91 @@
"uid": "prometheus"
},
"editorMode": "code",
- "expr": "100 * sum(rate(operation_duration_milliseconds_count{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=\"Success\"}[$__rate_interval])) / sum(rate(operation_duration_milliseconds_count{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\"}[$__rate_interval]))",
+ "expr": "100 * (sum(rate(operation_duration_milliseconds_count{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=\"Success\"}[$__rate_interval])) or vector(0)) / (sum(rate(operation_duration_milliseconds_count{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\"}[$__rate_interval])) > 0)",
"legendFormat": "success rate",
- "range": true,
+ "instant": true,
+ "range": false,
+ "refId": "A"
+ }
+ ],
+ "title": "Success rate (objective: >= 99%)",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "description": "Exact latency SLI: fraction of Success or Failure operations completed in <= 250 ms. ClientError and Ignored are excluded. Uses the opt-in threshold counter, not histogram interpolation. The Outcome filter does not change the SLO population.",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "max": 100,
+ "min": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "red",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 95
+ },
+ {
+ "color": "green",
+ "value": 99
+ }
+ ]
+ },
+ "unit": "percent"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 6,
+ "y": 4
+ },
+ "id": 8,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "percentChangeColorMode": "standard",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "showPercentChange": false,
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.6.0",
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "editorMode": "code",
+ "expr": "100 * (sum(rate(operation_duration_threshold_count_total{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\",sli_latency_threshold_ms=\"250\",sli_latency_result=\"within\"}[$__rate_interval])) or vector(0)) / (sum(rate(operation_duration_threshold_count_total{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\",sli_latency_threshold_ms=\"250\"}[$__rate_interval])) > 0)",
+ "legendFormat": "within 250 ms",
+ "instant": true,
+ "range": false,
"refId": "A"
}
],
- "title": "Success rate",
+ "title": "Within 250 ms (objective: >= 99%)",
"type": "stat"
},
{
@@ -136,7 +231,7 @@
"mode": "none"
},
"thresholdsStyle": {
- "mode": "off"
+ "mode": "line"
}
},
"mappings": [],
@@ -146,6 +241,10 @@
{
"color": "green",
"value": null
+ },
+ {
+ "color": "red",
+ "value": 250
}
]
},
@@ -155,11 +254,12 @@
},
"gridPos": {
"h": 8,
- "w": 18,
- "x": 6,
- "y": 0
+ "w": 12,
+ "x": 12,
+ "y": 4
},
"id": 2,
+ "description": "Approximate histogram percentiles across all outcomes, for diagnosis. The 250 ms line is a reference; the exact latency SLI is the Within 250 ms panel.",
"options": {
"legend": {
"calcs": [
@@ -214,7 +314,131 @@
"refId": "C"
}
],
- "title": "Latency percentiles",
+ "title": "Latency percentiles (diagnostic)",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "description": "Compare customer resources before filtering to one affected project. Success / (Success + Failure), excluding ClientError and Ignored regardless of the Outcome filter.",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "lineWidth": 2,
+ "spanNulls": false,
+ "thresholdsStyle": { "mode": "line" }
+ },
+ "max": 100,
+ "min": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "red", "value": 99 }
+ ]
+ },
+ "unit": "percent"
+ },
+ "overrides": [
+ {
+ "matcher": { "id": "byName", "options": "project-blue" },
+ "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }]
+ },
+ {
+ "matcher": { "id": "byName", "options": "project-green" },
+ "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }]
+ },
+ {
+ "matcher": { "id": "byName", "options": "project-red" },
+ "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }]
+ }
+ ]
+ },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
+ "id": 10,
+ "options": {
+ "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
+ "tooltip": { "mode": "multi", "sort": "desc" }
+ },
+ "pluginVersion": "11.6.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "editorMode": "code",
+ "expr": "100 * (sum by (CustomerResourceId) (rate(operation_duration_milliseconds_count{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=\"Success\"}[$__rate_interval])) or (0 * sum by (CustomerResourceId) (rate(operation_duration_milliseconds_count{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\"}[$__rate_interval])))) / (sum by (CustomerResourceId) (rate(operation_duration_milliseconds_count{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\"}[$__rate_interval])) > 0)",
+ "legendFormat": "{{CustomerResourceId}}",
+ "range": true,
+ "refId": "A"
+ }
+ ],
+ "title": "Availability by project",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "description": "Compare exact within-250-ms compliance by customer resource. Uses Success + Failure threshold counts, not interpolated histogram quantiles. The Outcome filter does not change the population.",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "lineWidth": 2,
+ "spanNulls": false,
+ "thresholdsStyle": { "mode": "line" }
+ },
+ "max": 100,
+ "min": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "red", "value": 99 }
+ ]
+ },
+ "unit": "percent"
+ },
+ "overrides": [
+ {
+ "matcher": { "id": "byName", "options": "project-blue" },
+ "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }]
+ },
+ {
+ "matcher": { "id": "byName", "options": "project-green" },
+ "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }]
+ },
+ {
+ "matcher": { "id": "byName", "options": "project-red" },
+ "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }]
+ }
+ ]
+ },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
+ "id": 11,
+ "options": {
+ "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
+ "tooltip": { "mode": "multi", "sort": "desc" }
+ },
+ "pluginVersion": "11.6.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "editorMode": "code",
+ "expr": "100 * (sum by (CustomerResourceId) (rate(operation_duration_threshold_count_total{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\",sli_latency_threshold_ms=\"250\",sli_latency_result=\"within\"}[$__rate_interval])) or (0 * sum by (CustomerResourceId) (rate(operation_duration_threshold_count_total{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\",sli_latency_threshold_ms=\"250\"}[$__rate_interval])))) / (sum by (CustomerResourceId) (rate(operation_duration_threshold_count_total{Operation=~\"$operation\",CustomerResourceId=~\"$customer\",LocationId=~\"$location\",Outcome=~\"Success|Failure\",sli_latency_threshold_ms=\"250\"}[$__rate_interval])) > 0)",
+ "legendFormat": "{{CustomerResourceId}}",
+ "range": true,
+ "refId": "A"
+ }
+ ],
+ "title": "Within 250 ms by project",
"type": "timeseries"
},
{
@@ -278,7 +502,7 @@
"h": 9,
"w": 12,
"x": 0,
- "y": 8
+ "y": 20
},
"id": 3,
"options": {
@@ -374,7 +598,7 @@
"h": 9,
"w": 12,
"x": 12,
- "y": 8
+ "y": 20
},
"id": 4,
"options": {
@@ -481,7 +705,7 @@
"h": 8,
"w": 12,
"x": 0,
- "y": 17
+ "y": 29
},
"id": 5,
"options": {
@@ -577,7 +801,7 @@
"h": 8,
"w": 12,
"x": 12,
- "y": 17
+ "y": 29
},
"id": 6,
"options": {
@@ -644,7 +868,7 @@
"h": 6,
"w": 24,
"x": 0,
- "y": 25
+ "y": 37
},
"id": 7,
"options": {
@@ -690,7 +914,7 @@
}
],
"preload": false,
- "refresh": "5s",
+ "refresh": "2s",
"schemaVersion": 41,
"tags": [
"trellis",
@@ -806,7 +1030,7 @@
]
},
"time": {
- "from": "now-15m",
+ "from": "now-5m",
"to": "now"
},
"timepicker": {},
diff --git a/sample/Observability/Grafana/grafana/provisioning/datasources/prometheus.yml b/sample/Observability/Grafana/grafana/provisioning/datasources/prometheus.yml
index 00f9915..77e82a4 100644
--- a/sample/Observability/Grafana/grafana/provisioning/datasources/prometheus.yml
+++ b/sample/Observability/Grafana/grafana/provisioning/datasources/prometheus.yml
@@ -8,3 +8,5 @@ datasources:
url: http://prometheus:9090
isDefault: true
editable: false
+ jsonData:
+ timeInterval: 2s
diff --git a/sample/Observability/Grafana/otel-collector-config.yaml b/sample/Observability/Grafana/otel-collector-config.yaml
index 9c361c0..fa54460 100644
--- a/sample/Observability/Grafana/otel-collector-config.yaml
+++ b/sample/Observability/Grafana/otel-collector-config.yaml
@@ -8,6 +8,7 @@ receivers:
processors:
batch:
+ timeout: 1s
exporters:
prometheus:
diff --git a/sample/Observability/Grafana/prometheus.yml b/sample/Observability/Grafana/prometheus.yml
index 454d12e..28f8024 100644
--- a/sample/Observability/Grafana/prometheus.yml
+++ b/sample/Observability/Grafana/prometheus.yml
@@ -1,6 +1,6 @@
global:
- scrape_interval: 5s
- evaluation_interval: 5s
+ scrape_interval: 2s
+ evaluation_interval: 2s
scrape_configs:
- job_name: otel-collector
diff --git a/sample/SliProductionDemo.Tests/DemoScenarioTests.cs b/sample/SliProductionDemo.Tests/DemoScenarioTests.cs
new file mode 100644
index 0000000..4d97fb0
--- /dev/null
+++ b/sample/SliProductionDemo.Tests/DemoScenarioTests.cs
@@ -0,0 +1,286 @@
+namespace SliProductionDemo.Tests;
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using SliProductionDemo;
+using SliProductionDemo.Controllers;
+using Trellis.ServiceLevelIndicators;
+
+public sealed class DemoScenarioTests
+{
+ [Theory]
+ [InlineData("read", new[] { 25, 35, 45, 55 })]
+ [InlineData("healthy", new[] { 45, 60, 75, 90 })]
+ [InlineData("slow", new[] { 550, 700, 850, 1_000 })]
+ [InlineData("client-error", new[] { 25, 35, 45 })]
+ [InlineData("throttled", new[] { 35, 50, 65 })]
+ [InlineData("failure", new[] { 450, 600, 750 })]
+ public void Each_operation_mode_uses_its_documented_delay_palette(string mode, int[] expectedValues)
+ {
+ for (var i = 0; i < 1_000; i++)
+ expectedValues.Should().Contain(ProjectsController.NextDelay(mode));
+ }
+
+ [Theory]
+ [InlineData("healthy", HttpStatusCode.OK, "Success", null)]
+ [InlineData("slow", HttpStatusCode.OK, "Success", null)]
+ [InlineData("client-error", HttpStatusCode.BadRequest, "ClientError", "The requested sync range is invalid.")]
+ [InlineData("throttled", HttpStatusCode.TooManyRequests, "Failure", "Sync capacity is temporarily exhausted.")]
+ [InlineData("failure", HttpStatusCode.ServiceUnavailable, "Failure", "The downstream sync service is unavailable.")]
+ [InlineData("unknown-mode", HttpStatusCode.BadRequest, "ClientError", "Unknown demo mode.")]
+ public async Task Sync_records_the_outcome_and_exact_latency_threshold(
+ string mode, HttpStatusCode expectedStatus, string expectedOutcome, string? expectedError)
+ {
+ using var capture = new MetricCapture();
+ using var application = new DemoApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.PostAsync(
+ $"/api/projects/project-red/sync?mode={mode}",
+ content: null,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(expectedStatus);
+ var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ payload.GetProperty("projectId").GetString().Should().Be("project-red");
+ payload.GetProperty("mode").GetString().Should().Be(mode);
+ if (expectedError is null)
+ payload.GetProperty("synchronized").GetBoolean().Should().BeTrue();
+ else
+ payload.GetProperty("error").GetString().Should().Be(expectedError);
+ var duration = capture.For("operation.duration").Should().ContainSingle().Subject;
+ var threshold = capture.For("operation.duration.threshold.count").Should().ContainSingle().Subject;
+ duration.Tags["CustomerResourceId"].Should().Be("project-red");
+ duration.Tags["Operation"].Should().Be("SyncProject");
+ duration.Tags["Outcome"].Should().Be(expectedOutcome);
+ threshold.Value.Should().Be(1);
+ threshold.Tags["CustomerResourceId"].Should().Be("project-red");
+ threshold.Tags["Operation"].Should().Be("SyncProject");
+ threshold.Tags["Outcome"].Should().Be(expectedOutcome);
+ threshold.Tags["sli.latency.threshold_ms"].Should().Be(250L);
+ if (mode is "slow" or "failure")
+ threshold.Tags["sli.latency.result"].Should().Be("exceeded");
+ threshold.Tags.Should().NotContainKeys("http.request.method", "http.response.status.code");
+ }
+
+ [Theory]
+ [InlineData("project-blue")]
+ [InlineData("project-green")]
+ [InlineData("project-red")]
+ public async Task Known_projects_retain_their_resource_dimension(string projectId)
+ {
+ using var capture = new MetricCapture();
+ using var application = new DemoApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ using var response = await client.GetAsync(
+ $"/api/projects/{projectId}", TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var duration = capture.For("operation.duration").Should().ContainSingle().Subject;
+ duration.Tags["CustomerResourceId"].Should().Be(projectId);
+ duration.Tags["Operation"].Should().Be("GetProject");
+ duration.Tags["Outcome"].Should().Be("Success");
+ capture.For("sli.diagnostics.unknown_customer_resource_id").Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Unknown_projects_share_one_bounded_resource_dimension(bool synchronize)
+ {
+ using var capture = new MetricCapture();
+ using var application = new DemoApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ foreach (var projectId in new[] { "unknown-project-one", "unknown-project-two" })
+ {
+ using var response = synchronize
+ ? await client.PostAsync(
+ $"/api/projects/{projectId}/sync",
+ content: null,
+ cancellationToken: TestContext.Current.CancellationToken)
+ : await client.GetAsync(
+ $"/api/projects/{projectId}", TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+
+ var durations = capture.For("operation.duration");
+ var thresholds = capture.For("operation.duration.threshold.count");
+ durations.Should().HaveCount(2);
+ thresholds.Should().HaveCount(2);
+ foreach (var measurement in durations.Concat(thresholds))
+ {
+ measurement.Tags["CustomerResourceId"].Should().Be(ServiceLevelIndicator.UnknownCustomerResourceId);
+ measurement.Tags["Outcome"].Should().Be("ClientError");
+ }
+
+ var diagnostics = capture.For("sli.diagnostics.unknown_customer_resource_id");
+ diagnostics.Should().HaveCount(2);
+ foreach (var diagnostic in diagnostics)
+ {
+ diagnostic.Value.Should().Be(1);
+ diagnostic.Tags.Keys.Should().BeEquivalentTo("Operation", "LocationId");
+ diagnostic.Tags["Operation"].Should().Be(synchronize ? "SyncProject" : "GetProject");
+ diagnostic.Tags["LocationId"].Should().Be(ServiceLevelIndicator.CreateLocationId("public", "westus3"));
+ }
+ }
+
+ [Fact]
+ public async Task Readiness_is_excluded_from_sli_measurement()
+ {
+ using var capture = new MetricCapture();
+ using var application = new DemoApplicationFactory(capture);
+ using var client = application.CreateClient();
+
+ application.Services.GetRequiredService>()
+ .Value.AutomaticallyEmitted.Should().BeTrue();
+ application.Services.GetRequiredService>()
+ .Value.CustomerResourceId.Should().Be(ServiceLevelIndicator.UnknownCustomerResourceId);
+
+ using var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ capture.Measurements.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData(0L, SliOutcome.Success, "within")]
+ [InlineData(90L, SliOutcome.Success, "within")]
+ [InlineData(65L, SliOutcome.Failure, "within")]
+ [InlineData(45L, SliOutcome.ClientError, "within")]
+ [InlineData(250L, SliOutcome.Success, "within")]
+ [InlineData(251L, SliOutcome.Success, "exceeded")]
+ [InlineData(450L, SliOutcome.Failure, "exceeded")]
+ public void Demo_threshold_counts_both_sides_of_250_ms_without_wall_clock_assumptions(
+ long elapsedMilliseconds, SliOutcome outcome, string expectedResult)
+ {
+ using var capture = new MetricCapture();
+ using var application = new DemoApplicationFactory(capture);
+ var sli = application.Services.GetRequiredService();
+
+ sli.Record("SyncProject", "project-red", elapsedMilliseconds, outcome);
+
+ var threshold = capture.For("operation.duration.threshold.count").Should().ContainSingle().Subject;
+ threshold.Value.Should().Be(1);
+ threshold.Tags["Operation"].Should().Be("SyncProject");
+ threshold.Tags["CustomerResourceId"].Should().Be("project-red");
+ threshold.Tags["Outcome"].Should().Be(outcome.ToString());
+ threshold.Tags["sli.latency.threshold_ms"].Should().Be(250L);
+ threshold.Tags["sli.latency.result"].Should().Be(expectedResult);
+ }
+
+ [Theory]
+ [InlineData("http://127.0.0.1:8097/health")]
+ [InlineData("https://localhost:8443/health")]
+ public async Task Readiness_checks_only_the_explicit_url(string target)
+ {
+ Uri? requested = null;
+ using var client = new HttpClient(new ProbeHandler(request =>
+ {
+ request.Method.Should().Be(HttpMethod.Get);
+ requested = request.RequestUri;
+ return new(HttpStatusCode.OK);
+ }));
+ using var error = new StringWriter();
+
+ var exitCode = await DemoReadiness.CheckAsync(
+ client, ["--health-check", target], error, TestContext.Current.CancellationToken);
+
+ exitCode.Should().Be(0);
+ requested.Should().Be(new Uri(target));
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData("--health-check")]
+ [InlineData("--urls http://localhost:8080 --health-check")]
+ [InlineData("--health-check http://localhost:8080/health --urls http://localhost:8080")]
+ public async Task Readiness_reports_wrong_arguments_without_sending_a_request(string arguments)
+ {
+ using var client = new HttpClient(new ProbeHandler(_ => throw new InvalidOperationException("Must not send.")));
+ using var error = new StringWriter();
+
+ var exitCode = await DemoReadiness.CheckAsync(
+ client, arguments.Split(' '), error, TestContext.Current.CancellationToken);
+
+ exitCode.Should().Be(2);
+ error.ToString().Should().Contain("Usage:").And.Contain("--health-check");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("/health")]
+ [InlineData("not-a-url")]
+ [InlineData("ftp://localhost/health")]
+ [InlineData("http://+:8080/health")]
+ [InlineData("http://localhost:0/health")]
+ public async Task Readiness_rejects_invalid_targets_with_usage(string target)
+ {
+ using var client = new HttpClient(new ProbeHandler(_ => throw new InvalidOperationException("Must not send.")));
+ using var error = new StringWriter();
+
+ var exitCode = await DemoReadiness.CheckAsync(
+ client, ["--health-check", target], error, TestContext.Current.CancellationToken);
+
+ exitCode.Should().Be(2);
+ error.ToString().Should().Contain("Usage:");
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.OK, 0)]
+ [InlineData(HttpStatusCode.NoContent, 0)]
+ [InlineData(HttpStatusCode.ServiceUnavailable, 1)]
+ public async Task Readiness_reports_the_target_http_status(HttpStatusCode status, int expectedExitCode)
+ {
+ using var client = new HttpClient(new ProbeHandler(_ => new(status)));
+ using var error = new StringWriter();
+
+ var exitCode = await DemoReadiness.CheckAsync(
+ client, ["--health-check", "http://localhost:8080/health"], error, TestContext.Current.CancellationToken);
+
+ exitCode.Should().Be(expectedExitCode);
+ if (expectedExitCode != 0)
+ error.ToString().Should().Contain($"HTTP {(int)status}");
+ else
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData(false, "failed")]
+ [InlineData(true, "timed out")]
+ public async Task Readiness_reports_transport_failures_without_an_unhandled_exception(bool timeout, string diagnostic)
+ {
+ Exception failure = timeout ? new TaskCanceledException("Timeout.") : new HttpRequestException("Unavailable.");
+ using var client = new HttpClient(new ProbeHandler(_ => throw failure));
+ using var error = new StringWriter();
+
+ var exitCode = await DemoReadiness.CheckAsync(
+ client, ["--health-check", "http://localhost:8080/health"], error, TestContext.Current.CancellationToken);
+
+ exitCode.Should().Be(1);
+ error.ToString().Should().Contain(diagnostic).And.NotContain("Unhandled exception");
+ }
+
+ private sealed class ProbeHandler(Func respond) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(respond(request));
+ }
+ }
+
+ private sealed class DemoApplicationFactory(MetricCapture capture) : WebApplicationFactory
+ {
+ protected override void ConfigureWebHost(IWebHostBuilder builder) =>
+ builder.ConfigureServices(services =>
+ services.Configure(options => options.Meter = capture.Meter));
+ }
+}
diff --git a/sample/SliProductionDemo.Tests/SliProductionDemo.Tests.csproj b/sample/SliProductionDemo.Tests/SliProductionDemo.Tests.csproj
new file mode 100644
index 0000000..85078a5
--- /dev/null
+++ b/sample/SliProductionDemo.Tests/SliProductionDemo.Tests.csproj
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/sample/SliProductionDemo/Controllers/ProjectsController.cs b/sample/SliProductionDemo/Controllers/ProjectsController.cs
new file mode 100644
index 0000000..d40e5c5
--- /dev/null
+++ b/sample/SliProductionDemo/Controllers/ProjectsController.cs
@@ -0,0 +1,80 @@
+namespace SliProductionDemo.Controllers;
+
+using Microsoft.AspNetCore.Mvc;
+using Trellis.ServiceLevelIndicators;
+
+///
+/// Project Tracker endpoints with repeatable operating modes and bounded latency jitter.
+///
+[ApiController]
+[Route("api/projects")]
+[Produces("application/json")]
+public sealed class ProjectsController : ControllerBase
+{
+ private static readonly string[] KnownProjects = ["project-blue", "project-green", "project-red"];
+ private static readonly int[] ReadDelays = [25, 35, 45, 55];
+ private static readonly Dictionary SyncModes = new(StringComparer.Ordinal)
+ {
+ ["healthy"] = new(StatusCodes.Status200OK, [45, 60, 75, 90]),
+ ["slow"] = new(StatusCodes.Status200OK, [550, 700, 850, 1_000]),
+ ["client-error"] = new(StatusCodes.Status400BadRequest, [25, 35, 45], "The requested sync range is invalid."),
+ ["throttled"] = new(StatusCodes.Status429TooManyRequests, [35, 50, 65], "Sync capacity is temporarily exhausted."),
+ ["failure"] = new(StatusCodes.Status503ServiceUnavailable, [450, 600, 750], "The downstream sync service is unavailable."),
+ };
+
+ ///
+ /// Returns one project using a consistently fast path.
+ ///
+ [HttpGet("{projectId}")]
+ [ServiceLevelIndicator(Operation = "GetProject")]
+ public async Task Get(
+ [CustomerResourceId] string projectId,
+ CancellationToken cancellationToken)
+ {
+ if (!KnownProjects.Contains(projectId, StringComparer.Ordinal))
+ return ProjectNotFound(projectId);
+
+ await Task.Delay(NextDelay("read"), cancellationToken);
+ return Ok(new { projectId, status = "Active" });
+ }
+
+ ///
+ /// Synchronizes one project using a deterministic operating mode.
+ ///
+ [HttpPost("{projectId}/sync")]
+ [ServiceLevelIndicator(Operation = "SyncProject")]
+ public async Task Sync(
+ [CustomerResourceId] string projectId,
+ [FromQuery] string mode = "healthy",
+ CancellationToken cancellationToken = default)
+ {
+ if (!KnownProjects.Contains(projectId, StringComparer.Ordinal))
+ return ProjectNotFound(projectId);
+
+ if (!SyncModes.TryGetValue(mode, out var scenario))
+ return BadRequest(new { projectId, mode, error = "Unknown demo mode." });
+
+ await Task.Delay(NextDelay(mode), cancellationToken);
+ return scenario.Error is null
+ ? StatusCode(scenario.StatusCode, new { projectId, mode, synchronized = true })
+ : StatusCode(scenario.StatusCode, new { projectId, mode, error = scenario.Error });
+ }
+
+ private NotFoundObjectResult ProjectNotFound(string projectId)
+ {
+ if (HttpContext.TryGetMeasuredOperation(out var measuredOperation))
+ measuredOperation.CustomerResourceId = ServiceLevelIndicator.UnknownCustomerResourceId;
+
+ return NotFound(new { projectId, error = "Project not found." });
+ }
+
+ internal static int NextDelay(string mode)
+ {
+ var values = mode == "read" ? ReadDelays
+ : SyncModes.TryGetValue(mode, out var scenario) ? scenario.Delays
+ : throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown demo latency mode.");
+ return values[Random.Shared.Next(values.Length)];
+ }
+
+ private sealed record SyncMode(int StatusCode, int[] Delays, string? Error = null);
+}
diff --git a/sample/SliProductionDemo/DemoReadiness.cs b/sample/SliProductionDemo/DemoReadiness.cs
new file mode 100644
index 0000000..5fc8fdb
--- /dev/null
+++ b/sample/SliProductionDemo/DemoReadiness.cs
@@ -0,0 +1,38 @@
+namespace SliProductionDemo;
+
+internal static class DemoReadiness
+{
+ internal const string Path = "/health";
+
+ internal static async Task CheckAsync(
+ HttpClient client, string[] args, TextWriter error, CancellationToken cancellationToken = default)
+ {
+ if (args is not ["--health-check", var target] ||
+ !Uri.TryCreate(target, UriKind.Absolute, out var address) ||
+ (address.Scheme != Uri.UriSchemeHttp && address.Scheme != Uri.UriSchemeHttps) ||
+ address.Port < 1)
+ {
+ error.WriteLine("Usage: SliProductionDemo --health-check ");
+ return 2;
+ }
+
+ try
+ {
+ using var response = await client.GetAsync(address, cancellationToken);
+ if (response.IsSuccessStatusCode)
+ return 0;
+
+ error.WriteLine($"Demo readiness check failed: HTTP {(int)response.StatusCode}.");
+ }
+ catch (HttpRequestException ex)
+ {
+ error.WriteLine($"Demo readiness check failed: {ex.Message}");
+ }
+ catch (OperationCanceledException ex)
+ {
+ error.WriteLine($"Demo readiness check timed out: {ex.Message}");
+ }
+
+ return 1;
+ }
+}
diff --git a/sample/SliProductionDemo/Program.cs b/sample/SliProductionDemo/Program.cs
new file mode 100644
index 0000000..2a18ac9
--- /dev/null
+++ b/sample/SliProductionDemo/Program.cs
@@ -0,0 +1,40 @@
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using SliProductionDemo;
+using Trellis.ServiceLevelIndicators;
+
+if (args.Contains("--health-check", StringComparer.Ordinal))
+{
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+ return await DemoReadiness.CheckAsync(client, args, Console.Error);
+}
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddControllers();
+builder.Services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService(
+ serviceName: "ProjectTrackerSliDemo",
+ serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown"))
+ .WithMetrics(metrics =>
+ {
+ metrics.AddServiceLevelIndicatorInstrumentation();
+ metrics.AddOtlpExporter();
+ });
+
+builder.Services.AddServiceLevelIndicator(options =>
+{
+ options.AutomaticallyEmitted = true;
+ options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
+ options.LatencyThresholds.Add(TimeSpan.FromMilliseconds(250));
+})
+.AddMvc();
+
+var app = builder.Build();
+
+app.UseServiceLevelIndicator();
+app.MapGet(DemoReadiness.Path, () => Results.Ok(new { status = "Healthy" }))
+ .ExcludeFromServiceLevelIndicator();
+app.MapControllers();
+
+await app.RunAsync();
+return 0;
diff --git a/sample/SliProductionDemo/Properties/launchSettings.json b/sample/SliProductionDemo/Properties/launchSettings.json
new file mode 100644
index 0000000..2b39fa1
--- /dev/null
+++ b/sample/SliProductionDemo/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "SliProductionDemo": {
+ "commandName": "Project",
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "http://localhost:5199"
+ }
+ }
+}
\ No newline at end of file
diff --git a/sample/WebApi/SampleWebApplicationSLI.csproj b/sample/SliProductionDemo/SliProductionDemo.csproj
similarity index 71%
rename from sample/WebApi/SampleWebApplicationSLI.csproj
rename to sample/SliProductionDemo/SliProductionDemo.csproj
index f0091bd..e41071d 100644
--- a/sample/WebApi/SampleWebApplicationSLI.csproj
+++ b/sample/SliProductionDemo/SliProductionDemo.csproj
@@ -1,14 +1,11 @@
-
+
True
false
-
-
-
diff --git a/sample/WebApi/appsettings.json b/sample/SliProductionDemo/appsettings.json
similarity index 100%
rename from sample/WebApi/appsettings.json
rename to sample/SliProductionDemo/appsettings.json
diff --git a/sample/WebApi/Api.http b/sample/WebApi/Api.http
deleted file mode 100644
index 4f7d785..0000000
--- a/sample/WebApi/Api.http
+++ /dev/null
@@ -1,17 +0,0 @@
-@HostAddress = https://localhost:63936
-
-### Get weather forecast
-GET {{HostAddress}}/WeatherForecast
-Accept: application/json
-
-### Get weather forecast (MyAction1)
-GET {{HostAddress}}/WeatherForecast/MyAction1
-Accept: application/json
-
-### Get weather forecast (custom operation "MyOperation")
-GET {{HostAddress}}/WeatherForecast/MyAction2
-Accept: application/json
-
-### Get weather forecast with customer resource id
-GET {{HostAddress}}/WeatherForecast/my-customer-resource-id
-Accept: application/json
diff --git a/sample/WebApi/ConfigureServiceLevelIndicatorOptions.cs b/sample/WebApi/ConfigureServiceLevelIndicatorOptions.cs
deleted file mode 100644
index 89f6348..0000000
--- a/sample/WebApi/ConfigureServiceLevelIndicatorOptions.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace SampleWebApplicationSLI;
-
-using Microsoft.Extensions.Options;
-using Trellis.ServiceLevelIndicators;
-
-internal sealed class ConfigureServiceLevelIndicatorOptions : IConfigureOptions
-{
- private readonly SampleApiMeters meters;
-
- public ConfigureServiceLevelIndicatorOptions(SampleApiMeters meters) => this.meters = meters;
-
- public void Configure(ServiceLevelIndicatorOptions options) => options.Meter = meters.Meter;
-}
diff --git a/sample/WebApi/Controllers/WeatherForecastController.cs b/sample/WebApi/Controllers/WeatherForecastController.cs
deleted file mode 100644
index 22604fc..0000000
--- a/sample/WebApi/Controllers/WeatherForecastController.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-namespace SampleWebApplicationSLI.Controllers;
-
-using Microsoft.AspNetCore.Mvc;
-using Trellis.ServiceLevelIndicators;
-
-///
-/// Weather forecast controller.
-///
-[ApiController]
-[Route("[controller]")]
-[Produces("application/json")]
-public class WeatherForecastController : ControllerBase
-{
- private static readonly string[] Summaries = new[]
- {
- "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
-};
-
- ///
- /// Should emit SLI metrics
- /// Operation: "GET WeatherForecast"
- /// CustomerResourceId = "SampleCustomerResourceId"
- /// Outcome = "Success"
- ///
- [HttpGet]
- public IEnumerable Get() => GetWeather();
-
- ///
- /// Should emit SLI metrics
- /// Operation: "GET WeatherForecast/MyAction1"
- /// CustomerResourceId = "SampleCustomerResourceId"
- /// Outcome = "Success"
- ///
-
- [HttpGet("MyAction1")]
- public IEnumerable GetCustom() => GetWeather();
-
- ///
- /// Should emit SLI metrics
- /// Operation: "MyOperation"
- /// CustomerResourceId = "SampleCustomerResourceId"
- /// Outcome = "Success"
- ///
- [HttpGet("MyAction2")]
- [ServiceLevelIndicator(Operation = "MyOperation")]
- public IEnumerable GetOperation() => GetWeather();
-
- ///
- /// Should emit SLI metrics
- /// Operation: "GET WeatherForecast/{customerResourceId}"
- /// CustomerResourceId = "Your input"
- /// Outcome = "Success"
- ///
- [HttpGet("{customerResourceId}")]
- public IEnumerable Get([CustomerResourceId] string customerResourceId) => GetWeather();
-
- ///
- /// Demo endpoint that emits Outcome = "ClientError".
- ///
- [HttpGet("demo/client-error/{customerResourceId}")]
- public IActionResult ClientError([CustomerResourceId] string customerResourceId) =>
- BadRequest(new { customerResourceId, error = "Invalid forecast request." });
-
- ///
- /// Demo endpoint that emits Outcome = "Failure" because 429 is service-impacting by default.
- ///
- [HttpGet("demo/throttled/{customerResourceId}")]
- public IActionResult Throttled([CustomerResourceId] string customerResourceId) =>
- StatusCode(StatusCodes.Status429TooManyRequests, new { customerResourceId, error = "Too many forecast requests." });
-
- ///
- /// Demo endpoint that emits Outcome = "Failure".
- ///
- [HttpGet("demo/server-error/{customerResourceId}")]
- public IActionResult ServerError([CustomerResourceId] string customerResourceId) =>
- StatusCode(StatusCodes.Status500InternalServerError, new { customerResourceId, error = "Forecast service unavailable." });
-
- private static WeatherForecast[] GetWeather() => Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = DateTime.Now.AddDays(index),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = Summaries[Random.Shared.Next(Summaries.Length)]
- })
- .ToArray();
-}
diff --git a/sample/WebApi/Program.cs b/sample/WebApi/Program.cs
deleted file mode 100644
index 9d0ebef..0000000
--- a/sample/WebApi/Program.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using Azure.Core;
-using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.Extensions.Options;
-using OpenTelemetry.Metrics;
-using OpenTelemetry.Resources;
-using SampleWebApplicationSLI;
-using Scalar.AspNetCore;
-using Trellis.ServiceLevelIndicators;
-
-var builder = WebApplication.CreateBuilder(args);
-
-// Add services to the container.
-
-builder.Services.AddControllers();
-// Learn more about configuring the built-in OpenAPI + Scalar setup in ASP.NET Core
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddOpenApi();
-builder.Services.AddProblemDetails();
-
-// Build a resource configuration action to set service information.
-Action configureResource = r => r.AddService(
- serviceName: "SampleWebApplicationSLI",
- serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown");
-
-builder.Services.AddOpenTelemetry()
- .ConfigureResource(configureResource)
- .WithMetrics(options =>
- {
- options.AddMeter(SampleApiMeters.MeterName);
- options.AddOtlpExporter();
- });
-
-builder.Services.AddSingleton();
-builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureServiceLevelIndicatorOptions>());
-builder.Services.AddServiceLevelIndicator(options =>
-{
- options.CustomerResourceId = "SampleCustomerResourceId";
- options.LocationId = ServiceLevelIndicator.CreateLocationId("public", AzureLocation.WestUS3.Name);
-})
-.AddMvc();
-
-var app = builder.Build();
-
-app.MapOpenApi();
-app.MapScalarApiReference();
-app.UseHttpsRedirection();
-app.UseServiceLevelIndicator();
-app.UseAuthorization();
-
-app.MapControllers();
-
-app.Run();
diff --git a/sample/WebApi/Properties/launchSettings.json b/sample/WebApi/Properties/launchSettings.json
deleted file mode 100644
index 0649eac..0000000
--- a/sample/WebApi/Properties/launchSettings.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "profiles": {
- "SampleWebApplicationSLI": {
- "commandName": "Project",
- "launchBrowser": true,
- "launchUrl": "scalar/v1",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- },
- "applicationUrl": "https://localhost:63936;http://localhost:63937"
- }
- }
-}
\ No newline at end of file
diff --git a/sample/WebApi/SampleApiMeters.cs b/sample/WebApi/SampleApiMeters.cs
deleted file mode 100644
index 8a09e12..0000000
--- a/sample/WebApi/SampleApiMeters.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace SampleWebApplicationSLI;
-
-using System.Diagnostics.Metrics;
-
-internal class SampleApiMeters
-{
- public const string MeterName = "SampleMeter";
-
- public Meter Meter { get; } = new Meter(MeterName);
-}
diff --git a/sample/WebApi/WeatherForecast.cs b/sample/WebApi/WeatherForecast.cs
deleted file mode 100644
index 906d361..0000000
--- a/sample/WebApi/WeatherForecast.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace SampleWebApplicationSLI;
-
-///
-/// Weather Forecast
-///
-public class WeatherForecast
-{
- ///
- /// Date of recording
- ///
- public DateTime Date { get; set; }
-
- ///
- /// Temperature in Centigrade.
- ///
- public int TemperatureC { get; set; }
-
- ///
- /// Temperature in Fahrenheit.
- ///
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
-
- ///
- /// Temperature feeling.
- ///
- public string? Summary { get; set; }
-}
diff --git a/sample/WebApi/appsettings.Development.json b/sample/WebApi/appsettings.Development.json
deleted file mode 100644
index 0c208ae..0000000
--- a/sample/WebApi/appsettings.Development.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- }
-}
diff --git a/sample/WebApiVersioned/Api.http b/sample/WebApiVersioned/Api.http
index b663576..9eeb02a 100644
--- a/sample/WebApiVersioned/Api.http
+++ b/sample/WebApiVersioned/Api.http
@@ -1,17 +1,12 @@
-@HostAddress = https://localhost:63936
+@HostAddress = http://localhost:5202
-### Hello World (default version)
-GET {{HostAddress}}/hello-world
+### Same operation and target, version 1.0
+GET {{HostAddress}}/projects/project-blue?api-version=1.0
Accept: application/json
-### Hello World with specific API version
-GET {{HostAddress}}/hello-world?api-version=2023-08-06
+### Same operation and target, version 2.0
+GET {{HostAddress}}/projects/project-blue?api-version=2.0
Accept: application/json
-### Hello World with name (customer resource id)
-GET {{HostAddress}}/hello-world/Xavier
-Accept: application/json
-
-### Hello World with name and specific API version
-GET {{HostAddress}}/hello-world/Xavier?api-version=2023-08-06
-Accept: application/json
+### Health is not measured
+GET {{HostAddress}}/health
diff --git a/sample/WebApiVersioned/Controllers/2023-06-06/HelloWorldController.cs b/sample/WebApiVersioned/Controllers/2023-06-06/HelloWorldController.cs
deleted file mode 100644
index a2e3cf9..0000000
--- a/sample/WebApiVersioned/Controllers/2023-06-06/HelloWorldController.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-namespace SampleVersionedWebApplicationSLI.Controllers._2023_06_06;
-
-using Asp.Versioning;
-using Microsoft.AspNetCore.Mvc;
-using Trellis.ServiceLevelIndicators;
-
-///
-/// Hello World controller.
-///
-[ApiController]
-[ApiVersion("1996-06-06")]
-[ApiVersion("2023-08-06")]
-[Route("hello-world")]
-[Produces("application/json")]
-public class HelloWorldController : ControllerBase
-{
- ///
- /// hello World.
- ///
- [HttpGet]
- public ActionResult Get()
- {
- var next = Random.Shared.Next(0, 100);
- if (next < 10) return BadRequest("Sim bad request");
- if (next < 20) return StatusCode(StatusCodes.Status500InternalServerError, "Sim Server error");
- return Ok("Hello World");
- }
-
- ///
- /// Hello world with name.
- ///
- ///
- ///
- [HttpGet("{name}")]
- public ActionResult GetCustom([CustomerResourceId] string name)
- {
- var next = Random.Shared.Next(0, 100);
- if (next < 10) return BadRequest("Sim bad request");
- if (next < 20) return StatusCode(StatusCodes.Status500InternalServerError, "Sim Server error");
- return Ok("Hello World " + name);
- }
-}
diff --git a/sample/WebApiVersioned/Controllers/ProjectsController.cs b/sample/WebApiVersioned/Controllers/ProjectsController.cs
new file mode 100644
index 0000000..5cc89da
--- /dev/null
+++ b/sample/WebApiVersioned/Controllers/ProjectsController.cs
@@ -0,0 +1,24 @@
+namespace SampleVersionedWebApplicationSLI.Controllers;
+
+using Asp.Versioning;
+using Microsoft.AspNetCore.Mvc;
+using Trellis.ServiceLevelIndicators;
+
+///
+/// Demonstrates the same measured operation across two API versions.
+///
+[ApiController]
+[ApiVersion("1.0")]
+[ApiVersion("2.0")]
+[Route("projects/{projectId:regex(^project-(blue|green|red)$)}")]
+[ServiceLevelIndicator(Operation = "GetProject")]
+public sealed class ProjectsController : ControllerBase
+{
+ ///
+ /// Gets a project with its resolved API version.
+ ///
+ /// The stable target resource.
+ [HttpGet]
+ public IActionResult Get([CustomerResourceId] string projectId) =>
+ Ok(new { projectId, apiVersion = HttpContext.ApiVersioningFeature.RequestedApiVersion?.ToString() });
+}
diff --git a/sample/WebApiVersioned/Program.cs b/sample/WebApiVersioned/Program.cs
index 87aed8d..bb4c1a4 100644
--- a/sample/WebApiVersioned/Program.cs
+++ b/sample/WebApiVersioned/Program.cs
@@ -1,5 +1,4 @@
using Asp.Versioning;
-using Azure.Core;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using Scalar.AspNetCore;
@@ -7,36 +6,33 @@
var builder = WebApplication.CreateBuilder(args);
-// Add services to the container.
-
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options => options.ApiVersionReader = new QueryStringApiVersionReader())
.AddMvc()
- .AddApiExplorer()
+ .AddApiExplorer(options => options.GroupNameFormat = "'v'VVV")
.AddOpenApi(options => options.Document.AddScalarTransformers());
builder.Services.AddProblemDetails();
-Action configureResource = r => r.AddService(
- serviceName: "SampleVersionedWebApplicationSLI",
- serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown");
builder.Services.AddOpenTelemetry()
- .ConfigureResource(configureResource)
- .WithMetrics(builder =>
+ .ConfigureResource(resource => resource.AddService("SampleVersionedWebApplicationSLI"))
+ .WithMetrics(metrics =>
{
- builder.AddServiceLevelIndicatorInstrumentation();
- builder.AddOtlpExporter();
+ metrics.AddServiceLevelIndicatorInstrumentation();
+ metrics.AddOtlpExporter();
});
builder.Services.AddServiceLevelIndicator(options =>
{
- options.CustomerResourceId = "SampleVersionedApiResource";
- options.LocationId = ServiceLevelIndicator.CreateLocationId("public", AzureLocation.WestUS3.Name);
+ options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "westus3");
+ options.AutomaticallyEmitted = false;
+ options.LatencyThresholds.Add(TimeSpan.FromMilliseconds(250));
})
.AddMvc()
.AddApiVersion();
var app = builder.Build();
+app.UseServiceLevelIndicator();
if (app.Environment.IsDevelopment())
{
@@ -53,17 +49,11 @@
});
}
-// Random delay.
-Random rnd = new Random();
-app.Use(async (context, next) =>
-{
- await Task.Delay(rnd.Next(40, 200));
- await next(context);
-});
-app.UseHttpsRedirection();
-app.UseServiceLevelIndicator();
-app.UseAuthorization();
-
app.MapControllers();
-
+app.MapGet("/health", () => Results.Ok());
app.Run();
+
+///
+/// Application entry point for integration tests.
+///
+public partial class Program;
diff --git a/sample/WebApiVersioned/Properties/launchSettings.json b/sample/WebApiVersioned/Properties/launchSettings.json
index b393a61..ddff729 100644
--- a/sample/WebApiVersioned/Properties/launchSettings.json
+++ b/sample/WebApiVersioned/Properties/launchSettings.json
@@ -2,12 +2,12 @@
"profiles": {
"SampleVersionedWebApplicationSLI": {
"commandName": "Project",
- "launchBrowser": true,
+ "launchBrowser": false,
"launchUrl": "scalar",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
- "applicationUrl": "https://localhost:63936;http://localhost:63937"
+ "applicationUrl": "http://localhost:5202"
}
}
}
diff --git a/sample/WebApiVersioned/SampleVersionedWebApplicationSLI.csproj b/sample/WebApiVersioned/SampleVersionedWebApplicationSLI.csproj
index 2fef110..be7e39e 100644
--- a/sample/WebApiVersioned/SampleVersionedWebApplicationSLI.csproj
+++ b/sample/WebApiVersioned/SampleVersionedWebApplicationSLI.csproj
@@ -2,12 +2,10 @@
True
false
- $(NoWarn);CA1707
-
diff --git a/sample/WebApiVersioned/run.cmd b/sample/WebApiVersioned/run.cmd
deleted file mode 100644
index b54a322..0000000
--- a/sample/WebApiVersioned/run.cmd
+++ /dev/null
@@ -1 +0,0 @@
-dotnet run --launch-profile SampleVersionedWebApplicationSLI
\ No newline at end of file