From 647d6a11af938772e7a9b16b7274903a6ae80fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1l=20G=C3=BClero=C4=9Flu?= Date: Mon, 17 Aug 2026 14:12:44 +0300 Subject: [PATCH 1/2] Add dynamic tracing-metadata usage attribution + fix Tracing Agents nav Usage attribution: - Tracing sessions accept a free-form metadata bag (sibling of agent), sanitized at ingest, flowing through recordUsageEvent -> usage_daily as a new dimension keyed by its canonical serialization. - Mongo/SQLite: usage_daily gains metadata/metadataKey; unique dims index bumped to v3 (adds metadataKey, same migration pattern as the earlier agentKey v1->v2 bump). - spend/report and analytics/usage group_by[_entity] accept metadata. for dynamic grouping, validated against an allowlist regex before it ever reaches a query. - agent-sdk 0.9.4 dependency bump (already published) to pick up TracingConfig.metadata support; console's own internal-agent tracing sink (agentService.ts) now forwards it too. Tracing UI: - Agents sub-nav item (Overview/Sessions/Threads/Agents) replaces the old per-agent-name directory that was rendered directly into the sidebar; new /dashboard/tracing/agents list page (reuses the existing /api/tracing/agents directory endpoint). Full suite green: 3207/3207 tests, tsc, eslint. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01R7LsJcWXZ2D9ZmhEWx1UVL --- package-lock.json | 8 +- package.json | 2 +- src/app/dashboard/tracing/agents/page.tsx | 211 ++++++++++++++++++ .../layout/launcher/ServiceSubNav.tsx | 92 +------- src/lib/database/mongodb/usage.mixin.ts | 14 +- src/lib/database/provider/types.base.ts | 16 ++ src/lib/database/sqlite/base.ts | 26 ++- src/lib/database/sqlite/schema.ts | 9 +- src/lib/database/sqlite/usage.mixin.ts | 13 +- src/lib/services/agentTracing.ts | 6 + src/lib/services/agents/agentService.ts | 2 + src/lib/services/otlpMapper.ts | 35 +++ src/lib/services/spend/spendService.ts | 9 +- src/lib/services/usage/usageBreakdown.ts | 49 +++- src/lib/services/usage/usageEvents.ts | 22 ++ src/lib/services/usage/usageRollup.ts | Bin 7170 -> 7486 bytes src/server/api/plugins/client-analytics.ts | 35 ++- src/server/api/plugins/client-spend.ts | 25 ++- src/server/api/plugins/client-tracing.ts | 52 +++++ 19 files changed, 496 insertions(+), 130 deletions(-) create mode 100644 src/app/dashboard/tracing/agents/page.tsx diff --git a/package-lock.json b/package-lock.json index 7ef9d4b8..9401e6c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@aws-sdk/client-textract": "^3.901.0", "@aws-sdk/util-stream-node": "^3.370.0", "@azure/search-documents": "^12.2.0", - "@cognipeer/agent-sdk": "^0.9.3", + "@cognipeer/agent-sdk": "^0.9.4", "@cognipeer/to-markdown": "^3.1.0", "@elastic/elasticsearch": "^8.19.1", "@fastify/websocket": "^11.2.0", @@ -2344,9 +2344,9 @@ "license": "MIT" }, "node_modules/@cognipeer/agent-sdk": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@cognipeer/agent-sdk/-/agent-sdk-0.9.3.tgz", - "integrity": "sha512-i286Z+/syLdkf1aMBGkWmcB6F6RnpKQf7a2ha48AVLD3NOoVQ5Ek7JDVslbth78v9uDVSYh1ar8wqLQFV0NIJg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@cognipeer/agent-sdk/-/agent-sdk-0.9.4.tgz", + "integrity": "sha512-P1iKiqQQ3Y+pGZ9dvv+6ASFyzJyd6fjuhwJFBOQymBvOglgC3MRHRBUk2QT25yHN6TYkGqshfag+bhj7w8QpHQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 07cf1b7f..f7ea7acb 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@aws-sdk/client-textract": "^3.901.0", "@aws-sdk/util-stream-node": "^3.370.0", "@azure/search-documents": "^12.2.0", - "@cognipeer/agent-sdk": "^0.9.3", + "@cognipeer/agent-sdk": "^0.9.4", "@cognipeer/to-markdown": "^3.1.0", "@elastic/elasticsearch": "^8.19.1", "@fastify/websocket": "^11.2.0", diff --git a/src/app/dashboard/tracing/agents/page.tsx b/src/app/dashboard/tracing/agents/page.tsx new file mode 100644 index 00000000..88545d81 --- /dev/null +++ b/src/app/dashboard/tracing/agents/page.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button, Text, Tooltip } from '@mantine/core'; +import { IconBook, IconEye, IconRobot } from '@tabler/icons-react'; +import PageContainer, { PageHeader } from '@/components/common/ui/PageContainer'; +import DataGrid, { type DataGridColumn } from '@/components/common/ui/DataGrid'; +import StatusBadge from '@/components/common/ui/StatusBadge'; +import { formatNumber, formatRelativeTime } from '@/lib/utils/tracingUtils'; +import { useDocsDrawer } from '@/components/docs/DocsDrawerContext'; + +interface AgentRecord { + name: string; + sessionsCount: number; + latestSessionAt: string | null; + latestStatus: string | null; +} + +interface AgentsResponse { + agents: AgentRecord[]; + total: number; +} + +// The directory endpoint is a lightweight "top N by recent activity" list +// (sub-nav/pickers use it too), not a paginated one — a generous cap covers +// realistic per-tenant agent counts without adding server-side pagination. +const DIRECTORY_LIMIT = 200; + +export default function TracingAgentsPage() { + const router = useRouter(); + const { openDocs } = useDocsDrawer(); + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + const fetchAgents = useCallback( + async (isRefresh = false, signal?: AbortSignal) => { + try { + if (isRefresh) setRefreshing(true); + else setLoading(true); + + const response = await fetch( + `/api/tracing/agents?limit=${DIRECTORY_LIMIT}`, + { signal, cache: 'no-store' }, + ); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to fetch agents'); + } + const data: AgentsResponse = await response.json(); + setAgents(data.agents || []); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + console.error('Failed to load agents:', error); + setAgents([]); + } finally { + if (!signal?.aborted) { + setLoading(false); + setRefreshing(false); + } + } + }, + [], + ); + + useEffect(() => { + const controller = new AbortController(); + void fetchAgents(false, controller.signal); + return () => controller.abort(); + }, [fetchAgents]); + + const filteredAgents = useMemo(() => { + const q = query.trim().toLowerCase(); + return agents.filter((a) => { + if (q && !a.name.toLowerCase().includes(q)) return false; + if (statusFilter !== 'all' && (a.latestStatus || 'unknown') !== statusFilter) return false; + return true; + }); + }, [agents, query, statusFilter]); + + const columns: DataGridColumn[] = [ + { + key: 'name', + label: 'Agent', + render: (a) => ( + + + + {a.name} + + + ), + }, + { + key: 'sessions', + label: 'Sessions', + align: 'right', + render: (a) => ( + + {formatNumber(a.sessionsCount)} + + ), + }, + { + key: 'status', + label: 'Latest status', + render: (a) => ( + + ), + }, + { + key: 'latest', + label: 'Last session', + render: (a) => ( + + {a.latestSessionAt ? formatRelativeTime(a.latestSessionAt) : '—'} + + ), + }, + ]; + + return ( + + } + onClick={() => openDocs('api-tracing')} + > + Docs + + } + /> + + + records={filteredAgents} + loading={loading} + rowKey={(a) => a.name} + onRowClick={(a) => + router.push(`/dashboard/tracing/agents/${encodeURIComponent(a.name)}`) + } + columns={columns} + search={{ + value: query, + onChange: setQuery, + placeholder: 'Search by agent name…', + }} + filters={[ + { + value: statusFilter, + onChange: setStatusFilter, + ariaLabel: 'Filter by latest status', + width: 160, + options: [ + { value: 'all', label: 'All statuses' }, + { value: 'success', label: 'Success' }, + { value: 'error', label: 'Error' }, + { value: 'running', label: 'Running' }, + ], + }, + ]} + onRefresh={() => void fetchAgents(true)} + refreshing={refreshing} + empty={{ + icon: , + title: 'No agents found', + description: 'Agents will appear once they report their first tracing session.', + }} + footerLeft={ + + {formatNumber(filteredAgents.length)} of {formatNumber(agents.length)} agents + + } + rowActions={(a) => [ + { + id: 'view', + label: 'View agent', + icon: , + onClick: () => + router.push(`/dashboard/tracing/agents/${encodeURIComponent(a.name)}`), + }, + ]} + /> + + ); +} diff --git a/src/components/layout/launcher/ServiceSubNav.tsx b/src/components/layout/launcher/ServiceSubNav.tsx index 5e1436f4..d44a8541 100644 --- a/src/components/layout/launcher/ServiceSubNav.tsx +++ b/src/components/layout/launcher/ServiceSubNav.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useEffect, useState } from 'react'; import { useTranslations } from '@/lib/i18n'; import type { DashboardServiceDefinition } from '@/lib/utils/dashboardServices'; import { ActionIcon, Text, Tooltip } from '@mantine/core'; @@ -260,6 +259,13 @@ export const SUBNAV_CONFIG: Record = { icon: IconMessage, matcher: (p) => p.startsWith('/dashboard/tracing/threads'), }, + { + id: 'agents', + label: 'Agents', + href: '/dashboard/tracing/agents', + icon: IconRobot, + matcher: (p) => p.startsWith('/dashboard/tracing/agents'), + }, ], cost: [ { @@ -617,63 +623,6 @@ export const SUBNAV_CONFIG: Record = { ], }; -// ── Dynamic agent directory for the Tracing sub-nav ──────────────────────── -// The observability landing page intentionally has NO agent list — agents -// live here in the left menu instead. Fetched once per page load (module -// cache, 5-minute TTL) so navigating between tracing pages doesn't refetch. - -interface TracingNavAgent { - name: string; - sessionsCount: number; -} - -const TRACING_AGENT_NAV_CAP = 20; -const TRACING_AGENT_CACHE_TTL_MS = 5 * 60 * 1000; -let tracingAgentCache: { at: number; agents: TracingNavAgent[] } | null = null; -let tracingAgentInflight: Promise | null = null; - -async function fetchTracingNavAgents(): Promise { - if (tracingAgentCache && Date.now() - tracingAgentCache.at < TRACING_AGENT_CACHE_TTL_MS) { - return tracingAgentCache.agents; - } - if (!tracingAgentInflight) { - tracingAgentInflight = (async () => { - try { - const res = await fetch(`/api/tracing/agents?limit=${TRACING_AGENT_NAV_CAP}`, { - cache: 'no-store', - }); - if (!res.ok) return tracingAgentCache?.agents ?? []; - const payload = (await res.json()) as { agents?: TracingNavAgent[] }; - const agents = (payload.agents ?? []).filter((a) => a.name); - tracingAgentCache = { at: Date.now(), agents }; - return agents; - } catch { - return tracingAgentCache?.agents ?? []; - } finally { - tracingAgentInflight = null; - } - })(); - } - return tracingAgentInflight; -} - -function useTracingNavAgents(enabled: boolean): TracingNavAgent[] { - const [agents, setAgents] = useState( - () => tracingAgentCache?.agents ?? [], - ); - useEffect(() => { - if (!enabled) return; - let alive = true; - void fetchTracingNavAgents().then((result) => { - if (alive) setAgents(result); - }); - return () => { - alive = false; - }; - }, [enabled]); - return agents; -} - interface ServiceSubNavProps { service: DashboardServiceDefinition; pathname: string; @@ -700,7 +649,6 @@ export default function ServiceSubNav({ const tNav = useTranslations('navigation'); const ServiceIcon = service.icon; const navItems = items ?? SUBNAV_CONFIG[service.id] ?? []; - const tracingAgents = useTracingNavAgents(service.id === 'tracing'); return (