Commit Graph

2 Commits

Author SHA1 Message Date
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
Aakash Kattelu e8ef1a06e5
Track user and session ID on Langfuse traces (#814)
* telemetry: use session and user IDs in langfuse

* test: update old span test

* fix: disable langfuse in unit tests

* fix: add post-loop synthesis span

* refactor: address PR review feedback on langfuse tracing

- Consolidate track_name onto LLMTelemetryContext as the sole home;
  remove the honcho_llm_call kwarg and update 4 callers to set it on
  telemetry directly. Sentry ai_track now reads telemetry.track_name.
- Decouple escaped-stream self-stamping from run-context exit ordering:
  stream_final_response now resets _in_agent_run explicitly around drain.
- Narrow langfuse_agent_step wrap in the tool loop — between-turn
  bookkeeping (iteration_callback, choice switch, increment) lifted
  outside the span so it scopes only the LLM call + tools.
- Reword test conftest comment to behavior-only language.

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

* refactor: switch langfuse spans to imperative handles

Replaces the context-manager-based langfuse_agent_run/step with imperative
LangfuseAgentRun/Step handles so the run span can outlive the function that
opens it. Streaming responses now own the run handle from construction and
close it after drain, stamping the accumulated streamed text as trace output
(previously blank). Multi-turn generations always stamp provider/model and
step metadata, fixing the regression where only the first turn was annotated.

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

* fix(llm): record effective prompt-only input on run span

The run-level Langfuse span recorded the raw messages parameter, which is
None for prompt-only calls. Mirror execute_tool_loop's handling and record
the synthesized user message so the trace input isn't blank.

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

* fix(llm): drop StreamingResponseWithMetadata.__anext__ to prevent span leak

The standalone __anext__ delegated straight to the inner stream, bypassing
the token-folding and Langfuse run-handle close that live only in the
__aiter__ generator. Any caller driving the wrapper via anext() instead of
`async for` would leak the run span and lose final-stream token accounting.
Latent today (all callers use `async for`), removed to close the footgun.

Add tests covering the run-handle drain path: full drain stamps the
accumulated streamed text as the span output and closes once; an abandoned
stream still closes via the finally rather than leaking.

* chore(llm): document intentional empty-body propagate_attributes block

The `with propagate_attributes(...): pass` stamps the active @observe trace
root via the context manager's __enter__ side effect; the empty body reads
as deletable dead code. Add a comment so it isn't removed. Addresses PR review.

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

* fix(llm): restore api.py types after __anext__ removal

Dropping StreamingResponseWithMetadata.__anext__ made it stop satisfying
the AsyncIterator protocol, breaking the result annotation and the
isinstance narrowing in honcho_llm_call. Widen the tool-less result
annotation to include StreamingResponseWithMetadata and narrow positively
to HonchoLLMCallResponse before reading .content.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
2026-06-23 00:03:21 -04:00