diff --git a/src/main.py b/src/main.py index 9a1d7e64..a1f47b93 100644 --- a/src/main.py +++ b/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) diff --git a/src/telemetry/client_context.py b/src/telemetry/client_context.py new file mode 100644 index 00000000..1abcb2ad --- /dev/null +++ b/src/telemetry/client_context.py @@ -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(), + } diff --git a/src/telemetry/emitter.py b/src/telemetry/emitter.py index 397ecd90..d327b3fc 100644 --- a/src/telemetry/emitter.py +++ b/src/telemetry/emitter.py @@ -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 diff --git a/tests/telemetry/test_emitter.py b/tests/telemetry/test_emitter.py index 6e05b651..ec127300 100644 --- a/tests/telemetry/test_emitter.py +++ b/tests/telemetry/test_emitter.py @@ -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", + }