nikkie-ftnextの日記

イベントレポートや読書メモを発信

opentelemetry-instrumentation-httpx のフックでリクエストボディの記録を素振り

はじめに

七尾百合子さん、お誕生日 235日目 おめでとうございます! nikkieです。

過去に OpenTelemetry でクライアント(HTTPX)と Web アプリ(FastAPI)を計装し、同一のトレース ID が得られることに感動しました。

そこにリクエストボディを残すことを考えます。

目次

opentelemetry-instrumentation-httpx の「Request and response hooks」

https://github.com/open-telemetry/opentelemetry-python-contrib/tree/v0.59b0/instrumentation/opentelemetry-instrumentation-httpx#request-and-response-hooks

HTTPXClientInstrumentorインスタンスinstrument()メソッドは、リクエストやレスポンスのフックを受け取れます。

HTTPXClientInstrumentor().instrument(
    request_hook=request_hook,
    response_hook=response_hook,
    async_request_hook=async_request_hook,
    async_response_hook=async_response_hook
)

These are functions that get called back by the instrumentation right after a span is created for a request and right before the span is finished while processing a response.

リクエストフック(関数)の引数を見ていくと

def request_hook(span, request):
    pass

async def async_request_hook(span, request):
    pass

https://github.com/open-telemetry/opentelemetry-python-contrib/blob/v0.59b0/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py#L266-L270

RequestHook = typing.Callable[[Span, "RequestInfo"], None]

AsyncRequestHook = typing.Callable[
    [Span, "RequestInfo"], typing.Awaitable[typing.Any]
]

opentelemetry.trace.span.Span1
https://github.com/open-telemetry/opentelemetry-python/blob/v1.38.0/opentelemetry-api/src/opentelemetry/trace/span.py#L56
https://opentelemetry-python.readthedocs.io/en/latest/api/trace.html#opentelemetry.trace.Span

RequestInfoは namedtuple です。
https://github.com/open-telemetry/opentelemetry-python-contrib/blob/v0.59b0/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py#L276-L281

class RequestInfo(typing.NamedTuple):
    method: bytes
    url: httpx.URL
    headers: httpx.Headers | None
    stream: httpx.SyncByteStream | httpx.AsyncByteStream | None
    extensions: dict[str, typing.Any] | None

async_request_hookを設定して、リクエストボディもトレースに記録する

FastAPI

Item を表す JSON を POST メソッドで送れるようにしました
Request Body - FastAPI

HTTPX

非同期なクライアントで実装し、async_request_hookを設定しました。

request.streamには HTTPX のByteStreamが来ます
https://github.com/encode/httpx/blob/0.28.1/httpx/_content.py#L31

class ByteStream(AsyncByteStream, SyncByteStream):
    def __init__(self, stream: bytes) -> None:
        # 省略

    def __iter__(self) -> Iterator[bytes]:
        # 省略

    async def __aiter__(self) -> AsyncIterator[bytes]:
        # 省略

HTTPX のコードを確認して見つけた扱い方にならっています。
https://github.com/encode/httpx/blob/0.28.1/tests/test_content.py#L32-L33

sync_content = b"".join(list(request.stream))
async_content = b"".join([part async for part in request.stream])

クライアントのトレースにリクエストボディが記録されています(attributes部分)!
(このID は Web アプリ側2と同一です!)

{
    "name": "POST",
    "context": {
        "trace_id": "0x6b10f19f3bcf436e7903830f179eb778",
        "span_id": "0x548ceefdb8c4319b",
        "trace_state": "[]"
    },
    "kind": "SpanKind.CLIENT",
    "parent_id": null,
    "start_time": "2025-11-07T12:32:32.251662Z",
    "end_time": "2025-11-07T12:32:32.268068Z",
    "status": {
        "status_code": "UNSET"
    },
    "attributes": {
        "http.method": "POST",
        "http.url": "http://localhost:8000/items/",
        "http.request.body": "{\"name\":\"awesome\",\"price\":100}",
        "http.status_code": 200
    },
    "events": [],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.38.0",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}

終わりに

opentelemetry-instrumentation-httpx の request hook を使って、Web アプリへのリクエストボディをトレースに残すことができました。

本番利用は未検証ですが、Web アプリもクライアントコードも自分の持ち物の場合は、今回の実装で運用時に助かりそうです。
例えば過去に経験した何万と送ったリクエストのうち1件だけエラーという事象には、トレース ID を使ってエラーを吐いた原因のリクエストを特定し、リクエストボディを元に再現調査と進めそうです。