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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
All packages in the repository are versioned together.

## [Unreleased]
## [1.1.0] - 2026-11-07

### Added

- **Observability** — `UseTelemetry()`, a pipeline stage that records a span and a request-duration
measurement per request, under the OpenTelemetry HTTP client semantic conventions.
- **No dependency on OpenTelemetry**, and there will not be one. Spans go to
`System.Diagnostics.Activity` and measurements to a `System.Diagnostics.Metrics.Meter`, both in
the base class library and both the vendor-neutral seam collectors already subscribe to. Taking
the SDK as a dependency would impose it on every consumer, including the ones exporting
somewhere else or nowhere. `RunaxHttpTelemetry.ActivitySourceName` and `MeterName` are the
constants to hand `AddSource` / `AddMeter`.
- **One span per attempt, not per call.** What the conventions describe — a resend carries
`http.request.resend_count` — and the only shape that separates a call that took four seconds
because the server was slow from one that took four seconds because two tries failed and the
backoff was waited out. A single span reports four seconds for both.
- Conventional attribute names throughout (`http.request.method`, `url.full`, `url.template`,
`server.address`, `http.response.status_code`, `error.type`), because conformance is the
feature: a dashboard built for `HttpClient` keeps working, and `http.method` instead of
`http.request.method` is indistinguishable from no instrumentation to anything consuming it.
The transport is the one addition, namespaced as `runax.http.transport`.
- Span named `{method} {url.template}` and never with the URL in it: a name carrying an id is one
operation per id, and a backend grouping by name ends up with a million groups of one. The
template comes from the contract rather than being parsed back out of a filled-in URL.
- `http.client.request.duration`, a histogram **in seconds** as the convention specifies — a unit
that differs from every other HTTP duration in the same backend cannot be compared with them.
Tagged with low-cardinality attributes only: `url.full` and the correlation id are deliberately
absent, since each distinct id in a path would become its own time series. A failed call is
still measured, because a p99 that improves when slow calls start failing instead of completing
is a graph that lies by omission.
- **Outermost stage** (`Order` 50, outside correlation's 100), and that ordering is a correctness
constraint rather than a preference: the correlation stage derives its id from
`Activity.Current`, so starting the span after it would leave the `traceparent` reaching the
server naming a span that does not exist — breaking the join between client and server traces
silently, since both halves look fine alone. It also means the span encloses the time the other
stages spend, including an auth stage fetching a token off a cold cache.
- Credentials in a URL are stripped from `url.full` unconditionally, not as an option: the
conventions require it, and a password in a span attribute is a password in a tracing backend's
search index. The query string is included by default, matching what the logging stage does with
the same information; `RecordQueryString = false` for an API that puts a key there. Bodies are
never recorded.
- Inert until something subscribes — `StartActivity` returns `null` with no listener and the
histogram skips an unsubscribed measurement — so this is safe to leave on in a library whose
consumers may have no collector. Tracing and metrics are separately disableable, since a span is
cheap to emit and expensive to store and a histogram is the reverse.
- AOT-safe with nothing to configure; the AOT smoke test publishes with the stage attached.
- **`WithTotalTimeout`** — a budget for the whole call, retries and the waits between them
included. `Timeout` bounds a single *attempt*, which reads like a call budget and is not one: the
transport request carrying it is built once, before the retry loop, so every attempt gets the
Expand Down Expand Up @@ -377,5 +419,6 @@ All packages in the repository are versioned together.
- Community health files: contributing guide, code of conduct, security policy, issue and pull
request templates, CODEOWNERS and Dependabot.

[Unreleased]: https://github.com/runax-software/runax-http/compare/v1.0.0...main
[Unreleased]: https://github.com/runax-software/runax-http/compare/v1.1.0...main
[1.1.0]: https://github.com/runax-software/runax-http/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/runax-software/runax-http/releases/tag/v1.0.0
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ discriminates on the status, a JSON property or a header. Retries are built in a
`WithRetry(3)`.

The generated code never references a specific HTTP library. It builds a transport-neutral
request and hands it to `IHttpTransport`. Serialization, retries, logging and auth all sit
above that boundary, so they are written once rather than once per transport.
request and hands it to `IHttpTransport`. Serialization, retries, logging, auth and telemetry all
sit above that boundary, so they are written once rather than once per transport — and the
[observability](docs/observability.md) that `UseTelemetry()` emits follows the OpenTelemetry HTTP
conventions, tags whichever transport answered, and takes no dependency on OpenTelemetry to do it.

## Packages

Expand Down
7 changes: 5 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ Ordered by what each item unblocks rather than by effort. Nothing here is a comm
any trim or AOT warning and then runs the binary.
- [x] **Per-package READMEs and docs** — one documentation page per concept, one README per
shipped package.
- [x] **Observability** — tracing and metrics on the pipeline under the OpenTelemetry HTTP client
semantic conventions, with the active transport as a tag. One span per *attempt*, so a retried
call is distinguishable from a slow one. Emits to `ActivitySource` and `Meter` in the base
library, so the package takes no dependency on OpenTelemetry and stays inert until something
subscribes. Outermost stage, because the correlation id below it is derived from the span.

## Backlog

Expand All @@ -40,8 +45,6 @@ Ordered by what each item unblocks rather than by effort. Nothing here is a comm
transport rather than smoothed over.
- [ ] **OpenAPI to interface** — generate the annotated interface from an OpenAPI document,
so the hand-written interface is a starting point rather than the only option.
- [ ] **Observability** — OpenTelemetry-ready tracing and metrics on the pipeline, with the
active transport as a tag.
- [ ] **Benchmarks (BenchmarkDotNet)** — guard per-call overhead and allocations against
regressions, and quantify the cost of each transport.
- [ ] **Runtime proxy fallback** — a `DispatchProxy`-based path for interfaces that aren't
Expand Down
6 changes: 3 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ New to it? Read the [root README](../README.md) for the pitch, then
| [Capabilities](capabilities.md) | What HTTP libraries disagree about, how a request's requirements are derived, and the rule that a transport may throw or degrade but never pretend |
| [Retries](retries.md) | Turning them on, the backoff curve, `Retry-After`, and the three defaults that are safety decisions rather than taste |
| [The request pipeline](pipeline.md) | Logging, authentication and correlation as transport-agnostic middleware; where it sits relative to retries, and what the logging stage refuses to write |
| [Observability](observability.md) | Spans and metrics under the OpenTelemetry HTTP semantic conventions, one span per attempt, the transport as a tag, and why the package takes no dependency on OpenTelemetry |
| [Serialization](serialization.md) | `IContentSerializer`, why no serializer is registered by default, source-generated JSON, per-client overrides |
| [Testing](testing.md) | `Runax.Http.TestKit`: a recording transport and a harness that stands up a real client without a socket, so you can assert on the request it built |
| [Configuration](configuration.md) | Every option on both builders, ordering guarantees, reading from `IConfiguration`, and using the runtime without a container |
Expand All @@ -42,6 +43,5 @@ New to it? Read the [root README](../README.md) for the pitch, then
## Not written yet

Pages arrive with the features they describe, so there is nothing here that documents something you
cannot use. Still to come, tracked on the [roadmap](../ROADMAP.md):

- **Observability** — waiting on the tracing and metrics work
cannot use. Everything currently shipped has a page; what is still to come is tracked on the
[roadmap](../ROADMAP.md).
164 changes: 164 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Observability

Spans and metrics on every request, under the OpenTelemetry HTTP client semantic conventions, with
the active transport as a tag.

```csharp
services.AddRunaxClient<IRepositoryClient>()
.UseTelemetry();
```

That is the whole setup on the client side. Nothing is on by default, and nothing is exported until
you point a collector at it.

## No dependency on OpenTelemetry

The package does not reference OpenTelemetry, and it never will. Spans go to
`System.Diagnostics.Activity` and measurements to a `System.Diagnostics.Metrics.Meter` — both in the
base class library, both designed as the vendor-neutral seam that collectors subscribe to. A library
that took the SDK as a dependency would make every consumer take it too, including the ones who
export to something else entirely or to nothing at all.

Subscribe by name:

```csharp
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource(RunaxHttpTelemetry.ActivitySourceName))
.WithMetrics(m => m.AddMeter(RunaxHttpTelemetry.MeterName));
```

Both names are `"Runax.Http"`. They are exposed as constants rather than documented strings so a
rename cannot silently unsubscribe you.

Until something subscribes, both are inert: `ActivitySource.StartActivity` returns `null` with no
registered listener, and the histogram skips a measurement when its meter has no subscriber. An
unobserved client pays for a null check, which is why this is safe to leave switched on in a library
whose consumers may have no collector.

## One span per attempt

A call that retries twice produces three spans, not one.

That is what the conventions describe — a resend carries `http.request.resend_count` — and it is the
only shape that can answer the question retries create. A call that took 4.2 seconds because the
server was slow and a call that took 4.2 seconds because two tries failed and the backoff was waited
out are different problems with different fixes. One span covering the whole call reports 4.2 seconds
for both.

The attempts are tied together by their parent span, and by the correlation id if that stage is on.

## Span attributes

| Attribute | Example | Notes |
| --- | --- | --- |
| `http.request.method` | `GET` | Always known — the method comes from an enum, so `_OTHER` never applies |
| `url.full` | `https://api.example.com/v3/repos/runax/http?state=open` | Credentials stripped; query optional, see below |
| `url.scheme` | `https` | |
| `url.template` | `/repos/{owner}/{name}` | The low-cardinality endpoint name, taken from the contract |
| `server.address` | `api.example.com` | |
| `server.port` | `8443` | Only when it is not the default for the scheme |
| `http.response.status_code` | `200` | |
| `http.request.resend_count` | `1` | Absent on a first attempt, not zero |
| `error.type` | `503`, `System.Net.Sockets.SocketException` | The status for an HTTP error, the exception type for a failure that never got one |
| `runax.http.transport` | `httpclient` | The transport that answered |
| `runax.http.client` | `IRepositoryClient` | The contract that issued it |
| `runax.http.correlation_id` | `4bf92f…` | Present when `UseCorrelation()` is on |

The span is named `{method} {url.template}` — `GET /repos/{owner}/{name}` — and its kind is
`Client`. The name deliberately does not contain the URL: a name with an id in it is one distinct
operation per id, and a backend that groups by operation name ends up with a million groups of one.

**4xx and 5xx set the span status to `Error`.** The server may consider a 404 a perfectly good
answer; the caller that asked for the thing did not get it, and for a client span the conventions
take the caller's side.

### Why the conventional names and not nicer ones

Because conformance *is* the feature. A dashboard, alert or exemplar query built against
`HttpClient` keeps working when a call starts going through a Runax.Http client, and the attributes a
backend knows how to group by are the ones it was told to expect. `http.method` instead of
`http.request.method` is indistinguishable from no instrumentation at all to anything consuming it.

The one addition is the transport, and it is namespaced under `runax.` for the same reason: inventing
an unprefixed attribute in someone else's namespace is how a convention rots.

## Metrics

One instrument: `http.client.request.duration`, a histogram, **in seconds**.

Seconds because the convention says seconds. A histogram whose unit differs from every other HTTP
duration in the same backend cannot be compared with them, and comparison is most of what a duration
is for.

Tags: `http.request.method`, `url.scheme`, `server.address`, `server.port`, `url.template`,
`http.response.status_code`, `error.type`, `runax.http.transport`, `runax.http.client`.

**`url.full` is not among them, and neither is the correlation id.** Every distinct id in a path
would become its own time series, and a metric backend charges for those by the million. High
cardinality belongs on a span, where it is one record rather than one series.

A failed call is still measured. A p99 that improves because slow calls started failing rather than
completing is a graph that lies by omission, so failures are recorded with `error.type` set and
remain separable from successes.

## Where the stage sits

Outermost — `Order` 50, outside correlation's 100.

Two reasons, and the second is a correctness constraint rather than a preference:

- The span has to enclose the time the other stages spend. An auth stage fetching a token off a cold
cache is part of how long the call took, and a span that starts after it under-reports.
- The correlation stage derives its id from `Activity.Current`. Start the span after correlation runs
and the `traceparent` header reaching the server names a span that does not exist — which breaks
the join between the client trace and the server's, silently, because both halves look fine on
their own.

With both stages on, the `traceparent` the server receives carries the trace id of the span the
client actually recorded, which is what makes the two ends one trace.

## The query string

Recorded by default, matching what the [logging stage](pipeline.md#logging) does with the same
information — one library should not take two positions on it.

Turn it off for an API that puts a key or a token in the query:

```csharp
.UseTelemetry(o => o.RecordQueryString = false)
```

A trace is stored and searchable for as long as your retention allows, which makes it a worse place
for a secret than a log line.

**Credentials in the URL are stripped unconditionally** and that is not configurable. The conventions
require it, and a password in a `url.full` attribute is a password in a tracing backend's search
index.

Request and response bodies are never recorded, for the reasons the
[logging stage](pipeline.md#what-it-will-not-log-and-why-that-is-not-configurable) sets out. Span
attributes persist in a backend exactly the way log lines do.

## Options

| Option | Default | What it does |
| --- | --- | --- |
| `Tracing` | `true` | Start a span per attempt |
| `Metrics` | `true` | Record the duration histogram |
| `RecordQueryString` | `true` | Include the query in `url.full` |
| `RecordClientName` | `true` | Tag `runax.http.client` |

Tracing and metrics are independently disableable, because the costs are not the same: a span per
request is cheap to emit and expensive to store, and a histogram is the reverse.

## Native AOT

`ActivitySource` and `Meter` need no reflection, so the stage is AOT-safe with nothing to configure.
The AOT smoke test publishes with this stage attached and asserts zero trim warnings — see
[Native AOT & trimming](aot.md).

## Next

- [The request pipeline](pipeline.md) — the stages this one wraps
- [Retries](retries.md) — why there is a span per attempt
- [Transports](transports.md) — what `runax.http.transport` names
8 changes: 6 additions & 2 deletions docs/pipeline.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The request pipeline

Logging, authentication and correlation, as middleware that works on every transport.
Logging, authentication, correlation and telemetry, as middleware that works on every transport.

`DelegatingHandler` is the obvious place to put all three, and it only exists in `HttpClient`-land —
so anything built on it cannot be reused for RestSharp or anything else. This is the replacement,
Expand All @@ -13,6 +13,7 @@ Each stage is independently opt-in:

```csharp
services.AddRunaxClient<IRepositoryClient>()
.UseTelemetry()
.UseCorrelation()
.UseLogging()
.UseBearerToken(() => tokenCache.Current)
Expand Down Expand Up @@ -53,7 +54,8 @@ Explicit, from `IHttpMiddleware.Order`. Lower runs first, meaning further from t

| Stage | Order | Why there |
| --- | --- | --- |
| Correlation | 100 | Outermost, so every stage inside — and every log line — sees the same id |
| Telemetry | 50 | Outermost. Its span has to enclose the time the other stages spend, and the correlation stage below reads the span as `Activity.Current` — reverse the two and the `traceparent` the server receives names a span that does not exist |
| Correlation | 100 | Inside telemetry, so its id can come from the span; outside everything else, so every stage and every log line sees the same id |
| Logging | 200 | Inside correlation so it can report the id; outside auth so its elapsed time includes fetching a token, which is latency the caller really experiences |
| Authentication | 300 | Innermost, so the token is attached as late as possible |

Expand Down Expand Up @@ -195,6 +197,8 @@ Three things to know:

## Next

- [Observability](observability.md) — the telemetry stage in detail: attributes, metrics, and why
there is a span per attempt
- [Retries](retries.md) — what the pipeline runs inside
- [Capabilities](capabilities.md) — why a stage may only touch headers
- [Architecture](architecture.md) — the boundary all of this sits above
Loading
Loading