mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
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:
parent
73072b8643
commit
d24240c014
42 changed files with 3185 additions and 662 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
32
litellm/tracing/normalizers/__init__.py
Normal file
32
litellm/tracing/normalizers/__init__.py
Normal 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",
|
||||
)
|
||||
22
litellm/tracing/normalizers/base.py
Normal file
22
litellm/tracing/normalizers/base.py
Normal 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
|
||||
31
litellm/tracing/normalizers/genai.py
Normal file
31
litellm/tracing/normalizers/genai.py
Normal 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", "")
|
||||
115
litellm/tracing/normalizers/langsmith.py
Normal file
115
litellm/tracing/normalizers/langsmith.py
Normal 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)
|
||||
59
litellm/tracing/normalizers/messages.py
Normal file
59
litellm/tracing/normalizers/messages.py
Normal 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
|
||||
27
litellm/tracing/normalizers/openinference.py
Normal file
27
litellm/tracing/normalizers/openinference.py
Normal 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"))
|
||||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
||||
|
||||
|
|
|
|||
158
litellm/tracing/ui_format.py
Normal file
158
litellm/tracing/ui_format.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
68
tests/test_litellm/tracing/normalizers/test_registry.py
Normal file
68
tests/test_litellm/tracing/normalizers/test_registry.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
||||
|
|
|
|||
119
tests/test_litellm/tracing/test_ui_format.py
Normal file
119
tests/test_litellm/tracing/test_ui_format.py
Normal 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": ""}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue