Skip to content

HTTP Client Instrumentation

Prometheus metrics and OTel trace spans for outbound httpx.AsyncClient calls. Every external API call made by the service becomes visible in Prometheus and in the distributed trace.

Installation

Bash
pip install obskit httpx

Quick Start

Python
import httpx
from obskit.integrations.http import instrument_httpx

# Wrap at construction time
client = instrument_httpx(httpx.AsyncClient(), name="twitter")

# All async methods are automatically instrumented
response = await client.get("https://api.twitter.com/endpoint")

Metrics

Metric Type Labels Description
http_client_requests_total Counter name, method, status_code Total outbound requests. status_code is the HTTP response code ("200", "404", …) or "error" for network exceptions.
http_client_duration_seconds Histogram name, method End-to-end request latency.

Trace Spans

Each HTTP call creates an OTel span named "HTTP <METHOD>" with:

  • http.method — e.g. GET, POST
  • http.client.name — the name you passed to instrument_httpx

The W3C traceparent header is injected into every outgoing request so the upstream service can join the trace.

Context Manager

Python
async with instrument_httpx(httpx.AsyncClient(), name="facebook") as client:
    response = await client.post(url, json=payload)

Multiple Clients

Python
twitter_client  = instrument_httpx(httpx.AsyncClient(base_url="https://api.twitter.com"),  name="twitter")
facebook_client = instrument_httpx(httpx.AsyncClient(base_url="https://graph.facebook.com"), name="facebook")
whatsapp_client = instrument_httpx(httpx.AsyncClient(base_url="https://api.whatsapp.com"),  name="whatsapp")

Each name value appears as a distinct name label in Prometheus, keeping metrics for different platform adapters separate.

API Reference

obskit.integrations.http.instrument_httpx

Python
instrument_httpx(
    client: Any, *, name: str = "default"
) -> InstrumentedHttpxClient

Wrap an httpx.AsyncClient with Prometheus metrics and OTel trace spans.

Parameters

client : httpx.AsyncClient The client instance to instrument. name : str Human-readable name used as the name label in metric series. Default: "default".

Returns

InstrumentedHttpxClient A transparent proxy that records metrics and creates OTel spans for every HTTP method call (get, post, put, patch, delete, head, options, request).

Example

import httpx from obskit.integrations.http import instrument_httpx

client = instrument_httpx(httpx.AsyncClient(), name="twitter") response = await client.get("https://api.twitter.com/endpoint")

Source code in src/obskit/integrations/http.py
Python
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
def instrument_httpx(client: Any, *, name: str = "default") -> InstrumentedHttpxClient:
    """Wrap an ``httpx.AsyncClient`` with Prometheus metrics and OTel trace spans.

    Parameters
    ----------
    client : httpx.AsyncClient
        The client instance to instrument.
    name : str
        Human-readable name used as the ``name`` label in metric series.
        Default: ``"default"``.

    Returns
    -------
    InstrumentedHttpxClient
        A transparent proxy that records metrics and creates OTel spans for
        every HTTP method call (``get``, ``post``, ``put``, ``patch``,
        ``delete``, ``head``, ``options``, ``request``).

    Example
    -------
    >>> import httpx
    >>> from obskit.integrations.http import instrument_httpx
    >>>
    >>> client = instrument_httpx(httpx.AsyncClient(), name="twitter")
    >>> response = await client.get("https://api.twitter.com/endpoint")
    """
    return InstrumentedHttpxClient(client, name)

obskit.integrations.http.InstrumentedHttpxClient

Prometheus + OTel instrumentation proxy for httpx.AsyncClient.

Do not instantiate directly — use :func:instrument_httpx.

Parameters

client : httpx.AsyncClient The underlying async HTTP client. name : str Human-readable label used in Prometheus metric series.

Source code in src/obskit/integrations/http.py
Python
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
class InstrumentedHttpxClient:
    """Prometheus + OTel instrumentation proxy for ``httpx.AsyncClient``.

    Do not instantiate directly — use :func:`instrument_httpx`.

    Parameters
    ----------
    client : httpx.AsyncClient
        The underlying async HTTP client.
    name : str
        Human-readable label used in Prometheus metric series.
    """

    __slots__ = ("_client", "_name")

    def __init__(self, client: Any, name: str) -> None:
        object.__setattr__(self, "_client", client)
        object.__setattr__(self, "_name", name)

    def __getattr__(self, attr: str) -> Any:
        original = getattr(self._client, attr)

        if attr not in _HTTP_METHODS or not asyncio.iscoroutinefunction(original):
            return original

        name = self._name
        method_label = attr.upper()

        @functools.wraps(original)
        async def _instrumented(*args: Any, **kwargs: Any) -> Any:
            # For the generic `request(method, url, ...)` form, extract the
            # HTTP method from the first positional arg.
            if attr == "request" and args:
                _method = str(args[0]).upper()
            else:
                _method = method_label

            # Ensure we have a mutable dict for traceparent injection.
            if kwargs.get("headers") is None:
                kwargs["headers"] = {}
            if not isinstance(kwargs["headers"], dict):
                kwargs["headers"] = dict(kwargs["headers"])

            start = time.perf_counter()
            status_code = "error"

            from obskit.tracing.tracer import (  # noqa: PLC0415
                async_trace_span,
                inject_trace_context,
            )

            async with async_trace_span(
                f"HTTP {_method}",
                component="http_client",
                attributes={"http.method": _method, "http.client.name": name},
            ):
                # Inject the current span's W3C traceparent into outgoing headers.
                inject_trace_context(kwargs["headers"])

                try:
                    response = await original(*args, **kwargs)
                    status_code = str(response.status_code)
                    return response
                except Exception:
                    raise
                finally:
                    elapsed = time.perf_counter() - start
                    HTTP_CLIENT_REQUESTS_TOTAL.labels(
                        name=name, method=_method, status_code=status_code
                    ).inc()
                    HTTP_CLIENT_DURATION_SECONDS.labels(
                        name=name, method=_method
                    ).observe(elapsed)

        return _instrumented

    # ------------------------------------------------------------------
    # Async context manager — proxy to the underlying client
    # ------------------------------------------------------------------

    async def __aenter__(self) -> InstrumentedHttpxClient:
        await self._client.__aenter__()
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self._client.__aexit__(*args)