honcho/tests/telemetry
Aakash Kattelu 602347d76c
feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845)
* feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream

Capture each LLM call once (CapturedLLMCall) and fan it out to multiple
exporters -- "one data model, two projections": a CloudEvents trace stream
(llm.call.traced / trace.content) and a Langfuse projection, both reconstructing
trace -> run -> step -> generation from the same source of truth.

- Capture seam (src/llm/capture.py): one canonicalization + content-addressed
  hashing point, with an O(N) per-span memo so repeated context isn't re-hashed.
- Session correlation threaded telemetry -> captured call -> exporters,
  namespaced only at the Langfuse export boundary.
- Span identity consolidated onto LLMTelemetryContext; dropped TRACE_ENDPOINT.
- Canonical generation/step names; dreamer branches nest under one dream trace;
  tool calls become spans under their step.
- LANGFUSE_EXPORTER_MODE toggle ("exporter" default; "inline" kept one release
  for side-by-side validation), centralized into computed settings predicates.
- Per-run/per-trace dedup registries (trace_session, langfuse_session) bounded
  by an LRU so dedup and span grouping survive long-running workers.
- Embedding-call tracing; deterministic high-volume event sampling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(telemetry): address trace-review findings (span/step_seq collisions, test, logging)

- Dreamer specialists mint a distinct span_id per execution (trace_id stays the
  shared dream run_id), so their CloudEvents trace resource ids no longer collide
  between deduction and induction.
- Tool-loop no-tool early-return streams the tail with the next ordinal
  (iteration+2) instead of reusing the in-loop call's step_seq, avoiding a
  colliding trace resource id; mirrors the synthesis path.
- Tighten test_clips_oversized_string to assert output stays within TRACE_MAX_BYTES.
- emit_trace logs the swallowed exception with exc_info for debuggability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(telemetry): silence exporter-mode Langfuse warning + drop summarizer run_id placeholder

Two CloudEvents/Langfuse correctness fixes, independent of the trace viewer.

Langfuse exporter-mode gating: annotate_current_generation_io (and its two
executor.py call-site guards) were gated on LANGFUSE_PUBLIC_KEY instead of
langfuse_inline_enabled. In the default `exporter` mode they called
get_client().update_current_generation() with no active @observe span, logging
"No active span in current context" (~14 per dialectic run) and building
throwaway model_dump payloads on every LLM call. The LangfuseExporter projects
I/O from the captured stream, so these helpers must no-op in exporter mode.
Gated all three on langfuse_inline_enabled; added a regression test; fixed a
stale conditional_observe docstring.

Summarizer run_id placeholder: AgentToolSummaryCreatedEvent hardcoded
run_id="deriver"/iteration=0 because summarization is a single LLM call, not an
agentic run. That placeholder pollutes run_id grouping in the CloudEvents stream
(any consumer that groups by run_id sees a phantom "deriver" run). Made
run_id/iteration optional (None) and re-keyed get_resource_id on
message_id:summary_type (the real per-summary identity; run_id/iteration can no
longer identify it); bumped schema_version 2->3. Xatu ingestion stores only the
CloudEvent envelope, so the field/resource_id/version changes are transparent to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: update docstrings to be less verbose

* fix(telemetry): address PR review on captured-stream tracing

- embedding traces get a fresh span_id under parent_span_id=run_id, so
  sibling embeddings in one run no longer share a span/idempotency key
- capture the provider finish_reason from stream chunks instead of
  hardcoding "stop" on a successful drain
- gate the Langfuse exporter behind TELEMETRY.ENABLED (master switch) so
  disabling telemetry sends no traces at all
- rename _emit_derived_content -> _emit_hashed_content
- inline the _emit_trace wrapper; drop unused trace_session.end_run

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: rename TELEMETRY_TRACE_PAYLOADS to TELEMETRY_TRACE_PAYLOADS_ENABLED

* fix(telemetry): capture provider tool calls in trace stream

The captured trace stream dropped assistant tool calls for openai/gemini:
build_captured_messages only read {role, content, tool_call_id}, but those
providers keep tool calls outside content (openai's tool_calls, gemini's
parts), so replayed tool-call turns landed as empty content and gemini lost
its text and tool results entirely. Anthropic (tool_use in content) was fine.

Normalize each input message per provider into a unified tool_calls
[{id, name, input}] field on CapturedMessage/TraceContentEvent, recovering
gemini text/results along the way, and fold tool_calls into
compute_content_hash so empty-content openai turns no longer collide in the
dedup store. langfuse_exporter._input now surfaces the calls.

Also fix a silent serialization drop: gemini thought_signature is bytes, so
model_dump(mode="json") on the traced event raised UnicodeDecodeError and
emit_trace swallowed it -- dropping the whole tool-calling iteration from the
trace stream (billing and Langfuse were unaffected). base64-encode the
signature on the telemetry path; replay keeps the raw bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): type replay tool-call dict for bytes signature

thought_signature widened to str | bytes | None, but
_tool_call_result_to_dict's literal was inferred as
dict[str, str | dict[str, Any]], so the bytes assignment failed project-wide
basedpyright (the per-file pre-commit hook didn't catch it). Annotate the
dict as dict[str, Any]; the replay path keeps the raw bytes unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: remove 3 tests

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:49:53 -04:00
..
__init__.py Telemetry Overhaul (#333) 2026-01-21 16:49:14 -05:00
conftest.py feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) 2026-07-02 16:49:53 -04:00
test_cross_agent_trace.py feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) 2026-07-02 16:49:53 -04:00
test_embedding_call_event.py feat: add new cloudevents for api routes (#637) 2026-05-20 18:25:30 -04:00
test_embedding_trace.py feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) 2026-07-02 16:49:53 -04:00
test_emit_function.py feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) 2026-07-02 16:49:53 -04:00
test_emitter.py feat: add new cloudevents for api routes (#637) 2026-05-20 18:25:30 -04:00
test_events.py feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) 2026-07-02 16:49:53 -04:00
test_langfuse_exporter.py feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) 2026-07-02 16:49:53 -04:00
test_representation_v2_fields.py feat: add new cloudevents for api routes (#637) 2026-05-20 18:25:30 -04:00
test_sentry_sampler.py Filter noisy sentry traces/profiles (#834) 2026-06-22 21:14:55 -04:00
test_summary_v2_fields.py feat: add new cloudevents for api routes (#637) 2026-05-20 18:25:30 -04:00
test_trace_events.py feat(telemetry): CloudEvents + Langfuse tracing as projections over a captured LLM stream (#845) 2026-07-02 16:49:53 -04:00