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
85 changes: 85 additions & 0 deletions apps/api/src/routes/internal/ai-sessions.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,91 @@ describe("POST /internal/ai-sessions/spans", () => {
}
})

// A `trace:` id is Maple's own: the vendor exposed no session key, so the
// trace IS the session. Both reads must key on the trace id — the session
// attribute would match nothing, and the page would report an empty session
// for a trace that is right there.
it("routes a trace-scoped id to the trace-keyed window and span reads", async () => {
const resolved = {
startTime: "2026-08-18 09:00:00.000000000",
endTime: "2026-08-20 11:00:00.000000000",
}
let windowSql: string | undefined
let spansSql: string | undefined
const harness = makeHarness({
compiledQuery: (_tenant, compiled) => {
windowSql = compiledQueryOf(compiled).sql
return compiledQueryOf(compiled)
.decodeRows([{ ...resolved, spanCount: "9" }])
.pipe(Effect.orDie)
},
compiledQueryBounded: (_tenant, compiled) => {
spansSql = compiledQueryOf(compiled).sql
return compiledQueryOf(compiled)
.decodeRows([spanRow(0), spanRow(1)])
.pipe(Effect.orDie)
},
})

try {
const response = await harness.post("/internal/ai-sessions/spans", {
sessionId: `trace:${TRACE_ID}`,
})
expect(response.status).toBe(200)
expect(response.body.data).toHaveLength(2)
expect(windowSql).toContain(`TraceId = '${TRACE_ID}'`)
expect(windowSql).not.toContain("maple_ai.session.id")
expect(spansSql).toContain(`TraceId = '${TRACE_ID}'`)
expect(spansSql).not.toContain("maple_ai.session.id")
// The bounds the window read handed back still prune the span read.
expect(spansSql).toContain(`Timestamp >= '${resolved.startTime}'`)
expect(spansSql).not.toContain("__PARAM_")
} finally {
await harness.dispose()
}
})

// The prefix is not proof: the value behind it reaches a warehouse param, so
// anything that is not a trace id must not get there. It falls through to the
// session read, where nothing carries it — the empty answer any unknown id gets.
it("does not hand a malformed trace-scoped id to the trace-keyed read", async () => {
let windowSql: string | undefined
let spansRead = false
const harness = makeHarness({
compiledQuery: (_tenant, compiled) => {
windowSql = compiledQueryOf(compiled).sql
return compiledQueryOf(compiled)
.decodeRows([
{
startTime: "1970-01-01 00:00:00.000000000",
endTime: "1970-01-02 00:00:00.000000000",
spanCount: "0",
},
])
.pipe(Effect.orDie)
},
compiledQueryBounded: (_tenant, compiled) => {
spansRead = true
return compiledQueryOf(compiled)
.decodeRows([spanRow(0)])
.pipe(Effect.orDie)
},
})

try {
const response = await harness.post("/internal/ai-sessions/spans", {
sessionId: "trace:not-a-trace-id' OR 1=1",
})
expect(response.status).toBe(200)
expect(response.body).toMatchObject({ data: [], truncated: false })
expect(windowSql).toContain("SpanAttributes['maple_ai.session.id'] =")
expect(windowSql).not.toContain("TraceId =")
expect(spansRead).toBe(false)
} finally {
await harness.dispose()
}
})

it("answers an id nothing in retention carries without reading spans", async () => {
let spansRead = false
const harness = makeHarness({
Expand Down
62 changes: 46 additions & 16 deletions apps/api/src/routes/internal/ai-sessions.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MapleInternalApi,
MAX_AI_SESSION_SPANS_RESPONSE_BYTES,
} from "@maple/domain/http"
import { traceSessionTraceId } from "@maple/domain/gen-ai"
import { Effect } from "effect"
import { CH } from "@maple/query-engine"
import * as Integrations from "@maple/query-engine-integrations"
Expand Down Expand Up @@ -87,29 +88,47 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group(
payload.startTime !== undefined && payload.endTime !== undefined
? { startTime: payload.startTime, endTime: payload.endTime }
: undefined
// A `trace:<TraceId>` id is Maple's own: the vendor exposed no
// session key, so the trace IS the session and both reads key on
// the trace id instead of the session attribute. The helper
// returns `undefined` for a vendor id AND for a prefixed one that
// is not 32 hex characters, so a forged value never reaches the
// trace-keyed param — it takes the session path, where nothing
// carries it and the caller gets the empty-session answer below.
const traceId = traceSessionTraceId(payload.sessionId)
// Annotated before the read: a 413 never reaches the code below.
// `window_source` is how often the extra resolve round-trip runs
// gets watched — it should stay the exception.
yield* Effect.annotateCurrentSpan({
orgId: tenant.orgId,
"maple.ai.session.id": payload.sessionId,
"maple.ai.session.kind": traceId === undefined ? "vendor" : "trace",
"maple.ai.window_source": hint === undefined ? "resolved" : "client",
})
// The spans read has to be partition-pruned on both levels, so a
// caller without bounds gets bounds first rather than an unpruned
// fan-out — see `aiSessionSpansQuery`. One extra round trip, and
// only on the deep-link path.
const resolved =
hint === undefined
? yield* warehouse.compiledQuery(
tenant,
CH.compile(Integrations.aiSessionWindowQuery(), {
orgId: tenant.orgId,
sessionId: payload.sessionId,
}),
{ profile: "list", context: "aiSessionWindow" },
)
: undefined
hint !== undefined
? undefined
: traceId === undefined
? yield* warehouse.compiledQuery(
tenant,
CH.compile(Integrations.aiSessionWindowQuery(), {
orgId: tenant.orgId,
sessionId: payload.sessionId,
}),
{ profile: "list", context: "aiSessionWindow" },
)
: yield* warehouse.compiledQuery(
tenant,
CH.compile(Integrations.aiTraceWindowQuery(), {
orgId: tenant.orgId,
traceId,
}),
{ profile: "list", context: "aiTraceWindow" },
)
// `min`/`max` over no rows return the epoch rather than nothing, so
// the count is what distinguishes an unknown session id.
const bounds = resolved?.[0]
Expand All @@ -123,15 +142,26 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group(
}
// One row past the cap: the extra row is what distinguishes a
// session that exactly fills the cap from one whose tail was cut.
const compiled = CH.compile(
Integrations.aiSessionSpansQuery({ limit: AI_SESSION_SPANS_MAX_SPANS + 1 }),
{ orgId: tenant.orgId, sessionId: payload.sessionId, ...window },
{ rowSchema: Integrations.aiSessionSpansRowSchema },
)
const compiled =
traceId === undefined
? CH.compile(
Integrations.aiSessionSpansQuery({
limit: AI_SESSION_SPANS_MAX_SPANS + 1,
}),
{ orgId: tenant.orgId, sessionId: payload.sessionId, ...window },
{ rowSchema: Integrations.aiSessionSpansRowSchema },
)
: CH.compile(
Integrations.aiTraceSpansQuery({
limit: AI_SESSION_SPANS_MAX_SPANS + 1,
}),
{ orgId: tenant.orgId, traceId, ...window },
{ rowSchema: Integrations.aiSessionSpansRowSchema },
)
const rows = yield* warehouse
.compiledQueryBounded(tenant, compiled, {
profile: "list",
context: "aiSessionSpans",
context: traceId === undefined ? "aiSessionSpans" : "aiTraceSpans",
responseLimits: {
maxRows: AI_SESSION_SPANS_MAX_SPANS + 1,
maxBytes: MAX_AI_SESSION_SPANS_RESPONSE_BYTES,
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/components/agent-sessions/agent-sessions-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format"
import { formatSessionDuration } from "@maple/ui/lib/replay-format"
import { ChatBubbleSparkleIcon } from "@/components/icons"
import { vendorIcon } from "@/lib/agent-sessions/vendor-icon"
import { sessionRowId } from "@/lib/agent-sessions/session-window"
import { vendorLabel } from "@/lib/agent-sessions/vendor-label"

/** The wire row from `listAiSessions` — one AI agent session, newest first. */
Expand Down Expand Up @@ -44,11 +45,11 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) {
<EmptyDescription>
Trace your AI agents with a supported framework, or emit OpenTelemetry{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.8em]">gen_ai</code>{" "}
spans with a{" "}
spans, and their sessions will show up here. A framework that groups its turns with a{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.8em]">
maple_ai.session.id
</code>{" "}
attribute, and their sessions will show up here.
attribute gets one session across every trace; anything else gets one per trace.
</EmptyDescription>
</EmptyHeader>
</Empty>
Expand Down Expand Up @@ -85,8 +86,11 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) {
{/* Identity lane: session id, framework underneath */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center gap-2">
<span className="min-w-0 truncate font-mono text-sm font-medium">
{session.sessionId}
<span
className="min-w-0 truncate font-mono text-sm font-medium"
title={session.sessionId}
>
{sessionRowId(session.sessionId)}
</span>
{/* On phones the right-hand lanes are gone, so the timestamp
anchors the top-right corner of the stacked row. */}
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/lib/agent-sessions/session-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
breadcrumbSessionId,
buildBackToSessionsHref,
resolveWindow,
sessionRowId,
} from "@/lib/agent-sessions/session-window"

describe("resolveWindow", () => {
Expand Down Expand Up @@ -48,6 +49,24 @@ describe("breadcrumbSessionId", () => {
})
})

describe("sessionRowId", () => {
// A framework's own id is the reader's vocabulary — it says what the session
// is, whatever its length.
it("leaves a vendor's own id whole", () => {
expect(sessionRowId("wrun_01KZTEBCDEFGHIJKLMNOPQRSTUV")).toBe("wrun_01KZTEBCDEFGHIJKLMNOPQRSTUV")
})

it("cuts a synthesized id to the head of its trace id", () => {
expect(sessionRowId("trace:7f3a4b5c6d7e8f901234567890abcdef")).toBe("trace:7f3a4b5c6d7e…")
})

// The prefix alone does not make an id Maple's, and a row must not claim a
// trace that isn't one.
it("leaves a prefixed id that is not a trace id alone", () => {
expect(sessionRowId("trace:not-a-trace-id")).toBe("trace:not-a-trace-id")
})
})

describe("buildBackToSessionsHref", () => {
it("keeps the list's own search and drops the detail page's params", () => {
const href = buildBackToSessionsHref("?vendorIds=eve&t=2026-08-19&end=2026-08-19&trace=abc&span=def")
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/lib/agent-sessions/session-window.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatWarehouseDateTime } from "@maple/query-engine"
import { MAPLE_AI_TRACE_SESSION_PREFIX, traceSessionTraceId } from "@maple/domain/gen-ai"
import { toEpochMs } from "@maple/ui/lib/time-format"

// Slack, not compensation: the list now reports each session's own bounds
Expand Down Expand Up @@ -65,3 +66,21 @@ export function breadcrumbSessionId(sessionId: string): string {
if (sessionId.length <= BREADCRUMB_ID_MAX_CHARS) return sessionId
return `${sessionId.slice(0, 9)}…${sessionId.slice(-4)}`
}

/** How much of a synthesized id's trace id a row shows — enough to match against
* a trace id the reader has in hand, short enough not to read as noise. */
const TRACE_SESSION_ID_CHARS = 12

/**
* A session id as the list should show it.
*
* A vendor's own id is the reader's vocabulary and stays whole. A synthesized
* `trace:<32 hex>` id is Maple's, and the tail of it identifies nothing — the
* row shows the head, and the full id stays on the element's title and in the
* URL.
*/
export function sessionRowId(sessionId: string): string {
const traceId = traceSessionTraceId(sessionId)
if (traceId === undefined) return sessionId
return `${MAPLE_AI_TRACE_SESSION_PREFIX}${traceId.slice(0, TRACE_SESSION_ID_CHARS)}…`
}
33 changes: 33 additions & 0 deletions packages/domain/src/gen-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ export const MAPLE_AI_VENDOR_VERSION_ATTR = "maple_ai.vendor.version"
/** The vendor's own session id, verbatim. */
export const MAPLE_AI_SESSION_ID_ATTR = "maple_ai.session.id"

/**
* Prefix of the session id Maple synthesizes for a GenAI trace that carries no
* {@link MAPLE_AI_SESSION_ID_ATTR}.
*
* The gateway stamps the session id only where the vendor exposes a session key
* — haystack, litellm, llamaindex, semantic_kernel and effect_ai never do, and
* the `unknown:*` buckets never do — so those traces have no session to belong
* to. Each one IS its own session: `trace:<TraceId>`, with the single trace as
* the whole context. The prefix is what keeps the two id spaces apart, and it
* is a colon-bearing shape no framework's own key is: read the id back with
* {@link traceSessionTraceId} rather than testing the prefix by hand.
*/
export const MAPLE_AI_TRACE_SESSION_PREFIX = "trace:"

/** A W3C trace id as the warehouse stores it — 32 lowercase hex characters. */
const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/

/**
* The trace id a synthesized session id names, or `undefined` when the id is a
* vendor's own.
*
* A prefixed id that is not shaped like a trace id is `undefined` too. This
* value reaches a warehouse `param.*`, so "looks like a trace id" is the
* boundary check that keeps a forged one out of the trace-keyed read — it falls
* through to the session-attribute read instead, where nothing carries it and
* the caller gets the empty-session answer.
*/
export const traceSessionTraceId = (sessionId: string): string | undefined => {
if (!sessionId.startsWith(MAPLE_AI_TRACE_SESSION_PREFIX)) return undefined
const traceId = sessionId.slice(MAPLE_AI_TRACE_SESSION_PREFIX.length)
return TRACE_ID_PATTERN.test(traceId) ? traceId : undefined
}

// Maple's native convention — the one dialect an app opts into deliberately
// rather than inheriting from a framework. These are ordinary span attributes
// an emitter writes itself, and Maple's own agents (`apps/api` chat +
Expand Down
10 changes: 9 additions & 1 deletion packages/domain/src/http/ai-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export class ListAiSessionsRequest extends Schema.Class<ListAiSessionsRequest>("
}) {}

export const AiSessionListItem = Schema.Struct({
/** The vendor's own session id, or `trace:<TraceId>` for an agent trace whose
* vendor exposes no session key — see `MAPLE_AI_TRACE_SESSION_PREFIX`. */
sessionId: Schema.String,
/** Vendor of the earliest session-bearing span, e.g. `eve`, `vercel_ai_sdk`. */
vendorId: Schema.String,
Expand Down Expand Up @@ -75,7 +77,13 @@ export class ListAiSessionsFacetsResponse extends Schema.Class<ListAiSessionsFac
export class GetAiSessionSpansRequest extends Schema.Class<GetAiSessionSpansRequest>(
"GetAiSessionSpansRequest",
)({
/** The framework's own session id, verbatim — `maple_ai.session.id`. */
/**
* The framework's own session id, verbatim — `maple_ai.session.id` — or the
* `trace:<TraceId>` id Maple synthesizes for a GenAI trace that carries none
* (`MAPLE_AI_TRACE_SESSION_PREFIX`). The handler routes on the prefix and
* validates the trace id behind it; a prefixed id that is not one reads as a
* session nothing carries, which answers empty like any unknown id.
*/
sessionId: Schema.String.check(Schema.isMinLength(1)),
// Optional, and the two halves are read as a pair — supply both or neither.
//
Expand Down
Loading
Loading