feat(query-engine): AI agent session queries + gen_ai integration layer - #540
Open
JeremyFunk wants to merge 3 commits into
Open
feat(query-engine): AI agent session queries + gen_ai integration layer#540JeremyFunk wants to merge 3 commits into
JeremyFunk wants to merge 3 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Read side for the AI agent session feature. The ingest gateway already stamps
maple_ai.vendor.id/.vendor.version/.session.idat 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.tsmaple_ai.session.idis sparse. A vendor stamps it on the spans that own the turn (ai.eve.turn,invoke_agent), never on the siblingchat,execute_toolor 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.aiSessionListQueryreturns one row per session (vendor, trace/span/error counts, services, window, duration) with optional vendor and service filters.aiSessionSpansQueryreturns every span of every trace in one session, with the fullSpanAttributes/ResourceAttributesmaps for the integration layer to map.Both do that fan-out in two stages against two tables:
traces, wheremapContains(SpanAttributes, 'maple_ai.session.id')rides themapKeys(SpanAttributes)bloom index and stays cheap over a week. Yields the qualifying trace-id set and nothing else.trace_detail_spans, whereTraceIdis a sort-key prefix(OrgId, TraceId, SpanId).The same fan-out on raw
tracestimes out at 10s on a 7-day window in production — that table is sorted(OrgId, ServiceName, SpanName, Timestamp)andidx_trace_idis 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;errorDetailTracesQueryalready splits across these two tables for the same reason.vendorIdresolves viaargMinover the earliest session-bearing span rather thanmax(). One trace legitimately carries several vendors — an eve agent calling through the Vercel AI SDK — andmax()pickedvercel_ai_sdkalphabetically whenevewas 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.tsA default
gen_aiintegration maps each span onto one standardised format covering all 62 GenAI semconv attributes. Every attribute in that convention is stabilitydevelopment, so there is no stable subset to draw a line at. Fields are declared once inAI_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, thegen_ai.openai.*moves, singularfinish_reason— and normalises their values where the rename changed them (vertex_ai→gcp.vertex_ai,tool_calls→tool_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 wholegen_ai.memory.*family.Per-vendor overrides are keyed on
maple_ai.vendor.idand 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 bothopeninference-openaiandunknown:openinference), andeve. 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 yieldsundefinedrather than throwing, and a span with no AI signal maps toisAiSpan: falsewith an empty payload.Verification
tsc --noEmitclean, oxlint and oxfmt clean,@maple/apitypechecks against it.mapAiSpansproduce the expected spans end to end, including the eve override, the legacy-dialect normalisations, and a non-AI infrastructure span mapping toisAiSpan: false.__sql_baseline__diff is additive only; no existing query's SQL changed.Known limits
hasMoresignal or cursor belongs with the API surface.trace_detail_spanscarries noScopeName, so mapping keys offmaple_ai.vendor.idalone. 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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.