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/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/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..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" @@ -18,6 +19,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 +157,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 +166,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 +198,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 +236,27 @@ 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 => { + // 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 }) +} + +/** 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( [ @@ -250,7 +266,11 @@ const serviceLabel = (h: HtmlBuilder, row: TreeRow): ReadonlyArray, 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..08cca3d228 --- /dev/null +++ b/apps/web/src/page/traces/detail/waterfall/tech.test.ts @@ -0,0 +1,93 @@ +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 · 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", + }) + }) + + 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", + label: "DynamoDB", + }) + }) + + test("an unknown system keeps a generic glyph and a readable name", () => { + expect(spanTech({ "messaging.system": "cloud_tasks" })).toMatchObject({ + 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", () => { + 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..e2d6d44f9e --- /dev/null +++ b/apps/web/src/page/traces/detail/waterfall/tech.ts @@ -0,0 +1,167 @@ +import { dbSystemAccentClass } from "@maple/ui/lib/cloud-platforms/accents" +import { getDbStyle } from "@maple/ui/components/service-map/service-map-db-style" +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" +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`): 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 known = GEN_AI_PROVIDERS.get(provider) + return { + icon: modelVendorIcon({ vendorSlug: known?.vendor ?? provider, family: null }), + label: `LLM call · ${known?.label ?? titleCase(provider)}`, + 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: humanizeDbSystem(system), + 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..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 @@ -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,41 @@ 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 worker = { + ...node("w", 1), + serviceName: "edge", + resourceAttributes: { "cloud.platform": "cloudflare.workers", "process.runtime.name": "workerd" }, + } + 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, worker]), resourceAttributes: api }], + } + scene( + { update, view }, + 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. + 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/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 diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index c9e17a3cba..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 [ce7c380b] +-- pipe:span_hierarchy:unwindowed:baseline [eb5aec43] 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'], '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 [c4559257] +-- pipe:span_hierarchy:windowed:baseline [48e4da1f] 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'], '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 bdb7d38269..c72b675e69 100644 --- a/packages/query-engine/src/ch/queries/errors.ts +++ b/packages/query-engine/src/ch/queries/errors.ts @@ -351,13 +351,26 @@ 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, 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 = ["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", + "cloud.platform", +] as const /** * Hard cap on spans returned for one trace. A waterfall with more than a few