From 2fd8e01b1f1fbf41aa093685db301c4dfdf9856f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 30 Sep 2026 23:38:19 +0200 Subject: [PATCH 1/6] fix(warehouse): read the current semconv key wherever we only read the legacy one An audit of every attribute key in the repo against the semconv v1.44.0 registry found read paths pinned to the deprecated spelling. Spans from current OTel instrumentation landed in an empty bucket or matched nothing: - traces: the `http_method` group-by (timeseries and breakdown) read only `http.method`, and the raw `environment` breakdown read only `deployment.environment`. Both now coalesce through `semconv-renames` (new `httpRequestMethodExpr`, existing `deploymentEnvExpr`). - span filters: the alias table now also covers `db.system`, `messaging.destination`, `rpc.system` and `http.host` -> `server.address`, so the service-map drilldowns and the gRPC template match either key. - where-clause: `deployment.environment.name` normalizes to the environment dimension instead of falling through to a span-attribute filter. - service dependencies drilldowns: the parser drops `(a OR b)` groups, so the HTTP drill never filtered on its target and the RPC drill would not either. Each drill is now a single aliased key; an RPC target that came from the system filters on `rpc.system.name`. - trace page and peek sheet: environment badge and 5xx detection read the current keys first. - setup audit, error prompt and log chips know `service.peer.name`, `rpc.system.name`, `rpc.response.status_code`, `url.full` and the current HTTP and messaging keys. The service-map external-edge rollup still classifies RPC off `rpc.system` and `rpc.service`; fixing that needs a migration and ships separately. --- .../services/service-dependencies-tab.tsx | 11 +++-- .../components/traces/trace-peek-sheet.tsx | 8 +++- apps/web/src/routes/traces/$traceId.tsx | 7 ++- .../application/grpc-service.ts | 2 +- .../domain/src/tinybird/semconv-renames.ts | 15 +++++++ packages/domain/src/where-clause.test.ts | 1 + packages/domain/src/where-clause.ts | 4 +- .../src/__sql_baseline__/integrations.sql | 2 +- .../src/product/setup-audit.test.ts | 2 +- .../src/product/setup-audit.ts | 2 + packages/query-engine/src/ch/ch.test.ts | 43 ++++++++++++++++++- .../query-engine/src/ch/queries/traces.ts | 7 +-- packages/query-engine/src/traces-shared.ts | 34 ++++++++++----- packages/ui/src/lib/error-prompt.ts | 8 ++++ packages/ui/src/lib/log-attributes.ts | 17 ++++++-- 15 files changed, 133 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/services/service-dependencies-tab.tsx b/apps/web/src/components/services/service-dependencies-tab.tsx index 13405a2552..9f3869e216 100644 --- a/apps/web/src/components/services/service-dependencies-tab.tsx +++ b/apps/web/src/components/services/service-dependencies-tab.tsx @@ -160,12 +160,17 @@ export function ServiceDependenciesTab({ const callCount = Number(edge.callCount ?? 0) const estimated = Number(edge.estimatedCallCount ?? callCount) const system = edge.targetSystem ? String(edge.targetSystem) : "" + // The where-clause parser drops `(a OR b)` groups, so each drill is one + // aliased key (the query engine matches both semconv spellings). An rpc + // TargetName falls back to the system when rpc.service is absent. const whereClause = kind === "messaging" - ? `SpanKind = 'Producer' AND messaging.destination = ${quoteWhereValue(target)}` + ? `SpanKind = 'Producer' AND messaging.destination.name = ${quoteWhereValue(target)}` : kind === "rpc" - ? `SpanKind = 'Client' AND rpc.service = ${quoteWhereValue(target)}` - : `SpanKind = 'Client' AND (server.address = ${quoteWhereValue(target)} OR http.host = ${quoteWhereValue(target)})` + ? system === target + ? `SpanKind = 'Client' AND rpc.system.name = ${quoteWhereValue(target)}` + : `SpanKind = 'Client' AND rpc.service = ${quoteWhereValue(target)}` + : `SpanKind = 'Client' AND server.address = ${quoteWhereValue(target)}` out.push({ id: `${kind}:${target}`, diff --git a/apps/web/src/components/traces/trace-peek-sheet.tsx b/apps/web/src/components/traces/trace-peek-sheet.tsx index 758e2ed712..08f4844e1b 100644 --- a/apps/web/src/components/traces/trace-peek-sheet.tsx +++ b/apps/web/src/components/traces/trace-peek-sheet.tsx @@ -350,7 +350,8 @@ function TracePeekLoaded({ const rootHttpInfo = getHttpInfo(rootSpan) const hasError = data.spans.some((s: Span) => { if (s.statusCode === "Error") return true - const httpStatus = s.spanAttributes?.["http.status_code"] + const httpStatus = + s.spanAttributes?.["http.response.status_code"] || s.spanAttributes?.["http.status_code"] const code = typeof httpStatus === "string" ? parseInt(httpStatus) : httpStatus return typeof code === "number" && code >= 500 }) @@ -388,7 +389,10 @@ function TracePeekLoaded({ traceId={traceId} hasError={hasError} httpStatusCode={rootHttpInfo?.statusCode} - deploymentEnv={rootSpan.resourceAttributes?.["deployment.environment"]} + deploymentEnv={ + rootSpan.resourceAttributes?.["deployment.environment.name"] || + rootSpan.resourceAttributes?.["deployment.environment"] + } commitSha={rootSpan.resourceAttributes?.["vcs.ref.head.revision"]} /> diff --git a/apps/web/src/routes/traces/$traceId.tsx b/apps/web/src/routes/traces/$traceId.tsx index eb88f89ae9..9f7cc6da9c 100644 --- a/apps/web/src/routes/traces/$traceId.tsx +++ b/apps/web/src/routes/traces/$traceId.tsx @@ -295,11 +295,14 @@ function TraceDetailContent({ const rootSpan = data.rootSpans[0] const rootHttpInfo = rootSpan ? getHttpInfo(rootSpan) : null - const deploymentEnv = rootSpan?.resourceAttributes?.["deployment.environment"] + const deploymentEnv = + rootSpan?.resourceAttributes?.["deployment.environment.name"] || + rootSpan?.resourceAttributes?.["deployment.environment"] const commitSha = rootSpan?.resourceAttributes?.["vcs.ref.head.revision"] const hasError = data.spans.some((s: Span) => { if (s.statusCode === "Error") return true - const httpStatus = s.spanAttributes?.["http.status_code"] + const httpStatus = + s.spanAttributes?.["http.response.status_code"] || s.spanAttributes?.["http.status_code"] if (httpStatus) { const code = typeof httpStatus === "string" ? parseInt(httpStatus) : httpStatus if (typeof code === "number" && code >= 500) return true diff --git a/packages/backend/src/dashboard-templates/application/grpc-service.ts b/packages/backend/src/dashboard-templates/application/grpc-service.ts index fb2aa836bc..274db9744d 100644 --- a/packages/backend/src/dashboard-templates/application/grpc-service.ts +++ b/packages/backend/src/dashboard-templates/application/grpc-service.ts @@ -13,7 +13,7 @@ import { } from "@maple/backend/dashboard-templates/helpers" import type { TemplateDefinition, WidgetDef } from "@maple/backend/dashboard-templates/types" -const GRPC_FILTER = `rpc.system = "grpc"` +const GRPC_FILTER = `rpc.system.name = "grpc"` function widgets(serviceName?: string): WidgetDef[] { const where = combineWhere(GRPC_FILTER, serviceWhereClause(serviceName)) diff --git a/packages/domain/src/tinybird/semconv-renames.ts b/packages/domain/src/tinybird/semconv-renames.ts index d2b460378d..a4df1f3934 100644 --- a/packages/domain/src/tinybird/semconv-renames.ts +++ b/packages/domain/src/tinybird/semconv-renames.ts @@ -104,3 +104,18 @@ export function containerRuntimeExpr(resourceAttributes: MapColumnLike): Expr { + return CH.coalesce( + CH.nullIf(spanAttributes.get("http.request.method"), ""), + spanAttributes.get("http.method"), + ) +} diff --git a/packages/domain/src/where-clause.test.ts b/packages/domain/src/where-clause.test.ts index de881f120e..17cfe9ce5a 100644 --- a/packages/domain/src/where-clause.test.ts +++ b/packages/domain/src/where-clause.test.ts @@ -26,6 +26,7 @@ describe("normalizeKey", () => { expect(normalizeKey("env")).toBe("deployment.environment") expect(normalizeKey("environment")).toBe("deployment.environment") expect(normalizeKey("deployment.environment")).toBe("deployment.environment") + expect(normalizeKey("deployment.environment.name")).toBe("deployment.environment") }) it("normalizes commit_sha alias", () => { diff --git a/packages/domain/src/where-clause.ts b/packages/domain/src/where-clause.ts index 930e18fff9..dabaa28990 100644 --- a/packages/domain/src/where-clause.ts +++ b/packages/domain/src/where-clause.ts @@ -39,7 +39,9 @@ export const normalizeKey = (raw: string): string => Match.value(raw.trim().toLowerCase()).pipe( Match.when("service", () => "service.name"), Match.when("span", () => "span.name"), - Match.whenOr("environment", "env", () => "deployment.environment"), + // The stable semconv key is a resource attribute too; without this it fell + // through to a span-attribute filter that matches nothing. + Match.whenOr("environment", "env", "deployment.environment.name", () => "deployment.environment"), // `deployment.commit_sha` is retired telemetry, kept only as an alias so a // saved where-clause written against it still names the commit filter. Match.whenOr("commit_sha", "deployment.commit_sha", () => "vcs.ref.head.revision"), diff --git a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql index 4fc4d5aee0..a3d725bc4e 100644 --- a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql +++ b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql @@ -2810,7 +2810,7 @@ SELECT FROM attribute_values_hourly WHERE OrgId = 'org_sql_catalog' AND AttributeScope = 'span' - AND AttributeKey IN ('peer.service', 'db.system', 'db.system.name', 'messaging.system', 'rpc.system') + AND AttributeKey IN ('service.peer.name', 'peer.service', 'db.system', 'db.system.name', 'messaging.system', 'rpc.system.name', 'rpc.system') AND Hour >= '2026-01-01 10:30:00' AND Hour <= '2026-01-03 14:15:00' AND AttributeValue != '' diff --git a/packages/query-engine-integrations/src/product/setup-audit.test.ts b/packages/query-engine-integrations/src/product/setup-audit.test.ts index 2cfa7bf1a5..b4faf57255 100644 --- a/packages/query-engine-integrations/src/product/setup-audit.test.ts +++ b/packages/query-engine-integrations/src/product/setup-audit.test.ts @@ -104,7 +104,7 @@ describe("auditPeerValueInventoryQuery", () => { const { sql } = compileUnsafe(auditPeerValueInventoryQuery(), baseParams) expect(sql).toContain("AttributeScope = 'span'") expect(sql).toContain( - "AttributeKey IN ('peer.service', 'db.system', 'db.system.name', 'messaging.system', 'rpc.system')", + "AttributeKey IN ('service.peer.name', 'peer.service', 'db.system', 'db.system.name', 'messaging.system', 'rpc.system.name', 'rpc.system')", ) expect(sql).toContain("AttributeValue != ''") expect(sql).toContain("GROUP BY attributeKey, attributeValue") diff --git a/packages/query-engine-integrations/src/product/setup-audit.ts b/packages/query-engine-integrations/src/product/setup-audit.ts index 2ea0af6f50..a613d90938 100644 --- a/packages/query-engine-integrations/src/product/setup-audit.ts +++ b/packages/query-engine-integrations/src/product/setup-audit.ts @@ -277,10 +277,12 @@ export function auditMetricLabelCardinalityQuery(opts: { limit?: number } = {}) /** Keys whose *values* name a dependency, where inconsistent spelling fragments a service-map node. */ export const AUDIT_PEER_KEYS = [ + "service.peer.name", "peer.service", "db.system", "db.system.name", "messaging.system", + "rpc.system.name", "rpc.system", ] as const diff --git a/packages/query-engine/src/ch/ch.test.ts b/packages/query-engine/src/ch/ch.test.ts index faa76524b2..a98b731a6e 100644 --- a/packages/query-engine/src/ch/ch.test.ts +++ b/packages/query-engine/src/ch/ch.test.ts @@ -734,9 +734,50 @@ describe("tracesBreakdownQuery", () => { const { sql } = compileUnsafe(q, baseParams) expect(sql).toContain("FROM traces") expect(sql).not.toContain("FROM service_overview_spans") - expect(sql).toContain("SpanAttributes['http.method'] AS name") + expect(sql).toContain( + "coalesce(nullIf(SpanAttributes['http.request.method'], ''), SpanAttributes['http.method']) AS name", + ) + }) + + it("groups by environment under either semconv spelling on the raw path", () => { + const q = tracesBreakdownQuery({ metric: "count", groupBy: "environment" }) + const { sql } = compileUnsafe(q, baseParams) + expect(sql).toContain("FROM traces") + expect(sql).toContain( + "coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS name", + ) + }) + + it("groups a timeseries by http_method under either semconv spelling", () => { + const q = tracesTimeseriesQuery({ + metric: "count", + needsSampling: false, + groupBy: ["http_method"], + bucketSeconds: 300, + }) + const { sql } = compileUnsafe(q, { ...baseParams, bucketSeconds: 300 }) + expect(sql).toContain( + "coalesce(nullIf(SpanAttributes['http.request.method'], ''), SpanAttributes['http.method'])", + ) }) + // Dependency drilldowns and dashboard templates filter on these keys; the + // service-map rollups already coalesce them, so the raw filter has to too. + for (const [key, canonical, legacy] of [ + ["db.system", "db.system.name", "db.system"], + ["messaging.destination.name", "messaging.destination.name", "messaging.destination"], + ["rpc.system.name", "rpc.system.name", "rpc.system"], + ["server.address", "server.address", "http.host"], + ] as const) { + it(`matches both semconv spellings when filtering on ${key}`, () => { + const q = tracesListQuery({ attributeFilters: [{ key, value: "x", mode: "equals" }] }) + const { sql } = compileUnsafe(q, baseParams) + expect(sql).toContain( + `if(SpanAttributes['${canonical}'] != '', SpanAttributes['${canonical}'], SpanAttributes['${legacy}']) = 'x'`, + ) + }) + } + it("groups by custom attribute", () => { const q = tracesBreakdownQuery({ metric: "count", diff --git a/packages/query-engine/src/ch/queries/traces.ts b/packages/query-engine/src/ch/queries/traces.ts index 3f0e25caa4..05e7c19506 100644 --- a/packages/query-engine/src/ch/queries/traces.ts +++ b/packages/query-engine/src/ch/queries/traces.ts @@ -18,6 +18,7 @@ import { Traces, TracesAggregatesHourly, } from "../tables" +import { deploymentEnvExpr, httpRequestMethodExpr } from "@maple/domain/tinybird/semconv-renames" import { METRIC_NEEDS } from "../../traces-shared" import type { ColumnDefs } from "@maple-dev/effect-clickhouse/types" import * as T from "@maple-dev/effect-clickhouse/types" @@ -172,7 +173,7 @@ function buildGroupNameExpr( parts.push(CH.toString_($.StatusCode)) break case "http_method": - parts.push(CH.toString_($.SpanAttributes.get("http.method"))) + parts.push(CH.toString_(httpRequestMethodExpr($.SpanAttributes))) break case "attribute": if (groupByAttributeKeys?.length) { @@ -289,13 +290,13 @@ function buildBreakdownGroupExpr( case "namespace": return $.ResourceAttributes.get("service.namespace") case "environment": - return $.ResourceAttributes.get("deployment.environment") + return deploymentEnvExpr($.ResourceAttributes) case "span_name": return $.SpanName case "status_code": return $.StatusCode case "http_method": - return $.SpanAttributes.get("http.method") + return httpRequestMethodExpr($.SpanAttributes) case "attribute": return groupByAttributeKey ? $.SpanAttributes.get(groupByAttributeKey) : $.ServiceName default: diff --git a/packages/query-engine/src/traces-shared.ts b/packages/query-engine/src/traces-shared.ts index 2b814f454b..bd740cee2d 100644 --- a/packages/query-engine/src/traces-shared.ts +++ b/packages/query-engine/src/traces-shared.ts @@ -42,20 +42,32 @@ import * as T from "@maple-dev/effect-clickhouse/types" // Semconv rename coalescing // -// OpenTelemetry renamed several HTTP span attributes in the stable semconv: -// http.method → http.request.method -// http.status_code → http.response.status_code -// `trace_list_mv` coalesces both spellings when it pre-extracts its columns -// (see materializations.ts), so the quick-filter facet counts cover spans that -// use *either* key. Filters that read the raw `traces` table must coalesce the -// same way — otherwise a facet shows a count while applying it matches zero -// rows (the data carries the new key, the filter looked up the old one). - -const HTTP_SEMCONV_ALIASES: Record = { +// OpenTelemetry renamed several span attributes: +// http.method → http.request.method +// http.status_code → http.response.status_code +// db.system → db.system.name +// messaging.destination → messaging.destination.name +// rpc.system → rpc.system.name +// http.host → server.address +// `trace_list_mv` and the service-map rollups coalesce both spellings when they +// pre-extract their columns (see materializations.ts), so facet counts and edge +// targets cover spans that use *either* key. Filters that read the raw `traces` +// table must coalesce the same way, otherwise a facet or a dependency drilldown +// shows a count while applying it matches zero rows. + +const SPAN_SEMCONV_ALIASES: Record = { "http.method": ["http.method", "http.request.method"], "http.request.method": ["http.method", "http.request.method"], "http.status_code": ["http.status_code", "http.response.status_code"], "http.response.status_code": ["http.status_code", "http.response.status_code"], + "db.system": ["db.system.name", "db.system"], + "db.system.name": ["db.system.name", "db.system"], + "messaging.destination": ["messaging.destination.name", "messaging.destination"], + "messaging.destination.name": ["messaging.destination.name", "messaging.destination"], + "rpc.system": ["rpc.system.name", "rpc.system"], + "rpc.system.name": ["rpc.system.name", "rpc.system"], + "server.address": ["server.address", "http.host"], + "http.host": ["server.address", "http.host"], } satisfies Record /** @@ -117,7 +129,7 @@ export function buildAttrFilterCondition( // `DeploymentEnv` (resource attributes). const aliasTable = mapName === "SpanAttributes" - ? HTTP_SEMCONV_ALIASES + ? SPAN_SEMCONV_ALIASES : mapName === "ResourceAttributes" ? RESOURCE_SEMCONV_ALIASES : undefined diff --git a/packages/ui/src/lib/error-prompt.ts b/packages/ui/src/lib/error-prompt.ts index cbff4c7906..0708ccd2e8 100644 --- a/packages/ui/src/lib/error-prompt.ts +++ b/packages/ui/src/lib/error-prompt.ts @@ -10,19 +10,27 @@ export interface ErrorPromptInput { attributes?: Record } +// Current semconv key first, its deprecated spelling after, so spans from +// either generation of instrumentation keep their context. const RELEVANT_KEYS = [ + "http.request.method", "http.method", + "url.full", "http.url", "http.route", + "http.response.status_code", "http.status_code", "db.system.name", "db.system", "db.query.text", "db.statement", + "rpc.system.name", "rpc.method", "rpc.service", "messaging.system", + "messaging.operation.type", "messaging.operation", + "messaging.destination.name", ] export function formatErrorPrompt({ message, serviceName, operation, attributes }: ErrorPromptInput): string { diff --git a/packages/ui/src/lib/log-attributes.ts b/packages/ui/src/lib/log-attributes.ts index 46bbe28a62..00e99300b5 100644 --- a/packages/ui/src/lib/log-attributes.ts +++ b/packages/ui/src/lib/log-attributes.ts @@ -49,7 +49,7 @@ const PROMOTED_RESOURCE_KEYS = new Set([ function scoreKey(key: string): number { if (key === "error" || key === "exception" || key.startsWith("exception.")) return 100 if (key === "http.status_code" || key === "http.response.status_code") return 95 - if (key === "rpc.grpc.status_code") return 90 + if (key === "rpc.grpc.status_code" || key === "rpc.response.status_code") return 90 if (key === "http.method" || key === "http.request.method") return 80 if ( key === "db.system" || @@ -60,10 +60,10 @@ function scoreKey(key: string): number { key === "db.operation.name" ) return 70 - if (key === "rpc.service" || key === "rpc.method") return 68 + if (key === "rpc.system.name" || key === "rpc.service" || key === "rpc.method") return 68 if (key === "user.id" || key === "enduser.id" || key === "customer_id" || key === "customer.id") return 66 if (key === "duration_ms" || key === "latency_ms" || key === "http.duration") return 60 - if (key === "http.url" || key === "http.route" || key === "url.path") return 55 + if (key === "http.url" || key === "url.full" || key === "http.route" || key === "url.path") return 55 if (key.startsWith("http.") || key.startsWith("url.")) return 40 if (key.startsWith("db.")) return 38 if (key.startsWith("rpc.")) return 36 @@ -104,8 +104,17 @@ export function getChipTone(key: string, value: string, severityText: string): C if (Number.isFinite(n) && n !== 0) return "error" } + // The current key carries the status name ("OK", "UNAVAILABLE") instead of a number. + if (key === "rpc.response.status_code" && value !== "" && value.toUpperCase() !== "OK") return "error" + if (key === "http.method" || key === "http.request.method") return "info" - if (key === "db.system" || key === "db.system.name" || key === "rpc.service" || key === "rpc.method") + if ( + key === "db.system" || + key === "db.system.name" || + key === "rpc.system.name" || + key === "rpc.service" || + key === "rpc.method" + ) return "info" if (rowIsError) return "muted" From b5b57dd607e833a5badc1970c08eb54a67d9d5c8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 30 Sep 2026 23:43:23 +0200 Subject: [PATCH 2/6] fix(web): drill dependency edges named by their system or by url.authority The service map names a messaging edge by its system when the span has no destination, and an http edge from server.address, then http.host, then url.authority. The drills only handled the first spelling of each, so those rows opened an empty trace list. Messaging edges named by the system now drill on messaging.system, and the server.address alias covers url.authority in the rollup's order. The drill builder moves to dependency-drill.ts with tests, including one that every drill parses without a dropped clause. --- .../services/dependency-drill.test.ts | 47 +++++++++++++++++++ .../components/services/dependency-drill.ts | 27 +++++++++++ .../services/service-dependencies-tab.tsx | 13 +---- packages/query-engine/src/ch/ch.test.ts | 11 ++++- packages/query-engine/src/traces-shared.ts | 6 +-- 5 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/services/dependency-drill.test.ts create mode 100644 apps/web/src/components/services/dependency-drill.ts diff --git a/apps/web/src/components/services/dependency-drill.test.ts b/apps/web/src/components/services/dependency-drill.test.ts new file mode 100644 index 0000000000..79a24389ca --- /dev/null +++ b/apps/web/src/components/services/dependency-drill.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest" +import { parseWhereClause } from "@maple/domain/where-clause" +import { dependencyDrillWhereClause } from "./dependency-drill" + +describe("dependencyDrillWhereClause", () => { + it("drills a messaging destination on the current key", () => { + expect(dependencyDrillWhereClause("messaging", "orders", "kafka")).toBe( + `SpanKind = 'Producer' AND messaging.destination.name = "orders"`, + ) + }) + + it("drills a messaging edge named by its system on messaging.system", () => { + expect(dependencyDrillWhereClause("messaging", "kafka", "kafka")).toBe( + `SpanKind = 'Producer' AND messaging.system = "kafka"`, + ) + }) + + it("drills an rpc service, or the system when the edge is named by it", () => { + expect(dependencyDrillWhereClause("rpc", "checkout.Cart", "grpc")).toBe( + `SpanKind = 'Client' AND rpc.service = "checkout.Cart"`, + ) + expect(dependencyDrillWhereClause("rpc", "grpc", "grpc")).toBe( + `SpanKind = 'Client' AND rpc.system.name = "grpc"`, + ) + }) + + it("drills an http edge on server.address", () => { + expect(dependencyDrillWhereClause("http", "api.stripe.test", "")).toBe( + `SpanKind = 'Client' AND server.address = "api.stripe.test"`, + ) + }) + + // The parser drops `(a OR b)` groups with a warning; a drill that relied on + // one silently filtered on SpanKind alone. + it("parses every drill into clauses without warnings", () => { + for (const [kind, target, system] of [ + ["messaging", "orders", "kafka"], + ["messaging", "kafka", "kafka"], + ["rpc", "grpc", "grpc"], + ["http", "api.stripe.test", ""], + ] as const) { + const parsed = parseWhereClause(dependencyDrillWhereClause(kind, target, system)) + expect(parsed.warnings).toEqual([]) + expect(parsed.clauses).toHaveLength(2) + } + }) +}) diff --git a/apps/web/src/components/services/dependency-drill.ts b/apps/web/src/components/services/dependency-drill.ts new file mode 100644 index 0000000000..7423dd41c0 --- /dev/null +++ b/apps/web/src/components/services/dependency-drill.ts @@ -0,0 +1,27 @@ +import { quoteWhereValue } from "@maple/domain/where-clause" + +/** + * Where-clause that drills from an external dependency edge to its spans. + * + * Mirrors how `service_external_edges_hourly_mv` names `TargetName`: messaging + * and rpc fall back to the system when the destination or service is absent, + * http falls back from `server.address` to `http.host` to `url.authority`. The + * where-clause parser drops `(a OR b)` groups, so each drill is one key; the + * query engine's span aliases match every spelling that key stands for. + */ +export function dependencyDrillWhereClause( + kind: "messaging" | "rpc" | "http", + target: string, + system: string, +): string { + const value = quoteWhereValue(target) + const namedBySystem = system !== "" && system === target + switch (kind) { + case "messaging": + return `SpanKind = 'Producer' AND ${namedBySystem ? "messaging.system" : "messaging.destination.name"} = ${value}` + case "rpc": + return `SpanKind = 'Client' AND ${namedBySystem ? "rpc.system.name" : "rpc.service"} = ${value}` + case "http": + return `SpanKind = 'Client' AND server.address = ${value}` + } +} diff --git a/apps/web/src/components/services/service-dependencies-tab.tsx b/apps/web/src/components/services/service-dependencies-tab.tsx index 9f3869e216..69fcebdb48 100644 --- a/apps/web/src/components/services/service-dependencies-tab.tsx +++ b/apps/web/src/components/services/service-dependencies-tab.tsx @@ -10,6 +10,7 @@ import { normalizeTimestampInput } from "@/lib/timezone-format" import { DependencyTable, type DependencyRow } from "./dependency-table" import type { DependencyKind } from "./dependency-type-badge" import { quoteWhereValue } from "@maple/domain/where-clause" +import { dependencyDrillWhereClause } from "./dependency-drill" interface ServiceDependenciesTabProps { serviceName: string @@ -160,17 +161,7 @@ export function ServiceDependenciesTab({ const callCount = Number(edge.callCount ?? 0) const estimated = Number(edge.estimatedCallCount ?? callCount) const system = edge.targetSystem ? String(edge.targetSystem) : "" - // The where-clause parser drops `(a OR b)` groups, so each drill is one - // aliased key (the query engine matches both semconv spellings). An rpc - // TargetName falls back to the system when rpc.service is absent. - const whereClause = - kind === "messaging" - ? `SpanKind = 'Producer' AND messaging.destination.name = ${quoteWhereValue(target)}` - : kind === "rpc" - ? system === target - ? `SpanKind = 'Client' AND rpc.system.name = ${quoteWhereValue(target)}` - : `SpanKind = 'Client' AND rpc.service = ${quoteWhereValue(target)}` - : `SpanKind = 'Client' AND server.address = ${quoteWhereValue(target)}` + const whereClause = dependencyDrillWhereClause(kind, target, system) out.push({ id: `${kind}:${target}`, diff --git a/packages/query-engine/src/ch/ch.test.ts b/packages/query-engine/src/ch/ch.test.ts index a98b731a6e..2587d56a43 100644 --- a/packages/query-engine/src/ch/ch.test.ts +++ b/packages/query-engine/src/ch/ch.test.ts @@ -761,13 +761,22 @@ describe("tracesBreakdownQuery", () => { ) }) + it("matches every http target the service map names when filtering on server.address", () => { + const q = tracesListQuery({ + attributeFilters: [{ key: "server.address", value: "x", mode: "equals" }], + }) + const { sql } = compileUnsafe(q, baseParams) + expect(sql).toContain( + "if(SpanAttributes['server.address'] != '', SpanAttributes['server.address'], if(SpanAttributes['http.host'] != '', SpanAttributes['http.host'], SpanAttributes['url.authority'])) = 'x'", + ) + }) + // Dependency drilldowns and dashboard templates filter on these keys; the // service-map rollups already coalesce them, so the raw filter has to too. for (const [key, canonical, legacy] of [ ["db.system", "db.system.name", "db.system"], ["messaging.destination.name", "messaging.destination.name", "messaging.destination"], ["rpc.system.name", "rpc.system.name", "rpc.system"], - ["server.address", "server.address", "http.host"], ] as const) { it(`matches both semconv spellings when filtering on ${key}`, () => { const q = tracesListQuery({ attributeFilters: [{ key, value: "x", mode: "equals" }] }) diff --git a/packages/query-engine/src/traces-shared.ts b/packages/query-engine/src/traces-shared.ts index bd740cee2d..4066d26b33 100644 --- a/packages/query-engine/src/traces-shared.ts +++ b/packages/query-engine/src/traces-shared.ts @@ -48,7 +48,7 @@ import * as T from "@maple-dev/effect-clickhouse/types" // db.system → db.system.name // messaging.destination → messaging.destination.name // rpc.system → rpc.system.name -// http.host → server.address +// http.host → server.address (and url.authority, the service map's last fallback) // `trace_list_mv` and the service-map rollups coalesce both spellings when they // pre-extract their columns (see materializations.ts), so facet counts and edge // targets cover spans that use *either* key. Filters that read the raw `traces` @@ -66,8 +66,8 @@ const SPAN_SEMCONV_ALIASES: Record = { "messaging.destination.name": ["messaging.destination.name", "messaging.destination"], "rpc.system": ["rpc.system.name", "rpc.system"], "rpc.system.name": ["rpc.system.name", "rpc.system"], - "server.address": ["server.address", "http.host"], - "http.host": ["server.address", "http.host"], + "server.address": ["server.address", "http.host", "url.authority"], + "http.host": ["server.address", "http.host", "url.authority"], } satisfies Record /** From 1cd252662669c6068218f15e256057bffa2ab7e3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 30 Sep 2026 23:44:15 +0200 Subject: [PATCH 3/6] fix(warehouse): group HTTP method with the same precedence the filters use httpRequestMethodExpr preferred http.request.method, while the span filter aliases and trace_list_mv's HttpMethod prefer http.method. On a span that carries both keys with different values, a group-by bucket and the filter it drills into disagreed. The group-by now compiles to the MV's expression. --- packages/domain/src/tinybird/semconv-renames.ts | 14 +++++++------- packages/query-engine/src/ch/ch.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/domain/src/tinybird/semconv-renames.ts b/packages/domain/src/tinybird/semconv-renames.ts index a4df1f3934..8a389e5957 100644 --- a/packages/domain/src/tinybird/semconv-renames.ts +++ b/packages/domain/src/tinybird/semconv-renames.ts @@ -106,16 +106,16 @@ export function containerRuntimeExpr(resourceAttributes: MapColumnLike): Expr { - return CH.coalesce( - CH.nullIf(spanAttributes.get("http.request.method"), ""), - spanAttributes.get("http.method"), - ) + const legacy = spanAttributes.get("http.method") + return CH.if_(legacy.neq(""), legacy, spanAttributes.get("http.request.method")) } diff --git a/packages/query-engine/src/ch/ch.test.ts b/packages/query-engine/src/ch/ch.test.ts index 2587d56a43..f5b4ec834e 100644 --- a/packages/query-engine/src/ch/ch.test.ts +++ b/packages/query-engine/src/ch/ch.test.ts @@ -735,7 +735,7 @@ describe("tracesBreakdownQuery", () => { expect(sql).toContain("FROM traces") expect(sql).not.toContain("FROM service_overview_spans") expect(sql).toContain( - "coalesce(nullIf(SpanAttributes['http.request.method'], ''), SpanAttributes['http.method']) AS name", + "if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS name", ) }) @@ -757,7 +757,7 @@ describe("tracesBreakdownQuery", () => { }) const { sql } = compileUnsafe(q, { ...baseParams, bucketSeconds: 300 }) expect(sql).toContain( - "coalesce(nullIf(SpanAttributes['http.request.method'], ''), SpanAttributes['http.method'])", + "if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method'])", ) }) From 8ac060b66c49c30deb8590ce3ebebfe194f7c835 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 1 Oct 2026 00:31:47 +0200 Subject: [PATCH 4/6] fix(web): make dependency drilldowns open the spans behind the edge Every drill on the service Dependencies tab opened an empty or unfiltered trace list, and has since the tab shipped: - `SpanKind = 'Client'` is not a where-clause field. The traces page treats unknown keys as span attributes, so it filtered on an attribute named `SpanKind` that no span carries. - The traces page filters root spans unless `root_only = false`, and a client span is almost never a root. - `ILIKE` is not a supported operator, so the service drill's target clause was dropped. Drills now open the span-level list and filter on one aliased target key. Edges named after their messaging or rpc system also require the missing destination or rpc.service, so they no longer match every destination of the system, and the rpc system drill uses the legacy key the rollup reads. Also from review: - A span filter reads the spelling the user typed first, so a saved filter on a legacy key keeps matching spans that dual-emit a different value under the new key. HTTP method and status stay legacy-first to agree with trace_list_mv. - rootHttpMethod and rootHttpStatusCode read both spellings. - A numeric gRPC status of 0 under rpc.response.status_code is not an error. --- .../services/dependency-drill.test.ts | 67 ++++++++----- .../components/services/dependency-drill.ts | 37 +++++-- .../services/service-dependencies-tab.tsx | 5 +- .../domain/src/tinybird/semconv-renames.ts | 10 ++ .../src/product/setup-audit.ts | 2 +- .../src/__sql_baseline__/catalog.sql | 96 +++++++++---------- packages/query-engine/src/ch/ch.test.ts | 13 ++- .../query-engine/src/ch/queries/traces.ts | 14 ++- packages/query-engine/src/traces-shared.ts | 19 ++-- packages/ui/src/lib/log-attributes.ts | 6 +- 10 files changed, 160 insertions(+), 109 deletions(-) diff --git a/apps/web/src/components/services/dependency-drill.test.ts b/apps/web/src/components/services/dependency-drill.test.ts index 79a24389ca..45e8639c23 100644 --- a/apps/web/src/components/services/dependency-drill.test.ts +++ b/apps/web/src/components/services/dependency-drill.test.ts @@ -1,47 +1,62 @@ import { describe, expect, it } from "vitest" -import { parseWhereClause } from "@maple/domain/where-clause" -import { dependencyDrillWhereClause } from "./dependency-drill" +import { parseWhereClause } from "@/lib/traces/advanced-filter-sync" +import { dependencyDrillWhereClause, type DependencyDrillKind } from "./dependency-drill" + +const filtersOf = (kind: DependencyDrillKind, target: string, system: string) => + parseWhereClause(dependencyDrillWhereClause(kind, target, system)) describe("dependencyDrillWhereClause", () => { it("drills a messaging destination on the current key", () => { - expect(dependencyDrillWhereClause("messaging", "orders", "kafka")).toBe( - `SpanKind = 'Producer' AND messaging.destination.name = "orders"`, - ) + expect(filtersOf("messaging", "orders", "kafka").filters.attributeFilters).toEqual([ + { key: "messaging.destination.name", value: "orders" }, + ]) }) - it("drills a messaging edge named by its system on messaging.system", () => { - expect(dependencyDrillWhereClause("messaging", "kafka", "kafka")).toBe( - `SpanKind = 'Producer' AND messaging.system = "kafka"`, - ) + it("drills a messaging edge named by its system to spans without a destination", () => { + expect(filtersOf("messaging", "kafka", "kafka").filters.attributeFilters).toEqual([ + { key: "messaging.system", value: "kafka" }, + { key: "messaging.destination.name", value: "", matchMode: "exists", negated: true }, + ]) }) - it("drills an rpc service, or the system when the edge is named by it", () => { - expect(dependencyDrillWhereClause("rpc", "checkout.Cart", "grpc")).toBe( - `SpanKind = 'Client' AND rpc.service = "checkout.Cart"`, - ) - expect(dependencyDrillWhereClause("rpc", "grpc", "grpc")).toBe( - `SpanKind = 'Client' AND rpc.system.name = "grpc"`, - ) + it("drills an rpc service, or the legacy system key when the edge is named by it", () => { + expect(filtersOf("rpc", "checkout.Cart", "grpc").filters.attributeFilters).toEqual([ + { key: "rpc.service", value: "checkout.Cart" }, + ]) + expect(filtersOf("rpc", "grpc", "grpc").filters.attributeFilters).toEqual([ + { key: "rpc.system", value: "grpc" }, + { key: "rpc.service", value: "", matchMode: "exists", negated: true }, + ]) }) - it("drills an http edge on server.address", () => { - expect(dependencyDrillWhereClause("http", "api.stripe.test", "")).toBe( - `SpanKind = 'Client' AND server.address = "api.stripe.test"`, - ) + it("drills http, database and service edges on their target key", () => { + expect(filtersOf("http", "api.stripe.test", "").filters.attributeFilters).toEqual([ + { key: "server.address", value: "api.stripe.test" }, + ]) + expect(filtersOf("database", "postgresql", "").filters.attributeFilters).toEqual([ + { key: "db.system.name", value: "postgresql" }, + ]) + expect(filtersOf("service", "billing", "").filters.attributeFilters).toEqual([ + { key: "server.address", value: "billing", matchMode: "contains" }, + ]) }) - // The parser drops `(a OR b)` groups with a warning; a drill that relied on - // one silently filtered on SpanKind alone. - it("parses every drill into clauses without warnings", () => { + // A client span is almost never a trace root, and the traces page filters + // roots unless told otherwise. A dropped clause or a `SpanKind` pseudo-attribute + // used to leave every drill on an empty or unfiltered list. + it("opens the span-level list with no dropped clauses or pseudo-attributes", () => { for (const [kind, target, system] of [ + ["service", "billing", ""], + ["database", "postgresql", ""], ["messaging", "orders", "kafka"], ["messaging", "kafka", "kafka"], ["rpc", "grpc", "grpc"], ["http", "api.stripe.test", ""], ] as const) { - const parsed = parseWhereClause(dependencyDrillWhereClause(kind, target, system)) - expect(parsed.warnings).toEqual([]) - expect(parsed.clauses).toHaveLength(2) + const { filters, warnings } = filtersOf(kind, target, system) + expect(warnings).toEqual([]) + expect(filters.rootOnly).toBe(false) + expect(filters.attributeFilters.map((f) => f.key)).not.toContain("SpanKind") } }) }) diff --git a/apps/web/src/components/services/dependency-drill.ts b/apps/web/src/components/services/dependency-drill.ts index 7423dd41c0..7c44ed235f 100644 --- a/apps/web/src/components/services/dependency-drill.ts +++ b/apps/web/src/components/services/dependency-drill.ts @@ -1,27 +1,44 @@ import { quoteWhereValue } from "@maple/domain/where-clause" +export type DependencyDrillKind = "service" | "database" | "messaging" | "rpc" | "http" + /** - * Where-clause that drills from an external dependency edge to its spans. + * Where-clause that drills from a dependency edge to the spans behind it. + * + * The traces page filters root spans unless `root_only = false`, and a client + * span is almost never a root, so every drill opens the span-level list. There + * is no span-kind filter (a `SpanKind = ...` clause becomes a span-attribute + * filter that matches nothing), and the parser drops `(a OR b)` groups, so each + * drill is one aliased key that the query engine matches under every spelling. * - * Mirrors how `service_external_edges_hourly_mv` names `TargetName`: messaging - * and rpc fall back to the system when the destination or service is absent, - * http falls back from `server.address` to `http.host` to `url.authority`. The - * where-clause parser drops `(a OR b)` groups, so each drill is one key; the - * query engine's span aliases match every spelling that key stands for. + * Targets mirror how the edge rollups name them: messaging and rpc fall back to + * the system when the destination or `rpc.service` is absent, so those drills + * also require the absence, otherwise they would match every destination of the + * system. The rpc system uses the legacy key because that is what the rollup + * reads today. */ export function dependencyDrillWhereClause( - kind: "messaging" | "rpc" | "http", + kind: DependencyDrillKind, target: string, system: string, ): string { const value = quoteWhereValue(target) const namedBySystem = system !== "" && system === target + const spans = (...clauses: string[]) => ["root_only = false", ...clauses].join(" AND ") switch (kind) { + case "service": + return spans(`server.address contains ${value}`) + case "database": + return spans(`db.system.name = ${value}`) case "messaging": - return `SpanKind = 'Producer' AND ${namedBySystem ? "messaging.system" : "messaging.destination.name"} = ${value}` + return namedBySystem + ? spans(`messaging.system = ${value}`, "messaging.destination.name !exists") + : spans(`messaging.destination.name = ${value}`) case "rpc": - return `SpanKind = 'Client' AND ${namedBySystem ? "rpc.system.name" : "rpc.service"} = ${value}` + return namedBySystem + ? spans(`rpc.system = ${value}`, "rpc.service !exists") + : spans(`rpc.service = ${value}`) case "http": - return `SpanKind = 'Client' AND server.address = ${value}` + return spans(`server.address = ${value}`) } } diff --git a/apps/web/src/components/services/service-dependencies-tab.tsx b/apps/web/src/components/services/service-dependencies-tab.tsx index 69fcebdb48..d6fa11b1da 100644 --- a/apps/web/src/components/services/service-dependencies-tab.tsx +++ b/apps/web/src/components/services/service-dependencies-tab.tsx @@ -9,7 +9,6 @@ import { formatLatency } from "@maple/ui/lib/format" import { normalizeTimestampInput } from "@/lib/timezone-format" import { DependencyTable, type DependencyRow } from "./dependency-table" import type { DependencyKind } from "./dependency-type-badge" -import { quoteWhereValue } from "@maple/domain/where-clause" import { dependencyDrillWhereClause } from "./dependency-drill" interface ServiceDependenciesTabProps { @@ -119,7 +118,7 @@ export function ServiceDependenciesTab({ p95DurationMs: Number(edge.p95DurationMs ?? 0), hasSampling: Boolean(edge.hasSampling), samplingWeight: Number(edge.samplingWeight ?? 1), - whereClause: `SpanKind = 'Client' AND server.address ILIKE ${quoteWhereValue(`%${target}%`)}`, + whereClause: dependencyDrillWhereClause("service", target, ""), }) } @@ -149,7 +148,7 @@ export function ServiceDependenciesTab({ p95DurationMs: Number(edge.p95DurationMs ?? 0), hasSampling: Boolean(edge.hasSampling), samplingWeight: Number(edge.samplingWeight ?? 1), - whereClause: `SpanKind = 'Client' AND db.system.name = ${quoteWhereValue(target)}`, + whereClause: dependencyDrillWhereClause("database", target, ""), }) } diff --git a/packages/domain/src/tinybird/semconv-renames.ts b/packages/domain/src/tinybird/semconv-renames.ts index 8a389e5957..c8236804c2 100644 --- a/packages/domain/src/tinybird/semconv-renames.ts +++ b/packages/domain/src/tinybird/semconv-renames.ts @@ -119,3 +119,13 @@ export function httpRequestMethodExpr(spanAttributes: MapColumnLike): Expr { + const legacy = spanAttributes.get("http.status_code") + return CH.if_(legacy.neq(""), legacy, spanAttributes.get("http.response.status_code")) +} diff --git a/packages/query-engine-integrations/src/product/setup-audit.ts b/packages/query-engine-integrations/src/product/setup-audit.ts index a613d90938..bff750e7ee 100644 --- a/packages/query-engine-integrations/src/product/setup-audit.ts +++ b/packages/query-engine-integrations/src/product/setup-audit.ts @@ -300,7 +300,7 @@ export const auditPeerValueRowSchema = Schema.Struct({ /** * The distinct values behind each dependency-naming key. Case-collision detection (`tinybird` vs - * `Tinybird`) happens app-side; the query just enumerates. Restricted to five keys, so this is a + * `Tinybird`) happens app-side; the query just enumerates. Restricted to seven keys, so this is a * bounded read of `attribute_values_hourly` rather than an attribute-value scan. */ export function auditPeerValueInventoryQuery(opts: { limit?: number } = {}) { diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 7cc21615e9..1231252fe4 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -8564,7 +8564,7 @@ SELECT OFFSET 0 FORMAT JSON --- pipe:list_traces:contains-match:baseline [40cb839e] +-- pipe:list_traces:contains-match:baseline [e1c7d3ae] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8577,9 +8577,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8602,7 +8602,7 @@ SELECT LIMIT 100 FORMAT JSON --- pipe:list_traces:contains-match:bloom [40cb839e] +-- pipe:list_traces:contains-match:bloom [e1c7d3ae] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8615,9 +8615,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8640,7 +8640,7 @@ SELECT LIMIT 100 FORMAT JSON --- pipe:list_traces:contains-match:text [40cb839e] +-- pipe:list_traces:contains-match:text [e1c7d3ae] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8653,9 +8653,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8678,7 +8678,7 @@ SELECT LIMIT 100 FORMAT JSON --- pipe:list_traces:default:baseline [81ae2912] +-- pipe:list_traces:default:baseline [5b987fa2] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8691,9 +8691,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8714,7 +8714,7 @@ SELECT LIMIT 100 FORMAT JSON --- pipe:list_traces:default:bloom [81ae2912] +-- pipe:list_traces:default:bloom [5b987fa2] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8727,9 +8727,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8750,7 +8750,7 @@ SELECT LIMIT 100 FORMAT JSON --- pipe:list_traces:default:text [81ae2912] +-- pipe:list_traces:default:text [5b987fa2] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8763,9 +8763,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8786,7 +8786,7 @@ SELECT LIMIT 100 FORMAT JSON --- pipe:list_traces:filtered:baseline [3ad9d1ec] +-- pipe:list_traces:filtered:baseline [8cdab2fc] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8799,9 +8799,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8838,7 +8838,7 @@ SELECT LIMIT 25 FORMAT JSON --- pipe:list_traces:filtered:bloom [2d366eb8] +-- pipe:list_traces:filtered:bloom [08b94a48] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8851,9 +8851,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -8890,7 +8890,7 @@ SELECT LIMIT 25 FORMAT JSON --- pipe:list_traces:filtered:text [ff504ae4] +-- pipe:list_traces:filtered:text [3cbe65b4] SELECT TraceId AS traceId, Timestamp AS startTime, @@ -8903,9 +8903,9 @@ SELECT SpanKind AS rootSpanKind, StatusCode AS rootSpanStatusCode, StatusMessage AS rootSpanStatusMessage, - SpanAttributes['http.method'] AS rootHttpMethod, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS rootHttpMethod, SpanAttributes['http.route'] AS rootHttpRoute, - SpanAttributes['http.status_code'] AS rootHttpStatusCode, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS rootHttpStatusCode, toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])) AS rootSpanAttributes, if(StatusCode = 'Error', 1, 0) AS hasError FROM traces @@ -13518,7 +13518,7 @@ SELECT ) AS errorCount_tiers FORMAT JSON --- spec:traces-list-grouped-attr-fallback:baseline [6b9a4329] +-- spec:traces-list-grouped-attr-fallback:baseline [17176ec5] SELECT TraceId AS traceId, argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, @@ -13531,9 +13531,9 @@ SELECT argMin(SpanName, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanName, argMin(SpanKind, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanKind, argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanStatusCode, - argMin(SpanAttributes['http.method'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, + argMin(if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, argMin(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, - argMin(SpanAttributes['http.status_code'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, + argMin(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, argMin(toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanAttributes, if(argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) = 'Error', 1, 0) AS hasError FROM trace_detail_spans @@ -13559,7 +13559,7 @@ SELECT LIMIT 50 FORMAT JSON --- spec:traces-list-grouped-attr-fallback:bloom [b78292d3] +-- spec:traces-list-grouped-attr-fallback:bloom [89660e07] SELECT TraceId AS traceId, argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, @@ -13572,9 +13572,9 @@ SELECT argMin(SpanName, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanName, argMin(SpanKind, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanKind, argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanStatusCode, - argMin(SpanAttributes['http.method'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, + argMin(if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, argMin(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, - argMin(SpanAttributes['http.status_code'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, + argMin(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, argMin(toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanAttributes, if(argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) = 'Error', 1, 0) AS hasError FROM trace_detail_spans @@ -13600,7 +13600,7 @@ SELECT LIMIT 50 FORMAT JSON --- spec:traces-list-grouped-attr-fallback:text [b34bb97f] +-- spec:traces-list-grouped-attr-fallback:text [948dcec3] SELECT TraceId AS traceId, argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, @@ -13613,9 +13613,9 @@ SELECT argMin(SpanName, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanName, argMin(SpanKind, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanKind, argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanStatusCode, - argMin(SpanAttributes['http.method'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, + argMin(if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, argMin(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, - argMin(SpanAttributes['http.status_code'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, + argMin(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, argMin(toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanAttributes, if(argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) = 'Error', 1, 0) AS hasError FROM trace_detail_spans @@ -13641,7 +13641,7 @@ SELECT LIMIT 50 FORMAT JSON --- spec:traces-list-grouped-duration-sort:baseline [093a8beb] +-- spec:traces-list-grouped-duration-sort:baseline [a37a427f] SELECT TraceId AS traceId, argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, @@ -13654,9 +13654,9 @@ SELECT argMin(SpanName, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanName, argMin(SpanKind, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanKind, argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanStatusCode, - argMin(SpanAttributes['http.method'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, + argMin(if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, argMin(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, - argMin(SpanAttributes['http.status_code'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, + argMin(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, argMin(toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanAttributes, if(argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) = 'Error', 1, 0) AS hasError FROM trace_detail_spans @@ -13681,7 +13681,7 @@ SELECT LIMIT 50 FORMAT JSON --- spec:traces-list-grouped:baseline [b3702242] +-- spec:traces-list-grouped:baseline [f4908fbe] SELECT TraceId AS traceId, argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, @@ -13694,9 +13694,9 @@ SELECT argMin(SpanName, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanName, argMin(SpanKind, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanKind, argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanStatusCode, - argMin(SpanAttributes['http.method'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, + argMin(if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, argMin(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, - argMin(SpanAttributes['http.status_code'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, + argMin(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, argMin(toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanAttributes, if(argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) = 'Error', 1, 0) AS hasError FROM trace_detail_spans @@ -13720,7 +13720,7 @@ SELECT LIMIT 50 FORMAT JSON --- spec:traces-list-grouped:bloom [b3702242] +-- spec:traces-list-grouped:bloom [f4908fbe] SELECT TraceId AS traceId, argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, @@ -13733,9 +13733,9 @@ SELECT argMin(SpanName, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanName, argMin(SpanKind, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanKind, argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanStatusCode, - argMin(SpanAttributes['http.method'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, + argMin(if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, argMin(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, - argMin(SpanAttributes['http.status_code'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, + argMin(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, argMin(toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanAttributes, if(argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) = 'Error', 1, 0) AS hasError FROM trace_detail_spans @@ -13759,7 +13759,7 @@ SELECT LIMIT 50 FORMAT JSON --- spec:traces-list-grouped:text [b3702242] +-- spec:traces-list-grouped:text [f4908fbe] SELECT TraceId AS traceId, argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, @@ -13772,9 +13772,9 @@ SELECT argMin(SpanName, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanName, argMin(SpanKind, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanKind, argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanStatusCode, - argMin(SpanAttributes['http.method'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, + argMin(if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpMethod, argMin(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, - argMin(SpanAttributes['http.status_code'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, + argMin(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpStatusCode, argMin(toJSONString(map('http.method', SpanAttributes['http.method'], 'http.request.method', SpanAttributes['http.request.method'], 'http.route', SpanAttributes['http.route'], 'http.target', SpanAttributes['http.target'], 'http.status_code', SpanAttributes['http.status_code'], 'http.response.status_code', SpanAttributes['http.response.status_code'], 'http.url', SpanAttributes['http.url'], 'url.full', SpanAttributes['url.full'], 'url.path', SpanAttributes['url.path'], 'server.address', SpanAttributes['server.address'], 'net.peer.name', SpanAttributes['net.peer.name'], 'screen.name', SpanAttributes['screen.name'])), (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootSpanAttributes, if(argMin(StatusCode, (if(ParentSpanId = '', 0, 1), Timestamp)) = 'Error', 1, 0) AS hasError FROM trace_detail_spans diff --git a/packages/query-engine/src/ch/ch.test.ts b/packages/query-engine/src/ch/ch.test.ts index f5b4ec834e..8a0355591c 100644 --- a/packages/query-engine/src/ch/ch.test.ts +++ b/packages/query-engine/src/ch/ch.test.ts @@ -771,18 +771,21 @@ describe("tracesBreakdownQuery", () => { ) }) - // Dependency drilldowns and dashboard templates filter on these keys; the - // service-map rollups already coalesce them, so the raw filter has to too. - for (const [key, canonical, legacy] of [ - ["db.system", "db.system.name", "db.system"], + // Dependency drilldowns and dashboard templates filter on these keys. The + // spelling the user typed is read first, so a saved legacy filter keeps its + // meaning on spans that dual-emit a different value under the new key. + for (const [key, first, second] of [ + ["db.system", "db.system", "db.system.name"], + ["db.system.name", "db.system.name", "db.system"], ["messaging.destination.name", "messaging.destination.name", "messaging.destination"], + ["rpc.system", "rpc.system", "rpc.system.name"], ["rpc.system.name", "rpc.system.name", "rpc.system"], ] as const) { it(`matches both semconv spellings when filtering on ${key}`, () => { const q = tracesListQuery({ attributeFilters: [{ key, value: "x", mode: "equals" }] }) const { sql } = compileUnsafe(q, baseParams) expect(sql).toContain( - `if(SpanAttributes['${canonical}'] != '', SpanAttributes['${canonical}'], SpanAttributes['${legacy}']) = 'x'`, + `if(SpanAttributes['${first}'] != '', SpanAttributes['${first}'], SpanAttributes['${second}']) = 'x'`, ) }) } diff --git a/packages/query-engine/src/ch/queries/traces.ts b/packages/query-engine/src/ch/queries/traces.ts index 05e7c19506..93a676e7da 100644 --- a/packages/query-engine/src/ch/queries/traces.ts +++ b/packages/query-engine/src/ch/queries/traces.ts @@ -18,7 +18,11 @@ import { Traces, TracesAggregatesHourly, } from "../tables" -import { deploymentEnvExpr, httpRequestMethodExpr } from "@maple/domain/tinybird/semconv-renames" +import { + deploymentEnvExpr, + httpRequestMethodExpr, + httpResponseStatusCodeExpr, +} from "@maple/domain/tinybird/semconv-renames" import { METRIC_NEEDS } from "../../traces-shared" import type { ColumnDefs } from "@maple-dev/effect-clickhouse/types" import * as T from "@maple-dev/effect-clickhouse/types" @@ -1453,9 +1457,9 @@ export function tracesRootListQuery(opts: TracesRootListOpts) { rootSpanKind: $.SpanKind, rootSpanStatusCode: $.StatusCode, rootSpanStatusMessage: $.StatusMessage, - rootHttpMethod: $.SpanAttributes.get("http.method"), + rootHttpMethod: httpRequestMethodExpr($.SpanAttributes), rootHttpRoute: $.SpanAttributes.get("http.route"), - rootHttpStatusCode: $.SpanAttributes.get("http.status_code"), + rootHttpStatusCode: httpResponseStatusCodeExpr($.SpanAttributes), rootSpanAttributes: CH.toJSONString(buildProjectedMapExpr(ROOT_SPAN_ATTR_KEYS, "SpanAttributes")), hasError: CH.if_($.StatusCode.eq("Error"), CH.lit(1), CH.lit(0)), })) @@ -1759,9 +1763,9 @@ export function traceListQuery(opts: TraceListOpts) { rootSpanName: argMin($.SpanName, rootOrder), rootSpanKind: argMin($.SpanKind, rootOrder), rootSpanStatusCode: argMin($.StatusCode, rootOrder), - rootHttpMethod: argMin($.SpanAttributes.get("http.method"), rootOrder), + rootHttpMethod: argMin(httpRequestMethodExpr($.SpanAttributes), rootOrder), rootHttpRoute: argMin($.SpanAttributes.get("http.route"), rootOrder), - rootHttpStatusCode: argMin($.SpanAttributes.get("http.status_code"), rootOrder), + rootHttpStatusCode: argMin(httpResponseStatusCodeExpr($.SpanAttributes), rootOrder), rootSpanAttributes: argMin( CH.toJSONString(buildProjectedMapExpr(ROOT_SPAN_ATTR_KEYS, "SpanAttributes")), rootOrder, diff --git a/packages/query-engine/src/traces-shared.ts b/packages/query-engine/src/traces-shared.ts index 4066d26b33..88319717fe 100644 --- a/packages/query-engine/src/traces-shared.ts +++ b/packages/query-engine/src/traces-shared.ts @@ -49,25 +49,26 @@ import * as T from "@maple-dev/effect-clickhouse/types" // messaging.destination → messaging.destination.name // rpc.system → rpc.system.name // http.host → server.address (and url.authority, the service map's last fallback) -// `trace_list_mv` and the service-map rollups coalesce both spellings when they -// pre-extract their columns (see materializations.ts), so facet counts and edge -// targets cover spans that use *either* key. Filters that read the raw `traces` -// table must coalesce the same way, otherwise a facet or a dependency drilldown -// shows a count while applying it matches zero rows. +// A filter on either spelling matches spans that carry either key. The key the +// user typed is read first, so a saved filter on a legacy key keeps matching a +// span that dual-emits a different value under the new key (`db.system=mssql` +// next to `db.system.name=microsoft.sql_server`). HTTP method and status are +// legacy-first under both spellings because that is how `trace_list_mv` +// pre-extracts them, and the facet counts must agree with the filter. const SPAN_SEMCONV_ALIASES: Record = { "http.method": ["http.method", "http.request.method"], "http.request.method": ["http.method", "http.request.method"], "http.status_code": ["http.status_code", "http.response.status_code"], "http.response.status_code": ["http.status_code", "http.response.status_code"], - "db.system": ["db.system.name", "db.system"], + "db.system": ["db.system", "db.system.name"], "db.system.name": ["db.system.name", "db.system"], - "messaging.destination": ["messaging.destination.name", "messaging.destination"], + "messaging.destination": ["messaging.destination", "messaging.destination.name"], "messaging.destination.name": ["messaging.destination.name", "messaging.destination"], - "rpc.system": ["rpc.system.name", "rpc.system"], + "rpc.system": ["rpc.system", "rpc.system.name"], "rpc.system.name": ["rpc.system.name", "rpc.system"], "server.address": ["server.address", "http.host", "url.authority"], - "http.host": ["server.address", "http.host", "url.authority"], + "http.host": ["http.host", "server.address", "url.authority"], } satisfies Record /** diff --git a/packages/ui/src/lib/log-attributes.ts b/packages/ui/src/lib/log-attributes.ts index 00e99300b5..e6cbe67f43 100644 --- a/packages/ui/src/lib/log-attributes.ts +++ b/packages/ui/src/lib/log-attributes.ts @@ -104,8 +104,10 @@ export function getChipTone(key: string, value: string, severityText: string): C if (Number.isFinite(n) && n !== 0) return "error" } - // The current key carries the status name ("OK", "UNAVAILABLE") instead of a number. - if (key === "rpc.response.status_code" && value !== "" && value.toUpperCase() !== "OK") return "error" + // The current key carries the status name ("OK", "UNAVAILABLE"); some instrumentation + // still writes the numeric gRPC code, where 0 is OK. + if (key === "rpc.response.status_code" && value !== "" && value !== "0" && value.toUpperCase() !== "OK") + return "error" if (key === "http.method" || key === "http.request.method") return "info" if ( From 717ed9141e24b43347834a0e2d86949f4cfb2256 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 1 Oct 2026 00:38:55 +0200 Subject: [PATCH 5/6] fix(ui): tone rpc.response.status_code by gRPC semantics Every non-OK value was an error chip. Semconv treats only UNKNOWN, DEADLINE_EXCEEDED, UNIMPLEMENTED, INTERNAL, UNAVAILABLE and DATA_LOSS as errors on a gRPC server span; the chip does not know the span kind, so other gRPC codes are a warning and values from other rpc systems stay neutral. Also documents and pins the one drill gap left: a destination or rpc.service named after its system shares an edge with the fallback spans, and the drill reaches only the fallback half because the where-clause has no OR. --- .../services/dependency-drill.test.ts | 12 +++++++ .../components/services/dependency-drill.ts | 5 +++ packages/ui/src/lib/log-attributes.test.ts | 20 +++++++++++ packages/ui/src/lib/log-attributes.ts | 35 ++++++++++++++++--- 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/lib/log-attributes.test.ts diff --git a/apps/web/src/components/services/dependency-drill.test.ts b/apps/web/src/components/services/dependency-drill.test.ts index 45e8639c23..4467959cda 100644 --- a/apps/web/src/components/services/dependency-drill.test.ts +++ b/apps/web/src/components/services/dependency-drill.test.ts @@ -19,6 +19,18 @@ describe("dependencyDrillWhereClause", () => { ]) }) + // The rollup merges `destination = kafka` spans into the same edge as the + // fallback spans; without OR in the where-clause only the fallback half is + // reachable. Pinned so a parser that gains OR support flips this on purpose. + it("drills only the fallback spans when a destination shares its system's name", () => { + expect(filtersOf("messaging", "kafka", "kafka").filters.attributeFilters).toContainEqual({ + key: "messaging.destination.name", + value: "", + matchMode: "exists", + negated: true, + }) + }) + it("drills an rpc service, or the legacy system key when the edge is named by it", () => { expect(filtersOf("rpc", "checkout.Cart", "grpc").filters.attributeFilters).toEqual([ { key: "rpc.service", value: "checkout.Cart" }, diff --git a/apps/web/src/components/services/dependency-drill.ts b/apps/web/src/components/services/dependency-drill.ts index 7c44ed235f..73f829840a 100644 --- a/apps/web/src/components/services/dependency-drill.ts +++ b/apps/web/src/components/services/dependency-drill.ts @@ -16,6 +16,11 @@ export type DependencyDrillKind = "service" | "database" | "messaging" | "rpc" | * also require the absence, otherwise they would match every destination of the * system. The rpc system uses the legacy key because that is what the rollup * reads today. + * + * Known gap: when a destination or rpc.service is literally named after its + * system, the rollup merges those spans and the fallback spans into one edge. + * Matching both needs an OR the where-clause parser does not support, so the + * drill shows only the fallback spans. */ export function dependencyDrillWhereClause( kind: DependencyDrillKind, diff --git a/packages/ui/src/lib/log-attributes.test.ts b/packages/ui/src/lib/log-attributes.test.ts new file mode 100644 index 0000000000..3834eddd44 --- /dev/null +++ b/packages/ui/src/lib/log-attributes.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { getChipTone } from "./log-attributes" + +describe("getChipTone for rpc.response.status_code", () => { + it("marks the gRPC codes semconv treats as server errors", () => { + expect(getChipTone("rpc.response.status_code", "UNAVAILABLE", "INFO")).toBe("error") + expect(getChipTone("rpc.response.status_code", "internal", "INFO")).toBe("error") + expect(getChipTone("rpc.response.status_code", "14", "INFO")).toBe("error") + }) + + it("warns on other non-OK gRPC codes and leaves OK alone", () => { + expect(getChipTone("rpc.response.status_code", "NOT_FOUND", "INFO")).toBe("warn") + expect(getChipTone("rpc.response.status_code", "OK", "INFO")).toBe("muted") + expect(getChipTone("rpc.response.status_code", "0", "INFO")).toBe("muted") + }) + + it("keeps values from other rpc systems neutral", () => { + expect(getChipTone("rpc.response.status_code", "-32601", "INFO")).toBe("muted") + }) +}) diff --git a/packages/ui/src/lib/log-attributes.ts b/packages/ui/src/lib/log-attributes.ts index e6cbe67f43..2e78868042 100644 --- a/packages/ui/src/lib/log-attributes.ts +++ b/packages/ui/src/lib/log-attributes.ts @@ -73,6 +73,32 @@ function scoreKey(key: string): number { return 20 } +// `rpc.response.status_code` values are system-specific. gRPC's are status +// names (or their numbers from older instrumentation); semconv counts only these +// as errors on a server span, and the chip does not know the span kind, so the +// other non-OK codes are a warning and any other system's values stay neutral. +const GRPC_SERVER_ERROR_CODES = new Set([ + "UNKNOWN", + "DEADLINE_EXCEEDED", + "UNIMPLEMENTED", + "INTERNAL", + "UNAVAILABLE", + "DATA_LOSS", + "2", + "4", + "12", + "13", + "14", + "15", +]) +const GRPC_CODES = new Set([ + ...GRPC_SERVER_ERROR_CODES, + ..."OK CANCELLED INVALID_ARGUMENT NOT_FOUND ALREADY_EXISTS PERMISSION_DENIED RESOURCE_EXHAUSTED FAILED_PRECONDITION ABORTED OUT_OF_RANGE UNAUTHENTICATED".split( + " ", + ), + ..."0 1 3 5 6 7 8 9 10 11 16".split(" "), +]) + function isNumericStatus(value: string): number | null { const n = Number(value) return Number.isInteger(n) && n >= 100 && n < 600 ? n : null @@ -104,10 +130,11 @@ export function getChipTone(key: string, value: string, severityText: string): C if (Number.isFinite(n) && n !== 0) return "error" } - // The current key carries the status name ("OK", "UNAVAILABLE"); some instrumentation - // still writes the numeric gRPC code, where 0 is OK. - if (key === "rpc.response.status_code" && value !== "" && value !== "0" && value.toUpperCase() !== "OK") - return "error" + if (key === "rpc.response.status_code") { + const code = value.toUpperCase() + if (GRPC_SERVER_ERROR_CODES.has(code)) return "error" + if (GRPC_CODES.has(code) && code !== "OK" && code !== "0") return "warn" + } if (key === "http.method" || key === "http.request.method") return "info" if ( From 0e1ad0e0396ff54db8816047b7229ae5b9322bd8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 1 Oct 2026 00:59:30 +0200 Subject: [PATCH 6/6] fix(ui): tone rpc.response.status_code only for gRPC rows A gRPC-looking value from another rpc system ("2", "INTERNAL") still turned red. pickImportantAttributes now passes the row's rpc.system.name (or legacy rpc.system) to getChipTone, and the gRPC classification applies only when that says grpc; otherwise the status stays neutral. --- packages/ui/src/lib/log-attributes.test.ts | 31 +++++++++++++++------- packages/ui/src/lib/log-attributes.ts | 20 +++++++++----- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/lib/log-attributes.test.ts b/packages/ui/src/lib/log-attributes.test.ts index 3834eddd44..6db024a3b1 100644 --- a/packages/ui/src/lib/log-attributes.test.ts +++ b/packages/ui/src/lib/log-attributes.test.ts @@ -1,20 +1,33 @@ import { describe, expect, it } from "vitest" -import { getChipTone } from "./log-attributes" +import { getChipTone, pickImportantAttributes } from "./log-attributes" describe("getChipTone for rpc.response.status_code", () => { it("marks the gRPC codes semconv treats as server errors", () => { - expect(getChipTone("rpc.response.status_code", "UNAVAILABLE", "INFO")).toBe("error") - expect(getChipTone("rpc.response.status_code", "internal", "INFO")).toBe("error") - expect(getChipTone("rpc.response.status_code", "14", "INFO")).toBe("error") + expect(getChipTone("rpc.response.status_code", "UNAVAILABLE", "INFO", "grpc")).toBe("error") + expect(getChipTone("rpc.response.status_code", "internal", "INFO", "grpc")).toBe("error") + expect(getChipTone("rpc.response.status_code", "14", "INFO", "grpc")).toBe("error") }) it("warns on other non-OK gRPC codes and leaves OK alone", () => { - expect(getChipTone("rpc.response.status_code", "NOT_FOUND", "INFO")).toBe("warn") - expect(getChipTone("rpc.response.status_code", "OK", "INFO")).toBe("muted") - expect(getChipTone("rpc.response.status_code", "0", "INFO")).toBe("muted") + expect(getChipTone("rpc.response.status_code", "NOT_FOUND", "INFO", "grpc")).toBe("warn") + expect(getChipTone("rpc.response.status_code", "OK", "INFO", "grpc")).toBe("muted") + expect(getChipTone("rpc.response.status_code", "0", "INFO", "grpc")).toBe("muted") }) - it("keeps values from other rpc systems neutral", () => { - expect(getChipTone("rpc.response.status_code", "-32601", "INFO")).toBe("muted") + it("keeps values from other or unknown rpc systems neutral", () => { + expect(getChipTone("rpc.response.status_code", "-32601", "INFO", "grpc")).toBe("muted") + expect(getChipTone("rpc.response.status_code", "INTERNAL", "INFO", "jsonrpc")).toBe("muted") + expect(getChipTone("rpc.response.status_code", "2", "INFO")).toBe("muted") + }) + + it("reads the rpc system from the row for the inline chips", () => { + const log = { + logAttributes: { "rpc.system.name": "grpc", "rpc.response.status_code": "UNAVAILABLE" }, + resourceAttributes: {}, + serviceName: "api", + severityText: "INFO", + } + const status = pickImportantAttributes(log).find((a) => a.key === "rpc.response.status_code") + expect(status?.tone).toBe("error") }) }) diff --git a/packages/ui/src/lib/log-attributes.ts b/packages/ui/src/lib/log-attributes.ts index 2e78868042..1f40325efb 100644 --- a/packages/ui/src/lib/log-attributes.ts +++ b/packages/ui/src/lib/log-attributes.ts @@ -73,10 +73,11 @@ function scoreKey(key: string): number { return 20 } -// `rpc.response.status_code` values are system-specific. gRPC's are status -// names (or their numbers from older instrumentation); semconv counts only these -// as errors on a server span, and the chip does not know the span kind, so the -// other non-OK codes are a warning and any other system's values stay neutral. +// `rpc.response.status_code` values are system-specific, so they are only toned +// when the row says the system is gRPC. gRPC's are status names (or their +// numbers from older instrumentation); semconv counts only these as errors on a +// server span, and the chip does not know the span kind, so the other non-OK +// codes are a warning. const GRPC_SERVER_ERROR_CODES = new Set([ "UNKNOWN", "DEADLINE_EXCEEDED", @@ -104,7 +105,11 @@ function isNumericStatus(value: string): number | null { return Number.isInteger(n) && n >= 100 && n < 600 ? n : null } -export function getChipTone(key: string, value: string, severityText: string): ChipTone { +/** + * `rpcSystem` is the row's `rpc.system.name` (or legacy `rpc.system`); without + * it `rpc.response.status_code` stays neutral. + */ +export function getChipTone(key: string, value: string, severityText: string, rpcSystem?: string): ChipTone { const sev = severityText.toUpperCase() const rowIsError = sev === "ERROR" || sev === "FATAL" @@ -130,7 +135,7 @@ export function getChipTone(key: string, value: string, severityText: string): C if (Number.isFinite(n) && n !== 0) return "error" } - if (key === "rpc.response.status_code") { + if (key === "rpc.response.status_code" && rpcSystem?.toLowerCase() === "grpc") { const code = value.toUpperCase() if (GRPC_SERVER_ERROR_CODES.has(code)) return "error" if (GRPC_CODES.has(code) && code !== "OK" && code !== "0") return "warn" @@ -156,6 +161,7 @@ function shouldSkip(key: string): boolean { } export function pickImportantAttributes(log: LogLike, limit = 4): PickedAttribute[] { + const rpcSystem = log.logAttributes["rpc.system.name"] || log.logAttributes["rpc.system"] const serviceNameLower = log.serviceName.toLowerCase() const scored: Array<{ key: string; value: string; score: number; source: "log" | "resource" }> = [] @@ -179,7 +185,7 @@ export function pickImportantAttributes(log: LogLike, limit = 4): PickedAttribut return scored.slice(0, limit).map(({ key, value, source }) => ({ key, value, - tone: getChipTone(key, value, log.severityText), + tone: getChipTone(key, value, log.severityText, rpcSystem), source, })) }