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:
parent
2ad56a4d71
commit
b9877e14d8
15
src/main.py
15
src/main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue