Skip to content

Observability: OpenTelemetry-ready tracing and metrics - #10

Merged
Katyara11 merged 1 commit into
mainfrom
feat/observability
Nov 7, 2026
Merged

Katyara11 merged 1 commit into
mainfrom
feat/observability

Conversation

@Katyara11

Copy link
Copy Markdown
Member

Closes the last roadmap item before 1.1.0.

UseTelemetry() records a span and an http.client.request.duration measurement per request attempt, under the OpenTelemetry HTTP client semantic conventions, with the active transport as a tag.

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

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

The decisions worth arguing about

No dependency on OpenTelemetry. Spans go to System.Diagnostics.Activity, measurements to a System.Diagnostics.Metrics.Meter — both in the BCL, 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.

One span per attempt, not per call. What the conventions describe (http.request.resend_count), and the only shape that separates "the server was slow" from "two tries failed and we waited out the backoff". A single span reports the same total for both.

Outermost stage, Order 50 — outside correlation's 100. This one is a correctness constraint, not a preference: CorrelationMiddleware derives its id from Activity.Current, so a span started after it would leave the traceparent reaching the server naming a span that does not exist. Both halves of the trace look fine alone; only the join is broken. There is a test for exactly this, asserting the traceparent header carries the recorded span's trace id.

Conventional attribute names, not nicer ones. 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 runax.http.transport.

The histogram is in seconds, as the convention specifies: a unit differing 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. Failures are still measured, because a p99 that improves when slow calls start failing rather than completing is a graph that lies by omission.

Secrets. Credentials in a URL are stripped from url.full unconditionally and that is not configurable. The query string is included by default, matching what the logging stage does with the same information — one library should not take two positions on it — with RecordQueryString = false for an API that puts a key there. Bodies are never recorded. A span attribute persists in a backend exactly the way a log line does.

Inert until subscribed. 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.

Verification

  • 19 new tests, asserted through the real ActivityListener / MeterListener rather than a seam of our own — the claim under test is "an OpenTelemetry setup will see this", and OTel sees it through those two types. A fake would only prove the middleware talks to the fake.
  • Full suite green on net8.0 / net9.0 / net10.0 (333 each) plus the RestSharp conformance suite (43).
  • dotnet build --warnaserror clean, dotnet format clean.
  • Native AOT publishes with zero trim warnings and the binary runs — the AOT smoke test now attaches this stage, since "AOT-safe" is a claim only a publish can check.

Also in here

  • docs/observability.md, and the docs index no longer has a "not written yet" entry.
  • docs/pipeline.md ordering table and the package README updated with the new stage.
  • ROADMAP: Observability moved to shipped.
  • CHANGELOG: [Unreleased] closed to ## [1.1.0].

🤖 Generated with Claude Code

UseTelemetry() records a span and a request-duration measurement per
attempt, 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 Meter, both in the BCL
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 at all.

One span per attempt rather than per call. That is what the conventions
describe — a resend carries http.request.resend_count — and it is 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, 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.

Outermost stage, at Order 50. That is a correctness constraint rather
than a preference: the correlation stage derives its id from
Activity.Current, so a span started 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 on their own.

The histogram is in seconds as the convention specifies, and 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. Failures are still measured, because a p99 that
improves when slow calls start failing instead of completing is a graph
that lies by omission.

Credentials in a URL are stripped from url.full unconditionally, not as
an option. Bodies are never recorded. Both for the reason the logging
stage gives: a span attribute persists in a backend exactly the way a
log line does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Katyara11
Katyara11 merged commit 80b8f14 into main Nov 7, 2026
2 checks passed
@Katyara11
Katyara11 deleted the feat/observability branch November 7, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant