diff --git a/src/__tests__/integration/tracing-session-metadata-parity.test.ts b/src/__tests__/integration/tracing-session-metadata-parity.test.ts new file mode 100644 index 00000000..520ffed4 --- /dev/null +++ b/src/__tests__/integration/tracing-session-metadata-parity.test.ts @@ -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) { + 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]); + }); +}); diff --git a/src/app/dashboard/models/[id]/page.tsx b/src/app/dashboard/models/[id]/page.tsx index 77bf300b..bce47f73 100644 --- a/src/app/dashboard/models/[id]/page.tsx +++ b/src/app/dashboard/models/[id]/page.tsx @@ -1358,14 +1358,16 @@ function UsageBreakdownCard({ modelId: string; costCurrency: string; }) { - const [groupBy, setGroupBy] = useState<'user' | 'token'>('user'); + const [groupBy, setGroupBy] = useState('user'); + const [metadataKeyInput, setMetadataKeyInput] = useState(''); const [breakdown, setBreakdown] = useState(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); @@ -1386,9 +1388,15 @@ function UsageBreakdownCard({ return (
-
+
- Usage by {groupBy === 'user' ? 'user' : 'API key'} + Usage by { + groupBy === 'user' + ? 'user' + : groupBy === 'token' + ? 'API key' + : `metadata: ${groupBy.slice('metadata.'.length)}` + }
@@ -1409,6 +1417,34 @@ function UsageBreakdownCard({ {option.label} ))} + + {(isMetadataMode || metadataKeyInput) && ( + setMetadataKeyInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && metadataKeyInput.trim()) { + setGroupBy(`metadata.${metadataKeyInput.trim()}`); + } + }} + onBlur={() => { + if (metadataKeyInput.trim()) { + setGroupBy(`metadata.${metadataKeyInput.trim()}`); + } + }} + /> + )}
{loading ? ( diff --git a/src/app/dashboard/tracing/sessions/[sessionId]/page.tsx b/src/app/dashboard/tracing/sessions/[sessionId]/page.tsx index 5db984a7..fe783b58 100644 --- a/src/app/dashboard/tracing/sessions/[sessionId]/page.tsx +++ b/src/app/dashboard/tracing/sessions/[sessionId]/page.tsx @@ -130,6 +130,7 @@ interface SessionDetailResponse { agentName?: string; agentVersion?: string; agentModel?: string; + metadata?: Record; status?: string; startedAt?: string; endedAt?: string; @@ -646,8 +647,11 @@ function getRecord(value: unknown): Record | 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 @@ -1259,6 +1263,27 @@ export default function SessionDetailPage({ params }: { params: Promise<{ sessio )} + {session.metadata && Object.keys(session.metadata).length > 0 && ( + + Metadata + + {Object.entries(session.metadata).map(([key, value]) => ( + + {key} + router.push(`/dashboard/tracing/sessions?metadataKey=${encodeURIComponent(key)}&metadataValue=${encodeURIComponent(value)}`)} + > + {value} + + + ))} + + + )} diff --git a/src/app/dashboard/tracing/sessions/page.tsx b/src/app/dashboard/tracing/sessions/page.tsx index dfcf0fd5..c62e6e03 100644 --- a/src/app/dashboard/tracing/sessions/page.tsx +++ b/src/app/dashboard/tracing/sessions/page.tsx @@ -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'; @@ -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(); @@ -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) => { @@ -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(() => { @@ -301,6 +316,46 @@ export default function TracingSessionsPage() { ], }, ]} + toolbarRight={ + + { + setMetadataKey(e.currentTarget.value); + setPage(1); + }} + style={{ width: 130 }} + /> + { + setMetadataValue(e.currentTarget.value); + setPage(1); + }} + style={{ width: 130 }} + /> + {(metadataKey || metadataValue) && ( + + { + setMetadataKey(''); + setMetadataValue(''); + setPage(1); + }} + > + + + + )} + + } onRefresh={() => void fetchSessions(true)} refreshing={refreshing} empty={{ diff --git a/src/app/dashboard/tracing/threads/page.tsx b/src/app/dashboard/tracing/threads/page.tsx index 2cd2c9ec..f47c2d36 100644 --- a/src/app/dashboard/tracing/threads/page.tsx +++ b/src/app/dashboard/tracing/threads/page.tsx @@ -1,12 +1,13 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Button, Text, Tooltip } from '@mantine/core'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ActionIcon, Button, Group, Text, TextInput, Tooltip } from '@mantine/core'; import { IconBook, IconEye, IconTimeline, + IconX, } from '@tabler/icons-react'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -46,6 +47,7 @@ const DEFAULT_PAGE_SIZE = 25; export default function TracingThreadsPage() { const router = useRouter(); + const searchParams = useSearchParams(); const { openDocs } = useDocsDrawer(); const [threads, setThreads] = useState([]); const [totalThreads, setTotalThreads] = useState(0); @@ -55,6 +57,12 @@ export default function TracingThreadsPage() { const [pageSize] = useState(DEFAULT_PAGE_SIZE); const [query, setQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); + const [metadataKey, setMetadataKey] = useState( + () => searchParams.get('metadataKey')?.trim() || '', + ); + const [metadataValue, setMetadataValue] = useState( + () => searchParams.get('metadataValue')?.trim() || '', + ); const buildQueryParams = useCallback(() => { const params = new URLSearchParams(); @@ -62,8 +70,19 @@ export default function TracingThreadsPage() { params.set('skip', ((page - 1) * pageSize).toString()); if (query) params.set('threadId', query.trim()); if (statusFilter !== 'all') params.set('status', statusFilter); + if (metadataKey.trim() && metadataValue.trim()) { + params.set('metadataKey', metadataKey.trim()); + params.set('metadataValue', metadataValue.trim()); + } return params; - }, [page, pageSize, query, statusFilter]); + }, [page, pageSize, query, statusFilter, metadataKey, metadataValue]); + + useEffect(() => { + 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]); const fetchThreads = useCallback( async (isRefresh = false, signal?: AbortSignal) => { @@ -256,6 +275,46 @@ export default function TracingThreadsPage() { ], }, ]} + toolbarRight={ + + { + setMetadataKey(e.currentTarget.value); + setPage(1); + }} + style={{ width: 130 }} + /> + { + setMetadataValue(e.currentTarget.value); + setPage(1); + }} + style={{ width: 130 }} + /> + {(metadataKey || metadataValue) && ( + + { + setMetadataKey(''); + setMetadataValue(''); + setPage(1); + }} + > + + + + )} + + } onRefresh={() => void fetchThreads(true)} refreshing={refreshing} empty={{ diff --git a/src/lib/database/mongodb/tracing.mixin.ts b/src/lib/database/mongodb/tracing.mixin.ts index 9615f8ce..89bec0f1 100644 --- a/src/lib/database/mongodb/tracing.mixin.ts +++ b/src/lib/database/mongodb/tracing.mixin.ts @@ -472,6 +472,25 @@ export function TracingMixin>(Bas match.startedAt = startedAt; } + // The materialized thread rollup has no metadata of its own (a thread + // spans several sessions, each with its own bag) — resolve to the + // threadIds of matching sessions first, same key charset the ingest + // sanitizer enforces. + const metadataKey = + typeof filters?.metadataKey === 'string' ? filters.metadataKey.trim() : ''; + const metadataValue = + typeof filters?.metadataValue === 'string' ? filters.metadataValue : undefined; + if (metadataKey && /^[a-zA-Z0-9_]{1,40}$/.test(metadataKey) && metadataValue !== undefined) { + const matchingThreadIds = await db + .collection(COLLECTIONS.agentTracingSessions) + .distinct('threadId', { + ...this.buildProjectScopeFilter(projectId), + [`metadata.${metadataKey}`]: metadataValue, + threadId: { $exists: true, $ne: null }, + }); + match.threadId = { $in: matchingThreadIds }; + } + const limit = parseInt(String(filters?.limit ?? '50')); const skip = parseInt(String(filters?.skip ?? '0')); @@ -908,6 +927,17 @@ export function TracingMixin>(Bas ]; } + // Same key charset the ingest sanitizer enforces (client-tracing.ts) — + // this becomes a literal Mongo field path (`metadata.`), so an + // unvalidated key would be a query-shape injection surface. + const metadataKey = + typeof filters?.metadataKey === 'string' ? filters.metadataKey.trim() : ''; + const metadataValue = + typeof filters?.metadataValue === 'string' ? filters.metadataValue : undefined; + if (metadataKey && /^[a-zA-Z0-9_]{1,40}$/.test(metadataKey) && metadataValue !== undefined) { + query[`metadata.${metadataKey}`] = metadataValue; + } + const limit = Math.max(0, parseInt(String(filters?.limit ?? '50'), 10) || 0); const skip = Math.max(0, parseInt(String(filters?.skip ?? '0'), 10) || 0); const includeTotal = filters?.includeTotal !== false; diff --git a/src/lib/database/sqlite/base.ts b/src/lib/database/sqlite/base.ts index 0340f96b..b4ba06ca 100644 --- a/src/lib/database/sqlite/base.ts +++ b/src/lib/database/sqlite/base.ts @@ -725,6 +725,12 @@ export class SQLiteProviderBase { "metadataKey TEXT NOT NULL DEFAULT ''", ); + // agent_tracing_sessions.metadata (free-form caller-supplied attribution + // tags, sibling of `agent`) was added after the table shipped — the CREATE + // TABLE below already declares it for fresh DBs, but existing SQLite files + // need it backfilled the same way agentModel/agentVersion were. + this.ensureTableColumn(db, TABLES.agentTracingSessions, 'metadata', "metadata TEXT DEFAULT '{}'"); + // external_model_pricing.versions (effective-dated price history) was // added after the table shipped; ensure on boot for DBs created before. this.ensureTableColumn(db, TABLES.externalModelPricing, 'versions', 'versions TEXT'); diff --git a/src/lib/database/sqlite/schema.ts b/src/lib/database/sqlite/schema.ts index 66f1e7d8..95685d4e 100644 --- a/src/lib/database/sqlite/schema.ts +++ b/src/lib/database/sqlite/schema.ts @@ -457,6 +457,7 @@ export const TENANT_SCHEMA_SQL = ` agentName TEXT, agentVersion TEXT, agentModel TEXT, + metadata TEXT DEFAULT '{}', config TEXT DEFAULT '{}', summary TEXT DEFAULT '{}', status TEXT, diff --git a/src/lib/database/sqlite/tracing.mixin.ts b/src/lib/database/sqlite/tracing.mixin.ts index 7af81320..159ebc60 100644 --- a/src/lib/database/sqlite/tracing.mixin.ts +++ b/src/lib/database/sqlite/tracing.mixin.ts @@ -99,12 +99,12 @@ export function TracingMixin>(Base db.prepare(` INSERT INTO ${TABLES.agentTracingSessions} - (id, sessionId, traceId, rootSpanId, threadId, tenantId, projectId, source, agent, agentName, agentVersion, agentModel, + (id, sessionId, traceId, rootSpanId, threadId, tenantId, projectId, source, agent, agentName, agentVersion, agentModel, metadata, config, summary, status, startedAt, endedAt, durationMs, errors, modelsUsed, toolsUsed, eventCounts, totalEvents, totalInputTokens, totalOutputTokens, totalCachedInputTokens, totalBytesIn, totalBytesOut, totalRequestBytes, totalResponseBytes, userId, apiTokenId, actorType, createdAt, updatedAt) - VALUES (@id, @sessionId, @traceId, @rootSpanId, @threadId, @tenantId, @projectId, @source, @agent, @agentName, @agentVersion, @agentModel, + VALUES (@id, @sessionId, @traceId, @rootSpanId, @threadId, @tenantId, @projectId, @source, @agent, @agentName, @agentVersion, @agentModel, @metadata, @config, @summary, @status, @startedAt, @endedAt, @durationMs, @errors, @modelsUsed, @toolsUsed, @eventCounts, @totalEvents, @totalInputTokens, @totalOutputTokens, @totalCachedInputTokens, @totalBytesIn, @totalBytesOut, @totalRequestBytes, @totalResponseBytes, @@ -122,6 +122,7 @@ export function TracingMixin>(Base agentName: session.agentName ?? null, agentVersion: session.agentVersion ?? null, agentModel: session.agentModel ?? null, + metadata: this.toJson(session.metadata ?? {}), config: this.toJson(session.config), summary: this.toJson(session.summary), status: session.status ?? null, @@ -312,6 +313,7 @@ export function TracingMixin>(Base if (data.agentName !== undefined) { sets.push('agentName = @agentName'); params.agentName = data.agentName; } if (data.agentVersion !== undefined) { sets.push('agentVersion = @agentVersion'); params.agentVersion = data.agentVersion; } if (data.agentModel !== undefined) { sets.push('agentModel = @agentModel'); params.agentModel = data.agentModel; } + if (data.metadata !== undefined) { sets.push('metadata = @metadata'); params.metadata = this.toJson(data.metadata); } if (data.config !== undefined) { sets.push('config = @config'); params.config = this.toJson(data.config); } if (data.summary !== undefined) { sets.push('summary = @summary'); params.summary = this.toJson(data.summary); } if (data.status !== undefined) { sets.push('status = @status'); params.status = data.status; } @@ -381,6 +383,20 @@ export function TracingMixin>(Base params.freeText = `%${freeText}%`; } + // Same key charset the ingest sanitizer enforces (client-tracing.ts) — + // the value is a bound param either way, but a key outside this shape + // is not a valid flat-object JSON path and should just match nothing + // rather than be handed to json_extract. + const metadataKey = + typeof filters?.metadataKey === 'string' ? filters.metadataKey.trim() : ''; + const metadataValue = + typeof filters?.metadataValue === 'string' ? filters.metadataValue : undefined; + if (metadataKey && /^[a-zA-Z0-9_]{1,40}$/.test(metadataKey) && metadataValue !== undefined) { + clauses.push('json_extract(metadata, @metadataPath) = @metadataValue'); + params.metadataPath = `$.${metadataKey}`; + params.metadataValue = metadataValue; + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; const limitValue = Number.parseInt(String(filters?.limit ?? '50'), 10); const skipValue = Number.parseInt(String(filters?.skip ?? '0'), 10); @@ -648,6 +664,16 @@ export function TracingMixin>(Base if (filters?.from) { clauses.push('createdAt >= @from'); params.from = (filters.from as Date).toISOString(); } if (filters?.to) { clauses.push('createdAt <= @to'); params.to = (filters.to as Date).toISOString(); } + const metadataKey = + typeof filters?.metadataKey === 'string' ? filters.metadataKey.trim() : ''; + const metadataValue = + typeof filters?.metadataValue === 'string' ? filters.metadataValue : undefined; + if (metadataKey && /^[a-zA-Z0-9_]{1,40}$/.test(metadataKey) && metadataValue !== undefined) { + clauses.push('json_extract(metadata, @metadataPath) = @metadataValue'); + params.metadataPath = `$.${metadataKey}`; + params.metadataValue = metadataValue; + } + const where = `WHERE ${clauses.join(' AND ')}`; const limit = (filters?.limit as number) ?? 50; const skip = (filters?.skip as number) ?? 0; @@ -800,6 +826,7 @@ export function TracingMixin>(Base agentName: r.agentName as string | undefined, agentVersion: r.agentVersion as string | undefined, agentModel: r.agentModel as string | undefined, + metadata: this.parseJson(r.metadata, {}), config: this.parseJson(r.config, {}), summary: this.parseJson(r.summary, {}), status: r.status as string | undefined, diff --git a/src/lib/services/agentTracing.ts b/src/lib/services/agentTracing.ts index e9627ad1..7607e4a5 100644 --- a/src/lib/services/agentTracing.ts +++ b/src/lib/services/agentTracing.ts @@ -298,6 +298,7 @@ const SESSION_LIST_PROJECTION = { totalInputTokens: 1, totalOutputTokens: 1, totalCachedInputTokens: 1, + metadata: 1, } as const; const SESSION_EVENT_SUMMARY_PROJECTION = { @@ -654,6 +655,8 @@ export class AgentTracingService { to?: string; limit?: string; skip?: string; + metadataKey?: string; + metadataValue?: string; }, ) { const db = await getDatabase(); @@ -667,6 +670,8 @@ export class AgentTracingService { to: filters?.to, limit: filters?.limit || '50', skip: filters?.skip || '0', + metadataKey: filters?.metadataKey, + metadataValue: filters?.metadataValue, }, projectId); return result; @@ -783,6 +788,8 @@ export class AgentTracingService { to?: string; limit?: string; skip?: string; + metadataKey?: string; + metadataValue?: string; }, ) { const db = await getDatabase(); @@ -797,6 +804,8 @@ export class AgentTracingService { to: filters?.to, limit: filters?.limit || '50', skip: filters?.skip || '0', + metadataKey: filters?.metadataKey, + metadataValue: filters?.metadataValue, }, projectId); return { @@ -813,6 +822,7 @@ export class AgentTracingService { totalInputTokens: s.totalInputTokens, totalOutputTokens: s.totalOutputTokens, totalCachedInputTokens: s.totalCachedInputTokens, + metadata: s.metadata, })), total: result.total, }; @@ -853,6 +863,7 @@ export class AgentTracingService { agentName: session.agentName, agentVersion: session.agentVersion, agentModel: session.agentModel, + metadata: session.metadata, status: session.status, startedAt: session.startedAt, endedAt: session.endedAt, diff --git a/src/server/api/plugins/client-tracing.ts b/src/server/api/plugins/client-tracing.ts index 408fbba4..cce184db 100644 --- a/src/server/api/plugins/client-tracing.ts +++ b/src/server/api/plugins/client-tracing.ts @@ -404,7 +404,13 @@ function getEventToolDetails( sections: Array> = getEventSections(event), ): Record | undefined { const candidates = [ - event.toolDetails, + // Network payloads aren't trustworthy just because the TS type says + // `toolDetails?: Record` — a bad upstream serializer can + // hand this the literal string "undefined" (see agent-sdk's + // sanitizeTracePayload), and Boolean("undefined") is true, so an + // unguarded string here survives `.find(Boolean)` and then gets + // spread into single-character indexed keys below. + toRecord(event.toolDetails), toRecord(event.metadata?.toolDetails), toRecord(event.data?.toolDetails), ...sections.map((section) => toRecord(section.toolDetails) || toRecord(section.details)), diff --git a/src/server/api/plugins/models.ts b/src/server/api/plugins/models.ts index ee95184d..650a4f1e 100644 --- a/src/server/api/plugins/models.ts +++ b/src/server/api/plugins/models.ts @@ -12,6 +12,7 @@ import { listUsageLogs, updateModel, } from '@/lib/services/models/modelService'; +import { parseMetadataGroupByKey, type UsageBreakdownGroupBy } from '@/lib/services/usage/usageBreakdown'; import type { IDynamicRoutingConfig } from '@/lib/database'; import type { UpdateModelInput } from '@/lib/services/models/types'; import { @@ -75,7 +76,7 @@ type ModelUsageQuery = { type ModelUsageBreakdownQuery = { from?: string; - groupBy?: 'user' | 'token'; + groupBy?: 'user' | 'token' | string; to?: string; }; @@ -644,10 +645,20 @@ export const modelsApiPlugin: FastifyPluginAsync = async (app) => { } const query = (request.query ?? {}) as ModelUsageBreakdownQuery; - if (query.groupBy !== undefined && query.groupBy !== 'user' && query.groupBy !== 'token') { - return reply.code(400).send({ error: '`groupBy` must be user or token' }); + const metadataKey = query.groupBy?.startsWith('metadata.') + ? parseMetadataGroupByKey(query.groupBy) + : undefined; + if (query.groupBy?.startsWith('metadata.') && !metadataKey) { + return reply.code(400).send({ + error: '`groupBy` metadata key must match /^[a-zA-Z0-9_]{1,40}$/', + }); + } + if (!metadataKey && query.groupBy !== undefined && query.groupBy !== 'user' && query.groupBy !== 'token') { + return reply.code(400).send({ error: '`groupBy` must be user, token, or metadata.' }); } - const groupBy = query.groupBy ?? 'user'; + const groupBy: UsageBreakdownGroupBy = metadataKey + ? (query.groupBy as UsageBreakdownGroupBy) + : ((query.groupBy as 'user' | 'token' | undefined) ?? 'user'); const to = buildDate(query.to) ?? new Date(); const from = buildDate(query.from) ?? new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); diff --git a/src/server/api/plugins/tracing.ts b/src/server/api/plugins/tracing.ts index 2ab0da5a..4cffc72f 100644 --- a/src/server/api/plugins/tracing.ts +++ b/src/server/api/plugins/tracing.ts @@ -10,6 +10,22 @@ import { const logger = createLogger('api:tracing'); +// Same key charset the ingest sanitizer enforces (client-tracing.ts) — the +// key reaches a Mongo `metadata.` dot-path / SQLite JSON path in the DB +// layer, so an unvalidated key is a query-shape injection surface. +const METADATA_KEY_PATTERN = /^[a-zA-Z0-9_]{1,40}$/; + +function parseMetadataFilter(query: Record): + | { metadataKey?: string; metadataValue?: string } + | { error: string } { + const key = query.metadataKey?.trim(); + if (!key) return {}; + if (!METADATA_KEY_PATTERN.test(key)) { + return { error: '`metadataKey` must match /^[a-zA-Z0-9_]{1,40}$/' }; + } + return { metadataKey: key, metadataValue: query.metadataValue }; +} + export const tracingApiPlugin: FastifyPluginAsync = async (app) => { const dashboardHandler = withApiRequestContext(async (request, reply) => { try { @@ -47,6 +63,10 @@ export const tracingApiPlugin: FastifyPluginAsync = async (app) => { try { const { projectId, session } = await requireProjectContextForRequest(request); const query = (request.query ?? {}) as Record; + const metadataFilter = parseMetadataFilter(query); + if ('error' in metadataFilter) { + return reply.code(400).send({ error: metadataFilter.error }); + } const result = await AgentTracingService.listSessions(session.tenantDbName, projectId, { agent: query.agent, from: query.from, @@ -55,6 +75,7 @@ export const tracingApiPlugin: FastifyPluginAsync = async (app) => { skip: query.skip || '0', status: query.status, to: query.to, + ...metadataFilter, }); return reply.code(200).send(result); @@ -127,6 +148,10 @@ export const tracingApiPlugin: FastifyPluginAsync = async (app) => { try { const { projectId, session } = await requireProjectContextForRequest(request); const query = (request.query ?? {}) as Record; + const metadataFilter = parseMetadataFilter(query); + if ('error' in metadataFilter) { + return reply.code(400).send({ error: metadataFilter.error }); + } const result = await AgentTracingService.listThreads(session.tenantDbName, projectId, { agent: query.agent, from: query.from, @@ -135,6 +160,7 @@ export const tracingApiPlugin: FastifyPluginAsync = async (app) => { status: query.status, threadId: query.threadId, to: query.to, + ...metadataFilter, }); return reply.code(200).send(result);