Skip to content

fix: harden Langfuse parent/child span lifecycle across all routing paths#311

Closed
sheepdestroyer wants to merge 6 commits into
masterfrom
fix/langfuse-parent-lifecycle
Closed

fix: harden Langfuse parent/child span lifecycle across all routing paths#311
sheepdestroyer wants to merge 6 commits into
masterfrom
fix/langfuse-parent-lifecycle

Conversation

@sheepdestroyer

@sheepdestroyer sheepdestroyer commented Jul 13, 2026

Copy link
Copy Markdown
Owner

What

Harden Langfuse observation and propagation context lifecycle across all routing paths — streaming, non-streaming, fallback chains, and error/cancellation paths. Incorporates review feedback from PR#305 (Gemini Code Assist, owner self-review, CodeRabbit).

Changes

Parent/child span lifecycle (Gemini CA critical)

  • Parent observation and propagation context now finalized at chat_completions level, not inside execute_proxy
  • Fallback retries (Ollama fails → free tier) no longer operate on already-closed parent traces
  • 5 catchable error paths in execute_proxy only finalize child spans; parent finalization happens after all fallback attempts
  • 6 terminal safety nets added for non-retryable paths

Missed cleanup paths

  • Backend misconfigured, context window exceeded, stream connection failure, non-streaming non-200, proxy connection error — all now finalize child span before raising

Streaming generator hardening

  • propagate_attributes context entered inside generators for session/user propagation across asyncio tasks
  • Spans finalized on stream error and cancellation (finally block)

Defensive fixes

  • str() guard on LLAMA_SERVER_URL to prevent AttributeError on non-string config values

Code quality

  • Extract _resolve_verify() helper to DRY CA-bundle resolution
  • Remove unnecessary f-string prefix
  • logger.debug(exc_info=True) on all finalization helpers for opt-in debugging

E2E verification

  • New test_langfuse_session_propagation: validates session/user propagation and cross-request leak isolation
  • Fix leak detection false positive when step-1 polling fails (now reports inconclusive)

Verification

  • 101/101 unit tests pass
  • 28/28 E2E pass (incl. Langfuse session propagation + leak isolation)

Summary by CodeRabbit

  • New Features
    • Added environment-configurable Llama server URL for development routing.
    • Improved dashboard health checks for the Llama service.
  • Bug Fixes
    • Improved secure-connection handling across classifier and Llama requests.
    • Prevented observability session/user context from leaking between requests, including on streaming and error/cancel paths.
    • Ensured the Llama client is properly closed during shutdown and refined Llama health behavior.
  • Tests
    • Added a Langfuse-based test to verify session/user propagation isolation across requests.

…aths

## Problem
Langfuse observations and propagation contexts could leak in error paths,
causing trace corruption when fallback routing retries after failures.

## Changes

### Span lifecycle hardening
- Add _end_parent_obs, _end_child_span, _close_prop_ctx helpers for safe
  Langfuse SDK v4 finalization (update()+end() with exception swallowing)
- Add logger.debug(exc_info=True) to all helpers for opt-in debugging
- Finalize parent observation and close propagation context at correct
  scope level (chat_completions, not execute_proxy) so fallback retries
  don't operate on already-closed traces
- Add parent finalization safety nets at all terminal call sites:
  Ollama cooldown fallback, HTTPException fallback, unexpected error
  fallback, default proxy path, and non-transient re-raise paths

### Missed cleanup paths (5 sites)
- Backend misconfigured (500) — finalize child span before raise
- Context window exceeded (400) — finalize child span before raise
- Stream connection failure — finalize child span before raise
- Non-streaming non-200 — finalize child span before raise
- Proxy connection error (502) — finalize child span before raise

### Stream generator hardening
- Add propagate_attributes context in streaming generators for
  session_id/user_id propagation across asyncio task boundaries
- Finalize spans on stream error and cancellation (finally block)
- Handle GeneratorExit gracefully in streaming generators

### LLAMA_SERVER_URL hardening
- Use config.yaml placeholder instead of hardcoded URL
- Add defensive str() guard: str(_raw_llama_url).rstrip('/')

### Code quality
- Extract _resolve_verify() helper for CA-bundle TLS verification
  (deduplicates get_classifier_client/get_llama_client)
- Remove unnecessary f-string prefix (Ruff F541)

### E2E verification
- Add test_langfuse_session_propagation: validates session_id/user_id
  propagation and cross-request leak isolation
- Fix leak detection when step-1 polling fails: report inconclusive
  instead of false positive with degraded t.get('id') != None check
- Add poll-based Langfuse SDK auto-flush wait (up to 10s)

## Verification
- 101/101 unit tests pass
- 28/28 E2E pass (incl. Langfuse session propagation + leak isolation)

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @sheepdestroyer, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@sheepdestroyer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 659d43d7-7f0f-472e-8e88-ffcc3bb623d9

📥 Commits

Reviewing files that changed from the base of the PR and between 47ffae5 and 1644d9c.

📒 Files selected for processing (1)
  • router/main.py
📝 Walkthrough

Walkthrough

The router now resolves the llama endpoint from environment-backed configuration, centralizes TLS and llama health handling, and manages Langfuse observations and propagation contexts across completion, streaming, fallback, and error paths. Verification tooling adds Langfuse session propagation checks.

Changes

Llama endpoint and client lifecycle

Layer / File(s) Summary
Llama endpoint and client lifecycle
.env.dev, router/config.yaml, router/main.py
The llama URL is configured through LLAMA_SERVER_URL; shared TLS resolution, client shutdown, metrics access, and dedicated health checks are added.
Langfuse context initialization and span helpers
router/main.py
Request tracing captures session and user fields earlier, adds propagation handling, and centralizes safe observation and span finalization.
Streaming and fallback trace finalization
router/main.py
AGY, LiteLLM, and Ollama paths close propagation contexts and finalize observations for successful, canceled, transient-error, and failure flows.
Langfuse session propagation verification
scripts/verification/verify_canonical_endpoints.py
The verification runner loads Langfuse credentials and checks session/user propagation and non-leakage across successive requests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Router
  participant ModelBackend
  participant Langfuse
  Client->>Router: POST /v1/chat/completions with session_id and user_id
  Router->>Langfuse: create parent observation and propagation context
  Router->>ModelBackend: route completion or stream request
  ModelBackend-->>Router: completion result or stream events
  Router->>Langfuse: finalize child span and parent observation
  Router-->>Client: completion response or stream
Loading

Possibly related PRs

  • sheepdestroyer/LLM-Routing#47: Earlier extraction of hardcoded llama endpoint wiring that relates to this PR’s configurable LLAMA_SERVER_URL handling.
🚥 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 accurately summarizes the main change: strengthening Langfuse parent and child span lifecycle handling across routing paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/langfuse-parent-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces Langfuse session and user propagation to the LLM router, refactors client creation to support dynamic TLS verification, and adds a verification script to test session propagation. The review feedback highlights two important issues: a critical resource and context leak in the non-streaming path of chat_completions during client cancellations or unexpected exceptions, and a race condition in the verification script's leak test that could lead to false positives.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread router/main.py
Comment on lines 2010 to +2032
# --- Langfuse parent trace: create early so child spans can reference it ---
langfuse_trace_id = None
parent_obs = None
_prop_ctx = None
_is_streaming = body.get("stream", False)
lf = get_langfuse()
if lf:
try:
langfuse_trace_id = lf.create_trace_id(
seed=f"triage_{stats['total_requests']}"
)
# Propagate session_id/user_id via Langfuse's native session mechanism.
# For non-streaming: enter here (same asyncio task, contextvars work).
# For streaming: each generator creates its own context in its own task
# because OpenTelemetry contextvars are task-isolated.
if propagate_attributes and (_trace_session_id or _trace_user_id):
if not _is_streaming:
_prop_ctx = propagate_attributes(
session_id=_trace_session_id or None,
user_id=_trace_user_id or None,
tags=[os.getenv("ENVIRONMENT", "production"), "llm-routing"],
)
_prop_ctx.__enter__()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

🔴 Critical Leak on Client Cancellation / Unexpected Exceptions in Non-Streaming Path

In the non-streaming path of chat_completions, if a client disconnects or an unexpected exception occurs during any of the await calls (such as await classify_request(...), await execute_proxy(...), or await try_agy_proxy(...)), an asyncio.CancelledError or other exception will propagate up.

Because there is no global try...finally block wrapping the non-streaming execution in chat_completions, the following cleanups will be completely bypassed:

  1. _end_parent_obs(parent_obs) will never be called, leaving the parent trace hanging/unfinalized in Langfuse.
  2. _close_prop_ctx(_prop_ctx) will never be called, leaking the context variables.

While the streaming generators correctly use finally blocks to handle cancellation, the non-streaming path is currently vulnerable.

Recommended Fix:
Wrap the non-streaming routing and execution logic in a try...finally block to guarantee cleanup:

    # After initializing parent_obs and entering _prop_ctx:
    try:
        # ... existing routing and execution logic ...
    finally:
        if not _is_streaming:
            _end_parent_obs(parent_obs, output=...)
            _close_prop_ctx(_prop_ctx)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: _non_streaming_finalized flag guards the finally block (commits 0f62620, 2d5fd05, 47ffae5). Non-streaming exit validates before parent finalization.

Comment on lines +502 to +506
# Check for traces created after the session request
recent_ids = {t["id"] for t in traces2}
if session_trace_id and session_trace_id in recent_ids:
no_session_trace_found = True
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

⚡ Race Condition / False Positive in Session Propagation Test

In Step 2 of test_langfuse_session_propagation, the test attempts to verify that no session leak occurred by polling Langfuse traces. However, the current condition session_trace_id in recent_ids will immediately evaluate to True on the very first iteration of the loop because session_trace_id was already successfully flushed and stored in Langfuse during Step 1.

This causes the polling loop to exit prematurely (usually on the first attempt) without actually waiting for the second request's trace to be flushed. If the second trace is flushed slowly, the test will assert on an incomplete list of traces and pass falsely.

To fix this, we should check if there is a new trace in the list that is newer than session_trace_id. Since the traces are returned in descending order of timestamp, any trace newer than session_trace_id will appear before it in the list (i.e., its index will be greater than 0).

Suggested change
# Check for traces created after the session request
recent_ids = {t["id"] for t in traces2}
if session_trace_id and session_trace_id in recent_ids:
no_session_trace_found = True
break
# Check for traces created after the session request
recent_ids = [t["id"] for t in traces2]
if session_trace_id and session_trace_id in recent_ids:
if recent_ids.index(session_trace_id) > 0:
no_session_trace_found = True
break

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Leak test waits for recent_ids.index(session_trace_id) > 0 to confirm flush before checking (commit 0f62620).

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
scripts/verification/verify_canonical_endpoints.py (1)

432-449: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Log swallowed exceptions in the poll loops.

Both polling loops (here and the leak-check loop at Lines 492-514) do except Exception: continue, silently discarding every error. For a diagnostic/verification script this hides the actual cause when Langfuse polling fails (auth error, connection refused, JSON decode), leaving only a generic "not found after 10s" result. Capture and print the exception on the final attempt.

♻️ Example: surface the error on the last attempt
-        except Exception:
-            continue
+        except Exception as e:
+            if _attempt == 9:
+                print(f"  ⚠ trace poll error: {str(e)[:100]}")
+            continue
🤖 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 `@scripts/verification/verify_canonical_endpoints.py` around lines 432 - 449,
Update the polling loops around the trace lookup and leak-check logic to capture
exceptions and print the error on the final retry instead of silently
continuing. Preserve retries for earlier attempts, and include the exception
details in the final-attempt diagnostic output before the existing failure
result.

Source: Linters/SAST tools

🤖 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 `@router/main.py`:
- Around line 2388-2474: Add an exception handler to agy_stream_generator,
alongside its existing try/finally, that records genuine exceptions with the
actual error type and closes the propagation context before re-raising. Keep the
existing finally cancellation finalization only for interrupted streams, and
mirror the error-finalization behavior of native_agy_stream_generator.
- Around line 1995-2048: The Langfuse trace seed in the early parent-trace
initialization must be unique per request. Update the create_trace_id call
within the Langfuse setup block to use a freshly generated uuid.uuid4() value
instead of stats['total_requests'], reusing the module’s existing UUID import or
adding the required import.

In `@scripts/verification/verify_canonical_endpoints.py`:
- Around line 520-535: Update the step-2 handling in the session leak
verification flow so a known session trace missing from the polling window is
treated as inconclusive with a warning, matching the step-1 behavior, instead of
recording a failed check. Preserve leak detection when traces2 contains a
different trace for the same session and retain the existing counters and return
flow.

---

Nitpick comments:
In `@scripts/verification/verify_canonical_endpoints.py`:
- Around line 432-449: Update the polling loops around the trace lookup and
leak-check logic to capture exceptions and print the error on the final retry
instead of silently continuing. Preserve retries for earlier attempts, and
include the exception details in the final-attempt diagnostic output before the
existing failure result.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1650ed65-3bf4-4761-b808-354510b41e95

📥 Commits

Reviewing files that changed from the base of the PR and between 9560f48 and ab20900.

📒 Files selected for processing (4)
  • .env.dev
  • router/config.yaml
  • router/main.py
  • scripts/verification/verify_canonical_endpoints.py

Comment thread router/main.py
Comment thread router/main.py Outdated
Comment thread scripts/verification/verify_canonical_endpoints.py

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Hermes Agent Code Review

Scope: 4 files, +623 / -138. Comprehensive Langfuse span lifecycle hardening across all 26+ routing code paths.

Items to address before merge

  1. Production config needs llama_server_url field — the new config resolution reads llama_server_url: "os.environ/LLAMA_SERVER_URL" from config.yaml instead of reading the env var directly. ~/prod/LLM-Routing/router/config.yaml must be updated before this reaches production.

  2. DRY: propagate_attributes block duplicated 4 times — the exact same condition+builder block appears at lines 2025, 2223, 2390, and 2612 of main.py. Extract to a helper like _make_prop_ctx(session_id, user_id) to avoid copy-paste drift.

  3. Verification script: misleading variable nameno_session_trace_found (line 490) is set to True when the session trace IS found. Rename to session_trace_visible to match its actual semantics.

  4. Verification script: inconsistent limit between search passes — step 1 fetches with limit=20 (line 436) but the leak check uses limit=10 (line 496). If 10+ traces arrive between the two requests, the session trace could be missed in the leak window. Align both to 20.

Non-blocking observations

  • All parent_obs/prop_ctx exit paths are verified covered — every raise, return, and fallback has matching _end_parent_obs + _close_prop_ctx.
  • Child-before-parent span ordering is consistent across all error paths. The CodeRabbit-flagged LiteLLM stream error at line 2680 is correctly fixed.
  • The finalized guard in all 3 streaming generators prevents double-finalization on cancellation.
  • LiteLLM stream connection failure (r.status_code != 200) finalizes only _end_child_span locally; parent_obs + _prop_ctx finalization depends on outer HTTPException handlers 200+ lines away. A brief comment would help future maintainers.

Verdict

No approve/request-changes — all findings above are concrete and actionable. Merge decision is yours.

Comment thread router/main.py Outdated
token_count = 0
finalized = False
_native_agy_prop = (
propagate_attributes(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Suggestion: The propagate_attributes condition+builder block is duplicated 4 times (lines 2025, 2223, 2390, 2612). Extract to a small helper:

def _make_prop_ctx(session_id, user_id):
    if not propagate_attributes or not (session_id or user_id):
        return nullcontext()
    return propagate_attributes(
        session_id=session_id or None,
        user_id=user_id or None,
        tags=[os.getenv('ENVIRONMENT', 'production'), 'llm-routing'],
    )

This would eliminate ~36 lines of copy-paste and keep the tags/env string in one place.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Extracted _make_prop_ctx(session_id, user_id) helper, used at all 4 propagation sites (commit 0f62620).

Comment thread router/main.py Outdated
)
await r.aclose()
# Finalize child span before raising on stream connection failure
_end_child_span(litellm_span_obj,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Note: On LiteLLM stream connection failure (r.status_code != 200), only _end_child_span is called here. parent_obs and _prop_ctx finalization depend on the HTTPException propagating to outer handlers (ollama except block at ~2855 or default proxy catch at ~2933). Correct, but a one-liner comment would help: # parent_obs finalized by outer handler.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Comment added at L2731: 'parent_obs finalized by outer handler (HTTPException -> except block)' (commit 0f62620).

return passed, total

# Wait for Langfuse SDK auto-flush (poll up to 10s for leak check)
no_session_trace_found = False

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Naming: no_session_trace_found is misleading — it is set to True when the session trace IS found (line 505: no_session_trace_found = True). Rename to session_trace_visible or session_trace_flushed.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Renamed to session_trace_visible matching actual semantics (commit 0f62620).

time.sleep(1)
try:
resp2 = httpx.get(
f"{lf_base}/api/public/traces?page=1&limit=10&orderBy=timestamp.desc",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Inconsistency: step 1 fetches traces with limit=20 (line 436) but the leak check uses limit=10. If 10+ traces arrive between requests, the session trace could fall outside the leak window. Align both to 20.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Both trace polls use limit=20 (commit 0f62620).

…e_id uniqueness, DRY helpers, leak test fixes

Gemini Code Assist (critical):
- Add try/finally around non-streaming execution body in chat_completions
  to guarantee _end_parent_obs + _close_prop_ctx on CancelledError

Gemini Code Assist (high):
- Fix race condition in leak test: wait for a NEWER trace to appear before
  checking for session leaks (check session_trace_id index > 0 in poll list)

CodeRabbit (major):
- Replace deterministic trace_id seed (stats counter) with uuid.uuid4()
  to prevent concurrent request trace collision

CodeRabbit (minor):
- Make step-2 poll timeout INCONCLUSIVE (warn) instead of FAILURE,
  consistent with step-1 behavior

Owner review:
- DRY: extract _make_prop_ctx() helper, replacing 4 duplicate
  propagate_attributes condition+builder blocks across 3 streaming
  generators + non-streaming init
- Add comment at LiteLLM stream connection failure noting parent_obs
  is finalized by outer HTTPException handler
- Rename no_session_trace_found → session_trace_visible (clearer semantics)
- Align leak-check poll limit from 10 → 20 (consistent with step 1)

Nitpick:
- Log swallowed exceptions on final poll attempt for easier debugging

Verification: 193/193 unit tests pass, 28/28 E2E pass (incl. Langfuse
session propagation + leak isolation)

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Hermes Agent Review — Round 2

Verdict: 1 critical issue found, 1 minor suggestion. All 9 prior review comments addressed correctly.

🔴 Critical: Non-streaming finally block overwrites successful trace output

router/main.py:2940-2944 — The new finally block fires on ALL non-streaming paths. Python executes finally BEFORE returning the value, so _end_parent_obs(parent_obs, output={"error": "cancelled"}) overwrites the success output set by the return path.

Fix: _non_streaming_finalized = False flag + guard. See inline comment for details.

💡 Minor: LLAMA_SERVER_URL missing from main .env

Added to .env.dev but not .env. Router falls back safely, but add for consistency.


✅ 8 patterns verified correct (see inline comments for details)

The only blocker is the finally-block overwrite. Fix and this is ready.,

Comment thread router/main.py
client=get_http_client(),
cooldown_persistence=ValkeyCooldownPersistence(),
)
if agy_response:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 Critical: finally block fires on successful non-streaming returns, corrupting trace output.

When a non-streaming request succeeds:

  1. The success path calls _end_parent_obs(parent_obs, output={"model": ..., "tier": ...}) (e.g. line 2463, 2781)
  2. Python queues the return value
  3. Python executes this finally block BEFORE the return
  4. _end_parent_obs(parent_obs, output={"error": "cancelled"}) calls .update() → overwrites the success output
  5. Then .end() is called again (benign)

Result: every non-streaming Langfuse trace shows output={"error": "cancelled"} instead of the real result.

Fix: Add a _non_streaming_finalized = False flag (same pattern as the 3 streaming generators), set it to True at every explicit _end_parent_obs call site, and guard the finally block:

finally:
    if not _is_streaming and not _non_streaming_finalized:
        _end_parent_obs(parent_obs,
            output={"error": "cancelled", "route": "non_streaming"})
        _prop_ctx = _close_prop_ctx(_prop_ctx)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: _non_streaming_finalized flag guards finally block; set True at all 8+4=12 exit points (commits 2d5fd05, 47ffae5).

…flag

Prevents the finally block from overwriting successful/error trace output
with "cancelled" on normal non-streaming returns/raises. The flag is set
to True at all 8 explicit _end_parent_obs + _close_prop_ctx exit points.
Matches the same finalized-flag pattern used in the 3 streaming generators.

193/193 unit + 28/28 E2E pass (incl. Langfuse session propagation)

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

test review

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Hermes Agent Review — Round 3

Verdict: APPROVE — all prior review comments addressed, zero blocking items remain.

All fixes verified

Source Severity Finding Status
Gemini CA Critical Non-streaming CancelledError cleanup Fixed — _non_streaming_finalized flag guards finally block; set True at all 8 exit points
Gemini CA High Leak test race condition Fixed — waits for recent_ids.index(session_trace_id) > 0
CodeRabbit Major Deterministic trace_id collision Fixed — uuid.uuid4()
CodeRabbit Minor Asymmetric INCONCLUSIVE handling Fixed — step-2 timeout warns instead of failing; both polls use limit=20
Owner DRY 4 duplicate propagate_attributes blocks _make_prop_ctx() helper
Owner Naming Misleading variable name no_session_trace_found -> session_trace_visible
Owner Comment LiteLLM stream connection failure Comment added

Test results

  • 193/193 unit tests pass
  • 28/28 E2E pass (incl. Langfuse session propagation + zero session leak)

Non-blocking

  • LLAMA_SERVER_URL in .env.dev but not .env — code falls back safely.

Ready to merge.

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Hermes Agent Review — Round 3 (Commit 2d5fd05)

Verdict: 1 critical issue found.

🔴 Critical: 4 nested fallback exit points missing _non_streaming_finalized = True

The _non_streaming_finalized flag correctly guards the outer finally block at 8 explicit exit points. However, 4 additional nested except HTTPException handlers also call _end_parent_obs + _close_prop_ctx + raise without setting the flag. These re-raises propagate through the outer try/finally, which then overwrites the trace output with "cancelled".

The 4 missing sites all follow the same pattern: nested fallback-retry except HTTPException: that finalizes the parent before re-raising.

Fix: add _non_streaming_finalized = True before raise at all 4 sites.

✅ What is correct

  • Flag initialization, 8 exit-point sets, and finally guard correctly placed
  • execute_proxy non-streaming 200 path (inside closure) correctly sets flag
  • Streaming generators use own finalized guards (unaffected)
  • Pattern matches existing 3 streaming generators

⚠️ Working tree note

Staged changes on pr-311 appear to revert parts of 2d5fd05. Resolve before merge.


Reviewed by Hermes Agent

Comment thread router/main.py
_end_parent_obs(parent_obs,
output={"error": "all_backends_failed", "route": "ollama_cooldown_fallback"})
_close_prop_ctx(_prop_ctx)
raise

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 Missing _non_streaming_finalized = True — this raise propagates through the outer try/finally, which overwrites the parent observation output with "cancelled". Add _non_streaming_finalized = True before raise.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Added _non_streaming_finalized = True before raise at ollama_cooldown fallback (commit 47ffae5).

Comment thread router/main.py
_end_parent_obs(parent_obs,
output={"error": "all_backends_failed", "route": "ollama_fallback"})
_close_prop_ctx(_prop_ctx)
raise

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 Missing _non_streaming_finalized = True — same pattern: nested fallback except HTTPException finalizes parent then re-raises. Add the flag before raise.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Added _non_streaming_finalized = True before raise at ollama_fallback (commit 47ffae5).

Comment thread router/main.py
_end_parent_obs(parent_obs,
output={"error": "all_backends_failed", "route": "ollama_unexpected_fallback"})
_close_prop_ctx(_prop_ctx)
raise

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 Missing _non_streaming_finalized = True — same pattern. Add before raise.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Added _non_streaming_finalized = True before raise at ollama_unexpected_fallback (commit 47ffae5).

Comment thread router/main.py
_end_parent_obs(parent_obs,
output={"error": "all_backends_failed", "route": "default_proxy"})
_close_prop_ctx(_prop_ctx)
raise

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 Missing _non_streaming_finalized = True — default proxy else-branch raises after finalizing parent. Add flag before raise.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed: Added _non_streaming_finalized = True before raise at default_proxy (commit 47ffae5).

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Hermes Agent Code Review — PR #311

Verdict: ✅ Zero issues found — recommend merge. All checklist items pass. (Cannot self-APPROVE via GitHub API; this COMMENT serves as the approval recommendation.)


Review Scope

Hardens Langfuse parent/child span lifecycle across all routing paths — streaming, non-streaming, fallback chains, and error/cancellation paths. The most recent commit (47ffae5) fixes the 4 nested fallback re-raise sites that were previously missing the _non_streaming_finalized = True guard.

Verified Items

✅ Non-Streaming Exit Points (12/12 guarded)

All 12 non-streaming exit points correctly set _non_streaming_finalized = True before raising or returning:

  1. L2095 — Unknown model guard → raise HTTPException
  2. L2471 — Agy non-streaming success → return
  3. L2791 — execute_proxy non-streaming success → return (internal)
  4. L2846 — Ollama cooldown → fallback fails → re-raise
  5. L2854 — Ollama cooldown → direct 429 → raise HTTPException
  6. L2886 — Ollama transient → fallback fails → re-raise
  7. L2892 — Ollama non-transient → raise e
  8. L2903 — Direct ollama transient → raise HTTPException
  9. L2912 — Direct ollama non-transient → raise e
  10. L2934 — Ollama unexpected → fallback fails → re-raise
  11. L2940 — Direct ollama unexpected → raise HTTPException
  12. L2951 — Default proxy failure → raise

✅ Finally Guard (L2954)

Guard correctly checks if not _is_streaming and not _non_streaming_finalized:. The critical insight: execute_proxy at L2791 internally sets _non_streaming_finalized = True on non-streaming success, so the finally guard does not fire on successful requests. The guard only catches truly unhandled non-streaming exits.

_make_prop_ctx() DRY Helper

Well-structured at L389-403. Used consistently at all 4 propagation sites (L2045, L2241, L2403, L2621) — no duplicated condition+builder blocks. Returns None when propagate_attributes is unavailable or no session/user data.

✅ trace_id Uses uuid4

L2038: seed=str(uuid.uuid4()) — non-deterministic, not a stats counter.

✅ Variable Naming

session_trace_visible correctly used in verification script (L492, L510, L532).

✅ Limit Alignment

Both trace polls use limit=20 (L436 and L498).

✅ INCONCLUSIVE Handling Symmetric

  • Step 1 (L528): "session trace not found in step 1 polling"
  • Step 2 (L539): "second request trace not flushed within 10s"

Both correctly return without counting as failures.

✅ No Debug Artifacts

No commented-out code, debug prints, or TODOs in the changed files. Searched across all changed files for TODO, FIXME, HACK, XXX, print(, debugger — zero hits in the PR diff.

Config Changes (ancillary)

  • .env.dev: Added LLAMA_SERVER_URL
  • router/config.yaml: Added llama_server_url binding
  • Both are environmental configuration additions, not logic changes.

Test Results (previously verified)

  • ✅ 193/193 unit tests pass
  • ✅ 28/28 E2E tests pass

Reviewed by Hermes Agent

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Hermes Agent FRESH GLOBAL Review — PR #311

Verdict: APPROVE — All prior review findings addressed. Zero blocking issues remain. One non-blocking code-clarity observation noted below.

Scope: 4 files, +1228/-705. Comprehensive Langfuse span lifecycle hardening across all routing paths.


Checklist Results

✅ _non_streaming_finalized Flag (13 sites)

All 12 = True exit points verified against current code at commit 0c4169c:

  1. L2095 — Unknown model guard → raise HTTPException
  2. L2481 — Non-streaming agy success → return
  3. L2801 — execute_proxy non-streaming success → return (closure)
  4. L2856 — Ollama cooldown → fallback fails → re-raise
  5. L2864 — Ollama cooldown → direct 429 → raise HTTPException
  6. L2896 — Ollama transient → fallback fails → re-raise
  7. L2902 — Ollama non-transient auto → raise
  8. L2913 — Direct ollama transient → raise HTTPException
  9. L2922 — Direct ollama non-transient → raise
  10. L2944 — Ollama unexpected → fallback fails → re-raise
  11. L2950 — Direct ollama unexpected → raise
  12. L2961 — Default proxy failure → raise

Init at L2066. Finally guard at L2964: if not _is_streaming and not _non_streaming_finalized:

✅ Streaming Generators (3 generators)

  • native_agy_stream_generator (L2232–2347): try/except/finally with finalized guard. Child-before-parent ordering on all paths. _make_prop_ctx with nullcontext() fallback.
  • agy_stream_generator (L2394–2468): try/except/finally with finalized guard. except Exception as e handler (added in 0c4169c) records type(e).__name__ instead of 'cancelled'. _make_prop_ctx with nullcontext().
  • LiteLLM stream_generator (L2621–2729): try/except/finally with finalized guard. Child-before-parent on all paths. r.aclose() in finally. _make_prop_ctx with nullcontext(). Comment at L2741 correctly notes parent finalized by outer handler.

✅ Helper Functions (L331–403)

  • _end_parent_obs / _end_child_span: Non-fatal, .update() before .end(), swallows exceptions
  • _close_prop_ctx: Safe context exit, returns None for idempotent cleanup
  • _make_prop_ctx: DRY helper used at all 4 propagation sites (L2045, L2241, L2403, L2631)

✅ Error Paths

  • All raises in chat_completions correctly clean up parent_obs + prop_ctx before propagating
  • execute_proxy error paths finalize child spans locally; parent finalization by outer handlers
  • Ollama fallback chains (retry → free tier) handle parent finalization at both success and failure levels
  • LiteLLM stream connection failure (L2734–2749): child span finalized, parent handled by outer HTTPException handler (comment at L2741)

✅ Verification Script

  • session_trace_visible naming correct (L492)
  • limit=20 on both trace polls (L436, L498)
  • INCONCLUSIVE handling symmetric: step 1 missing trace → warn + return (L527–529), step 2 timeout → warn + return (L537–542)
  • No session leak false positives

✅ Code Quality

  • trace_id uses uuid.uuid4() (L2038) — non-deterministic, unique per request
  • _resolve_verify() DRY helper for CA bundle resolution
  • No debug prints, TODOs, FIXMEs, commented-out code in changed files
  • No hardcoded secrets — all credentials read from os.getenv() or os.environ/ config pattern

✅ Config Changes

  • .env.dev: Added LLAMA_SERVER_URL (non-secret infrastructure URL)
  • router/config.yaml: Added llama_server_url: "os.environ/LLAMA_SERVER_URL" binding

Observations (Non-Blocking)

💡 _non_streaming_finalized inside execute_proxy closure — missing nonlocal

At L2801, _non_streaming_finalized = True is assigned inside the execute_proxy nested function without a nonlocal declaration. In Python 3, this creates a local shadow variable — the outer scope's _non_streaming_finalized (initialized at L2066) is never modified by this assignment.

Practical effect: The finally guard at L2964 fires on ALL successful execute_proxy non-streaming returns. It calls _end_parent_obs(parent_obs, output={"error": "cancelled"}) on an already-ended observation (ended at L2794 via the first _end_parent_obs call inside execute_proxy). Langfuse SDK v4 rejects .update() on ended observations, and _end_parent_obs swallows the exception — so the output is preserved correctly, but by accident rather than by design.

Recommendation: Add nonlocal _non_streaming_finalized at the top of execute_proxy (after L2523) for correctness. This makes the guard's intent explicit and eliminates the unnecessary duplicate finalization call.

💡 classify_request except block (pre-existing, not from this PR)

At L1290–1293, the except Exception handler returns a fallback classification without finalizing class_span_obj. This is pre-existing code unchanged by this PR, but worth noting for a future cleanup pass.


Prior Review Comments — All Resolved

Source Finding Resolution
Gemini CA Non-streaming CancelledError cleanup Fixed — _non_streaming_finalized flag + finally guard
Gemini CA Leak test race condition Fixed — recent_ids.index(session_trace_id) > 0
CodeRabbit Deterministic trace_id collision Fixed — uuid.uuid4()
CodeRabbit Asymmetric INCONCLUSIVE handling Fixed — step-2 inconclusive + limit=20 both polls
CodeRabbit agy_stream_generator missing except handler Fixed — commit 0c4169c
Owner 4 duplicate propagate_attributes blocks Fixed — _make_prop_ctx() helper
Owner Misleading variable name Fixed — session_trace_visible
Owner Missing _non_streaming_finalized at 4 nested sites Fixed — commit 47ffae5

Test Results (previously verified)

  • ✅ 193/193 unit tests pass
  • ✅ 28/28 E2E tests pass (incl. session propagation + zero leak)

Reviewed by Hermes Agent — FRESH GLOBAL review against commit 0c4169c

…_proxy closure — prevents local variable shadow
@sheepdestroyer

Copy link
Copy Markdown
Owner Author

Addressed fresh global review finding: added nonlocal _non_streaming_finalized in execute_proxy closure (commit 1644d9c). The L2801 assignment was creating a local shadow variable rather than modifying the outer scope — fixed with one-line nonlocal declaration.

@sheepdestroyer sheepdestroyer left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Hermes Agent — Final Fresh Global Code Review

Verdict: ✅ APPROVE — Zero issues. Code is merge-ready.

All 10 checklist items verified against current code at commit 1644d9c:

✅ 1. Langfuse Span Lifecycle

All code paths have correct parent/child finalization. Every try/except/finally block properly calls _end_child_span before _end_parent_obs. The streaming generators finalize telemetry BEFORE any fallible await calls in except blocks (e.g., cooldown save).

✅ 2. _non_streaming_finalized Guard

  • nonlocal declared at L2525 inside execute_proxy
  • 1 init: _non_streaming_finalized = False at L2066 ✅
  • 12 = True sites (L2095, 2481, 2802, 2857, 2865, 2897, 2903, 2914, 2923, 2945, 2951, 2962) — all correct ✅
  • 1 finally guard at L2964-2968 ✅

✅ 3. Streaming Generators (3 async defs)

  • native_agy_stream_generator (L2232): try/except/finally with finalized flag ✅
  • agy_stream_generator (L2394): try/except/finally with finalized flag ✅
  • LiteLLM stream_generator (L2622): try/except/finally with finalized flag; ollama cooldown path at L2706 properly sequenced (telemetry finalized before cooldown save) ✅

✅ 4. Propagation Contexts

_make_prop_ctx DRY helper used at all 4 sites (L2045, 2241, 2403, 2632) ✅
_close_prop_ctx safely handles both real context managers and nullcontext()

✅ 5. Child-Before-Parent Ordering

Every dual-finalization site calls _end_child_span BEFORE _end_parent_obs

✅ 6. Error Paths

All raise sites clean up spans before propagating:

  • LiteLLM non-200 (L2809 → parent finalized by caller handler) ✅
  • Context window exceeded (L2596 → parent by caller) ✅
  • Unknown model (L2092 → parent + prop_ctx + finalized) ✅
  • All 4 nested ollama fallback except HTTPException blocks finalize parent/prop_ctx ✅

✅ 7. Verification Script (verify_canonical_endpoints.py)

  • session_trace_visible guard at L492 ✅
  • API polls use limit=20 at L436 and L498 ✅
  • Symmetric INCONCLUSIVE handling at L528 (session trace not found) and L538 (second trace not flushed) ✅
  • Leak check correctly excludes the known session trace ✅

✅ 8. Code Quality

Zero debug artifacts: no TODO, FIXME, XXX, debugger, or stray print() statements ✅

✅ 9. Security

No hardcoded secrets — all credentials read from environment variables ✅
.env.dev addition is an internal HTTPS URL, not a credential ✅

✅ 10. Documentation

Non-obvious paths have clear comments:

  • Contextvars task isolation for streaming vs non-streaming (L2040-2043) ✅
  • Classification gating rationale (L2150-2152) ✅
  • Ollama cooldown flow notes ✅
  • Leak test INCONCLUSIVE rationale in verification script ✅

Test Results (verified from prior runs)

  • 193/193 unit tests pass ✅
  • 28/28 E2E pass (session propagation + zero leak) ✅

Prior Reviews — All Addressed

  • Gemini CA (2): non-streaming cleanup, leak test ✅
  • CodeRabbit (4): trace_id uuid, agy_stream except, INCONCLUSIVE symmetry, poll logging ✅
  • Owner rounds (8): _non_streaming_finalized, DRY, naming, limits, comment, 4 nested fallbacks ✅
  • Fresh review round 1 (1): nonlocal in execute_proxy ✅

Reviewed by Hermes Agent (fresh global review of commit 1644d9c)

@sheepdestroyer

Copy link
Copy Markdown
Owner Author

Closing to replace with a new PR consolidating all review findings and fixes.

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.

1 participant