Skip to content

feat(ingest): stamp AI vendor + session attributes on spans - #517

Merged
JeremyFunk merged 9 commits into
mainfrom
ai2/02-session-write
Aug 19, 2026
Merged

feat(ingest): stamp AI vendor + session attributes on spans#517
JeremyFunk merged 9 commits into
mainfrom
ai2/02-session-write

Conversation

@JeremyFunk

@JeremyFunkJeremyFunk commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

What

The write side of AI agent session tracking, entirely in the Rust ingest gateway. Each span is classified against the known AI agent/framework vendors and, on a match, stamped with:

AttributeValue
maple_ai.vendor.idvendor slug the span is attributed to
maple_ai.vendor.versionidentified vendor version — always "0" for now
maple_ai.session.idthe vendor's own session identifier, verbatim (not hashed)

Non-AI spans are untouched.

How

Stamping happens at decode time (enrich_trace_request), alongside the maple_org_id resource enrichment, by appending the attributes to each span in the decoded OTLP request. Because it runs before the write path forks, the stamps reach both destinations: the native row encoder picks them up as ordinary span attributes, and the forward-to-collector path carries them inside the re-encoded payload. Customer-supplied maple_ai.* keys are stripped first — the gateway is the authority for the namespace, same as for maple_org_id.

apps/ingest/src/ai_session.rs holds an ordered first-match table of per-vendor detection predicates over scope name, span name, span/resource attributes, and event attribute keys — covering claude_agent_sdk, dspy, eve, flue, google_adk, haystack, langchain, litellm, llamaindex, mastra, agno, microsoft_agent_framework, openai_agents_sdk, openinference-openai, crewai, pydantic_ai, semantic_kernel, smolagents, strands, effect_ai, spring_ai, and vercel_ai_sdk, plus generic unknown:genai / unknown:openinference / unknown:other dialect buckets for AI spans no vendor claims.

The session ID is the first non-empty session-granularity attribute for the matched vendor (e.g. gen_ai.conversation.id for mastra, eve.session.id for eve, langsmith.metadata.thread_id for langchain). Vendors whose instrumentation only emits run- or user-scoped IDs (litellm, llamaindex, haystack, semantic_kernel, effect_ai) get no session attribute — either the vendor gives us a real session ID or we don't write one.

No new columns, no indexes, no materialized views, no schema version.

Performance

Classification sits on the per-span hot path with a budget of ~50ns per trace (20+ spans average). The structure, in order of what carries the load:

  • Scope/resource facts are hoisted and computed once per ScopeSpans/ResourceSpans.
  • A const-built two-byte key screen (256 x u64: first byte -> bitmask of admissible second bytes) rejects a non-AI attribute key with one table load and a bit test - no string comparisons on the hot path.
  • The per-span hot loop is a store-free phase-1 screen pass; only a screen hit pays for the phase-2 evidence collection, which is kept out-of-line so its comparison literals stay off the fast path.
  • Evidence-bearing spans run the 22 ordered predicates as pure field reads; the AI-span stamp path reserves once and moves the session String instead of re-copying it.

criterion bench (benches/ai_session_bench.rs), Apple Silicon dev machine:

caseper unit
non-AI HTTP span, 12 attrs14 ns
non-AI DB span, 8 attrs12 ns
non-AI span, 30 attrs22 ns
mastra / vercel / claude AI spans52 / 107 / 36 ns
mixed 100-span batch (95% non-AI), full stamp path22 ns/span
20-span non-AI trace, full stamp path136 ns/trace (~6.8 ns/span)
20-span trace with 2 AI spans652 ns/trace

The hard floor under these detection semantics is the per-attribute screen itself: nearly every vendor has scope-free span-attribute clauses, so every span's keys must be looked at (~240 attrs x ~0.5ns per 20-span trace). Getting materially below this requires a semantic relaxation (e.g. a trust list of known non-AI instrumentation scopes that skips span scanning), deliberately not taken in this PR.

Tests

  • Unit tests per vendor: detection, session extraction, and the exact-match traps (openai_agents vs openinference-openai scope prefix, crewai's foreign-OpenInference-scope refusal, eve's scope+span-name conjunction, effect's resource-guarded span names).
  • stamp_trace_request test: AI span stamped, plain HTTP span untouched, spoofed customer maple_ai.* keys stripped.
  • Decode-path test in main.rs proving enrichment stamps spans on the shared path before the native/forward fork.

🤖 Generated with Claude Code

JeremyFunkand others added 4 commits August 18, 2026 15:54
Classify each span against the known AI agent/framework vendors at ingest
and stamp the result into span_attributes:
- maple.ai.vendor.id: vendor slug (22 vendors + unknown:* dialect buckets)
- maple.ai.vendor.version: identified vendor version, currently always "0"
- maple.ai.session.id: the vendor's own session identifier, verbatim
Detection is ordered first-match over per-vendor predicates on scope name,
span name, span/resource attributes, and event attribute keys. Session IDs
are taken only from session-granularity keys; vendors whose instrumentation
only emits run/user-scoped IDs get no session attribute. No new columns,
indexes, or materialized views - plain span attributes only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the AI vendor/session stamping from the trace row encoder to
decode-time enrichment (enrich_trace_request), alongside the maple_org_id
resource enrichment. The stamps now live inside the decoded OTLP payload
itself, so they reach both write paths: the native row encoder picks them
up as ordinary span attributes, and the forward-to-collector path carries
them in the re-encoded payload.
Also renames the namespace from maple.ai.* to maple_ai.*, and strips any
customer-supplied maple_ai.* keys before stamping - the gateway is the
authority for this namespace, matching the org enrichment semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…path
The first cut rescanned the attribute list once per predicate clause (~30-40
linear scans per span), costing ~220ns on a typical non-AI HTTP span and
~236ns/span mean on a realistic 95%-non-AI batch - far over the ~50ns/span
budget for the ingest hot path.
Restructure classification around precomputed facts:
- Scope and resource facts are computed once per ScopeSpans/ResourceSpans
and shared by every span under them.
- Each span gets exactly one pass over its attributes: a first-byte dispatch
fills a SpanEvidence struct (prefix bits, presence bits, the five value
slots predicates compare). The maple_ai.* namespace strip is folded into
the same pass and only pays for itself when such a key exists.
- Spans with no evidence (the overwhelming majority) exit before any vendor
predicate runs; span-name-only detection (haystack, effect_ai) is gated by
a first-byte check.
- The 22 vendor predicates become pure field reads over the facts, keeping
the ordered first-match semantics byte-for-byte (all existing tests pass
unchanged).
criterion bench (benches/ai_session_bench.rs), Apple Silicon dev machine:
non-AI http span, 12 attrs 219ns -> 21ns
non-AI db span, 8 attrs 154ns -> 18ns
non-AI span, 30 attrs 463ns -> 44ns
mastra / vercel / claude 56 / 281 / 23ns -> 46 / 71 / 35ns
mixed 100-span stamp path 236ns/span -> 24ns/span
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The root clippy.toml declared lint levels in [workspace.lints.*] tables,
which clippy.toml does not support (and apps/ingest is a standalone crate,
so workspace lint tables would not apply anyway) - clippy silently ignored
the whole file. Split it by mechanism:
- Lint LEVELS move into apps/ingest/Cargo.toml [lints.rust]/[lints.clippy],
verbatim except string_to_string, which clippy 1.95 removed in favor of
implicit_clone.
- clippy.toml keeps actual clippy CONFIG: allow unwrap/expect/panic in
tests, since those lints target production code and tests assert with
them by design.
Fallout fixes to keep the build green and the new code clean:
- telemetry.rs: the two #[allow(clippy::too_many_arguments)] become
#[expect(..., reason)] as allow_attributes now demands.
- ai_session.rs + bench: zero warnings under the new lints. Mostly
mechanical (to_owned, map_or, semicolons, Debug derive); the deliberate
shapes get #[expect] with reasons (evidence-flag structs, the first-byte
dispatch table, crewai span-name suffixes misread as file extensions).
Pre-existing code still carries ~370 unique warnings (str_to_string alone
is ~250); left for a separate cleanup pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JeremyFunkand others added 5 commits August 18, 2026 19:11
…an trace
Second optimization round, targeting a per-TRACE budget of 50ns (20+ spans
per average trace). Changes, each benched:
- Two-byte key screen: a const-built 256 x u64 table maps a key's first
byte to a bitmask of admissible second bytes. A non-AI key is rejected
with one table load and a bit test - no string comparisons, and none of
the comparison literals in the loop. First-byte-only screening still sent
http.* into the haystack. probe and server.* into three s-probes; the
pair test is what kills those.
- Store-free phase 1: the per-span hot loop is now a read-only screen pass
(screen_hits) with no SpanEvidence init and no stores. Only a screen hit
(superset of the real patterns, false positives possible) pays for the
phase-2 absorb into SpanEvidence, which is out-of-line so its comparison
literals stay off the hot path.
- Same screen construction for scope names and span names.
- AI-span stamp path: one Vec::reserve(3) instead of up to three doubling
reallocs, and the session String moves into its attribute instead of
being copied again.
criterion (Apple Silicon dev machine), vs the first optimization round:
non-AI http 12 attrs 21ns -> 14.1ns
non-AI db 8 attrs 18ns -> 12.3ns
non-AI 30 attrs 44ns -> 21.8ns
mixed 100-span batch 24ns -> 21.9ns/span
20-span non-AI trace (new) ~480ns -> 136.5ns (~6.8ns/span)
20-span trace w/ 2 AI (new) -> 652ns
The 50ns/trace target is not reachable under frozen detection semantics:
nearly every vendor has scope-free span-attribute clauses, so every span's
attribute keys must be screened, and ~240 attrs/trace x ~0.5ns (String
pointer chase + table test) is the measured floor. Closing the gap needs a
semantic relaxation - e.g. a trust list of known non-AI instrumentation
scopes that skips span scanning - deliberately not taken here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The origin/main merge auto-duplicated the identical [lints.rust]/
[lints.clippy] blocks in Cargo.toml, which cargo rejects as a duplicate
key; the dedup was applied locally but missed the merge commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JeremyFunk
JeremyFunk merged commit 6fd5adc into mainAug 19, 2026
25 checks passed
@JeremyFunk
JeremyFunk deleted the ai2/02-session-write branch August 19, 2026 10:16
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit de1947d · View workflow run

Sign up for freeto 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

@JeremyFunk