Skip to content

feat(query-engine): AI agent session queries + gen_ai integration layer - #540

Open
JeremyFunk wants to merge 3 commits into
mainfrom
feat/ai-agent-session-read
Open

feat(query-engine): AI agent session queries + gen_ai integration layer#540
JeremyFunk wants to merge 3 commits into
mainfrom
feat/ai-agent-session-read

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Read side for the AI agent session feature. The ingest gateway already stamps maple_ai.vendor.id / .vendor.version / .session.id at decode time (#517); this reads them back.

Built on main. Supersedes the stacked read path in #513, which predates the v2 write side — that chain can be closed if this lands.

Scope is the query and the integration layer only. The API surface (MCP, public API, dashboard) is a follow-up.

Queries — src/ai/ai-sessions.ts

maple_ai.session.id is sparse. A vendor stamps it on the spans that own the turn (ai.eve.turn, invoke_agent), never on the sibling chat, execute_tool or infrastructure spans in the same trace. So a session resolves at trace granularity: any span carrying the id pulls in every span of its trace, including the non-AI ones — the dashboard shows full agent context, not just the spans a framework happened to label.

aiSessionListQuery returns one row per session (vendor, trace/span/error counts, services, window, duration) with optional vendor and service filters. aiSessionSpansQuery returns every span of every trace in one session, with the full SpanAttributes / ResourceAttributes maps for the integration layer to map.

Both do that fan-out in two stages against two tables:

  • detect — scans traces, where mapContains(SpanAttributes, 'maple_ai.session.id') rides the mapKeys(SpanAttributes) bloom index and stays cheap over a week. Yields the qualifying trace-id set and nothing else.
  • fan out — reads trace_detail_spans, where TraceId is a sort-key prefix (OrgId, TraceId, SpanId).

The same fan-out on raw traces times out at 10s on a 7-day window in production — that table is sorted (OrgId, ServiceName, SpanName, Timestamp) and idx_trace_id is only a bloom skip index, which prunes far too little at real volume. Measured: a bare single-trace lookup alone exceeds the limit. No new table and no new index is involved; errorDetailTracesQuery already splits across these two tables for the same reason.

vendorId resolves via argMin over the earliest session-bearing span rather than max(). One trace legitimately carries several vendors — an eve agent calling through the Vercel AI SDK — and max() picked vercel_ai_sdk alphabetically when eve was the framework actually running the turn. The root-most session-bearing span is the one that names the framework.

The vendor and service filters both apply to the detection subquery. For service that means "the session-bearing spans came from this service" rather than "the trace touched this service" — a trace spans services by definition, so filtering the fan-out would silently drop spans and under-count spanCount.

Integration layer — ai-span-model.ts, ai-integrations.ts, ai-vendors.ts

A default gen_ai integration maps each span onto one standardised format covering all 62 GenAI semconv attributes. Every attribute in that convention is stability development, so there is no stable subset to draw a line at. Fields are declared once in AI_GENAI_FIELDS; the Effect schemas and the source-key table are both generated from it, so a new field cannot be added in one place and forgotten in another.

The default layer also reads the deprecated spellings — gen_ai.system, gen_ai.prompt/completion, gen_ai.usage.prompt_tokens/completion_tokens, the gen_ai.openai.* moves, singular finish_reason — and normalises their values where the rename changed them (vertex_aigcp.vertex_ai, tool_callstool_call). Source keys were taken from the GenAI semconv repo rather than the opentelemetry.io registry page, which is a stale snapshot: it still lists the deprecated set while missing 13 live attributes, including the whole gen_ai.memory.* family.

Per-vendor overrides are keyed on maple_ai.vendor.id and replace the default's source keys field by field. Three ship — vercel_ai_sdk (ai.*, read out of the installed SDK's own telemetry code), OpenInference (llm.* / input.value, covering both openinference-openai and unknown:openinference), and eve. Attribute keys that could not be verified against a source were dropped rather than guessed, along with two that would have silently mismapped: an SDK function id that is not an operation name, and a millisecond field where the convention is seconds. Adding a vendor is one table entry.

Attribute values arrive as strings from a ClickHouse Map(String, String), so decoding is tolerant by design: a value that will not parse yields undefined rather than throwing, and a span with no AI signal maps to isAiSpan: false with an empty payload.

Verification

  • 264 tests in the package, tsc --noEmit clean, oxlint and oxfmt clean, @maple/api typechecks against it.
  • Both queries run against the production warehouse over a 7-day window and return correctly.
  • Real production rows fed through mapAiSpans produce the expected spans end to end, including the eve override, the legacy-dialect normalisations, and a non-AI infrastructure span mapping to isAiSpan: false.
  • The __sql_baseline__ diff is additive only; no existing query's SQL changed.

Known limits

  • The spans query caps at 2000 spans and truncates silently. One real trace is already 250 spans at up to ~17KB of attributes each. A hasMore signal or cursor belongs with the API surface.
  • The time window bounds both query levels, so a session whose traces straddle the window edge returns partial spans.
  • trace_detail_spans carries no ScopeName, so mapping keys off maple_ai.vendor.id alone. The gateway already performed scope-based detection at write time, but a span it failed to classify cannot be rescued in the read path.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

JeremyFunk and others added 3 commits August 19, 2026 14:29
Read side for the AI agent session feature. The ingest gateway already
stamps `maple_ai.vendor.id` / `.vendor.version` / `.session.id` at decode
time; this is what reads them back.

Queries (`src/ai/ai-sessions.ts`)

`maple_ai.session.id` is sparse — a vendor stamps it on the spans that own
the turn (`ai.eve.turn`, `invoke_agent`), never on the sibling `chat`,
`execute_tool` or infrastructure spans. So a session resolves at TRACE
granularity: any span carrying the id pulls in every span of its trace.

Both queries do that fan-out in two stages against two tables. Detection
scans `traces`, where `mapContains(SpanAttributes, …)` rides the
`mapKeys(SpanAttributes)` bloom index and stays cheap over a week. The
fan-out then reads `trace_detail_spans`, where `TraceId` is a sort-key
prefix. The same fan-out on raw `traces` times out at 10s on a 7-day
window in production — that table is sorted (OrgId, ServiceName, SpanName,
Timestamp) and `idx_trace_id` is only a bloom skip index. No new table and
no new index: `errorDetailTracesQuery` already splits across the two for
the same reason.

`vendorId` resolves via argMin over the earliest session-bearing span, not
max(): one trace carries several vendors — an eve agent calling through
the Vercel AI SDK — and max() picked `vercel_ai_sdk` alphabetically when
`eve` was the framework running the turn.

Integration layer (`src/ai/ai-span-model.ts`, `ai-integrations.ts`,
`ai-vendors.ts`)

A default `gen_ai` integration maps each span onto one standardised format
covering all 62 GenAI semconv attributes — every one is stability
`development`, so there is no stable subset to draw a line at. Fields are
declared once in `AI_GENAI_FIELDS`; the Effect schemas and the source-key
table are both generated from it.

Per-vendor overrides are keyed on `maple_ai.vendor.id` and replace the
default's source keys field by field. Three ship: `vercel_ai_sdk` (`ai.*`,
read out of the installed SDK), OpenInference (`llm.*` / `input.value`),
and `eve`. Keys that could not be verified against a source were dropped
rather than guessed; adding a vendor is one table entry.

Attribute values arrive as strings from a ClickHouse Map, so decoding is
tolerant by design — a value that will not parse yields `undefined`, never
a throw, and a span with no AI signal maps to `isAiSpan: false`.

Verified end to end against the production warehouse: both queries return
correctly over a 7-day window, and real rows fed through `mapAiSpans`
produce the expected spans, including the legacy-dialect normalisations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review findings on the AI agent session read path.

The vendor stamp reaches a plain object index, and the ingest gateway
strips `maple_ai.*` from span attributes but NOT from resource
attributes — so a customer-supplied `maple_ai.vendor.id` of `constructor`
or `toString` resolved off `Object.prototype`, passed the `undefined`
guard, and produced an integration with `id: undefined`. That violates
`AiAgentSpanSchema` at the encode boundary, far from the cause, and was
memoised process-wide. Guarded with `Object.hasOwn`, and the attribute
merge and prompt-variable accumulator are now null-prototype so the whole
class is closed rather than the one reachable instance.

`decodeStringArray` wrapped anything that was not a string array into
`[raw]`. A parsed object or an array of numbers therefore became a
one-element array holding raw JSON text — a value that type-checks,
silently consumes the field, and denies the next alias its turn. Only an
unparseable value is the bare form now; parsed-but-wrong-shape decodes to
nothing, like every other type.

`decodeAttribute`'s switch had no exhaustiveness guard, and the package
has no `noImplicitReturns`, so a sixth field type would have compiled
clean and made every field of that type silently absent.

Also: export the spans cap so callers can request `+1` and detect
truncation instead of hardcoding it — truncation drops the END of a
session, which is where the agent's answer is; attach the row schemas to
the catalog fixtures, since the ClickHouse e2e sweep only runs its
64-bit decode assertion for fixtures that carry one; and assert the
query row and the mapper input agree, mutually — `extends` alone would
accept a query that grew a column the mapper never sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second round of review findings.

`traceEnd` was `max(Timestamp)`, but `Timestamp` is the span's START, so
the session's end was the moment its last span BEGAN. Every session
under-reported by exactly the last-starting span's duration — 10417 ms
against a true 10433 ms on a real production session — and a session
whose trace is one long span reported a duration of 0. The error is
always negative and always looks like plausible jitter, so it would not
have been noticed from the numbers. Now `max(Timestamp + Duration)`,
carried through as nanos, the same idiom `tracesDetailQuery` uses.
Verified against production: the endpoint is now the nanosecond-exact
trace end.

`aiSessionSpansQuery` with an empty `sessionId` matched every span that
merely LACKS the key, because ClickHouse reads a missing Map key back as
`''` — an empty param degraded into a whole-org trace dump. The presence
guard the list query already had now covers this one too.

The `maple_ai.*` envelope is read from span attributes only. It was read
from a merge of span and resource attributes, and the gateway strips that
namespace from span attributes but not from resource attributes, so one
forged resource attribute marked every span in a service as an AI span
and labelled it with a session id the query never matched on.

The OpenInference override dropped six of the default's legacy aliases
while the Vercel override kept them, so identifying a span as
OpenInference LOST its token counts and messages — a recognised vendor
mapped strictly worse than an unrecognised one. Both overrides now carry
the same policy, and a registry-driven test pins the invariant. The test
that asserted the old behaviour as intentional was encoding the bug; it
now demonstrates replacement by precedence instead.

Also: `count()` rather than approximate `uniq()` for `traceCount`, since
the derived table already emits one row per trace; and a `spanId`
tiebreaker so truncation is deterministic and cannot orphan children.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant