Observability¶
The observability schema is a frozen contract, not a byproduct. Metric names,
label sets and outcome values are constants in one module
(clientwright.core.telemetry.names); changing any of them is a breaking release.
That is what makes dashboards portable: every service, every adapter, every SDK —
one set of graphs.
Wiring it up¶
Telemetry backends arrive through AdapterDeps. Anything you leave out becomes a
null object — the engine never branches on "is metrics configured".
from clientwright import AdapterDeps, ClientConfig, build
from clientwright.adapters.observability import OpenTelemetryTracer, PrometheusClientMetrics
deps = AdapterDeps(
metrics=PrometheusClientMetrics(), # clientwright[metrics]
tracer=OpenTelemetryTracer(), # clientwright[tracing]
header_providers=(lambda: {"X-Request-ID": current_request_id()},),
)
client = build("httpx", ClientConfig(service_name="orders"), deps)
Both backends are optional protocols — ClientMetricsProtocol and
TracerProtocol are structural, so plugging in a custom sink means implementing
methods, not inheriting classes. The test double RecordingMetrics (see
Testing) is exactly such an implementation.
The metric families¶
| Metric | Type | Labels | Meaning |
|---|---|---|---|
http_client_requests_total |
counter | service, adapter, seam, method, origin, route, status, outcome | one per logical call |
http_client_request_duration_seconds |
histogram | service, adapter, seam, method, origin, route | whole-call latency |
http_client_body_duration_seconds |
histogram | same | time spent reading the response body |
http_client_attempts_total |
counter | service, adapter, seam, method, origin, outcome | one per physical attempt |
http_client_attempt_duration_seconds |
histogram | same | per-attempt latency |
http_client_inflight |
gauge | service, adapter, seam, origin | calls currently in flight |
http_client_circuit_state |
gauge | service, adapter, key | 0 closed / 1 half-open / 2 open |
http_client_redirect_hops_total |
counter | service, adapter, seam | hops the engine followed |
http_client_retry_skipped_total |
counter | service, adapter, seam, reason | wanted to retry, could not: method / non_replayable / deadline / budget |
http_client_uninstrumented_calls_total |
counter | service, adapter, seam | calls that bypassed the seam (see the aiohttp page) |
Three label conventions to internalize:
outcomeissuccessor a failure kind (connect_timeout,read_timeout,total_timeout,dns_error,disconnected,status,circuit_open, ...) — one shared taxonomy across every SDK.statusis the numeric HTTP status, or the stringnonewhen no response arrived at all. A503-then-retry-then-200call ends asstatus="200", outcome="success"; the503lives inattempts_total.routecomes from the per-call channel and isunknownuntil you set it.
The ratio everyone ends up graphing:
attempts_total / requests_total is your retry amplification per origin — the
number that tells you an upstream is degrading before the breaker opens.
Traces¶
With a tracer wired, every logical call is one CLIENT span — HTTP GET — with
http.request.method, server.origin, a redacted url.full, the response status
and an error status on failure. The engine injects traceparent (and whatever
your propagator emits) into outgoing headers from the client span itself, so
the upstream's server span becomes its child, not its sibling. Attempts and
redirect hops stay inside the one span: your trace waterfall shows the call as
your caller experienced it.
Logs¶
The built-in logging channel writes structured records to the standard logging
module under clientwright.client.<service_name> — call started (DEBUG), call
finished (INFO, configurable via success_log_level), call failed (WARNING) —
with method, redacted URL, outcome, status, duration, attempts and hops in
extra. Route it with your normal logging config; there is nothing bespoke to
integrate.
Redaction¶
URLs in logs and spans pass through a redactor before leaving the process.
Values of sensitive query parameters (token, code, client_secret,
api_key, ... ) become [redacted], and the set is extendable:
from clientwright import ClientConfig, ObservabilityConfig
config = ClientConfig(
service_name="identity",
observability=ObservabilityConfig(
sensitive_query_params=frozenset({"token", "sso_ticket"}),
),
)
Headers never reach logs or spans at all — excluded by design, so there is no
header list to maintain. Name-based redaction cannot catch PII sitting in a
path segment; for that there is a value-level seam, url_masker — see
Masking PII.
Turning channels off¶
Each channel is independent. Off means the null backend — zero overhead beyond a
method call, and no None-checks anywhere in your code or ours.