Repository navigation
feat: export agent traces as markdown for claude and codex - #43873
ishaan-berri wants to merge 22 commits into
Conversation
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
|
||
| def visible_parent(span: Span) -> str | None: | ||
| parent = by_id.get(span["parent_span_id"] or "") | ||
| while parent is not None and _hidden(parent): |
There was a problem hiding this comment.
Medium: Cyclic spans block proxy workers
With tracing enabled, a normal virtual-key holder can submit a root span, a framework span whose parent is itself, and a visible child of that framework span through POST /v1/traces, then request GET /v1/traces/{id}?format=md. Normalization and JSON trace loading accept this graph, but this loop never terminates; since the renderer executes synchronously inside the async endpoint, the export blocks that worker from serving other requests.
Track visited span IDs and reject or cut cyclic parent chains before following the next parent.
PR overviewThe PR adds Markdown export of agent traces for Claude and Codex, including rendering trace spans through the trace retrieval endpoint. One issue remains open, with none addressed so far. When tracing is enabled, a virtual-key holder can submit a cyclic span graph and request its Markdown export, causing the renderer to loop indefinitely. Because rendering runs synchronously inside the async endpoint, this blocks the affected proxy worker from serving other requests. Open issues (1)
Fixed/addressed: 0 · PR risk: 6/10 |
|
| # native routes with placeholders, e.g. "/v1/traces/{trace_id}" | ||
| for native_route in LiteLLMRoutes.litellm_native_routes.value: | ||
| if "{" in native_route and RouteChecks._route_matches_pattern(route=route, pattern=native_route): | ||
| return True |
There was a problem hiding this comment.
Admin viewer trace reads blocked
When a proxy-admin-view-only user requests an individual trace, this new match classifies the URL as an LLM API route. The admin-viewer access check rejects those routes before it allows safe GET requests, so both JSON and Markdown trace reads now return 403 even though scope_for() grants that role trace access.
| header += [f"## Subtree of `{root['name']}`", ""] | ||
| lines.append(_span_block(root, (root_input, root_output), 0)) | ||
| walk(root["span_id"], 1) |
| if root is None: | ||
| return "\n".join([*header, "_no spans_"]) |
| TRACE_IO_SQL: Final = f""" | ||
| SELECT SpanId AS span_id, Input AS input, Output AS output | ||
| FROM {OTEL_TRACES_TABLE} | ||
| WHERE TraceId = {{trace_id:String}} AND {_SCOPE_OTEL} | ||
| """ |
There was a problem hiding this comment.
When a trace's stored input and output exceed the reader's 4 MiB response limit, this query fails before Markdown rendering can apply the 2,000-character field limit. A valid trace whose metadata-only JSON response is readable can therefore fail to export as Markdown rather than producing clipped text.
|
|
||
| def _first_error_line(error: str) -> str: | ||
| """LangSmith records `repr(exc)` + traceback with no separator; keep just the exception.""" | ||
| return error.split("Traceback (most recent call last):", 1)[0].splitlines()[0].strip() |
There was a problem hiding this comment.
| TRACE_IO_SQL: Final = f""" | ||
| SELECT SpanId AS span_id, Input AS input, Output AS output | ||
| FROM {OTEL_TRACES_TABLE} | ||
| WHERE TraceId = {{trace_id:String}} AND {_SCOPE_OTEL} | ||
| """ |
There was a problem hiding this comment.
Repeated spans get mismatched IO
When a span has repeated stored rows, get_trace() selects its earliest timestamped row, but this unordered IO query returns every row and get_span_io() keeps whichever appears last. The Markdown export can pair the displayed span with input or output from a different row, and the result can vary between reads.
| for call in m.get("tool_calls") or []: | ||
| lines.append(f"**{m['role']}** → `{call.get('name')}({json.dumps(call.get('args'), default=str)})`") |
There was a problem hiding this comment.
Tool arguments bypass length limit
Tool-call arguments are serialized directly into the Markdown line without the clipping applied to message content. A large arguments object can dominate the paste despite the configured maximum field length, making the export harder to use.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
0abf1e4 to
c7482fd
Compare
a6e29f1 to
e3f5f65
Compare
df529d4 to
b7ab950
Compare
b7ab950 to
4a5f738
Compare
TLDR
Problem this solves:
GET /v1/traces/{trace_id}(the templated route never matched)How it solves it:
GET /v1/traces/{id}?format=mdreturns the run as Markdown: input, each step nested, output&span_id=exports only that step and its childrenlitellm_native_routes(e.g./v1/traces/{trace_id}) now match like OpenAI routes doStacked on #43864.
User Flow
Before: a developer wants Claude to explain why their agent run failed, but has to piece the trace together by hand
curl -H "Authorization: Bearer sk-…" https://litellm-domain/v1/traces/<id>with their virtual key returns401 Only proxy admin can be used…After: the same developer pastes one line into Claude Code or Codex
curl -H "Authorization: Bearer sk-…" "https://litellm-domain/v1/traces/<id>?format=md"returns200with Markdownget_customer_planfailed, and the model call didn'tA key only gets traces in its own team scope.
format=mduses the samescope_for()as the JSON read.Pre-Submission checklist
Screenshots / Proof of Fix
Local proxy on this branch, ClickHouse 25.8, real LangChain agent traces (a tool raised
ValueError). Requests used theresearch-botvirtual key, not the master key.Before (litellm_agent_tracing_integration)
curl -H "Authorization: Bearer $KEY" localhost:4011/v1/traces/f78f6df3…returns401 Authentication Error, Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/v1/traces/f78f6df3…After (this branch)
curl -H "Authorization: Bearer $KEY" "localhost:4011/v1/traces/f78f6df3…?format=md"returns200:Type
🆕 New Feature
🐛 Bug Fix
✅ Test
Caveats (if any)
Low
AGENT_TRACING_MARKDOWN_MAX_FIELD_CHARS(default 2000) so big prompts don't flood the paste🤖 Generated with Claude Code