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 d5bf8b1ef..6ff2f1ce6 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.test.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.test.ts @@ -366,11 +366,20 @@ describe("POST /internal/ai-sessions/spans", () => { describe("POST /internal/ai-sessions/list", () => { const LIST_BODY = { ...WINDOW, limit: 3 } - /** A stage-one row: a session id and the extent of its agent spans. */ + /** A stage-one row: a session id, the extent of its agent spans, and the + * measures the index answered — which the response carries through. */ const pageRow = (sessionId: string, agentStart: string, agentEnd: string) => ({ sessionId, agentStart, agentEnd, + models: ["claude-sonnet-5"], + agentNames: ["slack-agent"], + llmCalls: "4", + toolCalls: "2", + errorAgentSpans: "0", + totalTokens: 18_400, + cost: 0.12, + agentDurationMs: "600000", }) /** A stage-two row, in the wire shape the aggregation's SELECT decodes. */ @@ -487,10 +496,19 @@ describe("POST /internal/ai-sessions/list", () => { // The page's order is the order that was paged; re-sorting here would // let a row jump between pages on a scroll. A session with no row is // dropped rather than shown with blank counts. - expect((response.body.data as ReadonlyArray<{ sessionId: string }>).map((r) => r.sessionId)).toEqual([ - "wrun_beta", - `trace:${TRACE_ID}`, - ]) + const data = response.body.data as ReadonlyArray> + expect(data.map((r) => r.sessionId)).toEqual(["wrun_beta", `trace:${TRACE_ID}`]) + // The page's measures ride along on the aggregation's row. + expect(data[0]).toMatchObject({ + spanCount: 12, + models: ["claude-sonnet-5"], + agentNames: ["slack-agent"], + llmCalls: 4, + toolCalls: 2, + totalTokens: 18_400, + cost: 0.12, + }) + expect(data[0]).not.toHaveProperty("errorAgentSpans") // Three ranked, two returned. `ranked` is what the client pages on: on // `data.length` this short page reads as the end of the list, and the // next offset would be one too low and re-show a session. The gap is @@ -536,7 +554,7 @@ describe("POST /internal/ai-sessions/facets", () => { // two independent string literals in two packages. If either drifts both // arrays come back empty behind a 200 and the sidebar silently loses every // option — a failure that looks exactly like "no data in this window". - it("splits one union result into the two dimensions the sidebar reads", async () => { + it("splits one union result into the six dimensions the sidebar reads", async () => { const harness = makeHarness({ compiledQuery: (_tenant, compiled) => compiledQueryOf(compiled) @@ -544,6 +562,10 @@ describe("POST /internal/ai-sessions/facets", () => { { facetType: "vendor", name: "eve", count: 7 }, { facetType: "service", name: "agent-runner", count: 4 }, { facetType: "vendor", name: "vercel_ai_sdk", count: 2 }, + { facetType: "environment", name: "production", count: 9 }, + { facetType: "model", name: "claude-sonnet-5", count: 6 }, + { facetType: "agent", name: "slack-agent", count: 5 }, + { facetType: "tool", name: "search_traces", count: 3 }, ]) .pipe(Effect.orDie), }) @@ -556,6 +578,101 @@ describe("POST /internal/ai-sessions/facets", () => { { name: "vercel_ai_sdk", count: 2 }, ]) expect(response.body.services).toEqual([{ name: "agent-runner", count: 4 }]) + expect(response.body.environments).toEqual([{ name: "production", count: 9 }]) + expect(response.body.models).toEqual([{ name: "claude-sonnet-5", count: 6 }]) + expect(response.body.agents).toEqual([{ name: "slack-agent", count: 5 }]) + expect(response.body.tools).toEqual([{ name: "search_traces", count: 3 }]) + } finally { + await harness.dispose() + } + }) +}) + +describe("POST /internal/ai-sessions/list", () => { + // Every filter is a payload field the handler has to hand to the builder by + // name; a field the schema accepts and the handler forgets is a 200 that + // silently ignores the sidebar. So the compiled SQL is what gets asserted — + // the page's, which the stub answers empty so the fan-out never runs. + it("hands every filter and the sort to the page query", async () => { + let sql = "" + const harness = makeHarness({ + compiledQuery: (_tenant, compiled) => { + sql = compiledQueryOf(compiled).sql + return Effect.succeed([]) + }, + }) + + try { + const response = await harness.post("/internal/ai-sessions/list", { + ...WINDOW, + vendorIds: ["eve"], + serviceNames: ["agent-runner"], + deploymentEnvs: ["production"], + models: ["claude-sonnet-5"], + agentNames: ["slack-agent"], + toolNames: ["search_traces"], + search: "wrun01", + hasErrors: true, + excludeTraceSessions: true, + durationMinMs: 1000, + durationMaxMs: 90000, + costMin: 0.25, + costMax: 4, + tokensMin: 10, + tokensMax: 5000, + llmCallsMin: 1, + llmCallsMax: 20, + toolCallsMin: 2, + toolCallsMax: 30, + sortBy: "cost", + sortDir: "asc", + }) + expect(response.status).toBe(200) + expect(response.body).toEqual({ data: [] }) + for (const fragment of [ + "countIf(VendorId IN ('eve')) > 0", + "countIf(ServiceName IN ('agent-runner')) > 0", + "countIf(DeploymentEnv IN ('production')) > 0", + "countIf(Model IN ('claude-sonnet-5')) > 0", + "countIf(AgentName IN ('slack-agent')) > 0", + "countIf(ToolName IN ('search_traces')) > 0", + "SessionId LIKE 'wrun01%'", + "errorAgentSpans > 0", + "NOT (sessionId LIKE 'trace:%')", + "agentDurationMs >= 1000", + "agentDurationMs <= 90000", + "cost >= 0.25", + "cost <= 4", + "totalTokens >= 10", + "totalTokens <= 5000", + "llmCalls >= 1", + "llmCalls <= 20", + "toolCalls >= 2", + "toolCalls <= 30", + "ORDER BY cost ASC, agentStart DESC, sessionId ASC", + ]) { + expect(sql).toContain(fragment) + } + } finally { + await harness.dispose() + } + }) + + it("rejects a negative bound and an unknown sort key at the boundary", async () => { + const harness = makeHarness({ + compiledQuery: () => Effect.succeed([]), + }) + + try { + expect( + (await harness.post("/internal/ai-sessions/list", { ...WINDOW, costMin: -1 })).status, + ).toBe(400) + expect( + (await harness.post("/internal/ai-sessions/list", { ...WINDOW, sortBy: "spanCount" })).status, + ).toBe(400) + expect( + (await harness.post("/internal/ai-sessions/list", { ...WINDOW, tokensMin: 1.5 })).status, + ).toBe(400) } 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 79a1522b0..f2283ea23 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -34,8 +34,23 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) - const filters = { vendorIds: payload.vendorIds, serviceNames: payload.serviceNames } - const window = { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime } + // The counted filters go to BOTH stages, so they resolve a trace + // identically; the session-level ones and the sort rank the page + // and are the page's alone. + const filters = { + vendorIds: payload.vendorIds, + serviceNames: payload.serviceNames, + deploymentEnvs: payload.deploymentEnvs, + models: payload.models, + agentNames: payload.agentNames, + toolNames: payload.toolNames, + search: payload.search, + } + const window = { + orgId: tenant.orgId, + startTime: payload.startTime, + endTime: payload.endTime, + } // Two reads, not one: the page is ranked on `ai_trace_index` over the // caller's whole window, and only then is that page aggregated over // `trace_detail_spans` — inside the hours its own agent spans cover, @@ -48,6 +63,20 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( ...filters, limit: payload.limit, offset: payload.offset, + hasErrors: payload.hasErrors, + excludeTraceSessions: payload.excludeTraceSessions, + durationMinMs: payload.durationMinMs, + durationMaxMs: payload.durationMaxMs, + costMin: payload.costMin, + costMax: payload.costMax, + tokensMin: payload.tokensMin, + tokensMax: payload.tokensMax, + llmCallsMin: payload.llmCallsMin, + llmCallsMax: payload.llmCallsMax, + toolCallsMin: payload.toolCallsMin, + toolCallsMax: payload.toolCallsMax, + sortBy: payload.sortBy, + sortDir: payload.sortDir, }), window, ), @@ -57,7 +86,9 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( return new ListAiSessionsResponse({ data: [] }) } // Fixed-width warehouse literals, so they sort as the instants do. - const fanOutStart = page.map((row) => row.agentStart).reduce((a, b) => (a < b ? a : b)) + const fanOutStart = page + .map((row) => row.agentStart) + .reduce((a, b) => (a < b ? a : b)) const fanOutEnd = page.map((row) => row.agentEnd).reduce((a, b) => (a < b ? b : a)) // The row schema already coerces the UInt64 aggregates and decodes // exactly the response's fields, so rows pass through unmapped. @@ -88,9 +119,25 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( "maple.ai.page_size": page.length, "maple.ai.aggregated": rows.length, }) + // One row per session: the fan-out's facts (spans, services, the + // true extent, the all-span error count) joined with the page's + // measures (models, agents, calls, usage), which only the index + // can answer and the page already computed to rank on. const byId = new Map(rows.map((row) => [row.sessionId, row])) return new ListAiSessionsResponse({ - data: page.flatMap((row) => byId.get(row.sessionId) ?? []), + data: page.flatMap((ranked) => { + const row = byId.get(ranked.sessionId) + if (row === undefined) return [] + return { + ...row, + models: ranked.models, + agentNames: ranked.agentNames, + llmCalls: ranked.llmCalls, + toolCalls: ranked.toolCalls, + totalTokens: ranked.totalTokens, + cost: ranked.cost, + } + }), ranked: page.length, }) }), @@ -108,14 +155,18 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( profile: "list", context: "aiSessionsFacets", }) - // One UNION ALL result carrying both dimensions, split by facetType. - const pick = (facetType: string) => + // One UNION ALL result carrying every dimension, split by facetType. + const pick = (facetType: Integrations.AiSessionFacetType) => rows .filter((row) => row.facetType === facetType) .map((row) => ({ name: row.name, count: row.count })) return new ListAiSessionsFacetsResponse({ vendors: pick("vendor"), services: pick("service"), + environments: pick("environment"), + models: pick("model"), + agents: pick("agent"), + tools: pick("tool"), }) }), ) diff --git a/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts b/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts index 5f0161768..5bbff0591 100644 --- a/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts +++ b/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts @@ -17,13 +17,14 @@ import { afterAll, assert, beforeAll, describe, it } from "@effect/vitest" import { Effect } from "effect" -import { compileUnsafe } from "@maple-dev/clickhouse-builder" +import { compileUnionUnsafe, compileUnsafe } from "@maple-dev/clickhouse-builder" import { MAPLE_AI_SESSION_ID_ATTR, MAPLE_AI_TRACE_SESSION_PREFIX, MAPLE_AI_VENDOR_ID_ATTR, } from "@maple/domain/gen-ai" import * as Integrations from "@maple/query-engine-integrations" +import type { AiSessionPageOpts } from "@maple/query-engine-integrations" import { normalizeSqlForClickHouseClient } from "@maple/query-engine/execution" import { applyRealMigrations, @@ -48,8 +49,7 @@ const BASE_MS = Math.floor((Date.now() - 2 * HOUR_MS) / 1000) * 1000 // DateTime64(9) column and the fan-out is bounded by that literal, so a seed at // a fractional instant is the only thing that proves `Timestamp <= '{fanOutEnd}'` // still admits the very row that produced it. -const chDateTime = (epochMs: number): string => - new Date(epochMs).toISOString().replace("T", " ").slice(0, 23) +const chDateTime = (epochMs: number): string => new Date(epochMs).toISOString().replace("T", " ").slice(0, 23) /** The same instant as `ai_trace_index` renders it: DateTime64(9), so the * millisecond literal above padded out to nanoseconds. */ @@ -71,24 +71,79 @@ const AGENT_TRACE_3 = "aitraceindexe2e000000000000000006" interface SeedSpan { readonly traceId: string readonly spanId: string + readonly parentSpanId?: string + readonly name?: string readonly ms: number readonly service: string readonly status: string readonly attrs: Readonly> + readonly resource?: Readonly> } +const PRODUCTION = { "deployment.environment.name": "production" } + // The turn-owning span of the eve session: the only one of its trace that -// carries the session key, which is why resolution is per-TRACE. +// carries the session key, which is why resolution is per-TRACE. It names the +// agent, and it ROLLS UP the usage of the chat call beneath it — the shape +// several frameworks emit, and the reason a naive sum reads 300 tokens where +// 150 were billed. const AGENT_TURN_SPAN: SeedSpan = { traceId: AGENT_TRACE, spanId: "span-agent-1", + name: "invoke_agent slack-agent", ms: BASE_MS, service: "agent-service", status: "Ok", attrs: { [MAPLE_AI_VENDOR_ID_ATTR]: "eve", [MAPLE_AI_SESSION_ID_ATTR]: SESSION_ID, + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.name": "slack-agent", + "gen_ai.usage.input_tokens": "100", + "gen_ai.usage.output_tokens": "50", + "gen_ai.usage.cost": "0.02", }, + resource: PRODUCTION, +} + +// The model call under the turn span: the index row that carries the model, +// and the deepest reporter of the 150 tokens the turn span repeats. +const AGENT_CHAT_SPAN: SeedSpan = { + traceId: AGENT_TRACE, + spanId: "span-chat-1", + parentSpanId: "span-agent-1", + name: "chat claude-sonnet-5", + ms: BASE_MS + 1_000, + service: "agent-service", + status: "Ok", + attrs: { + [MAPLE_AI_VENDOR_ID_ATTR]: "eve", + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "claude-sonnet-5", + "gen_ai.response.model": "claude-sonnet-5-20260101", + "gen_ai.usage.input_tokens": "100", + "gen_ai.usage.output_tokens": "50", + "gen_ai.usage.cost": "0.02", + }, + resource: PRODUCTION, +} + +// A tool call under the turn span that failed by status: the index row that +// carries the tool, and the session's one failed agent span. +const AGENT_TOOL_SPAN: SeedSpan = { + traceId: AGENT_TRACE, + spanId: "span-tool-1", + parentSpanId: "span-agent-1", + name: "execute_tool search_traces", + ms: BASE_MS + 2_000, + service: "agent-service", + status: "Error", + attrs: { + [MAPLE_AI_VENDOR_ID_ATTR]: "eve", + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": "search_traces", + }, + resource: PRODUCTION, } // A second agent span on the SAME trace, stamped by the SDK the agent calls @@ -135,13 +190,24 @@ const AGENT_TURN_2_SPAN: SeedSpan = { // agent span, so its timestamp is `fanOutEnd`, and stage two's // `Timestamp <= '{fanOutEnd}'` has to admit the row it was measured from. A // millisecond dropped anywhere in that round trip erases this session. +// +// Vercel AI SDK dialect with no operation name: classified by the span-name +// rules, identified by `ai.model.id`, measured by `ai.usage.*`, and in the +// environment under the DEPRECATED semconv spelling. const SESSIONLESS_SPAN: SeedSpan = { traceId: SESSIONLESS_TRACE, spanId: "span-agent-2", + name: "ai.generateText.doGenerate", ms: BASE_MS + 60_123, service: "agent-service", status: "Error", - attrs: { [MAPLE_AI_VENDOR_ID_ATTR]: "vercel_ai_sdk" }, + attrs: { + [MAPLE_AI_VENDOR_ID_ATTR]: "vercel_ai_sdk", + "ai.model.id": "gpt-5", + "ai.usage.promptTokens": "10", + "ai.usage.completionTokens": "5", + }, + resource: { "deployment.environment": "staging" }, } // No `maple_ai.*`: must NOT materialize, and must not be detected as a session. @@ -172,6 +238,8 @@ const EARLY_TURN_SPAN: SeedSpan = { const SEED_SPANS: ReadonlyArray = [ AGENT_TURN_SPAN, + AGENT_CHAT_SPAN, + AGENT_TOOL_SPAN, AGENT_SDK_SPAN, AGENT_CHILD_SPAN, AGENT_TURN_2_SPAN, @@ -204,13 +272,13 @@ const seed = async (): Promise => { ] .map( ([orgId, span]) => - `(${quote(orgId)}, ${quote(chDateTime(span.ms))}, ${quote(span.traceId)}, ${quote(span.spanId)}, '', 'agent turn', 'Internal', ${quote(span.service)}, 1000000, ${quote(span.status)}, 1, ${chMap(span.attrs)})`, + `(${quote(orgId)}, ${quote(chDateTime(span.ms))}, ${quote(span.traceId)}, ${quote(span.spanId)}, ${quote(span.parentSpanId ?? "")}, ${quote(span.name ?? "agent turn")}, 'Internal', ${quote(span.service)}, 1000000, ${quote(span.status)}, 1, ${chMap(span.attrs)}, ${chMap(span.resource ?? {})})`, ) .join("\n,") await clickhouseExec( `INSERT INTO traces - (OrgId, Timestamp, TraceId, SpanId, ParentSpanId, SpanName, SpanKind, ServiceName, Duration, StatusCode, SampleRate, SpanAttributes) + (OrgId, Timestamp, TraceId, SpanId, ParentSpanId, SpanName, SpanKind, ServiceName, Duration, StatusCode, SampleRate, SpanAttributes, ResourceAttributes) VALUES\n${rows}`, database, ) @@ -238,18 +306,48 @@ describe.skipIf(!clickhouseE2eEnabled)("ai_trace_index materialization", () => { it("materializes exactly the vendor-stamped spans, column by column", async () => { const rows = await runJson( - `SELECT OrgId, toString(Timestamp) AS Timestamp, TraceId, SessionId, VendorId, ServiceName + `SELECT OrgId, toString(Timestamp) AS Timestamp, TraceId, SessionId, VendorId, ServiceName, + DeploymentEnv, Model, AgentName, ToolName, SpanId, ParentSpanId, Duration, + IsError, IsLlmCall, IsToolCall, Tokens, Cost FROM ai_trace_index ORDER BY Timestamp ASC`, ) - /** The index row a seed span is expected to produce, by name. */ - const indexRow = (orgId: string, span: SeedSpan) => ({ + /** The index row a seed span is expected to produce, by name — the + * identity as stamped, and every 0025 column as the seed implies it. */ + const indexRow = ( + orgId: string, + span: SeedSpan, + expect: Partial<{ + DeploymentEnv: string + Model: string + AgentName: string + ToolName: string + IsError: number + IsLlmCall: number + IsToolCall: number + Tokens: number + Cost: number + }> = {}, + ) => ({ OrgId: orgId, Timestamp: chTimestamp(span.ms), TraceId: span.traceId, SessionId: span.attrs[MAPLE_AI_SESSION_ID_ATTR] ?? "", VendorId: span.attrs[MAPLE_AI_VENDOR_ID_ATTR] ?? "", ServiceName: span.service, + DeploymentEnv: "", + Model: "", + AgentName: "", + ToolName: "", + SpanId: span.spanId, + ParentSpanId: span.parentSpanId ?? "", + Duration: 1_000_000, + IsError: span.status === "Error" ? 1 : 0, + IsLlmCall: 0, + IsToolCall: 0, + Tokens: 0, + Cost: 0, + ...expect, }) // Every vendor-stamped span and nothing else: `AGENT_CHILD_SPAN` and @@ -258,10 +356,38 @@ describe.skipIf(!clickhouseE2eEnabled)("ai_trace_index materialization", () => { // per TRACE rather than per span. assert.deepStrictEqual(rows, [ indexRow(ORG_ID, EARLY_TURN_SPAN), - indexRow(ORG_ID, AGENT_TURN_SPAN), + indexRow(ORG_ID, AGENT_TURN_SPAN, { + DeploymentEnv: "production", + AgentName: "slack-agent", + Tokens: 150, + Cost: 0.02, + }), + // Response model over request model; the one model call. + indexRow(ORG_ID, AGENT_CHAT_SPAN, { + DeploymentEnv: "production", + Model: "claude-sonnet-5-20260101", + IsLlmCall: 1, + Tokens: 150, + Cost: 0.02, + }), + indexRow(ORG_ID, AGENT_TOOL_SPAN, { + DeploymentEnv: "production", + ToolName: "search_traces", + IsToolCall: 1, + IsError: 1, + }), + // "agent turn" by name, no model, no usage: an agent span, not a call. indexRow(ORG_ID, AGENT_SDK_SPAN), indexRow(ORG_ID, AGENT_TURN_2_SPAN), - indexRow(ORG_ID, SESSIONLESS_SPAN), + // The Vercel AI SDK dialect resolves to the same columns, the deprecated + // environment spelling still resolves, and the name rules classify a + // span with no operation name as the model call it is. + indexRow(ORG_ID, SESSIONLESS_SPAN, { + DeploymentEnv: "staging", + Model: "gpt-5", + IsLlmCall: 1, + Tokens: 15, + }), indexRow(FOREIGN_ORG_ID, FOREIGN_SPAN), ]) }) @@ -333,13 +459,125 @@ describe.skipIf(!clickhouseE2eEnabled)("ai_trace_index materialization", () => { [ // Survived the `<= fanOutEnd` boundary it defined. [`${MAPLE_AI_TRACE_SESSION_PREFIX}${SESSIONLESS_TRACE}`, "vercel_ai_sdk", 1, 1], - // Two traces merged, four spans: the turn span, the SDK span that - // carries no session id, the plain child that is not in the index at - // all, and the second trace's turn span. `eve` and not the - // alphabetically-later `vercel_ai_sdk`, because the vendor is the - // earliest SESSION-BEARING span's. `EARLY_TURN_SPAN` is not among them. - [SESSION_ID, "eve", 2, 4], + // Two traces merged, six spans: the turn span, its chat and tool + // children, the SDK span that carries no session id, the plain child + // that is not in the index at all, and the second trace's turn span. + // `eve` and not the alphabetically-later `vercel_ai_sdk`, because the + // vendor is the earliest SESSION-BEARING span's. `EARLY_TURN_SPAN` is + // not among them. + [SESSION_ID, "eve", 2, 6], ], ) }) + + const WINDOW = { + orgId: ORG_ID, + startTime: chDateTime(BASE_MS - HOUR_MS), + endTime: chDateTime(BASE_MS + HOUR_MS), + } + const TRACE_SESSION_ID = `${MAPLE_AI_TRACE_SESSION_PREFIX}${SESSIONLESS_TRACE}` + + /** The real compiled page query, decoded through its own row schema. */ + const rankPage = async (opts: AiSessionPageOpts = {}) => { + const compiled = compileUnsafe(Integrations.aiSessionPageQuery(opts), WINDOW) + return Effect.runSync(compiled.decodeRows(await runJson(compiled.sql))) + } + + it("measures each session off the index the way the detail page does", async () => { + const [sessionless, session] = await rankPage() + + // Name-classified inference: no operation name, but a model and no + // tool/agent words in the span name. + assert.deepStrictEqual( + [sessionless!.models, sessionless!.agentNames, sessionless!.llmCalls, sessionless!.toolCalls], + [["gpt-5"], [], 1, 0], + ) + assert.strictEqual(sessionless!.totalTokens, 15) + assert.strictEqual(sessionless!.cost, 0) + assert.strictEqual(sessionless!.errorAgentSpans, 1) + // One span of 1ms: the extent is its own duration. + assert.strictEqual(sessionless!.agentDurationMs, 1) + + // The roll-up: the turn span reported the chat call's 150 tokens and + // $0.02 again; the deepest reporter is counted once. The usage lambda is + // raw SQL the builder cannot type-check, so this is where it is proven. + assert.deepStrictEqual( + [session!.models, session!.agentNames, session!.llmCalls, session!.toolCalls], + [["claude-sonnet-5-20260101"], ["slack-agent"], 1, 1], + ) + assert.strictEqual(session!.totalTokens, 150) + assert.strictEqual(session!.cost, 0.02) + assert.strictEqual(session!.errorAgentSpans, 1) + // From the first turn span to the end of the second trace's turn span. + assert.strictEqual(session!.agentDurationMs, 30_001) + }) + + it("applies each counted filter per trace, and each session filter on the ranked row", async () => { + const ids = async (opts: AiSessionPageOpts) => (await rankPage(opts)).map((row) => row.sessionId) + + assert.deepStrictEqual(await ids({ models: ["gpt-5"] }), [TRACE_SESSION_ID]) + assert.deepStrictEqual(await ids({ toolNames: ["search_traces"] }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ agentNames: ["slack-agent"] }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ deploymentEnvs: ["production"] }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ deploymentEnvs: ["staging"] }), [TRACE_SESSION_ID]) + // Dimensions that live on DIFFERENT spans of one trace combine: the model + // is on the chat span, the tool on the tool span, the session id on the + // turn span. A row-level AND would return nothing for any of these. + assert.deepStrictEqual( + await ids({ + models: ["claude-sonnet-5-20260101"], + toolNames: ["search_traces"], + agentNames: ["slack-agent"], + }), + [SESSION_ID], + ) + assert.deepStrictEqual(await ids({ search: SESSION_ID, toolNames: ["search_traces"] }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ models: ["gpt-5"], toolNames: ["search_traces"] }), []) + // A pasted trace id, with the prefix and ellipsis the list row shows. The + // seeds share all but their last character, so a prefix that stops short + // of it matches every trace — which is the prefix rule working. + assert.deepStrictEqual(await ids({ search: `trace:${SESSIONLESS_TRACE}…` }), [TRACE_SESSION_ID]) + assert.deepStrictEqual(await ids({ search: SESSIONLESS_TRACE.slice(0, 30) }), [ + TRACE_SESSION_ID, + SESSION_ID, + ]) + assert.deepStrictEqual(await ids({ excludeTraceSessions: true }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ hasErrors: true }), [TRACE_SESSION_ID, SESSION_ID]) + assert.deepStrictEqual(await ids({ tokensMin: 100 }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ tokensMax: 100 }), [TRACE_SESSION_ID]) + assert.deepStrictEqual(await ids({ costMin: 0.01 }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ toolCallsMin: 1 }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ llmCallsMin: 1 }), [TRACE_SESSION_ID, SESSION_ID]) + assert.deepStrictEqual(await ids({ durationMinMs: 10_000 }), [SESSION_ID]) + assert.deepStrictEqual(await ids({ sortBy: "totalTokens", sortDir: "asc" }), [ + TRACE_SESSION_ID, + SESSION_ID, + ]) + assert.deepStrictEqual(await ids({ sortBy: "cost", sortDir: "desc" }), [SESSION_ID, TRACE_SESSION_ID]) + assert.deepStrictEqual(await ids({ sortBy: "startTime", sortDir: "asc" }), [ + SESSION_ID, + TRACE_SESSION_ID, + ]) + }) + + it("counts the facets the filters select", async () => { + const compiled = compileUnionUnsafe(Integrations.aiSessionFacetsQuery(), WINDOW) + const rows = Effect.runSync(compiled.decodeRows(await runJson(compiled.sql))) + const facet = (facetType: string) => + rows + .filter((row) => row.facetType === facetType) + .map((row) => [row.name, row.count]) + .sort() + + assert.deepStrictEqual(facet("environment"), [ + ["production", 1], + ["staging", 1], + ]) + assert.deepStrictEqual(facet("model"), [ + ["claude-sonnet-5-20260101", 1], + ["gpt-5", 1], + ]) + assert.deepStrictEqual(facet("agent"), [["slack-agent", 1]]) + assert.deepStrictEqual(facet("tool"), [["search_traces", 1]]) + }) }) diff --git a/apps/api/src/services/warehouse/warehouse-catalog.ts b/apps/api/src/services/warehouse/warehouse-catalog.ts index 45560bfed..b8e0d7022 100644 --- a/apps/api/src/services/warehouse/warehouse-catalog.ts +++ b/apps/api/src/services/warehouse/warehouse-catalog.ts @@ -32,7 +32,9 @@ const TABLE_NOTES: Record> = { ai_trace_index: [ "GenAI agent spans ONLY (every row carries a non-empty `VendorId`), with the `maple_ai.*` identity pre-extracted to plain columns. ALWAYS prefer this over `traces` + `mapContains(SpanAttributes, 'maple_ai.…')` for finding agent traces/sessions — the raw-traces scan reads the full attribute Map per span and times out on day-plus windows.", "`SessionId` is '' on most rows: vendors stamp the session key only on turn-owning spans. Resolve a trace's session as `max(SessionId) GROUP BY TraceId`, and treat a trace whose max is '' as a sessionless single-trace session.", - "Holds only the agent spans, and only their identity — for every span of a detected trace, or for any span attribute (`StatusCode`, `error.type`, `gen_ai.*`), collect `TraceId`s here first, then read `trace_detail_spans` with `TraceId IN (…)` AND a `Timestamp` window.", + "`DeploymentEnv`, `Model`, `AgentName` and `ToolName` are the span's environment and GenAI identity, coalesced across dialects at insert (`gen_ai.*`, Vercel AI SDK `ai.*`, OpenInference `llm.*`/`tool.*`). '' where the span carries no such fact — a chat span has no tool — and on rows materialized before migration 0026. Filter and facet on these here rather than on `trace_detail_spans` attributes.", + "`IsLlmCall`, `IsToolCall`, `IsError` (UInt8 flags), `Tokens`, `Cost` (Float64) are the span's kind, failure and reported usage; `SpanId`/`ParentSpanId`/`Duration` are its own. Sum per session here for calls, failures, tokens and cost — but a wrapper span often repeats its children's usage, so subtract a child reporter's tokens from its parent (`ParentSpanId = SpanId`) before summing, or the total doubles.", + "Holds only the agent spans, and only their identity — for every span of a detected trace, or for any other span attribute (`StatusCode`, `error.type`, `gen_ai.usage.*`), collect `TraceId`s here first, then read `trace_detail_spans` with `TraceId IN (…)` AND a `Timestamp` window.", "Sorting key: `(OrgId, Timestamp, TraceId)`; filled forward by its MV, so windows predating the cluster's schema apply under-report.", ], service_overview_spans: [ diff --git a/apps/cli/src/server/local-schema-history.ts b/apps/cli/src/server/local-schema-history.ts index 403e138d3..a233922bb 100644 --- a/apps/cli/src/server/local-schema-history.ts +++ b/apps/cli/src/server/local-schema-history.ts @@ -163,4 +163,22 @@ export const LOCAL_SCHEMA_HISTORY: ReadonlyArray = Obje manifestDigest: "65f0bc9e91171fbefd4452373ad15f8139b56111ffff27f65ffa4a09ba82cdb2", projectRevision: "ed74788ef292834069e0ea6ee3b22d68fc604fb66cb54d2d551db67ce8d20b3a", }), + Object.freeze({ + // `ai_trace_index` widened with the sidebar's filter dimensions + // (`DeploymentEnv`, `Model`, `AgentName`, `ToolName`) and the per-span + // measures the page ranks on (`IsError`, `IsLlmCall`, `IsToolCall`, + // `Tokens`, `Cost`, plus `SpanId`/`ParentSpanId`/`Duration`), and + // `ai_trace_index_mv` recreated to fill them (ClickHouse migration + // 0026). Metadata-only ALTERs plus a view swap — no part is rewritten and + // no row moves. Rows materialized under v15 keep ''/0 in the new columns; + // nothing is backfilled. + // + // projectRevision stays the hardcoded constant, as for v12 to v15 — the + // identity this gate compares is the fingerprint/digest pair. + version: 16, + fingerprint: "d975e674ce66af41", + digest: "d975e674ce66af417e4398d8ca336d41340c9c1aa7b26082a6c55ecd02effe38", + manifestDigest: "f7d559f0db216379db02bc78c4e50f180f40588e124adf65665bf7eaaf556735", + projectRevision: "ed74788ef292834069e0ea6ee3b22d68fc604fb66cb54d2d551db67ce8d20b3a", + }), ] as const) diff --git a/apps/cli/src/server/local-schema-version.ts b/apps/cli/src/server/local-schema-version.ts index de7a1ff68..f11aae74c 100644 --- a/apps/cli/src/server/local-schema-version.ts +++ b/apps/cli/src/server/local-schema-version.ts @@ -1,4 +1,4 @@ // Increment this value for every structural change to the generated local // schema. The compatibility manifest and migration registry must be updated in // the same change before a new value can ship. -export const LOCAL_SCHEMA_VERSION = 15 as const +export const LOCAL_SCHEMA_VERSION = 16 as const diff --git a/apps/cli/src/server/local-store-migrations.ts b/apps/cli/src/server/local-store-migrations.ts index fce8947d2..afeaee76a 100644 --- a/apps/cli/src/server/local-store-migrations.ts +++ b/apps/cli/src/server/local-store-migrations.ts @@ -51,6 +51,7 @@ import { v11ToV12ServiceMapEdgeQuantilesModule } from "./local-store-migrations/ import { v12ToV13ServiceOperationsDiscriminatorsModule } from "./local-store-migrations/v12-to-v13-service-operations-discriminators" import { v13ToV14AiTraceIndexModule } from "./local-store-migrations/v13-to-v14-ai-trace-index" import { v14ToV15CommitShaVcsRevisionModule } from "./local-store-migrations/v14-to-v15-commit-sha-vcs-revision" +import { v15ToV16AiTraceIndexFilterColumnsModule } from "./local-store-migrations/v15-to-v16-ai-trace-index-filter-columns" import type { AnyLocalStoreMigrationModule, LocalStoreMigration, @@ -123,6 +124,7 @@ export const localStoreMigrations: ReadonlyArray = v12ToV13ServiceOperationsDiscriminatorsModule, v13ToV14AiTraceIndexModule, v14ToV15CommitShaVcsRevisionModule, + v15ToV16AiTraceIndexFilterColumnsModule, ] export const validateMigrationRegistry = ( diff --git a/apps/cli/src/server/local-store-migrations/v15-to-v16-ai-trace-index-filter-columns.ts b/apps/cli/src/server/local-store-migrations/v15-to-v16-ai-trace-index-filter-columns.ts new file mode 100644 index 000000000..3245beeff --- /dev/null +++ b/apps/cli/src/server/local-store-migrations/v15-to-v16-ai-trace-index-filter-columns.ts @@ -0,0 +1,291 @@ +// SAFETY-FILE: JSON rows here come from fixed internal formats and are validated before domain use. +import { cloneStoreForStaging } from "./journal-codecs" +import { resolve } from "node:path" +import { RAW_TELEMETRY_TTL_COLUMNS, readRawTelemetryRetentionDays, type Chdb } from "../chdb" +import type { + LocalStoreMigrationModule, + MigrationModuleContext, + MigrationOperation, + StateDispositionEntry, +} from "../local-store-migration-module" +import { withRawTelemetryRetentionFloor } from "../schema-manifest" +import { + LOCAL_SCHEMA_V15, + LOCAL_SCHEMA_V15_MANIFEST, + LOCAL_SCHEMA_V15_SQL, + LOCAL_SCHEMA_V16, + LOCAL_SCHEMA_V16_MANIFEST, + LOCAL_SCHEMA_V16_SQL, +} from "../schema-identity" +import { assertPhysicalSchema } from "../schema-physical" + +const RAW_TABLES = RAW_TELEMETRY_TTL_COLUMNS.map(([table]) => table) + +const MODULE_ID = "local-0015-to-0016-ai-trace-index-filter-columns" as const + +/** + * The local mirror of ClickHouse migration 0026. + * + * v16 widens `ai_trace_index` with the filter dimensions the Agent Sessions + * sidebar offers beyond framework and service — `DeploymentEnv`, `Model`, + * `AgentName`, `ToolName` — and the per-span measures the page ranks and + * filters on (`IsError`, `IsLlmCall`, `IsToolCall`, `Tokens`, `Cost`, with + * `SpanId`/`ParentSpanId`/`Duration`), and recreates `ai_trace_index_mv` so it + * fills them — every one a fact of the GenAI span, coalesced across dialects + * by `@maple/domain`'s `gen-ai-columns`. No row moves and no table is rebuilt. + * + * Two things the bundled v16 DDL cannot do on its own, both done in a + * pre-bootstrap block exactly as the v6 -> v7 edge did: + * + * 1. Widen the table. The DDL is `CREATE TABLE IF NOT EXISTS`, a no-op against + * the v14 table, so the explicit `ADD COLUMN IF NOT EXISTS` is what adds the + * columns — metadata-only, defaulting every existing row to `''`. + * 2. Replace the view. A materialized view's SELECT is frozen at creation, so + * the v14 view is dropped first or it simply survives the bootstrap's + * `IF NOT EXISTS`. chDB materializes views as tables, hence `DROP TABLE`. + * + * NOTHING IS BACKFILLED, as in 0026: rows materialized under v15 keep `''` + * and 0 in the new columns, which the facets drop, the filters never match and + * the sums count as nothing. Those sessions are still detected and listed — + * sliceable by framework and service, invisible under any model, agent, tool + * or environment, and ranked as if free — until raw `traces`' retention ages + * them out. The managed side accepts the same gap. + * + * Every statement is idempotent, so a resume after a crash lands in the same + * place. + */ + +/** Columns v16 adds to `ai_trace_index`, with the type the v16 DDL declares. */ +const FILTER_COLUMNS = [ + ["DeploymentEnv", "LowCardinality(String)"], + ["Model", "LowCardinality(String)"], + ["AgentName", "LowCardinality(String)"], + ["ToolName", "LowCardinality(String)"], + ["SpanId", "String"], + ["ParentSpanId", "String"], + ["Duration", "UInt64"], + ["IsError", "UInt8"], + ["IsLlmCall", "UInt8"], + ["IsToolCall", "UInt8"], + ["Tokens", "Float64"], + ["Cost", "Float64"], +] as const + +interface V15ToV16State { + readonly module: typeof MODULE_ID + readonly version: 1 + readonly rawRows: Readonly> + readonly retentionDays?: number +} + +interface V15ToV16Progress { + readonly installed: true +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const isCount = (value: unknown): value is string => typeof value === "string" && /^\d+$/.test(value) + +const decodeCounts = (value: unknown): Readonly> => { + if (!isRecord(value)) throw new Error("v15 -> v16 rawRows must be an object") + const counts: Record = {} + for (const table of RAW_TABLES) { + const count = value[table] + if (!isCount(count)) throw new Error(`v15 -> v16 rawRows.${table} must be an unsigned decimal string`) + counts[table] = count + } + if (Object.keys(value).some((table) => !RAW_TABLES.includes(table as (typeof RAW_TABLES)[number]))) + throw new Error("v15 -> v16 rawRows contains an unknown table") + return counts +} + +const decodeState = (value: unknown): V15ToV16State => { + if (!isRecord(value)) throw new Error("v15 -> v16 state must be an object") + const allowed = new Set(["module", "version", "rawRows", "retentionDays"]) + if (Object.keys(value).some((key) => !allowed.has(key))) + throw new Error("v15 -> v16 state contains an unknown field") + if (value.module !== MODULE_ID || value.version !== 1) + throw new Error("v15 -> v16 state has an unsupported module or version") + if ( + value.retentionDays !== undefined && + (typeof value.retentionDays !== "number" || !Number.isSafeInteger(value.retentionDays)) + ) + throw new Error("v15 -> v16 retentionDays must be an integer") + return { + module: MODULE_ID, + version: 1, + rawRows: decodeCounts(value.rawRows), + ...(!(value.retentionDays === undefined) ? { retentionDays: value.retentionDays } : undefined), + } +} + +const decodeProgress = (value: unknown): V15ToV16Progress | undefined => { + if (value === undefined) return undefined + if (!isRecord(value) || Object.keys(value).some((key) => key !== "installed") || value.installed !== true) + throw new Error("v15 -> v16 progress is invalid") + return { installed: true } +} + +const parseJsonEachRow = (value: string): A[] => + value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as A) + +const rawRowCounts = (db: Chdb): Readonly> => { + const quotedTables = RAW_TABLES.map((table) => `'${table}'`).join(", ") + const rows = parseJsonEachRow<{ table: string; rowCount: string }>( + db.query( + `SELECT table, toString(sum(rows)) AS rowCount FROM system.parts WHERE database = 'default' AND active = 1 AND table IN (${quotedTables}) GROUP BY table`, + ), + ) + const byTable = new Map(rows.map((row) => [row.table, row.rowCount])) + return Object.fromEntries(RAW_TABLES.map((table) => [table, byTable.get(table) ?? "0"])) +} + +const expectedManifest = (manifest: typeof LOCAL_SCHEMA_V15_MANIFEST, retentionDays: number | undefined) => + retentionDays === undefined + ? manifest + : withRawTelemetryRetentionFloor(manifest, RAW_TABLES, retentionDays) + +const preflight = async (context: MigrationModuleContext): Promise => { + await context.ensureCapacity() + const retentionDays = readRawTelemetryRetentionDays(context.dataDir) + const rawRows = await context.openSource( + (db) => { + assertPhysicalSchema(db, expectedManifest(LOCAL_SCHEMA_V15_MANIFEST, retentionDays)) + return rawRowCounts(db) + }, + { schemaSql: LOCAL_SCHEMA_V15_SQL, bootstrapSchema: false }, + ) + return { + module: MODULE_ID, + version: 1, + rawRows, + ...(!(retentionDays === undefined) ? { retentionDays } : undefined), + } +} + +const prepareTarget = async ( + context: MigrationModuleContext, + state: V15ToV16State, +): Promise => { + await context.closeStores() + const source = resolve(context.sourceDataDir) + const target = resolve(context.targetDataDir) + if (source !== target) { + await cloneStoreForStaging(source, target) + } + return state +} + +const apply = async (context: MigrationModuleContext): Promise => { + await context.openTarget( + (db) => { + db.exec("DROP TABLE IF EXISTS ai_trace_index_mv") + for (const [column, type] of FILTER_COLUMNS) { + db.exec(`ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS ${column} ${type}`) + } + }, + { schemaSql: LOCAL_SCHEMA_V15_SQL, bootstrapSchema: false }, + ) + // The v16 bootstrap recreates the view with its new SELECT; every other + // object already exists and its `IF NOT EXISTS` is a no-op. + return context.openTarget(() => ({ installed: true }) as const, { + schemaSql: LOCAL_SCHEMA_V16_SQL, + bootstrapSchema: true, + }) +} + +const verify = async ( + context: MigrationModuleContext, + state: V15ToV16State, + _progress: V15ToV16Progress, +): Promise => { + await context.openTarget( + (db) => { + assertPhysicalSchema(db, expectedManifest(LOCAL_SCHEMA_V16_MANIFEST, state.retentionDays)) + const targetRows = rawRowCounts(db) + for (const table of RAW_TABLES) { + if (targetRows[table] !== state.rawRows[table]) + throw new Error(`v15 -> v16 raw telemetry verification failed for ${table}`) + } + }, + { schemaSql: LOCAL_SCHEMA_V16_SQL, bootstrapSchema: false }, + ) +} + +const operations: ReadonlyArray = [ + { + id: "clone-v15-store", + description: "Clone the stopped v15 store into the staged migration target", + requiresQuiescence: true, + phase: "target-created", + }, + { + id: "widen-ai-trace-index", + description: + "Add the filter dimensions and per-span measures to ai_trace_index and rebuild ai_trace_index_mv to fill them", + requiresQuiescence: true, + phase: "copying", + }, + { + id: "verify-v16-schema", + description: "Verify the v16 physical schema and the retained raw telemetry counts", + requiresQuiescence: true, + phase: "copy-verified", + }, +] + +const dispositions: ReadonlyArray = [ + { + name: "local store", + classification: "authoritative", + disposition: "preserve-exact", + guarantee: "The clean stopped v15 store is cloned byte-for-byte before any DDL runs.", + }, + { + name: "traces", + classification: "authoritative", + disposition: "preserve-exact", + guarantee: + "The source of the replaced view is neither read nor rewritten; only the view definition and the index's column list change.", + }, + { + // Existing rows are kept and read back with '' in the four new columns: + // still detected and listed, filterable by framework and service, absent + // from the model/agent/tool/environment facets. The MV fills the columns + // for spans ingested after the edge. Forward-only, bounded by the 30-day + // TTL, and the same gap the managed side accepts. + name: "ai_trace_index", + classification: "derived", + disposition: "rebuild-within-retention-horizon", + guarantee: + "Existing rows are preserved untouched with empty filter columns; the rebuilt view fills them for spans materialized after the migration and the gap closes as the retention window rolls.", + preservationInterval: "from the migration forward", + sourceRetentionDays: 30, + targetRetentionDays: 30, + }, +] + +export const v15ToV16AiTraceIndexFilterColumnsModule: LocalStoreMigrationModule< + V15ToV16State, + V15ToV16Progress +> = { + id: MODULE_ID, + moduleVersion: 1, + description: "Add DeploymentEnv, Model, AgentName and ToolName to ai_trace_index and recreate its view", + from: LOCAL_SCHEMA_V15, + to: LOCAL_SCHEMA_V16, + operations, + dispositions, + decodeState, + decodeProgress, + preflight, + prepareTarget, + apply, + verify, + recover: async (_context, state, progress) => ({ state, progress }), +} diff --git a/apps/cli/src/server/schema-identity.ts b/apps/cli/src/server/schema-identity.ts index 77b1a3322..5c413fadd 100644 --- a/apps/cli/src/server/schema-identity.ts +++ b/apps/cli/src/server/schema-identity.ts @@ -14,6 +14,7 @@ import schemaV12Sql from "./schema/local-schema-v12.sql" with { type: "text" } import schemaV13Sql from "./schema/local-schema-v13.sql" with { type: "text" } import schemaV14Sql from "./schema/local-schema-v14.sql" with { type: "text" } import schemaV15Sql from "./schema/local-schema-v15.sql" with { type: "text" } +import schemaV16Sql from "./schema/local-schema-v16.sql" with { type: "text" } import { schemaDigest as digestSchema, schemaFingerprint as fingerprintSchema } from "./store-version" import { buildLocalSchemaManifest, type LocalSchemaManifest } from "./schema-manifest" import { LOCAL_SCHEMA_VERSION } from "./local-schema-version" @@ -73,6 +74,7 @@ const SNAPSHOT_SQL: ReadonlyArray = [ schemaV13Sql, schemaV14Sql, schemaV15Sql, + schemaV16Sql, ] export interface LocalSchemaSnapshot { @@ -127,6 +129,8 @@ export const LOCAL_SCHEMA_V14_SQL = snapshotAt(14).sql export const LOCAL_SCHEMA_V14_MANIFEST = snapshotAt(14).manifest export const LOCAL_SCHEMA_V15_SQL = snapshotAt(15).sql export const LOCAL_SCHEMA_V15_MANIFEST = snapshotAt(15).manifest +export const LOCAL_SCHEMA_V16_SQL = snapshotAt(16).sql +export const LOCAL_SCHEMA_V16_MANIFEST = snapshotAt(16).manifest export interface LocalSchemaIdentity { readonly version: number @@ -172,6 +176,7 @@ export const LOCAL_SCHEMA_V12 = identityAt(12) export const LOCAL_SCHEMA_V13 = identityAt(13) export const LOCAL_SCHEMA_V14 = identityAt(14) export const LOCAL_SCHEMA_V15 = identityAt(15) +export const LOCAL_SCHEMA_V16 = identityAt(16) export const CURRENT_LOCAL_SCHEMA: LocalSchemaIdentity = Object.freeze({ version: LOCAL_SCHEMA_VERSION, diff --git a/apps/cli/src/server/schema/local-inserts.json b/apps/cli/src/server/schema/local-inserts.json index 4866fb8f1..394629c03 100644 --- a/apps/cli/src/server/schema/local-inserts.json +++ b/apps/cli/src/server/schema/local-inserts.json @@ -1,5 +1,5 @@ { - "projectRevision": "bf3419c18e581ffab5fa4e24aa56f421ac9c3d5a1ce734596747f2331d7d2ae0", + "projectRevision": "25e53de2337d2079a4efc306435d995387cdf89cecafa60e142f5665ccdeebbb", "orgPlaceholder": "__ORG__", "datasources": { "traces": { diff --git a/apps/cli/src/server/schema/local-schema-v16.sql b/apps/cli/src/server/schema/local-schema-v16.sql new file mode 100644 index 000000000..1efc0b39b --- /dev/null +++ b/apps/cli/src/server/schema/local-schema-v16.sql @@ -0,0 +1,1931 @@ +-- This file is generated by scripts/generate-clickhouse-schema-sql.ts +-- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. +-- projectRevision: 25e53de2337d2079a4efc306435d995387cdf89cecafa60e142f5665ccdeebbb +-- localSchemaVersion: 15 + +CREATE TABLE IF NOT EXISTS ai_trace_index ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SessionId String, + VendorId LowCardinality(String), + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + Model LowCardinality(String), + AgentName LowCardinality(String), + ToolName LowCardinality(String), + SpanId String, + ParentSpanId String, + Duration UInt64, + IsError UInt8, + IsLlmCall UInt8, + IsToolCall UInt8, + Tokens Float64, + Cost Float64 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS alert_checks ( + OrgId LowCardinality(String), + RuleId String, + GroupKey String, + Timestamp DateTime64(3), + Status LowCardinality(String), + SignalType LowCardinality(String), + Comparator LowCardinality(String), + Threshold Float64, + ObservedValue Nullable(Float64), + SampleCount UInt32, + WindowMinutes UInt16, + WindowStart DateTime64(3), + WindowEnd DateTime64(3), + ConsecutiveBreaches UInt16, + ConsecutiveHealthy UInt16, + IncidentId Nullable(String), + IncidentTransition LowCardinality(String), + EvaluationDurationMs UInt32, + ErrorMessage Nullable(String), + ErrorCategory LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, RuleId, GroupKey, Timestamp) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS attribute_keys_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + AttributeKey LowCardinality(String), + AttributeScope LowCardinality(String), + UsageCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, AttributeScope, Hour, AttributeKey) +TTL Hour + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS attribute_values_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + AttributeKey LowCardinality(String), + AttributeValue String, + AttributeScope LowCardinality(String), + UsageCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, AttributeScope, AttributeKey, Hour, AttributeValue) +TTL Hour + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS error_events ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String DEFAULT '__unset__', + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ExceptionType LowCardinality(String), + ExceptionMessage String, + ExceptionStacktrace String, + TopFrame String, + FingerprintHash UInt64, + StatusMessage String, + Duration UInt64, + ErrorLabel String, + ServiceVersion LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, FingerprintHash, Timestamp) +TTL Timestamp + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS error_events_by_time ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String DEFAULT '__unset__', + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ExceptionType LowCardinality(String), + ExceptionMessage String, + ExceptionStacktrace String, + TopFrame String, + FingerprintHash UInt64, + StatusMessage String, + Duration UInt64, + ErrorLabel String, + ServiceVersion LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, FingerprintHash) +TTL Timestamp + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS error_fingerprints_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + FingerprintHash UInt64, + ServiceName SimpleAggregateFunction(anyLast, String), + ExceptionType SimpleAggregateFunction(anyLast, String), + ExceptionMessage SimpleAggregateFunction(anyLast, String), + ErrorLabel SimpleAggregateFunction(anyLast, String), + TopFrame SimpleAggregateFunction(anyLast, String), + OccurrenceCount SimpleAggregateFunction(sum, UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + LastSeen SimpleAggregateFunction(max, DateTime), + ServiceVersions SimpleAggregateFunction(groupUniqArrayArray, Array(String)) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Minute) +ORDER BY (OrgId, Minute, FingerprintHash) +TTL Minute + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS identity_links ( + OrgId LowCardinality(String), + VisitorId String, + UserId String, + FirstSeen SimpleAggregateFunction(min, DateTime64(9)) +) +ENGINE = AggregatingMergeTree +PARTITION BY tuple() +ORDER BY (OrgId, VisitorId, UserId) +TTL toDate(FirstSeen) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS logs ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TimestampTime DateTime, + TraceId String, + SpanId String, + TraceFlags UInt8, + SeverityText LowCardinality(String), + SeverityNumber UInt8, + ServiceName LowCardinality(String), + Body String, + ResourceSchemaUrl String, + ResourceAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + LogAttributes Map(LowCardinality(String), String), + ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)), + ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)), + LogAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(LogAttributes), mapValues(LogAttributes)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_attr_keys mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_attr_vals mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_lower_body lower(Body) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8 +) +ENGINE = MergeTree +PARTITION BY toDate(TimestampTime) +ORDER BY (OrgId, toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp) +TTL toDate(TimestampTime) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS logs_aggregates_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SeverityText LowCardinality(String), + DeploymentEnv LowCardinality(String), + Count SimpleAggregateFunction(sum, UInt64), + SizeBytes SimpleAggregateFunction(sum, UInt64), + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SeverityText, DeploymentEnv, ServiceNamespace) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS metric_catalog ( + OrgId LowCardinality(String), + Hour DateTime, + MetricType LowCardinality(String), + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription SimpleAggregateFunction(anyLast, String), + MetricUnit SimpleAggregateFunction(anyLast, String), + IsMonotonic SimpleAggregateFunction(anyLast, UInt8), + DataPointCount SimpleAggregateFunction(sum, UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + LastSeen SimpleAggregateFunction(max, DateTime) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, MetricType, ServiceName, MetricName, Hour) +TTL Hour + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_exponential_histogram ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Count UInt64, + Sum Float64, + Scale Int32, + ZeroCount UInt64, + PositiveOffset Int32, + PositiveBucketCounts Array(UInt64), + NegativeOffset Int32, + NegativeBucketCounts Array(UInt64), + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + Flags UInt32, + Min Nullable(Float64), + Max Nullable(Float64), + AggregationTemporality Int32 +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_gauge ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Value Float64, + Flags UInt32, + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)) +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_histogram ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Count UInt64, + Sum Float64, + BucketCounts Array(UInt64), + ExplicitBounds Array(Float64), + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + Flags UInt32, + Min Nullable(Float64), + Max Nullable(Float64), + AggregationTemporality Int32 +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_sum ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Value Float64, + Flags UInt32, + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + AggregationTemporality Int32, + IsMonotonic Bool +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS product_events ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + Source LowCardinality(String) DEFAULT 'browser', + SessionId String DEFAULT '', + Seq UInt32 DEFAULT 0, + VisitorId String DEFAULT '', + UserId String DEFAULT '', + GroupId String DEFAULT '', + Kind LowCardinality(String), + EventName String, + Host LowCardinality(String) DEFAULT '', + PagePath String DEFAULT '', + Url String DEFAULT '', + ServiceName LowCardinality(String) DEFAULT '', + Attributes Map(String, String) DEFAULT map(), + INDEX idx_event_name EventName TYPE set(64) GRANULARITY 4, + INDEX idx_user_id UserId TYPE bloom_filter GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, VisitorId, SessionId, Seq) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_address_resolutions_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + ParentServerAddress String, + ResolvedTargetService LowCardinality(String), + DeploymentEnv LowCardinality(String) +) +ENGINE = ReplacingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, SourceService, ParentServerAddress, ResolvedTargetService) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_external_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + TargetType LowCardinality(String), + TargetSystem LowCardinality(String), + TargetName String, + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampleRateSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, TargetType, TargetSystem, TargetName) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_children ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + ParentSpanId String, + ServiceName LowCardinality(String), + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, TraceId, ParentSpanId, Timestamp) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_map_db_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DbSystem LowCardinality(String), + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampledSpanCount SimpleAggregateFunction(sum, UInt64), + UnsampledSpanCount SimpleAggregateFunction(sum, UInt64), + SampleRateSum SimpleAggregateFunction(sum, Float64), + DbNamespace LowCardinality(String), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, DbSystem, DbNamespace) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_db_query_shapes_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DbSystem LowCardinality(String), + DeploymentEnv LowCardinality(String), + QueryKey String, + QueryLabel SimpleAggregateFunction(any, String), + SampleStatement SimpleAggregateFunction(any, String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedCount SimpleAggregateFunction(sum, Float64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + WeightedDurationSumMs SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32), + DbNamespace LowCardinality(String) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, DbSystem, DbNamespace, QueryKey) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + TargetService String, + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampledSpanCount SimpleAggregateFunction(sum, UInt64), + UnsampledSpanCount SimpleAggregateFunction(sum, UInt64), + SampleRateSum SimpleAggregateFunction(sum, Float64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, SourceService, TargetService) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_edges_hourly_ingest ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + TargetService String, + DeploymentEnv LowCardinality(String), + CallCount UInt64, + ErrorCount UInt64, + DurationSumMs Float64, + MaxDurationMs Float64, + SampledSpanCount UInt64, + UnsampledSpanCount UInt64, + SampleRateSum Float64 +) +ENGINE = Null; + +CREATE TABLE IF NOT EXISTS service_map_spans ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String, + ServiceName LowCardinality(String), + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, TraceId, SpanId, Timestamp) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_operations_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + SpanName String, + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64), + ClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + ServerSpanCount SimpleAggregateFunction(sum, UInt64), + RoutedSpanCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Hour) +ORDER BY (OrgId, ServiceName, DeploymentEnv, Hour, SpanName) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_operations_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + SpanName String, + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64), + ClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + ServerSpanCount SimpleAggregateFunction(sum, UInt64), + RoutedSpanCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, ServiceName, DeploymentEnv, Minute, SpanName) +TTL toDate(Minute) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + CommitSha LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + ApdexSatisfiedCount SimpleAggregateFunction(sum, UInt64), + ApdexToleratingCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Hour) +ORDER BY (OrgId, ServiceName, Hour, DeploymentEnv, ServiceNamespace, CommitSha) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + CommitSha LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + ApdexSatisfiedCount SimpleAggregateFunction(sum, UInt64), + ApdexToleratingCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, ServiceName, Minute, DeploymentEnv, ServiceNamespace, CommitSha) +TTL toDate(Minute) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_spans ( + OrgId LowCardinality(String), + Timestamp DateTime, + ServiceName LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String), + CommitSha LowCardinality(String), + SampleRate Float64 DEFAULT 1, + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, ServiceName, Timestamp) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_platforms_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + K8sCluster SimpleAggregateFunction(max, String), + K8sPodName SimpleAggregateFunction(max, String), + K8sDeploymentName SimpleAggregateFunction(max, String), + K8sStatefulSetName SimpleAggregateFunction(max, String), + K8sDaemonSetName SimpleAggregateFunction(max, String), + K8sNamespaceName SimpleAggregateFunction(max, String), + CloudPlatform SimpleAggregateFunction(max, String), + CloudProvider SimpleAggregateFunction(max, String), + FaasName SimpleAggregateFunction(max, String), + MapleSdkType SimpleAggregateFunction(max, String), + ProcessRuntimeName SimpleAggregateFunction(max, String), + SpanCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, DeploymentEnv) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_usage ( + OrgId LowCardinality(String), + ServiceName LowCardinality(String), + Hour DateTime, + LogCount UInt64, + LogSizeBytes UInt64, + TraceCount UInt64, + TraceSizeBytes UInt64, + SumMetricCount UInt64, + SumMetricSizeBytes UInt64, + GaugeMetricCount UInt64, + GaugeMetricSizeBytes UInt64, + HistogramMetricCount UInt64, + HistogramMetricSizeBytes UInt64, + ExpHistogramMetricCount UInt64, + ExpHistogramMetricSizeBytes UInt64 +) +ENGINE = SummingMergeTree +ORDER BY (OrgId, ServiceName, Hour) +TTL Hour + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS session_events ( + OrgId LowCardinality(String), + SessionId String, + Timestamp DateTime64(9), + Seq UInt32 DEFAULT 0, + Type LowCardinality(String), + Url String DEFAULT '', + TraceId String DEFAULT '', + Level LowCardinality(String) DEFAULT '', + Message String DEFAULT '', + TargetSelector String DEFAULT '', + TargetText String DEFAULT '', + NetMethod LowCardinality(String) DEFAULT '', + NetUrl String DEFAULT '', + NetStatus UInt16 DEFAULT 0, + NetDurationMs UInt32 DEFAULT 0, + ErrorStack String DEFAULT '', + Attributes Map(String, String), + VisitorId String DEFAULT '', + UserId String DEFAULT '', + GroupId String DEFAULT '', + INDEX idx_type Type TYPE set(16) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, SessionId, Timestamp, Seq) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS session_replay_events ( + OrgId LowCardinality(String), + SessionId String, + ChunkSeq UInt32, + Timestamp DateTime64(9), + DurationMs UInt32 DEFAULT 0, + EventCount UInt32 DEFAULT 0, + ByteSize UInt32 DEFAULT 0, + Events String, + IsCheckpoint UInt8 DEFAULT 0 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, SessionId, ChunkSeq) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS session_replays ( + OrgId LowCardinality(String), + SessionId String, + StartTime DateTime64(9), + EndTime Nullable(DateTime64(9)), + DurationMs Nullable(UInt32), + Status LowCardinality(String), + UserId String, + UrlInitial String, + UserAgent String, + BrowserName LowCardinality(String), + OsName LowCardinality(String), + DeviceType LowCardinality(String), + Country LowCardinality(String) DEFAULT '', + ServiceName LowCardinality(String), + PageViews UInt32 DEFAULT 0, + ClickCount UInt32 DEFAULT 0, + ErrorCount UInt32 DEFAULT 0, + TraceIds Array(String) DEFAULT [], + ResourceAttributes Map(LowCardinality(String), String), + Version UInt32, + VisitorId String DEFAULT '', + VisitorIsNew UInt8 DEFAULT 0, + UserEmail String DEFAULT '', + UserName String DEFAULT '', + GroupId String DEFAULT '', + GroupName String DEFAULT '', + UserTraits Map(String, String) DEFAULT map(), + Referrer String DEFAULT '', + ReferrerHost LowCardinality(String) DEFAULT '', + UtmSource LowCardinality(String) DEFAULT '', + UtmMedium LowCardinality(String) DEFAULT '', + UtmCampaign LowCardinality(String) DEFAULT '', + UtmTerm String DEFAULT '', + UtmContent String DEFAULT '', + Host LowCardinality(String) DEFAULT '', + EntryPath String DEFAULT '', + ExitPath String DEFAULT '', + Language LowCardinality(String) DEFAULT '', + LastActivityAt Nullable(DateTime64(9)) +) +ENGINE = ReplacingMergeTree +PARTITION BY toDate(StartTime) +ORDER BY (OrgId, SessionId) +TTL toDate(StartTime) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS span_metrics_calls_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + SpanKind LowCardinality(String), + AttrFingerprint UInt64, + ResourceFingerprint UInt64, + StartTimeUnix DateTime64(9), + LastValue AggregateFunction(argMax, Float64, DateTime64(9)) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, MetricName, SpanKind, AttrFingerprint, ResourceFingerprint, StartTimeUnix) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS trace_detail_spans ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SpanId String, + ParentSpanId String, + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + ServiceName LowCardinality(String), + Duration UInt64 DEFAULT 0, + StatusCode LowCardinality(String), + StatusMessage String, + SpanAttributes Map(LowCardinality(String), String), + ResourceAttributes Map(LowCardinality(String), String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, TraceId, SpanId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS trace_list_mv ( + OrgId LowCardinality(String), + TraceId String, + Timestamp DateTime, + ServiceName LowCardinality(String), + SpanName String, + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + HttpMethod LowCardinality(String), + HttpRoute String, + HttpStatusCode LowCardinality(String), + DeploymentEnv LowCardinality(String), + HasError UInt8, + TraceState String, + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS traces ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SpanId String, + ParentSpanId String, + TraceState String, + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + ServiceName LowCardinality(String), + ResourceSchemaUrl String, + ResourceAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + Duration UInt64 DEFAULT 0, + StatusCode LowCardinality(String), + StatusMessage String, + SpanAttributes Map(LowCardinality(String), String), + EventsTimestamp Array(DateTime64(9)), + EventsName Array(LowCardinality(String)), + EventsAttributes Array(Map(LowCardinality(String), String)), + LinksTraceId Array(String), + LinksSpanId Array(String), + LinksTraceState Array(String), + LinksAttributes Array(Map(LowCardinality(String), String)), + SampleRate Float64 DEFAULT multiIf(SpanAttributes['SampleRate'] != '' AND toFloat64OrZero(SpanAttributes['SampleRate']) >= 1.0, toFloat64OrZero(SpanAttributes['SampleRate']), match(TraceState, 'th:[0-9a-f]+'), 1.0 / greatest(1.0 - reinterpretAsUInt64(reverse(unhex(rightPad(extract(TraceState, 'th:([0-9a-f]+)'), 16, '0')))) / pow(2.0, 64), 0.0001), 1.0), + IsEntryPoint UInt8 DEFAULT if(SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '', 1, 0), + ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)), + ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)), + SpanAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(SpanAttributes), mapValues(SpanAttributes)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_keys mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_vals mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, ServiceName, SpanName, toDateTime(Timestamp)) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS traces_aggregates_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + StatusCode LowCardinality(String), + IsEntryPoint UInt8, + DeploymentEnv LowCardinality(String), + WeightedCount SimpleAggregateFunction(sum, Float64), + WeightedDurationSum SimpleAggregateFunction(sum, Float64), + WeightedErrorCount SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95, 0.99), UInt64, UInt32), + DurationMin SimpleAggregateFunction(min, UInt64), + DurationMax SimpleAggregateFunction(max, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS +SELECT + OrgId, + Timestamp, + TraceId, + SpanAttributes['maple_ai.session.id'] AS SessionId, + SpanAttributes['maple_ai.vendor.id'] AS VendorId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model, + coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName, + coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName, + SpanId, + ParentSpanId, + Duration, + toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError, + toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall, + toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall, + 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'], ''), SpanAttributes['llm.token_count.prompt'])) + 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'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) + 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'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS Tokens, + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost + FROM traces + WHERE SpanAttributes['maple_ai.vendor.id'] != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_by_time_mv TO error_events_by_time AS +WITH + arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei, + if(_ei > 0, EventsAttributes[_ei]['exception.type'], '') AS _exType, + if(_ei > 0, EventsAttributes[_ei]['exception.message'], StatusMessage) AS _exMsg, + if(_ei > 0, EventsAttributes[_ei]['exception.stacktrace'], '') AS _exStack, + -- Frame lines are matched by SHAPE, not by "contains :NUMBER". The old + -- rule accepted any line with a colon-digit, which let non-frame lines + -- in: Drizzle's `params: ` line, and the `Type: message` + -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values + -- and message text then entered the hash and split one bug into + -- thousands of issues — 23,035 fingerprints for six real + -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError + -- ones. + -- + -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts, + -- as is every redaction below. They used to be hand-copied here, which + -- let the reference implementation the tests exercise drift away from + -- the SQL that actually runs, silently. + arraySlice( + arrayFilter( + line -> match(line, '^[ \\t]*at |^[ \\t]*File "|^[ \\t]+from [^ ]+:[0-9]+|^[^ \\t@]+@[^ \\t]*:[0-9]+|^[ \\t]+[^ \\t]+\\.(go|rs):[0-9]+|^[0-9]+ +\\S.* +0x[0-9a-fA-F]+'), + splitByChar('\n', _exStack) + ), + 1, 3 + ) AS _rawFrames, + -- Redact every volatile token a frame line can carry: the URL origin + -- (so preview hosts share one fingerprint), Vite's 8-char bundle + -- content hash (so a deploy does not re-split every triaged browser and + -- Worker issue), then line numbers, hex pointers and long id runs. See + -- FRAME_REDACTIONS for the order and the reasoning. + arrayMap( + line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''), + _rawFrames + ) AS _topFrames, + if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame, + arrayStringConcat(_topFrames, '\n') AS _fpFrames, + -- JSON detection for the message signature below. + isValidJSON(StatusMessage) AS _isJson, + _isJson AND JSONType(StatusMessage) = 'Object' AS _isJsonObj, + -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level + -- keys, redact volatile tokens (long hex / numbers) in each raw value, then + -- sort by "key=value" so key order & whitespace don't matter. No assumption + -- about which keys exist — works for any producer's JSON shape. (Nested + -- objects are hashed as their raw substring; only top-level is canonicalized.) + arrayStringConcat( + arraySort( + arrayMap( + kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')), + JSONExtractKeysAndValuesRaw(StatusMessage) + ) + ), + '|' + ) AS _jsonSig, + -- The message signature is folded in ALWAYS, not only when there are no + -- frames. Bundled runtimes minify every module into one file, so the top + -- three frames of a Worker error are `toDatabaseError (worker.js)` for + -- every failing query alike: on frames alone, 25 distinct DatabaseError + -- bugs (316k occurrences) collapse into a single issue. The signature + -- restores that discrimination, and it cannot reinflate cardinality the + -- way a raw prefix would because everything variable is redacted first: + -- emails, URL origins, home directories, query strings, quoted values, + -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order, + -- what is deliberately kept, and the one residual it cannot reach. + multiIf( + _isJsonObj, _jsonSig, + substringUTF8( + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(StatusMessage, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'), + 1, 120 + ) + ) AS _msgSig, + -- Display-only, best-effort human label (decoupled from the fingerprint: + -- many labels may map to one hash). The broad key list here is a DISPLAY + -- heuristic only; the fingerprint above makes no key-name assumption. + multiIf( + JSONExtractString(StatusMessage, 'title') != '', JSONExtractString(StatusMessage, 'title'), + JSONExtractString(StatusMessage, 'message') != '', JSONExtractString(StatusMessage, 'message'), + JSONExtractString(StatusMessage, 'error') != '', JSONExtractString(StatusMessage, 'error'), + JSONExtractString(StatusMessage, '_tag') != '', JSONExtractString(StatusMessage, '_tag'), + JSONExtractString(StatusMessage, 'reason') != '', JSONExtractString(StatusMessage, 'reason'), + JSONExtractString(StatusMessage, 'name') != '', JSONExtractString(StatusMessage, 'name'), + JSONExtractString(StatusMessage, 'type') != '', extract(JSONExtractString(StatusMessage, 'type'), '([^/]+)$'), + 'JSON error' + ) AS _jsonLabel, + multiIf( + StatusMessage = '', 'Unknown Error', + position(StatusMessage, '{ readonly') = 1 OR position(StatusMessage, '└─') > 0, + if( + extract(StatusMessage, 'readonly (\\w+)') != '', + concat('Schema parse error: ', extract(StatusMessage, 'readonly (\\w+)')), + 'Schema parse error' + ), + _isJsonObj OR position(StatusMessage, '[') = 1, _jsonLabel, + left(StatusMessage, multiIf( + position(StatusMessage, ': ') > 3, toInt64(position(StatusMessage, ': ')) - 1, + position(StatusMessage, ' (') > 3, toInt64(position(StatusMessage, ' (')) - 1, + position(StatusMessage, '\n') > 3, toInt64(position(StatusMessage, '\n')) - 1, + least(toInt64(length(StatusMessage)), 150) + )) + ) AS _statusLabel, + if(_exType != '', _exType, _statusLabel) AS _errorLabel, + -- Both semconv spellings; the current key wins when both are present. + toUInt16OrZero( + if( + SpanAttributes['http.response.status_code'] != '', + SpanAttributes['http.response.status_code'], + SpanAttributes['http.status_code'] + ) + ) AS _httpStatus + SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + _exType AS ExceptionType, + _exMsg AS ExceptionMessage, + _exStack AS ExceptionStacktrace, + _topFrame AS TopFrame, + cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash, + StatusMessage, + Duration, + _errorLabel AS ErrorLabel, + ResourceAttributes['service.version'] AS ServiceVersion + FROM traces + WHERE StatusCode = 'Error' + -- Client-side runtimes (notably the native Cloudflare Workers + -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot + -- traffic arrived here as unlabelled "Unknown Error" issues. Drop a + -- span only when all three hold: 4xx, no exception event, and no + -- exception type. 5xx and anything carrying an exception still count, + -- and SpanKind is deliberately not consulted — these are Client spans. + AND NOT ( + _httpStatus >= 400 AND _httpStatus < 500 + AND _ei = 0 + AND _exType = '' + ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_mv TO error_events AS +WITH + arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei, + if(_ei > 0, EventsAttributes[_ei]['exception.type'], '') AS _exType, + if(_ei > 0, EventsAttributes[_ei]['exception.message'], StatusMessage) AS _exMsg, + if(_ei > 0, EventsAttributes[_ei]['exception.stacktrace'], '') AS _exStack, + -- Frame lines are matched by SHAPE, not by "contains :NUMBER". The old + -- rule accepted any line with a colon-digit, which let non-frame lines + -- in: Drizzle's `params: ` line, and the `Type: message` + -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values + -- and message text then entered the hash and split one bug into + -- thousands of issues — 23,035 fingerprints for six real + -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError + -- ones. + -- + -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts, + -- as is every redaction below. They used to be hand-copied here, which + -- let the reference implementation the tests exercise drift away from + -- the SQL that actually runs, silently. + arraySlice( + arrayFilter( + line -> match(line, '^[ \\t]*at |^[ \\t]*File "|^[ \\t]+from [^ ]+:[0-9]+|^[^ \\t@]+@[^ \\t]*:[0-9]+|^[ \\t]+[^ \\t]+\\.(go|rs):[0-9]+|^[0-9]+ +\\S.* +0x[0-9a-fA-F]+'), + splitByChar('\n', _exStack) + ), + 1, 3 + ) AS _rawFrames, + -- Redact every volatile token a frame line can carry: the URL origin + -- (so preview hosts share one fingerprint), Vite's 8-char bundle + -- content hash (so a deploy does not re-split every triaged browser and + -- Worker issue), then line numbers, hex pointers and long id runs. See + -- FRAME_REDACTIONS for the order and the reasoning. + arrayMap( + line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''), + _rawFrames + ) AS _topFrames, + if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame, + arrayStringConcat(_topFrames, '\n') AS _fpFrames, + -- JSON detection for the message signature below. + isValidJSON(StatusMessage) AS _isJson, + _isJson AND JSONType(StatusMessage) = 'Object' AS _isJsonObj, + -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level + -- keys, redact volatile tokens (long hex / numbers) in each raw value, then + -- sort by "key=value" so key order & whitespace don't matter. No assumption + -- about which keys exist — works for any producer's JSON shape. (Nested + -- objects are hashed as their raw substring; only top-level is canonicalized.) + arrayStringConcat( + arraySort( + arrayMap( + kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')), + JSONExtractKeysAndValuesRaw(StatusMessage) + ) + ), + '|' + ) AS _jsonSig, + -- The message signature is folded in ALWAYS, not only when there are no + -- frames. Bundled runtimes minify every module into one file, so the top + -- three frames of a Worker error are `toDatabaseError (worker.js)` for + -- every failing query alike: on frames alone, 25 distinct DatabaseError + -- bugs (316k occurrences) collapse into a single issue. The signature + -- restores that discrimination, and it cannot reinflate cardinality the + -- way a raw prefix would because everything variable is redacted first: + -- emails, URL origins, home directories, query strings, quoted values, + -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order, + -- what is deliberately kept, and the one residual it cannot reach. + multiIf( + _isJsonObj, _jsonSig, + substringUTF8( + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(StatusMessage, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'), + 1, 120 + ) + ) AS _msgSig, + -- Display-only, best-effort human label (decoupled from the fingerprint: + -- many labels may map to one hash). The broad key list here is a DISPLAY + -- heuristic only; the fingerprint above makes no key-name assumption. + multiIf( + JSONExtractString(StatusMessage, 'title') != '', JSONExtractString(StatusMessage, 'title'), + JSONExtractString(StatusMessage, 'message') != '', JSONExtractString(StatusMessage, 'message'), + JSONExtractString(StatusMessage, 'error') != '', JSONExtractString(StatusMessage, 'error'), + JSONExtractString(StatusMessage, '_tag') != '', JSONExtractString(StatusMessage, '_tag'), + JSONExtractString(StatusMessage, 'reason') != '', JSONExtractString(StatusMessage, 'reason'), + JSONExtractString(StatusMessage, 'name') != '', JSONExtractString(StatusMessage, 'name'), + JSONExtractString(StatusMessage, 'type') != '', extract(JSONExtractString(StatusMessage, 'type'), '([^/]+)$'), + 'JSON error' + ) AS _jsonLabel, + multiIf( + StatusMessage = '', 'Unknown Error', + position(StatusMessage, '{ readonly') = 1 OR position(StatusMessage, '└─') > 0, + if( + extract(StatusMessage, 'readonly (\\w+)') != '', + concat('Schema parse error: ', extract(StatusMessage, 'readonly (\\w+)')), + 'Schema parse error' + ), + _isJsonObj OR position(StatusMessage, '[') = 1, _jsonLabel, + left(StatusMessage, multiIf( + position(StatusMessage, ': ') > 3, toInt64(position(StatusMessage, ': ')) - 1, + position(StatusMessage, ' (') > 3, toInt64(position(StatusMessage, ' (')) - 1, + position(StatusMessage, '\n') > 3, toInt64(position(StatusMessage, '\n')) - 1, + least(toInt64(length(StatusMessage)), 150) + )) + ) AS _statusLabel, + if(_exType != '', _exType, _statusLabel) AS _errorLabel, + -- Both semconv spellings; the current key wins when both are present. + toUInt16OrZero( + if( + SpanAttributes['http.response.status_code'] != '', + SpanAttributes['http.response.status_code'], + SpanAttributes['http.status_code'] + ) + ) AS _httpStatus + SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + _exType AS ExceptionType, + _exMsg AS ExceptionMessage, + _exStack AS ExceptionStacktrace, + _topFrame AS TopFrame, + cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash, + StatusMessage, + Duration, + _errorLabel AS ErrorLabel, + ResourceAttributes['service.version'] AS ServiceVersion + FROM traces + WHERE StatusCode = 'Error' + -- Client-side runtimes (notably the native Cloudflare Workers + -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot + -- traffic arrived here as unlabelled "Unknown Error" issues. Drop a + -- span only when all three hold: 4xx, no exception event, and no + -- exception type. 5xx and anything carrying an exception still count, + -- and SpanKind is deliberately not consulted — these are Client spans. + AND NOT ( + _httpStatus >= 400 AND _httpStatus < 500 + AND _ei = 0 + AND _exType = '' + ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_fingerprints_minutely_mv TO error_fingerprints_minutely AS +SELECT + OrgId, + toStartOfMinute(Timestamp) AS Minute, + FingerprintHash, + anyLast(ServiceName) AS ServiceName, + anyLast(ExceptionType) AS ExceptionType, + anyLast(ExceptionMessage) AS ExceptionMessage, + anyLast(ErrorLabel) AS ErrorLabel, + anyLast(TopFrame) AS TopFrame, + count() AS OccurrenceCount, + min(Timestamp) AS FirstSeen, + max(Timestamp) AS LastSeen, + -- Distinct builds, not a sample: see ServiceVersions on the datasource. + groupUniqArray(ServiceVersion) AS ServiceVersions + FROM error_events + GROUP BY OrgId, Minute, FingerprintHash; + +CREATE MATERIALIZED VIEW IF NOT EXISTS identity_links_mv TO identity_links AS +SELECT + OrgId, + VisitorId, + UserId, + StartTime AS FirstSeen + FROM session_replays + WHERE VisitorId != '' AND UserId != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS log_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(LogAttributes)) AS AttributeKey, + 'log' AS AttributeScope, + count() AS UsageCount + FROM logs + WHERE LogAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS log_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'log' AS AttributeScope, + count() AS UsageCount + FROM logs + ARRAY JOIN + mapKeys(LogAttributes) AS AttributeKey, + mapValues(LogAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS logs_aggregates_hourly_mv TO logs_aggregates_hourly AS +SELECT + OrgId, + toStartOfHour(TimestampTime) AS Hour, + ServiceName, + SeverityText, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS Count, + sum(length(Body) + 200) AS SizeBytes, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM logs + GROUP BY OrgId, Hour, ServiceName, SeverityText, DeploymentEnv, ServiceNamespace; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + arrayJoin(mapKeys(Attributes)) AS AttributeKey, + 'metric' AS AttributeScope, + count() AS UsageCount + FROM metrics_sum + WHERE Attributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + AttributeKey, + AttributeValue, + 'metric' AS AttributeScope, + count() AS UsageCount + FROM metrics_sum + ARRAY JOIN + mapKeys(Attributes) AS AttributeKey, + mapValues(Attributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_exp_histogram_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'exponential_histogram' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_exponential_histogram + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_gauge_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'gauge' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_gauge + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_histogram_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'histogram' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_histogram + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_sum_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'sum' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + anyLast(toUInt8(IsMonotonic)) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_sum + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS product_events_mv TO product_events AS +SELECT + OrgId, + Timestamp, + 'browser' AS Source, + SessionId, + Seq, + VisitorId, + UserId, + GroupId, + Type AS Kind, + if(Type = 'navigation', '$pageview', Message) AS EventName, + domain(Url) AS Host, + path(Url) AS PagePath, + Url, + '' AS ServiceName, + Attributes + FROM session_events + WHERE Type IN ('navigation', 'custom'); + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_external_edges_hourly_mv TO service_external_edges_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', 'messaging', + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', 'rpc', + 'http' + ) AS TargetType, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', SpanAttributes['messaging.system'], + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', SpanAttributes['rpc.system'], + '' + ) AS TargetSystem, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', + if(coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '', coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']), SpanAttributes['messaging.system']), + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', + if(SpanAttributes['rpc.service'] != '', SpanAttributes['rpc.service'], SpanAttributes['rpc.system']), + if(SpanAttributes['server.address'] != '', + SpanAttributes['server.address'], + if(SpanAttributes['http.host'] != '', + SpanAttributes['http.host'], + SpanAttributes['url.authority'])) + ) AS TargetName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(Duration / 1000000) AS DurationSumMs, + max(Duration / 1000000) AS MaxDurationMs, + sum(SampleRate) AS SampleRateSum, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND SpanAttributes['db.system.name'] = '' + AND ServiceName != '' + AND ( + SpanAttributes['server.address'] != '' + OR SpanAttributes['http.host'] != '' + OR SpanAttributes['url.authority'] != '' + OR coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' + OR SpanAttributes['messaging.system'] != '' + OR SpanAttributes['rpc.service'] != '' + OR SpanAttributes['rpc.system'] != '' + ) + GROUP BY OrgId, Hour, ServiceName, TargetType, TargetSystem, TargetName, DeploymentEnv + HAVING TargetName != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_children_mv TO service_map_children AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + ParentSpanId, + ServiceName, + SpanKind, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') + AND ParentSpanId != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_db_edges_hourly_mv TO service_map_db_edges_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) AS DbSystem, + if(match(coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name']), '^([0-9a-fA-F]{32}|.*[.]hyperdrive[.]local)$'), 'hyperdrive', coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name'])) AS DbNamespace, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(Duration / 1000000) AS DurationSumMs, + max(Duration / 1000000) AS MaxDurationMs, + countIf(TraceState LIKE '%th:%') AS SampledSpanCount, + countIf(TraceState = '' OR TraceState NOT LIKE '%th:%') AS UnsampledSpanCount, + sum(SampleRate) AS SampleRateSum, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) != '' + AND ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DbSystem, DbNamespace, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_db_query_shapes_hourly_mv TO service_map_db_query_shapes_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) AS DbSystem, + if(match(coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name']), '^([0-9a-fA-F]{32}|.*[.]hyperdrive[.]local)$'), 'hyperdrive', coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name'])) AS DbNamespace, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + coalesce( + nullIf(SpanAttributes['db.query.fingerprint'], ''), + nullIf(SpanAttributes['db.statement.fingerprint'], ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', toString(cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(lower(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement'])), '\'[^\']*\'', '?'), '\\bin\\s*\\([^)]*\\)', 'in (?)'), '[0-9]+(\\.[0-9]+)?', '?'), '\\s+', ' '), '^\\s+|\\s+$', ''))), ''), ''), + toString(cityHash64(coalesce( + nullIf(SpanAttributes['db.query.summary'], ''), + nullIf(if(SpanAttributes['db.operation.name'] != '', trimBoth(concat(SpanAttributes['db.operation.name'], if(coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace']) != '', concat(' ', coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace'])), ''))), ''), ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', trimBoth(concat(upper(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '^\\s*(\\w+)')), if(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)') != '', concat(' ', extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)')), ''))), ''), ''), + nullIf(SpanAttributes['query.context'], ''), + nullIf(SpanAttributes['db.operation.name'], ''), + nullIf(SpanAttributes['db.operation'], ''), + SpanName +))) +) AS QueryKey, + any(substring(coalesce( + nullIf(SpanAttributes['db.query.summary'], ''), + nullIf(if(SpanAttributes['db.operation.name'] != '', trimBoth(concat(SpanAttributes['db.operation.name'], if(coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace']) != '', concat(' ', coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace'])), ''))), ''), ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', trimBoth(concat(upper(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '^\\s*(\\w+)')), if(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)') != '', concat(' ', extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)')), ''))), ''), ''), + nullIf(SpanAttributes['query.context'], ''), + nullIf(SpanAttributes['db.operation.name'], ''), + nullIf(SpanAttributes['db.operation'], ''), + SpanName +), 1, 220)) AS QueryLabel, + any(substring(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), 1, 1000)) AS SampleStatement, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(SampleRate) AS EstimatedCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration) * SampleRate / 1000000) AS WeightedDurationSumMs, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) != '' + AND ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DbSystem, DbNamespace, DeploymentEnv, QueryKey; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_edges_hourly_ingest_mv TO service_map_edges_hourly AS +SELECT + OrgId, + Hour, + SourceService, + TargetService, + DeploymentEnv, + CallCount, + ErrorCount, + DurationSumMs, + MaxDurationMs, + SampledSpanCount, + UnsampledSpanCount, + SampleRateSum + FROM service_map_edges_hourly_ingest; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_spans_mv TO service_map_spans AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + SpanKind, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv + FROM traces + WHERE SpanKind IN ('Client', 'Producer', 'Server', 'Consumer'); + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_operations_hourly_mv TO service_operations_hourly AS +SELECT + OrgId, + toStartOfHour(Minute) AS Hour, + ServiceName, + DeploymentEnv, + SpanName, + sum(SpanCount) AS SpanCount, + sum(EstimatedSpanCount) AS EstimatedSpanCount, + sum(ErrorCount) AS ErrorCount, + sum(EstimatedErrorCount) AS EstimatedErrorCount, + sum(DurationSum) AS DurationSum, + quantilesTDigestMergeState(0.5, 0.95)(DurationQuantiles) AS DurationQuantiles, + sum(ClassifiedSpanCount) AS ClassifiedSpanCount, + sum(ServerSpanCount) AS ServerSpanCount, + sum(RoutedSpanCount) AS RoutedSpanCount + FROM service_operations_minutely + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv, SpanName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_operations_minutely_mv TO service_operations_minutely AS +SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + if(((SpanName LIKE 'http.server %' OR SpanName IN ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')) AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != '')), concat(if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), ' ', if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path'])), SpanName) AS SpanName, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95)(Duration) AS DurationQuantiles, + count() AS ClassifiedSpanCount, + countIf(SpanKind IN ('Server', 'Consumer')) AS ServerSpanCount, + countIf(SpanAttributes['http.route'] != '') AS RoutedSpanCount + FROM traces + GROUP BY OrgId, Minute, ServiceName, DeploymentEnv, SpanName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_hourly_mv TO service_overview_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS DurationQuantiles, + min(toDateTime(Timestamp)) AS FirstSeen, + countIf(StatusCode != 'Error' AND Duration < 500000000) AS ApdexSatisfiedCount, + countIf(StatusCode != 'Error' AND Duration >= 500000000 AND Duration < 2000000000) AS ApdexToleratingCount + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '' + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv, ServiceNamespace, CommitSha; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_minutely_mv TO service_overview_minutely AS +SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS DurationQuantiles, + min(toDateTime(Timestamp)) AS FirstSeen, + countIf(StatusCode != 'Error' AND Duration < 500000000) AS ApdexSatisfiedCount, + countIf(StatusCode != 'Error' AND Duration >= 500000000 AND Duration < 2000000000) AS ApdexToleratingCount + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '' + GROUP BY OrgId, Minute, ServiceName, DeploymentEnv, ServiceNamespace, CommitSha; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_spans_mv TO service_overview_spans AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + ServiceName, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + SampleRate, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_platforms_hourly_mv TO service_platforms_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + max(ResourceAttributes['k8s.cluster.name']) AS K8sCluster, + max(ResourceAttributes['k8s.pod.name']) AS K8sPodName, + max(ResourceAttributes['k8s.deployment.name']) AS K8sDeploymentName, + max(ResourceAttributes['k8s.statefulset.name']) AS K8sStatefulSetName, + max(ResourceAttributes['k8s.daemonset.name']) AS K8sDaemonSetName, + max(ResourceAttributes['k8s.namespace.name']) AS K8sNamespaceName, + max(ResourceAttributes['cloud.platform']) AS CloudPlatform, + max(ResourceAttributes['cloud.provider']) AS CloudProvider, + max(ResourceAttributes['faas.name']) AS FaasName, + max(ResourceAttributes['maple.sdk.type']) AS MapleSdkType, + max(ResourceAttributes['process.runtime.name']) AS ProcessRuntimeName, + count() AS SpanCount + FROM traces + WHERE ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_logs_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(TimestampTime) AS Hour, + count() AS LogCount, + sum(length(Body) + 200) AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM logs + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_exp_histogram_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + count() AS ExpHistogramMetricCount, + count() * 300 AS ExpHistogramMetricSizeBytes + FROM metrics_exponential_histogram + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_gauge_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + count() AS GaugeMetricCount, + count() * 150 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_gauge + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_histogram_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + count() AS HistogramMetricCount, + count() * 250 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_histogram + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_sum_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + count() AS SumMetricCount, + count() * 150 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_sum + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_traces_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + count() AS TraceCount, + sum(length(SpanName) + 300) AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM traces + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS span_metrics_calls_hourly_mv TO span_metrics_calls_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + ServiceName, + MetricName, + Attributes['span.kind'] AS SpanKind, + cityHash64(mapKeys(Attributes), mapValues(Attributes)) AS AttrFingerprint, + cityHash64(mapKeys(ResourceAttributes), mapValues(ResourceAttributes)) AS ResourceFingerprint, + StartTimeUnix, + argMaxState(Value, TimeUnix) AS LastValue + FROM metrics_sum + -- 'traces.span.metrics.calls' is the name the collector actually emits: + -- spanmetricsconnector output is namespaced by the pipeline it is attached + -- to. Without it this MV matched nothing and the target sat at 0 rows since + -- it was created, while ~880k rows / 2 days of the real counter flowed past + -- into metrics_sum and every read fell back to the raw window-function scan + -- (~7s p95 -- see queries/metrics.ts). Keep this list in sync with + -- SPAN_METRICS_CALLS_NAMES on the read side. + WHERE MetricName IN ('span.metrics.calls', 'calls', 'traces.span.metrics.calls') AND IsMonotonic + GROUP BY OrgId, Hour, ServiceName, MetricName, SpanKind, AttrFingerprint, ResourceFingerprint, StartTimeUnix; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_detail_spans_mv TO trace_detail_spans AS +SELECT + OrgId, + Timestamp, + TraceId, + SpanId, + ParentSpanId, + SpanName, + SpanKind, + ServiceName, + Duration, + StatusCode, + StatusMessage, + SpanAttributes, + ResourceAttributes + FROM traces; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_list_mv_mv TO trace_list_mv AS +SELECT + OrgId, + TraceId, + toDateTime(Timestamp) AS Timestamp, + ServiceName, + if( + (SpanName LIKE 'http.server %' OR SpanName IN ('GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS')) + AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != ''), + concat( + if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), + ' ', + if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path']) + ), + SpanName + ) AS SpanName, + SpanKind, + Duration, + StatusCode, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS HttpMethod, + if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], if(SpanAttributes['url.path'] != '', SpanAttributes['url.path'], SpanAttributes['http.target'])) AS HttpRoute, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS HttpStatusCode, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + toUInt8( + StatusCode = 'Error' + OR (SpanAttributes['http.status_code'] != '' AND toUInt16OrZero(SpanAttributes['http.status_code']) >= 500) + OR (SpanAttributes['http.response.status_code'] != '' AND toUInt16OrZero(SpanAttributes['http.response.status_code']) >= 500) + ) AS HasError, + TraceState, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM traces + WHERE ParentSpanId = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_resource_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(ResourceAttributes)) AS AttributeKey, + 'resource' AS AttributeScope, + count() AS UsageCount + FROM traces + WHERE ResourceAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_resource_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'resource' AS AttributeScope, + count() AS UsageCount + FROM traces + ARRAY JOIN + mapKeys(ResourceAttributes) AS AttributeKey, + mapValues(ResourceAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_span_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(SpanAttributes)) AS AttributeKey, + 'span' AS AttributeScope, + count() AS UsageCount + FROM traces + WHERE SpanAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_span_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'span' AS AttributeScope, + count() AS UsageCount + FROM traces + ARRAY JOIN + mapKeys(SpanAttributes) AS AttributeKey, + mapValues(SpanAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS traces_aggregates_hourly_mv TO traces_aggregates_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + SpanName, + SpanKind, + StatusCode, + IsEntryPoint, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + sum(SampleRate) AS WeightedCount, + sum(toFloat64(Duration) * SampleRate) AS WeightedDurationSum, + sumIf(SampleRate, StatusCode = 'Error') AS WeightedErrorCount, + quantilesTDigestWeightedState(0.5, 0.95, 0.99)(Duration, toUInt32(SampleRate)) AS DurationQuantiles, + min(Duration) AS DurationMin, + max(Duration) AS DurationMax + FROM traces + GROUP BY OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv; diff --git a/apps/cli/src/server/schema/local-schema.sql b/apps/cli/src/server/schema/local-schema.sql index 5390dc109..eec0f1588 100644 --- a/apps/cli/src/server/schema/local-schema.sql +++ b/apps/cli/src/server/schema/local-schema.sql @@ -1,7 +1,7 @@ -- This file is generated by scripts/generate-clickhouse-schema-sql.ts -- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. --- projectRevision: bf3419c18e581ffab5fa4e24aa56f421ac9c3d5a1ce734596747f2331d7d2ae0 --- localSchemaVersion: 15 +-- projectRevision: 25e53de2337d2079a4efc306435d995387cdf89cecafa60e142f5665ccdeebbb +-- localSchemaVersion: 16 CREATE TABLE IF NOT EXISTS ai_trace_index ( OrgId LowCardinality(String), @@ -9,7 +9,19 @@ CREATE TABLE IF NOT EXISTS ai_trace_index ( TraceId String, SessionId String, VendorId LowCardinality(String), - ServiceName LowCardinality(String) + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + Model LowCardinality(String), + AgentName LowCardinality(String), + ToolName LowCardinality(String), + SpanId String, + ParentSpanId String, + Duration UInt64, + IsError UInt8, + IsLlmCall UInt8, + IsToolCall UInt8, + Tokens Float64, + Cost Float64 ) ENGINE = MergeTree PARTITION BY toDate(Timestamp) @@ -883,7 +895,19 @@ SELECT TraceId, SpanAttributes['maple_ai.session.id'] AS SessionId, SpanAttributes['maple_ai.vendor.id'] AS VendorId, - ServiceName + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model, + coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName, + coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName, + SpanId, + ParentSpanId, + Duration, + toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError, + toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall, + toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall, + 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'], ''), SpanAttributes['llm.token_count.prompt'])) + 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'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) + 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'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS Tokens, + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost FROM traces WHERE SpanAttributes['maple_ai.vendor.id'] != ''; diff --git a/apps/cli/test/local-store-migrations.test.ts b/apps/cli/test/local-store-migrations.test.ts index e6c864229..7c0633fe8 100644 --- a/apps/cli/test/local-store-migrations.test.ts +++ b/apps/cli/test/local-store-migrations.test.ts @@ -30,6 +30,7 @@ import { LOCAL_SCHEMA_V13_MANIFEST, LOCAL_SCHEMA_V14, LOCAL_SCHEMA_V15, + LOCAL_SCHEMA_V16, SCHEMA_DIGEST, SCHEMA_FINGERPRINT, } from "../src/server/schema-identity" @@ -77,16 +78,16 @@ import { tmpdir } from "node:os" import { join } from "node:path" describe("current local schema identity", () => { - it("matches the generated v15 revision and keeps the issue-297 identity frozen", () => { - expect(SCHEMA_FINGERPRINT).toBe("24710426938d7b4a") - expect(SCHEMA_DIGEST).toBe("24710426938d7b4adf615f87f78315c2a5c6145a0029c4f244a339888b25f6d3") + it("matches the generated v16 revision and keeps the issue-297 identity frozen", () => { + expect(SCHEMA_FINGERPRINT).toBe("d975e674ce66af41") + expect(SCHEMA_DIGEST).toBe("d975e674ce66af417e4398d8ca336d41340c9c1aa7b26082a6c55ecd02effe38") expect(ISSUE_297_TARGET_SCHEMA_PROJECT_REVISION).toBe( "506bc745f7a7eca202ec905a6403a6815e86413faf0cd3cbbf73881023edce91", ) expect(CURRENT_SCHEMA_PROJECT_REVISION).toMatch(/^[0-9a-f]{64}$/) expect(LOCAL_SCHEMA_MANIFEST.objects.length).toBeGreaterThan(60) - expect(CURRENT_LOCAL_SCHEMA.version).toBe(15) - expect(CURRENT_LOCAL_SCHEMA).toEqual(LOCAL_SCHEMA_V15) + expect(CURRENT_LOCAL_SCHEMA.version).toBe(16) + expect(CURRENT_LOCAL_SCHEMA).toEqual(LOCAL_SCHEMA_V16) const logs = LOCAL_SCHEMA_MANIFEST.objects.find((object) => object.name === "logs") expect(logs?.columns.some((column) => column.name.startsWith("idx_"))).toBe(false) expect(logs?.indexes).toContain("idx_lower_body") @@ -312,6 +313,7 @@ describe("local migration registry", () => { "local-0012-to-0013-service-operations-discriminators", "local-0013-to-0014-ai-trace-index", "local-0014-to-0015-commit-sha-vcs-revision", + "local-0015-to-0016-ai-trace-index-filter-columns", ]) expect(chain[0]?.from.fingerprint).toBe(LEGACY_SCHEMA_FINGERPRINT) expect(chain[0]?.to).toEqual(LOCAL_SCHEMA_V1) @@ -358,7 +360,7 @@ describe("local migration registry", () => { // One past the current tip — bump alongside LOCAL_SCHEMA_VERSION, or this // stops testing the future-store guard and starts testing the // unknown-fingerprint one. - { ...CURRENT_LOCAL_SCHEMA, version: 16, fingerprint: "future", digest: SCHEMA_DIGEST }, + { ...CURRENT_LOCAL_SCHEMA, version: 17, fingerprint: "future", digest: SCHEMA_DIGEST }, CURRENT_LOCAL_SCHEMA, ), ).toThrow(/newer than this build/) @@ -1362,6 +1364,7 @@ describe("v10 -> v11 product events module", () => { "local-0012-to-0013-service-operations-discriminators", "local-0013-to-0014-ai-trace-index", "local-0014-to-0015-commit-sha-vcs-revision", + "local-0015-to-0016-ai-trace-index-filter-columns", ]) expect(chain[0]?.to).toEqual(LOCAL_SCHEMA_V11) // The dropped table is declared, and the backfilled ones say what they diff --git a/apps/cli/test/native-local-store-migration.sh b/apps/cli/test/native-local-store-migration.sh index 960c789e9..5d3ddf426 100755 --- a/apps/cli/test/native-local-store-migration.sh +++ b/apps/cli/test/native-local-store-migration.sh @@ -142,7 +142,7 @@ grep -q "local store migrated" "$ROOT/migrate.out" || fail "native migration did # must be bumped in lockstep with LOCAL_SCHEMA_VERSION and the matching # LOCAL_SCHEMA_V.fingerprint in apps/cli/src/server/schema-identity.ts; # leaving it on the previous version is what makes this step fail after a bump. -jq -e '.formatVersion == 2 and .activation == "active" and .schemaVersion == 15 and .schema == "24710426938d7b4a"' \ +jq -e '.formatVersion == 2 and .activation == "active" and .schemaVersion == 16 and .schema == "d975e674ce66af41"' \ "$ROOT/maple-store-version.json" >/dev/null || fail "native migration wrote the wrong active identity" step "reopening promoted store in a fresh server" diff --git a/apps/ingest/src/clickhouse_insert_mappings.rs b/apps/ingest/src/clickhouse_insert_mappings.rs index 1cfe86ea0..881a10d18 100644 --- a/apps/ingest/src/clickhouse_insert_mappings.rs +++ b/apps/ingest/src/clickhouse_insert_mappings.rs @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-clickhouse-insert-mappings.ts // Do not edit manually. -pub const PROJECT_REVISION: &str = "bf3419c18e581ffab5fa4e24aa56f421ac9c3d5a1ce734596747f2331d7d2ae0"; +pub const PROJECT_REVISION: &str = "25e53de2337d2079a4efc306435d995387cdf89cecafa60e142f5665ccdeebbb"; // Gate for BYO-ClickHouse ingest readiness — the migration version, NOT the // Tinybird-coupled PROJECT_REVISION. Compared against // org_clickhouse_settings.schema_version. See @maple/domain/clickhouse diff --git a/apps/web/src/api/warehouse/ai-sessions.ts b/apps/web/src/api/warehouse/ai-sessions.ts index c9ac3de79..45398bdd7 100644 --- a/apps/web/src/api/warehouse/ai-sessions.ts +++ b/apps/web/src/api/warehouse/ai-sessions.ts @@ -1,5 +1,7 @@ import { Clock, Effect, Schema } from "effect" import { + AiSessionSortDir, + AiSessionSortKey, GetAiSessionSpansRequest, ListAiSessionsFacetsRequest, ListAiSessionsRequest, @@ -16,6 +18,25 @@ const ListAiSessionsInput = Schema.Struct({ offset: Schema.optional(Schema.Number), vendorIds: Schema.optional(Schema.Array(Schema.String)), serviceNames: Schema.optional(Schema.Array(Schema.String)), + deploymentEnvs: Schema.optional(Schema.Array(Schema.String)), + models: Schema.optional(Schema.Array(Schema.String)), + agentNames: Schema.optional(Schema.Array(Schema.String)), + toolNames: Schema.optional(Schema.Array(Schema.String)), + search: Schema.optional(Schema.String), + hasErrors: Schema.optional(Schema.Boolean), + excludeTraceSessions: Schema.optional(Schema.Boolean), + durationMinMs: Schema.optional(Schema.Number), + durationMaxMs: Schema.optional(Schema.Number), + costMin: Schema.optional(Schema.Number), + costMax: Schema.optional(Schema.Number), + tokensMin: Schema.optional(Schema.Number), + tokensMax: Schema.optional(Schema.Number), + llmCallsMin: Schema.optional(Schema.Number), + llmCallsMax: Schema.optional(Schema.Number), + toolCallsMin: Schema.optional(Schema.Number), + toolCallsMax: Schema.optional(Schema.Number), + sortBy: Schema.optional(AiSessionSortKey), + sortDir: Schema.optional(AiSessionSortDir), }) export type ListAiSessionsInput = Schema.Schema.Type @@ -37,13 +58,12 @@ export const listAiSessions = Effect.fn("AiSessions.listAiSessions")(function* ( Effect.gen(function* () { const client = yield* MapleInternalAtomClient return yield* client.aiSessionsInternal.list({ + // Everything but the window passes through by name; the request + // schema is where each field's bounds live. payload: new ListAiSessionsRequest({ + ...input, startTime: input.startTime ?? fallback.startTime, endTime: input.endTime ?? fallback.endTime, - limit: input.limit, - offset: input.offset, - vendorIds: input.vendorIds, - serviceNames: input.serviceNames, }), }) }), @@ -77,7 +97,14 @@ export const getAiSessionsFacets = Effect.fn("AiSessions.aiSessionsFacets")(func }) }), ) - return { vendors: result.vendors, services: result.services } + return { + vendors: result.vendors, + services: result.services, + environments: result.environments, + models: result.models, + agents: result.agents, + tools: result.tools, + } }) // Session spans (detail page) diff --git a/apps/web/src/components/agent-sessions/agent-sessions-filter-inputs.test.ts b/apps/web/src/components/agent-sessions/agent-sessions-filter-inputs.test.ts new file mode 100644 index 000000000..f2ede58d9 --- /dev/null +++ b/apps/web/src/components/agent-sessions/agent-sessions-filter-inputs.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" +import { agentSessionsFilterInputs, hasAgentSessionsFilters } from "./agent-sessions-filter-inputs" + +const window = { startTime: "2026-08-19 09:00:00", endTime: "2026-08-19 11:00:00" } + +describe("agentSessionsFilterInputs", () => { + it("sends only the window when nothing is set", () => { + expect(agentSessionsFilterInputs({}, window)).toEqual(window) + }) + + it("renames the URL keys and converts seconds to milliseconds", () => { + expect( + agentSessionsFilterInputs( + { + vendors: ["eve"], + services: [], + models: ["gpt-5.5"], + q: " wrun_01 ", + hasErrors: true, + grouped: true, + durationMin: 30, + durationMax: 600, + costMin: 0.1, + tokensMax: 200_000, + llmCallsMin: 1, + toolCallsMax: 9, + sortBy: "cost", + sortDir: "desc", + }, + window, + ), + ).toEqual({ + ...window, + vendorIds: ["eve"], + models: ["gpt-5.5"], + search: "wrun_01", + hasErrors: true, + excludeTraceSessions: true, + durationMinMs: 30_000, + durationMaxMs: 600_000, + costMin: 0.1, + tokensMax: 200_000, + llmCallsMin: 1, + toolCallsMax: 9, + sortBy: "cost", + sortDir: "desc", + }) + }) + + it("sorts by the menu row the URL resolves to, never by a pair the menu lacks", () => { + expect(agentSessionsFilterInputs({ sortBy: "startTime", sortDir: "desc" }, window)).toEqual(window) + expect(agentSessionsFilterInputs({ sortBy: "cost", sortDir: "asc" }, window)).toEqual(window) + expect(agentSessionsFilterInputs({ sortBy: "startTime", sortDir: "asc" }, window)).toEqual({ + ...window, + sortBy: "startTime", + sortDir: "asc", + }) + }) + + it("treats false toggles, empty arrays and blank search as no filter", () => { + expect(hasAgentSessionsFilters({})).toBe(false) + expect(hasAgentSessionsFilters({ hasErrors: false, services: [], sortBy: "cost" })).toBe(false) + expect(hasAgentSessionsFilters({ q: "a" })).toBe(true) + expect(hasAgentSessionsFilters({ durationMin: 0 })).toBe(true) + const inputs = agentSessionsFilterInputs({ hasErrors: false, grouped: false, q: " " }, window) + expect(inputs).toEqual(window) + }) +}) diff --git a/apps/web/src/components/agent-sessions/agent-sessions-filter-inputs.ts b/apps/web/src/components/agent-sessions/agent-sessions-filter-inputs.ts new file mode 100644 index 000000000..3bf175b9a --- /dev/null +++ b/apps/web/src/components/agent-sessions/agent-sessions-filter-inputs.ts @@ -0,0 +1,150 @@ +import type { AiSessionSortDir, AiSessionSortKey } from "@maple/domain/http" +import type { AiSessionsFilterInputs } from "@/hooks/use-infinite-ai-sessions" + +/** + * The URL state the agent-sessions list filters on. Structurally the decoded + * search schema from the route, declared here so this module stays importable + * without pulling in the route (and its component tree). + */ +export interface AgentSessionsSearchState { + readonly vendors?: ReadonlyArray + readonly services?: ReadonlyArray + readonly environments?: ReadonlyArray + readonly models?: ReadonlyArray + readonly agents?: ReadonlyArray + readonly tools?: ReadonlyArray + /** Session or trace id prefix. */ + readonly q?: string + readonly hasErrors?: boolean + /** Hide the `trace:` sessions — traces whose vendor exposes no session key. */ + readonly grouped?: boolean + /** Seconds, like the replays list; the warehouse filters in ms. */ + readonly durationMin?: number + readonly durationMax?: number + readonly costMin?: number + readonly costMax?: number + readonly tokensMin?: number + readonly tokensMax?: number + readonly llmCallsMin?: number + readonly llmCallsMax?: number + readonly toolCallsMin?: number + readonly toolCallsMax?: number + readonly sortBy?: AiSessionSortKey + readonly sortDir?: AiSessionSortDir +} + +/** The URL keys that are filters — everything the sidebar's Clear resets. */ +export const AGENT_SESSIONS_FILTER_KEYS = [ + "vendors", + "services", + "environments", + "models", + "agents", + "tools", + "q", + "hasErrors", + "grouped", + "durationMin", + "durationMax", + "costMin", + "costMax", + "tokensMin", + "tokensMax", + "llmCallsMin", + "llmCallsMax", + "toolCallsMin", + "toolCallsMax", +] as const satisfies ReadonlyArray + +/** + * One row of the sort menu: the measure and the direction it makes sense in. + * The list exposes no direction toggle — "most expensive" is the question, and + * "least expensive" is not one anybody asks a session list. + */ +export interface AgentSessionsSortOption { + readonly key: string + readonly label: string + readonly sortBy: AiSessionSortKey + readonly sortDir: AiSessionSortDir +} + +export const AGENT_SESSIONS_SORT_OPTIONS: ReadonlyArray = [ + { key: "newest", label: "Newest first", sortBy: "startTime", sortDir: "desc" }, + { key: "oldest", label: "Oldest first", sortBy: "startTime", sortDir: "asc" }, + { key: "longest", label: "Longest", sortBy: "durationMs", sortDir: "desc" }, + { key: "cost", label: "Most expensive", sortBy: "cost", sortDir: "desc" }, + { key: "tokens", label: "Most tokens", sortBy: "totalTokens", sortDir: "desc" }, + { key: "errors", label: "Most errors", sortBy: "errorSpanCount", sortDir: "desc" }, + { key: "llm-calls", label: "Most LLM calls", sortBy: "llmCalls", sortDir: "desc" }, + { key: "tool-calls", label: "Most tool calls", sortBy: "toolCalls", sortDir: "desc" }, +] + +export const DEFAULT_SORT_OPTION = AGENT_SESSIONS_SORT_OPTIONS[0]! + +/** + * The menu row a URL pair names; the default when the URL names none, or a + * pair the menu does not offer. The query is built from THIS, so what the + * select says is always what the list is sorted by. + */ +export function sortOptionFor( + sortBy: AiSessionSortKey | undefined, + sortDir: AiSessionSortDir | undefined, +): AgentSessionsSortOption { + return ( + AGENT_SESSIONS_SORT_OPTIONS.find( + (option) => option.sortBy === (sortBy ?? "startTime") && option.sortDir === (sortDir ?? "desc"), + ) ?? DEFAULT_SORT_OPTION + ) +} + +export function hasAgentSessionsFilters(search: AgentSessionsSearchState): boolean { + return AGENT_SESSIONS_FILTER_KEYS.some((key) => { + const value = search[key] + return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false + }) +} + +/** A multi-value param, or nothing — an empty array is not a filter. */ +const some = (values: ReadonlyArray | undefined) => (values?.length ? values : undefined) + +/** + * Warehouse filter inputs for a given URL state and resolved window. + * + * The window is resolved by the caller (it is refresh-aware), and everything + * else is a rename or a unit change: durations travel in the URL as whole + * seconds and are filtered in milliseconds. + */ +export function agentSessionsFilterInputs( + search: AgentSessionsSearchState, + window: { readonly startTime: string; readonly endTime: string }, +): AiSessionsFilterInputs { + const sortOption = sortOptionFor(search.sortBy, search.sortDir) + return { + startTime: window.startTime, + endTime: window.endTime, + vendorIds: some(search.vendors), + serviceNames: some(search.services), + deploymentEnvs: some(search.environments), + models: some(search.models), + agentNames: some(search.agents), + toolNames: some(search.tools), + search: search.q?.trim() || undefined, + hasErrors: search.hasErrors === true ? true : undefined, + excludeTraceSessions: search.grouped === true ? true : undefined, + durationMinMs: search.durationMin !== undefined ? search.durationMin * 1000 : undefined, + durationMaxMs: search.durationMax !== undefined ? search.durationMax * 1000 : undefined, + costMin: search.costMin, + costMax: search.costMax, + tokensMin: search.tokensMin, + tokensMax: search.tokensMax, + llmCallsMin: search.llmCallsMin, + llmCallsMax: search.llmCallsMax, + toolCallsMin: search.toolCallsMin, + toolCallsMax: search.toolCallsMax, + // Resolved through the menu, and left off for the default so the first + // page's SQL stays the baseline shape. + ...(sortOption === DEFAULT_SORT_OPTION + ? undefined + : { sortBy: sortOption.sortBy, sortDir: sortOption.sortDir }), + } +} diff --git a/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.test.tsx b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.test.tsx new file mode 100644 index 000000000..c34e6faee --- /dev/null +++ b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +// TEST-SEAM: This focused test replaces the router with a recorder — the sidebar only navigates. + +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Result } from "@/lib/effect-atom" +import type { AgentSessionsSearchState } from "./agent-sessions-filter-inputs" + +const navigate = vi.fn() +let search: AgentSessionsSearchState = {} + +vi.mock("@tanstack/react-router", () => ({ + getRouteApi: () => ({ useNavigate: () => navigate, useSearch: () => search }), +})) + +import { AgentSessionsFilterSidebar } from "./agent-sessions-filter-sidebar" +import { AgentSessionsToolbar } from "./agent-sessions-toolbar" +import { sortOptionFor } from "./agent-sessions-filter-inputs" + +const facets = Result.success({ + vendors: [ + { name: "eve", count: 12 }, + { name: "vercel_ai_sdk", count: 3 }, + ], + services: [{ name: "agent-runner", count: 15 }], + environments: [{ name: "production", count: 15 }], + models: [{ name: "openrouter/anthropic/claude-sonnet-5", count: 9 }], + agents: [], + tools: [{ name: "search_traces", count: 4 }], +}) + +/** What the recorded navigate call would write, given the search it started from. */ +const nextSearch = (): Record => { + const call = navigate.mock.calls.at(-1)?.[0] as { + search: (prev: AgentSessionsSearchState) => Record + } + return call.search(search) +} + +describe("AgentSessionsFilterSidebar", () => { + beforeEach(() => { + navigate.mockReset() + search = {} + }) + afterEach(cleanup) + + it("renders a section per counted dimension, hiding the ones with nothing to offer", () => { + render() + + for (const title of ["Framework", "Service", "Environment", "Model", "Tool"]) { + expect(screen.getByText(title)).toBeTruthy() + } + expect(screen.queryByText("Agent")).toBeNull() + // Framework ids read as labels, models as their last path segment, both + // with the session count beside them. + expect(screen.getByText("Vercel AI SDK")).toBeTruthy() + expect(screen.getByText("claude-sonnet-5")).toBeTruthy() + for (const title of ["Session length", "Cost", "Tokens", "LLM calls", "Tool calls"]) { + expect(screen.getByText(title)).toBeTruthy() + } + expect(screen.getByText("Hide single-trace sessions")).toBeTruthy() + }) + + it("accumulates a second framework rather than replacing the first", () => { + search = { vendors: ["eve"] } + render() + + fireEvent.click(screen.getByText("Vercel AI SDK")) + expect(nextSearch().vendors).toEqual(["eve", "vercel_ai_sdk"]) + }) + + it("keeps a selected value that the window no longer offers", () => { + search = { tools: ["send_email"] } + render() + + expect(screen.getByText("send_email")).toBeTruthy() + }) + + it("writes a preset as the range it names, and clears it on a second click", () => { + render() + + fireEvent.click(screen.getByText("Cost")) + fireEvent.click(screen.getByText("Over $1")) + expect(nextSearch()).toMatchObject({ costMin: 1, costMax: undefined }) + + search = { costMin: 1 } + cleanup() + render() + fireEvent.click(screen.getByText("Over $1")) + expect(nextSearch()).toMatchObject({ costMin: undefined, costMax: undefined }) + }) + + it("clears every filter but leaves the window and the sort alone", () => { + search = { + vendors: ["eve"], + q: "wrun", + hasErrors: true, + grouped: true, + tokensMin: 100, + sortBy: "cost", + sortDir: "desc", + } + render() + + fireEvent.click(screen.getByRole("button", { name: /clear all/i })) + const next = nextSearch() + expect(next).toMatchObject({ + vendors: undefined, + q: undefined, + hasErrors: undefined, + grouped: undefined, + tokensMin: undefined, + sortBy: "cost", + sortDir: "desc", + }) + }) + + it("toggles the single-trace filter on and writes nothing when it is off", () => { + render() + + fireEvent.click(screen.getByLabelText("Hide single-trace sessions")) + expect(nextSearch().grouped).toBe(true) + }) +}) + +describe("AgentSessionsToolbar", () => { + afterEach(cleanup) + + it("names the current sort and offers every measure", () => { + const onSortChange = vi.fn() + render( + , + ) + + // The menu itself is portal-rendered on open; jsdom sees the trigger. + expect(screen.getByRole("combobox", { name: "Sort sessions" })).toBeTruthy() + expect(screen.getByRole("button", { name: /with errors/i }).getAttribute("aria-pressed")).toBe( + "false", + ) + expect(screen.getByPlaceholderText("Session or trace ID…")).toBeTruthy() + }) + + it("falls back to newest-first for a pair the menu does not offer", () => { + expect(sortOptionFor(undefined, undefined).key).toBe("newest") + expect(sortOptionFor("cost", "asc").key).toBe("newest") + expect(sortOptionFor("startTime", "asc").key).toBe("oldest") + }) +}) diff --git a/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx index 988ac7504..e352d1148 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx @@ -4,6 +4,7 @@ import { Result } from "@/lib/effect-atom" import { FilterSection, SearchableFilterSection, + SingleCheckboxFilter, type FilterOption, } from "@/components/filters/filter-section" import { @@ -13,18 +14,66 @@ import { FilterSidebarHeader, FilterSidebarLoading, } from "@/components/filters/filter-sidebar" +import { RangeFilterSection, type RangePreset } from "@maple/ui/components/filters/range-filter-section" +import { Separator } from "@maple/ui/components/ui/separator" import { vendorLabel } from "@/lib/agent-sessions/vendor-label" +import { shortTarget } from "@/lib/agent-sessions/span-filters" +import { + AGENT_SESSIONS_FILTER_KEYS, + hasAgentSessionsFilters, + type AgentSessionsSearchState, +} from "./agent-sessions-filter-inputs" const routeApi = getRouteApi("/agent-sessions/") -/** A selected value absent from the current window stays checkable (count 0). */ -function withSelected(options: FilterOption[], selected?: string): FilterOption[] { - if (selected && !options.some((o) => o.name === selected)) { - return [{ name: selected, count: 0 }, ...options] - } - return options +/** Selected values absent from the current window stay checkable (count 0). */ +function withSelected( + options: ReadonlyArray, + selected: ReadonlyArray = [], +): FilterOption[] { + const missing = selected.filter((value) => !options.some((option) => option.name === value)) + return [...missing.map((name) => ({ name, count: 0 })), ...options] } +// No distribution behind these — a histogram would need the fan-out for every +// session in the window, which the facets read is built to avoid. Static +// thresholds, named for the question each one answers. +const DURATION_PRESETS: RangePreset[] = [ + { key: "quick", label: "Quick", value: "<10s", max: 10 }, + { key: "minute", label: "Over a minute", value: ">1m", min: 60 }, + { key: "long", label: "Long-running", value: ">10m", min: 600 }, +] +const COST_PRESETS: RangePreset[] = [ + { key: "dime", label: "Over 10¢", value: ">$0.10", min: 0.1 }, + { key: "dollar", label: "Over $1", value: ">$1", min: 1 }, +] +const TOKEN_PRESETS: RangePreset[] = [ + { key: "100k", label: "Over 100k", min: 100_000 }, + { key: "1m", label: "Over 1M", min: 1_000_000 }, +] +const LLM_CALL_PRESETS: RangePreset[] = [ + { key: "single", label: "Single call", value: "1", min: 1, max: 1 }, + { key: "loop", label: "Over 10", min: 10 }, + { key: "deep", label: "Over 50", min: 50 }, +] +const TOOL_CALL_PRESETS: RangePreset[] = [ + { key: "none", label: "No tools", value: "0", max: 0 }, + { key: "many", label: "Over 10", min: 10 }, +] + +type ListKey = "vendors" | "services" | "environments" | "models" | "agents" | "tools" +type RangeKey = + | "durationMin" + | "durationMax" + | "costMin" + | "costMax" + | "tokensMin" + | "tokensMax" + | "llmCallsMin" + | "llmCallsMax" + | "toolCallsMin" + | "toolCallsMax" + interface AgentSessionsFilterSidebarProps { /** * Distinct sessions per option, aggregated over the whole window rather than @@ -35,6 +84,10 @@ interface AgentSessionsFilterSidebarProps { { readonly vendors: ReadonlyArray readonly services: ReadonlyArray + readonly environments: ReadonlyArray + readonly models: ReadonlyArray + readonly agents: ReadonlyArray + readonly tools: ReadonlyArray }, unknown > @@ -42,44 +95,169 @@ interface AgentSessionsFilterSidebarProps { export function AgentSessionsFilterSidebar({ facetsResult }: AgentSessionsFilterSidebarProps) { const navigate = routeApi.useNavigate() - const search = routeApi.useSearch() + const search: AgentSessionsSearchState = routeApi.useSearch() - // Single-value params: take the last toggled option (switching values - // replaces the prior one; unchecking the only one clears it). - const setSingle = (key: "vendor" | "service", values: string[]) => { - navigate({ search: (prev) => ({ ...prev, [key]: values.at(-1) ?? undefined }) }) + const setList = (key: ListKey, values: string[]) => { + navigate({ search: (prev) => ({ ...prev, [key]: values.length > 0 ? values : undefined }) }) } + const setRange = + (minKey: RangeKey, maxKey: RangeKey) => (min: number | undefined, max: number | undefined) => { + navigate({ search: (prev) => ({ ...prev, [minKey]: min, [maxKey]: max }) }) + } + + // Everything the sidebar and the toolbar own; the window and the sort stay. const clearAllFilters = () => { - navigate({ search: (prev) => ({ ...prev, vendor: undefined, service: undefined }) }) + navigate({ + search: (prev) => ({ + ...prev, + ...Object.fromEntries(AGENT_SESSIONS_FILTER_KEYS.map((key) => [key, undefined])), + }), + }) } - const hasActiveFilters = !!search.vendor || !!search.service - return Result.builder(facetsResult) - .onInitial(() => ) + .onInitial(() => ) .onError((error) => ) .onSuccess((value, result) => { - const vendors = withSelected([...value.vendors], search.vendor) - const services = withSelected([...value.services], search.service) + const vendors = withSelected(value.vendors, search.vendors) + const services = withSelected(value.services, search.services) + const environments = withSelected(value.environments, search.environments) + const models = withSelected(value.models, search.models) + const agents = withSelected(value.agents, search.agents) + const tools = withSelected(value.tools, search.tools) return ( - + + {/* Counted facets first — they answer "what is in here" before you + know anything. The measured ranges follow, then the one structural + toggle. "With errors" is deliberately absent: the toolbar chip is + that filter, and two controls for one boolean read as a question + about whether they agree. */} setSingle("vendor", vals)} + selected={search.vendors ?? []} + onChange={(vals) => setList("vendors", vals)} getOptionLabel={vendorLabel} /> setSingle("service", vals)} + selected={search.services ?? []} + onChange={(vals) => setList("services", vals)} + /> + + {/* Sections with nothing to offer hide themselves: most orgs never + set an environment, and a framework that names no agents or tools + would leave an empty list that reads as broken. */} + {environments.length > 0 && ( + setList("environments", vals)} + /> + )} + + {models.length > 0 && ( + setList("models", vals)} + getOptionLabel={shortTarget} + /> + )} + + {agents.length > 0 && ( + setList("agents", vals)} + /> + )} + + {tools.length > 0 && ( + setList("tools", vals)} + /> + )} + + + + + + + + + + + + + + + + {/* A framework with no session key files every trace as its own + session; for an org running one of those this is the difference + between a list of conversations and a list of requests. */} + + navigate({ search: (prev) => ({ ...prev, grouped: checked || undefined }) }) + } /> {vendors.length === 0 && services.length === 0 && ( diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.test.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.test.tsx index 1fbe65086..efbda3675 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.test.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.test.tsx @@ -19,6 +19,12 @@ const session: AgentSessionRow = { spanCount: 12, errorSpanCount: 0, serviceNames: ["maple-slack-agent"], + models: ["claude-sonnet-5"], + agentNames: ["slack-agent"], + llmCalls: 4, + toolCalls: 2, + totalTokens: 18_400, + cost: 0.12, startTime: "2026-08-19 10:33:25.825000000", endTime: "2026-08-19 10:34:25.825000000", durationMs: 60_000, diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 2d87baec0..d4ac8ef7f 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -4,7 +4,10 @@ import { Link } from "@tanstack/react-router" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" +import { formatCount } from "@maple/ui/components/filters/range-filter-section" import { SquareSparkleIcon } from "@/components/icons" +import { formatCost } from "@/lib/agent-sessions/session-summary" +import { shortTarget } from "@/lib/agent-sessions/span-filters" import { vendorIcon } from "@/lib/agent-sessions/vendor-icon" import { sessionRowId } from "@/lib/agent-sessions/session-window" import { vendorLabel } from "@/lib/agent-sessions/vendor-label" @@ -18,6 +21,12 @@ export interface AgentSessionRow { readonly spanCount: number readonly errorSpanCount: number readonly serviceNames: ReadonlyArray + readonly models: ReadonlyArray + readonly agentNames: ReadonlyArray + readonly llmCalls: number + readonly toolCalls: number + readonly totalTokens: number + readonly cost: number readonly startTime: string readonly endTime: string readonly durationMs: number @@ -28,6 +37,17 @@ function absoluteTs(startTime: string): string { return Number.isNaN(parsed) ? startTime : new Date(parsed).toLocaleString() } +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}` + +/** "claude-sonnet-5 +1": the first model short, the rest as a count — the full + * list goes in the title. Gateways prefix models with a provider path that + * would truncate two different models to the same string. */ +function modelsLabel(models: ReadonlyArray): string { + const [first, ...rest] = models + if (first === undefined) return "" + return rest.length > 0 ? `${shortTarget(first)} +${rest.length}` : shortTarget(first) +} + interface AgentSessionsListProps { sessions: ReadonlyArray /** Fetch the next page — invoked when the bottom sentinel scrolls into view. */ @@ -163,17 +183,50 @@ export function AgentSessionsList({ - {/* Activity lane: duration + traces/spans */} -
+ {/* Model lane: what the session ran on — the first filter most + readers reach for, so it is visible before they open the rail. */} +
+ + {modelsLabel(session.models)} + +
+ + {/* Activity lane: duration + the work done. Traces and spans move + to the tooltip — they describe ingestion, calls and tools + describe the agent. */} +
{formatSessionDuration(session.durationMs)} - {session.traceCount} trace{session.traceCount === 1 ? "" : "s"} ·{" "} - {session.spanCount} span{session.spanCount === 1 ? "" : "s"} + {plural(session.llmCalls, "call")} · {plural(session.toolCalls, "tool")}
+ {/* Usage lane: tokens and cost, blank where nothing was reported — + a "0" here would read as "measured, and it was free". */} +
+ {session.totalTokens > 0 && ( + + {formatCount(session.totalTokens)} tok + + )} + {session.cost > 0 && ( + + {formatCost(session.cost)} + + )} +
+ {/* Signal lane: error chip */}
{hasErrors && } diff --git a/apps/web/src/components/agent-sessions/agent-sessions-toolbar.tsx b/apps/web/src/components/agent-sessions/agent-sessions-toolbar.tsx new file mode 100644 index 000000000..c61f1568a --- /dev/null +++ b/apps/web/src/components/agent-sessions/agent-sessions-toolbar.tsx @@ -0,0 +1,91 @@ +import { ToolbarSearch } from "@maple/ui/components/toolbar" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { cn } from "@maple/ui/lib/utils" +import { AGENT_SESSIONS_SORT_OPTIONS, type AgentSessionsSortOption } from "./agent-sessions-filter-inputs" + +interface AgentSessionsToolbarProps { + /** Current `q` search param (session / trace id prefix). */ + query: string + onSearch: (value: string | undefined) => void + /** `hasErrors` URL filter state — the chip toggles it. */ + errorsOnly: boolean + onToggleErrorsOnly: () => void + sortKey: string + onSortChange: (option: AgentSessionsSortOption) => void + /** Dim the controls while the list is refetching. */ + waiting?: boolean +} + +/** + * Search, the one-click error triage chip, and the sort. The session count + * lives in the page header; this row answers "which of these first". + */ +export function AgentSessionsToolbar({ + query, + onSearch, + errorsOnly, + onToggleErrorsOnly, + sortKey, + onSortChange, + waiting = false, +}: AgentSessionsToolbarProps) { + return ( + // Bare container rather than the shared `Toolbar`: this sits inside + // `DashboardLayout.Sticky`, which already supplies the border and padding. +
+ + +
+ + + +
+
+ ) +} 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..63f60cd3e 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 @@ -558,7 +558,6 @@ describe("SessionOverview", () => { const name = screen.getByText("gpt-4o-mini") expect(name.getAttribute("title")).toBe("openrouter/openai/gpt-4o-mini") }) - }) describe("SessionWaterfall", () => { 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..1852351c0 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 @@ -154,7 +154,9 @@ function Verdict({ - {verdict.label} + + {verdict.label} + on the final {turnWord} )} @@ -350,13 +352,7 @@ function TurnHealthStrip({ /* Where the time went */ /* -------------------------------------------------------------------------- */ -function TimeComposition({ - summary, - turns, -}: { - summary: SessionSummary - turns: readonly SessionTurn[] -}) { +function TimeComposition({ summary, turns }: { summary: SessionSummary; turns: readonly SessionTurn[] }) { // Under half a percent a legend row reads "0%" and says nothing; the bar // still draws the sliver in place, so nothing disappears from the timeline. const legend = summary.occupancy @@ -583,7 +579,10 @@ function ToolUsageRow({ tool, topToolCalls }: { tool: SessionToolUsage; topToolC {disclosable && ( )} 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..fe7e6fd9a 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 @@ -230,16 +230,7 @@ export function SessionWaterfall({ ) row?.scrollIntoView({ block: "center" }) }) - }, [ - landingSpanId, - revealedTurnId, - rows, - spanRowIndexById, - setFocusedId, - virtualizer, - getScrollElement, - ]) - + }, [landingSpanId, revealedTurnId, rows, spanRowIndexById, setFocusedId, virtualizer, getScrollElement]) return (
diff --git a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx index 424ff6cd3..6aef7402d 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx @@ -464,13 +464,7 @@ function ReasoningPart({ part }: { part: Extract>(() => new Set()) @@ -649,13 +643,7 @@ const toSpanId = Schema.decodeSync(SpanId) * was read from — Details is where a reader looks first, and a failed call * whose evidence is only on another tab reads as a call that did not fail. */ -function DetailsSection({ - span, - toolCalls, -}: { - span: AiSessionSpan - toolCalls: readonly SpanToolCall[] -}) { +function DetailsSection({ span, toolCalls }: { span: AiSessionSpan; toolCalls: readonly SpanToolCall[] }) { const detailResult = useAtomValue( span.traceId !== "" && span.spanId !== "" ? getSpanDetailResultAtom({ @@ -675,9 +663,7 @@ function DetailsSection({ // tab. The span's OWN call only — a model span's tool calls are its OUTPUT, // and their results say nothing about why the model call itself failed. const failedToolResult = - failed && classifyAiSpan(span) === "tool" - ? toolCalls.find((call) => call.own)?.resultText - : undefined + failed && classifyAiSpan(span) === "tool" ? toolCalls.find((call) => call.own)?.resultText : undefined return (
@@ -855,4 +841,3 @@ function collapsedText(parts: readonly SpanMessagePart[]): string { .join("\n") .trim() } - diff --git a/apps/web/src/components/agent-sessions/session-detail/span-visuals.ts b/apps/web/src/components/agent-sessions/session-detail/span-visuals.ts index e6c4efc3d..ae28e7d42 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-visuals.ts +++ b/apps/web/src/components/agent-sessions/session-detail/span-visuals.ts @@ -5,13 +5,7 @@ // token rather than a fifth hue — chart-5 and chart-2 are two near-identical // cyans in the light palette (ΔL 0.04, Δh 25°). -import { - DotsIcon, - FaceRobotIcon, - GearIcon, - PixelSparkleIcon, - type IconComponent, -} from "@/components/icons" +import { DotsIcon, FaceRobotIcon, GearIcon, PixelSparkleIcon, type IconComponent } from "@/components/icons" import type { AiSpanCategory } from "@/lib/agent-sessions/session-turns" import type { OccupancyKind } from "@/lib/agent-sessions/session-summary" diff --git a/apps/web/src/components/filters/range-filter-section.test.ts b/apps/web/src/components/filters/range-filter-section.test.ts index d6be038c4..f22080edc 100644 --- a/apps/web/src/components/filters/range-filter-section.test.ts +++ b/apps/web/src/components/filters/range-filter-section.test.ts @@ -105,3 +105,49 @@ describe("formatRange", () => { expect(formatRange(2500, undefined, "ms")).toBe("≥ 2.50s") }) }) + +// Counts and dollars share the control with durations: same draft/commit +// cycle, different vocabulary. A typed "120k" has to come back as 120000 and +// print as "120k" again, and a dollar amount must never grow a time suffix. +describe("count and usd units", () => { + it("parses scaled counts and bare numbers", () => { + expect(parseRange("100", "count")).toBe(100) + expect(parseRange("120k", "count")).toBe(120_000) + expect(parseRange("1.5M", "count")).toBe(1_500_000) + expect(parseRange("2b", "count")).toBe(2_000_000_000) + expect(parseRange("2m", "count")).toBe(2_000_000) + expect(parseRange("1.5", "count")).toBe(2) + expect(parseRange("90s", "count")).toBeUndefined() + }) + + it("parses a dollar amount with or without the sign, and nothing else", () => { + expect(parseRange("0.5", "usd")).toBe(0.5) + expect(parseRange("$ 1.25", "usd")).toBe(1.25) + expect(parseRange("$1", "usd")).toBe(1) + expect(parseRange("1k", "usd")).toBeUndefined() + expect(parseRange("$", "usd")).toBeUndefined() + }) + + it("formats counts compactly and dollars as money", () => { + expect(formatValue(999, "count")).toBe("999") + expect(formatValue(120_000, "count")).toBe("120k") + expect(formatValue(1_500_000, "count")).toBe("1.5M") + expect(formatValue(0.5, "usd")).toBe("$0.50") + expect(formatValue(0.0004, "usd")).toBe("$0.0004") + expect(formatRange(100_000, undefined, "count")).toBe("≥ 100k") + expect(formatRange(undefined, 2, "usd")).toBe("≤ $2.00") + }) + + it("round-trips the field text through parseRange", () => { + for (const count of [0, 100, 999, 1000, 1500, 1234, 120_000, 1_000_000]) { + expect(parseRange(formatCompact(count, "count"), "count")).toBe(count) + } + for (const usd of [0, 0.01, 0.5, 1.25, 10]) { + expect(parseRange(formatCompact(usd, "usd"), "usd")).toBe(usd) + } + // Bare inside the natural range, scaled only where it means the same thing. + expect(formatCompact(1234, "count")).toBe("1234") + expect(formatCompact(1500, "count")).toBe("1.5k") + expect(formatCompact(1.25, "usd")).toBe("1.25") + }) +}) diff --git a/apps/web/src/hooks/use-infinite-ai-sessions.ts b/apps/web/src/hooks/use-infinite-ai-sessions.ts index 8ddc9e9c6..2dfcacf67 100644 --- a/apps/web/src/hooks/use-infinite-ai-sessions.ts +++ b/apps/web/src/hooks/use-infinite-ai-sessions.ts @@ -1,7 +1,7 @@ import * as React from "react" import { Result } from "@/lib/effect-atom" -import { listAiSessions } from "@/api/warehouse/ai-sessions" +import { listAiSessions, type ListAiSessionsInput } from "@/api/warehouse/ai-sessions" import { listAiSessionsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import type { AgentSessionRow } from "@/components/agent-sessions/agent-sessions-list" @@ -14,14 +14,15 @@ export const MAX_RETAINED_AI_SESSIONS = 500 /** * The filter inputs the agent-sessions route assembles (resolved time window + - * sidebar filters). Pagination params are added by this hook — callers must not - * set `limit`/`offset` themselves. + * sidebar filters + sort). Pagination params are added by this hook — callers + * must not set `limit`/`offset` themselves. */ -export interface AiSessionsFilterInputs { +export type AiSessionsFilterInputs = Omit< + ListAiSessionsInput, + "limit" | "offset" | "startTime" | "endTime" +> & { startTime: string endTime: string - vendorIds?: ReadonlyArray - serviceNames?: ReadonlyArray } interface AiSessionsPage { diff --git a/apps/web/src/routes/agent-sessions/$sessionId.tsx b/apps/web/src/routes/agent-sessions/$sessionId.tsx index c6fdf2436..5efc0c5ae 100644 --- a/apps/web/src/routes/agent-sessions/$sessionId.tsx +++ b/apps/web/src/routes/agent-sessions/$sessionId.tsx @@ -236,7 +236,11 @@ function SessionDetailBody({ - + {summary.title === undefined ? ( // The id fallback title copies the full session id. diff --git a/apps/web/src/routes/agent-sessions/index.tsx b/apps/web/src/routes/agent-sessions/index.tsx index 0c26a487c..d1eb35ee0 100644 --- a/apps/web/src/routes/agent-sessions/index.tsx +++ b/apps/web/src/routes/agent-sessions/index.tsx @@ -1,13 +1,20 @@ import { useMemo } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" +import { AiSessionSortDir, AiSessionSortKey } from "@maple/domain/http" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { AgentSessionsList } from "@/components/agent-sessions/agent-sessions-list" import { AgentSessionsFilterSidebar } from "@/components/agent-sessions/agent-sessions-filter-sidebar" +import { AgentSessionsToolbar } from "@/components/agent-sessions/agent-sessions-toolbar" +import { + agentSessionsFilterInputs, + sortOptionFor, +} from "@/components/agent-sessions/agent-sessions-filter-inputs" import { NotFoundError } from "@/components/route-error" import { QueryErrorState } from "@/components/common/query-error-state" import { Result, useAtomValue } from "@/lib/effect-atom" +import { BooleanFromStringParam, NumberFromStringParam, OptionalStringArrayParam } from "@/lib/search-params" import { aiSessionsFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" @@ -19,10 +26,35 @@ import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-fl import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ToolbarStat } from "@maple/ui/components/toolbar" +const BooleanParam = Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])) +const NumberParam = Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])) + const agentSessionsSearchSchema = Schema.Struct({ - /** Vendor id as stamped by the gateway (e.g. `eve`), not the display label. */ - vendor: Schema.optional(Schema.String), - service: Schema.optional(Schema.String), + /** Vendor ids as stamped by the gateway (e.g. `eve`), not display labels. */ + vendors: OptionalStringArrayParam, + services: OptionalStringArrayParam, + environments: OptionalStringArrayParam, + models: OptionalStringArrayParam, + agents: OptionalStringArrayParam, + tools: OptionalStringArrayParam, + /** Session or trace id prefix. */ + q: Schema.optional(Schema.String), + hasErrors: BooleanParam, + /** Hide the `trace:` sessions — traces whose vendor exposes no session key. */ + grouped: BooleanParam, + /** Seconds, like the replays list. */ + durationMin: NumberParam, + durationMax: NumberParam, + costMin: NumberParam, + costMax: NumberParam, + tokensMin: NumberParam, + tokensMax: NumberParam, + llmCallsMin: NumberParam, + llmCallsMax: NumberParam, + toolCallsMin: NumberParam, + toolCallsMax: NumberParam, + sortBy: Schema.optional(AiSessionSortKey), + sortDir: Schema.optional(AiSessionSortDir), ...TimeRangeSearchFields, }) @@ -86,21 +118,19 @@ function AgentSessionsBody({ onTimeChange: (range: TimeRange, options?: { replace?: boolean }) => void }) { const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) const { startTime, endTime } = useEffectiveTimeRange( search.startTime, search.endTime, search.timePreset ?? "24h", ) - // Memoized on the resolved values: the hook keys its accumulated pages on - // these inputs, and a fresh object per render would reset them every time. + // Memoized on the search by VALUE, not by the reference the router hands + // back: the hook keys its accumulated pages on these inputs, and a fresh + // object per render would reset them every time. + const searchKey = JSON.stringify(search) const filterInputs = useMemo( - () => ({ - startTime, - endTime, - vendorIds: search.vendor ? [search.vendor] : undefined, - serviceNames: search.service ? [search.service] : undefined, - }), - [startTime, endTime, search.vendor, search.service], + () => agentSessionsFilterInputs(search, { startTime, endTime }), + [searchKey, startTime, endTime], ) const { firstPageResult, allData, hasNextPage, isCapped, isFetchingNextPage, fetchNextPage } = useInfiniteAiSessions(filterInputs) @@ -110,6 +140,7 @@ function AgentSessionsBody({ // is enough. const facetsResult = useAtomValue(aiSessionsFacetsResultAtom({ data: { startTime, endTime } })) const sessions = allData + const sortOption = sortOptionFor(search.sortBy, search.sortDir) const headerActions = (
@@ -126,6 +157,36 @@ function AgentSessionsBody({
) + const toolbar = ( + navigate({ search: (prev) => ({ ...prev, q: value }) })} + errorsOnly={search.hasErrors === true} + onToggleErrorsOnly={() => + navigate({ search: (prev) => ({ ...prev, hasErrors: prev.hasErrors ? undefined : true }) }) + } + sortKey={sortOption.key} + // The default sort leaves the URL clean, so a shared link only carries + // a sort when one was chosen. + onSortChange={(option) => + navigate({ + search: (prev) => ({ + ...prev, + sortBy: + option.sortBy === "startTime" && option.sortDir === "desc" + ? undefined + : option.sortBy, + sortDir: + option.sortBy === "startTime" && option.sortDir === "desc" + ? undefined + : option.sortDir, + }), + }) + } + waiting={firstPageResult.waiting} + /> + ) + return ( <> @@ -139,6 +200,7 @@ function AgentSessionsBody({ > {headerActions}
+ {toolbar} {Result.builder(firstPageResult) diff --git a/packages/domain/package.json b/packages/domain/package.json index d701fc73f..a464a3756 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -28,6 +28,7 @@ "./tinybird": "./src/tinybird/index.ts", "./tinybird/db-query-shape-sql": "./src/tinybird/db-query-shape-sql.ts", "./tinybird/fingerprint": "./src/tinybird/fingerprint.ts", + "./tinybird/gen-ai-columns": "./src/tinybird/gen-ai-columns.ts", "./tinybird/semconv-renames": "./src/tinybird/semconv-renames.ts", "./tinybird/span-display-name": "./src/tinybird/span-display-name.ts", "./clickhouse": "./src/clickhouse/index.ts", diff --git a/packages/domain/src/clickhouse/migrations/0026_ai_trace_index_filter_columns.ts b/packages/domain/src/clickhouse/migrations/0026_ai_trace_index_filter_columns.ts new file mode 100644 index 000000000..e04d17136 --- /dev/null +++ b/packages/domain/src/clickhouse/migrations/0026_ai_trace_index_filter_columns.ts @@ -0,0 +1,67 @@ +/** + * Migration 0026 — the filter dimensions the Agent Sessions sidebar offers, + * and the per-span measures the page ranks and filters on. + * + * `ai_trace_index` (0024) carried only the `maple_ai.*` identity and the + * service, so the list could be sliced by framework and by service and by + * nothing else. The dimensions a customer actually reaches for first — which + * model, which agent, which tool, which environment, which sessions failed or + * cost the most — were facts of the raw span, readable only through the + * `trace_detail_spans` fan-out, which is the expensive half of the read, runs + * over one page at a time since #741, and cannot serve a facet count or rank a + * page. + * + * Twelve columns, all pre-extracted at insert by `ai_trace_index_mv`, all facts + * of the GenAI span itself (see `tinybird/gen-ai-columns.ts`, which is also + * what a raw-table read of the same fact compiles from): + * + * - `DeploymentEnv`: the resource attribute under either semconv spelling, + * the same `DEPLOYMENT_ENV_SQL` every other pre-extracting MV uses. + * - `Model`, `AgentName`, `ToolName`: the GenAI identity of the span, + * coalesced across the canonical `gen_ai.*` keys and the Vercel AI SDK and + * OpenInference dialects. `''` where the span carries no such fact — a + * chat span has no tool, a tool span no model. + * - `IsLlmCall`, `IsToolCall`, `IsError`: the span's kind and whether it + * failed, by the same rules the detail page classifies with. + * - `Tokens`, `Cost`: the usage the span reported across every bucket, and + * `SpanId`/`ParentSpanId` so a wrapper span's roll-up of its children's + * usage can be taken off it at read time. `Duration`, so the page can + * measure a session's agent-span extent. + * + * A row materialized before this migration reads `''`/0 throughout: the facets + * drop the blank option, the filters never match it, and the sums count it as + * nothing — such a session is still detected and listed, sliceable by framework + * and service, and ranked as if free. + * + * NOTHING IS BACKFILLED, for the same reason 0024 backfilled nothing: the + * index fills forward and raw `traces`' 30-day TTL ages the gap out. The + * ALTERs are metadata-only; the view is dropped and recreated because a + * materialized view's SELECT is frozen at creation. + * + * `requiredForIngest: false` — the gateway writes `traces`, never this table. + * + * The CREATE statement below is the verbatim DDL as the schema emitter produced + * it at v26. Frozen history: never re-derive it from a later snapshot. + */ +export const migration_0026_ai_trace_index_filter_columns = { + version: 26, + description: + "Add the sidebar's filter dimensions and the page's per-span measures to ai_trace_index and recreate ai_trace_index_mv to fill them", + requiredForIngest: false, + statements: [ + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS DeploymentEnv LowCardinality(String)", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS Model LowCardinality(String)", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS AgentName LowCardinality(String)", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS ToolName LowCardinality(String)", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS SpanId String", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS ParentSpanId String", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS Duration UInt64", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS IsError UInt8", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS IsLlmCall UInt8", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS IsToolCall UInt8", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS Tokens Float64", + "ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS Cost Float64", + "DROP VIEW IF EXISTS ai_trace_index_mv", + "CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS\nSELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model,\n coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName,\n coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall,\n 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'], ''), SpanAttributes['llm.token_count.prompt'])) + 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'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) + 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'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS Tokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''", + ], +} as const diff --git a/packages/domain/src/clickhouse/migrations/index.test.ts b/packages/domain/src/clickhouse/migrations/index.test.ts index 415512ddc..dfabc0c23 100644 --- a/packages/domain/src/clickhouse/migrations/index.test.ts +++ b/packages/domain/src/clickhouse/migrations/index.test.ts @@ -31,6 +31,7 @@ import { migration_0022_service_map_edge_quantiles } from "./0022_service_map_ed import { migration_0023_service_operations_discriminators } from "./0023_service_operations_discriminators" import { migration_0024_ai_trace_index } from "./0024_ai_trace_index" import { migration_0025_commit_sha_vcs_revision } from "./0025_commit_sha_vcs_revision" +import { migration_0026_ai_trace_index_filter_columns } from "./0026_ai_trace_index_filter_columns" import { migration_0021_product_events } from "./0021_product_events" import { clickHouseSchemaVersion, latestMigrationVersion, migrations } from "./index" @@ -47,10 +48,10 @@ const renderedSql = migration_0004_service_namespace_projections.statements describe("ClickHouse migrations", () => { it("keeps migrations ordered by version", () => { expect(migrations.map((m) => m.version)).toEqual([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, ]) - expect(migrations.at(-1)).toBe(migration_0025_commit_sha_vcs_revision) - expect(latestMigrationVersion).toBe(25) + expect(migrations.at(-1)).toBe(migration_0026_ai_trace_index_filter_columns) + expect(latestMigrationVersion).toBe(26) // 0010 and 0014-0020 are read-path only and skipped by the ingest-gating // version; 0021 is not — the gateway writes `session_events`' new identity // columns and `product_events` directly, so a BYO-CH org must apply it @@ -73,6 +74,8 @@ describe("ClickHouse migrations", () => { expect(migration_0023_service_operations_discriminators.requiredForIngest).toBe(false) expect(migration_0024_ai_trace_index.requiredForIngest).toBe(false) expect(migration_0025_commit_sha_vcs_revision.requiredForIngest).toBe(false) + // 0026 widens the same MV-populated ai_trace_index and rebuilds its view. + expect(migration_0026_ai_trace_index_filter_columns.requiredForIngest).toBe(false) }) it("recreates both error-events MVs with the 4xx guard and the widened frame redaction", () => { @@ -608,3 +611,76 @@ describe("migration 0018 — Apple crash frames", () => { ) }) }) + +describe("migration 0026 — ai_trace_index filter columns", () => { + const statements = migration_0026_ai_trace_index_filter_columns.statements + + it("widens the index with idempotent ALTERs before recreating its view", () => { + const alters = statements.filter((stmt) => + stmt.startsWith("ALTER TABLE ai_trace_index ADD COLUMN IF NOT EXISTS"), + ) + expect(alters.map((stmt) => stmt.split(" ")[8])).toEqual([ + "DeploymentEnv", + "Model", + "AgentName", + "ToolName", + "SpanId", + "ParentSpanId", + "Duration", + "IsError", + "IsLlmCall", + "IsToolCall", + "Tokens", + "Cost", + ]) + // The facet dimensions are LowCardinality(String): never free text. + for (const stmt of alters.slice(0, 4)) expect(stmt).toMatch(/ LowCardinality\(String\)$/) + // The view's SELECT is frozen at creation, so the body change needs a drop + // — and the drop must come after the ALTERs and before the CREATE, or the + // recreated view maps a column its target does not yet have. + const drop = statements.indexOf("DROP VIEW IF EXISTS ai_trace_index_mv") + const create = statements.findIndex((stmt) => stmt.startsWith("CREATE MATERIALIZED VIEW")) + expect(drop).toBe(alters.length) + expect(create).toBe(drop + 1) + expect(statements).toHaveLength(alters.length + 2) + }) + + it("recreates the view with the coalesced GenAI identity, measures and the semconv environment", () => { + const create = statements.find((stmt) => stmt.startsWith("CREATE MATERIALIZED VIEW"))! + expect(create).toContain("TO ai_trace_index AS") + // The write filter is unchanged: membership in the table is still the + // detection predicate the read side relies on. + expect(create).toContain("WHERE SpanAttributes['maple_ai.vendor.id'] != ''") + expect(create).toContain( + "coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv", + ) + // Response model before request model, canonical keys before dialects. + expect(create).toContain( + "coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model", + ) + expect(create).toContain( + "coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName", + ) + expect(create).toContain( + "coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName", + ) + // The measures: the span's kind and failure as flags, its usage as sums + // the page can rank on, and the ids that let a roll-up be undone. + expect(create).toContain("SpanId,\n ParentSpanId,\n Duration,") + expect(create).toContain( + "toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError", + ) + expect(create).toMatch( + /toUInt8\(.*IN \('chat', 'generate_content', 'text_completion', 'fetch_response'\).*\) AS IsLlmCall/, + ) + expect(create).toMatch(/toUInt8\(.*IN \('execute_tool'\).*\) AS IsToolCall/) + expect(create).toMatch( + /toFloat64OrZero\(coalesce\(nullIf\(SpanAttributes\['gen_ai\.usage\.input_tokens'\], ''\).*\) AS Tokens/, + ) + expect(create).toContain("SpanAttributes['llm.cost.total'])) AS Cost") + }) + + it("does not backfill", () => { + expect(statements.some((stmt) => typeof stmt !== "string" || stmt.includes("INSERT"))).toBe(false) + }) +}) diff --git a/packages/domain/src/clickhouse/migrations/index.ts b/packages/domain/src/clickhouse/migrations/index.ts index 5ef49414f..2bae7577c 100644 --- a/packages/domain/src/clickhouse/migrations/index.ts +++ b/packages/domain/src/clickhouse/migrations/index.ts @@ -24,6 +24,7 @@ import { migration_0022_service_map_edge_quantiles } from "./0022_service_map_ed import { migration_0023_service_operations_discriminators } from "./0023_service_operations_discriminators" import { migration_0024_ai_trace_index } from "./0024_ai_trace_index" import { migration_0025_commit_sha_vcs_revision } from "./0025_commit_sha_vcs_revision" +import { migration_0026_ai_trace_index_filter_columns } from "./0026_ai_trace_index_filter_columns" /** * A migration statement is either a raw SQL string (structural DDL) or a @@ -80,6 +81,7 @@ export const migrations: ReadonlyArray = [ migration_0023_service_operations_discriminators, migration_0024_ai_trace_index, migration_0025_commit_sha_vcs_revision, + migration_0026_ai_trace_index_filter_columns, ] as const /** Highest migration `version` bundled — i.e. the schema level a fully-applied diff --git a/packages/domain/src/generated/clickhouse-schema.ts b/packages/domain/src/generated/clickhouse-schema.ts index 0fa6fe904..ee20d30ed 100644 --- a/packages/domain/src/generated/clickhouse-schema.ts +++ b/packages/domain/src/generated/clickhouse-schema.ts @@ -1,10 +1,10 @@ // This file is generated by scripts/generate-clickhouse-schema.ts // Do not edit manually. -export const projectRevision = "bf3419c18e581ffab5fa4e24aa56f421ac9c3d5a1ce734596747f2331d7d2ae0" as const +export const projectRevision = "25e53de2337d2079a4efc306435d995387cdf89cecafa60e142f5665ccdeebbb" as const export const latestSnapshotStatements: ReadonlyArray = [ - "CREATE TABLE IF NOT EXISTS ai_trace_index (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SessionId String,\n VendorId LowCardinality(String),\n ServiceName LowCardinality(String)\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 30 DAY", + "CREATE TABLE IF NOT EXISTS ai_trace_index (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SessionId String,\n VendorId LowCardinality(String),\n ServiceName LowCardinality(String),\n DeploymentEnv LowCardinality(String),\n Model LowCardinality(String),\n AgentName LowCardinality(String),\n ToolName LowCardinality(String),\n SpanId String,\n ParentSpanId String,\n Duration UInt64,\n IsError UInt8,\n IsLlmCall UInt8,\n IsToolCall UInt8,\n Tokens Float64,\n Cost Float64\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 30 DAY", "CREATE TABLE IF NOT EXISTS alert_checks (\n OrgId LowCardinality(String),\n RuleId String,\n GroupKey String,\n Timestamp DateTime64(3),\n Status LowCardinality(String),\n SignalType LowCardinality(String),\n Comparator LowCardinality(String),\n Threshold Float64,\n ObservedValue Nullable(Float64),\n SampleCount UInt32,\n WindowMinutes UInt16,\n WindowStart DateTime64(3),\n WindowEnd DateTime64(3),\n ConsecutiveBreaches UInt16,\n ConsecutiveHealthy UInt16,\n IncidentId Nullable(String),\n IncidentTransition LowCardinality(String),\n EvaluationDurationMs UInt32,\n ErrorMessage Nullable(String),\n ErrorCategory LowCardinality(String)\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, RuleId, GroupKey, Timestamp)\nTTL toDate(Timestamp) + INTERVAL 365 DAY", "CREATE TABLE IF NOT EXISTS attribute_keys_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n AttributeKey LowCardinality(String),\n AttributeScope LowCardinality(String),\n UsageCount SimpleAggregateFunction(sum, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, AttributeScope, Hour, AttributeKey)\nTTL Hour + INTERVAL 90 DAY", "CREATE TABLE IF NOT EXISTS attribute_values_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n AttributeKey LowCardinality(String),\n AttributeValue String,\n AttributeScope LowCardinality(String),\n UsageCount SimpleAggregateFunction(sum, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, AttributeScope, AttributeKey, Hour, AttributeValue)\nTTL Hour + INTERVAL 90 DAY", @@ -43,7 +43,7 @@ export const latestSnapshotStatements: ReadonlyArray = [ "CREATE TABLE IF NOT EXISTS trace_list_mv (\n OrgId LowCardinality(String),\n TraceId String,\n Timestamp DateTime,\n ServiceName LowCardinality(String),\n SpanName String,\n SpanKind LowCardinality(String),\n Duration UInt64,\n StatusCode LowCardinality(String),\n HttpMethod LowCardinality(String),\n HttpRoute String,\n HttpStatusCode LowCardinality(String),\n DeploymentEnv LowCardinality(String),\n HasError UInt8,\n TraceState String,\n ServiceNamespace LowCardinality(String),\n INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL Timestamp + INTERVAL 30 DAY", "CREATE TABLE IF NOT EXISTS traces (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SpanId String,\n ParentSpanId String,\n TraceState String,\n SpanName LowCardinality(String),\n SpanKind LowCardinality(String),\n ServiceName LowCardinality(String),\n ResourceSchemaUrl String,\n ResourceAttributes Map(LowCardinality(String), String),\n ScopeSchemaUrl String,\n ScopeName String,\n ScopeVersion String,\n ScopeAttributes Map(LowCardinality(String), String),\n Duration UInt64 DEFAULT 0,\n StatusCode LowCardinality(String),\n StatusMessage String,\n SpanAttributes Map(LowCardinality(String), String),\n EventsTimestamp Array(DateTime64(9)),\n EventsName Array(LowCardinality(String)),\n EventsAttributes Array(Map(LowCardinality(String), String)),\n LinksTraceId Array(String),\n LinksSpanId Array(String),\n LinksTraceState Array(String),\n LinksAttributes Array(Map(LowCardinality(String), String)),\n SampleRate Float64 DEFAULT multiIf(SpanAttributes['SampleRate'] != '' AND toFloat64OrZero(SpanAttributes['SampleRate']) >= 1.0, toFloat64OrZero(SpanAttributes['SampleRate']), match(TraceState, 'th:[0-9a-f]+'), 1.0 / greatest(1.0 - reinterpretAsUInt64(reverse(unhex(rightPad(extract(TraceState, 'th:([0-9a-f]+)'), 16, '0')))) / pow(2.0, 64), 0.0001), 1.0),\n IsEntryPoint UInt8 DEFAULT if(SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '', 1, 0),\n ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)),\n ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)),\n SpanAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(SpanAttributes), mapValues(SpanAttributes)),\n INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_span_attr_keys mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_span_attr_vals mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, ServiceName, SpanName, toDateTime(Timestamp))\nTTL toDate(Timestamp) + INTERVAL 30 DAY", "CREATE TABLE IF NOT EXISTS traces_aggregates_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n ServiceName LowCardinality(String),\n SpanName LowCardinality(String),\n SpanKind LowCardinality(String),\n StatusCode LowCardinality(String),\n IsEntryPoint UInt8,\n DeploymentEnv LowCardinality(String),\n WeightedCount SimpleAggregateFunction(sum, Float64),\n WeightedDurationSum SimpleAggregateFunction(sum, Float64),\n WeightedErrorCount SimpleAggregateFunction(sum, Float64),\n DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95, 0.99), UInt64, UInt32),\n DurationMin SimpleAggregateFunction(min, UInt64),\n DurationMax SimpleAggregateFunction(max, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv)\nTTL toDate(Hour) + INTERVAL 365 DAY", - "CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS\nSELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''", + "CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS\nSELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model,\n coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName,\n coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall,\n 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'], ''), SpanAttributes['llm.token_count.prompt'])) + 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'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) + 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'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS Tokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''", "CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_by_time_mv TO error_events_by_time AS\nWITH\n arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei,\n if(_ei > 0, EventsAttributes[_ei]['exception.type'], '') AS _exType,\n if(_ei > 0, EventsAttributes[_ei]['exception.message'], StatusMessage) AS _exMsg,\n if(_ei > 0, EventsAttributes[_ei]['exception.stacktrace'], '') AS _exStack,\n -- Frame lines are matched by SHAPE, not by \"contains :NUMBER\". The old\n -- rule accepted any line with a colon-digit, which let non-frame lines\n -- in: Drizzle's `params: ` line, and the `Type: message`\n -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values\n -- and message text then entered the hash and split one bug into\n -- thousands of issues — 23,035 fingerprints for six real\n -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError\n -- ones.\n --\n -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts,\n -- as is every redaction below. They used to be hand-copied here, which\n -- let the reference implementation the tests exercise drift away from\n -- the SQL that actually runs, silently.\n arraySlice(\n arrayFilter(\n line -> match(line, '^[ \\\\t]*at |^[ \\\\t]*File \"|^[ \\\\t]+from [^ ]+:[0-9]+|^[^ \\\\t@]+@[^ \\\\t]*:[0-9]+|^[ \\\\t]+[^ \\\\t]+\\\\.(go|rs):[0-9]+|^[0-9]+ +\\\\S.* +0x[0-9a-fA-F]+'),\n splitByChar('\\n', _exStack)\n ),\n 1, 3\n ) AS _rawFrames,\n -- Redact every volatile token a frame line can carry: the URL origin\n -- (so preview hosts share one fingerprint), Vite's 8-char bundle\n -- content hash (so a deploy does not re-split every triaged browser and\n -- Worker issue), then line numbers, hex pointers and long id runs. See\n -- FRAME_REDACTIONS for the order and the reasoning.\n arrayMap(\n line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''),\n _rawFrames\n ) AS _topFrames,\n if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame,\n arrayStringConcat(_topFrames, '\\n') AS _fpFrames,\n -- JSON detection for the message signature below.\n isValidJSON(StatusMessage) AS _isJson,\n _isJson AND JSONType(StatusMessage) = 'Object' AS _isJsonObj,\n -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level\n -- keys, redact volatile tokens (long hex / numbers) in each raw value, then\n -- sort by \"key=value\" so key order & whitespace don't matter. No assumption\n -- about which keys exist — works for any producer's JSON shape. (Nested\n -- objects are hashed as their raw substring; only top-level is canonicalized.)\n arrayStringConcat(\n arraySort(\n arrayMap(\n kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')),\n JSONExtractKeysAndValuesRaw(StatusMessage)\n )\n ),\n '|'\n ) AS _jsonSig,\n -- The message signature is folded in ALWAYS, not only when there are no\n -- frames. Bundled runtimes minify every module into one file, so the top\n -- three frames of a Worker error are `toDatabaseError (worker.js)` for\n -- every failing query alike: on frames alone, 25 distinct DatabaseError\n -- bugs (316k occurrences) collapse into a single issue. The signature\n -- restores that discrimination, and it cannot reinflate cardinality the\n -- way a raw prefix would because everything variable is redacted first:\n -- emails, URL origins, home directories, query strings, quoted values,\n -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order,\n -- what is deliberately kept, and the one residual it cannot reach.\n multiIf(\n _isJsonObj, _jsonSig,\n substringUTF8(\n replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(StatusMessage, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'),\n 1, 120\n )\n ) AS _msgSig,\n -- Display-only, best-effort human label (decoupled from the fingerprint:\n -- many labels may map to one hash). The broad key list here is a DISPLAY\n -- heuristic only; the fingerprint above makes no key-name assumption.\n multiIf(\n JSONExtractString(StatusMessage, 'title') != '', JSONExtractString(StatusMessage, 'title'),\n JSONExtractString(StatusMessage, 'message') != '', JSONExtractString(StatusMessage, 'message'),\n JSONExtractString(StatusMessage, 'error') != '', JSONExtractString(StatusMessage, 'error'),\n JSONExtractString(StatusMessage, '_tag') != '', JSONExtractString(StatusMessage, '_tag'),\n JSONExtractString(StatusMessage, 'reason') != '', JSONExtractString(StatusMessage, 'reason'),\n JSONExtractString(StatusMessage, 'name') != '', JSONExtractString(StatusMessage, 'name'),\n JSONExtractString(StatusMessage, 'type') != '', extract(JSONExtractString(StatusMessage, 'type'), '([^/]+)$'),\n 'JSON error'\n ) AS _jsonLabel,\n multiIf(\n StatusMessage = '', 'Unknown Error',\n position(StatusMessage, '{ readonly') = 1 OR position(StatusMessage, '└─') > 0,\n if(\n extract(StatusMessage, 'readonly (\\\\w+)') != '',\n concat('Schema parse error: ', extract(StatusMessage, 'readonly (\\\\w+)')),\n 'Schema parse error'\n ),\n _isJsonObj OR position(StatusMessage, '[') = 1, _jsonLabel,\n left(StatusMessage, multiIf(\n position(StatusMessage, ': ') > 3, toInt64(position(StatusMessage, ': ')) - 1,\n position(StatusMessage, ' (') > 3, toInt64(position(StatusMessage, ' (')) - 1,\n position(StatusMessage, '\\n') > 3, toInt64(position(StatusMessage, '\\n')) - 1,\n least(toInt64(length(StatusMessage)), 150)\n ))\n ) AS _statusLabel,\n if(_exType != '', _exType, _statusLabel) AS _errorLabel,\n -- Both semconv spellings; the current key wins when both are present.\n toUInt16OrZero(\n if(\n SpanAttributes['http.response.status_code'] != '',\n SpanAttributes['http.response.status_code'],\n SpanAttributes['http.status_code']\n )\n ) AS _httpStatus\n SELECT\n OrgId,\n toDateTime(Timestamp) AS Timestamp,\n TraceId,\n SpanId,\n ParentSpanId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n _exType AS ExceptionType,\n _exMsg AS ExceptionMessage,\n _exStack AS ExceptionStacktrace,\n _topFrame AS TopFrame,\n cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash,\n StatusMessage,\n Duration,\n _errorLabel AS ErrorLabel,\n ResourceAttributes['service.version'] AS ServiceVersion\n FROM traces\n WHERE StatusCode = 'Error'\n -- Client-side runtimes (notably the native Cloudflare Workers\n -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot\n -- traffic arrived here as unlabelled \"Unknown Error\" issues. Drop a\n -- span only when all three hold: 4xx, no exception event, and no\n -- exception type. 5xx and anything carrying an exception still count,\n -- and SpanKind is deliberately not consulted — these are Client spans.\n AND NOT (\n _httpStatus >= 400 AND _httpStatus < 500\n AND _ei = 0\n AND _exType = ''\n )", "CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_mv TO error_events AS\nWITH\n arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei,\n if(_ei > 0, EventsAttributes[_ei]['exception.type'], '') AS _exType,\n if(_ei > 0, EventsAttributes[_ei]['exception.message'], StatusMessage) AS _exMsg,\n if(_ei > 0, EventsAttributes[_ei]['exception.stacktrace'], '') AS _exStack,\n -- Frame lines are matched by SHAPE, not by \"contains :NUMBER\". The old\n -- rule accepted any line with a colon-digit, which let non-frame lines\n -- in: Drizzle's `params: ` line, and the `Type: message`\n -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values\n -- and message text then entered the hash and split one bug into\n -- thousands of issues — 23,035 fingerprints for six real\n -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError\n -- ones.\n --\n -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts,\n -- as is every redaction below. They used to be hand-copied here, which\n -- let the reference implementation the tests exercise drift away from\n -- the SQL that actually runs, silently.\n arraySlice(\n arrayFilter(\n line -> match(line, '^[ \\\\t]*at |^[ \\\\t]*File \"|^[ \\\\t]+from [^ ]+:[0-9]+|^[^ \\\\t@]+@[^ \\\\t]*:[0-9]+|^[ \\\\t]+[^ \\\\t]+\\\\.(go|rs):[0-9]+|^[0-9]+ +\\\\S.* +0x[0-9a-fA-F]+'),\n splitByChar('\\n', _exStack)\n ),\n 1, 3\n ) AS _rawFrames,\n -- Redact every volatile token a frame line can carry: the URL origin\n -- (so preview hosts share one fingerprint), Vite's 8-char bundle\n -- content hash (so a deploy does not re-split every triaged browser and\n -- Worker issue), then line numbers, hex pointers and long id runs. See\n -- FRAME_REDACTIONS for the order and the reasoning.\n arrayMap(\n line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''),\n _rawFrames\n ) AS _topFrames,\n if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame,\n arrayStringConcat(_topFrames, '\\n') AS _fpFrames,\n -- JSON detection for the message signature below.\n isValidJSON(StatusMessage) AS _isJson,\n _isJson AND JSONType(StatusMessage) = 'Object' AS _isJsonObj,\n -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level\n -- keys, redact volatile tokens (long hex / numbers) in each raw value, then\n -- sort by \"key=value\" so key order & whitespace don't matter. No assumption\n -- about which keys exist — works for any producer's JSON shape. (Nested\n -- objects are hashed as their raw substring; only top-level is canonicalized.)\n arrayStringConcat(\n arraySort(\n arrayMap(\n kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')),\n JSONExtractKeysAndValuesRaw(StatusMessage)\n )\n ),\n '|'\n ) AS _jsonSig,\n -- The message signature is folded in ALWAYS, not only when there are no\n -- frames. Bundled runtimes minify every module into one file, so the top\n -- three frames of a Worker error are `toDatabaseError (worker.js)` for\n -- every failing query alike: on frames alone, 25 distinct DatabaseError\n -- bugs (316k occurrences) collapse into a single issue. The signature\n -- restores that discrimination, and it cannot reinflate cardinality the\n -- way a raw prefix would because everything variable is redacted first:\n -- emails, URL origins, home directories, query strings, quoted values,\n -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order,\n -- what is deliberately kept, and the one residual it cannot reach.\n multiIf(\n _isJsonObj, _jsonSig,\n substringUTF8(\n replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(StatusMessage, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'),\n 1, 120\n )\n ) AS _msgSig,\n -- Display-only, best-effort human label (decoupled from the fingerprint:\n -- many labels may map to one hash). The broad key list here is a DISPLAY\n -- heuristic only; the fingerprint above makes no key-name assumption.\n multiIf(\n JSONExtractString(StatusMessage, 'title') != '', JSONExtractString(StatusMessage, 'title'),\n JSONExtractString(StatusMessage, 'message') != '', JSONExtractString(StatusMessage, 'message'),\n JSONExtractString(StatusMessage, 'error') != '', JSONExtractString(StatusMessage, 'error'),\n JSONExtractString(StatusMessage, '_tag') != '', JSONExtractString(StatusMessage, '_tag'),\n JSONExtractString(StatusMessage, 'reason') != '', JSONExtractString(StatusMessage, 'reason'),\n JSONExtractString(StatusMessage, 'name') != '', JSONExtractString(StatusMessage, 'name'),\n JSONExtractString(StatusMessage, 'type') != '', extract(JSONExtractString(StatusMessage, 'type'), '([^/]+)$'),\n 'JSON error'\n ) AS _jsonLabel,\n multiIf(\n StatusMessage = '', 'Unknown Error',\n position(StatusMessage, '{ readonly') = 1 OR position(StatusMessage, '└─') > 0,\n if(\n extract(StatusMessage, 'readonly (\\\\w+)') != '',\n concat('Schema parse error: ', extract(StatusMessage, 'readonly (\\\\w+)')),\n 'Schema parse error'\n ),\n _isJsonObj OR position(StatusMessage, '[') = 1, _jsonLabel,\n left(StatusMessage, multiIf(\n position(StatusMessage, ': ') > 3, toInt64(position(StatusMessage, ': ')) - 1,\n position(StatusMessage, ' (') > 3, toInt64(position(StatusMessage, ' (')) - 1,\n position(StatusMessage, '\\n') > 3, toInt64(position(StatusMessage, '\\n')) - 1,\n least(toInt64(length(StatusMessage)), 150)\n ))\n ) AS _statusLabel,\n if(_exType != '', _exType, _statusLabel) AS _errorLabel,\n -- Both semconv spellings; the current key wins when both are present.\n toUInt16OrZero(\n if(\n SpanAttributes['http.response.status_code'] != '',\n SpanAttributes['http.response.status_code'],\n SpanAttributes['http.status_code']\n )\n ) AS _httpStatus\n SELECT\n OrgId,\n toDateTime(Timestamp) AS Timestamp,\n TraceId,\n SpanId,\n ParentSpanId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n _exType AS ExceptionType,\n _exMsg AS ExceptionMessage,\n _exStack AS ExceptionStacktrace,\n _topFrame AS TopFrame,\n cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash,\n StatusMessage,\n Duration,\n _errorLabel AS ErrorLabel,\n ResourceAttributes['service.version'] AS ServiceVersion\n FROM traces\n WHERE StatusCode = 'Error'\n -- Client-side runtimes (notably the native Cloudflare Workers\n -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot\n -- traffic arrived here as unlabelled \"Unknown Error\" issues. Drop a\n -- span only when all three hold: 4xx, no exception event, and no\n -- exception type. 5xx and anything carrying an exception still count,\n -- and SpanKind is deliberately not consulted — these are Client spans.\n AND NOT (\n _httpStatus >= 400 AND _httpStatus < 500\n AND _ei = 0\n AND _exType = ''\n )", "CREATE MATERIALIZED VIEW IF NOT EXISTS error_fingerprints_minutely_mv TO error_fingerprints_minutely AS\nSELECT\n OrgId,\n toStartOfMinute(Timestamp) AS Minute,\n FingerprintHash,\n anyLast(ServiceName) AS ServiceName,\n anyLast(ExceptionType) AS ExceptionType,\n anyLast(ExceptionMessage) AS ExceptionMessage,\n anyLast(ErrorLabel) AS ErrorLabel,\n anyLast(TopFrame) AS TopFrame,\n count() AS OccurrenceCount,\n min(Timestamp) AS FirstSeen,\n max(Timestamp) AS LastSeen,\n -- Distinct builds, not a sample: see ServiceVersions on the datasource.\n groupUniqArray(ServiceVersion) AS ServiceVersions\n FROM error_events\n GROUP BY OrgId, Minute, FingerprintHash", diff --git a/packages/domain/src/generated/tinybird-project-manifest.ts b/packages/domain/src/generated/tinybird-project-manifest.ts index f2da6a254..921a44702 100644 --- a/packages/domain/src/generated/tinybird-project-manifest.ts +++ b/packages/domain/src/generated/tinybird-project-manifest.ts @@ -1,13 +1,13 @@ // This file is generated by scripts/generate-tinybird-project-manifest.ts // Do not edit manually. -export const projectRevision = "bf3419c18e581ffab5fa4e24aa56f421ac9c3d5a1ce734596747f2331d7d2ae0" as const +export const projectRevision = "25e53de2337d2079a4efc306435d995387cdf89cecafa60e142f5665ccdeebbb" as const export const datasources = [ { name: "ai_trace_index", content: - 'DESCRIPTION >\n GenAI agent spans only (maple_ai.vendor.id stamped), pre-extracted to plain columns. Detection/facet surface for the Agent Sessions pages. Populated by materialized view.\n\nSCHEMA >\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SessionId String,\n VendorId LowCardinality(String),\n ServiceName LowCardinality(String)\n\nENGINE "MergeTree"\nENGINE_PARTITION_KEY "toDate(Timestamp)"\nENGINE_SORTING_KEY "OrgId, Timestamp, TraceId"\nENGINE_TTL "toDate(Timestamp) + INTERVAL 30 DAY"', + 'DESCRIPTION >\n GenAI agent spans only (maple_ai.vendor.id stamped), pre-extracted to plain columns. Detection/facet surface for the Agent Sessions pages. Populated by materialized view.\n\nSCHEMA >\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SessionId String,\n VendorId LowCardinality(String),\n ServiceName LowCardinality(String),\n DeploymentEnv LowCardinality(String),\n Model LowCardinality(String),\n AgentName LowCardinality(String),\n ToolName LowCardinality(String),\n SpanId String,\n ParentSpanId String,\n Duration UInt64,\n IsError UInt8,\n IsLlmCall UInt8,\n IsToolCall UInt8,\n Tokens Float64,\n Cost Float64\n\nENGINE "MergeTree"\nENGINE_PARTITION_KEY "toDate(Timestamp)"\nENGINE_SORTING_KEY "OrgId, Timestamp, TraceId"\nENGINE_TTL "toDate(Timestamp) + INTERVAL 30 DAY"', }, { name: "alert_checks", @@ -205,7 +205,7 @@ export const pipes = [ { name: "ai_trace_index_mv", content: - "DESCRIPTION >\n Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), pre-extracting the maple_ai.* identity to plain columns.\n\nNODE ai_trace_index_mv_node\nSQL >\n SELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''\n\nTYPE MATERIALIZED\nDATASOURCE ai_trace_index", + "DESCRIPTION >\n Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), pre-extracting the maple_ai.* identity, the environment, the GenAI model/agent/tool and the span's kind, failure and usage to plain columns.\n\nNODE ai_trace_index_mv_node\nSQL >\n SELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model,\n coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName,\n coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall,\n 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'], ''), SpanAttributes['llm.token_count.prompt'])) + 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'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) + 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'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS Tokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''\n\nTYPE MATERIALIZED\nDATASOURCE ai_trace_index", }, { name: "error_events_by_time_mv", diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index 7112f1417..644717869 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -16,6 +16,27 @@ import { warehouseReadHttpErrors } from "./warehouse" // shapes exist for it alone, so they live in the internal tier where they can // follow the UI. +/** The measures the list can be ordered by; `startTime` is the default. */ +export const AI_SESSION_SORT_KEYS = [ + "startTime", + "durationMs", + "cost", + "totalTokens", + "errorSpanCount", + "llmCalls", + "toolCalls", +] as const +export const AiSessionSortKey = Schema.Literals(AI_SESSION_SORT_KEYS) +export type AiSessionSortKey = Schema.Schema.Type + +export const AiSessionSortDir = Schema.Literals(["asc", "desc"]) +export type AiSessionSortDir = Schema.Schema.Type + +/** A range bound. Every measure the list filters on is non-negative, so a + * negative bound is a malformed request rather than an empty page. */ +const RangeBound = Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))) +const CountBound = Schema.optional(Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))) + export class ListAiSessionsRequest extends Schema.Class("ListAiSessionsRequest")({ startTime: TinybirdDateTime, endTime: TinybirdDateTime, @@ -30,13 +51,44 @@ export class ListAiSessionsRequest extends Schema.Class(" * that ranking returned, so the offset costs nothing there. */ offset: Schema.optional(Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), - // Both filters land on the `ai_trace_index` level of both stages, each as - // its own per-trace existence test: `serviceNames` means "some agent span - // of the trace came from this service", not "the trace touched it", and a - // vendor and a service given together may be matched by different spans of - // the trace — see `aiSessionPageQuery`. + // The counted filters land on the `ai_trace_index` level of both stages, + // one per index column, each as its own per-trace existence test: + // `serviceNames` means "some agent span of the trace came from this + // service", not "the trace touched it", and a model and a tool given + // together are matched by different spans of the trace — see + // `aiSessionPageQuery`. Each selects exactly the population its facet + // counted. vendorIds: Schema.optional(Schema.Array(Schema.String)), serviceNames: Schema.optional(Schema.Array(Schema.String)), + deploymentEnvs: Schema.optional(Schema.Array(Schema.String)), + models: Schema.optional(Schema.Array(Schema.String)), + agentNames: Schema.optional(Schema.Array(Schema.String)), + toolNames: Schema.optional(Schema.Array(Schema.String)), + /** + * A session id or trace id, or the leading characters of one, matched as a + * prefix. Bounded because it becomes a `LIKE` pattern against the index — + * no id in either column is anywhere near this long. + */ + search: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200))), + // The session-level filters: applied to the ranked row over the measures + // the index carries per agent span, so they have no facet count behind + // them. `hasErrors` means a failed agent span; a session whose only error + // is on a non-agent span shows the badge but is not matched. + hasErrors: Schema.optional(Schema.Boolean), + /** Drop the `trace:` sessions — traces whose vendor exposes no session key. */ + excludeTraceSessions: Schema.optional(Schema.Boolean), + durationMinMs: RangeBound, + durationMaxMs: RangeBound, + costMin: RangeBound, + costMax: RangeBound, + tokensMin: CountBound, + tokensMax: CountBound, + llmCallsMin: CountBound, + llmCallsMax: CountBound, + toolCallsMin: CountBound, + toolCallsMax: CountBound, + sortBy: Schema.optional(AiSessionSortKey), + sortDir: Schema.optional(AiSessionSortDir), }) {} export const AiSessionListItem = Schema.Struct({ @@ -52,6 +104,17 @@ export const AiSessionListItem = Schema.Struct({ errorSpanCount: Schema.Number, /** Every service touched by the session's traces. */ serviceNames: Schema.Array(Schema.String), + /** Every model any agent span of the session ran on, dialects coalesced. */ + models: Schema.Array(Schema.String), + /** Every agent named on any agent span of the session. */ + agentNames: Schema.Array(Schema.String), + llmCalls: Schema.Number, + toolCalls: Schema.Number, + /** Tokens across every bucket, deepest reporter counted, so the number + * agrees with the detail page's header. */ + totalTokens: Schema.Number, + /** USD as the instrumentation priced it; 0 where nothing reported a cost. */ + cost: Schema.Number, /** Warehouse datetime literals, e.g. `2026-08-19 10:33:25.825000000`. */ startTime: Schema.String, endTime: Schema.String, @@ -95,6 +158,14 @@ export class ListAiSessionsFacetsResponse extends Schema.Class( diff --git a/packages/domain/src/tinybird/datasources.ts b/packages/domain/src/tinybird/datasources.ts index afb7962fc..765fcd259 100644 --- a/packages/domain/src/tinybird/datasources.ts +++ b/packages/domain/src/tinybird/datasources.ts @@ -1093,12 +1093,24 @@ export type TraceDetailSpansRow = InferRow * one hour, timeout at a day). This table holds only those spans, pre-extracted * to plain columns, so the same detection is a scan of ~10k narrow rows per day. * - * The columns are exactly what its readers need — the trace-id set, the - * grouping key, the two filter dimensions, and the agent-span bounds that tell - * the fan-out which hours to read. Everything else about an agent span (its - * failure attributes, its vendor version) is read per-trace off + * The columns are what its readers need — the trace-id set, the grouping key, + * the agent-span bounds that tell the fan-out which hours to read, the filter + * dimensions the sidebar offers (service, environment, and the span's model, + * agent and tool coalesced across dialects), and the per-span measures the + * page ranks and filters on: whether the span is a model call, a tool call, a + * failure, and the tokens and cost it reported, with `SpanId`/`ParentSpanId` + * so a wrapper's roll-up of its children's usage can be taken off it. Every + * one of those is a fact of the GenAI span itself, so the index can carry it + * and the page can filter, sort and page on it without the fan-out. What the + * index cannot carry is the trace's non-agent spans: the row's `spanCount`, + * its all-span `errorSpanCount` and its true extent still come per-trace off * `trace_detail_spans`, over the page's bounds rather than the caller's window. * + * `Model`/`AgentName`/`ToolName` are `''` on the rows that carry no such fact + * — a chat span has no tool, a tool span no model — and the facets drop the + * blank option. `DeploymentEnv` is the resource attribute under either semconv + * spelling, like every other MV that pre-extracts it. + * * Session ids live only on the turn-owning spans, so `SessionId` is '' for most * rows — resolution to a session key stays per-TRACE at read time, exactly as * documented in `query-engine-integrations/src/ai/ai-sessions.ts`. @@ -1114,6 +1126,20 @@ export const aiTraceIndex = defineDatasource("ai_trace_index", { SessionId: t.string(), VendorId: t.string().lowCardinality(), ServiceName: t.string().lowCardinality(), + // Migration 0026 — the sidebar's other facet dimensions, and the per-span + // measures the page ranks and filters on. All `gen-ai-columns.ts`. + DeploymentEnv: t.string().lowCardinality(), + Model: t.string().lowCardinality(), + AgentName: t.string().lowCardinality(), + ToolName: t.string().lowCardinality(), + SpanId: t.string(), + ParentSpanId: t.string(), + Duration: t.uint64(), + IsError: t.uint8(), + IsLlmCall: t.uint8(), + IsToolCall: t.uint8(), + Tokens: t.float64(), + Cost: t.float64(), }, engine: engine.mergeTree({ partitionKey: "toDate(Timestamp)", diff --git a/packages/domain/src/tinybird/gen-ai-columns.ts b/packages/domain/src/tinybird/gen-ai-columns.ts new file mode 100644 index 000000000..646b42252 --- /dev/null +++ b/packages/domain/src/tinybird/gen-ai-columns.ts @@ -0,0 +1,284 @@ +// The facts of one GenAI span that `ai_trace_index` carries, as SQL. +// +// Every AI framework speaks its own attribute dialect, and the read side +// reconciles them per vendor in `@maple/query-engine-integrations` — after the +// spans are already in hand. The index has no such luxury: its materialized +// view sees one span at a time, at insert, and has to settle the model, the +// agent, the tool, the usage and the span's kind right then. So every rule +// lives here, as SQL the view compiles from — the same contract as +// `semconv-renames.ts`, for the same reason: the index's column and any +// raw-table read of the same fact must agree byte for byte, or a filter +// selects a population the facet never counted. +// +// The key lists mirror the sources the integrations layer decodes (its default +// `gen_ai.*` keys plus the Vercel AI SDK and OpenInference dialects), and the +// classification rules transcribe `classifyAiSpan`/`isLlmCall` +// (`apps/web/src/lib/agent-sessions/session-turns.ts`) and `spanTokenBuckets` +// (`session-summary.ts`) — so a session's "12 calls · $0.40" in the list agrees +// with its own overview. `ai-span-columns.test.ts` in the integrations package +// pins every key list to that layer's own alias tables, so a key added on one +// side cannot drift silently. + +import type { Condition, Expr } from "@maple-dev/clickhouse-builder/expr" +import * as CH from "@maple-dev/clickhouse-builder/expr" +import { compile } from "@maple-dev/clickhouse-builder/sql" + +/** A `$.SpanAttributes`-shaped accessor: the builder's own, or the bare-column + * stand-in the SQL text below is compiled from. */ +export interface MapColumnLike { + get(key: string): Expr +} + +/** The span columns the classification and failure rules read alongside the + * attribute Map. */ +export interface GenAiSpanColumnsLike { + readonly SpanName: Expr + readonly StatusCode: Expr + readonly SpanAttributes: MapColumnLike +} + +const mapColumn = (name: string): MapColumnLike => { + const column = CH.dynamicColumn>(name) + return { get: (key) => CH.mapGet(column, key) } +} + +/** The bare `traces` columns, for the SQL text the view is rendered from. */ +const rawSpan: GenAiSpanColumnsLike = { + SpanName: CH.dynamicColumn("SpanName"), + StatusCode: CH.dynamicColumn("StatusCode"), + SpanAttributes: mapColumn("SpanAttributes"), +} + +const sql = (expr: Expr | Condition): string => compile(expr.toFragment()) + +/** + * The first non-empty value among `keys`, else `''`. + * + * Map lookups return `''` for a missing key, so each candidate but the last is + * wrapped in `nullIf` to become a `coalesce` fallback. The last stays a bare + * lookup so the whole expression is a non-Nullable `String` — it feeds a + * non-Nullable MV column, and `''` is what "none" reads as everywhere else. + */ +export const firstNonEmptyAttr = (attrs: MapColumnLike, keys: ReadonlyArray): Expr => { + const last = keys[keys.length - 1] + if (last === undefined) throw new Error("firstNonEmptyAttr needs at least one key") + const candidates = keys.slice(0, -1).map((key) => CH.nullIf(attrs.get(key), "")) + return candidates.length === 0 ? attrs.get(last) : CH.coalesce(...candidates, attrs.get(last)) +} + +// Identity — model, agent, tool + +/** + * The model a span ran on. Response model first, because it is the one the + * provider actually served — an alias or a "latest" tag in the request + * resolves to a dated snapshot in the response — then the request model, then + * the Vercel AI SDK and OpenInference spellings of the same two. + */ +export const GENAI_MODEL_KEYS = [ + "gen_ai.response.model", + "gen_ai.request.model", + "ai.response.model", + "ai.model.id", + "llm.model_name", +] as const + +/** The agent that owns the span. `ai.telemetry.functionId` is the name an app + * gave a traced Vercel AI SDK call — the only agent identity an older-SDK span + * has, and in production the same value the sibling `invoke_agent` span puts in + * `gen_ai.agent.name`. */ +export const GENAI_AGENT_NAME_KEYS = ["gen_ai.agent.name", "ai.telemetry.functionId"] as const + +/** The tool an `execute_tool` span ran. */ +export const GENAI_TOOL_NAME_KEYS = ["gen_ai.tool.name", "ai.toolCall.name", "tool.name"] as const + +export function genAiModelExpr(spanAttributes: MapColumnLike): Expr { + return firstNonEmptyAttr(spanAttributes, GENAI_MODEL_KEYS) +} + +export function genAiAgentNameExpr(spanAttributes: MapColumnLike): Expr { + return firstNonEmptyAttr(spanAttributes, GENAI_AGENT_NAME_KEYS) +} + +export function genAiToolNameExpr(spanAttributes: MapColumnLike): Expr { + return firstNonEmptyAttr(spanAttributes, GENAI_TOOL_NAME_KEYS) +} + +// Operation and kind — is this span a model call, a tool call? + +const INFERENCE_OPS = ["chat", "generate_content", "text_completion", "fetch_response"] as const +const RETRIEVAL_OPS = ["embeddings", "retrieval"] as const +const TOOL_OPS = ["execute_tool"] as const +const AGENT_OPS = ["invoke_agent", "create_agent", "invoke_workflow", "plan", "agent_step"] as const +const KNOWN_OPS = [...INFERENCE_OPS, ...RETRIEVAL_OPS, ...TOOL_OPS, ...AGENT_OPS] + +/** `openinference.span.kind` → the operation the integration layer would + * refine it to. Mirrors `OPENINFERENCE_SPAN_KIND_OPERATIONS` in `ai-vendors.ts`. */ +export const OPENINFERENCE_KIND_OPERATIONS = [ + ["LLM", "chat"], + ["TOOL", "execute_tool"], + ["AGENT", "invoke_agent"], + ["EMBEDDING", "embeddings"], + ["RETRIEVER", "retrieval"], +] as const + +/** The span's operation: `gen_ai.operation.name`, else the OpenInference kind + * translated, else `''`. */ +export function genAiOperationExpr(attrs: MapColumnLike): Expr { + const kind = attrs.get("openinference.span.kind") + return CH.coalesce( + CH.nullIf(attrs.get("gen_ai.operation.name"), ""), + CH.multiIf( + OPENINFERENCE_KIND_OPERATIONS.map(([from, to]): [Condition, Expr] => [ + kind.eq(from), + CH.lit(to), + ]), + CH.lit(""), + ), + ) +} + +/** + * `classifyAiSpan`'s span-name fallback, for a span whose operation is absent + * or one the convention does not name (`generate_text`): tool, then agent, + * then inference — in that order, because the first rule that fires wins in + * the client too. + */ +const nameLooks = (name: Expr, needles: readonly [string, ...string[]]): Condition => { + const lowered = CH.lower_(name) + const [first, ...rest] = needles + return rest.reduce((cond, needle) => cond.or(lowered.like(`%${needle}%`)), lowered.like(`%${first}%`)) +} + +/** A model turn — what the list counts as an "LLM call". Embeddings and + * retrieval are inference time but not calls, exactly as `isLlmCall` says. + * Every span the index holds is vendor-stamped, so the client's "is an AI + * span" guard on the name rules is already met. */ +export function genAiIsLlmCallCond($: Pick): Condition { + const attrs = $.SpanAttributes + const op = genAiOperationExpr(attrs) + const byOperation = CH.inList(op, INFERENCE_OPS) + const byName = CH.notInList(op, KNOWN_OPS) + .and( + CH.not( + genAiToolNameExpr(attrs) + .neq("") + .or(nameLooks($.SpanName, ["tool"])), + ), + ) + .and(CH.not(nameLooks($.SpanName, ["agent", "workflow"]))) + .and( + genAiModelExpr(attrs) + .neq("") + .or(nameLooks($.SpanName, ["chat", "completion"])), + ) + return byOperation.or(byName) +} + +export function genAiIsToolCallCond($: Pick): Condition { + const attrs = $.SpanAttributes + const op = genAiOperationExpr(attrs) + const byOperation = CH.inList(op, TOOL_OPS) + const byName = CH.notInList(op, KNOWN_OPS).and( + genAiToolNameExpr(attrs) + .neq("") + .or(nameLooks($.SpanName, ["tool"])), + ) + return byOperation.or(byName) +} + +// Failure + +/** `gen_ai.response.status` values that mean the generation failed — semconv's + * `failed` plus the pre-enum `error` dialect. Mirrors `spanFailed` in + * `session-turns.ts`. */ +export const GENAI_FAILED_RESPONSE_STATUSES = ["failed", "error"] as const + +/** + * The span failed: by its own status, or by an attribute-declared failure — + * frameworks record a failed model or tool call as a VALUE on an `Ok` span. + * Scoped to GenAI spans by construction (every index row is one), which is + * what keeps an HTTP span's `error.type` on an expected 4xx out of it. + */ +export function genAiIsErrorCond($: Pick): Condition { + const attrs = $.SpanAttributes + return $.StatusCode.eq("Error") + .or(attrs.get("error.type").neq("")) + .or(CH.inList(attrs.get("gen_ai.response.status"), GENAI_FAILED_RESPONSE_STATUSES)) +} + +// Usage — the five disjoint token buckets `spanTokenBuckets` sums, each under +// its canonical key, its legacy `gen_ai.*` alias, and the Vercel AI SDK and +// OpenInference spellings. Canonical first: a span carrying both spellings is +// read the way the integration layer reads it. + +export const GENAI_USAGE_KEYS = { + input: [ + "gen_ai.usage.input_tokens", + "gen_ai.usage.prompt_tokens", + "ai.usage.inputTokens", + "ai.usage.promptTokens", + "llm.token_count.prompt", + ], + cacheRead: [ + "gen_ai.usage.cache_read.input_tokens", + "gen_ai.usage.input_tokens.cached", + "ai.usage.cachedInputTokens", + "ai.usage.inputTokenDetails.cacheReadTokens", + "llm.token_count.prompt_details.cache_read", + ], + cacheWrite: [ + "gen_ai.usage.cache_creation.input_tokens", + "gen_ai.usage.cache_write.input_tokens", + "ai.usage.inputTokenDetails.cacheWriteTokens", + ], + output: [ + "gen_ai.usage.output_tokens", + "gen_ai.usage.completion_tokens", + "ai.usage.outputTokens", + "ai.usage.completionTokens", + "llm.token_count.completion", + ], + reasoning: [ + "gen_ai.usage.reasoning.output_tokens", + "gen_ai.usage.output_tokens.reasoning", + "ai.usage.reasoningTokens", + "ai.usage.outputTokenDetails.reasoningTokens", + "llm.token_count.completion_details.reasoning", + ], +} as const + +export const GENAI_COST_KEYS = ["gen_ai.usage.cost", "gen_ai.usage.total_cost", "llm.cost.total"] as const + +const tokenBucket = (attrs: MapColumnLike, keys: ReadonlyArray): Expr => + CH.toFloat64OrZero(firstNonEmptyAttr(attrs, keys)) + +/** Every token the span reported, across the five buckets. `toFloat64OrZero` + * rather than a UInt64 parse: a dialect that writes `1234.0` still counts, and + * the sums never approach 2^53. */ +export function genAiTokensExpr(attrs: MapColumnLike): Expr { + const buckets = Object.values(GENAI_USAGE_KEYS).map((keys) => tokenBucket(attrs, keys)) + return buckets.reduce((sum, bucket) => sum.add(bucket)) +} + +/** USD as the instrumentation priced the call; 0 where nothing did. */ +export function genAiCostExpr(attrs: MapColumnLike): Expr { + return CH.toFloat64OrZero(firstNonEmptyAttr(attrs, GENAI_COST_KEYS)) +} + +/** A flag column: `1` where the condition holds. */ +const flag = (cond: Condition): Expr => CH.compileFnCall("toUInt8", cond) + +/** + * SQL text for the materialized view and the migration DDL. Each compiles + * byte-identically to its builder form applied to the raw `traces` columns, so + * the index's pre-extracted value and a read straight off the raw table resolve + * the same span to the same value. + */ +export const GENAI_MODEL_SQL = sql(genAiModelExpr(rawSpan.SpanAttributes)) +export const GENAI_AGENT_NAME_SQL = sql(genAiAgentNameExpr(rawSpan.SpanAttributes)) +export const GENAI_TOOL_NAME_SQL = sql(genAiToolNameExpr(rawSpan.SpanAttributes)) +export const GENAI_IS_LLM_CALL_SQL = sql(flag(genAiIsLlmCallCond(rawSpan))) +export const GENAI_IS_TOOL_CALL_SQL = sql(flag(genAiIsToolCallCond(rawSpan))) +export const GENAI_IS_ERROR_SQL = sql(flag(genAiIsErrorCond(rawSpan))) +export const GENAI_TOKENS_SQL = sql(genAiTokensExpr(rawSpan.SpanAttributes)) +export const GENAI_COST_SQL = sql(genAiCostExpr(rawSpan.SpanAttributes)) diff --git a/packages/domain/src/tinybird/materializations.ts b/packages/domain/src/tinybird/materializations.ts index 279aa13ee..738c018e4 100644 --- a/packages/domain/src/tinybird/materializations.ts +++ b/packages/domain/src/tinybird/materializations.ts @@ -48,6 +48,16 @@ import { } from "./db-query-shape-sql" import { MAPLE_AI_SESSION_ID_ATTR, MAPLE_AI_VENDOR_ID_ATTR } from "../gen-ai" import { DEPLOYMENT_ENV_SQL, MESSAGING_DESTINATION_SQL } from "./semconv-renames" +import { + GENAI_AGENT_NAME_SQL, + GENAI_COST_SQL, + GENAI_IS_ERROR_SQL, + GENAI_IS_LLM_CALL_SQL, + GENAI_IS_TOOL_CALL_SQL, + GENAI_MODEL_SQL, + GENAI_TOKENS_SQL, + GENAI_TOOL_NAME_SQL, +} from "./gen-ai-columns" import { NORMALIZED_SPAN_NAME_SQL } from "./span-display-name" /** @@ -975,10 +985,16 @@ export const traceDetailSpansMv = defineMaterializedView("trace_detail_spans_mv" * * A missing Map key reads back as `''`, so the single `!= ''` comparison is * both the presence check and the non-empty check. + * + * The GenAI columns coalesce the dialects and classify the span at insert — + * the SQL comes from `gen-ai-columns.ts`, so a raw-table read of the same fact + * is the same expression. Migration 0026 added them; rows materialized before + * it carry `''`/0 throughout, which the facets drop, the filters never match + * and the sums count as nothing. */ export const aiTraceIndexMv = defineMaterializedView("ai_trace_index_mv", { description: - "Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), pre-extracting the maple_ai.* identity to plain columns.", + "Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), pre-extracting the maple_ai.* identity, the environment, the GenAI model/agent/tool and the span's kind, failure and usage to plain columns.", datasource: aiTraceIndex, nodes: [ node({ @@ -990,7 +1006,19 @@ export const aiTraceIndexMv = defineMaterializedView("ai_trace_index_mv", { TraceId, SpanAttributes['${MAPLE_AI_SESSION_ID_ATTR}'] AS SessionId, SpanAttributes['${MAPLE_AI_VENDOR_ID_ATTR}'] AS VendorId, - ServiceName + ServiceName, + ${DEPLOYMENT_ENV_SQL} AS DeploymentEnv, + ${GENAI_MODEL_SQL} AS Model, + ${GENAI_AGENT_NAME_SQL} AS AgentName, + ${GENAI_TOOL_NAME_SQL} AS ToolName, + SpanId, + ParentSpanId, + Duration, + ${GENAI_IS_ERROR_SQL} AS IsError, + ${GENAI_IS_LLM_CALL_SQL} AS IsLlmCall, + ${GENAI_IS_TOOL_CALL_SQL} AS IsToolCall, + ${GENAI_TOKENS_SQL} AS Tokens, + ${GENAI_COST_SQL} AS Cost FROM traces WHERE SpanAttributes['${MAPLE_AI_VENDOR_ID_ATTR}'] != '' `, diff --git a/packages/domain/src/tinybird/semconv-renames.ts b/packages/domain/src/tinybird/semconv-renames.ts index 7fbecc2bc..66cb7afb5 100644 --- a/packages/domain/src/tinybird/semconv-renames.ts +++ b/packages/domain/src/tinybird/semconv-renames.ts @@ -83,4 +83,3 @@ export function messagingDestinationExpr(spanAttributes: MapColumnLike): Expr= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND DeploymentEnv != '' + GROUP BY traceId) AS facet_traces + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + arrayJoin(names) AS name, + uniqExact(if(rawSessionId = '', concat('trace:', traceId), rawSessionId)) AS count, + 'model' AS facetType + FROM (SELECT + TraceId AS traceId, + max(SessionId) AS rawSessionId, + groupUniqArray(Model) AS names + FROM ai_trace_index + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Model != '' + GROUP BY traceId) AS facet_traces + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + arrayJoin(names) AS name, + uniqExact(if(rawSessionId = '', concat('trace:', traceId), rawSessionId)) AS count, + 'agent' AS facetType + FROM (SELECT + TraceId AS traceId, + max(SessionId) AS rawSessionId, + groupUniqArray(AgentName) AS names + FROM ai_trace_index + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND AgentName != '' + GROUP BY traceId) AS facet_traces + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + arrayJoin(names) AS name, + uniqExact(if(rawSessionId = '', concat('trace:', traceId), rawSessionId)) AS count, + 'tool' AS facetType + FROM (SELECT + TraceId AS traceId, + max(SessionId) AS rawSessionId, + groupUniqArray(ToolName) AS names + FROM ai_trace_index + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ToolName != '' + GROUP BY traceId) AS facet_traces + GROUP BY name + ORDER BY count DESC + LIMIT 50 FORMAT JSON -- builder:ai-sessions:aiSessionListQuery:default @@ -68,7 +140,14 @@ SELECT TraceId AS traceId, max(SessionId) AS rawSessionId, min(Timestamp) AS traceAgentStart, - max(Timestamp) AS traceAgentEnd + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters FROM ai_trace_index WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-02 10:30:00' @@ -83,7 +162,14 @@ SELECT TraceId AS traceId, max(SessionId) AS rawSessionId, min(Timestamp) AS traceAgentStart, - max(Timestamp) AS traceAgentEnd + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters FROM ai_trace_index WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-02 10:30:00' @@ -94,6 +180,88 @@ SELECT ORDER BY startTime DESC FORMAT JSON +-- builder:ai-sessions:aiSessionListQuery:every-counted-filter +SELECT + if(index_traces.rawSessionId = '', concat('trace:', session_traces.traceId), index_traces.rawSessionId) AS sessionId, + argMin(session_traces.vendorId, session_traces.sessionStart) AS vendorId, + argMin(session_traces.vendorVersion, session_traces.sessionStart) AS vendorVersion, + count() AS traceCount, + sum(session_traces.spanCount) AS spanCount, + sum(session_traces.errorSpanCount) AS errorSpanCount, + groupUniqArrayArray(session_traces.serviceNames) AS serviceNames, + toString(min(session_traces.traceStart)) AS startTime, + toString(fromUnixTimestamp64Nano(max(session_traces.traceEndNanos))) AS endTime, + intDiv(max(session_traces.traceEndNanos) - toUnixTimestamp64Nano(min(session_traces.traceStart)), 1000000) AS durationMs + FROM (SELECT + TraceId AS traceId, + argMin(SpanAttributes['maple_ai.vendor.id'], tuple(multiIf((mapContains(SpanAttributes, 'maple_ai.session.id') AND SpanAttributes['maple_ai.session.id'] != ''), 0, SpanAttributes['maple_ai.vendor.id'] != '', 1, 2), Timestamp)) AS vendorId, + argMin(SpanAttributes['maple_ai.vendor.version'], tuple(multiIf((mapContains(SpanAttributes, 'maple_ai.session.id') AND SpanAttributes['maple_ai.session.id'] != ''), 0, SpanAttributes['maple_ai.vendor.id'] != '', 1, 2), Timestamp)) AS vendorVersion, + min(if((mapContains(SpanAttributes, 'maple_ai.session.id') AND SpanAttributes['maple_ai.session.id'] != ''), Timestamp, toDateTime('2106-01-01 00:00:00'))) AS sessionStart, + count() AS spanCount, + countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (SpanAttributes['error.type'] != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + groupUniqArray(ServiceName) AS serviceNames, + min(Timestamp) AS traceStart, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceEndNanos + FROM trace_detail_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-02 10:30:00' - INTERVAL 3600 SECOND + AND Timestamp <= '2026-01-02 12:30:00' + INTERVAL 3600 SECOND + AND TraceId IN (SELECT + traceId AS traceId + FROM (SELECT + TraceId AS traceId, + max(SessionId) AS rawSessionId, + min(Timestamp) AS traceAgentStart, + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters + FROM ai_trace_index + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-02 10:30:00' + AND Timestamp <= '2026-01-02 12:30:00' + GROUP BY traceId + HAVING countIf(DeploymentEnv IN ('production')) > 0 + AND countIf(Model IN ('gpt-5.5')) > 0 + AND countIf(AgentName IN ('billing-agent')) > 0 + AND countIf(ToolName IN ('send_email')) > 0 + AND countIf((SessionId LIKE 'wrun\\_01%' OR TraceId LIKE 'wrun\\_01%')) > 0) AS agent_traces + WHERE if(rawSessionId = '', concat('trace:', traceId), rawSessionId) IN ('wrun_sql_catalog', 'trace:7f3a4b5c6d7e8f901234567890abcdef')) + GROUP BY traceId) AS session_traces + INNER JOIN (SELECT + traceId AS traceId, + rawSessionId AS rawSessionId + FROM (SELECT + TraceId AS traceId, + max(SessionId) AS rawSessionId, + min(Timestamp) AS traceAgentStart, + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters + FROM ai_trace_index + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-02 10:30:00' + AND Timestamp <= '2026-01-02 12:30:00' + GROUP BY traceId + HAVING countIf(DeploymentEnv IN ('production')) > 0 + AND countIf(Model IN ('gpt-5.5')) > 0 + AND countIf(AgentName IN ('billing-agent')) > 0 + AND countIf(ToolName IN ('send_email')) > 0 + AND countIf((SessionId LIKE 'wrun\\_01%' OR TraceId LIKE 'wrun\\_01%')) > 0) AS agent_traces + WHERE if(rawSessionId = '', concat('trace:', traceId), rawSessionId) IN ('wrun_sql_catalog', 'trace:7f3a4b5c6d7e8f901234567890abcdef')) AS index_traces ON session_traces.traceId = index_traces.traceId + GROUP BY sessionId + ORDER BY startTime DESC + FORMAT JSON + -- builder:ai-sessions:aiSessionListQuery:filtered SELECT if(index_traces.rawSessionId = '', concat('trace:', session_traces.traceId), index_traces.rawSessionId) AS sessionId, @@ -126,7 +294,14 @@ SELECT TraceId AS traceId, max(SessionId) AS rawSessionId, min(Timestamp) AS traceAgentStart, - max(Timestamp) AS traceAgentEnd + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters FROM ai_trace_index WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-02 10:30:00' @@ -143,7 +318,14 @@ SELECT TraceId AS traceId, max(SessionId) AS rawSessionId, min(Timestamp) AS traceAgentStart, - max(Timestamp) AS traceAgentEnd + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters FROM ai_trace_index WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-02 10:30:00' @@ -160,12 +342,27 @@ SELECT SELECT if(rawSessionId = '', concat('trace:', traceId), rawSessionId) AS sessionId, toString(min(traceAgentStart)) AS agentStart, - toString(max(traceAgentEnd)) AS agentEnd + toString(max(traceAgentEnd)) AS agentEnd, + groupUniqArrayArray(models) AS models, + groupUniqArrayArray(agentNames) AS agentNames, + sum(llmCalls) AS llmCalls, + sum(toolCalls) AS toolCalls, + sum(errorAgentSpans) AS errorAgentSpans, + sum(arraySum(r -> greatest(0., r.3 - arraySum(c -> if(c.2 = r.1, c.3, 0.), usageReporters)), usageReporters)) AS totalTokens, + sum(arraySum(r -> greatest(0., r.4 - arraySum(c -> if(c.2 = r.1, c.4, 0.), usageReporters)), usageReporters)) AS cost, + intDiv(max(traceAgentEndNanos) - toUnixTimestamp64Nano(min(traceAgentStart)), 1000000) AS agentDurationMs FROM (SELECT TraceId AS traceId, max(SessionId) AS rawSessionId, min(Timestamp) AS traceAgentStart, - max(Timestamp) AS traceAgentEnd + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters FROM ai_trace_index WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -176,16 +373,86 @@ SELECT LIMIT 50 FORMAT JSON +-- builder:ai-sessions:aiSessionPageQuery:every-filter +SELECT + if(rawSessionId = '', concat('trace:', traceId), rawSessionId) AS sessionId, + toString(min(traceAgentStart)) AS agentStart, + toString(max(traceAgentEnd)) AS agentEnd, + groupUniqArrayArray(models) AS models, + groupUniqArrayArray(agentNames) AS agentNames, + sum(llmCalls) AS llmCalls, + sum(toolCalls) AS toolCalls, + sum(errorAgentSpans) AS errorAgentSpans, + sum(arraySum(r -> greatest(0., r.3 - arraySum(c -> if(c.2 = r.1, c.3, 0.), usageReporters)), usageReporters)) AS totalTokens, + sum(arraySum(r -> greatest(0., r.4 - arraySum(c -> if(c.2 = r.1, c.4, 0.), usageReporters)), usageReporters)) AS cost, + intDiv(max(traceAgentEndNanos) - toUnixTimestamp64Nano(min(traceAgentStart)), 1000000) AS agentDurationMs + FROM (SELECT + TraceId AS traceId, + max(SessionId) AS rawSessionId, + min(Timestamp) AS traceAgentStart, + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters + FROM ai_trace_index + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + GROUP BY traceId + HAVING countIf(VendorId IN ('eve')) > 0 + AND countIf(ServiceName IN ('maple-slack-agent')) > 0 + AND countIf(DeploymentEnv IN ('production')) > 0 + AND countIf(Model IN ('gpt-5.5')) > 0 + AND countIf(AgentName IN ('billing-agent')) > 0 + AND countIf(ToolName IN ('send_email')) > 0 + AND countIf((SessionId LIKE 'wrun\\_01%' OR TraceId LIKE 'wrun\\_01%')) > 0) AS index_traces + GROUP BY sessionId + HAVING errorAgentSpans > 0 + AND NOT (sessionId LIKE 'trace:%') + AND agentDurationMs >= 1000 + AND agentDurationMs <= 600000 + AND cost >= 0.01 + AND cost <= 5 + AND totalTokens >= 100 + AND totalTokens <= 1000000 + AND llmCalls >= 1 + AND llmCalls <= 50 + AND toolCalls >= 1 + AND toolCalls <= 50 + ORDER BY cost ASC, agentStart DESC, sessionId ASC + LIMIT 25 + OFFSET 25 + FORMAT JSON + -- builder:ai-sessions:aiSessionPageQuery:filtered SELECT if(rawSessionId = '', concat('trace:', traceId), rawSessionId) AS sessionId, toString(min(traceAgentStart)) AS agentStart, - toString(max(traceAgentEnd)) AS agentEnd + toString(max(traceAgentEnd)) AS agentEnd, + groupUniqArrayArray(models) AS models, + groupUniqArrayArray(agentNames) AS agentNames, + sum(llmCalls) AS llmCalls, + sum(toolCalls) AS toolCalls, + sum(errorAgentSpans) AS errorAgentSpans, + sum(arraySum(r -> greatest(0., r.3 - arraySum(c -> if(c.2 = r.1, c.3, 0.), usageReporters)), usageReporters)) AS totalTokens, + sum(arraySum(r -> greatest(0., r.4 - arraySum(c -> if(c.2 = r.1, c.4, 0.), usageReporters)), usageReporters)) AS cost, + intDiv(max(traceAgentEndNanos) - toUnixTimestamp64Nano(min(traceAgentStart)), 1000000) AS agentDurationMs FROM (SELECT TraceId AS traceId, max(SessionId) AS rawSessionId, min(Timestamp) AS traceAgentStart, - max(Timestamp) AS traceAgentEnd + max(Timestamp) AS traceAgentEnd, + max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos, + groupUniqArrayIf(20)(Model, Model != '') AS models, + groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames, + sum(IsLlmCall) AS llmCalls, + sum(IsToolCall) AS toolCalls, + sum(IsError) AS errorAgentSpans, + groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters FROM ai_trace_index WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' 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 e3ed33bb4..4d337c5b7 100644 --- a/packages/query-engine-integrations/src/ai/ai-sessions.test.ts +++ b/packages/query-engine-integrations/src/ai/ai-sessions.test.ts @@ -9,6 +9,7 @@ import { import { aiSessionFacetsQuery, aiSessionListQuery, + idSearchPattern, aiSessionPageQuery, aiSessionSpansQuery, aiSessionSpansRowSchema, @@ -196,6 +197,14 @@ describe("aiSessionPageQuery", () => { sessionId: "wrun_01M0CSAEW96BH2W9185XZPRPKH", agentStart: "2026-08-19 10:33:25.825000000", agentEnd: "2026-08-19 10:33:36.242000000", + models: ["claude-sonnet-5"], + agentNames: ["slack-agent"], + llmCalls: "12", + toolCalls: "7", + errorAgentSpans: "1", + totalTokens: 184_320, + cost: 0.4125, + agentDurationMs: "10417", }, ]), ).toEqual([ @@ -203,9 +212,155 @@ describe("aiSessionPageQuery", () => { sessionId: "wrun_01M0CSAEW96BH2W9185XZPRPKH", agentStart: "2026-08-19 10:33:25.825000000", agentEnd: "2026-08-19 10:33:36.242000000", + models: ["claude-sonnet-5"], + agentNames: ["slack-agent"], + llmCalls: 12, + toolCalls: 7, + errorAgentSpans: 1, + totalTokens: 184_320, + cost: 0.4125, + agentDurationMs: 10_417, }, ]) }) + + it("tests every counted filter per trace, one per index column", () => { + const { sql } = compileUnsafe( + aiSessionPageQuery({ + deploymentEnvs: ["production"], + models: ["gpt-5.5", "claude-sonnet-5"], + agentNames: ["billing-agent"], + toolNames: ["send_email"], + search: " wrun01M0 ", + }), + params, + ) + const [where, having] = sql.split("GROUP BY traceId") + + // Per trace, not per row: a model sits on the chat span and a tool on the + // tool span, so a row predicate ANDing the two can never match. The + // grouping is what lets one facet's value and another's combine. + expect(having).toContain("countIf(DeploymentEnv IN ('production')) > 0") + expect(having).toContain("countIf(Model IN ('gpt-5.5', 'claude-sonnet-5')) > 0") + expect(having).toContain("countIf(AgentName IN ('billing-agent')) > 0") + expect(having).toContain("countIf(ToolName IN ('send_email')) > 0") + expect(having).toContain("countIf((SessionId LIKE 'wrun01M0%' OR TraceId LIKE 'wrun01M0%')) > 0") + expect(where).not.toContain(" IN ('") + expect(where).not.toContain("LIKE") + }) + + it("ignores a blank search", () => { + expect(compileUnsafe(aiSessionPageQuery({ search: " " }), params).sql).not.toContain("LIKE") + }) + + it("collects the measures per trace off the index, and sums them per session", () => { + const { sql } = compileUnsafe(aiSessionPageQuery(), params) + const [outer, inner] = sql.split("FROM (SELECT") + + expect(inner).toContain("groupUniqArrayIf(20)(Model, Model != '') AS models") + expect(inner).toContain("groupUniqArrayIf(20)(AgentName, AgentName != '') AS agentNames") + expect(inner).toContain("sum(IsLlmCall) AS llmCalls") + expect(inner).toContain("sum(IsToolCall) AS toolCalls") + expect(inner).toContain("sum(IsError) AS errorAgentSpans") + expect(inner).toContain( + "groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0)) AS usageReporters", + ) + expect(inner).toContain( + "max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) AS traceAgentEndNanos", + ) + + expect(outer).toContain("groupUniqArrayArray(models) AS models") + expect(outer).toContain("sum(llmCalls) AS llmCalls") + expect(outer).toContain("sum(errorAgentSpans) AS errorAgentSpans") + // Deepest reporter: a parent keeps only its excess over its reporting children. + expect(outer).toContain( + "sum(arraySum(r -> greatest(0., r.3 - arraySum(c -> if(c.2 = r.1, c.3, 0.), usageReporters)), usageReporters)) AS totalTokens", + ) + expect(outer).toContain( + "sum(arraySum(r -> greatest(0., r.4 - arraySum(c -> if(c.2 = r.1, c.4, 0.), usageReporters)), usageReporters)) AS cost", + ) + expect(outer).toContain( + "intDiv(max(traceAgentEndNanos) - toUnixTimestamp64Nano(min(traceAgentStart)), 1000000) AS agentDurationMs", + ) + // Still index-only: none of it reaches for the fan-out table. + expect(sql).not.toContain("trace_detail_spans") + }) + + it("filters the ranked row with HAVING, after the session grouping", () => { + const { sql } = compileUnsafe( + aiSessionPageQuery({ + hasErrors: true, + excludeTraceSessions: true, + durationMinMs: 1_000, + durationMaxMs: 60_000, + costMin: 0.5, + costMax: 2, + tokensMin: 100, + tokensMax: 200_000, + llmCallsMin: 1, + llmCallsMax: 40, + toolCallsMin: 2, + toolCallsMax: 9, + }), + params, + ) + + const having = sql.slice(sql.indexOf("GROUP BY sessionId"), sql.indexOf("ORDER BY")) + expect(having).toContain("errorAgentSpans > 0") + expect(having).toContain("NOT (sessionId LIKE 'trace:%')") + expect(having).toContain("agentDurationMs >= 1000") + expect(having).toContain("agentDurationMs <= 60000") + expect(having).toContain("cost >= 0.5") + expect(having).toContain("cost <= 2") + expect(having).toContain("totalTokens >= 100") + expect(having).toContain("totalTokens <= 200000") + expect(having).toContain("llmCalls >= 1") + expect(having).toContain("llmCalls <= 40") + expect(having).toContain("toolCalls >= 2") + expect(having).toContain("toolCalls <= 9") + }) + + it("treats an explicit false as no filter, and the default sort as the baseline order", () => { + const { sql } = compileUnsafe( + aiSessionPageQuery({ hasErrors: false, excludeTraceSessions: false }), + params, + ) + + expect(sql.slice(sql.indexOf("GROUP BY sessionId"))).not.toContain("HAVING") + expect(sql).toContain("ORDER BY agentStart DESC, sessionId ASC") + }) + + it("sorts by the requested measure with newest-first and the session id as tiebreaks", () => { + expect(compileUnsafe(aiSessionPageQuery({ sortBy: "cost", sortDir: "asc" }), params).sql).toContain( + "ORDER BY cost ASC, agentStart DESC, sessionId ASC", + ) + expect(compileUnsafe(aiSessionPageQuery({ sortBy: "durationMs" }), params).sql).toContain( + "ORDER BY agentDurationMs DESC, agentStart DESC, sessionId ASC", + ) + expect(compileUnsafe(aiSessionPageQuery({ sortBy: "errorSpanCount" }), params).sql).toContain( + "ORDER BY errorAgentSpans DESC, agentStart DESC, sessionId ASC", + ) + expect( + compileUnsafe(aiSessionPageQuery({ sortBy: "startTime", sortDir: "asc" }), params).sql, + ).toContain("ORDER BY agentStart ASC, sessionId ASC") + }) +}) + +describe("idSearchPattern", () => { + it("turns a pasted id into a prefix pattern", () => { + expect(idSearchPattern("wrun_01M0")).toBe("wrun\\_01M0%") + }) + + it("strips what the list row shows around a trace session id", () => { + expect(idSearchPattern("trace:7f3a4b5c…")).toBe("7f3a4b5c%") + expect(idSearchPattern("trace:7f3a4b5c6d7e8f901234567890abcdef")).toBe( + "7f3a4b5c6d7e8f901234567890abcdef%", + ) + }) + + it("escapes LIKE syntax so a pasted id matches literally", () => { + expect(idSearchPattern("50%_off\\")).toBe("50\\%\\_off\\\\%") + }) }) describe("aiSessionListQuery", () => { @@ -241,9 +396,7 @@ describe("aiSessionListQuery", () => { // The ids come back off the page's own rows, but they are session ids a // vendor chose, so the escaping is what stands between one and the query. - expect(sql).toContain( - `${SESSION_KEY} IN ('wrun_01M0CSAEW96BH2W9185XZPRPKH', 'sess\\'evil')`, - ) + expect(sql).toContain(`${SESSION_KEY} IN ('wrun_01M0CSAEW96BH2W9185XZPRPKH', 'sess\\'evil')`) expect(sql.split(`${SESSION_KEY} IN (`).length - 1).toBe(2) }) @@ -274,9 +427,7 @@ describe("aiSessionListQuery", () => { }) it("is org-scoped", () => { - expect(compileUnsafe(aiSessionListQuery(listOpts), listParams).tenantScope).toBe( - "single-tenant", - ) + expect(compileUnsafe(aiSessionListQuery(listOpts), listParams).tenantScope).toBe("single-tenant") }) it("selects the page's traces on index membership, with no attribute predicate", () => { @@ -420,9 +571,7 @@ describe("aiSessionListQuery", () => { // Not a failure the caller recovers from: `IN ()` is not SQL, and a caller // holding an empty page already knows to answer it without this read. expect(() => aiSessionListQuery({ sessionIds: [] })).toThrow(QueryBuilderDefect) - expect(() => aiSessionListQuery({ sessionIds: [] })).toThrow( - /needs the page's session ids/, - ) + expect(() => aiSessionListQuery({ sessionIds: [] })).toThrow(/needs the page's session ids/) }) it("leaves no unresolved param placeholder", () => { @@ -473,16 +622,24 @@ describe("aiSessionFacetsQuery", () => { expect(sql).toContain("UNION ALL") }) - it("counts distinct sessions per vendor and per service", () => { + it("counts distinct sessions per value of each index dimension", () => { const { sql } = compileUnionUnsafe(aiSessionFacetsQuery(), params) - expect(sql).toContain("groupUniqArray(VendorId) AS names") - expect(sql).toContain("groupUniqArray(ServiceName) AS names") - expect(sql.split("arrayJoin(names) AS name").length - 1).toBe(2) - expect(sql).toContain("'vendor' AS facetType") - expect(sql).toContain("'service' AS facetType") - expect(sql.split("GROUP BY name").length - 1).toBe(2) - expect(sql.split("ORDER BY count DESC").length - 1).toBe(2) + const dimensions = [ + ["vendor", "VendorId"], + ["service", "ServiceName"], + ["environment", "DeploymentEnv"], + ["model", "Model"], + ["agent", "AgentName"], + ["tool", "ToolName"], + ] as const + for (const [facetType, column] of dimensions) { + expect(sql).toContain(`groupUniqArray(${column}) AS names`) + expect(sql).toContain(`'${facetType}' AS facetType`) + } + expect(sql.split("arrayJoin(names) AS name").length - 1).toBe(dimensions.length) + expect(sql.split("GROUP BY name").length - 1).toBe(dimensions.length) + expect(sql.split("ORDER BY count DESC").length - 1).toBe(dimensions.length) }) it("counts the trace's session key, resolved one level below the count", () => { @@ -492,17 +649,17 @@ describe("aiSessionFacetsQuery", () => { // trace that lacks the id — most of them — as its own sessionless trace, // and roughly double every number in the sidebar. So the key is resolved // per trace and only then counted. - expect(sql.split(`uniqExact(${SESSION_KEY}) AS count`).length - 1).toBe(2) - expect(sql.split("GROUP BY traceId").length - 1).toBe(2) + expect(sql.split(`uniqExact(${SESSION_KEY}) AS count`).length - 1).toBe(6) + expect(sql.split("GROUP BY traceId").length - 1).toBe(6) expect(sql).not.toContain("uniqExact(SpanAttributes['maple_ai.session.id'])") }) it("repeats the org and window predicates on every union branch", () => { const { sql } = compileUnionUnsafe(aiSessionFacetsQuery(), params) - expect(orgPredicateCount(sql)).toBe(2) - expect(sql.split(`Timestamp >= '${params.startTime}'`).length - 1).toBe(2) - expect(sql.split(`Timestamp <= '${params.endTime}'`).length - 1).toBe(2) + expect(orgPredicateCount(sql)).toBe(6) + expect(sql.split(`Timestamp >= '${params.startTime}'`).length - 1).toBe(6) + expect(sql.split(`Timestamp <= '${params.endTime}'`).length - 1).toBe(6) }) it("is org-scoped", () => { @@ -516,10 +673,11 @@ describe("aiSessionFacetsQuery", () => { // the vendor guard — so the population a facet describes is exactly the // population its filter selects. Only the blank-option guard remains as a // predicate. - expect(sql.split("FROM ai_trace_index").length - 1).toBe(2) + expect(sql.split("FROM ai_trace_index").length - 1).toBe(6) expect(sql).not.toContain("mapContains") - expect(sql).toContain("VendorId != ''") - expect(sql).toContain("ServiceName != ''") + for (const column of ["VendorId", "ServiceName", "DeploymentEnv", "Model", "AgentName", "ToolName"]) { + expect(sql).toContain(`${column} != ''`) + } }) it("leaves no unresolved param placeholder", () => { diff --git a/packages/query-engine-integrations/src/ai/ai-sessions.ts b/packages/query-engine-integrations/src/ai/ai-sessions.ts index c43680f2b..b011203e7 100644 --- a/packages/query-engine-integrations/src/ai/ai-sessions.ts +++ b/packages/query-engine-integrations/src/ai/ai-sessions.ts @@ -106,6 +106,7 @@ import { Schema } from "effect" import * as CH from "@maple-dev/clickhouse-builder/expr" +import * as T from "@maple-dev/clickhouse-builder/types" import { compileFnCall, from, @@ -120,13 +121,14 @@ 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, type AiSessionSortDir, type AiSessionSortKey } from "@maple/domain/http" import { MAPLE_AI_SESSION_ID_ATTR, MAPLE_AI_TRACE_SESSION_PREFIX, MAPLE_AI_VENDOR_ID_ATTR, MAPLE_AI_VENDOR_VERSION_ATTR, } from "@maple/domain/gen-ai" +import { deepestReporterSum, usageReportersExpr } from "./ai-span-columns" const SESSION_ID_ATTR = MAPLE_AI_SESSION_ID_ATTR const VENDOR_ID_ATTR = MAPLE_AI_VENDOR_ID_ATTR @@ -200,17 +202,54 @@ const orderTuple = (...parts: ReadonlyArray): CH.Expr => const sessionKey = (rawSessionId: CH.Expr, traceId: CH.Expr): CH.Expr => CH.if_(rawSessionId.eq(""), CH.concat(MAPLE_AI_TRACE_SESSION_PREFIX, traceId), rawSessionId) -/** The filters the page and the list share; both apply them on `ai_trace_index`. */ +/** + * The filters the page and the list share; both apply them on `ai_trace_index`, + * each as a per-trace existence test — see `indexTraces`. One per index + * column, so each selects exactly the population `aiSessionFacetsQuery` + * counted for it. + */ export interface AiSessionFilterOpts { readonly vendorIds?: readonly string[] readonly serviceNames?: readonly string[] + readonly deploymentEnvs?: readonly string[] + readonly models?: readonly string[] + readonly agentNames?: readonly string[] + readonly toolNames?: readonly string[] + /** + * A session id or trace id, or the leading characters of one — what a + * reader pastes from a ticket, a log line, or the list row itself. Matched + * as a prefix against both id columns of the index. + */ + readonly search?: string } export interface AiSessionPageOpts extends AiSessionFilterOpts { - /** Sessions returned, most recently started first. */ + /** Sessions returned, most recently started first unless `sortBy` says otherwise. */ readonly limit?: number /** Sessions skipped before `limit` applies — the list's next page. */ readonly offset?: number + // Session-level filters — `HAVING` on the ranked session row, over the + // measures the index carries per span (migration 0026). A failure here is + // a failed AGENT span; the row's `errorSpanCount` counts every span of the + // trace, so a session whose only error is on an HTTP span is listed with a + // badge and not matched by the filter. A duration here is the extent of + // the agent spans, which the true extent trails by minutes at most (see + // `FAN_OUT_PAD_SECONDS`). + readonly hasErrors?: boolean + /** Drop the `trace:` sessions — traces whose vendor exposes no session key. */ + readonly excludeTraceSessions?: boolean + readonly durationMinMs?: number + readonly durationMaxMs?: number + readonly costMin?: number + readonly costMax?: number + readonly tokensMin?: number + readonly tokensMax?: number + readonly llmCallsMin?: number + readonly llmCallsMax?: number + readonly toolCallsMin?: number + readonly toolCallsMax?: number + readonly sortBy?: AiSessionSortKey + readonly sortDir?: AiSessionSortDir } export interface AiSessionPageOutput { @@ -222,6 +261,20 @@ export interface AiSessionPageOutput { * `fanOutEnd` params take back. */ readonly agentStart: string readonly agentEnd: string + /** Every model any agent span of the session ran on, dialects coalesced. */ + readonly models: readonly string[] + /** Every agent named on any agent span of the session. */ + readonly agentNames: readonly string[] + readonly llmCalls: number + readonly toolCalls: number + /** Failed agent spans — what `hasErrors` tests; not the row's all-span count. */ + readonly errorAgentSpans: number + /** Tokens across every bucket, deepest reporter counted — see `deepestReporterSum`. */ + readonly totalTokens: number + /** USD as the instrumentation priced it; 0 where nothing reported a cost. */ + readonly cost: number + /** Extent of the agent spans, what the duration filter and sort read. */ + readonly agentDurationMs: number } export interface AiSessionListOpts extends AiSessionFilterOpts { @@ -238,7 +291,6 @@ export interface AiSessionListOpts extends AiSessionFilterOpts { * (`startTime`/`endTime`) or the page's (`fanOutStart`/`fanOutEnd`). */ type IndexBounds = "window" | "page" - export interface AiSessionListOutput { /** The vendor's own session id, or `trace:` for a trace that has * none — see `MAPLE_AI_TRACE_SESSION_PREFIX`. */ @@ -256,11 +308,30 @@ export interface AiSessionListOutput { readonly durationMs: number } +/** + * A pasted id, as a `LIKE` prefix pattern. + * + * Strips what the list row itself shows around a `trace:` id — the prefix and + * the trailing ellipsis — so copying the visible text finds the row. Escapes the + * three characters `LIKE` reads as syntax; the builder quotes the literal. + */ +export function idSearchPattern(search: string): string { + let needle = search.trim() + if (needle.startsWith(MAPLE_AI_TRACE_SESSION_PREFIX)) { + needle = needle.slice(MAPLE_AI_TRACE_SESSION_PREFIX.length) + } + needle = needle.replace(/…+$/, "") + return `${needle.replace(/[\\%_]/g, (char) => `\\${char}`)}%` +} + +/** Distinct models/agents collected per trace for the list row. */ +const MAX_NAMES_PER_TRACE = 20 + /** * One row per agent trace in the window, off `ai_trace_index` alone: the - * trace's session key and the bounds of its agent spans. The level both stages - * share, so a trace resolves to the same session in the page and in the - * aggregation. + * trace's session key, the bounds of its agent spans, and the per-trace + * measures the page ranks on. The level both stages share, so a trace resolves + * to the same session in the page and in the aggregation. * * The page reads it over the caller's window; the aggregation over the page's * own bounds, which is the same thing for every trace ON the page: a page @@ -281,11 +352,24 @@ export interface AiSessionListOutput { * it. A row predicate would also narrow the rows `rawSessionId` is read from, * and a vendor filter would then file a trace under `trace:` whenever its * session-bearing span belongs to another vendor — an eve agent calling through - * the Vercel AI SDK carries both. The population is the one the facets count: - * `aiSessionFacetsQuery` also collects per trace and counts any-span. + * the Vercel AI SDK carries both. And the three GenAI identity columns are + * mutually exclusive by construction — a chat span has a model and no tool, a + * tool span the reverse, the session id sits on the turn-owning span alone — + * so a row predicate ANDing `Model IN (…)` with `ToolName IN (…)` could only + * match a row carrying both, and that pair of facets, each with a non-zero + * count, would return an empty list. The population is the one the facets + * count: `aiSessionFacetsQuery` also collects per trace and counts any-span. + * + * The measures are collected here per trace and summed per session one level + * up. Usage travels as the trace's reporters rather than a sum, because a + * wrapper's roll-up of its children cannot be undone one row at a time — see + * `usageReportersExpr`. */ -const indexTraces = (opts: AiSessionFilterOpts, bounds: IndexBounds) => - from(AiTraceIndex) +const indexTraces = (opts: AiSessionFilterOpts, bounds: IndexBounds) => { + const values = (list: readonly string[] | undefined) => (list?.length ? list : undefined) + const search = opts.search?.trim() || undefined + const carries = (cond: CH.Condition) => CH.countIf(cond).gt(0) + return from(AiTraceIndex) .select(($) => ({ traceId: $.TraceId, rawSessionId: CH.max_($.SessionId), @@ -295,6 +379,17 @@ const indexTraces = (opts: AiSessionFilterOpts, bounds: IndexBounds) => // `traceStart` in `aiSessionListQuery`. traceAgentStart: CH.min_($.Timestamp), traceAgentEnd: CH.max_($.Timestamp), + // `Timestamp` is the span's START; the extent ends where the + // last-starting agent span ended. Same idiom as `traceEndNanos`. + traceAgentEndNanos: CH.max_(CH.toUnixTimestamp64Nano($.Timestamp).add(CH.toInt64($.Duration))), + // Bounded per trace: a row is a list cell, and a trace that somehow + // names more models than that is not one the cell can show anyway. + models: CH.groupUniqArrayIf(MAX_NAMES_PER_TRACE)($.Model, $.Model.neq("")), + agentNames: CH.groupUniqArrayIf(MAX_NAMES_PER_TRACE)($.AgentName, $.AgentName.neq("")), + llmCalls: CH.sum($.IsLlmCall), + toolCalls: CH.sum($.IsToolCall), + errorAgentSpans: CH.sum($.IsError), + usageReporters: usageReportersExpr($), })) .where(($) => [ $.OrgId.eq(param.string("orgId")), @@ -303,11 +398,18 @@ const indexTraces = (opts: AiSessionFilterOpts, bounds: IndexBounds) => ]) .groupBy("traceId") .having(($) => [ - opts.vendorIds?.length ? CH.countIf(CH.inList($.VendorId, opts.vendorIds)).gt(0) : undefined, - opts.serviceNames?.length - ? CH.countIf(CH.inList($.ServiceName, opts.serviceNames)).gt(0) - : undefined, + CH.when(values(opts.vendorIds), (v) => carries(CH.inList($.VendorId, v))), + CH.when(values(opts.serviceNames), (v) => carries(CH.inList($.ServiceName, v))), + CH.when(values(opts.deploymentEnvs), (v) => carries(CH.inList($.DeploymentEnv, v))), + CH.when(values(opts.models), (v) => carries(CH.inList($.Model, v))), + CH.when(values(opts.agentNames), (v) => carries(CH.inList($.AgentName, v))), + CH.when(values(opts.toolNames), (v) => carries(CH.inList($.ToolName, v))), + CH.when(search, (needle) => { + const pattern = idSearchPattern(needle) + return carries($.SessionId.like(pattern).or($.TraceId.like(pattern))) + }), ]) +} /** * The page: which sessions the list shows, in what order, and where their @@ -325,8 +427,16 @@ const indexTraces = (opts: AiSessionFilterOpts, bounds: IndexBounds) => * carries only agent spans, and the two differ by under a second in practice * (see `FAN_OUT_PAD_SECONDS`). The row's `startTime` still reports the true * first span; the caller keeps the page in this order rather than re-sorting - * by it, so what is shown is the order that was paged. `sessionId` breaks - * ties, so a page boundary never splits two sessions that share a start. + * by it, so what is shown is the order that was paged. A measure sort + * (`sortBy`) orders by that measure first, with the first agent span and then + * `sessionId` breaking ties, so a page boundary never splits two sessions that + * share a start. + * + * The session-level filters are `HAVING` on the ranked row, over the measures + * the index carries per span, and cost nothing beyond the index scan the page + * already is. What they cannot do is count — a facet for "sessions over $1" + * would be another pass over the same index per bucket, which the sidebar + * does not ask for. * * The remaining gap is between traces, not inside one: a session whose OTHER * traces lie entirely outside the range is still found only by the traces that @@ -336,6 +446,39 @@ export function aiSessionPageQuery(opts: AiSessionPageOpts = {}) { const limit = opts.limit ?? 50 const offset = opts.offset ?? 0 + // The `HAVING` level sees the outer aliases by name only. + const having = { + sessionId: CH.dynamicColumn("sessionId", T.string), + errorAgentSpans: CH.dynamicColumn("errorAgentSpans", T.float64), + agentDurationMs: CH.dynamicColumn("agentDurationMs", T.float64), + cost: CH.dynamicColumn("cost", T.float64), + totalTokens: CH.dynamicColumn("totalTokens", T.float64), + llmCalls: CH.dynamicColumn("llmCalls", T.float64), + toolCalls: CH.dynamicColumn("toolCalls", T.float64), + } + const sortBy = opts.sortBy ?? "startTime" + const sortDir = opts.sortDir ?? "desc" + // The measure a sort key names on this level. `startTime` is the first + // agent span, and `errorSpanCount` the failed agent spans — the row's own + // numbers are the fan-out's, which the page cannot see. + const sortColumn = { + startTime: "agentStart", + durationMs: "agentDurationMs", + cost: "cost", + totalTokens: "totalTokens", + errorSpanCount: "errorAgentSpans", + llmCalls: "llmCalls", + toolCalls: "toolCalls", + } as const satisfies Record + const order: Array<[(typeof sortColumn)[AiSessionSortKey] | "sessionId", AiSessionSortDir]> = + sortBy === "startTime" + ? [["agentStart", sortDir]] + : [ + [sortColumn[sortBy], sortDir], + ["agentStart", "desc"], + ] + order.push(["sessionId", "asc"]) + const page = fromQuery(indexTraces(opts, "window"), "index_traces") .select(($) => ({ // The grouping key, and the only level that can compute it: the @@ -345,11 +488,40 @@ export function aiSessionPageQuery(opts: AiSessionPageOpts = {}) { sessionId: sessionKey($.rawSessionId, $.traceId), agentStart: CH.toString_(CH.min_($.traceAgentStart)), agentEnd: CH.toString_(CH.max_($.traceAgentEnd)), + models: CH.groupUniqArrayArray($.models), + agentNames: CH.groupUniqArrayArray($.agentNames), + llmCalls: CH.sum($.llmCalls), + toolCalls: CH.sum($.toolCalls), + errorAgentSpans: CH.sum($.errorAgentSpans), + totalTokens: deepestReporterSum("usageReporters", 3), + cost: deepestReporterSum("usageReporters", 4), + // Nanoseconds first, wrapped in `intDiv` — see `durationMs` in + // `aiSessionListQuery` for both. + agentDurationMs: CH.intDiv( + CH.max_($.traceAgentEndNanos).sub(CH.toUnixTimestamp64Nano(CH.min_($.traceAgentStart))), + 1_000_000, + ), })) .groupBy("sessionId") + .having(() => [ + CH.whenTrue(opts.hasErrors, () => having.errorAgentSpans.gt(0)), + CH.whenTrue(opts.excludeTraceSessions, () => + CH.not(having.sessionId.like(`${MAPLE_AI_TRACE_SESSION_PREFIX}%`)), + ), + CH.when(opts.durationMinMs, (v) => having.agentDurationMs.gte(v)), + CH.when(opts.durationMaxMs, (v) => having.agentDurationMs.lte(v)), + CH.when(opts.costMin, (v) => having.cost.gte(v)), + CH.when(opts.costMax, (v) => having.cost.lte(v)), + CH.when(opts.tokensMin, (v) => having.totalTokens.gte(v)), + CH.when(opts.tokensMax, (v) => having.totalTokens.lte(v)), + CH.when(opts.llmCallsMin, (v) => having.llmCalls.gte(v)), + CH.when(opts.llmCallsMax, (v) => having.llmCalls.lte(v)), + CH.when(opts.toolCallsMin, (v) => having.toolCalls.gte(v)), + CH.when(opts.toolCallsMax, (v) => having.toolCalls.lte(v)), + ]) // A String order, and a correct one: the literal is fixed-width // `YYYY-MM-DD hh:mm:ss.nnnnnnnnn`, so it sorts as the instant does. - .orderBy(["agentStart", "desc"], ["sessionId", "asc"]) + .orderBy(...order) .limit(limit) // Only a positive offset is emitted: `OFFSET 0` is a no-op that would still // change the compiled SQL of every first-page read. @@ -487,39 +659,37 @@ export function aiSessionListQuery(opts: AiSessionListOpts) { ]) .groupBy("traceId") - return ( - fromQuery(perTrace, "session_traces") - .innerJoinQuery(pageTraces, "index_traces", (t, i) => t.traceId.eq(i.traceId)) - .select(($) => ({ - // The key the page resolved, not one re-derived from the spans — see - // the file header for why the two must not be allowed to differ. - sessionId: sessionKey($.index_traces.rawSessionId, $.traceId), - vendorId: CH.argMin($.vendorId, $.sessionStart), - vendorVersion: CH.argMin($.vendorVersion, $.sessionStart), - // `count()`, not `uniq()`: the derived table already emits exactly one - // row per trace, so this is exact and cheaper — `uniq` is an - // approximate HLL that would start drifting on a very large session. - traceCount: CH.count(), - spanCount: CH.sum($.spanCount), - errorSpanCount: CH.sum($.errorSpanCount), - serviceNames: CH.groupUniqArrayArray($.serviceNames), - startTime: CH.toString_(CH.min_($.traceStart)), - endTime: CH.toString_(fromUnixTimestamp64Nano(CH.max_($.traceEndNanos))), - // Nanoseconds first: `Timestamp` is DateTime64(9), and subtracting two - // of them yields a Decimal whose scale the wire format then quotes. - // Wrapped in `intDiv` because `Expr.sub`/`div` do not parenthesize. - durationMs: CH.intDiv( - CH.max_($.traceEndNanos).sub(CH.toUnixTimestamp64Nano(CH.min_($.traceStart))), - 1_000_000, - ), - })) - .groupBy("sessionId") - .orderBy(["startTime", "desc"]) - .format("JSON") - ) + return fromQuery(perTrace, "session_traces") + .innerJoinQuery(pageTraces, "index_traces", (t, i) => t.traceId.eq(i.traceId)) + .select(($) => ({ + // The key the page resolved, not one re-derived from the spans — see + // the file header for why the two must not be allowed to differ. + sessionId: sessionKey($.index_traces.rawSessionId, $.traceId), + vendorId: CH.argMin($.vendorId, $.sessionStart), + vendorVersion: CH.argMin($.vendorVersion, $.sessionStart), + // `count()`, not `uniq()`: the derived table already emits exactly one + // row per trace, so this is exact and cheaper — `uniq` is an + // approximate HLL that would start drifting on a very large session. + traceCount: CH.count(), + spanCount: CH.sum($.spanCount), + errorSpanCount: CH.sum($.errorSpanCount), + serviceNames: CH.groupUniqArrayArray($.serviceNames), + startTime: CH.toString_(CH.min_($.traceStart)), + endTime: CH.toString_(fromUnixTimestamp64Nano(CH.max_($.traceEndNanos))), + // Nanoseconds first: `Timestamp` is DateTime64(9), and subtracting two + // of them yields a Decimal whose scale the wire format then quotes. + // Wrapped in `intDiv` because `Expr.sub`/`div` do not parenthesize. + durationMs: CH.intDiv( + CH.max_($.traceEndNanos).sub(CH.toUnixTimestamp64Nano(CH.min_($.traceStart))), + 1_000_000, + ), + })) + .groupBy("sessionId") + .orderBy(["startTime", "desc"]) + .format("JSON") } -// List facets (UNION ALL — vendor / service) +// List facets (UNION ALL — one branch per index dimension) export interface AiSessionFacetsOutput { readonly name: string @@ -527,13 +697,18 @@ export interface AiSessionFacetsOutput { readonly facetType: string } +export type AiSessionFacetType = "vendor" | "service" | "environment" | "model" | "agent" | "tool" + /** - * Distinct sessions per vendor and per service, for the list's filter sidebar. + * Distinct sessions per value of each index dimension, for the list's filter + * sidebar: vendor, service, environment, model, agent and tool. * - * This is the detection scan of `aiSessionListQuery` (an `ai_trace_index` read) - * and nothing else — no `trace_detail_spans` fan-out, which is the expensive - * half. It can be: both of the list's filters are applied at that level, so the - * population a facet describes is exactly the population its filter selects. + * This is the page's index scan (`indexTraces`) and nothing else — no + * `trace_detail_spans` fan-out, which is the expensive half. It can be: every + * one of the list's counted filters is applied at that level, so the population + * a facet describes is exactly the population its filter selects. The + * session-level filters (errors, the ranges) have no facet for the same reason + * in reverse — their numbers exist only per ranked row. * * What it cannot do is count per span. A session id is a fact about the TRACE, * so a facet keyed on the span's own value would count every agent span of a @@ -553,7 +728,7 @@ export interface AiSessionFacetsOutput { */ export function aiSessionFacetsQuery(): CHUnionQuery { const facet = ( - facetType: string, + facetType: AiSessionFacetType, name: ($: ColumnAccessor) => CH.Expr, ) => { const perTrace = from(AiTraceIndex) @@ -587,6 +762,10 @@ export function aiSessionFacetsQuery(): CHUnionQuery { return unionAll( facet("vendor", ($) => $.VendorId), facet("service", ($) => $.ServiceName), + facet("environment", ($) => $.DeploymentEnv), + facet("model", ($) => $.Model), + facet("agent", ($) => $.AgentName), + facet("tool", ($) => $.ToolName), ).format("JSON") } diff --git a/packages/query-engine-integrations/src/ai/ai-span-columns.test.ts b/packages/query-engine-integrations/src/ai/ai-span-columns.test.ts new file mode 100644 index 000000000..965214afd --- /dev/null +++ b/packages/query-engine-integrations/src/ai/ai-span-columns.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest" +import * as CH from "@maple-dev/clickhouse-builder/expr" +import * as T from "@maple-dev/clickhouse-builder/types" +import { compile } from "@maple-dev/clickhouse-builder/sql" +import { + GENAI_AGENT_NAME_KEYS, + GENAI_COST_KEYS, + GENAI_MODEL_KEYS, + GENAI_TOOL_NAME_KEYS, + GENAI_USAGE_KEYS, + OPENINFERENCE_KIND_OPERATIONS, + genAiIsErrorCond, + genAiIsLlmCallCond, + genAiIsToolCallCond, + genAiOperationExpr, + genAiTokensExpr, +} from "@maple/domain/tinybird/gen-ai-columns" +import type { AiGenAiField, MutableAiGenAiValues } from "@maple/domain/gen-ai" +import { genAiIntegration, resolveAiIntegration } from "./ai-integrations" +import { AI_VENDOR_INTEGRATIONS } from "./ai-vendors" +import { deepestReporterSum, usageReportersExpr } from "./ai-span-columns" + +/** Every key any integration reads for `field` — the default's plus each vendor's. */ +const decodedKeys = (field: AiGenAiField): ReadonlySet => + new Set([ + ...genAiIntegration.sources[field], + ...Object.keys(AI_VENDOR_INTEGRATIONS).flatMap( + (vendorId) => resolveAiIntegration(vendorId).sources[field], + ), + ]) + +const attrs = { + get: (key: string) => CH.mapGet(CH.dynamicColumn>("SpanAttributes"), key), +} +const columns = { + SpanName: CH.dynamicColumn("SpanName", T.string), + StatusCode: CH.dynamicColumn("StatusCode", T.string), + SpanAttributes: attrs, +} +const sql = (expr: { toFragment(): Parameters[0] }) => compile(expr.toFragment()) + +// The SQL lists are hand-copied from the integration layer's alias tables, +// because the index's MV cannot call into it. These pin every list to that +// layer, so a key that decodes on the detail page is one the list can filter +// on — and one the list reads that nothing decodes is a typo caught here. +describe("GenAI column key lists match the integration layer", () => { + it("model: response and request model keys", () => { + const decoded = new Set([...decodedKeys("responseModel"), ...decodedKeys("requestModel")]) + for (const key of GENAI_MODEL_KEYS) expect(decoded).toContain(key) + }) + + it("agent and tool names", () => { + for (const key of GENAI_AGENT_NAME_KEYS) expect(decodedKeys("agentName")).toContain(key) + for (const key of GENAI_TOOL_NAME_KEYS) expect(decodedKeys("toolName")).toContain(key) + }) + + it("every usage bucket and the cost, bucket for bucket", () => { + const fields = { + input: "usageInputTokens", + cacheRead: "usageCacheReadInputTokens", + cacheWrite: "usageCacheCreationInputTokens", + output: "usageOutputTokens", + reasoning: "usageReasoningOutputTokens", + } as const + for (const [bucket, field] of Object.entries(fields)) { + const decoded = decodedKeys(field) + for (const key of GENAI_USAGE_KEYS[bucket as keyof typeof GENAI_USAGE_KEYS]) { + expect(decoded, `${bucket}: ${key}`).toContain(key) + } + // And the other way: the list reads every key the detail page decodes, + // so a session's tokens cannot be counted on one page and not the other. + for (const key of decoded) { + expect( + GENAI_USAGE_KEYS[bucket as keyof typeof GENAI_USAGE_KEYS], + `${bucket}: ${key}`, + ).toContain(key) + } + } + for (const key of GENAI_COST_KEYS) expect(decodedKeys("usageCost")).toContain(key) + for (const key of decodedKeys("usageCost")) expect(GENAI_COST_KEYS).toContain(key) + }) + + it("translates the OpenInference span kinds the integration refines", () => { + const integration = resolveAiIntegration("openinference-openai") + for (const [kind, operation] of OPENINFERENCE_KIND_OPERATIONS) { + const values: MutableAiGenAiValues = {} + integration.refine?.(values, { + attributes: { "openinference.span.kind": kind }, + row: {} as never, + }) + expect(values.operationName, kind).toBe(operation) + } + }) +}) + +describe("span classification SQL", () => { + it("reads the operation from gen_ai.operation.name, else the OpenInference kind", () => { + expect(sql(genAiOperationExpr(attrs))).toBe( + "coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', ''))", + ) + }) + + it("counts a model turn by operation, or by name only for an unclassified agent span", () => { + const text = sql(genAiIsLlmCallCond(columns)) + expect(text).toContain("IN ('chat', 'generate_content', 'text_completion', 'fetch_response')") + // The name rules apply only where the operation is absent or unknown to + // the convention, only to vendor-stamped spans, and only after the tool + // and agent rules have declined — the client's order. + expect(text).toContain( + "NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step')", + ) + expect(text).toContain("NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], '')") + expect(text).toContain("lower(SpanName) LIKE '%tool%'") + expect(text).toContain("NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))") + expect(text).toContain("lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'") + }) + + it("counts a tool call by operation, or by a tool name / tool-ish span name", () => { + const text = sql(genAiIsToolCallCond(columns)) + expect(text).toContain("IN ('execute_tool')") + expect(text).toContain("SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'") + }) + + it("sums the five token buckets, each coalesced canonical-first", () => { + const text = sql(genAiTokensExpr(attrs)) + expect(text.split(" + ")).toHaveLength(5) + expect(text).toMatch( + /^toFloat64OrZero\(coalesce\(nullIf\(SpanAttributes\['gen_ai\.usage\.input_tokens'\], ''\)/, + ) + expect(text).toContain("SpanAttributes['llm.token_count.completion_details.reasoning']))") + }) + + it("flags a failure by status or by a declared failure attribute", () => { + expect(sql(genAiIsErrorCond(columns))).toBe( + "((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))", + ) + }) + + it("collects a trace's reporters, capped, and charges children to their parent", () => { + const reporters = usageReportersExpr({ + SpanId: CH.dynamicColumn("SpanId", T.string), + ParentSpanId: CH.dynamicColumn("ParentSpanId", T.string), + Tokens: CH.dynamicColumn("Tokens", T.float64), + Cost: CH.dynamicColumn("Cost", T.float64), + }) + expect(sql(reporters)).toBe( + "groupArrayIf(2000)(tuple(SpanId, ParentSpanId, Tokens, Cost), (Tokens > 0 OR Cost > 0))", + ) + expect(sql(deepestReporterSum("usageReporters", 4))).toBe( + "sum(arraySum(r -> greatest(0., r.4 - arraySum(c -> if(c.2 = r.1, c.4, 0.), usageReporters)), usageReporters))", + ) + }) +}) diff --git a/packages/query-engine-integrations/src/ai/ai-span-columns.ts b/packages/query-engine-integrations/src/ai/ai-span-columns.ts new file mode 100644 index 000000000..44b393883 --- /dev/null +++ b/packages/query-engine-integrations/src/ai/ai-span-columns.ts @@ -0,0 +1,71 @@ +// The session's usage, summed the way the detail page sums it. +// +// `ai_trace_index` carries every GenAI span's tokens and cost (migration +// 0026, `@maple/domain/tinybird/gen-ai-columns`), and several frameworks +// stamp `gen_ai.usage.*` on the model span AND sum it onto the agent span that +// wraps it — counting both doubles every number. `countableUsageSpans` in +// `apps/web/src/lib/agent-sessions/session-summary.ts` charges each reporter +// to its nearest reporting ancestor and keeps only the excess; this is that +// rule in SQL, one level deep, which is the shape every roll-up in production +// has. + +import type { Expr } from "@maple-dev/clickhouse-builder/expr" +import * as CH from "@maple-dev/clickhouse-builder/expr" +import * as T from "@maple-dev/clickhouse-builder/types" +import { compile } from "@maple-dev/clickhouse-builder/sql" +import { AI_SESSION_SPANS_MAX_SPANS } from "@maple/domain/http" + +/** + * Reporters collected per trace. The detail page reads at most this many spans + * of a session (`AI_SESSION_SPANS_MAX_SPANS`), so past it the two pages already + * disagree; the cap bounds the quadratic pass in {@link deepestReporterSum} at + * a few million comparisons for a pathological trace rather than unbounded. + */ +export const MAX_USAGE_REPORTERS_PER_TRACE = AI_SESSION_SPANS_MAX_SPANS + +/** + * One trace's usage reporters — `(SpanId, ParentSpanId, tokens, cost)` per + * index row that reported usage — for the deepest-reporter sum one level up, + * which needs the whole trace's reporters in hand at once. A span whose usage + * parses to zero throughout is not a reporter, the same as `spanTokenBuckets` + * returning a total of 0: a wrapper stamping empty usage must not be charged + * as a reporter whose children then owe it their tokens. + * + * Raw SQL because the cap is a parameter of the aggregate + * (`groupArrayIf(N)(…)`), a shape the builder's function-call helper does not + * render. + */ +export function usageReportersExpr($: { + readonly SpanId: Expr + readonly ParentSpanId: Expr + readonly Tokens: Expr + readonly Cost: Expr +}): Expr { + const reporter = CH.compileFnCall("tuple", $.SpanId, $.ParentSpanId, $.Tokens, $.Cost) + const reports = $.Tokens.gt(0).or($.Cost.gt(0)) + return CH.untypedExpr( + `groupArrayIf(${MAX_USAGE_REPORTERS_PER_TRACE})(${compile(reporter.toFragment())}, ${compile( + reports.toFragment(), + )})`, + ) +} + +/** + * The session's tokens (`element` 3) or cost (`element` 4), summed over the + * reporters of each of its traces with what a reporting CHILD already reported + * taken off its parent. The parent keeps only its excess over its children's + * sum — zero for a clean roll-up, the missing call's share when one child + * reported none. + * + * `reporters` is the column {@link usageReportersExpr} was selected as, named + * in raw SQL because the builder has no lambda syntax. `0.` keeps the whole + * expression Float64. + */ +export function deepestReporterSum(reporters: string, element: 3 | 4): Expr { + const own = `r.${element}` + const child = `c.${element}` + return CH.rawExpr( + `sum(arraySum(r -> greatest(0., ${own} - arraySum(c -> if(c.2 = r.1, ${child}, 0.), ${reporters})), ${reporters}))`, + T.float64, + ) +} diff --git a/packages/query-engine-integrations/src/ai/index.ts b/packages/query-engine-integrations/src/ai/index.ts index 16092bddc..9d2984bf4 100644 --- a/packages/query-engine-integrations/src/ai/index.ts +++ b/packages/query-engine-integrations/src/ai/index.ts @@ -16,6 +16,8 @@ export { aiSessionWindowQuery, aiTraceSpansQuery, aiTraceWindowQuery, + idSearchPattern, + type AiSessionFacetType, type AiSessionFacetsOutput, type AiSessionFilterOpts, type AiSessionListOpts, diff --git a/packages/query-engine-integrations/src/catalog.test.ts b/packages/query-engine-integrations/src/catalog.test.ts index c4d700ffe..081d3f4d7 100644 --- a/packages/query-engine-integrations/src/catalog.test.ts +++ b/packages/query-engine-integrations/src/catalog.test.ts @@ -84,9 +84,10 @@ describe("integration sql catalog", () => { return ` ${entry.id} — undeclared: [${mismatch.undeclared.join(", ")}] unselected: [${mismatch.unselected.join(", ")}]` }) - expect(drifted, `declared row schemas that no longer match their query:\n${drifted.join("\n")}`).toEqual( - [], - ) + expect( + drifted, + `declared row schemas that no longer match their query:\n${drifted.join("\n")}`, + ).toEqual([]) }) it("emits the same SQL as the recorded baseline", async () => { diff --git a/packages/query-engine-integrations/src/catalog.ts b/packages/query-engine-integrations/src/catalog.ts index 6875ff4b1..038cd000d 100644 --- a/packages/query-engine-integrations/src/catalog.ts +++ b/packages/query-engine-integrations/src/catalog.ts @@ -122,6 +122,62 @@ export const integrationFixtures: ReadonlyArray = [ aiPageBounds, ), }, + { + // Every filter the sidebar can send at once, plus a measure sort: the + // per-trace existence tests, the HAVING level and the raw lambda usage + // sum only compile in this shape. + module: "ai-sessions", + name: "aiSessionPageQuery", + label: "every-filter", + compile: () => + compileUnsafe( + CH.aiSessionPageQuery({ + vendorIds: ["eve"], + serviceNames: ["maple-slack-agent"], + deploymentEnvs: ["production"], + models: ["gpt-5.5"], + agentNames: ["billing-agent"], + toolNames: ["send_email"], + search: "wrun_01", + hasErrors: true, + excludeTraceSessions: true, + durationMinMs: 1_000, + durationMaxMs: 600_000, + costMin: 0.01, + costMax: 5, + tokensMin: 100, + tokensMax: 1_000_000, + llmCallsMin: 1, + llmCallsMax: 50, + toolCallsMin: 1, + toolCallsMax: 50, + sortBy: "cost", + sortDir: "asc", + limit: 25, + offset: 25, + }), + window, + ), + }, + { + // The same page under every counted filter — the aggregation runs with + // the filters the page ranked under, never without them. + module: "ai-sessions", + name: "aiSessionListQuery", + label: "every-counted-filter", + compile: () => + compileUnsafe( + CH.aiSessionListQuery({ + sessionIds: AI_PAGE_SESSION_IDS, + deploymentEnvs: ["production"], + models: ["gpt-5.5"], + agentNames: ["billing-agent"], + toolNames: ["send_email"], + search: "wrun_01", + }), + aiPageBounds, + ), + }, { module: "ai-sessions", name: "aiSessionFacetsQuery", diff --git a/packages/query-engine/src/ch/tables.ts b/packages/query-engine/src/ch/tables.ts index b647c2741..06c0c3088 100644 --- a/packages/query-engine/src/ch/tables.ts +++ b/packages/query-engine/src/ch/tables.ts @@ -108,6 +108,13 @@ export const TraceDetailSpans = table("trace_detail_spans", { * pre-extracted to plain columns — the Agent Sessions detection/facet surface. * `SessionId` is `''` on most rows: vendors stamp the session key only on the * turn-owning spans, so session resolution stays per-trace at read time. + * + * Migration 0026 added the sidebar's other facet dimensions (`DeploymentEnv`, + * `Model`, `AgentName`, `ToolName`) and the per-span measures the page ranks + * and filters on (`IsError`, `IsLlmCall`, `IsToolCall`, `Tokens`, `Cost`, with + * `SpanId`/`ParentSpanId`/`Duration`), all coalesced and classified at insert + * by `@maple/domain/tinybird/gen-ai-columns`; `''`/0 where the span carries no + * such fact, and on every row materialized before 0026. */ export const AiTraceIndex = table("ai_trace_index", { OrgId: orgId, @@ -116,6 +123,18 @@ export const AiTraceIndex = table("ai_trace_index", { SessionId: T.string, VendorId: T.string, ServiceName: T.string, + DeploymentEnv: T.string, + Model: T.string, + AgentName: T.string, + ToolName: T.string, + SpanId: T.string, + ParentSpanId: T.string, + Duration: T.uint64, + IsError: T.uint8, + IsLlmCall: T.uint8, + IsToolCall: T.uint8, + Tokens: T.float64, + Cost: T.float64, }) export const TraceListMv = table("trace_list_mv", { diff --git a/packages/ui/src/components/filters/range-filter-section.tsx b/packages/ui/src/components/filters/range-filter-section.tsx index 294dee4ba..1bcd1e670 100644 --- a/packages/ui/src/components/filters/range-filter-section.tsx +++ b/packages/ui/src/components/filters/range-filter-section.tsx @@ -10,7 +10,20 @@ import { FILTER_SECTION_LABEL } from "./filter-styles" /** The unit the caller's numbers are already in. Only affects parsing and display — * values cross this component's boundary unconverted. */ -export type RangeUnit = "ms" | "s" +/** What a bare number in the inputs means: a duration in ms or s, a plain + * count (tokens, calls), or US dollars. */ +export type RangeUnit = "ms" | "s" | "count" | "usd" + +const UNIT_WORDS = { + ms: "milliseconds", + s: "seconds", + count: "count", + usd: "US dollars", +} satisfies Record + +/** The suffix after the inputs. Counts carry none — "≥ 100" needs no unit — + * and dollars read as a sign, not a code. */ +const UNIT_SUFFIX = { ms: "ms", s: "s", count: "", usd: "$" } satisfies Record /** One shortcut below the inputs. `label` says why you'd click it ("Bounced", * "> p50"); `value` shows the threshold it resolves to. */ @@ -208,7 +221,7 @@ export function RangeFilterSection({
- {/[a-z]/i.test(draft.min) || /[a-z]/i.test(draft.max) ? "" : unit} + {/[a-z$]/i.test(draft.min) || /[a-z$]/i.test(draft.max) ? "" : UNIT_SUFFIX[unit]}
@@ -457,15 +470,34 @@ const UNIT_MS: Record = { ms: 1, s: 1000, m: 60_000, h: 3_600_00 number > +const COUNT_MULTIPLIERS = { k: 1_000, m: 1_000_000, b: 1_000_000_000 } satisfies Record + /** Accepts a bare number in the control's own unit, or a suffixed duration - * ("90s", "2m", "1h 30m"). Blank or unparseable means unbounded on that side. */ + * ("90s", "2m", "1h 30m"), a scaled count ("120k", "1.5m") or a dollar amount + * ("$0.50"). Blank or unparseable means unbounded on that side. */ export function parseRange(text: string, unit: RangeUnit): number | undefined { - const trimmed = text.trim().toLowerCase() + const trimmed = text + .trim() + .toLowerCase() + .replace(/^\$\s*/, unit === "usd" ? "" : "$") if (trimmed === "") return undefined if (/^\d+(\.\d+)?$/.test(trimmed)) { const bare = Number(trimmed) - return Number.isFinite(bare) && bare >= 0 ? bare : undefined + if (!Number.isFinite(bare) || bare < 0) return undefined + // A count is whole: "1.5" calls is a typo, and the request schema + // rejects a fractional count outright. + return unit === "count" ? Math.round(bare) : bare + } + + if (unit === "usd") return undefined + if (unit === "count") { + const scaled = /^(\d+(?:\.\d+)?)\s*([kmb])$/.exec(trimmed) + if (scaled === null) return undefined + const [, amount, suffix] = scaled + const multiplier = + suffix === "k" ? COUNT_MULTIPLIERS.k : suffix === "m" ? COUNT_MULTIPLIERS.m : COUNT_MULTIPLIERS.b + return Math.round(Number(amount) * multiplier) } if (!/^(\d+(\.\d+)?\s*(ms|s|m|h)\s*)+$/.test(trimmed)) return undefined @@ -481,7 +513,16 @@ export function parseRange(text: string, unit: RangeUnit): number | undefined { /** Always carries its unit — for badges, ticks and readouts, where there's no * input context to imply one. */ export function formatValue(value: number, unit: RangeUnit): string { - return unit === "ms" ? formatMs(value) : formatSeconds(value) + switch (unit) { + case "ms": + return formatMs(value) + case "s": + return formatSeconds(value) + case "count": + return formatCount(value) + case "usd": + return formatUsd(value) + } } /** For input fields: bare inside the unit's natural range so typed numbers read @@ -489,7 +530,9 @@ export function formatValue(value: number, unit: RangeUnit): string { * round-trips through `parseRange`. */ export function formatCompact(value: number | undefined, unit: RangeUnit): string { if (value === undefined) return "" - const threshold = unit === "ms" ? 1000 : 60 + // Dollars are always typed as the bare amount; "$" is the field's suffix. + if (unit === "usd") return String(value) + const threshold = unit === "ms" ? 1000 : unit === "s" ? 60 : 1000 if (value < threshold) return String(value) // The display formats round to something readable — "2h 2m" for 7325s. That's // right for a badge and wrong for a field the reader's own number goes back @@ -504,6 +547,24 @@ function formatMs(ms: number): string { return `${(ms / 1000).toFixed(2)}s` } +/** "120k", "1.5M", "2B" — and the bare number under a thousand. */ +export function formatCount(value: number): string { + const scaled = (divisor: number, suffix: string) => { + const n = value / divisor + return `${Number.isInteger(n) ? n : n.toFixed(1)}${suffix}` + } + if (value >= 1_000_000_000) return scaled(1_000_000_000, "B") + if (value >= 1_000_000) return scaled(1_000_000, "M") + if (value >= 1_000) return scaled(1_000, "k") + return String(value) +} + +/** "$0.50"; sub-cent amounts keep the digits that make them non-zero. */ +export function formatUsd(value: number): string { + if (value > 0 && value < 0.01) return `$${value.toFixed(4)}` + return `$${value.toFixed(2)}` +} + export function formatSeconds(seconds: number): string { if (seconds < 60) return `${Math.round(seconds * 10) / 10}s` if (seconds < 3600) {