Skip to content

fix: Langfuse v4 SDK compatibility + proper trace lifecycle#296

Closed
sheepdestroyer wants to merge 2 commits into
masterfrom
pr-285
Closed

fix: Langfuse v4 SDK compatibility + proper trace lifecycle#296
sheepdestroyer wants to merge 2 commits into
masterfrom
pr-285

Conversation

@sheepdestroyer

@sheepdestroyer sheepdestroyer commented Jul 13, 2026

Copy link
Copy Markdown
Owner

What

Fixes Langfuse tracing in the triage router after upgrading to Langfuse SDK v4.

Changes

  • Critical fix: parent observation (trace) was never ended — parent_obs.end() was missing entirely
  • Added _end_parent_obs() and _end_child_span() helpers compatible with v4 SDK (update() then end())
  • v4 removed .end(output=, metadata=) — all child spans now use update() before end()
  • Added _flush_langfuse_async() to flush after each request (was only flushing every 5 min via aggregate scores)
  • Added session_id, user_id, and environment metadata to traces
  • Replaced fire-and-forget asyncio.create_task() with await on non-streaming flush paths

Verification

  • 95/95 tests pass
  • Live dev deployment: traces appear in Langfuse with session IDs
  • HTTPS e2e: requests succeed, traces visible in dev Langfuse dashboard

Summary by CodeRabbit

  • Monitoring & Observability
    • Improved request tracing across classification, proxy routing, streaming, and non-streaming responses.
    • Added richer trace details, including session, user, model, environment, and tag information.
    • Ensured traces are finalized reliably during successful requests and error scenarios.
    • Improved background event flushing to avoid blocking request processing.
  • Bug Fixes
    • Prevented monitoring failures from interrupting normal request handling.

- Add _end_parent_obs() and _end_child_span() helpers (v4: update() then end())
- Replace all .end(output=, metadata=) calls with helpers
- Add _flush_langfuse_async() for async flush after each request
- Add session_id, user_id, environment metadata to traces
- Fix parent_obs.end() was never called (traces were incomplete)
- Use await for non-streaming flush paths, asyncio.create_task for generators

@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: 46 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: e801b3c4-598a-4744-8a22-f9cccc513e7c

📥 Commits

Reviewing files that changed from the base of the PR and between 5d06463 and 655844d.

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

Walkthrough

Changes

Langfuse observability lifecycle

Layer / File(s) Summary
Guarded observation helpers
router/main.py
Adds safe parent/child observation finalization and asynchronous flushing, then applies guarded child-span completion to classifier success and failure paths.
Request trace metadata
router/main.py
Extracts session and user identifiers and records them with model, environment, and tags in parent trace metadata.
Proxy observation finalization
router/main.py
Updates AGY and LiteLLM streaming, non-streaming, fallback, simulated, and error paths to end observations safely and flush asynchronously.

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

🚥 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 reflects the main change: Langfuse v4 compatibility and trace lifecycle handling.
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 pr-285

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 helper functions (_end_parent_obs, _end_child_span, and _flush_langfuse_async) to safely manage and finalize Langfuse traces and spans, and integrates session and user ID extraction for tracing. The code review feedback highlights several critical improvements: preventing premature garbage collection of unreferenced background tasks created with asyncio.create_task() by storing them in a background tasks set, dynamically passing non-null arguments to update() to avoid overwriting existing values in the Langfuse SDK, and ensuring that pending Langfuse events are flushed immediately on error paths and stream failures to prevent trace loss.

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 Outdated
"tier": target_model, "route": "google_oauth_direct"},
metadata={"latency_ms": latency_ms,
"completion_tokens": token_count})
asyncio.create_task(_flush_langfuse_async())

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

Creating a background task with asyncio.create_task() without keeping a reference to it can lead to the task being garbage collected before it completes (Ruff RUF006). Please add the task to the existing _background_tasks set to prevent premature garbage collection.

                                flush_task = asyncio.create_task(_flush_langfuse_async())
                                _background_tasks.add(flush_task)
                                flush_task.add_done_callback(_background_tasks.discard)

Comment thread router/main.py Outdated
"tier": target_model, "route": "google_oauth_direct"},
metadata={"latency_ms": latency_ms,
"completion_tokens": len(content) // 4})
asyncio.create_task(_flush_langfuse_async())

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

Creating a background task with asyncio.create_task() without keeping a reference to it can lead to the task being garbage collected before it completes (Ruff RUF006). Please add the task to the existing _background_tasks set to prevent premature garbage collection.

                                flush_task = asyncio.create_task(_flush_langfuse_async())
                                _background_tasks.add(flush_task)
                                flush_task.add_done_callback(_background_tasks.discard)

Comment thread router/main.py Outdated
"tier": target_model, "route": "litellm_fallback"},
metadata={"latency_ms": proxy_latency,
"completion_tokens": completion_chars // 4})
asyncio.create_task(_flush_langfuse_async())

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

Creating a background task with asyncio.create_task() without keeping a reference to it can lead to the task being garbage collected before it completes (Ruff RUF006). Please add the task to the existing _background_tasks set to prevent premature garbage collection.

Suggested change
asyncio.create_task(_flush_langfuse_async())
flush_task = asyncio.create_task(_flush_langfuse_async())
_background_tasks.add(flush_task)
flush_task.add_done_callback(_background_tasks.discard)

Comment thread router/main.py
Comment on lines +303 to +306
try:
if output is not None or metadata is not None:
parent_obs.update(output=output, metadata=metadata)
parent_obs.end()

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.

medium

Passing output=None or metadata=None to update() might accidentally overwrite or clear existing values in some versions of the Langfuse SDK. It is safer and more defensive to only pass the arguments that are not None dynamically.

Suggested change
try:
if output is not None or metadata is not None:
parent_obs.update(output=output, metadata=metadata)
parent_obs.end()
try:
update_kwargs = {}
if output is not None:
update_kwargs["output"] = output
if metadata is not None:
update_kwargs["metadata"] = metadata
if update_kwargs:
parent_obs.update(**update_kwargs)
parent_obs.end()

Comment thread router/main.py
Comment on lines +318 to +321
try:
if output is not None or metadata is not None:
span.update(output=output, metadata=metadata)
span.end()

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.

medium

Passing output=None or metadata=None to update() might accidentally overwrite or clear existing values in some versions of the Langfuse SDK. It is safer and more defensive to only pass the arguments that are not None dynamically.

Suggested change
try:
if output is not None or metadata is not None:
span.update(output=output, metadata=metadata)
span.end()
try:
update_kwargs = {}
if output is not None:
update_kwargs["output"] = output
if metadata is not None:
update_kwargs["metadata"] = metadata
if update_kwargs:
span.update(**update_kwargs)
span.end()

Comment thread router/main.py
Comment on lines +1988 to +1990
# guard: end parent obs before raising
_end_parent_obs(parent_obs,
output={"error": f"Unknown model: {client_model}"})

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.

medium

When raising an exception on error paths, it is important to flush the pending Langfuse events immediately to ensure the error trace is not delayed or lost.

        # guard: end parent obs before raising
        _end_parent_obs(parent_obs,
            output={"error": f"Unknown model: {client_model}"})
        await _flush_langfuse_async()

Comment thread router/main.py
Comment on lines +2227 to +2230
# End parent trace on stream error
_end_parent_obs(parent_obs,
output={"error": type(stream_err).__name__,
"route": "google_oauth_direct", "stream": True})

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.

medium

On stream error, the parent trace is ended but never flushed, which can delay or lose the error trace. Triggering a background flush ensures the error trace is sent immediately.

                                # End parent trace on stream error
                                _end_parent_obs(parent_obs,
                                    output={"error": type(stream_err).__name__,
                                            "route": "google_oauth_direct", "stream": True})
                                flush_task = asyncio.create_task(_flush_langfuse_async())
                                _background_tasks.add(flush_task)
                                flush_task.add_done_callback(_background_tasks.discard)

Comment thread router/main.py
Comment on lines +2558 to +2561
# End parent trace on stream error (before any cooldown logic)
_end_parent_obs(parent_obs,
output={"error": type(ex).__name__, "route": "litellm_fallback",
"stream": True})

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.

medium

On stream error, the parent trace is ended but never flushed, which can delay or lose the error trace. Triggering a background flush ensures the error trace is sent immediately.

                            # End parent trace on stream error (before any cooldown logic)
                            _end_parent_obs(parent_obs,
                                output={"error": type(ex).__name__, "route": "litellm_fallback",
                                        "stream": True})
                            flush_task = asyncio.create_task(_flush_langfuse_async())
                            _background_tasks.add(flush_task)
                            flush_task.add_done_callback(_background_tasks.discard)

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
router/main.py (1)

2537-2562: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Missing litellm_span_obj finalization in LiteLLM stream error path.

The except Exception as ex: block at lines 2556-2562 ends the parent observation but does not end the child span litellm_span_obj. This is inconsistent with the AGY stream error path (lines 2219-2226) which finalizes both. The child span will remain open in Langfuse when a LiteLLM stream errors mid-flight.

🐛 End child span before parent on stream error
                        except Exception as ex:
                            logger.error(f"Stream error: {ex}")
+                           if litellm_span_obj:
+                               _end_child_span(litellm_span_obj,
+                                   output={"error": type(ex).__name__},
+                                   metadata={"status": "failed"},
+                               )
                            # End parent trace on stream error (before any cooldown logic)
                            _end_parent_obs(parent_obs,
                                output={"error": type(ex).__name__, "route": "litellm_fallback",
                                        "stream": True})
🤖 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 `@router/main.py` around lines 2537 - 2562, In the LiteLLM streaming exception
handler around `except Exception as ex`, finalize `litellm_span_obj` before
calling `_end_parent_obs`, matching the AGY stream error path. Guard the
child-span cleanup for a non-null span and preserve the existing stream-error
logging and parent-observation handling.
🧹 Nitpick comments (4)
router/main.py (4)

296-335: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Langfuse helper functions are well-structured; fix docstring discrepancy.

The guarded helpers correctly implement the SDK v4 update() + end() pattern with null checks and exception swallowing. However, _end_child_span's docstring at line 314 states "errors are logged but never propagate" — the code only does pass with no logging. Either update the docstring or add a debug log for observability.

📝 Fix docstring or add debug log
 def _end_child_span(span, output=None, metadata=None) -> None:
-    """Safely finalize a Langfuse child span (SDK v4: update + end).
-
-    Non-fatal — errors are logged but never propagate.
-    """
+    """Safely finalize a Langfuse child span (SDK v4: update + end).
+
+    Non-fatal — errors are never propagated.
+    """
     if span is None:
         return
     try:
         if output is not None or metadata is not None:
             span.update(output=output, metadata=metadata)
         span.end()
-    except Exception:
-        pass
+    except Exception as e:
+        logger.debug(f"Langfuse child span finalization failed (non-fatal): {e}")
🤖 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 `@router/main.py` around lines 296 - 335, Update the _end_child_span docstring
to accurately state that exceptions are swallowed without logging, matching its
current except block. Do not add logging or alter the helper’s existing
non-fatal behavior.

2627-2646: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Non-streaming finalization is correct; note pre-existing trace gaps in adjacent error paths.

The changed lines correctly finalize both child span and parent observation with an awaited flush. However, the adjacent unchanged error paths at lines 2648-2655 (non-200 response) and 2656-2662 (exception → 502) raise HTTPException without ending parent_obs or litellm_span_obj. The same gap exists at lines 2582-2591 (streaming non-200 initial response). These are pre-existing but leave traces open on upstream failures — consider adding _end_parent_obs + _end_child_span calls in a follow-up.

🤖 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 `@router/main.py` around lines 2627 - 2646, Update the adjacent non-200 and
exception error paths, plus the streaming non-200 initial-response path, to
finalize both parent_obs via _end_parent_obs and litellm_span_obj via
_end_child_span before raising HTTPException. Preserve the existing error
responses and ensure each path awaits _flush_langfuse_async where required.

1147-1190: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove redundant try/except and null checks around helper calls.

_end_child_span already null-guards span and swallows all exceptions. The outer if class_span_obj: + try/except Exception: pass wrapper is dead code. This pattern repeats at lines 2194-2207, 2220-2226, 2261-2274, 2347-2354, 2357-2364, 2538-2548, and 2628-2638.

♻️ Simplify all helper call sites
             if response.status_code != 200:
-                if class_span_obj:
-                    try:
-                        _end_child_span(class_span_obj, 
+                _end_child_span(class_span_obj,
                             output={
                                 "status": response.status_code,
                                 "error": "classification_failed",
                             },
                             metadata={"latency_ms": latency},
                         )
-                    except Exception:
-                        pass
                 logger.error(

Apply the same simplification to every _end_child_span(...) and _end_parent_obs(...) call site that is wrapped in if <obj>: + try/except Exception: pass.

🤖 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 `@router/main.py` around lines 1147 - 1190, Remove the redundant if checks and
try/except Exception: pass wrappers around _end_child_span and _end_parent_obs
throughout the affected flow, including the shown classification handling and
the other listed call sites. Invoke each helper directly with its existing
arguments, relying on the helpers’ built-in null guarding and exception
swallowing; do not alter the surrounding response, logging, or return behavior.

2196-2231: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Store references to asyncio.create_task fire-and-forget flushes.

Ruff flags RUF006 at lines 2214, 2332, and 2555. Without a stored reference, the event loop may garbage-collect the task before _flush_langfuse_async() completes, causing traces to not flush. Use a module-level set to hold background tasks.

🔒 Add a background-task tracker
+# Module-level set to prevent fire-and-forget task GC
+_langfuse_flush_tasks: set = set()
+
+
 async def _flush_langfuse_async() -> None:
     """Flush pending Langfuse events via thread pool (non-blocking, non-fatal)."""
     try:
         lf = get_langfuse()
         if lf:
             loop = asyncio.get_running_loop()
             await loop.run_in_executor(None, lf.flush)
     except Exception:
         pass

Then at each asyncio.create_task site (lines 2214, 2332, 2555):

-                            asyncio.create_task(_flush_langfuse_async())
+                            _task = asyncio.create_task(_flush_langfuse_async())
+                            _langfuse_flush_tasks.add(_task)
+                            _task.add_done_callback(_langfuse_flush_tasks.discard)
🤖 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 `@router/main.py` around lines 2196 - 2231, Update the native stream tracing
flow around _flush_langfuse_async and the other two create_task call sites to
retain fire-and-forget tasks in a module-level set. Add completed-task cleanup
so references are released after execution, and replace each bare
asyncio.create_task invocation with registration in that tracker to satisfy
RUF006 and ensure flushes complete.

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.

Outside diff comments:
In `@router/main.py`:
- Around line 2537-2562: In the LiteLLM streaming exception handler around
`except Exception as ex`, finalize `litellm_span_obj` before calling
`_end_parent_obs`, matching the AGY stream error path. Guard the child-span
cleanup for a non-null span and preserve the existing stream-error logging and
parent-observation handling.

---

Nitpick comments:
In `@router/main.py`:
- Around line 296-335: Update the _end_child_span docstring to accurately state
that exceptions are swallowed without logging, matching its current except
block. Do not add logging or alter the helper’s existing non-fatal behavior.
- Around line 2627-2646: Update the adjacent non-200 and exception error paths,
plus the streaming non-200 initial-response path, to finalize both parent_obs
via _end_parent_obs and litellm_span_obj via _end_child_span before raising
HTTPException. Preserve the existing error responses and ensure each path awaits
_flush_langfuse_async where required.
- Around line 1147-1190: Remove the redundant if checks and try/except
Exception: pass wrappers around _end_child_span and _end_parent_obs throughout
the affected flow, including the shown classification handling and the other
listed call sites. Invoke each helper directly with its existing arguments,
relying on the helpers’ built-in null guarding and exception swallowing; do not
alter the surrounding response, logging, or return behavior.
- Around line 2196-2231: Update the native stream tracing flow around
_flush_langfuse_async and the other two create_task call sites to retain
fire-and-forget tasks in a module-level set. Add completed-task cleanup so
references are released after execution, and replace each bare
asyncio.create_task invocation with registration in that tracker to satisfy
RUF006 and ensure flushes complete.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb5c497a-8f5b-47bf-85ec-6cd7bd068fa9

📥 Commits

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

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

…ardening

- Dynamic kwargs for _end_parent_obs / _end_child_span update() (Gemini: avoid overwriting None)
- Fix _end_child_span docstring (CodeRabbit: 'logged' → 'never propagated')
- Register all fire-and-forget flush tasks in _background_tasks set (Gemini+CodeRabbit: RUF006)
- Flush Langfuse after error-path _end_parent_obs calls (Gemini: missing flush on stream errors)
- End litellm_span_obj before parent on LiteLLM stream error (CodeRabbit: missing finalization)
- Remove redundant if <obj>: + try/except wrappers around helper calls (CodeRabbit: dead code)
- Add trace finalization to non-200 and exception error paths (CodeRabbit: pre-existing gaps)
- Add trace finalization to streaming non-200 initial response (CodeRabbit: raised without ending traces)
@sheepdestroyer

Copy link
Copy Markdown
Owner Author

Closing in favor of a fresh PR with all review comments addressed. See #297.

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