Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 141 additions & 2 deletions frontend/e2e/agents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ const agentFields = [...new Set(agentRows.flatMap((row) => Object.keys(row)))];
type Captured = { query: string; startTime: string; endTime: string };
async function serveAgents(
page: Page,
options: { status?: number; rows?: LogRecord[]; datasets?: string[] } = {},
options: { status?: number; rows?: LogRecord[]; datasets?: string[]; fields?: string[] } = {},
) {
const captured: Captured[] = [];
const state = { status: options.status ?? 200, rows: options.rows ?? agentRows };
Expand All @@ -305,7 +305,9 @@ async function serveAgents(
json: (options.datasets ?? ['agent_telemetry']).map((name) => ({ name })),
});
if (path.endsWith('/schema'))
return route.fulfill({ json: { fields: agentFields.map((name) => ({ name })) } });
return route.fulfill({
json: { fields: (options.fields ?? agentFields).map((name) => ({ name })) },
});
if (path.endsWith('/info'))
return route.fulfill({
json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] },
Expand Down Expand Up @@ -723,3 +725,140 @@ for (const trace of ['trace-with-dashes', '']) {
await expect(details.getByRole('tab', { name: 'Span tree (1)' })).toBeVisible();
});
}

test('native Codex traces become runs with mapped model calls, tools and usage', async ({
page,
}) => {
// Spans as Codex CLI 0.162.1 exports them: no GenAI agent fields, usage on the
// response handler below each model call, and intermediate spans filtered out.
const trace = 'e5'.repeat(16);
const codex = (span: Omit<SpanFixture, 'trace' | 'status'>) =>
spanRows({
...span,
trace,
attributes: { 'service.name': 'codex-app-server', ...span.attributes },
});
const rows = [
codex({
id: 'e500000000000001',
parent: 'e5000000000000aa',
name: 'session_task.turn',
start: base + 1000,
duration: 4000,
attributes: {
model: 'gpt-6-luna',
'codex.turn.token_usage.input_tokens': '32558',
'codex.turn.token_usage.output_tokens': '38',
},
}),
codex({
id: 'e500000000000002',
parent: 'e5000000000000bb',
name: 'try_run_sampling_request',
start: base + 1100,
duration: 1600,
attributes: { model: 'gpt-6-luna' },
}),
codex({
id: 'e500000000000003',
parent: 'e500000000000002',
name: 'receiving_stream',
start: base + 1110,
duration: 1580,
}),
codex({
id: 'e500000000000004',
parent: 'e500000000000003',
name: 'handle_responses',
start: base + 2600,
duration: 40,
attributes: { 'gen_ai.usage.input_tokens': '32558', 'gen_ai.usage.output_tokens': '38' },
}),
codex({
id: 'e500000000000005',
parent: 'e5000000000000cc',
name: 'exec',
start: base + 2700,
duration: 137,
attributes: { tool_name: 'exec', call_id: 'call_codex' },
}),
].flat();
const { captured } = await serveAgents(page, {
rows,
datasets: ['codex-traces'],
fields: [...new Set([...rows.flatMap((row) => Object.keys(row)), 'gen_ai.usage.input_tokens'])],
});
await page.goto(`/agents?${range}`);
await page.getByLabel('Agents dataset').selectOption('codex-traces');
const row = runRow(page, 'Codex');
await expect(cell(row, 'Status')).toHaveText('Successful');
await expect(cell(row, 'Model')).toHaveText('gpt-6-luna');
await expect(cell(row, 'Tokens')).toContainText('32,558 in');
await expect(cell(row, 'Tokens')).toContainText('38 out');
await expect(cell(row, 'Spans')).toHaveText('3');
expect(captured[0].query).toContain(`"span_name" = 'session_task.turn'`);
expect(captured[0].query).toContain(`LIKE 'codex%'`);

const details = await openRun(page, 'Codex');
const children = details
.getByRole('list', { name: 'Run span tree' })
.getByRole('list', { name: 'Child spans' });
await expect(children.getByRole('button')).toHaveCount(2);
await children.getByRole('button', { name: /exec/ }).click();
const span = details.getByRole('region', { name: 'Selected span' });
await expect(span).toContainText('Mapped from native Codex telemetry');
// Facts and raw records show what Codex recorded, not the attached tree position.
await expect(fact(span, 'Parent span ID')).toHaveText('e5000000000000cc');
await span.getByText('Recorded fields (1 record)').click();
await expect(span).toContainText('"tool_name": "exec"');
await expect(span).not.toContainText('gen_ai.tool.name');
});

test('discloses the record limit from the queried rows when Codex mapping drops some', async ({
page,
}) => {
const trace = 'f6'.repeat(16);
const codex = (span: Omit<SpanFixture, 'trace' | 'status'>) =>
spanRows({
...span,
trace,
attributes: { 'service.name': 'codex-app-server', ...span.attributes },
});
const rows = [
codex({
id: 'f600000000000001',
name: 'session_task.turn',
start: base + 1000,
duration: 4000,
attributes: { model: 'gpt-6-luna', 'codex.turn.token_usage.input_tokens': '10' },
}),
codex({
id: 'f600000000000002',
parent: 'f600000000000001',
name: 'try_run_sampling_request',
start: base + 1100,
duration: 3000,
attributes: { model: 'gpt-6-luna' },
}),
// Stream spans count toward the record limit but are not shown as run spans.
...Array.from({ length: 998 }, (_, index) =>
codex({
id: (index + 1).toString(16).padStart(16, '0'),
parent: 'f600000000000002',
name: 'receiving_stream',
start: base + 1200,
duration: 10,
}),
),
].flat();
await serveAgents(page, {
rows,
datasets: ['codex-traces'],
fields: [...new Set(rows.flatMap((row) => Object.keys(row)))],
});
await page.goto(`/agents/codex-traces?${range}`);
await expect(cell(runRow(page, 'Codex'), 'Spans')).toHaveText('2');
await expect(page.getByRole('status').filter({ hasText: 'record limit' })).toContainText(
'narrow the time range',
);
});
20 changes: 18 additions & 2 deletions frontend/src/features/agents/AgentRunDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Bot, GitBranch, MessageSquare, Wrench } from 'lucide-react';
import { Badge, Button, Tabs } from '../../components/ui';
import { AGENT_HARNESSES, mappedHarness, recordedFields } from '../../lib/agentHarnesses';
import type { AgentRun, AgentRunStatus, AgentSpan } from '../../lib/agentRuns';

export function formatAgentDuration(value?: number) {
Expand Down Expand Up @@ -57,6 +58,11 @@ export function AgentRunDetails({ run }: { run: AgentRun }) {
const [tab, setTab] = useState('tree');
const [spanId, setSpanId] = useState(run.roots[0]?.id ?? run.spans[0]?.id);
const selected = run.spans.find((span) => span.id === spanId) ?? run.spans[0];
const selectedHarness = selected?.rows.map(mappedHarness).find(Boolean);
// Harness mapping can attach a span to its run when intermediate spans were not loaded.
const recordedParent = selected?.rows
.map((row) => recordedFields(row).span_parent_span_id ?? recordedFields(row).parent_span_id)
.find((value) => typeof value === 'string' && value.trim() && !/^0+$/.test(value.trim()));
const models = run.spans.filter((span) => span.kind === 'model');
const tools = run.spans.filter((span) => span.kind === 'tool');
const errors = run.spans.filter((span) => span.errors.length || span.status === 'failed');
Expand Down Expand Up @@ -233,7 +239,11 @@ export function AgentRunDetails({ run }: { run: AgentRun }) {
<div>
<dt>Parent span ID</dt>
<dd>
<code>{selected.parentSpanId ?? 'Not recorded'}</code>
<code>
{String(
(selectedHarness ? recordedParent : selected.parentSpanId) ?? 'Not recorded',
)}
</code>
</dd>
</div>
<div>
Expand All @@ -255,14 +265,20 @@ export function AgentRunDetails({ run }: { run: AgentRun }) {
</dd>
</div>
</dl>
{selectedHarness && (
<p className="agents-note">
Mapped from native {AGENT_HARNESSES[selectedHarness].label} telemetry. Recorded fields
are shown unchanged.
</p>
)}
<details className="agents-raw-fields">
<summary>
Recorded fields ({selected.rows.length}{' '}
{selected.rows.length === 1 ? 'record' : 'records'})
</summary>
{selected.rows.map((row, index) => (
<pre key={index} className="agents-record">
{JSON.stringify(row, null, 2)}
{JSON.stringify(recordedFields(row), null, 2)}
</pre>
))}
</details>
Expand Down
15 changes: 7 additions & 8 deletions frontend/src/features/agents/AgentTelemetryGuide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,24 +75,23 @@ export function AgentTelemetryGuide() {
<h3>3. Validate real telemetry</h3>
<p>
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.
tools and usage in Agents. Test successful, failed and interrupted work. Agents reads
native Claude Code and Codex trace fields directly, so their trace datasets need no field
mapping. Runs come from traces; a logs dataset is not listed on its own.
</p>
<details className="agent-setup-details">
<summary>Mapping and validation checklist</summary>
<p>For other agents and frameworks:</p>
<ul>
<li>
Map agent and operation to <code>gen_ai.agent.name</code> and{' '}
<code>gen_ai.operation.name</code>; models to <code>gen_ai.request.model</code> /{' '}
<code>gen_ai.response.model</code>; tools to <code>gen_ai.tool.name</code> (Claude:{' '}
<code>tool_name</code>).
<code>gen_ai.response.model</code>; tools to <code>gen_ai.tool.name</code>.
</li>
<li>
Map usage to <code>gen_ai.usage.input_tokens</code> and{' '}
<code>gen_ai.usage.output_tokens</code>. Claude input totals include{' '}
<code>input_tokens + cache_read_tokens + cache_creation_tokens</code>. Preserve absent
values as unknown.
<code>gen_ai.usage.output_tokens</code>, including cached input tokens in the input
total. Preserve absent values as unknown.
</li>
<li>
Validate 32-digit hexadecimal trace IDs, 16-digit hexadecimal span IDs, parent IDs,
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/features/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
buildAgentRunQuery,
discoverAgentDatasets,
} from '../../lib/agents';
import { detectAgentHarnesses, mapHarnessRows } from '../../lib/agentHarnesses';
import { buildAgentRuns } from '../../lib/agentRuns';
import { agentValueMatchesSearch } from '../../lib/agentSearch';
import {
Expand Down Expand Up @@ -101,6 +102,7 @@ export function AgentsPage() {
rows: agentDemoRows(Date.parse(bounds.endTime)).filter(
(row) => Date.parse(String(row.p_timestamp)) >= Date.parse(bounds.startTime),
),
limitReached: false,
timeColumn: 'p_timestamp',
traces: false,
};
Expand All @@ -114,7 +116,12 @@ export function AgentsPage() {
buildAgentRunQuery(dataset, fields, bounds, timeColumn),
signal,
);
return { rows, timeColumn, traces: info.logSourceFormats.includes('otel-traces') };
return {
rows: mapHarnessRows(detectAgentHarnesses(fields), rows),
limitReached: rows.length >= AGENT_RECORD_LIMIT,
Comment thread
VibeSWE marked this conversation as resolved.
timeColumn,
traces: info.logSourceFormats.includes('otel-traces'),
};
},
[client, mode, dataset, bounds],
),
Expand All @@ -132,7 +139,7 @@ export function AgentsPage() {
const runs = useMemo(
() =>
buildAgentRuns(result.data?.rows ?? [], result.data?.timeColumn, {
limitReached: (result.data?.rows.length ?? 0) >= AGENT_RECORD_LIMIT,
limitReached: result.data?.limitReached,
bounds,
}),
[result.data, bounds],
Expand Down Expand Up @@ -370,7 +377,7 @@ export function AgentsPage() {
the time range to explore them.
</p>
)}
{result.data.rows.length >= AGENT_RECORD_LIMIT && (
{result.data.limitReached && (
<p role="status" className="agents-warnings">
The record limit was reached. Runs may have additional spans or events; narrow
the time range to improve coverage.
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/agents/agentTelemetrySetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Inspect the installed runtime/version and its actual config location first. Merg

${config}

Use an already configured collector; configure its authenticated Parseable exporter separately with the destination trace dataset and X-P-Stream. Use separate collector logs and traces pipelines/exporters with distinct X-P-Stream values for their destination datasets. Keep credentials in the existing secret mechanism. Inspect real payloads from this installed runtime, map provider fields to the dashboard GenAI span contract, and preserve missing values as unknown. Claude input token totals include input_tokens + cache_read_tokens + cache_creation_tokens, only when those values are present. Map tool_name to gen_ai.tool.name. Codex trace schema must be captured and checked from this installed runtime. Prompt capture does not guarantee a full transcript.
Use an already configured collector; configure its authenticated Parseable exporter separately with the destination trace dataset and X-P-Stream. Use separate collector logs and traces pipelines/exporters with distinct X-P-Stream values for their destination datasets. Keep credentials in the existing secret mechanism. Parseable Agents reads native ${provider === 'claude' ? 'Claude Code' : 'Codex'} trace fields directly, so forward them unchanged rather than renaming them; runs come from the traces dataset. Prompt capture does not guarantee a full transcript.

Validate transport first (collector receipt and stored rows), then dashboard compatibility: run discovery, trace/parent relationships, models, tools, usage, and any captured messages. Test successful, failed, and interrupted/incomplete work; missing telemetry remains unknown/incomplete. Do not invent content, tokens, costs, evaluations, or live state. A synthetic fixture alone does not prove native CLI compatibility. Report changed files, restart steps, observed payload mappings, evidence, limitations, and how to restore previous exporter settings.`;
}
Loading
Loading