Skip to content

Telemetry support #1264

Closed
Closed
@florimondmanca

Description

@florimondmanca

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 the response hook. Right now the gist does this naively by storing an attribute on the request, 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? The aiohttp 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:
    ...

Metadata

Metadata

Assignees

No one assigned

    Labels

    discussionexternalRoot cause pending resolution in an external dependency

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions