Show execution traces in the Logs UI

Expanded Logs rows show the trace's duration, LLM calls, tokens, tool calls
and retrieval time, and open a side panel with a waterfall of every step:
retrieval, embeddings, per-source searches, model calls and tool calls,
with each step's details, previews and raw attributes. Chat turns paused
for approval show one waterfall per round. Searches and graph builds get
their own log types. Strings are translated in every locale.
This commit is contained in:
arc53-machine committed 2026-09-23 17:45:19 +01:00
1 parent 26881ba66b
commit 3310b7fa1b
19 files changed
+1921 -19

No files matched your search

+1
View File
@@ -57,6 +57,7 @@ const endpoints = {
TOOL_ANALYTICS: '/api/get_tool_analytics',
SCHEDULE_ANALYTICS: '/api/get_schedule_analytics',
LOGS: `/api/get_user_logs`,
TRACES: (params: string) => `/api/traces?${params}`,
MANAGE_SYNC: '/api/manage_sync',
SYNC_SOURCE: '/api/sync_source',
REINGEST_SOURCE: '/api/sources/reingest',
+11
View File
@@ -109,6 +109,17 @@ const userService = {
apiClient.post(endpoints.USER.SCHEDULE_ANALYTICS, data, token),
getLogs: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.LOGS, data, token),
getTraces: (
params: Record<string, string>,
token: string | null,
signal?: AbortSignal,
): Promise<any> =>
apiClient.get(
endpoints.USER.TRACES(new URLSearchParams(params).toString()),
token,
{},
signal,
),
manageSync: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.MANAGE_SYNC, data, token),
syncSource: (data: any, token: string | null): Promise<any> =>
+1 -1
View File
@@ -333,7 +333,7 @@ function InlineToolCallChip({
);
}
function ToolCallPanel({
export function ToolCallPanel({
title,
copyText,
children,
+109 -2
View File
@@ -411,7 +411,9 @@
"schedule": "Geplant",
"webhook": "Webhook",
"workflow": "Workflow",
"system": "System"
"system": "System",
"search": "Suche",
"graph": "Graph-Aufbau"
},
"detail": {
"agent": "Agent",
@@ -431,7 +433,112 @@
"workflow": "Workflow",
"activity": "Aktivität",
"steps": "Schritte",
"result": "Ergebnis"
"result": "Ergebnis",
"source": "Quelle"
},
"trace": {
"view": "Trace anzeigen",
"viewRounds_one": "Trace anzeigen ({{count}} Runde)",
"viewRounds_other": "Trace anzeigen ({{count}} Runden)",
"title": "Ausführungs-Trace",
"subtitle": "Jeder Schritt hinter diesem Eintrag – Abruf, Modellaufrufe und Tool-Aufrufe – mit seiner Dauer.",
"empty": "Für diesen Eintrag wurde kein Trace aufgezeichnet.",
"failed": "Der Trace konnte nicht geladen werden.",
"noSpans": "Für diese Ausführung wurde nichts aufgezeichnet.",
"round": "Runde {{n}}",
"step": "Schritt",
"toggleChildren": "Verschachtelte Schritte ein- oder ausblenden",
"droppedSpans_one": "{{count}} weiterer Schritt wurde nicht aufgezeichnet (Größenlimit des Traces).",
"droppedSpans_other": "{{count}} weitere Schritte wurden nicht aufgezeichnet (Größenlimit des Traces).",
"chips": {
"llmCalls_one": "{{count}} LLM-Aufruf",
"llmCalls_other": "{{count}} LLM-Aufrufe",
"tokens": "{{input}} ein / {{output}} aus",
"toolCalls_one": "{{count}} Tool-Aufruf",
"toolCalls_other": "{{count}} Tool-Aufrufe",
"retrieval": "RAG {{duration}}",
"errors_one": "{{count}} Fehler",
"errors_other": "{{count}} Fehler"
},
"headline": {
"tokens": "{{input}} → {{output}} Tok.",
"chunks_one": "{{count}} Abschnitt",
"chunks_other": "{{count}} Abschnitte",
"hits_one": "{{count}} Treffer",
"hits_other": "{{count}} Treffer",
"cached": "aus Cache"
},
"status": {
"ok": "OK",
"error": "Fehler",
"cancelled": "Abgebrochen",
"paused": "Pausiert",
"pending": "Wartet auf Freigabe",
"denied": "Abgelehnt",
"skipped": "Übersprungen"
},
"kinds": {
"agent": "Agent",
"llm": "LLM-Aufruf",
"tool": "Tool-Aufruf",
"retrieval": "Abruf",
"search": "Vektorsuche",
"embedding": "Embedding",
"rerank": "Neusortierung",
"guardrail": "Guardrail",
"step": "Schritt"
},
"sources": {
"stream": "Chat",
"answer": "Antwort-API",
"v1": "OpenAI-kompatible API",
"webhook": "Webhook",
"schedule": "Geplante Ausführung",
"search": "Such-API",
"mcp": "MCP-Suche",
"graph_extraction": "Graph-Aufbau",
"headless": "Hintergrundausführung"
},
"fields": {
"status": "Status",
"duration": "Dauer",
"provider": "Anbieter",
"model": "Modell",
"inputTokens": "Eingabe-Tokens",
"outputTokens": "Ausgabe-Tokens",
"cachedTokens": "Gecachte Tokens",
"timeToFirstToken": "Zeit bis zum ersten Token",
"cost": "Kosten",
"tokenSource": "Zweck",
"cacheHit": "Aus dem Cache",
"estimatedUsage": "Geschätzte Tokens",
"yes": "Ja",
"tool": "Tool",
"action": "Aktion",
"callId": "Aufruf-ID",
"retriever": "Retriever",
"sources": "Quellen",
"topK": "Top K",
"chunks": "Abschnitte",
"candidates": "Kandidaten",
"topScore": "Beste Bewertung",
"vectorStore": "Vektorspeicher",
"agentType": "Agententyp",
"toolCalls": "Tool-Aufrufe",
"stage": "Phase",
"checks": "Prüfungen",
"triggered": "Ausgelöst",
"blocked": "Blockiert",
"redacted": "Geschwärzt",
"nodeType": "Knotentyp",
"phase": "Abschnitt",
"query": "Anfrage",
"arguments": "Argumente",
"result": "Ergebnis",
"output": "Ausgabe",
"retrievedChunks": "Abgerufene Abschnitte",
"allAttributes": "Alle Attribute"
}
}
},
"teams": {
+109 -2
View File
@@ -416,7 +416,9 @@
"schedule": "Scheduled",
"webhook": "Webhook",
"workflow": "Workflow",
"system": "System"
"system": "System",
"search": "Search",
"graph": "Graph build"
},
"detail": {
"agent": "Agent",
@@ -436,7 +438,112 @@
"workflow": "Workflow",
"activity": "Activity",
"steps": "Steps",
"result": "Result"
"result": "Result",
"source": "Source"
},
"trace": {
"view": "View trace",
"viewRounds_one": "View trace ({{count}} round)",
"viewRounds_other": "View trace ({{count}} rounds)",
"title": "Execution trace",
"subtitle": "Every step behind this entry — retrieval, model calls and tool calls — with its timing.",
"empty": "No trace was recorded for this entry.",
"failed": "Could not load the trace.",
"noSpans": "Nothing was recorded for this run.",
"round": "Round {{n}}",
"step": "Step",
"toggleChildren": "Show or hide nested steps",
"droppedSpans_one": "{{count}} more step was not recorded (trace size limit).",
"droppedSpans_other": "{{count}} more steps were not recorded (trace size limit).",
"chips": {
"llmCalls_one": "{{count}} LLM call",
"llmCalls_other": "{{count}} LLM calls",
"tokens": "{{input}} in / {{output}} out",
"toolCalls_one": "{{count}} tool call",
"toolCalls_other": "{{count}} tool calls",
"retrieval": "RAG {{duration}}",
"errors_one": "{{count}} error",
"errors_other": "{{count}} errors"
},
"headline": {
"tokens": "{{input}} → {{output}} tok",
"chunks_one": "{{count}} chunk",
"chunks_other": "{{count}} chunks",
"hits_one": "{{count}} hit",
"hits_other": "{{count}} hits",
"cached": "cached"
},
"status": {
"ok": "OK",
"error": "Error",
"cancelled": "Cancelled",
"paused": "Paused",
"pending": "Awaiting approval",
"denied": "Denied",
"skipped": "Skipped"
},
"kinds": {
"agent": "Agent",
"llm": "LLM call",
"tool": "Tool call",
"retrieval": "Retrieval",
"search": "Vector search",
"embedding": "Embedding",
"rerank": "Rerank",
"guardrail": "Guardrail",
"step": "Step"
},
"sources": {
"stream": "Chat",
"answer": "Answer API",
"v1": "OpenAI-compatible API",
"webhook": "Webhook",
"schedule": "Scheduled run",
"search": "Search API",
"mcp": "MCP search",
"graph_extraction": "Graph build",
"headless": "Background run"
},
"fields": {
"status": "Status",
"duration": "Duration",
"provider": "Provider",
"model": "Model",
"inputTokens": "Input tokens",
"outputTokens": "Output tokens",
"cachedTokens": "Cached tokens",
"timeToFirstToken": "Time to first token",
"cost": "Cost",
"tokenSource": "Purpose",
"cacheHit": "Served from cache",
"estimatedUsage": "Estimated tokens",
"yes": "Yes",
"tool": "Tool",
"action": "Action",
"callId": "Call ID",
"retriever": "Retriever",
"sources": "Sources",
"topK": "Top K",
"chunks": "Chunks",
"candidates": "Candidates",
"topScore": "Top score",
"vectorStore": "Vector store",
"agentType": "Agent type",
"toolCalls": "Tool calls",
"stage": "Stage",
"checks": "Checks",
"triggered": "Triggered",
"blocked": "Blocked",
"redacted": "Redacted",
"nodeType": "Node type",
"phase": "Phase",
"query": "Query",
"arguments": "Arguments",
"result": "Result",
"output": "Output",
"retrievedChunks": "Retrieved chunks",
"allAttributes": "All attributes"
}
}
},
"teams": {
+109 -2
View File
@@ -411,7 +411,9 @@
"schedule": "Programado",
"webhook": "Webhook",
"workflow": "Flujo de trabajo",
"system": "Sistema"
"system": "Sistema",
"search": "Búsqueda",
"graph": "Creación de grafo"
},
"detail": {
"agent": "Agente",
@@ -431,7 +433,112 @@
"workflow": "Flujo de trabajo",
"activity": "Actividad",
"steps": "Pasos",
"result": "Resultado"
"result": "Resultado",
"source": "Origen"
},
"trace": {
"view": "Ver traza",
"viewRounds_one": "Ver traza ({{count}} ronda)",
"viewRounds_other": "Ver traza ({{count}} rondas)",
"title": "Traza de ejecución",
"subtitle": "Cada paso detrás de esta entrada (recuperación, llamadas al modelo y llamadas a herramientas) con su duración.",
"empty": "No se registró ninguna traza para esta entrada.",
"failed": "No se pudo cargar la traza.",
"noSpans": "No se registró nada en esta ejecución.",
"round": "Ronda {{n}}",
"step": "Paso",
"toggleChildren": "Mostrar u ocultar pasos anidados",
"droppedSpans_one": "{{count}} paso más no se registró (límite de tamaño de la traza).",
"droppedSpans_other": "{{count}} pasos más no se registraron (límite de tamaño de la traza).",
"chips": {
"llmCalls_one": "{{count}} llamada al LLM",
"llmCalls_other": "{{count}} llamadas al LLM",
"tokens": "{{input}} entrada / {{output}} salida",
"toolCalls_one": "{{count}} llamada a herramienta",
"toolCalls_other": "{{count}} llamadas a herramientas",
"retrieval": "RAG {{duration}}",
"errors_one": "{{count}} error",
"errors_other": "{{count}} errores"
},
"headline": {
"tokens": "{{input}} → {{output}} tok",
"chunks_one": "{{count}} fragmento",
"chunks_other": "{{count}} fragmentos",
"hits_one": "{{count}} resultado",
"hits_other": "{{count}} resultados",
"cached": "en caché"
},
"status": {
"ok": "OK",
"error": "Error",
"cancelled": "Cancelado",
"paused": "En pausa",
"pending": "Esperando aprobación",
"denied": "Denegado",
"skipped": "Omitido"
},
"kinds": {
"agent": "Agente",
"llm": "Llamada al LLM",
"tool": "Llamada a herramienta",
"retrieval": "Recuperación",
"search": "Búsqueda vectorial",
"embedding": "Embedding",
"rerank": "Reordenación",
"guardrail": "Guardrail",
"step": "Paso"
},
"sources": {
"stream": "Chat",
"answer": "API de respuestas",
"v1": "API compatible con OpenAI",
"webhook": "Webhook",
"schedule": "Ejecución programada",
"search": "API de búsqueda",
"mcp": "Búsqueda MCP",
"graph_extraction": "Creación de grafo",
"headless": "Ejecución en segundo plano"
},
"fields": {
"status": "Estado",
"duration": "Duración",
"provider": "Proveedor",
"model": "Modelo",
"inputTokens": "Tokens de entrada",
"outputTokens": "Tokens de salida",
"cachedTokens": "Tokens en caché",
"timeToFirstToken": "Tiempo hasta el primer token",
"cost": "Coste",
"tokenSource": "Propósito",
"cacheHit": "Servido desde caché",
"estimatedUsage": "Tokens estimados",
"yes": "Sí",
"tool": "Herramienta",
"action": "Acción",
"callId": "ID de llamada",
"retriever": "Recuperador",
"sources": "Fuentes",
"topK": "Top K",
"chunks": "Fragmentos",
"candidates": "Candidatos",
"topScore": "Mejor puntuación",
"vectorStore": "Almacén vectorial",
"agentType": "Tipo de agente",
"toolCalls": "Llamadas a herramientas",
"stage": "Etapa",
"checks": "Comprobaciones",
"triggered": "Activadas",
"blocked": "Bloqueado",
"redacted": "Censurado",
"nodeType": "Tipo de nodo",
"phase": "Fase",
"query": "Consulta",
"arguments": "Argumentos",
"result": "Resultado",
"output": "Salida",
"retrievedChunks": "Fragmentos recuperados",
"allAttributes": "Todos los atributos"
}
}
},
"teams": {
+109 -2
View File
@@ -410,7 +410,9 @@
"schedule": "スケジュール",
"webhook": "Webhook",
"workflow": "ワークフロー",
"system": "システム"
"system": "システム",
"search": "検索",
"graph": "グラフ構築"
},
"detail": {
"agent": "エージェント",
@@ -430,7 +432,112 @@
"workflow": "ワークフロー",
"activity": "アクティビティ",
"steps": "ステップ",
"result": "結果"
"result": "結果",
"source": "ソース"
},
"trace": {
"view": "トレースを表示",
"viewRounds_one": "トレースを表示({{count}} ラウンド)",
"viewRounds_other": "トレースを表示({{count}} ラウンド)",
"title": "実行トレース",
"subtitle": "このエントリの各ステップ(検索、モデル呼び出し、ツール呼び出し)とその所要時間。",
"empty": "このエントリのトレースは記録されていません。",
"failed": "トレースを読み込めませんでした。",
"noSpans": "この実行では何も記録されませんでした。",
"round": "ラウンド {{n}}",
"step": "ステップ",
"toggleChildren": "ネストされたステップの表示を切り替え",
"droppedSpans_one": "さらに {{count}} 個のステップは記録されていません(トレースのサイズ上限)。",
"droppedSpans_other": "さらに {{count}} 個のステップは記録されていません(トレースのサイズ上限)。",
"chips": {
"llmCalls_one": "LLM 呼び出し {{count}} 回",
"llmCalls_other": "LLM 呼び出し {{count}} 回",
"tokens": "入力 {{input}} / 出力 {{output}}",
"toolCalls_one": "ツール呼び出し {{count}} 回",
"toolCalls_other": "ツール呼び出し {{count}} 回",
"retrieval": "RAG {{duration}}",
"errors_one": "エラー {{count}} 件",
"errors_other": "エラー {{count}} 件"
},
"headline": {
"tokens": "{{input}} → {{output}} トークン",
"chunks_one": "{{count}} チャンク",
"chunks_other": "{{count}} チャンク",
"hits_one": "{{count}} 件ヒット",
"hits_other": "{{count}} 件ヒット",
"cached": "キャッシュ"
},
"status": {
"ok": "OK",
"error": "エラー",
"cancelled": "キャンセル",
"paused": "一時停止",
"pending": "承認待ち",
"denied": "拒否",
"skipped": "スキップ"
},
"kinds": {
"agent": "エージェント",
"llm": "LLM 呼び出し",
"tool": "ツール呼び出し",
"retrieval": "検索",
"search": "ベクトル検索",
"embedding": "埋め込み",
"rerank": "再ランク付け",
"guardrail": "ガードレール",
"step": "ステップ"
},
"sources": {
"stream": "チャット",
"answer": "回答 API",
"v1": "OpenAI 互換 API",
"webhook": "Webhook",
"schedule": "スケジュール実行",
"search": "検索 API",
"mcp": "MCP 検索",
"graph_extraction": "グラフ構築",
"headless": "バックグラウンド実行"
},
"fields": {
"status": "ステータス",
"duration": "所要時間",
"provider": "プロバイダー",
"model": "モデル",
"inputTokens": "入力トークン",
"outputTokens": "出力トークン",
"cachedTokens": "キャッシュされたトークン",
"timeToFirstToken": "最初のトークンまでの時間",
"cost": "コスト",
"tokenSource": "用途",
"cacheHit": "キャッシュから提供",
"estimatedUsage": "推定トークン",
"yes": "はい",
"tool": "ツール",
"action": "アクション",
"callId": "呼び出し ID",
"retriever": "リトリーバー",
"sources": "ソース",
"topK": "Top K",
"chunks": "チャンク",
"candidates": "候補",
"topScore": "最高スコア",
"vectorStore": "ベクトルストア",
"agentType": "エージェントの種類",
"toolCalls": "ツール呼び出し",
"stage": "ステージ",
"checks": "チェック",
"triggered": "発動",
"blocked": "ブロック",
"redacted": "マスク済み",
"nodeType": "ノードの種類",
"phase": "フェーズ",
"query": "クエリ",
"arguments": "引数",
"result": "結果",
"output": "出力",
"retrievedChunks": "取得したチャンク",
"allAttributes": "すべての属性"
}
}
},
"teams": {
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import de from './de.json';
import en from './en.json';
import es from './es.json';
import jp from './jp.json';
import ru from './ru.json';
import zhTW from './zh-TW.json';
import zh from './zh.json';
type Tree = { [key: string]: string | Tree };
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
const flatten = (tree: Tree, prefix = ''): string[] =>
Object.entries(tree).flatMap(([key, value]) =>
typeof value === 'string'
? [prefix + key.replace(PLURAL_SUFFIX, '')]
: flatten(value, `${prefix}${key}.`),
);
const traceBlock = (locale: { settings: object }): Tree =>
((locale.settings as Tree).logs as Tree).trace as Tree;
const keysOf = (locale: { settings: object }): string[] =>
Array.from(new Set(flatten(traceBlock(locale)))).sort();
const LOCALES = { es, de, jp, ru, zh, zhTW };
describe('settings.logs.trace locale block', () => {
it.each(Object.entries(LOCALES))(
'%s has the same keys as en',
(_name, locale) => {
expect(keysOf(locale)).toEqual(keysOf(en));
},
);
it.each(Object.entries(LOCALES))(
'%s is translated, not an English copy',
(_name, locale) => {
expect(traceBlock(locale).title).not.toBe(traceBlock(en).title);
expect((traceBlock(locale).fields as Tree).duration).not.toBe(
(traceBlock(en).fields as Tree).duration,
);
},
);
it('every locale provides both plural forms i18next falls back through', () => {
[en, ...Object.values(LOCALES)].forEach((locale) => {
const chips = traceBlock(locale).chips as Tree;
expect(chips.llmCalls_one).toBeTruthy();
expect(chips.llmCalls_other).toBeTruthy();
});
});
it('every locale labels the new log event types', () => {
[en, ...Object.values(LOCALES)].forEach((locale) => {
const types = ((locale.settings as Tree).logs as Tree).types as Tree;
expect(types.search).toBeTruthy();
expect(types.graph).toBeTruthy();
});
});
});
+123 -2
View File
@@ -423,7 +423,9 @@
"schedule": "По расписанию",
"webhook": "Вебхук",
"workflow": "Рабочий процесс",
"system": "Система"
"system": "Система",
"search": "Поиск",
"graph": "Построение графа"
},
"detail": {
"agent": "Агент",
@@ -443,7 +445,126 @@
"workflow": "Рабочий процесс",
"activity": "Активность",
"steps": "Шаги",
"result": "Результат"
"result": "Результат",
"source": "Источник"
},
"trace": {
"view": "Открыть трассировку",
"viewRounds_one": "Открыть трассировку ({{count}} раунд)",
"viewRounds_few": "Открыть трассировку ({{count}} раунда)",
"viewRounds_many": "Открыть трассировку ({{count}} раундов)",
"viewRounds_other": "Открыть трассировку ({{count}} раунда)",
"title": "Трассировка выполнения",
"subtitle": "Каждый шаг за этой записью — поиск, вызовы модели и инструментов — с длительностью.",
"empty": "Для этой записи трассировка не сохранена.",
"failed": "Не удалось загрузить трассировку.",
"noSpans": "Для этого запуска ничего не записано.",
"round": "Раунд {{n}}",
"step": "Шаг",
"toggleChildren": "Показать или скрыть вложенные шаги",
"droppedSpans_one": "Ещё {{count}} шаг не записан (лимит размера трассировки).",
"droppedSpans_few": "Ещё {{count}} шага не записаны (лимит размера трассировки).",
"droppedSpans_many": "Ещё {{count}} шагов не записаны (лимит размера трассировки).",
"droppedSpans_other": "Ещё {{count}} шага не записаны (лимит размера трассировки).",
"chips": {
"llmCalls_one": "{{count}} вызов LLM",
"llmCalls_few": "{{count}} вызова LLM",
"llmCalls_many": "{{count}} вызовов LLM",
"llmCalls_other": "{{count}} вызова LLM",
"tokens": "{{input}} вход / {{output}} выход",
"toolCalls_one": "{{count}} вызов инструмента",
"toolCalls_few": "{{count}} вызова инструментов",
"toolCalls_many": "{{count}} вызовов инструментов",
"toolCalls_other": "{{count}} вызова инструментов",
"retrieval": "RAG {{duration}}",
"errors_one": "{{count}} ошибка",
"errors_few": "{{count}} ошибки",
"errors_many": "{{count}} ошибок",
"errors_other": "{{count}} ошибки"
},
"headline": {
"tokens": "{{input}} → {{output}} ток.",
"chunks_one": "{{count}} фрагмент",
"chunks_few": "{{count}} фрагмента",
"chunks_many": "{{count}} фрагментов",
"chunks_other": "{{count}} фрагмента",
"hits_one": "{{count}} совпадение",
"hits_few": "{{count}} совпадения",
"hits_many": "{{count}} совпадений",
"hits_other": "{{count}} совпадения",
"cached": "из кэша"
},
"status": {
"ok": "ОК",
"error": "Ошибка",
"cancelled": "Отменено",
"paused": "Приостановлено",
"pending": "Ожидает подтверждения",
"denied": "Отклонено",
"skipped": "Пропущено"
},
"kinds": {
"agent": "Агент",
"llm": "Вызов LLM",
"tool": "Вызов инструмента",
"retrieval": "Поиск",
"search": "Векторный поиск",
"embedding": "Эмбеддинг",
"rerank": "Переранжирование",
"guardrail": "Ограничитель",
"step": "Шаг"
},
"sources": {
"stream": "Чат",
"answer": "API ответов",
"v1": "OpenAI-совместимый API",
"webhook": "Вебхук",
"schedule": "Запуск по расписанию",
"search": "API поиска",
"mcp": "Поиск MCP",
"graph_extraction": "Построение графа",
"headless": "Фоновый запуск"
},
"fields": {
"status": "Статус",
"duration": "Длительность",
"provider": "Провайдер",
"model": "Модель",
"inputTokens": "Входные токены",
"outputTokens": "Выходные токены",
"cachedTokens": "Токены из кэша",
"timeToFirstToken": "Время до первого токена",
"cost": "Стоимость",
"tokenSource": "Назначение",
"cacheHit": "Из кэша",
"estimatedUsage": "Оценочные токены",
"yes": "Да",
"tool": "Инструмент",
"action": "Действие",
"callId": "ID вызова",
"retriever": "Ретривер",
"sources": "Источники",
"topK": "Top K",
"chunks": "Фрагменты",
"candidates": "Кандидаты",
"topScore": "Лучшая оценка",
"vectorStore": "Векторное хранилище",
"agentType": "Тип агента",
"toolCalls": "Вызовы инструментов",
"stage": "Этап",
"checks": "Проверки",
"triggered": "Сработали",
"blocked": "Заблокировано",
"redacted": "Скрыто",
"nodeType": "Тип узла",
"phase": "Фаза",
"query": "Запрос",
"arguments": "Аргументы",
"result": "Результат",
"output": "Вывод",
"retrievedChunks": "Найденные фрагменты",
"allAttributes": "Все атрибуты"
}
}
},
"teams": {
+109 -2
View File
@@ -410,7 +410,9 @@
"schedule": "排程",
"webhook": "Webhook",
"workflow": "工作流程",
"system": "系統"
"system": "系統",
"search": "搜尋",
"graph": "圖譜建置"
},
"detail": {
"agent": "代理",
@@ -430,7 +432,112 @@
"workflow": "工作流程",
"activity": "活動",
"steps": "步驟",
"result": "結果"
"result": "結果",
"source": "來源"
},
"trace": {
"view": "檢視追蹤",
"viewRounds_one": "檢視追蹤({{count}} 輪)",
"viewRounds_other": "檢視追蹤({{count}} 輪)",
"title": "執行追蹤",
"subtitle": "此項目背後的每個步驟(檢索、模型呼叫和工具呼叫)及其耗時。",
"empty": "此項目沒有記錄追蹤。",
"failed": "無法載入追蹤。",
"noSpans": "此次執行沒有記錄任何內容。",
"round": "第 {{n}} 輪",
"step": "步驟",
"toggleChildren": "顯示或隱藏巢狀步驟",
"droppedSpans_one": "另有 {{count}} 個步驟未被記錄(追蹤大小限制)。",
"droppedSpans_other": "另有 {{count}} 個步驟未被記錄(追蹤大小限制)。",
"chips": {
"llmCalls_one": "{{count}} 次 LLM 呼叫",
"llmCalls_other": "{{count}} 次 LLM 呼叫",
"tokens": "輸入 {{input}} / 輸出 {{output}}",
"toolCalls_one": "{{count}} 次工具呼叫",
"toolCalls_other": "{{count}} 次工具呼叫",
"retrieval": "RAG {{duration}}",
"errors_one": "{{count}} 個錯誤",
"errors_other": "{{count}} 個錯誤"
},
"headline": {
"tokens": "{{input}} → {{output}} 詞元",
"chunks_one": "{{count}} 個片段",
"chunks_other": "{{count}} 個片段",
"hits_one": "{{count}} 個命中",
"hits_other": "{{count}} 個命中",
"cached": "快取"
},
"status": {
"ok": "正常",
"error": "錯誤",
"cancelled": "已取消",
"paused": "已暫停",
"pending": "等待核准",
"denied": "已拒絕",
"skipped": "已略過"
},
"kinds": {
"agent": "代理",
"llm": "LLM 呼叫",
"tool": "工具呼叫",
"retrieval": "檢索",
"search": "向量搜尋",
"embedding": "嵌入",
"rerank": "重新排序",
"guardrail": "防護機制",
"step": "步驟"
},
"sources": {
"stream": "聊天",
"answer": "回答 API",
"v1": "OpenAI 相容 API",
"webhook": "Webhook",
"schedule": "排程執行",
"search": "搜尋 API",
"mcp": "MCP 搜尋",
"graph_extraction": "圖譜建置",
"headless": "背景執行"
},
"fields": {
"status": "狀態",
"duration": "耗時",
"provider": "供應商",
"model": "模型",
"inputTokens": "輸入詞元",
"outputTokens": "輸出詞元",
"cachedTokens": "快取詞元",
"timeToFirstToken": "首個詞元耗時",
"cost": "費用",
"tokenSource": "用途",
"cacheHit": "來自快取",
"estimatedUsage": "估算詞元",
"yes": "是",
"tool": "工具",
"action": "動作",
"callId": "呼叫 ID",
"retriever": "檢索器",
"sources": "來源",
"topK": "Top K",
"chunks": "片段",
"candidates": "候選",
"topScore": "最高分",
"vectorStore": "向量儲存",
"agentType": "代理類型",
"toolCalls": "工具呼叫",
"stage": "階段",
"checks": "檢查項目",
"triggered": "已觸發",
"blocked": "已封鎖",
"redacted": "已遮蔽",
"nodeType": "節點類型",
"phase": "階段",
"query": "查詢",
"arguments": "參數",
"result": "結果",
"output": "輸出",
"retrievedChunks": "檢索到的片段",
"allAttributes": "所有屬性"
}
}
},
"teams": {
+109 -2
View File
@@ -410,7 +410,9 @@
"schedule": "计划",
"webhook": "Webhook",
"workflow": "工作流",
"system": "系统"
"system": "系统",
"search": "搜索",
"graph": "图谱构建"
},
"detail": {
"agent": "代理",
@@ -430,7 +432,112 @@
"workflow": "工作流",
"activity": "活动",
"steps": "步骤",
"result": "结果"
"result": "结果",
"source": "来源"
},
"trace": {
"view": "查看追踪",
"viewRounds_one": "查看追踪({{count}} 轮)",
"viewRounds_other": "查看追踪({{count}} 轮)",
"title": "执行追踪",
"subtitle": "此条目背后的每一步(检索、模型调用和工具调用)及其耗时。",
"empty": "此条目没有记录追踪。",
"failed": "无法加载追踪。",
"noSpans": "此次运行没有记录任何内容。",
"round": "第 {{n}} 轮",
"step": "步骤",
"toggleChildren": "显示或隐藏嵌套步骤",
"droppedSpans_one": "另有 {{count}} 个步骤未被记录(追踪大小限制)。",
"droppedSpans_other": "另有 {{count}} 个步骤未被记录(追踪大小限制)。",
"chips": {
"llmCalls_one": "{{count}} 次 LLM 调用",
"llmCalls_other": "{{count}} 次 LLM 调用",
"tokens": "输入 {{input}} / 输出 {{output}}",
"toolCalls_one": "{{count}} 次工具调用",
"toolCalls_other": "{{count}} 次工具调用",
"retrieval": "RAG {{duration}}",
"errors_one": "{{count}} 个错误",
"errors_other": "{{count}} 个错误"
},
"headline": {
"tokens": "{{input}} → {{output}} 词元",
"chunks_one": "{{count}} 个片段",
"chunks_other": "{{count}} 个片段",
"hits_one": "{{count}} 个命中",
"hits_other": "{{count}} 个命中",
"cached": "缓存"
},
"status": {
"ok": "正常",
"error": "错误",
"cancelled": "已取消",
"paused": "已暂停",
"pending": "等待批准",
"denied": "已拒绝",
"skipped": "已跳过"
},
"kinds": {
"agent": "智能体",
"llm": "LLM 调用",
"tool": "工具调用",
"retrieval": "检索",
"search": "向量搜索",
"embedding": "嵌入",
"rerank": "重排序",
"guardrail": "防护栏",
"step": "步骤"
},
"sources": {
"stream": "聊天",
"answer": "回答 API",
"v1": "OpenAI 兼容 API",
"webhook": "Webhook",
"schedule": "定时运行",
"search": "搜索 API",
"mcp": "MCP 搜索",
"graph_extraction": "图谱构建",
"headless": "后台运行"
},
"fields": {
"status": "状态",
"duration": "耗时",
"provider": "提供商",
"model": "模型",
"inputTokens": "输入词元",
"outputTokens": "输出词元",
"cachedTokens": "缓存词元",
"timeToFirstToken": "首个词元耗时",
"cost": "费用",
"tokenSource": "用途",
"cacheHit": "来自缓存",
"estimatedUsage": "估算词元",
"yes": "是",
"tool": "工具",
"action": "操作",
"callId": "调用 ID",
"retriever": "检索器",
"sources": "来源",
"topK": "Top K",
"chunks": "片段",
"candidates": "候选",
"topScore": "最高分",
"vectorStore": "向量存储",
"agentType": "智能体类型",
"toolCalls": "工具调用",
"stage": "阶段",
"checks": "检查项",
"triggered": "已触发",
"blocked": "已拦截",
"redacted": "已脱敏",
"nodeType": "节点类型",
"phase": "阶段",
"query": "查询",
"arguments": "参数",
"result": "结果",
"output": "输出",
"retrievedChunks": "检索到的片段",
"allAttributes": "全部属性"
}
}
},
"teams": {
+63 -3
View File
@@ -1,3 +1,4 @@
import { Activity } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -16,7 +17,10 @@ import {
} from '../components/ui/select';
import { useLoaderState } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { LogData } from './types';
import TraceChips from './traces/TraceChips';
import TraceSheet from './traces/TraceSheet';
import { formatDurationMs } from './traces/traceUtils';
import { LogData, TraceRef } from './types';
type LogsProps = {
agentId?: string;
@@ -35,6 +39,7 @@ export default function Logs({ agentId, tableHeader }: LogsProps) {
const [typeFilter, setTypeFilter] = useState('all');
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [openTrace, setOpenTrace] = useState<TraceRef | null>(null);
const logs = Object.values(logsByPage).flat();
@@ -127,6 +132,8 @@ export default function Logs({ agentId, tableHeader }: LogsProps) {
{ label: t('settings.logs.types.webhook'), value: 'webhook' },
{ label: t('settings.logs.types.workflow'), value: 'workflow' },
{ label: t('settings.logs.types.system'), value: 'system' },
{ label: t('settings.logs.types.search'), value: 'search' },
{ label: t('settings.logs.types.graph'), value: 'graph' },
];
return (
@@ -178,8 +185,14 @@ export default function Logs({ agentId, tableHeader }: LogsProps) {
setPage={setPage}
loading={loadingLogs}
tableHeader={tableHeader}
onViewTrace={setOpenTrace}
/>
</div>
<TraceSheet
traceRef={openTrace}
agentId={agentId}
onClose={() => setOpenTrace(null)}
/>
</div>
);
}
@@ -189,8 +202,15 @@ type LogsTableProps = {
setPage: React.Dispatch<React.SetStateAction<number>>;
loading: boolean;
tableHeader?: string;
onViewTrace: (ref: TraceRef) => void;
};
function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) {
function LogsTable({
logs,
setPage,
loading,
tableHeader,
onViewTrace,
}: LogsTableProps) {
const { t } = useTranslation();
const observerRef = useRef<IntersectionObserver | null>(null);
const [openLogId, setOpenLogId] = useState<string | null>(null);
@@ -248,6 +268,7 @@ function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) {
log={log}
isOpen={openLogId === log.id}
onToggle={handleLogToggle}
onViewTrace={onViewTrace}
/>
</div>
);
@@ -258,6 +279,7 @@ function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) {
log={log}
isOpen={openLogId === log.id}
onToggle={handleLogToggle}
onViewTrace={onViewTrace}
/>
);
})}
@@ -271,17 +293,19 @@ function formatDuration(start?: string, end?: string): string | null {
if (!start || !end) return null;
const ms = new Date(end).getTime() - new Date(start).getTime();
if (isNaN(ms) || ms < 0) return null;
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
return formatDurationMs(ms);
}
function Log({
log,
isOpen,
onToggle,
onViewTrace,
}: {
log: LogData;
isOpen: boolean;
onToggle: (id: string) => void;
onViewTrace: (ref: TraceRef) => void;
}) {
const { t } = useTranslation();
const logLevelColor = {
@@ -336,6 +360,17 @@ function Log({
} else if (log.event_type === 'system' || log.event_type === 'webhook') {
if (log.endpoint)
detailRows.push([t('settings.logs.detail.endpoint'), log.endpoint]);
} else if (log.event_type === 'search' || log.event_type === 'graph') {
if (log.source)
detailRows.push([
t('settings.logs.detail.source'),
t(`settings.logs.trace.sources.${log.source}`, log.source),
]);
if (log.status)
detailRows.push([
t('settings.logs.detail.status'),
t(`settings.logs.trace.status.${log.status}`, log.status),
]);
}
const textBlocks: { label: string; text: string; isError?: boolean }[] = [];
@@ -412,6 +447,11 @@ function Log({
</h2>
)}
<h2 className="text-xs text-[#913400] dark:text-orange-500">{`[${log.action}]`}</h2>
{log.trace && (
<h2 className="text-muted-foreground text-xs tabular-nums">
{formatDurationMs(log.trace.duration_ms)}
</h2>
)}
<h2
className={`max-w-72 text-xs ${logLevelColor[log.level]} wrap-break-word`}
>
@@ -423,6 +463,26 @@ function Log({
</div>
{isOpen && (
<div className="dark:bg-background rounded-b-xl bg-[#F1F1F1] px-4 py-3">
{log.trace && (
<div className="flex flex-wrap items-center gap-2 px-2 pb-3">
<TraceChips
durationMs={log.trace.duration_ms}
counts={log.trace.summary}
/>
<button
type="button"
onClick={() => log.trace && onViewTrace(log.trace.ref)}
className="border-border hover:bg-muted text-foreground inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs dark:hover:bg-white/10"
>
<Activity className="size-3.5" />
{log.trace.count > 1
? t('settings.logs.trace.viewRounds', {
count: log.trace.count,
})
: t('settings.logs.trace.view')}
</button>
</div>
)}
{detailRows.length > 0 && (
<div className="flex flex-col gap-1 px-2 pb-2">
{detailRows.map(([label, value]) => (
@@ -0,0 +1,93 @@
import {
Brain,
Coins,
Search,
Timer,
TriangleAlert,
Wrench,
} from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { TraceCounts } from '../types';
import { formatDurationMs, formatTokens } from './traceUtils';
type TraceChipsProps = {
durationMs?: number;
counts: TraceCounts;
};
/** Compact stat chips for a trace: duration, LLM calls, tokens, tools, RAG time, errors. */
export default function TraceChips({ durationMs, counts }: TraceChipsProps) {
const { t } = useTranslation();
const chips: {
key: string;
icon: React.ReactNode;
label: string;
tone?: 'danger';
}[] = [];
if (durationMs !== undefined)
chips.push({
key: 'duration',
icon: <Timer className="size-3" />,
label: formatDurationMs(durationMs),
});
if (counts.llm_calls)
chips.push({
key: 'llm',
icon: <Brain className="size-3" />,
label: t('settings.logs.trace.chips.llmCalls', {
count: counts.llm_calls,
}),
});
if (counts.input_tokens || counts.output_tokens)
chips.push({
key: 'tokens',
icon: <Coins className="size-3" />,
label: t('settings.logs.trace.chips.tokens', {
input: formatTokens(counts.input_tokens),
output: formatTokens(counts.output_tokens),
}),
});
if (counts.tool_calls)
chips.push({
key: 'tools',
icon: <Wrench className="size-3" />,
label: t('settings.logs.trace.chips.toolCalls', {
count: counts.tool_calls,
}),
});
if (counts.retrieval_calls)
chips.push({
key: 'retrieval',
icon: <Search className="size-3" />,
label: t('settings.logs.trace.chips.retrieval', {
duration: formatDurationMs(counts.retrieval_ms),
}),
});
if (counts.errors)
chips.push({
key: 'errors',
icon: <TriangleAlert className="size-3" />,
label: t('settings.logs.trace.chips.errors', { count: counts.errors }),
tone: 'danger',
});
if (!chips.length) return null;
return (
<div className="flex flex-wrap gap-1.5">
{chips.map((chip) => (
<span
key={chip.key}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs tabular-nums ${
chip.tone === 'danger'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'bg-muted text-foreground dark:bg-white/10'
}`}
>
{chip.icon}
{chip.label}
</span>
))}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Pill } from '../../admin/AdminUI';
import userService from '../../api/services/userService';
import Spinner from '../../components/Spinner';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '../../components/ui/sheet';
import { selectToken } from '../../preferences/preferenceSlice';
import { formatDateTime } from '../../utils/dateTimeUtils';
import { Trace, TraceRef } from '../types';
import TraceChips from './TraceChips';
import TraceWaterfall from './TraceWaterfall';
type TraceSheetProps = {
traceRef: TraceRef | null;
agentId?: string;
onClose: () => void;
};
const STATUS_TONE: Record<string, 'success' | 'danger' | 'warning' | 'muted'> =
{
ok: 'success',
error: 'danger',
paused: 'warning',
cancelled: 'muted',
};
/**
* Side panel showing the execution trace(s) behind one Logs row. A chat turn
* paused for tool approval has one trace per round, shown in order.
*/
export default function TraceSheet({
traceRef,
agentId,
onClose,
}: TraceSheetProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [traces, setTraces] = useState<Trace[]>([]);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!traceRef) return;
const controller = new AbortController();
setLoading(true);
setFailed(false);
setTraces([]);
const params: Record<string, string> = {
[traceRef.field]: traceRef.value,
};
if (agentId) params.api_key_id = agentId;
userService
.getTraces(params, token, controller.signal)
.then(async (response: Response) => {
if (!response.ok) throw new Error('Failed to load traces');
const data = await response.json();
setTraces(data.traces ?? []);
})
.catch((error: unknown) => {
if ((error as Error)?.name === 'AbortError') return;
console.error(error);
setFailed(true);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [traceRef?.field, traceRef?.value, agentId, token]);
return (
<Sheet open={traceRef !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
className="bg-card w-full overflow-y-auto sm:max-w-3xl"
>
<SheetHeader className="pb-0">
<SheetTitle>{t('settings.logs.trace.title')}</SheetTitle>
<SheetDescription>
{t('settings.logs.trace.subtitle')}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-6 px-4 pb-6">
{loading && (
<div className="flex justify-center py-10">
<Spinner />
</div>
)}
{!loading && failed && (
<p className="text-sm text-red-500">
{t('settings.logs.trace.failed')}
</p>
)}
{!loading && !failed && traces.length === 0 && (
<p className="text-muted-foreground text-sm">
{t('settings.logs.trace.empty')}
</p>
)}
{traces.map((trace, index) => (
<section key={trace.id} className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{traces.length > 1 && (
<span className="text-foreground text-sm font-medium">
{t('settings.logs.trace.round', { n: index + 1 })}
</span>
)}
<span className="text-muted-foreground text-xs">
{t(
`settings.logs.trace.sources.${trace.source}`,
trace.source,
)}
</span>
<span className="text-muted-foreground text-xs">
{formatDateTime(trace.started_at)}
</span>
<Pill tone={STATUS_TONE[trace.status] ?? 'muted'}>
{t(
`settings.logs.trace.status.${trace.status}`,
trace.status,
)}
</Pill>
</div>
<TraceChips
durationMs={trace.duration_ms}
counts={trace.summary ?? {}}
/>
{trace.dropped_spans > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t('settings.logs.trace.droppedSpans', {
count: trace.dropped_spans,
})}
</p>
)}
<TraceWaterfall trace={trace} />
</section>
))}
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,202 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ToolCallPanel } from '../../conversation/AnswerFlow';
import { TraceSpan } from '../types';
import { formatDurationMs, formatTokens } from './traceUtils';
type Row = [string, string];
function str(value: unknown): string | undefined {
if (value === null || value === undefined || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(', ');
return String(value);
}
function jsonText(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
type ChunkPreview = {
title?: string;
source?: string;
score?: number;
text?: string;
};
/** Everything recorded for one span: its key facts, previews and raw attributes. */
export default function TraceSpanDetails({ span }: { span: TraceSpan }) {
const { t } = useTranslation();
const a = span.attributes || {};
const f = (key: string) => t(`settings.logs.trace.fields.${key}`);
const yes = t('settings.logs.trace.fields.yes');
const rows: Row[] = [];
const push = (label: string, value: unknown) => {
const text = str(value);
if (text !== undefined) rows.push([label, text]);
};
push(
f('status'),
t(`settings.logs.trace.status.${span.status}`, span.status),
);
push(f('duration'), formatDurationMs(span.duration_ms));
switch (span.kind) {
case 'llm': {
push(f('provider'), a['gen_ai.provider.name']);
push(f('model'), a['gen_ai.request.model']);
const input = a['gen_ai.usage.input_tokens'];
const output = a['gen_ai.usage.output_tokens'];
if (typeof input === 'number')
push(f('inputTokens'), formatTokens(input));
if (typeof output === 'number')
push(f('outputTokens'), formatTokens(output));
push(f('cachedTokens'), a['gen_ai.usage.cache_read.input_tokens']);
if (typeof a['docsgpt.ttft_ms'] === 'number')
push(f('timeToFirstToken'), formatDurationMs(a['docsgpt.ttft_ms']));
if (
typeof a['docsgpt.cost_usd'] === 'number' &&
a['docsgpt.cost_usd'] > 0
)
push(f('cost'), `$${a['docsgpt.cost_usd'].toFixed(6)}`);
push(f('tokenSource'), a['docsgpt.token_source']);
if (a['docsgpt.cache_hit']) push(f('cacheHit'), yes);
if (a['docsgpt.usage_estimated']) push(f('estimatedUsage'), yes);
break;
}
case 'tool':
push(f('tool'), a['docsgpt.tool']);
push(f('action'), a['docsgpt.action'] ?? a['gen_ai.tool.name']);
push(f('callId'), a['gen_ai.tool.call.id']);
break;
case 'retrieval':
case 'search':
case 'embedding':
case 'rerank':
push(f('retriever'), a['docsgpt.retriever']);
push(f('sources'), a['docsgpt.source_ids'] ?? a['gen_ai.data_source.id']);
push(f('model'), a['gen_ai.request.model']);
push(f('topK'), a['docsgpt.top_k']);
push(f('chunks'), a['docsgpt.chunk_count'] ?? a['docsgpt.kept_count']);
push(f('candidates'), a['docsgpt.candidate_count']);
push(f('topScore'), a['docsgpt.top_score']);
push(f('vectorStore'), a['docsgpt.vector_store']);
break;
case 'agent':
push(f('agentType'), a['docsgpt.agent_type']);
push(f('model'), a['gen_ai.request.model']);
push(f('sources'), a['docsgpt.source_count']);
push(f('toolCalls'), a['docsgpt.tool_call_count']);
break;
case 'guardrail':
push(f('stage'), a['docsgpt.guardrail.stage']);
push(f('checks'), a['docsgpt.guardrail.checks']);
push(f('triggered'), a['docsgpt.guardrail.triggered']);
if (a['docsgpt.guardrail.blocked']) push(f('blocked'), yes);
if (a['docsgpt.guardrail.redacted']) push(f('redacted'), yes);
break;
case 'step':
push(f('nodeType'), a['docsgpt.workflow.node_type']);
push(f('phase'), a['docsgpt.research.phase']);
break;
}
const preview = span.preview || {};
const chunks = Array.isArray(preview.chunks)
? (preview.chunks as ChunkPreview[])
: [];
const otherPreviews = Object.entries(preview).filter(
([key]) =>
!['arguments', 'result', 'query', 'chunks', 'output'].includes(key),
);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)] gap-x-3 gap-y-1">
{rows.map(([label, value]) => (
<React.Fragment key={label}>
<span className="text-muted-foreground">{label}</span>
<span className="text-foreground break-all">{value}</span>
</React.Fragment>
))}
</div>
{span.error && (
<div className="rounded-lg bg-red-50 px-3 py-2 font-mono whitespace-pre-wrap text-red-700 dark:bg-red-900/30 dark:text-red-300">
{span.error}
</div>
)}
{preview.query !== undefined && (
<ToolCallPanel title={f('query')} copyText={jsonText(preview.query)}>
<p className="font-mono whitespace-pre-wrap">
{jsonText(preview.query)}
</p>
</ToolCallPanel>
)}
{preview.arguments !== undefined && (
<ToolCallPanel
title={f('arguments')}
copyText={jsonText(preview.arguments)}
>
<p className="max-h-60 overflow-y-auto font-mono whitespace-pre-wrap">
{jsonText(preview.arguments)}
</p>
</ToolCallPanel>
)}
{preview.result !== undefined && (
<ToolCallPanel title={f('result')} copyText={jsonText(preview.result)}>
<p className="max-h-60 overflow-y-auto font-mono whitespace-pre-wrap">
{jsonText(preview.result)}
</p>
</ToolCallPanel>
)}
{preview.output !== undefined && (
<ToolCallPanel title={f('output')} copyText={jsonText(preview.output)}>
<p className="max-h-60 overflow-y-auto whitespace-pre-wrap">
{jsonText(preview.output)}
</p>
</ToolCallPanel>
)}
{chunks.length > 0 && (
<ToolCallPanel
title={f('retrievedChunks')}
copyText={JSON.stringify(chunks, null, 2)}
>
<ol className="flex max-h-72 flex-col gap-2 overflow-y-auto">
{chunks.map((chunk, index) => (
<li key={index} className="flex flex-col gap-0.5">
<span className="text-foreground font-medium">
{index + 1}. {chunk.title || chunk.source || '—'}
{typeof chunk.score === 'number' && (
<span className="text-muted-foreground ml-2 font-normal tabular-nums">
{chunk.score.toFixed(3)}
</span>
)}
</span>
{chunk.text && (
<span className="text-muted-foreground line-clamp-3">
{chunk.text}
</span>
)}
</li>
))}
</ol>
</ToolCallPanel>
)}
{otherPreviews.map(([key, value]) => (
<ToolCallPanel key={key} title={key} copyText={jsonText(value)}>
<p className="max-h-60 overflow-y-auto font-mono whitespace-pre-wrap">
{jsonText(value)}
</p>
</ToolCallPanel>
))}
<details>
<summary className="text-muted-foreground cursor-pointer select-none">
{f('allAttributes')}
</summary>
<pre className="text-muted-foreground mt-1 max-h-60 overflow-y-auto font-mono whitespace-pre-wrap">
{JSON.stringify(a, null, 2)}
</pre>
</details>
</div>
);
}
@@ -0,0 +1,187 @@
import {
Binary,
Bot,
Brain,
ChevronRight,
Database,
ListFilter,
ListTree,
Search,
ShieldCheck,
Wrench,
} from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trace, TraceSpan } from '../types';
import TraceSpanDetails from './TraceSpanDetails';
import {
barGeometry,
buildSpanRows,
formatDurationMs,
spanHeadline,
traceTotalMs,
} from './traceUtils';
const KIND_STYLE: Record<string, { icon: React.ElementType; bar: string }> = {
agent: { icon: Bot, bar: 'bg-violet-500' },
llm: { icon: Brain, bar: 'bg-sky-500' },
tool: { icon: Wrench, bar: 'bg-amber-500' },
retrieval: { icon: Search, bar: 'bg-emerald-500' },
search: { icon: Database, bar: 'bg-emerald-400' },
embedding: { icon: Binary, bar: 'bg-teal-500' },
rerank: { icon: ListFilter, bar: 'bg-lime-500' },
guardrail: { icon: ShieldCheck, bar: 'bg-rose-400' },
step: { icon: ListTree, bar: 'bg-indigo-400' },
};
function barClass(span: TraceSpan): string {
if (span.status === 'error') return 'bg-red-500';
if (['cancelled', 'pending', 'denied', 'skipped'].includes(span.status))
return 'bg-gray-400 dark:bg-gray-500';
return (KIND_STYLE[span.kind] ?? KIND_STYLE.step).bar;
}
const SCALE_STEPS = [0, 0.25, 0.5, 0.75, 1];
const INDENT_PX = 14;
/** A waterfall of one trace's spans; click a row for its details. */
export default function TraceWaterfall({ trace }: { trace: Trace }) {
const { t } = useTranslation();
const rows = useMemo(() => buildSpanRows(trace.spans), [trace.spans]);
const totalMs = traceTotalMs(trace.duration_ms, trace.spans);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const visibleRows = useMemo(() => {
const out: typeof rows = [];
let hiddenBelowDepth: number | null = null;
for (const row of rows) {
if (hiddenBelowDepth !== null && row.depth > hiddenBelowDepth) continue;
hiddenBelowDepth = collapsed.has(row.span.id) ? row.depth : null;
out.push(row);
}
return out;
}, [rows, collapsed]);
const toggleCollapsed = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
if (!rows.length) {
return (
<p className="text-muted-foreground py-4 text-center text-xs">
{t('settings.logs.trace.noSpans')}
</p>
);
}
return (
<div className="flex flex-col text-xs" role="tree">
<div className="grid grid-cols-[minmax(0,5fr)_minmax(0,4fr)] gap-3 pb-1">
<span className="text-muted-foreground">
{t('settings.logs.trace.step')}
</span>
<div className="text-muted-foreground relative h-4">
{SCALE_STEPS.map((step) => (
<span
key={step}
className="absolute top-0 -translate-x-1/2 tabular-nums first:translate-x-0 last:-translate-x-full"
style={{ left: `${step * 100}%` }}
>
{formatDurationMs(totalMs * step)}
</span>
))}
</div>
</div>
{visibleRows.map(({ span, depth, hasChildren }) => {
const Icon = (KIND_STYLE[span.kind] ?? KIND_STYLE.step).icon;
const { left, width } = barGeometry(span, totalMs);
const selected = selectedId === span.id;
const headline = spanHeadline(span, t);
return (
<div key={span.id} role="treeitem" aria-selected={selected}>
<div
role="button"
tabIndex={0}
onClick={() => setSelectedId(selected ? null : span.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedId(selected ? null : span.id);
}
}}
className={`grid w-full cursor-pointer grid-cols-[minmax(0,5fr)_minmax(0,4fr)] items-center gap-3 rounded-md py-1 text-left ${
selected
? 'bg-muted dark:bg-white/10'
: 'hover:bg-muted/60 dark:hover:bg-white/5'
}`}
>
<span
className="flex min-w-0 items-center gap-1.5"
style={{ paddingLeft: depth * INDENT_PX }}
>
{hasChildren ? (
<button
type="button"
aria-label={t('settings.logs.trace.toggleChildren')}
aria-expanded={!collapsed.has(span.id)}
onClick={(e) => {
e.stopPropagation();
toggleCollapsed(span.id);
}}
onKeyDown={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<ChevronRight
className={`size-3 transition-transform ${collapsed.has(span.id) ? '' : 'rotate-90'}`}
/>
</button>
) : (
<span className="w-3 shrink-0" />
)}
<Icon
className={`size-3.5 shrink-0 ${span.status === 'error' ? 'text-red-500' : 'text-muted-foreground'}`}
aria-label={t(
`settings.logs.trace.kinds.${span.kind}`,
span.kind,
)}
/>
<span className="text-foreground truncate" title={span.name}>
{span.name}
</span>
{headline && (
<span className="text-muted-foreground shrink-0 truncate">
{headline}
</span>
)}
<span className="text-muted-foreground ml-auto shrink-0 pl-2 tabular-nums">
{formatDurationMs(span.duration_ms)}
</span>
</span>
<span className="bg-muted/60 relative h-3 rounded-sm dark:bg-white/5">
<span
className={`absolute inset-y-0 rounded-sm ${barClass(span)}`}
style={{ left: `${left}%`, width: `${width}%` }}
title={`${span.name} · ${formatDurationMs(span.duration_ms)}`}
/>
</span>
</div>
{selected && (
<div
className="border-border my-1 rounded-lg border px-3 py-2"
style={{ marginLeft: depth * INDENT_PX }}
>
<TraceSpanDetails span={span} />
</div>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { TraceSpan } from '../types';
import {
barGeometry,
buildSpanRows,
formatDurationMs,
formatTokens,
spanHeadline,
traceTotalMs,
} from './traceUtils';
const span = (overrides: Partial<TraceSpan>): TraceSpan => ({
id: 's',
parent_id: null,
kind: 'llm',
name: 'chat m',
status: 'ok',
offset_ms: 0,
duration_ms: 10,
attributes: {},
...overrides,
});
describe('buildSpanRows', () => {
it('orders depth-first by start offset with depths', () => {
const rows = buildSpanRows([
span({ id: 'llm2', parent_id: 'agent', offset_ms: 50 }),
span({ id: 'agent', kind: 'agent', offset_ms: 5, duration_ms: 100 }),
span({ id: 'retrieval', kind: 'retrieval', offset_ms: 0 }),
span({ id: 'tool', kind: 'tool', parent_id: 'agent', offset_ms: 20 }),
span({ id: 'inner', parent_id: 'tool', offset_ms: 21 }),
]);
expect(rows.map((r) => [r.span.id, r.depth])).toEqual([
['retrieval', 0],
['agent', 0],
['tool', 1],
['inner', 2],
['llm2', 1],
]);
});
it('treats spans whose parent is missing as roots', () => {
const rows = buildSpanRows([span({ id: 'a', parent_id: 'gone' })]);
expect(rows).toHaveLength(1);
expect(rows[0].depth).toBe(0);
});
it('marks rows that have children', () => {
const rows = buildSpanRows([
span({ id: 'p', kind: 'agent' }),
span({ id: 'c', parent_id: 'p' }),
]);
expect(rows[0].hasChildren).toBe(true);
expect(rows[1].hasChildren).toBe(false);
});
});
describe('traceTotalMs', () => {
it('uses the larger of the stored duration and the last span end', () => {
expect(traceTotalMs(100, [span({ offset_ms: 90, duration_ms: 30 })])).toBe(
120,
);
expect(traceTotalMs(500, [span({ offset_ms: 0, duration_ms: 30 })])).toBe(
500,
);
});
it('never returns zero', () => {
expect(traceTotalMs(0, [])).toBeGreaterThan(0);
});
});
describe('barGeometry', () => {
it('positions a span as percentages of the total', () => {
expect(barGeometry(span({ offset_ms: 25, duration_ms: 50 }), 100)).toEqual({
left: 25,
width: 50,
});
});
it('keeps tiny spans visible and inside the track', () => {
const tiny = barGeometry(span({ offset_ms: 100, duration_ms: 0 }), 100);
expect(tiny.width).toBeGreaterThan(0);
expect(tiny.left + tiny.width).toBeLessThanOrEqual(100);
});
});
describe('formatDurationMs', () => {
it.each([
[0.4, '<1 ms'],
[12.4, '12 ms'],
[999, '999 ms'],
[1234, '1.23 s'],
[15432, '15.4 s'],
[65000, '1m 05s'],
])('%s -> %s', (ms, expected) => {
expect(formatDurationMs(ms)).toBe(expected);
});
it('shows a dash for missing values', () => {
expect(formatDurationMs(undefined)).toBe('—');
});
});
describe('formatTokens', () => {
it('abbreviates large counts', () => {
expect(formatTokens(950)).toBe('950');
expect(formatTokens(12345)).toBe('12.3k');
expect(formatTokens(2_500_000)).toBe('2.5M');
});
});
const HEADLINES: Record<string, string> = {
'settings.logs.trace.headline.tokens': '{{input}} → {{output}} tok',
'settings.logs.trace.headline.chunks': '{{count}} chunks',
'settings.logs.trace.headline.hits': '{{count}} hits',
'settings.logs.trace.headline.cached': 'cached',
};
const t = (key: string, options: Record<string, unknown> = {}) =>
(HEADLINES[key] ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name) =>
String(options[name]),
);
describe('spanHeadline', () => {
it('summarises an llm span by tokens', () => {
expect(
spanHeadline(
span({
attributes: {
'gen_ai.usage.input_tokens': 1200,
'gen_ai.usage.output_tokens': 80,
},
}),
t,
),
).toBe('1.2k → 80 tok');
});
it('summarises a retrieval span by chunk count', () => {
expect(
spanHeadline(
span({ kind: 'retrieval', attributes: { 'docsgpt.chunk_count': 4 } }),
t,
),
).toBe('4 chunks');
});
it('is empty when nothing is known', () => {
expect(spanHeadline(span({ kind: 'step' }), t)).toBe('');
});
it('labels a cached llm call', () => {
expect(
spanHeadline(span({ attributes: { 'docsgpt.cache_hit': true } }), t),
).toBe('cached');
});
});
+140
View File
@@ -0,0 +1,140 @@
import { TraceSpan } from '../types';
export type SpanRow = {
span: TraceSpan;
depth: number;
hasChildren: boolean;
};
/**
* Flatten a trace's spans into display rows: depth-first, children in start
* order under their parent. A span whose parent is missing (dropped by the
* span cap) is shown at the top level rather than hidden.
*/
export function buildSpanRows(spans: TraceSpan[]): SpanRow[] {
const ids = new Set(spans.map((s) => s.id));
const children = new Map<string | null, TraceSpan[]>();
for (const span of spans) {
const parent =
span.parent_id && ids.has(span.parent_id) ? span.parent_id : null;
const list = children.get(parent) ?? [];
list.push(span);
children.set(parent, list);
}
for (const list of children.values()) {
list.sort((a, b) => a.offset_ms - b.offset_ms);
}
const rows: SpanRow[] = [];
const visit = (parent: string | null, depth: number) => {
for (const span of children.get(parent) ?? []) {
const kids = children.get(span.id);
rows.push({ span, depth, hasChildren: Boolean(kids?.length) });
visit(span.id, depth + 1);
}
};
visit(null, 0);
return rows;
}
/** Width of the timeline: the stored duration, or the last span end if later. */
export function traceTotalMs(
durationMs: number | undefined,
spans: TraceSpan[],
): number {
const lastEnd = spans.reduce(
(max, s) => Math.max(max, (s.offset_ms || 0) + (s.duration_ms || 0)),
0,
);
return Math.max(durationMs || 0, lastEnd, 1);
}
const MIN_BAR_PERCENT = 0.6;
/** A span's bar as left offset and width, in percent of the timeline. */
export function barGeometry(
span: TraceSpan,
totalMs: number,
): { left: number; width: number } {
const total = totalMs > 0 ? totalMs : 1;
const width = Math.max(
((span.duration_ms || 0) / total) * 100,
MIN_BAR_PERCENT,
);
const left = Math.min(
Math.max(((span.offset_ms || 0) / total) * 100, 0),
100 - width,
);
return { left: round(left), width: round(width) };
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
/** Human duration: `<1 ms`, `12 ms`, `1.23 s`, `15.4 s`, `1m 05s`. */
export function formatDurationMs(ms: number | null | undefined): string {
if (ms === null || ms === undefined || Number.isNaN(ms)) return '—';
if (ms < 1) return '<1 ms';
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 10_000) return `${(ms / 1000).toFixed(2)} s`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
}
/** Token counts abbreviated: `950`, `12.3k`, `2.5M`. */
export function formatTokens(n: number | null | undefined): string {
const value = n ?? 0;
if (value >= 1_000_000) return `${trimZero((value / 1_000_000).toFixed(1))}M`;
if (value >= 1000) return `${trimZero((value / 1000).toFixed(1))}k`;
return String(Math.round(value));
}
function trimZero(s: string): string {
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
function num(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
type Translate = (key: string, options?: Record<string, unknown>) => string;
/**
* A short, kind-specific summary shown next to a span's name, e.g.
* `1.2k → 80 tok` for an LLM call or `4 chunks` for a retrieval.
*/
export function spanHeadline(span: TraceSpan, t: Translate): string {
const a = span.attributes || {};
switch (span.kind) {
case 'llm': {
if (a['docsgpt.cache_hit'])
return t('settings.logs.trace.headline.cached');
const input = num(a['gen_ai.usage.input_tokens']);
const output = num(a['gen_ai.usage.output_tokens']);
if (input === undefined && output === undefined) return '';
return t('settings.logs.trace.headline.tokens', {
input: formatTokens(input),
output: formatTokens(output),
});
}
case 'retrieval':
case 'rerank': {
const chunks = num(a['docsgpt.chunk_count'] ?? a['docsgpt.kept_count']);
return chunks === undefined
? ''
: t('settings.logs.trace.headline.chunks', { count: chunks });
}
case 'search': {
const hits = num(a['docsgpt.candidate_count']);
return hits === undefined
? ''
: t('settings.logs.trace.headline.hits', { count: hits });
}
default:
return '';
}
}
+76 -1
View File
@@ -17,7 +17,74 @@ export type ChunkType = {
};
export type LogEventType =
'chat' | 'schedule' | 'webhook' | 'workflow' | 'system';
'chat' | 'schedule' | 'webhook' | 'workflow' | 'system' | 'search' | 'graph';
/** Counts rolled up from a trace's spans; every field may be absent. */
export type TraceCounts = {
llm_calls?: number;
tool_calls?: number;
retrieval_calls?: number;
retrieval_ms?: number;
input_tokens?: number;
output_tokens?: number;
errors?: number;
};
/** The id a Logs row's traces are looked up by. */
export type TraceRef = {
field: 'id' | 'request_id' | 'message_id' | 'activity_id' | 'workflow_run_id';
value: string;
};
/** Trace summary attached to a Logs row (merged over its rounds). */
export type LogTraceSummary = {
ref: TraceRef;
count: number;
duration_ms: number;
status?: string;
started_at?: string;
summary: TraceCounts;
};
export type TraceSpanKind =
| 'agent'
| 'llm'
| 'tool'
| 'retrieval'
| 'search'
| 'embedding'
| 'rerank'
| 'guardrail'
| 'step';
export type TraceSpan = {
id: string;
parent_id: string | null;
kind: TraceSpanKind | string;
name: string;
status: string;
offset_ms: number;
duration_ms: number;
attributes: Record<string, unknown>;
preview?: Record<string, unknown>;
error?: string;
};
export type Trace = {
id: string;
request_id?: string | null;
message_id?: string | null;
source: string;
name?: string | null;
status: string;
started_at: string;
duration_ms: number;
span_count: number;
dropped_spans: number;
summary: TraceCounts;
spans: TraceSpan[];
otel_trace_id?: string | null;
};
export type LogData = {
id: string;
@@ -33,9 +100,17 @@ export type LogData = {
tool_calls?: Record<string, any>[];
agent_id?: string;
attachments?: string[];
request_id?: string;
message_id?: string;
// system + webhook events (stack_logs)
endpoint?: string;
stacks?: Record<string, any>[];
activity_id?: string;
// search + graph events (request_traces)
source?: string;
duration_ms?: number;
// Present when the row has a stored execution trace.
trace?: LogTraceSummary;
// workflow events (workflow_runs)
workflow_name?: string;
result?: Record<string, any>;