feat(telemetry): inject client identity into event bodies

Read X-Honcho-Host, X-Honcho-Plugin and X-Honcho-Agent-Model in the
request middleware into ContextVars next to the request ID, and have the
CloudEvents emitter inject them as a nested `client` object alongside
`honcho_version`. Members are null outside a request (deriver worker).

Emitter-injected body fields are exempt from per-event schema
versioning, so no event schemas are bumped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ajspig 2026-09-03 12:43:31 -04:00
parent 2ad56a4d71
commit b9877e14d8
4 changed files with 151 additions and 0 deletions

View File

@ -44,6 +44,13 @@ from src.telemetry import (
register_db_pool_collector,
shutdown_telemetry,
)
from src.telemetry.client_context import (
HEADER_AGENT_MODEL,
HEADER_HOST,
HEADER_PLUGIN,
reset_client_context,
set_client_context,
)
from src.telemetry.logging import get_route_template
from src.telemetry.sentry import initialize_sentry
@ -248,6 +255,13 @@ async def track_request(
# Store in request state and context var
request.state.request_id = request_id
token = request_context.set(f"api:{request_id}")
# Optional client identity headers; the telemetry emitter injects these
# into every event body emitted during this request.
client_tokens = set_client_context(
host=request.headers.get(HEADER_HOST),
plugin=request.headers.get(HEADER_PLUGIN),
agent_model=request.headers.get(HEADER_AGENT_MODEL),
)
try:
start_time = time.perf_counter()
@ -265,4 +279,5 @@ async def track_request(
return response
finally:
reset_client_context(client_tokens)
request_context.reset(token)

View File

@ -0,0 +1,75 @@
"""Per-request client identity for telemetry enrichment.
Clients may send ``X-Honcho-Host``, ``X-Honcho-Plugin`` and
``X-Honcho-Agent-Model`` to identify themselves. The API middleware parks the
values in ContextVars for the duration of the request; the CloudEvents emitter
reads them when it serializes an event body, as a nested ``client`` object::
"client": {
"host": "claude-code/2.1.3 (darwin)",
"plugin": "claude-honcho/0.2.11",
"agent_model": "claude-sonnet-4-5"
}
Outside a request (deriver worker, tests, startup) the vars are unset and
every member is ``null``; the ``client`` object itself is always present so
``data.client.host`` is a safe path for consumers.
These are emitter-injected body fields, like ``honcho_version``, and are
exempt from per-event schema versioning.
"""
from contextvars import ContextVar, Token
HEADER_HOST = "X-Honcho-Host"
HEADER_PLUGIN = "X-Honcho-Plugin"
HEADER_AGENT_MODEL = "X-Honcho-Agent-Model"
# Header values are client-controlled; cap them so a misbehaving client can't
# bloat every event body.
_MAX_VALUE_LEN = 256
client_host: ContextVar[str | None] = ContextVar("client_host", default=None)
client_plugin: ContextVar[str | None] = ContextVar("client_plugin", default=None)
client_agent_model: ContextVar[str | None] = ContextVar(
"client_agent_model", default=None
)
ClientContextTokens = tuple[Token[str | None], Token[str | None], Token[str | None]]
def _clean(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not value:
return None
return value[:_MAX_VALUE_LEN]
def set_client_context(
*, host: str | None, plugin: str | None, agent_model: str | None
) -> ClientContextTokens:
"""Set the client ContextVars; returns tokens for ``reset_client_context``."""
return (
client_host.set(_clean(host)),
client_plugin.set(_clean(plugin)),
client_agent_model.set(_clean(agent_model)),
)
def reset_client_context(tokens: ClientContextTokens) -> None:
"""Restore the client ContextVars to their pre-request values."""
host_token, plugin_token, model_token = tokens
client_host.reset(host_token)
client_plugin.reset(plugin_token)
client_agent_model.reset(model_token)
def client_context_body() -> dict[str, str | None]:
"""The ``client`` object the emitter injects; members are ``None`` when unset."""
return {
"host": client_host.get(),
"plugin": client_plugin.get(),
"agent_model": client_agent_model.get(),
}

View File

@ -20,6 +20,7 @@ from cloudevents.conversion import to_json # pyright: ignore[reportUnknownVaria
from cloudevents.http import CloudEvent
from src._version import HONCHO_VERSION
from src.telemetry.client_context import client_context_body
if TYPE_CHECKING:
from src.telemetry.events.base import BaseEvent
@ -303,6 +304,9 @@ class TelemetryEmitter:
# unchanged. Only the serialized body that hits the wire carries the extras.
body: dict[str, Any] = event.model_dump(mode="json")
body["honcho_version"] = HONCHO_VERSION
# Client identity from the request headers (set by the API middleware);
# members are null outside a request, e.g. in the deriver worker.
body["client"] = client_context_body()
# Buffer-full check happens here because deque(maxlen=) silently evicts.
# Detect by length-before-append; if at capacity, the append will displace

View File

@ -15,6 +15,7 @@ import asyncio
import contextlib
import json
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -1037,3 +1038,59 @@ class TestHonchoVersionInjection:
# The event instance must be unchanged by emit().
assert before == after
assert "honcho_version" not in after
class TestClientContextInjection:
"""The ``client`` body object comes from X-Honcho-* request headers via the
API middleware; like honcho_version it is emitter-injected and exempt from
per-event schema versioning."""
@staticmethod
def _emit_and_capture_body(emitter: TelemetryEmitter) -> dict[str, Any]:
with patch("src.config.settings") as mock_settings:
mock_settings.TELEMETRY.NAMESPACE = "test"
emitter.emit(create_test_event())
return emitter._buffer[-1].data
def test_client_object_null_members_outside_request(self):
emitter = TelemetryEmitter(endpoint="http://test:8001/events")
emitter._running = True
body = self._emit_and_capture_body(emitter)
assert body["client"] == {"host": None, "plugin": None, "agent_model": None}
def test_request_headers_land_in_client_object(self):
"""End to end: the real track_request middleware sets the ContextVars
from the headers, and an event emitted inside the request carries them."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.main import track_request
emitter = TelemetryEmitter(endpoint="http://test:8001/events")
emitter._running = True
app = FastAPI()
app.middleware("http")(track_request)
async def probe() -> dict[str, Any]:
return self._emit_and_capture_body(emitter)
app.get("/probe")(probe)
with TestClient(app) as client:
resp = client.get(
"/probe",
headers={
"X-Honcho-Host": "claude-code/2.1.3 (darwin)",
"X-Honcho-Plugin": "claude-honcho/0.2.11",
"X-Honcho-Agent-Model": "claude-sonnet-4-5",
},
)
assert resp.json()["client"] == {
"host": "claude-code/2.1.3 (darwin)",
"plugin": "claude-honcho/0.2.11",
"agent_model": "claude-sonnet-4-5",
}