feat(ui): agent traces open in a side drawer with a chat-style run view (#43972)

* feat(ui): redesign agent trace run view with chat-style detail pane

Tree with connector lines, typed icon tiles and provider logos, hover
cards with timing, and Input/Output sections rendered as message cards.

* fix(tracing): show text for block-list message content and split normalizers per convention

OpenAI responses-style content (reasoning + text blocks) rendered as raw
JSON in the trace view. Keep the text blocks and drop opaque reasoning.

Move each convention into litellm/tracing/normalizers with an ordered
registry so new frameworks plug in without touching OTLP decoding.

* fix(tracing): keep long message histories as valid JSON and parse function_call blocks

* feat(ui): open agent traces in a resizable side drawer with a devtool-style tree

Clicking a run opens it in a drawer over the list instead of a full page.
j/k and the header arrows switch runs, Esc closes. The tree gets dashed
connectors, per-span waterfall bars, mono tool names and real provider
logos. AI messages with reasoning/function_call blocks render as text.

* fix(trace-ui): address review: valid JSON trimming, drawer keys, reduced motion, narrow screens

* feat(tracing): serve span content in a standard LiteLLM UI format

GET /v1/traces/{trace_id}/spans/{span_id} now also returns input_ui and
output_ui, a tagged union of messages, fields or text built server side by
litellm/tracing/ui_format.py. The trace UI renders from those fields and only
falls back to client-side parsing when talking to an older proxy. The raw
input and output strings are unchanged, and so is storage

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

* fix(tracing): fall back to an elision marker when shortened messages still exceed the size limit

* fix(tracing): keep both messages when tool_calls are oversized and keep failed-tool styling

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ishaan-berri 2026-10-01 10:55:51 -07:00 • committed by GitHub
parent 73072b8643
commit d24240c014
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3185 additions and 662 deletions

View file

@ -9,14 +9,23 @@ Pure functions, no I/O. Two steps:
"""
import json
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from itertools import accumulate
from types import MappingProxyType
from typing import Any, Final
from typing import Final
from pydantic import JsonValue, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
from litellm.constants import OTLP_MAX_ATTRIBUTE_VALUE_BYTES, OTLP_MAX_BODY_BYTES
from litellm.rust_bridge.traces import DecodedSpan
from litellm.rust_bridge.traces import decode_otlp as native_decode_otlp
from litellm.tracing.types import SpanRow, SpanType
from litellm.tracing.normalizers import select_normalizer
from litellm.tracing.normalizers.base import to_int
from litellm.tracing.types import SpanRow
_MESSAGE_LIST: Final = TypeAdapter(tuple[dict[str, JsonValue], ...])
_MAX_JSON_ESCAPE_BYTES: Final = 6
# attributes whose content we lift into Input/Output and drop from SpanAttributes
_HEAVY_ATTRIBUTES: Final = frozenset(
@ -30,18 +39,6 @@ _HEAVY_ATTRIBUTES: Final = frozenset(
"output.value",
}
)
# LangChain / Deep Agents middleware wrappers: real spans, but noise in the UI
_FRAMEWORK_SUFFIXES: Final = (
".wrap_model_call",
".wrap_tool_call",
".before_agent",
".after_agent",
".before_model",
".after_model",
)
_LLM_OPERATIONS: Final = frozenset({"chat", "text_completion", "generate_content"})
_LC_ROLES: Final = MappingProxyType({"human": "user", "ai": "assistant", "system": "system", "tool": "tool"})
_OPENINFERENCE_TYPES: Final[Mapping[str, SpanType]] = MappingProxyType({"AGENT": "agent", "LLM": "llm", "TOOL": "tool"})
class InvalidOTLPPayloadError(ValueError):
@ -60,6 +57,83 @@ def _truncate(value: str) -> str:
return f"{kept}…[truncated {size - OTLP_MAX_ATTRIBUTE_VALUE_BYTES} bytes]"
def _size(value: str) -> int:
return len(value.encode("utf-8"))
class _ElisionMarker(TypedDict):
role: ReadOnly[str]
content: ReadOnly[str]
def _elided(count: int) -> str:
marker: Final[_ElisionMarker] = {"role": "system", "content": f"…[{count} earlier messages truncated]"}
return json.dumps(marker)
def _with_content(message: Mapping[str, JsonValue], content: str) -> str:
return json.dumps(MappingProxyType({**message, "content": content}), default=lambda proxy: proxy.copy())
def _shrunk_message(message: Mapping[str, JsonValue], budget: int) -> str:
"""One message cut to `budget` bytes, as valid JSON.
Shortens `content` first; if other fields (e.g. huge tool_calls) still don't fit, keeps only role + content.
"""
content: Final = message.get("content")
text: Final = content if isinstance(content, str) else json.dumps(content)
role_only: Final = MappingProxyType({"role": message.get("role", "user")})
attempts: Final = (
_cut_content(message, text, budget, 1),
_cut_content(role_only, text, budget, 1),
_cut_content(role_only, text, budget, _MAX_JSON_ESCAPE_BYTES),
)
return next((attempt for attempt in attempts if _size(attempt) <= budget), attempts[-1])
def _cut_content(message: Mapping[str, JsonValue], text: str, budget: int, escape_factor: int) -> str:
overhead: Final = _size(_with_content(message, ""))
room: Final = max(0, budget - overhead - 48) // escape_factor
kept: Final = text.encode("utf-8")[:room].decode("utf-8", "ignore")
return _with_content(message, f"{kept}…[truncated {_size(text) - _size(kept)} bytes]")
def _newest_that_fit(encoded: tuple[str, ...], budget: int) -> int:
"""How many trailing messages fit in `budget` bytes (comma separators included), scanning newest first."""
sizes: Final = tuple(_size(m) + 1 for m in reversed(encoded))
totals: Final = tuple(accumulate(sizes))
return next((count for count, total in enumerate(totals) if total > budget), len(totals))
def _truncate_payload(value: str) -> str:
"""Message arrays keep the first message, an elision marker and the newest messages that fit.
The result is always valid JSON: if even those don't fit, the first and last messages are shortened.
Anything that isn't a message array is byte-truncated as before.
"""
if _size(value) <= OTLP_MAX_ATTRIBUTE_VALUE_BYTES or not value.startswith("["):
return _truncate(value)
try:
messages: Final = _MESSAGE_LIST.validate_json(value)
except ValidationError:
return _truncate(value)
if len(messages) < 2:
return _truncate(value)
encoded: Final = tuple(json.dumps(m) for m in messages)
marker_budget: Final = _size(_elided(len(messages))) + 1
budget: Final = OTLP_MAX_ATTRIBUTE_VALUE_BYTES - 2 - _size(encoded[0]) - 1 - marker_budget
kept: Final = min(_newest_that_fit(encoded[1:], budget), len(messages) - 2)
if kept > 0:
tail: Final = encoded[len(encoded) - kept :]
return "[" + ", ".join((encoded[0], _elided(len(messages) - 1 - kept), *tail)) + "]"
half: Final = (OTLP_MAX_ATTRIBUTE_VALUE_BYTES - marker_budget - 4) // 2
middle: Final = (_elided(len(messages) - 2),) if len(messages) > 2 else ()
shrunk: Final = (
"[" + ", ".join((_shrunk_message(messages[0], half), *middle, _shrunk_message(messages[-1], half))) + "]"
)
return shrunk if _size(shrunk) <= OTLP_MAX_ATTRIBUTE_VALUE_BYTES else "[" + _elided(len(messages)) + "]"
def decode_otlp(
body: bytes, content_type: str | None = None, content_encoding: str | None = None
) -> tuple[SpanRow, ...]:
@ -116,157 +190,17 @@ def _span_row(span: DecodedSpan) -> SpanRow:
row["SpanAttributes"] = { # mutable-ok: the Rust JSON bridge requires a plain dict for span attributes
k: _truncate(v) for k, v in attributes.items() if k not in _HEAVY_ATTRIBUTES
}
row["Input"], row["Output"] = _truncate(row["Input"]), _truncate(row["Output"])
row["Input"], row["Output"] = _truncate_payload(row["Input"]), _truncate(row["Output"])
return row
def _loads(value: str) -> object:
try:
return json.loads(value)
except (ValueError, TypeError):
return None
def _lc_message(message: Mapping[str, Any]) -> dict[str, Any]:
"""LangChain serialized message (or plain {role, content}) -> {role, content, tool_calls?}."""
kwargs = message.get("kwargs", message)
role = _LC_ROLES.get(kwargs.get("type") or kwargs.get("role"), kwargs.get("role") or kwargs.get("type") or "")
content = kwargs.get("content", "")
out: dict[str, Any] = { # mutable-ok: the framework message is built for JSON serialization
"role": role,
"content": content if isinstance(content, str) else json.dumps(content),
}
if kwargs.get("tool_calls"):
out["tool_calls"] = tuple(
{"name": t.get("name"), "args": t.get("args")} # mutable-ok: JSON tool calls need object payloads
for t in kwargs["tool_calls"]
)
if role == "tool" and kwargs.get("name"):
out["name"] = kwargs["name"]
return out
def _langsmith_type(row: SpanRow, attributes: Mapping[str, str]) -> SpanType:
kind = attributes.get("langsmith.span.kind", "chain")
name = row["SpanName"]
if not row["ParentSpanId"] or name == attributes.get("langsmith.metadata.lc_agent_name"):
return "agent"
if kind in ("llm", "tool"):
return kind
if name.endswith(_FRAMEWORK_SUFFIXES):
return "framework"
return "chain"
def _langsmith_io(row: SpanRow, attributes: Mapping[str, str]) -> None:
prompt = _loads(attributes.get("gen_ai.prompt", ""))
completion = _loads(attributes.get("gen_ai.completion", ""))
prompt_payload = prompt if isinstance(prompt, dict) else MappingProxyType({})
if row["ObservationType"] == "llm" and isinstance(completion, dict):
messages = prompt_payload.get("messages") or ((),)
batch = messages[0] if messages and isinstance(messages[0], list) else messages
row["Input"] = (
json.dumps(tuple(_lc_message(m) for m in batch if isinstance(m, dict)))
if isinstance(batch, (list, tuple))
else ""
)
generations: Final = completion.get("generations")
first: Final = generations[0] if isinstance(generations, list) and generations else None
item: Final = first[0] if isinstance(first, list) and first else None
message: Final = item.get("message") if isinstance(item, dict) else None
generation: Final = message.get("kwargs") if isinstance(message, dict) else None
if isinstance(generation, dict):
row["Output"] = json.dumps(_lc_message(generation))
metadata: Final = generation.get("response_metadata")
row["LiteLLMRequestId"] = metadata.get("id", "") if isinstance(metadata, dict) else ""
else:
row["Output"] = attributes.get("gen_ai.completion", "")
return
if row["ObservationType"] == "tool":
output = completion.get("output", completion) if isinstance(completion, dict) else completion
if isinstance(output, dict) and "update" in output: # LangGraph Command, e.g. Deep Agents `task`
update: Final = output.get("update")
update_messages = update.get("messages") or () if isinstance(update, dict) else ()
output = update_messages[-1] if update_messages else output
if isinstance(output, dict):
output = output.get("content", output)
row["Input"] = attributes.get("gen_ai.prompt", "")
row["Output"] = output if isinstance(output, str) else json.dumps(output)
return
if row["ObservationType"] == "agent":
input_messages = prompt.get("messages") if isinstance(prompt, dict) else None
output_messages = completion.get("messages") if isinstance(completion, dict) else None
# agents built with @traceable take arbitrary args, not a message list: keep the raw payload then
row["Input"] = (
json.dumps(tuple(_lc_message(m) for m in input_messages if isinstance(m, dict)))
if input_messages
else attributes.get("gen_ai.prompt", "")
)
row["Output"] = (
json.dumps(_lc_message(output_messages[-1]))
if output_messages and isinstance(output_messages[-1], dict)
else attributes.get("gen_ai.completion", "")
)
return
row["Input"] = attributes.get("gen_ai.prompt", "")
row["Output"] = attributes.get("gen_ai.completion", "")
def normalize_langsmith(row: SpanRow, attributes: Mapping[str, str]) -> None:
row["ObservationType"] = _langsmith_type(row, attributes)
row["AgentName"] = attributes.get("langsmith.metadata.lc_agent_name", "")
row["Model"] = attributes.get("gen_ai.request.model", "")
_langsmith_io(row, attributes)
def normalize_genai(row: SpanRow, attributes: Mapping[str, str]) -> None:
operation = attributes.get("gen_ai.operation.name", "")
if operation == "invoke_agent" or not row["ParentSpanId"]:
row["ObservationType"] = "agent"
elif operation in _LLM_OPERATIONS:
row["ObservationType"] = "llm"
elif operation == "execute_tool":
row["ObservationType"] = "tool"
row["AgentName"] = attributes.get("gen_ai.agent.name", "")
row["Model"] = attributes.get("gen_ai.request.model") or attributes.get("gen_ai.response.model", "")
row["LiteLLMRequestId"] = attributes.get("gen_ai.response.id", "")
row["Input"] = attributes.get("gen_ai.input.messages") or attributes.get("gen_ai.tool.call.arguments", "")
row["Output"] = attributes.get("gen_ai.output.messages") or attributes.get("gen_ai.tool.call.result", "")
def normalize_openinference(row: SpanRow, attributes: Mapping[str, str]) -> None:
kind = attributes.get("openinference.span.kind", "").upper()
row["ObservationType"] = _OPENINFERENCE_TYPES.get(kind, "agent" if not row["ParentSpanId"] else "chain")
row["AgentName"] = attributes.get("agent.name", "")
row["Model"] = attributes.get("llm.model_name", "")
row["Input"] = attributes.get("input.value", "")
row["Output"] = attributes.get("output.value", "")
row["InputTokens"] = _to_int(attributes.get("llm.token_count.prompt"))
row["OutputTokens"] = _to_int(attributes.get("llm.token_count.completion"))
def _set_tokens(row: SpanRow, attributes: Mapping[str, str]) -> None:
row["InputTokens"] = _to_int(attributes.get("gen_ai.usage.input_tokens"))
row["OutputTokens"] = _to_int(attributes.get("gen_ai.usage.output_tokens"))
def _to_int(value: str | None) -> int:
try:
return int(value) if value else 0
except ValueError:
return 0
def select_normalizer(scope_name: str, attributes: Mapping[str, str]) -> Callable[[SpanRow, Mapping[str, str]], None]:
if scope_name == "langsmith" or "langsmith.span.kind" in attributes:
return normalize_langsmith
if "openinference.span.kind" in attributes:
return normalize_openinference
return normalize_genai
row["InputTokens"] = to_int(attributes.get("gen_ai.usage.input_tokens"))
row["OutputTokens"] = to_int(attributes.get("gen_ai.usage.output_tokens"))
def normalize(row: SpanRow, attributes: Mapping[str, str]) -> None:
select_normalizer(row["ScopeName"], attributes)(row, attributes)
select_normalizer(row["ScopeName"], attributes).normalize(row, attributes)
if not row["InputTokens"] and not row["OutputTokens"]:
_set_tokens(row, attributes)

View file

@ -0,0 +1,32 @@
"""Per-convention span normalizers, tried in order: the first whose `matches()` is true wins."""
from collections.abc import Mapping, Sequence
from typing import Final
from litellm.tracing.normalizers.base import SpanNormalizer
from litellm.tracing.normalizers.genai import GenAISemconvNormalizer
from litellm.tracing.normalizers.langsmith import LangSmithNormalizer
from litellm.tracing.normalizers.openinference import OpenInferenceNormalizer
NORMALIZERS: Final[tuple[SpanNormalizer, ...]] = (
LangSmithNormalizer(),
OpenInferenceNormalizer(),
GenAISemconvNormalizer(),
)
_FALLBACK: Final[SpanNormalizer] = GenAISemconvNormalizer()
def select_normalizer(
scope_name: str, attributes: Mapping[str, str], registry: Sequence[SpanNormalizer] = NORMALIZERS
) -> SpanNormalizer:
return next((n for n in registry if n.matches(scope_name, attributes)), _FALLBACK)
__all__ = (
"NORMALIZERS",
"GenAISemconvNormalizer",
"LangSmithNormalizer",
"OpenInferenceNormalizer",
"SpanNormalizer",
"select_normalizer",
)

View file

@ -0,0 +1,22 @@
from collections.abc import Mapping
from typing import Protocol
from litellm.tracing.types import SpanRow
class SpanNormalizer(Protocol):
"""Maps one tracing convention's span attributes onto the LiteLLM `SpanRow` columns."""
@property
def name(self) -> str: ...
def matches(self, scope_name: str, attributes: Mapping[str, str]) -> bool: ...
def normalize(self, row: SpanRow, attributes: Mapping[str, str]) -> None: ...
def to_int(value: str | None) -> int:
try:
return int(value) if value else 0
except ValueError:
return 0

View file

@ -0,0 +1,31 @@
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final
from litellm.tracing.types import SpanRow
_LLM_OPERATIONS: Final = frozenset({"chat", "text_completion", "generate_content"})
@dataclass(frozen=True, slots=True)
class GenAISemconvNormalizer:
"""OTEL `gen_ai.*` semantic conventions. Matches every span, so it belongs last as the fallback."""
name: str = "genai"
def matches(self, scope_name: str, attributes: Mapping[str, str]) -> bool:
return True
def normalize(self, row: SpanRow, attributes: Mapping[str, str]) -> None:
operation: Final = attributes.get("gen_ai.operation.name", "")
if operation == "invoke_agent" or not row["ParentSpanId"]:
row["ObservationType"] = "agent"
elif operation in _LLM_OPERATIONS:
row["ObservationType"] = "llm"
elif operation == "execute_tool":
row["ObservationType"] = "tool"
row["AgentName"] = attributes.get("gen_ai.agent.name", "")
row["Model"] = attributes.get("gen_ai.request.model") or attributes.get("gen_ai.response.model", "")
row["LiteLLMRequestId"] = attributes.get("gen_ai.response.id", "")
row["Input"] = attributes.get("gen_ai.input.messages") or attributes.get("gen_ai.tool.call.arguments", "")
row["Output"] = attributes.get("gen_ai.output.messages") or attributes.get("gen_ai.tool.call.result", "")

View file

@ -0,0 +1,115 @@
"""LangSmith OTEL mode, which LangChain, LangGraph and Deep Agents export through."""
import json
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from litellm.tracing.normalizers.messages import lc_message
from litellm.tracing.types import SpanRow, SpanType
# LangChain / Deep Agents middleware wrappers: real spans, but noise in the UI
_FRAMEWORK_SUFFIXES: Final = (
".wrap_model_call",
".wrap_tool_call",
".before_agent",
".after_agent",
".before_model",
".after_model",
)
def _loads(value: str) -> object:
try:
return json.loads(value)
except (ValueError, TypeError):
return None
def _span_type(row: SpanRow, attributes: Mapping[str, str]) -> SpanType:
kind: Final = attributes.get("langsmith.span.kind", "chain")
name: Final = row["SpanName"]
if not row["ParentSpanId"] or name == attributes.get("langsmith.metadata.lc_agent_name"):
return "agent"
if kind in ("llm", "tool"):
return kind
if name.endswith(_FRAMEWORK_SUFFIXES):
return "framework"
return "chain"
def _tool_output(completion: object) -> object:
raw: Final = completion.get("output", completion) if isinstance(completion, dict) else completion
update: Final = raw.get("update") if isinstance(raw, dict) else None
update_messages: Final = update.get("messages") or () if isinstance(update, dict) else ()
is_command: Final = isinstance(raw, dict) and "update" in raw
# LangGraph Command (e.g. the Deep Agents `task` tool): the result is the last update message
output: Final = update_messages[-1] if is_command and update_messages else raw
return output.get("content", output) if isinstance(output, dict) else output
def _set_agent_io(row: SpanRow, attributes: Mapping[str, str], prompt: object, completion: object) -> None:
input_messages: Final = prompt.get("messages") if isinstance(prompt, dict) else None
output_messages: Final = completion.get("messages") if isinstance(completion, dict) else None
# agents built with @traceable take arbitrary args, not a message list: keep the raw payload then
row["Input"] = (
json.dumps(tuple(lc_message(m) for m in input_messages if isinstance(m, dict)))
if input_messages
else attributes.get("gen_ai.prompt", "")
)
row["Output"] = (
json.dumps(lc_message(output_messages[-1]))
if output_messages and isinstance(output_messages[-1], dict)
else attributes.get("gen_ai.completion", "")
)
def _set_io(row: SpanRow, attributes: Mapping[str, str]) -> None:
prompt: Final = _loads(attributes.get("gen_ai.prompt", ""))
completion: Final = _loads(attributes.get("gen_ai.completion", ""))
if row["ObservationType"] == "llm" and isinstance(completion, dict):
prompt_payload: Final = prompt if isinstance(prompt, dict) else MappingProxyType({})
messages: Final = prompt_payload.get("messages") or ((),)
batch: Final = messages[0] if messages and isinstance(messages[0], list) else messages
row["Input"] = (
json.dumps(tuple(lc_message(m) for m in batch if isinstance(m, dict)))
if isinstance(batch, (list, tuple))
else ""
)
generations: Final = completion.get("generations")
first: Final = generations[0] if isinstance(generations, list) and generations else None
item: Final = first[0] if isinstance(first, list) and first else None
message: Final = item.get("message") if isinstance(item, dict) else None
generation: Final = message.get("kwargs") if isinstance(message, dict) else None
if isinstance(generation, dict):
row["Output"] = json.dumps(lc_message(generation))
metadata: Final = generation.get("response_metadata")
row["LiteLLMRequestId"] = metadata.get("id", "") if isinstance(metadata, dict) else ""
return
row["Output"] = attributes.get("gen_ai.completion", "")
return
if row["ObservationType"] == "tool":
output: Final = _tool_output(completion)
row["Input"] = attributes.get("gen_ai.prompt", "")
row["Output"] = output if isinstance(output, str) else json.dumps(output)
return
if row["ObservationType"] == "agent":
_set_agent_io(row, attributes, prompt, completion)
return
row["Input"] = attributes.get("gen_ai.prompt", "")
row["Output"] = attributes.get("gen_ai.completion", "")
@dataclass(frozen=True, slots=True)
class LangSmithNormalizer:
name: str = "langsmith"
def matches(self, scope_name: str, attributes: Mapping[str, str]) -> bool:
return scope_name == "langsmith" or "langsmith.span.kind" in attributes
def normalize(self, row: SpanRow, attributes: Mapping[str, str]) -> None:
row["ObservationType"] = _span_type(row, attributes)
row["AgentName"] = attributes.get("langsmith.metadata.lc_agent_name", "")
row["Model"] = attributes.get("gen_ai.request.model", "")
_set_io(row, attributes)

View file

@ -0,0 +1,59 @@
import json
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Final, Literal, TypeAlias
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
ChatRole: TypeAlias = Literal["system", "user", "assistant", "tool"]
MESSAGE_ROLES: Final[Mapping[str, ChatRole]] = MappingProxyType(
{"human": "user", "user": "user", "ai": "assistant", "assistant": "assistant", "system": "system", "tool": "tool"}
)
class _ContentBlock(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
type: str = ""
text: str | None = None
_CONTENT_BLOCKS: Final = TypeAdapter(tuple[_ContentBlock, ...])
_NON_TEXT_BLOCKS: Final = frozenset(
{"reasoning", "thinking", "redacted_thinking", "function_call", "tool_use", "tool_call"}
)
def content_text(content: object) -> str:
"""Message content as display text: Responses-style block lists keep only their text blocks."""
if content is None:
return ""
if isinstance(content, str):
return content
try:
blocks: Final = _CONTENT_BLOCKS.validate_python(content)
except ValidationError:
return json.dumps(content)
if not all(block.text is not None or block.type in _NON_TEXT_BLOCKS for block in blocks):
return json.dumps(content)
return "\n\n".join(block.text for block in blocks if block.text is not None)
def lc_message(message: Mapping[str, Any]) -> dict[str, Any]:
"""LangChain serialized message (or plain {role, content}) -> {role, content, tool_calls?}."""
kwargs: Final = message.get("kwargs", message)
role: Final = MESSAGE_ROLES.get(
kwargs.get("type") or kwargs.get("role"), kwargs.get("role") or kwargs.get("type") or ""
)
out: Final[dict[str, Any]] = { # mutable-ok: the framework message is built for JSON serialization
"role": role,
"content": content_text(kwargs.get("content", "")),
}
if kwargs.get("tool_calls"):
out["tool_calls"] = tuple(
{"name": t.get("name"), "args": t.get("args")} # mutable-ok: JSON tool calls need object payloads
for t in kwargs["tool_calls"]
)
if role == "tool" and kwargs.get("name"):
out["name"] = kwargs["name"]
return out

View file

@ -0,0 +1,27 @@
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from litellm.tracing.normalizers.base import to_int
from litellm.tracing.types import SpanRow, SpanType
_OPENINFERENCE_TYPES: Final[Mapping[str, SpanType]] = MappingProxyType({"AGENT": "agent", "LLM": "llm", "TOOL": "tool"})
@dataclass(frozen=True, slots=True)
class OpenInferenceNormalizer:
name: str = "openinference"
def matches(self, scope_name: str, attributes: Mapping[str, str]) -> bool:
return "openinference.span.kind" in attributes
def normalize(self, row: SpanRow, attributes: Mapping[str, str]) -> None:
kind: Final = attributes.get("openinference.span.kind", "").upper()
row["ObservationType"] = _OPENINFERENCE_TYPES.get(kind, "agent" if not row["ParentSpanId"] else "chain")
row["AgentName"] = attributes.get("agent.name", "")
row["Model"] = attributes.get("llm.model_name", "")
row["Input"] = attributes.get("input.value", "")
row["Output"] = attributes.get("output.value", "")
row["InputTokens"] = to_int(attributes.get("llm.token_count.prompt"))
row["OutputTokens"] = to_int(attributes.get("llm.token_count.completion"))

View file

@ -28,6 +28,7 @@ from litellm.tracing.types import (
TraceScope,
TraceSummary,
)
from litellm.tracing.ui_format import to_ui_content
NANOS_PER_MS: Final = 1_000_000
SPEND_WINDOW_MS: Final = 30 * 60 * 1000
@ -344,5 +345,7 @@ class ClickHouseTraceStore:
span_id=rows[0]["span_id"],
input=rows[0]["input"],
output=rows[0]["output"],
input_ui=to_ui_content(rows[0]["input"]),
output_ui=to_ui_content(rows[0]["output"]),
attributes=rows[0]["attributes"],
)

View file

@ -14,6 +14,8 @@ from typing import Literal
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.tracing.ui_format import UIContent
SpanType = Literal["agent", "llm", "tool", "chain", "framework"]
SpanStatus = Literal["ok", "error", "unset"]
@ -84,6 +86,8 @@ class SpanDetail(TypedDict):
span_id: ReadOnly[str]
input: ReadOnly[str]
output: ReadOnly[str]
input_ui: ReadOnly[UIContent]
output_ui: ReadOnly[UIContent]
attributes: ReadOnly[dict[str, str]]

View file

@ -0,0 +1,158 @@
"""The LiteLLM UI content format: span input / output reduced to messages, key/value fields or plain text."""
import json
from collections.abc import Mapping, Sequence
from typing import Final, Literal, TypeAlias
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.tracing.normalizers.messages import MESSAGE_ROLES, ChatRole, content_text
class UIToolCall(TypedDict):
name: ReadOnly[str]
arguments: ReadOnly[str]
class UIMessage(TypedDict):
role: ReadOnly[ChatRole]
content: ReadOnly[str]
name: ReadOnly[NotRequired[str]]
tool_calls: ReadOnly[NotRequired[tuple[UIToolCall, ...]]]
class UIField(TypedDict):
key: ReadOnly[str]
value: ReadOnly[str]
class UIMessages(TypedDict):
kind: ReadOnly[Literal["messages"]]
messages: ReadOnly[tuple[UIMessage, ...]]
class UIFields(TypedDict):
kind: ReadOnly[Literal["fields"]]
fields: ReadOnly[tuple[UIField, ...]]
class UIText(TypedDict):
kind: ReadOnly[Literal["text"]]
text: ReadOnly[str]
UIContent: TypeAlias = UIMessages | UIFields | UIText
class _ToolFunction(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
name: str = ""
arguments: JsonValue = None
class _RawToolCall(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
name: str = ""
args: JsonValue = None
arguments: JsonValue = None
function: _ToolFunction | None = None
class _RawMessage(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
role: str | None = None
type: str | None = None
content: JsonValue = None
name: str | None = None
tool_calls: tuple[_RawToolCall, ...] | None = None
kwargs: "_RawMessage | None" = None
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_MESSAGE: Final = TypeAdapter(_RawMessage)
_MESSAGES: Final = TypeAdapter(tuple[_RawMessage, ...])
def _unwrapped(message: _RawMessage) -> _RawMessage:
return message.kwargs if message.kwargs is not None else message
def _is_message(message: _RawMessage) -> bool:
has_role: Final = message.role is not None or message.type in MESSAGE_ROLES
return has_role and ("content" in message.model_fields_set or bool(message.tool_calls))
def _arguments_text(arguments: JsonValue) -> str:
match arguments:
case str():
return arguments
case None:
return "{}"
case _:
return json.dumps(arguments)
def _tool_call(call: _RawToolCall) -> UIToolCall:
if call.function is not None:
return UIToolCall(name=call.function.name or call.name, arguments=_arguments_text(call.function.arguments))
return UIToolCall(name=call.name, arguments=_arguments_text(call.arguments if call.args is None else call.args))
def _role(message: _RawMessage, has_tool_calls: bool) -> ChatRole:
"""Known roles and LangChain types map directly; any other role is the assistant when it calls tools, else the user."""
known: Final = MESSAGE_ROLES.get(message.role or message.type or "")
if known is not None:
return known
return "assistant" if has_tool_calls else "user"
def _ui_message(message: _RawMessage) -> UIMessage:
calls: Final = tuple(_tool_call(call) for call in message.tool_calls or ())
role: Final = _role(message, bool(calls))
content: Final = content_text(message.content)
match (message.name or None, calls):
case (None, ()):
return UIMessage(role=role, content=content)
case (None, _):
return UIMessage(role=role, content=content, tool_calls=calls)
case (str() as name, ()):
return UIMessage(role=role, content=content, name=name)
case (str() as name, _):
return UIMessage(role=role, content=content, name=name, tool_calls=calls)
def _messages(parsed: Sequence[JsonValue] | Mapping[str, JsonValue]) -> tuple[_RawMessage, ...] | None:
try:
raw: Final = (
(_MESSAGE.validate_python(parsed),) if isinstance(parsed, Mapping) else _MESSAGES.validate_python(parsed)
)
except ValidationError:
return None
unwrapped: Final = tuple(_unwrapped(message) for message in raw)
return unwrapped if unwrapped and all(_is_message(message) for message in unwrapped) else None
def _field_value(value: JsonValue) -> str:
return value if isinstance(value, str) else json.dumps(value)
def _parsed(raw: str) -> JsonValue:
try:
return _JSON.validate_json(raw)
except ValidationError:
return raw
def to_ui_content(raw: str) -> UIContent:
if not raw:
return UIText(kind="text", text="")
parsed: Final = _parsed(raw)
if not isinstance(parsed, list | dict):
return UIText(kind="text", text=parsed if isinstance(parsed, str) else raw)
messages: Final = _messages(parsed)
if messages is not None:
return UIMessages(kind="messages", messages=tuple(_ui_message(message) for message in messages))
if isinstance(parsed, dict):
return UIFields(kind="fields", fields=tuple(UIField(key=k, value=_field_value(v)) for k, v in parsed.items()))
return UIText(kind="text", text=raw)

View file

@ -11,7 +11,8 @@ from fastapi.testclient import TestClient
from litellm.proxy import tracing_endpoints
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.tracing import TracingPayloadTooLargeError
from litellm.tracing import TraceReceiver, TracingPayloadTooLargeError
from litellm.tracing.store import ClickHouseTraceStore
TEAM_KEY = UserAPIKeyAuth(
token="hashed-key", team_id="team-research", org_id="org-1", user_role=LitellmUserRoles.INTERNAL_USER
@ -148,6 +149,24 @@ def test_get_span_404_and_200(client, receiver):
receiver.get_span.assert_awaited_with("t1", "s1", {"team_ids": ("team-research",), "api_key_hash": ""}, "")
def test_get_span_serves_ui_content_from_stored_payloads(client, monkeypatch):
storage = MagicMock()
stored_output = '{"role": "ai", "content": "", "tool_calls": [{"name": "lookup", "args": {"id": 7}}]}'
storage.query = AsyncMock(
return_value=[{"span_id": "s1", "input": '{"city": "Paris"}', "output": stored_output, "attributes": {}}]
)
monkeypatch.setattr(tracing_endpoints, "receiver", TraceReceiver(ClickHouseTraceStore(storage)))
body = client.get("/v1/traces/t1/spans/s1").json()
assert body["output"] == stored_output
assert body["input_ui"] == {"kind": "fields", "fields": [{"key": "city", "value": "Paris"}]}
assert body["output_ui"] == {
"kind": "messages",
"messages": [
{"role": "assistant", "content": "", "tool_calls": [{"name": "lookup", "arguments": '{"id": 7}'}]}
],
}
def test_trace_detail_passes_scoped_reference(client, receiver):
receiver.get_trace.return_value = {"summary": {"trace_id": "t1"}, "agents": [], "spans": []}
assert client.get("/v1/traces/t1?trace_ref=run-one").status_code == 200

View file

@ -0,0 +1,68 @@
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from litellm.tracing.normalizers import (
NORMALIZERS,
GenAISemconvNormalizer,
LangSmithNormalizer,
OpenInferenceNormalizer,
select_normalizer,
)
from litellm.tracing.types import SpanRow
_NO_ATTRIBUTES: Final[Mapping[str, str]] = MappingProxyType({})
def test_langsmith_scope_selects_langsmith_without_any_attributes():
assert isinstance(select_normalizer("langsmith", _NO_ATTRIBUTES), LangSmithNormalizer)
def test_langsmith_kind_attribute_selects_langsmith_under_any_scope():
assert isinstance(select_normalizer("other", MappingProxyType({"langsmith.span.kind": "llm"})), LangSmithNormalizer)
def test_langsmith_wins_over_openinference_when_both_markers_present():
attributes: Final = MappingProxyType({"langsmith.span.kind": "llm", "openinference.span.kind": "LLM"})
assert isinstance(select_normalizer("other", attributes), LangSmithNormalizer)
def test_openinference_kind_attribute_selects_openinference():
assert isinstance(
select_normalizer("other", MappingProxyType({"openinference.span.kind": "LLM"})), OpenInferenceNormalizer
)
def test_unmarked_span_falls_back_to_genai():
assert isinstance(
select_normalizer("other", MappingProxyType({"gen_ai.operation.name": "chat"})), GenAISemconvNormalizer
)
def test_empty_registry_falls_back_to_genai():
assert isinstance(select_normalizer("langsmith", _NO_ATTRIBUTES, registry=()), GenAISemconvNormalizer)
def test_registry_names_are_unique():
names: Final = tuple(n.name for n in NORMALIZERS)
assert len(names) == len(frozenset(names))
@dataclass(frozen=True, slots=True)
class _CustomNormalizer:
name: str = "custom"
def matches(self, scope_name: str, attributes: Mapping[str, str]) -> bool:
return scope_name == "custom-sdk"
def normalize(self, row: SpanRow, attributes: Mapping[str, str]) -> None:
return None
def test_normalizer_inserted_ahead_in_custom_registry_wins_only_where_it_matches():
registry: Final = (_CustomNormalizer(), *NORMALIZERS)
assert isinstance(
select_normalizer("custom-sdk", MappingProxyType({"langsmith.span.kind": "llm"}), registry), _CustomNormalizer
)
assert isinstance(select_normalizer("langsmith", _NO_ATTRIBUTES, registry), LangSmithNormalizer)

View file

@ -125,6 +125,49 @@ def test_incomplete_langsmith_completion_preserves_the_export(completion):
assert rows[0]["Output"] == completion
def test_llm_block_list_content_keeps_only_text():
reasoning = {"type": "reasoning", "summary": [], "encrypted_content": "gAAAAB-opaque"}
history = [reasoning, {"type": "text", "text": "Earlier answer", "annotations": []}]
answer = [reasoning, {"type": "text", "text": "Part one"}, {"type": "text", "text": "Part two"}]
prompt = {
"messages": [
[
{"kwargs": {"type": "human", "content": "refund please"}},
{"kwargs": {"type": "ai", "content": history}},
{"kwargs": {"type": "ai", "content": [reasoning]}},
]
]
}
completion = {"generations": [[{"message": {"kwargs": {"type": "ai", "content": answer}}}]]}
span = _span(
"ChatOpenAI",
b"\x03" * 8,
b"\x02" * 8,
langsmith__span__kind="llm",
gen_ai__prompt=json.dumps(prompt),
gen_ai__completion=json.dumps(completion),
)
rows = decode_otlp(_export(span, scope="langsmith"), "application/x-protobuf")
assert [m["content"] for m in json.loads(rows[0]["Input"])] == ["refund please", "Earlier answer", ""]
assert json.loads(rows[0]["Output"])["content"] == "Part one\n\nPart two"
assert "encrypted_content" not in rows[0]["Input"] + rows[0]["Output"]
def test_llm_unrecognized_list_content_is_kept_as_json():
content = [{"type": "image_url", "image_url": {"url": "https://x.test/a.png"}}]
completion = {"generations": [[{"message": {"kwargs": {"type": "ai", "content": content}}}]]}
span = _span(
"ChatOpenAI",
b"\x03" * 8,
b"\x02" * 8,
langsmith__span__kind="llm",
gen_ai__prompt='{"messages": [[{"kwargs": {"type": "human", "content": "hi"}}]]}',
gen_ai__completion=json.dumps(completion),
)
rows = decode_otlp(_export(span, scope="langsmith"), "application/x-protobuf")
assert json.loads(json.loads(rows[0]["Output"])["content"]) == content
def test_task_tool_output_is_subagent_final_message_text(rows_by_name):
task = rows_by_name["task"]
assert json.loads(task["Input"])["subagent_type"] == "researcher"
@ -189,6 +232,64 @@ def test_long_values_are_truncated_with_marker():
assert len(task["Input"].split("…")[0].encode()) <= 100
def test_long_message_history_drops_middle_messages_and_stays_valid_json():
history = [{"kwargs": {"type": "human", "content": f"turn {i} " + "x" * 60}} for i in range(12)]
prompt = json.dumps({"messages": [[{"kwargs": {"type": "system", "content": "be brief"}}, *history]]})
completion = json.dumps({"generations": [[{"message": {"kwargs": {"type": "ai", "content": "ok"}}}]]})
span = _span(
"ChatOpenAI",
b"\x03" * 8,
b"\x02" * 8,
langsmith__span__kind="llm",
gen_ai__prompt=prompt,
gen_ai__completion=completion,
)
with patch.object(decode, "OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 400):
rows = decode_otlp(_export(span, scope="langsmith"), "application/x-protobuf")
messages = json.loads(rows[0]["Input"])
assert len(rows[0]["Input"].encode()) <= 400
assert messages[0]["content"] == "be brief"
assert "earlier messages truncated" in messages[1]["content"]
assert messages[-1]["content"].startswith("turn 11 ")
kept = int(messages[1]["content"].split("[")[1].split()[0])
assert kept + len(messages) - 2 == 12
@pytest.mark.parametrize(
"messages",
[
[{"role": "system", "content": "s" * 2000}, {"role": "user", "content": "short question"}],
[{"role": "user", "content": "a" * 900}, {"role": "assistant", "content": "b" * 900}],
[
{"role": "system", "content": "s" * 900},
{"role": "user", "content": "middle"},
{"role": "user", "content": "q" * 900},
],
],
ids=["huge-first-message", "two-messages", "huge-first-and-last"],
)
def test_oversized_message_arrays_are_shortened_not_cut(messages):
with patch.object(decode, "OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 400):
out = decode._truncate_payload(json.dumps(messages))
assert len(out.encode()) <= 400
kept = json.loads(out)
assert kept[0]["role"] == messages[0]["role"]
assert kept[-1]["role"] == messages[-1]["role"]
assert all(isinstance(m["content"], str) for m in kept)
def test_oversized_non_content_fields_still_fit_the_limit():
heavy = {"role": "assistant", "content": "x", "tool_calls": [{"name": "t", "args": {"blob": "z" * 3000}}]}
messages = [heavy, {"role": "user", "content": "—" * 900}]
with patch.object(decode, "OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 400):
out = decode._truncate_payload(json.dumps(messages))
kept = json.loads(out)
assert len(out.encode()) <= 400
assert [m["role"] for m in kept] == ["assistant", "user"]
assert kept[0]["content"].startswith("x")
assert kept[1]["content"].startswith("\u2014")
# ---------------------------------------------------------------- status / exceptions

View file

@ -306,11 +306,16 @@ async def test_get_span_not_found_and_found():
store = ClickHouseTraceStore(client)
scope: TraceScope = {"team_ids": (), "api_key_hash": ""}
assert await store.get_span("t", "s", scope) is None
client.query = AsyncMock(return_value=[{"span_id": "s", "input": "i", "output": "o", "attributes": {"k": "v"}}])
stored_input = '[{"role": "user", "content": "hi"}]'
client.query = AsyncMock(
return_value=[{"span_id": "s", "input": stored_input, "output": '{"ok": true}', "attributes": {"k": "v"}}]
)
assert await store.get_span("t", "s", scope) == {
"span_id": "s",
"input": "i",
"output": "o",
"input": stored_input,
"output": '{"ok": true}',
"input_ui": {"kind": "messages", "messages": ({"role": "user", "content": "hi"},)},
"output_ui": {"kind": "fields", "fields": ({"key": "ok", "value": "true"},)},
"attributes": {"k": "v"},
}

View file

@ -0,0 +1,119 @@
import json
import pytest
from litellm.tracing.ui_format import to_ui_content
def test_message_array_maps_roles_and_keeps_order():
raw = json.dumps(
[
{"role": "system", "content": "be brief"},
{"role": "human", "content": "hi"},
{"role": "tool", "name": "lookup", "content": "42"},
{"role": "narrator", "content": "aside"},
]
)
assert to_ui_content(raw) == {
"kind": "messages",
"messages": (
{"role": "system", "content": "be brief"},
{"role": "user", "content": "hi"},
{"role": "tool", "content": "42", "name": "lookup"},
{"role": "user", "content": "aside"},
),
}
@pytest.mark.parametrize(
"call",
[
{"name": "get_plan", "args": {"customer_id": "c-1"}},
{"name": "get_plan", "arguments": '{"customer_id": "c-1"}'},
{"id": "call_1", "type": "function", "function": {"name": "get_plan", "arguments": '{"customer_id": "c-1"}'}},
],
)
def test_single_assistant_message_with_tool_call(call: dict[str, object]):
content = to_ui_content(json.dumps({"role": "assistant", "content": None, "tool_calls": [call]}))
assert content["kind"] == "messages"
(message,) = content["messages"]
assert message["role"] == "assistant"
assert message["content"] == ""
calls = message.get("tool_calls")
assert calls is not None and len(calls) == 1
assert calls[0]["name"] == "get_plan"
assert json.loads(calls[0]["arguments"]) == {"customer_id": "c-1"}
def test_unknown_role_with_tool_calls_is_assistant():
content = to_ui_content(json.dumps({"role": "model", "content": "", "tool_calls": [{"name": "f", "args": None}]}))
assert content == {
"kind": "messages",
"messages": ({"role": "assistant", "content": "", "tool_calls": ({"name": "f", "arguments": "{}"},)},),
}
def test_block_list_content_keeps_text_and_drops_reasoning():
raw = json.dumps(
{
"role": "assistant",
"content": [
{"type": "reasoning", "encrypted_content": "opaque"},
{"type": "thinking", "thinking": "hidden chain"},
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
],
}
)
assert to_ui_content(raw) == {
"kind": "messages",
"messages": ({"role": "assistant", "content": "first\n\nsecond"},),
}
def test_langchain_kwargs_shape():
raw = json.dumps(
[
{"lc": 1, "type": "constructor", "kwargs": {"type": "human", "content": "question"}},
{"kwargs": {"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {"q": "x"}}]}},
]
)
content = to_ui_content(raw)
assert content["kind"] == "messages"
human, ai = content["messages"]
assert human == {"role": "user", "content": "question"}
assert ai["role"] == "assistant"
assert ai.get("tool_calls") == ({"name": "search", "arguments": '{"q": "x"}'},)
def test_plain_object_becomes_fields_in_key_order():
raw = json.dumps({"zeta": "plain", "alpha": {"nested": [1, 2]}, "count": 3, "missing": None})
assert to_ui_content(raw) == {
"kind": "fields",
"fields": (
{"key": "zeta", "value": "plain"},
{"key": "alpha", "value": '{"nested": [1, 2]}'},
{"key": "count", "value": "3"},
{"key": "missing", "value": "null"},
),
}
def test_object_with_role_but_no_content_is_fields():
assert to_ui_content('{"role": "admin", "user_id": "u1"}')["kind"] == "fields"
def test_json_string_becomes_its_text():
assert to_ui_content(json.dumps('line one\n"quoted"')) == {"kind": "text", "text": 'line one\n"quoted"'}
@pytest.mark.parametrize(
"raw",
['[{"role": "user", "content": "cut of', "plain words", "42", "[1, 2]", "[]"],
)
def test_non_message_non_object_payloads_keep_the_raw_string(raw: str):
assert to_ui_content(raw) == {"kind": "text", "text": raw}
def test_empty_is_empty_text():
assert to_ui_content("") == {"kind": "text", "text": ""}

View file

@ -64,6 +64,61 @@
}
@theme inline {
--animate-slide-left: slide-left 200ms cubic-bezier(0, 0, 0.2, 1) both;
--animate-view-fade-in: view-fade-in 100ms cubic-bezier(0.4, 0, 0.2, 1) both;
--animate-slot-slide-in: slot-slide-in 150ms cubic-bezier(0, 0, 0.2, 1) both;
--animate-trace-drawer-in: trace-drawer-in 200ms cubic-bezier(0.25, 1, 0.5, 1) both;
--animate-trace-drawer-out: trace-drawer-out 200ms cubic-bezier(0.4, 0, 1, 1) both;
@keyframes trace-drawer-in {
from {
opacity: 0;
transform: translateX(2rem) scaleX(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes trace-drawer-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateX(2rem) scaleX(0.98);
}
}
@keyframes slide-left {
from {
opacity: 0;
transform: translateX(2rem) scaleX(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes view-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slot-slide-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes scroll-fade-reveal-e {
from {
--scroll-fade-e: var(--_scroll-fade-size-e, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
@ -74,6 +129,22 @@
}
}
@layer utilities {
.animate-trace-drawer-in,
.animate-trace-drawer-out {
transform-origin: right center;
}
@media (prefers-reduced-motion: reduce) {
.animate-trace-drawer-in,
.animate-trace-drawer-out,
.animate-slide-left,
.animate-view-fade-in,
.animate-slot-slide-in {
animation: none !important;
}
}
}
@utility scroll-fade-e {
--_scroll-fade-size-e: var(--scroll-fade-e-size, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
--scroll-fade-mask: linear-gradient(to right, #000 0, #000 calc(100% - var(--scroll-fade-e, 0px)), transparent 100%);
@ -141,6 +212,38 @@
--sidebar-ring: oklch(0.707 0.022 261.325);
--neutral-border: #dcddeb;
--logo-surface: oklch(1 0 0);
--trace-text: oklch(0.21 0.03 256);
--trace-text-2: oklch(0.35 0.03 256);
--trace-text-secondary: oklch(0.35 0.03 256);
--trace-duration: oklch(0.48 0.03 230);
--trace-key: oklch(0.55 0.03 240);
--trace-placeholder: oklch(0.7 0.02 240);
--trace-surface: oklch(1 0 0);
--trace-chip: oklch(0.975 0.006 220);
--trace-row-hover: oklch(0.975 0.008 215);
--trace-row-selected: oklch(0.95 0.035 200);
--trace-brand: oklch(0.6 0.13 195);
--trace-border: oklch(0.92 0.01 230);
--trace-line: oklch(0.88 0.03 205);
--trace-card-border: oklch(0.93 0.01 230);
--trace-dot: oklch(0.86 0.05 190);
--trace-tab-active: oklch(0.95 0.025 205);
--trace-tab-hover: oklch(0.93 0.02 215);
--trace-tag: oklch(0.95 0.02 205);
--trace-chain: oklch(0.56 0.17 255);
--trace-llm: oklch(0.6 0.13 215);
--trace-tool: oklch(0.64 0.14 165);
--trace-glyph: oklch(0.99 0 0);
--trace-human: oklch(0.5 0.15 260);
--trace-human-glyph: oklch(0.95 0.04 210);
--trace-turn: oklch(0.96 0.03 200);
--trace-turn-border: oklch(0.75 0.1 200);
--trace-ok: oklch(0.92 0.08 160);
--trace-ok-glyph: oklch(0.55 0.15 155);
--trace-called: oklch(0.93 0.06 185);
--trace-called-text: oklch(0.38 0.08 195);
--trace-shadow-md: 0 4px 6px -1px #0b1b2e1a, 0 2px 4px -1px #0b1b2e0f;
--trace-shadow-xs: 0 1px 2px 0 #0b1b2e0d;
}
.dark {
@ -183,9 +286,73 @@
--sidebar-border: oklch(0.187 0 0);
--sidebar-ring: oklch(0.569 0 0);
--neutral-border: var(--border);
--trace-text: oklch(0.96 0.005 220);
--trace-text-2: oklch(0.88 0.01 220);
--trace-text-secondary: oklch(0.88 0.01 220);
--trace-duration: oklch(0.78 0.03 200);
--trace-key: oklch(0.68 0.03 220);
--trace-placeholder: oklch(0.5 0.02 230);
--trace-surface: oklch(0.19 0.012 240);
--trace-chip: oklch(0.23 0.015 235);
--trace-row-hover: oklch(0.23 0.018 230);
--trace-row-selected: oklch(0.29 0.05 210);
--trace-brand: oklch(0.78 0.13 190);
--trace-border: oklch(0.3 0.02 235);
--trace-line: oklch(0.36 0.04 210);
--trace-card-border: oklch(0.27 0.02 235);
--trace-dot: oklch(0.45 0.06 195);
--trace-tab-active: oklch(0.28 0.03 215);
--trace-tab-hover: oklch(0.32 0.03 220);
--trace-tag: oklch(0.28 0.03 215);
--trace-chain: oklch(0.6 0.17 255);
--trace-llm: oklch(0.64 0.13 215);
--trace-tool: oklch(0.68 0.14 165);
--trace-glyph: oklch(0.99 0 0);
--trace-human: oklch(0.56 0.15 260);
--trace-human-glyph: oklch(0.95 0.04 210);
--trace-turn: oklch(0.29 0.05 210);
--trace-turn-border: oklch(0.5 0.09 200);
--trace-ok: oklch(0.35 0.07 160);
--trace-ok-glyph: oklch(0.82 0.15 155);
--trace-called: oklch(0.32 0.06 190);
--trace-called-text: oklch(0.88 0.08 185);
--trace-shadow-md: 0 4px 6px -1px #00000080, 0 2px 4px -1px #00000066;
--trace-shadow-xs: 0 1px 2px 0 #0000004d;
}
@theme inline {
--color-trace-text: var(--trace-text);
--color-trace-text-2: var(--trace-text-2);
--color-trace-text-secondary: var(--trace-text-secondary);
--color-trace-duration: var(--trace-duration);
--color-trace-key: var(--trace-key);
--color-trace-placeholder: var(--trace-placeholder);
--color-trace-surface: var(--trace-surface);
--color-trace-chip: var(--trace-chip);
--color-trace-row-hover: var(--trace-row-hover);
--color-trace-row-selected: var(--trace-row-selected);
--color-trace-brand: var(--trace-brand);
--color-trace-border: var(--trace-border);
--color-trace-line: var(--trace-line);
--color-trace-card-border: var(--trace-card-border);
--color-trace-dot: var(--trace-dot);
--color-trace-tab-active: var(--trace-tab-active);
--color-trace-tab-hover: var(--trace-tab-hover);
--color-trace-tag: var(--trace-tag);
--color-trace-chain: var(--trace-chain);
--color-trace-llm: var(--trace-llm);
--color-trace-tool: var(--trace-tool);
--color-trace-glyph: var(--trace-glyph);
--color-trace-human: var(--trace-human);
--color-trace-human-glyph: var(--trace-human-glyph);
--color-trace-turn: var(--trace-turn);
--color-trace-turn-border: var(--trace-turn-border);
--color-trace-ok: var(--trace-ok);
--color-trace-ok-glyph: var(--trace-ok-glyph);
--color-trace-called: var(--trace-called);
--color-trace-called-text: var(--trace-called-text);
--shadow-trace-md: var(--trace-shadow-md);
--shadow-trace-xs: var(--trace-shadow-xs);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);

View file

@ -163,17 +163,51 @@ describe("AgentTracesSection", () => {
expect(failed.length + ok.length).toBe(runs.length);
});
it("opens the run in place and goes back to the list", async () => {
it("opens a run in a side drawer over the list and swaps runs without closing it", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderSection();
const rows = await screen.findAllByTestId("agent-trace-row");
fireEvent.click(rows[0]);
expect(screen.getByTestId("run-view")).toHaveTextContent(`run ${runs[0].trace_id}`);
expect(screen.queryByTestId("runs-table")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("back"));
const drawer = screen.getByRole("complementary", { name: "Trace details" });
expect(within(drawer).getByTestId("run-view")).toHaveTextContent(`run ${runs[0].trace_id}`);
expect(screen.getByTestId("runs-table")).toBeInTheDocument();
expect(rows[0]).toHaveAttribute("aria-selected", "true");
fireEvent.click(rows[1]);
expect(screen.getByRole("complementary", { name: "Trace details" })).toBe(drawer);
expect(within(drawer).getByTestId("run-view")).toHaveTextContent(`run ${runs[1].trace_id}`);
expect(rows[1]).toHaveAttribute("aria-selected", "true");
expect(rows[0]).toHaveAttribute("aria-selected", "false");
});
it("closes the drawer when the open row is clicked again or Escape is pressed", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderSection();
const rows = await screen.findAllByTestId("agent-trace-row");
fireEvent.click(rows[0]);
fireEvent.click(rows[0]);
expect(rows[0]).toHaveAttribute("aria-selected", "false");
fireEvent.click(rows[1]);
fireEvent.keyDown(window, { key: "Escape" });
expect(rows[1]).toHaveAttribute("aria-selected", "false");
});
it("moves to the next and previous run with j / k and the header arrows", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderSection();
const rows = await screen.findAllByTestId("agent-trace-row");
fireEvent.click(rows[0]);
fireEvent.keyDown(window, { key: "j" });
expect(screen.getByTestId("run-view")).toHaveTextContent(`run ${runs[1].trace_id}`);
fireEvent.keyDown(window, { key: "k" });
expect(screen.getByTestId("run-view")).toHaveTextContent(`run ${runs[0].trace_id}`);
expect(screen.getByRole("button", { name: "Previous trace (K)" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Next trace (J)" }));
expect(screen.getByTestId("run-view")).toHaveTextContent(`run ${runs[1].trace_id}`);
});
it("plots every loaded run on the timeline", async () => {

View file

@ -1,11 +1,14 @@
"use client";
import { Plug } from "lucide-react";
import moment from "moment";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable";
import { RunDrawer } from "./RunDrawer";
import { ALL_SERVICES, RunsToolbar, type RunStatusFilter } from "./RunsToolbar";
import { RunView } from "./TraceDrawer";
import type { TraceSummary } from "./traceTypes";
import { previewText } from "./traceUtils";
import { TimeRangeControls } from "./TimeRangeControls";
@ -31,6 +34,8 @@ export function filterRuns(
});
}
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[] =>
runs.filter((run) => {
const t = moment(run.start_time).valueOf();
@ -120,19 +125,12 @@ export function AgentTracesSection({
);
}
if (openTrace !== null) {
return (
<RunView
traceId={openTrace.trace_id}
traceRef={openTrace.trace_ref}
accessToken={accessToken}
onBack={() => openRun(null)}
/>
);
}
const toggleRun = (trace: TraceSummary | null) =>
openRun(trace !== null && openTrace !== null && runKey(trace) === runKey(openTrace) ? null : trace);
return (
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
<RunDrawer trace={openTrace} runs={runs} accessToken={accessToken} onSelect={openRun} />
<RunsToolbar
query={query}
service={service}
@ -142,13 +140,10 @@ export function AgentTracesSection({
onServiceChange={setService}
onStatusChange={setStatus}
>
<button
type="button"
onClick={() => setShowSetup(true)}
className="shrink-0 px-1 text-[11px] text-muted-foreground underline-offset-2 hover:text-info hover:underline"
>
<Button variant="outline" size="sm" onClick={() => setShowSetup(true)} className="shrink-0 gap-1.5">
<Plug className="size-3.5" />
Set up tracing
</button>
</Button>
{timeControls && (
<TimeRangeControls
range={zoom ?? range}
@ -168,7 +163,8 @@ export function AgentTracesSection({
error={traces.error}
hasMore={traces.hasMore}
onLoadMore={traces.loadMore}
onOpenTrace={openRun}
onOpenTrace={toggleRun}
selectedKey={openTrace === null ? null : runKey(openTrace)}
/>
<footer
data-testid="runs-footer"

View file

@ -3,6 +3,7 @@
import { ArrowDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { StatusMark } from "./StatusMark";
import type { TraceSummary } from "./traceTypes";
@ -15,6 +16,7 @@ interface AgentTracesTableProps {
hasMore: boolean;
onLoadMore: () => void;
onOpenTrace: (trace: TraceSummary) => void;
selectedKey?: string | null;
}
const SECOND_MS = 1000;
@ -53,6 +55,7 @@ export function AgentTracesTable({
hasMore,
onLoadMore,
onOpenTrace,
selectedKey = null,
}: AgentTracesTableProps) {
const isEmpty = !isLoading && !error && traces.length === 0;
return (
@ -81,7 +84,13 @@ export function AgentTracesTable({
key={run.trace_ref || run.trace_id}
data-testid="agent-trace-row"
onClick={() => onOpenTrace(run)}
className="h-9 cursor-pointer border-b border-border/60 text-[12px] hover:bg-accent/50"
aria-selected={selectedKey === (run.trace_ref || run.trace_id)}
className={cn(
"h-9 cursor-pointer border-b border-border/60 text-[12px] transition-colors duration-150 motion-reduce:transition-none",
selectedKey === (run.trace_ref || run.trace_id)
? "bg-trace-row-selected shadow-[inset_2px_0_0_var(--trace-brand)]"
: "hover:bg-trace-row-hover",
)}
>
<td
className="px-3 font-mono text-[11px] tabular-nums text-muted-foreground"

View file

@ -1,5 +1,7 @@
"use client";
import { type KeyValue, KeyValueRows } from "./KeyValueRows";
import { Card } from "./MessageCard";
import type { Span } from "./traceTypes";
interface AttributesDetailProps {
@ -9,25 +11,34 @@ interface AttributesDetailProps {
isLoading: boolean;
}
/** Raw OTEL attributes as a key / value grid, ids first. */
export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section aria-label={title} className="flex flex-col gap-2">
<h3 className="py-1 text-[13px] font-medium tracking-[-0.26px] text-trace-text">{title}</h3>
<Card className="px-3 py-2.5">{children}</Card>
</section>
);
}
/** Ids, then the raw OTEL attributes, as dot-bulleted key / value rows. */
export function AttributesDetail({ traceId, span, attributes, isLoading }: AttributesDetailProps) {
const entries: [string, string][] = [
const ids: KeyValue[] = [
["trace_id", traceId],
["span_id", span.span_id],
["parent_span_id", span.parent_span_id ?? "—"],
...Object.entries(attributes ?? {}).sort(([a], [b]) => a.localeCompare(b)),
];
const attributeEntries: KeyValue[] = Object.entries(attributes ?? {}).sort(([a], [b]) => a.localeCompare(b));
return (
<div className="p-3">
<div className="overflow-hidden rounded border border-border bg-card font-mono text-[11px]">
{entries.map(([key, value]) => (
<div key={key} className="grid grid-cols-[minmax(120px,42%)_1fr] border-b border-border last:border-0">
<div className="border-r border-border bg-muted/40 px-2.5 py-2 break-all text-muted-foreground">{key}</div>
<div className="px-2.5 py-2 break-all text-foreground">{value}</div>
</div>
))}
</div>
{isLoading && <div className="mt-2 font-mono text-[11px] text-muted-foreground">Loading attributes…</div>}
<div className="flex flex-col gap-4 px-7 pt-1 pb-4">
<DetailGroup title="Identifiers">
<KeyValueRows entries={ids} mono />
</DetailGroup>
{attributeEntries.length > 0 && (
<DetailGroup title="Attributes">
<KeyValueRows entries={attributeEntries} />
</DetailGroup>
)}
{isLoading && <div className="text-[13px] text-trace-duration">Loading attributes…</div>}
</div>
);
}

View file

@ -0,0 +1,35 @@
"use client";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/cva.config";
/** Snaps between 0 and auto height; children stay mounted but inert while closed. */
export function Collapse({
open,
children,
className,
}: {
open: boolean;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("grid", open ? "grid-rows-[1fr]" : "grid-rows-[0fr]")} aria-hidden={!open} inert={!open}>
<div className={cn("min-h-0 overflow-hidden", className)}>{children}</div>
</div>
);
}
/** Right-pointing chevron that rotates to point down when open. */
export function FoldChevron({ open, className }: { open: boolean; className?: string }) {
return (
<ChevronRight
className={cn(
"transition-transform duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none",
open && "rotate-90",
className,
)}
/>
);
}

View file

@ -1,17 +1,24 @@
"use client";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { AlertTriangle, Bot, CornerDownRight, Wrench } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/cva.config";
import { agentTraceSpanCall } from "../../networking";
import { type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows";
import { Card, MessageCard, Section, ToolResultCard } from "./MessageCard";
import type { ErrorSource } from "./traceTree";
import type { Span, SpanDetail, TraceMessage } from "./traceTypes";
import { errorSource, parseMessages, prettyPayload } from "./traceUtils";
import type { Span, SpanDetail, TraceMessage, UIContent, UIMessage } from "./traceTypes";
import { errorSource, parseJson, parseMessages, prettyPayload } from "./traceUtils";
const ERROR_SOURCE_LABEL: Record<ErrorSource, string> = { tool: "Tool", model: "Model", litellm: "LiteLLM" };
const TRACEBACK_MARKER = "Traceback (most recent call last):";
const STATUS_TEXT = "px-5 py-2 text-[13px] tracking-[-0.26px] text-trace-duration";
const PAYLOAD_PRE =
"font-mono text-[13px] leading-[1.5] tracking-[-0.26px] break-words whitespace-pre-wrap text-trace-text";
/** LangSmith records `repr(exc)` + traceback with no separator; keep the exception line. */
/** Exporters record `repr(exc)` + traceback with no separator; keep the exception line. */
export const errorHeadline = (error: string): string =>
(error.split(TRACEBACK_MARKER, 1)[0].split("\n")[0] ?? "").trim() || error.trim();
@ -29,93 +36,104 @@ export function useSpanDetail(accessToken: string, traceId: string, spanId: stri
return useQuery(queryOptions);
}
export function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-1.5 font-mono text-[9px] font-medium tracking-[0.1em] text-muted-foreground uppercase">
{children}
</div>
);
}
export function TextBlock({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<section>
<SectionLabel>{label}</SectionLabel>
<div
className={`mt-1.5 max-h-72 overflow-auto rounded border border-border bg-card p-3 leading-5 whitespace-pre-wrap break-words text-foreground ${
mono ? "font-mono text-[11px]" : "text-[12px]"
}`}
>
{value}
</div>
</section>
);
}
function RoleIcon({ role }: { role: string }) {
if (role === "assistant") return <Bot className="size-3" />;
if (role === "tool") return <Wrench className="size-3" />;
return <CornerDownRight className="size-3" />;
}
export function MessageBlock({ message }: { message: TraceMessage }) {
return (
<section className="overflow-hidden rounded border border-border bg-card">
<div className="flex h-7 items-center gap-2 border-b border-border bg-muted/40 px-2.5 font-mono text-[9px] tracking-[0.1em] text-muted-foreground uppercase">
<RoleIcon role={message.role} />
{message.role}
{message.name ? <span className="normal-case">· {message.name}</span> : null}
</div>
{(message.tool_calls ?? []).map((call, i) => (
<div
key={`${call.name}-${i}`}
className="border-b border-border bg-muted/20 p-2.5 font-mono text-[11px] break-all text-foreground last:border-b-0"
>
{call.name}
<span className="text-muted-foreground">(</span>
<span className="text-muted-foreground">{JSON.stringify(call.args)}</span>
<span className="text-muted-foreground">)</span>
</div>
))}
{message.content && (
<div className="p-2.5 text-[12px] leading-5 whitespace-pre-wrap break-words text-foreground">
{message.content}
</div>
)}
</section>
);
}
export function ErrorBlock({ span }: { span: Span }) {
const source = errorSource(span);
if (!source) return null;
const headline = errorHeadline(span.error ?? "") || "Span reported an error status.";
return (
<section aria-label="Error" className="rounded border border-destructive/40 bg-destructive/5 p-3">
<div className="flex items-center gap-2 font-mono text-[9px] font-medium tracking-[0.08em] text-destructive uppercase">
<AlertTriangle className="size-3" />
<section
aria-label="Error"
className="mx-5 mb-2 rounded-[4px] border-[0.67px] border-destructive/40 bg-destructive/5 px-3 py-2.5"
>
<div className="flex items-center gap-2 text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-destructive">
<AlertTriangle className="size-3.5" />
{ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
</div>
<pre className="mt-2 font-mono text-[11px] leading-5 whitespace-pre-wrap break-words text-foreground">
{headline}
</pre>
<pre className={cn("mt-2", PAYLOAD_PRE)}>{headline}</pre>
</section>
);
}
function Payload({ label, value, mono }: { label: string; value: string; mono: boolean }) {
const messages = parseMessages(value);
if (messages) {
return (
<>
<SectionLabel>{`${label}${messages.length > 1 ? ` · ${messages.length} messages` : ""}`}</SectionLabel>
{messages.map((message, i) => (
<MessageBlock key={`${message.role}-${i}`} message={message} />
))}
</>
);
function FieldsCard({ entries }: { entries: readonly KeyValue[] }) {
return (
<Card className="px-3 py-2.5">
<KeyValueRows entries={entries} />
</Card>
);
}
function TextCard({ text }: { text: string }) {
return (
<Card className="px-3 py-2.5">
<pre className={cn("max-h-96 overflow-auto", PAYLOAD_PRE)}>{text}</pre>
</Card>
);
}
function PlainPayload({ value }: { value: string }) {
const entries = objectEntries(parseJson(value));
if (entries && entries.length > 0) return <FieldsCard entries={entries} />;
return <TextCard text={prettyPayload(value)} />;
}
function Messages({ messages, model }: { messages: TraceMessage[]; model: string | null }) {
return (
<>
{messages.map((message, i) => (
<MessageCard key={`${message.role}-${i}`} message={message} model={model} />
))}
</>
);
}
const toTraceMessage = (message: UIMessage): TraceMessage => ({
...message,
tool_calls: message.tool_calls?.map((call) => ({
name: call.name,
args: parseJson(call.arguments) ?? call.arguments,
})),
});
interface PayloadProps {
value: string;
span: Span;
role: "input" | "output";
}
const isToolResult = ({ span, role }: Omit<PayloadProps, "value">): boolean =>
span.type === "tool" && role === "output";
function ToolResult({ value, span }: Omit<PayloadProps, "role">) {
return <ToolResultCard name={span.name} result={value} failed={span.status === "error"} />;
}
const singleText = (content: UIContent): string | null =>
content.kind === "messages" && content.messages.length === 1 && !content.messages[0].tool_calls?.length
? content.messages[0].content
: null;
function UIPayload({ content, ...props }: PayloadProps & { content: UIContent }) {
const toolText = isToolResult(props) ? singleText(content) : null;
if (toolText !== null) return <ToolResult {...props} value={toolText} />;
if (content.kind === "messages") {
return <Messages messages={content.messages.map(toTraceMessage)} model={props.span.model} />;
}
return <TextBlock label={label} value={prettyPayload(value)} mono={mono} />;
if (isToolResult(props)) return <ToolResult {...props} />;
if (content.kind === "fields" && content.fields.length > 0) {
return <FieldsCard entries={content.fields.map((field): KeyValue => [field.key, field.value])} />;
}
return <TextCard text={content.kind === "text" ? content.text : props.value} />;
}
function Payload(props: PayloadProps) {
const messages = parseMessages(props.value);
if (messages) return <Messages messages={messages} model={props.span.model} />;
if (isToolResult(props)) return <ToolResult {...props} />;
return <PlainPayload value={props.value} />;
}
function SpanPayload({ content, ...props }: PayloadProps & { content: UIContent | undefined }) {
return content ? <UIPayload content={content} {...props} /> : <Payload {...props} />;
}
interface DetailContentProps {
@ -125,26 +143,29 @@ interface DetailContentProps {
span: Span;
}
/** Content tab: the error first (if any), then what went in and what came out. */
/** Content tab: the error first (if any), then collapsible Input and Output rendered as chat cards. */
export function DetailContent({ accessToken, traceId, traceRef, span }: DetailContentProps) {
const detailQuery = useSpanDetail(accessToken, traceId, span.span_id, traceRef);
const detail = detailQuery.data;
const isTool = span.type === "tool";
const empty = detail && !detail.input && !detail.output;
return (
<div className="space-y-3 p-3">
<div className="flex flex-col px-2 pt-1 pb-4">
<ErrorBlock span={span} />
{detailQuery.isLoading && <div className="font-mono text-[11px] text-muted-foreground">Loading span…</div>}
{detailQuery.isError && (
<div className="font-mono text-[11px] text-muted-foreground">
Could not load span: {detailQuery.error.message}
</div>
)}
{detail?.input ? <Payload label={isTool ? "Input" : "Input"} value={detail.input} mono={isTool} /> : null}
{detail?.output ? <Payload label="Output" value={detail.output} mono={isTool} /> : null}
{detailQuery.isLoading && <div className={STATUS_TEXT}>Loading span…</div>}
{detailQuery.isError && <div className={STATUS_TEXT}>Could not load span: {detailQuery.error.message}</div>}
{detail?.input ? (
<Section title="Input">
<SpanPayload value={detail.input} content={detail.input_ui} span={span} role="input" />
</Section>
) : null}
{detail?.output ? (
<Section title="Output">
<SpanPayload value={detail.output} content={detail.output_ui} span={span} role="output" />
</Section>
) : null}
{empty && span.status !== "error" && (
<div className="py-12 text-center font-mono text-[11px] text-muted-foreground">
<div className="py-12 text-center text-[13px] tracking-[-0.26px] text-trace-duration">
No content recorded for this span.
</div>
)}

View file

@ -1,9 +1,10 @@
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import { DetailPane } from "./DetailPane";
import { absoluteTime, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import type { GroupRowData, SpanRowData } from "./traceTree";
import type { Span, SpanDetail, Trace } from "./traceTypes";
@ -14,7 +15,9 @@ vi.mock("../../networking", () => ({
import { agentTraceSpanCall } from "../../networking";
const span = (overrides: Partial<Span> & Pick<Span, "span_id">): Span => ({
type SpanFields = Partial<Span> & Pick<Span, "span_id">;
const span = (overrides: SpanFields): Span => ({
parent_span_id: "root",
name: overrides.span_id,
type: "chain",
@ -76,6 +79,8 @@ const trace: Trace = {
spans: [root, llm, failedTool],
};
const LONG_NOTE = "Escalated twice already. ".repeat(6).trim();
const details: Record<string, SpanDetail> = {
llm1: {
span_id: "llm1",
@ -86,7 +91,7 @@ const details: Record<string, SpanDetail> = {
output: JSON.stringify({
role: "assistant",
content: "",
tool_calls: [{ name: "get_customer_plan", args: { customer_id: "acme-404" } }],
tool_calls: [{ name: "get_customer_plan", args: { customer_id: "acme-404", note: LONG_NOTE } }],
}),
attributes: { "gen_ai.request.model": "claude-sonnet-4-5" },
},
@ -99,6 +104,40 @@ const details: Record<string, SpanDetail> = {
},
};
const standardDetail: SpanDetail = {
span_id: "llm1",
input: "raw input left unparsed",
output: "raw output left unparsed",
input_ui: { kind: "fields", fields: [{ key: "ticket_id", value: "T-981" }] },
output_ui: {
kind: "messages",
messages: [
{
role: "assistant",
content: "Refund approved for T-981.",
tool_calls: [{ name: "issue_refund", arguments: '{"amount_usd": 40}' }],
},
],
},
attributes: {},
};
const textDetail: SpanDetail = {
span_id: "llm1",
input: "",
output: '{"answer": "all done"}',
output_ui: { kind: "text", text: "all done" },
attributes: {},
};
const failedToolMessageDetail: SpanDetail = {
span_id: "tool1",
input: '{"customer_id":"acme-404"}',
output: "raw tool output",
output_ui: { kind: "messages", messages: [{ role: "tool", content: "permission denied: /etc/shadow" }] },
attributes: {},
};
const spanRow = (s: Span): SpanRowData => ({
kind: "span",
id: s.span_id,
@ -124,7 +163,7 @@ describe("DetailPane", () => {
expect(screen.getByRole("tab", { name: "Request" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Attributes" })).toBeInTheDocument();
expect(await screen.findByText("You are a LiteLLM support agent.")).toBeInTheDocument();
expect(screen.getByText("get_customer_plan")).toBeInTheDocument();
expect(screen.getAllByText("get_customer_plan").length).toBeGreaterThan(0);
expect(vi.mocked(agentTraceSpanCall)).toHaveBeenCalledWith("sk-test", "t1", "llm1", undefined);
});
@ -134,8 +173,9 @@ describe("DetailPane", () => {
expect(error).toHaveTextContent("Tool · ValueError");
expect(error).toHaveTextContent("ValueError('customer acme-404 not found in billing DB')");
expect(error).not.toHaveTextContent("Traceback");
// tool args render as pretty JSON under "Input"
expect(await screen.findByText(/"customer_id": "acme-404"/)).toBeInTheDocument();
const input = await screen.findByRole("region", { name: "Input" });
expect(input).toHaveTextContent("customer_id");
expect(input).toHaveTextContent("acme-404");
});
it("shows the LiteLLM request facts on the Request tab", async () => {
@ -188,4 +228,97 @@ describe("DetailPane", () => {
await waitFor(() => expect(writeText).toHaveBeenCalled());
expect(writeText.mock.calls[0][0]).toContain("http://proxy.test/v1/traces/t1?format=md&span_id=llm1");
});
it("renders the AI tool call as a card and expands a long argument on click", async () => {
const user = userEvent.setup();
renderPane(spanRow(llm));
const output = await screen.findByRole("region", { name: "Output" });
expect(output).toHaveTextContent("AI");
expect(output).toHaveTextContent("get_customer_plan");
const expand = within(output).getAllByRole("button", { name: "Expand note" })[0];
expect(within(output).queryAllByText(LONG_NOTE, { selector: "pre", ignore: "[inert] *" })).toHaveLength(0);
await user.click(expand);
expect(within(output).getAllByRole("button", { name: "Collapse note" })[0]).toHaveAttribute(
"aria-expanded",
"true",
);
expect(within(output).getAllByText(LONG_NOTE, { selector: "pre", ignore: "[inert] *" })).not.toHaveLength(0);
});
it("collapses the Input section without touching Output", async () => {
const user = userEvent.setup();
renderPane(spanRow(llm));
const input = await screen.findByRole("region", { name: "Input" });
const systemText = "You are a LiteLLM support agent.";
expect(within(input).getByText(systemText, { ignore: "[inert] *" })).toBeInTheDocument();
await user.click(within(input).getByRole("button", { name: "Input" }));
expect(within(input).getByRole("button", { name: "Input" })).toHaveAttribute("aria-expanded", "false");
expect(within(input).queryByText(systemText, { ignore: "[inert] *" })).not.toBeInTheDocument();
const output = screen.getByRole("region", { name: "Output" });
expect(within(output).getAllByText("get_customer_plan", { ignore: "[inert] *" })).not.toHaveLength(0);
});
it("renders the standard input_ui / output_ui instead of re-parsing the raw payload", async () => {
vi.mocked(agentTraceSpanCall).mockResolvedValue(standardDetail);
renderPane(spanRow(llm));
const input = await screen.findByRole("region", { name: "Input" });
expect(input).toHaveTextContent("ticket_id");
expect(input).toHaveTextContent("T-981");
expect(input).not.toHaveTextContent("raw input left unparsed");
const output = screen.getByRole("region", { name: "Output" });
expect(output).toHaveTextContent("AI");
expect(output).toHaveTextContent("Refund approved for T-981.");
expect(output).toHaveTextContent("issue_refund");
expect(output).toHaveTextContent("amount_usd");
expect(output).not.toHaveTextContent("raw output left unparsed");
});
it("keeps the failed-tool styling when a tool's output arrives as a single message", async () => {
vi.mocked(agentTraceSpanCall).mockResolvedValue(failedToolMessageDetail);
renderPane(spanRow(failedTool));
const output = await screen.findByRole("region", { name: "Output" });
const result = within(output).getByText("permission denied: /etc/shadow");
expect(result).toHaveClass("text-destructive");
expect(output).not.toHaveTextContent("AI");
});
it("shows a text output_ui as its plain text", async () => {
vi.mocked(agentTraceSpanCall).mockResolvedValue(textDetail);
renderPane(spanRow(llm));
const output = await screen.findByRole("region", { name: "Output" });
expect(within(output).getByText("all done", { selector: "pre" })).toBeInTheDocument();
expect(output).not.toHaveTextContent("answer");
});
it("groups ids and OTEL attributes into separate key / value sections on the Attributes tab", async () => {
const user = userEvent.setup();
renderPane(spanRow(llm));
await user.click(screen.getByRole("tab", { name: "Attributes" }));
const ids = screen.getByRole("region", { name: "Identifiers" });
expect(ids).toHaveTextContent("span_idllm1");
expect(ids).toHaveTextContent("parent_span_idroot");
const attributes = await screen.findByRole("region", { name: "Attributes" });
expect(attributes).toHaveTextContent("gen_ai.request.model");
expect(attributes).not.toHaveTextContent("span_id");
});
});
describe("SpanHoverCard", () => {
it("shows absolute Start / End times and the agent tag after hovering the row", async () => {
const user = userEvent.setup();
const traceStartMs = Date.parse(trace.summary.start_time);
const timed = span({ span_id: "timed", start_offset_ms: 2000, duration_ms: 3000 });
renderWithProviders(
<SpanHoverCard facts={spanFacts(timed)} traceStartMs={traceStartMs}>
<button type="button">row</button>
</SpanHoverCard>,
);
expect(screen.queryByTestId("span-hover-card")).not.toBeInTheDocument();
await user.hover(screen.getByRole("button", { name: "row" }));
const card = await screen.findByTestId("span-hover-card", {}, { timeout: 2000 });
const time = within(card).getByRole("region", { name: "Time" });
expect(time).toHaveTextContent(`Start${absoluteTime(traceStartMs, 2000)}`);
expect(time).toHaveTextContent(`End${absoluteTime(traceStartMs, 5000)}`);
expect(within(card).getByRole("region", { name: "Tags" })).toHaveTextContent("agent:support_triage_agent");
});
});

View file

@ -9,10 +9,12 @@ import { cn } from "@/lib/cva.config";
import { AttributesDetail } from "./AttributesDetail";
import { CopyButton } from "./CopyButton";
import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent";
import { IdChip } from "./IdChip";
import { RequestDetail } from "./RequestDetail";
import { SpanIcon } from "./SpanIcon";
import { agentHandoffText } from "./TraceDrawer";
import type { GroupRowData, TreeRow } from "./traceTree";
import type { Span, Trace } from "./traceTypes";
import type { Span, SpanType, Trace } from "./traceTypes";
import { fmtMs, fmtTok } from "./traceUtils";
interface DetailPaneProps {
@ -30,25 +32,61 @@ const TABS: { id: Tab; label: string }[] = [
{ id: "attributes", label: "Attributes" },
];
function PaneHeader({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
function PaneHeader({
type,
model,
failed,
title,
idValue,
onClose,
}: {
type: SpanType;
model: string | null;
failed: boolean;
title: React.ReactNode;
idValue?: string;
onClose: () => void;
}) {
return (
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-card px-3">
{children}
<Button variant="ghost" size="icon-xs" onClick={onClose} aria-label="Close details" className="ml-auto">
<PanelRightClose className="size-3.5" />
<div className="flex h-6 shrink-0 items-center justify-between gap-4 px-7">
<div className="flex min-w-0 items-center">
<span className="mr-2 shrink-0">
<SpanIcon type={type} model={model} error={failed} size="md" />
</span>
<h2 className="min-w-0 truncate text-[14px] leading-5 font-medium tracking-[-0.56px] text-trace-text">
{title}
</h2>
{idValue && (
<span className="ml-2 flex shrink-0">
<IdChip value={idValue} label="Copy span ID" />
</span>
)}
</div>
<Button
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close details"
className="size-6 shrink-0 rounded-[4px] text-trace-text-2"
>
<PanelRightClose className="size-4" />
</Button>
</div>
);
}
function PaneFooter({ children }: { children: React.ReactNode }) {
return <div className="flex h-9 shrink-0 items-center gap-2 border-t border-border bg-card px-3">{children}</div>;
return (
<div className="flex h-10 shrink-0 items-center gap-2 border-t border-trace-border bg-trace-surface px-7">
{children}
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<span>
<span className="text-muted-foreground/70">{label}=</span>
<span className="text-trace-key">{label} </span>
{value}
</span>
);
@ -75,20 +113,19 @@ function SpanPane({
);
const tokens = span.input_tokens + span.output_tokens;
return (
<aside className="flex h-full min-w-0 flex-col bg-background" aria-label="Span details">
<PaneHeader onClose={onClose}>
<span
className={cn("size-1.5 rounded-full", span.status === "error" ? "bg-destructive" : "bg-muted-foreground/60")}
/>
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[12px] font-medium text-foreground">
{span.type === "llm" ? span.model || span.name : span.name}
</div>
<div className="truncate font-mono text-[9px] text-muted-foreground">{span.span_id}</div>
</div>
<CopyButton value={span.span_id} label="Copy span ID" iconOnly />
</PaneHeader>
<div role="tablist" className="flex h-8 shrink-0 items-end gap-1 border-b border-border bg-card px-3">
<aside
className="flex h-full min-w-0 animate-view-fade-in flex-col bg-trace-surface pt-4 text-[13px] text-trace-text motion-reduce:animate-none"
aria-label="Span details"
>
<PaneHeader
type={span.type}
model={span.model}
failed={span.status === "error"}
title={span.type === "llm" ? span.model || span.name : span.name}
idValue={span.span_id}
onClose={onClose}
/>
<div role="tablist" aria-label="Span detail sections" className="flex shrink-0 items-center gap-2 py-2 pr-2 pl-6">
{TABS.map((t) => (
<button
key={t.id}
@ -97,17 +134,17 @@ function SpanPane({
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={cn(
"-mb-px h-8 border-b-2 px-2 font-mono text-[10px]",
"rounded-[4px] px-2 py-1 text-[13px] leading-[18px] font-medium transition-colors duration-100 focus-visible:outline-2 focus-visible:outline-trace-brand motion-reduce:transition-none",
tab === t.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
? "bg-trace-tab-active text-trace-text hover:bg-trace-tab-hover"
: "text-trace-key hover:bg-trace-tab-active",
)}
>
{t.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div key={tab} role="tabpanel" className="min-h-0 flex-1 overflow-auto">
{tab === "content" && (
<DetailContent accessToken={accessToken} traceId={traceId} traceRef={trace.summary.trace_ref} span={span} />
)}
@ -129,7 +166,7 @@ function SpanPane({
label="Copy step"
copiedLabel="Command copied"
/>
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] tabular-nums text-muted-foreground">
<div className="ml-auto flex items-center gap-3 text-[13px] text-trace-duration tabular-nums">
<Meta label="time" value={fmtMs(span.duration_ms)} />
{tokens > 0 && <Meta label="tokens" value={fmtTok(tokens)} />}
</div>
@ -140,9 +177,9 @@ function SpanPane({
function GroupMetric({ label, value }: { label: string; value: string }) {
return (
<div className="border-r border-b border-border p-3">
<div className="font-mono text-[9px] tracking-[0.1em] text-muted-foreground uppercase">{label}</div>
<div className="mt-1 font-mono text-[12px] tabular-nums text-foreground">{value}</div>
<div className="border-r border-b border-trace-card-border p-3">
<div className="text-[13px] font-medium text-trace-key">{label}</div>
<div className="mt-1 text-[13px] text-trace-text tabular-nums">{value}</div>
</div>
);
}
@ -152,15 +189,23 @@ function GroupPane({ trace, row, onClose }: { trace: Trace; row: GroupRowData; o
const tokens = row.members.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0);
const firstFailure = row.members.find((m) => m.status === "error" && m.error);
return (
<aside className="flex h-full min-w-0 flex-col bg-background" aria-label="Group details">
<PaneHeader onClose={onClose}>
<span className={cn("size-1.5 rounded-full", row.failedCount ? "bg-destructive" : "bg-muted-foreground/60")} />
<span className="truncate font-mono text-[12px] font-medium text-foreground">
{row.name} <span className="text-muted-foreground">×{row.members.length}</span>
</span>
</PaneHeader>
<div className="min-h-0 flex-1 overflow-auto p-3">
<div className="grid grid-cols-2 overflow-hidden rounded border border-border bg-card">
<aside
className="flex h-full min-w-0 animate-view-fade-in flex-col bg-trace-surface pt-4 text-[13px] text-trace-text motion-reduce:animate-none"
aria-label="Group details"
>
<PaneHeader
type={row.type}
model={row.members[0]?.model ?? null}
failed={row.failedCount > 0}
title={
<>
{row.name} <span className="text-trace-duration">×{row.members.length}</span>
</>
}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-auto px-7 py-3">
<div className="grid grid-cols-2 overflow-hidden rounded-[4px] border border-trace-card-border bg-trace-surface">
<GroupMetric label="Invocations" value={row.members.length.toLocaleString()} />
<GroupMetric label="Failed" value={row.failedCount.toLocaleString()} />
<GroupMetric label="p50 latency" value={fmtMs(row.p50Duration)} />
@ -169,9 +214,9 @@ function GroupPane({ trace, row, onClose }: { trace: Trace; row: GroupRowData; o
<GroupMetric label="Type" value={row.type} />
</div>
{firstFailure?.error && (
<section className="mt-3 rounded border border-destructive/30 p-3">
<div className="font-mono text-[9px] tracking-[0.1em] text-destructive uppercase">Failure pattern</div>
<p className="mt-2 font-mono text-[11px] leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
<section className="mt-3 rounded-[4px] border border-destructive/30 px-3.5 py-3">
<div className="text-[12px] font-medium text-destructive">Failure pattern</div>
<p className="mt-2 font-mono text-[12px] leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
</section>
)}
</div>
@ -189,7 +234,7 @@ function GroupPane({ trace, row, onClose }: { trace: Trace; row: GroupRowData; o
export function DetailPane({ trace, row, accessToken, onClose }: DetailPaneProps) {
if (!row || row.kind === "load-more") {
return (
<div className="grid h-full place-items-center bg-background font-mono text-[11px] text-muted-foreground">
<div className="grid h-full place-items-center bg-trace-surface text-[13px] text-trace-duration">
Select a span to inspect it.
</div>
);

View file

@ -1,27 +0,0 @@
import { cn } from "@/lib/cva.config";
interface DurationBarProps {
startMs: number;
durationMs: number;
totalMs: number;
error?: boolean;
className?: string;
}
/** Waterfall bar: a hairline track with the span's slice of the run's timeline. */
export function DurationBar({ startMs, durationMs, totalMs, error = false, className }: DurationBarProps) {
const left = totalMs > 0 ? Math.max(0, Math.min(98, (startMs / totalMs) * 100)) : 0;
const width = totalMs > 0 ? Math.max(1.5, Math.min(100 - left, (durationMs / totalMs) * 100)) : 1.5;
return (
<div className={cn("relative h-3 w-full overflow-hidden", className)} aria-hidden="true">
<div className="absolute top-[5px] left-0 h-px w-full bg-border" />
<div
className={cn(
"absolute top-[3px] h-[5px] min-w-[2px] rounded-[1px]",
error ? "bg-destructive/70" : "bg-muted-foreground/60",
)}
style={{ left: `${left}%`, width: `${width}%` }}
/>
</div>
);
}

View file

@ -0,0 +1,21 @@
"use client";
import { Copy } from "lucide-react";
import { copyToClipboard } from "@/utils/dataUtils";
/** Small chip that copies an id; shows "ID" alone, or the id itself when `showValue` is set. */
export function IdChip({ value, label, showValue = false }: { value: string; label: string; showValue?: boolean }) {
return (
<button
type="button"
aria-label={label}
title={value}
onClick={() => void copyToClipboard(value, "ID copied")}
className="inline-flex h-5 min-w-0 shrink-0 items-center gap-1 rounded-[3px] border border-transparent bg-trace-chip px-1 font-mono text-[13px] leading-none font-medium text-trace-key transition-colors duration-100 hover:text-trace-text-2 focus-visible:outline-2 focus-visible:outline-trace-brand motion-reduce:transition-none"
>
<span className="truncate">{showValue ? value : "ID"}</span>
<Copy className="size-3 shrink-0" />
</button>
);
}

View file

@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "./Collapse";
const LONG_VALUE_CHARS = 90;
const ID_KEY = /(^|_)id$/i;
export type KeyValue = readonly [key: string, value: string];
export const isLongValue = (value: string): boolean => value.length > LONG_VALUE_CHARS || value.includes("\n");
export const displayValue = (value: unknown): string => {
if (typeof value === "string") return value;
if (value === null || value === undefined) return String(value);
return JSON.stringify(value);
};
export const objectEntries = (value: unknown): KeyValue[] | null => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return Object.entries(value).map(([key, v]): KeyValue => [key, displayValue(v)]);
};
const ROW = "flex min-w-0 items-baseline gap-2.5 py-0.5";
const KEY = "shrink-0 text-[13px] leading-[1.2] font-medium tracking-[-0.26px] whitespace-nowrap";
function Lead({ children }: { children: React.ReactNode }) {
return <span className="flex size-4 shrink-0 items-center justify-center self-center">{children}</span>;
}
function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
const [key, value] = entry;
const [open, setOpen] = useState(false);
const valueClass = cn(
"min-w-0 flex-1 text-left text-[13px] leading-[1.2] tracking-[-0.26px] text-trace-text",
mono || ID_KEY.test(key) ? "font-mono" : "font-sans",
);
if (!isLongValue(value)) {
return (
<li className={ROW}>
<Lead>
<span className="size-2 rounded-full bg-trace-dot" />
</Lead>
<span className={cn(KEY, "text-trace-key")}>{key}</span>
<span className={cn(valueClass, "truncate")} title={value}>
{value}
</span>
</li>
);
}
const head = (
<>
<Lead>
<FoldChevron open={open} className="size-3 text-trace-text" />
</Lead>
<span className={cn(KEY, "text-trace-duration")}>{key}</span>
</>
);
const toggle = {
type: "button",
"aria-expanded": open,
"aria-label": `${open ? "Collapse" : "Expand"} ${key}`,
onClick: () => setOpen((prev) => !prev),
} as const;
return (
<li className={ROW}>
{open ? (
<>
<button {...toggle} className="flex shrink-0 cursor-pointer items-baseline gap-2.5 text-left">
{head}
</button>
<pre className={cn(valueClass, "leading-[1.5] break-words whitespace-pre-wrap")}>{value}</pre>
</>
) : (
<button {...toggle} className="flex min-w-0 flex-1 cursor-pointer items-baseline gap-2.5 text-left">
{head}
<span className={cn(valueClass, "truncate")}>{value.replace(/\n/g, "\\n")}</span>
</button>
)}
</li>
);
}
/** Dot-bulleted key / value list; long values show a chevron and expand in place on click. */
export function KeyValueRows({
entries,
mono = false,
className,
}: {
entries: readonly KeyValue[];
mono?: boolean;
className?: string;
}) {
return (
<ul className={cn("flex flex-col gap-3", className)}>
{entries.map((entry) => (
<KeyValueRow key={entry[0]} entry={entry} mono={mono} />
))}
</ul>
);
}

View file

@ -0,0 +1,80 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MessageCard, Section, ToolResultCard } from "./MessageCard";
vi.mock("./spanProvider", () => ({ useSpanProvider: () => null }));
const LONG_QUERY = "Find every invoice for the customer that was billed twice. ".repeat(3).trim();
describe("ToolResultCard", () => {
it("expands a multiline result to its full text and keeps short results on one line", async () => {
const user = userEvent.setup();
const result = "line one\nline two\nTraceback: boom";
const { unmount } = render(<ToolResultCard name="read_file" result={result} failed />);
const toggle = screen.getByRole("button", { name: "Expand read_file result" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
await user.click(toggle);
expect(screen.getByRole("button", { name: "Collapse read_file result" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText((_, el) => el?.tagName === "PRE" && el.textContent === result)).toBeVisible();
unmount();
render(<ToolResultCard name="ls" result="No files found" />);
expect(screen.getByText("No files found")).toBeVisible();
expect(screen.queryByRole("button", { name: /^(Expand|Collapse) ls result$/ })).not.toBeInTheDocument();
});
});
describe("MessageCard", () => {
it("folds the card body from the role tile and restores it", async () => {
const user = userEvent.setup();
render(<MessageCard message={{ role: "user", content: "Why was I billed twice?" }} model={null} />);
expect(screen.getByText("Why was I billed twice?")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Collapse User" }));
expect(screen.queryByText("Why was I billed twice?")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Expand User" }));
expect(screen.getByText("Why was I billed twice?")).toBeVisible();
});
it("lists tool call args as dot rows and expands only the long value in place", async () => {
const user = userEvent.setup();
render(
<MessageCard
message={{
role: "assistant",
content: "",
tool_calls: [{ name: "search_invoices", args: { customer_id: "acme-404", query: LONG_QUERY } }],
}}
model={null}
/>,
);
expect(screen.getByText("acme-404")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Expand customer_id" })).not.toBeInTheDocument();
expect(screen.queryByText(LONG_QUERY, { selector: "pre" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Expand query" }));
expect(screen.getByRole("button", { name: "Collapse query" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText(LONG_QUERY, { selector: "pre" })).toBeVisible();
});
it("shows a tool result as one line with the tool name and its output", () => {
render(<MessageCard message={{ role: "tool", name: "ls", content: "No files found" }} model={null} />);
expect(screen.getByText("ls")).toBeInTheDocument();
expect(screen.getByText("No files found")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy ls result" })).toBeInTheDocument();
});
});
describe("Section", () => {
it("hides its body immediately when collapsed", async () => {
const user = userEvent.setup();
render(
<Section title="Output">
<p>final answer</p>
</Section>,
);
await user.click(screen.getByRole("button", { name: "Output" }));
expect(screen.getByRole("button", { name: "Output" })).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("final answer")).not.toBeVisible();
});
});

View file

@ -0,0 +1,265 @@
"use client";
import { Bot, ScrollText, UserRound, Wrench } from "lucide-react";
import { useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Logo } from "@/components/molecules/logo/Logo";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "./Collapse";
import { CopyButton } from "./CopyButton";
import { displayValue, type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows";
import { useSpanProvider } from "./spanProvider";
import type { TraceMessage, TraceToolCall } from "./traceTypes";
const ROLE_LABEL: Record<string, string> = { user: "User", system: "System", assistant: "AI", tool: "Tool" };
const BLOCK_GAP = "mb-[1lh] last:mb-0";
const markdownComponents: Components = {
p: ({ node, ...props }) => <p className={BLOCK_GAP} {...props} />,
ul: ({ node, ...props }) => <ul className={cn(BLOCK_GAP, "list-disc pl-6")} {...props} />,
ol: ({ node, ...props }) => <ol className={cn(BLOCK_GAP, "list-decimal pl-6")} {...props} />,
li: ({ node, ...props }) => <li className="pl-0.5 [&>p]:mb-0 [&>ol]:mb-0 [&>ul]:mb-0" {...props} />,
h1: ({ node, ...props }) => <h1 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
h2: ({ node, ...props }) => <h2 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
h3: ({ node, ...props }) => <h3 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
blockquote: ({ node, ...props }) => (
<blockquote className={cn(BLOCK_GAP, "border-l-2 border-trace-border pl-3 text-trace-duration")} {...props} />
),
pre: ({ node, ...props }) => (
<pre
className={cn(
BLOCK_GAP,
"max-w-full overflow-x-auto rounded-[4px] bg-trace-chip p-2.5 font-mono text-[13px] leading-[1.5] tracking-normal",
)}
{...props}
/>
),
code: ({ node, className, ...props }) => <code className={cn("font-mono", className)} {...props} />,
a: ({ node, ...props }) => <a className="text-trace-brand underline" target="_blank" rel="noreferrer" {...props} />,
img: ({ alt }) => <span>{alt || "Image omitted"}</span>,
};
export function Markdown({ text }: { text: string }) {
return (
<div className="text-[14px] leading-[1.65] tracking-[-0.42px] break-words text-trace-text">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
export function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<section
className={cn(
"rounded-[4px] border-[0.67px] border-trace-card-border bg-trace-surface shadow-[0_1px_1px_0_rgba(16,24,40,0.02)]",
className,
)}
>
{children}
</section>
);
}
const TILE = "grid size-4 shrink-0 place-items-center rounded-[2px] p-0.5";
function RoleGlyph({ role, model, failed }: { role: string; model: string | null; failed: boolean }) {
const provider = useSpanProvider(role === "assistant" ? model : null);
if (role === "user") return <UserRound className="size-3 fill-current text-trace-human-glyph" strokeWidth={2.5} />;
if (role === "tool") return <Wrench className={cn("size-3", failed ? "text-white" : "text-trace-glyph")} />;
if (role === "system") return <ScrollText className="size-3 text-trace-glyph" />;
if (provider) return <Logo provider={provider} className="size-3" />;
return <Bot className="size-3 text-trace-glyph" />;
}
const tileTone = (role: string, failed: boolean, hasLogo = false): string => {
if (failed) return "bg-destructive";
if (role === "user") return "bg-trace-human";
if (role === "assistant") return hasLogo ? "bg-white ring-1 ring-trace-line ring-inset" : "bg-trace-llm";
if (role === "tool") return "bg-trace-tool";
return "bg-trace-duration";
};
function RoleTile({ role, model = null, failed = false }: { role: string; model?: string | null; failed?: boolean }) {
const provider = useSpanProvider(role === "assistant" ? model : null);
return (
<span className={cn(TILE, tileTone(role, failed, provider !== null))}>
<RoleGlyph role={role} model={model} failed={failed} />
</span>
);
}
function FoldTile({
role,
model,
label,
open,
onToggle,
}: {
role: string;
model: string | null;
label: string;
open: boolean;
onToggle: () => void;
}) {
const provider = useSpanProvider(role === "assistant" ? model : null);
return (
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${label}`}
onClick={onToggle}
className={cn(
TILE,
"group/tile cursor-pointer transition-colors duration-150 group-hover/header:bg-trace-tab-active focus-visible:bg-trace-tab-active motion-reduce:transition-none",
tileTone(role, false, provider !== null),
)}
>
<span className="contents group-hover/header:hidden group-focus-visible/tile:hidden">
<RoleGlyph role={role} model={model} failed={false} />
</span>
<FoldChevron
open={open}
className="hidden size-3 text-trace-duration group-hover/header:block group-focus-visible/tile:block"
/>
</button>
);
}
const HEADER =
"group/header flex min-w-0 items-center gap-2.5 border-[0.67px] border-trace-card-border bg-trace-surface py-1.5 pr-2 pl-3 transition-colors duration-150 hover:bg-trace-chip motion-reduce:transition-none dark:bg-trace-chip";
const LABEL = "shrink-0 text-[13px] leading-[1.2] font-semibold tracking-[-0.26px] text-trace-text";
const CARD_COPY =
"ml-auto size-5 shrink-0 rounded-[3px] text-trace-text-2 opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
const INLINE_COPY = "size-4 shrink-0 rounded-[3px] text-trace-duration";
function ToolCallBlock({ call }: { call: TraceToolCall }) {
const raw: KeyValue[] = [
["arguments", displayValue(call.args)],
["name", call.name],
];
const argEntries = objectEntries(call.args);
return (
<>
<KeyValueRows entries={raw} />
<div className="-mx-3 flex h-7 items-center gap-2.5 py-1.5 pr-3 pl-[30px] transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none">
<RoleTile role="tool" />
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-trace-text">
{call.name}
</span>
<CopyButton value={call.name} label={`Copy ${call.name} name`} iconOnly className={INLINE_COPY} />
</span>
</div>
{argEntries && argEntries.length > 0 && <KeyValueRows entries={argEntries} className="pl-10" />}
</>
);
}
/** One chat message as a card with a sticky header; hovering the role tile turns it into a fold chevron. */
export function MessageCard({ message, model }: { message: TraceMessage; model: string | null }) {
const [open, setOpen] = useState(true);
const label = message.role === "tool" ? message.name ?? "Tool" : ROLE_LABEL[message.role] ?? message.role;
if (message.role === "tool") return <ToolResultCard name={label} result={message.content} />;
const calls = message.tool_calls ?? [];
const copyValue = message.content || calls.map((c) => `${c.name}(${displayValue(c.args)})`).join("\n");
const hasBody = Boolean(message.content) || calls.length > 0;
const expanded = open && hasBody;
return (
<article className="rounded-[4px] shadow-[0_1px_1px_0_rgba(16,24,40,0.02)]">
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-[4px] border-b-0" : "rounded-[4px]")}>
<FoldTile role={message.role} model={model} label={label} open={open} onToggle={() => setOpen((v) => !v)} />
<span className={LABEL}>{label}</span>
<CopyButton value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
</div>
{expanded && (
<div
className={cn(
"flex flex-col gap-2 rounded-b-[4px] border-[0.67px] border-t-0 border-trace-card-border bg-trace-surface px-3 pt-1.5 pb-3",
message.role === "system" && "[&_*]:text-trace-duration",
)}
>
{message.content && <Markdown text={message.content} />}
{calls.map((call, i) => (
<ToolCallBlock key={`${call.name}-${i}`} call={call} />
))}
</div>
)}
</article>
);
}
const LONG_RESULT_CHARS = 120;
/** Tool result card: one line by default; long or multiline results expand in place. Red when the tool failed. */
export function ToolResultCard({ name, result, failed = false }: { name: string; result: string; failed?: boolean }) {
const [open, setOpen] = useState(false);
const expandable = result.length > LONG_RESULT_CHARS || result.includes("\n");
const tone = failed ? "text-destructive" : "text-trace-duration";
return (
<article className="flex flex-col">
<div
className={cn(HEADER, open ? "rounded-t-[4px]" : "rounded-[4px]", "shadow-[0_1px_1px_0_rgba(16,24,40,0.02)]")}
>
<RoleTile role="tool" failed={failed} />
<span className="flex shrink-0 items-center gap-1.5">
<span className={cn(LABEL, failed && "text-destructive")}>{name}</span>
<CopyButton value={name} label={`Copy ${name} name`} iconOnly className={INLINE_COPY} />
</span>
{expandable ? (
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${name} result`}
onClick={() => setOpen((prev) => !prev)}
className={cn("flex min-w-0 cursor-pointer items-center gap-1 text-left", tone)}
>
<FoldChevron open={open} className="size-3 shrink-0" />
{!open && <span className="min-w-0 truncate text-[13px] leading-[1.2]">{result}</span>}
</button>
) : (
<span className={cn("min-w-0 truncate text-[13px] leading-[1.2] tracking-[-0.26px]", tone)}>
{result || "No output"}
</span>
)}
<CopyButton value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
</div>
{open && (
<pre
className={cn(
"max-h-80 overflow-auto rounded-b-[4px] border-[0.67px] border-t-0 border-trace-card-border bg-trace-chip px-3 py-2 font-mono text-[12px] leading-[1.5] break-words whitespace-pre-wrap",
tone,
)}
>
{result}
</pre>
)}
</article>
);
}
/** Collapsible "Input" / "Output" section: sticky header, chevron turns, body snaps open and shut. */
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<section aria-label={title}>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-trace-surface py-1 pr-3 pl-2 text-left transition-colors duration-150 hover:bg-trace-chip motion-reduce:transition-none"
>
<FoldChevron open={open} className="size-3 shrink-0 text-trace-text" />
<span className="text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-trace-text">{title}</span>
</button>
<div hidden={!open} inert={!open} className="flex flex-col gap-2.5 pr-4 pb-4 pl-7">
{children}
</div>
</section>
);
}

View file

@ -6,8 +6,10 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import { LogDetailsDrawer } from "../LogDetailsDrawer";
import { CopyButton } from "./CopyButton";
import { formatCost } from "./AgentTracesTable";
import { DetailGroup } from "./AttributesDetail";
import { CopyButton } from "./CopyButton";
import { type KeyValue, KeyValueRows } from "./KeyValueRows";
import type { Span } from "./traceTypes";
import { fmtMs, fmtTok } from "./traceUtils";
import { useSpanRequestLog } from "./useSpanRequestLog";
@ -28,60 +30,44 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
const logNotFound = lookupDone && logQuery.data === null;
if (span.type !== "llm") {
return (
<div className="py-16 text-center font-mono text-[11px] text-muted-foreground">
This span is not a model request.
</div>
);
return <div className="py-16 text-center text-[13px] text-trace-duration">This span is not a model request.</div>;
}
const rows: [string, string][] = [
["Model", span.model ?? "—"],
["Cost", span.spend == null ? "—" : formatCost(span.spend)],
["Input tokens", fmtTok(span.input_tokens)],
["Output tokens", fmtTok(span.output_tokens)],
["Total tokens", fmtTok(span.input_tokens + span.output_tokens)],
["Latency", fmtMs(span.duration_ms)],
const usage: KeyValue[] = [
["model", span.model ?? "—"],
["cost", span.spend == null ? "—" : formatCost(span.spend)],
["input_tokens", fmtTok(span.input_tokens)],
["output_tokens", fmtTok(span.output_tokens)],
["total_tokens", fmtTok(span.input_tokens + span.output_tokens)],
["latency", fmtMs(span.duration_ms)],
];
return (
<div className="p-3">
<dl className="overflow-hidden rounded border border-border bg-card">
{rows.map(([label, value]) => (
<div key={label} className="grid min-h-8 grid-cols-[128px_1fr] border-b border-border last:border-0">
<dt className="flex items-center bg-muted/40 px-2.5 font-mono text-[10px] text-muted-foreground">
{label}
</dt>
<dd className="flex min-w-0 items-center px-2.5 font-mono text-[11px] tabular-nums text-foreground">
{value}
</dd>
<div className="flex flex-col gap-4 px-7 pt-1 pb-4">
<DetailGroup title="Usage">
<KeyValueRows entries={usage} />
</DetailGroup>
<DetailGroup title="LiteLLM request">
{span.litellm_request_id ? (
<div className="flex flex-col gap-2.5">
<div className="flex min-w-0 items-center gap-1">
<KeyValueRows entries={[["request_id", span.litellm_request_id]]} mono className="min-w-0 flex-1" />
<CopyButton value={span.litellm_request_id} label="Copy request ID" iconOnly />
</div>
<Button
variant="outline"
size="xs"
className="self-start border-trace-border text-[13px] text-trace-text-2 shadow-trace-xs"
onClick={() => setDrawerOpen(true)}
>
Open request log <ArrowUpRight className="size-3.5" />
</Button>
{logNotFound && <p className="text-[13px] text-trace-duration">No request log found for this call.</p>}
</div>
))}
</dl>
<div className="mt-3 rounded border border-border bg-muted/40 p-3">
<div className="flex items-center font-mono text-[9px] tracking-[0.1em] text-muted-foreground uppercase">
Request ID
{span.litellm_request_id && (
<CopyButton value={span.litellm_request_id} label="Copy request ID" iconOnly className="ml-auto" />
)}
</div>
<div className="mt-1.5 font-mono text-[11px] break-all text-foreground">
{span.litellm_request_id ?? "Not linked to a LiteLLM request"}
</div>
</div>
{span.litellm_request_id && (
<Button
variant="outline"
size="sm"
className="mt-3 w-full gap-1.5 font-mono text-[11px]"
onClick={() => setDrawerOpen(true)}
>
Open request log <ArrowUpRight className="size-3" />
</Button>
)}
{logNotFound && (
<p className="mt-2 font-mono text-[11px] text-muted-foreground">No request log found for this call.</p>
)}
) : (
<p className="text-[13px] text-trace-duration">Not linked to a LiteLLM request</p>
)}
</DetailGroup>
<LogDetailsDrawer
open={drawerOpen && Boolean(logQuery.data)}
onClose={() => setDrawerOpen(false)}

View file

@ -0,0 +1,76 @@
import { screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { clampDrawerWidth, RunDrawer } from "./RunDrawer";
import type { TraceSummary } from "./traceTypes";
vi.mock("./TraceDrawer", () => ({
RunView: ({ traceId }: { traceId: string }) => <div data-testid="run-view">run {traceId}</div>,
}));
const run = (trace_id: string): TraceSummary => ({
trace_id,
name: trace_id,
service: "svc",
input_preview: "",
start_time: "2026-09-30T00:00:00Z",
duration_ms: 1,
status: "ok",
span_count: 1,
agent_count: 1,
llm_calls: 0,
tool_calls: 0,
error_count: 0,
input_tokens: 0,
output_tokens: 0,
models: [],
});
const mockReducedMotion = (reduce: boolean) =>
vi
.spyOn(window, "matchMedia")
.mockImplementation(
(query: string) => ({ matches: reduce && query.includes("reduce"), media: query }) as MediaQueryList,
);
describe("clampDrawerWidth", () => {
it("keeps at least 700px and a 100px strip of list on wide screens", () => {
expect(clampDrawerWidth(200, 1600)).toBe(700);
expect(clampDrawerWidth(5000, 1600)).toBe(1500);
expect(clampDrawerWidth(900, 1600)).toBe(900);
});
it("never exceeds the screen on viewports narrower than the minimum width", () => {
expect(clampDrawerWidth(900, 600)).toBe(600);
expect(clampDrawerWidth(100, 600)).toBe(600);
expect(clampDrawerWidth(750, 760)).toBe(700);
});
});
describe("RunDrawer", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("unmounts right away on close when the user prefers reduced motion", () => {
mockReducedMotion(true);
const runs = [run("a")];
const { rerender } = renderWithProviders(
<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={vi.fn()} />,
);
expect(screen.getByRole("complementary", { name: "Trace details" })).toBeInTheDocument();
rerender(<RunDrawer trace={null} runs={runs} accessToken="sk" onSelect={vi.fn()} />);
expect(screen.queryByRole("complementary", { name: "Trace details" })).not.toBeInTheDocument();
});
it("plays the exit animation before unmounting when motion is allowed", () => {
mockReducedMotion(false);
const runs = [run("a")];
const { rerender } = renderWithProviders(
<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={vi.fn()} />,
);
rerender(<RunDrawer trace={null} runs={runs} accessToken="sk" onSelect={vi.fn()} />);
expect(screen.getByRole("complementary", { name: "Trace details" })).toHaveClass("animate-trace-drawer-out");
});
});

View file

@ -0,0 +1,229 @@
"use client";
import { ChevronDown, ChevronsRight, ChevronUp } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/cva.config";
import { RunView } from "./TraceDrawer";
import type { TraceSummary } from "./traceTypes";
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const MIN_WIDTH = 700;
const MIN_LEFT_GAP = 100;
const DEFAULT_FRACTION = 0.56;
const KEY_STEP = 32;
const viewportWidth = (): number => (typeof window === "undefined" ? 1440 : window.innerWidth);
/** At least 700px when the screen allows, never wider than the screen minus a 100px strip of list (or the full screen). */
export const clampDrawerWidth = (width: number, viewport: number): number => {
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
};
const prefersReducedMotion = (): boolean =>
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
const readStoredWidth = (): number | null => {
try {
const raw = window.localStorage.getItem(WIDTH_KEY);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
};
const storeWidth = (width: number): void => {
try {
window.localStorage.setItem(WIDTH_KEY, String(width));
} catch {
return;
}
};
const isTypingTarget = (target: EventTarget | null): boolean =>
target instanceof HTMLElement &&
target.matches("input, textarea, select, [contenteditable='true'], [role='combobox']");
function useDrawerWidth() {
const [width, setWidth] = useState(() =>
clampDrawerWidth(readStoredWidth() ?? viewportWidth() * DEFAULT_FRACTION, viewportWidth()),
);
const update = useCallback((next: number) => {
const clamped = clampDrawerWidth(next, viewportWidth());
setWidth(clamped);
storeWidth(clamped);
}, []);
useEffect(() => {
const onResize = () => setWidth((w) => clampDrawerWidth(w, viewportWidth()));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return [width, update] as const;
}
function ResizeHandle({ width, onResize }: { width: number; onResize: (width: number) => void }) {
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) onResize(viewportWidth() - event.clientX);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
else return;
event.preventDefault();
event.stopPropagation();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize trace panel"
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onKeyDown={onKeyDown}
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
>
<span
className={cn(
"h-full w-[0.67px] bg-trace-line transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
dragging && "w-0.5 bg-trace-brand",
)}
/>
</div>
);
}
function HeaderButton({
label,
disabled = false,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="grid size-7 place-items-center rounded-[4px] text-trace-key transition-colors duration-150 hover:bg-trace-row-hover hover:text-trace-text disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
>
{children}
</button>
);
}
interface RunDrawerProps {
trace: TraceSummary | null;
runs: readonly TraceSummary[];
accessToken: string;
onSelect: (trace: TraceSummary | null) => void;
}
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
/** Right-side drawer over the runs list: resizable, keeps the list clickable, swaps runs in place. */
export function RunDrawer({ trace, runs, accessToken, onSelect }: RunDrawerProps) {
const [width, setWidth] = useDrawerWidth();
const [lastShown, setLastShown] = useState<TraceSummary | null>(trace);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (trace !== null && trace !== lastShown) setLastShown(trace);
const shown = trace ?? lastShown;
const closing = trace === null && shown !== null;
if (trace !== null && exitedKey !== null) setExitedKey(null);
if (closing && exitedKey !== runKey(shown) && prefersReducedMotion()) setExitedKey(runKey(shown));
const index = trace === null ? -1 : runs.findIndex((run) => runKey(run) === runKey(trace));
const step = useCallback(
(delta: number) => {
const next = runs[index + delta];
if (next) onSelect(next);
},
[runs, index, onSelect],
);
useEffect(() => {
if (trace === null) return;
const onKeyDown = (event: KeyboardEvent) => {
const modified = event.metaKey || event.ctrlKey || event.altKey;
if (modified || isTypingTarget(event.target)) return;
if (event.key === "Escape") {
event.preventDefault();
onSelect(null);
} else if (event.key === "j") {
event.preventDefault();
step(1);
} else if (event.key === "k") {
event.preventDefault();
step(-1);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [trace, step, onSelect]);
if (shown === null || (closing && exitedKey === runKey(shown))) return null;
return (
<aside
aria-label="Trace details"
data-testid="run-drawer"
style={{ width }}
onAnimationEnd={(event) => {
if (closing && event.target === event.currentTarget) setExitedKey(runKey(shown));
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-trace-surface shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
)}
>
<ResizeHandle width={width} onResize={setWidth} />
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-trace-line px-2">
<HeaderButton label="Close (Esc)" onClick={() => onSelect(null)}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-trace-line" />
<HeaderButton label="Next trace (J)" disabled={index < 0 || index >= runs.length - 1} onClick={() => step(1)}>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label="Previous trace (K)" disabled={index <= 0} onClick={() => step(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
<span className="ml-1 font-mono text-[11px] text-trace-key tabular-nums">
{index + 1} / {runs.length}
</span>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<RunView
key={runKey(shown)}
traceId={shown.trace_id}
traceRef={shown.trace_ref}
accessToken={accessToken}
onBack={() => onSelect(null)}
embedded
/>
</div>
</aside>
);
}

View file

@ -0,0 +1,185 @@
"use client";
import { Check } from "lucide-react";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { formatCost } from "./AgentTracesTable";
import { errorHeadline } from "./DetailContent";
import { SpanIcon } from "./SpanIcon";
import type { GroupRowData } from "./traceTree";
import type { Span, SpanType } from "./traceTypes";
import { fmtMs, fmtTok } from "./traceUtils";
export const HOVER_OPEN_DELAY_MS = 300;
const HOVER_CLOSE_DELAY_MS = 100;
const TYPE_LABEL: Record<SpanType, string> = {
agent: "Agent",
llm: "LLM",
tool: "Tool",
chain: "Chain",
framework: "Framework",
};
const ABSOLUTE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
};
const ABSOLUTE_TIME = new Intl.DateTimeFormat("en-US", ABSOLUTE_TIME_OPTIONS);
export const absoluteTime = (traceStartMs: number, offsetMs: number): string =>
ABSOLUTE_TIME.format(new Date(traceStartMs + offsetMs));
export interface HoverFacts {
name: string;
type: SpanType;
model: string | null;
startMs: number;
durationMs: number;
tokens: number;
spend: number | null;
error: string | null;
failed: boolean;
tags: readonly string[];
}
const agentTags = (agent: string): readonly string[] => (agent ? [`agent:${agent}`] : []);
export const spanFacts = (span: Span): HoverFacts => ({
name: span.name,
type: span.type,
model: span.model,
startMs: span.start_offset_ms,
durationMs: span.duration_ms,
tokens: span.input_tokens + span.output_tokens,
spend: span.spend ?? null,
error: span.error ?? null,
failed: span.status === "error",
tags: agentTags(span.agent),
});
export const groupFacts = (row: GroupRowData): HoverFacts => {
const start = Math.min(...row.members.map((m) => m.start_offset_ms));
const end = Math.max(...row.members.map((m) => m.start_offset_ms + m.duration_ms));
return {
name: `${row.name} ×${row.members.length}`,
type: row.type,
model: row.members[0]?.model ?? null,
startMs: start,
durationMs: end - start,
tokens: row.members.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0),
spend: null,
error: row.members.find((m) => m.status === "error" && m.error)?.error ?? null,
failed: row.failedCount > 0,
tags: agentTags(row.agent),
};
};
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4">
<dt className="text-trace-duration">{label}</dt>
<dd className="font-mono text-trace-text tabular-nums">{value}</dd>
</div>
);
}
function FactSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section aria-label={title} className="flex flex-col gap-1.5">
<h4 className="font-semibold text-trace-text">{title}</h4>
{children}
</section>
);
}
function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartMs: number }) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-2">
<span className="mt-0.5">
<SpanIcon type={facts.type} model={facts.model} error={facts.failed} size="sm" />
</span>
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-semibold text-trace-text">{facts.name}</span>
{!facts.failed && (
<span
className="grid size-4 shrink-0 place-items-center rounded-full bg-trace-ok p-0.5"
aria-label="Success"
>
<Check className="size-3 text-trace-ok-glyph" strokeWidth={2.5} />
</span>
)}
</div>
<span className="text-[12px] text-trace-duration">
{TYPE_LABEL[facts.type]}
{facts.model ? ` · ${facts.model}` : ""}
</span>
</div>
</div>
<FactSection title="Time">
<dl className="flex flex-col gap-1.5">
<Fact label="Start" value={absoluteTime(traceStartMs, facts.startMs)} />
<Fact label="End" value={absoluteTime(traceStartMs, facts.startMs + facts.durationMs)} />
<Fact label="Duration" value={fmtMs(facts.durationMs)} />
</dl>
</FactSection>
{(facts.tokens > 0 || facts.spend != null) && (
<FactSection title="Usage">
<dl className="flex flex-col gap-1.5">
{facts.tokens > 0 && <Fact label="Tokens" value={fmtTok(facts.tokens)} />}
{facts.spend != null && <Fact label="Cost" value={formatCost(facts.spend)} />}
</dl>
</FactSection>
)}
{facts.tags.length > 0 && (
<FactSection title="Tags">
<ul className="flex flex-wrap gap-1">
{facts.tags.map((tag) => (
<li key={tag} className="rounded-[3px] bg-trace-tag px-1 py-0.5 text-trace-duration">
{tag}
</li>
))}
</ul>
</FactSection>
)}
{facts.error && (
<div className="rounded-[4px] bg-destructive/10 px-2 py-1.5 font-mono text-[12px] break-words text-destructive">
{errorHeadline(facts.error)}
</div>
)}
</div>
);
}
interface SpanHoverCardProps {
facts: HoverFacts;
traceStartMs: number;
children: React.ReactElement;
}
/** Wraps a tree row: after a hover delay, shows timing, usage, tags and the failure headline beside the drawer. */
export function SpanHoverCard({ facts, traceStartMs, children }: SpanHoverCardProps) {
return (
<HoverCard>
<HoverCardTrigger delay={HOVER_OPEN_DELAY_MS} closeDelay={HOVER_CLOSE_DELAY_MS} render={children} />
<HoverCardContent
side="left"
align="start"
sideOffset={0}
alignOffset={0}
data-testid="span-hover-card"
className="max-h-[min(520px,calc(100vh-16px))] w-auto max-w-[420px] overflow-y-auto rounded-[6px] border border-trace-border bg-trace-surface p-3 text-[13px] leading-[1.2] tracking-[-0.26px] text-trace-text shadow-trace-md ring-0 transition-opacity duration-150 ease-[cubic-bezier(0,0,0.2,1)] data-closed:animate-none data-ending-style:opacity-0 data-open:animate-none motion-reduce:transition-none"
>
<HoverCardBody facts={facts} traceStartMs={traceStartMs} />
</HoverCardContent>
</HoverCard>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { Bot, BrainCircuit, Link2, Network, Wrench } from "lucide-react";
import { Logo } from "@/components/molecules/logo/Logo";
import { cn } from "@/lib/cva.config";
import { useSpanProvider } from "./spanProvider";
import type { SpanType } from "./traceTypes";
const SIZE = {
sm: "size-4 rounded-[2px]",
card: "size-[18px] rounded-[3px]",
md: "size-5 rounded-full",
lg: "size-5 rounded-full",
} as const;
const GLYPH = { sm: "size-3", card: "size-3", md: "size-3", lg: "size-3" } as const;
const LOGO_TILE = "bg-white ring-1 ring-trace-line ring-inset dark:bg-white";
export type IconSize = keyof typeof SIZE;
interface SpanIconProps {
type: SpanType;
model?: string | null;
error?: boolean;
size?: IconSize;
}
const TYPE_GLYPH: Record<SpanType, typeof Bot> = {
agent: Bot,
llm: BrainCircuit,
tool: Wrench,
chain: Link2,
framework: Network,
};
const tileTone = (type: SpanType, error: boolean): string => {
if (error) return "bg-destructive text-white";
if (type === "tool") return "bg-trace-tool text-trace-glyph";
if (type === "llm") return "bg-trace-llm text-trace-glyph";
if (type === "framework") return "bg-trace-key text-trace-glyph";
return "bg-trace-chain text-trace-glyph";
};
/** Solid square type tile; LLM spans show their provider's logo, knocked out to the tile glyph color. */
export function SpanIcon({ type, model = null, error = false, size = "md" }: SpanIconProps) {
const provider = useSpanProvider(type === "llm" ? model : null);
const Glyph = TYPE_GLYPH[type];
const showLogo = provider !== null && !error;
return (
<span
className={cn("grid shrink-0 place-items-center", SIZE[size], showLogo ? LOGO_TILE : tileTone(type, error))}
data-testid="span-icon"
data-provider={provider ?? undefined}
>
{showLogo ? <Logo provider={provider} className={GLYPH[size]} /> : <Glyph className={GLYPH[size]} />}
</span>
);
}

View file

@ -1,30 +1,22 @@
"use client";
import {
Bot,
BrainCircuit,
ChevronDown,
ChevronRight,
CircleDot,
CornerDownRight,
MoreHorizontal,
Network,
Wrench,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { BarChart3, Clock, Coins, ListTree, MoreHorizontal, Timer } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/cva.config";
import { DurationBar } from "./DurationBar";
import type { TreeRow } from "./traceTree";
import type { SpanType } from "./traceTypes";
import { fmtMs } from "./traceUtils";
import { formatCost } from "./AgentTracesTable";
import { FoldChevron } from "./Collapse";
import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import { SpanIcon } from "./SpanIcon";
import type { GroupRowData, SpanRowData, TreeRow } from "./traceTree";
import type { TraceSummary } from "./traceTypes";
import { fmtMs, fmtTok, type TreeGuide, treeGuides } from "./traceUtils";
interface SpanTreeProps {
rows: TreeRow[];
spanCount: number;
totalMs: number;
summary: TraceSummary;
selectedId: string;
hideFramework: boolean;
onSelect: (id: string) => void;
@ -32,23 +24,283 @@ interface SpanTreeProps {
onToggleSpan: (id: string) => void;
onToggleGroup: (id: string) => void;
onLoadMore: (groupId: string) => void;
/** Inside the side drawer J/K switch runs, so spans move with the arrow keys. */
embedded?: boolean;
}
const ROW_GRID = "grid h-8 w-full grid-cols-[minmax(275px,1fr)_minmax(94px,27%)_64px] items-center px-2";
const INDENT_PX = 15;
const rowClass = (selected: boolean): string =>
cn(
ROW_GRID,
"border-b border-border/60 text-left outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset",
selected ? "bg-accent shadow-[inset_2px_0_0_var(--foreground)]" : "hover:bg-muted/60",
"relative flex w-full items-stretch border-l-2 px-3.5 text-left outline-none transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] focus-visible:shadow-[inset_0_0_0_2px_var(--trace-brand)] motion-reduce:transition-none",
selected ? "border-l-trace-brand bg-trace-row-selected" : "border-l-transparent hover:bg-trace-row-hover",
);
/** Span list with a framework toggle, a timeline column and keyboard hints. */
const LINE = "border-dashed border-trace-line [border-width:0] [border-left-width:1px]";
const ELBOW = "border-dashed border-trace-line [border-width:0] [border-left-width:1px] [border-bottom-width:1px]";
const NAME = "truncate text-[13px] leading-[1.2] font-medium";
const MONO_NAME = "truncate font-mono text-[12px] leading-[1.2]";
const META = "font-mono text-[11px] leading-[14px] text-trace-duration tabular-nums";
function Stat({ icon: Icon, children }: { icon: typeof Clock; children: React.ReactNode }) {
return (
<span className="inline-flex items-center gap-1">
<Icon className="size-3" />
{children}
</span>
);
}
const Slash = () => <span className="text-trace-key">/</span>;
function Summary({ summary }: { summary: TraceSummary }) {
const tokens = summary.input_tokens + summary.output_tokens;
return (
<div className="flex flex-wrap items-center gap-2 px-4 py-2 transition-colors duration-150 hover:bg-trace-turn motion-reduce:transition-none">
<span className="flex items-center gap-1.5 text-[12px] leading-[13.8px] font-semibold text-trace-text">
<span className="grid size-5 place-items-center rounded-[4px] bg-trace-row-selected text-[var(--trace-summary-glyph,#0d3d77)]">
<BarChart3 className="size-3" strokeWidth={1.5} />
</span>
Summary
</span>
<span className="flex basis-full flex-wrap items-center gap-x-2 pl-6 font-mono text-[11.5px] leading-[13.8px] text-trace-text-secondary tabular-nums">
<Stat icon={ListTree}>{`${summary.span_count.toLocaleString()} spans`}</Stat>
<Slash />
<Stat icon={Timer}>{fmtMs(summary.duration_ms)}</Stat>
{tokens > 0 && (
<>
<Slash />
<Stat icon={Coins}>{fmtTok(tokens)}</Stat>
</>
)}
{summary.spend != null && (
<>
<Slash />
<span>{formatCost(summary.spend)}</span>
</>
)}
</span>
</div>
);
}
/** One 20px column per ancestor: pass-through rails for open branches, an elbow into this row's tile. */
function Gutters({ depth, guide }: { depth: number; guide: TreeGuide }) {
return Array.from({ length: depth }, (_, level) => {
const parentColumn = level === depth - 1;
const passThrough = parentColumn ? !guide.last : guide.rails[level] === true;
return (
<span key={level} aria-hidden="true" data-testid="tree-gutter" className="relative w-5 shrink-0">
{passThrough && (
<span data-guide="rail" className={cn("absolute inset-y-0 left-[calc(50%-0.5px)] w-px", LINE)} />
)}
{parentColumn && (
<span data-guide="elbow" className={cn("absolute top-0 left-[calc(50%-0.5px)] h-3.5 w-1/2", ELBOW)} />
)}
</span>
);
});
}
function TileColumn({ tile, stem }: { tile: React.ReactNode; stem: boolean }) {
return (
<span className="flex w-5 shrink-0 flex-col items-center pt-1">
<span className="relative z-raised">{tile}</span>
{stem && <span aria-hidden="true" data-guide="stem" className={cn("w-px grow", LINE)} />}
</span>
);
}
interface RowProps {
guide: TreeGuide;
selected: boolean;
traceStartMs: number;
totalMs: number;
onSelect: (id: string) => void;
onToggleSpan: (id: string) => void;
onToggleGroup: (id: string) => void;
}
function Caret({ open, onToggle, label }: { open: boolean; onToggle: () => void; label: string }) {
return (
<span
role="button"
tabIndex={-1}
aria-label={label}
className="grid size-6 shrink-0 place-items-center self-start rounded-[4px] p-0.5 text-trace-text-secondary transition-colors duration-150 hover:bg-trace-tab-active motion-reduce:transition-none"
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
>
<FoldChevron open={open} className="size-4" />
</span>
);
}
function RowBody({ children, trailing }: { children: React.ReactNode; trailing: React.ReactNode }) {
return (
<span className="ml-2 grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 py-1">
<span className="flex min-w-0 flex-col gap-1">{children}</span>
{trailing}
</span>
);
}
interface WaterfallProps {
startMs: number;
durationMs: number;
totalMs: number;
tone: string;
}
/** Thin bar showing where this span sits inside the run's timeline. */
function Waterfall({ startMs, durationMs, totalMs, tone }: WaterfallProps) {
const left = totalMs > 0 ? Math.min(99, (startMs / totalMs) * 100) : 0;
const width = totalMs > 0 ? Math.max(0.8, Math.min(100 - left, (durationMs / totalMs) * 100)) : 0.8;
return (
<span
aria-hidden="true"
data-testid="span-waterfall"
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-trace-chip"
>
<span className={cn("absolute inset-y-0 rounded-full", tone)} style={{ left: `${left}%`, width: `${width}%` }} />
</span>
);
}
const barTone = (type: string, failed: boolean): string => {
if (failed) return "bg-destructive";
if (type === "tool") return "bg-trace-tool";
if (type === "llm") return "bg-trace-llm";
return "bg-trace-chain";
};
function SpanRow({
row,
guide,
selected,
traceStartMs,
totalMs,
onSelect,
onToggleSpan,
}: RowProps & { row: SpanRowData }) {
const { span } = row;
const failed = span.status === "error";
const tokens = span.input_tokens + span.output_tokens;
const isLlm = span.type === "llm";
const duration = <span className={cn(META, "shrink-0")}>{fmtMs(span.duration_ms)}</span>;
const caret = row.hasChildren && (
<Caret open={!row.collapsed} label={row.collapsed ? "Expand" : "Collapse"} onToggle={() => onToggleSpan(row.id)} />
);
const leafDuration = !row.hasChildren && !isLlm && <span className="flex min-h-5 items-center">{duration}</span>;
return (
<SpanHoverCard facts={spanFacts(span)} traceStartMs={traceStartMs}>
<button
type="button"
role="treeitem"
aria-expanded={row.hasChildren ? !row.collapsed : undefined}
aria-selected={selected}
data-row-id={row.id}
onClick={() => onSelect(row.id)}
onDoubleClick={() => row.hasChildren && onToggleSpan(row.id)}
className={rowClass(selected)}
>
<Gutters depth={row.depth} guide={guide} />
<TileColumn tile={<SpanIcon type={span.type} model={span.model} error={failed} />} stem={guide.stem} />
<RowBody trailing={caret || leafDuration}>
<span className={cn("flex min-w-0 items-center gap-2", row.hasChildren ? "min-h-6" : "min-h-5")}>
<span
className={cn(span.type === "tool" ? MONO_NAME : NAME, failed ? "text-destructive" : "text-trace-text")}
>
{span.name}
</span>
{isLlm && span.model && (
<span className="h-4 max-w-[150px] shrink-0 truncate rounded-[3px] bg-trace-chip px-1 font-mono text-[11px] leading-4 text-trace-text-secondary">
{span.model.split("/").pop()}
</span>
)}
{(row.hasChildren || isLlm) && duration}
{isLlm && tokens > 0 && <span className={META}>{fmtTok(tokens)} tok</span>}
</span>
<Waterfall
startMs={span.start_offset_ms}
durationMs={span.duration_ms}
totalMs={totalMs}
tone={barTone(span.type, failed)}
/>
</RowBody>
</button>
</SpanHoverCard>
);
}
function GroupRow({
row,
guide,
selected,
traceStartMs,
totalMs,
onSelect,
onToggleGroup,
}: RowProps & { row: GroupRowData }) {
const groupStart = Math.min(...row.members.map((m) => m.start_offset_ms));
const groupEnd = Math.max(...row.members.map((m) => m.start_offset_ms + m.duration_ms));
return (
<SpanHoverCard facts={groupFacts(row)} traceStartMs={traceStartMs}>
<button
type="button"
role="treeitem"
aria-expanded={row.expanded}
aria-selected={selected}
data-row-id={row.id}
onClick={() => {
onSelect(row.id);
onToggleGroup(row.id);
}}
className={rowClass(selected)}
>
<Gutters depth={row.depth} guide={guide} />
<TileColumn
tile={<SpanIcon type={row.type} model={row.members[0]?.model ?? null} error={row.failedCount > 0} />}
stem={guide.stem}
/>
<RowBody
trailing={
<Caret
open={row.expanded}
label={row.expanded ? "Collapse" : "Expand"}
onToggle={() => onToggleGroup(row.id)}
/>
}
>
<span className="flex min-h-6 min-w-0 items-center gap-2">
<span
className={cn(
row.type === "tool" ? MONO_NAME : NAME,
row.isFailureGroup ? "text-destructive" : "text-trace-text",
)}
>
{row.name}
</span>
<span className={META}>×{row.members.length}</span>
<span className={META}>p50 {fmtMs(row.p50Duration)}</span>
{row.failedCount > 0 && <span className={cn(META, "text-destructive")}>{row.failedCount} failed</span>}
</span>
<Waterfall
startMs={groupStart}
durationMs={groupEnd - groupStart}
totalMs={totalMs}
tone={barTone(row.type, row.isFailureGroup)}
/>
</RowBody>
</button>
</SpanHoverCard>
);
}
/** Span tree with connector lines, a run summary on top, a framework toggle and keyboard hints. */
export function SpanTree({
rows,
spanCount,
totalMs,
summary,
selectedId,
hideFramework,
onSelect,
@ -56,8 +308,11 @@ export function SpanTree({
onToggleSpan,
onToggleGroup,
onLoadMore,
embedded = false,
}: SpanTreeProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const guides = useMemo(() => treeGuides(rows.map((row) => row.depth)), [rows]);
const traceStartMs = Date.parse(summary.start_time);
useEffect(() => {
scrollRef.current
@ -67,12 +322,10 @@ export function SpanTree({
return (
<section className="flex h-full min-h-0 min-w-0 flex-col border-r border-border bg-card" aria-label="Run spans">
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-muted/40 px-2.5">
<span className="shrink-0 font-mono text-[9px] text-muted-foreground tabular-nums">
{`${spanCount.toLocaleString()} spans`}
</span>
<label className="ml-auto flex shrink-0 cursor-pointer items-center gap-2 font-mono text-[9px] text-muted-foreground">
hide framework
<div className="mt-3.5 mr-2 mb-2 ml-4 flex h-8 shrink-0 items-center gap-1">
<span className="text-[13px] leading-[15.6px] font-semibold tracking-[-0.26px] text-trace-key">Spans</span>
<label className="ml-auto flex shrink-0 cursor-pointer items-center gap-2 text-[12px] text-muted-foreground">
Hide framework
<Switch
size="sm"
checked={hideFramework}
@ -81,34 +334,58 @@ export function SpanTree({
/>
</label>
</div>
<div
className={cn(
ROW_GRID,
"h-7 shrink-0 border-b border-border bg-muted/60 font-mono text-[8px] tracking-[0.1em] text-muted-foreground uppercase",
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto pb-4">
<Summary summary={summary} />
<div role="tree" aria-label="Spans in time order">
{rows.map((row, i) => {
if (row.kind === "load-more") {
return (
<button
key={row.id}
type="button"
onClick={() => onLoadMore(row.groupId)}
className={cn(rowClass(false), "h-8 text-[13px] tracking-[-0.26px] text-trace-text-secondary")}
>
<Gutters depth={row.depth} guide={guides[i]} />
<span className="ml-0.5 flex items-center gap-2">
<MoreHorizontal className="relative z-raised size-3.5" /> Load 20 more
<span className="text-trace-duration">({row.remaining} remaining)</span>
</span>
</button>
);
}
const shared = {
guide: guides[i],
selected: selectedId === row.id,
traceStartMs,
totalMs: summary.duration_ms,
onSelect,
onToggleSpan,
onToggleGroup,
};
return row.kind === "group" ? (
<GroupRow key={row.id} row={row} {...shared} />
) : (
<SpanRow key={row.id} row={row} {...shared} />
);
})}
</div>
</div>
<div className="flex h-8 shrink-0 items-center gap-3 border-t border-border px-3.5 text-[11px] text-muted-foreground">
{embedded ? (
<>
<span>
<Kbd>↑</Kbd>/<Kbd>↓</Kbd> step
</span>
<span>
<Kbd>J</Kbd>/<Kbd>K</Kbd> trace
</span>
</>
) : (
<span>
<Kbd>J</Kbd>/<Kbd>K</Kbd> move
</span>
)}
>
<span className="pl-6">Span</span>
<span>Timeline</span>
<span className="text-right">Time</span>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto" role="tree" aria-label="Spans in time order">
{rows.map((row) => (
<TreeRowItem
key={row.id}
row={row}
selected={selectedId === row.id}
totalMs={totalMs}
onSelect={onSelect}
onToggleSpan={onToggleSpan}
onToggleGroup={onToggleGroup}
onLoadMore={onLoadMore}
/>
))}
</div>
<div className="flex h-7 shrink-0 items-center gap-3 border-t border-border bg-muted/60 px-3 font-mono text-[8px] text-muted-foreground">
<span>
<Kbd>J</Kbd>/<Kbd>K</Kbd> move
</span>
<span>
<Kbd>←</Kbd>/<Kbd>→</Kbd> fold
</span>
@ -127,141 +404,3 @@ function Kbd({ children }: { children: React.ReactNode }) {
</kbd>
);
}
interface TreeRowItemProps {
row: TreeRow;
selected: boolean;
totalMs: number;
onSelect: (id: string) => void;
onToggleSpan: (id: string) => void;
onToggleGroup: (id: string) => void;
onLoadMore: (groupId: string) => void;
}
function TreeRowItem({ row, selected, totalMs, onSelect, onToggleSpan, onToggleGroup, onLoadMore }: TreeRowItemProps) {
if (row.kind === "load-more") {
return (
<button
type="button"
onClick={() => onLoadMore(row.groupId)}
className="flex h-8 w-full items-center gap-2 border-b border-border/60 px-2 font-mono text-[9px] text-foreground hover:bg-muted/60"
style={{ paddingLeft: `${18 + row.depth * INDENT_PX}px` }}
>
<MoreHorizontal className="size-3" /> load 20 more{" "}
<span className="text-muted-foreground">({row.remaining} remaining)</span>
</button>
);
}
if (row.kind === "group") {
const start = Math.min(...row.members.map((m) => m.start_offset_ms));
const end = Math.max(...row.members.map((m) => m.start_offset_ms + m.duration_ms));
return (
<button
type="button"
role="treeitem"
aria-expanded={row.expanded}
aria-selected={selected}
data-row-id={row.id}
onClick={() => {
onSelect(row.id);
onToggleGroup(row.id);
}}
className={rowClass(selected)}
>
<div className="flex min-w-0 items-center" style={{ paddingLeft: `${row.depth * INDENT_PX}px` }}>
{row.expanded ? (
<ChevronDown className="mr-0.5 size-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="mr-0.5 size-3 shrink-0 text-muted-foreground" />
)}
<TypeIcon type={row.type} error={row.failedCount > 0} />
<span
className={cn(
"ml-1.5 truncate font-mono text-[10px]",
row.isFailureGroup ? "text-destructive" : "text-foreground",
)}
>
{row.name}
</span>
<span className="ml-1 font-mono text-[9px] text-muted-foreground">×{row.members.length}</span>
<span className="ml-2 hidden truncate font-mono text-[8px] text-muted-foreground xl:block">
p50={fmtMs(row.p50Duration)}
</span>
{row.failedCount > 0 && (
<span className="ml-2 shrink-0 font-mono text-[8px] text-destructive">{row.failedCount} failed</span>
)}
</div>
<DurationBar startMs={start} durationMs={end - start} totalMs={totalMs} error={row.isFailureGroup} />
<span className="text-right font-mono text-[9px] text-muted-foreground tabular-nums">
{fmtMs(row.p50Duration)}
</span>
</button>
);
}
const { span } = row;
const failed = span.status === "error";
return (
<button
type="button"
role="treeitem"
aria-expanded={row.hasChildren ? !row.collapsed : undefined}
aria-selected={selected}
data-row-id={row.id}
onClick={() => onSelect(row.id)}
onDoubleClick={() => row.hasChildren && onToggleSpan(row.id)}
className={rowClass(selected)}
>
<div className="flex min-w-0 items-center" style={{ paddingLeft: `${row.depth * INDENT_PX}px` }}>
{row.hasChildren ? (
<span
role="button"
tabIndex={-1}
aria-label={row.collapsed ? "Expand" : "Collapse"}
className="mr-0.5 shrink-0"
onClick={(event) => {
event.stopPropagation();
onToggleSpan(row.id);
}}
>
{row.collapsed ? (
<ChevronRight className="size-3 text-muted-foreground" />
) : (
<ChevronDown className="size-3 text-muted-foreground" />
)}
</span>
) : (
<CornerDownRight className="mr-0.5 size-3 shrink-0 text-muted-foreground/50" />
)}
<TypeIcon type={span.type} error={failed} />
<span className={cn("ml-1.5 truncate font-mono text-[10px]", failed ? "text-destructive" : "text-foreground")}>
{span.name}
</span>
{span.model && (
<span className="ml-2 hidden truncate font-mono text-[8px] text-muted-foreground 2xl:block">
{span.model.split("/").pop()}
</span>
)}
</div>
<DurationBar startMs={span.start_offset_ms} durationMs={span.duration_ms} totalMs={totalMs} error={failed} />
<span
className={cn(
"text-right font-mono text-[9px] tabular-nums",
failed ? "text-destructive" : "text-muted-foreground",
)}
>
{fmtMs(span.duration_ms)}
</span>
</button>
);
}
function TypeIcon({ type, error }: { type: SpanType; error: boolean }) {
const className = cn("size-3 shrink-0", error ? "text-destructive" : "text-muted-foreground");
if (type === "agent") return <Bot className={className} />;
if (type === "llm") return <BrainCircuit className={className} />;
if (type === "tool") return <Wrench className={className} />;
if (type === "framework") return <Network className={className} />;
return <CircleDot className={className} />;
}

View file

@ -83,6 +83,22 @@ describe("RunView", () => {
expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
});
it("inside the drawer moves spans with the arrow keys and leaves J / K to switch runs", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceCall).mockResolvedValue(research);
renderWithProviders(
<RunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} embedded />,
);
const root = rootSpanId(research);
expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
await user.keyboard("j");
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
await user.keyboard("{ArrowDown}");
expect(screen.getByTestId("detail-pane").getAttribute("data-row-id")).not.toBe(root);
expect(screen.queryByRole("button", { name: "Back to runs" })).not.toBeInTheDocument();
});
it("moves the selection with J / K and closes the detail pane with Esc", async () => {
const user = userEvent.setup();
renderRun(research);

View file

@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Check, Circle, Copy } from "lucide-react";
import { ArrowLeft, Check, Copy } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
@ -11,7 +11,9 @@ import { copyToClipboard } from "@/utils/dataUtils";
import { agentTraceCall, getProxyBaseUrl } from "../../networking";
import { DetailPane } from "./DetailPane";
import { IdChip } from "./IdChip";
import { formatCost } from "./AgentTracesTable";
import { SpanIcon } from "./SpanIcon";
import { SpanTree } from "./SpanTree";
import type { SpanTreeState, TreeRow } from "./traceTree";
import type { Trace } from "./traceTypes";
@ -80,7 +82,7 @@ function CopyForAgent({ traceId, traceRef }: { traceId: string; traceRef?: strin
<Button
variant="outline"
size="xs"
className="shrink-0 gap-1.5 rounded-[4px] font-mono text-[10px] shadow-none"
className="h-7 shrink-0 gap-1.5 rounded-md text-[12px] shadow-none"
onClick={async () =>
setCopied(await copyToClipboard(agentHandoffText(traceId, null, traceRef), "Command copied"))
}
@ -93,40 +95,40 @@ function CopyForAgent({ traceId, traceRef }: { traceId: string; traceRef?: strin
function Stat({ label, value, error = false }: { label: string; value: string; error?: boolean }) {
return (
<span className={cn("shrink-0", error && "text-destructive")}>
<span className="text-muted-foreground/70">{label} </span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-[5px] border border-border bg-card px-1.5 py-px text-[12px] tabular-nums",
error && "border-destructive/40 bg-destructive/10 text-destructive",
)}
>
<span className={cn("text-muted-foreground", error && "text-destructive/80")}>{label} </span>
{value}
</span>
);
}
function RunHeader({ trace, onBack }: { trace: Trace; onBack: () => void }) {
function RunHeader({ trace, onBack, embedded }: { trace: Trace; onBack: () => void; embedded: boolean }) {
const { summary } = trace;
const failed = summary.error_count > 0;
return (
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-card px-2">
<button
type="button"
onClick={onBack}
className="grid size-7 shrink-0 place-items-center rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Back to runs"
>
<ArrowLeft className="size-4" />
</button>
<Circle className={cn("size-2 shrink-0 fill-current", failed ? "text-destructive" : "text-muted-foreground")} />
<h1 className="shrink-0 truncate text-[14px] font-medium text-foreground">{traceDisplayName(summary)}</h1>
<span className="flex min-w-0 items-center gap-1 font-mono text-[11px] text-muted-foreground">
<span className="truncate">{summary.trace_id}</span>
<button
type="button"
aria-label="Copy trace ID"
className="shrink-0 hover:text-foreground"
onClick={() => void copyToClipboard(summary.trace_id, "Trace ID copied")}
>
<Copy className="size-3" />
</button>
</span>
<div className="flex min-w-0 items-center gap-4 font-mono text-[11px] text-foreground tabular-nums">
<header className="flex min-h-11 shrink-0 flex-wrap items-center gap-x-2 gap-y-1.5 border-b border-border bg-card px-3 py-1.5">
{!embedded && (
<>
<button
type="button"
onClick={onBack}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Back to runs"
>
<ArrowLeft className="size-4" />
</button>
<span className="mx-1 h-[18px] w-px bg-border" />
</>
)}
<SpanIcon type="agent" error={failed} size="lg" />
<h1 className="min-w-0 truncate text-[14px] font-medium text-foreground">{traceDisplayName(summary)}</h1>
<IdChip value={summary.trace_id} label="Copy trace ID" showValue />
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<Stat label="duration" value={fmtMs(summary.duration_ms)} />
<Stat label="steps" value={summary.span_count.toLocaleString()} />
<Stat label="cost" value={summary.spend == null ? "—" : formatCost(summary.spend)} />
@ -140,7 +142,18 @@ function RunHeader({ trace, onBack }: { trace: Trace; onBack: () => void }) {
}
/** Tree + detail pane for one loaded run, with J/K/arrow keyboard navigation. */
function RunBody({ trace, accessToken, initialSpanId }: { trace: Trace; accessToken: string; initialSpanId?: string }) {
const SPAN_KEYS = { down: ["j", "J", "ArrowDown"], up: ["k", "K", "ArrowUp"] } as const;
const EMBEDDED_SPAN_KEYS = { down: ["ArrowDown"], up: ["ArrowUp"] } as const;
interface RunBodyProps {
trace: Trace;
accessToken: string;
initialSpanId?: string;
embedded: boolean;
}
function RunBody({ trace, accessToken, initialSpanId, embedded }: RunBodyProps) {
const spanKeys = embedded ? EMBEDDED_SPAN_KEYS : SPAN_KEYS;
const initial = useMemo(() => initialRunSelection(trace, initialSpanId), [trace, initialSpanId]);
const [state, setState] = useState<SpanTreeState>(initial.state);
const [selectedId, setSelectedId] = useState<string>(initial.selectedId);
@ -184,11 +197,11 @@ function RunBody({ trace, accessToken, initialSpanId }: { trace: Trace; accessTo
setDetailOpen(false);
return;
}
if (["j", "J", "ArrowDown"].includes(event.key)) {
if ((spanKeys.down as readonly string[]).includes(event.key)) {
event.preventDefault();
const next = rows[Math.min(rows.length - 1, index + 1)];
if (next) select(next.id);
} else if (["k", "K", "ArrowUp"].includes(event.key)) {
} else if ((spanKeys.up as readonly string[]).includes(event.key)) {
event.preventDefault();
const next = rows[Math.max(0, index - 1)];
if (next) select(next.id);
@ -202,21 +215,20 @@ function RunBody({ trace, accessToken, initialSpanId }: { trace: Trace; accessTo
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [rows, selectedRow, detailOpen, select, toggleSpan, toggleGroup]);
}, [rows, selectedRow, detailOpen, select, toggleSpan, toggleGroup, spanKeys]);
return (
<div
className={cn(
"grid min-h-0 flex-1",
detailOpen
? "grid-cols-1 grid-rows-2 lg:grid-cols-[minmax(0,3fr)_minmax(360px,2fr)] lg:grid-rows-1 2xl:grid-cols-[minmax(0,2fr)_minmax(420px,1fr)]"
? "grid-cols-1 grid-rows-2 lg:grid-cols-[minmax(340px,400px)_minmax(0,1fr)] lg:grid-rows-1"
: "grid-cols-1",
)}
>
<SpanTree
rows={rows}
spanCount={trace.summary.span_count}
totalMs={trace.summary.duration_ms}
summary={trace.summary}
selectedId={selectedRow?.id ?? selectedId}
hideFramework={state.hideFramework}
onSelect={select}
@ -224,9 +236,12 @@ function RunBody({ trace, accessToken, initialSpanId }: { trace: Trace; accessTo
onToggleSpan={toggleSpan}
onToggleGroup={toggleGroup}
onLoadMore={loadMore}
embedded={embedded}
/>
{detailOpen && (
<DetailPane trace={trace} row={selectedRow} accessToken={accessToken} onClose={() => setDetailOpen(false)} />
<div className="min-h-0 min-w-0 animate-slide-left motion-reduce:animate-none">
<DetailPane trace={trace} row={selectedRow} accessToken={accessToken} onClose={() => setDetailOpen(false)} />
</div>
)}
</div>
);
@ -238,10 +253,12 @@ interface RunViewProps {
initialSpanId?: string;
accessToken: string;
onBack: () => void;
/** Rendered inside the side drawer: the drawer owns closing and sizing. */
embedded?: boolean;
}
/** One agent run: header with totals and "Copy for agent", span tree on the left, span details on the right. */
export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack }: RunViewProps) {
export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack, embedded = false }: RunViewProps) {
const traceQuery = useQuery({
queryKey: ["agentTrace", traceId, traceRef, accessToken],
queryFn: () => agentTraceCall(accessToken, traceId, traceRef),
@ -251,8 +268,18 @@ export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack
if (traceQuery.isLoading) {
return (
<div role="status" aria-label="Loading trace" className="flex h-[60vh] items-center justify-center">
<UiLoadingSpinner className="size-6 text-muted-foreground" />
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
}
@ -273,11 +300,20 @@ export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack
}
return (
<div
className="flex min-h-[560px] flex-1 flex-col overflow-hidden border-y border-border bg-background"
className={cn(
"flex flex-1 flex-col overflow-hidden bg-background",
embedded ? "min-h-0 animate-view-fade-in motion-reduce:animate-none" : "min-h-[560px] border-y border-border",
)}
data-testid="run-view"
>
<RunHeader trace={trace} onBack={onBack} />
<RunBody key={trace.summary.trace_id} trace={trace} accessToken={accessToken} initialSpanId={initialSpanId} />
<RunHeader trace={trace} onBack={onBack} embedded={embedded} />
<RunBody
key={trace.summary.trace_id}
trace={trace}
accessToken={accessToken}
initialSpanId={initialSpanId}
embedded={embedded}
/>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { costMapLookup, resolveSpanProvider } from "./spanProvider";
const costMap = {
"gpt-6.1-sol": { litellm_provider: "openai" },
"claude-sonnet-5-5": { litellm_provider: "anthropic" },
"no-provider-model": { input_cost_per_token: 1 },
};
const lookup = costMapLookup(costMap);
describe("resolveSpanProvider", () => {
it("uses a known provider prefix without consulting the cost map", () => {
expect(resolveSpanProvider("anthropic/unlisted-model", costMapLookup({}))).toBe("anthropic");
});
it("looks the model up in the cost map", () => {
expect(resolveSpanProvider("gpt-6.1-sol", lookup)).toBe("openai");
});
it("falls back to the bare model name when the prefix is not a provider", () => {
expect(resolveSpanProvider("my-team/claude-sonnet-5-5", lookup)).toBe("anthropic");
});
it("returns null for unknown models, missing providers and empty input", () => {
expect(resolveSpanProvider("totally-unknown", lookup)).toBeNull();
expect(resolveSpanProvider("no-provider-model", lookup)).toBeNull();
expect(resolveSpanProvider(null, lookup)).toBeNull();
});
it("tolerates a cost map that is not an object", () => {
expect(resolveSpanProvider("gpt-6.1-sol", costMapLookup(undefined))).toBeNull();
expect(resolveSpanProvider("gpt-6.1-sol", costMapLookup("oops"))).toBeNull();
});
});

View file

@ -0,0 +1,34 @@
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
export type ProviderLookup = (model: string) => string | undefined;
const hasKnownLogo = (provider: string): boolean => getProviderLogoAndName(provider).logo !== "";
const costMapProvider = (costMap: unknown, model: string): string | undefined => {
if (typeof costMap !== "object" || costMap === null) return undefined;
const entry: unknown = Reflect.get(costMap, model);
if (typeof entry !== "object" || entry === null) return undefined;
const provider: unknown = Reflect.get(entry, "litellm_provider");
return typeof provider === "string" && provider ? provider : undefined;
};
export const costMapLookup =
(costMap: unknown): ProviderLookup =>
(model) =>
costMapProvider(costMap, model);
/** `openai/gpt-x` → "openai"; otherwise the cost map's provider for the full name, then the bare model. */
export function resolveSpanProvider(model: string | null, lookup: ProviderLookup): string | null {
if (!model) return null;
const slash = model.indexOf("/");
const prefix = slash > 0 ? model.slice(0, slash) : "";
if (prefix && hasKnownLogo(prefix)) return prefix;
const bare = model.slice(model.lastIndexOf("/") + 1);
return lookup(model) ?? lookup(bare) ?? null;
}
export function useSpanProvider(model: string | null): string | null {
const { data } = useModelCostMap();
return resolveSpanProvider(model, costMapLookup(data));
}

View file

@ -72,11 +72,40 @@ export interface TracePage {
next_cursor: string | null;
}
/** `input` / `output` are JSON strings (messages for llm spans, raw args / result for tools). */
/** Mirrors `litellm/tracing/ui_format.py`: span content already reduced to what the UI renders. */
export interface UIToolCall {
name: string;
/** JSON-encoded arguments. */
arguments: string;
}
export interface UIMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
tool_calls?: UIToolCall[];
}
export interface UIField {
key: string;
value: string;
}
export type UIContent =
| { kind: "messages"; messages: UIMessage[] }
| { kind: "fields"; fields: UIField[] }
| { kind: "text"; text: string };
/**
* `input` / `output` are the raw stored JSON strings. `input_ui` / `output_ui` are the
* standard rendering; optional because older proxies don't send them.
*/
export interface SpanDetail {
span_id: string;
input: string;
output: string;
input_ui?: UIContent;
output_ui?: UIContent;
attributes: Record<string, string>;
}

View file

@ -15,10 +15,12 @@ import {
groupRowId,
isFrameworkSpan,
median,
messageText,
parseMessages,
previewText,
revealSpanInState,
ROOT_KEY,
treeGuides,
} from "./traceUtils";
const swarm = swarmTrace as Trace;
@ -71,6 +73,13 @@ describe("formatting", () => {
expect(previewText("plain text")).toBe("plain text");
});
it("decodes JSON escapes in a truncated preview and treats an empty array as no preview", () => {
expect(previewText('[{"role": "user", "content": "Thanks\\u2014keep the \\u00a55,000 budget\\nplease \\u20')).toBe(
"Thanks—keep the ¥5,000 budget please ",
);
expect(previewText("[]")).toBe("");
});
it("pulls the user message out of an OpenInference LangChain input", () => {
const input =
'{"messages": [{"type": "human", "data": {"content": "Customer acme-7 keeps hitting 429s", "type": "human"';
@ -266,4 +275,36 @@ describe("payload helpers", () => {
expect(parseMessages('{"file_path":"/tmp/x"}')).toBeNull();
expect(parseMessages("not json")).toBeNull();
});
it("shows block-list message content as its text and drops reasoning blocks", () => {
const reasoning = { type: "reasoning", summary: [], encrypted_content: "gAAAAB-opaque" };
const content = JSON.stringify([reasoning, { type: "text", text: "Part one" }, { type: "text", text: "Part two" }]);
const [message] = parseMessages(JSON.stringify({ role: "assistant", content })) ?? [];
expect(message.content).toBe("Part one\n\nPart two");
expect(messageText(JSON.stringify([reasoning]))).toBe("");
const image = JSON.stringify([{ type: "image_url", image_url: { url: "https://x.test/a.png" } }]);
expect(messageText(image)).toBe(image);
expect(messageText("[not json")).toBe("[not json");
});
});
describe("treeGuides", () => {
it("draws a rail only for ancestors that still have later siblings", () => {
const guides = treeGuides([0, 1, 2, 2, 1, 2, 3]);
expect(guides.map((g) => g.last)).toEqual([true, false, false, true, true, true, true]);
expect(guides[2].rails).toEqual([true]);
expect(guides[5].rails).toEqual([false]);
expect(guides[6].rails).toEqual([false, false]);
});
it("treats a sibling after a deeper subtree as continuing the branch", () => {
const guides = treeGuides([0, 1, 2, 3, 1]);
expect(guides[1].last).toBe(false);
expect(guides[3].rails).toEqual([true, false]);
});
it("drops a stem from a row only when the next row is its child", () => {
const guides = treeGuides([0, 1, 2, 1, 1, 0]);
expect(guides.map((g) => g.stem)).toEqual([true, true, false, false, false, false]);
});
});

View file

@ -272,11 +272,37 @@ const isMessage = (value: unknown): value is TraceMessage => {
return isObject && "role" in value && typeof (value as TraceMessage).role === "string";
};
const blockText = (block: unknown): string | null => {
if (typeof block !== "object" || block === null) return null;
const text: unknown = Reflect.get(block, "text");
return typeof text === "string" ? text : null;
};
const NON_TEXT_BLOCKS: ReadonlySet<unknown> = new Set(["reasoning", "function_call", "tool_use", "tool_call"]);
const blockType = (block: unknown): unknown =>
typeof block === "object" && block !== null ? Reflect.get(block, "type") : undefined;
const isKnownBlock = (block: unknown): boolean => blockText(block) !== null || NON_TEXT_BLOCKS.has(blockType(block));
/** Block-list content (reasoning / function_call / text blocks) as its text; unknown content unchanged. */
export function messageText(content: string): string {
if (!content.startsWith("[")) return content;
const blocks = parseJson(content);
if (!Array.isArray(blocks) || blocks.length === 0 || !blocks.every(isKnownBlock)) return content;
return blocks
.map(blockText)
.filter((text): text is string => text !== null)
.join("\n\n");
}
const withText = (message: TraceMessage): TraceMessage => ({ ...message, content: messageText(message.content) });
/** An llm span's input (array of messages) or output (one message); null when it isn't one. */
export function parseMessages(value: string): TraceMessage[] | null {
const parsed = parseJson(value);
if (Array.isArray(parsed)) return parsed.every(isMessage) ? parsed : null;
return isMessage(parsed) ? [parsed] : null;
if (Array.isArray(parsed)) return parsed.every(isMessage) ? parsed.map(withText) : null;
return isMessage(parsed) ? [withText(parsed)] : null;
}
/** Pretty JSON when the payload is JSON, else the raw string. */
@ -297,18 +323,56 @@ const PREVIEW_USER_CONTENT =
* Human text for an input preview. Previews are often a (possibly truncated) JSON
* message array; show the last user/tool message's content when we can find it.
*/
const decodeJsonString = (escaped: string): string => {
const complete = escaped.replace(/\\u[0-9a-fA-F]{0,3}$|\\$/, "");
const parsed = parseJson(`"${complete}"`);
return typeof parsed === "string" ? parsed : complete;
};
export function previewText(preview: string): string {
if (!preview) return "";
if (!preview || /^\s*\[\s*\]\s*$/.test(preview)) return "";
const messages = parseMessages(preview);
if (messages) {
const last = [...messages].reverse().find((m) => m.role === "user" || m.role === "tool") ?? messages.at(-1);
return last?.content || preview;
return last?.content || "";
}
if (!/^\s*[[{]/.test(preview)) return preview;
const match = PREVIEW_USER_CONTENT.exec(preview);
return match ? match[1].replace(/\\n/g, " ").replace(/\\"/g, '"') : preview;
return match ? decodeJsonString(match[1]).replace(/\s+/g, " ") : preview;
}
/** Trace display name; root spans without a name fall back to the service. */
export const traceDisplayName = (summary: Pick<TraceSummary, "name" | "service">): string =>
summary.name || summary.service || "(unnamed trace)";
/* ------------------------------------------------------------------ */
/* Tree connector guides */
/* ------------------------------------------------------------------ */
export interface TreeGuide {
/** Per ancestor level 1..depth-1: whether that ancestor has a later sibling, so its rail continues. */
rails: readonly boolean[];
/** Last child of its parent: the elbow ends here instead of continuing down. */
last: boolean;
/** The next row is this row's child, so a stem runs down from this row's tile. */
stem: boolean;
}
/** Rail / elbow / stem flags for each row from row depths alone, in one backward pass. */
export function treeGuides(depths: readonly number[]): TreeGuide[] {
const levels: boolean[] = [];
return depths
.map((depth, i) => ({ depth, i }))
.reverse()
.map(({ depth, i }) => {
const guide: TreeGuide = {
rails: Array.from({ length: Math.max(0, depth - 1) }, (_, k) => levels[k + 1] === true),
last: levels[depth] !== true,
stem: (depths[i + 1] ?? -1) > depth,
};
levels.length = depth;
levels[depth] = true;
return guide;
})
.reverse();
}