From 0f2f5185d41148ec987ee1d1a63195c78aa4c2e0 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 20:54:15 +0200 Subject: [PATCH 01/17] feat(ingest): stamp every Agent Sessions aggregate and filter fact on the span The gateway now decides, per stamped span, whether it is a tool call, whether it failed, whether it is a tool call's paused copy, and its model, agent, tool, tool call id, response id, tool description and a failed tool call's result, and writes each as a maple_ai.* stamp beside the llm-call marker and usage buckets. One pass over the span's attributes feeds every fact, usage included, in place of a scan per key. OpenAI Agents' OpenInference instrumentor names an agent only in graph.node.id on its AGENT span; that id is the agent name where it equals the span name. --- apps/ingest/benches/ai_session_bench.rs | 358 ++++++++++- apps/ingest/src/ai_session.rs | 29 +- apps/ingest/src/ai_session/claude_code.rs | 1 + apps/ingest/src/ai_session/facts.rs | 730 ++++++++++++++++++++++ apps/ingest/src/ai_session/usage.rs | 176 ++---- 5 files changed, 1164 insertions(+), 130 deletions(-) create mode 100644 apps/ingest/src/ai_session/facts.rs diff --git a/apps/ingest/benches/ai_session_bench.rs b/apps/ingest/benches/ai_session_bench.rs index 46aa140c35..b158917a4d 100644 --- a/apps/ingest/benches/ai_session_bench.rs +++ b/apps/ingest/benches/ai_session_bench.rs @@ -393,6 +393,361 @@ fn bench_stamp_trace(c: &mut Criterion) { group.finish(); } +/// One scope's worth of agent spans, as its framework exports them: the agent +/// wrapper, the model call with usage, and a tool call. +type VendorBatch = ( + &'static str, + Vec<(&'static str, Vec<(&'static str, &'static str)>)>, +); + +#[expect(clippy::too_many_lines, reason = "one fixture per vendor")] +fn vendor_batches() -> Vec { + let tool = |op: &'static str, name_key: &'static str| { + vec![ + ("gen_ai.operation.name", op), + (name_key, "search_docs"), + ("gen_ai.tool.call.id", "call_8f14e45f"), + ( + "gen_ai.tool.description", + "Search the product documentation.", + ), + ( + "gen_ai.tool.call.arguments", + "{\"query\":\"refund policy\"}", + ), + ("gen_ai.tool.call.result", "{\"hits\":3}"), + ] + }; + vec![ + ( + "gen_ai", + vec![ + ( + "invoke_agent support", + vec![ + ("gen_ai.operation.name", "invoke_agent"), + ("gen_ai.agent.name", "support"), + ("gen_ai.conversation.id", "conv-1"), + ("gen_ai.usage.input_tokens", "900"), + ("gen_ai.usage.output_tokens", "120"), + ], + ), + ( + "chat openai/gpt-5-mini", + vec![ + ("gen_ai.operation.name", "chat"), + ("gen_ai.provider.name", "openrouter.chat"), + ("gen_ai.request.model", "openai/gpt-5-mini"), + ("gen_ai.response.id", "gen-1"), + ("gen_ai.usage.input_tokens", "900"), + ("gen_ai.usage.output_tokens", "120"), + ("ai.usage.inputTokenDetails.noCacheTokens", "900"), + ("ai.usage.outputTokenDetails.textTokens", "56"), + ("ai.usage.outputTokenDetails.reasoningTokens", "64"), + ], + ), + ( + "execute_tool search_docs", + tool("execute_tool", "gen_ai.tool.name"), + ), + ], + ), + ( + "ai", + vec![ + ("ai.generateText", { + let mut attrs = vec![("ai.telemetry.functionId", "support")]; + attrs.extend([("ai.usage.promptTokens", "812"), ("ai.model.id", "gpt-5")]); + attrs + }), + ( + "ai.generateText.doGenerate", + vec![ + ("ai.model.id", "gpt-5"), + ("ai.model.provider", "openai.chat"), + ("ai.response.id", "resp-1"), + ("ai.usage.inputTokens", "812"), + ("ai.usage.outputTokens", "96"), + ("gen_ai.system", "openai.chat"), + ("gen_ai.request.model", "gpt-5"), + ], + ), + ( + "ai.toolCall", + vec![ + ("ai.toolCall.name", "search_docs"), + ("ai.toolCall.id", "call_1"), + ("ai.toolCall.args", "{}"), + ("ai.toolCall.result", "{\"hits\":3}"), + ], + ), + ], + ), + ( + "@mastra/otel-exporter", + vec![ + ( + "invoke_agent support", + vec![ + ("mastra.span.type", "agent_run"), + ("gen_ai.operation.name", "invoke_agent"), + ("gen_ai.agent.name", "support"), + ("gen_ai.conversation.id", "conv-2"), + ], + ), + ( + "chat openai/gpt-5-mini", + vec![ + ("mastra.span.type", "model_generation"), + ("gen_ai.operation.name", "chat"), + ("gen_ai.request.model", "openai/gpt-5-mini"), + ("gen_ai.usage.input_tokens", "849"), + ("gen_ai.usage.output_tokens", "2200"), + ("gen_ai.usage.reasoning_tokens", "1792"), + ], + ), + ( + "execute_tool search_docs", + tool("execute_tool", "gen_ai.tool.name"), + ), + ], + ), + ( + "strands.telemetry.tracer", + vec![ + ( + "invoke_agent support", + vec![ + ("gen_ai.operation.name", "invoke_agent"), + ("gen_ai.agent.name", "support"), + ("gen_ai.system", "strands-agents"), + ("gen_ai.usage.input_tokens", "103"), + ], + ), + ( + "chat", + vec![ + ("gen_ai.operation.name", "chat"), + ( + "gen_ai.request.model", + "us.anthropic.claude-sonnet-4-20250514-v1:0", + ), + ("gen_ai.usage.input_tokens", "12"), + ("gen_ai.usage.cache_read_input_tokens", "4000"), + ("gen_ai.usage.output_tokens", "35"), + ], + ), + ( + "execute_tool search_docs", + tool("execute_tool", "gen_ai.tool.name"), + ), + ], + ), + ( + "openinference.instrumentation.openai_agents", + vec![ + ( + "Agent workflow", + vec![ + ("openinference.span.kind", "AGENT"), + ("graph.node.id", "support"), + ], + ), + ( + "generation", + vec![ + ("openinference.span.kind", "LLM"), + ("llm.model_name", "gpt-5-mini"), + ("llm.token_count.prompt", "330"), + ("llm.token_count.completion", "96"), + ("llm.token_count.completion_details.reasoning", "107"), + ], + ), + ( + "search_docs", + vec![ + ("openinference.span.kind", "TOOL"), + ("tool.name", "search_docs"), + ("input.value", "{}"), + ("output.value", "{\"hits\":3}"), + ], + ), + ], + ), + ( + "langsmith", + vec![ + ( + "LangGraph", + vec![ + ("gen_ai.operation.name", "chain"), + ("langsmith.metadata.thread_id", "t-1"), + ], + ), + ( + "ChatOpenAI", + vec![ + ("gen_ai.operation.name", "chat"), + ("gen_ai.system", "openai"), + ("gen_ai.request.model", "gpt-4o-mini"), + ("gen_ai.usage.input_tokens", "143"), + ("gen_ai.usage.output_tokens", "32"), + ], + ), + ("search_docs", tool("execute_tool", "gen_ai.tool.name")), + ], + ), + ( + "pydantic-ai", + vec![ + ( + "invoke_agent support", + vec![ + ("gen_ai.operation.name", "invoke_agent"), + ("gen_ai.agent.name", "support"), + ("gen_ai.aggregated_usage.input_tokens", "133"), + ], + ), + ( + "chat gpt-4o-mini", + vec![ + ("gen_ai.operation.name", "chat"), + ("gen_ai.request.model", "gpt-4o-mini"), + ("gen_ai.usage.input_tokens", "133"), + ("gen_ai.usage.output_tokens", "46"), + ("operation.cost", "4.755e-05"), + ], + ), + ("running tool", tool("execute_tool", "gen_ai.tool.name")), + ], + ), + ( + "com.anthropic.claude_code.tracing", + vec![ + ( + "claude_code.interaction", + vec![ + ("span.type", "interaction"), + ("session.id", "cc-1"), + ("user_prompt", "fix it"), + ], + ), + ( + "claude_code.llm_request", + vec![ + ("span.type", "llm_request"), + ("session.id", "cc-1"), + ("gen_ai.request.model", "claude-sonnet-4-5"), + ("input_tokens", "3"), + ("output_tokens", "93"), + ("cache_creation_tokens", "2025"), + ], + ), + ( + "claude_code.tool", + vec![ + ("span.type", "tool"), + ("session.id", "cc-1"), + ("tool_name", "Bash"), + ("tool_use_id", "toolu_1"), + ], + ), + ], + ), + ( + "openrouter", + vec![( + "LLM Generation", + vec![ + ("gen_ai.operation.name", "chat"), + ("gen_ai.request.model", "anthropic/claude-haiku-4.5"), + ("gen_ai.response.id", "gen-2"), + ("gen_ai.usage.input_tokens", "5021"), + ("gen_ai.usage.input_tokens.cached", "4248"), + ("gen_ai.usage.output_tokens", "263"), + ("gen_ai.usage.total_cost", "0.0027"), + ("session.id", "or-1"), + ], + )], + ), + ( + "support-agent", + vec![ + ( + "chat gpt-5", + vec![ + ("gen_ai.operation.name", "chat"), + ("gen_ai.request.model", "gpt-5"), + ("gen_ai.usage.input_tokens", "812"), + ("gen_ai.usage.output_tokens", "96"), + ], + ), + ("execute_tool search_docs", { + let mut attrs = tool("execute_tool", "gen_ai.tool.name"); + attrs.push(("error.type", "TimeoutError")); + attrs + }), + ], + ), + ] +} + +/// The stamping path itself: a request of agent spans only (ten vendors, each +/// scope's wrapper/model-call/tool-call shape repeated to 120 spans), measured +/// per span. Every span here pays the full classification and stamp, so this +/// is the case the per-span stamps move; the mixed benches above show what +/// that costs a realistic batch. +fn bench_stamp_ai_batch(c: &mut Criterion) { + let mut group = c.benchmark_group("ai_stamp_ai_batch"); + group.warm_up_time(Duration::from_millis(500)); + group.measurement_time(Duration::from_secs(3)); + + let scope_spans: Vec = vendor_batches() + .into_iter() + .map(|(scope, shapes)| ScopeSpans { + scope: Some(InstrumentationScope { + name: scope.to_owned(), + ..Default::default() + }), + spans: (0..120) + .map(|i| { + let (name, pairs) = &shapes[i % shapes.len()]; + Span { + name: (*name).to_owned(), + attributes: attrs(pairs), + ..Default::default() + } + }) + .collect(), + ..Default::default() + }) + .collect(); + let span_count: usize = scope_spans.iter().map(|ss| ss.spans.len()).sum(); + let request = ExportTraceServiceRequest { + resource_spans: vec![ResourceSpans { + resource: Some(Resource { + attributes: service_resource("support-agent"), + ..Default::default() + }), + scope_spans, + ..Default::default() + }], + }; + + group.throughput(Throughput::Elements(span_count as u64)); + group.bench_function("ai_1200_spans_10_vendors", |b| { + b.iter_batched( + || request.clone(), + |mut request| { + stamp_trace_request(&mut request); + black_box(request) + }, + criterion::BatchSize::LargeInput, + ); + }); + group.finish(); +} + fn attr_count(request: &ExportTraceServiceRequest) -> usize { request .resource_spans @@ -407,6 +762,7 @@ criterion_group!( benches, bench_classify, bench_stamp_request, - bench_stamp_trace + bench_stamp_trace, + bench_stamp_ai_batch ); criterion_main!(benches); diff --git a/apps/ingest/src/ai_session.rs b/apps/ingest/src/ai_session.rs index 357d54e4d9..c2d25bbe8e 100644 --- a/apps/ingest/src/ai_session.rs +++ b/apps/ingest/src/ai_session.rs @@ -26,11 +26,12 @@ //! keys become the `gen_ai.*` keys every reader keys on, and the phases of its //! tool calls are left unstamped — see `ai_session/claude_code.rs`. //! -//! Every stamped span also says whether it is a model call -//! (`maple_ai.llm_call`), and a model call gets its token usage restated as -//! five disjoint `maple_ai.usage.*` buckets, whatever convention its emitter -//! reported under; agent and workflow wrappers get none — see -//! `ai_session/usage.rs`. +//! Every stamped span also carries the facts Agent Sessions aggregates and +//! filters on, decided here once: whether it is a model call +//! (`maple_ai.llm_call`) or a tool call, whether it failed, its model, agent +//! and tool, and a model call's token usage as five disjoint +//! `maple_ai.usage.*` buckets, whatever convention its emitter reported under +//! — see `ai_session/facts.rs` and `ai_session/usage.rs`. //! //! Detection is ordered first-match over the vendor predicates below; the //! session ID is the first non-empty session-granularity attribute for the @@ -66,6 +67,7 @@ use opentelemetry_proto::tonic::common::v1::{any_value, AnyValue, KeyValue}; use opentelemetry_proto::tonic::trace::v1::span::Event; mod claude_code; +mod facts; mod usage; pub const ATTR_NAMESPACE: &str = "maple_ai."; @@ -166,9 +168,10 @@ pub fn stamp_trace_request(request: &mut ExportTraceServiceRequest) { } claude_code::normalize(span); } - // One reserve, not up to four doubling reallocs that each - // copy every existing KeyValue. - span.attributes.reserve(4); + let mut stamps = facts::stamps(span, classification.vendor); + // One reserve, not a doubling realloc per push that each copy + // every existing KeyValue. + span.attributes.reserve(stamps.len() + 3); span.attributes .push(string_attribute(VENDOR_ID_ATTR, classification.vendor)); span.attributes @@ -179,7 +182,7 @@ pub fn stamp_trace_request(request: &mut ExportTraceServiceRequest) { span.attributes .push(owned_string_attribute(SESSION_ID_ATTR, session_id)); } - usage::stamp(span, classification.vendor); + span.attributes.append(&mut stamps); } } } @@ -1813,6 +1816,14 @@ mod tests { attr_value(tool, "gen_ai.tool.call.result").as_deref(), Some(r#"{"error":"Syntax error"}"#) ); + // The folded failure reaches the stamps: stamped before its phase was + // seen, the call had read as a paused copy with no result. + assert_eq!(attr_value(tool, "maple_ai.error").as_deref(), Some("1")); + assert_eq!( + attr_value(tool, "maple_ai.tool.error_result").as_deref(), + Some(r#"{"error":"Syntax error"}"#) + ); + assert!(attr_value(tool, "maple_ai.tool.paused").is_none()); } #[test] diff --git a/apps/ingest/src/ai_session/claude_code.rs b/apps/ingest/src/ai_session/claude_code.rs index c129dfd44f..584822a22f 100644 --- a/apps/ingest/src/ai_session/claude_code.rs +++ b/apps/ingest/src/ai_session/claude_code.rs @@ -188,6 +188,7 @@ pub(super) fn fold_tool_failures(request: &mut ExportTraceServiceRequest, failur .push(owned("gen_ai.tool.call.result", result)); } } + super::facts::mark_tool_failed(span); } } diff --git a/apps/ingest/src/ai_session/facts.rs b/apps/ingest/src/ai_session/facts.rs new file mode 100644 index 0000000000..33ca394086 --- /dev/null +++ b/apps/ingest/src/ai_session/facts.rs @@ -0,0 +1,730 @@ +//! Every fact Agent Sessions aggregates or filters on, decided here for each +//! stamped span and written as a `maple_ai.*` stamp, so `ai_trace_index_mv` +//! is a plain projection of these keys and the detail page reads the same +//! verdicts the list sums. +//! +//! - `maple_ai.llm_call` and the usage buckets: see `usage.rs` +//! - `maple_ai.tool_call`: `1` on a tool call +//! - `maple_ai.error`: `1` on a span that failed, by its status or by +//! attribute (`error.type`, a failed `gen_ai.response.status`) +//! - `maple_ai.model`, `maple_ai.agent.name`, `maple_ai.tool.name`, +//! `maple_ai.tool.call_id`, `maple_ai.response.id`: each fact's first +//! non-empty value across the dialects' keys (and OpenAI Agents' agent from +//! its graph node, see [`agent_name`]) +//! - `maple_ai.tool.description`, cut to [`TOOL_DESCRIPTION_MAX`] characters, +//! on a tool call +//! - `maple_ai.tool.error_result`: a failed tool call's result, cut to +//! [`TOOL_ERROR_RESULT_MAX`], where several frameworks put the only account +//! of the failure +//! - `maple_ai.tool.paused`: `1` on a tool call's copy that recorded no +//! outcome, the copy a call paused for a human's approval leaves behind +//! +//! A flag is written only when it holds and a value only when there is one: +//! `maple_ai.llm_call`, present on every stamped span, is what tells a reader +//! the rest were decided. +//! +//! Performance: the span's attributes are read in one pass, each key looked +//! up once in a table of every key any fact reads, instead of one scan of +//! the attributes per key. Only stamped spans get here. + +use std::sync::LazyLock; + +use opentelemetry_proto::tonic::common::v1::{any_value, KeyValue}; +use opentelemetry_proto::tonic::trace::v1::status::StatusCode; +use opentelemetry_proto::tonic::trace::v1::Span; + +use super::{owned_string_attribute, usage, value_str}; + +const TOOL_CALL_ATTR: &str = "maple_ai.tool_call"; +const ERROR_ATTR: &str = "maple_ai.error"; +const MODEL_ATTR: &str = "maple_ai.model"; +const AGENT_NAME_ATTR: &str = "maple_ai.agent.name"; +const TOOL_NAME_ATTR: &str = "maple_ai.tool.name"; +const TOOL_CALL_ID_ATTR: &str = "maple_ai.tool.call_id"; +const TOOL_DESCRIPTION_ATTR: &str = "maple_ai.tool.description"; +const TOOL_ERROR_RESULT_ATTR: &str = "maple_ai.tool.error_result"; +const TOOL_PAUSED_ATTR: &str = "maple_ai.tool.paused"; +const RESPONSE_ID_ATTR: &str = "maple_ai.response.id"; + +/// A description is a sentence meant for a model, but a framework can inline +/// a schema or a whole prompt there; the tool page shows it as prose. +const TOOL_DESCRIPTION_MAX: usize = 2_000; +/// An explanation of a failure is its opening; the rest of a result is payload. +const TOOL_ERROR_RESULT_MAX: usize = 1_000; +/// The result Google ADK records on a tool call it paused to ask a human for +/// confirmation. The call runs again under the same call id once approved. +const CONFIRMATION_REQUEST: &str = "This tool call requires confirmation"; + +// Each fact's keys, canonical first: the first non-empty value wins. + +/// Response model first: an alias in the request resolves to a dated +/// snapshot in the response. +pub(super) const MODEL_KEYS: &[&str] = &[ + "gen_ai.response.model", + "gen_ai.request.model", + "ai.response.model", + "ai.model.id", + "llm.model_name", +]; +/// `ai.telemetry.functionId` is the name an app gave a traced Vercel AI SDK +/// call, the only agent identity an older-SDK span has. +const AGENT_NAME_KEYS: &[&str] = &["gen_ai.agent.name", "ai.telemetry.functionId"]; +pub(super) const TOOL_NAME_KEYS: &[&str] = &["gen_ai.tool.name", "ai.toolCall.name", "tool.name"]; +const TOOL_CALL_ID_KEYS: &[&str] = &["gen_ai.tool.call.id", "ai.toolCall.id"]; +const TOOL_DESCRIPTION_KEYS: &[&str] = &["gen_ai.tool.description", "tool.description"]; +const TOOL_RESULT_KEYS: &[&str] = &["gen_ai.tool.call.result", "ai.toolCall.result"]; +const RESPONSE_ID_KEYS: &[&str] = &["gen_ai.response.id", "ai.response.id"]; + +/// Text facts, by slot. +const OPERATION: usize = 0; +const SPAN_KIND: usize = 1; +const MODEL: usize = 2; +const AGENT_NAME: usize = 3; +const TOOL_NAME: usize = 4; +const TOOL_CALL_ID: usize = 5; +const TOOL_DESCRIPTION: usize = 6; +const TOOL_RESULT: usize = 7; +const RESPONSE_ID: usize = 8; +const ERROR_TYPE: usize = 9; +const RESPONSE_STATUS: usize = 10; +const GRAPH_NODE_ID: usize = 11; +const TEXT_KEYS: [&[&str]; 12] = [ + &["gen_ai.operation.name"], + &["openinference.span.kind"], + MODEL_KEYS, + AGENT_NAME_KEYS, + TOOL_NAME_KEYS, + TOOL_CALL_ID_KEYS, + TOOL_DESCRIPTION_KEYS, + TOOL_RESULT_KEYS, + RESPONSE_ID_KEYS, + &["error.type"], + &["gen_ai.response.status"], + &["graph.node.id"], +]; + +/// Number facts, by slot: the first key whose value is a finite, +/// non-negative number wins. +pub(super) const INPUT: usize = 0; +pub(super) const CACHE_READ: usize = 1; +pub(super) const CACHE_WRITE: usize = 2; +pub(super) const OUTPUT: usize = 3; +pub(super) const REASONING: usize = 4; +pub(super) const UNCACHED_INPUT: usize = 5; +pub(super) const VISIBLE_OUTPUT: usize = 6; +pub(super) const COST: usize = 7; +const NUMBER_KEYS: [&[&str]; 8] = [ + usage::INPUT_KEYS, + usage::CACHE_READ_KEYS, + usage::CACHE_WRITE_KEYS, + usage::OUTPUT_KEYS, + usage::REASONING_KEYS, + usage::UNCACHED_INPUT_KEYS, + usage::VISIBLE_OUTPUT_KEYS, + usage::COST_KEYS, +]; + +#[derive(Clone, Copy)] +enum Slot { + Text(usize), + Number(usize), +} + +/// Every key above as `(key, slot, rank)`, bucketed by the key's length; +/// `rank` is the key's place in its fact's list. A span's key is compared +/// only against the few keys of its own length, so the common miss costs an +/// index and a short loop, never a string comparison. +static KEY_TABLE: LazyLock>> = LazyLock::new(|| { + let mut table: Vec> = Vec::new(); + let lists = TEXT_KEYS + .iter() + .enumerate() + .map(|(index, keys)| (Slot::Text(index), *keys)) + .chain( + NUMBER_KEYS + .iter() + .enumerate() + .map(|(index, keys)| (Slot::Number(index), *keys)), + ); + for (slot, keys) in lists { + for (key, rank) in keys.iter().zip(0u8..) { + if table.len() <= key.len() { + table.resize_with(key.len() + 1, Vec::new); + } + table[key.len()].push((key, slot, rank)); + } + } + table +}); + +/// One span's facts, borrowed from its attributes. +pub(super) struct Facts<'a> { + text: [&'a str; TEXT_KEYS.len()], + text_rank: [u8; TEXT_KEYS.len()], + number: [Option; NUMBER_KEYS.len()], + number_rank: [u8; NUMBER_KEYS.len()], +} + +impl<'a> Facts<'a> { + pub(super) fn read(attrs: &'a [KeyValue]) -> Self { + let mut facts = Self { + text: [""; TEXT_KEYS.len()], + text_rank: [u8::MAX; TEXT_KEYS.len()], + number: [None; NUMBER_KEYS.len()], + number_rank: [u8::MAX; NUMBER_KEYS.len()], + }; + let table = &*KEY_TABLE; + for attr in attrs { + let Some(&found) = table.get(attr.key.len()).and_then(|keys| { + keys.iter().find(|(key, ..)| { + key.as_bytes().last() == attr.key.as_bytes().last() && *key == attr.key + }) + }) else { + continue; + }; + match found { + (_, Slot::Text(slot), rank) if rank < facts.text_rank[slot] => { + let value = value_str(attr); + if !value.is_empty() { + facts.text[slot] = value; + facts.text_rank[slot] = rank; + } + } + (_, Slot::Number(slot), rank) if rank < facts.number_rank[slot] => { + if let Some(value) = number(attr) { + facts.number[slot] = Some(value); + facts.number_rank[slot] = rank; + } + } + _ => {} + } + } + facts + } + + pub(super) fn number(&self, slot: usize) -> Option { + self.number[slot] + } + + pub(super) fn model(&self) -> &'a str { + self.text[MODEL] + } + + pub(super) fn tool_name(&self) -> &'a str { + self.text[TOOL_NAME] + } + + /// `gen_ai.operation.name`, else the OpenInference span kind translated. + pub(super) fn operation(&self) -> &'a str { + let op = self.text[OPERATION]; + if !op.is_empty() { + return op; + } + match self.text[SPAN_KIND] { + "LLM" => "chat", + "TOOL" => "execute_tool", + "AGENT" => "invoke_agent", + "EMBEDDING" => "embeddings", + "RETRIEVER" => "retrieval", + _ => "", + } + } +} + +#[expect( + clippy::cast_precision_loss, + reason = "token counts and costs stay far below 2^53" +)] +fn number(attr: &KeyValue) -> Option { + let value = match attr.value.as_ref()?.value.as_ref()? { + any_value::Value::IntValue(int) => *int as f64, + any_value::Value::DoubleValue(double) => *double, + any_value::Value::StringValue(text) => text.trim().parse().ok()?, + _ => return None, + }; + (value.is_finite() && value >= 0.0).then_some(value) +} + +/// Does `name` contain `needle` (lowercase ASCII), ignoring ASCII case? +pub(super) fn name_has(name: &str, needle: &str) -> bool { + name.as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +/// `text` cut to `max` characters. +fn truncate(text: &str, max: usize) -> &str { + text.char_indices() + .nth(max) + .map_or(text, |(end, _)| &text[..end]) +} + +/// Decide every fact of one stamped span, as the stamps to write on it. +pub(super) fn stamps(span: &Span, vendor: &str) -> Vec { + let failed_status = span + .status + .as_ref() + .is_some_and(|status| status.code == StatusCode::Error as i32); + let mut stamps = Vec::with_capacity(12); + let facts = Facts::read(&span.attributes); + let llm_call = usage::stamp(span, vendor, &facts, &mut stamps); + let tool_call = !llm_call && is_tool_call(&facts, &span.name); + let failed = failed_status + || !facts.text[ERROR_TYPE].is_empty() + || ["failed", "error"] + .iter() + .any(|status| facts.text[RESPONSE_STATUS].eq_ignore_ascii_case(status)); + let mut text = |key: &str, value: &str| { + if !value.is_empty() { + stamps.push(owned_string_attribute(key, value.to_owned())); + } + }; + text(MODEL_ATTR, facts.model()); + text(AGENT_NAME_ATTR, agent_name(&facts, vendor, &span.name)); + text(TOOL_NAME_ATTR, facts.tool_name()); + text(TOOL_CALL_ID_ATTR, facts.text[TOOL_CALL_ID]); + if llm_call { + text(RESPONSE_ID_ATTR, facts.text[RESPONSE_ID]); + } + let result = facts.text[TOOL_RESULT]; + if tool_call { + text( + TOOL_DESCRIPTION_ATTR, + truncate(facts.text[TOOL_DESCRIPTION], TOOL_DESCRIPTION_MAX), + ); + if failed { + text( + TOOL_ERROR_RESULT_ATTR, + truncate(result, TOOL_ERROR_RESULT_MAX), + ); + } + } + let paused = + tool_call && !failed && (result.is_empty() || result.contains(CONFIRMATION_REQUEST)); + for (key, holds) in [ + (TOOL_CALL_ATTR, tool_call), + (ERROR_ATTR, failed), + (TOOL_PAUSED_ATTR, paused), + ] { + if holds { + stamps.push(owned_string_attribute(key, "1".to_owned())); + } + } + stamps +} + +/// The agent that owns the span. OpenAI Agents' OpenInference instrumentor +/// names an agent only in `graph.node.id` on its AGENT span, where it equals +/// the span name ("Triage Agent"); the run's root AGENT span has no node id, +/// and agno's node id is a hash that never equals the span name. +fn agent_name<'a>(facts: &Facts<'a>, vendor: &str, span_name: &str) -> &'a str { + let node = facts.text[GRAPH_NODE_ID]; + match facts.text[AGENT_NAME] { + "" if matches!(vendor, "openai_agents_sdk" | "unknown:openinference") + && facts.text[SPAN_KIND] == "AGENT" + && node == span_name => + { + node + } + name => name, + } +} + +/// A tool call: the convention's tool operation, or, under an operation the +/// convention does not name, a tool name or a span name saying "tool". +fn is_tool_call(facts: &Facts, span_name: &str) -> bool { + let op = facts.operation(); + op == "execute_tool" + || (!usage::KNOWN_OPS.contains(&op) + && (!facts.tool_name().is_empty() || name_has(span_name, "tool"))) +} + +/// Mark a stamped tool call as failed after the fact: Claude Code records a +/// tool run's failure on a phase span that can arrive after its call's +/// (`claude_code::fold_tool_failures`). +pub(super) fn mark_tool_failed(span: &mut Span) { + span.attributes.retain(|attr| attr.key != TOOL_PAUSED_ATTR); + let has = |key: &str| span.attributes.iter().any(|attr| attr.key == key); + let result = (!has(TOOL_ERROR_RESULT_ATTR)) + .then(|| Facts::read(&span.attributes).text[TOOL_RESULT]) + .filter(|result| !result.is_empty()) + .map(|result| truncate(result, TOOL_ERROR_RESULT_MAX).to_owned()); + let error = !has(ERROR_ATTR); + if let Some(result) = result { + span.attributes + .push(owned_string_attribute(TOOL_ERROR_RESULT_ATTR, result)); + } + if error { + span.attributes + .push(owned_string_attribute(ERROR_ATTR, "1".to_owned())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_session::stamp_trace_request; + use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; + use opentelemetry_proto::tonic::common::v1::InstrumentationScope; + use opentelemetry_proto::tonic::resource::v1::Resource; + use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans, Status}; + + type Attrs<'a> = &'a [(&'a str, &'a str)]; + type Stamps = Vec<(String, String)>; + + fn span(name: &str, attrs: Attrs) -> Span { + Span { + name: name.to_owned(), + attributes: attrs + .iter() + .map(|(key, value)| owned_string_attribute(key, (*value).to_owned())) + .collect(), + ..Default::default() + } + } + + /// One scope's spans through the gateway's stamping pass: each span's + /// `maple_ai.*` stamps, less the vendor, session and usage ones. + fn stamps(scope: &str, spans: Vec) -> Vec { + let mut request = ExportTraceServiceRequest { + resource_spans: vec![ResourceSpans { + resource: Some(Resource::default()), + scope_spans: vec![ScopeSpans { + scope: Some(InstrumentationScope { + name: scope.to_owned(), + ..Default::default() + }), + spans, + ..Default::default() + }], + ..Default::default() + }], + }; + stamp_trace_request(&mut request); + request.resource_spans[0].scope_spans[0] + .spans + .iter() + .map(|span| { + span.attributes + .iter() + .filter(|attr| { + attr.key.starts_with("maple_ai.") + && !attr.key.starts_with("maple_ai.vendor.") + && !attr.key.starts_with("maple_ai.usage.") + && attr.key != "maple_ai.session.id" + }) + .map(|attr| (attr.key.clone(), value_str(attr).to_owned())) + .collect() + }) + .collect() + } + + fn pairs(expected: Attrs) -> Stamps { + expected + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() + } + + fn has(stamps: &Stamps, key: &str) -> bool { + stamps.iter().any(|(stamp, _)| stamp == key) + } + + /// `captures/docs_vercel-ai-sdk_b`: the agent, its model call and a tool + /// call. + #[test] + fn vercel_v7_agent_call_and_tool() { + let got = stamps( + "gen_ai", + vec![ + span( + "invoke_agent support", + &[ + ("gen_ai.operation.name", "invoke_agent"), + ("gen_ai.agent.name", "support"), + ("gen_ai.request.model", "openai/gpt-4o-mini"), + ], + ), + span( + "chat openai/gpt-4o-mini", + &[ + ("gen_ai.operation.name", "chat"), + ("gen_ai.request.model", "openai/gpt-4o-mini"), + ("gen_ai.response.model", "openai/gpt-4o-mini-2024-07-18"), + ("gen_ai.response.id", "gen-1"), + ], + ), + span( + "execute_tool search_docs", + &[ + ("gen_ai.operation.name", "execute_tool"), + ("gen_ai.tool.name", "search_docs"), + ("gen_ai.tool.call.id", "call_1"), + ("gen_ai.tool.description", "Search the docs."), + ("gen_ai.tool.call.result", "{\"hits\":3}"), + ], + ), + ], + ); + assert_eq!( + got, + [ + pairs(&[ + ("maple_ai.llm_call", "0"), + (MODEL_ATTR, "openai/gpt-4o-mini"), + (AGENT_NAME_ATTR, "support"), + ]), + pairs(&[ + ("maple_ai.llm_call", "1"), + (MODEL_ATTR, "openai/gpt-4o-mini-2024-07-18"), + (RESPONSE_ID_ATTR, "gen-1"), + ]), + pairs(&[ + ("maple_ai.llm_call", "0"), + (TOOL_NAME_ATTR, "search_docs"), + (TOOL_CALL_ID_ATTR, "call_1"), + (TOOL_DESCRIPTION_ATTR, "Search the docs."), + (TOOL_CALL_ATTR, "1"), + ]), + ] + ); + } + + /// The legacy `ai` scope names no operation: its tool call is found by + /// name, its agent by the telemetry function id. + #[test] + fn vercel_legacy_dialect_keys() { + let got = stamps( + "ai", + vec![ + span( + "ai.generateText", + &[ + ("ai.telemetry.functionId", "support"), + ("ai.model.id", "gpt-5"), + ], + ), + span( + "ai.toolCall", + &[ + ("ai.toolCall.name", "search_docs"), + ("ai.toolCall.id", "call_2"), + ("ai.toolCall.result", "[]"), + ], + ), + ], + ); + assert_eq!( + got, + [ + pairs(&[ + ("maple_ai.llm_call", "0"), + (MODEL_ATTR, "gpt-5"), + (AGENT_NAME_ATTR, "support"), + ]), + pairs(&[ + ("maple_ai.llm_call", "0"), + (TOOL_NAME_ATTR, "search_docs"), + (TOOL_CALL_ID_ATTR, "call_2"), + (TOOL_CALL_ATTR, "1"), + ]), + ] + ); + } + + /// A failure by status, by `error.type` and by response status; a failed + /// tool call carries its result, cut, and is no paused copy. + #[test] + fn failures_and_the_failed_tool_result() { + let mut errored = span("chat gpt-5", &[("gen_ai.operation.name", "chat")]); + errored.status = Some(Status { + code: StatusCode::Error as i32, + ..Default::default() + }); + let long = "x".repeat(TOOL_ERROR_RESULT_MAX + 50); + let got = stamps( + "support-agent", + vec![ + errored, + span( + "chat gpt-5", + &[ + ("gen_ai.operation.name", "chat"), + ("gen_ai.response.status", "Failed"), + ], + ), + span( + "execute_tool fetch", + &[ + ("gen_ai.operation.name", "execute_tool"), + ("gen_ai.tool.name", "fetch"), + ("error.type", "TimeoutError"), + ("gen_ai.tool.call.result", &long), + ], + ), + span( + "execute_tool fetch", + &[ + ("gen_ai.operation.name", "execute_tool"), + ("error.type", ""), + ], + ), + ], + ); + let failed_call = pairs(&[("maple_ai.llm_call", "1"), (ERROR_ATTR, "1")]); + assert_eq!(got[0], failed_call); + assert_eq!(got[1], failed_call); + assert_eq!( + got[2], + pairs(&[ + ("maple_ai.llm_call", "0"), + (TOOL_NAME_ATTR, "fetch"), + (TOOL_ERROR_RESULT_ATTR, &long[..TOOL_ERROR_RESULT_MAX]), + (TOOL_CALL_ATTR, "1"), + (ERROR_ATTR, "1"), + ]) + ); + // An empty `error.type` is no failure; a call with no result is a + // paused copy. + assert_eq!( + got[3], + pairs(&[ + ("maple_ai.llm_call", "0"), + (TOOL_CALL_ATTR, "1"), + (TOOL_PAUSED_ATTR, "1"), + ]) + ); + } + + /// Google ADK's human-in-the-loop capture: the paused copy's result is the + /// confirmation request; the approved run under the same id is a call. + #[test] + fn a_confirmation_request_is_a_paused_copy() { + let tool = |result: &str| { + span( + "execute_tool delete_file", + &[ + ("gen_ai.operation.name", "execute_tool"), + ("gen_ai.tool.name", "delete_file"), + ("gen_ai.tool.call.id", "adk-1"), + ("gen_ai.tool.call.result", result), + ], + ) + }; + let got = stamps( + "gcp.vertex.agent", + vec![ + tool("{\"error\": \"This tool call requires confirmation, please approve or reject.\"}"), + tool("{\"deleted\": true}"), + ], + ); + assert!(has(&got[0], TOOL_PAUSED_ATTR)); + assert!(!has(&got[1], TOOL_PAUSED_ATTR)); + } + + /// PROD `cs-demo-004` (OpenAI Agents TS through OpenInference): an agent + /// is named only by `graph.node.id` on its AGENT span, equal to the span + /// name; the run's root AGENT span has no node id. + #[test] + fn openai_agents_name_the_agent_by_its_graph_node() { + let agent = |name: &str, node: Option<&str>| { + let mut attrs = vec![("openinference.span.kind", "AGENT")]; + attrs.extend(node.map(|node| ("graph.node.id", node))); + span(name, &attrs) + }; + let got = stamps( + "@arizeai/openinference-instrumentation-openai-agents", + vec![ + agent("Customer service", None), + agent("Triage Agent", Some("Triage Agent")), + span( + "search_faq", + &[ + ("openinference.span.kind", "TOOL"), + ("tool.name", "search_faq"), + ("graph.node.id", "search_faq"), + ], + ), + ], + ); + assert_eq!(got[0], pairs(&[("maple_ai.llm_call", "0")])); + assert_eq!( + got[1], + pairs(&[ + ("maple_ai.llm_call", "0"), + (AGENT_NAME_ATTR, "Triage Agent") + ]) + ); + assert!(!has(&got[2], AGENT_NAME_ATTR) && has(&got[2], TOOL_CALL_ATTR)); + // agno's node id is a hash: never the span name, never an agent. + let agno = stamps( + "openinference.instrumentation.agno", + vec![agent("Agent.run", Some("5f2c1e0a"))], + ); + assert_eq!(agno[0], pairs(&[("maple_ai.llm_call", "0")])); + } + + /// Outside the convention's operations a tool name or a span name saying + /// "tool" makes a tool call; a memory operation never does. + #[test] + fn tool_calls_by_name_under_unknown_operations() { + let got = stamps( + "support-agent", + vec![ + span("run_tools", &[("gen_ai.operation.name", "workflow_step")]), + span( + "search_memory notes", + &[ + ("gen_ai.operation.name", "search_memory"), + ("gen_ai.tool.name", "notes"), + ], + ), + ], + ); + assert!(has(&got[0], TOOL_CALL_ATTR)); + assert!(!has(&got[1], TOOL_CALL_ATTR)); + } + + #[test] + fn a_long_description_is_cut() { + let long = "d".repeat(TOOL_DESCRIPTION_MAX + 1); + let got = stamps( + "support-agent", + vec![span( + "execute_tool fetch", + &[ + ("gen_ai.operation.name", "execute_tool"), + ("gen_ai.tool.description", &long), + ("gen_ai.tool.call.result", "ok"), + ], + )], + ); + let cut = ( + TOOL_DESCRIPTION_ATTR.to_owned(), + long[..TOOL_DESCRIPTION_MAX].to_owned(), + ); + assert!(got[0].contains(&cut)); + } + + #[test] + fn every_key_reads_one_fact() { + let mut keys: Vec<&str> = KEY_TABLE.iter().flatten().map(|(key, ..)| *key).collect(); + let count = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), count, "a key in two lists"); + } + + #[test] + fn truncation_counts_characters() { + assert_eq!(truncate("héllo", 2), "hé"); + assert_eq!(truncate("héllo", 9), "héllo"); + } + + #[test] + fn name_has_ignores_case() { + assert!(name_has("ai.toolCall", "tool")); + assert!(name_has("ChatOpenAI", "chat")); + assert!(!name_has("to", "tool")); + } +} diff --git a/apps/ingest/src/ai_session/usage.rs b/apps/ingest/src/ai_session/usage.rs index 38bfba2a1d..cc9fcd5566 100644 --- a/apps/ingest/src/ai_session/usage.rs +++ b/apps/ingest/src/ai_session/usage.rs @@ -31,11 +31,12 @@ //! `gen_ai.usage.input_tokens` as inclusive, so writing the uncached figure //! there would misreport the span to anyone reading it raw. -use opentelemetry_proto::tonic::common::v1::{any_value, KeyValue}; +use opentelemetry_proto::tonic::common::v1::KeyValue; use opentelemetry_proto::tonic::trace::v1::span::SpanKind; use opentelemetry_proto::tonic::trace::v1::Span; -use super::{owned_string_attribute, value_str}; +use super::facts::{self, name_has, Facts}; +use super::owned_string_attribute; const INPUT_TOKENS_ATTR: &str = "maple_ai.usage.input_tokens"; const CACHE_READ_TOKENS_ATTR: &str = "maple_ai.usage.cache_read_tokens"; @@ -50,14 +51,14 @@ const LLM_CALL_ATTR: &str = "maple_ai.llm_call"; // Vercel AI SDK and OpenInference dialects. Read for every vendor, so an // emitter that dual-writes two dialects is read the same way whoever it is. -const INPUT_KEYS: &[&str] = &[ +pub(super) const INPUT_KEYS: &[&str] = &[ "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "ai.usage.inputTokens", "ai.usage.promptTokens", "llm.token_count.prompt", ]; -const CACHE_READ_KEYS: &[&str] = &[ +pub(super) const CACHE_READ_KEYS: &[&str] = &[ "gen_ai.usage.cache_read.input_tokens", // OpenRouter Broadcast. "gen_ai.usage.input_tokens.cached", @@ -67,7 +68,7 @@ const CACHE_READ_KEYS: &[&str] = &[ "ai.usage.inputTokenDetails.cacheReadTokens", "llm.token_count.prompt_details.cache_read", ]; -const CACHE_WRITE_KEYS: &[&str] = &[ +pub(super) const CACHE_WRITE_KEYS: &[&str] = &[ "gen_ai.usage.cache_creation.input_tokens", "gen_ai.usage.cache_write.input_tokens", "gen_ai.usage.input_tokens.cache_write", @@ -75,14 +76,14 @@ const CACHE_WRITE_KEYS: &[&str] = &[ "ai.usage.inputTokenDetails.cacheWriteTokens", "llm.token_count.prompt_details.cache_write", ]; -const OUTPUT_KEYS: &[&str] = &[ +pub(super) const OUTPUT_KEYS: &[&str] = &[ "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens", "ai.usage.outputTokens", "ai.usage.completionTokens", "llm.token_count.completion", ]; -const REASONING_KEYS: &[&str] = &[ +pub(super) const REASONING_KEYS: &[&str] = &[ "gen_ai.usage.reasoning.output_tokens", "gen_ai.usage.output_tokens.reasoning", // Mastra. @@ -95,9 +96,9 @@ const REASONING_KEYS: &[&str] = &[ ]; /// The Vercel AI SDK reports the disjoint figures itself; they win over any /// arithmetic on the containing ones. -const UNCACHED_INPUT_KEYS: &[&str] = &["ai.usage.inputTokenDetails.noCacheTokens"]; -const VISIBLE_OUTPUT_KEYS: &[&str] = &["ai.usage.outputTokenDetails.textTokens"]; -const COST_KEYS: &[&str] = &[ +pub(super) const UNCACHED_INPUT_KEYS: &[&str] = &["ai.usage.inputTokenDetails.noCacheTokens"]; +pub(super) const VISIBLE_OUTPUT_KEYS: &[&str] = &["ai.usage.outputTokenDetails.textTokens"]; +pub(super) const COST_KEYS: &[&str] = &[ "gen_ai.usage.cost", "gen_ai.usage.total_cost", "llm.cost.total", @@ -107,27 +108,15 @@ const COST_KEYS: &[&str] = &[ "openrouter.cost", ]; -/// The model a call ran on, as `GENAI_MODEL_KEYS` in -/// `packages/domain/src/tinybird/gen-ai-columns.ts` reads it. -const MODEL_KEYS: &[&str] = &[ - "gen_ai.response.model", - "gen_ai.request.model", - "ai.response.model", - "ai.model.id", - "llm.model_name", -]; -const TOOL_NAME_KEYS: &[&str] = &["gen_ai.tool.name", "ai.toolCall.name", "tool.name"]; - const INFERENCE_OPS: [&str; 4] = [ "chat", "generate_content", "text_completion", "fetch_response", ]; -/// Every operation the convention names, as `KNOWN_OPS` in -/// `packages/domain/src/tinybird/gen-ai-columns.ts` lists them, memory-store -/// operations included: none of them is a model call by its span name. -const KNOWN_OPS: [&str; 19] = [ +/// Every operation the convention names, memory-store operations included: +/// none of them is a model call or a tool call by its span name. +pub(super) const KNOWN_OPS: [&str; 19] = [ "chat", "generate_content", "text_completion", @@ -153,19 +142,17 @@ const KNOWN_OPS: [&str; 19] = [ const BEDROCK_REGION_PREFIXES: [&str; 4] = ["us.", "eu.", "apac.", "global."]; /// Mark whether `span` is the model call, and stamp its usage buckets if it -/// is. -pub(super) fn stamp(span: &mut Span, vendor: &str) { - let call = is_model_call(vendor, span); - span.attributes.push(owned_string_attribute( +/// is. Returns whether it is. +pub(super) fn stamp(span: &Span, vendor: &str, facts: &Facts, out: &mut Vec) -> bool { + let call = is_model_call(vendor, span, facts); + out.push(owned_string_attribute( LLM_CALL_ATTR, if call { "1" } else { "0" }.to_owned(), )); if !call { - return; + return false; } - let attrs = &span.attributes; - let usage = Usage::read(attrs, input_excludes_cache(vendor, attrs)); - let cost = first_number(attrs, COST_KEYS).filter(|cost| *cost > 0.0); + let usage = Usage::read(facts, input_excludes_cache(vendor, facts.model())); let tokens = [ (INPUT_TOKENS_ATTR, usage.input), (CACHE_READ_TOKENS_ATTR, usage.cache_read), @@ -173,23 +160,23 @@ pub(super) fn stamp(span: &mut Span, vendor: &str) { (OUTPUT_TOKENS_ATTR, usage.output), (REASONING_TOKENS_ATTR, usage.reasoning), ]; - span.attributes.extend( + out.extend( tokens .into_iter() .filter(|(_, count)| *count > 0) .map(|(key, count)| owned_string_attribute(key, count.to_string())), ); - if let Some(cost) = cost { - span.attributes - .push(owned_string_attribute(COST_ATTR, cost.to_string())); + if let Some(cost) = facts.number(facts::COST).filter(|cost| *cost > 0.0) { + out.push(owned_string_attribute(COST_ATTR, cost.to_string())); } + true } /// Is this span the model call itself, rather than an agent, step or workflow /// wrapper that repeats its calls' usage? -fn is_model_call(vendor: &str, span: &Span) -> bool { - let (span_name, attrs) = (span.name.as_str(), span.attributes.as_slice()); - let op = operation(attrs); +fn is_model_call(vendor: &str, span: &Span, facts: &Facts) -> bool { + let span_name = span.name.as_str(); + let op = facts.operation(); match vendor { // `call_llm` wraps its `generate_content` child with the same figures. "google_adk" if span_name == "call_llm" => false, @@ -208,45 +195,25 @@ fn is_model_call(vendor: &str, span: &Span) -> bool { _ => { vendor.starts_with("unknown:") && span.kind != SpanKind::Server as i32 - && named_like_a_model_call(op, span_name, attrs) + && named_like_a_model_call(op, span_name, facts) } } } -/// `gen_ai.operation.name`, else the OpenInference span kind translated, as -/// `genAiOperationExpr` reads it. -fn operation(attrs: &[KeyValue]) -> &str { - let op = first_text(attrs, &["gen_ai.operation.name"]); - if !op.is_empty() { - return op; - } - match first_text(attrs, &["openinference.span.kind"]) { - "LLM" => "chat", - "TOOL" => "execute_tool", - "AGENT" => "invoke_agent", - "EMBEDDING" => "embeddings", - "RETRIEVER" => "retrieval", - _ => "", - } -} - -/// The span-name fallback of `genAiIsLlmCallCond`, for a dialect Maple has no -/// vendor rules for: an op outside the convention, not a tool, not an agent or +/// The span-name fallback `classifyAiSpan` applies to a span ingested before +/// this stamp, for a dialect Maple has no vendor rules for: an op outside the convention, not a tool, not an agent or /// workflow, and a model named (or a name that says chat/completion). -fn named_like_a_model_call(op: &str, span_name: &str, attrs: &[KeyValue]) -> bool { +fn named_like_a_model_call(op: &str, span_name: &str, facts: &Facts) -> bool { if KNOWN_OPS.contains(&op) { return false; } - let name = span_name.to_ascii_lowercase(); - if !first_text(attrs, TOOL_NAME_KEYS).is_empty() || name.contains("tool") { + if !facts.tool_name().is_empty() || name_has(span_name, "tool") { return false; } - if name.contains("agent") || name.contains("workflow") { + if name_has(span_name, "agent") || name_has(span_name, "workflow") { return false; } - !first_text(attrs, MODEL_KEYS).is_empty() - || name.contains("chat") - || name.contains("completion") + !facts.model().is_empty() || name_has(span_name, "chat") || name_has(span_name, "completion") } /// Does the prompt figure exclude the cache buckets? Only where the emitter @@ -254,14 +221,14 @@ fn named_like_a_model_call(op: &str, span_name: &str, attrs: &[KeyValue]) -> boo /// `gen_ai.provider.name` cannot tell: it names the model's vendor, not the /// reporting convention, and these frameworks stamp values unrelated to the /// client (Strands `strands-agents`, ADK `gemini`, MAF `openai`). -fn input_excludes_cache(vendor: &str, attrs: &[KeyValue]) -> bool { +fn input_excludes_cache(vendor: &str, model: &str) -> bool { match vendor { // Claude Code reports the Messages API's own usage. "claude_agent_sdk" => true, // Their native Anthropic/Bedrock clients pass raw usage through; their // OpenAI, Gemini and LiteLLM clients report it inclusive. "strands" | "google_adk" | "agno" | "microsoft_agent_framework" => { - is_native_anthropic_or_bedrock_model(first_text(attrs, MODEL_KEYS)) + is_native_anthropic_or_bedrock_model(model) } _ => false, } @@ -289,28 +256,28 @@ struct Usage { } impl Usage { - fn read(attrs: &[KeyValue], input_excludes_cache: bool) -> Self { - let count = |keys: &[&str]| first_number(attrs, keys).map(tokens); - let prompt = count(INPUT_KEYS).unwrap_or(0); - let cache_read = count(CACHE_READ_KEYS).unwrap_or(0); - let cache_write = count(CACHE_WRITE_KEYS).unwrap_or(0); - let completion = count(OUTPUT_KEYS).unwrap_or(0); + fn read(facts: &Facts, input_excludes_cache: bool) -> Self { + let count = |slot| facts.number(slot).map(tokens); + let prompt = count(facts::INPUT).unwrap_or(0); + let cache_read = count(facts::CACHE_READ).unwrap_or(0); + let cache_write = count(facts::CACHE_WRITE).unwrap_or(0); + let completion = count(facts::OUTPUT).unwrap_or(0); // An inclusive prompt cannot be smaller than the cache it contains, so // a prompt that is must be a raw passthrough the vendor rule missed. let cache = cache_read + cache_write; let excludes_cache = input_excludes_cache || cache > prompt; // The completion is what the provider billed (`total_tokens` is prompt // + completion), so a reasoning figure larger than it is clamped. - let reasoning = count(REASONING_KEYS).unwrap_or(0).min(completion); + let reasoning = count(facts::REASONING).unwrap_or(0).min(completion); Self { - input: count(UNCACHED_INPUT_KEYS).unwrap_or(if excludes_cache { + input: count(facts::UNCACHED_INPUT).unwrap_or(if excludes_cache { prompt } else { prompt - cache }), cache_read, cache_write, - output: count(VISIBLE_OUTPUT_KEYS).unwrap_or(completion - reasoning), + output: count(facts::VISIBLE_OUTPUT).unwrap_or(completion - reasoning), reasoning, } } @@ -325,43 +292,6 @@ fn tokens(value: f64) -> u64 { value as u64 } -/// The first of `keys` whose value is a finite, non-negative number. -fn first_number(attrs: &[KeyValue], keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - attrs - .iter() - .filter(|attr| attr.key == *key) - .find_map(number) - }) -} - -#[expect( - clippy::cast_precision_loss, - reason = "token counts and costs stay far below 2^53" -)] -fn number(attr: &KeyValue) -> Option { - let value = match attr.value.as_ref()?.value.as_ref()? { - any_value::Value::IntValue(int) => *int as f64, - any_value::Value::DoubleValue(double) => *double, - any_value::Value::StringValue(text) => text.trim().parse().ok()?, - _ => return None, - }; - (value.is_finite() && value >= 0.0).then_some(value) -} - -/// The first non-empty string value among `keys`, else `""`. -fn first_text<'a>(attrs: &'a [KeyValue], keys: &[&str]) -> &'a str { - keys.iter() - .find_map(|key| { - attrs - .iter() - .filter(|attr| attr.key == *key) - .map(value_str) - .find(|text| !text.is_empty()) - }) - .unwrap_or("") -} - /// Each integration's usage spans as its instrumentation exports them: from /// the trace-capture recordings (`captures/`), from EU production spans /// (PROD), or, where no capture exercises the path, from the framework source @@ -369,9 +299,9 @@ fn first_text<'a>(attrs: &'a [KeyValue], keys: &[&str]) -> &'a str { #[cfg(test)] mod tests { use super::*; - use crate::ai_session::{stamp_trace_request, VENDOR_ID_ATTR}; + use crate::ai_session::{stamp_trace_request, value_str, VENDOR_ID_ATTR}; use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; - use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope}; + use opentelemetry_proto::tonic::common::v1::{any_value, AnyValue, InstrumentationScope}; use opentelemetry_proto::tonic::resource::v1::Resource; use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans}; @@ -429,12 +359,18 @@ mod tests { .iter() .map(|span| { let attrs = &span.attributes; - let bucket = |key| first_number(attrs, &[key]).map_or(0, tokens); + let text = |key| { + attrs + .iter() + .find(|attr| attr.key == key) + .map_or("", value_str) + }; + let bucket = |key| text(key).parse().unwrap_or(0); let has_buckets = attrs .iter() .any(|attr| attr.key.starts_with("maple_ai.usage.") && attr.key != COST_ATTR); Stamped { - vendor: first_text(attrs, &[VENDOR_ID_ATTR]).to_owned(), + vendor: text(VENDOR_ID_ATTR).to_owned(), llm_call: attrs .iter() .find(|attr| attr.key == LLM_CALL_ATTR) @@ -1658,7 +1594,7 @@ mod tests { owned_string_attribute("ai.usage.reasoningTokens", "10".to_owned()), ]; assert_eq!( - Usage::read(&attrs, false), + Usage::read(&Facts::read(&attrs), false), Usage { input: 120, cache_read: 0, From 9e69e5aca0625cc4fbe910b47624be7e6f9274cb Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 20:59:39 +0200 Subject: [PATCH 02/17] feat(agent-sessions): ai_trace_index_mv projects the gateway's maple_ai.* stamps Migration 0039 (local schema v26; both placeholders, renumbered at merge) recreates ai_trace_index_mv so Model, AgentName, ToolName, ResponseId, IsLlmCall, IsToolCall, IsError, the five token buckets, Tokens, Cost, ToolDescription and FailedToolCallResult each read the fact the ingest gateway stamped on the span. The operation lists, span-name needles, dialect key lists and per-provider usage conventions leave the view; what stays is generic: the environment, error.type, the status message and the failure fingerprint's redaction chain. --- apps/cli/src/server/local-schema-history.ts | 13 + apps/cli/src/server/local-schema-version.ts | 2 +- .../server/local-store-migrations/steps.ts | 26 + apps/cli/src/server/schema-identity.ts | 3 + apps/cli/src/server/schema/local-inserts.json | 2 +- .../src/server/schema/local-schema-v26.sql | 2135 +++++++++++++++++ apps/cli/src/server/schema/local-schema.sql | 38 +- apps/cli/test/local-store-migrations.test.ts | 16 +- apps/cli/test/native-local-store-migration.sh | 2 +- apps/ingest/src/clickhouse_insert_mappings.rs | 2 +- .../0039_ai_trace_index_gateway_stamps.ts | 40 + .../src/clickhouse/migrations/index.test.ts | 46 +- .../domain/src/clickhouse/migrations/index.ts | 2 + packages/domain/src/gen-ai.ts | 68 +- .../domain/src/generated/clickhouse-schema.ts | 4 +- .../generated/tinybird-project-manifest.ts | 4 +- packages/domain/src/tinybird/datasources.ts | 13 +- .../domain/src/tinybird/gen-ai-columns.ts | 575 +---- .../domain/src/tinybird/materializations.ts | 12 +- packages/query-engine/src/ch/tables.ts | 5 +- 20 files changed, 2454 insertions(+), 554 deletions(-) create mode 100644 apps/cli/src/server/schema/local-schema-v26.sql create mode 100644 packages/domain/src/clickhouse/migrations/0039_ai_trace_index_gateway_stamps.ts diff --git a/apps/cli/src/server/local-schema-history.ts b/apps/cli/src/server/local-schema-history.ts index 04f078d63f..03e6963a4f 100644 --- a/apps/cli/src/server/local-schema-history.ts +++ b/apps/cli/src/server/local-schema-history.ts @@ -314,6 +314,19 @@ export const LOCAL_SCHEMA_HISTORY: ReadonlyArray = Obje manifestDigest: "005ad815cff50e1c642dfc696cf423d7d9a3c7ee58b8cdcd4ad647bf35ab1297", projectRevision: "ed74788ef292834069e0ea6ee3b22d68fc604fb66cb54d2d551db67ce8d20b3a", }), + Object.freeze({ + // TODO(v26): what changed, whether any part is rewritten or any row + // moves, and what this edge does NOT backfill. + // + // projectRevision is carried forward deliberately: it is a hardcoded + // constant that no longer tracks the generator's header, and the identity + // this gate compares is the fingerprint/digest pair. + version: 26, + fingerprint: "203c87dde2b5aedc", + digest: "203c87dde2b5aedc28de3b2fd72829991b28fdf703bbb40ae72ebd835dbdb67c", + manifestDigest: "a3f69dea62db6610d63a6b2442f86d9071c91c286b5935e764700efc01fa7d7a", + projectRevision: "ed74788ef292834069e0ea6ee3b22d68fc604fb66cb54d2d551db67ce8d20b3a", + }), ] as const) /** Immutable SQLite control DDL identities, checked by clickhouse:schema:check. */ diff --git a/apps/cli/src/server/local-schema-version.ts b/apps/cli/src/server/local-schema-version.ts index e9cf58bf6e..34332377ac 100644 --- a/apps/cli/src/server/local-schema-version.ts +++ b/apps/cli/src/server/local-schema-version.ts @@ -1,7 +1,7 @@ // Increment this value for every structural change to the generated local // schema. The compatibility manifest and migration registry must be updated in // the same change before a new value can ship. -export const LOCAL_SCHEMA_VERSION = 25 as const +export const LOCAL_SCHEMA_VERSION = 26 as const /** SQLite eventing state has its own independent version sequence. */ export const LOCAL_CONTROL_SCHEMA_VERSION = 1 as const diff --git a/apps/cli/src/server/local-store-migrations/steps.ts b/apps/cli/src/server/local-store-migrations/steps.ts index 52d0d56fd7..3523bb7f46 100644 --- a/apps/cli/src/server/local-store-migrations/steps.ts +++ b/apps/cli/src/server/local-store-migrations/steps.ts @@ -1114,5 +1114,31 @@ export const LOCAL_STORE_STEPS: ReadonlyArray = [ }, ], }, + { + id: "local-0025-to-0026-ai-trace-index-gateway-stamps", + from: 25, + to: 26, + description: "Recreate ai_trace_index_mv as a projection of the ingest gateway's maple_ai.* stamps", + clonedBefore: "any DDL runs", + beforeBootstrap: [dropViews("ai_trace_index_mv")], + plan: [ + [ + "rebuild-ai-trace-index-view", + "Rebuild ai_trace_index_mv to project the maple_ai.* facts the ingest gateway stamps on each span", + ], + ], + verifies: "Verify the v26 physical schema and the retained raw telemetry counts", + dispositions: [ + AI_TRACE_INDEX_SOURCE, + { + name: "ai_trace_index", + classification: "derived", + disposition: "rebuild-within-retention-horizon", + guarantee: + "Existing rows are preserved untouched with the values the v25 view gave them; the rebuilt view fills spans materialized after the migration from the gateway's stamps, and the gap closes as the retention window rolls.", + ...AI_TRACE_INDEX_FORWARD, + }, + ], + }, // local-schema:bump appends the next step above this line. ] diff --git a/apps/cli/src/server/schema-identity.ts b/apps/cli/src/server/schema-identity.ts index 51da9a8fe4..fcec7de73a 100644 --- a/apps/cli/src/server/schema-identity.ts +++ b/apps/cli/src/server/schema-identity.ts @@ -24,6 +24,7 @@ import schemaV22Sql from "./schema/local-schema-v22.sql" with { type: "text" } import schemaV23Sql from "./schema/local-schema-v23.sql" with { type: "text" } import schemaV24Sql from "./schema/local-schema-v24.sql" with { type: "text" } import schemaV25Sql from "./schema/local-schema-v25.sql" with { type: "text" } +import schemaV26Sql from "./schema/local-schema-v26.sql" with { type: "text" } import { schemaDigest as digestSchema, schemaFingerprint as fingerprintSchema } from "./store-version" import { buildLocalSchemaManifest, type LocalSchemaManifest } from "./schema-manifest" import { LOCAL_SCHEMA_VERSION } from "./local-schema-version" @@ -93,6 +94,7 @@ const SNAPSHOT_SQL: ReadonlyArray = [ schemaV23Sql, schemaV24Sql, schemaV25Sql, + schemaV26Sql, ] export interface LocalSchemaSnapshot { @@ -175,6 +177,7 @@ export const LOCAL_SCHEMA_V22 = localSchemaIdentity(22) export const LOCAL_SCHEMA_V23 = localSchemaIdentity(23) export const LOCAL_SCHEMA_V24 = localSchemaIdentity(24) export const LOCAL_SCHEMA_V25 = localSchemaIdentity(25) +export const LOCAL_SCHEMA_V26 = localSchemaIdentity(26) export const CURRENT_LOCAL_SCHEMA: LocalSchemaIdentity = Object.freeze({ version: LOCAL_SCHEMA_VERSION, diff --git a/apps/cli/src/server/schema/local-inserts.json b/apps/cli/src/server/schema/local-inserts.json index ede98341a8..5161ba4aa1 100644 --- a/apps/cli/src/server/schema/local-inserts.json +++ b/apps/cli/src/server/schema/local-inserts.json @@ -1,5 +1,5 @@ { - "projectRevision": "92181b09631cc85a9bfc1ea57dc85000f3c6770b0799e5b357692f4634ce8042", + "projectRevision": "6f17e5994398bfddfebe6a99f13f097bbe822a95820cf6e0cb575082823d4f32", "orgPlaceholder": "__ORG__", "datasources": { "traces": { diff --git a/apps/cli/src/server/schema/local-schema-v26.sql b/apps/cli/src/server/schema/local-schema-v26.sql new file mode 100644 index 0000000000..97751b2ef3 --- /dev/null +++ b/apps/cli/src/server/schema/local-schema-v26.sql @@ -0,0 +1,2135 @@ +-- This file is generated by scripts/generate-clickhouse-schema-sql.ts +-- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. +-- projectRevision: 6f17e5994398bfddfebe6a99f13f097bbe822a95820cf6e0cb575082823d4f32 +-- localSchemaVersion: 25 + +CREATE TABLE IF NOT EXISTS ai_crawler_requests ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + ServiceName LowCardinality(String), + Crawler LowCardinality(String), + Host LowCardinality(String), + Path String, + HttpStatus UInt16 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS ai_trace_index ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SessionId String, + VendorId LowCardinality(String), + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + Model LowCardinality(String), + AgentName LowCardinality(String), + ToolName LowCardinality(String), + SpanId String, + ParentSpanId String, + Duration UInt64, + IsError UInt8, + IsLlmCall UInt8, + IsToolCall UInt8, + Tokens Float64, + Cost Float64, + ResponseId String, + VendorVersion LowCardinality(String), + InputTokens Float64, + CacheReadTokens Float64, + CacheWriteTokens Float64, + OutputTokens Float64, + ReasoningTokens Float64, + ErrorType LowCardinality(String), + StatusMessage String, + ToolDescription String, + FailedToolCallResult String, + ErrorFingerprint UInt64 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS alert_checks ( + OrgId LowCardinality(String), + RuleId String, + GroupKey String, + Timestamp DateTime64(3), + Status LowCardinality(String), + SignalType LowCardinality(String), + Comparator LowCardinality(String), + Threshold Float64, + ObservedValue Nullable(Float64), + SampleCount UInt32, + WindowMinutes UInt16, + WindowStart DateTime64(3), + WindowEnd DateTime64(3), + ConsecutiveBreaches UInt16, + ConsecutiveHealthy UInt16, + IncidentId Nullable(String), + IncidentTransition LowCardinality(String), + EvaluationDurationMs UInt32, + ErrorMessage Nullable(String), + ErrorCategory LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, RuleId, GroupKey, Timestamp) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS attribute_keys_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + AttributeKey LowCardinality(String), + AttributeScope LowCardinality(String), + UsageCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, AttributeScope, Hour, AttributeKey) +TTL Hour + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS attribute_values_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + AttributeKey LowCardinality(String), + AttributeValue String, + AttributeScope LowCardinality(String), + UsageCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, AttributeScope, AttributeKey, Hour, AttributeValue) +TTL Hour + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS audit_log ( + OrgId LowCardinality(String), + Id String, + OccurredAt DateTime64(3), + RecordedAt DateTime64(3), + ActorType LowCardinality(String), + UserId String, + ApiKeyId String, + ActorId String, + ActorLabel String, + AffectedUserId String, + Source LowCardinality(String), + Action LowCardinality(String), + Outcome LowCardinality(String), + DenialReason String, + ResourceType LowCardinality(String), + ResourceId String, + ChangedFields Array(String), + Changes String, + Metadata String, + RequestId String, + OriginIp String, + OriginCountry LowCardinality(String) +) +ENGINE = ReplacingMergeTree +PARTITION BY toYYYYMM(OccurredAt) +ORDER BY (OrgId, OccurredAt, Id) +TTL toDate(OccurredAt) + INTERVAL 2190 DAY; + +CREATE TABLE IF NOT EXISTS error_events ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String DEFAULT '__unset__', + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ExceptionType LowCardinality(String), + ExceptionMessage String, + ExceptionStacktrace String, + TopFrame String, + FingerprintHash UInt64, + StatusMessage String, + Duration UInt64, + ErrorLabel String, + ServiceVersion LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, FingerprintHash, Timestamp) +TTL Timestamp + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS error_events_by_time ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String DEFAULT '__unset__', + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ExceptionType LowCardinality(String), + ExceptionMessage String, + ExceptionStacktrace String, + TopFrame String, + FingerprintHash UInt64, + StatusMessage String, + Duration UInt64, + ErrorLabel String, + ServiceVersion LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, FingerprintHash) +TTL Timestamp + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS error_fingerprints_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + FingerprintHash UInt64, + ServiceName SimpleAggregateFunction(anyLast, String), + ExceptionType SimpleAggregateFunction(anyLast, String), + ExceptionMessage SimpleAggregateFunction(anyLast, String), + ErrorLabel SimpleAggregateFunction(anyLast, String), + TopFrame SimpleAggregateFunction(anyLast, String), + OccurrenceCount SimpleAggregateFunction(sum, UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + LastSeen SimpleAggregateFunction(max, DateTime), + ServiceVersions SimpleAggregateFunction(groupUniqArrayArray, Array(String)) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Minute) +ORDER BY (OrgId, Minute, FingerprintHash) +TTL Minute + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS identity_links ( + OrgId LowCardinality(String), + VisitorId String, + UserId String, + FirstSeen SimpleAggregateFunction(min, DateTime64(9)) +) +ENGINE = AggregatingMergeTree +PARTITION BY tuple() +ORDER BY (OrgId, VisitorId, UserId) +TTL toDate(FirstSeen) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS logs ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TimestampTime DateTime, + TraceId String, + SpanId String, + TraceFlags UInt8, + SeverityText LowCardinality(String), + SeverityNumber UInt8, + ServiceName LowCardinality(String), + Body String, + ResourceSchemaUrl String, + ResourceAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + LogAttributes Map(LowCardinality(String), String), + ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)), + ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)), + LogAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(LogAttributes), mapValues(LogAttributes)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_attr_keys mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_attr_vals mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_lower_body lower(Body) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8 +) +ENGINE = MergeTree +PARTITION BY toDate(TimestampTime) +ORDER BY (OrgId, toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp) +TTL toDate(TimestampTime) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS logs_aggregates_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SeverityText LowCardinality(String), + DeploymentEnv LowCardinality(String), + Count SimpleAggregateFunction(sum, UInt64), + SizeBytes SimpleAggregateFunction(sum, UInt64), + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SeverityText, DeploymentEnv, ServiceNamespace) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS metric_catalog ( + OrgId LowCardinality(String), + Hour DateTime, + MetricType LowCardinality(String), + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription SimpleAggregateFunction(anyLast, String), + MetricUnit SimpleAggregateFunction(anyLast, String), + IsMonotonic SimpleAggregateFunction(anyLast, UInt8), + DataPointCount SimpleAggregateFunction(sum, UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + LastSeen SimpleAggregateFunction(max, DateTime) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, MetricType, ServiceName, MetricName, Hour) +TTL Hour + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_exponential_histogram ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Count UInt64, + Sum Float64, + Scale Int32, + ZeroCount UInt64, + PositiveOffset Int32, + PositiveBucketCounts Array(UInt64), + NegativeOffset Int32, + NegativeBucketCounts Array(UInt64), + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + Flags UInt32, + Min Nullable(Float64), + Max Nullable(Float64), + AggregationTemporality Int32 +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_gauge ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Value Float64, + Flags UInt32, + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)) +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_histogram ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Count UInt64, + Sum Float64, + BucketCounts Array(UInt64), + ExplicitBounds Array(Float64), + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + Flags UInt32, + Min Nullable(Float64), + Max Nullable(Float64), + AggregationTemporality Int32 +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_sum ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Value Float64, + Flags UInt32, + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + AggregationTemporality Int32, + IsMonotonic Bool +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS product_events ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + Source LowCardinality(String) DEFAULT 'browser', + SessionId String DEFAULT '', + Seq UInt32 DEFAULT 0, + VisitorId String DEFAULT '', + UserId String DEFAULT '', + GroupId String DEFAULT '', + Kind LowCardinality(String), + EventName String, + Host LowCardinality(String) DEFAULT '', + PagePath String DEFAULT '', + Url String DEFAULT '', + ServiceName LowCardinality(String) DEFAULT '', + Attributes Map(String, String) DEFAULT map(), + TraceId String DEFAULT '', + SpanId String DEFAULT '', + INDEX idx_event_name EventName TYPE set(64) GRANULARITY 4, + INDEX idx_user_id UserId TYPE bloom_filter GRANULARITY 4, + INDEX idx_trace_id TraceId TYPE bloom_filter GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, VisitorId, SessionId, Seq) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_address_resolutions_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + ParentServerAddress String, + ResolvedTargetService LowCardinality(String), + DeploymentEnv LowCardinality(String) +) +ENGINE = ReplacingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, SourceService, ParentServerAddress, ResolvedTargetService) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_external_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + TargetType LowCardinality(String), + TargetSystem LowCardinality(String), + TargetName String, + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampleRateSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, TargetType, TargetSystem, TargetName) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_children ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + ParentSpanId String, + ServiceName LowCardinality(String), + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, TraceId, ParentSpanId, Timestamp) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_map_db_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DbSystem LowCardinality(String), + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampledSpanCount SimpleAggregateFunction(sum, UInt64), + UnsampledSpanCount SimpleAggregateFunction(sum, UInt64), + SampleRateSum SimpleAggregateFunction(sum, Float64), + DbNamespace LowCardinality(String), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, DbSystem, DbNamespace) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_db_query_shapes_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DbSystem LowCardinality(String), + DeploymentEnv LowCardinality(String), + QueryKey String, + QueryLabel SimpleAggregateFunction(any, String), + SampleStatement SimpleAggregateFunction(any, String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedCount SimpleAggregateFunction(sum, Float64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + WeightedDurationSumMs SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32), + DbNamespace LowCardinality(String) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, DbSystem, DbNamespace, QueryKey) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + TargetService String, + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampledSpanCount SimpleAggregateFunction(sum, UInt64), + UnsampledSpanCount SimpleAggregateFunction(sum, UInt64), + SampleRateSum SimpleAggregateFunction(sum, Float64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, SourceService, TargetService) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_edges_hourly_ingest ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + TargetService String, + DeploymentEnv LowCardinality(String), + CallCount UInt64, + ErrorCount UInt64, + DurationSumMs Float64, + MaxDurationMs Float64, + SampledSpanCount UInt64, + UnsampledSpanCount UInt64, + SampleRateSum Float64 +) +ENGINE = Null; + +CREATE TABLE IF NOT EXISTS service_map_spans ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String, + ServiceName LowCardinality(String), + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, TraceId, SpanId, Timestamp) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_operations_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + SpanName String, + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64), + ClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + ServerSpanCount SimpleAggregateFunction(sum, UInt64), + RoutedSpanCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Hour) +ORDER BY (OrgId, ServiceName, DeploymentEnv, Hour, SpanName) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_operations_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + SpanName String, + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64), + ClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + ServerSpanCount SimpleAggregateFunction(sum, UInt64), + RoutedSpanCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, ServiceName, DeploymentEnv, Minute, SpanName) +TTL toDate(Minute) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + CommitSha LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + ApdexSatisfiedCount SimpleAggregateFunction(sum, UInt64), + ApdexToleratingCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Hour) +ORDER BY (OrgId, ServiceName, Hour, DeploymentEnv, ServiceNamespace, CommitSha) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + CommitSha LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + ApdexSatisfiedCount SimpleAggregateFunction(sum, UInt64), + ApdexToleratingCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, ServiceName, Minute, DeploymentEnv, ServiceNamespace, CommitSha) +TTL toDate(Minute) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_spans ( + OrgId LowCardinality(String), + Timestamp DateTime, + ServiceName LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String), + CommitSha LowCardinality(String), + SampleRate Float64 DEFAULT 1, + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, ServiceName, Timestamp) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_platforms_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + K8sCluster SimpleAggregateFunction(max, String), + K8sPodName SimpleAggregateFunction(max, String), + K8sDeploymentName SimpleAggregateFunction(max, String), + K8sStatefulSetName SimpleAggregateFunction(max, String), + K8sDaemonSetName SimpleAggregateFunction(max, String), + K8sNamespaceName SimpleAggregateFunction(max, String), + CloudPlatform SimpleAggregateFunction(max, String), + CloudProvider SimpleAggregateFunction(max, String), + FaasName SimpleAggregateFunction(max, String), + MapleSdkType SimpleAggregateFunction(max, String), + ProcessRuntimeName SimpleAggregateFunction(max, String), + SpanCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, DeploymentEnv) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_usage ( + OrgId LowCardinality(String), + ServiceName LowCardinality(String), + Hour DateTime, + LogCount UInt64, + LogSizeBytes UInt64, + TraceCount UInt64, + TraceSizeBytes UInt64, + SumMetricCount UInt64, + SumMetricSizeBytes UInt64, + GaugeMetricCount UInt64, + GaugeMetricSizeBytes UInt64, + HistogramMetricCount UInt64, + HistogramMetricSizeBytes UInt64, + ExpHistogramMetricCount UInt64, + ExpHistogramMetricSizeBytes UInt64 +) +ENGINE = SummingMergeTree +ORDER BY (OrgId, ServiceName, Hour) +TTL Hour + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS session_events ( + OrgId LowCardinality(String), + SessionId String, + Timestamp DateTime64(9), + Seq UInt32 DEFAULT 0, + Type LowCardinality(String), + Url String DEFAULT '', + TraceId String DEFAULT '', + Level LowCardinality(String) DEFAULT '', + Message String DEFAULT '', + TargetSelector String DEFAULT '', + TargetText String DEFAULT '', + NetMethod LowCardinality(String) DEFAULT '', + NetUrl String DEFAULT '', + NetStatus UInt16 DEFAULT 0, + NetDurationMs UInt32 DEFAULT 0, + ErrorStack String DEFAULT '', + Attributes Map(String, String), + VisitorId String DEFAULT '', + UserId String DEFAULT '', + GroupId String DEFAULT '', + INDEX idx_type Type TYPE set(16) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, SessionId, Timestamp, Seq) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS session_replay_events ( + OrgId LowCardinality(String), + SessionId String, + ChunkSeq UInt32, + Timestamp DateTime64(9), + DurationMs UInt32 DEFAULT 0, + EventCount UInt32 DEFAULT 0, + ByteSize UInt32 DEFAULT 0, + Events String, + IsCheckpoint UInt8 DEFAULT 0 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, SessionId, ChunkSeq) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS session_replays ( + OrgId LowCardinality(String), + SessionId String, + StartTime DateTime64(9), + EndTime Nullable(DateTime64(9)), + DurationMs Nullable(UInt32), + Status LowCardinality(String), + UserId String, + UrlInitial String, + UserAgent String, + BrowserName LowCardinality(String), + OsName LowCardinality(String), + DeviceType LowCardinality(String), + Country LowCardinality(String) DEFAULT '', + ServiceName LowCardinality(String), + PageViews UInt32 DEFAULT 0, + ClickCount UInt32 DEFAULT 0, + ErrorCount UInt32 DEFAULT 0, + TraceIds Array(String) DEFAULT [], + ResourceAttributes Map(LowCardinality(String), String), + Version UInt32, + VisitorId String DEFAULT '', + VisitorIsNew UInt8 DEFAULT 0, + UserEmail String DEFAULT '', + UserName String DEFAULT '', + GroupId String DEFAULT '', + GroupName String DEFAULT '', + UserTraits Map(String, String) DEFAULT map(), + Referrer String DEFAULT '', + ReferrerHost LowCardinality(String) DEFAULT '', + UtmSource LowCardinality(String) DEFAULT '', + UtmMedium LowCardinality(String) DEFAULT '', + UtmCampaign LowCardinality(String) DEFAULT '', + UtmTerm String DEFAULT '', + UtmContent String DEFAULT '', + Host LowCardinality(String) DEFAULT '', + EntryPath String DEFAULT '', + ExitPath String DEFAULT '', + Language LowCardinality(String) DEFAULT '', + LastActivityAt Nullable(DateTime64(9)) +) +ENGINE = ReplacingMergeTree +PARTITION BY toDate(StartTime) +ORDER BY (OrgId, SessionId) +TTL toDate(StartTime) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS span_metrics_calls_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + SpanKind LowCardinality(String), + AttrFingerprint UInt64, + ResourceFingerprint UInt64, + StartTimeUnix DateTime64(9), + LastValue AggregateFunction(argMax, Float64, DateTime64(9)) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, MetricName, SpanKind, AttrFingerprint, ResourceFingerprint, StartTimeUnix) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS trace_detail_spans ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SpanId String, + ParentSpanId String, + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + ServiceName LowCardinality(String), + Duration UInt64 DEFAULT 0, + StatusCode LowCardinality(String), + StatusMessage String, + SpanAttributes Map(LowCardinality(String), String), + ResourceAttributes Map(LowCardinality(String), String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, TraceId, SpanId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS trace_facets_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SpanName String, + HttpMethod LowCardinality(String), + HttpStatusCode LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + HasError UInt8, + TraceCount SimpleAggregateFunction(sum, UInt64), + DurationMin SimpleAggregateFunction(min, UInt64), + DurationMax SimpleAggregateFunction(max, UInt64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SpanName, HttpMethod, HttpStatusCode, DeploymentEnv, ServiceNamespace, HasError) +TTL Hour + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS trace_list_mv ( + OrgId LowCardinality(String), + TraceId String, + Timestamp DateTime, + ServiceName LowCardinality(String), + SpanName String, + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + HttpMethod LowCardinality(String), + HttpRoute String, + HttpStatusCode LowCardinality(String), + DeploymentEnv LowCardinality(String), + HasError UInt8, + TraceState String, + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL Timestamp + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS traces ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SpanId String, + ParentSpanId String, + TraceState String, + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + ServiceName LowCardinality(String), + ResourceSchemaUrl String, + ResourceAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + Duration UInt64 DEFAULT 0, + StatusCode LowCardinality(String), + StatusMessage String, + SpanAttributes Map(LowCardinality(String), String), + EventsTimestamp Array(DateTime64(9)), + EventsName Array(LowCardinality(String)), + EventsAttributes Array(Map(LowCardinality(String), String)), + LinksTraceId Array(String), + LinksSpanId Array(String), + LinksTraceState Array(String), + LinksAttributes Array(Map(LowCardinality(String), String)), + SampleRate Float64 DEFAULT multiIf(SpanAttributes['SampleRate'] != '' AND toFloat64OrZero(SpanAttributes['SampleRate']) >= 1.0, toFloat64OrZero(SpanAttributes['SampleRate']), match(TraceState, 'th:[0-9a-f]+'), 1.0 / greatest(1.0 - reinterpretAsUInt64(reverse(unhex(rightPad(extract(TraceState, 'th:([0-9a-f]+)'), 16, '0')))) / pow(2.0, 64), 0.0001), 1.0), + IsEntryPoint UInt8 DEFAULT if(SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '', 1, 0), + ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)), + ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)), + SpanAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(SpanAttributes), mapValues(SpanAttributes)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_keys mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_vals mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, ServiceName, SpanName, toDateTime(Timestamp)) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS traces_aggregates_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + StatusCode LowCardinality(String), + IsEntryPoint UInt8, + DeploymentEnv LowCardinality(String), + WeightedCount SimpleAggregateFunction(sum, Float64), + WeightedDurationSum SimpleAggregateFunction(sum, Float64), + WeightedErrorCount SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95, 0.99), UInt64, UInt32), + DurationMin SimpleAggregateFunction(min, UInt64), + DurationMax SimpleAggregateFunction(max, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS ai_crawler_requests_mv TO ai_crawler_requests AS +SELECT + OrgId, + Timestamp, + TraceId, + ServiceName, + arrayElement(['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'Meta-ExternalAgent', 'Meta-WebIndexer', 'Meta-ExternalFetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai'], multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'meta-externalagent', 'meta-webindexer', 'meta-externalfetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai'])) AS Crawler, + lower(replaceRegexpOne(coalesce(nullIf(SpanAttributes['server.address'], ''), nullIf(SpanAttributes['http.host'], ''), nullIf(SpanAttributes['net.host.name'], ''), nullIf(domain(SpanAttributes['url.full']), ''), nullIf(domain(SpanAttributes['http.url']), ''), ''), ':[0-9]+$', '')) AS Host, + leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) AS Path, + toUInt16OrZero(coalesce(nullIf(SpanAttributes['http.response.status_code'], ''), nullIf(SpanAttributes['http.status_code'], ''), '')) AS HttpStatus + FROM traces + WHERE SpanKind = 'Server' + AND multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'meta-externalagent', 'meta-webindexer', 'meta-externalfetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai']) > 0 + AND leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS +SELECT + OrgId, + Timestamp, + TraceId, + SpanAttributes['maple_ai.session.id'] AS SessionId, + SpanAttributes['maple_ai.vendor.id'] AS VendorId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + SpanAttributes['maple_ai.model'] AS Model, + SpanAttributes['maple_ai.agent.name'] AS AgentName, + SpanAttributes['maple_ai.tool.name'] AS ToolName, + SpanId, + ParentSpanId, + Duration, + toUInt8(SpanAttributes['maple_ai.error'] = '1') AS IsError, + toUInt8(SpanAttributes['maple_ai.llm_call'] = '1') AS IsLlmCall, + toUInt8(SpanAttributes['maple_ai.tool_call'] = '1') AS IsToolCall, + toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS Tokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cost']) AS Cost, + SpanAttributes['maple_ai.response.id'] AS ResponseId, + SpanAttributes['maple_ai.vendor.version'] AS VendorVersion, + toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) AS InputTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) AS CacheReadTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) AS CacheWriteTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) AS OutputTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS ReasoningTokens, + SpanAttributes['error.type'] AS ErrorType, + leftUTF8(StatusMessage, 400) AS StatusMessage, + SpanAttributes['maple_ai.tool.description'] AS ToolDescription, + SpanAttributes['maple_ai.tool.error_result'] AS FailedToolCallResult, + if(SpanAttributes['maple_ai.error'] = '1', cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(SpanAttributes['maple_ai.tool.error_result'], ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint + FROM traces + WHERE SpanAttributes['maple_ai.vendor.id'] != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_by_time_mv TO error_events_by_time AS +WITH + arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei, + -- Only fill the old Unknown Error bucket. Event values (including + -- empty fields) and spans with StatusMessage keep every hash input. + _ei = 0 AND StatusMessage = '' AS _useAttrs, + if( + _ei > 0, EventsAttributes[_ei]['exception.type'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.type'], ''), SpanAttributes['error.type']), '') + ) AS _exType, + if( + _ei > 0, EventsAttributes[_ei]['exception.message'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.message'], ''), SpanAttributes['error.message']), StatusMessage) + ) AS _exMsg, + if( + _ei > 0, EventsAttributes[_ei]['exception.stacktrace'], + if(_useAttrs, SpanAttributes['exception.stacktrace'], '') + ) AS _exStack, + if(_useAttrs, _exMsg, StatusMessage) AS _msgText, + -- Frame lines are matched by SHAPE, not by "contains :NUMBER". The old + -- rule accepted any line with a colon-digit, which let non-frame lines + -- in: Drizzle's `params: ` line, and the `Type: message` + -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values + -- and message text then entered the hash and split one bug into + -- thousands of issues — 23,035 fingerprints for six real + -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError + -- ones. + -- + -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts, + -- as is every redaction below. They used to be hand-copied here, which + -- let the reference implementation the tests exercise drift away from + -- the SQL that actually runs, silently. + arraySlice( + arrayFilter( + line -> match(line, '^[ \\t]*at |^[ \\t]*File "|^[ \\t]+from [^ ]+:[0-9]+|^[^ \\t@]+@[^ \\t]*:[0-9]+|^[ \\t]+[^ \\t]+\\.(go|rs):[0-9]+|^[0-9]+ +\\S.* +0x[0-9a-fA-F]+'), + splitByChar('\n', _exStack) + ), + 1, 3 + ) AS _rawFrames, + -- Redact every volatile token a frame line can carry: the URL origin + -- (so preview hosts share one fingerprint), Vite's 8-char bundle + -- content hash (so a deploy does not re-split every triaged browser and + -- Worker issue), then line numbers, hex pointers and long id runs. See + -- FRAME_REDACTIONS for the order and the reasoning. + arrayMap( + line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''), + _rawFrames + ) AS _topFrames, + if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame, + arrayStringConcat(_topFrames, '\n') AS _fpFrames, + -- JSON detection for the message signature below. + isValidJSON(_msgText) AS _isJson, + _isJson AND JSONType(_msgText) = 'Object' AS _isJsonObj, + -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level + -- keys, redact volatile tokens (long hex / numbers) in each raw value, then + -- sort by "key=value" so key order & whitespace don't matter. No assumption + -- about which keys exist — works for any producer's JSON shape. (Nested + -- objects are hashed as their raw substring; only top-level is canonicalized.) + arrayStringConcat( + arraySort( + arrayMap( + kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')), + JSONExtractKeysAndValuesRaw(_msgText) + ) + ), + '|' + ) AS _jsonSig, + -- The message signature is folded in ALWAYS, not only when there are no + -- frames. Bundled runtimes minify every module into one file, so the top + -- three frames of a Worker error are `toDatabaseError (worker.js)` for + -- every failing query alike: on frames alone, 25 distinct DatabaseError + -- bugs (316k occurrences) collapse into a single issue. The signature + -- restores that discrimination, and it cannot reinflate cardinality the + -- way a raw prefix would because everything variable is redacted first: + -- emails, URL origins, home directories, query strings, quoted values, + -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order, + -- what is deliberately kept, and the one residual it cannot reach. + multiIf( + _isJsonObj, _jsonSig, + substringUTF8( + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(_msgText, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'), + 1, 120 + ) + ) AS _msgSig, + -- Display-only, best-effort human label (decoupled from the fingerprint: + -- many labels may map to one hash). The broad key list here is a DISPLAY + -- heuristic only; the fingerprint above makes no key-name assumption. + multiIf( + JSONExtractString(_msgText, 'title') != '', JSONExtractString(_msgText, 'title'), + JSONExtractString(_msgText, 'message') != '', JSONExtractString(_msgText, 'message'), + JSONExtractString(_msgText, 'error') != '', JSONExtractString(_msgText, 'error'), + JSONExtractString(_msgText, '_tag') != '', JSONExtractString(_msgText, '_tag'), + JSONExtractString(_msgText, 'reason') != '', JSONExtractString(_msgText, 'reason'), + JSONExtractString(_msgText, 'name') != '', JSONExtractString(_msgText, 'name'), + JSONExtractString(_msgText, 'type') != '', extract(JSONExtractString(_msgText, 'type'), '([^/]+)$'), + 'JSON error' + ) AS _jsonLabel, + multiIf( + _msgText = '', 'Unknown Error', + position(_msgText, '{ readonly') = 1 OR position(_msgText, '└─') > 0, + if( + extract(_msgText, 'readonly (\\w+)') != '', + concat('Schema parse error: ', extract(_msgText, 'readonly (\\w+)')), + 'Schema parse error' + ), + _isJsonObj OR position(_msgText, '[') = 1, _jsonLabel, + left(_msgText, multiIf( + position(_msgText, ': ') > 3, toInt64(position(_msgText, ': ')) - 1, + position(_msgText, ' (') > 3, toInt64(position(_msgText, ' (')) - 1, + position(_msgText, '\n') > 3, toInt64(position(_msgText, '\n')) - 1, + least(toInt64(length(_msgText)), 150) + )) + ) AS _statusLabel, + if(_exType != '', _exType, _statusLabel) AS _errorLabel, + -- Both semconv spellings; the current key wins when both are present. + toUInt16OrZero( + if( + SpanAttributes['http.response.status_code'] != '', + SpanAttributes['http.response.status_code'], + SpanAttributes['http.status_code'] + ) + ) AS _httpStatus + SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + _exType AS ExceptionType, + _exMsg AS ExceptionMessage, + _exStack AS ExceptionStacktrace, + _topFrame AS TopFrame, + cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash, + StatusMessage, + Duration, + _errorLabel AS ErrorLabel, + ResourceAttributes['service.version'] AS ServiceVersion + FROM traces + WHERE StatusCode = 'Error' + -- Client-side runtimes (notably the native Cloudflare Workers + -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot + -- traffic arrived here as unlabelled "Unknown Error" issues. Drop a + -- span only when all hold: 4xx, no exception event, no exception.type + -- attribute, and no error.type beyond the status code itself (HTTP + -- semconv sets error.type to the bare status on a non-2xx response, + -- which carries no exception). 5xx and anything carrying a real + -- exception still count, and SpanKind is deliberately not consulted — + -- these are Client spans. + AND NOT ( + _httpStatus >= 400 AND _httpStatus < 500 + AND _ei = 0 + AND SpanAttributes['exception.type'] = '' + AND (SpanAttributes['error.type'] = '' OR SpanAttributes['error.type'] = toString(_httpStatus)) + ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_mv TO error_events AS +WITH + arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei, + -- Only fill the old Unknown Error bucket. Event values (including + -- empty fields) and spans with StatusMessage keep every hash input. + _ei = 0 AND StatusMessage = '' AS _useAttrs, + if( + _ei > 0, EventsAttributes[_ei]['exception.type'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.type'], ''), SpanAttributes['error.type']), '') + ) AS _exType, + if( + _ei > 0, EventsAttributes[_ei]['exception.message'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.message'], ''), SpanAttributes['error.message']), StatusMessage) + ) AS _exMsg, + if( + _ei > 0, EventsAttributes[_ei]['exception.stacktrace'], + if(_useAttrs, SpanAttributes['exception.stacktrace'], '') + ) AS _exStack, + if(_useAttrs, _exMsg, StatusMessage) AS _msgText, + -- Frame lines are matched by SHAPE, not by "contains :NUMBER". The old + -- rule accepted any line with a colon-digit, which let non-frame lines + -- in: Drizzle's `params: ` line, and the `Type: message` + -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values + -- and message text then entered the hash and split one bug into + -- thousands of issues — 23,035 fingerprints for six real + -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError + -- ones. + -- + -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts, + -- as is every redaction below. They used to be hand-copied here, which + -- let the reference implementation the tests exercise drift away from + -- the SQL that actually runs, silently. + arraySlice( + arrayFilter( + line -> match(line, '^[ \\t]*at |^[ \\t]*File "|^[ \\t]+from [^ ]+:[0-9]+|^[^ \\t@]+@[^ \\t]*:[0-9]+|^[ \\t]+[^ \\t]+\\.(go|rs):[0-9]+|^[0-9]+ +\\S.* +0x[0-9a-fA-F]+'), + splitByChar('\n', _exStack) + ), + 1, 3 + ) AS _rawFrames, + -- Redact every volatile token a frame line can carry: the URL origin + -- (so preview hosts share one fingerprint), Vite's 8-char bundle + -- content hash (so a deploy does not re-split every triaged browser and + -- Worker issue), then line numbers, hex pointers and long id runs. See + -- FRAME_REDACTIONS for the order and the reasoning. + arrayMap( + line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''), + _rawFrames + ) AS _topFrames, + if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame, + arrayStringConcat(_topFrames, '\n') AS _fpFrames, + -- JSON detection for the message signature below. + isValidJSON(_msgText) AS _isJson, + _isJson AND JSONType(_msgText) = 'Object' AS _isJsonObj, + -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level + -- keys, redact volatile tokens (long hex / numbers) in each raw value, then + -- sort by "key=value" so key order & whitespace don't matter. No assumption + -- about which keys exist — works for any producer's JSON shape. (Nested + -- objects are hashed as their raw substring; only top-level is canonicalized.) + arrayStringConcat( + arraySort( + arrayMap( + kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')), + JSONExtractKeysAndValuesRaw(_msgText) + ) + ), + '|' + ) AS _jsonSig, + -- The message signature is folded in ALWAYS, not only when there are no + -- frames. Bundled runtimes minify every module into one file, so the top + -- three frames of a Worker error are `toDatabaseError (worker.js)` for + -- every failing query alike: on frames alone, 25 distinct DatabaseError + -- bugs (316k occurrences) collapse into a single issue. The signature + -- restores that discrimination, and it cannot reinflate cardinality the + -- way a raw prefix would because everything variable is redacted first: + -- emails, URL origins, home directories, query strings, quoted values, + -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order, + -- what is deliberately kept, and the one residual it cannot reach. + multiIf( + _isJsonObj, _jsonSig, + substringUTF8( + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(_msgText, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'), + 1, 120 + ) + ) AS _msgSig, + -- Display-only, best-effort human label (decoupled from the fingerprint: + -- many labels may map to one hash). The broad key list here is a DISPLAY + -- heuristic only; the fingerprint above makes no key-name assumption. + multiIf( + JSONExtractString(_msgText, 'title') != '', JSONExtractString(_msgText, 'title'), + JSONExtractString(_msgText, 'message') != '', JSONExtractString(_msgText, 'message'), + JSONExtractString(_msgText, 'error') != '', JSONExtractString(_msgText, 'error'), + JSONExtractString(_msgText, '_tag') != '', JSONExtractString(_msgText, '_tag'), + JSONExtractString(_msgText, 'reason') != '', JSONExtractString(_msgText, 'reason'), + JSONExtractString(_msgText, 'name') != '', JSONExtractString(_msgText, 'name'), + JSONExtractString(_msgText, 'type') != '', extract(JSONExtractString(_msgText, 'type'), '([^/]+)$'), + 'JSON error' + ) AS _jsonLabel, + multiIf( + _msgText = '', 'Unknown Error', + position(_msgText, '{ readonly') = 1 OR position(_msgText, '└─') > 0, + if( + extract(_msgText, 'readonly (\\w+)') != '', + concat('Schema parse error: ', extract(_msgText, 'readonly (\\w+)')), + 'Schema parse error' + ), + _isJsonObj OR position(_msgText, '[') = 1, _jsonLabel, + left(_msgText, multiIf( + position(_msgText, ': ') > 3, toInt64(position(_msgText, ': ')) - 1, + position(_msgText, ' (') > 3, toInt64(position(_msgText, ' (')) - 1, + position(_msgText, '\n') > 3, toInt64(position(_msgText, '\n')) - 1, + least(toInt64(length(_msgText)), 150) + )) + ) AS _statusLabel, + if(_exType != '', _exType, _statusLabel) AS _errorLabel, + -- Both semconv spellings; the current key wins when both are present. + toUInt16OrZero( + if( + SpanAttributes['http.response.status_code'] != '', + SpanAttributes['http.response.status_code'], + SpanAttributes['http.status_code'] + ) + ) AS _httpStatus + SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + _exType AS ExceptionType, + _exMsg AS ExceptionMessage, + _exStack AS ExceptionStacktrace, + _topFrame AS TopFrame, + cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash, + StatusMessage, + Duration, + _errorLabel AS ErrorLabel, + ResourceAttributes['service.version'] AS ServiceVersion + FROM traces + WHERE StatusCode = 'Error' + -- Client-side runtimes (notably the native Cloudflare Workers + -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot + -- traffic arrived here as unlabelled "Unknown Error" issues. Drop a + -- span only when all hold: 4xx, no exception event, no exception.type + -- attribute, and no error.type beyond the status code itself (HTTP + -- semconv sets error.type to the bare status on a non-2xx response, + -- which carries no exception). 5xx and anything carrying a real + -- exception still count, and SpanKind is deliberately not consulted — + -- these are Client spans. + AND NOT ( + _httpStatus >= 400 AND _httpStatus < 500 + AND _ei = 0 + AND SpanAttributes['exception.type'] = '' + AND (SpanAttributes['error.type'] = '' OR SpanAttributes['error.type'] = toString(_httpStatus)) + ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_fingerprints_minutely_mv TO error_fingerprints_minutely AS +SELECT + OrgId, + toStartOfMinute(Timestamp) AS Minute, + FingerprintHash, + anyLast(ServiceName) AS ServiceName, + anyLast(ExceptionType) AS ExceptionType, + anyLast(ExceptionMessage) AS ExceptionMessage, + anyLast(ErrorLabel) AS ErrorLabel, + anyLast(TopFrame) AS TopFrame, + count() AS OccurrenceCount, + min(Timestamp) AS FirstSeen, + max(Timestamp) AS LastSeen, + -- Distinct builds, not a sample: see ServiceVersions on the datasource. + groupUniqArray(ServiceVersion) AS ServiceVersions + FROM error_events + GROUP BY OrgId, Minute, FingerprintHash; + +CREATE MATERIALIZED VIEW IF NOT EXISTS identity_links_mv TO identity_links AS +SELECT + OrgId, + VisitorId, + UserId, + StartTime AS FirstSeen + FROM session_replays + WHERE VisitorId != '' AND UserId != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS log_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(LogAttributes)) AS AttributeKey, + 'log' AS AttributeScope, + count() AS UsageCount + FROM logs + WHERE LogAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS log_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'log' AS AttributeScope, + count() AS UsageCount + FROM logs + ARRAY JOIN + mapKeys(LogAttributes) AS AttributeKey, + mapValues(LogAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS logs_aggregates_hourly_mv TO logs_aggregates_hourly AS +SELECT + OrgId, + toStartOfHour(TimestampTime) AS Hour, + ServiceName, + SeverityText, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS Count, + sum(length(Body) + 200) AS SizeBytes, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM logs + GROUP BY OrgId, Hour, ServiceName, SeverityText, DeploymentEnv, ServiceNamespace; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + arrayJoin(mapKeys(Attributes)) AS AttributeKey, + 'metric' AS AttributeScope, + count() AS UsageCount + FROM metrics_sum + WHERE Attributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + AttributeKey, + AttributeValue, + 'metric' AS AttributeScope, + count() AS UsageCount + FROM metrics_sum + ARRAY JOIN + mapKeys(Attributes) AS AttributeKey, + mapValues(Attributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_exp_histogram_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'exponential_histogram' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_exponential_histogram + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_gauge_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'gauge' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_gauge + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_histogram_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'histogram' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_histogram + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_sum_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'sum' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + anyLast(toUInt8(IsMonotonic)) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_sum + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS product_events_mv TO product_events AS +SELECT + OrgId, + Timestamp, + 'browser' AS Source, + SessionId, + Seq, + VisitorId, + UserId, + GroupId, + Type AS Kind, + if(Type = 'navigation', '$pageview', Message) AS EventName, + domain(Url) AS Host, + path(Url) AS PagePath, + Url, + '' AS ServiceName, + Attributes, + '' AS TraceId, + '' AS SpanId + FROM session_events + WHERE Type IN ('navigation', 'custom'); + +CREATE MATERIALIZED VIEW IF NOT EXISTS product_events_traces_mv TO product_events AS +SELECT + OrgId, + Timestamp, + 'trace' AS Source, + SpanAttributes['session.id'] AS SessionId, + 0 AS Seq, + SpanAttributes['maple.product_event.visitor_id'] AS VisitorId, + SpanAttributes['maple.product_event.user_id'] AS UserId, + SpanAttributes['maple.product_event.group_id'] AS GroupId, + 'custom' AS Kind, + SpanAttributes['maple.product_event.name'] AS EventName, + domain(SpanAttributes['maple.product_event.url']) AS Host, + path(SpanAttributes['maple.product_event.url']) AS PagePath, + SpanAttributes['maple.product_event.url'] AS Url, + ServiceName, + mapUpdate( + CAST( + mapFilter( + (k, v) -> NOT startsWith(k, 'maple.product_event.') + AND ( + NOT has(mapKeys(SpanAttributes), 'maple.product_event.include') + OR has( + arrayMap( + key -> trimBoth(key), + splitByChar(',', SpanAttributes['maple.product_event.include']) + ), + k + ) + ), + SpanAttributes + ), + 'Map(String, String)' + ), + mapApply( + (k, v) -> (substring(k, 26), v), + mapFilter((k, v) -> startsWith(k, 'maple.product_event.prop.'), SpanAttributes) + ) + ) AS Attributes, + TraceId, + SpanId + FROM traces + WHERE SpanAttributes['maple.product_event.name'] != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_external_edges_hourly_mv TO service_external_edges_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', 'messaging', + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', 'rpc', + 'http' + ) AS TargetType, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', SpanAttributes['messaging.system'], + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', SpanAttributes['rpc.system'], + '' + ) AS TargetSystem, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', + if(coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '', coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']), SpanAttributes['messaging.system']), + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', + if(SpanAttributes['rpc.service'] != '', SpanAttributes['rpc.service'], SpanAttributes['rpc.system']), + if(SpanAttributes['server.address'] != '', + SpanAttributes['server.address'], + if(SpanAttributes['http.host'] != '', + SpanAttributes['http.host'], + SpanAttributes['url.authority'])) + ) AS TargetName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(Duration / 1000000) AS DurationSumMs, + max(Duration / 1000000) AS MaxDurationMs, + sum(SampleRate) AS SampleRateSum, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND SpanAttributes['db.system.name'] = '' + AND ServiceName != '' + AND ( + SpanAttributes['server.address'] != '' + OR SpanAttributes['http.host'] != '' + OR SpanAttributes['url.authority'] != '' + OR coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' + OR SpanAttributes['messaging.system'] != '' + OR SpanAttributes['rpc.service'] != '' + OR SpanAttributes['rpc.system'] != '' + ) + GROUP BY OrgId, Hour, ServiceName, TargetType, TargetSystem, TargetName, DeploymentEnv + HAVING TargetName != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_children_mv TO service_map_children AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + ParentSpanId, + ServiceName, + SpanKind, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') + AND ParentSpanId != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_db_edges_hourly_mv TO service_map_db_edges_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) AS DbSystem, + if(match(coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name']), '^([0-9a-fA-F]{32}|.*[.]hyperdrive[.]local)$'), 'hyperdrive', coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name'])) AS DbNamespace, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(Duration / 1000000) AS DurationSumMs, + max(Duration / 1000000) AS MaxDurationMs, + countIf(TraceState LIKE '%th:%') AS SampledSpanCount, + countIf(TraceState = '' OR TraceState NOT LIKE '%th:%') AS UnsampledSpanCount, + sum(SampleRate) AS SampleRateSum, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) != '' + AND ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DbSystem, DbNamespace, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_db_query_shapes_hourly_mv TO service_map_db_query_shapes_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) AS DbSystem, + if(match(coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name']), '^([0-9a-fA-F]{32}|.*[.]hyperdrive[.]local)$'), 'hyperdrive', coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name'])) AS DbNamespace, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + coalesce( + nullIf(SpanAttributes['db.query.fingerprint'], ''), + nullIf(SpanAttributes['db.statement.fingerprint'], ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', toString(cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(lower(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement'])), '\'[^\']*\'', '?'), '\\bin\\s*\\([^)]*\\)', 'in (?)'), '[0-9]+(\\.[0-9]+)?', '?'), '\\s+', ' '), '^\\s+|\\s+$', ''))), ''), ''), + toString(cityHash64(coalesce( + nullIf(SpanAttributes['db.query.summary'], ''), + nullIf(if(SpanAttributes['db.operation.name'] != '', trimBoth(concat(SpanAttributes['db.operation.name'], if(coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace']) != '', concat(' ', coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace'])), ''))), ''), ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', trimBoth(concat(upper(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '^\\s*(\\w+)')), if(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)') != '', concat(' ', extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)')), ''))), ''), ''), + nullIf(SpanAttributes['query.context'], ''), + nullIf(SpanAttributes['db.operation.name'], ''), + nullIf(SpanAttributes['db.operation'], ''), + SpanName +))) +) AS QueryKey, + any(substring(coalesce( + nullIf(SpanAttributes['db.query.summary'], ''), + nullIf(if(SpanAttributes['db.operation.name'] != '', trimBoth(concat(SpanAttributes['db.operation.name'], if(coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace']) != '', concat(' ', coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace'])), ''))), ''), ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', trimBoth(concat(upper(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '^\\s*(\\w+)')), if(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)') != '', concat(' ', extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)')), ''))), ''), ''), + nullIf(SpanAttributes['query.context'], ''), + nullIf(SpanAttributes['db.operation.name'], ''), + nullIf(SpanAttributes['db.operation'], ''), + SpanName +), 1, 220)) AS QueryLabel, + any(substring(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), 1, 1000)) AS SampleStatement, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(SampleRate) AS EstimatedCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration) * SampleRate / 1000000) AS WeightedDurationSumMs, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) != '' + AND ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DbSystem, DbNamespace, DeploymentEnv, QueryKey; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_edges_hourly_ingest_mv TO service_map_edges_hourly AS +SELECT + OrgId, + Hour, + SourceService, + TargetService, + DeploymentEnv, + CallCount, + ErrorCount, + DurationSumMs, + MaxDurationMs, + SampledSpanCount, + UnsampledSpanCount, + SampleRateSum + FROM service_map_edges_hourly_ingest; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_spans_mv TO service_map_spans AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + SpanKind, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv + FROM traces + WHERE SpanKind IN ('Client', 'Producer', 'Server', 'Consumer'); + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_operations_hourly_mv TO service_operations_hourly AS +SELECT + OrgId, + toStartOfHour(Minute) AS Hour, + ServiceName, + DeploymentEnv, + SpanName, + sum(SpanCount) AS SpanCount, + sum(EstimatedSpanCount) AS EstimatedSpanCount, + sum(ErrorCount) AS ErrorCount, + sum(EstimatedErrorCount) AS EstimatedErrorCount, + sum(DurationSum) AS DurationSum, + quantilesTDigestMergeState(0.5, 0.95)(DurationQuantiles) AS DurationQuantiles, + sum(ClassifiedSpanCount) AS ClassifiedSpanCount, + sum(ServerSpanCount) AS ServerSpanCount, + sum(RoutedSpanCount) AS RoutedSpanCount + FROM service_operations_minutely + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv, SpanName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_operations_minutely_mv TO service_operations_minutely AS +SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + if(((SpanName LIKE 'http.server %' OR SpanName IN ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')) AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != '')), concat(if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), ' ', if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path'])), SpanName) AS SpanName, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95)(Duration) AS DurationQuantiles, + count() AS ClassifiedSpanCount, + countIf(SpanKind IN ('Server', 'Consumer')) AS ServerSpanCount, + countIf(SpanAttributes['http.route'] != '') AS RoutedSpanCount + FROM traces + GROUP BY OrgId, Minute, ServiceName, DeploymentEnv, SpanName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_hourly_mv TO service_overview_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS DurationQuantiles, + min(toDateTime(Timestamp)) AS FirstSeen, + countIf(StatusCode != 'Error' AND Duration < 500000000) AS ApdexSatisfiedCount, + countIf(StatusCode != 'Error' AND Duration >= 500000000 AND Duration < 2000000000) AS ApdexToleratingCount + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '' + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv, ServiceNamespace, CommitSha; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_minutely_mv TO service_overview_minutely AS +SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS DurationQuantiles, + min(toDateTime(Timestamp)) AS FirstSeen, + countIf(StatusCode != 'Error' AND Duration < 500000000) AS ApdexSatisfiedCount, + countIf(StatusCode != 'Error' AND Duration >= 500000000 AND Duration < 2000000000) AS ApdexToleratingCount + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '' + GROUP BY OrgId, Minute, ServiceName, DeploymentEnv, ServiceNamespace, CommitSha; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_spans_mv TO service_overview_spans AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + ServiceName, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + SampleRate, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_platforms_hourly_mv TO service_platforms_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + max(ResourceAttributes['k8s.cluster.name']) AS K8sCluster, + max(ResourceAttributes['k8s.pod.name']) AS K8sPodName, + max(ResourceAttributes['k8s.deployment.name']) AS K8sDeploymentName, + max(ResourceAttributes['k8s.statefulset.name']) AS K8sStatefulSetName, + max(ResourceAttributes['k8s.daemonset.name']) AS K8sDaemonSetName, + max(ResourceAttributes['k8s.namespace.name']) AS K8sNamespaceName, + max(ResourceAttributes['cloud.platform']) AS CloudPlatform, + max(ResourceAttributes['cloud.provider']) AS CloudProvider, + max(ResourceAttributes['faas.name']) AS FaasName, + max(ResourceAttributes['maple.sdk.type']) AS MapleSdkType, + max(ResourceAttributes['process.runtime.name']) AS ProcessRuntimeName, + count() AS SpanCount + FROM traces + WHERE ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_logs_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(TimestampTime) AS Hour, + count() AS LogCount, + sum(length(Body) + 200) AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM logs + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_exp_histogram_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + count() AS ExpHistogramMetricCount, + count() * 300 AS ExpHistogramMetricSizeBytes + FROM metrics_exponential_histogram + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_gauge_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + count() AS GaugeMetricCount, + count() * 150 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_gauge + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_histogram_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + count() AS HistogramMetricCount, + count() * 250 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_histogram + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_sum_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + count() AS SumMetricCount, + count() * 150 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_sum + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_traces_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + count() AS TraceCount, + sum(length(SpanName) + 300) AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM traces + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS span_metrics_calls_hourly_mv TO span_metrics_calls_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + ServiceName, + MetricName, + Attributes['span.kind'] AS SpanKind, + cityHash64(mapKeys(Attributes), mapValues(Attributes)) AS AttrFingerprint, + cityHash64(mapKeys(ResourceAttributes), mapValues(ResourceAttributes)) AS ResourceFingerprint, + StartTimeUnix, + argMaxState(Value, TimeUnix) AS LastValue + FROM metrics_sum + -- 'traces.span.metrics.calls' is the name the collector actually emits: + -- spanmetricsconnector output is namespaced by the pipeline it is attached + -- to. Without it this MV matched nothing and the target sat at 0 rows since + -- it was created, while ~880k rows / 2 days of the real counter flowed past + -- into metrics_sum and every read fell back to the raw window-function scan + -- (~7s p95 -- see queries/metrics.ts). Keep this list in sync with + -- SPAN_METRICS_CALLS_NAMES on the read side. + WHERE MetricName IN ('span.metrics.calls', 'calls', 'traces.span.metrics.calls') AND IsMonotonic + GROUP BY OrgId, Hour, ServiceName, MetricName, SpanKind, AttrFingerprint, ResourceFingerprint, StartTimeUnix; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_detail_spans_mv TO trace_detail_spans AS +SELECT + OrgId, + Timestamp, + TraceId, + SpanId, + ParentSpanId, + SpanName, + SpanKind, + ServiceName, + Duration, + StatusCode, + StatusMessage, + SpanAttributes, + ResourceAttributes + FROM traces; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_facets_hourly_mv TO trace_facets_hourly AS +SELECT + OrgId, + toStartOfHour(Timestamp) AS Hour, + ServiceName, + SpanName, + HttpMethod, + HttpStatusCode, + DeploymentEnv, + ServiceNamespace, + HasError, + count() AS TraceCount, + min(Duration) AS DurationMin, + max(Duration) AS DurationMax, + quantilesTDigestState(0.5, 0.95)(Duration) AS DurationQuantiles + FROM trace_list_mv + GROUP BY OrgId, Hour, ServiceName, SpanName, HttpMethod, HttpStatusCode, DeploymentEnv, ServiceNamespace, HasError; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_list_mv_mv TO trace_list_mv AS +SELECT + OrgId, + TraceId, + toDateTime(Timestamp) AS Timestamp, + ServiceName, + if( + (SpanName LIKE 'http.server %' OR SpanName IN ('GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS')) + AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != ''), + concat( + if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), + ' ', + if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path']) + ), + SpanName + ) AS SpanName, + SpanKind, + Duration, + StatusCode, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS HttpMethod, + if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], if(SpanAttributes['url.path'] != '', SpanAttributes['url.path'], SpanAttributes['http.target'])) AS HttpRoute, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS HttpStatusCode, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + toUInt8( + StatusCode = 'Error' + OR (SpanAttributes['http.status_code'] != '' AND toUInt16OrZero(SpanAttributes['http.status_code']) >= 500) + OR (SpanAttributes['http.response.status_code'] != '' AND toUInt16OrZero(SpanAttributes['http.response.status_code']) >= 500) + ) AS HasError, + TraceState, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM traces + WHERE ParentSpanId = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_resource_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(ResourceAttributes)) AS AttributeKey, + 'resource' AS AttributeScope, + count() AS UsageCount + FROM traces + WHERE ResourceAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_resource_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'resource' AS AttributeScope, + count() AS UsageCount + FROM traces + ARRAY JOIN + mapKeys(ResourceAttributes) AS AttributeKey, + mapValues(ResourceAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_span_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(SpanAttributes)) AS AttributeKey, + 'span' AS AttributeScope, + count() AS UsageCount + FROM traces + WHERE SpanAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_span_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'span' AS AttributeScope, + count() AS UsageCount + FROM traces + ARRAY JOIN + mapKeys(SpanAttributes) AS AttributeKey, + mapValues(SpanAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS traces_aggregates_hourly_mv TO traces_aggregates_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + SpanName, + SpanKind, + StatusCode, + IsEntryPoint, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + sum(SampleRate) AS WeightedCount, + sum(toFloat64(Duration) * SampleRate) AS WeightedDurationSum, + sumIf(SampleRate, StatusCode = 'Error') AS WeightedErrorCount, + quantilesTDigestWeightedState(0.5, 0.95, 0.99)(Duration, toUInt32(SampleRate)) AS DurationQuantiles, + min(Duration) AS DurationMin, + max(Duration) AS DurationMax + FROM traces + GROUP BY OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv; diff --git a/apps/cli/src/server/schema/local-schema.sql b/apps/cli/src/server/schema/local-schema.sql index 2c45ee75e9..ac8cb5b85f 100644 --- a/apps/cli/src/server/schema/local-schema.sql +++ b/apps/cli/src/server/schema/local-schema.sql @@ -1,7 +1,7 @@ -- This file is generated by scripts/generate-clickhouse-schema-sql.ts -- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. --- projectRevision: 92181b09631cc85a9bfc1ea57dc85000f3c6770b0799e5b357692f4634ce8042 --- localSchemaVersion: 25 +-- projectRevision: 6f17e5994398bfddfebe6a99f13f097bbe822a95820cf6e0cb575082823d4f32 +-- localSchemaVersion: 26 CREATE TABLE IF NOT EXISTS ai_crawler_requests ( OrgId LowCardinality(String), @@ -991,29 +991,29 @@ SELECT SpanAttributes['maple_ai.vendor.id'] AS VendorId, ServiceName, coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, - coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model, - coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName, - coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName, + SpanAttributes['maple_ai.model'] AS Model, + SpanAttributes['maple_ai.agent.name'] AS AgentName, + SpanAttributes['maple_ai.tool.name'] AS ToolName, SpanId, ParentSpanId, Duration, - toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError, - toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall, - toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall, - multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('openai', 'gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai', 'openrouter'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])))) + multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic', 'openai', 'openrouter'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])))) AS Tokens, - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost, - coalesce(nullIf(SpanAttributes['gen_ai.response.id'], ''), SpanAttributes['ai.response.id']) AS ResponseId, + toUInt8(SpanAttributes['maple_ai.error'] = '1') AS IsError, + toUInt8(SpanAttributes['maple_ai.llm_call'] = '1') AS IsLlmCall, + toUInt8(SpanAttributes['maple_ai.tool_call'] = '1') AS IsToolCall, + toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS Tokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cost']) AS Cost, + SpanAttributes['maple_ai.response.id'] AS ResponseId, SpanAttributes['maple_ai.vendor.version'] AS VendorVersion, - multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('openai', 'gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai', 'openrouter'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])))) AS InputTokens, - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) AS CacheReadTokens, - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) AS CacheWriteTokens, - multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic', 'openai', 'openrouter'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])))) AS OutputTokens, - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS ReasoningTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) AS InputTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) AS CacheReadTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) AS CacheWriteTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) AS OutputTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS ReasoningTokens, SpanAttributes['error.type'] AS ErrorType, leftUTF8(StatusMessage, 400) AS StatusMessage, - leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.description'], ''), SpanAttributes['tool.description']), 2000) AS ToolDescription, - if((((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')) AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))), leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), SpanAttributes['ai.toolCall.result']), 1000), '') AS FailedToolCallResult, - if(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')), cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(if((((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')) AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))), leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), SpanAttributes['ai.toolCall.result']), 1000), ''), ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint + SpanAttributes['maple_ai.tool.description'] AS ToolDescription, + SpanAttributes['maple_ai.tool.error_result'] AS FailedToolCallResult, + if(SpanAttributes['maple_ai.error'] = '1', cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(SpanAttributes['maple_ai.tool.error_result'], ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint FROM traces WHERE SpanAttributes['maple_ai.vendor.id'] != ''; diff --git a/apps/cli/test/local-store-migrations.test.ts b/apps/cli/test/local-store-migrations.test.ts index 6b534f54f6..ea3995ce99 100644 --- a/apps/cli/test/local-store-migrations.test.ts +++ b/apps/cli/test/local-store-migrations.test.ts @@ -29,6 +29,7 @@ import { LOCAL_SCHEMA_V23, LOCAL_SCHEMA_V24, LOCAL_SCHEMA_V25, + LOCAL_SCHEMA_V26, SCHEMA_DIGEST, SCHEMA_FINGERPRINT, } from "../src/server/schema-identity" @@ -93,16 +94,16 @@ const [v10ToV11ProductEventsModule] = localStoreMigrations.filter( ) describe("current local schema identity", () => { - it("matches the generated v25 revision and keeps the issue-297 identity frozen", () => { - expect(SCHEMA_FINGERPRINT).toBe("bfaed79bcf2423f5") - expect(SCHEMA_DIGEST).toBe("bfaed79bcf2423f532b582ab321d4b5f55410853cb381cad1df7041e0663f941") + it("matches the generated v26 revision and keeps the issue-297 identity frozen", () => { + expect(SCHEMA_FINGERPRINT).toBe("203c87dde2b5aedc") + expect(SCHEMA_DIGEST).toBe("203c87dde2b5aedc28de3b2fd72829991b28fdf703bbb40ae72ebd835dbdb67c") expect(ISSUE_297_TARGET_SCHEMA_PROJECT_REVISION).toBe( "506bc745f7a7eca202ec905a6403a6815e86413faf0cd3cbbf73881023edce91", ) expect(CURRENT_SCHEMA_PROJECT_REVISION).toMatch(/^[0-9a-f]{64}$/) expect(LOCAL_SCHEMA_MANIFEST.objects.length).toBeGreaterThan(60) - expect(CURRENT_LOCAL_SCHEMA.version).toBe(25) - expect(CURRENT_LOCAL_SCHEMA).toEqual(LOCAL_SCHEMA_V25) + expect(CURRENT_LOCAL_SCHEMA.version).toBe(26) + expect(CURRENT_LOCAL_SCHEMA).toEqual(LOCAL_SCHEMA_V26) const logs = LOCAL_SCHEMA_MANIFEST.objects.find((object) => object.name === "logs") expect(logs?.columns.some((column) => column.name.startsWith("idx_"))).toBe(false) expect(logs?.indexes).toContain("idx_lower_body") @@ -355,6 +356,7 @@ describe("local migration registry", () => { "local-0022-to-0023-ai-crawler-requests", "local-0023-to-0024-trace-facets-hourly", "local-0024-to-0025-trace-facets-hourly-daily-partition", + "local-0025-to-0026-ai-trace-index-gateway-stamps", ]) expect(chain[0]?.from.fingerprint).toBe(LEGACY_SCHEMA_FINGERPRINT) expect(chain[0]?.to).toEqual(LOCAL_SCHEMA_V1) @@ -401,7 +403,7 @@ describe("local migration registry", () => { // One past the current tip — bump alongside LOCAL_SCHEMA_VERSION, or this // stops testing the future-store guard and starts testing the // unknown-fingerprint one. - { ...CURRENT_LOCAL_SCHEMA, version: 26, fingerprint: "future", digest: SCHEMA_DIGEST }, + { ...CURRENT_LOCAL_SCHEMA, version: 27, fingerprint: "future", digest: SCHEMA_DIGEST }, CURRENT_LOCAL_SCHEMA, ), ).toThrow(/newer than this build/) @@ -545,6 +547,7 @@ describe("local migration registry", () => { "local-0022-to-0023-ai-crawler-requests", "local-0023-to-0024-trace-facets-hourly", "local-0024-to-0025-trace-facets-hourly-daily-partition", + "local-0025-to-0026-ai-trace-index-gateway-stamps", ] const versions = (id: string) => { const match = /^local-(\d{4})-to-(\d{4})-[a-z0-9]+(-[a-z0-9]+)*$/.exec(id) @@ -1557,6 +1560,7 @@ describe("v10 -> v11 product events module", () => { "local-0022-to-0023-ai-crawler-requests", "local-0023-to-0024-trace-facets-hourly", "local-0024-to-0025-trace-facets-hourly-daily-partition", + "local-0025-to-0026-ai-trace-index-gateway-stamps", ]) expect(chain[0]?.to).toEqual(LOCAL_SCHEMA_V11) // The dropped table is declared, and the backfilled ones say what they diff --git a/apps/cli/test/native-local-store-migration.sh b/apps/cli/test/native-local-store-migration.sh index 59760d6b9c..3b16f25946 100755 --- a/apps/cli/test/native-local-store-migration.sh +++ b/apps/cli/test/native-local-store-migration.sh @@ -142,7 +142,7 @@ grep -q "local store migrated" "$ROOT/migrate.out" || fail "native migration did # must be bumped in lockstep with LOCAL_SCHEMA_VERSION and the matching # LOCAL_SCHEMA_V.fingerprint in apps/cli/src/server/schema-identity.ts; # leaving it on the previous version is what makes this step fail after a bump. -jq -e '.formatVersion == 2 and .activation == "active" and .schemaVersion == 25 and .schema == "bfaed79bcf2423f5"' \ +jq -e '.formatVersion == 2 and .activation == "active" and .schemaVersion == 26 and .schema == "203c87dde2b5aedc"' \ "$ROOT/maple-store-version.json" >/dev/null || fail "native migration wrote the wrong active identity" step "reopening promoted store in a fresh server" diff --git a/apps/ingest/src/clickhouse_insert_mappings.rs b/apps/ingest/src/clickhouse_insert_mappings.rs index 4cddff62cd..e736732d47 100644 --- a/apps/ingest/src/clickhouse_insert_mappings.rs +++ b/apps/ingest/src/clickhouse_insert_mappings.rs @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-clickhouse-insert-mappings.ts // Do not edit manually. -pub const PROJECT_REVISION: &str = "92181b09631cc85a9bfc1ea57dc85000f3c6770b0799e5b357692f4634ce8042"; +pub const PROJECT_REVISION: &str = "6f17e5994398bfddfebe6a99f13f097bbe822a95820cf6e0cb575082823d4f32"; // Gate for BYO-ClickHouse ingest readiness — the migration version, NOT the // Tinybird-coupled PROJECT_REVISION. Compared against // org_clickhouse_settings.schema_version. See @maple/domain/clickhouse diff --git a/packages/domain/src/clickhouse/migrations/0039_ai_trace_index_gateway_stamps.ts b/packages/domain/src/clickhouse/migrations/0039_ai_trace_index_gateway_stamps.ts new file mode 100644 index 0000000000..a096ef61c5 --- /dev/null +++ b/packages/domain/src/clickhouse/migrations/0039_ai_trace_index_gateway_stamps.ts @@ -0,0 +1,40 @@ +/** + * Migration 0039 — `ai_trace_index_mv` projects the ingest gateway's stamps. + * + * The view used to decide every GenAI fact itself, at insert: which span is a + * model call or a tool call (operation lists, span-name needles), whether it + * failed, the model, agent, tool and response id coalesced across each + * dialect's keys, and the usage under a per-provider convention. Those rules + * were integration knowledge in SQL, copied by hand from the read side, and + * the two drifted. The gateway now decides each of them once per span and + * stamps it as a `maple_ai.*` attribute (`MAPLE_AI_STAMP_ATTRS`, + * `apps/ingest/src/ai_session/facts.rs`), so the view reads those keys and + * nothing else — plus the generic columns, `error.type`, and the failure + * fingerprint's redaction chain. A dialect is now taught to the gateway. + * + * No column changes. NOTHING IS BACKFILLED: rows materialized before this + * migration keep the values the old view gave them until raw `traces`' 30-day + * TTL ages them out. The gateway must stamp before this view runs, or the + * spans ingested in between materialize as neither a call nor a tool, with no + * usage — so the ingest deploy goes first. + * + * The view is dropped and recreated because a materialized view's SELECT is + * frozen at creation. + * + * `requiredForIngest: false` — the gateway writes `traces`, never this table. + * + * Numbered 0039 because 0035-0038 are claimed by open pull requests; renumber + * at merge time. + * + * The CREATE statement below is the verbatim DDL as the schema emitter produced + * it at v39. Frozen history: never re-derive it from a later snapshot. + */ +export const migration_0039_ai_trace_index_gateway_stamps = { + version: 39, + description: "Recreate ai_trace_index_mv as a projection of the ingest gateway's maple_ai.* stamps", + requiredForIngest: false, + statements: [ + "DROP VIEW IF EXISTS ai_trace_index_mv", + "CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS\nSELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n SpanAttributes['maple_ai.model'] AS Model,\n SpanAttributes['maple_ai.agent.name'] AS AgentName,\n SpanAttributes['maple_ai.tool.name'] AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(SpanAttributes['maple_ai.error'] = '1') AS IsError,\n toUInt8(SpanAttributes['maple_ai.llm_call'] = '1') AS IsLlmCall,\n toUInt8(SpanAttributes['maple_ai.tool_call'] = '1') AS IsToolCall,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS Tokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cost']) AS Cost,\n SpanAttributes['maple_ai.response.id'] AS ResponseId,\n SpanAttributes['maple_ai.vendor.version'] AS VendorVersion,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) AS InputTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) AS CacheReadTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) AS CacheWriteTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) AS OutputTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS ReasoningTokens,\n SpanAttributes['error.type'] AS ErrorType,\n leftUTF8(StatusMessage, 400) AS StatusMessage,\n SpanAttributes['maple_ai.tool.description'] AS ToolDescription,\n SpanAttributes['maple_ai.tool.error_result'] AS FailedToolCallResult,\n if(SpanAttributes['maple_ai.error'] = '1', cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(SpanAttributes['maple_ai.tool.error_result'], ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''", + ], +} as const diff --git a/packages/domain/src/clickhouse/migrations/index.test.ts b/packages/domain/src/clickhouse/migrations/index.test.ts index 5850b21fdc..e1c4ddd9a3 100644 --- a/packages/domain/src/clickhouse/migrations/index.test.ts +++ b/packages/domain/src/clickhouse/migrations/index.test.ts @@ -39,6 +39,7 @@ import { migration_0031_ai_trace_index_list_columns } from "./0031_ai_trace_inde import { migration_0032_ai_trace_index_tool_detail_columns } from "./0032_ai_trace_index_tool_detail_columns" import { migration_0033_ai_crawler_requests } from "./0033_ai_crawler_requests" import { migration_0034_trace_facets_hourly, traceFacetsHourlyBackfill } from "./0034_trace_facets_hourly" +import { migration_0039_ai_trace_index_gateway_stamps } from "./0039_ai_trace_index_gateway_stamps" import { latestSnapshotStatements } from "../../generated/clickhouse-schema" import { clickHouseSchemaVersion, latestMigrationVersion, migrations } from "./index" @@ -56,10 +57,10 @@ describe("ClickHouse migrations", () => { it("keeps migrations ordered by version", () => { expect(migrations.map((m) => m.version)).toEqual([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, + 28, 29, 30, 31, 32, 33, 34, 39, ]) - expect(migrations.at(-1)).toBe(migration_0034_trace_facets_hourly) - expect(latestMigrationVersion).toBe(34) + expect(migrations.at(-1)).toBe(migration_0039_ai_trace_index_gateway_stamps) + expect(latestMigrationVersion).toBe(39) // 0010 and 0014-0020 are read-path only and skipped by the ingest-gating // version; 0021 is not — the gateway writes `session_events`' new identity // columns and `product_events` directly, so a BYO-CH org must apply it @@ -96,6 +97,8 @@ describe("ClickHouse migrations", () => { expect(migration_0033_ai_crawler_requests.requiredForIngest).toBe(false) // 0034 adds the MV-populated trace_facets_hourly. expect(migration_0034_trace_facets_hourly.requiredForIngest).toBe(false) + // 0039 only recreates the MV-populated ai_trace_index's view. + expect(migration_0039_ai_trace_index_gateway_stamps.requiredForIngest).toBe(false) }) it("recreates both error-events MVs with the span-attribute exception fallback", () => { @@ -922,3 +925,40 @@ describe("migration 0032 — ai_trace_index tool detail columns", () => { expect(migration.statements.some(isBackfill)).toBe(false) }) }) + +describe("migration 0039 — ai_trace_index_mv projects the gateway's stamps", () => { + it("recreates the view over the maple_ai.* stamps, with no dialect key and no convention", () => { + const [drop, create, ...rest] = migration_0039_ai_trace_index_gateway_stamps.statements + expect(rest).toEqual([]) + expect(drop).toBe("DROP VIEW IF EXISTS ai_trace_index_mv") + expect(create).toBe(latestSnapshotStatements.find((stmt) => stmt.includes("ai_trace_index_mv TO"))) + for (const projection of [ + "SpanAttributes['maple_ai.model'] AS Model", + "SpanAttributes['maple_ai.agent.name'] AS AgentName", + "SpanAttributes['maple_ai.tool.name'] AS ToolName", + "toUInt8(SpanAttributes['maple_ai.error'] = '1') AS IsError", + "toUInt8(SpanAttributes['maple_ai.llm_call'] = '1') AS IsLlmCall", + "toUInt8(SpanAttributes['maple_ai.tool_call'] = '1') AS IsToolCall", + "toFloat64OrZero(SpanAttributes['maple_ai.usage.cost']) AS Cost", + "SpanAttributes['maple_ai.response.id'] AS ResponseId", + "toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) AS InputTokens", + "SpanAttributes['maple_ai.tool.description'] AS ToolDescription", + "SpanAttributes['maple_ai.tool.error_result'] AS FailedToolCallResult", + ]) { + expect(create).toContain(projection) + } + // The only attribute keys read are the gateway's, `error.type`, and the + // resource's environment. + const keys = [...create.matchAll(/Attributes\['([^']+)'\]/g)].map(([, key]) => key) + expect(new Set(keys.filter((key) => !key.startsWith("maple_ai.")))).toEqual( + new Set(["deployment.environment.name", "deployment.environment", "error.type"]), + ) + expect(create).not.toContain("multiIf") + expect(create).not.toContain("LIKE") + }) + + it("does not backfill and does not gate ingest", () => { + expect(migration_0039_ai_trace_index_gateway_stamps.requiredForIngest).toBe(false) + expect(migration_0039_ai_trace_index_gateway_stamps.statements.some(isBackfill)).toBe(false) + }) +}) diff --git a/packages/domain/src/clickhouse/migrations/index.ts b/packages/domain/src/clickhouse/migrations/index.ts index 7c5a5aecbd..f47e2343c8 100644 --- a/packages/domain/src/clickhouse/migrations/index.ts +++ b/packages/domain/src/clickhouse/migrations/index.ts @@ -33,6 +33,7 @@ import { migration_0031_ai_trace_index_list_columns } from "./0031_ai_trace_inde import { migration_0032_ai_trace_index_tool_detail_columns } from "./0032_ai_trace_index_tool_detail_columns" import { migration_0033_ai_crawler_requests } from "./0033_ai_crawler_requests" import { migration_0034_trace_facets_hourly } from "./0034_trace_facets_hourly" +import { migration_0039_ai_trace_index_gateway_stamps } from "./0039_ai_trace_index_gateway_stamps" /** * A migration statement is either a raw SQL string (structural DDL) or a @@ -98,6 +99,7 @@ export const migrations: ReadonlyArray = [ migration_0032_ai_trace_index_tool_detail_columns, migration_0033_ai_crawler_requests, migration_0034_trace_facets_hourly, + migration_0039_ai_trace_index_gateway_stamps, ] as const /** Highest migration `version` bundled — i.e. the schema level a fully-applied diff --git a/packages/domain/src/gen-ai.ts b/packages/domain/src/gen-ai.ts index 214cb2cf03..89535dfd93 100644 --- a/packages/domain/src/gen-ai.ts +++ b/packages/domain/src/gen-ai.ts @@ -119,6 +119,51 @@ export const MAPLE_GENAI_INPUT_MESSAGES_DROPPED_ATTR = "maple_ai.input_messages_ */ export const MAPLE_GENAI_MODEL_DURATION_MS_ATTR = "maple_ai.model_duration_ms" +/** + * Every fact Agent Sessions aggregates or filters on, as the ingest gateway + * decided it for one span (`apps/ingest/src/ai_session/facts.rs`, `usage.rs`). + * `ai_trace_index_mv` projects these and holds no vendor rule, and the detail + * page reads the same ones, so the list and the page cannot disagree. + * + * - `llmCall`: `"1"` on the model call, `"0"` on every other stamped span. Its + * presence is what says the gateway decided the rest; a span ingested + * before it did carries none, and its readers keep their op/name rules and + * usage conventions for it until it ages out of the 30-day TTL. + * - `toolCall`, `error`, `toolPaused`: `"1"` where they hold, absent otherwise. + * `toolPaused` marks a tool call's copy that recorded no outcome — no result + * or only Google ADK's confirmation request — which a call paused for a + * human's approval leaves behind. + * - `model`, `agentName`, `toolName`, `toolCallId`, `responseId`: the first + * non-empty value across the dialects' keys; `responseId` on model calls. + * - `toolDescription` (on tool calls) and `toolErrorResult` (a failed tool + * call's result), cut by the gateway. + * - The usage buckets, on the model call alone: `inputTokens` the uncached + * prompt, `outputTokens` the visible completion, a span's total the plain + * sum of the five; `cost` in USD as the emitter priced the call. An agent or + * workflow wrapper repeating its calls' usage carries none. + * + * Gateway-owned: stripped from customer input with the rest of the namespace. + */ +export const MAPLE_AI_STAMP_ATTRS = { + llmCall: "maple_ai.llm_call", + toolCall: "maple_ai.tool_call", + error: "maple_ai.error", + toolPaused: "maple_ai.tool.paused", + model: "maple_ai.model", + agentName: "maple_ai.agent.name", + toolName: "maple_ai.tool.name", + toolCallId: "maple_ai.tool.call_id", + responseId: "maple_ai.response.id", + toolDescription: "maple_ai.tool.description", + toolErrorResult: "maple_ai.tool.error_result", + inputTokens: "maple_ai.usage.input_tokens", + cacheReadTokens: "maple_ai.usage.cache_read_tokens", + cacheWriteTokens: "maple_ai.usage.cache_write_tokens", + outputTokens: "maple_ai.usage.output_tokens", + reasoningTokens: "maple_ai.usage.reasoning_tokens", + cost: "maple_ai.usage.cost", +} as const + // Usage conventions — which of a reporter's token figures already contain // which others. // @@ -128,11 +173,13 @@ export const MAPLE_GENAI_MODEL_DURATION_MS_ATTR = "maple_ai.model_duration_ms" // Anthropic's `input_tokens` EXCLUDES `cache_read_input_tokens` and // `cache_creation_input_tokens`; Gemini's `candidatesTokenCount` EXCLUDES // `thoughtsTokenCount`. A total that adds the five as if they were disjoint -// bills a cache-heavy or reasoning-heavy call nearly twice. Both readers of -// usage — `spanTokenBuckets` in the web app and `genAiTokensExpr` behind -// `ai_trace_index` — resolve the reporter's convention here first and carve -// the contained buckets back out, so `input` always means the uncached prompt, -// `output` the visible completion, and a total is always the plain sum. +// bills a cache-heavy or reasoning-heavy call nearly twice. The ingest gateway +// settles this per emitter and stamps disjoint buckets (`MAPLE_AI_STAMP_ATTRS`); +// `spanTokenBuckets` resolves the reporter's convention here only for a span +// ingested before it did, carving the contained buckets back out, so `input` +// always means the uncached prompt, `output` the visible completion, and a +// total is always the plain sum. Remove once those spans have aged out of the +// 30-day TTL. export interface GenAiUsageConvention { /** The prompt figure already contains the cache-read and cache-write buckets. */ @@ -255,6 +302,17 @@ export const AI_GENAI_FIELDS = { // none. Instrumentations that price calls themselves (OpenLLMetry, // OpenInference, Logfire) each use their own key; this is OpenLLMetry's. usageCost: { key: "gen_ai.usage.cost", type: "number" }, + // The gateway's verdicts and disjoint usage buckets — see + // `MAPLE_AI_STAMP_ATTRS`. + mapleLlmCall: { key: MAPLE_AI_STAMP_ATTRS.llmCall, type: "number" }, + mapleToolCall: { key: MAPLE_AI_STAMP_ATTRS.toolCall, type: "number" }, + mapleError: { key: MAPLE_AI_STAMP_ATTRS.error, type: "number" }, + mapleInputTokens: { key: MAPLE_AI_STAMP_ATTRS.inputTokens, type: "number" }, + mapleCacheReadTokens: { key: MAPLE_AI_STAMP_ATTRS.cacheReadTokens, type: "number" }, + mapleCacheWriteTokens: { key: MAPLE_AI_STAMP_ATTRS.cacheWriteTokens, type: "number" }, + mapleOutputTokens: { key: MAPLE_AI_STAMP_ATTRS.outputTokens, type: "number" }, + mapleReasoningTokens: { key: MAPLE_AI_STAMP_ATTRS.reasoningTokens, type: "number" }, + mapleCost: { key: MAPLE_AI_STAMP_ATTRS.cost, type: "number" }, // conversation conversationId: { key: "gen_ai.conversation.id", type: "string" }, diff --git a/packages/domain/src/generated/clickhouse-schema.ts b/packages/domain/src/generated/clickhouse-schema.ts index ba9061d079..99c7b22519 100644 --- a/packages/domain/src/generated/clickhouse-schema.ts +++ b/packages/domain/src/generated/clickhouse-schema.ts @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-clickhouse-schema.ts // Do not edit manually. -export const projectRevision = "92181b09631cc85a9bfc1ea57dc85000f3c6770b0799e5b357692f4634ce8042" as const +export const projectRevision = "6f17e5994398bfddfebe6a99f13f097bbe822a95820cf6e0cb575082823d4f32" as const export const latestSnapshotStatements: ReadonlyArray = [ "CREATE TABLE IF NOT EXISTS ai_crawler_requests (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n ServiceName LowCardinality(String),\n Crawler LowCardinality(String),\n Host LowCardinality(String),\n Path String,\n HttpStatus UInt16\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 30 DAY", @@ -47,7 +47,7 @@ export const latestSnapshotStatements: ReadonlyArray = [ "CREATE TABLE IF NOT EXISTS traces (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SpanId String,\n ParentSpanId String,\n TraceState String,\n SpanName LowCardinality(String),\n SpanKind LowCardinality(String),\n ServiceName LowCardinality(String),\n ResourceSchemaUrl String,\n ResourceAttributes Map(LowCardinality(String), String),\n ScopeSchemaUrl String,\n ScopeName String,\n ScopeVersion String,\n ScopeAttributes Map(LowCardinality(String), String),\n Duration UInt64 DEFAULT 0,\n StatusCode LowCardinality(String),\n StatusMessage String,\n SpanAttributes Map(LowCardinality(String), String),\n EventsTimestamp Array(DateTime64(9)),\n EventsName Array(LowCardinality(String)),\n EventsAttributes Array(Map(LowCardinality(String), String)),\n LinksTraceId Array(String),\n LinksSpanId Array(String),\n LinksTraceState Array(String),\n LinksAttributes Array(Map(LowCardinality(String), String)),\n SampleRate Float64 DEFAULT multiIf(SpanAttributes['SampleRate'] != '' AND toFloat64OrZero(SpanAttributes['SampleRate']) >= 1.0, toFloat64OrZero(SpanAttributes['SampleRate']), match(TraceState, 'th:[0-9a-f]+'), 1.0 / greatest(1.0 - reinterpretAsUInt64(reverse(unhex(rightPad(extract(TraceState, 'th:([0-9a-f]+)'), 16, '0')))) / pow(2.0, 64), 0.0001), 1.0),\n IsEntryPoint UInt8 DEFAULT if(SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '', 1, 0),\n ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)),\n ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)),\n SpanAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(SpanAttributes), mapValues(SpanAttributes)),\n INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_span_attr_keys mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_span_attr_vals mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,\n INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, ServiceName, SpanName, toDateTime(Timestamp))\nTTL toDate(Timestamp) + INTERVAL 30 DAY", "CREATE TABLE IF NOT EXISTS traces_aggregates_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n ServiceName LowCardinality(String),\n SpanName LowCardinality(String),\n SpanKind LowCardinality(String),\n StatusCode LowCardinality(String),\n IsEntryPoint UInt8,\n DeploymentEnv LowCardinality(String),\n WeightedCount SimpleAggregateFunction(sum, Float64),\n WeightedDurationSum SimpleAggregateFunction(sum, Float64),\n WeightedErrorCount SimpleAggregateFunction(sum, Float64),\n DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95, 0.99), UInt64, UInt32),\n DurationMin SimpleAggregateFunction(min, UInt64),\n DurationMax SimpleAggregateFunction(max, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv)\nTTL toDate(Hour) + INTERVAL 365 DAY", "CREATE MATERIALIZED VIEW IF NOT EXISTS ai_crawler_requests_mv TO ai_crawler_requests AS\nSELECT\n OrgId,\n Timestamp,\n TraceId,\n ServiceName,\n arrayElement(['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'Meta-ExternalAgent', 'Meta-WebIndexer', 'Meta-ExternalFetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai'], multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'meta-externalagent', 'meta-webindexer', 'meta-externalfetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai'])) AS Crawler,\n lower(replaceRegexpOne(coalesce(nullIf(SpanAttributes['server.address'], ''), nullIf(SpanAttributes['http.host'], ''), nullIf(SpanAttributes['net.host.name'], ''), nullIf(domain(SpanAttributes['url.full']), ''), nullIf(domain(SpanAttributes['http.url']), ''), ''), ':[0-9]+$', '')) AS Host,\n leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) AS Path,\n toUInt16OrZero(coalesce(nullIf(SpanAttributes['http.response.status_code'], ''), nullIf(SpanAttributes['http.status_code'], ''), '')) AS HttpStatus\n FROM traces\n WHERE SpanKind = 'Server'\n AND multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'meta-externalagent', 'meta-webindexer', 'meta-externalfetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai']) > 0\n AND leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) != ''", - "CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS\nSELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model,\n coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName,\n coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall,\n multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('openai', 'gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai', 'openrouter'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])))) + multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic', 'openai', 'openrouter'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])))) AS Tokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost,\n coalesce(nullIf(SpanAttributes['gen_ai.response.id'], ''), SpanAttributes['ai.response.id']) AS ResponseId,\n SpanAttributes['maple_ai.vendor.version'] AS VendorVersion,\n multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('openai', 'gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai', 'openrouter'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])))) AS InputTokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) AS CacheReadTokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) AS CacheWriteTokens,\n multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic', 'openai', 'openrouter'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])))) AS OutputTokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS ReasoningTokens,\n SpanAttributes['error.type'] AS ErrorType,\n leftUTF8(StatusMessage, 400) AS StatusMessage,\n leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.description'], ''), SpanAttributes['tool.description']), 2000) AS ToolDescription,\n if((((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')) AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))), leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), SpanAttributes['ai.toolCall.result']), 1000), '') AS FailedToolCallResult,\n if(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')), cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(if((((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')) AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))), leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), SpanAttributes['ai.toolCall.result']), 1000), ''), ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''", + "CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS\nSELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n SpanAttributes['maple_ai.model'] AS Model,\n SpanAttributes['maple_ai.agent.name'] AS AgentName,\n SpanAttributes['maple_ai.tool.name'] AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(SpanAttributes['maple_ai.error'] = '1') AS IsError,\n toUInt8(SpanAttributes['maple_ai.llm_call'] = '1') AS IsLlmCall,\n toUInt8(SpanAttributes['maple_ai.tool_call'] = '1') AS IsToolCall,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS Tokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cost']) AS Cost,\n SpanAttributes['maple_ai.response.id'] AS ResponseId,\n SpanAttributes['maple_ai.vendor.version'] AS VendorVersion,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) AS InputTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) AS CacheReadTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) AS CacheWriteTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) AS OutputTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS ReasoningTokens,\n SpanAttributes['error.type'] AS ErrorType,\n leftUTF8(StatusMessage, 400) AS StatusMessage,\n SpanAttributes['maple_ai.tool.description'] AS ToolDescription,\n SpanAttributes['maple_ai.tool.error_result'] AS FailedToolCallResult,\n if(SpanAttributes['maple_ai.error'] = '1', cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(SpanAttributes['maple_ai.tool.error_result'], ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''", "CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_by_time_mv TO error_events_by_time AS\nWITH\n arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei,\n -- Only fill the old Unknown Error bucket. Event values (including\n -- empty fields) and spans with StatusMessage keep every hash input.\n _ei = 0 AND StatusMessage = '' AS _useAttrs,\n if(\n _ei > 0, EventsAttributes[_ei]['exception.type'],\n if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.type'], ''), SpanAttributes['error.type']), '')\n ) AS _exType,\n if(\n _ei > 0, EventsAttributes[_ei]['exception.message'],\n if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.message'], ''), SpanAttributes['error.message']), StatusMessage)\n ) AS _exMsg,\n if(\n _ei > 0, EventsAttributes[_ei]['exception.stacktrace'],\n if(_useAttrs, SpanAttributes['exception.stacktrace'], '')\n ) AS _exStack,\n if(_useAttrs, _exMsg, StatusMessage) AS _msgText,\n -- Frame lines are matched by SHAPE, not by \"contains :NUMBER\". The old\n -- rule accepted any line with a colon-digit, which let non-frame lines\n -- in: Drizzle's `params: ` line, and the `Type: message`\n -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values\n -- and message text then entered the hash and split one bug into\n -- thousands of issues — 23,035 fingerprints for six real\n -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError\n -- ones.\n --\n -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts,\n -- as is every redaction below. They used to be hand-copied here, which\n -- let the reference implementation the tests exercise drift away from\n -- the SQL that actually runs, silently.\n arraySlice(\n arrayFilter(\n line -> match(line, '^[ \\\\t]*at |^[ \\\\t]*File \"|^[ \\\\t]+from [^ ]+:[0-9]+|^[^ \\\\t@]+@[^ \\\\t]*:[0-9]+|^[ \\\\t]+[^ \\\\t]+\\\\.(go|rs):[0-9]+|^[0-9]+ +\\\\S.* +0x[0-9a-fA-F]+'),\n splitByChar('\\n', _exStack)\n ),\n 1, 3\n ) AS _rawFrames,\n -- Redact every volatile token a frame line can carry: the URL origin\n -- (so preview hosts share one fingerprint), Vite's 8-char bundle\n -- content hash (so a deploy does not re-split every triaged browser and\n -- Worker issue), then line numbers, hex pointers and long id runs. See\n -- FRAME_REDACTIONS for the order and the reasoning.\n arrayMap(\n line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''),\n _rawFrames\n ) AS _topFrames,\n if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame,\n arrayStringConcat(_topFrames, '\\n') AS _fpFrames,\n -- JSON detection for the message signature below.\n isValidJSON(_msgText) AS _isJson,\n _isJson AND JSONType(_msgText) = 'Object' AS _isJsonObj,\n -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level\n -- keys, redact volatile tokens (long hex / numbers) in each raw value, then\n -- sort by \"key=value\" so key order & whitespace don't matter. No assumption\n -- about which keys exist — works for any producer's JSON shape. (Nested\n -- objects are hashed as their raw substring; only top-level is canonicalized.)\n arrayStringConcat(\n arraySort(\n arrayMap(\n kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')),\n JSONExtractKeysAndValuesRaw(_msgText)\n )\n ),\n '|'\n ) AS _jsonSig,\n -- The message signature is folded in ALWAYS, not only when there are no\n -- frames. Bundled runtimes minify every module into one file, so the top\n -- three frames of a Worker error are `toDatabaseError (worker.js)` for\n -- every failing query alike: on frames alone, 25 distinct DatabaseError\n -- bugs (316k occurrences) collapse into a single issue. The signature\n -- restores that discrimination, and it cannot reinflate cardinality the\n -- way a raw prefix would because everything variable is redacted first:\n -- emails, URL origins, home directories, query strings, quoted values,\n -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order,\n -- what is deliberately kept, and the one residual it cannot reach.\n multiIf(\n _isJsonObj, _jsonSig,\n substringUTF8(\n replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(_msgText, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'),\n 1, 120\n )\n ) AS _msgSig,\n -- Display-only, best-effort human label (decoupled from the fingerprint:\n -- many labels may map to one hash). The broad key list here is a DISPLAY\n -- heuristic only; the fingerprint above makes no key-name assumption.\n multiIf(\n JSONExtractString(_msgText, 'title') != '', JSONExtractString(_msgText, 'title'),\n JSONExtractString(_msgText, 'message') != '', JSONExtractString(_msgText, 'message'),\n JSONExtractString(_msgText, 'error') != '', JSONExtractString(_msgText, 'error'),\n JSONExtractString(_msgText, '_tag') != '', JSONExtractString(_msgText, '_tag'),\n JSONExtractString(_msgText, 'reason') != '', JSONExtractString(_msgText, 'reason'),\n JSONExtractString(_msgText, 'name') != '', JSONExtractString(_msgText, 'name'),\n JSONExtractString(_msgText, 'type') != '', extract(JSONExtractString(_msgText, 'type'), '([^/]+)$'),\n 'JSON error'\n ) AS _jsonLabel,\n multiIf(\n _msgText = '', 'Unknown Error',\n position(_msgText, '{ readonly') = 1 OR position(_msgText, '└─') > 0,\n if(\n extract(_msgText, 'readonly (\\\\w+)') != '',\n concat('Schema parse error: ', extract(_msgText, 'readonly (\\\\w+)')),\n 'Schema parse error'\n ),\n _isJsonObj OR position(_msgText, '[') = 1, _jsonLabel,\n left(_msgText, multiIf(\n position(_msgText, ': ') > 3, toInt64(position(_msgText, ': ')) - 1,\n position(_msgText, ' (') > 3, toInt64(position(_msgText, ' (')) - 1,\n position(_msgText, '\\n') > 3, toInt64(position(_msgText, '\\n')) - 1,\n least(toInt64(length(_msgText)), 150)\n ))\n ) AS _statusLabel,\n if(_exType != '', _exType, _statusLabel) AS _errorLabel,\n -- Both semconv spellings; the current key wins when both are present.\n toUInt16OrZero(\n if(\n SpanAttributes['http.response.status_code'] != '',\n SpanAttributes['http.response.status_code'],\n SpanAttributes['http.status_code']\n )\n ) AS _httpStatus\n SELECT\n OrgId,\n toDateTime(Timestamp) AS Timestamp,\n TraceId,\n SpanId,\n ParentSpanId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n _exType AS ExceptionType,\n _exMsg AS ExceptionMessage,\n _exStack AS ExceptionStacktrace,\n _topFrame AS TopFrame,\n cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash,\n StatusMessage,\n Duration,\n _errorLabel AS ErrorLabel,\n ResourceAttributes['service.version'] AS ServiceVersion\n FROM traces\n WHERE StatusCode = 'Error'\n -- Client-side runtimes (notably the native Cloudflare Workers\n -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot\n -- traffic arrived here as unlabelled \"Unknown Error\" issues. Drop a\n -- span only when all hold: 4xx, no exception event, no exception.type\n -- attribute, and no error.type beyond the status code itself (HTTP\n -- semconv sets error.type to the bare status on a non-2xx response,\n -- which carries no exception). 5xx and anything carrying a real\n -- exception still count, and SpanKind is deliberately not consulted —\n -- these are Client spans.\n AND NOT (\n _httpStatus >= 400 AND _httpStatus < 500\n AND _ei = 0\n AND SpanAttributes['exception.type'] = ''\n AND (SpanAttributes['error.type'] = '' OR SpanAttributes['error.type'] = toString(_httpStatus))\n )", "CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_mv TO error_events AS\nWITH\n arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei,\n -- Only fill the old Unknown Error bucket. Event values (including\n -- empty fields) and spans with StatusMessage keep every hash input.\n _ei = 0 AND StatusMessage = '' AS _useAttrs,\n if(\n _ei > 0, EventsAttributes[_ei]['exception.type'],\n if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.type'], ''), SpanAttributes['error.type']), '')\n ) AS _exType,\n if(\n _ei > 0, EventsAttributes[_ei]['exception.message'],\n if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.message'], ''), SpanAttributes['error.message']), StatusMessage)\n ) AS _exMsg,\n if(\n _ei > 0, EventsAttributes[_ei]['exception.stacktrace'],\n if(_useAttrs, SpanAttributes['exception.stacktrace'], '')\n ) AS _exStack,\n if(_useAttrs, _exMsg, StatusMessage) AS _msgText,\n -- Frame lines are matched by SHAPE, not by \"contains :NUMBER\". The old\n -- rule accepted any line with a colon-digit, which let non-frame lines\n -- in: Drizzle's `params: ` line, and the `Type: message`\n -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values\n -- and message text then entered the hash and split one bug into\n -- thousands of issues — 23,035 fingerprints for six real\n -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError\n -- ones.\n --\n -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts,\n -- as is every redaction below. They used to be hand-copied here, which\n -- let the reference implementation the tests exercise drift away from\n -- the SQL that actually runs, silently.\n arraySlice(\n arrayFilter(\n line -> match(line, '^[ \\\\t]*at |^[ \\\\t]*File \"|^[ \\\\t]+from [^ ]+:[0-9]+|^[^ \\\\t@]+@[^ \\\\t]*:[0-9]+|^[ \\\\t]+[^ \\\\t]+\\\\.(go|rs):[0-9]+|^[0-9]+ +\\\\S.* +0x[0-9a-fA-F]+'),\n splitByChar('\\n', _exStack)\n ),\n 1, 3\n ) AS _rawFrames,\n -- Redact every volatile token a frame line can carry: the URL origin\n -- (so preview hosts share one fingerprint), Vite's 8-char bundle\n -- content hash (so a deploy does not re-split every triaged browser and\n -- Worker issue), then line numbers, hex pointers and long id runs. See\n -- FRAME_REDACTIONS for the order and the reasoning.\n arrayMap(\n line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, 'https?://[^/ )]+', ''), '-[A-Za-z0-9_-]{8}\\\\.js', '.js'), '-[A-Za-z0-9_-]{8}\\\\.css', '.css'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''),\n _rawFrames\n ) AS _topFrames,\n if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame,\n arrayStringConcat(_topFrames, '\\n') AS _fpFrames,\n -- JSON detection for the message signature below.\n isValidJSON(_msgText) AS _isJson,\n _isJson AND JSONType(_msgText) = 'Object' AS _isJsonObj,\n -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level\n -- keys, redact volatile tokens (long hex / numbers) in each raw value, then\n -- sort by \"key=value\" so key order & whitespace don't matter. No assumption\n -- about which keys exist — works for any producer's JSON shape. (Nested\n -- objects are hashed as their raw substring; only top-level is canonicalized.)\n arrayStringConcat(\n arraySort(\n arrayMap(\n kv -> concat(kv.1, '=', replaceRegexpAll(kv.2, '[0-9a-fA-F]{8,}|[0-9]+', '#')),\n JSONExtractKeysAndValuesRaw(_msgText)\n )\n ),\n '|'\n ) AS _jsonSig,\n -- The message signature is folded in ALWAYS, not only when there are no\n -- frames. Bundled runtimes minify every module into one file, so the top\n -- three frames of a Worker error are `toDatabaseError (worker.js)` for\n -- every failing query alike: on frames alone, 25 distinct DatabaseError\n -- bugs (316k occurrences) collapse into a single issue. The signature\n -- restores that discrimination, and it cannot reinflate cardinality the\n -- way a raw prefix would because everything variable is redacted first:\n -- emails, URL origins, home directories, query strings, quoted values,\n -- then ids and every digit run. See MSG_TEXT_REDACTIONS for the order,\n -- what is deliberately kept, and the one residual it cannot reach.\n multiIf(\n _isJsonObj, _jsonSig,\n substringUTF8(\n replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(_msgText, 1, 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#'),\n 1, 120\n )\n ) AS _msgSig,\n -- Display-only, best-effort human label (decoupled from the fingerprint:\n -- many labels may map to one hash). The broad key list here is a DISPLAY\n -- heuristic only; the fingerprint above makes no key-name assumption.\n multiIf(\n JSONExtractString(_msgText, 'title') != '', JSONExtractString(_msgText, 'title'),\n JSONExtractString(_msgText, 'message') != '', JSONExtractString(_msgText, 'message'),\n JSONExtractString(_msgText, 'error') != '', JSONExtractString(_msgText, 'error'),\n JSONExtractString(_msgText, '_tag') != '', JSONExtractString(_msgText, '_tag'),\n JSONExtractString(_msgText, 'reason') != '', JSONExtractString(_msgText, 'reason'),\n JSONExtractString(_msgText, 'name') != '', JSONExtractString(_msgText, 'name'),\n JSONExtractString(_msgText, 'type') != '', extract(JSONExtractString(_msgText, 'type'), '([^/]+)$'),\n 'JSON error'\n ) AS _jsonLabel,\n multiIf(\n _msgText = '', 'Unknown Error',\n position(_msgText, '{ readonly') = 1 OR position(_msgText, '└─') > 0,\n if(\n extract(_msgText, 'readonly (\\\\w+)') != '',\n concat('Schema parse error: ', extract(_msgText, 'readonly (\\\\w+)')),\n 'Schema parse error'\n ),\n _isJsonObj OR position(_msgText, '[') = 1, _jsonLabel,\n left(_msgText, multiIf(\n position(_msgText, ': ') > 3, toInt64(position(_msgText, ': ')) - 1,\n position(_msgText, ' (') > 3, toInt64(position(_msgText, ' (')) - 1,\n position(_msgText, '\\n') > 3, toInt64(position(_msgText, '\\n')) - 1,\n least(toInt64(length(_msgText)), 150)\n ))\n ) AS _statusLabel,\n if(_exType != '', _exType, _statusLabel) AS _errorLabel,\n -- Both semconv spellings; the current key wins when both are present.\n toUInt16OrZero(\n if(\n SpanAttributes['http.response.status_code'] != '',\n SpanAttributes['http.response.status_code'],\n SpanAttributes['http.status_code']\n )\n ) AS _httpStatus\n SELECT\n OrgId,\n toDateTime(Timestamp) AS Timestamp,\n TraceId,\n SpanId,\n ParentSpanId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n _exType AS ExceptionType,\n _exMsg AS ExceptionMessage,\n _exStack AS ExceptionStacktrace,\n _topFrame AS TopFrame,\n cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig) AS FingerprintHash,\n StatusMessage,\n Duration,\n _errorLabel AS ErrorLabel,\n ResourceAttributes['service.version'] AS ServiceVersion\n FROM traces\n WHERE StatusCode = 'Error'\n -- Client-side runtimes (notably the native Cloudflare Workers\n -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot\n -- traffic arrived here as unlabelled \"Unknown Error\" issues. Drop a\n -- span only when all hold: 4xx, no exception event, no exception.type\n -- attribute, and no error.type beyond the status code itself (HTTP\n -- semconv sets error.type to the bare status on a non-2xx response,\n -- which carries no exception). 5xx and anything carrying a real\n -- exception still count, and SpanKind is deliberately not consulted —\n -- these are Client spans.\n AND NOT (\n _httpStatus >= 400 AND _httpStatus < 500\n AND _ei = 0\n AND SpanAttributes['exception.type'] = ''\n AND (SpanAttributes['error.type'] = '' OR SpanAttributes['error.type'] = toString(_httpStatus))\n )", "CREATE MATERIALIZED VIEW IF NOT EXISTS error_fingerprints_minutely_mv TO error_fingerprints_minutely AS\nSELECT\n OrgId,\n toStartOfMinute(Timestamp) AS Minute,\n FingerprintHash,\n anyLast(ServiceName) AS ServiceName,\n anyLast(ExceptionType) AS ExceptionType,\n anyLast(ExceptionMessage) AS ExceptionMessage,\n anyLast(ErrorLabel) AS ErrorLabel,\n anyLast(TopFrame) AS TopFrame,\n count() AS OccurrenceCount,\n min(Timestamp) AS FirstSeen,\n max(Timestamp) AS LastSeen,\n -- Distinct builds, not a sample: see ServiceVersions on the datasource.\n groupUniqArray(ServiceVersion) AS ServiceVersions\n FROM error_events\n GROUP BY OrgId, Minute, FingerprintHash", diff --git a/packages/domain/src/generated/tinybird-project-manifest.ts b/packages/domain/src/generated/tinybird-project-manifest.ts index a253ccf616..0316c9bdf5 100644 --- a/packages/domain/src/generated/tinybird-project-manifest.ts +++ b/packages/domain/src/generated/tinybird-project-manifest.ts @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-tinybird-project-manifest.ts // Do not edit manually. -export const projectRevision = "92181b09631cc85a9bfc1ea57dc85000f3c6770b0799e5b357692f4634ce8042" as const +export const projectRevision = "6f17e5994398bfddfebe6a99f13f097bbe822a95820cf6e0cb575082823d4f32" as const export const datasources = [ { @@ -225,7 +225,7 @@ export const pipes = [ { name: "ai_trace_index_mv", content: - "DESCRIPTION >\n Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), pre-extracting the maple_ai.* identity, the environment, the GenAI model/agent/tool, the span's kind and usage, and its failure — whether it failed, the error.type it named, its status message, a failed tool call's result and the failure's fingerprint — plus the tool's own description, to plain columns.\n\nNODE ai_trace_index_mv_node\nSQL >\n SELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) AS Model,\n coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), SpanAttributes['ai.telemetry.functionId']) AS AgentName,\n coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))) AS IsError,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR (((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'))) AND NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))) AND (coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), SpanAttributes['llm.model_name']) != '' OR (lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'))))) AS IsLlmCall,\n toUInt8((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))) AS IsToolCall,\n multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('openai', 'gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai', 'openrouter'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])))) + multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic', 'openai', 'openrouter'), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])), greatest(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])))) AS Tokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), SpanAttributes['llm.cost.total'])) AS Cost,\n coalesce(nullIf(SpanAttributes['gen_ai.response.id'], ''), SpanAttributes['ai.response.id']) AS ResponseId,\n SpanAttributes['maple_ai.vendor.version'] AS VendorVersion,\n multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('openai', 'gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai', 'openrouter'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), SpanAttributes['llm.token_count.prompt'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])))) AS InputTokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), SpanAttributes['llm.token_count.prompt_details.cache_read'])) AS CacheReadTokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_creation.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.cache_write.input_tokens'], ''), SpanAttributes['ai.usage.inputTokenDetails.cacheWriteTokens'])) AS CacheWriteTokens,\n multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('anthropic', 'openai', 'openrouter'), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning']))), coalesce(nullIf(SpanAttributes['gen_ai.provider.name'], ''), nullIf(SpanAttributes['gen_ai.system'], ''), nullIf(SpanAttributes['ai.model.provider'], ''), nullIf(SpanAttributes['llm.provider'], ''), SpanAttributes['llm.system']) IN ('gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])), greatest(0, toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), SpanAttributes['llm.token_count.completion'])) - toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])))) AS OutputTokens,\n toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.reasoning.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.output_tokens.reasoning'], ''), nullIf(SpanAttributes['ai.usage.reasoningTokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokenDetails.reasoningTokens'], ''), SpanAttributes['llm.token_count.completion_details.reasoning'])) AS ReasoningTokens,\n SpanAttributes['error.type'] AS ErrorType,\n leftUTF8(StatusMessage, 400) AS StatusMessage,\n leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.description'], ''), SpanAttributes['tool.description']), 2000) AS ToolDescription,\n if((((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')) AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))), leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), SpanAttributes['ai.toolCall.result']), 1000), '') AS FailedToolCallResult,\n if(((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')), cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(if((((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error')) AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) IN ('execute_tool') OR (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', '')) NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND (coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%')))), leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), SpanAttributes['ai.toolCall.result']), 1000), ''), ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''\n\nTYPE MATERIALIZED\nDATASOURCE ai_trace_index\nDEPLOYMENT_METHOD alter", + "DESCRIPTION >\n Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), projecting the facts the ingest gateway stamped on each (maple_ai.*: identity, model/agent/tool, kind, usage, failure, a failed tool call's result, the tool's description) plus the environment, the error.type, the status message and the failure's fingerprint, to plain columns.\n\nNODE ai_trace_index_mv_node\nSQL >\n SELECT\n OrgId,\n Timestamp,\n TraceId,\n SpanAttributes['maple_ai.session.id'] AS SessionId,\n SpanAttributes['maple_ai.vendor.id'] AS VendorId,\n ServiceName,\n coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv,\n SpanAttributes['maple_ai.model'] AS Model,\n SpanAttributes['maple_ai.agent.name'] AS AgentName,\n SpanAttributes['maple_ai.tool.name'] AS ToolName,\n SpanId,\n ParentSpanId,\n Duration,\n toUInt8(SpanAttributes['maple_ai.error'] = '1') AS IsError,\n toUInt8(SpanAttributes['maple_ai.llm_call'] = '1') AS IsLlmCall,\n toUInt8(SpanAttributes['maple_ai.tool_call'] = '1') AS IsToolCall,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS Tokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cost']) AS Cost,\n SpanAttributes['maple_ai.response.id'] AS ResponseId,\n SpanAttributes['maple_ai.vendor.version'] AS VendorVersion,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) AS InputTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) AS CacheReadTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) AS CacheWriteTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) AS OutputTokens,\n toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS ReasoningTokens,\n SpanAttributes['error.type'] AS ErrorType,\n leftUTF8(StatusMessage, 400) AS StatusMessage,\n SpanAttributes['maple_ai.tool.description'] AS ToolDescription,\n SpanAttributes['maple_ai.tool.error_result'] AS FailedToolCallResult,\n if(SpanAttributes['maple_ai.error'] = '1', cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(SpanAttributes['maple_ai.tool.error_result'], ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )\"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )\"]*', '?#'), '\\'[^\\' ]*/[^\\' ]*\\'|\\'[^\\' ]{25,}\\'', '\\'#\\''), '\"[^\" ]*/[^\" ]*\"|\"[^\" ]{25,}\"', '\"#\"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint\n FROM traces\n WHERE SpanAttributes['maple_ai.vendor.id'] != ''\n\nTYPE MATERIALIZED\nDATASOURCE ai_trace_index\nDEPLOYMENT_METHOD alter", }, { name: "error_events_by_time_mv", diff --git a/packages/domain/src/tinybird/datasources.ts b/packages/domain/src/tinybird/datasources.ts index e07a2258e0..4b670d4d48 100644 --- a/packages/domain/src/tinybird/datasources.ts +++ b/packages/domain/src/tinybird/datasources.ts @@ -1171,7 +1171,8 @@ export const aiTraceIndex = defineDatasource("ai_trace_index", { VendorId: t.string().lowCardinality(), ServiceName: t.string().lowCardinality(), // Migration 0026 — the sidebar's other facet dimensions, and the per-span - // measures the page ranks and filters on. All `gen-ai-columns.ts`. + // measures the page ranks and filters on. Since 0039 the GenAI ones project a + // fact the ingest gateway stamped (`gen-ai-columns.ts`). DeploymentEnv: t.string().lowCardinality(), Model: t.string().lowCardinality(), AgentName: t.string().lowCardinality(), @@ -1207,11 +1208,11 @@ export const aiTraceIndex = defineDatasource("ai_trace_index", { // its header each seeked `trace_detail_spans` inside the window's whole // spread of partitions — seconds to tens of seconds, and the header's // description was the page's render gate. All three are facts of the tool - // span itself. `StatusMessage` and `ToolDescription` are truncated by the - // view (`GENAI_STATUS_MESSAGE_MAX`, `GENAI_TOOL_DESCRIPTION_MAX`); only - // tool spans ever carry a description, so the column is '' on the rest. - // `FailedToolCallResult` is a failed tool call's result (truncated, - // `GENAI_FAILED_TOOL_CALL_RESULT_MAX`; '' on every other span), because + // span itself. `StatusMessage` is truncated by the view + // (`GENAI_STATUS_MESSAGE_MAX`), `ToolDescription` by the ingest gateway, + // which stamps it on tool calls only, so the column is '' on the rest. + // `FailedToolCallResult` is a failed tool call's result (truncated by the + // gateway; '' on every other span), because // several frameworks describe a tool failure there and nowhere else. // `ErrorFingerprint` groups failures: a hash of that result, else of the // status message, redacted as `error_events` redacts messages; 0 on spans diff --git a/packages/domain/src/tinybird/gen-ai-columns.ts b/packages/domain/src/tinybird/gen-ai-columns.ts index 358833968e..dd30973d9b 100644 --- a/packages/domain/src/tinybird/gen-ai-columns.ts +++ b/packages/domain/src/tinybird/gen-ai-columns.ts @@ -1,345 +1,90 @@ // The facts of one GenAI span that `ai_trace_index` carries, as SQL. // -// Every AI framework speaks its own attribute dialect, and the read side -// reconciles them per vendor in `@maple/query-engine-integrations` — after the -// spans are already in hand. The index has no such luxury: its materialized -// view sees one span at a time, at insert, and has to settle the model, the -// agent, the tool, the usage and the span's kind right then. So every rule -// lives here, as SQL the view compiles from — the same contract as -// `semconv-renames.ts`, for the same reason: the index's column and any -// raw-table read of the same fact must agree byte for byte, or a filter -// selects a population the facet never counted. +// Every fact the index aggregates or filters on is decided by the ingest +// gateway, once per span, and written onto the span as a `maple_ai.*` stamp +// (`MAPLE_AI_STAMP_ATTRS`, decided in `apps/ingest/src/ai_session/facts.rs` +// and `usage.rs`): whether the span is a model call or a tool call, whether it +// failed, its model, agent and tool, and a model call's usage as five disjoint +// buckets. So the view is a projection of those stamps plus generic OTel +// fields, and holds no vendor rule: a dialect is taught to the gateway, where +// the span is still whole, never to this SQL. The detail page reads the same +// stamps, which is what keeps the list and the page equal. // -// The key lists mirror the sources the integrations layer decodes (its default -// `gen_ai.*` keys plus the Vercel AI SDK and OpenInference dialects), and the -// classification rules transcribe `classifyAiSpan`/`isLlmCall` -// (`packages/agent-sessions/src/session-turns.ts`) and `spanTokenBuckets` -// (`session-summary.ts`) — so a session's "12 calls · $0.40" in the list agrees -// with its own overview. `ai-span-columns.test.ts` in the integrations package -// pins every key list to that layer's own alias tables, so a key added on one -// side cannot drift silently. +// The one computation left here is generic: the failure fingerprint, hashed +// through the redaction chain `error_events` fingerprints messages with. -import type { Condition, Expr } from "@maple-dev/effect-clickhouse/expr" +import type { Expr } from "@maple-dev/effect-clickhouse/expr" import * as CH from "@maple-dev/effect-clickhouse/expr" import { compile } from "@maple-dev/effect-clickhouse/sql" import * as T from "@maple-dev/effect-clickhouse/types" import { applyRedactions, chRedactChain, MSG_TEXT_REDACTIONS } from "./fingerprint" -import { - GENAI_DEFAULT_USAGE_CONVENTION, - GENAI_PROVIDER_USAGE_CONVENTIONS, - GENAI_VENDOR_USAGE_CONVENTIONS, - MAPLE_AI_VENDOR_ID_ATTR, - type GenAiUsageConvention, -} from "../gen-ai" +import { MAPLE_AI_STAMP_ATTRS } from "../gen-ai" -/** A `$.SpanAttributes`-shaped accessor: the builder's own, or the bare-column - * stand-in the SQL text below is compiled from. */ -export interface MapColumnLike { - get(key: string): Expr -} +const sql = (expr: Expr): string => compile(expr.toFragment()) -/** The span columns the classification and failure rules read alongside the - * attribute Map. */ -export interface GenAiSpanColumnsLike { - readonly SpanName: Expr - readonly StatusCode: Expr - readonly StatusMessage: Expr - readonly SpanAttributes: MapColumnLike -} +const attr = (key: string): Expr => + CH.mapGet(CH.dynamicColumn>("SpanAttributes"), key) -const mapColumn = (name: string): MapColumnLike => { - const column = CH.dynamicColumn>(name) - return { get: (key) => CH.mapGet(column, key) } -} +/** A gateway flag: `1` where it stamped `"1"`. */ +const flag = (key: string): Expr => CH.compileFnCall("toUInt8", attr(key).eq("1")) -/** The bare `traces` columns, for the SQL text the view is rendered from. */ -const rawSpan: GenAiSpanColumnsLike = { - SpanName: CH.dynamicColumn("SpanName"), - StatusCode: CH.dynamicColumn("StatusCode"), - StatusMessage: CH.dynamicColumn("StatusMessage"), - SpanAttributes: mapColumn("SpanAttributes"), -} +const number = (key: string): Expr => CH.toFloat64OrZero(attr(key)) -const sql = (expr: Expr | Condition): string => compile(expr.toFragment()) - -/** - * The first non-empty value among `keys`, else `''`. - * - * Map lookups return `''` for a missing key, so each candidate but the last is - * wrapped in `nullIf` to become a `coalesce` fallback. The last stays a bare - * lookup so the whole expression is a non-Nullable `String` — it feeds a - * non-Nullable MV column, and `''` is what "none" reads as everywhere else. - */ -export const firstNonEmptyAttr = (attrs: MapColumnLike, keys: ReadonlyArray): Expr => { - const last = keys[keys.length - 1] - if (last === undefined) throw new Error("firstNonEmptyAttr needs at least one key") - const candidates = keys.slice(0, -1).map((key) => CH.nullIf(attrs.get(key), "")) - return candidates.length === 0 ? attrs.get(last) : CH.coalesce(...candidates, attrs.get(last)) -} - -/** Characters, not bytes: `left` cuts mid-codepoint on any text holding one, - * and these two columns hold whatever a framework wrote. */ +/** Characters, not bytes: `left` cuts mid-codepoint on any text holding one. */ const leftUTF8 = (value: Expr, chars: number): Expr => CH.compileFnCall("leftUTF8", value, CH.lit(chars)) -// Identity — model, agent, tool - -/** - * The model a span ran on. Response model first, because it is the one the - * provider actually served — an alias or a "latest" tag in the request - * resolves to a dated snapshot in the response — then the request model, then - * the Vercel AI SDK and OpenInference spellings of the same two. - */ -export const GENAI_MODEL_KEYS = [ - "gen_ai.response.model", - "gen_ai.request.model", - "ai.response.model", - "ai.model.id", - "llm.model_name", -] as const - -/** The agent that owns the span. `ai.telemetry.functionId` is the name an app - * gave a traced Vercel AI SDK call — the only agent identity an older-SDK span - * has, and in production the same value the sibling `invoke_agent` span puts in - * `gen_ai.agent.name`. */ -export const GENAI_AGENT_NAME_KEYS = ["gen_ai.agent.name", "ai.telemetry.functionId"] as const - -/** The tool an `execute_tool` span ran. */ -export const GENAI_TOOL_NAME_KEYS = ["gen_ai.tool.name", "ai.toolCall.name", "tool.name"] as const - -/** What the tool told the model it does — the tool detail page's header. Only - * tool spans carry it, and a tool that stamps one stamps it on every call. */ -export const GENAI_TOOL_DESCRIPTION_KEYS = ["gen_ai.tool.description", "tool.description"] as const - -/** - * How much of a description the index carries. A description is a sentence - * meant for a model, but nothing stops a framework from inlining a schema or a - * whole prompt, and the index is a narrow table read a million rows at a time. - * The header shows it as prose, so a cut past this is a cut nobody sees. - */ -export const GENAI_TOOL_DESCRIPTION_MAX = 2_000 - -export function genAiModelExpr(spanAttributes: MapColumnLike): Expr { - return firstNonEmptyAttr(spanAttributes, GENAI_MODEL_KEYS) -} - -export function genAiAgentNameExpr(spanAttributes: MapColumnLike): Expr { - return firstNonEmptyAttr(spanAttributes, GENAI_AGENT_NAME_KEYS) -} - -export function genAiToolNameExpr(spanAttributes: MapColumnLike): Expr { - return firstNonEmptyAttr(spanAttributes, GENAI_TOOL_NAME_KEYS) -} - -export function genAiToolDescriptionExpr(spanAttributes: MapColumnLike): Expr { - return leftUTF8( - firstNonEmptyAttr(spanAttributes, GENAI_TOOL_DESCRIPTION_KEYS), - GENAI_TOOL_DESCRIPTION_MAX, - ) -} - -/** The provider's id for the response — the one fact two observations of the - * same model call share (an app's span and a gateway's mirror of it), and so - * the key the session sums dedupe on. */ -export const GENAI_RESPONSE_ID_KEYS = ["gen_ai.response.id", "ai.response.id"] as const - -export function genAiResponseIdExpr(spanAttributes: MapColumnLike): Expr { - return firstNonEmptyAttr(spanAttributes, GENAI_RESPONSE_ID_KEYS) -} - -// Operation and kind — is this span a model call, a tool call? - -const INFERENCE_OPS = ["chat", "generate_content", "text_completion", "fetch_response"] as const -const RETRIEVAL_OPS = ["embeddings", "retrieval"] as const -const TOOL_OPS = ["execute_tool"] as const -const AGENT_OPS = ["invoke_agent", "create_agent", "invoke_workflow", "plan", "agent_step"] as const -const KNOWN_OPS = [...INFERENCE_OPS, ...RETRIEVAL_OPS, ...TOOL_OPS, ...AGENT_OPS] - -/** `openinference.span.kind` → the operation the integration layer would - * refine it to. Mirrors `OPENINFERENCE_SPAN_KIND_OPERATIONS` in `ai-vendors.ts`. */ -export const OPENINFERENCE_KIND_OPERATIONS = [ - ["LLM", "chat"], - ["TOOL", "execute_tool"], - ["AGENT", "invoke_agent"], - ["EMBEDDING", "embeddings"], - ["RETRIEVER", "retrieval"], -] as const - -/** The span's operation: `gen_ai.operation.name`, else the OpenInference kind - * translated, else `''`. */ -export function genAiOperationExpr(attrs: MapColumnLike): Expr { - const kind = attrs.get("openinference.span.kind") - return CH.coalesce( - CH.nullIf(attrs.get("gen_ai.operation.name"), ""), - CH.multiIf( - OPENINFERENCE_KIND_OPERATIONS.map(([from, to]): [Condition, Expr] => [ - kind.eq(from), - CH.lit(to), - ]), - CH.lit(""), - ), - ) -} - -/** - * `classifyAiSpan`'s span-name fallback, for a span whose operation is absent - * or one the convention does not name (`generate_text`): tool, then agent, - * then inference — in that order, because the first rule that fires wins in - * the client too. - */ -const nameLooks = (name: Expr, needles: readonly [string, ...string[]]): Condition => { - const lowered = CH.lower_(name) - const [first, ...rest] = needles - return rest.reduce((cond, needle) => cond.or(lowered.like(`%${needle}%`)), lowered.like(`%${first}%`)) -} - -/** A model turn — what the list counts as an "LLM call". Embeddings and - * retrieval are inference time but not calls, exactly as `isLlmCall` says. - * Every span the index holds is vendor-stamped, so the client's "is an AI - * span" guard on the name rules is already met. */ -export function genAiIsLlmCallCond($: Pick): Condition { - const attrs = $.SpanAttributes - const op = genAiOperationExpr(attrs) - const byOperation = CH.inList(op, INFERENCE_OPS) - const byName = CH.notInList(op, KNOWN_OPS) - .and( - CH.not( - genAiToolNameExpr(attrs) - .neq("") - .or(nameLooks($.SpanName, ["tool"])), - ), - ) - .and(CH.not(nameLooks($.SpanName, ["agent", "workflow"]))) - .and( - genAiModelExpr(attrs) - .neq("") - .or(nameLooks($.SpanName, ["chat", "completion"])), - ) - return byOperation.or(byName) -} - -export function genAiIsToolCallCond($: Pick): Condition { - const attrs = $.SpanAttributes - const op = genAiOperationExpr(attrs) - const byOperation = CH.inList(op, TOOL_OPS) - const byName = CH.notInList(op, KNOWN_OPS).and( - genAiToolNameExpr(attrs) - .neq("") - .or(nameLooks($.SpanName, ["tool"])), - ) - return byOperation.or(byName) -} - -// Failure - -/** `gen_ai.response.status` values that mean the generation failed — semconv's - * `failed` plus the pre-enum `error` dialect. Mirrors `spanFailed` in - * `session-turns.ts`. */ -export const GENAI_FAILED_RESPONSE_STATUSES = ["failed", "error"] as const - -/** WHY the span failed, where it named a reason — plain OTel semconv, which is - * why one key answers for every dialect today. `''` is a real answer: a call - * that failed naming no type, which the tools page labels `unknown`. */ -export const GENAI_ERROR_TYPE_KEYS = ["error.type"] as const - -export function genAiErrorTypeExpr(spanAttributes: MapColumnLike): Expr { - return firstNonEmptyAttr(spanAttributes, GENAI_ERROR_TYPE_KEYS) -} - -/** - * The span failed: by its own status, or by an attribute-declared failure — - * frameworks record a failed model or tool call as a VALUE on an `Ok` span. - * Scoped to GenAI spans by construction (every index row is one), which is - * what keeps an HTTP span's `error.type` on an expected 4xx out of it. - * - * The type half is {@link genAiErrorTypeExpr} rather than the bare lookup, so a - * second source key added to the list cannot make `IsError` and `ErrorType` - * disagree — a row the page would file under a named type while counting it as - * a success. - */ -export function genAiIsErrorCond($: Pick): Condition { - const attrs = $.SpanAttributes - return $.StatusCode.eq("Error") - .or(genAiErrorTypeExpr(attrs).neq("")) - .or(CH.inList(attrs.get("gen_ai.response.status"), GENAI_FAILED_RESPONSE_STATUSES)) -} - /** * How much of a span's status message the index carries. A status message and * nothing else is what most failures carry, so it is the identity of a failure * group as often as the type is — but a framework that puts a stack trace there - * would otherwise make the index as wide as the raw span, and a `GROUP BY` on - * one is not free either. The tools page clamps the message to a line anyway. + * would otherwise make the index as wide as the raw span. */ export const GENAI_STATUS_MESSAGE_MAX = 400 -export function genAiStatusMessageExpr($: Pick): Expr { - return leftUTF8($.StatusMessage, GENAI_STATUS_MESSAGE_MAX) -} - -/** What a tool call returned. On a failed call it is often the only account of - * the failure: an agent framework that catches a tool's error hands its message - * back to the model as the call's result, on an `Ok` span with no status - * message. */ -export const GENAI_TOOL_CALL_RESULT_KEYS = ["gen_ai.tool.call.result", "ai.toolCall.result"] as const - -/** How much of a failed call's result the index carries. An explanation of a - * failure is its opening; the rest of a result is payload. */ -export const GENAI_FAILED_TOOL_CALL_RESULT_MAX = 1_000 - -/** - * The result of a tool call that failed, `''` on every other span. Only a - * failure's result is carried: a successful one is the call's payload, not a - * fact the index filters or groups on. - */ -export function genAiFailedToolCallResultExpr( - $: Pick, -): Expr { - return CH.if_( - genAiIsErrorCond($).and(genAiIsToolCallCond($)), - leftUTF8( - firstNonEmptyAttr($.SpanAttributes, GENAI_TOOL_CALL_RESULT_KEYS), - GENAI_FAILED_TOOL_CALL_RESULT_MAX, - ), - CH.lit(""), - ) -} - -/** How much of a failure's text is redacted and hashed. No longer than either - * `GENAI_STATUS_MESSAGE_MAX` or `GENAI_FAILED_TOOL_CALL_RESULT_MAX`, so a - * fingerprint is a function of its row's own `FailedToolCallResult` and - * `StatusMessage`. */ +/** How much of a failure's text is redacted and hashed: no longer than the + * status message the index carries, nor the failed tool call's result the + * gateway cuts, so a fingerprint is a function of its row's own + * `FailedToolCallResult` and `StatusMessage`. */ export const GENAI_ERROR_FINGERPRINT_CHARS = 400 +const statusMessage = leftUTF8(CH.dynamicColumn("StatusMessage"), GENAI_STATUS_MESSAGE_MAX) +const failedToolCallResult = attr(MAPLE_AI_STAMP_ATTRS.toolErrorResult) +const input = number(MAPLE_AI_STAMP_ATTRS.inputTokens) +const cacheRead = number(MAPLE_AI_STAMP_ATTRS.cacheReadTokens) +const cacheWrite = number(MAPLE_AI_STAMP_ATTRS.cacheWriteTokens) +const output = number(MAPLE_AI_STAMP_ATTRS.outputTokens) +const reasoning = number(MAPLE_AI_STAMP_ATTRS.reasoningTokens) + /** * The failure group a failed span belongs to, `0` on every other span: a hash * of its failed tool call's result, else of its status message, after the * redactions `error_events` fingerprints messages with — so a missing key at * `["evidence"][0]` and at `["evidence"][1]` is one group, and a timeout that * reports its elapsed milliseconds is one group rather than one per call. - * Neither the tool name nor `ErrorType` is hashed: failures are grouped within - * one tool. - * - * The status message is the index's own, {@link genAiStatusMessageExpr}, which - * the view projects under the raw column's name. Whichever of the two - * ClickHouse binds `StatusMessage` to inside this expression, the hash is the - * same: the cut here is no longer than that one. */ -export function genAiErrorFingerprintExpr($: GenAiSpanColumnsLike): Expr { - const text = leftUTF8( - CH.coalesce(CH.nullIf(genAiFailedToolCallResultExpr($), ""), genAiStatusMessageExpr($)), - GENAI_ERROR_FINGERPRINT_CHARS, - ) - return CH.if_( - genAiIsErrorCond($), - CH.cityHash64(CH.rawExpr(chRedactChain(sql(text), MSG_TEXT_REDACTIONS), T.string)), - CH.lit(0), - ) -} +const errorFingerprint = CH.if_( + attr(MAPLE_AI_STAMP_ATTRS.error).eq("1"), + CH.cityHash64( + CH.rawExpr( + chRedactChain( + sql( + leftUTF8( + CH.coalesce(CH.nullIf(failedToolCallResult, ""), statusMessage), + GENAI_ERROR_FINGERPRINT_CHARS, + ), + ), + MSG_TEXT_REDACTIONS, + ), + T.string, + ), + ), + CH.lit(0), +) /** - * The text {@link genAiErrorFingerprintExpr} hashes, from a failed row's own - * columns — the TypeScript mirror its grouping is tested against, as + * The text the fingerprint hashes, from a failed row's own columns — the + * TypeScript mirror its grouping is tested against, as * `computeFingerprintInputs` is for `error_events`. Cut by code point, as * `leftUTF8` cuts. If you change one, change both. */ @@ -354,196 +99,24 @@ export const genAiErrorFingerprintText = (row: { MSG_TEXT_REDACTIONS, ) -// Usage — the five token buckets `spanTokenBuckets` sums, each under its -// canonical key, its legacy `gen_ai.*` alias, and the Vercel AI SDK and -// OpenInference spellings. Canonical first: a span carrying both spellings is -// read the way the integration layer reads it. - -export const GENAI_USAGE_KEYS = { - input: [ - "gen_ai.usage.input_tokens", - "gen_ai.usage.prompt_tokens", - "ai.usage.inputTokens", - "ai.usage.promptTokens", - "llm.token_count.prompt", - ], - cacheRead: [ - "gen_ai.usage.cache_read.input_tokens", - "gen_ai.usage.input_tokens.cached", - "ai.usage.cachedInputTokens", - "ai.usage.inputTokenDetails.cacheReadTokens", - "llm.token_count.prompt_details.cache_read", - ], - cacheWrite: [ - "gen_ai.usage.cache_creation.input_tokens", - "gen_ai.usage.cache_write.input_tokens", - "ai.usage.inputTokenDetails.cacheWriteTokens", - ], - output: [ - "gen_ai.usage.output_tokens", - "gen_ai.usage.completion_tokens", - "ai.usage.outputTokens", - "ai.usage.completionTokens", - "llm.token_count.completion", - ], - reasoning: [ - "gen_ai.usage.reasoning.output_tokens", - "gen_ai.usage.output_tokens.reasoning", - "ai.usage.reasoningTokens", - "ai.usage.outputTokenDetails.reasoningTokens", - "llm.token_count.completion_details.reasoning", - ], -} as const - -export const GENAI_COST_KEYS = ["gen_ai.usage.cost", "gen_ai.usage.total_cost", "llm.cost.total"] as const - -/** The provider that served the call, which decides the usage convention: the - * semconv key, its pre-rename spelling, then the Vercel AI SDK and - * OpenInference dialects — the sources the integration layer decodes - * `providerName` from. */ -export const GENAI_PROVIDER_NAME_KEYS = [ - "gen_ai.provider.name", - "gen_ai.system", - "ai.model.provider", - "llm.provider", - "llm.system", -] as const - -/** Pre-rename `gen_ai.system` spellings of the providers the convention table - * names. The read side canonicalises them before its lookup - * (`LEGACY_SYSTEM_VALUES` in `ai-integrations.ts`); the view has to match - * them as written. */ -export const GENAI_PROVIDER_LEGACY_VALUES = [ - ["gcp.gemini", "gemini"], - ["gcp.vertex_ai", "vertex_ai"], -] as const - -export function genAiProviderNameExpr(attrs: MapColumnLike): Expr { - return firstNonEmptyAttr(attrs, GENAI_PROVIDER_NAME_KEYS) -} - -const tokenBucket = (attrs: MapColumnLike, keys: ReadonlyArray): Expr => - CH.toFloat64OrZero(firstNonEmptyAttr(attrs, keys)) - -const greatest = (a: Expr, b: Expr): Expr => - CH.compileFnCall("greatest", a, b) - -/** - * `nested` where the span's convention says the containing figure already - * holds the contained bucket, `apart` where it reports them separately — - * `genAiUsageConvention` as a `multiIf`: the vendor's verdict first, then the - * provider's under its current and its legacy spelling, then the default. - */ -const byConvention = ( - attrs: MapColumnLike, - axis: keyof GenAiUsageConvention, - nested: Expr, - apart: Expr, -): Expr => { - const branches: Array<[Condition, Expr]> = [] - const split = ( - column: Expr, - table: ReadonlyMap, - spellings: (name: string) => ReadonlyArray, - ) => { - const names = (holds: boolean) => - [...table] - .filter(([, convention]) => convention[axis] === holds) - .flatMap(([name]) => spellings(name)) - const yes = names(true) - const no = names(false) - if (yes.length > 0) branches.push([CH.inList(column, yes), nested]) - if (no.length > 0) branches.push([CH.inList(column, no), apart]) - } - split(attrs.get(MAPLE_AI_VENDOR_ID_ATTR), GENAI_VENDOR_USAGE_CONVENTIONS, (name) => [name]) - split(genAiProviderNameExpr(attrs), GENAI_PROVIDER_USAGE_CONVENTIONS, (name) => [ - name, - ...GENAI_PROVIDER_LEGACY_VALUES.filter(([canonical]) => canonical === name).map( - ([, legacy]) => legacy, - ), - ]) - return CH.multiIf(branches, GENAI_DEFAULT_USAGE_CONVENTION[axis] ? nested : apart) -} - -/** - * Every token the span reported, with the buckets its convention nests carved - * back out — the sum `spanTokenBuckets` reaches. The prompt is - * `greatest(input, cacheRead + cacheWrite)` where the prompt figure already - * contains the cache buckets (OpenAI, OpenRouter, Gemini, every vendor that - * re-sums) and `input + cacheRead + cacheWrite` where it excludes them - * (Anthropic); the completion the same against the reasoning bucket. - * `toFloat64OrZero` rather than a UInt64 parse: a dialect that writes `1234.0` - * still counts, and the sums never approach 2^53. - */ -export function genAiTokensExpr(attrs: MapColumnLike): Expr { - const bucket = (name: keyof typeof GENAI_USAGE_KEYS) => tokenBucket(attrs, GENAI_USAGE_KEYS[name]) - const input = bucket("input") - const cache = bucket("cacheRead").add(bucket("cacheWrite")) - const output = bucket("output") - const reasoning = bucket("reasoning") - return byConvention(attrs, "inputIncludesCache", greatest(input, cache), input.add(cache)).add( - byConvention(attrs, "outputIncludesReasoning", greatest(output, reasoning), output.add(reasoning)), - ) -} - -/** - * The five buckets as disjoint figures under the reporter's convention — - * `input` the uncached prompt, `output` the visible completion, each clamped - * at zero like the detail page clamps them — whose sum is `genAiTokensExpr`. - * The index carries them as their own columns since migration 0031; a - * raw-table read of the same split compiles the same expressions. - */ -export function genAiUsageBucketsExpr( - attrs: MapColumnLike, -): Readonly>> { - const bucket = (name: keyof typeof GENAI_USAGE_KEYS) => tokenBucket(attrs, GENAI_USAGE_KEYS[name]) - const floor = (expr: Expr) => CH.compileFnCall("greatest", CH.lit(0), expr) - const input = bucket("input") - const cacheRead = bucket("cacheRead") - const cacheWrite = bucket("cacheWrite") - const output = bucket("output") - const reasoning = bucket("reasoning") - return { - input: byConvention(attrs, "inputIncludesCache", floor(input.sub(cacheRead).sub(cacheWrite)), input), - cacheRead, - cacheWrite, - output: byConvention(attrs, "outputIncludesReasoning", floor(output.sub(reasoning)), output), - reasoning, - } -} - -/** USD as the instrumentation priced the call; 0 where nothing did. */ -export function genAiCostExpr(attrs: MapColumnLike): Expr { - return CH.toFloat64OrZero(firstNonEmptyAttr(attrs, GENAI_COST_KEYS)) -} - -/** A flag column: `1` where the condition holds. */ -const flag = (cond: Condition): Expr => CH.compileFnCall("toUInt8", cond) - -/** - * SQL text for the materialized view and the migration DDL. Each compiles - * byte-identically to its builder form applied to the raw `traces` columns, so - * the index's pre-extracted value and a read straight off the raw table resolve - * the same span to the same value. - */ -export const GENAI_MODEL_SQL = sql(genAiModelExpr(rawSpan.SpanAttributes)) -export const GENAI_AGENT_NAME_SQL = sql(genAiAgentNameExpr(rawSpan.SpanAttributes)) -export const GENAI_TOOL_NAME_SQL = sql(genAiToolNameExpr(rawSpan.SpanAttributes)) -export const GENAI_RESPONSE_ID_SQL = sql(genAiResponseIdExpr(rawSpan.SpanAttributes)) -export const GENAI_IS_LLM_CALL_SQL = sql(flag(genAiIsLlmCallCond(rawSpan))) -export const GENAI_IS_TOOL_CALL_SQL = sql(flag(genAiIsToolCallCond(rawSpan))) -export const GENAI_IS_ERROR_SQL = sql(flag(genAiIsErrorCond(rawSpan))) -export const GENAI_TOKENS_SQL = sql(genAiTokensExpr(rawSpan.SpanAttributes)) -export const GENAI_COST_SQL = sql(genAiCostExpr(rawSpan.SpanAttributes)) -export const GENAI_ERROR_TYPE_SQL = sql(genAiErrorTypeExpr(rawSpan.SpanAttributes)) -export const GENAI_STATUS_MESSAGE_SQL = sql(genAiStatusMessageExpr(rawSpan)) -export const GENAI_TOOL_DESCRIPTION_SQL = sql(genAiToolDescriptionExpr(rawSpan.SpanAttributes)) -export const GENAI_FAILED_TOOL_CALL_RESULT_SQL = sql(genAiFailedToolCallResultExpr(rawSpan)) -export const GENAI_ERROR_FINGERPRINT_SQL = sql(genAiErrorFingerprintExpr(rawSpan)) - -const usageBuckets = genAiUsageBucketsExpr(rawSpan.SpanAttributes) -export const GENAI_INPUT_TOKENS_SQL = sql(usageBuckets.input) -export const GENAI_CACHE_READ_TOKENS_SQL = sql(usageBuckets.cacheRead) -export const GENAI_CACHE_WRITE_TOKENS_SQL = sql(usageBuckets.cacheWrite) -export const GENAI_OUTPUT_TOKENS_SQL = sql(usageBuckets.output) -export const GENAI_REASONING_TOKENS_SQL = sql(usageBuckets.reasoning) +/** SQL text for the materialized view and the migration DDL. */ +export const GENAI_MODEL_SQL = sql(attr(MAPLE_AI_STAMP_ATTRS.model)) +export const GENAI_AGENT_NAME_SQL = sql(attr(MAPLE_AI_STAMP_ATTRS.agentName)) +export const GENAI_TOOL_NAME_SQL = sql(attr(MAPLE_AI_STAMP_ATTRS.toolName)) +export const GENAI_RESPONSE_ID_SQL = sql(attr(MAPLE_AI_STAMP_ATTRS.responseId)) +export const GENAI_IS_LLM_CALL_SQL = sql(flag(MAPLE_AI_STAMP_ATTRS.llmCall)) +export const GENAI_IS_TOOL_CALL_SQL = sql(flag(MAPLE_AI_STAMP_ATTRS.toolCall)) +export const GENAI_IS_ERROR_SQL = sql(flag(MAPLE_AI_STAMP_ATTRS.error)) +export const GENAI_TOKENS_SQL = sql(input.add(cacheRead).add(cacheWrite).add(output).add(reasoning)) +export const GENAI_COST_SQL = sql(number(MAPLE_AI_STAMP_ATTRS.cost)) +/** Plain OTel semconv: why the span failed, where it named a reason. */ +export const GENAI_ERROR_TYPE_SQL = sql(attr("error.type")) +export const GENAI_STATUS_MESSAGE_SQL = sql(statusMessage) +export const GENAI_TOOL_DESCRIPTION_SQL = sql(attr(MAPLE_AI_STAMP_ATTRS.toolDescription)) +export const GENAI_FAILED_TOOL_CALL_RESULT_SQL = sql(failedToolCallResult) +export const GENAI_ERROR_FINGERPRINT_SQL = sql(errorFingerprint) +export const GENAI_INPUT_TOKENS_SQL = sql(input) +export const GENAI_CACHE_READ_TOKENS_SQL = sql(cacheRead) +export const GENAI_CACHE_WRITE_TOKENS_SQL = sql(cacheWrite) +export const GENAI_OUTPUT_TOKENS_SQL = sql(output) +export const GENAI_REASONING_TOKENS_SQL = sql(reasoning) diff --git a/packages/domain/src/tinybird/materializations.ts b/packages/domain/src/tinybird/materializations.ts index b77078efe3..165ea3de2b 100644 --- a/packages/domain/src/tinybird/materializations.ts +++ b/packages/domain/src/tinybird/materializations.ts @@ -1030,9 +1030,13 @@ export const traceDetailSpansMv = defineMaterializedView("trace_detail_spans_mv" * A missing Map key reads back as `''`, so the single `!= ''` comparison is * both the presence check and the non-empty check. * - * The GenAI columns coalesce the dialects and classify the span at insert — - * the SQL comes from `gen-ai-columns.ts`, so a raw-table read of the same fact - * is the same expression. Migration 0026 added them; rows materialized before + * Every other GenAI column is a projection of the facts the gateway decided + * for the span and stamped on it (`MAPLE_AI_STAMP_ATTRS`, SQL from + * `gen-ai-columns.ts`) since migration 0039: the view holds no vendor rule, + * and a dialect is taught to the gateway instead. Rows materialized before it + * keep the values the view's own rules gave them until the 30-day TTL. Before + * 0039 the view coalesced the dialects and classified the span itself. + * Migration 0026 added the columns; rows materialized before * it carry `''`/0 throughout, which the facets drop, the filters never match * and the sums count as nothing. Migration 0027 changed `Tokens` to count a * nested cache or reasoning bucket once, under the reporter's usage @@ -1048,7 +1052,7 @@ export const traceDetailSpansMv = defineMaterializedView("trace_detail_spans_mv" */ export const aiTraceIndexMv = defineMaterializedView("ai_trace_index_mv", { description: - "Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), pre-extracting the maple_ai.* identity, the environment, the GenAI model/agent/tool, the span's kind and usage, and its failure — whether it failed, the error.type it named, its status message, a failed tool call's result and the failure's fingerprint — plus the tool's own description, to plain columns.", + "Populates ai_trace_index with GenAI agent spans (maple_ai.vendor.id stamped), projecting the facts the ingest gateway stamped on each (maple_ai.*: identity, model/agent/tool, kind, usage, failure, a failed tool call's result, the tool's description) plus the environment, the error.type, the status message and the failure's fingerprint, to plain columns.", datasource: aiTraceIndex, // Migration 0026's columns are additive, and the rows already in the target // are explicitly allowed to carry ''/0 for them (see above). Without this, diff --git a/packages/query-engine/src/ch/tables.ts b/packages/query-engine/src/ch/tables.ts index 43d7231f09..f9d24780fd 100644 --- a/packages/query-engine/src/ch/tables.ts +++ b/packages/query-engine/src/ch/tables.ts @@ -112,8 +112,9 @@ export const TraceDetailSpans = table("trace_detail_spans", { * Migration 0026 added the sidebar's other facet dimensions (`DeploymentEnv`, * `Model`, `AgentName`, `ToolName`) and the per-span measures the page ranks * and filters on (`IsError`, `IsLlmCall`, `IsToolCall`, `Tokens`, `Cost`, with - * `SpanId`/`ParentSpanId`/`Duration`), all coalesced and classified at insert - * by `@maple/domain/tinybird/gen-ai-columns`; `''`/0 where the span carries no + * `SpanId`/`ParentSpanId`/`Duration`), each since 0039 a projection of the + * fact the ingest gateway stamped on the span + * (`@maple/domain/tinybird/gen-ai-columns`); `''`/0 where the span carries no * such fact, and on every row materialized before 0026. */ export const AiTraceIndex = table("ai_trace_index", { From 1c1c8bf66a1f1e88d4d2c852d8304826a6035815 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 21:06:12 +0200 Subject: [PATCH 03/17] feat(agent-sessions): the detail page and /summary read the gateway's stamps On a span the ingest gateway stamped (maple_ai.llm_call present), classifyAiSpan, isLlmCall and spanFailed take its verdicts, spanTokenBuckets and the new spanCost its buckets and cost, and the /summary SQL its verdicts, names and buckets: the facts ai_trace_index sums, so the list and the page cannot disagree. Spans ingested before keep the op/name rules and usage conventions until the 30-day TTL. The materialization e2e seeds carry the stamps the gateway would have written. --- .../session-detail/span-expansion.tsx | 9 +- .../src/session-summary.test.ts | 40 ++++ .../agent-sessions/src/session-summary.ts | 64 ++++-- .../agent-sessions/src/session-turns.test.ts | 34 +++- packages/agent-sessions/src/session-turns.ts | 13 +- ...dex-materialization.clickhouse.e2e.test.ts | 152 +++++++++++--- .../src/__sql_baseline__/integrations.sql | 120 +++++------ .../src/ai/ai-sessions.test.ts | 22 ++- .../src/ai/ai-sessions.ts | 86 +++++--- .../src/ai/ai-span-columns.test.ts | 186 +----------------- .../src/ai/ai-span-columns.ts | 5 +- 11 files changed, 409 insertions(+), 322 deletions(-) diff --git a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx index 934b93d2a2..3691277b4a 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx @@ -30,6 +30,7 @@ import { classifyAiSpan, formatCost, payload, + spanCost, spanFailed, spanMessages, spanToolCalls, @@ -217,6 +218,7 @@ function OpenInTracesLink({ span }: { span: AiSessionSpan }) { function MetaStrip({ span }: { span: AiSessionSpan }) { const { effectiveTimezone } = useTimezonePreference() const ttftMs = spanTtftMs(span) + const cost = spanCost(span) const pairs: readonly (readonly [string, ReactNode])[] = [ ["provider", span.genAi.providerName], @@ -233,12 +235,7 @@ function MetaStrip({ span }: { span: AiSessionSpan }) { span.genAi.requestMaxTokens === undefined ? undefined : formatNumber(span.genAi.requestMaxTokens), ], ["temperature", span.genAi.requestTemperature], - [ - "cost", - span.genAi.usageCost === undefined ? undefined : ( - {formatCost(span.genAi.usageCost)} - ), - ], + ["cost", cost === undefined ? undefined : {formatCost(cost)}], ] return ( diff --git a/packages/agent-sessions/src/session-summary.test.ts b/packages/agent-sessions/src/session-summary.test.ts index 9f139208f7..5c0929a692 100644 --- a/packages/agent-sessions/src/session-summary.test.ts +++ b/packages/agent-sessions/src/session-summary.test.ts @@ -221,6 +221,46 @@ describe("buildSessionSummary — tokens and models", () => { }) }) + it("reads the ingest gateway's buckets and cost on a span it stamped", () => { + // Strands TS: `invoke_agent` repeats its chat's `gen_ai.usage.*` under an + // unstamped loop span. The gateway stamped buckets on the chat alone, and + // its buckets, not the emitter's figures, are what the list sums. + const reported = { usageInputTokens: 218, usageOutputTokens: 28, usageCost: 0.5 } + const summary = summarize([ + agentSpan({ + spanId: "agent", + startMs: 0, + durationMs: 5 * SECOND, + genAi: { ...reported, mapleLlmCall: 0 }, + }), + llmSpan({ + spanId: "chat", + parentSpanId: "loop", + startMs: SECOND, + durationMs: SECOND, + genAi: { + ...reported, + mapleLlmCall: 1, + mapleInputTokens: 18, + mapleCacheReadTokens: 200, + mapleOutputTokens: 20, + mapleReasoningTokens: 8, + mapleCost: 0.002, + }, + }), + ]) + + expect(summary.tokens).toEqual({ + input: 18, + cacheRead: 200, + cacheWrite: 0, + output: 20, + reasoning: 8, + total: 246, + }) + expect(summary.cost).toBe(0.002) + }) + it("counts usage at the deepest span that reports it", () => { const summary = summarize([ // The framework reports a turn total on the agent span AND on each model diff --git a/packages/agent-sessions/src/session-summary.ts b/packages/agent-sessions/src/session-summary.ts index d79285a420..8dd0fea3a6 100644 --- a/packages/agent-sessions/src/session-summary.ts +++ b/packages/agent-sessions/src/session-summary.ts @@ -5,11 +5,11 @@ // parallel would otherwise report 180% of itself. Tokens are counted at the // deepest span that reports them, because frameworks that also roll usage up to // the agent span would otherwise double the bill. And token buckets are -// normalised to be disjoint at the point they are read off a span: a provider -// that counts cached tokens inside its prompt figure, or reasoning inside its -// completion figure, has them carved back out (see `genAiUsageConvention`), so -// `input` always means the uncached prompt, `output` the visible completion, -// and a total is always the plain sum of the buckets. +// disjoint: the ingest gateway stamps them so on the model call alone, and a +// span ingested before it did has its provider's nested figures carved back out +// where it is read (see `genAiUsageConvention`), so `input` always means the +// uncached prompt, `output` the visible completion, and a total is always the +// plain sum of the buckets. import { genAiUsageConvention } from "@maple/domain/gen-ai" import type { AiSessionSpan } from "@maple/domain/http" @@ -431,19 +431,51 @@ const EMPTY_TOKENS: SessionTokenTotals = { } /** - * The five `gen_ai.usage.*` buckets a span reports, normalised to disjoint - * buckets — or nothing when it reports none. A reporter whose prompt figure - * already contains its cache buckets, or whose completion figure contains its - * reasoning, has them carved back out (`genAiUsageConvention` says which), so - * `input` is always the uncached prompt, `output` the visible completion, and - * the total is always the sum, whichever convention the reporter billed under. - * `ai_trace_index`'s `Tokens` column reaches the same sum at insert - * (`genAiTokensExpr`), which is what keeps the list's usage equal to the + * A span's usage as five disjoint buckets, or nothing when it reports none: + * `input` the uncached prompt, `output` the visible completion, the total + * their sum. On a span the ingest gateway stamped, the buckets it wrote + * (`MAPLE_AI_STAMP_ATTRS`), which only a model call carries — the figures + * `ai_trace_index` sums, which is what keeps the list's usage equal to the * detail page's. Exported so the waterfall and the flow split a span's usage * the same way the header does rather than re-deriving the prompt/completion * halves. */ export function spanTokenBuckets(span: AiSessionSpan): SessionTokenTotals | undefined { + if (span.genAi.mapleLlmCall === undefined) return reportedTokenBuckets(span) + const { mapleInputTokens, mapleCacheReadTokens, mapleCacheWriteTokens } = span.genAi + const { mapleOutputTokens, mapleReasoningTokens } = span.genAi + if ( + mapleInputTokens === undefined && + mapleCacheReadTokens === undefined && + mapleCacheWriteTokens === undefined && + mapleOutputTokens === undefined && + mapleReasoningTokens === undefined + ) { + return undefined + } + return tokenTotals({ + input: mapleInputTokens ?? 0, + cacheRead: mapleCacheReadTokens ?? 0, + cacheWrite: mapleCacheWriteTokens ?? 0, + output: mapleOutputTokens ?? 0, + reasoning: mapleReasoningTokens ?? 0, + }) +} + +/** What a span reported it cost, read the way {@link spanTokenBuckets} reads + * its tokens: the gateway's figure on a span it stamped, else the emitter's. */ +export function spanCost(span: AiSessionSpan): number | undefined { + return span.genAi.mapleLlmCall === undefined ? span.genAi.usageCost : span.genAi.mapleCost +} + +/** + * The five `gen_ai.usage.*` buckets of a span ingested before the gateway + * stamped usage, as its emitter reported them: a reporter whose prompt figure + * already contains its cache buckets, or whose completion figure contains its + * reasoning, has them carved back out (`genAiUsageConvention` says which). + * Remove once those spans have aged out of the 30-day TTL. + */ +function reportedTokenBuckets(span: AiSessionSpan): SessionTokenTotals | undefined { const { usageInputTokens, usageCacheReadInputTokens, usageCacheCreationInputTokens } = span.genAi const { usageOutputTokens, usageReasoningOutputTokens } = span.genAi if ( @@ -590,7 +622,7 @@ function countedLlmCalls( costsBySpan: ReadonlyMap, ): readonly AiSessionSpan[] { const reportsUsage = (span: AiSessionSpan) => - (spanTokenBuckets(span)?.total ?? 0) > 0 || (span.genAi.usageCost ?? 0) > 0 + (spanTokenBuckets(span)?.total ?? 0) > 0 || (spanCost(span) ?? 0) > 0 const claimed = (span: AiSessionSpan) => tokensBySpan.has(span.spanId) || (costsBySpan.get(span.spanId) ?? 0) > 0 const deepest = spans.filter((span) => { @@ -674,7 +706,7 @@ function costBySpan( const bySpan = new Map() const reported = new Map() for (const span of spans) { - const cost = span.genAi.usageCost + const cost = spanCost(span) if (cost === 0) bySpan.set(span.spanId, 0) if (cost !== undefined && cost > 0) reported.set(span.spanId, cost) } @@ -1286,7 +1318,7 @@ export function callMetaParts(span: AiSessionSpan, options?: CallMetaOptions): r const buckets = options?.usage === false ? undefined : spanTokenBuckets(span) if (buckets !== undefined && buckets.total > 0) parts.push(tokenFlowLabel(buckets)) - const cost = options?.usage === false ? undefined : span.genAi.usageCost + const cost = options?.usage === false ? undefined : spanCost(span) if (cost !== undefined) parts.push(formatCost(cost)) const ttftMs = spanTtftMs(span) if (ttftMs !== undefined) parts.push(`ttft ${formatDuration(ttftMs)}`) diff --git a/packages/agent-sessions/src/session-turns.test.ts b/packages/agent-sessions/src/session-turns.test.ts index e290ea96b9..86a8d5be14 100644 --- a/packages/agent-sessions/src/session-turns.test.ts +++ b/packages/agent-sessions/src/session-turns.test.ts @@ -11,7 +11,7 @@ import { userMessages, } from "./span-test-support" import { buildSessionSummary } from "./session-summary" -import { buildSessionTurns, classifyAiSpan, isLlmCall, spanTtftMs } from "./session-turns" +import { buildSessionTurns, classifyAiSpan, isLlmCall, spanFailed, spanTtftMs } from "./session-turns" const SECOND = 1000 @@ -603,6 +603,38 @@ describe("isLlmCall", () => { }) }) +describe("the ingest gateway's verdicts", () => { + const stamped = (spanName: string, genAi: AiSessionSpan["genAi"]) => + makeSpan({ spanId: "a", startMs: 0, durationMs: 1, spanName, vendorId: "unknown:genai", genAi }) + + it("decide a stamped span, whatever its operation and name say", () => { + // ADK's `call_llm` names a model over its `generate_content` child. + const wrapper = stamped("call_llm", { responseModel: "gemini-2.5", mapleLlmCall: 0 }) + expect(classifyAiSpan(wrapper)).toBe("agent") + expect(isLlmCall(wrapper)).toBe(false) + // A LiteLLM `acompletion` is the call, outside the convention's operations. + const call = stamped("litellm_request", { operationName: "acompletion", mapleLlmCall: 1 }) + expect(classifyAiSpan(call)).toBe("inference") + expect(isLlmCall(call)).toBe(true) + const tool = stamped("search_docs", { mapleLlmCall: 0, mapleToolCall: 1 }) + expect(classifyAiSpan(tool)).toBe("tool") + // Embeddings stay inference time, never a call. + expect(classifyAiSpan(stamped("embed", { operationName: "embeddings", mapleLlmCall: 0 }))).toBe( + "inference", + ) + }) + + it("decide whether a stamped span failed", () => { + expect(spanFailed(stamped("execute_tool", { mapleLlmCall: 0, mapleError: 1 }))).toBe(true) + // The gateway already weighed the span's own `error.type`. + expect(spanFailed(stamped("execute_tool", { mapleLlmCall: 0, errorType: "" }))).toBe(false) + // A span before the gateway stamped: the attribute rule. + expect( + spanFailed(makeSpan({ spanId: "a", startMs: 0, durationMs: 1, genAi: { errorType: "Timeout" } })), + ).toBe(true) + }) +}) + describe("spanTtftMs", () => { it("converts the seconds the convention records into milliseconds", () => { const span = llmSpan({ spanId: "a", startMs: 0, durationMs: 8000, ttftSeconds: 1.4 }) diff --git a/packages/agent-sessions/src/session-turns.ts b/packages/agent-sessions/src/session-turns.ts index 883817de53..28c9dd8e13 100644 --- a/packages/agent-sessions/src/session-turns.ts +++ b/packages/agent-sessions/src/session-turns.ts @@ -64,6 +64,14 @@ export function spanModel(span: AiSessionSpan): string | undefined { export function classifyAiSpan(span: AiSessionSpan): AiSpanCategory { const operation = span.genAi.operationName + // The ingest gateway's verdict, on a span it stamped (`MAPLE_AI_STAMP_ATTRS`): + // the one the sessions list counts. The rules below serve the spans ingested + // before it did, until they age out of the 30-day TTL. + if (span.genAi.mapleLlmCall !== undefined) { + if (span.genAi.mapleLlmCall === 1) return "inference" + if (span.genAi.mapleToolCall === 1) return "tool" + return operation !== undefined && RETRIEVAL_OPS.has(operation) ? "inference" : "agent" + } if (operation !== undefined) { if (INFERENCE_OPS.has(operation) || RETRIEVAL_OPS.has(operation)) return "inference" if (TOOL_OPS.has(operation)) return "tool" @@ -98,6 +106,7 @@ export function classifyAiSpan(span: AiSessionSpan): AiSpanCategory { * call count, the model rows and the token column. */ export function isLlmCall(span: AiSessionSpan): boolean { + if (span.genAi.mapleLlmCall !== undefined) return span.genAi.mapleLlmCall === 1 const operation = span.genAi.operationName if (operation !== undefined && RETRIEVAL_OPS.has(operation)) return false return classifyAiSpan(span) === "inference" @@ -117,10 +126,12 @@ const FAILED_RESPONSE_STATUSES = new Set(["failed", "error"]) * semconv sets only when the operation errored) or a failed * `gen_ai.response.status` counts too. Scoped to AI spans because HTTP * instrumentation legitimately stamps `error.type` on expected 4xx requests - * whose span status is deliberately not `Error`. + * whose span status is deliberately not `Error`. On a span the ingest gateway + * stamped, its verdict (`MAPLE_AI_STAMP_ATTRS.error`), which is this rule. */ export function spanFailed(span: AiSessionSpan): boolean { if (span.statusCode === "Error") return true + if (span.genAi.mapleLlmCall !== undefined) return span.genAi.mapleError === 1 if (!span.isAiSpan) return false const errorType = span.genAi.errorType if (errorType !== undefined && errorType !== "") return true diff --git a/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts b/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts index fa32e888c6..e6c0c181f0 100644 --- a/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts +++ b/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts @@ -20,6 +20,7 @@ import { Effect } from "effect" import { compileUnionUnsafe, compileUnsafe } from "@maple-dev/effect-clickhouse" import { MAPLE_AI_SESSION_ID_ATTR, + MAPLE_AI_STAMP_ATTRS, MAPLE_AI_TRACE_SESSION_PREFIX, MAPLE_AI_VENDOR_ID_ATTR, MAPLE_AI_VENDOR_VERSION_ATTR, @@ -88,11 +89,60 @@ interface SeedSpan { const PRODUCTION = { "deployment.environment.name": "production" } +/** + * What the ingest gateway stamps on a span it classifies + * (`apps/ingest/src/ai_session/facts.rs`, `usage.rs`), spelled out per seed as + * the gateway would have written it: the view reads these and nothing else. + * The seeds keep their dialect attributes so the detail reads below see whole + * spans. `usage` is input, cache read, cache write, output, reasoning; a zero + * bucket is left out, as the gateway leaves it out. + */ +const gateway = (facts: { + readonly llmCall?: boolean + readonly toolCall?: boolean + readonly error?: boolean + readonly model?: string + readonly agentName?: string + readonly toolName?: string + readonly responseId?: string + readonly toolDescription?: string + readonly toolErrorResult?: string + readonly usage?: readonly [number, number, number, number, number] + readonly cost?: string +}): Readonly> => { + const stamps: Record = { [MAPLE_AI_STAMP_ATTRS.llmCall]: facts.llmCall ? "1" : "0" } + const text: ReadonlyArray = [ + [MAPLE_AI_STAMP_ATTRS.model, facts.model], + [MAPLE_AI_STAMP_ATTRS.agentName, facts.agentName], + [MAPLE_AI_STAMP_ATTRS.toolName, facts.toolName], + [MAPLE_AI_STAMP_ATTRS.responseId, facts.responseId], + [MAPLE_AI_STAMP_ATTRS.toolDescription, facts.toolDescription], + [MAPLE_AI_STAMP_ATTRS.toolErrorResult, facts.toolErrorResult], + [MAPLE_AI_STAMP_ATTRS.cost, facts.cost], + [MAPLE_AI_STAMP_ATTRS.toolCall, facts.toolCall ? "1" : undefined], + [MAPLE_AI_STAMP_ATTRS.error, facts.error ? "1" : undefined], + ] + for (const [key, value] of text) if (value !== undefined) stamps[key] = value + const buckets = [ + MAPLE_AI_STAMP_ATTRS.inputTokens, + MAPLE_AI_STAMP_ATTRS.cacheReadTokens, + MAPLE_AI_STAMP_ATTRS.cacheWriteTokens, + MAPLE_AI_STAMP_ATTRS.outputTokens, + MAPLE_AI_STAMP_ATTRS.reasoningTokens, + ] + buckets.forEach((key, index) => { + const count = facts.usage?.[index] ?? 0 + if (count > 0) stamps[key] = String(count) + }) + return stamps +} + // The turn-owning span of the eve session: the only one of its trace that // carries the session key, which is why resolution is per-TRACE. It names the // agent, and it ROLLS UP the usage of the chat call beneath it, bucket for // bucket — the shape several frameworks emit, and the reason a naive sum reads -// 300 tokens where 150 were billed. +// 300 tokens where 150 were billed. The gateway stamps usage on the chat alone, +// so this row reads none. const AGENT_TURN_SPAN: SeedSpan = { traceId: AGENT_TRACE, spanId: "span-agent-1", @@ -112,6 +162,7 @@ const AGENT_TURN_SPAN: SeedSpan = { "gen_ai.usage.output_tokens": "50", "gen_ai.usage.reasoning.output_tokens": "10", "gen_ai.usage.cost": "0.02", + ...gateway({ agentName: "slack-agent" }), }, resource: PRODUCTION, } @@ -119,8 +170,8 @@ const AGENT_TURN_SPAN: SeedSpan = { // The model call under the turn span: the index row that carries the model, // and the deepest reporter of the 150 tokens the turn span repeats. Served // through OpenRouter, whose prompt figure already contains the cached tokens -// and whose completion figure contains the reasoning — so the 40 and the 10 -// reported beside them are NOT added again, and the row still reads 150. +// and whose completion figure contains the reasoning — so the gateway carved +// the 40 and the 10 back out of them, and the row still reads 150. const AGENT_CHAT_SPAN: SeedSpan = { traceId: AGENT_TRACE, spanId: "span-chat-1", @@ -141,6 +192,13 @@ const AGENT_CHAT_SPAN: SeedSpan = { "gen_ai.usage.output_tokens": "50", "gen_ai.usage.reasoning.output_tokens": "10", "gen_ai.usage.cost": "0.02", + ...gateway({ + llmCall: true, + model: "claude-sonnet-5-20260101", + responseId: "gen-e2e-1", + usage: [60, 40, 0, 40, 10], + cost: "0.02", + }), }, resource: PRODUCTION, } @@ -170,6 +228,13 @@ const MIRROR_CALL_SPAN: SeedSpan = { "gen_ai.usage.output_tokens": "50", "gen_ai.usage.output_tokens.reasoning": "10", "gen_ai.usage.total_cost": "0.03", + ...gateway({ + llmCall: true, + model: "claude-sonnet-5-20260101", + responseId: "gen-e2e-1", + usage: [60, 40, 0, 40, 10], + cost: "0.03", + }), }, } @@ -188,6 +253,7 @@ const MIRROR_ATTEMPT_SPAN: SeedSpan = { [MAPLE_AI_VENDOR_ID_ATTR]: "openrouter", "gen_ai.operation.name": "chat", "gen_ai.response.id": "gen-e2e-1:attempt-0", + ...gateway({ llmCall: true, responseId: "gen-e2e-1:attempt-0" }), }, } @@ -210,6 +276,12 @@ const AGENT_TOOL_SPAN: SeedSpan = { "gen_ai.tool.name": "search_traces", "error.type": "TimeoutError", "gen_ai.tool.description": "Search traces by attribute.", + ...gateway({ + toolCall: true, + error: true, + toolName: "search_traces", + toolDescription: "Search traces by attribute.", + }), }, resource: PRODUCTION, } @@ -225,7 +297,11 @@ const AGENT_SDK_SPAN: SeedSpan = { ms: BASE_MS + 5_000, service: "agent-service", status: "Ok", - attrs: { [MAPLE_AI_VENDOR_ID_ATTR]: "vercel_ai_sdk", [MAPLE_AI_VENDOR_VERSION_ATTR]: "5" }, + attrs: { + [MAPLE_AI_VENDOR_ID_ATTR]: "vercel_ai_sdk", + [MAPLE_AI_VENDOR_VERSION_ATTR]: "5", + ...gateway({}), + }, } // A plain child of the agent trace, BEFORE its first agent span: no `maple_ai.*` @@ -258,6 +334,7 @@ const AGENT_TURN_2_SPAN: SeedSpan = { [MAPLE_AI_VENDOR_ID_ATTR]: "eve", [MAPLE_AI_SESSION_ID_ATTR]: SESSION_ID, "gen_ai.agent.name": "critic-agent", + ...gateway({ agentName: "critic-agent" }), }, } @@ -266,9 +343,10 @@ const AGENT_TURN_2_SPAN: SeedSpan = { // `Timestamp <= '{fanOutEnd}'` has to admit the row it was measured from. A // millisecond dropped anywhere in that round trip erases this session. // -// Vercel AI SDK dialect with no operation name: classified by the span-name -// rules, identified by `ai.model.id`, measured by `ai.usage.*`, and in the -// environment under the DEPRECATED semconv spelling. +// Vercel AI SDK dialect with no operation name: the model call by the +// gateway's legacy-scope rule (`ai.*.doGenerate`), identified by `ai.model.id`, +// measured off `ai.usage.*`, and in the environment under the DEPRECATED +// semconv spelling. const SESSIONLESS_SPAN: SeedSpan = { traceId: SESSIONLESS_TRACE, spanId: "span-agent-2", @@ -283,6 +361,7 @@ const SESSIONLESS_SPAN: SeedSpan = { // The SDK re-sums the prompt, so the 4 cached are inside the 10. "ai.usage.cachedInputTokens": "4", "ai.usage.completionTokens": "5", + ...gateway({ llmCall: true, error: true, model: "gpt-5", usage: [6, 4, 0, 5, 0] }), }, resource: { "deployment.environment": "staging" }, } @@ -310,6 +389,7 @@ const EARLY_TURN_SPAN: SeedSpan = { attrs: { [MAPLE_AI_VENDOR_ID_ATTR]: "eve", [MAPLE_AI_SESSION_ID_ATTR]: SESSION_ID, + ...gateway({}), }, } @@ -336,7 +416,7 @@ const FOREIGN_SPAN: SeedSpan = { ms: BASE_MS + 180_000, service: "agent-service", status: "Ok", - attrs: { [MAPLE_AI_VENDOR_ID_ATTR]: "eve" }, + attrs: { [MAPLE_AI_VENDOR_ID_ATTR]: "eve", ...gateway({}) }, } // One tool's calls under a third org, so no session read above sees them. The @@ -365,24 +445,43 @@ const toolCallSpan = ( [MAPLE_AI_VENDOR_ID_ATTR]: "eve", "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": "submit_findings", + ...gateway({ toolCall: true, toolName: "submit_findings" }), ...fields.attrs, }, }) const MISSING_KEY_0_SPAN = toolCallSpan("span-tool-failure-1", 0, { status: "Ok", - attrs: { "error.type": "tool_error", "gen_ai.tool.call.result": missingKeyResult(0) }, + attrs: { + "error.type": "tool_error", + "gen_ai.tool.call.result": missingKeyResult(0), + ...gateway({ + toolCall: true, + error: true, + toolName: "submit_findings", + toolErrorResult: missingKeyResult(0), + }), + }, }) const MISSING_KEY_1_SPAN = toolCallSpan("span-tool-failure-2", 1_000, { status: "Ok", - attrs: { "error.type": "tool_error", "gen_ai.tool.call.result": missingKeyResult(1) }, + attrs: { + "error.type": "tool_error", + "gen_ai.tool.call.result": missingKeyResult(1), + ...gateway({ + toolCall: true, + error: true, + toolName: "submit_findings", + toolErrorResult: missingKeyResult(1), + }), + }, }) // A failure described only by its status, as other frameworks record one: no // result, so the status message is what it is fingerprinted by. const STATUS_ONLY_FAILURE_SPAN = toolCallSpan("span-tool-failure-3", 2_000, { status: "Error", statusMessage: "effect-agent.execute_tool: Tool execution reached a failed terminal state", - attrs: {}, + attrs: gateway({ toolCall: true, error: true, toolName: "submit_findings" }), }) // A call that succeeded: its result is a payload, not a failure, so the index // carries neither the result nor a fingerprint. @@ -402,9 +501,9 @@ const TOOL_FAILURE_ORG_SPANS: ReadonlyArray = [ // // A reused Strands agent across two requests: each `invoke_agent` reports the // agent's accumulated usage, over an event-loop span that reports nothing, over -// the one chat it ran. The list must climb past the loop span to net the chat -// against its agent, and count nothing of an agent span whose calls reported — -// 204 + 44 + 269 + 78 = 595 tokens, where the roll-ups alone claim 843 more. +// the one chat it ran — 204 + 44 + 269 + 78 = 595 tokens, where the roll-ups +// alone claim 843 more. The gateway stamps usage on the chats alone, so the +// list sums them with nothing to net. // // And a gateway request that failed at every provider: the generation and its // provider attempt are both model spans with no usage, one call. @@ -430,6 +529,7 @@ const strandsTurn = (turn: number, agent: [number, number], chat: [number, numbe "gen_ai.agent.name": "assistant", "gen_ai.usage.input_tokens": String(agent[0]), "gen_ai.usage.output_tokens": String(agent[1]), + ...gateway({ agentName: "assistant" }), }, }, { @@ -440,7 +540,7 @@ const strandsTurn = (turn: number, agent: [number, number], chat: [number, numbe ms: BASE_MS + 400_000 + turn * 10_000 + 1, service: "strands-service", status: "Ok", - attrs: { ...strands, "gen_ai.operation.name": "execute_event_loop_cycle" }, + attrs: { ...strands, "gen_ai.operation.name": "execute_event_loop_cycle", ...gateway({}) }, }, { traceId, @@ -456,6 +556,7 @@ const strandsTurn = (turn: number, agent: [number, number], chat: [number, numbe "gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": String(chat[0]), "gen_ai.usage.output_tokens": String(chat[1]), + ...gateway({ llmCall: true, model: "gpt-4o-mini", usage: [chat[0], 0, 0, chat[1], 0] }), }, }, ] @@ -476,6 +577,12 @@ const NETTING_ORG_SPANS: ReadonlyArray = [ "gen_ai.operation.name": "chat", "gen_ai.request.model": "openai/gpt-4o-mini", "gen_ai.response.id": "gen-e2e-failed", + ...gateway({ + llmCall: true, + error: true, + model: "openai/gpt-4o-mini", + responseId: "gen-e2e-failed", + }), }, }, { @@ -490,6 +597,7 @@ const NETTING_ORG_SPANS: ReadonlyArray = [ [MAPLE_AI_VENDOR_ID_ATTR]: "openrouter", "gen_ai.operation.name": "chat", "gen_ai.response.id": "gen-e2e-failed:attempt-0", + ...gateway({ llmCall: true, error: true, responseId: "gen-e2e-failed:attempt-0" }), }, }, ] @@ -621,16 +729,14 @@ describe.skipIf(!clickhouseE2eEnabled)("ai_trace_index materialization", () => { // per TRACE rather than per span. assert.deepStrictEqual(rows, [ indexRow(ORG_ID, EARLY_TURN_SPAN), - // OpenRouter nests the cache in the prompt and the reasoning in the - // completion, so the buckets carve both out and still sum to `Tokens`. + // The roll-up the gateway stamped no usage on: whatever its + // `gen_ai.usage.*` repeats, the row reports nothing. indexRow(ORG_ID, AGENT_TURN_SPAN, { DeploymentEnv: "production", AgentName: "slack-agent", - Tokens: 150, - Cost: 0.02, - buckets: [60, 40, 0, 40, 10], }), - // Response model over request model; the one model call. + // Every column below is the gateway's stamp, projected; the buckets sum + // to `Tokens`. indexRow(ORG_ID, AGENT_CHAT_SPAN, { DeploymentEnv: "production", Model: "claude-sonnet-5-20260101", @@ -667,9 +773,7 @@ describe.skipIf(!clickhouseE2eEnabled)("ai_trace_index materialization", () => { // "agent turn" by name, no model, no usage: an agent span, not a call. indexRow(ORG_ID, AGENT_SDK_SPAN), indexRow(ORG_ID, AGENT_TURN_2_SPAN, { AgentName: "critic-agent" }), - // The Vercel AI SDK dialect resolves to the same columns, the deprecated - // environment spelling still resolves, and the name rules classify a - // span with no operation name as the model call it is. + // The deprecated environment spelling still resolves. indexRow(ORG_ID, SESSIONLESS_SPAN, { DeploymentEnv: "staging", Model: "gpt-5", diff --git a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql index b03c53a8c0..5cfec0c50e 100644 --- a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql +++ b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql @@ -759,7 +759,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -790,7 +790,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -817,20 +817,20 @@ SELECT intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, count() AS spanCount, countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, - countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, - countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + countIf((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))) AS llmCalls, + countIf((SpanAttributes['maple_ai.tool_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))))) AS toolCalls, + countIf(((StatusCode = 'Error' OR SpanAttributes['maple_ai.error'] = '1') OR ((NOT (SpanAttributes['maple_ai.llm_call'] != '') AND SpanAttributes['maple_ai.vendor.id'] != '') AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')))), 0) AS inputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')))), 0) AS outputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmInputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCacheReadTokens, + countIf(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')) != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCost, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')), ((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')))) AND if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')) != '')) AS models, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')), if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')) != '') AS agentNames FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -856,20 +856,20 @@ SELECT intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, count() AS spanCount, countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, - countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, - countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + countIf((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))) AS llmCalls, + countIf((SpanAttributes['maple_ai.tool_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))))) AS toolCalls, + countIf(((StatusCode = 'Error' OR SpanAttributes['maple_ai.error'] = '1') OR ((NOT (SpanAttributes['maple_ai.llm_call'] != '') AND SpanAttributes['maple_ai.vendor.id'] != '') AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')))), 0) AS inputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')))), 0) AS outputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmInputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCacheReadTokens, + countIf(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')) != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCost, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')), ((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')))) AND if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')) != '')) AS models, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')), if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')) != '') AS agentNames FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -907,7 +907,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -929,7 +929,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -950,20 +950,20 @@ SELECT intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, count() AS spanCount, countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, - countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, - countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + countIf((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))) AS llmCalls, + countIf((SpanAttributes['maple_ai.tool_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))))) AS toolCalls, + countIf(((StatusCode = 'Error' OR SpanAttributes['maple_ai.error'] = '1') OR ((NOT (SpanAttributes['maple_ai.llm_call'] != '') AND SpanAttributes['maple_ai.vendor.id'] != '') AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')))), 0) AS inputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')))), 0) AS outputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmInputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCacheReadTokens, + countIf(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')) != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCost, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')), ((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')))) AND if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')) != '')) AS models, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')), if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')) != '') AS agentNames FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -982,20 +982,20 @@ SELECT intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - toUnixTimestamp64Nano(min(Timestamp)), 1000000) AS durationMs, count() AS spanCount, countIf(SpanAttributes['maple_ai.vendor.id'] != '') AS aiSpanCount, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))) AS llmCalls, - countIf((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))) AS toolCalls, - countIf((StatusCode = 'Error' OR (SpanAttributes['maple_ai.vendor.id'] != '' AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), 0) AS inputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), 0) AS outputTokens, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), 0) AS cacheReadTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmInputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmOutputTokens, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCacheReadTokens, - countIf(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '') != '') AS costReporters, - ifNotFinite(sum(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), 0) AS cost, - ifNotFinite(sumIf(toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')), (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))), 0) AS llmCost, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), ''), ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')) AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '')) AS models, - groupUniqArrayIf(50)(coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '') != '') AS agentNames + countIf((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))) AS llmCalls, + countIf((SpanAttributes['maple_ai.tool_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('execute_tool') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') = '' AND SpanAttributes['maple_ai.vendor.id'] != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') != ''))))) AS toolCalls, + countIf(((StatusCode = 'Error' OR SpanAttributes['maple_ai.error'] = '1') OR ((NOT (SpanAttributes['maple_ai.llm_call'] != '') AND SpanAttributes['maple_ai.vendor.id'] != '') AND (coalesce(nullIf(SpanAttributes['error.type'], ''), '') != '' OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))))) AS errorSpanCount, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), '')))), 0) AS inputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), '')))), 0) AS outputTokens, + ifNotFinite(sum(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), '')))), 0) AS cacheReadTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.prompt_tokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokens'], ''), nullIf(SpanAttributes['ai.usage.promptTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmInputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.output_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.reasoning_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.completion_tokens'], ''), nullIf(SpanAttributes['ai.usage.outputTokens'], ''), nullIf(SpanAttributes['ai.usage.completionTokens'], ''), nullIf(SpanAttributes['llm.token_count.completion'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmOutputTokens, + ifNotFinite(sumIf(if(SpanAttributes['maple_ai.llm_call'] != '', toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_read_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.cache_read.input_tokens'], ''), nullIf(SpanAttributes['gen_ai.usage.input_tokens.cached'], ''), nullIf(SpanAttributes['ai.usage.cachedInputTokens'], ''), nullIf(SpanAttributes['ai.usage.inputTokenDetails.cacheReadTokens'], ''), nullIf(SpanAttributes['llm.token_count.prompt_details.cache_read'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCacheReadTokens, + countIf(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')) != '') AS costReporters, + ifNotFinite(sum(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), '')))), 0) AS cost, + ifNotFinite(sumIf(toFloat64OrZero(if(SpanAttributes['maple_ai.llm_call'] != '', coalesce(nullIf(SpanAttributes['maple_ai.usage.cost'], ''), ''), coalesce(nullIf(SpanAttributes['gen_ai.usage.cost'], ''), nullIf(SpanAttributes['gen_ai.usage.total_cost'], ''), nullIf(SpanAttributes['llm.cost.total'], ''), ''))), (SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = ''))))), 0) AS llmCost, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')), ((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (SpanAttributes['maple_ai.llm_call'] != '') AND (coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') IN ('chat', 'generate_content', 'text_completion', 'fetch_response') OR ((coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), '') NOT IN ('embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step') AND coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '') != '') AND coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], ''), nullIf(SpanAttributes['ai.toolCall.name'], ''), nullIf(SpanAttributes['tool.name'], ''), '') = '')))) AND if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.model'], coalesce(nullIf(SpanAttributes['gen_ai.response.model'], ''), nullIf(SpanAttributes['ai.response.model'], ''), nullIf(SpanAttributes['gen_ai.request.model'], ''), nullIf(SpanAttributes['ai.model.id'], ''), nullIf(SpanAttributes['llm.model_name'], ''), '')) != '')) AS models, + groupUniqArrayIf(50)(if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')), if(SpanAttributes['maple_ai.llm_call'] != '', SpanAttributes['maple_ai.agent.name'], coalesce(nullIf(SpanAttributes['gen_ai.agent.name'], ''), nullIf(SpanAttributes['ai.telemetry.functionId'], ''), '')) != '') AS agentNames FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' diff --git a/packages/query-engine-integrations/src/ai/ai-sessions.test.ts b/packages/query-engine-integrations/src/ai/ai-sessions.test.ts index ef97307687..88e26ed715 100644 --- a/packages/query-engine-integrations/src/ai/ai-sessions.test.ts +++ b/packages/query-engine-integrations/src/ai/ai-sessions.test.ts @@ -1425,12 +1425,28 @@ describe("aiSessionSummaryQuery", () => { it("guards every usage sum against a non-finite attribute", () => { const { sql } = compileUnsafe(aiSessionSummaryQuery(), summaryParams) for (const alias of ["inputTokens", "llmInputTokens", "cost", "llmCost"]) { - expect(sql, alias).toMatch( - new RegExp(`ifNotFinite\\(sum(If)?\\(toFloat64OrZero\\([^\\n]*, 0\\) AS ${alias},`), - ) + expect(sql, alias).toMatch(new RegExp(`ifNotFinite\\(sum(If)?\\([^\\n]*, 0\\) AS ${alias},`)) } }) + it("reads a span the gateway stamped by its verdicts, names and buckets", () => { + const { sql } = compileUnsafe(aiSessionSummaryQuery(), summaryParams) + const stamped = "SpanAttributes['maple_ai.llm_call'] != ''" + // The gateway's prompt not read from cache, else the reported prompt. + expect(sql).toContain( + `if(${stamped}, toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.input_tokens'], ''), '')) + toFloat64OrZero(coalesce(nullIf(SpanAttributes['maple_ai.usage.cache_write_tokens'], ''), '')), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], '')`, + ) + // A model call and a tool call by the gateway's verdict, else by the + // op/model rules; a failure by its verdict or the span's status. + expect(sql).toContain(`countIf((SpanAttributes['maple_ai.llm_call'] = '1' OR (NOT (${stamped}) AND `) + expect(sql).toContain(`countIf((SpanAttributes['maple_ai.tool_call'] = '1' OR (NOT (${stamped}) AND `) + expect(sql).toContain( + `countIf(((StatusCode = 'Error' OR SpanAttributes['maple_ai.error'] = '1') OR ((NOT (${stamped}) AND `, + ) + expect(sql).toContain(`if(${stamped}, SpanAttributes['maple_ai.model'], `) + expect(sql).toContain(`if(${stamped}, SpanAttributes['maple_ai.agent.name'], `) + }) + it("reads the whole session's measures ungrouped, under the same detection", () => { const totals = compileUnsafe(aiSessionTotalsQuery(), summaryParams).sql const trace = compileUnsafe(aiTraceTotalsQuery(), traceParams).sql diff --git a/packages/query-engine-integrations/src/ai/ai-sessions.ts b/packages/query-engine-integrations/src/ai/ai-sessions.ts index 26347e9773..a9bd8980e1 100644 --- a/packages/query-engine-integrations/src/ai/ai-sessions.ts +++ b/packages/query-engine-integrations/src/ai/ai-sessions.ts @@ -151,6 +151,7 @@ import { AI_RETRIEVAL_OPERATIONS, AI_TOOL_OPERATIONS, MAPLE_AI_SESSION_ID_ATTR, + MAPLE_AI_STAMP_ATTRS, MAPLE_AI_TRACE_SESSION_PREFIX, MAPLE_AI_VENDOR_ID_ATTR, MAPLE_NATIVE_TURN_ID_ATTR, @@ -1603,6 +1604,15 @@ const SUMMARY_ARRAY_CAP = 50 * when there are any (`per-call`) and the plain sum otherwise (`roll-up`), * which is the deepest-reporter rule the page applies, at turn granularity. * + * A span the ingest gateway stamped (`MAPLE_AI_STAMP_ATTRS.llmCall` present) is + * read by the gateway's verdicts, names and buckets instead, the facts the + * list and the detail page read too. Only its model call carries usage, so a + * session of such spans is always per-call; its `input` is the prompt not read + * from cache (uncached plus cache write) and its `output` the completion + * (visible plus reasoning), so the three add up to its total. The rules above + * serve the spans ingested before the gateway stamped them, until they age out + * of the 30-day TTL. + * * Every Float64 aggregate is guarded with `ifNotFinite`: `toFloat64OrZero` * parses `nan` and `inf` successfully, one such attribute would poison the * whole sum, and `CHNumber` refuses to decode it. @@ -1618,26 +1628,45 @@ const summaryMeasures_ = ($: SpanColumns) => { const isAi = vendorId.neq("") const operation = field("operationName") // Response model first, request model second — `spanModel` on the page. - const model = attr([...aiFieldSourceKeys("responseModel"), ...aiFieldSourceKeys("requestModel")]) - const toolName = field("toolName") - const agentName = field("agentName") - const isLlmCall = operation.in_(...AI_INFERENCE_OPERATIONS).or( - operation - .notIn(...AI_RETRIEVAL_OPERATIONS, ...AI_TOOL_OPERATIONS, ...AI_AGENT_OPERATIONS) - .and(model.neq("")) - .and(toolName.eq("")), - ) - const isToolCall = operation - .in_(...AI_TOOL_OPERATIONS) - .or(operation.eq("").and(isAi).and(toolName.neq(""))) - // The list query's error rule, so the summary and the list badge agree. - const failed = $.StatusCode.eq("Error").or( - isAi.and( - field("errorType") - .neq("") - .or(CH.inList($.SpanAttributes.get(RESPONSE_STATUS_ATTR), FAILED_RESPONSE_STATUSES)), - ), - ) + const stamp = (key: string) => $.SpanAttributes.get(key) + const stamped = stamp(MAPLE_AI_STAMP_ATTRS.llmCall).neq("") + const unstamped = CH.not(stamped) + const byGateway = (gateway: CH.Expr, reported: CH.Expr) => CH.if_(stamped, gateway, reported) + const reportedModel = attr([...aiFieldSourceKeys("responseModel"), ...aiFieldSourceKeys("requestModel")]) + const reportedToolName = field("toolName") + const model = byGateway(stamp(MAPLE_AI_STAMP_ATTRS.model), reportedModel) + const agentName = byGateway(stamp(MAPLE_AI_STAMP_ATTRS.agentName), field("agentName")) + const isLlmCall = stamp(MAPLE_AI_STAMP_ATTRS.llmCall) + .eq("1") + .or( + unstamped.and( + operation.in_(...AI_INFERENCE_OPERATIONS).or( + operation + .notIn(...AI_RETRIEVAL_OPERATIONS, ...AI_TOOL_OPERATIONS, ...AI_AGENT_OPERATIONS) + .and(reportedModel.neq("")) + .and(reportedToolName.eq("")), + ), + ), + ) + const isToolCall = stamp(MAPLE_AI_STAMP_ATTRS.toolCall) + .eq("1") + .or( + unstamped.and( + operation + .in_(...AI_TOOL_OPERATIONS) + .or(operation.eq("").and(isAi).and(reportedToolName.neq(""))), + ), + ) + // The list's error rule, so the summary and the list badge agree. + const failed = $.StatusCode.eq("Error") + .or(stamp(MAPLE_AI_STAMP_ATTRS.error).eq("1")) + .or( + unstamped.and(isAi).and( + field("errorType") + .neq("") + .or(CH.inList($.SpanAttributes.get(RESPONSE_STATUS_ATTR), FAILED_RESPONSE_STATUSES)), + ), + ) const conversationId = attr([ // First, as `mapleIntegration` reads it: Maple's turn id wins over the // conversation id its engine's tool spans carry, which names the session. @@ -1646,10 +1675,17 @@ const summaryMeasures_ = ($: SpanColumns) => { // What `eveIntegration` lifts into the field. "eve.turn.id", ]) - const inputTokens = number("usageInputTokens") - const outputTokens = number("usageOutputTokens") - const cacheReadTokens = number("usageCacheReadInputTokens") - const cost = number("usageCost") + const inputTokens = byGateway( + number("mapleInputTokens").add(number("mapleCacheWriteTokens")), + number("usageInputTokens"), + ) + const outputTokens = byGateway( + number("mapleOutputTokens").add(number("mapleReasoningTokens")), + number("usageOutputTokens"), + ) + const cacheReadTokens = byGateway(number("mapleCacheReadTokens"), number("usageCacheReadInputTokens")) + const costField = byGateway(field("mapleCost"), field("usageCost")) + const cost = CH.toFloat64OrZero(costField) const finite = (expr: CH.Expr) => CH.ifNotFinite(expr, 0) @@ -1678,7 +1714,7 @@ const summaryMeasures_ = ($: SpanColumns) => { llmCacheReadTokens: finite(CH.sumIf(cacheReadTokens, isLlmCall)), // Spans that reported a cost at all: zero means "not measured", which the // page distinguishes from "free". - costReporters: CH.countIf(field("usageCost").neq("")), + costReporters: CH.countIf(costField.neq("")), cost: finite(CH.sum(cost)), llmCost: finite(CH.sumIf(cost, isLlmCall)), models: CH.groupUniqArrayIf(SUMMARY_ARRAY_CAP)(model, isLlmCall.and(model.neq(""))), diff --git a/packages/query-engine-integrations/src/ai/ai-span-columns.test.ts b/packages/query-engine-integrations/src/ai/ai-span-columns.test.ts index 1e5b032013..14406f592f 100644 --- a/packages/query-engine-integrations/src/ai/ai-span-columns.test.ts +++ b/packages/query-engine-integrations/src/ai/ai-span-columns.test.ts @@ -2,32 +2,6 @@ import { describe, expect, it } from "vitest" import * as CH from "@maple-dev/effect-clickhouse/expr" import * as T from "@maple-dev/effect-clickhouse/types" import { compile } from "@maple-dev/effect-clickhouse/sql" -import { - GENAI_AGENT_NAME_KEYS, - GENAI_COST_KEYS, - GENAI_MODEL_KEYS, - GENAI_PROVIDER_LEGACY_VALUES, - GENAI_PROVIDER_NAME_KEYS, - GENAI_RESPONSE_ID_KEYS, - GENAI_ERROR_TYPE_KEYS, - GENAI_TOOL_CALL_RESULT_KEYS, - GENAI_TOOL_DESCRIPTION_KEYS, - GENAI_TOOL_NAME_KEYS, - GENAI_USAGE_KEYS, - OPENINFERENCE_KIND_OPERATIONS, - genAiIsErrorCond, - genAiIsLlmCallCond, - genAiIsToolCallCond, - genAiOperationExpr, - genAiTokensExpr, -} from "@maple/domain/tinybird/gen-ai-columns" -import { - GENAI_PROVIDER_USAGE_CONVENTIONS, - type AiGenAiField, - type MutableAiGenAiValues, -} from "@maple/domain/gen-ai" -import { LEGACY_SYSTEM_VALUES, genAiIntegration, resolveAiIntegration } from "./ai-integrations" -import { AI_VENDOR_INTEGRATIONS } from "./ai-vendors" import { childClaimsExpr, nettedReportersExpr, @@ -39,167 +13,9 @@ import { usageReportersExpr, } from "./ai-span-columns" -/** Every key any integration reads for `field` — the default's plus each vendor's. */ -const decodedKeys = (field: AiGenAiField): ReadonlySet => - new Set([ - ...genAiIntegration.sources[field], - ...Object.keys(AI_VENDOR_INTEGRATIONS).flatMap( - (vendorId) => resolveAiIntegration(vendorId).sources[field], - ), - ]) - -const attrs = { - get: (key: string) => CH.mapGet(CH.dynamicColumn>("SpanAttributes"), key), -} -const columns = { - SpanName: CH.dynamicColumn("SpanName", T.string), - StatusCode: CH.dynamicColumn("StatusCode", T.string), - SpanAttributes: attrs, -} const sql = (expr: { toFragment(): Parameters[0] }) => compile(expr.toFragment()) -// The SQL lists are hand-copied from the integration layer's alias tables, -// because the index's MV cannot call into it. These pin every list to that -// layer, so a key that decodes on the detail page is one the list can filter -// on — and one the list reads that nothing decodes is a typo caught here. -describe("GenAI column key lists match the integration layer", () => { - it("model: response and request model keys", () => { - const decoded = new Set([...decodedKeys("responseModel"), ...decodedKeys("requestModel")]) - for (const key of GENAI_MODEL_KEYS) expect(decoded).toContain(key) - }) - - it("agent and tool names, and the response id the session dedupes on", () => { - for (const key of GENAI_AGENT_NAME_KEYS) expect(decodedKeys("agentName")).toContain(key) - for (const key of GENAI_TOOL_NAME_KEYS) expect(decodedKeys("toolName")).toContain(key) - for (const key of GENAI_RESPONSE_ID_KEYS) expect(decodedKeys("responseId")).toContain(key) - for (const key of decodedKeys("responseId")) expect(GENAI_RESPONSE_ID_KEYS).toContain(key) - }) - - // Both directions, in both lists: the tool detail page groups its failures by - // the index's `ErrorType` and renders the index's `ToolDescription`, so a key - // only one side reads is a failure the page files under `unknown` or a - // description it never shows. The index's `FailedToolCallResult` and the - // `ErrorFingerprint` hashed from it read the call's result the same way, or a - // failure explained only there is grouped by an empty status message. - it("the failure's type, the call's result and the tool's description, exactly", () => { - expect([...GENAI_ERROR_TYPE_KEYS]).toEqual([...decodedKeys("errorType")]) - expect([...GENAI_TOOL_CALL_RESULT_KEYS]).toEqual([...decodedKeys("toolCallResult")]) - expect([...GENAI_TOOL_DESCRIPTION_KEYS]).toEqual([...decodedKeys("toolDescription")]) - }) - - it("every usage bucket and the cost, bucket for bucket", () => { - const fields = { - input: "usageInputTokens", - cacheRead: "usageCacheReadInputTokens", - cacheWrite: "usageCacheCreationInputTokens", - output: "usageOutputTokens", - reasoning: "usageReasoningOutputTokens", - } as const - for (const [bucket, field] of Object.entries(fields)) { - const decoded = decodedKeys(field) - for (const key of GENAI_USAGE_KEYS[bucket as keyof typeof GENAI_USAGE_KEYS]) { - expect(decoded, `${bucket}: ${key}`).toContain(key) - } - // And the other way: the list reads every key the detail page decodes, - // so a session's tokens cannot be counted on one page and not the other. - for (const key of decoded) { - expect( - GENAI_USAGE_KEYS[bucket as keyof typeof GENAI_USAGE_KEYS], - `${bucket}: ${key}`, - ).toContain(key) - } - } - for (const key of GENAI_COST_KEYS) expect(decodedKeys("usageCost")).toContain(key) - for (const key of decodedKeys("usageCost")) expect(GENAI_COST_KEYS).toContain(key) - }) - - it("the provider that decides the usage convention, and its legacy spellings", () => { - for (const key of GENAI_PROVIDER_NAME_KEYS) expect(decodedKeys("providerName")).toContain(key) - for (const key of decodedKeys("providerName")) expect(GENAI_PROVIDER_NAME_KEYS).toContain(key) - // The view matches the pre-rename `gen_ai.system` values the read side - // canonicalises, for every provider the convention table names. - for (const [canonical, legacy] of GENAI_PROVIDER_LEGACY_VALUES) { - expect(LEGACY_SYSTEM_VALUES.get(legacy)).toBe(canonical) - } - for (const [legacy, canonical] of LEGACY_SYSTEM_VALUES) { - if (!GENAI_PROVIDER_USAGE_CONVENTIONS.has(canonical)) continue - expect(GENAI_PROVIDER_LEGACY_VALUES.map(([, value]) => value)).toContain(legacy) - } - }) - - it("translates the OpenInference span kinds the integration refines", () => { - const integration = resolveAiIntegration("openinference-openai") - for (const [kind, operation] of OPENINFERENCE_KIND_OPERATIONS) { - const values: MutableAiGenAiValues = {} - integration.refine?.(values, { - attributes: { "openinference.span.kind": kind }, - row: {} as never, - read: () => undefined, - }) - expect(values.operationName, kind).toBe(operation) - } - }) -}) - -describe("span classification SQL", () => { - it("reads the operation from gen_ai.operation.name, else the OpenInference kind", () => { - expect(sql(genAiOperationExpr(attrs))).toBe( - "coalesce(nullIf(SpanAttributes['gen_ai.operation.name'], ''), multiIf(SpanAttributes['openinference.span.kind'] = 'LLM', 'chat', SpanAttributes['openinference.span.kind'] = 'TOOL', 'execute_tool', SpanAttributes['openinference.span.kind'] = 'AGENT', 'invoke_agent', SpanAttributes['openinference.span.kind'] = 'EMBEDDING', 'embeddings', SpanAttributes['openinference.span.kind'] = 'RETRIEVER', 'retrieval', ''))", - ) - }) - - it("counts a model turn by operation, or by name only for an unclassified agent span", () => { - const text = sql(genAiIsLlmCallCond(columns)) - expect(text).toContain("IN ('chat', 'generate_content', 'text_completion', 'fetch_response')") - // The name rules apply only where the operation is absent or unknown to - // the convention, only to vendor-stamped spans, and only after the tool - // and agent rules have declined — the client's order. - expect(text).toContain( - "NOT IN ('chat', 'generate_content', 'text_completion', 'fetch_response', 'embeddings', 'retrieval', 'execute_tool', 'invoke_agent', 'create_agent', 'invoke_workflow', 'plan', 'agent_step')", - ) - expect(text).toContain("NOT ((coalesce(nullIf(SpanAttributes['gen_ai.tool.name'], '')") - expect(text).toContain("lower(SpanName) LIKE '%tool%'") - expect(text).toContain("NOT ((lower(SpanName) LIKE '%agent%' OR lower(SpanName) LIKE '%workflow%'))") - expect(text).toContain("lower(SpanName) LIKE '%chat%' OR lower(SpanName) LIKE '%completion%'") - }) - - it("counts a tool call by operation, or by a tool name / tool-ish span name", () => { - const text = sql(genAiIsToolCallCond(columns)) - expect(text).toContain("IN ('execute_tool')") - expect(text).toContain("SpanAttributes['tool.name']) != '' OR lower(SpanName) LIKE '%tool%'") - }) - - it("sums the token buckets under the reporter's convention, each coalesced canonical-first", () => { - const text = sql(genAiTokensExpr(attrs)) - // The prompt half: the cache buckets nest in the prompt figure for the - // re-summing vendors and the OpenAI-shaped providers, and sit beside it - // for Anthropic; the default nests. - expect(text).toContain( - "multiIf(SpanAttributes['maple_ai.vendor.id'] IN ('vercel_ai_sdk', 'maple'), greatest(", - ) - expect(text).toContain( - "IN ('openai', 'gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai', 'openrouter'), greatest(", - ) - expect(text).toContain( - "IN ('anthropic'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens']", - ) - // The completion half: reasoning nests for Anthropic and the OpenAI-shaped - // providers, and sits beside the completion for Gemini. - expect(text).toContain("IN ('anthropic', 'openai', 'openrouter'), greatest(") - expect(text).toContain( - "IN ('gcp.gemini', 'gemini', 'gcp.vertex_ai', 'vertex_ai'), toFloat64OrZero(coalesce(nullIf(SpanAttributes['gen_ai.usage.output_tokens']", - ) - // Every bucket is read canonical-first, down to the OpenInference spelling. - expect(text).toContain("coalesce(nullIf(SpanAttributes['gen_ai.usage.input_tokens'], '')") - expect(text).toContain("SpanAttributes['llm.token_count.completion_details.reasoning']))") - }) - - it("flags a failure by status or by a declared failure attribute", () => { - expect(sql(genAiIsErrorCond(columns))).toBe( - "((StatusCode = 'Error' OR SpanAttributes['error.type'] != '') OR SpanAttributes['gen_ai.response.status'] IN ('failed', 'error'))", - ) - }) - +describe("session usage SQL", () => { it("collects a trace's reporters and model calls, capped", () => { const reporters = usageReportersExpr({ SpanId: CH.dynamicColumn("SpanId", T.string), diff --git a/packages/query-engine-integrations/src/ai/ai-span-columns.ts b/packages/query-engine-integrations/src/ai/ai-span-columns.ts index aca4331d54..90630f91eb 100644 --- a/packages/query-engine-integrations/src/ai/ai-span-columns.ts +++ b/packages/query-engine-integrations/src/ai/ai-span-columns.ts @@ -10,7 +10,10 @@ // charges each reporter to its nearest reporting ancestor and keeps only the // excess; {@link sessionUsageSum} is that rule in SQL, over the trace's // index rows ({@link usageLinksExpr}): Strands puts an event-loop span -// between the agent and its calls, the Vercel AI SDK a step span. +// between the agent and its calls, the Vercel AI SDK a step span. Since +// migration 0039 the index reads usage the ingest gateway stamped on the +// model call alone, so on rows materialized after it no wrapper reports and +// the netting changes nothing; it serves the older rows until they age out. // - A sub-step of a call: a gateway records its provider attempts as model // spans under the model span (OpenRouter's `provider attempt N`), and an SDK // wraps `doGenerate` in `generateText`. A model span that reports no usage From 4dc5db18bb36730fc51c40cdbe4a04436499ae43 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 21:08:04 +0200 Subject: [PATCH 04/17] chore(ingest): name the stamp key table's entry type, point the stamps at MAPLE_AI_STAMP_ATTRS --- apps/ingest/src/ai_session/facts.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/ingest/src/ai_session/facts.rs b/apps/ingest/src/ai_session/facts.rs index 33ca394086..dd04972146 100644 --- a/apps/ingest/src/ai_session/facts.rs +++ b/apps/ingest/src/ai_session/facts.rs @@ -21,7 +21,8 @@ //! //! A flag is written only when it holds and a value only when there is one: //! `maple_ai.llm_call`, present on every stamped span, is what tells a reader -//! the rest were decided. +//! the rest were decided. The keys must match `MAPLE_AI_STAMP_ATTRS` in +//! `packages/domain/src/gen-ai.ts`. //! //! Performance: the span's attributes are read in one pass, each key looked //! up once in a table of every key any fact reads, instead of one scan of @@ -59,7 +60,7 @@ const CONFIRMATION_REQUEST: &str = "This tool call requires confirmation"; /// Response model first: an alias in the request resolves to a dated /// snapshot in the response. -pub(super) const MODEL_KEYS: &[&str] = &[ +const MODEL_KEYS: &[&str] = &[ "gen_ai.response.model", "gen_ai.request.model", "ai.response.model", @@ -69,7 +70,7 @@ pub(super) const MODEL_KEYS: &[&str] = &[ /// `ai.telemetry.functionId` is the name an app gave a traced Vercel AI SDK /// call, the only agent identity an older-SDK span has. const AGENT_NAME_KEYS: &[&str] = &["gen_ai.agent.name", "ai.telemetry.functionId"]; -pub(super) const TOOL_NAME_KEYS: &[&str] = &["gen_ai.tool.name", "ai.toolCall.name", "tool.name"]; +const TOOL_NAME_KEYS: &[&str] = &["gen_ai.tool.name", "ai.toolCall.name", "tool.name"]; const TOOL_CALL_ID_KEYS: &[&str] = &["gen_ai.tool.call.id", "ai.toolCall.id"]; const TOOL_DESCRIPTION_KEYS: &[&str] = &["gen_ai.tool.description", "tool.description"]; const TOOL_RESULT_KEYS: &[&str] = &["gen_ai.tool.call.result", "ai.toolCall.result"]; @@ -130,12 +131,15 @@ enum Slot { Number(usize), } +/// A key, the fact it feeds, and its rank in that fact's list. +type Key = (&'static str, Slot, u8); + /// Every key above as `(key, slot, rank)`, bucketed by the key's length; /// `rank` is the key's place in its fact's list. A span's key is compared -/// only against the few keys of its own length, so the common miss costs an -/// index and a short loop, never a string comparison. -static KEY_TABLE: LazyLock>> = LazyLock::new(|| { - let mut table: Vec> = Vec::new(); +/// only against the few keys of its own length, and their last byte first, so +/// the common miss costs an index and a short loop, rarely a string compare. +static KEY_TABLE: LazyLock>> = LazyLock::new(|| { + let mut table: Vec> = Vec::new(); let lists = TEXT_KEYS .iter() .enumerate() From 7a763ea8372fb8c78f4b84139003747cba11b868 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 21:12:01 +0200 Subject: [PATCH 05/17] docs(agent-sessions): point comments at the gateway's stamps instead of the deleted SQL builders --- apps/ingest/src/ai_session/claude_code.rs | 4 ++-- packages/domain/src/tinybird/datasources.ts | 2 +- packages/query-engine-integrations/src/ai/ai-tools.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ingest/src/ai_session/claude_code.rs b/apps/ingest/src/ai_session/claude_code.rs index 584822a22f..e99c1f2c21 100644 --- a/apps/ingest/src/ai_session/claude_code.rs +++ b/apps/ingest/src/ai_session/claude_code.rs @@ -4,8 +4,8 @@ //! Claude Code (`com.anthropic.claude_code.tracing`) names its facts in its own //! vocabulary — `input_tokens`, `tool_name`, `user_prompt` — and puts a tool's //! output in a `tool.output` span event. Every reader of an agent span keys on -//! `gen_ai.*`: the `ai_trace_index` materialized view settles usage and kind at -//! insert from fixed key lists, and the read side never sees span events. So the +//! `gen_ai.*`: the gateway's own facts pass (`facts.rs`) and the detail page read +//! fixed key lists, and the read side never sees span events. So the //! restatement happens here, once, where the span is still whole, rather than as //! a dialect every reader (the view, the integrations layer, a BYO-ClickHouse //! schema) has to learn — and an index row materialized without it could never diff --git a/packages/domain/src/tinybird/datasources.ts b/packages/domain/src/tinybird/datasources.ts index 4b670d4d48..35ae2fefd3 100644 --- a/packages/domain/src/tinybird/datasources.ts +++ b/packages/domain/src/tinybird/datasources.ts @@ -1193,7 +1193,7 @@ export const aiTraceIndex = defineDatasource("ai_trace_index", { ResponseId: t.string(), // Migration 0031 — the last facts the Agent Sessions list read off the // raw spans: the vendor's version beside its id, and the five disjoint - // buckets `Tokens` is the sum of (`genAiUsageBucketsExpr`), so a row + // buckets `Tokens` is the sum of (the gateway's `maple_ai.usage.*`), so a row // renders from one index query instead of a fan-out over // `trace_detail_spans`. '' / 0 on rows materialized before it. VendorVersion: t.string().lowCardinality(), diff --git a/packages/query-engine-integrations/src/ai/ai-tools.ts b/packages/query-engine-integrations/src/ai/ai-tools.ts index ee281e7261..38b7a61c4c 100644 --- a/packages/query-engine-integrations/src/ai/ai-tools.ts +++ b/packages/query-engine-integrations/src/ai/ai-tools.ts @@ -656,7 +656,7 @@ const groupFilter = (opts: AiToolErrorsOpts, $: Pick Date: Tue, 29 Sep 2026 21:14:52 +0200 Subject: [PATCH 06/17] fix(agent-sessions): number the gateway-stamps view migration 0035 Migration versions must be contiguous, and a BYO-ClickHouse instance at 0039 would skip a lower number landing later. The in-flight view changes rebase onto this one and drop their own migrations of the view. --- ... => 0035_ai_trace_index_gateway_stamps.ts} | 12 +++++------ .../src/clickhouse/migrations/index.test.ts | 20 +++++++++---------- .../domain/src/clickhouse/migrations/index.ts | 4 ++-- packages/domain/src/tinybird/datasources.ts | 2 +- .../domain/src/tinybird/materializations.ts | 4 ++-- .../src/ai/ai-span-columns.ts | 2 +- packages/query-engine/src/ch/tables.ts | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) rename packages/domain/src/clickhouse/migrations/{0039_ai_trace_index_gateway_stamps.ts => 0035_ai_trace_index_gateway_stamps.ts} (93%) diff --git a/packages/domain/src/clickhouse/migrations/0039_ai_trace_index_gateway_stamps.ts b/packages/domain/src/clickhouse/migrations/0035_ai_trace_index_gateway_stamps.ts similarity index 93% rename from packages/domain/src/clickhouse/migrations/0039_ai_trace_index_gateway_stamps.ts rename to packages/domain/src/clickhouse/migrations/0035_ai_trace_index_gateway_stamps.ts index a096ef61c5..fb0e864e41 100644 --- a/packages/domain/src/clickhouse/migrations/0039_ai_trace_index_gateway_stamps.ts +++ b/packages/domain/src/clickhouse/migrations/0035_ai_trace_index_gateway_stamps.ts @@ -1,5 +1,5 @@ /** - * Migration 0039 — `ai_trace_index_mv` projects the ingest gateway's stamps. + * Migration 0035 — `ai_trace_index_mv` projects the ingest gateway's stamps. * * The view used to decide every GenAI fact itself, at insert: which span is a * model call or a tool call (operation lists, span-name needles), whether it @@ -23,14 +23,14 @@ * * `requiredForIngest: false` — the gateway writes `traces`, never this table. * - * Numbered 0039 because 0035-0038 are claimed by open pull requests; renumber - * at merge time. + * This view replaces the in-flight view changes of other pull requests, which + * rebase onto it and drop their own migrations of this view. * * The CREATE statement below is the verbatim DDL as the schema emitter produced - * it at v39. Frozen history: never re-derive it from a later snapshot. + * it at v35. Frozen history: never re-derive it from a later snapshot. */ -export const migration_0039_ai_trace_index_gateway_stamps = { - version: 39, +export const migration_0035_ai_trace_index_gateway_stamps = { + version: 35, description: "Recreate ai_trace_index_mv as a projection of the ingest gateway's maple_ai.* stamps", requiredForIngest: false, statements: [ diff --git a/packages/domain/src/clickhouse/migrations/index.test.ts b/packages/domain/src/clickhouse/migrations/index.test.ts index e1c4ddd9a3..866ab1802b 100644 --- a/packages/domain/src/clickhouse/migrations/index.test.ts +++ b/packages/domain/src/clickhouse/migrations/index.test.ts @@ -39,7 +39,7 @@ import { migration_0031_ai_trace_index_list_columns } from "./0031_ai_trace_inde import { migration_0032_ai_trace_index_tool_detail_columns } from "./0032_ai_trace_index_tool_detail_columns" import { migration_0033_ai_crawler_requests } from "./0033_ai_crawler_requests" import { migration_0034_trace_facets_hourly, traceFacetsHourlyBackfill } from "./0034_trace_facets_hourly" -import { migration_0039_ai_trace_index_gateway_stamps } from "./0039_ai_trace_index_gateway_stamps" +import { migration_0035_ai_trace_index_gateway_stamps } from "./0035_ai_trace_index_gateway_stamps" import { latestSnapshotStatements } from "../../generated/clickhouse-schema" import { clickHouseSchemaVersion, latestMigrationVersion, migrations } from "./index" @@ -57,10 +57,10 @@ describe("ClickHouse migrations", () => { it("keeps migrations ordered by version", () => { expect(migrations.map((m) => m.version)).toEqual([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 39, + 28, 29, 30, 31, 32, 33, 34, 35, ]) - expect(migrations.at(-1)).toBe(migration_0039_ai_trace_index_gateway_stamps) - expect(latestMigrationVersion).toBe(39) + expect(migrations.at(-1)).toBe(migration_0035_ai_trace_index_gateway_stamps) + expect(latestMigrationVersion).toBe(35) // 0010 and 0014-0020 are read-path only and skipped by the ingest-gating // version; 0021 is not — the gateway writes `session_events`' new identity // columns and `product_events` directly, so a BYO-CH org must apply it @@ -97,8 +97,8 @@ describe("ClickHouse migrations", () => { expect(migration_0033_ai_crawler_requests.requiredForIngest).toBe(false) // 0034 adds the MV-populated trace_facets_hourly. expect(migration_0034_trace_facets_hourly.requiredForIngest).toBe(false) - // 0039 only recreates the MV-populated ai_trace_index's view. - expect(migration_0039_ai_trace_index_gateway_stamps.requiredForIngest).toBe(false) + // 0035 only recreates the MV-populated ai_trace_index's view. + expect(migration_0035_ai_trace_index_gateway_stamps.requiredForIngest).toBe(false) }) it("recreates both error-events MVs with the span-attribute exception fallback", () => { @@ -926,9 +926,9 @@ describe("migration 0032 — ai_trace_index tool detail columns", () => { }) }) -describe("migration 0039 — ai_trace_index_mv projects the gateway's stamps", () => { +describe("migration 0035 — ai_trace_index_mv projects the gateway's stamps", () => { it("recreates the view over the maple_ai.* stamps, with no dialect key and no convention", () => { - const [drop, create, ...rest] = migration_0039_ai_trace_index_gateway_stamps.statements + const [drop, create, ...rest] = migration_0035_ai_trace_index_gateway_stamps.statements expect(rest).toEqual([]) expect(drop).toBe("DROP VIEW IF EXISTS ai_trace_index_mv") expect(create).toBe(latestSnapshotStatements.find((stmt) => stmt.includes("ai_trace_index_mv TO"))) @@ -958,7 +958,7 @@ describe("migration 0039 — ai_trace_index_mv projects the gateway's stamps", ( }) it("does not backfill and does not gate ingest", () => { - expect(migration_0039_ai_trace_index_gateway_stamps.requiredForIngest).toBe(false) - expect(migration_0039_ai_trace_index_gateway_stamps.statements.some(isBackfill)).toBe(false) + expect(migration_0035_ai_trace_index_gateway_stamps.requiredForIngest).toBe(false) + expect(migration_0035_ai_trace_index_gateway_stamps.statements.some(isBackfill)).toBe(false) }) }) diff --git a/packages/domain/src/clickhouse/migrations/index.ts b/packages/domain/src/clickhouse/migrations/index.ts index f47e2343c8..6da69ef0b1 100644 --- a/packages/domain/src/clickhouse/migrations/index.ts +++ b/packages/domain/src/clickhouse/migrations/index.ts @@ -33,7 +33,7 @@ import { migration_0031_ai_trace_index_list_columns } from "./0031_ai_trace_inde import { migration_0032_ai_trace_index_tool_detail_columns } from "./0032_ai_trace_index_tool_detail_columns" import { migration_0033_ai_crawler_requests } from "./0033_ai_crawler_requests" import { migration_0034_trace_facets_hourly } from "./0034_trace_facets_hourly" -import { migration_0039_ai_trace_index_gateway_stamps } from "./0039_ai_trace_index_gateway_stamps" +import { migration_0035_ai_trace_index_gateway_stamps } from "./0035_ai_trace_index_gateway_stamps" /** * A migration statement is either a raw SQL string (structural DDL) or a @@ -99,7 +99,7 @@ export const migrations: ReadonlyArray = [ migration_0032_ai_trace_index_tool_detail_columns, migration_0033_ai_crawler_requests, migration_0034_trace_facets_hourly, - migration_0039_ai_trace_index_gateway_stamps, + migration_0035_ai_trace_index_gateway_stamps, ] as const /** Highest migration `version` bundled — i.e. the schema level a fully-applied diff --git a/packages/domain/src/tinybird/datasources.ts b/packages/domain/src/tinybird/datasources.ts index 35ae2fefd3..2f890ca7da 100644 --- a/packages/domain/src/tinybird/datasources.ts +++ b/packages/domain/src/tinybird/datasources.ts @@ -1171,7 +1171,7 @@ export const aiTraceIndex = defineDatasource("ai_trace_index", { VendorId: t.string().lowCardinality(), ServiceName: t.string().lowCardinality(), // Migration 0026 — the sidebar's other facet dimensions, and the per-span - // measures the page ranks and filters on. Since 0039 the GenAI ones project a + // measures the page ranks and filters on. Since 0035 the GenAI ones project a // fact the ingest gateway stamped (`gen-ai-columns.ts`). DeploymentEnv: t.string().lowCardinality(), Model: t.string().lowCardinality(), diff --git a/packages/domain/src/tinybird/materializations.ts b/packages/domain/src/tinybird/materializations.ts index 165ea3de2b..7c65687b1a 100644 --- a/packages/domain/src/tinybird/materializations.ts +++ b/packages/domain/src/tinybird/materializations.ts @@ -1032,10 +1032,10 @@ export const traceDetailSpansMv = defineMaterializedView("trace_detail_spans_mv" * * Every other GenAI column is a projection of the facts the gateway decided * for the span and stamped on it (`MAPLE_AI_STAMP_ATTRS`, SQL from - * `gen-ai-columns.ts`) since migration 0039: the view holds no vendor rule, + * `gen-ai-columns.ts`) since migration 0035: the view holds no vendor rule, * and a dialect is taught to the gateway instead. Rows materialized before it * keep the values the view's own rules gave them until the 30-day TTL. Before - * 0039 the view coalesced the dialects and classified the span itself. + * 0035 the view coalesced the dialects and classified the span itself. * Migration 0026 added the columns; rows materialized before * it carry `''`/0 throughout, which the facets drop, the filters never match * and the sums count as nothing. Migration 0027 changed `Tokens` to count a diff --git a/packages/query-engine-integrations/src/ai/ai-span-columns.ts b/packages/query-engine-integrations/src/ai/ai-span-columns.ts index 90630f91eb..14b064c2f7 100644 --- a/packages/query-engine-integrations/src/ai/ai-span-columns.ts +++ b/packages/query-engine-integrations/src/ai/ai-span-columns.ts @@ -11,7 +11,7 @@ // excess; {@link sessionUsageSum} is that rule in SQL, over the trace's // index rows ({@link usageLinksExpr}): Strands puts an event-loop span // between the agent and its calls, the Vercel AI SDK a step span. Since -// migration 0039 the index reads usage the ingest gateway stamped on the +// migration 0035 the index reads usage the ingest gateway stamped on the // model call alone, so on rows materialized after it no wrapper reports and // the netting changes nothing; it serves the older rows until they age out. // - A sub-step of a call: a gateway records its provider attempts as model diff --git a/packages/query-engine/src/ch/tables.ts b/packages/query-engine/src/ch/tables.ts index f9d24780fd..5ce7580e11 100644 --- a/packages/query-engine/src/ch/tables.ts +++ b/packages/query-engine/src/ch/tables.ts @@ -112,7 +112,7 @@ export const TraceDetailSpans = table("trace_detail_spans", { * Migration 0026 added the sidebar's other facet dimensions (`DeploymentEnv`, * `Model`, `AgentName`, `ToolName`) and the per-span measures the page ranks * and filters on (`IsError`, `IsLlmCall`, `IsToolCall`, `Tokens`, `Cost`, with - * `SpanId`/`ParentSpanId`/`Duration`), each since 0039 a projection of the + * `SpanId`/`ParentSpanId`/`Duration`), each since 0035 a projection of the * fact the ingest gateway stamped on the span * (`@maple/domain/tinybird/gen-ai-columns`); `''`/0 where the span carries no * such fact, and on every row materialized before 0026. From 3d15749bcb75ccbc8751b04ad907daa97cc26124 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 21:15:35 +0200 Subject: [PATCH 07/17] fix(agent-sessions): the prompt-cache check counts the gateway's cache buckets --- .../agent-sessions/src/session-checks.test.ts | 28 +++++++++++++++++++ packages/agent-sessions/src/session-checks.ts | 6 +++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/agent-sessions/src/session-checks.test.ts b/packages/agent-sessions/src/session-checks.test.ts index b04f6097cd..267fc384b4 100644 --- a/packages/agent-sessions/src/session-checks.test.ts +++ b/packages/agent-sessions/src/session-checks.test.ts @@ -400,6 +400,34 @@ describe("buildSessionChecks", () => { expect(byId(checks(firstTurn()), "prompt-cache").status).toBe("skipped") }) + it("reads the prompt cache off the gateway's buckets on a span it stamped", () => { + // Strands' pre-rename cache key, which the detail decode does not alias: + // only the gateway's bucket says the call read from cache. + const call = (spanId: string, startMs: number) => + llmSpan({ + spanId, + parentSpanId: "a1", + startMs, + durationMs: SECOND, + model: "claude-opus-5", + genAi: { + conversationId: "t1", + mapleLlmCall: 1, + mapleInputTokens: 1_000, + mapleCacheReadTokens: 9_000, + mapleOutputTokens: 100, + }, + }) + const report = checks([ + agentSpan({ spanId: "a1", startMs: 0, durationMs: MINUTE, genAi: { conversationId: "t1" } }), + call("c1", SECOND), + call("c2", 5 * SECOND), + call("c3", 10 * SECOND), + call("c4", 15 * SECOND), + ]) + expect(byId(report, "prompt-cache").headline).toBe("Cache hit rate 90% over 3 calls") + }) + // Short test conversations (DSPy peaked at 870 tokens; Claude Agent SDK on // Haiku 4.5 sent 1.2K–2.3K against a 4,096-token minimum) cannot be cached, // so reading them as misses warned "0% ... missed the cache" on every one. diff --git a/packages/agent-sessions/src/session-checks.ts b/packages/agent-sessions/src/session-checks.ts index c7b08805d1..8c3266c3c7 100644 --- a/packages/agent-sessions/src/session-checks.ts +++ b/packages/agent-sessions/src/session-checks.ts @@ -627,9 +627,13 @@ function stallCheck(findings: readonly SessionFinding[]): SessionCheck { const isClaude = (span: AiSessionSpan): boolean => span.genAi.providerName === "anthropic" || /claude/i.test(spanModel(span) ?? "") +/** The emitter's own cache keys, or the gateway's buckets, which it stamps + * from dialect spellings the detail decode may not alias. */ const reportsCache = (span: AiSessionSpan): boolean => span.genAi.usageCacheReadInputTokens !== undefined || - span.genAi.usageCacheCreationInputTokens !== undefined + span.genAi.usageCacheCreationInputTokens !== undefined || + span.genAi.mapleCacheReadTokens !== undefined || + span.genAi.mapleCacheWriteTokens !== undefined /** * One span per model call, so a call its framework also rolled up (ADK From fabfe4d01599e5a5d7b08175a88f2c7af1388558 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 21:16:18 +0200 Subject: [PATCH 08/17] fix(agent-sessions): the detail page names the agent the gateway named The span mapper prefers maple_ai.agent.name over the decoded dialect keys, so every reader of genAi.agentName (header, turns, waterfall, filters) shows the agent the list and its facets show, OpenAI Agents' graph node included. --- .../src/__sql_baseline__/integrations.sql | 8 ++++---- .../src/ai/ai-integrations.test.ts | 14 ++++++++++++++ .../src/ai/ai-integrations.ts | 6 ++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql index 5cfec0c50e..0350d45fb0 100644 --- a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql +++ b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql @@ -759,7 +759,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + mapFilter((k, v) -> (((k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'maple_ai.agent.name', '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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -790,7 +790,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + mapFilter((k, v) -> (((k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'maple_ai.agent.name', '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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -907,7 +907,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + mapFilter((k, v) -> (((k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'maple_ai.agent.name', '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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' @@ -929,7 +929,7 @@ SELECT StatusCode AS statusCode, StatusMessage AS statusMessage, toString(Timestamp) AS timestamp, - 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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes + mapFilter((k, v) -> (((k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'maple_ai.agent.name', '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', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.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', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_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.finish_reason', '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', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes) AS spanAttributes FROM trace_detail_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' diff --git a/packages/query-engine-integrations/src/ai/ai-integrations.test.ts b/packages/query-engine-integrations/src/ai/ai-integrations.test.ts index 3bcc8cde88..559a326c3e 100644 --- a/packages/query-engine-integrations/src/ai/ai-integrations.test.ts +++ b/packages/query-engine-integrations/src/ai/ai-integrations.test.ts @@ -162,6 +162,20 @@ describe("value decoding", () => { expect(mapAiSpan(row({ "gen_ai.request.model": " " })).genAi.requestModel).toBeUndefined() }) + it("names the agent the ingest gateway named, as the list does", () => { + // OpenAI Agents through OpenInference names its agent only in the AGENT + // span's graph node, which the gateway reads and no dialect key carries. + const agent = mapAiSpan( + row({ + "maple_ai.vendor.id": "openai_agents_sdk", + "openinference.span.kind": "AGENT", + "graph.node.id": "Triage Agent", + "maple_ai.agent.name": "Triage Agent", + }), + ) + expect(agent.genAi.agentName).toBe("Triage Agent") + }) + it("treats JSON null as not captured", () => { expect(mapAiSpan(row({ "gen_ai.input.messages": "null" })).genAi.inputMessages).toBeUndefined() }) diff --git a/packages/query-engine-integrations/src/ai/ai-integrations.ts b/packages/query-engine-integrations/src/ai/ai-integrations.ts index 374eeafca8..0ee97cc0ee 100644 --- a/packages/query-engine-integrations/src/ai/ai-integrations.ts +++ b/packages/query-engine-integrations/src/ai/ai-integrations.ts @@ -19,6 +19,7 @@ import { AI_GENAI_FIELDS, AI_PROMPT_VARIABLE_PREFIX, MAPLE_AI_SESSION_ID_ATTR, + MAPLE_AI_STAMP_ATTRS, MAPLE_AI_VENDOR_ID_ATTR, MAPLE_AI_VENDOR_VERSION_ATTR, type AiAgentSpan, @@ -285,6 +286,7 @@ export const aiSpanAttributeKeys: readonly string[] = [ MAPLE_AI_SESSION_ID_ATTR, MAPLE_AI_VENDOR_ID_ATTR, MAPLE_AI_VENDOR_VERSION_ATTR, + MAPLE_AI_STAMP_ATTRS.agentName, ...[genAiIntegration, ...resolvedIntegrations.values()].flatMap((integration) => Object.values(integration.sources).flat(), ), @@ -376,6 +378,10 @@ export const mapAiSpan = (row: AiSessionSpansOutput): AiAgentSpan => { } } integration.refine?.(genAi, { row, attributes, read }) + // The agent the ingest gateway named, which the list and its facets show: it + // reads names no dialect key carries (OpenAI Agents' graph node). + const stampedAgent = readAttribute(attributes, MAPLE_AI_STAMP_ATTRS.agentName) + if (stampedAgent !== undefined) genAi.agentName = stampedAgent const promptVariables = collectPromptVariables(attributes) const sessionId = readAttribute(attributes, MAPLE_AI_SESSION_ID_ATTR) From 0410c4dac62600dc10a58e732e3b56e212603c18 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 21:17:04 +0200 Subject: [PATCH 09/17] chore(agent-sessions): drop unread stamp keys, unexport in-file constants, fix stale comments --- ...dex-materialization.clickhouse.e2e.test.ts | 4 ++-- packages/domain/src/gen-ai.ts | 19 ++++++++----------- packages/domain/src/tinybird/datasources.ts | 2 +- .../domain/src/tinybird/gen-ai-columns.ts | 4 ++-- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts b/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts index e6c0c181f0..2907bc2415 100644 --- a/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts +++ b/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts @@ -996,8 +996,8 @@ describe.skipIf(!clickhouseE2eEnabled)("ai_trace_index materialization", () => { it("measures each session off the index the way the detail page does", async () => { const [sessionless, session] = await rankPage() - // Name-classified inference: no operation name, but a model and no - // tool/agent words in the span name. + // The gateway's model call: a legacy-scope `doGenerate` with no + // operation name. assert.deepStrictEqual( [sessionless!.models, sessionless!.agentNames, sessionless!.llmCalls, sessionless!.toolCalls], [["gpt-5"], [], 1, 0], diff --git a/packages/domain/src/gen-ai.ts b/packages/domain/src/gen-ai.ts index 89535dfd93..a031266036 100644 --- a/packages/domain/src/gen-ai.ts +++ b/packages/domain/src/gen-ai.ts @@ -129,12 +129,11 @@ export const MAPLE_GENAI_MODEL_DURATION_MS_ATTR = "maple_ai.model_duration_ms" * presence is what says the gateway decided the rest; a span ingested * before it did carries none, and its readers keep their op/name rules and * usage conventions for it until it ages out of the 30-day TTL. - * - `toolCall`, `error`, `toolPaused`: `"1"` where they hold, absent otherwise. - * `toolPaused` marks a tool call's copy that recorded no outcome — no result - * or only Google ADK's confirmation request — which a call paused for a - * human's approval leaves behind. - * - `model`, `agentName`, `toolName`, `toolCallId`, `responseId`: the first - * non-empty value across the dialects' keys; `responseId` on model calls. + * - `toolCall`, `error`: `"1"` where they hold, absent otherwise. + * - `model`, `agentName`, `toolName`, `responseId`: the first non-empty value + * across the dialects' keys; `responseId` on model calls. + * - The gateway also stamps `maple_ai.tool.call_id` and `maple_ai.tool.paused` + * (a tool call's copy that recorded no outcome), which nothing reads yet. * - `toolDescription` (on tool calls) and `toolErrorResult` (a failed tool * call's result), cut by the gateway. * - The usage buckets, on the model call alone: `inputTokens` the uncached @@ -148,11 +147,9 @@ export const MAPLE_AI_STAMP_ATTRS = { llmCall: "maple_ai.llm_call", toolCall: "maple_ai.tool_call", error: "maple_ai.error", - toolPaused: "maple_ai.tool.paused", model: "maple_ai.model", agentName: "maple_ai.agent.name", toolName: "maple_ai.tool.name", - toolCallId: "maple_ai.tool.call_id", responseId: "maple_ai.response.id", toolDescription: "maple_ai.tool.description", toolErrorResult: "maple_ai.tool.error_result", @@ -195,7 +192,7 @@ const NESTED: GenAiUsageConvention = { inputIncludesCache: true, outputIncludesR * under. Only providers whose wire shape was checked are listed; anything else * takes {@link GENAI_DEFAULT_USAGE_CONVENTION}. */ -export const GENAI_PROVIDER_USAGE_CONVENTIONS: ReadonlyMap = new Map([ +const GENAI_PROVIDER_USAGE_CONVENTIONS: ReadonlyMap = new Map([ // Messages API: `input_tokens` excludes both cache buckets and is billed // beside them; `output_tokens` includes the thinking tokens. ["anthropic", { inputIncludesCache: false, outputIncludesReasoning: true }], @@ -214,7 +211,7 @@ export const GENAI_PROVIDER_USAGE_CONVENTIONS: ReadonlyMap = new Map([ +const GENAI_VENDOR_USAGE_CONVENTIONS: ReadonlyMap = new Map([ // The Vercel AI SDK emits `gen_ai.usage.input_tokens` as // `usage.inputTokens.total` and the output as `outputTokens.total`, and its // providers build both totals as the sum of their parts (`@ai-sdk/anthropic` @@ -234,7 +231,7 @@ export const GENAI_VENDOR_USAGE_CONVENTIONS: ReadonlyMap * The columns are what its readers need — the trace-id set, the grouping key, * the agent-span bounds that tell the fan-out which hours to read, the filter * dimensions the sidebar offers (service, environment, and the span's model, - * agent and tool coalesced across dialects), and the per-span measures the + * agent and tool as stamped by the ingest gateway), and the per-span measures the * page ranks and filters on: whether the span is a model call, a tool call, a * failure, and the tokens and cost it reported, with `SpanId`/`ParentSpanId` * so a wrapper's roll-up of its children's usage can be taken off it. Every diff --git a/packages/domain/src/tinybird/gen-ai-columns.ts b/packages/domain/src/tinybird/gen-ai-columns.ts index dd30973d9b..8a47670f88 100644 --- a/packages/domain/src/tinybird/gen-ai-columns.ts +++ b/packages/domain/src/tinybird/gen-ai-columns.ts @@ -40,13 +40,13 @@ const leftUTF8 = (value: Expr, chars: number): Expr => * group as often as the type is — but a framework that puts a stack trace there * would otherwise make the index as wide as the raw span. */ -export const GENAI_STATUS_MESSAGE_MAX = 400 +const GENAI_STATUS_MESSAGE_MAX = 400 /** How much of a failure's text is redacted and hashed: no longer than the * status message the index carries, nor the failed tool call's result the * gateway cuts, so a fingerprint is a function of its row's own * `FailedToolCallResult` and `StatusMessage`. */ -export const GENAI_ERROR_FINGERPRINT_CHARS = 400 +const GENAI_ERROR_FINGERPRINT_CHARS = 400 const statusMessage = leftUTF8(CH.dynamicColumn("StatusMessage"), GENAI_STATUS_MESSAGE_MAX) const failedToolCallResult = attr(MAPLE_AI_STAMP_ATTRS.toolErrorResult) From 280f997949ded2614801ff0c19bccf2945962c3e Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:09:55 +0200 Subject: [PATCH 10/17] fix(agent-sessions): build the e2e gateway stamps without an open dictionary binding --- ...dex-materialization.clickhouse.e2e.test.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts b/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts index 2907bc2415..4362cd0995 100644 --- a/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts +++ b/packages/backend/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts @@ -110,8 +110,18 @@ const gateway = (facts: { readonly usage?: readonly [number, number, number, number, number] readonly cost?: string }): Readonly> => { - const stamps: Record = { [MAPLE_AI_STAMP_ATTRS.llmCall]: facts.llmCall ? "1" : "0" } - const text: ReadonlyArray = [ + const usage = [ + MAPLE_AI_STAMP_ATTRS.inputTokens, + MAPLE_AI_STAMP_ATTRS.cacheReadTokens, + MAPLE_AI_STAMP_ATTRS.cacheWriteTokens, + MAPLE_AI_STAMP_ATTRS.outputTokens, + MAPLE_AI_STAMP_ATTRS.reasoningTokens, + ].map((key, index): readonly [string, string | undefined] => { + const count = facts.usage?.[index] ?? 0 + return [key, count > 0 ? String(count) : undefined] + }) + const entries: ReadonlyArray = [ + [MAPLE_AI_STAMP_ATTRS.llmCall, facts.llmCall ? "1" : "0"], [MAPLE_AI_STAMP_ATTRS.model, facts.model], [MAPLE_AI_STAMP_ATTRS.agentName, facts.agentName], [MAPLE_AI_STAMP_ATTRS.toolName, facts.toolName], @@ -121,20 +131,11 @@ const gateway = (facts: { [MAPLE_AI_STAMP_ATTRS.cost, facts.cost], [MAPLE_AI_STAMP_ATTRS.toolCall, facts.toolCall ? "1" : undefined], [MAPLE_AI_STAMP_ATTRS.error, facts.error ? "1" : undefined], + ...usage, ] - for (const [key, value] of text) if (value !== undefined) stamps[key] = value - const buckets = [ - MAPLE_AI_STAMP_ATTRS.inputTokens, - MAPLE_AI_STAMP_ATTRS.cacheReadTokens, - MAPLE_AI_STAMP_ATTRS.cacheWriteTokens, - MAPLE_AI_STAMP_ATTRS.outputTokens, - MAPLE_AI_STAMP_ATTRS.reasoningTokens, - ] - buckets.forEach((key, index) => { - const count = facts.usage?.[index] ?? 0 - if (count > 0) stamps[key] = String(count) - }) - return stamps + return Object.fromEntries( + entries.filter((entry): entry is readonly [string, string] => entry[1] !== undefined), + ) } // The turn-owning span of the eve session: the only one of its trace that From a2552a6826459f8e3a38d70e0a32e24575549d39 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:10:43 +0200 Subject: [PATCH 11/17] test(agent-sessions): pin the stamp keys against the ingest gateway's sources --- packages/domain/src/gen-ai.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 packages/domain/src/gen-ai.test.ts diff --git a/packages/domain/src/gen-ai.test.ts b/packages/domain/src/gen-ai.test.ts new file mode 100644 index 0000000000..360afb2a84 --- /dev/null +++ b/packages/domain/src/gen-ai.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" +import { MAPLE_AI_STAMP_ATTRS } from "./gen-ai" + +// The ingest gateway writes these keys and every reader here reads them, and +// nothing at runtime reconciles the two: a key renamed on one side reads as +// every span materializing with no model, no calls and no usage. So the +// literals are pinned against the Rust sources that write them. +const gatewaySource = ["facts.rs", "usage.rs"] + .map((file) => + readFileSync(new URL(`../../../apps/ingest/src/ai_session/${file}`, import.meta.url), "utf8"), + ) + .join("\n") + +describe("MAPLE_AI_STAMP_ATTRS", () => { + it.each(Object.values(MAPLE_AI_STAMP_ATTRS))("is written by the ingest gateway: %s", (key) => { + expect(gatewaySource).toContain(`"${key}"`) + }) +}) From 16ea93329459577b44e21ae791c50371af9a777a Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:18:29 +0200 Subject: [PATCH 12/17] fix(ingest): read a text fact of any OTLP type, as the warehouse Map holds it An integer tool call id or response id, a structured tool call result and a non-string error.type counted when the view read the Map; the stamps now count them too, stringified the way the row encoder writes the Map. Only Google ADK writes the confirmation request, so only its tool results are searched for it. --- apps/ingest/src/ai_session/facts.rs | 183 +++++++++++++++++++++------- apps/ingest/src/ai_session/usage.rs | 2 +- apps/ingest/src/telemetry.rs | 2 +- 3 files changed, 138 insertions(+), 49 deletions(-) diff --git a/apps/ingest/src/ai_session/facts.rs b/apps/ingest/src/ai_session/facts.rs index dd04972146..6295a800cc 100644 --- a/apps/ingest/src/ai_session/facts.rs +++ b/apps/ingest/src/ai_session/facts.rs @@ -30,11 +30,12 @@ use std::sync::LazyLock; -use opentelemetry_proto::tonic::common::v1::{any_value, KeyValue}; +use opentelemetry_proto::tonic::common::v1::{any_value, AnyValue, KeyValue}; use opentelemetry_proto::tonic::trace::v1::status::StatusCode; use opentelemetry_proto::tonic::trace::v1::Span; -use super::{owned_string_attribute, usage, value_str}; +use super::{owned_string_attribute, usage}; +use crate::telemetry::any_value_string; const TOOL_CALL_ATTR: &str = "maple_ai.tool_call"; const ERROR_ATTR: &str = "maple_ai.error"; @@ -161,9 +162,11 @@ static KEY_TABLE: LazyLock>> = LazyLock::new(|| { table }); -/// One span's facts, borrowed from its attributes. +/// One span's facts, borrowed from its attributes. A text fact is any value +/// the warehouse Map would hold as a non-empty string: an integer call id or a +/// structured tool result counts, as it did when the view read the Map. pub(super) struct Facts<'a> { - text: [&'a str; TEXT_KEYS.len()], + text: [Option<&'a AnyValue>; TEXT_KEYS.len()], text_rank: [u8; TEXT_KEYS.len()], number: [Option; NUMBER_KEYS.len()], number_rank: [u8; NUMBER_KEYS.len()], @@ -172,7 +175,7 @@ pub(super) struct Facts<'a> { impl<'a> Facts<'a> { pub(super) fn read(attrs: &'a [KeyValue]) -> Self { let mut facts = Self { - text: [""; TEXT_KEYS.len()], + text: [None; TEXT_KEYS.len()], text_rank: [u8::MAX; TEXT_KEYS.len()], number: [None; NUMBER_KEYS.len()], number_rank: [u8::MAX; NUMBER_KEYS.len()], @@ -188,9 +191,8 @@ impl<'a> Facts<'a> { }; match found { (_, Slot::Text(slot), rank) if rank < facts.text_rank[slot] => { - let value = value_str(attr); - if !value.is_empty() { - facts.text[slot] = value; + if let Some(value) = attr.value.as_ref().filter(|value| present(value)) { + facts.text[slot] = Some(value); facts.text_rank[slot] = rank; } } @@ -210,21 +212,36 @@ impl<'a> Facts<'a> { self.number[slot] } + /// A text fact's value if it is a string, else `""`: what the rules that + /// compare a fact to a known literal read. + fn str(&self, slot: usize) -> &'a str { + match self.text[slot].and_then(|value| value.value.as_ref()) { + Some(any_value::Value::StringValue(text)) => text, + _ => "", + } + } + + /// A text fact as the stamp writes it, stringified the way the row encoder + /// writes the Map. + fn owned(&self, slot: usize) -> Option { + self.text[slot].map(any_value_string) + } + pub(super) fn model(&self) -> &'a str { - self.text[MODEL] + self.str(MODEL) } - pub(super) fn tool_name(&self) -> &'a str { - self.text[TOOL_NAME] + pub(super) fn has_tool_name(&self) -> bool { + self.text[TOOL_NAME].is_some() } /// `gen_ai.operation.name`, else the OpenInference span kind translated. pub(super) fn operation(&self) -> &'a str { - let op = self.text[OPERATION]; + let op = self.str(OPERATION); if !op.is_empty() { return op; } - match self.text[SPAN_KIND] { + match self.str(SPAN_KIND) { "LLM" => "chat", "TOOL" => "execute_tool", "AGENT" => "invoke_agent", @@ -256,11 +273,22 @@ pub(super) fn name_has(name: &str, needle: &str) -> bool { .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) } +/// Would the warehouse Map hold this value as a non-empty string? +fn present(value: &AnyValue) -> bool { + match value.value.as_ref() { + Some(any_value::Value::StringValue(text)) => !text.is_empty(), + Some(any_value::Value::BytesValue(bytes)) => !bytes.is_empty(), + Some(any_value::Value::StringValueStrindex(_)) | None => false, + Some(_) => true, + } +} + /// `text` cut to `max` characters. -fn truncate(text: &str, max: usize) -> &str { - text.char_indices() - .nth(max) - .map_or(text, |(end, _)| &text[..end]) +fn truncate(mut text: String, max: usize) -> String { + if let Some((end, _)) = text.char_indices().nth(max) { + text.truncate(end); + } + text } /// Decide every fact of one stamped span, as the stamps to write on it. @@ -274,37 +302,44 @@ pub(super) fn stamps(span: &Span, vendor: &str) -> Vec { let llm_call = usage::stamp(span, vendor, &facts, &mut stamps); let tool_call = !llm_call && is_tool_call(&facts, &span.name); let failed = failed_status - || !facts.text[ERROR_TYPE].is_empty() + || facts.text[ERROR_TYPE].is_some() || ["failed", "error"] .iter() - .any(|status| facts.text[RESPONSE_STATUS].eq_ignore_ascii_case(status)); - let mut text = |key: &str, value: &str| { - if !value.is_empty() { - stamps.push(owned_string_attribute(key, value.to_owned())); + .any(|status| facts.str(RESPONSE_STATUS).eq_ignore_ascii_case(status)); + let mut text = |key: &str, value: Option| { + if let Some(value) = value.filter(|value| !value.is_empty()) { + stamps.push(owned_string_attribute(key, value)); } }; - text(MODEL_ATTR, facts.model()); + text(MODEL_ATTR, facts.owned(MODEL)); text(AGENT_NAME_ATTR, agent_name(&facts, vendor, &span.name)); - text(TOOL_NAME_ATTR, facts.tool_name()); - text(TOOL_CALL_ID_ATTR, facts.text[TOOL_CALL_ID]); + text(TOOL_NAME_ATTR, facts.owned(TOOL_NAME)); + text(TOOL_CALL_ID_ATTR, facts.owned(TOOL_CALL_ID)); if llm_call { - text(RESPONSE_ID_ATTR, facts.text[RESPONSE_ID]); + text(RESPONSE_ID_ATTR, facts.owned(RESPONSE_ID)); } - let result = facts.text[TOOL_RESULT]; if tool_call { text( TOOL_DESCRIPTION_ATTR, - truncate(facts.text[TOOL_DESCRIPTION], TOOL_DESCRIPTION_MAX), + facts + .owned(TOOL_DESCRIPTION) + .map(|text| truncate(text, TOOL_DESCRIPTION_MAX)), ); if failed { text( TOOL_ERROR_RESULT_ATTR, - truncate(result, TOOL_ERROR_RESULT_MAX), + facts + .owned(TOOL_RESULT) + .map(|text| truncate(text, TOOL_ERROR_RESULT_MAX)), ); } } - let paused = - tool_call && !failed && (result.is_empty() || result.contains(CONFIRMATION_REQUEST)); + // Only Google ADK writes the confirmation request, so only its results are + // searched for it. + let paused = tool_call + && !failed + && (facts.text[TOOL_RESULT].is_none() + || (vendor == "google_adk" && facts.str(TOOL_RESULT).contains(CONFIRMATION_REQUEST))); for (key, holds) in [ (TOOL_CALL_ATTR, tool_call), (ERROR_ATTR, failed), @@ -321,17 +356,15 @@ pub(super) fn stamps(span: &Span, vendor: &str) -> Vec { /// names an agent only in `graph.node.id` on its AGENT span, where it equals /// the span name ("Triage Agent"); the run's root AGENT span has no node id, /// and agno's node id is a hash that never equals the span name. -fn agent_name<'a>(facts: &Facts<'a>, vendor: &str, span_name: &str) -> &'a str { - let node = facts.text[GRAPH_NODE_ID]; - match facts.text[AGENT_NAME] { - "" if matches!(vendor, "openai_agents_sdk" | "unknown:openinference") - && facts.text[SPAN_KIND] == "AGENT" - && node == span_name => - { - node - } - name => name, +fn agent_name(facts: &Facts, vendor: &str, span_name: &str) -> Option { + if facts.text[AGENT_NAME].is_some() { + return facts.owned(AGENT_NAME); } + let node = facts.str(GRAPH_NODE_ID); + (matches!(vendor, "openai_agents_sdk" | "unknown:openinference") + && facts.str(SPAN_KIND) == "AGENT" + && node == span_name) + .then(|| node.to_owned()) } /// A tool call: the convention's tool operation, or, under an operation the @@ -340,7 +373,7 @@ fn is_tool_call(facts: &Facts, span_name: &str) -> bool { let op = facts.operation(); op == "execute_tool" || (!usage::KNOWN_OPS.contains(&op) - && (!facts.tool_name().is_empty() || name_has(span_name, "tool"))) + && (facts.has_tool_name() || name_has(span_name, "tool"))) } /// Mark a stamped tool call as failed after the fact: Claude Code records a @@ -350,9 +383,10 @@ pub(super) fn mark_tool_failed(span: &mut Span) { span.attributes.retain(|attr| attr.key != TOOL_PAUSED_ATTR); let has = |key: &str| span.attributes.iter().any(|attr| attr.key == key); let result = (!has(TOOL_ERROR_RESULT_ATTR)) - .then(|| Facts::read(&span.attributes).text[TOOL_RESULT]) + .then(|| Facts::read(&span.attributes).owned(TOOL_RESULT)) + .flatten() .filter(|result| !result.is_empty()) - .map(|result| truncate(result, TOOL_ERROR_RESULT_MAX).to_owned()); + .map(|result| truncate(result, TOOL_ERROR_RESULT_MAX)); let error = !has(ERROR_ATTR); if let Some(result) = result { span.attributes @@ -367,7 +401,7 @@ pub(super) fn mark_tool_failed(span: &mut Span) { #[cfg(test)] mod tests { use super::*; - use crate::ai_session::stamp_trace_request; + use crate::ai_session::{stamp_trace_request, value_str}; use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; use opentelemetry_proto::tonic::common::v1::InstrumentationScope; use opentelemetry_proto::tonic::resource::v1::Resource; @@ -689,6 +723,61 @@ mod tests { assert!(!has(&got[1], TOOL_CALL_ATTR)); } + /// A value the warehouse Map holds as a string counts whatever its OTLP + /// type: an integer call id, a structured tool result, a boolean + /// `error.type`. Only ADK's results are searched for its confirmation. + #[test] + fn non_string_values_count_as_the_map_holds_them() { + let typed = |key: &str, value: any_value::Value| KeyValue { + key: key.to_owned(), + key_strindex: 0, + value: Some(AnyValue { value: Some(value) }), + }; + let result = + any_value::Value::ArrayValue(opentelemetry_proto::tonic::common::v1::ArrayValue { + values: vec![AnyValue { + value: Some(any_value::Value::StringValue("boom".to_owned())), + }], + }); + let mut tool = span( + "execute_tool fetch", + &[ + ("gen_ai.operation.name", "execute_tool"), + ( + "gen_ai.tool.call.result", + "This tool call requires confirmation", + ), + ], + ); + tool.attributes.extend([ + typed("gen_ai.tool.call.id", any_value::Value::IntValue(7)), + typed("error.type", any_value::Value::BoolValue(true)), + ]); + tool.attributes[1] = typed("gen_ai.tool.call.result", result); + let unconfirmed = span( + "execute_tool fetch", + &[ + ("gen_ai.operation.name", "execute_tool"), + ( + "gen_ai.tool.call.result", + "This tool call requires confirmation", + ), + ], + ); + let got = stamps("support-agent", vec![tool, unconfirmed]); + assert_eq!( + got[0], + pairs(&[ + ("maple_ai.llm_call", "0"), + (TOOL_CALL_ID_ATTR, "7"), + (TOOL_ERROR_RESULT_ATTR, "[\"boom\"]"), + (TOOL_CALL_ATTR, "1"), + (ERROR_ATTR, "1"), + ]) + ); + assert!(!has(&got[1], TOOL_PAUSED_ATTR)); + } + #[test] fn a_long_description_is_cut() { let long = "d".repeat(TOOL_DESCRIPTION_MAX + 1); @@ -721,8 +810,8 @@ mod tests { #[test] fn truncation_counts_characters() { - assert_eq!(truncate("héllo", 2), "hé"); - assert_eq!(truncate("héllo", 9), "héllo"); + assert_eq!(truncate("héllo".to_owned(), 2), "hé"); + assert_eq!(truncate("héllo".to_owned(), 9), "héllo"); } #[test] diff --git a/apps/ingest/src/ai_session/usage.rs b/apps/ingest/src/ai_session/usage.rs index cc9fcd5566..14ebaa6e58 100644 --- a/apps/ingest/src/ai_session/usage.rs +++ b/apps/ingest/src/ai_session/usage.rs @@ -207,7 +207,7 @@ fn named_like_a_model_call(op: &str, span_name: &str, facts: &Facts) -> bool { if KNOWN_OPS.contains(&op) { return false; } - if !facts.tool_name().is_empty() || name_has(span_name, "tool") { + if facts.has_tool_name() || name_has(span_name, "tool") { return false; } if name_has(span_name, "agent") || name_has(span_name, "workflow") { diff --git a/apps/ingest/src/telemetry.rs b/apps/ingest/src/telemetry.rs index 9e879c91b0..2ca2762ffd 100644 --- a/apps/ingest/src/telemetry.rs +++ b/apps/ingest/src/telemetry.rs @@ -4010,7 +4010,7 @@ fn attr_map(attributes: &[KeyValue]) -> Map { out } -fn any_value_string(value: &AnyValue) -> String { +pub(crate) fn any_value_string(value: &AnyValue) -> String { match value.value.as_ref() { Some(any_value::Value::StringValue(value)) => value.clone(), Some(any_value::Value::BoolValue(value)) => value.to_string(), From b0d0599b7413587909f1bdf3c9b3ef05476f024e Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:18:57 +0200 Subject: [PATCH 13/17] fix(ingest): stamp a reported $0 cost, so a free call is not read as unpriced --- apps/ingest/src/ai_session/usage.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/ingest/src/ai_session/usage.rs b/apps/ingest/src/ai_session/usage.rs index 14ebaa6e58..72e10c77e1 100644 --- a/apps/ingest/src/ai_session/usage.rs +++ b/apps/ingest/src/ai_session/usage.rs @@ -20,7 +20,8 @@ //! - `maple_ai.usage.cache_read_tokens`, `maple_ai.usage.cache_write_tokens` //! - `maple_ai.usage.output_tokens`: the visible completion //! - `maple_ai.usage.reasoning_tokens` -//! - `maple_ai.usage.cost`: USD, as the emitter priced the call +//! - `maple_ai.usage.cost`: USD, as the emitter priced the call; `0` is kept, +//! because a free call is not an unpriced one //! //! A span's total is the plain sum of the five token buckets. Only the span //! that IS the model call carries them: a wrapper's figures are a roll-up of @@ -166,7 +167,7 @@ pub(super) fn stamp(span: &Span, vendor: &str, facts: &Facts, out: &mut Vec 0) .map(|(key, count)| owned_string_attribute(key, count.to_string())), ); - if let Some(cost) = facts.number(facts::COST).filter(|cost| *cost > 0.0) { + if let Some(cost) = facts.number(facts::COST) { out.push(owned_string_attribute(COST_ATTR, cost.to_string())); } true @@ -1449,8 +1450,9 @@ mod tests { ); } - /// A failed call reports nothing, and a customer's own `maple_ai.usage.*` - /// is stripped with the rest of the namespace rather than trusted. + /// A failed call reports no tokens but keeps the price it reported, $0 for + /// a free model; a customer's own `maple_ai.usage.*` is stripped with the + /// rest of the namespace rather than trusted. #[test] fn zero_usage_writes_nothing_and_spoofed_buckets_are_stripped() { let stamped = stamp_spans( @@ -1479,7 +1481,8 @@ mod tests { ); // Still the model call: a failed call counts once. assert_eq!(stamped[0].llm_call.as_deref(), Some("1")); - assert!(stamped[0].buckets.is_none() && stamped[0].cost.is_none()); + assert!(stamped[0].buckets.is_none()); + assert_eq!(stamped[0].cost.as_deref(), Some("0")); assert_eq!(stamped[1].llm_call.as_deref(), Some("0")); assert!(stamped[1].buckets.is_none() && stamped[1].cost.is_none()); } From 72dbc30cb5bec717ecf19f3263d55b8524c8618d Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:19:11 +0200 Subject: [PATCH 14/17] fix(ingest): saturate the cache sum, so an absurd customer figure cannot overflow it --- apps/ingest/src/ai_session/usage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ingest/src/ai_session/usage.rs b/apps/ingest/src/ai_session/usage.rs index 72e10c77e1..a3ab7bdf99 100644 --- a/apps/ingest/src/ai_session/usage.rs +++ b/apps/ingest/src/ai_session/usage.rs @@ -265,7 +265,7 @@ impl Usage { let completion = count(facts::OUTPUT).unwrap_or(0); // An inclusive prompt cannot be smaller than the cache it contains, so // a prompt that is must be a raw passthrough the vendor rule missed. - let cache = cache_read + cache_write; + let cache = cache_read.saturating_add(cache_write); let excludes_cache = input_excludes_cache || cache > prompt; // The completion is what the provider billed (`total_tokens` is prompt // + completion), so a reasoning figure larger than it is clamped. From 7e9daf9eda2e20a96408ea345587327cb01c05ea Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:19:43 +0200 Subject: [PATCH 15/17] test(agent-sessions): hash the gateway's Rust sources into the stamp-key pin, match only their constants --- packages/domain/src/gen-ai.test.ts | 2 +- turbo.json | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/domain/src/gen-ai.test.ts b/packages/domain/src/gen-ai.test.ts index 360afb2a84..4f9b5d1b41 100644 --- a/packages/domain/src/gen-ai.test.ts +++ b/packages/domain/src/gen-ai.test.ts @@ -14,6 +14,6 @@ const gatewaySource = ["facts.rs", "usage.rs"] describe("MAPLE_AI_STAMP_ATTRS", () => { it.each(Object.values(MAPLE_AI_STAMP_ATTRS))("is written by the ingest gateway: %s", (key) => { - expect(gatewaySource).toContain(`"${key}"`) + expect(gatewaySource).toContain(`&str = "${key}";`) }) }) diff --git a/turbo.json b/turbo.json index 9abfa88abb..80cd565e2d 100644 --- a/turbo.json +++ b/turbo.json @@ -66,6 +66,13 @@ "dependsOn": ["^build"], "outputs": [] }, + // `gen-ai.test.ts` pins the stamp keys against the ingest gateway's Rust + // sources, which the default package-scoped inputs miss. + "@maple/domain#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/apps/ingest/src/ai_session/*.rs"] + }, "eval": { "dependsOn": ["^build"], "cache": false From 09ab4e97f05e3c865b39c27d475e892f8b71b534 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:20:19 +0200 Subject: [PATCH 16/17] test(agent-sessions): prove the gateway's failure verdict overrides a span's own error.type --- packages/agent-sessions/src/session-turns.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-sessions/src/session-turns.test.ts b/packages/agent-sessions/src/session-turns.test.ts index 86a8d5be14..6956b93add 100644 --- a/packages/agent-sessions/src/session-turns.test.ts +++ b/packages/agent-sessions/src/session-turns.test.ts @@ -627,7 +627,7 @@ describe("the ingest gateway's verdicts", () => { it("decide whether a stamped span failed", () => { expect(spanFailed(stamped("execute_tool", { mapleLlmCall: 0, mapleError: 1 }))).toBe(true) // The gateway already weighed the span's own `error.type`. - expect(spanFailed(stamped("execute_tool", { mapleLlmCall: 0, errorType: "" }))).toBe(false) + expect(spanFailed(stamped("execute_tool", { mapleLlmCall: 0, errorType: "Timeout" }))).toBe(false) // A span before the gateway stamped: the attribute rule. expect( spanFailed(makeSpan({ spanId: "a", startMs: 0, durationMs: 1, genAi: { errorType: "Timeout" } })), From 38f256c1c555b94c9ec5ba73b380f1b6307785bb Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 29 Sep 2026 22:20:20 +0200 Subject: [PATCH 17/17] chore(agent-sessions): cut process notes and a duplicate test, rewrap comments --- .../migrations/0035_ai_trace_index_gateway_stamps.ts | 3 --- packages/domain/src/clickhouse/migrations/index.test.ts | 5 ----- packages/domain/src/gen-ai.ts | 2 -- packages/domain/src/tinybird/datasources.ts | 8 ++++---- packages/query-engine-integrations/src/ai/ai-sessions.ts | 2 +- 5 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/domain/src/clickhouse/migrations/0035_ai_trace_index_gateway_stamps.ts b/packages/domain/src/clickhouse/migrations/0035_ai_trace_index_gateway_stamps.ts index fb0e864e41..7eb1427d7f 100644 --- a/packages/domain/src/clickhouse/migrations/0035_ai_trace_index_gateway_stamps.ts +++ b/packages/domain/src/clickhouse/migrations/0035_ai_trace_index_gateway_stamps.ts @@ -23,9 +23,6 @@ * * `requiredForIngest: false` — the gateway writes `traces`, never this table. * - * This view replaces the in-flight view changes of other pull requests, which - * rebase onto it and drop their own migrations of this view. - * * The CREATE statement below is the verbatim DDL as the schema emitter produced * it at v35. Frozen history: never re-derive it from a later snapshot. */ diff --git a/packages/domain/src/clickhouse/migrations/index.test.ts b/packages/domain/src/clickhouse/migrations/index.test.ts index 866ab1802b..c10ba1375e 100644 --- a/packages/domain/src/clickhouse/migrations/index.test.ts +++ b/packages/domain/src/clickhouse/migrations/index.test.ts @@ -956,9 +956,4 @@ describe("migration 0035 — ai_trace_index_mv projects the gateway's stamps", ( expect(create).not.toContain("multiIf") expect(create).not.toContain("LIKE") }) - - it("does not backfill and does not gate ingest", () => { - expect(migration_0035_ai_trace_index_gateway_stamps.requiredForIngest).toBe(false) - expect(migration_0035_ai_trace_index_gateway_stamps.statements.some(isBackfill)).toBe(false) - }) }) diff --git a/packages/domain/src/gen-ai.ts b/packages/domain/src/gen-ai.ts index a031266036..9e246e27a0 100644 --- a/packages/domain/src/gen-ai.ts +++ b/packages/domain/src/gen-ai.ts @@ -132,8 +132,6 @@ export const MAPLE_GENAI_MODEL_DURATION_MS_ATTR = "maple_ai.model_duration_ms" * - `toolCall`, `error`: `"1"` where they hold, absent otherwise. * - `model`, `agentName`, `toolName`, `responseId`: the first non-empty value * across the dialects' keys; `responseId` on model calls. - * - The gateway also stamps `maple_ai.tool.call_id` and `maple_ai.tool.paused` - * (a tool call's copy that recorded no outcome), which nothing reads yet. * - `toolDescription` (on tool calls) and `toolErrorResult` (a failed tool * call's result), cut by the gateway. * - The usage buckets, on the model call alone: `inputTokens` the uncached diff --git a/packages/domain/src/tinybird/datasources.ts b/packages/domain/src/tinybird/datasources.ts index 50acbddc6f..e69df8e200 100644 --- a/packages/domain/src/tinybird/datasources.ts +++ b/packages/domain/src/tinybird/datasources.ts @@ -1193,8 +1193,8 @@ export const aiTraceIndex = defineDatasource("ai_trace_index", { ResponseId: t.string(), // Migration 0031 — the last facts the Agent Sessions list read off the // raw spans: the vendor's version beside its id, and the five disjoint - // buckets `Tokens` is the sum of (the gateway's `maple_ai.usage.*`), so a row - // renders from one index query instead of a fan-out over + // buckets `Tokens` is the sum of (the gateway's `maple_ai.usage.*`), so + // a row renders from one index query instead of a fan-out over // `trace_detail_spans`. '' / 0 on rows materialized before it. VendorVersion: t.string().lowCardinality(), InputTokens: t.float64(), @@ -1212,8 +1212,8 @@ export const aiTraceIndex = defineDatasource("ai_trace_index", { // (`GENAI_STATUS_MESSAGE_MAX`), `ToolDescription` by the ingest gateway, // which stamps it on tool calls only, so the column is '' on the rest. // `FailedToolCallResult` is a failed tool call's result (truncated by the - // gateway; '' on every other span), because - // several frameworks describe a tool failure there and nowhere else. + // gateway; '' on every other span), because several frameworks describe + // a tool failure there and nowhere else. // `ErrorFingerprint` groups failures: a hash of that result, else of the // status message, redacted as `error_events` redacts messages; 0 on spans // that did not fail. Redacting at read time would cost seconds per million diff --git a/packages/query-engine-integrations/src/ai/ai-sessions.ts b/packages/query-engine-integrations/src/ai/ai-sessions.ts index a9bd8980e1..d28edb77c9 100644 --- a/packages/query-engine-integrations/src/ai/ai-sessions.ts +++ b/packages/query-engine-integrations/src/ai/ai-sessions.ts @@ -1627,11 +1627,11 @@ const summaryMeasures_ = ($: SpanColumns) => { const vendorId = $.SpanAttributes.get(VENDOR_ID_ATTR) const isAi = vendorId.neq("") const operation = field("operationName") - // Response model first, request model second — `spanModel` on the page. const stamp = (key: string) => $.SpanAttributes.get(key) const stamped = stamp(MAPLE_AI_STAMP_ATTRS.llmCall).neq("") const unstamped = CH.not(stamped) const byGateway = (gateway: CH.Expr, reported: CH.Expr) => CH.if_(stamped, gateway, reported) + // Response model first, request model second — `spanModel` on the page. const reportedModel = attr([...aiFieldSourceKeys("responseModel"), ...aiFieldSourceKeys("requestModel")]) const reportedToolName = field("toolName") const model = byGateway(stamp(MAPLE_AI_STAMP_ATTRS.model), reportedModel)