Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 123 additions & 6 deletions apps/api/src/routes/internal/ai-sessions.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<Record<string, unknown>>
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
Expand Down Expand Up @@ -536,14 +554,18 @@ 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)
.decodeRows([
{ 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),
})
Expand All @@ -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()
}
Expand Down
63 changes: 57 additions & 6 deletions apps/api/src/routes/internal/ai-sessions.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
),
Expand All @@ -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.
Expand Down Expand Up @@ -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,
})
}),
Expand All @@ -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"),
})
}),
)
Expand Down
Loading
Loading