Description
Prompted by discussions on Gitter, and somewhat related to #790.
"Telemetry" refers to the possibility of extracting metrics from a running program that uses HTTPX, in order to get insights on performed requests (what they are, how many of them, to which endpoints, etc).
So…
What do other HTTP clients do?
Requests
Requests doesn't have any telemetry built-in, nor any API specifically targeted at telemetry/tracing. (Hooks don't seem to be enough, since there's only a response
hook.)
For this reason it seems most instrumentation projects like OpenTelemetry and Datadog APM take a monkeypatching approach.
For example, OpenTelemetry monkeypatches the high-level API (and nothing else, which means session requests aren't traced):
import requests
import opentelemetry.instrumentation.requests
# You can optionally pass a custom TracerProvider to
RequestInstrumentor.instrument()
opentelemetry.instrumentation.requests.RequestsInstrumentor().instrument()
response = requests.get(url="https://www.example.org/")
And Datadog APM monkeypatches Session.send()
(which also has limitations because then custom/subclassed sessions aren't monkeypatched by default):
from ddtrace import patch
patch(requests=True)
import requests
requests.get("https://www.datadoghq.com")
aiohttp
aiohttp
has a Client Tracing API, which is basically "event hooks on steroids". The Tracing Reference lists 15 different callbacks, most of them related to HTTP networking rather than client functionality.
OpenTelemetry integrates with this feature by exporting a create_trace_config()
function, as well as a helper for preparing span names:
import aiohttp
from opentelemetry.instrumentation.aiohttp_client import create_trace_config, url_path_span_name
import yarl
def strip_query_params(url: yarl.URL) -> str:
return str(url.with_query(None))
open_telemetry_trace_config = create_trace_config(
# Remove all query params from the URL attribute on the span.
url_filter=strip_query_params,
# Use the URL's path as the span name.
span_name=url_path_span_name
)
async with aiohttp.ClientSession(trace_configs=[open_telemetry_trace_config]) as session:
...
Datadog APM doesn't have support for aiohttp
yet (but it does support OpenTelemetry in general, for the above setup + the OpenTelemetry Datadog exporter would do the trick).
OpenTelemetry
As mentioned by @agronholm on Gitter, the current trend seems to shift library integration support to OpenTelemetry, a CNCF project for collecting and exposing metrics from applications. The opentelemetry-python client has support for a variety of exporters (Prometheus (an open standard for metric ingestion as well), Datadog, OpenCensus…), as well as support for instrumentating various libraries. (There's even instrumentation for Starlette and ASGI — how interesting! I need to add this to awesome-asgi…)
Thanks to the exporter mechanism, vendors can integrate with OpenTelemetry, removing the need for building and maintaining vendor-specific integrations.
From my perspective, we'll most likely want HTTPX to be supported by OpenTelemetry, since by transitivity this will give us the widest vendor support possible.
(Side note: I checked, and OpenTelemetry seems to support asyncio
fine enough via its RuntimeContext
API (although it's sync on the surface) - see open-telemetry/opentelemetry-python#395. Doesn't seem like there's any support for trio or curio, though.)
How?
Update 2020-11-23: This is outdated — we'll most likely want to build on top of the Transport API, see #1264 (comment).
A minimal HTTPX-OpenTelemetry integration could maybe (see questions/blockers below) be built on the upcoming Event Hooks functionality (#790, #1215).
Taking inspiration from the aiohttp
OpenTelemetry implementation, I ended up with a proof-of-concept implementation in this gist:
https://gist.github.com/florimondmanca/37ffea4dc4aac0bad8f30534783b0b3d
Would be used like so:
from opentelemetry.instrumentation import httpx_client as httpx_opentelemetry
opentelemetry_on_request, opentelemetry_on_response = httpx_opentelemetry.create_hooks()
event_hooks = {
"request": [opentelemetry_on_request],
"response": [opentelemetry_on_response]
}
with httpx.Client(event_hooks=event_hooks) as client:
...
There are a couple of questions/blockers, though:
- We need to persist contextual information from the
request
hook to theresponse
hook. Right now the gist does this naively by storing an attribute on therequest
, but this would break down completely if any component in the call stack swaps the request for another (eg authentication or redirects). - Do we need a
request_exception
hook? Theaiohttp
integration uses such a hook to store an error status code based on exception information.
Overall, it feels like implementing this on top of a middleware-like API (rather than callback-based event hooks) would allow addressing these issues while providing an elegant implementation:
class OpenTelemetryInterceptor:
def __init__(self):
self._tracer = ...
def __call__(self, request, send):
with self._tracer.start_as_current_span(...) as span:
... # Store request information on `span`
try:
response = send(request)
except httpx.RequestError:
... # Process exception
raise
else:
... # Store response information on `span`.
return response
Such an "interceptor" API (as described in #790 (comment)) would also make the usage simpler, since we don't need to unpack a 2-tuple of callables and put them in the correct dict entries…
from opentelemetry.instrumentation.httpx import OpenTelemetryInterceptor
with httpx.Client(interceptors=[OpenTelemetryInterceptor()]) as client:
...