Skip to content

fix(tracing): make a fleet agent's trace whole when it runs in its own Langfuse project#1893

Merged
mabry1985 merged 1 commit into
mainfrom
fix/fleet-whole-trace-generation
Jul 7, 2026
Merged

fix(tracing): make a fleet agent's trace whole when it runs in its own Langfuse project#1893
mabry1985 merged 1 commit into
mainfrom
fix/fleet-whole-trace-generation

Conversation

@mabry1985

@mabry1985 mabry1985 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Closes #1892.

Problem

When a fleet agent is pointed at a dedicated Langfuse project (separate from the shared LiteLLM gateway's project), a single turn's trace fragments across two projects:

  • SPANs (a2a-stream, tool:*) → the agent's project (agent's own OTLP key). ✅
  • GENERATIONs (<agent>-turn, the LLM call with model/tokens/cost) → the gateway's project — because the gateway's LiteLLM success callback logs every generation with the gateway's Langfuse key.

Same trace_id, two project_ids. A no-tool turn then shows in the agent's project as just an empty a2a-stream wrapper — the actual model call is invisible there. TraceContextMiddleware's docstring even names the now-broken assumption: "the gateway runs its Langfuse callback in the SAME project."

Fix

TraceContextMiddleware.wrap_model_call is the single seam that sees every model call. It already stamps the trace context onto the request; now it also emits — into the agent's own project — a lightweight generation via a new observability.tracing.trace_generation() helper: model + token usage + cost + duration, no prompt/completion payload. The full IO stays in the gateway project (unchanged), joinable by trace_id.

  • When agent and gateway share a project (the common case), this is a small summary node next to the gateway's detailed one — harmless.
  • When they differ, the agent's trace is now whole.

This deliberately keeps the heavy LLM detail in the gateway project (that's where LLM-observability for all consumers lives) while making the fleet trace stand on its own.

Tests

tests/test_trace_context_middleware.py — 6 new cases: records model/usage/cost, handles both ModelResponse.result and bare-AIMessage returns, no-op when tracing disabled, never raises on garbage/sink-blowup, and awrap_model_call emits + passes the response through untouched. Full file green (13) + adjacent trace/subagent suites (18 total).

Context

Homelab fleet dogfooding — jon/roxy/frank/vera moved to a dedicated protoLabs.studio Langfuse project (homelab-iac #185); the gateway stays on its own project for external consumers. Requires a base rebuild + fleet redeploy to activate.

Summary by CodeRabbit

  • New Features

    • Added improved tracing for model calls, including duration and usage details, so completed generations are captured more consistently.
    • Enhanced tracing to preserve visibility even when responses are returned in different shapes.
  • Bug Fixes

    • Reduced gaps in trace records when model calls are routed through different projects.
    • Tracing now fails safely, avoiding interruptions if logging is unavailable or data is incomplete.

…e is whole

When an agent runs in a *different* Langfuse project than the LiteLLM
gateway (e.g. a dedicated fleet project), the gateway's success callback
logs the full-detail generation into the GATEWAY's project — leaving the
agent's own trace with a hole where its model call should be. Only the
structural spans (a2a-stream, tool:*) land in the agent's project, so a
no-tool turn shows as an empty wrapper and the trace is un-whole.

TraceContextMiddleware already sees every model call (it stamps the trace
context onto the request). Extend it to ALSO emit, into the agent's own
Langfuse project, a lightweight generation — model + token usage + cost,
no prompt/completion payload — via a new tracing.trace_generation() helper.
The heavy IO stays in the gateway project (unchanged); the two are joinable
by trace_id. When agent and gateway share a project (the common case) this
is just a small summary node alongside the gateway's detailed one.

Best-effort throughout: no-op when tracing is disabled, handles both the
ModelResponse and bare-AIMessage return shapes, never raises.

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

protoquinn Bot commented Jul 7, 2026

Copy link
Copy Markdown

👀 Quinn is reviewing — verdict (PASS / WARN / FAIL) + findings to follow.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a trace_generation tracing helper in observability/tracing.py that logs an LLM generation as a Langfuse observation with model, usage, cost, and duration metadata. Middleware wrap_model_call/awrap_model_call now measure call duration and emit a fleet generation via a new _emit_fleet_generation helper. Tests cover the new behavior.

Changes

Fleet Generation Tracing

Layer / File(s) Summary
trace_generation tracing helper
observability/tracing.py
Adds trace_generation(...) which creates a Langfuse generation observation with model, usage_details, cost_details, and metadata (session_id, trace_id, duration_ms); no-ops when tracing is disabled or on exception.
Middleware fleet generation emission
graph/middleware/trace_context.py
Adds time import and _emit_fleet_generation(response, duration_ms), a best-effort helper extracting AIMessage/usage/model from the response and computing cost, then calling trace_generation. Updates wrap_model_call/awrap_model_call to time handler calls and invoke this emission.
Fleet generation tests
tests/test_trace_context_middleware.py
Adds _Resp/_ai helpers and tests covering usage/cost/duration recording, bare AIMessage responses, disabled tracing no-op, exception swallowing, and async awrap_model_call emission.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant TraceContextMiddleware
  participant Handler
  participant Tracing as observability.tracing

  Caller->>TraceContextMiddleware: wrap_model_call/awrap_model_call(request)
  TraceContextMiddleware->>Handler: handler(_with_trace(request))
  Handler-->>TraceContextMiddleware: response (AIMessage, usage, model)
  TraceContextMiddleware->>TraceContextMiddleware: compute duration_ms via time.monotonic()
  TraceContextMiddleware->>Tracing: _emit_fleet_generation(response, duration_ms)
  Tracing->>Tracing: cost_usd via pricing.cost_usd(model, usage)
  Tracing->>Tracing: trace_generation(name, model, usage, cost_usd, duration_ms)
  TraceContextMiddleware-->>Caller: response
Loading

Possibly related issues

Possibly related PRs

  • protoLabsAI/protoAgent#1880: Both PRs modify TraceContextMiddleware's wrap_model_call/awrap_model_call wrapping logic in graph/middleware/trace_context.py, this PR building generation emission on top of that trace-context stamping.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the tracing fix for fleet agents in their own Langfuse project.
Linked Issues check ✅ Passed The PR implements the linked fix by emitting a lightweight generation from protoAgent while preserving gateway logging.
Out of Scope Changes check ✅ Passed The added helper, middleware changes, and tests all support the tracing fix and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fleet-whole-trace-generation

Comment @coderabbitai help to get the list of available commands.

@protoquinn protoquinn Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Audit — PR #1893 | fix(tracing): make a fleet agent's trace whole when it runs in its own Langfuse project

VERDICT: WARN (pending CI — all 8 checks queued/in_progress; approve-on-green policy promotes to APPROVED automatically once every check is terminal-green, no re-review needed)


CI Status

  • build: queued
  • Fleet integration (multi-instance): queued
  • Python tests: queued
  • Lint (ruff + import contracts): queued
  • A2A live smoke (lean tier): queued
  • Web E2E smoke: queued
  • Verify workspace config: queued
  • gitleaks (tree): in_progress

Diff Review

  • 3 files, +203/−2 — trace_context.py, tracing.py, and tests. Clean implementation spanning the middleware seam and the observability layer.
  • _emit_fleet_generation follows the same defensive pattern as _with_trace: try/except Exception + debug log, never raises. Correctly handles both ModelResponse.result and bare AIMessage returns.
  • trace_generation() in observability/tracing.py mirrors trace_tool_call()start_observation + immediate end so it nests under the session span without becoming the parent. Usage/cost encoding matches Langfuse conventions.
  • Timing uses time.monotonic() (correct clock, immune to NTP/wall adjustments).
  • 6 new tests cover emission path, bare-AIMessage, disabled-tracing no-op, error resilience, and async passthrough — all well-isolated with monkeypatch.

Observations

  • CLAWPATCH: no findings on the PR's changed files — clean structural pass.
  • CodeRabbit: no unresolved threads.
  • LOW: _emit_fleet_generation calls from observability import pricing, tracing inside the method body on every invocation. Python caches imports, so this is a style nit, not a perf issue — but worth noting it's intentional (avoiding module-level import that might load Langfuse SDK before config is settled).

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 7, 2026

Copy link
Copy Markdown

Submitted COMMENT review on #1893.

@protoquinn

protoquinn Bot commented Jul 7, 2026

Copy link
Copy Markdown

✅ CI went terminal-green with no blockers on the prior review — promoting it to APPROVED per the approve-on-green policy (#748).

@protoquinn protoquinn Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI terminal-green, no blockers on prior review — auto-approving on green (#748).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@observability/tracing.py`:
- Around line 391-418: Extract the repeated observation lifecycle in
trace_generation into a shared private helper, since it duplicates the same
enable-check/try/_langfuse.start_observation/.end()/except None pattern already
used by trace_tool_call. Add a small helper such as _start_and_end that accepts
as_type, name, and kwargs, performs the start/end/swallows-exceptions flow, and
have trace_generation call it while keeping its usage_details, cost_details, and
metadata assembly intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3524d89e-6355-484f-8371-09d80d60eee3

📥 Commits

Reviewing files that changed from the base of the PR and between cdb3e37 and abe3b7a.

📒 Files selected for processing (3)
  • graph/middleware/trace_context.py
  • observability/tracing.py
  • tests/test_trace_context_middleware.py

Comment thread observability/tracing.py
Comment on lines +391 to +418
try:
usage_details = None
if usage:
candidates = {
"input": usage.get("input_tokens"),
"output": usage.get("output_tokens"),
"total": usage.get("total_tokens"),
}
usage_details = {k: int(v) for k, v in candidates.items() if v} or None
span = _langfuse.start_observation(
name=name,
as_type="generation",
model=model or None,
usage_details=usage_details,
cost_details=({"total": float(cost_usd)} if cost_usd else None),
metadata={
"session_id": session_id,
"trace_id": _trace_id_ctx.get(),
"duration_ms": duration_ms,
# Full prompt/completion IO is logged by the gateway's LiteLLM
# callback into the gateway Langfuse project (joined by trace_id).
"detail_in": "gateway_project",
},
)
span.end()
return span
except Exception:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the shared "start_observation → end → swallow" scaffold.

trace_generation duplicates the enable-check/try/start_observation/.end()/except-None skeleton already used by trace_tool_call (lines 332-359). A small private helper (e.g., _start_and_end(as_type, name, **kwargs)) would remove this duplication as more observation-emitting helpers are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observability/tracing.py` around lines 391 - 418, Extract the repeated
observation lifecycle in trace_generation into a shared private helper, since it
duplicates the same enable-check/try/_langfuse.start_observation/.end()/except
None pattern already used by trace_tool_call. Add a small helper such as
_start_and_end that accepts as_type, name, and kwargs, performs the
start/end/swallows-exceptions flow, and have trace_generation call it while
keeping its usage_details, cost_details, and metadata assembly intact.

@protoreview protoreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA panel review — WARN

code-review-structural · head abe3b7a83b48 · shadow — comment-only

[review-synthesizer completed: workflow code-review-structural:report]

Prose brief

Overall risk: Low. All findings are minor or nit — the two correctness items (adjacent truthiness checks in tracing.py) are the same root cause: if v / if cost_usd silently discard legitimate zero values from generation traces. No data is lost outside observability, but downstream trace consumers would see gaps where zero-token or zero-cost calls happened.

Fix first: The two falsy-guard bugs in observability/tracing.py (lines ~393 and ~398). A single PR changing both if vif v is not None and if cost_usd else None → unconditional cost_details construction repairs both. The session_id omission in _emit_fleet_generation is the next priority — it's a one-line kwarg addition.

Disagreements: None — the panel was a single structural-analysis pass, and the verifier confirmed every finding against the diff and surrounding code. No LLM-based review findings were submitted to merge or reconcile.

Verification impact: All six findings survived; none were refuted. The verifier strengthened each with concrete notes (e.g., pricing.cost_usd docstring confirming 0.0 for empty usage, the test_trace_context_middleware.py monkeypatch confirming the real trace_generation path is never exercised).

Gaps: No LLM review-finder passes contributed findings — the entire panel consists of structural-analysis output. The structural pass covered correctness, cross-file consistency, test coverage, and conventions; no security, removed-behavior, or broader integration lanes were examined.

[
  {
    "file": "observability/tracing.py",
    "line": 393,
    "severity": "minor",
    "category": "correctness",
    "claim": "The dict comprehension `{k: int(v) for k, v in candidates.items() if v}` filters out zero-valued token counts (0 is falsy), silently dropping genuine zero-token metrics when a model reports them.",
    "evidence": "`usage.get(\"input_tokens\")` returns `None` for missing keys and `0` for present-but-zero keys; `if v` treats both as falsy, so a legitimate `input_tokens: 0` is discarded. Fix: `if v is not None`.",
    "verdict": "confirmed",
    "note": "PR diff line ~393 shows `if v` — Python treats both `None` and `0` as falsy, so a model reporting `input_tokens: 0` is silently discarded alongside missing keys. `if v is not None` would fix it.",
    "source": "structural"
  },
  {
    "file": "observability/tracing.py",
    "line": 398,
    "severity": "minor",
    "category": "correctness",
    "claim": "The expression `({\"total\": float(cost_usd)} if cost_usd else None)` treats `0.0` as falsy, suppressing a legitimately-zero cost from the generation span.",
    "evidence": "`pricing.cost_usd` returns `0.0` for empty usage and can return `0.0` after rounding for tiny-usage calls (e.g. protolabs/nano at $3e-8/input token). The `if cost_usd` false-branch sets `cost_details=None`, indistinguishable from 'cost was never computed.' Fix: `if cost_usd is not None` (or compute cost_details unconditionally).",
    "verdict": "confirmed",
    "note": "PR diff line ~398 shows `if cost_usd else None`. `pricing.cost_usd` docstring says '0.0 for empty usage' and `round(3e-8, 6)` = `0.0` for protolabs/nano single-token calls. `0.0` is falsy, so cost_details becomes `None`.",
    "source": "structural"
  },
  {
    "file": "graph/middleware/trace_context.py",
    "line": 109,
    "severity": "minor",
    "category": "cross-file",
    "claim": "_emit_fleet_generation calls trace_generation() without passing session_id, so generation observations always carry session_id=\"\" while trace_tool_call observations in the same trace carry the real session ID — inconsistent metadata within one trace.",
    "evidence": "In `_emit_fleet_generation` (line 109): `tracing.trace_generation(name=name, model=model, usage=usage, cost_usd=cost, duration_ms=duration_ms)` — no `session_id` kwarg. But `trace_generation`'s signature accepts `session_id: str = \"\"` and stamps it into metadata, mirroring `trace_tool_call` which always receives the real session_id from its callers.",
    "verdict": "confirmed",
    "note": "PR diff shows `trace_generation(...)` call at ~line 109 without `session_id`. `audit.py` always passes `session_id=tracing.current_session_id()` to `trace_tool_call`. `trace_generation`'s signature defaults `session_id=\"\"` and stamps it into metadata. Confirmed inconsistency.",
    "source": "structural"
  },
  {
    "file": "observability/tracing.py",
    "line": 362,
    "severity": "minor",
    "category": "tests",
    "claim": "New public function trace_generation() has no unit test in test_tracing.py, breaking the module's convention that every public function has a dedicated test and leaving its real implementation path unexercised.",
    "evidence": "Every other public function in tracing.py (trace_session, trace_tool_call, score_current_trace, trace_span, flush, current_trace_context) has a dedicated test in tests/test_tracing.py. trace_generation is only exercised indirectly through middleware tests that monkeypatch it away with a lambda — the real usage_details construction, cost_details conditional, and start_observation/end wiring are never executed by any test.",
    "verdict": "confirmed",
    "note": "Read full `tests/test_tracing.py` — it tests trace_session, trace_tool_call, score_current_trace, trace_span, flush, current_trace_context but has zero coverage for trace_generation. Middleware tests in `test_trace_context_middleware.py` monkeypatch `tracing.trace_generation` with a lambda, so the real implementation path (usage_details, cost_details, start_observation/end) is never executed.",
    "source": "structural"
  },
  {
    "file": "graph/middleware/trace_context.py",
    "line": 1,
    "severity": "nit",
    "category": "cross-file",
    "claim": "The module docstring describes only trace-stamping behavior and does not mention the new fleet-generation emit behavior now performed by wrap_model_call and awrap_model_call.",
    "evidence": "The docstring says the middleware 'stamps a fresh trace context' and is 'No-op ... when tracing is disabled' — but after this PR, both wrapper methods also time the handler call and emit a generation span via _emit_fleet_generation, which is unmentioned.",
    "verdict": "confirmed",
    "note": "Read `trace_context.py` module docstring from default branch (identical in PR diff — no docstring change). It describes only `_with_trace` stamping behavior. PR adds timing + `_emit_fleet_generation` call in both `wrap_model_call` and `awrap_model_call` — docstring was not updated.",
    "source": "structural"
  },
  {
    "file": "graph/middleware/trace_context.py",
    "line": 71,
    "severity": "nit",
    "category": "conventions",
    "claim": "The private method name '_emit_fleet_generation' implies fleet-only (multi-project) behavior, but it is called unconditionally from both wrap_model_call and awrap_model_call on every model call regardless of project configuration.",
    "evidence": "Called on lines 113 and 119 without any fleet/project guard: `self._emit_fleet_generation(response, …)`. The PR description confirms it runs even when 'agent and gateway share a project (the common case)' — making 'fleet' in the name misleading to readers scanning the call site.",
    "verdict": "confirmed",
    "note": "PR diff shows `_emit_fleet_generation()` called unconditionally in both `wrap_model_call` (~line 113) and `awrap_model_call` (~line 119) with no project/fleet guard. The method's own docstring describes it as for the 'different project' case, yet it fires always. Name is misleading but behavior is intentional.",
    "source": "structural"
  }
]

@mabry1985 mabry1985 merged commit 8960b9f into main Jul 7, 2026
10 checks passed
@mabry1985 mabry1985 deleted the fix/fleet-whole-trace-generation branch July 7, 2026 19:45
protoquinn Bot pushed a commit that referenced this pull request Jul 7, 2026
….96.0 cut (#1895)

Nine PRs merged since v0.95.1 shipped without a changelog entry, so the
generated release notes (updater + Discord, single-source from CHANGELOG) would
have omitted a headline feature and a security-relevant default change. Backfill
before rolling the release:

- Added: Hermes first-class runtime (#1889 + hardening #1891)
- Changed: filesystem.allow_run defaults OFF on the headless tier (#1888)
- Fixed: fleet members no longer inherit hub identity (#1886/#1882/#1881),
  whole trace on a dedicated Langfuse project (#1893), background knowledge
  ingest wakes the agent (#1887), quieter plugin hot-reload logs (#1885)

The already-present entries (#1880 tracing, #1890 plugin-view 401, #1879
subagent max_turns) are unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fleet tracing: LLM-generation spans land in the shared gateway's Langfuse project, not the agent's own — split traces

1 participant