From e342d3b97ac915c7abe083838b8ebc6843afa1c5 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 2 Sep 2026 06:38:54 +0200 Subject: [PATCH] feat(agent-sessions): page a session's spans and summarise it in the warehouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail page read a session in one response capped at 2,000 spans and called anything past that truncated. The largest session in the warehouse has ~209k spans in one trace, a tenth of them the agent's own; one page showed its first minute. The `spans` read now pages by a keyset cursor on its own (timestamp, spanId) order — `nextCursor` replaces `truncated` — and takes a `scope` (all, the agent's spans, or the app's), `traceIds` for a turn's traces without session detection, and a `limit`. A new `summary` read returns the whole session's totals from an ungrouped aggregate, with per-turn rows grouped by conversation id (falling back to the trace) beside it; usage is summed over all spans and over model calls alone so the handler can apply the page's deepest-reporter rule. The page loads the opening whole — a session that fits is complete after one read, as before — and continues with the agent's spans alone, fetching a turn's app spans from the Trace view's header on demand. The Overview leads with the warehouse totals when the session is only partly loaded, the transcript ends on a load-more divider, and a deep link's bounds are stamped from the totals so a cut-short window is never written into the URL. --- .../routes/internal/ai-sessions.http.test.ts | 279 ++++++++++++- .../src/routes/internal/ai-sessions.http.ts | 335 ++++++++++++---- apps/web/src/api/warehouse/ai-sessions.ts | 48 ++- .../session-detail/session-detail.test.tsx | 91 ++++- .../session-detail/session-overview.tsx | 71 ++++ .../session-detail/session-transcript.tsx | 46 ++- .../session-detail/session-views.tsx | 30 +- .../session-detail/session-waterfall.tsx | 59 +++ apps/web/src/hooks/use-session-spans.test.tsx | 149 +++++++ apps/web/src/hooks/use-session-spans.ts | 256 ++++++++++++ apps/web/src/lab/agent-session-lab.tsx | 21 +- .../src/lib/agent-sessions/session-summary.ts | 6 +- .../agent-sessions/session-transcript.test.ts | 12 +- .../lib/agent-sessions/session-transcript.ts | 36 +- .../src/lib/agent-sessions/session-turns.ts | 22 +- .../services/atoms/warehouse-query-atoms.ts | 6 +- .../src/routes/agent-sessions/$sessionId.tsx | 73 +++- packages/domain/src/gen-ai.ts | 11 + packages/domain/src/http/ai-sessions.ts | 188 ++++++++- .../src/__sql_baseline__/integrations.sql | 194 +++++++++ .../src/ai/ai-integrations.ts | 11 + .../src/ai/ai-sessions.test.ts | 154 ++++++++ .../src/ai/ai-sessions.ts | 368 +++++++++++++++++- .../query-engine-integrations/src/ai/index.ts | 9 + .../query-engine-integrations/src/catalog.ts | 73 ++++ 25 files changed, 2334 insertions(+), 214 deletions(-) create mode 100644 apps/web/src/hooks/use-session-spans.test.tsx create mode 100644 apps/web/src/hooks/use-session-spans.ts diff --git a/apps/api/src/routes/internal/ai-sessions.http.test.ts b/apps/api/src/routes/internal/ai-sessions.http.test.ts index 8d2a3cbf0..43287c646 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.test.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest" import { AiSessionsInternalApiGroup, AI_SESSION_SPANS_MAX_SPANS, + AI_SESSION_SUMMARY_MAX_TURNS, CurrentTenant, V1SchemaErrors, V1UnexpectedErrors, @@ -114,7 +115,7 @@ describe("POST /internal/ai-sessions/spans", () => { } }) - it("cuts the session at the row cap and says so", async () => { + it("cuts the page at the row cap and hands back where the next one starts", async () => { // The query asks for one row past the cap precisely so this case is // distinguishable from a session that exactly fills it. const rows = Array.from({ length: AI_SESSION_SPANS_MAX_SPANS + 1 }, (_, index) => spanRow(index)) @@ -126,8 +127,10 @@ describe("POST /internal/ai-sessions/spans", () => { try { const response = await harness.post("/internal/ai-sessions/spans", SPANS_BODY) expect(response.status).toBe(200) - expect(response.body.truncated).toBe(true) expect(response.body.data).toHaveLength(AI_SESSION_SPANS_MAX_SPANS) + // The last row RETURNED, not the extra one: the next page starts after it. + const last = spanRow(AI_SESSION_SPANS_MAX_SPANS - 1) + expect(response.body.nextCursor).toEqual({ timestamp: last.timestamp, spanId: last.spanId }) } finally { await harness.dispose() } @@ -144,7 +147,7 @@ describe("POST /internal/ai-sessions/spans", () => { try { const response = await harness.post("/internal/ai-sessions/spans", SPANS_BODY) expect(response.status).toBe(200) - expect(response.body.truncated).toBe(false) + expect(response.body.nextCursor).toBeUndefined() expect(response.body.data).toHaveLength(2) } finally { await harness.dispose() @@ -271,7 +274,7 @@ describe("POST /internal/ai-sessions/spans", () => { sessionId: "trace:not-a-trace-id' OR 1=1", }) expect(response.status).toBe(200) - expect(response.body).toMatchObject({ data: [], truncated: false }) + expect(response.body).toEqual({ data: [] }) expect(windowSql).toContain("SpanAttributes['maple_ai.session.id'] =") expect(windowSql).not.toContain("TraceId =") expect(spansRead).toBe(false) @@ -306,7 +309,7 @@ describe("POST /internal/ai-sessions/spans", () => { try { const response = await harness.post("/internal/ai-sessions/spans", { sessionId: SESSION_ID }) expect(response.status).toBe(200) - expect(response.body).toMatchObject({ data: [], truncated: false }) + expect(response.body).toEqual({ data: [] }) expect(spansRead).toBe(false) } finally { await harness.dispose() @@ -388,3 +391,269 @@ describe("POST /internal/ai-sessions/facets", () => { } }) }) + +describe("POST /internal/ai-sessions/spans — pages and scopes", () => { + const captureSpansSql = () => { + let sql: string | undefined + const harness = makeHarness({ + compiledQueryBounded: (_tenant, compiled) => { + sql = compiledQueryOf(compiled).sql + return compiledQueryOf(compiled) + .decodeRows([spanRow(0)]) + .pipe(Effect.orDie) + }, + }) + return { harness, sql: () => sql } + } + + it("resumes after the cursor, on the agent spans alone", async () => { + const { harness, sql } = captureSpansSql() + try { + const after = { timestamp: "2026-08-19 10:00:00.000000000", spanId: "00000000000007cf" } + const response = await harness.post("/internal/ai-sessions/spans", { + ...SPANS_BODY, + scope: "ai", + after, + limit: 500, + }) + expect(response.status).toBe(200) + expect(sql()).toContain("SpanAttributes['maple_ai.vendor.id'] != ''") + expect(sql()).toContain( + `(Timestamp > '${after.timestamp}' OR (Timestamp = '${after.timestamp}' AND SpanId > '${after.spanId}'))`, + ) + expect(sql()).toContain("LIMIT 501") + } finally { + await harness.dispose() + } + }) + + it("reads a turn's app spans by its traces, skipping session detection", async () => { + const { harness, sql } = captureSpansSql() + try { + const other = "0123456789abcdef0123456789abcdef" + const response = await harness.post("/internal/ai-sessions/spans", { + ...SPANS_BODY, + scope: "app", + traceIds: [TRACE_ID, other], + }) + expect(response.status).toBe(200) + expect(sql()).toContain(`TraceId IN ('${TRACE_ID}', '${other}')`) + expect(sql()).toContain("SpanAttributes['maple_ai.vendor.id'] = ''") + expect(sql()).not.toContain("FROM traces") + } finally { + await harness.dispose() + } + }) + + it("refuses a trace-pinned read with no window to bound it", async () => { + const { harness } = captureSpansSql() + try { + const response = await harness.post("/internal/ai-sessions/spans", { + sessionId: SESSION_ID, + traceIds: [TRACE_ID], + }) + expect(response.status).toBe(400) + } finally { + await harness.dispose() + } + }) + + it("refuses a trace id that is not one", async () => { + const { harness } = captureSpansSql() + try { + const response = await harness.post("/internal/ai-sessions/spans", { + ...SPANS_BODY, + traceIds: ["not-a-trace-id' OR 1=1"], + }) + expect(response.status).toBe(400) + } finally { + await harness.dispose() + } + }) +}) + +describe("POST /internal/ai-sessions/summary", () => { + /** One turn row, in the wire shape `aiSessionSummaryRowSchema` decodes. */ + const turnRow = (overrides: Record) => ({ + turnKey: "turn_0", + conversationId: "turn_0", + traceIds: [TRACE_ID], + startTime: "2026-08-19 10:00:00.000000000", + endTime: "2026-08-19 10:00:10.000000000", + durationMs: "10000", + spanCount: "40", + aiSpanCount: "6", + llmCalls: "3", + toolCalls: "2", + errorSpanCount: "0", + inputTokens: "0", + outputTokens: "0", + cacheReadTokens: "0", + llmInputTokens: "0", + llmOutputTokens: "0", + llmCacheReadTokens: "0", + costReporters: "0", + cost: "0", + llmCost: "0", + models: ["gpt-5"], + agentNames: ["slack-agent"], + ...overrides, + }) + + /** The session's own row, in the wire shape `aiSessionTotalsRowSchema` decodes. */ + const totalsRow = (overrides: Record) => { + const { turnKey: _turnKey, conversationId: _conversationId, traceIds: _traceIds, ...measures } = turnRow({}) + return { traceCount: "1", ...measures, ...overrides } + } + + /** Two reads: the turn rows under `GROUP BY`, the session's row without. */ + const summaryHarness = ( + rows: ReadonlyArray>, + totals: ReadonlyArray>, + ) => { + const sqls: string[] = [] + const harness = makeHarness({ + compiledQuery: (_tenant, compiled) => { + const sql = compiledQueryOf(compiled).sql + sqls.push(sql) + return compiledQueryOf(compiled) + .decodeRows(sql.includes("GROUP BY turnKey") ? rows : totals) + .pipe(Effect.orDie) + }, + }) + return { harness, sqls } + } + + it("reports the session's own row as the totals, and the turn rows beside it", async () => { + const { harness, sqls } = summaryHarness( + [ + turnRow({ inputTokens: "300", llmInputTokens: "150", outputTokens: "60", llmOutputTokens: "30" }), + turnRow({ + turnKey: "turn_1", + conversationId: "turn_1", + traceIds: [TRACE_ID, "0123456789abcdef0123456789abcdef"], + startTime: "2026-08-19 10:00:20.000000000", + endTime: "2026-08-19 10:00:35.500000000", + errorSpanCount: "1", + inputTokens: "100", + llmInputTokens: "100", + models: ["gpt-5", "claude-opus-5"], + agentNames: [], + }), + ], + [ + // Usage reported per model call AND rolled up onto the agent span: + // the per-call figures are the total, the roll-up is not added on top. + totalsRow({ + traceCount: "2", + spanCount: "80", + aiSpanCount: "12", + llmCalls: "6", + toolCalls: "4", + errorSpanCount: "1", + endTime: "2026-08-19 10:00:35.500000000", + durationMs: "35500", + inputTokens: "400", + llmInputTokens: "250", + outputTokens: "60", + llmOutputTokens: "30", + costReporters: "4", + cost: "0.02", + llmCost: "0.01", + models: ["gpt-5", "claude-opus-5"], + }), + ], + ) + try { + const response = await harness.post("/internal/ai-sessions/summary", SPANS_BODY) + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ + spanCount: 80, + aiSpanCount: 12, + traceCount: 2, + startTime: "2026-08-19 10:00:00.000000000", + endTime: "2026-08-19 10:00:35.500000000", + durationMs: 35_500, + llmCalls: 6, + toolCalls: 4, + errorSpanCount: 1, + tokens: { input: 250, output: 30, cacheRead: 0 }, + tokenReporting: "per-call", + cost: 0.01, + models: ["gpt-5", "claude-opus-5"], + agentNames: ["slack-agent"], + turnsTruncated: false, + }) + const turns = response.body.turns as Array> + expect(turns).toHaveLength(2) + expect(turns[1]).toMatchObject({ turnKey: "turn_1", tokens: { input: 100, output: 0, cacheRead: 0 } }) + expect(turns[1]).not.toHaveProperty("cost") + expect(sqls).toHaveLength(2) + for (const sql of sqls) expect(sql).toContain(`SpanAttributes['maple_ai.session.id'] = '${SESSION_ID}'`) + } finally { + await harness.dispose() + } + }) + + // The rule is applied to the session's row, never to the turn rows summed: + // a turn span's roll-up and its model calls can land in different rows. + it("does not double usage split across a turn row and its trace's row", async () => { + const { harness } = summaryHarness( + [ + turnRow({ inputTokens: "300", llmCalls: "0" }), + turnRow({ turnKey: TRACE_ID, conversationId: "", inputTokens: "300", llmInputTokens: "300" }), + ], + [totalsRow({ inputTokens: "600", llmInputTokens: "300" })], + ) + try { + const response = await harness.post("/internal/ai-sessions/summary", SPANS_BODY) + expect(response.body).toMatchObject({ tokens: { input: 300, output: 0, cacheRead: 0 }, tokenReporting: "per-call" }) + } finally { + await harness.dispose() + } + }) + + it("counts a roll-up when no model call reported usage", async () => { + const { harness } = summaryHarness( + [turnRow({ inputTokens: "300", outputTokens: "60" })], + [totalsRow({ inputTokens: "300", outputTokens: "60" })], + ) + try { + const response = await harness.post("/internal/ai-sessions/summary", SPANS_BODY) + expect(response.body).toMatchObject({ + tokens: { input: 300, output: 60, cacheRead: 0 }, + tokenReporting: "roll-up", + }) + expect(response.body).not.toHaveProperty("cost") + } finally { + await harness.dispose() + } + }) + + it("keeps exact totals when the turn list is cut", async () => { + const rows = Array.from({ length: AI_SESSION_SUMMARY_MAX_TURNS + 1 }, (_, index) => + turnRow({ turnKey: `turn_${index}`, conversationId: `turn_${index}`, spanCount: "1" }), + ) + const { harness } = summaryHarness(rows, [totalsRow({ spanCount: String(AI_SESSION_SUMMARY_MAX_TURNS + 1) })]) + try { + const response = await harness.post("/internal/ai-sessions/summary", SPANS_BODY) + expect(response.body).toMatchObject({ spanCount: AI_SESSION_SUMMARY_MAX_TURNS + 1, turnsTruncated: true }) + expect(response.body.turns).toHaveLength(AI_SESSION_SUMMARY_MAX_TURNS) + } finally { + await harness.dispose() + } + }) + + it("answers an unknown session with empty totals and no bounds", async () => { + // An aggregate over no rows is still one row, with a zero count. + const { harness } = summaryHarness([], [totalsRow({ spanCount: "0", traceCount: "0" })]) + try { + const response = await harness.post("/internal/ai-sessions/summary", SPANS_BODY) + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ spanCount: 0, turns: [], tokenReporting: "none" }) + expect(response.body).not.toHaveProperty("startTime") + } finally { + await harness.dispose() + } + }) +}) diff --git a/apps/api/src/routes/internal/ai-sessions.http.ts b/apps/api/src/routes/internal/ai-sessions.http.ts index 314af07ad..ab699315e 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -2,12 +2,17 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { AiSessionTooLargeError, AI_SESSION_SPANS_MAX_SPANS, + AI_SESSION_SUMMARY_MAX_TURNS, CurrentTenant, GetAiSessionSpansResponse, + GetAiSessionSummaryResponse, ListAiSessionsFacetsResponse, ListAiSessionsResponse, MapleInternalApi, MAX_AI_SESSION_SPANS_RESPONSE_BYTES, + type AiSessionTokenReporting, + type AiSessionTokenTotals, + type AiSessionTurnSummary, } from "@maple/domain/http" import { traceSessionTraceId } from "@maple/domain/gen-ai" import { Effect } from "effect" @@ -29,6 +34,72 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( Effect.gen(function* () { const warehouse = yield* WarehouseQueryService + /** + * The window a session read is bounded by, and how the session is keyed. + * + * A `trace:` id is Maple's own: the vendor exposed no session + * key, so the trace IS the session and the reads key on the trace id + * instead of the session attribute. The helper returns `undefined` for + * a vendor id AND for a prefixed one that is not 32 hex characters, so a + * forged value never reaches the trace-keyed param — it takes the + * session path, where nothing carries it and the caller gets the + * empty-session answer. + * + * Both halves of the hint or neither: a lone bound would silently pin + * the other end of the read to the param placeholder. Without a hint + * the bounds are resolved from the id first, because every read has to + * be partition-pruned on both levels rather than fan out unpruned — see + * `aiSessionSpansQuery`. One extra round trip, and only on the + * deep-link path; `window_source` is how often that runs gets watched. + */ + const resolveRead = Effect.fn("aiSessions.resolveRead")(function* (payload: { + readonly sessionId: string + readonly startTime?: string + readonly endTime?: string + }) { + const tenant = yield* CurrentTenant.Context + const hint = + payload.startTime !== undefined && payload.endTime !== undefined + ? { startTime: payload.startTime, endTime: payload.endTime } + : undefined + const traceId = traceSessionTraceId(payload.sessionId) + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "maple.ai.session.id": payload.sessionId, + "maple.ai.session.kind": traceId === undefined ? "vendor" : "trace", + "maple.ai.window_source": hint === undefined ? "resolved" : "client", + }) + const resolved = + hint !== undefined + ? undefined + : traceId === undefined + ? yield* warehouse.compiledQuery( + tenant, + CH.compile(Integrations.aiSessionWindowQuery(), { + orgId: tenant.orgId, + sessionId: payload.sessionId, + }), + { profile: "list", context: "aiSessionWindow" }, + ) + : yield* warehouse.compiledQuery( + tenant, + CH.compile(Integrations.aiTraceWindowQuery(), { + orgId: tenant.orgId, + traceId, + }), + { profile: "list", context: "aiTraceWindow" }, + ) + // `min`/`max` over no rows return the epoch rather than nothing, so + // the count is what distinguishes an unknown session id. + const bounds = resolved?.[0] + const window = + hint ?? + (bounds !== undefined && bounds.spanCount > 0 + ? { startTime: bounds.startTime, endTime: bounds.endTime } + : undefined) + return { tenant, traceId, window } + }) + return handlers .handle("list", ({ payload }) => Effect.gen(function* () { @@ -82,89 +153,49 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( ) .handle("spans", ({ payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - // Both halves or neither: a lone bound would silently pin the - // other end of the read to the param placeholder. - const hint = - payload.startTime !== undefined && payload.endTime !== undefined - ? { startTime: payload.startTime, endTime: payload.endTime } - : undefined - // A `trace:` id is Maple's own: the vendor exposed no - // session key, so the trace IS the session and both reads key on - // the trace id instead of the session attribute. The helper - // returns `undefined` for a vendor id AND for a prefixed one that - // is not 32 hex characters, so a forged value never reaches the - // trace-keyed param — it takes the session path, where nothing - // carries it and the caller gets the empty-session answer below. - const traceId = traceSessionTraceId(payload.sessionId) // Annotated before the read: a 413 never reaches the code below. - // `window_source` is how often the extra resolve round-trip runs - // gets watched — it should stay the exception. - yield* Effect.annotateCurrentSpan({ - orgId: tenant.orgId, - "maple.ai.session.id": payload.sessionId, - "maple.ai.session.kind": traceId === undefined ? "vendor" : "trace", - "maple.ai.window_source": hint === undefined ? "resolved" : "client", - }) - // The spans read has to be partition-pruned on both levels, so a - // caller without bounds gets bounds first rather than an unpruned - // fan-out — see `aiSessionSpansQuery`. One extra round trip, and - // only on the deep-link path. - const resolved = - hint !== undefined - ? undefined - : traceId === undefined - ? yield* warehouse.compiledQuery( - tenant, - CH.compile(Integrations.aiSessionWindowQuery(), { - orgId: tenant.orgId, - sessionId: payload.sessionId, - }), - { profile: "list", context: "aiSessionWindow" }, - ) - : yield* warehouse.compiledQuery( - tenant, - CH.compile(Integrations.aiTraceWindowQuery(), { - orgId: tenant.orgId, - traceId, - }), - { profile: "list", context: "aiTraceWindow" }, - ) - // `min`/`max` over no rows return the epoch rather than nothing, so - // the count is what distinguishes an unknown session id. - const bounds = resolved?.[0] - const window = - hint ?? - (bounds !== undefined && bounds.spanCount > 0 - ? { startTime: bounds.startTime, endTime: bounds.endTime } - : undefined) + const { tenant, traceId, window } = yield* resolveRead(payload) if (window === undefined) { - return new GetAiSessionSpansResponse({ data: [], truncated: false }) + return new GetAiSessionSpansResponse({ data: [] }) + } + const limit = payload.limit ?? AI_SESSION_SPANS_MAX_SPANS + // One row past the page: the extra row is what distinguishes a + // session that exactly fills the page from one with a page after. + const opts = { + limit: limit + 1, + scope: payload.scope, + after: payload.after, } - // One row past the cap: the extra row is what distinguishes a - // session that exactly fills the cap from one whose tail was cut. + const rowSchema = { rowSchema: Integrations.aiSessionSpansRowSchema } const compiled = - traceId === undefined + payload.traceIds !== undefined ? CH.compile( - Integrations.aiSessionSpansQuery({ - limit: AI_SESSION_SPANS_MAX_SPANS + 1, - }), - { orgId: tenant.orgId, sessionId: payload.sessionId, ...window }, - { rowSchema: Integrations.aiSessionSpansRowSchema }, - ) - : CH.compile( - Integrations.aiTraceSpansQuery({ - limit: AI_SESSION_SPANS_MAX_SPANS + 1, - }), - { orgId: tenant.orgId, traceId, ...window }, - { rowSchema: Integrations.aiSessionSpansRowSchema }, + Integrations.aiTraceSpansQuery({ ...opts, traceIds: payload.traceIds }), + { orgId: tenant.orgId, ...window }, + rowSchema, ) + : traceId === undefined + ? CH.compile( + Integrations.aiSessionSpansQuery(opts), + { orgId: tenant.orgId, sessionId: payload.sessionId, ...window }, + rowSchema, + ) + : CH.compile( + Integrations.aiTraceSpansQuery(opts), + { orgId: tenant.orgId, traceId, ...window }, + rowSchema, + ) const rows = yield* warehouse .compiledQueryBounded(tenant, compiled, { profile: "list", - context: traceId === undefined ? "aiSessionSpans" : "aiTraceSpans", + context: + payload.traceIds !== undefined + ? "aiTracesSpans" + : traceId === undefined + ? "aiSessionSpans" + : "aiTraceSpans", responseLimits: { - maxRows: AI_SESSION_SPANS_MAX_SPANS + 1, + maxRows: limit + 1, maxBytes: MAX_AI_SESSION_SPANS_RESPONSE_BYTES, }, }) @@ -180,19 +211,161 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( ), ), ) - const truncated = rows.length > AI_SESSION_SPANS_MAX_SPANS - const spans = rows.slice(0, AI_SESSION_SPANS_MAX_SPANS) + const page = rows.slice(0, limit) + const last = page[page.length - 1] + const nextCursor = + rows.length > limit && last !== undefined + ? { timestamp: last.timestamp, spanId: last.spanId } + : undefined yield* Effect.annotateCurrentSpan({ - "maple.ai.span_count": spans.length, - "maple.ai.truncated": truncated, + "maple.ai.span_count": page.length, + "maple.ai.scope": payload.scope ?? "all", + "maple.ai.has_more": nextCursor !== undefined, }) - // Mapped server-side: the raw attribute maps are the dominant - // weight of this read and nothing downstream needs them. + // Mapped server-side: the raw attribute map is the dominant weight + // of this read and nothing downstream needs it. return new GetAiSessionSpansResponse({ - data: Integrations.mapAiSpans(spans), - truncated, + data: Integrations.mapAiSpans(page), + ...(nextCursor !== undefined && { nextCursor }), }) }), ) + .handle("summary", ({ payload }) => + Effect.gen(function* () { + const { tenant, traceId, window } = yield* resolveRead(payload) + if (window === undefined) { + return emptySummary() + } + // Two reads over the same spans, side by side: the turn rows are + // capped, and a session grouping into more turns than the cap + // must still report exact totals — those come from the ungrouped + // read, which no cap touches. + const params = + traceId === undefined + ? { orgId: tenant.orgId, sessionId: payload.sessionId, ...window } + : { orgId: tenant.orgId, traceId, ...window } + const turnsQuery = + traceId === undefined + ? Integrations.aiSessionSummaryQuery() + : Integrations.aiTraceSummaryQuery() + const totalsQuery = + traceId === undefined ? Integrations.aiSessionTotalsQuery() : Integrations.aiTraceTotalsQuery() + const kind = traceId === undefined ? "aiSession" : "aiTrace" + const [rows, totals] = yield* Effect.all( + [ + warehouse.compiledQuery( + tenant, + CH.compile(turnsQuery, params, { rowSchema: Integrations.aiSessionSummaryRowSchema }), + { context: `${kind}Summary` }, + ), + warehouse.compiledQuery( + tenant, + CH.compile(totalsQuery, params, { rowSchema: Integrations.aiSessionTotalsRowSchema }), + { context: `${kind}Totals` }, + ), + ], + { concurrency: 2 }, + ) + const summary = foldSummary(totals[0], rows) + yield* Effect.annotateCurrentSpan({ + "maple.ai.span_count": summary.spanCount, + "maple.ai.turn_count": summary.turns.length, + }) + return summary + }), + ) }), ) + +const NO_TOKENS: AiSessionTokenTotals = { input: 0, output: 0, cacheRead: 0 } + +const emptySummary = () => + new GetAiSessionSummaryResponse({ + spanCount: 0, + aiSpanCount: 0, + traceCount: 0, + durationMs: 0, + llmCalls: 0, + toolCalls: 0, + errorSpanCount: 0, + tokens: NO_TOKENS, + tokenReporting: "none", + models: [], + agentNames: [], + turns: [], + turnsTruncated: false, + }) + +/** + * A set of spans' usage under the deepest-reporter rule: the model-call spans' + * figures when any model call reported, the plain sum otherwise. See + * `summaryMeasures` in the query module for why it returns both. Applied to + * the session's own row for the totals, and to each turn row for the turn — + * never to the turn rows summed, since a turn span's roll-up and its model + * calls can land in different rows. + */ +const usageOf = (row: Integrations.AiSessionTotalsOutput | Integrations.AiSessionSummaryOutput) => { + const perCall = row.llmInputTokens + row.llmOutputTokens + row.llmCacheReadTokens > 0 + const reporting: AiSessionTokenReporting = + perCall ? "per-call" : row.inputTokens + row.outputTokens + row.cacheReadTokens > 0 ? "roll-up" : "none" + const tokens: AiSessionTokenTotals = perCall + ? { input: row.llmInputTokens, output: row.llmOutputTokens, cacheRead: row.llmCacheReadTokens } + : { input: row.inputTokens, output: row.outputTokens, cacheRead: row.cacheReadTokens } + // Cost follows the same rule, but only once something reported one: a + // per-call session whose calls carry no price still has a session cost if + // the wrapper stamped one. + const cost = + row.costReporters === 0 ? undefined : perCall && row.llmCost > 0 ? row.llmCost : row.cost + return { reporting, tokens, cost } +} + +/** The session's row and its turn rows, as the response shape. */ +const foldSummary = ( + totals: Integrations.AiSessionTotalsOutput | undefined, + rows: readonly Integrations.AiSessionSummaryOutput[], +) => { + // An aggregate over no rows still yields one row, with a zero count. + if (totals === undefined || totals.spanCount === 0) return emptySummary() + const turnsTruncated = rows.length > AI_SESSION_SUMMARY_MAX_TURNS + const kept = rows.slice(0, AI_SESSION_SUMMARY_MAX_TURNS) + const usage = usageOf(totals) + + const turns: AiSessionTurnSummary[] = kept.map((row) => { + const usage = usageOf(row) + return { + turnKey: row.turnKey, + conversationId: row.conversationId, + traceIds: row.traceIds, + startTime: row.startTime, + endTime: row.endTime, + durationMs: row.durationMs, + spanCount: row.spanCount, + aiSpanCount: row.aiSpanCount, + llmCalls: row.llmCalls, + toolCalls: row.toolCalls, + errorSpanCount: row.errorSpanCount, + tokens: usage.tokens, + ...(usage.cost !== undefined && { cost: usage.cost }), + models: row.models, + agentNames: row.agentNames, + } + }) + return new GetAiSessionSummaryResponse({ + spanCount: totals.spanCount, + aiSpanCount: totals.aiSpanCount, + traceCount: totals.traceCount, + startTime: totals.startTime, + endTime: totals.endTime, + durationMs: totals.durationMs, + llmCalls: totals.llmCalls, + toolCalls: totals.toolCalls, + errorSpanCount: totals.errorSpanCount, + tokens: usage.tokens, + tokenReporting: usage.reporting, + ...(usage.cost !== undefined && { cost: usage.cost }), + models: totals.models, + agentNames: totals.agentNames, + turns, + turnsTruncated, + }) +} diff --git a/apps/web/src/api/warehouse/ai-sessions.ts b/apps/web/src/api/warehouse/ai-sessions.ts index c9ac3de79..b6b009487 100644 --- a/apps/web/src/api/warehouse/ai-sessions.ts +++ b/apps/web/src/api/warehouse/ai-sessions.ts @@ -1,6 +1,10 @@ import { Clock, Effect, Schema } from "effect" import { + AI_SESSION_SPANS_MAX_TRACE_IDS, + AiSessionSpanCursor, + AiSessionSpanScope, GetAiSessionSpansRequest, + GetAiSessionSummaryRequest, ListAiSessionsFacetsRequest, ListAiSessionsRequest, } from "@maple/domain/http" @@ -90,6 +94,13 @@ const AiSessionSpansInput = Schema.Struct({ // warehouse find the session by id across retention. startTime: Schema.optional(WarehouseDateTimeString), endTime: Schema.optional(WarehouseDateTimeString), + /** `all` when absent — the first page of a session. */ + scope: Schema.optional(AiSessionSpanScope), + /** The previous page's `nextCursor`. */ + after: Schema.optional(AiSessionSpanCursor), + /** A turn's traces, for its `app` spans — needs the window. */ + traceIds: Schema.optional(Schema.Array(Schema.String).check(Schema.isMaxLength(AI_SESSION_SPANS_MAX_TRACE_IDS))), + limit: Schema.optional(Schema.Number), }) export type AiSessionSpansInput = Schema.Schema.Type @@ -111,9 +122,44 @@ export const getAiSessionSpans = Effect.fn("AiSessions.aiSessionSpans")(function ...(input.startTime !== undefined && input.endTime !== undefined ? { startTime: input.startTime, endTime: input.endTime } : undefined), + ...(input.scope !== undefined && { scope: input.scope }), + ...(input.after !== undefined && { after: input.after }), + ...(input.traceIds !== undefined && { traceIds: input.traceIds }), + ...(input.limit !== undefined && { limit: input.limit }), + }), + }) + }), + ) + return { data: result.data, nextCursor: result.nextCursor } +}) +export type AiSessionSpansPage = Effect.Success> + +const AiSessionSummaryInput = Schema.Struct({ + sessionId: Schema.String.check(Schema.isMinLength(1)), + startTime: Schema.optional(WarehouseDateTimeString), + endTime: Schema.optional(WarehouseDateTimeString), +}) +export type AiSessionSummaryInput = Schema.Schema.Type + +/** The whole session's totals, however many spans it has — see `GetAiSessionSummaryResponse`. */ +export const getAiSessionSummary = Effect.fn("AiSessions.aiSessionSummary")(function* ({ + data, +}: { + data: AiSessionSummaryInput +}) { + const input = yield* decodeInput(AiSessionSummaryInput, data, "aiSessionSummary") + yield* Effect.annotateCurrentSpan("sessionId", input.sessionId) + return yield* runWarehouseQuery("aiSessionSummary", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.aiSessionsInternal.summary({ + payload: new GetAiSessionSummaryRequest({ + sessionId: input.sessionId, + ...(input.startTime !== undefined && input.endTime !== undefined + ? { startTime: input.startTime, endTime: input.endTime } + : undefined), }), }) }), ) - return { data: result.data, truncated: result.truncated } }) diff --git a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx index 9177593a9..6909d33d2 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx @@ -45,13 +45,14 @@ vi.mock("@/lib/services/atoms/warehouse-query-atoms", async (importOriginal) => return { ...actual, getSpanDetailResultAtom: () => disabledResultAtom() } }) -import type { AiSessionSpan } from "@maple/domain/http" +import type { AiSessionSpan, GetAiSessionSummaryResponse } from "@maple/domain/http" +import type { SessionSpansState } from "@/hooks/use-session-spans" import { agentSpan, llmSpan, makeSpan, toolSpan, userMessages } from "@/lib/agent-sessions/span-test-support" import { buildSessionSummary, type SessionSummary } from "@/lib/agent-sessions/session-summary" import { buildSessionTurns, type SessionTurn } from "@/lib/agent-sessions/session-turns" import { SessionFlow } from "./session-flow" import { SessionOverview } from "./session-overview" -import { SessionViews, type SessionView } from "./session-views" +import { SessionViews, type SessionPaging, type SessionView } from "./session-views" import { SessionWaterfall } from "./session-waterfall" import type { SpanDetailTab } from "./span-expansion" @@ -303,6 +304,7 @@ function Waterfall(props: { revealedTurnId?: string onSelectSpan?: (spanId: string | undefined) => void spanTab?: SpanDetailTab + appSpans?: SessionSpansState["appSpans"] }) { return ( { expect(screen.getByText("GET /repo/file")).toBeTruthy() }) + // A partly loaded session: a turn past the first page holds agent spans + // alone, and the header is where the reader asks for the rest. + it("offers to load app spans for a turn that has none, once they would be shown", () => { + const load = vi.fn() + const appSpans = { of: () => undefined, load } + const view = render() + // Hidden under the agent-only toggle: fetching them would draw nothing. + expect(screen.queryByRole("button", { name: /^Load app spans/ })).toBeNull() + + view.rerender() + // Turn 1 came with its HTTP span; turn 2 did not. + const buttons = screen.getAllByRole("button", { name: /^Load app spans for Turn/ }) + expect(buttons).toHaveLength(1) + expect(buttons[0]!.getAttribute("aria-label")).toBe("Load app spans for Turn 2") + fireEvent.click(buttons[0]!) + expect(load).toHaveBeenCalledWith(turns[1]) + }) + + it("says how far a turn's app spans have come", () => { + const appSpans = { + of: () => ({ loading: false, loaded: 2000, cursor: { timestamp: "t", spanId: "s" }, complete: false, failed: false }), + load: noop, + } + render() + expect(screen.getAllByRole("button", { name: /Load more app spans \(2,000 loaded\)/ }).length).toBe(2) + }) + it("narrows to the spans that match the filter", () => { render() @@ -1076,7 +1106,13 @@ describe("SessionFlow", () => { describe("SessionViews", () => { /** `view` is a search param on the real page; here it is local state. */ - function Views(props: { turns?: readonly SessionTurn[]; summary?: SessionSummary; view?: SessionView }) { + function Views(props: { + turns?: readonly SessionTurn[] + summary?: SessionSummary + view?: SessionView + paging?: SessionPaging + totals?: GetAiSessionSummaryResponse + }) { const [view, setView] = useState(props.view ?? "trace") const [selectedSpanId, setSelectedSpanId] = useState(undefined) return ( @@ -1085,13 +1121,60 @@ describe("SessionViews", () => { onViewChange={setView} turns={props.turns ?? turns} summary={props.summary ?? summary} - truncated={false} + paging={props.paging} + totals={props.totals} selectedSpanId={selectedSpanId} onSelectSpan={setSelectedSpanId} /> ) } + const totals: GetAiSessionSummaryResponse = { + spanCount: 209_220, + aiSpanCount: 19_506, + traceCount: 1, + startTime: "2026-08-27 22:18:58.869000000", + endTime: "2026-08-27 23:56:55.809000000", + durationMs: 5_876_940, + llmCalls: 17_439, + toolCalls: 0, + errorSpanCount: 3, + tokens: { input: 1_000_000, output: 50_000, cacheRead: 0 }, + tokenReporting: "per-call", + cost: 12.5, + models: ["gpt-5"], + agentNames: [], + turns: [], + turnsTruncated: false, + } + + // A session larger than one page: the Overview leads with the whole + // session's totals, and the transcript ends on a way to load the rest. + it("shows the whole session's totals and a way to load more when partly loaded", () => { + const onLoadMore = vi.fn() + const paging = { hasMore: true, loadingMore: false, onLoadMore, appSpans: { of: () => undefined, load: noop } } + render() + + const whole = screen.getByTestId("whole-session") + expect(within(whole).getByText("209,220")).toBeTruthy() + expect(within(whole).getByText("19,506")).toBeTruthy() + expect(within(whole).getByText("17,439")).toBeTruthy() + expect(within(whole).getByText("$12.50")).toBeTruthy() + expect(within(whole).getByText(/read from the 8 spans loaded so far/)).toBeTruthy() + + fireEvent.click(screen.getByRole("tab", { name: /Transcript/ })) + expect(screen.getByText("More of this session follows")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Load more" })) + expect(onLoadMore).toHaveBeenCalledTimes(1) + }) + + it("shows neither for a session loaded whole", () => { + render() + expect(screen.queryByTestId("whole-session")).toBeNull() + fireEvent.click(screen.getByRole("tab", { name: /Transcript/ })) + expect(screen.queryByText("More of this session follows")).toBeNull() + }) + // Both debug views read the query and the span-kind toggle, so both controls // stay mounted in both. it("keeps the filter and the span-kind toggle reachable in both debug views", () => { diff --git a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx index 3b4060c91..9dda1ac85 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx @@ -1,5 +1,7 @@ import { useMemo, useState, type ReactNode } from "react" +import type { GetAiSessionSummaryResponse } from "@maple/domain/http" + import { ArrowRightIcon, ChevronRightIcon, CircleXmarkIcon } from "@/components/icons" import { Button } from "@maple/ui/components/ui/button" import { formatNumber, formatPercent } from "@maple/ui/lib/format" @@ -53,6 +55,7 @@ const SEVERITY_DOT = { export function SessionOverview({ turns, summary, + totals, selectedSpanId, onSelectSpan, spanTab, @@ -63,6 +66,12 @@ export function SessionOverview({ }: { turns: readonly SessionTurn[] summary: SessionSummary + /** + * The whole session's totals from the warehouse, for a session only partly + * loaded — everything else on this page is computed from the spans in + * hand, and says so when these are present. + */ + totals?: GetAiSessionSummaryResponse /** The one span open in the popover (`?span=`). */ selectedSpanId: string | undefined /** Raised with a span id to open it, `undefined` to close. */ @@ -89,6 +98,7 @@ export function SessionOverview({
+ {totals !== undefined && } value.toLocaleString("en-US") + const facts: ReadonlyArray<{ label: string; value: string }> = [ + { label: "Spans", value: exact(totals.spanCount) }, + { label: "Agent spans", value: exact(totals.aiSpanCount) }, + { label: "Traces", value: exact(totals.traceCount) }, + { label: "Duration", value: formatSessionDuration(totals.durationMs) }, + { label: "Model calls", value: exact(totals.llmCalls) }, + { label: "Tool calls", value: exact(totals.toolCalls) }, + { label: "Errors", value: exact(totals.errorSpanCount) }, + { label: "Tokens", value: totals.tokenReporting === "none" ? "—" : formatNumber(tokens) }, + { label: "Cost", value: totals.cost === undefined ? "—" : formatCost(totals.cost) }, + ] + + return ( +
+
+

Whole session

+

+ Everything below is read from the {exact(loadedSpanCount)} spans loaded so far. +

+
+
+ {facts.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+ {totals.models.length > 0 && ( +

+ {totals.models.map(shortTarget).join(" · ")} +

+ )} +
+ ) +} + /* -------------------------------------------------------------------------- */ /* Verdict */ /* -------------------------------------------------------------------------- */ diff --git a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx index 591ccad2b..de4045f96 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx @@ -2,6 +2,7 @@ import { useDeferredValue, useEffect, useMemo, useRef, type ReactNode } from "re import { useVirtualizer } from "@tanstack/react-virtual" import type { AiSessionSpan } from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { formatBytes, formatDuration, formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -89,7 +90,9 @@ export function SessionTranscript({ query, showThinking, showPayloads, - truncated, + hasMore, + loadingMore, + onLoadMore, collapsedTurns, onToggleTurn, openRows, @@ -105,8 +108,10 @@ export function SessionTranscript({ showThinking: boolean /** The toolbar's "Expand tool payloads" chip: arguments and results open by default. */ showPayloads: boolean - /** The response dropped the END of the session. */ - truncated: boolean + /** Agent spans remain past the loaded pages: the END of the session is not here yet. */ + hasMore: boolean + loadingMore: boolean + onLoadMore: (() => void) | undefined collapsedTurns: ReadonlySet onToggleTurn: (turnId: string) => void /** Rows whose disclosure the reader has flipped away from its default — held @@ -129,10 +134,10 @@ export function SessionTranscript({ toolResults, query: deferredQuery, showThinking, - truncated, + hasMore, collapsedTurns, }), - [turns, toolResults, deferredQuery, showThinking, truncated, collapsedTurns], + [turns, toolResults, deferredQuery, showThinking, hasMore, collapsedTurns], ) const virtualizer = useVirtualizer({ @@ -195,6 +200,8 @@ export function SessionTranscript({ void) | undefined row: TranscriptRow timeZone: string showPayloads: boolean @@ -1264,7 +1274,12 @@ function NoteBlock({ row }: { row: Extract }) { ) } -function DividerBlock({ row, timeZone }: BlockProps & { row: Extract }) { +function DividerBlock({ + row, + timeZone, + loadingMore, + onLoadMore, +}: BlockProps & { row: Extract }) { if (row.dividerKind === "compaction") { return (
- Session truncated + More of this session follows

- This session has more spans than one response carries — later activity is not shown, and this - is not where the session ended. + The agent's later spans are not loaded yet — this is not where the session ended.

-

Narrow the time range to see the rest.

+ {onLoadMore !== undefined && ( + + )}
) } diff --git a/apps/web/src/components/agent-sessions/session-detail/session-views.tsx b/apps/web/src/components/agent-sessions/session-detail/session-views.tsx index f78f73480..4bb17be24 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-views.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-views.tsx @@ -13,7 +13,10 @@ import { SearchInput } from "@maple/ui/components/ui/search-input" import { Switch } from "@maple/ui/components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" +import type { GetAiSessionSummaryResponse } from "@maple/domain/http" + import { useAppHotkey } from "@/hooks/use-app-hotkey" +import type { SessionSpansState } from "@/hooks/use-session-spans" import type { SessionSummary } from "@/lib/agent-sessions/session-summary" import type { SessionTurn } from "@/lib/agent-sessions/session-turns" import { SessionFlow } from "./session-flow" @@ -48,12 +51,25 @@ const DEBUG_VIEWS: readonly SessionView[] = ["trace", "flow"] * but not the span-kind toggle, which it has no use for: it never shows the * app's own HTTP spans at all. */ +/** + * How a session larger than one page continues: the next page of agent spans, + * and each turn's app spans on demand. Absent for a session that fit one page + * — then every view already holds the whole session. + */ +export interface SessionPaging { + readonly hasMore: boolean + readonly loadingMore: boolean + readonly onLoadMore: () => void + readonly appSpans: SessionSpansState["appSpans"] +} + export function SessionViews({ view, onViewChange, turns, summary, - truncated, + paging, + totals, selectedSpanId, onSelectSpan, }: { @@ -61,8 +77,10 @@ export function SessionViews({ onViewChange: (view: SessionView) => void turns: readonly SessionTurn[] summary: SessionSummary - /** The response dropped the END of the session — the transcript says so. */ - truncated: boolean + /** Present while the session is only partly loaded. */ + paging: SessionPaging | undefined + /** The whole session's totals, for the Overview of a partly loaded session. */ + totals: GetAiSessionSummaryResponse | undefined /** The span open in the inspection popover, in whichever view (`?span=`). */ selectedSpanId: string | undefined /** Raised with a span id to open it, `undefined` to close. */ @@ -281,6 +299,7 @@ export function SessionViews({ setCollapsedTurns((previous) => toggled(previous, turnId))} + appSpans={paging?.appSpans} selectedSpanId={selectedSpanId} revealedSpanId={revealedSpanId} revealedTurnId={revealedTurnId} @@ -337,7 +357,9 @@ export function SessionViews({ query={query} showThinking={showThinking} showPayloads={showPayloads} - truncated={truncated} + hasMore={paging?.hasMore === true} + loadingMore={paging?.loadingMore === true} + onLoadMore={paging?.onLoadMore} collapsedTurns={collapsedTurns} onToggleTurn={(turnId) => setCollapsedTurns((previous) => toggled(previous, turnId))} openRows={openRows} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx index 8e45b8f44..1e67267e7 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx @@ -9,6 +9,7 @@ import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { cn } from "@maple/ui/lib/utils" import { useListNavigation } from "@/hooks/use-list-navigation" +import type { SessionSpansState } from "@/hooks/use-session-spans" import { usePageScrollMargin } from "@/hooks/use-page-scroll-margin" import { buildSessionAxis, type AxisTick, type SessionAxis } from "@/lib/agent-sessions/session-axis" import { @@ -79,6 +80,12 @@ interface SessionWaterfallProps { /** Expansion state lives in SessionViews so a Trace → Flow → Trace round-trip keeps it. */ collapsedTurns: ReadonlySet onToggleTurn: (turnId: string) => void + /** + * How a turn's app spans are fetched, for a session larger than one page — + * its later turns hold the agent's spans alone until asked. Absent for a + * session loaded whole. + */ + appSpans?: SessionSpansState["appSpans"] /** The one span open in the popover (`?span=`). */ selectedSpanId: string | undefined /** A span sent here from another view's inspection panel: its row is scrolled @@ -104,6 +111,7 @@ export function SessionWaterfall({ collapseIdle, collapsedTurns, onToggleTurn, + appSpans, selectedSpanId, revealedSpanId, revealedTurnId, @@ -314,6 +322,10 @@ export function SessionWaterfall({ turns={turns} axis={axis} collapsed={collapsedTurns.has(row.turn.id)} + // App spans are hidden under the agent-only toggle, so + // offering to load them there would fetch rows the view + // then does not draw. + appSpans={agentSpansOnly ? undefined : appSpans} revealed={revealedTurnId === row.turn.id} onToggle={() => onToggleTurn(row.turn.id)} /> @@ -462,6 +474,7 @@ function TurnHeader({ collapsed, revealed, onToggle, + appSpans, }: { row: Extract /** The whole session's turns: a reporter wider than this one belongs to none. */ @@ -471,6 +484,7 @@ function TurnHeader({ /** The turn the reader was sent here to see: marked until they move on. */ revealed: boolean onToggle: () => void + appSpans: SessionSpansState["appSpans"] | undefined }) { const { turn } = row // Tokens and duration are facts about the turn, not about the rows on screen: @@ -484,6 +498,7 @@ function TurnHeader({ // is a segment of the session, not an established exchange with the user. const ordinal = `${turn.anchorKind === "trace" ? "Segment" : "Turn"} ${turn.index}` const traceId = turn.traceIds[0] + const appLoad = appSpanLoad(turn, appSpans) return (
)} + {appLoad !== undefined && ( + + )} {traceId !== undefined && ( @@ -781,3 +813,30 @@ function spanTokens(span: AiSessionSpan): string { const completion = buckets.output + buckets.reasoning return `${formatNumber(buckets.total - completion)} → ${formatNumber(completion)}` } + +/** + * The header's "load app spans" control for one turn, or nothing when there + * is nothing to load: the session came whole, the turn already holds app + * spans from the first page, or every page of them has been fetched. + */ +function appSpanLoad( + turn: SessionTurn, + appSpans: SessionSpansState["appSpans"] | undefined, +): { label: string; disabled: boolean; onClick: () => void } | undefined { + if (appSpans === undefined) return undefined + const state = appSpans.of(turn) + if (state === undefined) { + // The first page carried the session's opening whole, app spans and all, + // so a turn that already shows some was never cut. + if (turn.spans.some((span) => !span.isAiSpan)) return undefined + return { label: "Load app spans", disabled: false, onClick: () => appSpans.load(turn) } + } + if (state.loading) return { label: "Loading app spans…", disabled: true, onClick: () => undefined } + if (state.failed) return { label: "Retry app spans", disabled: false, onClick: () => appSpans.load(turn) } + if (state.complete) return undefined + return { + label: `Load more app spans (${state.loaded.toLocaleString("en-US")} loaded)`, + disabled: false, + onClick: () => appSpans.load(turn), + } +} diff --git a/apps/web/src/hooks/use-session-spans.test.tsx b/apps/web/src/hooks/use-session-spans.test.tsx new file mode 100644 index 000000000..c8bd08518 --- /dev/null +++ b/apps/web/src/hooks/use-session-spans.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import type { AiSessionSpan } from "@maple/domain/http" +import type { AiSessionSpansPage } from "@/api/warehouse/ai-sessions" +import { Atom, Result } from "@/lib/effect-atom" +import type { QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms" +import { agentSpan, llmSpan, makeSpan } from "@/lib/agent-sessions/span-test-support" +import { buildSessionTurns } from "@/lib/agent-sessions/session-turns" + +import { useSessionSpans, type SessionSpansReads } from "./use-session-spans" + +const mocks = { + firstPage: { data: [] as readonly AiSessionSpan[], nextCursor: undefined as { timestamp: string; spanId: string } | undefined }, +} +/** Every page past the first goes through the injected fetcher. */ +const fetchPage = vi.fn() +/** One atom per distinct input, the way the real family behaves. */ +type FirstPageAtom = Atom.Atom> +const atoms = new Map() +const reads: SessionSpansReads = { + firstPageAtom: (input) => { + const key = JSON.stringify(input) + let atom = atoms.get(key) + if (atom === undefined) { + atom = Atom.make(Result.success(mocks.firstPage)) + atoms.set(key, atom) + } + return atom + }, + fetchPage, +} + +const SECOND = 1000 +const CURSOR = { timestamp: "2026-08-19 10:00:30.000000000", spanId: "llm-1" } + +const firstPageSpans = [ + agentSpan({ spanId: "agent-1", startMs: 0, durationMs: 30 * SECOND }), + llmSpan({ spanId: "llm-1", parentSpanId: "agent-1", startMs: SECOND, durationMs: 5 * SECOND }), + makeSpan({ spanId: "http-1", parentSpanId: "agent-1", startMs: 2 * SECOND, durationMs: 100, spanName: "GET /x", isAiSpan: false }), +] +const secondPageSpans = [ + agentSpan({ spanId: "agent-2", startMs: 60 * SECOND, durationMs: 30 * SECOND }), + llmSpan({ spanId: "llm-2", parentSpanId: "agent-2", startMs: 61 * SECOND, durationMs: 5 * SECOND }), +] + +afterEach(() => { + fetchPage.mockReset() + atoms.clear() + mocks.firstPage = { data: [], nextCursor: undefined } +}) + +describe("useSessionSpans", () => { + it("is complete after one page when the session fits", () => { + mocks.firstPage = { data: firstPageSpans, nextCursor: undefined } + const { result } = renderHook(() => useSessionSpans("s1", undefined, reads)) + + expect(result.current.partial).toBe(false) + expect(result.current.hasMore).toBe(false) + expect(result.current.spans.map((span) => span.spanId)).toEqual(["agent-1", "llm-1", "http-1"]) + }) + + it("continues past the first page with the agent's spans alone, after the cursor", async () => { + mocks.firstPage = { data: firstPageSpans, nextCursor: CURSOR } + fetchPage.mockResolvedValueOnce({ data: secondPageSpans, nextCursor: undefined }) + const { result } = renderHook(() => + useSessionSpans("s1", { startTime: "2026-08-19 09:00:00", endTime: "2026-08-19 11:00:00" }, reads), + ) + + expect(result.current.partial).toBe(true) + expect(result.current.hasMore).toBe(true) + act(() => result.current.loadMore()) + await waitFor(() => expect(result.current.hasMore).toBe(false)) + + expect(fetchPage).toHaveBeenCalledWith({ + sessionId: "s1", + startTime: "2026-08-19 09:00:00", + endTime: "2026-08-19 11:00:00", + scope: "ai", + after: CURSOR, + limit: 2000, + }) + expect(result.current.spans.map((span) => span.spanId)).toEqual(["agent-1", "llm-1", "http-1", "agent-2", "llm-2"]) + // The session stays partial: the later turns hold agent spans alone. + expect(result.current.partial).toBe(true) + }) + + it("loads a turn's app spans by its traces and bounds, and drops repeats", async () => { + mocks.firstPage = { data: firstPageSpans, nextCursor: CURSOR } + const appSpan = makeSpan({ spanId: "http-2", parentSpanId: "agent-1", startMs: 3 * SECOND, durationMs: 100, spanName: "GET /y", isAiSpan: false }) + // `http-1` comes back too — the first page already had it. + fetchPage.mockResolvedValueOnce({ data: [firstPageSpans[2]!, appSpan], nextCursor: undefined }) + const { result } = renderHook(() => useSessionSpans("s1", undefined, reads)) + const turn = buildSessionTurns(firstPageSpans)[0]! + + expect(result.current.appSpans.of(turn)).toBeUndefined() + act(() => result.current.appSpans.load(turn)) + expect(result.current.appSpans.of(turn)?.loading).toBe(true) + await waitFor(() => expect(result.current.appSpans.of(turn)?.complete).toBe(true)) + + const call = fetchPage.mock.calls[0]![0] + expect(call).toMatchObject({ sessionId: "s1", scope: "app", traceIds: turn.traceIds, limit: 2000 }) + // A minute either side: the trace's opening span, parent of the turn's + // root, started before the turn's first agent span. + expect(call.startTime).toBe("2026-08-19 09:59:00") + expect(call.endTime).toBe("2026-08-19 10:01:30") + expect(result.current.appSpans.of(turn)?.loaded).toBe(2) + expect(result.current.spans.map((span) => span.spanId)).toEqual(["agent-1", "llm-1", "http-1", "http-2"]) + }) + + it("keeps a turn loadable when a page of its app spans failed", async () => { + mocks.firstPage = { data: firstPageSpans, nextCursor: CURSOR } + fetchPage.mockRejectedValueOnce(new Error("boom")) + const { result } = renderHook(() => useSessionSpans("s1", undefined, reads)) + const turn = buildSessionTurns(firstPageSpans)[0]! + + act(() => result.current.appSpans.load(turn)) + await waitFor(() => expect(result.current.appSpans.of(turn)?.failed).toBe(true)) + expect(result.current.appSpans.of(turn)?.loading).toBe(false) + }) + + it("drops the pages of a read the window moved on from, and a response landing late", async () => { + mocks.firstPage = { data: firstPageSpans, nextCursor: CURSOR } + let resolveLate: (page: { data: readonly AiSessionSpan[]; nextCursor: undefined }) => void = () => undefined + fetchPage + .mockResolvedValueOnce({ data: secondPageSpans, nextCursor: CURSOR }) + .mockImplementationOnce(() => new Promise((resolve) => { resolveLate = resolve })) + const early = { startTime: "2026-08-19 09:00:00", endTime: "2026-08-19 11:00:00" } + const { result, rerender } = renderHook(({ window }) => useSessionSpans("s1", window, reads), { + initialProps: { window: early }, + }) + + act(() => result.current.loadMore()) + await waitFor(() => expect(result.current.spans).toHaveLength(5)) + // A second page is in flight when the window changes. + act(() => result.current.loadMore()) + expect(result.current.loadingMore).toBe(true) + rerender({ window: { startTime: "2026-08-19 08:00:00", endTime: "2026-08-19 12:00:00" } }) + + expect(result.current.spans).toHaveLength(3) + expect(result.current.loadingMore).toBe(false) + await act(async () => { + resolveLate({ data: [agentSpan({ spanId: "late", startMs: 0, durationMs: SECOND })], nextCursor: undefined }) + }) + expect(result.current.spans.map((span) => span.spanId)).not.toContain("late") + expect(result.current.hasMore).toBe(true) + }) +}) diff --git a/apps/web/src/hooks/use-session-spans.ts b/apps/web/src/hooks/use-session-spans.ts new file mode 100644 index 000000000..3f5c1b0e5 --- /dev/null +++ b/apps/web/src/hooks/use-session-spans.ts @@ -0,0 +1,256 @@ +import * as React from "react" + +import type { AiSessionSpan, AiSessionSpanCursor } from "@maple/domain/http" +import { formatWarehouseDateTime } from "@maple/query-engine" + +import { + getAiSessionSpans, + type AiSessionSpansInput, + type AiSessionSpansPage, +} from "@/api/warehouse/ai-sessions" +import type { SessionTurn } from "@/lib/agent-sessions/session-turns" +import type { SessionWindow } from "@/lib/agent-sessions/session-window" +import { Result, useAtomValue, type Atom } from "@/lib/effect-atom" +import { mapleRuntime } from "@/lib/registry" +import { logClientError } from "@/lib/services/common/telemetry" +import { aiSessionSpansResultAtom, type QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms" + +/** + * One turn's app spans — the service's own HTTP/DB work sharing the agent's + * traces — as far as they have been loaded. + * + * `loaded` counts spans this hook fetched for the turn, not the app spans the + * turn holds: the first page carried every span of the session's opening, so a + * turn inside it has its app spans without a fetch. `cursor` set means the + * turn has more than one page of them. + */ +export interface TurnAppSpansState { + readonly loading: boolean + readonly loaded: number + readonly cursor: AiSessionSpanCursor | undefined + readonly complete: boolean + readonly failed: boolean +} + +export interface SessionSpansState { + /** The first page — the atom's `Result`, which is what the page renders on. */ + readonly firstPage: Result.Result + /** Every span loaded so far, deduplicated, in the session's own order. */ + readonly spans: readonly AiSessionSpan[] + /** + * The session did not fit the first page. Every later page carries the + * agent's spans alone, so a turn beyond the opening shows the app's spans + * only once `loadAppSpans` fetched them. + */ + readonly partial: boolean + /** Agent spans remain past what is loaded. */ + readonly hasMore: boolean + readonly loadingMore: boolean + readonly loadMore: () => void + readonly appSpans: { + readonly of: (turn: SessionTurn) => TurnAppSpansState | undefined + readonly load: (turn: SessionTurn) => void + } +} + +/** Agent spans a page carries past the first: the same ceiling the first page has. */ +const PAGE_SIZE = 2_000 + +/** + * Slack around a turn's bounds for its app-span read. A turn past the first + * page is bounded by its AGENT spans alone, and the server span that opened + * the trace — the parent of the turn's own root — started before the first of + * them. Same figure as the session window's own padding. + */ +const APP_SPANS_PADDING_MS = 60_000 + +/** The two reads, injectable so a test can stand in fakes for both. */ +export interface SessionSpansReads { + /** The first page — an atom, so the page keeps its skeleton/retention semantics. */ + readonly firstPageAtom: (input: AiSessionSpansInput) => Atom.Atom> + /** Every page past the first. */ + readonly fetchPage: (data: AiSessionSpansInput) => Promise +} + +const warehouseReads: SessionSpansReads = { + firstPageAtom: (input) => aiSessionSpansResultAtom({ data: input }), + fetchPage: (data) => mapleRuntime.runPromise(getAiSessionSpans({ data })), +} + +const NO_APP_SPANS: TurnAppSpansState = { + loading: false, + loaded: 0, + cursor: undefined, + complete: false, + failed: false, +} + +/** What the hook has loaded past the first page, for one first-page input. */ +interface Loaded { + readonly key: string + readonly pages: ReadonlyArray + readonly appPages: ReadonlyArray + readonly appSpans: ReadonlyMap + readonly loadingMore: boolean +} + +// Shared empties, so the derived `loaded` below keeps its identities across +// renders while nothing has been fetched under the key — every memo downstream +// of `spans` is keyed on them. +const NO_PAGES: ReadonlyArray = [] +const NO_TURNS: ReadonlyMap = new Map() + +const nothingLoaded = (key: string): Loaded => ({ + key, + pages: NO_PAGES, + appPages: NO_PAGES, + appSpans: NO_TURNS, + loadingMore: false, +}) + +/** + * What a turn's app-span state is keyed by. Turn ids are derived from the + * spans in hand and a page appended later can re-derive every one of them + * (`buildSessionTurns` picks its anchor rule from the whole list); the span + * that opened the turn survives that far more often than the id does. + */ +const turnKey = (turn: SessionTurn) => turn.anchor.spanId + +/** + * A session's spans, loaded in the order the reader needs them. + * + * The first page is the session's opening, every span of it — a session that + * fits is complete after one read, which is the common case and the only one + * the page used to handle. A session that does not fit continues in pages of + * the AGENT's spans alone: the transcript is built from those, and they are a + * fraction of a large session's rows (in production a tenth, the rest being + * the app's own SQL and HTTP). The app's spans for a turn past the opening are + * fetched when the reader asks for that turn, by the turn's traces and bounds. + * + * Pages after the first live in component state rather than in atoms: they + * are appended to one growing list keyed by the first page's input, the way + * the list page's `useInfiniteAiSessions` does it. A window or session change + * drops them, because they belong to the read they extended — the key on the + * state is what says which read that was, and a response landing after the + * key moved on is discarded. + */ +export function useSessionSpans( + sessionId: string, + window: SessionWindow | undefined, + reads: SessionSpansReads = warehouseReads, +): SessionSpansState { + const input = React.useMemo(() => ({ sessionId, ...window }), [sessionId, window]) + const key = JSON.stringify(input) + const firstPage = useAtomValue(reads.firstPageAtom(input)) + + const [stored, setStored] = React.useState(() => nothingLoaded(key)) + // Derived on render rather than reset in an effect: a stale entry is simply + // not this key's, and the first write under the new key replaces it. + const loaded = stored.key === key ? stored : nothingLoaded(key) + // A change for a key that is no longer current is dropped: it belongs to a + // read the page moved on from, and writing it would wipe the current key's. + const update = React.useCallback( + (forKey: string, change: (previous: Loaded) => Loaded) => + setStored((previous) => { + const current = previous.key === forKey ? previous : key === forKey ? nothingLoaded(forKey) : undefined + return current === undefined ? previous : change(current) + }), + [key], + ) + + const firstCursor = Result.isSuccess(firstPage) ? firstPage.value.nextCursor : undefined + const lastCursor = loaded.pages.length > 0 ? loaded.pages[loaded.pages.length - 1]!.nextCursor : firstCursor + const partial = firstCursor !== undefined + const hasMore = lastCursor !== undefined + + const spans = React.useMemo(() => { + const first = Result.isSuccess(firstPage) ? firstPage.value.data : [] + return dedupeInOrder([ + ...first, + ...loaded.pages.flatMap((page) => page.data), + ...loaded.appPages.flatMap((page) => page.data), + ]) + }, [firstPage, loaded.pages, loaded.appPages]) + + const loadMore = React.useCallback(() => { + if (loaded.loadingMore || lastCursor === undefined) return + update(key, (previous) => ({ ...previous, loadingMore: true })) + reads + .fetchPage({ ...input, scope: "ai", after: lastCursor, limit: PAGE_SIZE }) + .then((page) => { + update(key, (previous) => ({ ...previous, pages: [...previous.pages, page], loadingMore: false })) + }) + .catch((error: unknown) => { + logClientError("ai_session.pagination_failed", error) + update(key, (previous) => ({ ...previous, loadingMore: false })) + }) + }, [reads, input, key, lastCursor, loaded.loadingMore, update]) + + const loadAppSpans = React.useCallback( + (turn: SessionTurn) => { + const id = turnKey(turn) + const state = loaded.appSpans.get(id) ?? NO_APP_SPANS + if (state.loading || state.complete) return + const setTurn = (next: TurnAppSpansState) => + update(key, (previous) => ({ ...previous, appSpans: new Map(previous.appSpans).set(id, next) })) + setTurn({ ...state, loading: true, failed: false }) + reads + .fetchPage({ + sessionId, + startTime: formatWarehouseDateTime(turn.startMs - APP_SPANS_PADDING_MS), + endTime: formatWarehouseDateTime(turn.endMs + APP_SPANS_PADDING_MS), + traceIds: turn.traceIds, + scope: "app", + limit: PAGE_SIZE, + ...(state.cursor !== undefined && { after: state.cursor }), + }) + .then((page) => { + update(key, (previous) => ({ + ...previous, + appPages: [...previous.appPages, page], + appSpans: new Map(previous.appSpans).set(id, { + loading: false, + loaded: state.loaded + page.data.length, + cursor: page.nextCursor, + complete: page.nextCursor === undefined, + failed: false, + }), + })) + }) + .catch((error: unknown) => { + logClientError("ai_session.app_spans_failed", error) + setTurn({ ...state, loading: false, failed: true }) + }) + }, + [reads, key, loaded.appSpans, sessionId, update], + ) + + const of = React.useCallback((turn: SessionTurn) => loaded.appSpans.get(turnKey(turn)), [loaded.appSpans]) + + return { + firstPage, + spans, + partial, + hasMore, + loadingMore: loaded.loadingMore, + loadMore, + appSpans: { of, load: loadAppSpans }, + } +} + +/** + * Later pages never repeat a span, but a turn's app-span read can: the first + * page carried the session's opening whole, so a turn straddling its end has + * some app spans twice. First occurrence wins, and the session's order — the + * page order — is kept, since every consumer sorts by start time anyway. + */ +function dedupeInOrder(spans: readonly AiSessionSpan[]): readonly AiSessionSpan[] { + const seen = new Set() + const kept: AiSessionSpan[] = [] + for (const span of spans) { + if (seen.has(span.spanId)) continue + seen.add(span.spanId) + kept.push(span) + } + return kept +} diff --git a/apps/web/src/lab/agent-session-lab.tsx b/apps/web/src/lab/agent-session-lab.tsx index 51c618055..40685e7d7 100644 --- a/apps/web/src/lab/agent-session-lab.tsx +++ b/apps/web/src/lab/agent-session-lab.tsx @@ -11,10 +11,10 @@ import { buildSessionTurns } from "@/lib/agent-sessions/session-turns" * the Overview, the waterfall, the flow graph and the transcript without a * warehouse. * - * The two toggles are the session-level states no fixture can be in and out of - * at once: message capture off (the production default, which the transcript - * has to survive as pure structure) and a truncated response (the END of the - * session missing). + * The toggle is the session-level state no fixture can be in and out of at + * once: message capture off, the production default, which the transcript has + * to survive as pure structure. A partly loaded session is not simulated here — + * it needs the paged reads, and the page is where those live. * * The page's own scroller is a `PageLayout.ScrollArea`, which is where the * views' sticky control bar pins; the plain `overflow-auto` column here stands @@ -22,7 +22,6 @@ import { buildSessionTurns } from "@/lib/agent-sessions/session-turns" */ export function AgentSessionLab({ initialView }: { initialView?: SessionView }) { const [captureOff, setCaptureOff] = useState(false) - const [truncated, setTruncated] = useState(false) const spans = useMemo( () => (captureOff ? buildCaptureOffFixture() : buildAgentSessionFixture()), @@ -54,15 +53,6 @@ export function AgentSessionLab({ initialView }: { initialView?: SessionView }) > Capture off - - Truncated -
{/* The same slot and the same classes `PageLayout.ScrollArea` carries: @@ -76,7 +66,8 @@ export function AgentSessionLab({ initialView }: { initialView?: SessionView }) onViewChange={setView} turns={turns} summary={summary} - truncated={truncated} + paging={undefined} + totals={undefined} selectedSpanId={selectedSpanId} onSelectSpan={setSelectedSpanId} /> diff --git a/apps/web/src/lib/agent-sessions/session-summary.ts b/apps/web/src/lib/agent-sessions/session-summary.ts index c8b7e870f..89336e7ba 100644 --- a/apps/web/src/lib/agent-sessions/session-summary.ts +++ b/apps/web/src/lib/agent-sessions/session-summary.ts @@ -200,8 +200,10 @@ export function buildSessionSummary({ const ordered = [...spans].sort((a, b) => spanStartMs(a) - spanStartMs(b)) const byId = new Map(ordered.map((span) => [span.spanId, span])) - const startMs = Math.min(...ordered.map(spanStartMs)) - const endMs = Math.max(...ordered.map(spanEndMs)) + // Reduced, not spread: a partly loaded session can hold tens of thousands + // of spans, past what a spread argument list survives. + const startMs = ordered.reduce((min, span) => Math.min(min, spanStartMs(span)), Number.POSITIVE_INFINITY) + const endMs = ordered.reduce((max, span) => Math.max(max, spanEndMs(span)), Number.NEGATIVE_INFINITY) const wallClockMs = endMs - startMs const idleGaps = findIdleGaps(ordered) diff --git a/apps/web/src/lib/agent-sessions/session-transcript.test.ts b/apps/web/src/lib/agent-sessions/session-transcript.test.ts index c6f9957e3..f393178f0 100644 --- a/apps/web/src/lib/agent-sessions/session-transcript.test.ts +++ b/apps/web/src/lib/agent-sessions/session-transcript.test.ts @@ -17,7 +17,7 @@ function transcript(spans: readonly AiSessionSpan[], overrides: Partial { }) // The END of the session is what truncation drops, so the divider is last. - it("closes a truncated session on a terminal divider", () => { - const rows = transcript(simple, { truncated: true }) + it("closes a partly loaded session on a terminal divider", () => { + const rows = transcript(simple, { hasMore: true }) const last = rows.at(-1) if (last?.kind !== "divider") throw new Error("expected a terminal divider") - expect(last.dividerKind).toBe("truncated") + expect(last.dividerKind).toBe("more") }) it("adds no divider to a whole session", () => { @@ -1556,7 +1556,7 @@ describe("buildTranscript — filtering", () => { durationMs: 5 * SECOND, children: [llmSpan({ spanId: "l1", parentSpanId: "agent", startMs: 0, durationMs: SECOND })], }) - expect(transcript(silent, { query: "zzz", truncated: true })).toHaveLength(0) + expect(transcript(silent, { query: "zzz", hasMore: true })).toHaveLength(0) }) }) @@ -1570,7 +1570,7 @@ describe("buildTranscript — row keys", () => { toolResults: sessionToolResults(spans), query: "", showThinking: true, - truncated: true, + hasMore: true, collapsedTurns: new Set(), }) expect(rows.length).toBeGreaterThan(20) diff --git a/apps/web/src/lib/agent-sessions/session-transcript.ts b/apps/web/src/lib/agent-sessions/session-transcript.ts index cb786995d..2844fb2d3 100644 --- a/apps/web/src/lib/agent-sessions/session-transcript.ts +++ b/apps/web/src/lib/agent-sessions/session-transcript.ts @@ -54,7 +54,7 @@ export type TranscriptNoteKind = /** The emitting service changed and so did what it records. */ | "capture-boundary" -export type TranscriptDividerKind = "compaction" | "truncated" +export type TranscriptDividerKind = "compaction" | "more" /** Which halves of a call an emitter records. Mixed sessions have several. */ export type CaptureCoverage = "both" | "input" | "output" | "none" @@ -239,8 +239,8 @@ export interface TranscriptInput { readonly query: string /** The toolbar's "Thinking" chip. */ readonly showThinking: boolean - /** `GetAiSessionSpansResponse.truncated` — the END of the session is missing. */ - readonly truncated: boolean + /** Agent spans remain past the loaded pages — the END of the session is not here yet. */ + readonly hasMore: boolean readonly collapsedTurns: ReadonlySet } @@ -327,9 +327,23 @@ export function buildTranscript(input: TranscriptInput): readonly TranscriptRow[ } } + // Pages are the session's oldest spans first, so what is missing is the END + // of the session and the divider is terminal: it says where the reading + // stops, not where the agent did. + const moreDivider: TranscriptRow = { + kind: "divider", + key: "divider:more", + depth: 0, + dividerKind: "more", + startMs: undefined, + } + // Nothing survived. The empty state says which of the two reasons it was, and - // a lone banner or a divider hanging over nothing would only muddy it. - if (body.length === 0) return [] + // a lone banner hanging over nothing would only muddy it. The exception is a + // session with more still to load and no filter in the way: "no AI activity" + // is not yet true of it — the opening was the app's own work — so the + // divider stands alone as the honest row. + if (body.length === 0) return input.hasMore && input.query === "" ? [moreDivider] : [] const rows: TranscriptRow[] = [] if (bannerUp) { @@ -344,17 +358,7 @@ export function buildTranscript(input: TranscriptInput): readonly TranscriptRow[ } rows.push(...body) - // Truncation drops the END of the session, so the divider is terminal and - // unconditional: it says where the reading stops, not where the agent did. - if (input.truncated) { - rows.push({ - kind: "divider", - key: "divider:truncated", - depth: 0, - dividerKind: "truncated", - startMs: undefined, - }) - } + if (input.hasMore) rows.push(moreDivider) return rows } diff --git a/apps/web/src/lib/agent-sessions/session-turns.ts b/apps/web/src/lib/agent-sessions/session-turns.ts index 73080ca2e..b444a83a5 100644 --- a/apps/web/src/lib/agent-sessions/session-turns.ts +++ b/apps/web/src/lib/agent-sessions/session-turns.ts @@ -11,6 +11,12 @@ // `span-tree.ts` applies inside a single trace, so a child that a skewed clock // placed outside its parent can land in a neighbouring turn. +import { + AI_AGENT_OPERATIONS, + AI_INFERENCE_OPERATIONS, + AI_RETRIEVAL_OPERATIONS, + AI_TOOL_OPERATIONS, +} from "@maple/domain/gen-ai" import type { AiSessionSpan } from "@maple/domain/http" import { toEpochMs } from "@maple/ui/lib/time-format" @@ -21,16 +27,16 @@ import { toEpochMs } from "@maple/ui/lib/time-format" */ export type AiSpanCategory = "agent" | "inference" | "tool" | "other" -// `gen_ai.operation.name` is an open set, so these group the semantic -// convention's operation names plus `agent_step`, which the Vercel AI SDK emits -// and production data carries. An unknown value falls through to the span-name -// rules below rather than being rejected. -const INFERENCE_OPS = new Set(["chat", "generate_content", "text_completion", "fetch_response"]) +// The operation vocabulary is shared with the server-side session summary +// (`@maple/domain/gen-ai`), so an "llm call" is the same span in both places. +// An unknown value falls through to the span-name rules below rather than +// being rejected. +const INFERENCE_OPS: ReadonlySet = new Set(AI_INFERENCE_OPERATIONS) /** Inference-shaped work that is not a chat completion: counted as inference * occupancy, never as an "llm call" — an embedding is not a model turn. */ -const RETRIEVAL_OPS = new Set(["embeddings", "retrieval"]) -const TOOL_OPS = new Set(["execute_tool"]) -const AGENT_OPS = new Set(["invoke_agent", "create_agent", "invoke_workflow", "plan", "agent_step"]) +const RETRIEVAL_OPS: ReadonlySet = new Set(AI_RETRIEVAL_OPERATIONS) +const TOOL_OPS: ReadonlySet = new Set(AI_TOOL_OPERATIONS) +const AGENT_OPS: ReadonlySet = new Set(AI_AGENT_OPERATIONS) export function spanStartMs(span: AiSessionSpan): number { return toEpochMs(span.timestamp) diff --git a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts index b3023b3cf..a75dd1b4a 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -113,7 +113,7 @@ import { getSessionTraceSummaries, listReplays, } from "@/api/warehouse/replays" -import { getAiSessionSpans, getAiSessionsFacets, listAiSessions } from "@/api/warehouse/ai-sessions" +import { getAiSessionSpans, getAiSessionSummary, getAiSessionsFacets, listAiSessions } from "@/api/warehouse/ai-sessions" import { getWebAnalyticsBreakdowns, getWebAnalyticsEvents, @@ -341,6 +341,10 @@ export const aiSessionSpansResultAtom = makeQueryAtomFamily(getAiSessionSpans, { staleTime: 60_000, }) +export const aiSessionSummaryResultAtom = makeQueryAtomFamily(getAiSessionSummary, { + staleTime: 60_000, +}) + export const replaysFacetsResultAtom = makeQueryAtomFamily(getReplaysFacets, { staleTime: 30_000, }) diff --git a/apps/web/src/routes/agent-sessions/$sessionId.tsx b/apps/web/src/routes/agent-sessions/$sessionId.tsx index c6fdf2436..a8fec7373 100644 --- a/apps/web/src/routes/agent-sessions/$sessionId.tsx +++ b/apps/web/src/routes/agent-sessions/$sessionId.tsx @@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, type ReactNode } from "react" import { createFileRoute, Link, useNavigate, useRouterState } from "@tanstack/react-router" import { Schema } from "effect" -import type { AiSessionSpan } from "@maple/domain/http" +import type { GetAiSessionSummaryResponse } from "@maple/domain/http" import { formatWarehouseDateTime } from "@maple/query-engine" +import { toEpochMs } from "@maple/ui/lib/time-format" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" @@ -22,6 +23,7 @@ import { type SessionView, } from "@/components/agent-sessions/session-detail/session-views" import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" +import { useSessionSpans, type SessionSpansState } from "@/hooks/use-session-spans" import { breadcrumbSessionId, buildBackToSessionsHref, @@ -33,7 +35,8 @@ import { vendorIcon } from "@/lib/agent-sessions/vendor-icon" import { vendorLabel } from "@/lib/agent-sessions/vendor-label" import { Result, useAtomValue } from "@/lib/effect-atom" import { displayError } from "@/lib/error-messages" -import { aiSessionSpansResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" +import { aiSessionSummaryResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" const agentSessionSearchSchema = Schema.Struct({ // Warehouse timestamps: the session's first and last span, carried in from @@ -80,9 +83,17 @@ function AgentSessionDetailContent() { const queryWindow = useMemo(() => resolveWindow(search.t, search.end), [search.t, search.end]) // `undefined` spreads to nothing, which is exactly the request the endpoint // reads as "resolve this session from its id". - const result = useAtomValue(aiSessionSpansResultAtom({ data: { sessionId, ...queryWindow } })) + const spansState = useSessionSpans(sessionId, queryWindow) + // The whole session's totals — only for a session that did not fit the + // first page. One that did is complete in hand, and what the page computes + // from it IS the total; the extra warehouse read would buy nothing. + const summaryResult = useAtomValue( + spansState.partial + ? aiSessionSummaryResultAtom({ data: { sessionId, ...queryWindow } }) + : disabledResultAtom(), + ) - return Result.builder(result) + return Result.builder(spansState.firstPage) .onInitial(() => ( @@ -151,7 +162,11 @@ function AgentSessionDetailContent() { ) : ( - + ), ) .render() @@ -159,13 +174,14 @@ function AgentSessionDetailContent() { function SessionDetailBody({ sessionId, - spans, - truncated, + spansState, + totals, }: { sessionId: string - spans: readonly AiSessionSpan[] - truncated: boolean + spansState: SessionSpansState + totals: GetAiSessionSummaryResponse | undefined }) { + const { spans, partial, hasMore, loadingMore, loadMore, appSpans } = spansState const turns = useMemo(() => buildSessionTurns(spans), [spans]) const summary = useMemo(() => buildSessionSummary({ spans, turns }), [spans, turns]) @@ -199,17 +215,25 @@ function SessionDetailBody({ // A session still being written gets the bounds it had at read time, exactly // as a link from the list page does — the padding `resolveWindow` adds is the // only slack either one has. + // + // For a session that did not fit the first page the bounds are the whole + // session's, from the totals read: stamping where the spans in hand end + // would make every later load of the link read a session cut short. Until + // that read answers — and if it never does — the link stays unstamped, + // which costs a resolve per load and lies to no one. + const startMs = totals?.startTime === undefined ? summary.startMs : toEpochMs(totals.startTime) + const endMs = totals?.endTime === undefined ? summary.endMs : toEpochMs(totals.endTime) useEffect(() => { - if (search.t !== undefined) return + if (search.t !== undefined || (partial && totals === undefined)) return navigate({ replace: true, search: (prev: Record) => ({ ...prev, - t: formatWarehouseDateTime(summary.startMs), - end: formatWarehouseDateTime(summary.endMs), + t: formatWarehouseDateTime(startMs), + end: formatWarehouseDateTime(endMs), }), }) - }, [navigate, search.t, summary.startMs, summary.endMs]) + }, [navigate, search.t, partial, totals, startMs, endMs]) const selectSpan = useCallback( (spanId: string | undefined) => { @@ -272,13 +296,23 @@ function SessionDetailBody({ `overflow-x-hidden` means a span that escapes its truncation can never make the whole page scroll sideways. */} - {truncated && ( + {partial && (
- - This session has more spans than one response carries — everything after - the {summary.spanCount.toLocaleString()} spans below is missing, so the - totals and the waterfall both stop early. + + + {hasMore ? "Showing the first " : "Showing "} + {summary.spanCount.toLocaleString()} + {totals === undefined ? " spans" : ` of ${totals.spanCount.toLocaleString()} spans`} + {hasMore + ? " — the agent's later spans load in pages, and a turn's app spans on demand in the Traces view." + : " — every agent span is loaded; a turn's app spans load on demand in the Traces view."} + + {hasMore && ( + + )}
@@ -294,7 +328,8 @@ function SessionDetailBody({ onViewChange={changeView} turns={turns} summary={summary} - truncated={truncated} + paging={partial ? { hasMore, loadingMore, onLoadMore: loadMore, appSpans } : undefined} + totals={partial ? totals : undefined} selectedSpanId={search.span} onSelectSpan={selectSpan} /> diff --git a/packages/domain/src/gen-ai.ts b/packages/domain/src/gen-ai.ts index a4efc47db..7cbb1c368 100644 --- a/packages/domain/src/gen-ai.ts +++ b/packages/domain/src/gen-ai.ts @@ -80,6 +80,17 @@ export const traceSessionTraceId = (sessionId: string): string | undefined => { export const MAPLE_NATIVE_SESSION_ID_ATTR = "maple_ai.session.id" /** Groups one turn's spans inside a session; lifted into `conversationId` read-side. */ export const MAPLE_NATIVE_TURN_ID_ATTR = "maple_ai.turn.id" + +// `gen_ai.operation.name` is an open set. These group the semantic convention's +// operation names — plus `agent_step`, which the Vercel AI SDK emits and +// production data carries — into the four readings the product distinguishes. +// Shared between the session summary query and the web's span classifier so an +// "llm call" is the same span on the server and on the page. +export const AI_INFERENCE_OPERATIONS = ["chat", "generate_content", "text_completion", "fetch_response"] as const +/** Inference-shaped work that is not a model turn: an embedding is never an "llm call". */ +export const AI_RETRIEVAL_OPERATIONS = ["embeddings", "retrieval"] as const +export const AI_TOOL_OPERATIONS = ["execute_tool"] as const +export const AI_AGENT_OPERATIONS = ["invoke_agent", "create_agent", "invoke_workflow", "plan", "agent_step"] as const /** * Count of whole oldest messages dropped from `gen_ai.input.messages` to fit * the emitter's attribute budget. Write-only diagnostics: nothing decodes it, diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index a2225fb7c..d9bc6085d 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -82,9 +82,40 @@ export class ListAiSessionsFacetsResponse extends Schema.Class + +/** + * Keyset position in a session's span order (`timestamp`, then `spanId`). Both + * values are copied from the last span of the previous page: the timestamp is + * the warehouse literal at nanosecond precision, which is what makes the pair + * unique — agent spans routinely share a millisecond. + */ +export const AiSessionSpanCursor = Schema.Struct({ + timestamp: TinybirdDateTime, + spanId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64)), +}) +export type AiSessionSpanCursor = Schema.Schema.Type + +const TraceIdHex = Schema.String.check(Schema.isPattern(/^[0-9a-f]{32}$/)) + +/** Traces one span read may be pinned to — a turn's worth, not a session's. */ +export const AI_SESSION_SPANS_MAX_TRACE_IDS = 100 + +/** + * Row ceiling for one page of a session's spans. The handler asks the query for + * one row past it, so an exactly-full page is distinguishable from one with a + * page after it. + */ +export const AI_SESSION_SPANS_MAX_SPANS = 2_000 + export class GetAiSessionSpansRequest extends Schema.Class( "GetAiSessionSpansRequest", -)({ +)( + Schema.Struct({ /** * The framework's own session id, verbatim — `maple_ai.session.id` — or the * `trace:` id Maple synthesizes for a GenAI trace that carries none @@ -110,6 +141,125 @@ export class GetAiSessionSpansRequest extends Schema.Class + request.traceIds === undefined || + (request.startTime !== undefined && request.endTime !== undefined) || + "traceIds requires startTime and endTime", + { identifier: "TraceIdsNeedWindow" }, + ), + ), +) {} + +export class GetAiSessionSummaryRequest extends Schema.Class( + "GetAiSessionSummaryRequest", +)({ + /** As on `GetAiSessionSpansRequest`, including the `trace:` form. */ + sessionId: Schema.String.check(Schema.isMinLength(1)), + /** As on `GetAiSessionSpansRequest`: both or neither. */ + startTime: Schema.optionalKey(TinybirdDateTime), + endTime: Schema.optionalKey(TinybirdDateTime), +}) {} + +export const AiSessionTokenTotals = Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cacheRead: Schema.Number, +}) +export type AiSessionTokenTotals = Schema.Schema.Type + +/** + * How a session's usage was reported, which decides which spans' figures the + * totals sum. `per-call`: the model-call spans carry usage, and the totals are + * theirs alone — an agent span that also carries a roll-up of its children is + * not added on top. `roll-up`: no model-call span reported anything, so the + * totals are what the wrapping spans reported. `none`: nothing did. + */ +export const AiSessionTokenReporting = Schema.Literals(["per-call", "roll-up", "none"]) +export type AiSessionTokenReporting = Schema.Schema.Type + +/** + * One turn of a session as the warehouse groups it: by `gen_ai.conversation.id` + * (and the vendor spellings of it), falling back to the trace. + * + * The grouping sees one span at a time. A span that carries the id is the + * turn's; a child that does not — a model call under a turn span that alone + * was stamped — lands in its trace's row instead. The page's own turn model + * walks parents and so places those children; these rows therefore sum to the + * session exactly, but their count and their per-turn split are only as good + * as the emitter's stamping. Session totals in the response are exact. + */ +export const AiSessionTurnSummary = Schema.Struct({ + turnKey: Schema.String, + /** Empty when the turn is a trace with no conversation id. */ + conversationId: Schema.String, + traceIds: Schema.Array(Schema.String), + /** Warehouse datetime literals, like the list row's. */ + startTime: Schema.String, + endTime: Schema.String, + durationMs: Schema.Number, + spanCount: Schema.Number, + aiSpanCount: Schema.Number, + llmCalls: Schema.Number, + toolCalls: Schema.Number, + errorSpanCount: Schema.Number, + tokens: AiSessionTokenTotals, + /** Absent when no span of the turn reported a cost. */ + cost: Schema.optionalKey(Schema.Number), + models: Schema.Array(Schema.String), + agentNames: Schema.Array(Schema.String), +}) +export type AiSessionTurnSummary = Schema.Schema.Type + +/** Turn rows one summary carries. A session grouping into more is summarised + * from its first rows alone and says so with `turnsTruncated`. */ +export const AI_SESSION_SUMMARY_MAX_TURNS = 1_000 + +/** + * The whole session's totals, computed in the warehouse — so they hold for a + * session far larger than one spans response, which is the reason this exists. + */ +export class GetAiSessionSummaryResponse extends Schema.Class( + "GetAiSessionSummaryResponse", +)({ + spanCount: Schema.Number, + aiSpanCount: Schema.Number, + traceCount: Schema.Number, + /** Absent for an unknown session — one with no spans. */ + startTime: Schema.optionalKey(Schema.String), + endTime: Schema.optionalKey(Schema.String), + durationMs: Schema.Number, + llmCalls: Schema.Number, + toolCalls: Schema.Number, + errorSpanCount: Schema.Number, + tokens: AiSessionTokenTotals, + tokenReporting: AiSessionTokenReporting, + cost: Schema.optionalKey(Schema.Number), + models: Schema.Array(Schema.String), + agentNames: Schema.Array(Schema.String), + turns: Schema.Array(AiSessionTurnSummary), + turnsTruncated: Schema.Boolean, }) {} /** @@ -133,19 +283,14 @@ export class GetAiSessionSpansResponse extends Schema.Class()( "@maple/http/ai-sessions/AiSessionTooLargeError", @@ -185,7 +330,7 @@ export class AiSessionTooLargeError extends HttpTaggedError (k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'gen_ai.operation.name', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model', 'gen_ai.request.max_tokens', 'gen_ai.request.choice.count', 'gen_ai.request.temperature', 'gen_ai.request.top_p', 'gen_ai.request.top_k', 'gen_ai.request.stop_sequences', 'gen_ai.request.frequency_penalty', 'gen_ai.request.presence_penalty', 'gen_ai.request.encoding_formats', 'gen_ai.request.seed', 'gen_ai.openai.request.seed', 'gen_ai.request.stream', 'gen_ai.request.reasoning.level', 'gen_ai.request.previous_response.id', 'gen_ai.request.stream_cursor', 'gen_ai.response.id', 'gen_ai.response.model', 'gen_ai.response.finish_reasons', 'gen_ai.response.finish_reason', 'gen_ai.response.status', 'gen_ai.response.time_to_first_chunk', 'gen_ai.output.type', 'gen_ai.usage.input_tokens', 'gen_ai.usage.prompt_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.input_tokens.cached', 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_write.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.completion_tokens', 'gen_ai.usage.reasoning.output_tokens', 'gen_ai.usage.output_tokens.reasoning', 'gen_ai.usage.cost', 'gen_ai.usage.total_cost', 'gen_ai.conversation.id', 'gen_ai.conversation.compacted', 'gen_ai.agent.id', 'gen_ai.agent.name', 'gen_ai.agent.description', 'gen_ai.agent.version', 'gen_ai.tool.name', 'gen_ai.tool.call.id', 'gen_ai.tool.description', 'gen_ai.tool.type', 'gen_ai.tool.call.arguments', 'gen_ai.tool.call.result', 'gen_ai.tool.definitions', 'gen_ai.system_instructions', 'gen_ai.input.messages', 'gen_ai.prompt', 'gen_ai.output.messages', 'gen_ai.completion', 'gen_ai.data_source.id', 'gen_ai.retrieval.query.text', 'gen_ai.retrieval.top_k', 'gen_ai.retrieval.documents', 'gen_ai.memory.store.id', 'gen_ai.memory.record.id', 'gen_ai.memory.record.count', 'gen_ai.memory.query.text', 'gen_ai.memory.records', 'gen_ai.embeddings.dimension.count', 'gen_ai.evaluation.name', 'gen_ai.evaluation.score.value', 'gen_ai.evaluation.score.label', 'gen_ai.evaluation.explanation', 'gen_ai.prompt.name', 'gen_ai.prompt.version', 'gen_ai.workflow.name', 'error.type', 'server.address', 'server.port', 'ai.model.provider', 'ai.model.id', 'ai.response.id', 'ai.response.model', 'ai.response.finishReason', 'gen_ai.client.operation.time_to_first_chunk', 'ai.usage.inputTokens', 'ai.usage.promptTokens', 'ai.usage.cachedInputTokens', 'ai.usage.inputTokenDetails.cacheReadTokens', 'ai.usage.inputTokenDetails.cacheWriteTokens', 'ai.usage.outputTokens', 'ai.usage.completionTokens', 'ai.usage.reasoningTokens', 'ai.usage.outputTokenDetails.reasoningTokens', 'ai.telemetry.functionId', 'ai.toolCall.name', 'ai.toolCall.id', 'ai.toolCall.args', 'ai.toolCall.result', 'ai.prompt.tools', 'ai.prompt.messages', 'ai.prompt', 'llm.provider', 'llm.system', 'llm.model_name', 'llm.token_count.prompt', 'llm.token_count.prompt_details.cache_read', 'llm.token_count.completion', 'llm.token_count.completion_details.reasoning', 'llm.cost.total', 'tool.name', 'tool.description', 'llm.tools', 'llm.input_messages', 'input.value', 'llm.output_messages', 'output.value', 'openinference.span.kind', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%'), SpanAttributes) AS spanAttributes + FROM trace_detail_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND TraceId IN (SELECT + TraceId AS TraceId + FROM traces + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND (mapContains(SpanAttributes, 'maple_ai.session.id') AND SpanAttributes['maple_ai.session.id'] != '') + AND SpanAttributes['maple_ai.session.id'] = 'wrun_sql_catalog') + AND SpanAttributes['maple_ai.vendor.id'] != '' + AND (Timestamp > '2026-01-01 10:30:00.123456789' OR (Timestamp = '2026-01-01 10:30:00.123456789' AND SpanId > '00000000000007d0')) + ORDER BY timestamp ASC, spanId ASC + LIMIT 2000 + FORMAT JSON + -- builder:ai-sessions:aiSessionSpansQuery:default SELECT TraceId AS traceId, @@ -146,6 +177,83 @@ SELECT LIMIT 2000 FORMAT JSON +-- builder:ai-sessions:aiSessionSummaryQuery:default +SELECT + if(coalesce(nullIf(SpanAttributes['gen_ai.conversation.id'], ''), nullIf(SpanAttributes['eve.turn.id'], ''), nullIf(SpanAttributes['maple_ai.turn.id'], ''), '') != '', coalesce(nullIf(SpanAttributes['gen_ai.conversation.id'], ''), nullIf(SpanAttributes['eve.turn.id'], ''), nullIf(SpanAttributes['maple_ai.turn.id'], ''), ''), TraceId) AS turnKey, + max(coalesce(nullIf(SpanAttributes['gen_ai.conversation.id'], ''), nullIf(SpanAttributes['eve.turn.id'], ''), nullIf(SpanAttributes['maple_ai.turn.id'], ''), '')) AS conversationId, + groupUniqArray(TraceId) AS traceIds, + toString(min(Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, + count() AS spanCount, + countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, + countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, + countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + FROM trace_detail_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND TraceId IN (SELECT + TraceId AS TraceId + FROM traces + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND (mapContains(SpanAttributes, 'maple_ai.session.id') AND SpanAttributes['maple_ai.session.id'] != '') + AND SpanAttributes['maple_ai.session.id'] = 'wrun_sql_catalog') + GROUP BY turnKey + ORDER BY startTime ASC + LIMIT 1001 + FORMAT JSON + +-- builder:ai-sessions:aiSessionTotalsQuery:default +SELECT + uniqExact(TraceId) AS traceCount, + toString(min(Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, + count() AS spanCount, + countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, + countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, + countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + FROM trace_detail_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND TraceId IN (SELECT + TraceId AS TraceId + FROM traces + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND (mapContains(SpanAttributes, 'maple_ai.session.id') AND SpanAttributes['maple_ai.session.id'] != '') + AND SpanAttributes['maple_ai.session.id'] = 'wrun_sql_catalog') + FORMAT JSON + -- builder:ai-sessions:aiSessionWindowQuery:default SELECT toString(min(Timestamp) - INTERVAL 86400 SECOND) AS startTime, @@ -179,6 +287,92 @@ SELECT LIMIT 2000 FORMAT JSON +-- builder:ai-sessions:aiTraceSpansQuery:traces-app-scope +SELECT + TraceId AS traceId, + SpanId AS spanId, + ParentSpanId AS parentSpanId, + SpanName AS spanName, + SpanKind AS spanKind, + ServiceName AS serviceName, + Duration / 1000000 AS durationMs, + StatusCode AS statusCode, + StatusMessage AS statusMessage, + toString(Timestamp) AS timestamp, + mapFilter((k, v) -> (k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'gen_ai.operation.name', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model', 'gen_ai.request.max_tokens', 'gen_ai.request.choice.count', 'gen_ai.request.temperature', 'gen_ai.request.top_p', 'gen_ai.request.top_k', 'gen_ai.request.stop_sequences', 'gen_ai.request.frequency_penalty', 'gen_ai.request.presence_penalty', 'gen_ai.request.encoding_formats', 'gen_ai.request.seed', 'gen_ai.openai.request.seed', 'gen_ai.request.stream', 'gen_ai.request.reasoning.level', 'gen_ai.request.previous_response.id', 'gen_ai.request.stream_cursor', 'gen_ai.response.id', 'gen_ai.response.model', 'gen_ai.response.finish_reasons', 'gen_ai.response.finish_reason', 'gen_ai.response.status', 'gen_ai.response.time_to_first_chunk', 'gen_ai.output.type', 'gen_ai.usage.input_tokens', 'gen_ai.usage.prompt_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.input_tokens.cached', 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_write.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.completion_tokens', 'gen_ai.usage.reasoning.output_tokens', 'gen_ai.usage.output_tokens.reasoning', 'gen_ai.usage.cost', 'gen_ai.usage.total_cost', 'gen_ai.conversation.id', 'gen_ai.conversation.compacted', 'gen_ai.agent.id', 'gen_ai.agent.name', 'gen_ai.agent.description', 'gen_ai.agent.version', 'gen_ai.tool.name', 'gen_ai.tool.call.id', 'gen_ai.tool.description', 'gen_ai.tool.type', 'gen_ai.tool.call.arguments', 'gen_ai.tool.call.result', 'gen_ai.tool.definitions', 'gen_ai.system_instructions', 'gen_ai.input.messages', 'gen_ai.prompt', 'gen_ai.output.messages', 'gen_ai.completion', 'gen_ai.data_source.id', 'gen_ai.retrieval.query.text', 'gen_ai.retrieval.top_k', 'gen_ai.retrieval.documents', 'gen_ai.memory.store.id', 'gen_ai.memory.record.id', 'gen_ai.memory.record.count', 'gen_ai.memory.query.text', 'gen_ai.memory.records', 'gen_ai.embeddings.dimension.count', 'gen_ai.evaluation.name', 'gen_ai.evaluation.score.value', 'gen_ai.evaluation.score.label', 'gen_ai.evaluation.explanation', 'gen_ai.prompt.name', 'gen_ai.prompt.version', 'gen_ai.workflow.name', 'error.type', 'server.address', 'server.port', 'ai.model.provider', 'ai.model.id', 'ai.response.id', 'ai.response.model', 'ai.response.finishReason', 'gen_ai.client.operation.time_to_first_chunk', 'ai.usage.inputTokens', 'ai.usage.promptTokens', 'ai.usage.cachedInputTokens', 'ai.usage.inputTokenDetails.cacheReadTokens', 'ai.usage.inputTokenDetails.cacheWriteTokens', 'ai.usage.outputTokens', 'ai.usage.completionTokens', 'ai.usage.reasoningTokens', 'ai.usage.outputTokenDetails.reasoningTokens', 'ai.telemetry.functionId', 'ai.toolCall.name', 'ai.toolCall.id', 'ai.toolCall.args', 'ai.toolCall.result', 'ai.prompt.tools', 'ai.prompt.messages', 'ai.prompt', 'llm.provider', 'llm.system', 'llm.model_name', 'llm.token_count.prompt', 'llm.token_count.prompt_details.cache_read', 'llm.token_count.completion', 'llm.token_count.completion_details.reasoning', 'llm.cost.total', 'tool.name', 'tool.description', 'llm.tools', 'llm.input_messages', 'input.value', 'llm.output_messages', 'output.value', 'openinference.span.kind', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%'), SpanAttributes) AS spanAttributes + FROM trace_detail_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND TraceId IN ('7f3a4b5c6d7e8f901234567890abcdef', '0123456789abcdef0123456789abcdef') + AND SpanAttributes['maple_ai.vendor.id'] = '' + ORDER BY timestamp ASC, spanId ASC + LIMIT 2000 + FORMAT JSON + +-- builder:ai-sessions:aiTraceSummaryQuery:default +SELECT + if(coalesce(nullIf(SpanAttributes['gen_ai.conversation.id'], ''), nullIf(SpanAttributes['eve.turn.id'], ''), nullIf(SpanAttributes['maple_ai.turn.id'], ''), '') != '', coalesce(nullIf(SpanAttributes['gen_ai.conversation.id'], ''), nullIf(SpanAttributes['eve.turn.id'], ''), nullIf(SpanAttributes['maple_ai.turn.id'], ''), ''), TraceId) AS turnKey, + max(coalesce(nullIf(SpanAttributes['gen_ai.conversation.id'], ''), nullIf(SpanAttributes['eve.turn.id'], ''), nullIf(SpanAttributes['maple_ai.turn.id'], ''), '')) AS conversationId, + groupUniqArray(TraceId) AS traceIds, + toString(min(Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, + count() AS spanCount, + countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, + countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, + countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + FROM trace_detail_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND TraceId = '7f3a4b5c6d7e8f901234567890abcdef' + GROUP BY turnKey + ORDER BY startTime ASC + LIMIT 1001 + FORMAT JSON + +-- builder:ai-sessions:aiTraceTotalsQuery:default +SELECT + uniqExact(TraceId) AS traceCount, + toString(min(Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, + count() AS spanCount, + countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, + countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, + countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, + countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, + groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + FROM trace_detail_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND TraceId = '7f3a4b5c6d7e8f901234567890abcdef' + FORMAT JSON + -- builder:ai-sessions:aiTraceWindowQuery:default SELECT toString(min(Timestamp) - INTERVAL 86400 SECOND) AS startTime, diff --git a/packages/query-engine-integrations/src/ai/ai-integrations.ts b/packages/query-engine-integrations/src/ai/ai-integrations.ts index cb75d6dda..4bc7f341a 100644 --- a/packages/query-engine-integrations/src/ai/ai-integrations.ts +++ b/packages/query-engine-integrations/src/ai/ai-integrations.ts @@ -265,6 +265,17 @@ export const aiSpanAttributeKeys: readonly string[] = [ ]), ] +/** + * Every source key any integration reads for one field, the canonical spelling + * first — what a warehouse aggregation coalesces over to read the field the + * way `mapAiSpan` would, whichever vendor stamped the span. + */ +export const aiFieldSourceKeys = (field: AiGenAiField): readonly string[] => [ + ...new Set( + [genAiIntegration, ...resolvedIntegrations.values()].flatMap((integration) => integration.sources[field]), + ), +] + /** The integration for a vendor stamp, or the default for a stamp with no entry. */ export const resolveAiIntegration = (vendorId: string | undefined): ResolvedAiIntegration => { const resolved = vendorId === undefined ? undefined : resolvedIntegrations.get(vendorId) diff --git a/packages/query-engine-integrations/src/ai/ai-sessions.test.ts b/packages/query-engine-integrations/src/ai/ai-sessions.test.ts index 9d614fd9a..6d5f8d2e8 100644 --- a/packages/query-engine-integrations/src/ai/ai-sessions.test.ts +++ b/packages/query-engine-integrations/src/ai/ai-sessions.test.ts @@ -6,8 +6,13 @@ import { aiSessionListQuery, aiSessionSpansQuery, aiSessionSpansRowSchema, + aiSessionSummaryQuery, + aiSessionSummaryRowSchema, + aiSessionTotalsQuery, aiSessionWindowQuery, aiTraceSpansQuery, + aiTraceSummaryQuery, + aiTraceTotalsQuery, aiTraceWindowQuery, } from "./ai-sessions" @@ -597,3 +602,152 @@ describe("aiTraceSpansQuery", () => { expect(row?.spanAttributes).toEqual({ "maple_ai.vendor.id": "llamaindex" }) }) }) + +describe("aiSessionSpansQuery — scope and cursor", () => { + it("keeps the agent spans alone under the ai scope, and the app's alone under app", () => { + const ai = compileUnsafe(aiSessionSpansQuery({ scope: "ai" }), spanParams).sql + const app = compileUnsafe(aiSessionSpansQuery({ scope: "app" }), spanParams).sql + const all = compileUnsafe(aiSessionSpansQuery(), spanParams).sql + + expect(ai).toContain("AND SpanAttributes['maple_ai.vendor.id'] != ''") + expect(app).toContain("AND SpanAttributes['maple_ai.vendor.id'] = ''") + expect(all).not.toContain("AND SpanAttributes['maple_ai.vendor.id']") + }) + + it("resumes strictly after the cursor in the page order", () => { + const { sql } = compileUnsafe( + aiSessionSpansQuery({ after: { timestamp: "2026-08-19 10:00:00.123456789", spanId: "aa11" } }), + spanParams, + ) + + // Same pair, same direction as the ORDER BY — the tuple comparison + // spelled out, since the tie-breaker only applies at an equal timestamp. + expect(sql).toContain( + "(Timestamp > '2026-08-19 10:00:00.123456789' OR (Timestamp = '2026-08-19 10:00:00.123456789' AND SpanId > 'aa11'))", + ) + expect(sql).toContain("ORDER BY timestamp ASC, spanId ASC") + }) + + it("escapes a cursor span id carrying a quote", () => { + const { sql } = compileUnsafe( + aiSessionSpansQuery({ after: { timestamp: "2026-08-19 10:00:00.000000000", spanId: "a'b" } }), + spanParams, + ) + expect(sql).toContain("SpanId > 'a\\'b'") + }) +}) + +describe("aiTraceSpansQuery — a turn's traces", () => { + it("reads the named traces and nothing else, with no detection level", () => { + const { sql } = compileUnsafe( + aiTraceSpansQuery({ traceIds: [TRACE_ID, "0123456789abcdef0123456789abcdef"], scope: "app" }), + { orgId: "org_1", startTime: "2026-08-18 00:00:00", endTime: "2026-08-19 23:59:59" }, + ) + + expect(sql).toContain(`TraceId IN ('${TRACE_ID}', '0123456789abcdef0123456789abcdef')`) + expect(sql).not.toContain("__PARAM_") + expect(sql).not.toContain("FROM traces") + expect(sql).toContain("SpanAttributes['maple_ai.vendor.id'] = ''") + }) +}) + +describe("aiSessionSummaryQuery", () => { + const summaryParams = { ...spanParams } + + it("groups the session's spans by conversation id, falling back to the trace", () => { + const { sql } = compileUnsafe(aiSessionSummaryQuery(), summaryParams) + + expect(sql).toContain("FROM trace_detail_spans") + expect(sql).toContain("TraceId IN (SELECT") + expect(sql).toContain(`SpanAttributes['maple_ai.session.id'] = 'wrun_01M0CSAEW96BH2W9185XZPRPKH'`) + expect(sql).toContain("GROUP BY turnKey") + expect(sql).toContain("ORDER BY startTime ASC") + expect(sql).toContain("LIMIT 1001") + // The turn ids the refine hooks lift into the field are read alongside it. + expect(sql).toContain( + "coalesce(nullIf(SpanAttributes['gen_ai.conversation.id'], ''), nullIf(SpanAttributes['eve.turn.id'], ''), nullIf(SpanAttributes['maple_ai.turn.id'], ''), '')", + ) + }) + + it("reads usage across every vendor spelling, per call and in total", () => { + const { sql } = compileUnsafe(aiSessionSummaryQuery(), summaryParams) + + for (const key of ["gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "ai.usage.inputTokens", "llm.token_count.prompt"]) { + expect(sql, key).toContain(`SpanAttributes['${key}']`) + } + expect(sql).toContain("AS inputTokens") + expect(sql).toContain("AS llmInputTokens") + expect(sql).toContain("IN ('chat', 'generate_content', 'text_completion', 'fetch_response')") + expect(sql).toContain("NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent'") + }) + + it("is org-scoped on both levels", () => { + const { sql } = compileUnsafe(aiSessionSummaryQuery(), summaryParams) + expect(orgPredicateCount(sql)).toBe(2) + }) + + it("keys a trace session on the trace, with the same projection", () => { + const session = compileUnsafe(aiSessionSummaryQuery(), summaryParams).sql + const trace = compileUnsafe(aiTraceSummaryQuery(), traceParams).sql + + expect(trace).toContain(`TraceId = '${TRACE_ID}'`) + expect(trace).not.toContain("FROM traces") + expect(trace.split("FROM trace_detail_spans")[0]).toBe(session.split("FROM trace_detail_spans")[0]) + expect(orgPredicateCount(trace)).toBe(1) + }) + + it("guards every usage sum against a non-finite attribute", () => { + const { sql } = compileUnsafe(aiSessionSummaryQuery(), summaryParams) + for (const alias of ["inputTokens", "llmInputTokens", "cost", "llmCost"]) { + expect(sql, alias).toMatch(new RegExp(`ifNotFinite\\(sum(If)?\\(toFloat64OrZero\\([^\\n]*, 0\\) AS ${alias},`)) + } + }) + + it("reads the whole session's measures ungrouped, under the same detection", () => { + const totals = compileUnsafe(aiSessionTotalsQuery(), summaryParams).sql + const trace = compileUnsafe(aiTraceTotalsQuery(), traceParams).sql + + expect(totals).toContain("uniqExact(TraceId) AS traceCount") + expect(totals).not.toContain("GROUP BY") + expect(totals).not.toContain("turnKey") + expect(totals).toContain("AS llmInputTokens") + expect(totals).toContain(`SpanAttributes['maple_ai.session.id'] = 'wrun_01M0CSAEW96BH2W9185XZPRPKH'`) + expect(orgPredicateCount(totals)).toBe(2) + expect(trace).toContain(`TraceId = '${TRACE_ID}'`) + expect(orgPredicateCount(trace)).toBe(1) + }) + + it("decodes the quoted 64-bit aggregates and the arrays", () => { + const compiled = compileUnsafe(aiSessionSummaryQuery(), summaryParams, { + rowSchema: aiSessionSummaryRowSchema, + }) + const [row] = decodeRows(compiled, [ + { + turnKey: "turn_0", + conversationId: "turn_0", + traceIds: ["6b0c0e0a"], + startTime: "2026-08-19 10:33:25.825000000", + endTime: "2026-08-19 10:33:26.825000000", + durationMs: "1000", + spanCount: "12", + aiSpanCount: "4", + llmCalls: "2", + toolCalls: "1", + errorSpanCount: "0", + inputTokens: "300", + outputTokens: "40", + cacheReadTokens: "0", + llmInputTokens: "300", + llmOutputTokens: "40", + llmCacheReadTokens: "0", + costReporters: "2", + cost: 0.0123, + llmCost: 0.0123, + models: ["gpt-5"], + agentNames: [], + }, + ]) + + expect(row).toMatchObject({ spanCount: 12, durationMs: 1000, inputTokens: 300, cost: 0.0123, models: ["gpt-5"] }) + }) +}) diff --git a/packages/query-engine-integrations/src/ai/ai-sessions.ts b/packages/query-engine-integrations/src/ai/ai-sessions.ts index 01fc28d4d..72dad3126 100644 --- a/packages/query-engine-integrations/src/ai/ai-sessions.ts +++ b/packages/query-engine-integrations/src/ai/ai-sessions.ts @@ -100,15 +100,25 @@ import { } from "@maple-dev/clickhouse-builder" import { AiTraceIndex, TraceDetailSpans, Traces } from "@maple/query-engine/ch/tables" import { CHNumber } from "@maple/query-engine/ch/schema" -import { AI_SESSION_SPANS_MAX_SPANS } from "@maple/domain/http" import { + AI_SESSION_SPANS_MAX_SPANS, + AI_SESSION_SUMMARY_MAX_TURNS, + type AiSessionSpanScope, +} from "@maple/domain/http" +import { + AI_AGENT_OPERATIONS, + AI_INFERENCE_OPERATIONS, AI_PROMPT_VARIABLE_PREFIX, + AI_RETRIEVAL_OPERATIONS, + AI_TOOL_OPERATIONS, MAPLE_AI_SESSION_ID_ATTR, MAPLE_AI_TRACE_SESSION_PREFIX, MAPLE_AI_VENDOR_ID_ATTR, MAPLE_AI_VENDOR_VERSION_ATTR, + MAPLE_NATIVE_TURN_ID_ATTR, + type AiGenAiField, } from "@maple/domain/gen-ai" -import { aiSpanAttributeKeys } from "./ai-integrations" +import { aiFieldSourceKeys, aiSpanAttributeKeys } from "./ai-integrations" const SESSION_ID_ATTR = MAPLE_AI_SESSION_ID_ATTR const VENDOR_ID_ATTR = MAPLE_AI_VENDOR_ID_ATTR @@ -512,6 +522,18 @@ export function aiTraceWindowQuery() { export interface AiSessionSpansOpts { readonly limit?: number + /** `all` when absent. See `AiSessionSpanScope` in `@maple/domain/http`. */ + readonly scope?: AiSessionSpanScope + /** Spans strictly after this `(timestamp, spanId)` position — the previous page's last row. */ + readonly after?: { readonly timestamp: string; readonly spanId: string } +} + +export interface AiTraceSpansOpts extends AiSessionSpansOpts { + /** + * Read these traces rather than the one `traceId` param names. For the + * per-turn read the detail page makes: the turn already knows its traces. + */ + readonly traceIds?: readonly string[] } export interface AiSessionSpansOutput { @@ -603,20 +625,7 @@ const spanProjection = ($: ColumnAccessor) => ( */ export function aiSessionSpansQuery(opts: AiSessionSpansOpts = {}) { const limit = opts.limit ?? AI_SESSION_SPANS_MAX_SPANS - - const sessionTraceIds = from(Traces) - .select(($) => ({ TraceId: $.TraceId })) - .where(($) => [ - $.OrgId.eq(param.string("orgId")), - $.Timestamp.gte(param.dateTimeString("startTime")), - $.Timestamp.lte(param.dateTimeString("endTime")), - // The presence guard is what stops an empty `sessionId` param from - // matching every span that simply LACKS the key — ClickHouse reads a - // missing Map key back as `''`, so equality alone would turn a blank - // session id into a whole-org trace dump. - hasSessionId($.SpanAttributes, $.SpanAttributes.get(SESSION_ID_ATTR)), - $.SpanAttributes.get(SESSION_ID_ATTR).eq(param.string("sessionId")), - ]) + const sessionTraceIds = sessionTraceIdsSubquery() return ( from(TraceDetailSpans) @@ -626,17 +635,56 @@ export function aiSessionSpansQuery(opts: AiSessionSpansOpts = {}) { $.Timestamp.gte(param.dateTimeString("startTime")), $.Timestamp.lte(param.dateTimeString("endTime")), inSubquery($.TraceId, sessionTraceIds), + scopePredicate($, opts.scope), + opts.after === undefined ? undefined : afterCursor($, opts.after), ]) // `spanId` breaks ties: agent spans routinely share a millisecond, and // without it the LIMIT cuts an arbitrary subset, so two loads of the same - // truncated session can disagree and a parent can survive while its - // children are dropped. + // page can disagree and a parent can survive while its children are + // dropped. The same pair is the keyset the next page resumes from. .orderBy(["timestamp", "asc"], ["spanId", "asc"]) .limit(limit) .format("JSON") ) } +type SpanColumns = ColumnAccessor + +/** The traces carrying the session id, within the window — the detection half + * of every session-keyed read. */ +const sessionTraceIdsSubquery = () => + from(Traces) + .select(($) => ({ TraceId: $.TraceId })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTimeString("startTime")), + $.Timestamp.lte(param.dateTimeString("endTime")), + // The presence guard is what stops an empty `sessionId` param from + // matching every span that simply LACKS the key — ClickHouse reads a + // missing Map key back as `''`, so equality alone would turn a blank + // session id into a whole-org trace dump. + hasSessionId($.SpanAttributes, $.SpanAttributes.get(SESSION_ID_ATTR)), + $.SpanAttributes.get(SESSION_ID_ATTR).eq(param.string("sessionId")), + ]) + +/** The vendor stamp is on every span the gateway classified as GenAI and on no + * other, so it is what separates the agent's spans from the app's own. */ +const scopePredicate = ($: SpanColumns, scope: AiSessionSpanScope | undefined) => + scope === "ai" + ? $.SpanAttributes.get(VENDOR_ID_ATTR).neq("") + : scope === "app" + ? $.SpanAttributes.get(VENDOR_ID_ATTR).eq("") + : undefined + +/** + * Strictly after the previous page's last row, in the order the pages are read. + * The timestamp literal carries the row's own nanoseconds (`toString(Timestamp)` + * is what the page returned), so the comparison is exact rather than a + * millisecond bucket that would re-read or skip the boundary's neighbours. + */ +const afterCursor = ($: SpanColumns, after: { readonly timestamp: string; readonly spanId: string }) => + $.Timestamp.gt(after.timestamp).or($.Timestamp.eq(after.timestamp).and($.SpanId.gt(after.spanId))) + /** * Every span of ONE trace, oldest first — the spans of a `trace:` session. * @@ -650,7 +698,7 @@ export function aiSessionSpansQuery(opts: AiSessionSpansOpts = {}) { * sort key, the `Timestamp` predicate prunes partitions, and only both together * keep this off every partition the table retains. */ -export function aiTraceSpansQuery(opts: AiSessionSpansOpts = {}) { +export function aiTraceSpansQuery(opts: AiTraceSpansOpts = {}) { const limit = opts.limit ?? AI_SESSION_SPANS_MAX_SPANS return from(TraceDetailSpans) @@ -659,9 +707,289 @@ export function aiTraceSpansQuery(opts: AiSessionSpansOpts = {}) { $.OrgId.eq(param.string("orgId")), $.Timestamp.gte(param.dateTimeString("startTime")), $.Timestamp.lte(param.dateTimeString("endTime")), - $.TraceId.eq(param.string("traceId")), + opts.traceIds === undefined + ? $.TraceId.eq(param.string("traceId")) + : CH.inList($.TraceId, opts.traceIds), + scopePredicate($, opts.scope), + opts.after === undefined ? undefined : afterCursor($, opts.after), ]) .orderBy(["timestamp", "asc"], ["spanId", "asc"]) .limit(limit) .format("JSON") } + +// --------------------------------------------------------------------------- +// Session summary — the whole session's totals, computed where the spans are +// --------------------------------------------------------------------------- + +/** The whole session, in one row — every field of a turn row that is not the turn's own key. */ +export interface AiSessionTotalsOutput { + readonly traceCount: number + readonly startTime: string + readonly endTime: string + readonly durationMs: number + readonly spanCount: number + readonly aiSpanCount: number + readonly llmCalls: number + readonly toolCalls: number + readonly errorSpanCount: number + readonly inputTokens: number + readonly outputTokens: number + readonly cacheReadTokens: number + readonly llmInputTokens: number + readonly llmOutputTokens: number + readonly llmCacheReadTokens: number + readonly costReporters: number + readonly cost: number + readonly llmCost: number + readonly models: readonly string[] + readonly agentNames: readonly string[] +} + +export interface AiSessionSummaryOutput { + readonly turnKey: string + readonly conversationId: string + readonly traceIds: readonly string[] + readonly startTime: string + readonly endTime: string + readonly durationMs: number + readonly spanCount: number + readonly aiSpanCount: number + readonly llmCalls: number + readonly toolCalls: number + readonly errorSpanCount: number + readonly inputTokens: number + readonly outputTokens: number + readonly cacheReadTokens: number + readonly llmInputTokens: number + readonly llmOutputTokens: number + readonly llmCacheReadTokens: number + readonly costReporters: number + readonly cost: number + readonly llmCost: number + readonly models: readonly string[] + readonly agentNames: readonly string[] +} + +const summaryMeasures = { + startTime: Schema.String, + endTime: Schema.String, + durationMs: CHNumber, + spanCount: CHNumber, + aiSpanCount: CHNumber, + llmCalls: CHNumber, + toolCalls: CHNumber, + errorSpanCount: CHNumber, + inputTokens: CHNumber, + outputTokens: CHNumber, + cacheReadTokens: CHNumber, + llmInputTokens: CHNumber, + llmOutputTokens: CHNumber, + llmCacheReadTokens: CHNumber, + costReporters: CHNumber, + cost: CHNumber, + llmCost: CHNumber, + models: Schema.Array(Schema.String), + agentNames: Schema.Array(Schema.String), +} + +export const aiSessionSummaryRowSchema: CompiledQueryRowSchema = Schema.Struct({ + turnKey: Schema.String, + conversationId: Schema.String, + traceIds: Schema.Array(Schema.String), + ...summaryMeasures, +}) + +export const aiSessionTotalsRowSchema: CompiledQueryRowSchema = Schema.Struct({ + traceCount: CHNumber, + ...summaryMeasures, +}) + +/** Distinct values one turn row keeps of an open-ended dimension. */ +const SUMMARY_ARRAY_CAP = 50 + +/** + * The measures of a set of spans — one turn's, or the whole session's. + * + * The turn key is `gen_ai.conversation.id` under every spelling the mapper + * reads — its source keys plus the two turn ids the vendor refine hooks lift + * into it — falling back to the trace. That is the page's rule 1 and rule 3; + * rule 2 (an agent root opening a turn) needs the parent chain and is not + * attempted here. Nor is the chain walked for an untagged child of a tagged + * span: it groups under its trace, so the rows partition the session exactly + * while a turn row may hold fewer spans than the page's turn of the same id. + * + * Every attribute is read the way `mapAiSpan` reads it: the first non-empty + * value across that field's source keys. An "llm call" and a "tool call" are + * the page's `classifyAiSpan` reduced to what an aggregation can see — + * operation name, model, tool name — without the span-name heuristics. + * + * Usage is summed twice: over every span, and over the model-call spans alone. + * A framework that reports usage per call AND rolls it up onto the agent span + * would double under a plain sum; the handler picks the model-call figures + * when there are any (`per-call`) and the plain sum otherwise (`roll-up`), + * which is the deepest-reporter rule the page applies, at turn granularity. + * + * Every Float64 aggregate is guarded with `ifNotFinite`: `toFloat64OrZero` + * parses `nan` and `inf` successfully, one such attribute would poison the + * whole sum, and `CHNumber` refuses to decode it. + */ +const summaryMeasures_ = ($: SpanColumns) => { + // `coalesce(nullIf(a, ''), nullIf(b, ''), …, '')`: the first key with a value. + const attr = (keys: readonly string[]) => + CH.coalesce(...keys.map((key) => CH.nullIf($.SpanAttributes.get(key), "")), CH.lit("")) + const field = (name: AiGenAiField) => attr(aiFieldSourceKeys(name)) + const number = (name: AiGenAiField) => CH.toFloat64OrZero(field(name)) + + const vendorId = $.SpanAttributes.get(VENDOR_ID_ATTR) + const isAi = vendorId.neq("") + const operation = field("operationName") + // Response model first, request model second — `spanModel` on the page. + const model = attr([...aiFieldSourceKeys("responseModel"), ...aiFieldSourceKeys("requestModel")]) + const toolName = field("toolName") + const agentName = field("agentName") + const isLlmCall = operation + .in_(...AI_INFERENCE_OPERATIONS) + .or( + operation + .notIn(...AI_RETRIEVAL_OPERATIONS, ...AI_TOOL_OPERATIONS, ...AI_AGENT_OPERATIONS) + .and(model.neq("")) + .and(toolName.eq("")), + ) + const isToolCall = operation.in_(...AI_TOOL_OPERATIONS).or(operation.eq("").and(isAi).and(toolName.neq(""))) + // The list query's error rule, so the summary and the list badge agree. + const failed = $.StatusCode.eq("Error").or( + isAi.and( + field("errorType") + .neq("") + .or(CH.inList($.SpanAttributes.get(RESPONSE_STATUS_ATTR), FAILED_RESPONSE_STATUSES)), + ), + ) + const conversationId = attr([ + ...aiFieldSourceKeys("conversationId"), + // What `eveIntegration` and `mapleIntegration` lift into the field. + "eve.turn.id", + MAPLE_NATIVE_TURN_ID_ATTR, + ]) + const inputTokens = number("usageInputTokens") + const outputTokens = number("usageOutputTokens") + const cacheReadTokens = number("usageCacheReadInputTokens") + const cost = number("usageCost") + + const finite = (expr: CH.Expr) => CH.ifNotFinite(expr, 0) + + return { + conversationId, + startTime: CH.toString_(CH.min_($.Timestamp)), + endTime: fromUnixTimestamp64Nano( + CH.max_(CH.toUnixTimestamp64Nano($.Timestamp).add(CH.toInt64($.Duration))), + ), + durationMs: CH.intDiv( + CH.max_(CH.toUnixTimestamp64Nano($.Timestamp).add(CH.toInt64($.Duration))).sub( + CH.toUnixTimestamp64Nano(CH.min_($.Timestamp)), + ), + 1_000_000, + ), + spanCount: CH.count(), + aiSpanCount: CH.countIf(isAi), + llmCalls: CH.countIf(isLlmCall), + toolCalls: CH.countIf(isToolCall), + errorSpanCount: CH.countIf(failed), + inputTokens: finite(CH.sum(inputTokens)), + outputTokens: finite(CH.sum(outputTokens)), + cacheReadTokens: finite(CH.sum(cacheReadTokens)), + llmInputTokens: finite(CH.sumIf(inputTokens, isLlmCall)), + llmOutputTokens: finite(CH.sumIf(outputTokens, isLlmCall)), + llmCacheReadTokens: finite(CH.sumIf(cacheReadTokens, isLlmCall)), + // Spans that reported a cost at all: zero means "not measured", which the + // page distinguishes from "free". + costReporters: CH.countIf(field("usageCost").neq("")), + cost: finite(CH.sum(cost)), + llmCost: finite(CH.sumIf(cost, isLlmCall)), + models: CH.groupUniqArrayIf(SUMMARY_ARRAY_CAP)(model, isLlmCall.and(model.neq(""))), + agentNames: CH.groupUniqArrayIf(SUMMARY_ARRAY_CAP)(agentName, agentName.neq("")), + } +} + +/** One row per turn: the measures under the turn key. */ +const summaryProjection = ($: SpanColumns) => { + const { conversationId, ...measures } = summaryMeasures_($) + return { + turnKey: CH.if_(conversationId.neq(""), conversationId, $.TraceId), + // One id per group by construction: a conversation group carries its id + // on every row, a trace group carries `''` on every row. + conversationId: CH.max_(conversationId), + traceIds: CH.groupUniqArray($.TraceId), + ...measures, + } +} + +/** One row for the whole session: the same measures, ungrouped. Read beside + * the turn rows, so a session grouping into more turns than one response + * carries still reports exact totals — the turn LIMIT cuts the list alone. */ +const totalsProjection = ($: SpanColumns) => { + const { conversationId: _, ...measures } = summaryMeasures_($) + return { traceCount: CH.uniqExact($.TraceId), ...measures } +} + +/** + * The summary of a session keyed by its vendor id: {@link aiSessionSpansQuery}'s + * detection half feeding {@link summaryProjection}. Same window contract as the + * span read — required, bounding both levels. + */ +export function aiSessionSummaryQuery() { + return from(TraceDetailSpans) + .select(summaryProjection) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTimeString("startTime")), + $.Timestamp.lte(param.dateTimeString("endTime")), + inSubquery($.TraceId, sessionTraceIdsSubquery()), + ]) + .groupBy("turnKey") + .orderBy(["startTime", "asc"]) + .limit(AI_SESSION_SUMMARY_MAX_TURNS + 1) + .format("JSON") +} + +/** The summary of a `trace:` session — {@link aiTraceSpansQuery}'s shape. */ +export function aiTraceSummaryQuery() { + return from(TraceDetailSpans) + .select(summaryProjection) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTimeString("startTime")), + $.Timestamp.lte(param.dateTimeString("endTime")), + $.TraceId.eq(param.string("traceId")), + ]) + .groupBy("turnKey") + .orderBy(["startTime", "asc"]) + .limit(AI_SESSION_SUMMARY_MAX_TURNS + 1) + .format("JSON") +} + +/** The whole session's measures in one row — {@link aiSessionSummaryQuery} ungrouped. */ +export function aiSessionTotalsQuery() { + return from(TraceDetailSpans) + .select(totalsProjection) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTimeString("startTime")), + $.Timestamp.lte(param.dateTimeString("endTime")), + inSubquery($.TraceId, sessionTraceIdsSubquery()), + ]) + .format("JSON") +} + +/** The whole `trace:` session's measures in one row. */ +export function aiTraceTotalsQuery() { + return from(TraceDetailSpans) + .select(totalsProjection) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTimeString("startTime")), + $.Timestamp.lte(param.dateTimeString("endTime")), + $.TraceId.eq(param.string("traceId")), + ]) + .format("JSON") +} diff --git a/packages/query-engine-integrations/src/ai/index.ts b/packages/query-engine-integrations/src/ai/index.ts index 07936b635..6239f8b01 100644 --- a/packages/query-engine-integrations/src/ai/index.ts +++ b/packages/query-engine-integrations/src/ai/index.ts @@ -12,18 +12,27 @@ export { aiSessionListQuery, aiSessionSpansQuery, aiSessionSpansRowSchema, + aiSessionSummaryQuery, + aiSessionSummaryRowSchema, + aiSessionTotalsQuery, + aiSessionTotalsRowSchema, aiSessionWindowQuery, aiTraceSpansQuery, + aiTraceSummaryQuery, + aiTraceTotalsQuery, aiTraceWindowQuery, type AiSessionFacetsOutput, type AiSessionListOpts, type AiSessionListOutput, type AiSessionSpansOpts, type AiSessionSpansOutput, + type AiSessionSummaryOutput, + type AiSessionTotalsOutput, type AiSessionWindowOutput, } from "./ai-sessions" export { + aiFieldSourceKeys, aiSpanAttributeKeys, genAiIntegration, mapAiSpan, diff --git a/packages/query-engine-integrations/src/catalog.ts b/packages/query-engine-integrations/src/catalog.ts index 7c4487f9e..315a9d79c 100644 --- a/packages/query-engine-integrations/src/catalog.ts +++ b/packages/query-engine-integrations/src/catalog.ts @@ -130,6 +130,79 @@ export const integrationFixtures: ReadonlyArray = [ { rowSchema: CH.aiSessionSpansRowSchema }, ), }, + { + // The second page of a large session's agent spans: the keyset cursor + // and the scope predicate, on the session-keyed form. + module: "ai-sessions", + name: "aiSessionSpansQuery", + label: "ai-scope-after-cursor", + compile: () => + compileUnsafe( + CH.aiSessionSpansQuery({ + scope: "ai", + after: { timestamp: "2026-01-01 10:30:00.123456789", spanId: "00000000000007d0" }, + }), + { ...window, sessionId: "wrun_sql_catalog" }, + { rowSchema: CH.aiSessionSpansRowSchema }, + ), + }, + { + // One turn's app spans: the detail page names the turn's traces and asks + // for the complement of the agent spans it already holds. + module: "ai-sessions", + name: "aiTraceSpansQuery", + label: "traces-app-scope", + compile: () => + compileUnsafe( + CH.aiTraceSpansQuery({ scope: "app", traceIds: [AI_TRACE_ID, "0123456789abcdef0123456789abcdef"] }), + window, + { rowSchema: CH.aiSessionSpansRowSchema }, + ), + }, + { + module: "ai-sessions", + name: "aiSessionSummaryQuery", + label: "default", + compile: () => + compileUnsafe( + CH.aiSessionSummaryQuery(), + { ...window, sessionId: "wrun_sql_catalog" }, + { rowSchema: CH.aiSessionSummaryRowSchema }, + ), + }, + { + module: "ai-sessions", + name: "aiTraceSummaryQuery", + label: "default", + compile: () => + compileUnsafe( + CH.aiTraceSummaryQuery(), + { ...window, traceId: AI_TRACE_ID }, + { rowSchema: CH.aiSessionSummaryRowSchema }, + ), + }, + { + module: "ai-sessions", + name: "aiSessionTotalsQuery", + label: "default", + compile: () => + compileUnsafe( + CH.aiSessionTotalsQuery(), + { ...window, sessionId: "wrun_sql_catalog" }, + { rowSchema: CH.aiSessionTotalsRowSchema }, + ), + }, + { + module: "ai-sessions", + name: "aiTraceTotalsQuery", + label: "default", + compile: () => + compileUnsafe( + CH.aiTraceTotalsQuery(), + { ...window, traceId: AI_TRACE_ID }, + { rowSchema: CH.aiSessionTotalsRowSchema }, + ), + }, { module: "cloudflare-infra", name: "cloudflareZoneLatencySQL",