Observability¶
headgate writes no log records. A client reports what it does as typed events to the observers you install, its errors carry what a diagnosis needs (see errors), and three counters of degraded state are readable on demand. What to record, where, and at what level is the application's decision.
Events¶
An observer is any object with a notify(event) method, installed with HttpOptions(observers=(...)):
from headgate import HttpOptions
from headgate.events import CallFinished, Event, RateLimited
class Metrics:
def notify(self, event: Event) -> None:
match event:
case CallFinished(call=call, status_code=status, error=error, elapsed=elapsed):
record_call(call.method, call.route or call.url, status, error, elapsed)
case RateLimited(delay=delay, reason=reason):
record_wait(reason, delay)
case _:
pass
http = HttpOptions(observers=(Metrics(),))
| Event | When | Carries |
|---|---|---|
CallStarted |
a call's request is built | call |
AttemptFinished |
each send ends: a response head arrived, or it raised | attempt, status_code or error, elapsed |
RetryScheduled |
the retry layer will send again | attempt, delay, reason ("503", "ConnectError", "hedge") |
RateLimited |
the rate budget held the call, or its response paused the budget | delay, reason: paced, throttled, paused, pushback |
BreakerChanged |
an origin's breaker changed state | origin, previous, state, failures, call |
CacheResult |
the cache answered | outcome: hit, miss, shared, degraded |
Deprecated |
the response announced deprecation or a sunset | deprecated, deprecated_at, sunset_at, link, sunset_link |
CallFinished |
a call ends, either way | status_code, error (the exception type's name), attempts, elapsed |
Every call event carries call, a CallInfo: the method, the url as given (sanitized), the route (the t-string
template, "series/{series_id}", or None for a str URL), the absolute full_url (sanitized), and a process-unique
call_id that groups the events of one call among many concurrent ones. Durations are seconds on the client's clock.
Events carry sanitized data only: no header, no body, no credential. They are frozen dataclasses, so an observer can queue them for another thread.
Observers run inline, in the order given, on the task making the call; hand slow work to a queue. An exception an
observer raises never reaches the call: it is swallowed and counted per observer class in
headgate.health.observer_failures(). A client without observers builds no events at all.
OpenTelemetry¶
pip install 'headgate[otel]' (it needs only opentelemetry-api; bring your own SDK):
from headgate import HttpOptions
from headgate.otel import OpenTelemetryObserver
otel = OpenTelemetryObserver() # the global tracer and meter providers by default
http = otel.install(HttpOptions(user_agent="shop-sync/1.4"))
install adds it as an observer and as the outermost middleware, and every call then gets:
- one CLIENT span per logical call, named
GETorGET series/{series_id}, covering every attempt, backoff wait, token mint and the body read, with the stable HTTP semantic-convention attributes:http.request.method,server.address,server.port,url.full(redacted),url.template,http.response.status_code,http.request.resend_countanderror.type(the status for a 4xx or 5xx, else theClientErrorclass name). A failed call's status isERROR; a cancelled call's is left unset; - the
http.client.request.durationhistogram, with the same attributes lessurl.fullandurl.template; - W3C Trace Context on every attempt:
traceparent(andtracestate) is written onto each send, re-sends included, and the call's span is current while an attempt is in flight. Baggage is not sent unless you pass a propagator that sends it.
It works with any SDK TracerProvider, with Logfire, and with Sentry's OpenTelemetry integration. For a span per
physical send as well, add opentelemetry-instrumentation-httpx's HTTPX2ClientInstrumentor().instrument(): it
patches httpx2's network transport, below headgate's stack, so its spans nest under headgate's. Both then record
http.client.request.duration, so give one a no-op meter provider (OpenTelemetryObserver(meter_provider=...)).
Request ids¶
Every ClientError and every Stream has request_id, read from the first of HttpOptions.request_id_headers the
response carries: x-request-id, request-id, x-amzn-requestid, x-ms-request-id and cf-ray by default. Put a
provider's own header first if it uses another.
Deprecation and sunset¶
A provider retiring an endpoint says so on its responses. When a response carries a valid Deprecation header (RFC
9745's @<epoch> date, or the older true or HTTP-date forms) or a Sunset date (RFC 8594), the call emits a
Deprecated event with the parsed dates and the rel="deprecation" and rel="sunset" links, resolved and redacted.
An observer that alerts on it finds every endpoint a client depends on that is going away, before it does.
User-Agent¶
Every request carries User-Agent: headgate/<version> httpx2/<version> python/<version>.
HttpOptions(user_agent="shop-sync/1.4") puts your product token first, which is what a provider's support team sees.
A User-Agent in HttpOptions(headers=...) replaces it entirely.
Health snapshots¶
headgate.health reads the process-wide state that says something is degraded, as fresh copies a metrics callback can
poll:
breaker_states(registry=...): aBreakerSnapshot(state, refusing)per origin, of the shared registry by default;cache_degradations(): per host, how many requests found the cache broken and went to the network;observer_failures(): per observer class, how many events itsnotifyraised on.