From a7b2d2f79a8484857740bbd734e62d5660b1ce87 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:17:00 -0500 Subject: [PATCH 1/4] feat(agents): inspect runs and preserve observability context --- frontend/README.md | 2 +- frontend/docs/api-contracts.md | 2 +- .../agents-runs/run-detail-desktop.png | Bin 0 -> 110063 bytes .../agents-runs/run-detail-mobile.png | Bin 0 -> 99872 bytes .../agents-runs/run-list-desktop.png | Bin 0 -> 98970 bytes frontend/docs/prism-parity.md | 18 +- frontend/docs/validation-review.md | 22 + frontend/e2e/agents-navigation.spec.ts | 394 ++++++++++ frontend/e2e/agents.spec.ts | 699 +++++++++++++++++- frontend/e2e/app.spec.ts | 7 +- frontend/e2e/classic-links.spec.ts | 26 +- frontend/e2e/logs-depth-handoffs.spec.ts | 13 +- frontend/e2e/native-regressions.spec.ts | 15 +- frontend/src/app/Sidebar.tsx | 38 +- .../src/features/agents/AgentRunDetails.tsx | 273 +++++++ .../features/agents/AgentsPage.stories.tsx | 40 +- frontend/src/features/agents/AgentsPage.tsx | 405 +++++++--- frontend/src/features/agents/agents.css | 227 ++++++ frontend/src/features/logs/LogsPage.tsx | 242 +++++- .../src/features/traces/TraceDetailSheet.tsx | 19 + frontend/src/features/traces/TracesPage.tsx | 59 +- frontend/src/lib/agentRuns.test.ts | 549 ++++++++++++++ frontend/src/lib/agentRuns.ts | 510 +++++++++++++ frontend/src/lib/agents.live.test.ts | 321 ++++++++ frontend/src/lib/agents.test.ts | 50 +- frontend/src/lib/agents.ts | 134 +++- .../src/lib/observabilityNavigation.test.ts | 156 ++++ frontend/src/lib/observabilityNavigation.ts | 233 ++++++ frontend/story-tests/native-screens.spec.ts | 15 +- 29 files changed, 4201 insertions(+), 268 deletions(-) create mode 100644 frontend/docs/parity-screenshots/agents-runs/run-detail-desktop.png create mode 100644 frontend/docs/parity-screenshots/agents-runs/run-detail-mobile.png create mode 100644 frontend/docs/parity-screenshots/agents-runs/run-list-desktop.png create mode 100644 frontend/e2e/agents-navigation.spec.ts create mode 100644 frontend/src/features/agents/AgentRunDetails.tsx create mode 100644 frontend/src/lib/agentRuns.test.ts create mode 100644 frontend/src/lib/agentRuns.ts create mode 100644 frontend/src/lib/agents.live.test.ts create mode 100644 frontend/src/lib/observabilityNavigation.test.ts create mode 100644 frontend/src/lib/observabilityNavigation.ts diff --git a/frontend/README.md b/frontend/README.md index 39f8084a5..33fce92ff 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -66,7 +66,7 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind - Datasets: search, create logs/metrics/traces with an optional custom partition, manage information and ingestion/storage statistics, inspect schema, explore, set/clear retention, configure/disable hot tier when the server enables it, and delete with typed name confirmation. Time partitions cannot be set at creation. Static schema upload, tags/labels, existing partition editing and index management are not supported. - Dashboards: server-backed lists and detail routes, search/tags/favourites/sort/pagination, ownership, full-document saves with conflict resolution, SQL and PromQL tiles, six variable types, URL time ranges and selections, classic import/export, and explicit migration of old browser-local dashboards. Demo dashboards use independent in-memory state per client. - Team: native users and one-time passwords, role and privilege management, default OIDC role configuration, provider group mappings and role provenance, and API key creation/copy/deletion with configurable expiration from 30 minutes to 90 days or no expiration. New UI-created keys default to 90 days; existing keys keep their original lifetime. Search and 25-row pagination apply to every tab; authorization stays with the backend. -- Agents: schema-discovered GenAI telemetry datasets, bounded recent records, time ranges, local search, record inspection and trace handoff. +- Agents: schema-discovered GenAI datasets, related spans grouped into runs, successful/failed/incomplete status, observed duration, models and recorded token usage. Run details include the span tree, model and tool calls, errors, captured conversation and raw fields. Search/status/model filters and captured time bounds survive navigation between Agents, Traces and Logs. Missing telemetry and bounded samples are explicit; evaluations and cost estimates remain deferred. - Cluster: registered node reachability and resource metrics with independent request states; read-only. - Errors: grouped issues, full-range frequency analytics, and occurrence inspection for OTLP traces and recognized log severity schemas, plus paginated OTLP error spans and trace handoff. Groups match exact service, error type and message values; issue assignment and resolution are not persisted. - Workspace setup: `/onboarding` discovers accessible datasets and opens the matching explorer; `/new` explains server scope and provides connection/setup actions. Hosted workspace creation is not exposed by this server. diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index c562fbcc8..0ae0cf18d 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -215,7 +215,7 @@ See [observability validation](observability-validation.md) for bounds, caps, er ## Native operations and setup screens -Agents uses existing dataset list, schema, metadata and SQL requests. Discovery recognizes flattened `gen_ai.agent.name`, `gen_ai.agent.id`, `gen_ai.operation.name`, `gen_ai.request.model` and `gen_ai.tool.name`; direct dataset links defer the full schema scan until the selector is focused or rescanned. Discovery is limited to four concurrent schema requests and skips forbidden/missing datasets. Queries quote dataset/field identifiers, use the dataset time partition, and return at most 100 recent non-null GenAI records in captured UTC bounds. Search applies only to those loaded records. Run aggregation, evaluations and pricing are not inferred. Unknown demo dataset names cannot return synthetic records. +Agents uses existing dataset list, schema, metadata and SQL requests. Discovery recognizes flattened `gen_ai.agent.name`, `gen_ai.agent.id`, `gen_ai.operation.name`, `gen_ai.request.model`, `gen_ai.response.model` and `gen_ai.tool.name`; direct dataset links defer the full schema scan until the selector is focused or rescanned. Discovery is limited to four concurrent schema requests and skips forbidden/missing datasets. Queries quote dataset/field identifiers and use the dataset time partition. Up to 100 recent trace IDs containing GenAI telemetry seed a query for related spans, including children without GenAI attributes; the outer response is bounded to 1,000 flattened records. Explicit half-open UTC predicates preserve exact time bounds inside the minute-expanded query envelope. Search, status and model filters apply to the loaded runs. Run normalization deduplicates span identities before counting usage, preserves event content and displays absent values distinctly from zero. A unique outer agent span can certify recovery within its descendants: explicit completed OK permits recovered child errors, which remain visible. Failed or incomplete ancestors and separate roots retain their outcome. Completed UNSET spans are accepted; missing status or completion remains incomplete. Missing identity, parents, clipped time coverage and the separate 100-trace / 1,000-record caps are disclosed. Evaluations and cost estimates remain deferred until telemetry and pricing inputs are reliable. Unknown demo dataset names cannot return synthetic records. Cluster reads `GET /api/v1/cluster/info` and `/api/v1/cluster/metrics` through the shared authenticated client. The first returns node identity, storage/staging paths, reachability and nullable status/error text. Metrics are independent: a denied metrics request does not hide node health. Nonfinite Rust metric values serialized as null display as unavailable per field. Missing cluster routes (404/405) show a deployment-mode explanation; 403 and 401 retain shared permission/session handling. No node deletion is exposed. diff --git a/frontend/docs/parity-screenshots/agents-runs/run-detail-desktop.png b/frontend/docs/parity-screenshots/agents-runs/run-detail-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..52d4b01a7323e3bcc223bbbd1f915e28d5c3875f GIT binary patch literal 110063 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 + ) : ( +

No errors reported in the loaded spans.

+ ), + }, + { + value: 'conversation', + label: 'Conversation', + content: conversation.length ? ( +
+ {conversation.map((span) => ( +
+

{span.name}

+ {span.conversation.map((entry, index) => ( +
+

{entry.label}

+
{capturedValue(entry.value)}
+
+ ))} +
+ ))} +
+ ) : ( +

+ No conversation content captured in these spans. Content is shown only when your + instrumentation records it; some exporters store messages in a separate logs + dataset. +

+ ), + }, + ]} + /> + {selected && ( +
+
+

{selected.name}

+ +
+
+
+
Span ID
+
+ {selected.spanId ?? 'Not recorded'} +
+
+
+
Parent span ID
+
+ {selected.parentSpanId ?? 'Not recorded'} +
+
+
+
Type
+
{selected.kind}
+
+
+
Duration
+
{formatAgentDuration(selected.durationMs)}
+
+
+
Model / tool
+
{selected.model ?? selected.toolName ?? 'Not recorded'}
+
+
+
Token usage
+
+ +
+
+
+
+ + Recorded fields ({selected.rows.length}{' '} + {selected.rows.length === 1 ? 'record' : 'records'}) + + {selected.rows.map((row, index) => ( +
+                {JSON.stringify(row, null, 2)}
+              
+ ))} +
+
+ )} + + ); +} diff --git a/frontend/src/features/agents/AgentsPage.stories.tsx b/frontend/src/features/agents/AgentsPage.stories.tsx index 8a485c8e8..5934dd714 100644 --- a/frontend/src/features/agents/AgentsPage.stories.tsx +++ b/frontend/src/features/agents/AgentsPage.stories.tsx @@ -5,6 +5,7 @@ import { AppContext } from '../../app/AppProvider'; import { createDemoClient } from '../../lib/demo'; import { ApiError } from '../../lib/apiError'; import { agentDemoFields, agentDemoRows } from '../../lib/agents'; +import { buildAgentRuns } from '../../lib/agentRuns'; import { AgentsPage } from './AgentsPage'; type State = @@ -15,7 +16,23 @@ type State = | 'loading' | 'no-datasets' | 'missing-fields' - | 'demo'; + | 'demo' + | 'success' + | 'failed' + | 'incomplete' + | 'missing-telemetry' + | 'run-details'; +function exampleRows(state: State) { + if (state === 'missing-telemetry') + return [ + { + p_timestamp: new Date().toISOString(), + 'gen_ai.agent.name': 'Sparse assistant', + 'gen_ai.operation.name': 'invoke_agent', + }, + ]; + return agentDemoRows(); +} function Example({ state }: { state: State }) { const client = useMemo( () => ({ @@ -46,7 +63,7 @@ function Example({ state }: { state: State }) { }); if (state === 'permission') throw new ApiError('Access to agent_telemetry is denied', 403); if (state === 'error') throw new ApiError('Query node unavailable', 503); - return state === 'empty' ? [] : agentDemoRows(); + return state === 'empty' ? [] : exampleRows(state); }, }), [state], @@ -58,7 +75,19 @@ function Example({ state }: { state: State }) { { path: '/agents', element: }, { path: '/agents/:dataset', element: }, ], - { initialEntries: [state === 'no-datasets' ? '/agents' : '/agents/agent_telemetry'] }, + { + initialEntries: [ + state === 'no-datasets' + ? '/agents' + : `/agents/agent_telemetry${ + state === 'run-details' + ? `?${new URLSearchParams({ run: buildAgentRuns(exampleRows(state))[0].id })}` + : ['success', 'failed', 'incomplete'].includes(state) + ? `?status=${state}` + : '' + }`, + ], + }, ), [state], ); @@ -94,3 +123,8 @@ export const Loading: Story = { args: { state: 'loading' } }; export const NoDatasets: Story = { args: { state: 'no-datasets' } }; export const MissingFields: Story = { args: { state: 'missing-fields' } }; export const Demo: Story = { args: { state: 'demo' } }; +export const SuccessfulRuns: Story = { args: { state: 'success' } }; +export const FailedRuns: Story = { args: { state: 'failed' } }; +export const IncompleteRuns: Story = { args: { state: 'incomplete' } }; +export const MissingTelemetry: Story = { args: { state: 'missing-telemetry' } }; +export const RunDetails: Story = { args: { state: 'run-details' } }; diff --git a/frontend/src/features/agents/AgentsPage.tsx b/frontend/src/features/agents/AgentsPage.tsx index 34b6edb91..19dd84d64 100644 --- a/frontend/src/features/agents/AgentsPage.tsx +++ b/frontend/src/features/agents/AgentsPage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useState } from 'react'; -import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Bot, RefreshCw } from 'lucide-react'; import { Badge, Button, EmptyState, Input, Select, Sheet } from '../../components/ui'; import { PageHeader } from '../../components/explorer/PageHeader'; @@ -9,35 +9,46 @@ import { useApp } from '../../app/AppProvider'; import { useAsync } from '../../hooks/useAsync'; import { AGENT_RECORD_LIMIT, + AGENT_RUN_LIMIT, agentDemoRows, agentRangeFromParams, agentBoundsFromParams, agentsPath, - agentText, - buildAgentQuery, + buildAgentRunQuery, discoverAgentDatasets, } from '../../lib/agents'; +import { buildAgentRuns } from '../../lib/agentRuns'; +import { + currentObservabilitySearch, + useObservabilityNavigation, +} from '../../lib/observabilityNavigation'; import { timeBounds } from '../../lib/query'; -import type { LogRecord, TimeRange } from '../../lib/types'; +import type { TimeRange } from '../../lib/types'; +import { AgentRunDetails, AgentStatus, AgentTokens, formatAgentDuration } from './AgentRunDetails'; import './agents.css'; export function AgentsPage() { const { client, mode } = useApp(); const { dataset } = useParams<{ dataset: string }>(); const navigate = useNavigate(); + const location = useLocation(); const [params, setParams] = useSearchParams(); const searchString = params.toString(); + // Filter edits must not recapture a moving relative time range and rerun the query. + const timeString = JSON.stringify([params.get('start'), params.get('end'), params.get('range')]); const range = useMemo( () => agentRangeFromParams(new URLSearchParams(searchString)), - [searchString], + [timeString], ); - const bounds = useMemo( + const rawBounds = useMemo( () => agentBoundsFromParams(new URLSearchParams(searchString)), - [searchString], + [timeString], ); - const suffix = `?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime, ...(typeof range === 'string' ? { range } : {}) })}`; - const [search, setSearch] = useState(''); - const [selected, setSelected] = useState(); + const boundsKey = JSON.stringify(rawBounds); + const bounds = useMemo(() => JSON.parse(boundsKey) as ReturnType, [boundsKey]); + const search = params.get('search') ?? ''; + const status = params.get('status') ?? ''; + const model = params.get('model') ?? ''; const [discoveryRequested, setDiscoveryRequested] = useState(false); const discoveryEnabled = !dataset || discoveryRequested || mode === 'demo'; const discovery = useAsync( @@ -58,7 +69,7 @@ export function AgentsPage() { if (mode === 'demo') { if (dataset !== 'agent_telemetry') throw new Error( - 'This dataset is not available in the demo workspace. Select agent_telemetry to explore sample records.', + 'This dataset is not available in the demo workspace. Select agent_telemetry to explore sample runs.', ); return { rows: agentDemoRows(Date.parse(bounds.endTime)).filter( @@ -74,7 +85,7 @@ export function AgentsPage() { ]); const timeColumn = info.timePartition || 'p_timestamp'; const rows = await client.query( - buildAgentQuery(dataset, fields, bounds, timeColumn), + buildAgentRunQuery(dataset, fields, bounds, timeColumn), signal, ); return { rows, timeColumn, traces: info.logSourceFormats.includes('otel-traces') }; @@ -82,42 +93,77 @@ export function AgentsPage() { [client, mode, dataset, bounds], ), ); + const authorizedDataset = useRef(undefined); + if (result.data) authorizedDataset.current = dataset; + const { href } = useObservabilityNavigation({ + view: 'agents', + dataset, + range, + bounds, + params, + enabled: !!dataset && authorizedDataset.current === dataset, + }); + const runs = useMemo( + () => + buildAgentRuns(result.data?.rows ?? [], result.data?.timeColumn, { + limitReached: (result.data?.rows.length ?? 0) >= AGENT_RECORD_LIMIT, + bounds, + }), + [result.data, bounds], + ); + const visibleRuns = useMemo( + () => + runs.filter( + (run) => + (!status || run.status === status) && + (!model || run.models.includes(model)) && + (!search || + run.spans.some((span) => + span.rows.some((row) => + JSON.stringify(row).toLowerCase().includes(search.toLowerCase()), + ), + )), + ), + [runs, search, status, model], + ); + const selected = runs.find((run) => run.id === params.get('run')); + const selectedTraceId = + selected?.traceId && /^[a-fA-F0-9]{32}$/.test(selected.traceId) ? selected.traceId : undefined; + const updateFilter = (key: string, value: string) => { + const next = new URLSearchParams( + currentObservabilitySearch(location.pathname, params.toString()), + ); + if (value) next.set(key, value); + else next.delete(key); + setParams(next, { replace: true }); + }; + const closeRun = () => { + const next = new URLSearchParams( + currentObservabilitySearch(location.pathname, params.toString()), + ); + next.delete('run'); + setParams(next, { replace: true }); + }; const changeRange = (value: TimeRange) => { - const next = new URLSearchParams(params), + const next = new URLSearchParams( + currentObservabilitySearch(location.pathname, params.toString()), + ), captured = timeBounds(value); next.set('start', captured.startTime); next.set('end', captured.endTime); if (typeof value === 'string') next.set('range', value); else next.delete('range'); + next.delete('run'); setParams(next); - setSelected(undefined); }; - const rows = - result.data?.rows.filter((row) => - Object.values(row).some( - (value) => typeof value === 'string' && value.toLowerCase().includes(search.toLowerCase()), - ), - ) ?? []; + const models = [...new Set(runs.flatMap((run) => run.models))].sort(); return ( + ); + } + return ( +
+

+ Connect Claude Code or Codex through an OpenTelemetry collector, then map real telemetry + into the Agents span format. +

+ { + setProvider(value as TelemetryProvider); + setCapture(false); + }} + aria-label="Coding agent" + items={[ + { value: 'claude', label: 'Claude Code', content: providerContent('claude') }, + { value: 'codex', label: 'Codex', content: providerContent('codex') }, + ]} + /> +
+ ); +} diff --git a/frontend/src/features/agents/AgentsPage.stories.tsx b/frontend/src/features/agents/AgentsPage.stories.tsx index 5934dd714..26f78d531 100644 --- a/frontend/src/features/agents/AgentsPage.stories.tsx +++ b/frontend/src/features/agents/AgentsPage.stories.tsx @@ -21,6 +21,7 @@ type State = | 'failed' | 'incomplete' | 'missing-telemetry' + | 'setup' | 'run-details'; function exampleRows(state: State) { if (state === 'missing-telemetry') @@ -77,15 +78,17 @@ function Example({ state }: { state: State }) { ], { initialEntries: [ - state === 'no-datasets' - ? '/agents' - : `/agents/agent_telemetry${ - state === 'run-details' - ? `?${new URLSearchParams({ run: buildAgentRuns(exampleRows(state))[0].id })}` - : ['success', 'failed', 'incomplete'].includes(state) - ? `?status=${state}` - : '' - }`, + state === 'setup' + ? '/agents?setup=1' + : state === 'no-datasets' + ? '/agents' + : `/agents/agent_telemetry${ + state === 'run-details' + ? `?${new URLSearchParams({ run: buildAgentRuns(exampleRows(state))[0].id })}` + : ['success', 'failed', 'incomplete'].includes(state) + ? `?status=${state}` + : '' + }`, ], }, ), @@ -128,3 +131,4 @@ export const FailedRuns: Story = { args: { state: 'failed' } }; export const IncompleteRuns: Story = { args: { state: 'incomplete' } }; export const MissingTelemetry: Story = { args: { state: 'missing-telemetry' } }; export const RunDetails: Story = { args: { state: 'run-details' } }; +export const TelemetrySetup: Story = { args: { state: 'setup' } }; diff --git a/frontend/src/features/agents/AgentsPage.tsx b/frontend/src/features/agents/AgentsPage.tsx index 19dd84d64..7c661553d 100644 --- a/frontend/src/features/agents/AgentsPage.tsx +++ b/frontend/src/features/agents/AgentsPage.tsx @@ -25,6 +25,7 @@ import { import { timeBounds } from '../../lib/query'; import type { TimeRange } from '../../lib/types'; import { AgentRunDetails, AgentStatus, AgentTokens, formatAgentDuration } from './AgentRunDetails'; +import { AgentTelemetryGuide } from './AgentTelemetryGuide'; import './agents.css'; export function AgentsPage() { @@ -49,6 +50,7 @@ export function AgentsPage() { const search = params.get('search') ?? ''; const status = params.get('status') ?? ''; const model = params.get('model') ?? ''; + const setupOpen = params.get('setup') === '1'; const [discoveryRequested, setDiscoveryRequested] = useState(false); const discoveryEnabled = !dataset || discoveryRequested || mode === 'demo'; const discovery = useAsync( @@ -162,7 +164,12 @@ export function AgentsPage() { {mode === 'demo' ? 'Demo telemetry' : 'Live data'}} + actions={ + <> + {mode === 'demo' ? 'Demo telemetry' : 'Live data'} + + + } />
+ params.has(name), + ); + const linkedRange = hasTimeContext ? observabilityRange(params) : linkedView.range; setHandoffBounds({ range: JSON.stringify(linkedRange), bounds: observabilityBounds(params, linkedRange), diff --git a/frontend/src/features/traces/TracesPage.tsx b/frontend/src/features/traces/TracesPage.tsx index 615ab21d1..6a129ce43 100644 --- a/frontend/src/features/traces/TracesPage.tsx +++ b/frontend/src/features/traces/TracesPage.tsx @@ -172,7 +172,8 @@ function TracesExplorer({ dataset, datasets }: { dataset: string; datasets: Data [range, refresh, params.get('start'), params.get('end')], ); const boundsKey = JSON.stringify(rawBounds); - const bounds = useMemo(() => JSON.parse(boundsKey) as typeof rawBounds, [boundsKey]); + // A refresh must also reload requests whose absolute bounds have not changed. + const bounds = useMemo(() => JSON.parse(boundsKey) as typeof rawBounds, [boundsKey, refresh]); const [serviceInput, setServiceInput] = useState(service); const [lookup, setLookup] = useState(traceId); useEffect(() => setServiceInput(service), [service]); @@ -273,8 +274,7 @@ function TracesExplorer({ dataset, datasets }: { dataset: string; datasets: Data if (typeof range === 'string') { const refreshed = timeBounds(range); update({ start: refreshed.startTime, end: refreshed.endTime }, false); - } - setRefresh((value) => value + 1); + } else setRefresh((value) => value + 1); }} >
{mode === 'demo' ? 'Demo telemetry' : 'Live data'}} /> -

- Recent telemetry records, including individual spans and events. Run-level analytics and - model pricing are unavailable in this edition. -

- {[...params.keys()].some((key) => !['start', 'end', 'range'].includes(key)) && ( -

- This link includes options that are not applied here:{' '} - {[...new Set(params.keys())] - .filter((key) => !['start', 'end', 'range'].includes(key)) - .join(', ')} - . Use the native time range and search controls to refine these records. -

- )}
setSearch(event.target.value)} - /> - {result.data.rows.length} records loaded + <> +
+ {[ + ['Runs', runs.length], + ['Successful', runs.filter((run) => run.status === 'success').length], + ['Failed', runs.filter((run) => run.status === 'failed').length], + ['Incomplete', runs.filter((run) => run.status === 'incomplete').length], + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))}
-

- Showing up to {AGENT_RECORD_LIMIT} recent records in the selected time range. Search - filters loaded records. -

- {!rows.length ? ( - - ) : ( -
- - - - {['Time', 'Agent', 'Operation', 'Model / tool', 'Status', 'Details'].map( - (label) => ( +
+
+ updateFilter('search', event.target.value)} + /> + + + + {visibleRuns.length} of {runs.length} runs + +
+

+ Related spans are grouped by trace or explicit run ID. Filters apply to loaded + runs. Up to {AGENT_RUN_LIMIT} recent traces and{' '} + {AGENT_RECORD_LIMIT.toLocaleString()} + records are loaded in this time range; missing or partial telemetry is marked in + run details. +

+ {runs.filter((run) => run.traceId).length >= AGENT_RUN_LIMIT && ( +

+ The {AGENT_RUN_LIMIT}-trace limit was reached. Older runs may be omitted; narrow + the time range to explore them. +

+ )} + {result.data.rows.length >= AGENT_RECORD_LIMIT && ( +

+ The record limit was reached. Runs may have additional spans or events; narrow + the time range to improve coverage. +

+ )} + {!visibleRuns.length ? ( + + ) : ( +
+
+ + + {[ + 'Run', + 'Status', + 'Started (UTC)', + 'Duration', + 'Model', + 'Tokens', + 'Spans', + 'Details', + ].map((label) => ( - ), - )} - - - - {rows.map((row, index) => ( - - - - - - - + ))} - ))} - -
{label}
{agentText(row, result.data!.timeColumn)}{agentText(row, 'gen_ai.agent.name', 'gen_ai.agent.id')}{agentText(row, 'gen_ai.operation.name', 'span_name')}{agentText(row, 'gen_ai.tool.name', 'gen_ai.request.model')} - {row.span_status_code === 2 - ? 'Error' - : row.span_status_code === 1 - ? 'OK' - : '—'} - - -
-
- )} - + + + {visibleRuns.map((run) => ( + + + {run.name} + {!!run.warnings.length && ( + Partial telemetry + )} + + + + + {run.startTime ?? 'Not recorded'} + {formatAgentDuration(run.durationMs)} + {run.models.join(', ') || 'Not recorded'} + + + + {run.spans.length} + + + + + ))} + + +
+ )} + + )} )} { - if (!open) setSelected(undefined); + if (!open) closeRun(); }} - title="Agent telemetry record" + title="Agent run details" + className="agents-run-sheet" > {selected && ( <> - {result.data?.traces && - typeof selected.span_trace_id === 'string' && - /^[a-fA-F0-9]{32}$/.test(selected.span_trace_id) && ( + {mode !== 'demo' && selectedTraceId && ( + + )} + )} diff --git a/frontend/src/features/agents/agents.css b/frontend/src/features/agents/agents.css index b043295c8..16c20e599 100644 --- a/frontend/src/features/agents/agents.css +++ b/frontend/src/features/agents/agents.css @@ -62,3 +62,230 @@ padding: 12px; } } +.agents-related-links { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin: 8px 0 16px; + font-size: 13px; +} +.agents-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 16px 0; +} +.agents-summary > div { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: 12px; +} +.agents-summary span { + color: var(--muted); + font-size: 13px; +} +.agents-summary strong { + font-size: 24px; + font-weight: 600; +} +.agents-tokens { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + font-variant-numeric: tabular-nums; +} +.agents-row-warning { + display: block; + color: var(--muted); + font-size: 11px; + margin-top: 6px; +} +.agents-run-sheet { + width: min(920px, 100vw); +} +.agents-run-details { + min-width: 0; +} +.agents-detail-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.agents-detail-heading h2, +.agents-detail-heading h3 { + margin: 0; + overflow-wrap: anywhere; +} +.agents-detail-heading h2 { + font-size: 18px; +} +.agents-detail-heading h3 { + font-size: 15px; +} +.agents-run-id { + font-size: 12px; + color: var(--muted); + overflow-wrap: anywhere; +} +.agents-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin: 20px 0; + font-size: 13px; +} +.agents-facts dt { + color: var(--muted); + margin-bottom: 6px; +} +.agents-facts dd { + margin: 0; + overflow-wrap: anywhere; +} +.agents-warnings { + padding: 12px 16px; + background: var(--color-surface-hover); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; + line-height: 1.6; + margin: 16px 0; +} +.agents-warnings ul { + margin: 6px 0 0; + padding-left: 20px; +} +.agents-run-details .ui-tabs-list { + overflow-x: auto; + max-width: 100%; +} +.agents-run-details .ui-tabs-trigger { + flex-shrink: 0; + white-space: nowrap; +} +.agents-span-tree, +.agents-call-list, +.agents-error-list { + list-style: none; + margin: 0; + padding: 0; +} +.agents-span-tree .agents-span-tree { + margin-left: 16px; + padding-left: 8px; + border-left: 1px solid var(--border); +} +.agents-span-button { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid transparent; + border-radius: 6px; + padding: 10px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 12px; + cursor: pointer; + text-align: left; +} +.agents-span-button:hover, +.agents-span-button[aria-pressed='true'] { + background: var(--color-surface-hover); + border-color: var(--border); +} +.agents-span-button:focus-visible, +.agents-raw-fields summary:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} +.agents-span-name, +.agents-span-meta { + display: flex; + align-items: center; + gap: 8px; +} +.agents-span-name { + overflow-wrap: anywhere; + min-width: 0; +} +.agents-span-name svg { + flex-shrink: 0; + color: var(--muted); +} +.agents-span-meta { + flex-shrink: 0; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.agents-call-list > li, +.agents-error-list > li { + border-bottom: 1px solid var(--border); + padding: 6px 0; +} +.agents-call-facts { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 6px 10px 12px; + color: var(--muted); + font-size: 12px; +} +.agents-selected-span { + border-top: 1px solid var(--border); + margin-top: 20px; + padding-top: 20px; +} +.agents-raw-fields summary { + cursor: pointer; + color: var(--muted); + font-size: 12px; + padding: 8px 0; +} +.agents-raw-fields pre, +.agents-message, +.agents-error-list pre { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--color-surface-hover); + padding: 12px; +} +.agents-conversation h3 { + font-size: 14px; +} +.agents-message { + margin: 12px 0; +} +.agents-message h4 { + font-size: 12px; + color: var(--muted); + margin: 0 0 8px; +} +.agents-message pre { + margin: 0; +} +@media (max-width: 600px) { + .agents-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .agents-span-button { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .agents-facts { + grid-template-columns: 1fr; + } + .agents-span-tree .agents-span-tree { + margin-left: 8px; + padding-left: 4px; + } +} diff --git a/frontend/src/features/logs/LogsPage.tsx b/frontend/src/features/logs/LogsPage.tsx index 5a531588f..6ae2d56ba 100644 --- a/frontend/src/features/logs/LogsPage.tsx +++ b/frontend/src/features/logs/LogsPage.tsx @@ -1,7 +1,14 @@ import { createPortal } from 'react-dom'; import { createId } from '../../lib/ids'; -import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; -import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { + Link, + useLocation, + useNavigate, + useNavigationType, + useParams, + useSearchParams, +} from 'react-router-dom'; import { PanelLeft, RefreshCw, Terminal, WrapText, Download } from 'lucide-react'; import { Button, EmptyState, Select, Dialog, Input } from '../../components/ui'; import { LinkNotice } from '../../components/explorer/LinkNotice'; @@ -31,7 +38,14 @@ import { import { useApp } from '../../app/AppProvider'; import { ApiError } from '../../lib/client'; import { useAsync } from '../../hooks/useAsync'; -import { timeBounds } from '../../lib/query'; +import { timeBounds, quoteIdentifier, quoteLiteral } from '../../lib/query'; +import { + observabilityRange, + observabilityBounds, + observabilitySnapshot, + captureObservability, + useObservabilityNavigation, +} from '../../lib/observabilityNavigation'; import { classicLogsParams, classicLogsState } from '../../lib/classicUi'; import type { LogRecord, LogFilter } from '../../lib/types'; export function LogsPage() { @@ -41,13 +55,25 @@ export function LogsPage() { const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client])); const dataset = currentDataset || datasets.data?.[0]?.name || ''; const location = useLocation(); + const initialLocationKey = useRef(location.key); + const navigationType = useNavigationType(); const [params] = useSearchParams(); + const schema = useAsync( + useCallback( + (signal) => (dataset ? client.schema(dataset, signal) : Promise.resolve([])), + [client, dataset], + ), + ); + const [traceFocus, setTraceFocus] = useState<{ field: string; traceId: string }>(); const [dropped, setDropped] = useState([]); const [view, dispatchView] = useReducer(logViewReducer, dataset, initialLogView); const { range, search, expression, timezone, limit, columns, wrap, showHistogram } = view; const editView = (patch: Partial>) => dispatchView({ type: 'edit', patch }); - const setRange = (range: LogViewState['range']) => editView({ range }); + const setRange = (range: LogViewState['range']) => { + setHandoffBounds(undefined); + editView({ range }); + }; const setSearch = (search: string) => editView({ search }); const setExpression = (expression: LogFilterExpression) => { setFilterNotice(''); @@ -62,6 +88,10 @@ export function LogsPage() { const [debounced, setDebounced] = useState({ dataset, search: '' }); const [filterNotice, setFilterNotice] = useState(''); const [revision, setRevision] = useState(0); + const [handoffBounds, setHandoffBounds] = useState<{ + range: string; + bounds: { startTime: string; endTime: string }; + }>(); const [contextRow, setContextRow] = useState(); const [shareUrl, setShareUrl] = useState(''); const [shareStatus, setShareStatus] = useState(''); @@ -88,15 +118,23 @@ export function LogsPage() { setShareUrl(''); setLinkError(''); setRowFields({ fields: [] }); + setTraceFocus(undefined); } // A classic Logs link carries its range and filters in the query string. Each // one is applied once, including one that arrives while this page is open, // and after the dataset reset above so a link to another dataset keeps them. - const link = classicLogsParams.some((name) => params.has(name)) ? location.key : undefined; + const traceNavigation = params.has('traceId') || params.has('logTraceId'); + const nativeNavigation = + traceNavigation || (params.has('observe') && ['start', 'end'].some((name) => params.has(name))); + const link = + !params.has('observe') && !traceNavigation && classicLogsParams.some((name) => params.has(name)) + ? location.key + : undefined; const [appliedLink, setAppliedLink] = useState(); if (link && dataset && appliedLink !== link) { const linked = classicLogsState(params); setAppliedLink(link); + setTraceFocus(undefined); setFilterNotice(''); setRow(undefined); setContextRow(undefined); @@ -129,13 +167,59 @@ export function LogsPage() { setLinkError(''); }; const [appliedView, setAppliedView] = useState(); - if (params.has('logView') && appliedView !== location.key && dataset) { + const savedSnapshot = observabilitySnapshot(params, 'logs'); + const restoredParams = + savedSnapshot?.dataset === dataset ? new URLSearchParams(savedSnapshot.query) : undefined; + const restoreNavigation = + !!restoredParams && + !params.has('logView') && + !nativeNavigation && + ((appliedView === undefined && location.key === initialLocationKey.current) || + (navigationType === 'POP' && appliedView !== location.key)); + const incomingTraceId = + params.get('traceId') ?? + params.get('logTraceId') ?? + (restoreNavigation ? restoredParams?.get('logTraceId') : undefined); + const hasTraceFocus = !!incomingTraceId && /^[a-fA-F0-9]{32}$/.test(incomingTraceId); + if ( + (params.has('logView') || nativeNavigation || restoreNavigation) && + appliedView !== location.key && + dataset && + view.dataset === dataset && + (!hasTraceFocus || !schema.loading) + ) { setAppliedView(location.key); try { - const view = decodeLogView(params.get('logView')!); - if (view.dataset !== dataset) + let linkedView = params.has('logView') + ? decodeLogView(params.get('logView')!) + : restoreNavigation && restoredParams?.has('logView') + ? decodeLogView(restoredParams.get('logView')!) + : view; + if (nativeNavigation || restoreNavigation) { + const linkedRange = observabilityRange(params); + setHandoffBounds({ + range: JSON.stringify(linkedRange), + bounds: observabilityBounds(params, linkedRange), + }); + linkedView = { ...linkedView, range: linkedRange }; + } + if (hasTraceFocus) { + const field = ['span_trace_id', 'trace_id', 'trace.id', 'traceId'].find((field) => + schema.data?.includes(field), + ); + if (!field) + throw new Error( + schema.error + ? 'Could not check the trace ID fields for this dataset. Retry after checking dataset access.' + : 'This dataset has no trace ID field. Trace-focused logs are unavailable.', + ); + setTraceFocus({ field, traceId: incomingTraceId! }); + } else setTraceFocus(undefined); + if (linkedView.dataset !== dataset) throw new Error('The shared view dataset does not match this page.'); - applyView(view); + applyView(linkedView); + if (traceNavigation && !hasTraceFocus) + setFilterNotice('The trace ID in this link is invalid and was not applied.'); } catch (error) { setLinkError(error instanceof Error ? error.message : 'Invalid shared view'); } @@ -143,22 +227,51 @@ export function LogsPage() { // Consume handoffs once so subsequent edits are not overwritten by navigation. useEffect(() => { const classicApplied = !!link && appliedLink === link; - const nativeApplied = params.has('logView') && appliedView === location.key && !linkError; + const nativeApplied = + (params.has('logView') || nativeNavigation) && appliedView === location.key && !linkError; if (!classicApplied && !nativeApplied) return; const rest = new URLSearchParams(location.search); if (classicApplied) for (const name of classicLogsParams) rest.delete(name); - if (nativeApplied) rest.delete('logView'); + if (nativeApplied) { + rest.delete('logView'); + rest.delete('logTraceId'); + if (nativeNavigation) + for (const name of ['start', 'end', 'range', 'traceId']) rest.delete(name); + } + // Store the applied view atomically with consuming the handoff so a reload cannot lose its focus. + const captured = nativeApplied + ? captureObservability(rest, { + view: 'logs', + dataset, + range: view.range, + bounds: + handoffBounds?.range === JSON.stringify(view.range) + ? handoffBounds.bounds + : timeBounds(view.range), + query: new URLSearchParams({ + logView: encodeLogView(view), + ...(traceFocus ? { logTraceId: traceFocus.traceId } : {}), + }), + }) + : rest; navigate( - { pathname: location.pathname, search: rest.toString(), hash: location.hash }, + { pathname: location.pathname, search: captured.toString(), hash: location.hash }, { replace: true, state: location.state }, ); - }, [link, appliedLink, appliedView, linkError, location, navigate, params]); - const schema = useAsync( - useCallback( - (signal) => (dataset ? client.schema(dataset, signal) : Promise.resolve([])), - [client, dataset], - ), - ); + }, [ + link, + appliedLink, + appliedView, + linkError, + location, + navigate, + params, + nativeNavigation, + dataset, + view, + handoffBounds, + traceFocus, + ]); const debouncedSearch = debounced.dataset === dataset ? debounced.search : ''; const info = useAsync( useCallback( @@ -169,10 +282,20 @@ export function LogsPage() { const timestampField = info.data?.timePartition || 'p_timestamp'; const compiled = useMemo(() => { try { + let criteria = dataset + ? compileLogCriteria(dataset, debouncedSearch, expression, timestampField) + : undefined; + if (criteria && traceFocus) { + const focusPredicate = `${quoteIdentifier(traceFocus.field)} = ${quoteLiteral(traceFocus.traceId)}`; + criteria = { + ...criteria, + predicate: criteria.predicate + ? `(${criteria.predicate}) AND ${focusPredicate}` + : focusPredicate, + }; + } return { - criteria: dataset - ? compileLogCriteria(dataset, debouncedSearch, expression, timestampField) - : undefined, + criteria, error: '', }; } catch (error) { @@ -181,7 +304,7 @@ export function LogsPage() { error: error instanceof Error ? error.message : 'Invalid log query', }; } - }, [dataset, debouncedSearch, expression, timestampField]); + }, [dataset, debouncedSearch, expression, timestampField, traceFocus]); const { source, predicate, timestamp } = compiled.criteria ?? {}; // Stabilize by semantic SQL values: client IDs and empty groups do not trigger queries. const criteria = useMemo( @@ -190,18 +313,57 @@ export function LogsPage() { [source, predicate, timestamp], ); // Capture now again only for a changed range, effective criteria, or explicit refresh. - const bounds = useMemo(() => timeBounds(range), [range, criteria, revision]); + const bounds = useMemo( + () => + handoffBounds?.range === JSON.stringify(range) ? handoffBounds.bounds : timeBounds(range), + [range, criteria, revision, handoffBounds], + ); const sql = useMemo( () => (criteria ? buildRowsFromCriteria(criteria, limit, bounds) : ''), [criteria, limit, bounds], ); - const ready = !!criteria && !info.loading && !linkError && !compiled.error; + const ready = + !!criteria && + !info.loading && + !linkError && + !compiled.error && + (!hasTraceFocus || appliedView === location.key); const results = useAsync( useCallback( (signal) => (ready ? client.query({ sql, ...bounds }, signal) : Promise.resolve(undefined)), [client, sql, bounds, ready], ), ); + const authorizedDataset = useRef(undefined); + if (results.data) authorizedDataset.current = dataset; + const encodedNavigationView = useMemo(() => { + try { + return encodeLogView(view); + } catch { + return undefined; + } + }, [view]); + const navigation = useObservabilityNavigation({ + view: 'logs', + dataset, + range, + bounds, + params, + query: + encodedNavigationView === undefined + ? undefined + : new URLSearchParams({ + logView: encodedNavigationView, + ...(traceFocus ? { logTraceId: traceFocus.traceId } : {}), + }), + enabled: + encodedNavigationView !== undefined && + authorizedDataset.current === dataset && + !link && + !params.has('logView') && + !nativeNavigation && + !restoreNavigation, + }); const histogram = useAsync( useCallback( async (signal) => { @@ -229,14 +391,17 @@ export function LogsPage() { setFilterNotice(error instanceof Error ? error.message : 'Could not add this filter.'); } }; - const refresh = () => setRevision((value) => value + 1); + const refresh = () => { + setHandoffBounds(undefined); + setRevision((value) => value + 1); + }; const share = () => { try { const encoded = encodeLogView({ ...view, range: bounds }); setShareUrl( new URL( appPath( - `/logs/explore/${encodeURIComponent(dataset)}?${new URLSearchParams({ logView: encoded })}`, + `/logs/explore/${encodeURIComponent(dataset)}?${new URLSearchParams({ logView: encoded, ...(traceFocus ? { logTraceId: traceFocus.traceId } : {}) })}`, ), window.location.origin, ).href, @@ -255,7 +420,7 @@ export function LogsPage() { setEndpoint(event.target.value)} + error={validated.error} + hint="The local default is http://127.0.0.1:4318. Localhost refers to the environment running your agent." + /> + +

+ {claude + ? 'Content flags are off by default. Enabling them can export sensitive prompts, responses and tool content when supported; identity metadata and file paths may still be exported with these flags off. Logs are not automatically attached to spans.' + : 'Prompt logging is off by default. Enabling it exports user prompts; this is not a full transcript switch and tool output snippets and identity metadata may still be exported when this is off.'} +

+ +

2. Merge into user settings

+

+ Preserve existing settings in{' '} + {claude ? '~/.claude/settings.json' : '~/.codex/config.toml'}.{' '} + {claude + ? 'Merge the env object; reconcile existing destinations and authentication before replacing these keys. Exporter settings are honored in user or managed configuration, not project settings. Managed destinations may override yours. Enhanced telemetry is a beta feature.' + : 'Edit existing otel keys and inline exporters in place. Duplicate otel tables or exporter keys make TOML invalid. Project otel settings are ignored. CODEX_HOME can relocate the user config file.'} +

+ {validated.endpoint && ( + + )} +

+ Restart the actual CLI or agent runtime to apply settings. Enabling an exporter alone does + not guarantee complete runs in Agents. +

+

3. Validate real telemetry

+

+ Confirm collector receipt and stored rows first, then run discovery, parent links, models, + tools and usage in Agents. Test successful, failed and interrupted work. Native CLI + compatibility has not been verified end to end; capture Codex’s actual trace schema from + your installed runtime. +

+
+ Mapping and validation checklist +
    +
  • + Map agent and operation to gen_ai.agent.name and{' '} + gen_ai.operation.name; models to gen_ai.request.model /{' '} + gen_ai.response.model; tools to gen_ai.tool.name (Claude:{' '} + tool_name). +
  • +
  • + Map usage to gen_ai.usage.input_tokens and{' '} + gen_ai.usage.output_tokens. Claude input totals include{' '} + input_tokens + cache_read_tokens + cache_creation_tokens. Preserve absent + values as unknown. +
  • +
  • + Validate 32-digit hexadecimal trace IDs, 16-digit hexadecimal span IDs, parent IDs, + timestamps and status. Retain errors and unfinished spans; do not group an entire + session as one run. +
  • +
  • + Put GenAI attributes on spans, not just resource metadata. Optional structured + messages use gen_ai.input.messages / gen_ai.output.messages. + Prompt capture is not a full transcript guarantee. +
  • +
  • + An ordinary collector does not reconstruct GenAI runs automatically. Provider mapping + or a stateful adapter may be needed. Missing telemetry stays unknown or incomplete; do + not invent content, usage, costs or evaluations. +
  • +
+
+
+ Copy instructions for your coding agent + {validated.endpoint && ( + + )} +
+

+ Official documentation:{' '} + {claude ? ( + + Claude Code telemetry + + ) : ( + <> + + Codex configuration + {' '} + ·{' '} + + Codex telemetry + + + )} +

+