Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/api/src/routes/internal/ai-sessions.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ const spanRow = (index: number) => ({
statusMessage: "",
timestamp: "2026-08-19 10:00:00.000000000",
spanAttributes: { "gen_ai.operation.name": "chat", "maple_ai.session.id": SESSION_ID },
resourceAttributes: {},
})

const makeHarness = (overrides: Partial<WarehouseQueryServiceApi>) => {
Expand Down Expand Up @@ -230,7 +229,8 @@ describe("POST /internal/ai-sessions/spans", () => {
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 projection names the key; the predicate is what must be absent.
expect(spansSql).not.toContain("SpanAttributes['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_")
Expand Down
8 changes: 8 additions & 0 deletions lib/clickhouse-builder/src/ch/core-dsl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ describe("expression functions", () => {
expect(sql).toContain("map('key1', Name, 'key2', 'val') AS m")
})

it("compiles mapFilterKeys with the DSL's own conditions on the key", () => {
const q = CH.from(TestTable).select(($) => ({
m: CH.mapFilterKeys($.Attrs, (k) => k.in_("a", "b").or(k.like("x.%"))),
}))
const { sql } = compileCHUnsafe(q, {})
expect(sql).toContain("mapFilter((k, v) -> (k IN ('a', 'b') OR k LIKE 'x.%'), Attrs) AS m")
})

it("compiles empty mapLiteral", () => {
const q = CH.from(TestTable).select(() => ({ m: CH.mapLiteral() }))
const { sql } = compileCHUnsafe(q, {})
Expand Down
2 changes: 1 addition & 1 deletion lib/clickhouse-builder/src/ch/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export {
has,
} from "./array"

export { mapContains, mapGet, mapKeys, mapValues, mapLiteral } from "./map"
export { mapContains, mapFilterKeys, mapGet, mapKeys, mapValues, mapLiteral } from "./map"

export { toJSONString } from "./json"

Expand Down
17 changes: 17 additions & 0 deletions lib/clickhouse-builder/src/ch/functions/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ export function mapValues(mapExpr: Expr<Record<string, string>>): Expr<ReadonlyA
return makeExpr(raw(`mapValues(${compile(mapExpr.toFragment())})`), STRINGS)
}

/**
* `mapFilter((k, v) -> <predicate>, map)` — the entries whose KEY passes.
*
* The predicate is built from the lambda's key parameter, so it can use every
* condition the DSL has (`in_`, `like`, `or`, …). Values are not inspected.
*/
export function mapFilterKeys(
mapExpr: Expr<Record<string, string>>,
predicate: (key: Expr<string>) => Condition,
): Expr<Record<string, string>> {
const key = makeExpr(raw("k"), T.string.schema)
return makeExpr(
raw(`mapFilter((k, v) -> ${compile(predicate(key).toFragment())}, ${compile(mapExpr.toFragment())})`),
STRING_MAP,
)
}

export function mapLiteral(...pairs: Array<[string, Expr<string>]>): Expr<Record<string, string>> {
if (pairs.length === 0) return makeExpr(raw("map()"), STRING_MAP)
const args = pairs.map(([k, v]) => `${compile(str(k))}, ${compile(v.toFragment())}`).join(", ")
Expand Down
1 change: 1 addition & 0 deletions lib/clickhouse-builder/src/ch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export {
mapGet,
mapKeys,
mapValues,
mapFilterKeys,
mapLiteral,
// JSON
toJSONString,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,7 @@ SELECT
StatusCode AS statusCode,
StatusMessage AS statusMessage,
toString(Timestamp) AS timestamp,
SpanAttributes AS spanAttributes,
ResourceAttributes AS resourceAttributes
mapFilter((k, v) -> (k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'gen_ai.operation.name', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model', 'gen_ai.request.max_tokens', 'gen_ai.request.choice.count', 'gen_ai.request.temperature', 'gen_ai.request.top_p', 'gen_ai.request.top_k', 'gen_ai.request.stop_sequences', 'gen_ai.request.frequency_penalty', 'gen_ai.request.presence_penalty', 'gen_ai.request.encoding_formats', 'gen_ai.request.seed', 'gen_ai.openai.request.seed', 'gen_ai.request.stream', 'gen_ai.request.reasoning.level', 'gen_ai.request.previous_response.id', 'gen_ai.request.stream_cursor', 'gen_ai.response.id', 'gen_ai.response.model', 'gen_ai.response.finish_reasons', 'gen_ai.response.finish_reason', 'gen_ai.response.status', 'gen_ai.response.time_to_first_chunk', 'gen_ai.output.type', 'gen_ai.usage.input_tokens', 'gen_ai.usage.prompt_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.input_tokens.cached', 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_write.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.completion_tokens', 'gen_ai.usage.reasoning.output_tokens', 'gen_ai.usage.output_tokens.reasoning', 'gen_ai.usage.cost', 'gen_ai.usage.total_cost', 'gen_ai.conversation.id', 'gen_ai.conversation.compacted', 'gen_ai.agent.id', 'gen_ai.agent.name', 'gen_ai.agent.description', 'gen_ai.agent.version', 'gen_ai.tool.name', 'gen_ai.tool.call.id', 'gen_ai.tool.description', 'gen_ai.tool.type', 'gen_ai.tool.call.arguments', 'gen_ai.tool.call.result', 'gen_ai.tool.definitions', 'gen_ai.system_instructions', 'gen_ai.input.messages', 'gen_ai.prompt', 'gen_ai.output.messages', 'gen_ai.completion', 'gen_ai.data_source.id', 'gen_ai.retrieval.query.text', 'gen_ai.retrieval.top_k', 'gen_ai.retrieval.documents', 'gen_ai.memory.store.id', 'gen_ai.memory.record.id', 'gen_ai.memory.record.count', 'gen_ai.memory.query.text', 'gen_ai.memory.records', 'gen_ai.embeddings.dimension.count', 'gen_ai.evaluation.name', 'gen_ai.evaluation.score.value', 'gen_ai.evaluation.score.label', 'gen_ai.evaluation.explanation', 'gen_ai.prompt.name', 'gen_ai.prompt.version', 'gen_ai.workflow.name', 'error.type', 'server.address', 'server.port', 'ai.model.provider', 'ai.model.id', 'ai.response.id', 'ai.response.model', 'ai.response.finishReason', 'gen_ai.client.operation.time_to_first_chunk', 'ai.usage.inputTokens', 'ai.usage.promptTokens', 'ai.usage.cachedInputTokens', 'ai.usage.inputTokenDetails.cacheReadTokens', 'ai.usage.inputTokenDetails.cacheWriteTokens', 'ai.usage.outputTokens', 'ai.usage.completionTokens', 'ai.usage.reasoningTokens', 'ai.usage.outputTokenDetails.reasoningTokens', 'ai.telemetry.functionId', 'ai.toolCall.name', 'ai.toolCall.id', 'ai.toolCall.args', 'ai.toolCall.result', 'ai.prompt.tools', 'ai.prompt.messages', 'ai.prompt', 'llm.provider', 'llm.system', 'llm.model_name', 'llm.token_count.prompt', 'llm.token_count.prompt_details.cache_read', 'llm.token_count.completion', 'llm.token_count.completion_details.reasoning', 'llm.cost.total', 'tool.name', 'tool.description', 'llm.tools', 'llm.input_messages', 'input.value', 'llm.output_messages', 'output.value', 'openinference.span.kind', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%'), SpanAttributes) AS spanAttributes
FROM trace_detail_spans
WHERE OrgId = 'org_sql_catalog'
AND Timestamp >= '2026-01-01 10:30:00'
Expand Down Expand Up @@ -519,8 +518,7 @@ SELECT
StatusCode AS statusCode,
StatusMessage AS statusMessage,
toString(Timestamp) AS timestamp,
SpanAttributes AS spanAttributes,
ResourceAttributes AS resourceAttributes
mapFilter((k, v) -> (k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'gen_ai.operation.name', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model', 'gen_ai.request.max_tokens', 'gen_ai.request.choice.count', 'gen_ai.request.temperature', 'gen_ai.request.top_p', 'gen_ai.request.top_k', 'gen_ai.request.stop_sequences', 'gen_ai.request.frequency_penalty', 'gen_ai.request.presence_penalty', 'gen_ai.request.encoding_formats', 'gen_ai.request.seed', 'gen_ai.openai.request.seed', 'gen_ai.request.stream', 'gen_ai.request.reasoning.level', 'gen_ai.request.previous_response.id', 'gen_ai.request.stream_cursor', 'gen_ai.response.id', 'gen_ai.response.model', 'gen_ai.response.finish_reasons', 'gen_ai.response.finish_reason', 'gen_ai.response.status', 'gen_ai.response.time_to_first_chunk', 'gen_ai.output.type', 'gen_ai.usage.input_tokens', 'gen_ai.usage.prompt_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.input_tokens.cached', 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_write.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.completion_tokens', 'gen_ai.usage.reasoning.output_tokens', 'gen_ai.usage.output_tokens.reasoning', 'gen_ai.usage.cost', 'gen_ai.usage.total_cost', 'gen_ai.conversation.id', 'gen_ai.conversation.compacted', 'gen_ai.agent.id', 'gen_ai.agent.name', 'gen_ai.agent.description', 'gen_ai.agent.version', 'gen_ai.tool.name', 'gen_ai.tool.call.id', 'gen_ai.tool.description', 'gen_ai.tool.type', 'gen_ai.tool.call.arguments', 'gen_ai.tool.call.result', 'gen_ai.tool.definitions', 'gen_ai.system_instructions', 'gen_ai.input.messages', 'gen_ai.prompt', 'gen_ai.output.messages', 'gen_ai.completion', 'gen_ai.data_source.id', 'gen_ai.retrieval.query.text', 'gen_ai.retrieval.top_k', 'gen_ai.retrieval.documents', 'gen_ai.memory.store.id', 'gen_ai.memory.record.id', 'gen_ai.memory.record.count', 'gen_ai.memory.query.text', 'gen_ai.memory.records', 'gen_ai.embeddings.dimension.count', 'gen_ai.evaluation.name', 'gen_ai.evaluation.score.value', 'gen_ai.evaluation.score.label', 'gen_ai.evaluation.explanation', 'gen_ai.prompt.name', 'gen_ai.prompt.version', 'gen_ai.workflow.name', 'error.type', 'server.address', 'server.port', 'ai.model.provider', 'ai.model.id', 'ai.response.id', 'ai.response.model', 'ai.response.finishReason', 'gen_ai.client.operation.time_to_first_chunk', 'ai.usage.inputTokens', 'ai.usage.promptTokens', 'ai.usage.cachedInputTokens', 'ai.usage.inputTokenDetails.cacheReadTokens', 'ai.usage.inputTokenDetails.cacheWriteTokens', 'ai.usage.outputTokens', 'ai.usage.completionTokens', 'ai.usage.reasoningTokens', 'ai.usage.outputTokenDetails.reasoningTokens', 'ai.telemetry.functionId', 'ai.toolCall.name', 'ai.toolCall.id', 'ai.toolCall.args', 'ai.toolCall.result', 'ai.prompt.tools', 'ai.prompt.messages', 'ai.prompt', 'llm.provider', 'llm.system', 'llm.model_name', 'llm.token_count.prompt', 'llm.token_count.prompt_details.cache_read', 'llm.token_count.completion', 'llm.token_count.completion_details.reasoning', 'llm.cost.total', 'tool.name', 'tool.description', 'llm.tools', 'llm.input_messages', 'input.value', 'llm.output_messages', 'output.value', 'openinference.span.kind', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%'), SpanAttributes) AS spanAttributes
FROM trace_detail_spans
WHERE OrgId = 'org_sql_catalog'
AND Timestamp >= '2026-01-01 10:30:00'
Expand Down
22 changes: 0 additions & 22 deletions packages/query-engine-integrations/src/ai/ai-integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const row = (
statusMessage: "",
timestamp: "2026-08-12 15:18:42.207000000",
spanAttributes,
resourceAttributes: {},
...overrides,
})

Expand Down Expand Up @@ -351,18 +350,6 @@ describe("span envelope", () => {
})
})

it("reads gen_ai keys from span attributes alone", () => {
// A resource-level `gen_ai.*` key describes the process, not the
// operation: honouring it would stamp every span of that service —
// Postgres, HTTP, everything — as an AI span.
const mapped = mapAiSpan(
row({}, { resourceAttributes: { "gen_ai.request.model": "resource-level" } }),
)

expect(mapped.genAi.requestModel).toBeUndefined()
expect(mapped.isAiSpan).toBe(false)
})

it("maps a whole trace's worth of spans in order", () => {
const mapped = mapAiSpans([
row(INVOKE_AGENT_ATTRS),
Expand Down Expand Up @@ -397,15 +384,6 @@ describe("resolveAiIntegration", () => {
})

describe("untrusted attribute keys", () => {
it("ignores a vendor stamp that arrives via a resource attribute", () => {
// The envelope is read from span attributes alone, so a resource-level
// stamp neither selects an integration nor marks the span.
const mapped = mapAiSpan(row({}, { resourceAttributes: { "maple_ai.vendor.id": "eve" } }))

expect(mapped.vendorId).toBeUndefined()
expect(mapped.isAiSpan).toBe(false)
})

it("keeps a prompt variable literally named __proto__ as AI signal", () => {
expect(mapAiSpan(row({ "gen_ai.prompt.variable.__proto__": "kept" })).isAiSpan).toBe(true)
})
Expand Down
26 changes: 26 additions & 0 deletions packages/query-engine-integrations/src/ai/ai-integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export interface AiIntegration {
* field from something other than a single attribute.
*/
readonly refine?: (values: MutableAiGenAiValues, ctx: AiRefineContext) => void
/**
* Attribute keys `refine` reads that no source list names. The span read
* projects only the keys the mapper is known to read (`aiSpanAttributeKeys`),
* so a key missing here is a key `refine` never sees.
*/
readonly refineKeys?: readonly string[]
}

/** An integration carrying a source list for every catalog field. */
Expand Down Expand Up @@ -239,6 +245,26 @@ const resolvedIntegrations = new Map<string, ResolvedAiIntegration>(
]),
)

/**
* Every attribute key the mapper can read off a span, across every integration:
* the envelope, each field's source keys, and what the refine hooks read. The
* span read projects the attribute map down to these (plus the
* `AI_PROMPT_VARIABLE_PREFIX` family, which has no fixed key), so a key not in
* this list never reaches `mapAiSpan` — in production the map's bulk is
* `db.query.text` and friends, which the mapper never looked at.
*/
export const aiSpanAttributeKeys: readonly string[] = [
...new Set([
MAPLE_AI_SESSION_ID_ATTR,
MAPLE_AI_VENDOR_ID_ATTR,
MAPLE_AI_VENDOR_VERSION_ATTR,
...[genAiIntegration, ...resolvedIntegrations.values()].flatMap((integration) =>
Object.values(integration.sources).flat(),
),
...Object.values(AI_VENDOR_INTEGRATIONS).flatMap((vendor) => vendor.refineKeys ?? []),
]),
]

/** The integration for a vendor stamp, or the default for a stamp with no entry. */
export const resolveAiIntegration = (vendorId: string | undefined): ResolvedAiIntegration => {
const resolved = vendorId === undefined ? undefined : resolvedIntegrations.get(vendorId)
Expand Down
33 changes: 25 additions & 8 deletions packages/query-engine-integrations/src/ai/ai-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,12 +707,31 @@ describe("aiSessionSpansQuery", () => {
expect(sql).toContain("TraceId IN (SELECT")
expect(sql).toContain("FROM traces")
expect(sql).toContain("Duration / 1000000 AS durationMs")
expect(sql).toContain("SpanAttributes AS spanAttributes")
expect(sql).toContain("ResourceAttributes AS resourceAttributes")
expect(sql).toContain("mapFilter((k, v) -> (k IN ('maple_ai.session.id', ")
expect(sql).toContain("OR k LIKE 'gen_ai.prompt.variable.%'), SpanAttributes) AS spanAttributes")
expect(sql).not.toContain("ResourceAttributes")
expect(sql).toContain("ORDER BY timestamp ASC")
expect(sql).toContain("LIMIT 2000")
})

it("projects every key the mapper reads, across vendors", () => {
const { sql } = compileUnsafe(aiSessionSpansQuery(), spanParams)

for (const key of [
"maple_ai.vendor.id",
"gen_ai.input.messages",
"gen_ai.usage.prompt_tokens", // legacy alias
"ai.usage.inputTokens", // vercel_ai_sdk
"llm.token_count.prompt", // openinference
"openinference.span.kind", // read by a refine hook, not a source list
"eve.turn.id",
"maple_ai.turn.id",
"error.type",
]) {
expect(sql, key).toContain(`'${key}'`)
}
})

it("repeats the org predicate on every level that reads a table", () => {
const { sql } = compileUnsafe(aiSessionSpansQuery(), spanParams)

Expand Down Expand Up @@ -769,7 +788,6 @@ describe("aiSessionSpansQuery", () => {
"maple_ai.vendor.id": "eve",
"maple_ai.session.id": "wrun_01M0CSAEW96BH2W9185XZPRPKH",
},
resourceAttributes: { "service.name": "maple-slack-agent" },
},
])

Expand All @@ -778,7 +796,6 @@ describe("aiSessionSpansQuery", () => {
"maple_ai.vendor.id": "eve",
"maple_ai.session.id": "wrun_01M0CSAEW96BH2W9185XZPRPKH",
})
expect(row?.resourceAttributes).toEqual({ "service.name": "maple-slack-agent" })
})
})

Expand Down Expand Up @@ -919,16 +936,17 @@ describe("aiTraceSpansQuery", () => {
expect(sql).toContain(`TraceId = '${TRACE_ID}'`)
expect(sql).not.toContain("TraceId IN (SELECT")
expect(sql).not.toContain("FROM traces")
expect(sql).not.toContain("maple_ai.session.id")
// The projection still names the key; only the predicate is gone.
expect(sql).not.toContain("SpanAttributes['maple_ai.session.id']")
})

it("keeps the projection and the order of the session form", () => {
const { sql } = compileUnsafe(aiTraceSpansQuery(), traceParams)

// One shape whichever kind of session the detail page opened.
expect(sql).toContain("Duration / 1000000 AS durationMs")
expect(sql).toContain("SpanAttributes AS spanAttributes")
expect(sql).toContain("ResourceAttributes AS resourceAttributes")
expect(sql).toContain("SpanAttributes) AS spanAttributes")
expect(sql).not.toContain("ResourceAttributes")
expect(sql).toContain("ORDER BY timestamp ASC, spanId ASC")
expect(sql).toContain("LIMIT 2000")
expect(compileUnsafe(aiTraceSpansQuery({ limit: 100 }), traceParams).sql).toContain("LIMIT 100")
Expand Down Expand Up @@ -975,7 +993,6 @@ describe("aiTraceSpansQuery", () => {
timestamp: "2026-08-19 10:33:25.825000000",
// A sessionless vendor: the stamp is there, the session key is not.
spanAttributes: { "maple_ai.vendor.id": "llamaindex" },
resourceAttributes: { "service.name": "rag-service" },
},
])

Expand Down
23 changes: 14 additions & 9 deletions packages/query-engine-integrations/src/ai/ai-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,13 @@ import { AiTraceIndex, TraceDetailSpans, Traces } from "@maple/query-engine/ch/t
import { CHNumber } from "@maple/query-engine/ch/schema"
import { AI_SESSION_SPANS_MAX_SPANS, type AiSessionSortDir, type AiSessionSortKey } from "@maple/domain/http"
import {
AI_PROMPT_VARIABLE_PREFIX,
MAPLE_AI_SESSION_ID_ATTR,
MAPLE_AI_TRACE_SESSION_PREFIX,
MAPLE_AI_VENDOR_ID_ATTR,
MAPLE_AI_VENDOR_VERSION_ATTR,
} from "@maple/domain/gen-ai"
import { aiSpanAttributeKeys } from "./ai-integrations"
import { deepestReporterSum, usageReportersExpr } from "./ai-span-columns"

const SESSION_ID_ATTR = MAPLE_AI_SESSION_ID_ATTR
Expand Down Expand Up @@ -854,7 +856,6 @@ export interface AiSessionSpansOutput {
readonly statusMessage: string
readonly timestamp: string
readonly spanAttributes: Record<string, string>
readonly resourceAttributes: Record<string, string>
}

export const aiSessionSpansRowSchema: CompiledQueryRowSchema<AiSessionSpansOutput> = Schema.Struct({
Expand All @@ -872,7 +873,6 @@ export const aiSessionSpansRowSchema: CompiledQueryRowSchema<AiSessionSpansOutpu
// so this is a plain Record. Not `Schema.fromJsonString(…)` — that is for the
// observability path, which reads maps already serialized to a string.
spanAttributes: Schema.Record(Schema.String, Schema.String),
resourceAttributes: Schema.Record(Schema.String, Schema.String),
})

/** Shared by both span reads, so a session keyed by id and one keyed by trace
Expand All @@ -888,8 +888,14 @@ const spanProjection = ($: ColumnAccessor<typeof TraceDetailSpans.columns>) => (
statusCode: $.StatusCode,
statusMessage: $.StatusMessage,
timestamp: CH.toString_($.Timestamp),
spanAttributes: $.SpanAttributes,
resourceAttributes: $.ResourceAttributes,
// The map cut down to what `mapAiSpan` reads. Measured on production's
// largest sessions, the whole map is dominated by keys the mapper never
// touches (`db.query.text` alone was half of one session's bytes), and
// `ResourceAttributes` — which the mapper deliberately ignores, see
// `mapAiSpan` — was another 60% on top. Neither is read any more.
spanAttributes: CH.mapFilterKeys($.SpanAttributes, (key) =>
key.in_(...aiSpanAttributeKeys).or(key.like(`${AI_PROMPT_VARIABLE_PREFIX}%`)),
),
})

/**
Expand All @@ -898,11 +904,10 @@ const spanProjection = ($: ColumnAccessor<typeof TraceDetailSpans.columns>) => (
* `sessionId` is a compile param rather than an opts field, so one compiled SQL
* string serves every session.
*
* Both attribute Maps come back whole: the integration layer that normalizes
* these into gen_ai form needs keys this query cannot know in advance. Projecting
* only the keys it wants is a later optimisation, and a real one — one production
* trace already carries 250 spans with up to ~17KB of attributes each, so callers
* should expect megabyte-scale payloads at the default limit.
* The attribute map is projected down to the keys the integration layer reads
* (`aiSpanAttributeKeys`); everything else on the span stays in the warehouse.
* Even so, a content-heavy vendor puts whole prompts in `gen_ai.input.messages`,
* so callers should still expect megabyte-scale payloads at the default limit.
*
* No scope columns: `trace_detail_spans` does not carry `ScopeName`/`ScopeVersion`,
* and the read path does not need them. The ingest gateway already did the
Expand Down
Loading
Loading