From 87c5d3a78a6eabcab5937c8498352bb8e3f3f9b3 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 5 Jun 2026 17:51:52 +0200 Subject: [PATCH 1/5] chore(frontend): migrate tracing off deprecated/legacy endpoints (AGE-3788) Move the web tracing calls (sessions, delete, single-trace, flat-span, batch-trace + rate-limit meta) from the deprecated /tracing/* stack onto the Fern /spans/* and /traces/* endpoints under @agenta/entities, keeping Zod validation at the boundary. - New scaffolding: trace/api/{client,request,adapters}.ts + envelope Zod schemas - Repoint OSS drawers, observability + sessions consumers to @agenta/entities/trace - Flip span/trace_type enum catch-all "undefined" -> "unknown" (backend value) - Fix sessions enrichment grouping for the new endpoints' canonicalized ag.* attrs - Add unit + integration tests for the migrated functions and adapters Only /tracing/spans/analytics remains (Phase 6, gated on the MetricSpec contract). --- .../SessionDrawer/store/sessionDrawerStore.ts | 14 +- .../components/DeleteTraceModal/index.tsx | 6 +- .../components/TraceHeader/index.tsx | 6 +- .../TraceDrawer/store/traceDrawerStore.ts | 35 ++- web/oss/src/services/tracing/api/index.ts | 194 +------------ .../state/newObservability/atoms/queries.ts | 62 ++-- .../newObservability/atoms/queryHelpers.ts | 15 +- .../agenta-entities/src/trace/api/adapters.ts | 79 +++++ .../agenta-entities/src/trace/api/api.ts | 253 ++++++++++------ .../agenta-entities/src/trace/api/client.ts | 73 +++++ .../agenta-entities/src/trace/api/index.ts | 8 + .../agenta-entities/src/trace/api/request.ts | 100 +++++++ .../agenta-entities/src/trace/core/index.ts | 13 + .../agenta-entities/src/trace/core/schema.ts | 72 ++++- .../agenta-entities/src/trace/index.ts | 3 + .../trace-migration.integration.test.ts | 157 ++++++++++ .../unit/trace-migration-adapters.test.ts | 162 +++++++++++ .../tests/unit/trace-migration-api.test.ts | 270 ++++++++++++++++++ 18 files changed, 1197 insertions(+), 325 deletions(-) create mode 100644 web/packages/agenta-entities/src/trace/api/adapters.ts create mode 100644 web/packages/agenta-entities/src/trace/api/client.ts create mode 100644 web/packages/agenta-entities/src/trace/api/request.ts create mode 100644 web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts create mode 100644 web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts create mode 100644 web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts diff --git a/web/oss/src/components/SharedDrawers/SessionDrawer/store/sessionDrawerStore.ts b/web/oss/src/components/SharedDrawers/SessionDrawer/store/sessionDrawerStore.ts index 258932bbb5..fa06c4e99e 100644 --- a/web/oss/src/components/SharedDrawers/SessionDrawer/store/sessionDrawerStore.ts +++ b/web/oss/src/components/SharedDrawers/SessionDrawer/store/sessionDrawerStore.ts @@ -1,4 +1,6 @@ import { + fetchAllPreviewTraces, + fetchPreviewTrace, isSpansResponse, isTracesResponse, transformTracesResponseToTree, @@ -16,7 +18,6 @@ import {AnnotationDto} from "@/oss/lib/hooks/useAnnotations/types" import {getNodeById, observabilityTransformer} from "@/oss/lib/traces/observability_helpers" import {queryAllAnnotations} from "@/oss/services/annotations/api" import {AgentaTreeDTO, TracesWithAnnotations} from "@/oss/services/observability/types" -import {fetchAllPreviewTraces, fetchPreviewTrace} from "@/oss/services/tracing/api" import {SpanLink, TraceSpanNode, TracesResponse} from "@/oss/services/tracing/types" import {selectedAppIdAtom} from "@/oss/state/app/selectors/app" import {getOrgValues} from "@/oss/state/org" @@ -122,7 +123,7 @@ export const sessionTracesQueryAtom = atomWithQuery((get) => { queryKey: ["session-traces", projectId, appId, sessionId], queryFn: async () => { if (!sessionId) return {traces: [], count: 0} - return fetchAllPreviewTraces(params, appId as string) + return fetchAllPreviewTraces(params, appId as string, projectId ?? "") }, enabled: sessionExists && Boolean(appId || projectId) && Boolean(sessionId), refetchOnWindowFocus: false, @@ -391,9 +392,10 @@ export const sessionDrawerAnnotationLinkTracesQueryAtom = atomWithQuery< Record >((get) => { const targets = get(sessionDrawerAnnotationLinkTargetsAtom) + const projectId = get(projectIdAtom) return { - queryKey: ["session-drawer-annotation-links", targets], + queryKey: ["session-drawer-annotation-links", targets, projectId ?? "none"], enabled: Array.isArray(targets) && targets.length > 0, refetchOnWindowFocus: false, queryFn: async () => { @@ -402,7 +404,9 @@ export const sessionDrawerAnnotationLinkTracesQueryAtom = atomWithQuery< const traceResponses = await Promise.all( uniqueTraceIds.map(async (traceId) => { - const response = await fetchPreviewTrace(traceId) + // `any`: see traceDrawerStore — loose to match the runtime + // multi-shape handling until AGE-3788 Phase 7 unifies FE types. + const response: any = await fetchPreviewTrace(traceId, projectId ?? "") const tree = response?.response?.tree as AgentaTreeDTO | undefined if (tree) { @@ -418,7 +422,7 @@ export const sessionDrawerAnnotationLinkTracesQueryAtom = atomWithQuery< return { traceId, nodes: transformTracingResponse( - transformTracesResponseToTree(fallback), + transformTracesResponseToTree(fallback as never), ) as unknown as TracesWithAnnotations[], } }), diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/DeleteTraceModal/index.tsx b/web/oss/src/components/SharedDrawers/TraceDrawer/components/DeleteTraceModal/index.tsx index 414d8c787c..68f52beea9 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/DeleteTraceModal/index.tsx +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/DeleteTraceModal/index.tsx @@ -1,12 +1,13 @@ import {useState} from "react" +import {deletePreviewTrace} from "@agenta/entities/trace" import {DeleteOutlined} from "@ant-design/icons" import {Modal} from "antd" import {useAtom, useAtomValue, useSetAtom} from "jotai" import Router from "next/router" -import {deletePreviewTrace} from "@/oss/services/tracing/api" import {useObservability} from "@/oss/state/newObservability" +import {getProjectValues} from "@/oss/state/project" import {traceIdAtom} from "@/oss/state/url/trace" import {closeTraceDrawerAtom} from "../../store/traceDrawerStore" @@ -27,7 +28,8 @@ const DeleteTraceModal = () => { const handleDelete = async () => { try { setIsLoading(true) - await Promise.all(traceIds.map((id) => deletePreviewTrace(id))) + const {projectId} = getProjectValues() + await Promise.all(traceIds.map((id) => deletePreviewTrace(id, projectId ?? ""))) await fetchTraces() const isCurrentTraceDeleted = traceIds.includes(currentTraceId || "") diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceHeader/index.tsx b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceHeader/index.tsx index 9071855464..84325469e1 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceHeader/index.tsx +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceHeader/index.tsx @@ -1,6 +1,7 @@ import {useCallback, useEffect, useMemo, useState} from "react" import { + fetchAllPreviewTraces, isSpansResponse, isTracesResponse, transformTracesResponseToTree, @@ -17,11 +18,11 @@ import { traceDrawerBackTargetAtom, traceDrawerIsLinkedViewAtom, } from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" -import {fetchAllPreviewTraces} from "@/oss/services/tracing/api" import {TraceSpanNode} from "@/oss/services/tracing/types" import {selectedAppIdAtom} from "@/oss/state/app/selectors/app" import {useObservability} from "@/oss/state/newObservability" import buildTraceQueryParams from "@/oss/state/newObservability/utils/buildTraceQueryParams" +import {getProjectValues} from "@/oss/state/project" import {getNodeTimestamp, getSpanIdFromNode, getTraceIdFromNode, toISOString} from "./assets/helper" import {NavSource, NavState, TraceHeaderProps} from "./assets/types" @@ -177,7 +178,8 @@ const TraceHeader = ({ } try { - const response = await fetchAllPreviewTraces(params, appId) + const {projectId} = getProjectValues() + const response = await fetchAllPreviewTraces(params, appId, projectId ?? "") console.debug("[TraceNav] fetchRelative:response", { direction, diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/store/traceDrawerStore.ts b/web/oss/src/components/SharedDrawers/TraceDrawer/store/traceDrawerStore.ts index b0cef8d2eb..251361e188 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/store/traceDrawerStore.ts +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/store/traceDrawerStore.ts @@ -1,4 +1,8 @@ -import {transformTracesResponseToTree, transformTracingResponse} from "@agenta/entities/trace" +import { + fetchPreviewTrace, + transformTracesResponseToTree, + transformTracingResponse, +} from "@agenta/entities/trace" import {atom} from "jotai" import {atomWithStorage} from "jotai/utils" import {atomWithImmer} from "jotai-immer" @@ -10,9 +14,9 @@ import {AnnotationDto} from "@/oss/lib/hooks/useAnnotations/types" import {getNodeById, observabilityTransformer} from "@/oss/lib/traces/observability_helpers" import {queryAllAnnotations} from "@/oss/services/annotations/api" import {AgentaTreeDTO, TracesWithAnnotations} from "@/oss/services/observability/types" -import {fetchPreviewTrace} from "@/oss/services/tracing/api" import {SpanLink, TracesResponse} from "@/oss/services/tracing/types" import {getOrgValues} from "@/oss/state/org" +import {projectIdAtom} from "@/oss/state/project" export type TraceDrawerSpanLink = SpanLink & {key?: string} interface AnnotationLinkTarget { @@ -130,14 +134,15 @@ export const setTraceDrawerTraceAtom = atom( // Fetches the currently selected trace. export const traceDrawerQueryAtom = atomWithQuery((get) => { const traceId = get(traceDrawerTraceIdAtom) + const projectId = get(projectIdAtom) return { - queryKey: ["trace-drawer", traceId ?? "none"], + queryKey: ["trace-drawer", traceId ?? "none", projectId ?? "none"], enabled: Boolean(traceId), refetchOnWindowFocus: false, queryFn: async () => { if (!traceId) return null - return fetchPreviewTrace(traceId) + return fetchPreviewTrace(traceId, projectId ?? "") }, } }) @@ -171,7 +176,11 @@ const flattenTraces = (nodes: TracesWithAnnotations[]): TracesWithAnnotations[] } export const traceDrawerBaseTracesAtom = atom((get) => { - const {data: traceResponse} = get(traceDrawerQueryAtom) + // `any` on purpose: these stores accept multiple response shapes (legacy map, + // agenta `.response.tree`, new typed TracesResponse) and branch at runtime via + // normalizeTracesResponse. AGE-3788 Phase 7 unifies the FE trace types; until + // then the loose local mirrors the pre-migration (untyped) handling. + const traceResponse: any = get(traceDrawerQueryAtom).data const tree = traceResponse?.response?.tree as AgentaTreeDTO | undefined if (tree) { @@ -182,7 +191,7 @@ export const traceDrawerBaseTracesAtom = atom((get) => if (!fallback) return [] return transformTracingResponse( - transformTracesResponseToTree(fallback), + transformTracesResponseToTree(fallback as never), ) as unknown as TracesWithAnnotations[] }) @@ -287,9 +296,10 @@ export const annotationLinkTargetsAtom = atom((get) => { export const annotationLinkTracesQueryAtom = atomWithQuery>( (get) => { const targets = get(annotationLinkTargetsAtom) + const projectId = get(projectIdAtom) return { - queryKey: ["trace-drawer-annotation-links", targets], + queryKey: ["trace-drawer-annotation-links", targets, projectId ?? "none"], enabled: Array.isArray(targets) && targets.length > 0, refetchOnWindowFocus: false, queryFn: async () => { @@ -298,7 +308,7 @@ export const annotationLinkTracesQueryAtom = atomWithQuery { - const response = await fetchPreviewTrace(traceId) + const response: any = await fetchPreviewTrace(traceId, projectId ?? "") const tree = response?.response?.tree as AgentaTreeDTO | undefined if (tree) { @@ -314,7 +324,7 @@ export const annotationLinkTracesQueryAtom = atomWithQuery((get) => { export const linkedSpanTracesQueryAtom = atomWithQuery>( (get) => { const targets = get(linkedSpanTargetsAtom) + const projectId = get(projectIdAtom) return { - queryKey: ["trace-drawer-linked-spans", targets], + queryKey: ["trace-drawer-linked-spans", targets, projectId ?? "none"], enabled: Array.isArray(targets) && targets.length > 0, refetchOnWindowFocus: false, queryFn: async () => { @@ -396,7 +407,7 @@ export const linkedSpanTracesQueryAtom = atomWithQuery { - const response = await fetchPreviewTrace(traceId) + const response: any = await fetchPreviewTrace(traceId, projectId ?? "") const tree = response?.response?.tree as AgentaTreeDTO | undefined if (tree) { @@ -412,7 +423,7 @@ export const linkedSpanTracesQueryAtom = atomWithQuery = {}, - appId: string, - signal?: AbortSignal, -) => { - const base = getBaseUrl() - const projectId = ensureProjectId() - const applicationId = ensureAppId(appId) - - // New query endpoint expects POST with JSON body - const url = new URL(`${base}/tracing/spans/query`) - if (projectId) url.searchParams.set("project_id", projectId) - if (applicationId) url.searchParams.set("application_id", applicationId) - - const payload: Record = {} - Object.entries(params).forEach(([key, value]) => { - if (value === undefined || value === null) return - if (key === "size") { - payload.limit = Number(value) - } else if (key === "filter" && typeof value === "string") { - try { - payload.filter = JSON.parse(value) - } catch { - payload.filter = value - } - } else { - payload[key] = value - } - }) - - return fetchJson(url, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(payload), - signal, - }) -} - -/** - * Bucket state for adaptive pacing. `null` when the backend didn't return - * the corresponding header (OSS deployments without EE throttling, errors - * before headers, etc.). - */ -export interface PreviewTracesRateLimit { - /** `X-RateLimit-Remaining` — tokens left in the throttle bucket. */ - remaining: number | null - /** `X-RateLimit-Limit` — bucket capacity. Only set on 429 responses. */ - limit: number | null -} - -/** Successful return shape from `fetchAllPreviewTracesWithMeta`. */ -export interface PreviewTracesWithMetaResult { - data: any - rateLimit: PreviewTracesRateLimit -} - -const parseRateLimitHeader = (headers: Headers, name: string): number | null => { - const raw = headers.get(name) - if (!raw) return null - const n = Number.parseInt(raw, 10) - return Number.isFinite(n) ? n : null -} - -/** - * Variant of `fetchAllPreviewTraces` that also returns the throttling bucket - * state via `X-RateLimit-*` headers. Used by the bulk export to pace requests - * adaptively without depending on knowing the user's plan tier — the server - * tells us how much headroom is left on every successful response. - */ -export const fetchAllPreviewTracesWithMeta = async ( - params: Record = {}, - appId: string, - signal?: AbortSignal, -): Promise => { - const base = getBaseUrl() - const projectId = ensureProjectId() - const applicationId = ensureAppId(appId) - - const url = new URL(`${base}/tracing/spans/query`) - if (projectId) url.searchParams.set("project_id", projectId) - if (applicationId) url.searchParams.set("application_id", applicationId) - - const payload: Record = {} - Object.entries(params).forEach(([key, value]) => { - if (value === undefined || value === null) return - if (key === "size") { - payload.limit = Number(value) - } else if (key === "filter" && typeof value === "string") { - try { - payload.filter = JSON.parse(value) - } catch { - payload.filter = value - } - } else { - payload[key] = value - } - }) - - const {data, headers} = await fetchJsonWithMeta(url, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(payload), - signal, - }) - - return { - data, - rateLimit: { - remaining: parseRateLimitHeader(headers, "x-ratelimit-remaining"), - limit: parseRateLimitHeader(headers, "x-ratelimit-limit"), - }, - } -} - -export const fetchPreviewTrace = async (traceId: string) => { - const base = getBaseUrl() - const projectId = ensureProjectId() - - const url = new URL(`${base}/tracing/traces/${traceId}`) - if (projectId) url.searchParams.set("project_id", projectId) - - return fetchJson(url) -} - -export const deletePreviewTrace = async (traceId: string) => { - const base = getBaseUrl() - const projectId = ensureProjectId() - - const url = new URL(`${base}/tracing/traces/${traceId}`) - if (projectId) url.searchParams.set("project_id", projectId) - - return fetchJson(url, {method: "DELETE"}) -} - -export const fetchSessions = async (params: { - appId?: string - windowing?: { - oldest?: string - newest?: string - next?: string - limit?: number - order?: string - } - cursor?: string - filter?: any - realtime?: boolean -}) => { - const base = getBaseUrl() - const projectId = ensureProjectId() - const applicationId = params.appId ? ensureAppId(params.appId) : undefined - - const url = new URL(`${base}/tracing/sessions/query`) - if (projectId) url.searchParams.set("project_id", projectId) - if (applicationId) url.searchParams.set("application_id", applicationId) - - const payload: Record = {} - - // Initialize windowing if it doesn't exist but we have a cursor - if (params.windowing || params.cursor) { - payload.windowing = {...(params.windowing || {})} - - // If cursor is provided, it goes into windowing.next - if (params.cursor) { - payload.windowing.next = params.cursor - } - } - - if (params.filter) { - payload.filter = params.filter - } - - // Add realtime parameter (true = latest/unstable, false/undefined = all/stable) - if (params.realtime !== undefined) { - payload.realtime = params.realtime - } - - return fetchJson(url, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(payload), - }) -} +// AGE-3788: fetchAllPreviewTraces / fetchAllPreviewTracesWithMeta / fetchPreviewTrace +// / deletePreviewTrace / fetchSessions moved to the Fern client under +// `@agenta/entities/trace` (Phases 1-5). Only the analytics dashboard below +// remains here, pending the Phase 6 migration (gated on the MetricSpec contract). export const fetchGenerationsDashboardData = async ( appId: string | null | undefined, diff --git a/web/oss/src/state/newObservability/atoms/queries.ts b/web/oss/src/state/newObservability/atoms/queries.ts index 26bf90a760..17e2caa054 100644 --- a/web/oss/src/state/newObservability/atoms/queries.ts +++ b/web/oss/src/state/newObservability/atoms/queries.ts @@ -276,29 +276,34 @@ export const sessionsQueryAtom = atomWithInfiniteQuery((get) => { }, queryFn: async ({pageParam}: {pageParam?: {newest?: string; oldest?: string}}) => { - const {fetchSessions} = await import("@/oss/services/tracing/api") - - const response: any = await fetchSessions({ - appId: (appId as string) || undefined, - windowing: { - limit, - // Base time window from sort (initial boundaries for first page) - oldest: baseWindowing.oldest, - newest: baseWindowing.newest, - // Pagination cursors override base boundaries for subsequent pages: - // - In DESC order: pageParam.newest moves backward, oldest stays fixed - // - In ASC order: pageParam.oldest moves forward, newest stays fixed - ...(pageParam?.oldest && {oldest: pageParam.oldest}), - ...(pageParam?.newest && {newest: pageParam.newest}), + // AGE-3788 Phase 1: sessions now go through the Fern client in + // @agenta/entities/trace (POST /spans/sessions/query). projectId is + // passed explicitly (the entities fn does not read it from state). + const {fetchSessions} = await import("@agenta/entities/trace") + + const response = await fetchSessions( + { + appId: (appId as string) || undefined, + windowing: { + limit, + // Base time window from sort (initial boundaries for first page) + oldest: baseWindowing.oldest, + newest: baseWindowing.newest, + // Pagination cursors override base boundaries for subsequent pages: + // - In DESC order: pageParam.newest moves backward, oldest stays fixed + // - In ASC order: pageParam.oldest moves forward, newest stays fixed + ...(pageParam?.oldest && {oldest: pageParam.oldest}), + ...(pageParam?.newest && {newest: pageParam.newest}), + }, + realtime: realtimeMode, }, - filter: undefined, - realtime: realtimeMode, - }) + projectId ?? "", + ) return { - session_ids: response.session_ids || [], - count: response.count || 0, - nextWindowing: response.windowing, + session_ids: response?.session_ids || [], + count: response?.count || 0, + nextWindowing: response?.windowing, } }, enabled: sessionExists && Boolean(appId || projectId), @@ -386,7 +391,7 @@ export const sessionsSpansQueryAtom = atomWithInfiniteQuery((get) => { }, ]) - return executeTraceQuery({ + const result = await executeTraceQuery({ params: specificParams, pageParam: pageParam as {newest?: string} | undefined, appId: appId as string, @@ -394,6 +399,15 @@ export const sessionsSpansQueryAtom = atomWithInfiniteQuery((get) => { hasAnnotationConditions, hasAnnotationOperator, }) + // Tag each trace with the session it was queried for. The grouping + // below relies on this instead of re-reading `attributes.ag.session.id`: + // the new /traces/query endpoint canonicalises `ag` to {data,type,metrics} + // and no longer serialises `ag.session.id` on the span, but every trace + // here is already scoped to `sessionId` by the filter above. (AGE-3788) + result.traces.forEach((t) => { + ;(t as TraceSpanNode & {__sessionId?: string}).__sessionId = sessionId + }) + return result }) const results = await Promise.all(promises) @@ -442,7 +456,11 @@ export const sessionsSpansAtom = selectAtom( const key = trace.span_id || trace.key if (!key || seen.has(key)) return seen.add(key) - const sessionId = (trace.attributes as any)?.ag?.session?.id as string + // Prefer the session id tagged at query time (the trace was fetched + // with a per-session filter). Fall back to the attribute path for any + // legacy/other caller that still serialises `ag.session.id`. (AGE-3788) + const sessionId = ((trace as TraceSpanNode & {__sessionId?: string}).__sessionId || + (trace.attributes as any)?.ag?.session?.id) as string if (sessionId) { if (!grouped[sessionId]) grouped[sessionId] = [] diff --git a/web/oss/src/state/newObservability/atoms/queryHelpers.ts b/web/oss/src/state/newObservability/atoms/queryHelpers.ts index 30d99c82ba..2946cd5dae 100644 --- a/web/oss/src/state/newObservability/atoms/queryHelpers.ts +++ b/web/oss/src/state/newObservability/atoms/queryHelpers.ts @@ -1,19 +1,18 @@ import { + fetchAllPreviewTracesWithMeta, isSpansResponse, isTracesResponse, transformTracesResponseToTree, transformTracingResponse, + type PreviewTracesRateLimit, } from "@agenta/entities/trace" import { normalizeReferenceValue, parseReferenceKey, } from "@/oss/components/pages/observability/assets/filters/referenceUtils" -import { - fetchAllPreviewTracesWithMeta, - type PreviewTracesRateLimit, -} from "@/oss/services/tracing/api" import {TraceSpanNode} from "@/oss/services/tracing/types" +import {getProjectValues} from "@/oss/state/project" export interface Condition { field: string @@ -267,8 +266,14 @@ export const executeTraceQuery = async ({ // even a long-running scan sees fresh bucket state on every page. let lastRateLimit: PreviewTracesRateLimit = {remaining: null, limit: null} + const {projectId} = getProjectValues() const fetchPage = async (pageParams: Record) => { - const result = await fetchAllPreviewTracesWithMeta(pageParams, appId, signal) + const result = await fetchAllPreviewTracesWithMeta( + pageParams, + appId, + projectId ?? "", + signal, + ) lastRateLimit = result.rateLimit return result.data } diff --git a/web/packages/agenta-entities/src/trace/api/adapters.ts b/web/packages/agenta-entities/src/trace/api/adapters.ts new file mode 100644 index 0000000000..d2e60942f7 --- /dev/null +++ b/web/packages/agenta-entities/src/trace/api/adapters.ts @@ -0,0 +1,79 @@ +/** + * Boundary adapters for the tracing migration (AGE-3788). + * + * The new Fern endpoints return the canonical `TraceOutput` tree, whose `spans` + * field is the SAME recursive span-name map (`Record`) + * that `traceSpanSchema` / `buildTree` already consume. So these adapters only + * normalise the OUTER ENVELOPE; the tree-building is reused, not rewritten. + * + * /traces/{id} -> TraceResponse {trace} -> fernTraceOutputToNodes + * /traces/query -> TracesResponse {traces:[]} -> fernTracesToLegacyTraceMap (transitional) + * /spans/query -> SpansResponse {spans:[]} -> fernSpansToNodes + * + * All three are transitional: Phase 7 moves consumers onto the Fern tree + * directly and deletes the legacy-map bridge (`fernTracesToLegacyTraceMap`). + */ +import type {TraceOutput, TraceSpan, TraceSpanNode, TracesResponse} from "../core" + +import {transformTracesResponseToTree, transformTracingResponse} from "./helpers" + +/** Strip dashes from a trace id to match the canonical (undashed) key form. */ +const canonicalTraceId = (id: string): string => id.replace(/-/g, "") + +/** + * `GET /traces/{id}` (`TraceResponse.trace`) -> enriched `TraceSpanNode[]`. + * + * Wraps the single `TraceOutput` into the legacy envelope shape and reuses the + * existing `transformTracesResponseToTree` + `transformTracingResponse` + * pipeline (identical to what the observability/drawer consumers already do). + */ +export function fernTraceOutputToNodes(trace: TraceOutput | null | undefined): TraceSpanNode[] { + if (!trace?.spans) return [] + const legacyEnvelope: TracesResponse = { + count: 1, + traces: { + [canonicalTraceId(trace.trace_id ?? "trace")]: { + spans: trace.spans as TracesResponse["traces"][string]["spans"], + }, + }, + } + return transformTracingResponse(transformTracesResponseToTree(legacyEnvelope)) +} + +/** + * `POST /traces/query` (`TracesResponse.traces: TraceOutput[]`) -> the legacy + * map-shaped `{count, traces: {[traceIdNoDashes]: {spans}}}`. + * + * TRANSITIONAL (Phase 5): `traceBatchFetcher` coalesces per-atom single-trace + * reads, strips dashes, and slices a per-request `{count, traces:{[idNoDashes]:...}}` + * out of this map. Keying by the undashed id keeps that coalescer + its + * consumers (`traceEntityAtomFamily`) byte-identical. Deleted in Phase 7. + */ +export function fernTracesToLegacyTraceMap( + traces: TraceOutput[] | null | undefined, +): TracesResponse { + const out: TracesResponse["traces"] = {} + for (const trace of traces ?? []) { + if (!trace?.trace_id) continue + out[canonicalTraceId(trace.trace_id)] = { + spans: (trace.spans ?? {}) as TracesResponse["traces"][string]["spans"], + } + } + return {count: Object.keys(out).length, traces: out} +} + +/** + * `POST /spans/query` flat `spans: TraceSpan[]` -> enriched `TraceSpanNode[]`. + * + * Flat spans form no tree (no `spans` children), so each maps to a leaf node; + * `transformTracingResponse` adds `key`/`invocationIds` for rendering. + */ +export function fernSpansToNodes(spans: TraceSpan[] | null | undefined): TraceSpanNode[] { + if (!spans?.length) return [] + return transformTracingResponse(spans as TraceSpanNode[]) +} + +// NOTE: the ETL pipeline (`evaluationRun/etl/hydrateScenariosTransform.ts`) and +// `resolveMappings.ts` consume the legacy MAP shape `data.traces[idNoDashes].spans` +// (verified), which `fernTracesToLegacyTraceMap` already produces — so no +// separate root-name-map adapter is needed. diff --git a/web/packages/agenta-entities/src/trace/api/api.ts b/web/packages/agenta-entities/src/trace/api/api.ts index 0028971021..813669141f 100644 --- a/web/packages/agenta-entities/src/trace/api/api.ts +++ b/web/packages/agenta-entities/src/trace/api/api.ts @@ -14,17 +14,29 @@ * ``` */ -import {axios, getAgentaApiUrl} from "@agenta/shared/api" +import type {AgentaApi} from "@agentaai/api-client" // See testcase/api/api.ts for rationale — the shared barrel pulls in CSS deps. import {safeParseWithLogging} from "../../shared/utils/zodSchema" import { spansResponseSchema, - tracesResponseSchema, + sessionIdsResponseSchema, + traceIdResponseSchema, + traceResponseSchema, + tracesArrayResponseSchema, type SpansResponse, type TracesResponse, + type SessionIdsResponse, + type TraceIdResponse, } from "../core" +import {fernTracesToLegacyTraceMap} from "./adapters" +// AGE-3788: all trace api functions are migrated to the Fern client +// (Phases 1-5): sessions, delete, single-trace, flat-span (querySpans) and +// trace-tree (queryTraces). No raw axios remains in this module. +import {callFern, getTracesClient, isAbortError, projectScopedRequest} from "./client" +import {buildSpansQueryRequest, buildTracesQueryRequest} from "./request" + /** * Query parameters for fetching traces/spans */ @@ -52,43 +64,121 @@ export async function fetchAllPreviewTraces( appId: string, projectId: string, ): Promise { - const baseUrl = getAgentaApiUrl() - - // Build query parameters - const queryParams = new URLSearchParams() - if (projectId) queryParams.set("project_id", projectId) - if (appId) queryParams.set("application_id", appId) - - // Build request payload - const payload: Record = {} - Object.entries(params).forEach(([key, value]) => { - if (value === undefined || value === null) return - if (key === "size") { - payload.limit = Number(value) - } else if (key === "filter" && typeof value === "string") { - try { - payload.filter = JSON.parse(value) - } catch { - payload.filter = value - } - } else { - payload[key] = value - } - }) - - const response = await axios.post( - `${baseUrl}/tracing/spans/query?${queryParams.toString()}`, - payload, + // AGE-3788 Phases 4-5: flat-span queries (focus !== "trace") go through Fern + // querySpans (POST /spans/query, flat SpansResponse); trace-tree queries + // (focus === "trace") go through queryTraces (POST /traces/query -> + // {traces: TraceOutput[]}, adapted back to the legacy map so the coalescer, + // prefetch, ETL and OSS drawers keep reading data.traces[traceIdNoDashes]). + // The new /spans/query rejects focus="trace" with 409. + // + // OQ(P5 integration): the trace-tree callers pass UNDASHED trace_ids in the + // filter (matching the legacy /tracing/spans/query behaviour). Whether + // /traces/query accepts undashed ids in `filtering` must be confirmed + // against a live backend — preserved as-is; covered by integration, not units. + const opts = projectScopedRequest(projectId, appId) + const data = await callFern("[fetchAllPreviewTraces]", () => + params.focus !== "trace" + ? getTracesClient().querySpans(buildSpansQueryRequest(params), opts) + : getTracesClient().queryTraces(buildTracesQueryRequest(params), opts), ) + if (!data) return null + return parseSpansOrTraces(params.focus, data) +} - // Try parsing as SpansResponse first (spans array format) - const spansResult = spansResponseSchema.safeParse(response.data) - if (spansResult.success) { - return spansResult.data +/** + * Parse + adapt a raw Fern response by focus: flat-span (`SpansResponse`) or + * trace-tree (`{traces: TraceOutput[]}` -> legacy map). Shared by the plain and + * the `WithMeta` fetchers so the focus handling lives in one place. + */ +function parseSpansOrTraces( + focus: TraceQueryParams["focus"], + data: unknown, +): SpansResponse | TracesResponse | null { + if (focus !== "trace") { + return safeParseWithLogging(spansResponseSchema, data, "[fetchAllPreviewTraces:spans]") } + const parsed = safeParseWithLogging( + tracesArrayResponseSchema, + data, + "[fetchAllPreviewTraces:traces]", + ) + if (!parsed) return null + return fernTracesToLegacyTraceMap(parsed.traces ?? []) +} + +/** + * Bucket state for adaptive pacing. `null` when the backend didn't return the + * corresponding header (OSS deployments without EE throttling, errors before + * headers, etc.). + */ +export interface PreviewTracesRateLimit { + /** `X-RateLimit-Remaining` — tokens left in the throttle bucket. */ + remaining: number | null + /** `X-RateLimit-Limit` — bucket capacity. Only set on 429 responses. */ + limit: number | null +} - // Fall back to TracesResponse (traces record format) - return safeParseWithLogging(tracesResponseSchema, response.data, "[fetchAllPreviewTraces]") +/** Successful return shape from `fetchAllPreviewTracesWithMeta`. */ +export interface PreviewTracesWithMetaResult { + data: SpansResponse | TracesResponse | null + rateLimit: PreviewTracesRateLimit +} + +const parseRateLimitHeader = (headers: Headers, name: string): number | null => { + const raw = headers.get(name) + if (!raw) return null + const n = Number.parseInt(raw, 10) + return Number.isFinite(n) ? n : null +} + +/** + * Variant of `fetchAllPreviewTraces` that also returns the throttling bucket + * state via `X-RateLimit-*` response headers. Used by the bulk export to pace + * requests adaptively without knowing the user's plan tier. + * + * AGE-3788 Phase 5 (CQ2): migrated to the Fern client. Uses `.withRawResponse()` + * to read the headers off the raw `Response` (Fern's typed methods otherwise + * discard them). Same focus branching as `fetchAllPreviewTraces`. + * + * INTEGRATION-VERIFY (not unit-testable): the `X-RateLimit-*` headers must + * survive Fern's transport on a live backend, and the bulk-export pacing must + * still throttle correctly. Confirm against a running server. + */ +export async function fetchAllPreviewTracesWithMeta( + params: TraceQueryParams = {}, + appId: string, + projectId: string, + signal?: AbortSignal, +): Promise { + const opts = projectScopedRequest(projectId, appId, signal) + const empty: PreviewTracesRateLimit = {remaining: null, limit: null} + try { + const {data, rawResponse} = + params.focus !== "trace" + ? await getTracesClient() + .querySpans(buildSpansQueryRequest(params), opts) + .withRawResponse() + : await getTracesClient() + .queryTraces(buildTracesQueryRequest(params), opts) + .withRawResponse() + + const headers = rawResponse.headers + return { + data: parseSpansOrTraces(params.focus, data), + rateLimit: { + remaining: parseRateLimitHeader(headers, "x-ratelimit-remaining"), + limit: parseRateLimitHeader(headers, "x-ratelimit-limit"), + }, + } + } catch (error) { + if (isAbortError(error)) throw error + + console.error( + "[fetchAllPreviewTracesWithMeta] failed:", + error instanceof Error ? error.message : String(error), + ) + return {data: null, rateLimit: empty} + } } /** @@ -102,17 +192,21 @@ export async function fetchPreviewTrace( traceId: string, projectId: string, ): Promise { - const baseUrl = getAgentaApiUrl() - - const queryParams = new URLSearchParams() - if (projectId) queryParams.set("project_id", projectId) - - const response = await axios.get( - `${baseUrl}/tracing/traces/${traceId}?${queryParams.toString()}`, + // AGE-3788 Phase 3: GET /traces/{id} via Fern (was GET /tracing/traces/{id}). + // The new TraceResponse = {count, trace: TraceOutput}. We validate it then + // adapt to the legacy map shape {traces:{[traceIdNoDashes]:{spans}}} so the + // existing consumers stay unchanged: + // - drawer stores' normalizeTracesResponse take the `raw.traces` branch + // - annotationFormController reads `traces[traceKeyNoDashes].spans` + // (This refines the eng-review A1 note: consumers want the MAP, not nodes — + // verified against the real call sites.) Retired in Phase 7. + const data = await callFern("[fetchPreviewTrace]", () => + getTracesClient().fetchTrace({trace_id: traceId}, projectScopedRequest(projectId)), ) - - // API returns TracesResponse format with count and traces record - return safeParseWithLogging(tracesResponseSchema, response.data, "[fetchPreviewTrace]") + if (!data) return null + const parsed = safeParseWithLogging(traceResponseSchema, data, "[fetchPreviewTrace]") + if (!parsed) return null + return fernTracesToLegacyTraceMap(parsed.trace ? [parsed.trace] : []) } /** @@ -122,17 +216,18 @@ export async function fetchPreviewTrace( * @param projectId - Project ID * @returns Delete response */ -export async function deletePreviewTrace(traceId: string, projectId: string): Promise { - const baseUrl = getAgentaApiUrl() - - const queryParams = new URLSearchParams() - if (projectId) queryParams.set("project_id", projectId) - - const response = await axios.delete( - `${baseUrl}/tracing/traces/${traceId}?${queryParams.toString()}`, +export async function deletePreviewTrace( + traceId: string, + projectId: string, +): Promise { + // AGE-3788 Phase 2: DELETE /traces/{id} via Fern (was DELETE /tracing/traces/{id}). + // Response shape changed {links:[...]} -> {count, trace_id}; consumers ignore + // the body, but we validate it for safety per the keep-zod-at-boundary rule. + const data = await callFern("[deletePreviewTrace]", () => + getTracesClient().deleteTrace({trace_id: traceId}, projectScopedRequest(projectId)), ) - - return response.data + if (!data) return null + return safeParseWithLogging(traceIdResponseSchema, data, "[deletePreviewTrace]") } /** @@ -162,38 +257,24 @@ export interface SessionQueryParams { export async function fetchSessions( params: SessionQueryParams, projectId: string, -): Promise { - const baseUrl = getAgentaApiUrl() - - const queryParams = new URLSearchParams() - if (projectId) queryParams.set("project_id", projectId) - if (params.appId) queryParams.set("application_id", params.appId) - - const payload: Record = {} +): Promise { + // AGE-3788 Phase 1: POST /spans/sessions/query via Fern (was /tracing/sessions/query). + // Request/response shapes are identical; the new SessionsQueryRequest is + // {realtime?, windowing?} — the legacy `filter` param has no equivalent and + // was always passed undefined by the sessions list, so it is dropped. + const windowing: Record = {...(params.windowing || {})} + if (params.cursor) windowing.next = params.cursor - // Initialize windowing if it doesn't exist but we have a cursor - if (params.windowing || params.cursor) { - payload.windowing = {...(params.windowing || {})} - - // If cursor is provided, it goes into windowing.next - if (params.cursor) { - ;(payload.windowing as Record).next = params.cursor - } - } + const request: AgentaApi.SessionsQueryRequest = {} + if (Object.keys(windowing).length > 0) request.windowing = windowing as AgentaApi.Windowing + if (params.realtime !== undefined) request.realtime = params.realtime - if (params.filter) { - payload.filter = params.filter - } - - // Add realtime parameter (true = latest/unstable, false/undefined = all/stable) - if (params.realtime !== undefined) { - payload.realtime = params.realtime - } - - const response = await axios.post( - `${baseUrl}/tracing/sessions/query?${queryParams.toString()}`, - payload, + const data = await callFern("[fetchSessions]", () => + getTracesClient().querySpansSessions( + request, + projectScopedRequest(projectId, params.appId), + ), ) - - return response.data + if (!data) return null + return safeParseWithLogging(sessionIdsResponseSchema, data, "[fetchSessions]") } diff --git a/web/packages/agenta-entities/src/trace/api/client.ts b/web/packages/agenta-entities/src/trace/api/client.ts new file mode 100644 index 0000000000..13e6014483 --- /dev/null +++ b/web/packages/agenta-entities/src/trace/api/client.ts @@ -0,0 +1,73 @@ +/** + * Fern client wrapper for the tracing API (AGE-3788). + * + * All trace/span/session/analytics calls go through the Fern-generated + * `@agentaai/api-client` via the workspace SDK singleton, replacing the raw + * axios/fetch layer. The host app initialises the SDK singleton at boot + * (host, auth); all entities share the same instance through + * `getAgentaSdkClient()`. + * + * Pattern mirrors `secret/api/client.ts` and `workflow/api/api.ts`. + */ +import {getAgentaSdkClient} from "@agenta/sdk" + +/** The Fern `traces` resource client (spans, traces, sessions, analytics). */ +export function getTracesClient() { + return getAgentaSdkClient().traces +} + +/** + * Per-request options that scope a Fern call to a project (and optionally an + * application). The new endpoints do NOT model `project_id`/`application_id` + * in the request body — the legacy layer injected them as query params, so we + * mirror that through Fern's `BaseRequestOptions.queryParams`. + * + * STANDING RULE (AGE-3788): project/app scope ALWAYS rides queryParams, never + * the body. `abortSignal` is threaded so TanStack Query can cancel in-flight + * requests. + */ +export function projectScopedRequest(projectId: string, appId?: string, abortSignal?: AbortSignal) { + const queryParams: Record = {} + if (projectId) queryParams.project_id = projectId + if (appId) queryParams.application_id = appId + return {queryParams, abortSignal} +} + +/** + * Boundary wrapper for Fern calls. + * + * Fern methods THROW `AgentaApiError` on non-2xx, whereas the legacy raw + * layer returned data and callers branched on shape. To preserve the existing + * consumer contract (null on failure, parsed via zod), every migrated api + * function wraps its Fern call here: + * - non-2xx / unexpected error -> returns null (logged) + * - AbortError -> rethrown, so TanStack Query cancels + * cleanly instead of caching null + */ +/** True for fetch/Fern abort + timeout cancellations (vs real failures). */ +export function isAbortError(error: unknown): boolean { + if ( + error instanceof DOMException && + (error.name === "AbortError" || error.name === "TimeoutError") + ) { + return true + } + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as {name?: string}).name === "AbortError" + ) +} + +export async function callFern(label: string, fn: () => Promise): Promise { + try { + return await fn() + } catch (error) { + // Let aborts propagate so query clients can distinguish cancel from failure. + if (isAbortError(error)) throw error + + console.error(`${label} failed:`, error instanceof Error ? error.message : String(error)) + return null + } +} diff --git a/web/packages/agenta-entities/src/trace/api/index.ts b/web/packages/agenta-entities/src/trace/api/index.ts index 6ca65af063..0a54fbf29c 100644 --- a/web/packages/agenta-entities/src/trace/api/index.ts +++ b/web/packages/agenta-entities/src/trace/api/index.ts @@ -7,11 +7,14 @@ // API functions export { fetchAllPreviewTraces, + fetchAllPreviewTracesWithMeta, fetchPreviewTrace, deletePreviewTrace, fetchSessions, type TraceQueryParams, type SessionQueryParams, + type PreviewTracesRateLimit, + type PreviewTracesWithMetaResult, } from "./api" // Helper utilities @@ -22,3 +25,8 @@ export { transformTracesResponseToTree, transformTracingResponse, } from "./helpers" + +// Fern client + request builders + boundary adapters (AGE-3788 scaffolding) +export {getTracesClient, projectScopedRequest, callFern} from "./client" +export {buildSpansQueryRequest, buildTracesQueryRequest, toFilteringInput} from "./request" +export {fernTraceOutputToNodes, fernTracesToLegacyTraceMap, fernSpansToNodes} from "./adapters" diff --git a/web/packages/agenta-entities/src/trace/api/request.ts b/web/packages/agenta-entities/src/trace/api/request.ts new file mode 100644 index 0000000000..bc9d40ade0 --- /dev/null +++ b/web/packages/agenta-entities/src/trace/api/request.ts @@ -0,0 +1,100 @@ +/** + * Request builders for the tracing API (AGE-3788). + * + * Single home for translating the legacy `TraceQueryParams` shape into the + * Fern request objects, replacing the param-transform reducer that was + * copy-pasted across three functions. Keeping it here also gives one place to + * evolve the freeform-filter -> structured `FilteringInput` translation + * (OQ1, finalised in Phase 4). + */ +import type {AgentaApi} from "@agentaai/api-client" + +/** Legacy query params accepted by the pre-migration trace api functions. */ +export interface TraceQueryParams { + size?: number + /** + * Retained for source compatibility. The new `/spans/query` endpoint + * always returns FLAT spans (focus="trace" => 409), so `buildSpansQueryRequest` + * intentionally ignores `focus`. Trace-tree callers use `queryTraces` + * (`/traces/query`) instead. + */ + focus?: "trace" | "span" | "chat" + format?: string + filter?: string | Record + oldest?: string + newest?: string + cursor?: string + order?: AgentaApi.Windowing.Order + [key: string]: unknown +} + +/** + * Parse the legacy `filter` param (freeform JSON string OR already-structured + * object) into a Fern `FilteringInput`. + * + * The batch fetchers already pass a structured `{conditions:[{field,operator,value}]}` + * object, which maps directly. Freeform string filters are JSON-parsed. + * + * OQ1: the observability filter UI (Phase 4) may produce freeform shapes whose + * operators don't map 1:1 onto Fern's structured operators — that translation + * is finalised when Phase 4 wires the filter UI. Until then this passes + * structured input through and parses JSON strings. + */ +export function toFilteringInput( + filter: string | Record | undefined, +): AgentaApi.FilteringInput | undefined { + if (filter === undefined || filter === null) return undefined + let parsed: unknown = filter + if (typeof filter === "string") { + try { + parsed = JSON.parse(filter) + } catch { + // Not JSON — nothing structured we can send; drop it. + return undefined + } + } + if (typeof parsed !== "object" || parsed === null) return undefined + // Already FilteringInput-shaped ({operator?, conditions[]}). + return parsed as AgentaApi.FilteringInput +} + +/** + * Shared windowing + filtering body. `SpansQueryRequest` and `TracesQueryRequest` + * are structurally identical ({filtering, windowing, query_*_ref}), so both + * builders share this and only differ in their nominal return type. + */ +function buildWindowAndFilter(params: TraceQueryParams): { + windowing?: AgentaApi.Windowing + filtering?: AgentaApi.FilteringInput +} { + const windowing: AgentaApi.Windowing = {} + if (params.size !== undefined) windowing.limit = Number(params.size) + if (params.cursor) windowing.next = params.cursor + if (params.oldest) windowing.oldest = params.oldest + if (params.newest) windowing.newest = params.newest + if (params.order) windowing.order = params.order + + const out: {windowing?: AgentaApi.Windowing; filtering?: AgentaApi.FilteringInput} = {} + if (Object.keys(windowing).length > 0) out.windowing = windowing + const filtering = toFilteringInput(params.filter) + if (filtering) out.filtering = filtering + return out +} + +/** + * Map legacy `TraceQueryParams` -> Fern `SpansQueryRequest` for `POST /spans/query`. + * Pagination/time range ride `windowing` (cursor-only via `windowing.next`). + */ +export function buildSpansQueryRequest(params: TraceQueryParams = {}): AgentaApi.SpansQueryRequest { + return buildWindowAndFilter(params) +} + +/** + * Map legacy `TraceQueryParams` -> Fern `TracesQueryRequest` for `POST /traces/query` + * (the trace-tree path; a trace matches when any span matches the filter). + */ +export function buildTracesQueryRequest( + params: TraceQueryParams = {}, +): AgentaApi.TracesQueryRequest { + return buildWindowAndFilter(params) +} diff --git a/web/packages/agenta-entities/src/trace/core/index.ts b/web/packages/agenta-entities/src/trace/core/index.ts index 93bdbb0a66..6cd2f00872 100644 --- a/web/packages/agenta-entities/src/trace/core/index.ts +++ b/web/packages/agenta-entities/src/trace/core/index.ts @@ -38,6 +38,19 @@ export { spansResponseSchema, type SpansResponse, type TraceListResponse, + // New envelope schemas (AGE-3788) + traceOutputSchema, + type TraceOutput, + traceResponseSchema, + type TraceResponse, + tracesArrayResponseSchema, + type TracesArrayResponse, + windowingSchema, + type Windowing, + sessionIdsResponseSchema, + type SessionIdsResponse, + traceIdResponseSchema, + type TraceIdResponse, // Parsing utilities parseTraceSpan, parseTracesResponse, diff --git a/web/packages/agenta-entities/src/trace/core/schema.ts b/web/packages/agenta-entities/src/trace/core/schema.ts index efaa4cc5cc..cbb5632622 100644 --- a/web/packages/agenta-entities/src/trace/core/schema.ts +++ b/web/packages/agenta-entities/src/trace/core/schema.ts @@ -29,7 +29,10 @@ import { // --- ENUMS ------------------------------------------------------------------- -export const TraceTypeEnum = z.enum(["invocation", "annotation", "undefined"]) +// Catch-all is "unknown" to match the Fern-generated `TraceType` (new +// /spans|traces/* endpoints). The deprecated /tracing/* stack emitted +// "undefined" for the same case; AGE-3788 canonicalises on the new value. +export const TraceTypeEnum = z.enum(["invocation", "annotation", "unknown"]) export type TraceType = z.infer export const SpanCategoryEnum = z.enum([ @@ -44,7 +47,9 @@ export const SpanCategoryEnum = z.enum([ "completion", "chat", "rerank", - "undefined", + // Catch-all is "unknown" to match the Fern-generated `SpanType`. + // Deprecated /tracing/* emitted "undefined"; AGE-3788 canonicalises here. + "unknown", ]) export type SpanCategory = z.infer @@ -225,6 +230,69 @@ export const spansResponseSchema = z.object({ }) export type SpansResponse = z.infer +// --- NEW ENVELOPE SCHEMAS (AGE-3788) ----------------------------------------- +// The new /traces/* endpoints return the canonical Fern `TraceOutput` tree: +// GET /traces/{id} -> TraceResponse = {count, trace: TraceOutput} +// POST /traces/query -> TracesResponse = {count, traces: TraceOutput[]} +// where TraceOutput = {trace_id, spans: Record}. +// This is the SAME recursive span-name map as `traceSpanSchema`, so we reuse +// it for the node payload and only model the new outer envelope here. The +// legacy map-shaped `tracesResponseSchema` above is kept until Phase 7, when +// all consumers move onto these and it is deleted. + +export const traceOutputSchema = z.object({ + trace_id: z.string().optional().nullable(), + spans: z + .record(z.string(), z.union([traceSpanSchema, z.array(traceSpanSchema)])) + .optional() + .nullable(), +}) +export type TraceOutput = z.infer + +// GET /traces/{id} +export const traceResponseSchema = z.object({ + count: z.number().optional(), + trace: traceOutputSchema.optional().nullable(), +}) +export type TraceResponse = z.infer + +// POST /traces/query +export const tracesArrayResponseSchema = z.object({ + count: z.number().optional(), + traces: z.array(traceOutputSchema).optional().nullable(), +}) +export type TracesArrayResponse = z.infer + +// Cursor/time-window pagination block (Fern `Windowing`). Kept lenient — the +// FE only reads it back to pass `next`/`oldest`/`newest` to the next page. +export const windowingSchema = z + .object({ + newest: z.string().optional().nullable(), + oldest: z.string().optional().nullable(), + next: z.string().optional().nullable(), + limit: z.number().optional().nullable(), + order: z.string().optional().nullable(), + interval: z.number().optional().nullable(), + rate: z.number().optional().nullable(), + }) + .passthrough() +export type Windowing = z.infer + +// POST /spans/sessions/query -> SessionIdsResponse +export const sessionIdsResponseSchema = z.object({ + count: z.number().optional(), + session_ids: z.array(z.string()).optional(), + windowing: windowingSchema.optional().nullable(), +}) +export type SessionIdsResponse = z.infer + +// DELETE /traces/{id} -> TraceIdResponse (body ignored by consumers, validated for safety) +export const traceIdResponseSchema = z.object({ + count: z.number().optional(), + trace_id: z.string().optional().nullable(), +}) +export type TraceIdResponse = z.infer + // Combined response type for list queries export interface TraceListResponse { traces: TraceSpanNode[] diff --git a/web/packages/agenta-entities/src/trace/index.ts b/web/packages/agenta-entities/src/trace/index.ts index 6be72564cd..c3394f7ecf 100644 --- a/web/packages/agenta-entities/src/trace/index.ts +++ b/web/packages/agenta-entities/src/trace/index.ts @@ -103,11 +103,14 @@ export type { // API functions export { fetchAllPreviewTraces, + fetchAllPreviewTracesWithMeta, fetchPreviewTrace, deletePreviewTrace, fetchSessions, type TraceQueryParams, type SessionQueryParams, + type PreviewTracesRateLimit, + type PreviewTracesWithMetaResult, } from "./api" // Helper utilities diff --git a/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts b/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts new file mode 100644 index 0000000000..37856f268e --- /dev/null +++ b/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts @@ -0,0 +1,157 @@ +/** + * Integration tests for the AGE-3788 tracing migration — the two risks that + * CANNOT be covered by unit tests (they assert real backend behaviour): + * + * RISK 1 — /traces/query trace_id filter format. + * The coalescer (traceBatchFetcher), prefetch, and ETL all pass UNDASHED + * trace_ids in `filtering` (matching the legacy /tracing/spans/query) and + * read the result back as `data.traces[traceIdNoDashes]`. If /traces/query + * expects DASHED ids, the filter silently matches nothing → empty trace + * drawer / empty scenario hydration. This test proves which id format the + * new endpoint accepts. + * + * RISK 2 — X-RateLimit-* headers survive Fern transport. + * The bulk-trace export paces itself off `X-RateLimit-Remaining/Limit` + * read from the response headers. The migration reads them via Fern's + * `.withRawResponse()`. This test proves the headers reach the FE through + * Fern (and, on EE, carry numeric throttle state). + * + * Gating: + * - `hasBackend` — apiKey + projectId provisioned by global setup. + * - AGENTA_TEST_TRACE_ID — a real trace_id that exists in the test project + * (RISK 1 needs a known trace to look up). Supply + * it once tracing data exists in the test backend; + * the backend team can provision it like + * AGENTA_TEST_TRACE_SPAN_ID. + * - AGENTA_TEST_EXPECT_RATELIMIT — set on EE deployments where the throttle + * middleware emits numeric X-RateLimit-* headers. + * + * Run: pnpm run test:integration (vitest.integration.config.ts) + */ +import {getAgentaSdkClient} from "@agenta/sdk" +import {beforeAll, describe, expect, it} from "vitest" + +import { + fetchAllPreviewTraces, + fetchAllPreviewTracesWithMeta, + isTracesResponse, +} from "../../src/trace" +import type {TracesResponse} from "../../src/trace/core" + +import {TEST_CONFIG, hasBackend} from "./helpers/env" + +const TEST_TRACE_ID = process.env.AGENTA_TEST_TRACE_ID || "" +const EXPECT_RATELIMIT = Boolean(process.env.AGENTA_TEST_EXPECT_RATELIMIT) + +// Seed the lazy Fern SDK singleton with the test backend + key BEFORE any api +// function runs. getTracesClient() calls getAgentaSdkClient() argless, so the +// first (seeding) call here fixes the host/auth for the whole worker. Also set +// the env vars so any argless init elsewhere resolves to the same backend. +beforeAll(() => { + if (!hasBackend) return + process.env.AGENTA_HOST = TEST_CONFIG.apiUrl + process.env.AGENTA_API_KEY = TEST_CONFIG.apiKey + getAgentaSdkClient({host: TEST_CONFIG.apiUrl, apiKey: TEST_CONFIG.apiKey}) +}) + +const traceIdFilter = (value: string) => + JSON.stringify({conditions: [{field: "trace_id", operator: "in", value: [value]}]}) + +// --- RISK 1: /traces/query trace_id filter format ---------------------------- + +describe.skipIf(!hasBackend || !TEST_TRACE_ID)( + "AGE-3788 RISK 1 — /traces/query accepts UNDASHED trace_ids in filtering", + () => { + it("returns the trace when filtered by an UNDASHED trace_id", async () => { + const undashed = TEST_TRACE_ID.replace(/-/g, "") + const res = await fetchAllPreviewTraces( + {focus: "trace", filter: traceIdFilter(undashed)}, + "", + TEST_CONFIG.projectId, + ) + + expect(res).not.toBeNull() + expect(isTracesResponse(res)).toBe(true) + // The coalescer + ETL look up data.traces[undashed]. If this key is + // absent, the undashed filter did NOT match — the coalescer would + // silently return empty and the trace drawer/hydration would break. + expect((res as TracesResponse).traces[undashed]).toBeDefined() + }) + + // Diagnostic: surfaces which id format the backend honours. If the dashed + // form matches but the undashed form does not, the FE coalescer's + // canonicalIds (undashed) must switch to dashed before P5 ships. + it("[diagnostic] reports dashed vs undashed filter match", async () => { + const undashed = TEST_TRACE_ID.replace(/-/g, "") + const [dashedRes, undashedRes] = await Promise.all([ + fetchAllPreviewTraces( + {focus: "trace", filter: traceIdFilter(TEST_TRACE_ID)}, + "", + TEST_CONFIG.projectId, + ), + fetchAllPreviewTraces( + {focus: "trace", filter: traceIdFilter(undashed)}, + "", + TEST_CONFIG.projectId, + ), + ]) + const keys = (r: unknown) => + isTracesResponse(r) ? Object.keys((r as TracesResponse).traces) : [] + + console.info("[AGE-3788 trace-id-format]", { + dashedMatched: keys(dashedRes), + undashedMatched: keys(undashedRes), + }) + // At least one format must match a known trace, else the migration + // can't fetch trace trees at all. + expect(keys(dashedRes).length + keys(undashedRes).length).toBeGreaterThan(0) + }) + }, +) + +// --- RISK 2: X-RateLimit-* headers through Fern ------------------------------- + +describe.skipIf(!hasBackend)( + "AGE-3788 RISK 2 — bulk-export rate-limit headers via Fern .withRawResponse()", + () => { + it("returns the {data, rateLimit} shape without throwing", async () => { + const res = await fetchAllPreviewTracesWithMeta( + {focus: "span", size: 1}, + "", + TEST_CONFIG.projectId, + ) + expect(res).toHaveProperty("data") + // remaining/limit are number-or-null: EE emits X-RateLimit-Remaining + // on every 200 (proving the header survives Fern transport); + // X-RateLimit-Limit is only set on 429 so it is null here; OSS + // without throttling returns null for both. + const isNumOrNull = (v: unknown) => v === null || typeof v === "number" + expect(isNumOrNull(res.rateLimit.remaining)).toBe(true) + expect(isNumOrNull(res.rateLimit.limit)).toBe(true) + + console.info("[AGE-3788 rate-limit]", res.rateLimit) + }) + + it.skipIf(!EXPECT_RATELIMIT)( + "exposes a numeric X-RateLimit-Remaining on a throttled (EE) backend", + async () => { + const res = await fetchAllPreviewTracesWithMeta( + {focus: "span", size: 1}, + "", + TEST_CONFIG.projectId, + ) + expect(typeof res.rateLimit.remaining).toBe("number") + }, + ) + }, +) + +// --- Smoke: Fern transport + auth reach the backend at all ------------------- + +describe.skipIf(!hasBackend)("AGE-3788 smoke — Fern client reaches the backend", () => { + it("querySpans (flat) settles without throwing and returns the spans shape", async () => { + const res = await fetchAllPreviewTraces({focus: "span", size: 1}, "", TEST_CONFIG.projectId) + // Either a valid SpansResponse or null (validation miss) — never a throw. + expect(res === null || "spans" in res).toBe(true) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts b/web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts new file mode 100644 index 0000000000..f6e5541994 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts @@ -0,0 +1,162 @@ +/** + * Unit tests for the AGE-3788 tracing-migration scaffolding (Phase 0): + * - buildSpansQueryRequest / toFilteringInput (legacy params -> Fern request) + * - fernTraceOutputToNodes / fernTracesToLegacyTraceMap / fernSpansToNodes + * (Fern envelope -> existing FE structures, via the reused transform) + * - the trace_type/span_type enum flip ("undefined" -> "unknown") + * + * Pure logic — no network. Golden-fixture parity (real old+new payloads) is a + * separate T6/T7/T8 concern; here we cover the param mapping, the envelope + * normalisation, dash-stripping, and the enum drift fix in isolation. + */ +import {describe, expect, it} from "vitest" + +import { + buildSpansQueryRequest, + fernSpansToNodes, + fernTraceOutputToNodes, + fernTracesToLegacyTraceMap, + toFilteringInput, +} from "../../src/trace/api" +import { + SpanCategoryEnum, + TraceTypeEnum, + type TraceOutput, + type TraceSpan, +} from "../../src/trace/core" + +// --- fixtures ---------------------------------------------------------------- + +const leafSpan = (id: string, name: string, traceId = "t-1"): TraceSpan => ({ + trace_id: traceId, + span_id: id, + span_name: name, +}) + +// A TraceOutput whose `spans` is the span-name-keyed map the new endpoints return. +const traceOutput = (traceId: string): TraceOutput => ({ + trace_id: traceId, + spans: { + root: { + ...leafSpan("s-root", "root", traceId), + spans: { + child: leafSpan("s-child", "child", traceId), + }, + }, + }, +}) + +// --- buildSpansQueryRequest -------------------------------------------------- + +describe("buildSpansQueryRequest", () => { + it("maps size -> windowing.limit and cursor -> windowing.next", () => { + const req = buildSpansQueryRequest({size: 50, cursor: "abc"}) + expect(req.windowing?.limit).toBe(50) + expect(req.windowing?.next).toBe("abc") + }) + + it("maps oldest/newest into windowing", () => { + const req = buildSpansQueryRequest({oldest: "2026-01-01", newest: "2026-02-01"}) + expect(req.windowing?.oldest).toBe("2026-01-01") + expect(req.windowing?.newest).toBe("2026-02-01") + }) + + it("omits windowing entirely when no pagination params are given", () => { + expect(buildSpansQueryRequest({}).windowing).toBeUndefined() + }) + + it("passes a structured filter object through as filtering", () => { + const filter = {conditions: [{field: "trace_id", operator: "in", value: ["x"]}]} + const req = buildSpansQueryRequest({filter}) + expect(req.filtering).toEqual(filter) + }) + + it("parses a JSON-string filter into filtering", () => { + const req = buildSpansQueryRequest({filter: '{"conditions":[]}'}) + expect(req.filtering).toEqual({conditions: []}) + }) +}) + +describe("toFilteringInput", () => { + it("returns undefined for undefined / non-JSON string", () => { + expect(toFilteringInput(undefined)).toBeUndefined() + expect(toFilteringInput("not json")).toBeUndefined() + }) + it("passes structured objects through", () => { + expect(toFilteringInput({operator: "and", conditions: []})).toEqual({ + operator: "and", + conditions: [], + }) + }) +}) + +// --- fernTraceOutputToNodes (single trace envelope) -------------------------- + +describe("fernTraceOutputToNodes", () => { + it("returns [] for null / absent spans", () => { + expect(fernTraceOutputToNodes(null)).toEqual([]) + expect(fernTraceOutputToNodes({trace_id: "t", spans: null})).toEqual([]) + }) + + it("builds a tree with key + invocationIds + children", () => { + const nodes = fernTraceOutputToNodes(traceOutput("t-1")) + expect(nodes).toHaveLength(1) + const root = nodes[0] + expect(root.span_id).toBe("s-root") + expect(root.key).toBe("s-root") + expect(root.invocationIds).toEqual({trace_id: "t-1", span_id: "s-root"}) + expect(root.children).toHaveLength(1) + expect(root.children?.[0].span_id).toBe("s-child") + }) +}) + +// --- fernTracesToLegacyTraceMap (batch, transitional) ------------------------ + +describe("fernTracesToLegacyTraceMap", () => { + it("keys the map by UNDASHED trace_id (coalescer contract)", () => { + const dashed = "0d2e4f6a-1111-2222-3333-444455556666" + const undashed = dashed.replace(/-/g, "") + const out = fernTracesToLegacyTraceMap([{...traceOutput(dashed)}]) + expect(Object.keys(out.traces)).toEqual([undashed]) + expect(out.count).toBe(1) + expect(out.traces[undashed].spans).toBeDefined() + }) + + it("skips entries without a trace_id and handles empty input", () => { + expect(fernTracesToLegacyTraceMap(null)).toEqual({count: 0, traces: {}}) + const out = fernTracesToLegacyTraceMap([{spans: {}} as TraceOutput]) + expect(out.count).toBe(0) + }) +}) + +// --- fernSpansToNodes (flat) ------------------------------------------------- + +describe("fernSpansToNodes", () => { + it("maps flat spans to enriched leaf nodes", () => { + const nodes = fernSpansToNodes([leafSpan("s-1", "a"), leafSpan("s-2", "b")]) + expect(nodes.map((n) => n.span_id)).toEqual(["s-1", "s-2"]) + expect(nodes[0].key).toBe("s-1") + expect(nodes[0].invocationIds).toEqual({trace_id: "t-1", span_id: "s-1"}) + }) + it("returns [] for empty / null", () => { + expect(fernSpansToNodes(null)).toEqual([]) + expect(fernSpansToNodes([])).toEqual([]) + }) +}) + +// --- enum drift fix ("undefined" -> "unknown") ------------------------------- + +describe("trace/span type enum flip (AGE-3788)", () => { + it("accepts the Fern catch-all 'unknown'", () => { + expect(TraceTypeEnum.safeParse("unknown").success).toBe(true) + expect(SpanCategoryEnum.safeParse("unknown").success).toBe(true) + }) + it("no longer accepts the legacy 'undefined'", () => { + expect(TraceTypeEnum.safeParse("undefined").success).toBe(false) + expect(SpanCategoryEnum.safeParse("undefined").success).toBe(false) + }) + it("still accepts known values", () => { + expect(TraceTypeEnum.safeParse("invocation").success).toBe(true) + expect(SpanCategoryEnum.safeParse("llm").success).toBe(true) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts b/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts new file mode 100644 index 0000000000..f9e4944d92 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts @@ -0,0 +1,270 @@ +/** + * Unit tests for the AGE-3788 Phase 1/2 api functions (sessions + delete), + * migrated to the Fern client. + * + * Mocks `@agenta/sdk` (not axios) so we assert the Fern method is called with + * the right body + queryParams without constructing a real client, per the + * pattern in retrieveWorkflowRevision.test.ts. + */ +import {beforeEach, describe, expect, it, vi} from "vitest" + +const querySpansSessions = vi.fn() +const deleteTrace = vi.fn() +const fetchTrace = vi.fn() +const querySpans = vi.fn() +const queryTraces = vi.fn() + +vi.mock("@agenta/sdk", () => ({ + getAgentaSdkClient: () => ({ + traces: {querySpansSessions, deleteTrace, fetchTrace, querySpans, queryTraces}, + }), +})) + +// Import AFTER the mock so the unit-under-test picks up the fake client. +import { + deletePreviewTrace, + fetchAllPreviewTraces, + fetchAllPreviewTracesWithMeta, + fetchPreviewTrace, + fetchSessions, + transformTracesResponseToTree, + transformTracingResponse, +} from "../../src/trace/api" + +beforeEach(() => { + querySpansSessions.mockReset() + deleteTrace.mockReset() + fetchTrace.mockReset() + querySpans.mockReset() + queryTraces.mockReset() +}) + +describe("fetchSessions (Phase 1 — POST /spans/sessions/query)", () => { + it("sends windowing + realtime as the body and project/app as queryParams", async () => { + querySpansSessions.mockResolvedValueOnce({ + count: 2, + session_ids: ["s1", "s2"], + windowing: {next: "cur-2"}, + }) + + const res = await fetchSessions( + {appId: "app-1", windowing: {limit: 50, oldest: "2026-01-01"}, realtime: true}, + "proj-9", + ) + + expect(querySpansSessions).toHaveBeenCalledTimes(1) + const [body, opts] = querySpansSessions.mock.calls[0] + expect(body).toEqual({windowing: {limit: 50, oldest: "2026-01-01"}, realtime: true}) + expect(opts.queryParams).toEqual({project_id: "proj-9", application_id: "app-1"}) + expect(res).toEqual({count: 2, session_ids: ["s1", "s2"], windowing: {next: "cur-2"}}) + }) + + it("folds cursor into windowing.next", async () => { + querySpansSessions.mockResolvedValueOnce({count: 0, session_ids: []}) + await fetchSessions({cursor: "abc"}, "proj-9") + const [body] = querySpansSessions.mock.calls[0] + expect(body.windowing).toEqual({next: "abc"}) + }) + + it("returns null when the Fern call throws (AgentaApiError -> null)", async () => { + querySpansSessions.mockRejectedValueOnce(new Error("500")) + expect(await fetchSessions({}, "proj-9")).toBeNull() + }) + + it("rethrows AbortError so the query client can cancel", async () => { + const abort = new DOMException("Aborted", "AbortError") + querySpansSessions.mockRejectedValueOnce(abort) + await expect(fetchSessions({}, "proj-9")).rejects.toBe(abort) + }) +}) + +describe("deletePreviewTrace (Phase 2 — DELETE /traces/{id})", () => { + it("calls deleteTrace with trace_id + project queryParam and returns the id response", async () => { + deleteTrace.mockResolvedValueOnce({count: 1, trace_id: "t-1"}) + const res = await deletePreviewTrace("t-1", "proj-9") + expect(deleteTrace).toHaveBeenCalledTimes(1) + const [body, opts] = deleteTrace.mock.calls[0] + expect(body).toEqual({trace_id: "t-1"}) + expect(opts.queryParams).toEqual({project_id: "proj-9"}) + expect(res).toEqual({count: 1, trace_id: "t-1"}) + }) + + it("returns null when the delete throws", async () => { + deleteTrace.mockRejectedValueOnce(new Error("boom")) + expect(await deletePreviewTrace("t-1", "proj-9")).toBeNull() + }) +}) + +describe("fetchPreviewTrace (Phase 3 — GET /traces/{id})", () => { + const DASHED = "0d2e4f6a-1111-2222-3333-444455556666" + const UNDASHED = DASHED.replace(/-/g, "") + // span-name-keyed map shared by the old + new payloads (the part that does + // not change across the migration — only the outer envelope does). + const spansMap = { + root: { + trace_id: DASHED, + span_id: "s-root", + span_name: "root", + spans: { + child: {trace_id: DASHED, span_id: "s-child", span_name: "child"}, + }, + }, + } + + it("calls fetchTrace with trace_id + project queryParam, returns legacy map keyed UNDASHED", async () => { + fetchTrace.mockResolvedValueOnce({count: 1, trace: {trace_id: DASHED, spans: spansMap}}) + const res = await fetchPreviewTrace(DASHED, "proj-9") + const [body, opts] = fetchTrace.mock.calls[0] + expect(body).toEqual({trace_id: DASHED}) + expect(opts.queryParams).toEqual({project_id: "proj-9"}) + // annotationFormController reads traces[undashed].spans — key must be undashed. + expect(Object.keys(res!.traces)).toEqual([UNDASHED]) + expect(res!.traces[UNDASHED].spans).toBeDefined() + }) + + // GOLDEN CONVERGENCE (T6 CRITICAL): the new-endpoint path must produce the + // SAME TraceSpanNode[] tree as the legacy /tracing/traces/{id} path did. + // NOTE: fixtures are representative; replace with REAL captured old+new + // payloads when backend access is available (T6 follow-up). + it("new-path tree deep-equals old-path tree", async () => { + fetchTrace.mockResolvedValueOnce({count: 1, trace: {trace_id: DASHED, spans: spansMap}}) + const newResult = await fetchPreviewTrace(DASHED, "proj-9") + + // Legacy /tracing/traces/{id} envelope for the same trace. + const oldEquivalent = {count: 1, traces: {[DASHED]: {spans: spansMap}}} + + const newTree = transformTracingResponse(transformTracesResponseToTree(newResult!)) + const oldTree = transformTracingResponse( + transformTracesResponseToTree(oldEquivalent as never), + ) + + expect(newTree).toEqual(oldTree) + expect(newTree).toHaveLength(1) + expect(newTree[0].span_id).toBe("s-root") + expect(newTree[0].children?.[0].span_id).toBe("s-child") + }) + + it("returns an empty map when the trace is absent", async () => { + fetchTrace.mockResolvedValueOnce({count: 0, trace: null}) + const res = await fetchPreviewTrace(DASHED, "proj-9") + expect(res).toEqual({count: 0, traces: {}}) + }) + + it("returns null when fetchTrace throws", async () => { + fetchTrace.mockRejectedValueOnce(new Error("404")) + expect(await fetchPreviewTrace(DASHED, "proj-9")).toBeNull() + }) +}) + +describe("fetchAllPreviewTraces (Phase 4 — flat spans via POST /spans/query)", () => { + it("routes focus=span to querySpans with structured filtering + windowing + queryParams", async () => { + querySpans.mockResolvedValueOnce({count: 1, spans: [{trace_id: "t", span_id: "s"}]}) + const filter = JSON.stringify({ + conditions: [{field: "span_id", operator: "in", value: ["s"]}], + }) + const res = await fetchAllPreviewTraces( + {size: 10, focus: "span", filter}, + "app-1", + "proj-9", + ) + + expect(querySpans).toHaveBeenCalledTimes(1) + const [request, opts] = querySpans.mock.calls[0] + expect(request.windowing).toEqual({limit: 10}) + expect(request.filtering).toEqual({ + conditions: [{field: "span_id", operator: "in", value: ["s"]}], + }) + // focus is intentionally NOT forwarded — /spans/query is always flat. + expect(request.focus).toBeUndefined() + expect(opts.queryParams).toEqual({project_id: "proj-9", application_id: "app-1"}) + expect(res).toEqual({count: 1, spans: [{trace_id: "t", span_id: "s"}]}) + }) + + it("routes focus=chat (and undefined) to querySpans too (only 'trace' stays legacy)", async () => { + querySpans.mockResolvedValue({count: 0, spans: []}) + await fetchAllPreviewTraces({focus: "chat"}, "", "proj-9") + await fetchAllPreviewTraces({}, "", "proj-9") + expect(querySpans).toHaveBeenCalledTimes(2) + // No application_id queryParam when appId is empty. + expect(querySpans.mock.calls[0][1].queryParams).toEqual({project_id: "proj-9"}) + }) + + it("returns null when querySpans throws", async () => { + querySpans.mockRejectedValueOnce(new Error("429")) + expect(await fetchAllPreviewTraces({focus: "span"}, "", "proj-9")).toBeNull() + }) +}) + +describe("fetchAllPreviewTraces (Phase 5 — trace-tree via POST /traces/query)", () => { + const DASHED = "0d2e4f6a-1111-2222-3333-444455556666" + const UNDASHED = DASHED.replace(/-/g, "") + + it("routes focus=trace to queryTraces and adapts the array to the legacy map (undashed keys)", async () => { + const filter = JSON.stringify({ + conditions: [{field: "trace_id", operator: "in", value: [UNDASHED]}], + }) + queryTraces.mockResolvedValueOnce({ + count: 1, + traces: [{trace_id: DASHED, spans: {root: {trace_id: DASHED, span_id: "s"}}}], + }) + + const res = (await fetchAllPreviewTraces({focus: "trace", filter}, "", "proj-9")) as { + count: number + traces: Record + } + + expect(queryTraces).toHaveBeenCalledTimes(1) + const [request, opts] = queryTraces.mock.calls[0] + expect(request.filtering).toEqual({ + conditions: [{field: "trace_id", operator: "in", value: [UNDASHED]}], + }) + expect(opts.queryParams).toEqual({project_id: "proj-9"}) + // The coalescer + ETL read data.traces[traceIdNoDashes] — key must be undashed. + expect(Object.keys(res.traces)).toEqual([UNDASHED]) + }) + + it("returns null when queryTraces throws", async () => { + queryTraces.mockRejectedValueOnce(new Error("500")) + expect(await fetchAllPreviewTraces({focus: "trace"}, "", "proj-9")).toBeNull() + }) +}) + +describe("fetchAllPreviewTracesWithMeta (Phase 5 CQ2 — rate-limit pacing)", () => { + // The meta variant reads X-RateLimit-* off the raw Response via Fern's + // .withRawResponse(), so the mock returns a {withRawResponse} thenable. + const withRaw = (data: unknown, headers: Headers) => ({ + withRawResponse: () => Promise.resolve({data, rawResponse: {headers}}), + }) + + it("parses X-RateLimit-* from rawResponse.headers and returns {data, rateLimit}", async () => { + const headers = new Headers({"x-ratelimit-remaining": "42", "x-ratelimit-limit": "120"}) + querySpans.mockReturnValueOnce( + withRaw({count: 1, spans: [{trace_id: "t", span_id: "s"}]}, headers), + ) + + const res = await fetchAllPreviewTracesWithMeta({focus: "span"}, "app-1", "proj-9") + expect(res.rateLimit).toEqual({remaining: 42, limit: 120}) + expect(res.data).toEqual({count: 1, spans: [{trace_id: "t", span_id: "s"}]}) + }) + + it("returns null rate-limit fields when the headers are absent", async () => { + querySpans.mockReturnValueOnce(withRaw({count: 0, spans: []}, new Headers())) + const res = await fetchAllPreviewTracesWithMeta({focus: "span"}, "", "proj-9") + expect(res.rateLimit).toEqual({remaining: null, limit: null}) + }) + + it("returns {data:null, rateLimit:nulls} on a non-abort failure", async () => { + querySpans.mockReturnValueOnce({withRawResponse: () => Promise.reject(new Error("500"))}) + const res = await fetchAllPreviewTracesWithMeta({focus: "span"}, "", "proj-9") + expect(res.data).toBeNull() + expect(res.rateLimit).toEqual({remaining: null, limit: null}) + }) + + it("rethrows AbortError so the export can cancel", async () => { + const abort = new DOMException("Aborted", "AbortError") + querySpans.mockReturnValueOnce({withRawResponse: () => Promise.reject(abort)}) + await expect(fetchAllPreviewTracesWithMeta({focus: "span"}, "", "proj-9")).rejects.toBe( + abort, + ) + }) +}) From 37a9257ee329be72a7ab055bb08dd87ae1c74c09 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 5 Jun 2026 18:50:09 +0200 Subject: [PATCH 2/5] docs(entities): update ETL trace docstrings to the new /traces/query endpoint (AGE-3788) The ETL hydration + mapping resolver now read traces via the migrated fetchAllPreviewTraces (Fern queryTraces, POST /traces/query) adapted to the legacy map shape, not the deprecated POST /tracing/spans/query. Comment-only. --- .../src/evaluationRun/etl/hydrateScenariosTransform.ts | 2 +- .../agenta-entities/src/evaluationRun/etl/resolveMappings.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/packages/agenta-entities/src/evaluationRun/etl/hydrateScenariosTransform.ts b/web/packages/agenta-entities/src/evaluationRun/etl/hydrateScenariosTransform.ts index 8c2cd4a38f..e8aac9fdc6 100644 --- a/web/packages/agenta-entities/src/evaluationRun/etl/hydrateScenariosTransform.ts +++ b/web/packages/agenta-entities/src/evaluationRun/etl/hydrateScenariosTransform.ts @@ -10,7 +10,7 @@ * - results (one per `step_key`): POST /evaluations/results/query * - metrics (per-scenario scores): POST /evaluations/metrics/query * - testcases (input data): POST /testcases/query - * - traces (app outputs/spans): POST /tracing/spans/query + * - traces (app outputs/spans): POST /traces/query (Fern queryTraces) * (filter: trace_id IN [...]) * * This factory returns a `Transform` diff --git a/web/packages/agenta-entities/src/evaluationRun/etl/resolveMappings.ts b/web/packages/agenta-entities/src/evaluationRun/etl/resolveMappings.ts index 45c43510f3..671a854ff4 100644 --- a/web/packages/agenta-entities/src/evaluationRun/etl/resolveMappings.ts +++ b/web/packages/agenta-entities/src/evaluationRun/etl/resolveMappings.ts @@ -181,7 +181,8 @@ export function getAtPath(obj: unknown, path: string): unknown { /** * Try to find `path` somewhere inside a trace envelope. Handles every shape * we've seen in the wild: - * - `{spans: {: span}}` — bulk /tracing/spans/query + * - `{spans: {: span}}` — legacy-map shape from the adapter + * (Fern /traces/query, via fernTracesToLegacyTraceMap) * - `{spans: [span, ...]}` — array form (some endpoints) * - `{response: {tree: [...]}}` — agenta-format wrapped response * - the envelope IS the span — endpoint-stripped form From c1dd28b9a32142d061077ad11136caa81742315d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 5 Jun 2026 19:52:01 +0200 Subject: [PATCH 3/5] feat(frontend): migrate generations dashboard to /spans/analytics/query (AGE-3788) Move the observability generations dashboard off the deprecated POST /tracing/spans/analytics onto the Fern client's traces.querySpansAnalytics (POST /spans/analytics/query). - entities: add fetchSpansAnalytics + analyticsResponseSchema/metricsBucketSchema. Omit specs so the backend applies DEFAULT_ANALYTICS_SPECS; filter is a JSON-string query param; project/app scope rides queryParams. - oss: rewrite fetchGenerationsDashboardData to use fetchSpansAnalytics and analyticsToGeneration, which reads the spec-based metrics dict (buckets[].metrics keyed by dotted MetricSpec.path) instead of the old total/errors bucket split. Drop the last raw /tracing/* fetch. - remove the now-dead TracingDashboardData type. - add 6 unit tests for fetchSpansAnalytics. --- web/oss/src/services/tracing/api/index.ts | 39 ++++---- web/oss/src/services/tracing/lib/helpers.ts | 85 ++++++++++++----- web/oss/src/services/tracing/types/index.ts | 25 +---- .../agenta-entities/src/trace/api/api.ts | 65 +++++++++++++ .../agenta-entities/src/trace/api/index.ts | 2 + .../agenta-entities/src/trace/core/index.ts | 4 + .../agenta-entities/src/trace/core/schema.ts | 22 +++++ .../agenta-entities/src/trace/index.ts | 7 ++ .../tests/unit/trace-migration-api.test.ts | 91 ++++++++++++++++++- 9 files changed, 273 insertions(+), 67 deletions(-) diff --git a/web/oss/src/services/tracing/api/index.ts b/web/oss/src/services/tracing/api/index.ts index 7cfecba80b..1c619dd45b 100644 --- a/web/oss/src/services/tracing/api/index.ts +++ b/web/oss/src/services/tracing/api/index.ts @@ -1,19 +1,20 @@ +import {fetchSpansAnalytics} from "@agenta/entities/trace" import dayjs from "dayjs" import utc from "dayjs/plugin/utc" import {SortResult} from "@/oss/components/Filters/Sort" -import {ensureAppId, fetchJson, getBaseUrl} from "@/oss/lib/api/assets/fetchClient" +import {ensureAppId} from "@/oss/lib/api/assets/fetchClient" import {getProjectValues} from "@/oss/state/project" -import {calculateIntervalFromDuration, tracingToGeneration} from "../lib/helpers" -import {GenerationDashboardData, TracingDashboardData} from "../types" +import {analyticsToGeneration, calculateIntervalFromDuration} from "../lib/helpers" +import {GenerationDashboardData} from "../types" dayjs.extend(utc) -// AGE-3788: fetchAllPreviewTraces / fetchAllPreviewTracesWithMeta / fetchPreviewTrace -// / deletePreviewTrace / fetchSessions moved to the Fern client under -// `@agenta/entities/trace` (Phases 1-5). Only the analytics dashboard below -// remains here, pending the Phase 6 migration (gated on the MetricSpec contract). +// AGE-3788: every tracing call in this module is now served by the Fern client +// under `@agenta/entities/trace` — list/detail/delete/sessions (Phases 1-5) and +// the analytics dashboard below (Phase 6, `fetchSpansAnalytics` -> +// POST /spans/analytics/query). No raw `/tracing/*` fetch remains. export const fetchGenerationsDashboardData = async ( appId: string | null | undefined, @@ -28,7 +29,6 @@ export const fetchGenerationsDashboardData = async ( const {projectId: propsProjectId, signal, ...options} = _options const {projectId: stateProjectId} = getProjectValues() - const base = getBaseUrl() const projectId = propsProjectId || stateProjectId const applicationId = ensureAppId(appId || undefined) @@ -36,10 +36,6 @@ export const fetchGenerationsDashboardData = async ( throw new DOMException("Aborted", "AbortError") } - const url = new URL(`${base}/tracing/spans/analytics`) - if (projectId) url.searchParams.set("project_id", projectId) - if (applicationId) url.searchParams.set("application_id", applicationId) - const conditions: any[] = [] if (applicationId) { @@ -101,21 +97,18 @@ export const fetchGenerationsDashboardData = async ( if (durationHours <= 24) rangeString = "24_hours" else if (durationHours <= 168) rangeString = "7_days" - const payload: Record = { + const analytics = await fetchSpansAnalytics({ + projectId: projectId ?? "", + appId: applicationId, focus: "trace", interval, oldest: startTime, newest: endTime, - ...(conditions.length ? {filter: {conditions}} : {}), - } - - const response = await fetchJson(url, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(payload), - signal, + filter: conditions.length ? {conditions} : undefined, + abortSignal: signal, }) - const valTracing = response as TracingDashboardData - return tracingToGeneration(valTracing, rangeString) as GenerationDashboardData + // `fetchSpansAnalytics` returns null on non-2xx / shape-mismatch; the + // dashboard treats that as "no data" rather than throwing. + return analyticsToGeneration(analytics ?? {buckets: []}, rangeString) as GenerationDashboardData } diff --git a/web/oss/src/services/tracing/lib/helpers.ts b/web/oss/src/services/tracing/lib/helpers.ts index aeec15aad9..fcae1778bd 100644 --- a/web/oss/src/services/tracing/lib/helpers.ts +++ b/web/oss/src/services/tracing/lib/helpers.ts @@ -4,11 +4,10 @@ import { sortSpansByStartTime, transformTracesResponseToTree, transformTracingResponse, + type AnalyticsResponse, } from "@agenta/entities/trace" import dayjs from "dayjs" -import {TracingDashboardData} from "../types" - // Re-export entity functions for backward compatibility export { isSpansResponse, @@ -67,40 +66,80 @@ export const normalizeDurationSeconds = (d = 0) => d / 1_000 export const formatTick = (ts: number | string, range: string) => dayjs(ts).format(range === "24_hours" ? "h:mm a" : range === "7_days" ? "ddd" : "D MMM") -export function tracingToGeneration(tracing: TracingDashboardData, range: string) { - const buckets = tracing.buckets ?? [] +// Dotted `MetricSpec.path` keys for the buckets returned by the new +// `/spans/analytics/query` endpoint. These match the backend's +// DEFAULT_ANALYTICS_SPECS (api/oss/src/core/tracing/service.py), which is what +// the endpoint applies when the request omits `specs`. +const COST_PATH = "attributes.ag.metrics.costs.cumulative.total" +const TOKENS_PATH = "attributes.ag.metrics.tokens.cumulative.total" +const DURATION_PATH = "attributes.ag.metrics.duration.cumulative" +const ERRORS_PATH = "attributes.ag.metrics.errors.cumulative" +const TRACE_TYPE_PATH = "attributes.ag.type.trace" + +type BucketMetrics = AnalyticsResponse["buckets"] extends (infer B)[] | null | undefined + ? B extends {metrics?: infer M} + ? M + : never + : never + +/** Read a numeric field (e.g. `sum`, `count`, `mean`) from one metric blob. */ +const metricField = (metrics: BucketMetrics, path: string, field: string): number => { + const blob = metrics?.[path] + const value = blob?.[field] + return typeof value === "number" && Number.isFinite(value) ? value : 0 +} + +/** + * Map the new spec-based analytics response onto the generation dashboard + * shape (AGE-3788). The old `/tracing/spans/analytics` endpoint returned a + * success/error split per bucket (`total` vs `errors`); the new endpoint + * returns per-metric aggregates keyed by dotted spec path, so we reconstruct + * the dashboard figures: + * - total count = `type.trace` count (root-span count per bucket) + * - failure count = `errors.cumulative` sum + * - success count = total − failures + * - cost / tokens = `costs|tokens.cumulative.total` sum (over all spans) + * - latency = `duration.cumulative` sum / count (avg over all spans) + */ +export function analyticsToGeneration(analytics: AnalyticsResponse, range: string) { + const buckets = analytics.buckets ?? [] let successCount = 0 let errorCount = 0 let totalCost = 0 let totalTokens = 0 - let totalSuccessDuration = 0 + let totalDurationS = 0 + let totalDurationCount = 0 const data = buckets.map((b) => { - const succC = b.total?.count ?? 0 - const errC = b.errors?.count ?? 0 + const m = b.metrics as BucketMetrics - const succCost = b.total?.costs ?? 0 - const errCost = b.errors?.costs ?? 0 + const cost = metricField(m, COST_PATH, "sum") + const tokens = metricField(m, TOKENS_PATH, "sum") + const failure = metricField(m, ERRORS_PATH, "sum") - const succTok = b.total?.tokens ?? 0 - const errTok = b.errors?.tokens ?? 0 + const durationCount = metricField(m, DURATION_PATH, "count") + // Prefer the trace-type root count; fall back to the duration sample + // count when the categorical metric is absent (e.g. span focus). + const total = metricField(m, TRACE_TYPE_PATH, "count") || durationCount + const success = Math.max(0, total - failure) - const succDurS = normalizeDurationSeconds(b.total?.duration ?? 0) + const durationS = normalizeDurationSeconds(metricField(m, DURATION_PATH, "sum")) - successCount += succC - errorCount += errC - totalCost += succCost + errCost - totalTokens += succTok + errTok - totalSuccessDuration += succDurS + successCount += success + errorCount += failure + totalCost += cost + totalTokens += tokens + totalDurationS += durationS + totalDurationCount += durationCount return { timestamp: formatTick(b.timestamp, range), - success_count: succC, - failure_count: errC, - cost: succCost + errCost, - latency: succC ? succDurS / Math.max(succC, 1) : 0, // avg latency per success in the bucket - total_tokens: succTok + errTok, + success_count: success, + failure_count: failure, + cost, + latency: durationCount ? durationS / durationCount : 0, // avg latency in the bucket + total_tokens: tokens, } }) @@ -112,7 +151,7 @@ export function tracingToGeneration(tracing: TracingDashboardData, range: string failure_rate: totalCount ? errorCount / totalCount : 0, total_cost: totalCost, avg_cost: totalCount ? totalCost / totalCount : 0, - avg_latency: successCount ? totalSuccessDuration / successCount : 0, + avg_latency: totalDurationCount ? totalDurationS / totalDurationCount : 0, total_tokens: totalTokens, avg_tokens: totalCount ? totalTokens / totalCount : 0, } diff --git a/web/oss/src/services/tracing/types/index.ts b/web/oss/src/services/tracing/types/index.ts index 423728d297..31bead2047 100644 --- a/web/oss/src/services/tracing/types/index.ts +++ b/web/oss/src/services/tracing/types/index.ts @@ -120,26 +120,11 @@ export interface SpansResponse { spans: TraceSpan[] } -export interface TracingDashboardData { - buckets: { - errors: { - costs: number - count: number - duration: number - tokens: number - } - timestamp: string - total: { - costs: number - count: number - duration: number - tokens: number - } - interval: number - }[] - count: number - version: string -} +// AGE-3788: `TracingDashboardData` (the old success/error bucket split returned +// by the deprecated `/tracing/spans/analytics`) was removed. The dashboard now +// reads spec-based metric buckets from `/spans/analytics/query` via the entities +// `AnalyticsResponse` type; `analyticsToGeneration` maps them onto +// `GenerationDashboardData` below. export interface GenerationDashboardData { data: { diff --git a/web/packages/agenta-entities/src/trace/api/api.ts b/web/packages/agenta-entities/src/trace/api/api.ts index 813669141f..a44e236bf3 100644 --- a/web/packages/agenta-entities/src/trace/api/api.ts +++ b/web/packages/agenta-entities/src/trace/api/api.ts @@ -24,10 +24,12 @@ import { traceIdResponseSchema, traceResponseSchema, tracesArrayResponseSchema, + analyticsResponseSchema, type SpansResponse, type TracesResponse, type SessionIdsResponse, type TraceIdResponse, + type AnalyticsResponse, } from "../core" import {fernTracesToLegacyTraceMap} from "./adapters" @@ -278,3 +280,66 @@ export async function fetchSessions( if (!data) return null return safeParseWithLogging(sessionIdsResponseSchema, data, "[fetchSessions]") } + +export interface SpansAnalyticsParams { + projectId: string + appId?: string | null + /** Aggregation focus — "trace" mirrors the legacy dashboard query. */ + focus?: AgentaApi.Focus + /** Bucket size in minutes. */ + interval?: number + /** ISO window bounds (inclusive). `newest` undefined means "now". */ + oldest?: string + newest?: string + /** + * Structured span filter (`{conditions: [...]}`), same dialect as the + * legacy analytics `filter` body. Serialized to the JSON-string `filter` + * query param expected by the new endpoint. + */ + filter?: unknown + abortSignal?: AbortSignal +} + +/** + * POST /spans/analytics/query via Fern (`querySpansAnalytics`) — replaces the + * deprecated `POST /tracing/spans/analytics` used by the observability + * generation dashboard (AGE-3788 Phase 6). + * + * `specs` is intentionally omitted: when absent, the backend applies its + * `DEFAULT_ANALYTICS_SPECS` (duration / errors / costs / tokens cumulative + + * trace/span type counts), which is exactly the set the dashboard needs. The + * response `buckets[].metrics` dict is keyed by each spec's dotted path; the + * OSS transform (`analyticsToGeneration`) reads the numeric fields it needs. + */ +export async function fetchSpansAnalytics( + params: SpansAnalyticsParams, +): Promise { + const { + projectId, + appId, + focus = "trace", + interval, + oldest, + newest, + filter, + abortSignal, + } = params + + if (!projectId) return null + + const request: AgentaApi.QuerySpansAnalyticsRequest = {focus} + if (interval !== undefined) request.interval = interval + if (oldest) request.oldest = oldest + if (newest) request.newest = newest + // `filter`/`specs` are JSON-string query params on the new endpoint. + if (filter !== undefined && filter !== null) request.filter = JSON.stringify(filter) + + const data = await callFern("[fetchSpansAnalytics]", () => + getTracesClient().querySpansAnalytics( + request, + projectScopedRequest(projectId, appId ?? undefined, abortSignal), + ), + ) + if (!data) return null + return safeParseWithLogging(analyticsResponseSchema, data, "[fetchSpansAnalytics]") +} diff --git a/web/packages/agenta-entities/src/trace/api/index.ts b/web/packages/agenta-entities/src/trace/api/index.ts index 0a54fbf29c..72e87c8e03 100644 --- a/web/packages/agenta-entities/src/trace/api/index.ts +++ b/web/packages/agenta-entities/src/trace/api/index.ts @@ -11,8 +11,10 @@ export { fetchPreviewTrace, deletePreviewTrace, fetchSessions, + fetchSpansAnalytics, type TraceQueryParams, type SessionQueryParams, + type SpansAnalyticsParams, type PreviewTracesRateLimit, type PreviewTracesWithMetaResult, } from "./api" diff --git a/web/packages/agenta-entities/src/trace/core/index.ts b/web/packages/agenta-entities/src/trace/core/index.ts index 6cd2f00872..cfe53b9362 100644 --- a/web/packages/agenta-entities/src/trace/core/index.ts +++ b/web/packages/agenta-entities/src/trace/core/index.ts @@ -51,6 +51,10 @@ export { type SessionIdsResponse, traceIdResponseSchema, type TraceIdResponse, + metricsBucketSchema, + type MetricsBucket, + analyticsResponseSchema, + type AnalyticsResponse, // Parsing utilities parseTraceSpan, parseTracesResponse, diff --git a/web/packages/agenta-entities/src/trace/core/schema.ts b/web/packages/agenta-entities/src/trace/core/schema.ts index 59f94da0d2..a78641bcbe 100644 --- a/web/packages/agenta-entities/src/trace/core/schema.ts +++ b/web/packages/agenta-entities/src/trace/core/schema.ts @@ -301,6 +301,28 @@ export const traceIdResponseSchema = z.object({ }) export type TraceIdResponse = z.infer +// POST /spans/analytics/query -> AnalyticsResponse (Fern `querySpansAnalytics`) +// Each bucket's `metrics` dict is keyed by the dotted `MetricSpec.path` (e.g. +// "attributes.ag.metrics.costs.cumulative.total"); each value is a free-form +// stats blob ({type, count, sum, mean, min, max, ...histogram/percentiles}). +// Kept deliberately lenient — the dashboard reads a few numeric fields and must +// tolerate missing/extra keys across metric types and backend revisions. +export const metricsBucketSchema = z.object({ + timestamp: z.string(), + interval: z.number().optional().nullable(), + metrics: z + .record(z.string(), z.record(z.string(), z.unknown()).nullable()) + .optional() + .nullable(), +}) +export type MetricsBucket = z.infer + +export const analyticsResponseSchema = z.object({ + count: z.number().optional(), + buckets: z.array(metricsBucketSchema).optional().nullable(), +}) +export type AnalyticsResponse = z.infer + // Combined response type for list queries export interface TraceListResponse { traces: TraceSpanNode[] diff --git a/web/packages/agenta-entities/src/trace/index.ts b/web/packages/agenta-entities/src/trace/index.ts index c3394f7ecf..7b076011bf 100644 --- a/web/packages/agenta-entities/src/trace/index.ts +++ b/web/packages/agenta-entities/src/trace/index.ts @@ -85,6 +85,11 @@ export { spansResponseSchema, type SpansResponse, type TraceListResponse, + // Analytics (AGE-3788 Phase 6) + metricsBucketSchema, + type MetricsBucket, + analyticsResponseSchema, + type AnalyticsResponse, } from "./core" // Type definitions @@ -107,8 +112,10 @@ export { fetchPreviewTrace, deletePreviewTrace, fetchSessions, + fetchSpansAnalytics, type TraceQueryParams, type SessionQueryParams, + type SpansAnalyticsParams, type PreviewTracesRateLimit, type PreviewTracesWithMetaResult, } from "./api" diff --git a/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts b/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts index f9e4944d92..4fc7c90462 100644 --- a/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts @@ -13,10 +13,18 @@ const deleteTrace = vi.fn() const fetchTrace = vi.fn() const querySpans = vi.fn() const queryTraces = vi.fn() +const querySpansAnalytics = vi.fn() vi.mock("@agenta/sdk", () => ({ getAgentaSdkClient: () => ({ - traces: {querySpansSessions, deleteTrace, fetchTrace, querySpans, queryTraces}, + traces: { + querySpansSessions, + deleteTrace, + fetchTrace, + querySpans, + queryTraces, + querySpansAnalytics, + }, }), })) @@ -27,6 +35,7 @@ import { fetchAllPreviewTracesWithMeta, fetchPreviewTrace, fetchSessions, + fetchSpansAnalytics, transformTracesResponseToTree, transformTracingResponse, } from "../../src/trace/api" @@ -37,6 +46,7 @@ beforeEach(() => { fetchTrace.mockReset() querySpans.mockReset() queryTraces.mockReset() + querySpansAnalytics.mockReset() }) describe("fetchSessions (Phase 1 — POST /spans/sessions/query)", () => { @@ -268,3 +278,82 @@ describe("fetchAllPreviewTracesWithMeta (Phase 5 CQ2 — rate-limit pacing)", () ) }) }) + +describe("fetchSpansAnalytics (Phase 6 — POST /spans/analytics/query)", () => { + it("omits `specs` (backend defaults), sends focus/interval/window and JSON-string filter", async () => { + querySpansAnalytics.mockResolvedValueOnce({count: 0, buckets: []}) + + await fetchSpansAnalytics({ + projectId: "proj-9", + appId: "app-1", + focus: "trace", + interval: 60, + oldest: "2026-01-01T00:00:00Z", + newest: "2026-01-02T00:00:00Z", + filter: {conditions: [{field: "references", operator: "in", value: [{id: "app-1"}]}]}, + }) + + expect(querySpansAnalytics).toHaveBeenCalledTimes(1) + const [request, opts] = querySpansAnalytics.mock.calls[0] + // `specs` MUST be absent so the backend applies DEFAULT_ANALYTICS_SPECS. + expect(request).not.toHaveProperty("specs") + expect(request.focus).toBe("trace") + expect(request.interval).toBe(60) + expect(request.oldest).toBe("2026-01-01T00:00:00Z") + expect(request.newest).toBe("2026-01-02T00:00:00Z") + // filter is a JSON-encoded string query param, not a structured body. + expect(typeof request.filter).toBe("string") + expect(JSON.parse(request.filter)).toEqual({ + conditions: [{field: "references", operator: "in", value: [{id: "app-1"}]}], + }) + expect(opts.queryParams).toEqual({project_id: "proj-9", application_id: "app-1"}) + }) + + it("omits `filter` when no conditions are supplied", async () => { + querySpansAnalytics.mockResolvedValueOnce({count: 0, buckets: []}) + await fetchSpansAnalytics({projectId: "proj-9", interval: 30}) + const [request, opts] = querySpansAnalytics.mock.calls[0] + expect(request).not.toHaveProperty("filter") + expect(request).not.toHaveProperty("specs") + expect(request.focus).toBe("trace") // default focus + expect(opts.queryParams).toEqual({project_id: "proj-9"}) + }) + + it("returns null without calling Fern when projectId is empty", async () => { + const res = await fetchSpansAnalytics({projectId: ""}) + expect(res).toBeNull() + expect(querySpansAnalytics).not.toHaveBeenCalled() + }) + + it("parses a representative analytics response (metrics keyed by dotted path)", async () => { + const buckets = [ + { + timestamp: "2026-01-01T00:00:00Z", + interval: 60, + metrics: { + "attributes.ag.metrics.costs.cumulative.total": { + type: "numeric/continuous", + count: 3, + sum: 0.42, + }, + "attributes.ag.type.trace": {type: "categorical/single", count: 3}, + }, + }, + ] + querySpansAnalytics.mockResolvedValueOnce({count: 1, buckets}) + const res = await fetchSpansAnalytics({projectId: "proj-9"}) + expect(res).toEqual({count: 1, buckets}) + }) + + it("returns null when the Fern call throws (AgentaApiError -> null)", async () => { + querySpansAnalytics.mockRejectedValueOnce(new Error("500")) + const res = await fetchSpansAnalytics({projectId: "proj-9"}) + expect(res).toBeNull() + }) + + it("rethrows AbortError so TanStack Query can cancel", async () => { + const abort = new DOMException("Aborted", "AbortError") + querySpansAnalytics.mockRejectedValueOnce(abort) + await expect(fetchSpansAnalytics({projectId: "proj-9"})).rejects.toBe(abort) + }) +}) From 623cfe1d904eac0b701cd80ab77288a4a331d4d8 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 6 Jun 2026 15:50:41 +0200 Subject: [PATCH 4/5] chore(frontend): address PR review nits on tracing migration (AGE-3788) CodeRabbit follow-ups (quick wins; no behavior change): - toFilteringInput: reject array-shaped filters (typeof [] === 'object') before casting to FilteringInput. - GenerationDashboardData: make the never-populated, unread prompt_tokens / completion_tokens / enviornment / variant fields optional in both the services/tracing and types_ee copies, and drop the masking 'as GenerationDashboardData' cast (annotate analyticsToGeneration's return). - integration test: parse AGENTA_TEST_EXPECT_RATELIMIT explicitly so 'false'/'0' don't enable the EE-only assertion path. - golden-convergence test: replace 'as never' with 'as unknown as TracesResponse'; tighten the focus=trace adapter assertion to TracesResponse. - adapters test: exercise the missing-trace_id skip path explicitly. --- web/oss/src/lib/types_ee.ts | 12 ++++++++---- web/oss/src/services/tracing/api/index.ts | 2 +- web/oss/src/services/tracing/lib/helpers.ts | 7 ++++++- web/oss/src/services/tracing/types/index.ts | 12 ++++++++---- .../agenta-entities/src/trace/api/request.ts | 4 +++- .../trace-migration.integration.test.ts | 4 +++- .../tests/unit/trace-migration-adapters.test.ts | 3 ++- .../tests/unit/trace-migration-api.test.ts | 15 ++++++++++----- 8 files changed, 41 insertions(+), 18 deletions(-) diff --git a/web/oss/src/lib/types_ee.ts b/web/oss/src/lib/types_ee.ts index 92fb607149..b2537cf12a 100644 --- a/web/oss/src/lib/types_ee.ts +++ b/web/oss/src/lib/types_ee.ts @@ -60,10 +60,14 @@ export interface GenerationDashboardData { cost: number latency: number total_tokens: number - prompt_tokens: number - completion_tokens: number - enviornment: string - variant: string + // AGE-3788: optional — the new /spans/analytics/query metrics carry no + // prompt/completion token split or per-bucket environment/variant, and + // the legacy transform never populated them. Kept in sync with the + // duplicate interface in services/tracing/types. + prompt_tokens?: number + completion_tokens?: number + enviornment?: string + variant?: string }[] total_count: number failure_rate: number diff --git a/web/oss/src/services/tracing/api/index.ts b/web/oss/src/services/tracing/api/index.ts index 1c619dd45b..2216505734 100644 --- a/web/oss/src/services/tracing/api/index.ts +++ b/web/oss/src/services/tracing/api/index.ts @@ -110,5 +110,5 @@ export const fetchGenerationsDashboardData = async ( // `fetchSpansAnalytics` returns null on non-2xx / shape-mismatch; the // dashboard treats that as "no data" rather than throwing. - return analyticsToGeneration(analytics ?? {buckets: []}, rangeString) as GenerationDashboardData + return analyticsToGeneration(analytics ?? {buckets: []}, rangeString) } diff --git a/web/oss/src/services/tracing/lib/helpers.ts b/web/oss/src/services/tracing/lib/helpers.ts index fcae1778bd..27927d4432 100644 --- a/web/oss/src/services/tracing/lib/helpers.ts +++ b/web/oss/src/services/tracing/lib/helpers.ts @@ -8,6 +8,8 @@ import { } from "@agenta/entities/trace" import dayjs from "dayjs" +import type {GenerationDashboardData} from "../types" + // Re-export entity functions for backward compatibility export { isSpansResponse, @@ -101,7 +103,10 @@ const metricField = (metrics: BucketMetrics, path: string, field: string): numbe * - cost / tokens = `costs|tokens.cumulative.total` sum (over all spans) * - latency = `duration.cumulative` sum / count (avg over all spans) */ -export function analyticsToGeneration(analytics: AnalyticsResponse, range: string) { +export function analyticsToGeneration( + analytics: AnalyticsResponse, + range: string, +): GenerationDashboardData { const buckets = analytics.buckets ?? [] let successCount = 0 diff --git a/web/oss/src/services/tracing/types/index.ts b/web/oss/src/services/tracing/types/index.ts index 31bead2047..816c273c75 100644 --- a/web/oss/src/services/tracing/types/index.ts +++ b/web/oss/src/services/tracing/types/index.ts @@ -134,10 +134,14 @@ export interface GenerationDashboardData { cost: number latency: number total_tokens: number - prompt_tokens: number - completion_tokens: number - enviornment: string - variant: string + // The new `/spans/analytics/query` metrics do not split tokens by + // prompt/completion and carry no environment/variant per bucket. These + // were never populated by the legacy transform either and are unread by + // the observability dashboard, so they are optional. + prompt_tokens?: number + completion_tokens?: number + enviornment?: string + variant?: string }[] total_count: number failure_rate: number diff --git a/web/packages/agenta-entities/src/trace/api/request.ts b/web/packages/agenta-entities/src/trace/api/request.ts index bc9d40ade0..dc5126f081 100644 --- a/web/packages/agenta-entities/src/trace/api/request.ts +++ b/web/packages/agenta-entities/src/trace/api/request.ts @@ -53,7 +53,9 @@ export function toFilteringInput( return undefined } } - if (typeof parsed !== "object" || parsed === null) return undefined + // Arrays satisfy `typeof === "object"`; reject them so malformed JSON like + // `[]` is dropped instead of being cast to a (non-array) FilteringInput. + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined // Already FilteringInput-shaped ({operator?, conditions[]}). return parsed as AgentaApi.FilteringInput } diff --git a/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts b/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts index 37856f268e..865e54dfff 100644 --- a/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts +++ b/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts @@ -41,7 +41,9 @@ import type {TracesResponse} from "../../src/trace/core" import {TEST_CONFIG, hasBackend} from "./helpers/env" const TEST_TRACE_ID = process.env.AGENTA_TEST_TRACE_ID || "" -const EXPECT_RATELIMIT = Boolean(process.env.AGENTA_TEST_EXPECT_RATELIMIT) +// Explicit truthy parse: a bare Boolean(...) treats "false"/"0" as true, which +// would wrongly enable the EE-only rate-limit assertion path. +const EXPECT_RATELIMIT = /^(1|true|yes)$/i.test(process.env.AGENTA_TEST_EXPECT_RATELIMIT ?? "") // Seed the lazy Fern SDK singleton with the test backend + key BEFORE any api // function runs. getTracesClient() calls getAgentaSdkClient() argless, so the diff --git a/web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts b/web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts index f6e5541994..0d577230d0 100644 --- a/web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts +++ b/web/packages/agenta-entities/tests/unit/trace-migration-adapters.test.ts @@ -124,7 +124,8 @@ describe("fernTracesToLegacyTraceMap", () => { it("skips entries without a trace_id and handles empty input", () => { expect(fernTracesToLegacyTraceMap(null)).toEqual({count: 0, traces: {}}) - const out = fernTracesToLegacyTraceMap([{spans: {}} as TraceOutput]) + // Explicitly exercise the missing-trace_id skip path. + const out = fernTracesToLegacyTraceMap([{trace_id: undefined, spans: {}} as TraceOutput]) expect(out.count).toBe(0) }) }) diff --git a/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts b/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts index 4fc7c90462..6b4a669cc5 100644 --- a/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/trace-migration-api.test.ts @@ -39,6 +39,7 @@ import { transformTracesResponseToTree, transformTracingResponse, } from "../../src/trace/api" +import type {TracesResponse} from "../../src/trace/core" beforeEach(() => { querySpansSessions.mockReset() @@ -144,8 +145,11 @@ describe("fetchPreviewTrace (Phase 3 — GET /traces/{id})", () => { const oldEquivalent = {count: 1, traces: {[DASHED]: {spans: spansMap}}} const newTree = transformTracingResponse(transformTracesResponseToTree(newResult!)) + // The literal `spansMap` omits some optional span fields, so a direct + // cast won't structurally match; `as unknown as TracesResponse` is + // honest about the shim while still typing the value (vs `as never`). const oldTree = transformTracingResponse( - transformTracesResponseToTree(oldEquivalent as never), + transformTracesResponseToTree(oldEquivalent as unknown as TracesResponse), ) expect(newTree).toEqual(oldTree) @@ -218,10 +222,11 @@ describe("fetchAllPreviewTraces (Phase 5 — trace-tree via POST /traces/query)" traces: [{trace_id: DASHED, spans: {root: {trace_id: DASHED, span_id: "s"}}}], }) - const res = (await fetchAllPreviewTraces({focus: "trace", filter}, "", "proj-9")) as { - count: number - traces: Record - } + const res = (await fetchAllPreviewTraces( + {focus: "trace", filter}, + "", + "proj-9", + )) as TracesResponse expect(queryTraces).toHaveBeenCalledTimes(1) const [request, opts] = queryTraces.mock.calls[0] From 7ab989dd5d77968d65603a73f37d0d4b7a1cca1b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sat, 6 Jun 2026 19:18:54 +0200 Subject: [PATCH 5/5] fix(frontend): keep dashboard latency in ms + add analytics integration tests (AGE-3788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against live project data via a gated integration test: ag.metrics.duration.cumulative is in MILLISECONDS (avg ~1229ms across 8.5k traces), but analyticsToGeneration divided by 1000 while the dashboard renders latency with an 'ms' suffix — so avg/per-bucket latency displayed 1000x too small (1229ms shown as '1.23ms'). The legacy transform had the same bug on the same attribute; this corrects it now that real data confirms the unit. Also adds /spans/analytics/query integration coverage: - shape smoke (gated on hasBackend) — proves the Fern call + envelope parse. - live-data assertions (gated on AGENTA_LIVE_* env, no secrets committed) — prove the default-spec dotted metric paths the OSS transform reads are present with numeric aggregates. --- web/oss/src/services/tracing/lib/helpers.ts | 14 ++- .../trace-migration.integration.test.ts | 110 +++++++++++++++++- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/web/oss/src/services/tracing/lib/helpers.ts b/web/oss/src/services/tracing/lib/helpers.ts index 27927d4432..4a308716cf 100644 --- a/web/oss/src/services/tracing/lib/helpers.ts +++ b/web/oss/src/services/tracing/lib/helpers.ts @@ -113,7 +113,7 @@ export function analyticsToGeneration( let errorCount = 0 let totalCost = 0 let totalTokens = 0 - let totalDurationS = 0 + let totalDurationMs = 0 let totalDurationCount = 0 const data = buckets.map((b) => { @@ -129,13 +129,17 @@ export function analyticsToGeneration( const total = metricField(m, TRACE_TYPE_PATH, "count") || durationCount const success = Math.max(0, total - failure) - const durationS = normalizeDurationSeconds(metricField(m, DURATION_PATH, "sum")) + // `ag.metrics.duration.cumulative` is stored in MILLISECONDS, and the + // dashboard renders latency with an "ms" suffix — so keep it in ms. (The + // legacy transform divided by 1000 here, which made the dashboard show + // latencies 1000× too small; verified against live data — AGE-3788.) + const durationMs = metricField(m, DURATION_PATH, "sum") successCount += success errorCount += failure totalCost += cost totalTokens += tokens - totalDurationS += durationS + totalDurationMs += durationMs totalDurationCount += durationCount return { @@ -143,7 +147,7 @@ export function analyticsToGeneration( success_count: success, failure_count: failure, cost, - latency: durationCount ? durationS / durationCount : 0, // avg latency in the bucket + latency: durationCount ? durationMs / durationCount : 0, // avg latency (ms) in the bucket total_tokens: tokens, } }) @@ -156,7 +160,7 @@ export function analyticsToGeneration( failure_rate: totalCount ? errorCount / totalCount : 0, total_cost: totalCost, avg_cost: totalCount ? totalCost / totalCount : 0, - avg_latency: totalDurationCount ? totalDurationS / totalDurationCount : 0, + avg_latency: totalDurationCount ? totalDurationMs / totalDurationCount : 0, // ms total_tokens: totalTokens, avg_tokens: totalCount ? totalTokens / totalCount : 0, } diff --git a/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts b/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts index 865e54dfff..2ddf05794a 100644 --- a/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts +++ b/web/packages/agenta-entities/tests/integration/trace-migration.integration.test.ts @@ -34,12 +34,24 @@ import {beforeAll, describe, expect, it} from "vitest" import { fetchAllPreviewTraces, fetchAllPreviewTracesWithMeta, + fetchSpansAnalytics, isTracesResponse, } from "../../src/trace" -import type {TracesResponse} from "../../src/trace/core" +import type {AnalyticsResponse, TracesResponse} from "../../src/trace/core" import {TEST_CONFIG, hasBackend} from "./helpers/env" +// Optional live-data override: point the analytics test at an EXISTING project +// that already has traces (the ephemeral account from global setup is empty, so +// it can only assert response shape, not real aggregates). Credentials come from +// env — never hardcode them. Run with: +// AGENTA_API_URL=... AGENTA_LIVE_PROJECT_ID=... AGENTA_LIVE_API_KEY=... \ +// pnpm run test:integration +const LIVE_PROJECT_ID = process.env.AGENTA_LIVE_PROJECT_ID || "" +const LIVE_API_KEY = process.env.AGENTA_LIVE_API_KEY || "" +const LIVE_API_URL = process.env.AGENTA_API_URL || "" +const hasLiveAnalytics = Boolean(LIVE_PROJECT_ID && LIVE_API_KEY && LIVE_API_URL) + const TEST_TRACE_ID = process.env.AGENTA_TEST_TRACE_ID || "" // Explicit truthy parse: a bare Boolean(...) treats "false"/"0" as true, which // would wrongly enable the EE-only rate-limit assertion path. @@ -157,3 +169,99 @@ describe.skipIf(!hasBackend)("AGE-3788 smoke — Fern client reaches the backend expect(res === null || "spans" in res).toBe(true) }) }) + +// --- Phase 6: /spans/analytics/query end-to-end (the generation dashboard) --- +// This is the function the observability dashboard atom chain ultimately calls: +// useObservabilityDashboard → observabilityDashboardQueryAtom +// → fetchGenerationsDashboardData → fetchSpansAnalytics (this) → analyticsToGeneration +// Asserting it here proves the migrated Fern wiring + the metric-path contract +// the OSS transform reads (buckets[].metrics keyed by dotted MetricSpec path). + +const DURATION_PATH = "attributes.ag.metrics.duration.cumulative" +const COST_PATH = "attributes.ag.metrics.costs.cumulative.total" +const TOKENS_PATH = "attributes.ag.metrics.tokens.cumulative.total" +const ERRORS_PATH = "attributes.ag.metrics.errors.cumulative" +const TRACE_TYPE_PATH = "attributes.ag.type.trace" + +type Buckets = NonNullable +const sumField = (buckets: Buckets, path: string, field: string): number => + buckets.reduce((acc, b) => { + const v = ( + b.metrics as Record | null> | null | undefined + )?.[path]?.[field] + return acc + (typeof v === "number" ? v : 0) + }, 0) + +// Shape-only smoke against whatever project the harness provisioned (empty +// ephemeral project is fine — proves the Fern call + envelope parse work). +describe.skipIf(!hasBackend)("AGE-3788 Phase 6 — querySpansAnalytics shape", () => { + it("omits specs, returns an AnalyticsResponse with a buckets array (or null)", async () => { + const oldest = new Date(Date.now() - 24 * 3600 * 1000).toISOString().split(".")[0] + const res = await fetchSpansAnalytics({ + projectId: TEST_CONFIG.projectId, + focus: "trace", + interval: 720, + oldest, + }) + // null on a validation miss is tolerated; otherwise buckets must be an array. + expect(res === null || Array.isArray(res.buckets)).toBe(true) + }) +}) + +// Real-data assertions against a project that already has traces. +describe.skipIf(!hasLiveAnalytics)( + "AGE-3788 Phase 6 — querySpansAnalytics against a project with real data", + () => { + beforeAll(() => { + process.env.AGENTA_HOST = LIVE_API_URL + process.env.AGENTA_API_KEY = LIVE_API_KEY + getAgentaSdkClient({host: LIVE_API_URL, apiKey: LIVE_API_KEY}) + }) + + it("returns default-spec metric buckets keyed by the dotted paths the transform reads", async () => { + const oldest = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString().split(".")[0] + const res = await fetchSpansAnalytics({ + projectId: LIVE_PROJECT_ID, + focus: "trace", + interval: 720, + oldest, + }) + + expect(res).not.toBeNull() + const buckets = (res?.buckets ?? []) as Buckets + expect(Array.isArray(buckets)).toBe(true) + + const durationSum = sumField(buckets, DURATION_PATH, "sum") + const durationCount = sumField(buckets, DURATION_PATH, "count") + const costSum = sumField(buckets, COST_PATH, "sum") + const tokensSum = sumField(buckets, TOKENS_PATH, "sum") + const errorsSum = sumField(buckets, ERRORS_PATH, "sum") + const traceCount = sumField(buckets, TRACE_TYPE_PATH, "count") + + // The duration metric is the SAME ag.metrics.duration.cumulative the + // legacy /tracing/spans/analytics summed — this logs its real unit so + // the dashboard's /1000 normalization can be confirmed against actuals. + console.info("[AGE-3788 analytics LIVE]", { + bucketCount: buckets.length, + durationSum, + durationCount, + avgDurationRaw: durationCount ? durationSum / durationCount : 0, + avgDuration_div1000: durationCount ? durationSum / durationCount / 1000 : 0, + costSum, + tokensSum, + errorsSum, + traceCount, + sampleBucketMetricKeys: Object.keys(buckets[0]?.metrics ?? {}), + }) + + // The contract analyticsToGeneration depends on: the default-spec + // dotted paths are present with numeric aggregates. + expect(buckets.length).toBeGreaterThan(0) + expect(traceCount).toBeGreaterThan(0) + expect(durationCount).toBeGreaterThan(0) + expect(Number.isFinite(durationSum)).toBe(true) + expect(Number.isFinite(costSum)).toBe(true) + expect(Number.isFinite(tokensSum)).toBe(true) + }) + }, +)