From 5f4ea4ee722d6de123989e2f4afd3e78306a8d96 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 1 Nov 2026 01:03:07 +0100 Subject: [PATCH 1/5] feat(web): mark what each waterfall span called and what runs its service Each span row gets a brand mark left of its name for the system it called (LLM vendor, then database, messaging, RPC), and the service label carries the runtime or the Effect mark. The span hierarchy query projects the extra span and resource keys the marks read. --- apps/web/src/lab/worst-case-trace.ts | 30 +++- .../page/traces/detail/waterfall/platform.ts | 1 + .../src/page/traces/detail/waterfall/row.ts | 49 ++++-- .../page/traces/detail/waterfall/tech.test.ts | 89 ++++++++++ .../src/page/traces/detail/waterfall/tech.ts | 159 ++++++++++++++++++ .../detail/waterfall/waterfall.scene.test.ts | 31 ++++ .../src/__sql_baseline__/catalog.sql | 12 +- .../query-engine/src/ch/queries/errors.ts | 18 +- 8 files changed, 357 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/page/traces/detail/waterfall/tech.test.ts create mode 100644 apps/web/src/page/traces/detail/waterfall/tech.ts diff --git a/apps/web/src/lab/worst-case-trace.ts b/apps/web/src/lab/worst-case-trace.ts index 09f29b50db..a84d9ccfad 100644 --- a/apps/web/src/lab/worst-case-trace.ts +++ b/apps/web/src/lab/worst-case-trace.ts @@ -23,23 +23,43 @@ function row(overrides: Partial & { spanId: string; spanName: const SERVICES = [LONG_SERVICE, "checkout-api", "unknown_service:node", "a"] +// What each span called, cycling down the chain: the waterfall marks each one. +const CALLS: ReadonlyArray> = [ + {}, + { "db.system.name": "postgresql" }, + { "gen_ai.provider.name": "anthropic" }, + { "messaging.system": "kafka" }, + { "messaging.system": "some_inhouse_queue" }, +] + +// What runs each service, so a service entering the chain shows its runtime. +const RUNTIMES = new Map>([ + ["api", { "telemetry.sdk.name": "effect", "process.runtime.name": "bun" }], + [LONG_SERVICE, { "telemetry.sdk.name": "@maple-dev/effect-sdk", "process.runtime.name": "nodejs" }], + ["checkout-api", { "process.runtime.name": "bun" }], + ["unknown_service:node", { "telemetry.sdk.language": "python" }], +]) + /** A single parent chain, `depth` spans deep, so indentation is the only thing that grows. */ function chain(depth: number, worst: boolean): SpanHierarchyRow[] { - return Array.from({ length: depth }, (_, i) => - row({ + return Array.from({ length: depth }, (_, i) => { + const serviceName = worst ? SERVICES[i % SERVICES.length]! : "api" + return row({ spanId: `d${i}`, parentSpanId: i === 0 ? "" : `d${i - 1}`, spanName: worst ? `${i === 0 ? "POST" : "call"} /api/v2/organizations/{orgId}/projects/{projectId}/checkout/step-${i}` : `step ${i}`, - serviceName: worst ? SERVICES[i % SERVICES.length]! : "api", + serviceName, spanKind: i === 0 ? "Server" : "Internal", + spanAttributes: JSON.stringify(CALLS[i % CALLS.length]), + resourceAttributes: JSON.stringify(RUNTIMES.get(serviceName) ?? {}), startTime: formatWarehouseDateTimeMs(T0_MS + i * 5), durationMs: Math.max(0.001, 2000 - i * 33), statusCode: worst && i === depth - 1 ? "Error" : "Ok", statusMessage: worst && i === depth - 1 ? "x".repeat(300) : "", - }), - ) + }) + }) } export function worstCaseTrace(mode: WorstCaseMode) { diff --git a/apps/web/src/page/traces/detail/waterfall/platform.ts b/apps/web/src/page/traces/detail/waterfall/platform.ts index 263200a939..57d98ad3e7 100644 --- a/apps/web/src/page/traces/detail/waterfall/platform.ts +++ b/apps/web/src/page/traces/detail/waterfall/platform.ts @@ -6,6 +6,7 @@ import type { IconName } from "../../../../ui/icon" // label, icon, edge, location and outcome; the accents come from the shared @maple/ui module. export interface RowPlatform { + readonly id: string readonly label: string readonly icon: IconName readonly accentClassName: string diff --git a/apps/web/src/page/traces/detail/waterfall/row.ts b/apps/web/src/page/traces/detail/waterfall/row.ts index 01b7377502..e5a615f471 100644 --- a/apps/web/src/page/traces/detail/waterfall/row.ts +++ b/apps/web/src/page/traces/detail/waterfall/row.ts @@ -18,6 +18,7 @@ import { serviceDot } from "../../../../ui/values" import type { SpanNodeData } from "../trace-data" import { Message } from "./model" import { rowPlatform } from "./platform" +import { type Mark, serviceRuntime, spanTech } from "./tech" import { countDescendants, type TreeRow, treeLevel } from "./tree" // `SpanRow` (packages/ui/src/components/traces/span-row.tsx) and `PixelDurationBar`. @@ -155,10 +156,7 @@ const pixelBar = ( ((i + 1) / TOTAL_BLOCKS) * 100 > leftPercent && (i / TOTAL_BLOCKS) * 100 < spanEnd return h.div( [ - h.Attribute( - "class", - `w-[5px] h-2.5 rounded-2xs shrink-0 ${isActive ? "" : "bg-muted"}`, - ), + h.Attribute("class", `w-[5px] h-2.5 rounded-2xs shrink-0 ${isActive ? "" : "bg-muted"}`), ...(isActive ? [h.Style({ "background-color": color })] : []), ], [], @@ -167,6 +165,21 @@ const pixelBar = ( ) } +const markIcon = (h: HtmlBuilder, mark: Mark): Html => + h.span( + [h.Class("flex shrink-0"), h.Role("img"), h.AriaLabel(mark.label), h.Title(mark.label)], + [icon(h, mark.icon, { size: 12, className: mark.className })], + ) + +/** An empty tech slot, so names align whether or not a row has a mark. */ +const emptySlot = (h: HtmlBuilder): Html => h.span([h.Class("w-3 shrink-0")], []) + +/** What the span called (Postgres, Kafka, an LLM), left of its name. */ +const techIcon = (h: HtmlBuilder, span: SpanNodeData): Html => { + const tech = spanTech({ ...span.spanAttributes }) + return tech === null ? emptySlot(h) : markIcon(h, tech) +} + const missingRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): Html => { const span = row.node return h.div( @@ -184,6 +197,7 @@ const missingRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): Htm [ ...treeGuides(h, row, ctx.isExpanded), toggle(h, span, ctx.isExpanded), + emptySlot(h), badge( h, { @@ -221,26 +235,24 @@ const missingRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): Htm type HttpInfo = NonNullable> -/** The service where the call path enters it; a child in its parent's service keeps only the platform. */ +/** The Cloudflare Worker mark, else the service's runtime or Effect. */ +const serviceMark = (span: SpanNodeData): Mark | null => { + const platform = rowPlatform({ ...span.spanAttributes }) + if (platform?.id === "cloudflare") + return { icon: platform.icon, label: platform.label, className: platform.accentClassName } + return serviceRuntime({ ...span.resourceAttributes }) +} + +/** The service where the call path enters it, with what runs it; a child in its parent's service shows none. */ const serviceLabel = (h: HtmlBuilder, row: TreeRow): ReadonlyArray => { const span = row.node - const platform = rowPlatform({ ...span.spanAttributes }) - const platformIcon = - platform === null - ? [] - : [ - icon(h, platform.icon, { - size: 12, - className: cn("shrink-0", platform.accentClassName), - attributes: [h.AriaLabel(platform.label)], - }), - ] - if (row.parent !== null && row.parent.serviceName === span.serviceName) return platformIcon + if (row.parent !== null && row.parent.serviceName === span.serviceName) return [] + const mark = serviceMark(span) return [ h.span( [h.Class("flex min-w-0 max-w-[30%] shrink items-center gap-1.5 font-mono text-3xs")], [ - ...platformIcon, + ...(mark === null ? [] : [markIcon(h, mark)]), serviceDot(h, { serviceName: span.serviceName, className: "size-1.5" }), h.span( [ @@ -461,6 +473,7 @@ export const spanRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): [ ...treeGuides(h, row, ctx.isExpanded), toggle(h, span, ctx.isExpanded), + techIcon(h, span), nameLabel(h, span, httpInfo), ...serviceLabel(h, row), ...edgeLabel(h, span), diff --git a/apps/web/src/page/traces/detail/waterfall/tech.test.ts b/apps/web/src/page/traces/detail/waterfall/tech.test.ts new file mode 100644 index 0000000000..8e3c89e600 --- /dev/null +++ b/apps/web/src/page/traces/detail/waterfall/tech.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest" +import { serviceRuntime, spanTech } from "./tech" + +describe("spanTech", () => { + test("names the system a span called, by its brand where there is one", () => { + expect(spanTech({ "db.system.name": "postgresql" })).toMatchObject({ + icon: "PostgresIcon", + label: "PostgreSQL", + }) + expect(spanTech({ "db.system": "redis" })).toMatchObject({ icon: "RedisIcon", label: "Redis" }) + expect(spanTech({ "messaging.system": "kafka" })).toMatchObject({ icon: "KafkaIcon", label: "Kafka" }) + expect(spanTech({ "rpc.system": "grpc" })).toMatchObject({ icon: "ConnectionIcon", label: "gRPC" }) + }) + + test("an LLM call beats the transport it went over, and the model vendor picks the mark", () => { + expect( + spanTech({ "gen_ai.provider.name": "anthropic", "http.request.method": "POST" }), + ).toMatchObject({ icon: "AnthropicIcon" }) + expect(spanTech({ "gen_ai.system": "gcp.vertex_ai" })).toMatchObject({ + icon: "GoogleIcon", + label: "LLM call · Google", + }) + expect(spanTech({ "gen_ai.provider.name": "some-new-lab" })).toMatchObject({ + icon: "ChatBubbleSparkleIcon", + }) + }) + + test("a database beats a queue or RPC system on the same span", () => { + expect(spanTech({ "db.system.name": "postgresql", "messaging.system": "kafka" })).toMatchObject({ + icon: "PostgresIcon", + }) + expect(spanTech({ "db.system": "dynamodb", "rpc.system": "aws-api" })).toMatchObject({ + icon: "DatabaseIcon", + }) + }) + + test("an unknown system keeps a generic glyph and a readable name", () => { + expect(spanTech({ "messaging.system": "cloud_tasks" })).toMatchObject({ + icon: "PaperPlaneIcon", + label: "Cloud Tasks", + }) + }) + + test("system values match whatever their case or padding", () => { + expect(spanTech({ "messaging.system": " Kafka " })).toMatchObject({ icon: "KafkaIcon" }) + expect(spanTech({ "db.system.name": "PostgreSQL" })).toMatchObject({ icon: "PostgresIcon" }) + }) + + test("a span that names no system, or only blank ones, gets no mark", () => { + expect(spanTech({})).toBeNull() + expect( + spanTech({ "http.request.method": "GET", "db.system.name": "", "messaging.system": " " }), + ).toBeNull() + }) +}) + +describe("serviceRuntime", () => { + test("the Effect SDK wins and names the runtime it ran on", () => { + expect( + serviceRuntime({ "telemetry.sdk.name": "effect", "process.runtime.name": "bun" }), + ).toMatchObject({ icon: "EffectIcon", label: "Effect on Bun" }) + expect(serviceRuntime({ "telemetry.sdk.name": "@maple-dev/effect-sdk" })).toMatchObject({ + icon: "EffectIcon", + label: "Effect", + }) + }) + + test("the runtime name beats the SDK language, which is the fallback", () => { + expect( + serviceRuntime({ "process.runtime.name": "bun", "telemetry.sdk.language": "nodejs" }), + ).toMatchObject({ icon: "BunIcon", label: "Bun" }) + expect(serviceRuntime({ "telemetry.sdk.language": "python" })).toMatchObject({ + icon: "PythonIcon", + label: "Python", + }) + }) + + test("a runtime name with no glyph falls back to the SDK language", () => { + expect( + serviceRuntime({ "process.runtime.name": "pypy", "telemetry.sdk.language": "python" }), + ).toMatchObject({ icon: "PythonIcon" }) + }) + + test("a runtime with no glyph, or no runtime at all, gets no mark", () => { + expect(serviceRuntime({ "telemetry.sdk.language": "go" })).toBeNull() + expect(serviceRuntime({ "telemetry.sdk.language": "webjs" })).toBeNull() + expect(serviceRuntime({})).toBeNull() + }) +}) diff --git a/apps/web/src/page/traces/detail/waterfall/tech.ts b/apps/web/src/page/traces/detail/waterfall/tech.ts new file mode 100644 index 0000000000..ab9b41a31b --- /dev/null +++ b/apps/web/src/page/traces/detail/waterfall/tech.ts @@ -0,0 +1,159 @@ +import { dbSystemAccentClass } from "@maple/ui/lib/cloud-platforms/accents" +import { getDbStyle } from "@maple/ui/components/service-map/service-map-db-style" +import { pickAttr } from "../../../../service-map/trace-flow/cloud-platforms" +import { DB_ICON } from "../../../../service-map/map/node-card" +import { resolveRuntimeGlyph } from "../../../../service-map/map/runtime" +import type { IconName } from "../../../../ui/icon" +import { modelVendorIcon } from "../../../agent-sessions/kit/vendor" + +// The marks a waterfall row carries: what the span called (gen_ai, then db, messaging, rpc, +// the order Jaeger and Uptrace settle on) and what runs the service it enters. + +export interface Mark { + readonly icon: IconName + readonly label: string + /** Tint for single-colour glyphs; empty for brand marks that carry their own colours. */ + readonly className: string +} + +const MUTED = "text-muted-foreground" + +/** `pickAttr`, trimmed and lowercased for the lookups below. */ +const systemOf = (attrs: Record, ...keys: ReadonlyArray): string | null => + pickAttr(attrs, ...keys) + ?.trim() + .toLowerCase() ?? null + +const titleCase = (system: string): string => + system + .split(/[._\s-]+/) + .filter((word) => word !== "") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + +// `gen_ai.provider.name` values (and the older `gen_ai.system`) to `modelVendorIcon` slugs. +const GEN_AI_VENDORS = new Map([ + ["aws.bedrock", "amazon"], + ["azure.ai.inference", "microsoft"], + ["azure.ai.openai", "microsoft"], + ["az.ai.inference", "microsoft"], + ["az.ai.openai", "microsoft"], + ["gcp.gemini", "google"], + ["gcp.gen_ai", "google"], + ["gcp.vertex_ai", "google"], + ["gemini", "google"], + ["vertex_ai", "google"], + ["ibm.watsonx.ai", "ibm-granite"], + ["mistral_ai", "mistralai"], + ["x_ai", "x-ai"], + ["xai", "x-ai"], +]) + +const genAi = (attrs: Record): Mark | null => { + const provider = systemOf(attrs, "gen_ai.provider.name", "gen_ai.system") + if (provider === null) return null + const vendor = GEN_AI_VENDORS.get(provider) ?? provider + return { + icon: modelVendorIcon({ vendorSlug: vendor, family: null }), + label: `LLM call · ${titleCase(vendor)}`, + className: MUTED, + } +} + +const database = (attrs: Record): Mark | null => { + const system = systemOf(attrs, "db.system.name", "db.system") + if (system === null) return null + const style = getDbStyle(system) + return { + icon: DB_ICON[style.icon], + label: style.label, + className: style.branded ? "" : dbSystemAccentClass(system), + } +} + +const MESSAGING_ICONS = new Map([ + ["kafka", "KafkaIcon"], + ["nats", "NatsIcon"], + ["rabbitmq", "RabbitmqIcon"], + ["gcp_pubsub", "GooglePubSubIcon"], + ["aws_sqs", "AwsIcon"], + ["aws.sqs", "AwsIcon"], + ["aws_sns", "AwsIcon"], + ["aws.sns", "AwsIcon"], + ["cloudflare_queues", "CloudflareBrandIcon"], +]) + +const MESSAGING_LABELS = new Map([ + ["kafka", "Kafka"], + ["nats", "NATS"], + ["rabbitmq", "RabbitMQ"], + ["gcp_pubsub", "Pub/Sub"], + ["aws_sqs", "SQS"], + ["aws.sqs", "SQS"], + ["aws_sns", "SNS"], + ["aws.sns", "SNS"], + ["cloudflare_queues", "Cloudflare Queues"], +]) + +const messaging = (attrs: Record): Mark | null => { + const system = systemOf(attrs, "messaging.system") + if (system === null) return null + const icon = MESSAGING_ICONS.get(system) + return { + icon: icon ?? "PaperPlaneIcon", + label: MESSAGING_LABELS.get(system) ?? titleCase(system), + className: icon === undefined || icon === "KafkaIcon" ? MUTED : "", + } +} + +const RPC_LABELS = new Map([ + ["grpc", "gRPC"], + ["connect_rpc", "Connect RPC"], + ["jsonrpc", "JSON-RPC"], + ["java_rmi", "Java RMI"], + ["dotnet_wcf", "WCF"], + ["apache_dubbo", "Dubbo"], + ["aws-api", "AWS API"], +]) + +const rpc = (attrs: Record): Mark | null => { + const system = systemOf(attrs, "rpc.system") + if (system === null) return null + const isAws = system === "aws-api" + return { + icon: isAws ? "AwsIcon" : "ConnectionIcon", + label: RPC_LABELS.get(system) ?? titleCase(system), + className: isAws ? "" : MUTED, + } +} + +/** What this span called, from its own attributes; null when it names no system. */ +export const spanTech = (attrs: Record): Mark | null => + genAi(attrs) ?? database(attrs) ?? messaging(attrs) ?? rpc(attrs) + +const EFFECT_SDKS = new Set(["effect", "@maple-dev/effect-sdk"]) + +const glyphOf = (resource: Record, key: string) => { + const name = pickAttr(resource, key) + return name === null ? null : resolveRuntimeGlyph(name) +} + +/** + * What runs the service, from its resource: the Effect mark when the Effect SDK reported the + * span, else the runtime (`process.runtime.name`, then `telemetry.sdk.language` when the + * runtime is missing or has no glyph); null when neither has one. + */ +export const serviceRuntime = (resource: Record): Mark | null => { + const named = glyphOf(resource, "process.runtime.name") + const runtime = + named === null || named.icon === null ? (glyphOf(resource, "telemetry.sdk.language") ?? named) : named + const sdk = systemOf(resource, "telemetry.sdk.name") + if (sdk !== null && EFFECT_SDKS.has(sdk)) + return { + icon: "EffectIcon", + label: runtime === null ? "Effect" : `Effect on ${runtime.full}`, + className: "text-foreground", + } + if (runtime === null || runtime.icon === null) return null + return { icon: runtime.icon, label: runtime.full, className: MUTED } +} diff --git a/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts b/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts index fd8df60793..23a31bafed 100644 --- a/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts +++ b/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts @@ -1,8 +1,10 @@ import { SpanId, TraceId } from "@maple/domain" import { Schema } from "effect" import { + all, click, expect as expectView, + expectAll, expectOutMessage, given, Mount, @@ -120,6 +122,35 @@ describe("waterfall", () => { ) }) + test("rows mark what each span called and what runs the service it enters", () => { + const api = { "telemetry.sdk.name": "effect", "process.runtime.name": "bun" } + const query = { + ...node("q", 1), + spanAttributes: { "db.system.name": "postgresql" }, + resourceAttributes: api, + } + const llm = { + ...node("l", 1), + serviceName: "agent", + spanAttributes: { "gen_ai.provider.name": "anthropic" }, + resourceAttributes: { "telemetry.sdk.language": "python" }, + } + const trace: TraceData = { + ...small, + rootSpans: [{ ...node("r", 0, [query, llm]), resourceAttributes: api }], + } + scene( + { update, view }, + given(update(entered(trace), windowOver(3)).model), + Mount.resolve(VirtualizeWaterfall, windowOver(3)), + expectView(role("img", { name: "PostgreSQL" })).toExist(), + expectView(role("img", { name: "LLM call · Anthropic" })).toExist(), + expectView(role("img", { name: "Python" })).toExist(), + // The query stays in the root's service, so only the root carries its runtime. + expectAll(all.role("img", { name: "Effect on Bun" })).toHaveCount(1), + ) + }) + test("a selection made after mount reveals its ancestors without undoing user toggles", () => { const folded = update(entered(), Message.ClickedCollapseAll()).model const selected = setSelectedSpanId(folded, idOf("c")).model diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index c9e17a3cba..e83cfb2bff 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -13899,7 +13899,7 @@ SELECT LIMIT 50 FORMAT JSON --- pipe:span_hierarchy:unwindowed:baseline [ce7c380b] +-- pipe:span_hierarchy:unwindowed:baseline [06d47b00] SELECT traces.TraceId AS traceId, traces.SpanId AS spanId, @@ -13912,8 +13912,8 @@ SELECT traces.Timestamp AS startTime, traces.StatusCode AS statusCode, traces.StatusMessage AS statusMessage, - toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'url.full', SpanAttributes['url.full'], 'http.url', SpanAttributes['http.url'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'url.path', SpanAttributes['url.path'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'cache.system', SpanAttributes['cache.system'], 'cache.result', SpanAttributes['cache.result'], 'cache.name', SpanAttributes['cache.name'], 'cache.operation', SpanAttributes['cache.operation'], 'cache.lookup_performed', SpanAttributes['cache.lookup_performed'], 'db.system.name', SpanAttributes['db.system.name'], 'db.system', SpanAttributes['db.system'], 'cloud.platform', SpanAttributes['cloud.platform'], 'cloudflare.colo', SpanAttributes['cloudflare.colo'], 'faas.invoked_region', SpanAttributes['faas.invoked_region'], 'cloudflare.outcome', SpanAttributes['cloudflare.outcome'])) AS spanAttributes, - toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'])) AS resourceAttributes, + toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'url.full', SpanAttributes['url.full'], 'http.url', SpanAttributes['http.url'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'url.path', SpanAttributes['url.path'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'cache.system', SpanAttributes['cache.system'], 'cache.result', SpanAttributes['cache.result'], 'cache.name', SpanAttributes['cache.name'], 'cache.operation', SpanAttributes['cache.operation'], 'cache.lookup_performed', SpanAttributes['cache.lookup_performed'], 'db.system.name', SpanAttributes['db.system.name'], 'db.system', SpanAttributes['db.system'], 'cloud.platform', SpanAttributes['cloud.platform'], 'cloudflare.colo', SpanAttributes['cloudflare.colo'], 'faas.invoked_region', SpanAttributes['faas.invoked_region'], 'cloudflare.outcome', SpanAttributes['cloudflare.outcome'], 'messaging.system', SpanAttributes['messaging.system'], 'rpc.system', SpanAttributes['rpc.system'], 'gen_ai.provider.name', SpanAttributes['gen_ai.provider.name'], 'gen_ai.system', SpanAttributes['gen_ai.system'])) AS spanAttributes, + toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'], 'process.runtime.name', ResourceAttributes['process.runtime.name'], 'telemetry.sdk.language', ResourceAttributes['telemetry.sdk.language'], 'telemetry.sdk.name', ResourceAttributes['telemetry.sdk.name'])) AS resourceAttributes, 'related' AS relationship FROM traces WHERE traces.TraceId = '0af7651916cd43dd8448eb211c80319c' @@ -13929,7 +13929,7 @@ SELECT LIMIT 5000 FORMAT JSON --- pipe:span_hierarchy:windowed:baseline [c4559257] +-- pipe:span_hierarchy:windowed:baseline [2de7a730] SELECT traces.TraceId AS traceId, traces.SpanId AS spanId, @@ -13942,8 +13942,8 @@ SELECT traces.Timestamp AS startTime, traces.StatusCode AS statusCode, traces.StatusMessage AS statusMessage, - toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'url.full', SpanAttributes['url.full'], 'http.url', SpanAttributes['http.url'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'url.path', SpanAttributes['url.path'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'cache.system', SpanAttributes['cache.system'], 'cache.result', SpanAttributes['cache.result'], 'cache.name', SpanAttributes['cache.name'], 'cache.operation', SpanAttributes['cache.operation'], 'cache.lookup_performed', SpanAttributes['cache.lookup_performed'], 'db.system.name', SpanAttributes['db.system.name'], 'db.system', SpanAttributes['db.system'], 'cloud.platform', SpanAttributes['cloud.platform'], 'cloudflare.colo', SpanAttributes['cloudflare.colo'], 'faas.invoked_region', SpanAttributes['faas.invoked_region'], 'cloudflare.outcome', SpanAttributes['cloudflare.outcome'])) AS spanAttributes, - toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'])) AS resourceAttributes, + toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'url.full', SpanAttributes['url.full'], 'http.url', SpanAttributes['http.url'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'url.path', SpanAttributes['url.path'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'cache.system', SpanAttributes['cache.system'], 'cache.result', SpanAttributes['cache.result'], 'cache.name', SpanAttributes['cache.name'], 'cache.operation', SpanAttributes['cache.operation'], 'cache.lookup_performed', SpanAttributes['cache.lookup_performed'], 'db.system.name', SpanAttributes['db.system.name'], 'db.system', SpanAttributes['db.system'], 'cloud.platform', SpanAttributes['cloud.platform'], 'cloudflare.colo', SpanAttributes['cloudflare.colo'], 'faas.invoked_region', SpanAttributes['faas.invoked_region'], 'cloudflare.outcome', SpanAttributes['cloudflare.outcome'], 'messaging.system', SpanAttributes['messaging.system'], 'rpc.system', SpanAttributes['rpc.system'], 'gen_ai.provider.name', SpanAttributes['gen_ai.provider.name'], 'gen_ai.system', SpanAttributes['gen_ai.system'])) AS spanAttributes, + toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'], 'process.runtime.name', ResourceAttributes['process.runtime.name'], 'telemetry.sdk.language', ResourceAttributes['telemetry.sdk.language'], 'telemetry.sdk.name', ResourceAttributes['telemetry.sdk.name'])) AS resourceAttributes, if(traces.SpanId = '00f067aa0ba902b7', 'target', 'related') AS relationship FROM traces WHERE traces.TraceId = '0af7651916cd43dd8448eb211c80319c' diff --git a/packages/query-engine/src/ch/queries/errors.ts b/packages/query-engine/src/ch/queries/errors.ts index bdb7d38269..ae3f94979b 100644 --- a/packages/query-engine/src/ch/queries/errors.ts +++ b/packages/query-engine/src/ch/queries/errors.ts @@ -351,13 +351,25 @@ const TREE_SPAN_ATTR_KEYS = [ "cloudflare.colo", "faas.invoked_region", "cloudflare.outcome", + // The waterfall's technology icon: what the span called, beyond `db.system`. + "messaging.system", + "rpc.system", + "gen_ai.provider.name", + "gen_ai.system", ] as const /** - * Resource attribute keys the trace-detail header reads (deployment env + commit). - * Everything else in `ResourceAttributes` is loaded lazily by `spanDetailQuery`. + * Resource attribute keys the trace detail reads: the header's env and commit, and the + * runtime and SDK behind each service's mark in the waterfall. Everything else in + * `ResourceAttributes` is loaded lazily by `spanDetailQuery`. */ -const TREE_RESOURCE_ATTR_KEYS = ["deployment.environment", "vcs.ref.head.revision"] as const +const TREE_RESOURCE_ATTR_KEYS = [ + "deployment.environment", + "vcs.ref.head.revision", + "process.runtime.name", + "telemetry.sdk.language", + "telemetry.sdk.name", +] as const /** * Hard cap on spans returned for one trace. A waterfall with more than a few From 67de1008c55e00c1cd25d95b155ef2cd3a76e828 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 1 Nov 2026 01:12:02 +0100 Subject: [PATCH 2/5] fix(web): name LLM providers properly and keep empty projections out of Color-by --- .../detail/timeline/timeline.scene.test.ts | 9 ++++ .../traces/detail/timeline/view-chrome.ts | 11 +++-- .../page/traces/detail/waterfall/tech.test.ts | 4 +- .../src/page/traces/detail/waterfall/tech.ts | 46 +++++++++++-------- 4 files changed, 47 insertions(+), 23 deletions(-) diff --git a/apps/web/src/page/traces/detail/timeline/timeline.scene.test.ts b/apps/web/src/page/traces/detail/timeline/timeline.scene.test.ts index 3ef204ebba..bbc01da6d4 100644 --- a/apps/web/src/page/traces/detail/timeline/timeline.scene.test.ts +++ b/apps/web/src/page/traces/detail/timeline/timeline.scene.test.ts @@ -20,6 +20,7 @@ import { Message, OutMessage } from "./message" import type { Model } from "./model" import { init, setSelectedSpanId, update } from "./update" import { view as timelineView } from "./view" +import { attributeKeys } from "./view-chrome" const traceId = Schema.decodeSync(TraceId)("6843a8853182b32339b2dd5120d423f2") const spanId = Schema.decodeSync(SpanId) @@ -156,6 +157,14 @@ describe("trace timeline", () => { expect(stepped.focusedIndex).toBe(0) }) + test("Color-by offers only keys a span actually carries, not the query's empty projections", () => { + const projected = { "server.address": "db.internal", "rpc.system": "", "gen_ai.system": "" } + const keys = attributeKeys([ + { ...root, spanAttributes: projected, children: [{ ...child, spanAttributes: projected }] }, + ]) + expect(keys).toEqual([{ kind: "spanAttribute", key: "server.address", count: 2 }]) + }) + test("picking a color scheme is reported to the page; the same one is ignored", () => { const model = entered() expect(update(model, Message.SelectedColorBy({ colorBy: "preset:spanKind" })).outMessage).toEqual( diff --git a/apps/web/src/page/traces/detail/timeline/view-chrome.ts b/apps/web/src/page/traces/detail/timeline/view-chrome.ts index c317302f00..14691b11f6 100644 --- a/apps/web/src/page/traces/detail/timeline/view-chrome.ts +++ b/apps/web/src/page/traces/detail/timeline/view-chrome.ts @@ -51,7 +51,9 @@ export const searchBar = (h: H, model: Model, matchCount: number, totalCount: nu h.button( [ h.AriaLabel("Clear search"), - h.Class("-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-foreground active:text-foreground"), + h.Class( + "-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-foreground active:text-foreground", + ), h.Type("button"), h.OnClick(Message.ClickedClearSearch()), ], @@ -70,8 +72,11 @@ const countKeys = ( read: (span: SpanNodeData) => Readonly>, ) => { const counts = new Map() + // The trace query projects fixed keys as "" when a span lacks them; only real values count. const visit = (span: SpanNodeData): void => { - Object.keys(read(span)).forEach((key) => counts.set(key, (counts.get(key) ?? 0) + 1)) + Object.entries(read(span)).forEach(([key, value]) => { + if (value !== "") counts.set(key, (counts.get(key) ?? 0) + 1) + }) span.children.forEach(visit) } spans.forEach(visit) @@ -81,7 +86,7 @@ const countKeys = ( } /** `useTraceAttributeKeys`: the trace's commonest span and resource attribute keys, 20 of each. */ -const attributeKeys = (rootSpans: ReadonlyArray): ReadonlyArray => [ +export const attributeKeys = (rootSpans: ReadonlyArray): ReadonlyArray => [ ...countKeys(rootSpans, (span) => span.spanAttributes).map(([key, count]) => ({ kind: "spanAttribute" as const, key, diff --git a/apps/web/src/page/traces/detail/waterfall/tech.test.ts b/apps/web/src/page/traces/detail/waterfall/tech.test.ts index 8e3c89e600..f694855e30 100644 --- a/apps/web/src/page/traces/detail/waterfall/tech.test.ts +++ b/apps/web/src/page/traces/detail/waterfall/tech.test.ts @@ -18,10 +18,12 @@ describe("spanTech", () => { ).toMatchObject({ icon: "AnthropicIcon" }) expect(spanTech({ "gen_ai.system": "gcp.vertex_ai" })).toMatchObject({ icon: "GoogleIcon", - label: "LLM call · Google", + label: "LLM call · Vertex AI", }) + expect(spanTech({ "gen_ai.provider.name": "openai" })).toMatchObject({ label: "LLM call · OpenAI" }) expect(spanTech({ "gen_ai.provider.name": "some-new-lab" })).toMatchObject({ icon: "ChatBubbleSparkleIcon", + label: "LLM call · Some New Lab", }) }) diff --git a/apps/web/src/page/traces/detail/waterfall/tech.ts b/apps/web/src/page/traces/detail/waterfall/tech.ts index ab9b41a31b..4a6ddba81d 100644 --- a/apps/web/src/page/traces/detail/waterfall/tech.ts +++ b/apps/web/src/page/traces/detail/waterfall/tech.ts @@ -31,31 +31,39 @@ const titleCase = (system: string): string => .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ") -// `gen_ai.provider.name` values (and the older `gen_ai.system`) to `modelVendorIcon` slugs. -const GEN_AI_VENDORS = new Map([ - ["aws.bedrock", "amazon"], - ["azure.ai.inference", "microsoft"], - ["azure.ai.openai", "microsoft"], - ["az.ai.inference", "microsoft"], - ["az.ai.openai", "microsoft"], - ["gcp.gemini", "google"], - ["gcp.gen_ai", "google"], - ["gcp.vertex_ai", "google"], - ["gemini", "google"], - ["vertex_ai", "google"], - ["ibm.watsonx.ai", "ibm-granite"], - ["mistral_ai", "mistralai"], - ["x_ai", "x-ai"], - ["xai", "x-ai"], +// `gen_ai.provider.name` values (and the older `gen_ai.system`): the `modelVendorIcon` slug and +// the name a reader knows. Anything else keeps its own value as the slug. +const GEN_AI_PROVIDERS = new Map>([ + ["anthropic", { vendor: "anthropic", label: "Anthropic" }], + ["openai", { vendor: "openai", label: "OpenAI" }], + ["openrouter", { vendor: "openrouter", label: "OpenRouter" }], + ["cohere", { vendor: "cohere", label: "Cohere" }], + ["deepseek", { vendor: "deepseek", label: "DeepSeek" }], + ["perplexity", { vendor: "perplexity", label: "Perplexity" }], + ["groq", { vendor: "groq", label: "Groq" }], + ["mistral_ai", { vendor: "mistralai", label: "Mistral" }], + ["x_ai", { vendor: "x-ai", label: "xAI" }], + ["xai", { vendor: "x-ai", label: "xAI" }], + ["aws.bedrock", { vendor: "amazon", label: "AWS Bedrock" }], + ["azure.ai.inference", { vendor: "microsoft", label: "Azure AI" }], + ["az.ai.inference", { vendor: "microsoft", label: "Azure AI" }], + ["azure.ai.openai", { vendor: "microsoft", label: "Azure OpenAI" }], + ["az.ai.openai", { vendor: "microsoft", label: "Azure OpenAI" }], + ["gcp.gemini", { vendor: "google", label: "Gemini" }], + ["gemini", { vendor: "google", label: "Gemini" }], + ["gcp.gen_ai", { vendor: "google", label: "Google Gen AI" }], + ["gcp.vertex_ai", { vendor: "google", label: "Vertex AI" }], + ["vertex_ai", { vendor: "google", label: "Vertex AI" }], + ["ibm.watsonx.ai", { vendor: "ibm-granite", label: "watsonx" }], ]) const genAi = (attrs: Record): Mark | null => { const provider = systemOf(attrs, "gen_ai.provider.name", "gen_ai.system") if (provider === null) return null - const vendor = GEN_AI_VENDORS.get(provider) ?? provider + const known = GEN_AI_PROVIDERS.get(provider) return { - icon: modelVendorIcon({ vendorSlug: vendor, family: null }), - label: `LLM call · ${titleCase(vendor)}`, + icon: modelVendorIcon({ vendorSlug: known?.vendor ?? provider, family: null }), + label: `LLM call · ${known?.label ?? titleCase(provider)}`, className: MUTED, } } From 49d6f04fd50e7a0697e428bbc9f65ca6f0519074 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 1 Nov 2026 01:30:02 +0100 Subject: [PATCH 3/5] fix(web): drop the span kind before the service name on narrow waterfall rows --- apps/web/src/page/traces/detail/waterfall/row.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/traces/detail/waterfall/row.ts b/apps/web/src/page/traces/detail/waterfall/row.ts index e5a615f471..922e65cf3a 100644 --- a/apps/web/src/page/traces/detail/waterfall/row.ts +++ b/apps/web/src/page/traces/detail/waterfall/row.ts @@ -262,7 +262,11 @@ const serviceLabel = (h: HtmlBuilder, row: TreeRow): ReadonlyArray Date: Sun, 1 Nov 2026 01:31:06 +0100 Subject: [PATCH 4/5] fix(web): readable labels for unrecognised database systems --- apps/web/src/page/traces/detail/waterfall/tech.test.ts | 2 ++ apps/web/src/page/traces/detail/waterfall/tech.ts | 4 ++-- apps/web/src/service-map/trace-flow/cloud-platforms.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/traces/detail/waterfall/tech.test.ts b/apps/web/src/page/traces/detail/waterfall/tech.test.ts index f694855e30..08cca3d228 100644 --- a/apps/web/src/page/traces/detail/waterfall/tech.test.ts +++ b/apps/web/src/page/traces/detail/waterfall/tech.test.ts @@ -33,6 +33,7 @@ describe("spanTech", () => { }) expect(spanTech({ "db.system": "dynamodb", "rpc.system": "aws-api" })).toMatchObject({ icon: "DatabaseIcon", + label: "DynamoDB", }) }) @@ -41,6 +42,7 @@ describe("spanTech", () => { icon: "PaperPlaneIcon", label: "Cloud Tasks", }) + expect(spanTech({ "db.system.name": "Other_SQL" })).toMatchObject({ label: "Other Sql" }) }) test("system values match whatever their case or padding", () => { diff --git a/apps/web/src/page/traces/detail/waterfall/tech.ts b/apps/web/src/page/traces/detail/waterfall/tech.ts index 4a6ddba81d..e2d6d44f9e 100644 --- a/apps/web/src/page/traces/detail/waterfall/tech.ts +++ b/apps/web/src/page/traces/detail/waterfall/tech.ts @@ -1,6 +1,6 @@ import { dbSystemAccentClass } from "@maple/ui/lib/cloud-platforms/accents" import { getDbStyle } from "@maple/ui/components/service-map/service-map-db-style" -import { pickAttr } from "../../../../service-map/trace-flow/cloud-platforms" +import { humanizeDbSystem, pickAttr } from "../../../../service-map/trace-flow/cloud-platforms" import { DB_ICON } from "../../../../service-map/map/node-card" import { resolveRuntimeGlyph } from "../../../../service-map/map/runtime" import type { IconName } from "../../../../ui/icon" @@ -74,7 +74,7 @@ const database = (attrs: Record): Mark | null => { const style = getDbStyle(system) return { icon: DB_ICON[style.icon], - label: style.label, + label: humanizeDbSystem(system), className: style.branded ? "" : dbSystemAccentClass(system), } } diff --git a/apps/web/src/service-map/trace-flow/cloud-platforms.ts b/apps/web/src/service-map/trace-flow/cloud-platforms.ts index 0b2a7c794f..4f70065201 100644 --- a/apps/web/src/service-map/trace-flow/cloud-platforms.ts +++ b/apps/web/src/service-map/trace-flow/cloud-platforms.ts @@ -52,7 +52,7 @@ const DB_SYSTEM_LABELS = new Map( }), ) -function humanizeDbSystem(system: string): string { +export function humanizeDbSystem(system: string): string { const known = DB_SYSTEM_LABELS.get(system.toLowerCase()) if (known) return known const tail = system.split(".").pop() ?? system From 8f2c591d5a559f2a0413cac6131acecb20951ace Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 1 Nov 2026 03:54:40 +0100 Subject: [PATCH 5/5] fix(web): mark Workers that report cloud.platform on the resource --- apps/web/src/page/traces/detail/waterfall/row.ts | 10 +++++++--- .../traces/detail/waterfall/waterfall.scene.test.ts | 12 +++++++++--- .../query-engine/src/__sql_baseline__/catalog.sql | 8 ++++---- packages/query-engine/src/ch/queries/errors.ts | 3 ++- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/src/page/traces/detail/waterfall/row.ts b/apps/web/src/page/traces/detail/waterfall/row.ts index 922e65cf3a..b7e0bb3b11 100644 --- a/apps/web/src/page/traces/detail/waterfall/row.ts +++ b/apps/web/src/page/traces/detail/waterfall/row.ts @@ -1,4 +1,5 @@ import { cacheResultStyles, getCacheInfo } from "@maple/ui/lib/cache" +import { CLOUDFLARE_ACCENT_CLASS } from "@maple/ui/lib/cloud-platforms/accents" import { getServiceColor } from "@maple/ui/lib/colors" import { formatDuration } from "@maple/ui/lib/format" import { getHttpInfo, HTTP_METHOD_COLORS, httpStatusTone } from "@maple/ui/lib/http" @@ -237,9 +238,12 @@ type HttpInfo = NonNullable> /** The Cloudflare Worker mark, else the service's runtime or Effect. */ const serviceMark = (span: SpanNodeData): Mark | null => { - const platform = rowPlatform({ ...span.spanAttributes }) - if (platform?.id === "cloudflare") - return { icon: platform.icon, label: platform.label, className: platform.accentClassName } + // Workers report `cloud.platform` on the resource; Worker spans may carry it themselves. + const isWorker = + rowPlatform({ ...span.spanAttributes })?.id === "cloudflare" || + span.resourceAttributes["cloud.platform"] === "cloudflare.workers" + if (isWorker) + return { icon: "CloudflareIcon", label: "Cloudflare Worker", className: CLOUDFLARE_ACCENT_CLASS } return serviceRuntime({ ...span.resourceAttributes }) } diff --git a/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts b/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts index 23a31bafed..c088ce1454 100644 --- a/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts +++ b/apps/web/src/page/traces/detail/waterfall/waterfall.scene.test.ts @@ -129,6 +129,11 @@ describe("waterfall", () => { spanAttributes: { "db.system.name": "postgresql" }, resourceAttributes: api, } + const worker = { + ...node("w", 1), + serviceName: "edge", + resourceAttributes: { "cloud.platform": "cloudflare.workers", "process.runtime.name": "workerd" }, + } const llm = { ...node("l", 1), serviceName: "agent", @@ -137,13 +142,14 @@ describe("waterfall", () => { } const trace: TraceData = { ...small, - rootSpans: [{ ...node("r", 0, [query, llm]), resourceAttributes: api }], + rootSpans: [{ ...node("r", 0, [query, llm, worker]), resourceAttributes: api }], } scene( { update, view }, - given(update(entered(trace), windowOver(3)).model), - Mount.resolve(VirtualizeWaterfall, windowOver(3)), + given(update(entered(trace), windowOver(4)).model), + Mount.resolve(VirtualizeWaterfall, windowOver(4)), expectView(role("img", { name: "PostgreSQL" })).toExist(), + expectView(role("img", { name: "Cloudflare Worker" })).toExist(), expectView(role("img", { name: "LLM call · Anthropic" })).toExist(), expectView(role("img", { name: "Python" })).toExist(), // The query stays in the root's service, so only the root carries its runtime. diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index e83cfb2bff..21a45773db 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -13899,7 +13899,7 @@ SELECT LIMIT 50 FORMAT JSON --- pipe:span_hierarchy:unwindowed:baseline [06d47b00] +-- pipe:span_hierarchy:unwindowed:baseline [eb5aec43] SELECT traces.TraceId AS traceId, traces.SpanId AS spanId, @@ -13913,7 +13913,7 @@ SELECT traces.StatusCode AS statusCode, traces.StatusMessage AS statusMessage, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'url.full', SpanAttributes['url.full'], 'http.url', SpanAttributes['http.url'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'url.path', SpanAttributes['url.path'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'cache.system', SpanAttributes['cache.system'], 'cache.result', SpanAttributes['cache.result'], 'cache.name', SpanAttributes['cache.name'], 'cache.operation', SpanAttributes['cache.operation'], 'cache.lookup_performed', SpanAttributes['cache.lookup_performed'], 'db.system.name', SpanAttributes['db.system.name'], 'db.system', SpanAttributes['db.system'], 'cloud.platform', SpanAttributes['cloud.platform'], 'cloudflare.colo', SpanAttributes['cloudflare.colo'], 'faas.invoked_region', SpanAttributes['faas.invoked_region'], 'cloudflare.outcome', SpanAttributes['cloudflare.outcome'], 'messaging.system', SpanAttributes['messaging.system'], 'rpc.system', SpanAttributes['rpc.system'], 'gen_ai.provider.name', SpanAttributes['gen_ai.provider.name'], 'gen_ai.system', SpanAttributes['gen_ai.system'])) AS spanAttributes, - toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'], 'process.runtime.name', ResourceAttributes['process.runtime.name'], 'telemetry.sdk.language', ResourceAttributes['telemetry.sdk.language'], 'telemetry.sdk.name', ResourceAttributes['telemetry.sdk.name'])) AS resourceAttributes, + toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'], 'process.runtime.name', ResourceAttributes['process.runtime.name'], 'telemetry.sdk.language', ResourceAttributes['telemetry.sdk.language'], 'telemetry.sdk.name', ResourceAttributes['telemetry.sdk.name'], 'cloud.platform', ResourceAttributes['cloud.platform'])) AS resourceAttributes, 'related' AS relationship FROM traces WHERE traces.TraceId = '0af7651916cd43dd8448eb211c80319c' @@ -13929,7 +13929,7 @@ SELECT LIMIT 5000 FORMAT JSON --- pipe:span_hierarchy:windowed:baseline [2de7a730] +-- pipe:span_hierarchy:windowed:baseline [48e4da1f] SELECT traces.TraceId AS traceId, traces.SpanId AS spanId, @@ -13943,7 +13943,7 @@ SELECT traces.StatusCode AS statusCode, traces.StatusMessage AS statusMessage, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'url.full', SpanAttributes['url.full'], 'http.url', SpanAttributes['http.url'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'url.path', SpanAttributes['url.path'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'cache.system', SpanAttributes['cache.system'], 'cache.result', SpanAttributes['cache.result'], 'cache.name', SpanAttributes['cache.name'], 'cache.operation', SpanAttributes['cache.operation'], 'cache.lookup_performed', SpanAttributes['cache.lookup_performed'], 'db.system.name', SpanAttributes['db.system.name'], 'db.system', SpanAttributes['db.system'], 'cloud.platform', SpanAttributes['cloud.platform'], 'cloudflare.colo', SpanAttributes['cloudflare.colo'], 'faas.invoked_region', SpanAttributes['faas.invoked_region'], 'cloudflare.outcome', SpanAttributes['cloudflare.outcome'], 'messaging.system', SpanAttributes['messaging.system'], 'rpc.system', SpanAttributes['rpc.system'], 'gen_ai.provider.name', SpanAttributes['gen_ai.provider.name'], 'gen_ai.system', SpanAttributes['gen_ai.system'])) AS spanAttributes, - toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'], 'process.runtime.name', ResourceAttributes['process.runtime.name'], 'telemetry.sdk.language', ResourceAttributes['telemetry.sdk.language'], 'telemetry.sdk.name', ResourceAttributes['telemetry.sdk.name'])) AS resourceAttributes, + toJSONString(map('deployment.environment', ResourceAttributes['deployment.environment'], 'vcs.ref.head.revision', ResourceAttributes['vcs.ref.head.revision'], 'process.runtime.name', ResourceAttributes['process.runtime.name'], 'telemetry.sdk.language', ResourceAttributes['telemetry.sdk.language'], 'telemetry.sdk.name', ResourceAttributes['telemetry.sdk.name'], 'cloud.platform', ResourceAttributes['cloud.platform'])) AS resourceAttributes, if(traces.SpanId = '00f067aa0ba902b7', 'target', 'related') AS relationship FROM traces WHERE traces.TraceId = '0af7651916cd43dd8448eb211c80319c' diff --git a/packages/query-engine/src/ch/queries/errors.ts b/packages/query-engine/src/ch/queries/errors.ts index ae3f94979b..c72b675e69 100644 --- a/packages/query-engine/src/ch/queries/errors.ts +++ b/packages/query-engine/src/ch/queries/errors.ts @@ -360,7 +360,7 @@ const TREE_SPAN_ATTR_KEYS = [ /** * Resource attribute keys the trace detail reads: the header's env and commit, and the - * runtime and SDK behind each service's mark in the waterfall. Everything else in + * runtime, SDK and platform behind each service's mark in the waterfall. Everything else in * `ResourceAttributes` is loaded lazily by `spanDetailQuery`. */ const TREE_RESOURCE_ATTR_KEYS = [ @@ -369,6 +369,7 @@ const TREE_RESOURCE_ATTR_KEYS = [ "process.runtime.name", "telemetry.sdk.language", "telemetry.sdk.name", + "cloud.platform", ] as const /**