Skip to content

Commit 602347d

Browse files
akatteluclaude
andauthored
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>
1 parent 502e20a commit 602347d

37 files changed

Lines changed: 3519 additions & 114 deletions

.env.template

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,11 @@ LLM_OPENAI_API_KEY=your-api-key-here
276276
# TELEMETRY_MAX_BUFFER_SIZE=10000
277277
# TELEMETRY_NAMESPACE=honcho # Inherits from NAMESPACE if not set
278278

279+
# Full-fidelity payload tracing (llm.call.traced / trace.content). Default-off
280+
# TELEMETRY_TRACE_PAYLOADS_ENABLED=false # Trace events ship to TELEMETRY_ENDPOINT
281+
# TELEMETRY_TRACE_MAX_BYTES=262144 # Per-message cap; oversized content is clipped
282+
# TELEMETRY_TRACE_PURPOSES=[] # JSON list of CallPurpose values to capture; empty = all
283+
279284
# =============================================================================
280285
# Cache
281286
# =============================================================================

src/config.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,19 @@ class TelemetrySettings(HonchoSettings):
11761176
# that join high-volume events to aggregate envelopes first.
11771177
HIGH_VOLUME_SAMPLE_RATE: Annotated[float, Field(default=1.0, ge=0.0, le=1.0)] = 1.0
11781178

1179+
# --- Full-fidelity payload tracing (llm.call.traced / trace.content) ---
1180+
# Master toggle for replay-grade content capture. Default-off.
1181+
TRACE_PAYLOADS_ENABLED: bool = False
1182+
1183+
# Per-message cap (bytes) for captured content; oversized string content is
1184+
# clipped (with a marker) and the call is flagged was_truncated.
1185+
TRACE_MAX_BYTES: Annotated[int, Field(default=262144, gt=0)] = 262144
1186+
1187+
# Allowlist of CallPurpose values to capture; empty = all. Typed as str to
1188+
# keep the enum out of config (validated against CallPurpose at the producer,
1189+
# same pattern as LLMTelemetryContext.call_purpose).
1190+
TRACE_PURPOSES: list[str] = Field(default_factory=list)
1191+
11791192

11801193
class CacheSettings(HonchoSettings):
11811194
model_config = SettingsConfigDict(env_prefix="CACHE_", extra="ignore") # pyright: ignore
@@ -1345,6 +1358,17 @@ def _require_api_key_for_turbopuffer(self) -> "VectorStoreSettings":
13451358
return self
13461359

13471360

1361+
class TraceViewerSettings(HonchoSettings):
1362+
model_config = SettingsConfigDict(env_prefix="TRACE_VIEWER_", extra="ignore") # pyright: ignore
1363+
1364+
ENABLED: bool = False
1365+
HOST: str = "127.0.0.1"
1366+
PORT: int = 8002
1367+
STORAGE_DIR: str = "./traces"
1368+
MAX_REQUEST_BYTES: int = 10 * 1024 * 1024 # 10 MB
1369+
VENDOR_CDN_BASE: str = "https://cdn.jsdelivr.net/npm"
1370+
1371+
13481372
class AppSettings(HonchoSettings):
13491373
# No env_prefix for app-level settings
13501374
model_config = SettingsConfigDict( # pyright: ignore
@@ -1364,6 +1388,29 @@ class AppSettings(HonchoSettings):
13641388
EMBED_MESSAGES: bool = True
13651389
LANGFUSE_HOST: str | None = None
13661390
LANGFUSE_PUBLIC_KEY: str | None = None
1391+
# How Langfuse traces are produced:
1392+
# "exporter" (default) — Langfuse is a projection over the captured
1393+
# CapturedLLMCall stream (LangfuseExporter), the same source of truth as
1394+
# the CloudEvents trace stream.
1395+
# "inline" — legacy live instrumentation (@observe + propagate_attributes
1396+
# spans during execution). Kept one release for side-by-side validation.
1397+
LANGFUSE_EXPORTER_MODE: Literal["inline", "exporter"] = "exporter"
1398+
1399+
@property
1400+
def langfuse_inline_enabled(self) -> bool:
1401+
"""True when the legacy inline Langfuse instrumentation is active
1402+
(keys configured + ``LANGFUSE_EXPORTER_MODE == "inline"``)."""
1403+
return (
1404+
bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "inline"
1405+
)
1406+
1407+
@property
1408+
def langfuse_exporter_enabled(self) -> bool:
1409+
"""True when the Langfuse exporter (a projection over the captured call
1410+
stream) is active (keys configured + ``LANGFUSE_EXPORTER_MODE == "exporter"``)."""
1411+
return (
1412+
bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "exporter"
1413+
)
13671414

13681415
# Origins allowed by the FastAPI CORSMiddleware
13691416
CORS_ORIGINS: list[str] = [
@@ -1394,6 +1441,7 @@ class AppSettings(HonchoSettings):
13941441
CACHE: CacheSettings = Field(default_factory=CacheSettings)
13951442
DREAM: DreamSettings = Field(default_factory=DreamSettings)
13961443
VECTOR_STORE: VectorStoreSettings = Field(default_factory=VectorStoreSettings)
1444+
TRACE_VIEWER: TraceViewerSettings = Field(default_factory=TraceViewerSettings)
13971445

13981446
@field_validator("LOG_LEVEL")
13991447
def validate_log_level(cls, v: str) -> str:

src/deriver/deriver.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
import time
33

4+
from nanoid import generate as generate_nanoid
5+
46
from src import crud
57
from src.config import ConfiguredModelSettings, settings
68
from src.crud.representation import RepresentationManager
@@ -142,6 +144,7 @@ async def process_representation_tasks_batch(
142144
model_config = base_model_config
143145

144146
# Single LLM call
147+
trace_id = generate_nanoid()
145148
llm_start = time.perf_counter()
146149
response = await honcho_llm_call(
147150
model_config=model_config,
@@ -159,6 +162,8 @@ async def process_representation_tasks_batch(
159162
parent_category="representation",
160163
observed=observed,
161164
track_name="Minimal Deriver",
165+
trace_id=trace_id,
166+
span_id=trace_id,
162167
),
163168
)
164169
llm_duration = (time.perf_counter() - llm_start) * 1000

src/dialectic/chat.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ async def agentic_chat(
5050
session = await crud.get_session(
5151
db, workspace_name=workspace_name, session_name=session_name
5252
)
53+
# Read the opaque Session.id while the instance is still bound; the ORM
54+
# object detaches once this read-only session closes below.
55+
session_id = session.id if session else None
5356
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
5457
configuration = get_configuration(None, session, workspace)
5558

@@ -68,6 +71,7 @@ async def agentic_chat(
6871
agent = DialecticAgent(
6972
workspace_name=workspace_name,
7073
session_name=session_name,
74+
session_id=session_id,
7175
observer=observer,
7276
observed=observed,
7377
observer_peer_card=observer_peer_card,
@@ -111,6 +115,9 @@ async def agentic_chat_stream(
111115
session = await crud.get_session(
112116
db, workspace_name=workspace_name, session_name=session_name
113117
)
118+
# Read the opaque Session.id while the instance is still bound; the ORM
119+
# object detaches once this read-only session closes below.
120+
session_id = session.id if session else None
114121
workspace = await crud.get_workspace(db, workspace_name=workspace_name)
115122
configuration = get_configuration(None, session, workspace)
116123

@@ -129,6 +136,7 @@ async def agentic_chat_stream(
129136
agent = DialecticAgent(
130137
workspace_name=workspace_name,
131138
session_name=session_name,
139+
session_id=session_id,
132140
observer=observer,
133141
observed=observed,
134142
observer_peer_card=observer_peer_card,

src/dialectic/core.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def __init__(
6868
observed_peer_card: list[str] | None = None,
6969
metric_key: str | None = None,
7070
reasoning_level: ReasoningLevel = "low",
71+
session_id: str | None = None,
7172
):
7273
"""
7374
Initialize the dialectic agent.
@@ -81,9 +82,11 @@ def __init__(
8182
observed_peer_card: Biographical information about the observed peer
8283
metric_key: Optional key for logging metrics (if provided, agent won't log separately)
8384
reasoning_level: Level of reasoning to apply
85+
session_id: ID used for grouping traces (not session_name)
8486
"""
8587
self.workspace_name: str = workspace_name
8688
self.session_name: str | None = session_name
89+
self.session_id: str | None = session_id
8790
self.observer: str = observer
8891
self.observed: str = observed
8992
self.observer_peer_card: list[str] | None = observer_peer_card
@@ -179,6 +182,7 @@ async def _prefetch_relevant_observations(self, query: str) -> str | None:
179182
workspace_name=self.workspace_name,
180183
run_id=self._run_id,
181184
parent_category="dialectic",
185+
session_id=self.session_id,
182186
):
183187
query_embedding = await embedding_client.embed(query)
184188

@@ -316,6 +320,9 @@ def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryConte
316320
parent_category="dialectic",
317321
agent_type="dialectic",
318322
run_id=self._run_id,
323+
trace_id=self._run_id,
324+
span_id=self._run_id,
325+
session_id=self.session_id,
319326
peer_name=self.observed,
320327
track_name=track_name,
321328
)

src/dreamer/specialists.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ async def run(
169169
SpecialistResult with metrics and content
170170
"""
171171
run_id = parent_run_id or generate_nanoid()
172+
# Specialists sharing the orchestrator's run_id (one dream trace) each get a
173+
# distinct span_id so their CloudEvents trace resource ids don't collide;
174+
# trace_id stays run_id so Langfuse still groups them (keyed by agent_type).
175+
span_id = generate_nanoid() if parent_run_id is not None else run_id
172176
task_name = f"dreamer_{self.name}_{run_id}"
173177
start_time = time.perf_counter()
174178

@@ -292,6 +296,11 @@ async def run(
292296
parent_category="dream",
293297
agent_type=self.name,
294298
run_id=run_id,
299+
# Root span per specialist run (distinct span_id, see above).
300+
# parent_span_id stays None for now; wiring specialists as
301+
# children of a dream-level trace is forking (out of scope).
302+
trace_id=run_id,
303+
span_id=span_id,
295304
observer=observer,
296305
observed=observed,
297306
track_name=f"Dreamer/{self.name}",

src/embedding_client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import tiktoken
1010
from google import genai
1111
from google.genai import types as genai_types
12+
from nanoid import generate as generate_nanoid
1213
from openai import AsyncOpenAI
1314

1415
from .config import EmbeddingModelConfig, resolve_embedding_model_config, settings
@@ -88,6 +89,7 @@ def _publish_embedding_event(
8889
get_embedding_call_purpose,
8990
get_embedding_parent_category,
9091
get_embedding_run_id,
92+
get_embedding_session_id,
9193
get_embedding_workspace_name,
9294
)
9395

@@ -121,6 +123,30 @@ def _publish_embedding_event(
121123
run_id=get_embedding_run_id(),
122124
)
123125
)
126+
127+
# Trace stream (ground-truth) — gated on payload tracing. Each embedding
128+
# gets its own span nested under the driving agent run (parent_span_id =
129+
# run_id), so multiple embeddings in one run don't share a span id.
130+
if settings.TELEMETRY.TRACE_PAYLOADS_ENABLED:
131+
from src.telemetry.events import EmbeddingCallTracedEvent, emit_trace
132+
133+
run_id = get_embedding_run_id()
134+
span_id = generate_nanoid()
135+
emit_trace(
136+
EmbeddingCallTracedEvent(
137+
trace_id=run_id or span_id,
138+
span_id=span_id,
139+
parent_span_id=run_id,
140+
session_id=get_embedding_session_id(),
141+
call_purpose=purpose_slug,
142+
parent_category=get_embedding_parent_category(),
143+
provider=provider,
144+
model=model,
145+
provider_input_tokens=input_tokens_estimate,
146+
provider_output_tokens=0,
147+
input_count=input_count,
148+
)
149+
)
124150
except Exception: # pragma: no cover - telemetry must not raise
125151
logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True)
126152

src/llm/backend.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class ToolCallResult:
1414
id: str
1515
name: str
1616
input: dict[str, Any]
17-
thought_signature: str | None = None
17+
# Gemini returns this as raw bytes; other providers omit it.
18+
thought_signature: str | bytes | None = None
1819

1920

2021
@dataclass(slots=True)

0 commit comments

Comments
 (0)