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
124 changes: 124 additions & 0 deletions src/__tests__/integration/tracing-session-metadata-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Session-level `metadata` (the dynamic attribution bag, sibling of `agent`),
* run against BOTH providers.
*
* The defect being pinned: `IAgentTracingSession.metadata` reached the type
* and the MongoDB provider (which persists arbitrary extra document fields
* for free) but the SQLite provider never got a matching column — every
* session created on SQLite silently dropped `metadata` on write, so it
* never came back on read, never appeared in the UI, and could never be
* searched. MongoDB's implicit persistence hid the gap in manual testing.
*/

import { it, expect, beforeEach } from 'vitest';

import { describeForEachProvider } from './db-parity.helper';

type SessionSeed = {
sessionId: string;
projectId: string;
tenantId: string;
};

function seedDoc(seed: SessionSeed, metadata?: Record<string, string>) {
return {
sessionId: seed.sessionId,
projectId: seed.projectId,
tenantId: seed.tenantId,
agentName: 'Pulse Worker Agent',
status: 'in_progress',
startedAt: new Date(),
errors: [],
modelsUsed: [],
toolsUsed: [],
eventCounts: {},
totalEvents: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCachedInputTokens: 0,
...(metadata ? { metadata } : {}),
};
}

describeForEachProvider('Agent tracing session metadata', (getDb) => {
let seed: SessionSeed;

beforeEach(async () => {
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const db = getDb();
const slug = `tracing-md-${unique}`;
const dbName = `tenant_${slug}`;
const tenant = await db.createTenant({
companyName: 'Tracing Metadata',
slug,
dbName,
licenseType: 'FREE',
ownerId: 'pending',
});
await db.switchToTenant(dbName);
seed = { sessionId: `sess-${unique}`, projectId: `proj-${unique}`, tenantId: String(tenant._id) };
});

it('round-trips metadata through create and findById', async () => {
const db = getDb();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const doc = seedDoc(seed, { complexity: 'complex', workspace: 'ws_1' }) as any;
await db.createAgentTracingSession(doc);

const session = await db.findAgentTracingSessionById(seed.sessionId, seed.projectId);
expect(session?.metadata).toEqual({ complexity: 'complex', workspace: 'ws_1' });
});

it('round-trips metadata through updateAgentTracingSession', async () => {
const db = getDb();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.createAgentTracingSession(seedDoc(seed) as any);

await db.updateAgentTracingSession(
seed.sessionId,
{ metadata: { workspace: 'ws_2' } },
seed.projectId,
);

const session = await db.findAgentTracingSessionById(seed.sessionId, seed.projectId);
expect(session?.metadata).toEqual({ workspace: 'ws_2' });
});

it('finds a session by an exact metadata key/value match', async () => {
const db = getDb();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.createAgentTracingSession(seedDoc(seed, { workspace: 'ws_target' }) as any);
const otherSeed: SessionSeed = { ...seed, sessionId: `${seed.sessionId}-other` };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const otherDoc = seedDoc(otherSeed, { workspace: 'ws_other' }) as any;
await db.createAgentTracingSession(otherDoc);

const result = await db.listAgentTracingSessions(
{ metadataKey: 'workspace', metadataValue: 'ws_target' },
seed.projectId,
);

expect(result.sessions.map((s) => s.sessionId)).toEqual([seed.sessionId]);
});

it('finds a thread by an exact metadata key/value match on its sessions', async () => {
const db = getDb();
const threadSeed = { ...seedDoc(seed, { workspace: 'ws_target' }), threadId: `thr-${seed.sessionId}` };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.createAgentTracingSession(threadSeed as any);
const otherSeed: SessionSeed = { ...seed, sessionId: `${seed.sessionId}-other` };
const otherThreadSeed = {
...seedDoc(otherSeed, { workspace: 'ws_other' }),
threadId: `thr-${otherSeed.sessionId}`,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.createAgentTracingSession(otherThreadSeed as any);

const result = await db.listAgentTracingThreads(
{ metadataKey: 'workspace', metadataValue: 'ws_target' },
seed.projectId,
);

expect(result.threads.map((t) => t.threadId)).toEqual([threadSeed.threadId]);
});
});
44 changes: 40 additions & 4 deletions src/app/dashboard/models/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1358,14 +1358,16 @@ function UsageBreakdownCard({
modelId: string;
costCurrency: string;
}) {
const [groupBy, setGroupBy] = useState<'user' | 'token'>('user');
const [groupBy, setGroupBy] = useState<string>('user');
const [metadataKeyInput, setMetadataKeyInput] = useState('');
const [breakdown, setBreakdown] = useState<UsageBreakdownDto | null>(null);
const [loading, setLoading] = useState(true);
const isMetadataMode = groupBy.startsWith('metadata.');

useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/models/${modelId}/usage/breakdown?groupBy=${groupBy}`)
fetch(`/api/models/${modelId}/usage/breakdown?groupBy=${encodeURIComponent(groupBy)}`)
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (!cancelled) setBreakdown(data?.breakdown ?? null);
Expand All @@ -1386,9 +1388,15 @@ function UsageBreakdownCard({

return (
<div className="ds-card">
<div className="ds-row-between" style={{ padding: '14px 18px' }}>
<div className="ds-row-between" style={{ padding: '14px 18px', flexWrap: 'wrap', gap: 8 }}>
<div className="ds-h3">
Usage by {groupBy === 'user' ? 'user' : 'API key'}
Usage by {
groupBy === 'user'
? 'user'
: groupBy === 'token'
? 'API key'
: `metadata: ${groupBy.slice('metadata.'.length)}`
}
</div>
<div className="ds-row ds-gap-xs">
<span className="ds-faint" style={{ fontSize: 11, marginRight: 6 }}>
Expand All @@ -1409,6 +1417,34 @@ function UsageBreakdownCard({
{option.label}
</button>
))}
<button
type="button"
className={`ds-period-btn ${isMetadataMode ? 'active' : ''}`}
onClick={() => {
if (!isMetadataMode) setMetadataKeyInput('');
}}
>
Metadata
</button>
{(isMetadataMode || metadataKeyInput) && (
<input
className="ds-input"
style={{ width: 110, fontSize: 12 }}
placeholder="key"
value={metadataKeyInput}
onChange={(e) => setMetadataKeyInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && metadataKeyInput.trim()) {
setGroupBy(`metadata.${metadataKeyInput.trim()}`);
}
}}
onBlur={() => {
if (metadataKeyInput.trim()) {
setGroupBy(`metadata.${metadataKeyInput.trim()}`);
}
}}
/>
)}
</div>
</div>
{loading ? (
Expand Down
29 changes: 27 additions & 2 deletions src/app/dashboard/tracing/sessions/[sessionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ interface SessionDetailResponse {
agentName?: string;
agentVersion?: string;
agentModel?: string;
metadata?: Record<string, string>;
status?: string;
startedAt?: string;
endedAt?: string;
Expand Down Expand Up @@ -646,8 +647,11 @@ function getRecord(value: unknown): Record<string, unknown> | undefined {
}

function ToolDetailsBlock({ event }: { event: TracingEvent }) {
const metadataDetails = getRecord(event.metadata?.toolDetails);
const details = event.toolDetails || metadataDetails;
// Both sides go through getRecord: a network payload can hand this the
// literal string "undefined" (a bad upstream serializer's stringified
// absence), and `||` alone would accept it as truthy — then spreading it
// below splits it into single-character indexed keys.
const details = getRecord(event.toolDetails) || getRecord(event.metadata?.toolDetails);
if (!details) return null;

const name = typeof details.name === 'string' && details.name.trim().length > 0
Expand Down Expand Up @@ -1259,6 +1263,27 @@ export default function SessionDetailPage({ params }: { params: Promise<{ sessio
</Group>
</Stack>
)}
{session.metadata && Object.keys(session.metadata).length > 0 && (
<Stack gap={2}>
<Text size="sm" c="dimmed">Metadata</Text>
<Stack gap={4}>
{Object.entries(session.metadata).map(([key, value]) => (
<Group key={key} justify="space-between" wrap="nowrap" gap="xs">
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>{key}</Text>
<Text
size="xs"
c="blue"
fw={500}
style={{ cursor: 'pointer', fontFamily: 'monospace', textAlign: 'right', wordBreak: 'break-all' }}
onClick={() => router.push(`/dashboard/tracing/sessions?metadataKey=${encodeURIComponent(key)}&metadataValue=${encodeURIComponent(value)}`)}
>
{value}
</Text>
</Group>
))}
</Stack>
</Stack>
)}
</Stack>
</Card>

Expand Down
59 changes: 57 additions & 2 deletions src/app/dashboard/tracing/sessions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button, Group, Text, Tooltip } from '@mantine/core';
import { ActionIcon, Button, Group, Text, TextInput, Tooltip } from '@mantine/core';
import {
IconBook,
IconCamera,
IconEye,
IconX,
} from '@tabler/icons-react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
Expand Down Expand Up @@ -56,6 +57,12 @@ export default function TracingSessionsPage() {
const [agentFilter, setAgentFilter] = useState(
() => searchParams.get('agent')?.trim() || '',
);
const [metadataKey, setMetadataKey] = useState(
() => searchParams.get('metadataKey')?.trim() || '',
);
const [metadataValue, setMetadataValue] = useState(
() => searchParams.get('metadataValue')?.trim() || '',
);

const buildQueryParams = useCallback(() => {
const params = new URLSearchParams();
Expand All @@ -64,8 +71,12 @@ export default function TracingSessionsPage() {
if (query) params.set('query', query.trim());
if (statusFilter !== 'all') params.set('status', statusFilter);
if (agentFilter) params.set('agent', agentFilter.trim());
if (metadataKey.trim() && metadataValue.trim()) {
params.set('metadataKey', metadataKey.trim());
params.set('metadataValue', metadataValue.trim());
}
return params;
}, [page, pageSize, query, statusFilter, agentFilter]);
}, [page, pageSize, query, statusFilter, agentFilter, metadataKey, metadataValue]);

const fetchSessions = useCallback(
async (isRefresh = false, signal?: AbortSignal) => {
Expand Down Expand Up @@ -103,6 +114,10 @@ export default function TracingSessionsPage() {
useEffect(() => {
const agentParam = searchParams.get('agent')?.trim() || '';
setAgentFilter((current) => (current === agentParam ? current : agentParam));
const metadataKeyParam = searchParams.get('metadataKey')?.trim() || '';
setMetadataKey((current) => (current === metadataKeyParam ? current : metadataKeyParam));
const metadataValueParam = searchParams.get('metadataValue')?.trim() || '';
setMetadataValue((current) => (current === metadataValueParam ? current : metadataValueParam));
}, [searchParams]);

useEffect(() => {
Expand Down Expand Up @@ -301,6 +316,46 @@ export default function TracingSessionsPage() {
],
},
]}
toolbarRight={
<Group gap={4} wrap="nowrap">
<TextInput
size="xs"
placeholder="metadata key"
value={metadataKey}
onChange={(e) => {
setMetadataKey(e.currentTarget.value);
setPage(1);
}}
style={{ width: 130 }}
/>
<TextInput
size="xs"
placeholder="value"
value={metadataValue}
onChange={(e) => {
setMetadataValue(e.currentTarget.value);
setPage(1);
}}
style={{ width: 130 }}
/>
{(metadataKey || metadataValue) && (
<Tooltip label="Clear metadata filter" withArrow>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={() => {
setMetadataKey('');
setMetadataValue('');
setPage(1);
}}
>
<IconX size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
}
onRefresh={() => void fetchSessions(true)}
refreshing={refreshing}
empty={{
Expand Down
Loading
Loading