Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions apps/web/src/components/services/dependency-drill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest"
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(filtersOf("messaging", "orders", "kafka").filters.attributeFilters).toEqual([
{ key: "messaging.destination.name", value: "orders" },
])
})

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 },
])
})

// 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" },
])
expect(filtersOf("rpc", "grpc", "grpc").filters.attributeFilters).toEqual([
{ key: "rpc.system", value: "grpc" },
{ key: "rpc.service", value: "", matchMode: "exists", negated: true },
])
})

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" },
])
})

// 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 { filters, warnings } = filtersOf(kind, target, system)
expect(warnings).toEqual([])
expect(filters.rootOnly).toBe(false)
expect(filters.attributeFilters.map((f) => f.key)).not.toContain("SpanKind")
}
})
})
49 changes: 49 additions & 0 deletions apps/web/src/components/services/dependency-drill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { quoteWhereValue } from "@maple/domain/where-clause"

export type DependencyDrillKind = "service" | "database" | "messaging" | "rpc" | "http"

/**
* 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.
*
* 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.
*
* 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,
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 namedBySystem
? spans(`messaging.system = ${value}`, "messaging.destination.name !exists")
Comment thread
Makisuo marked this conversation as resolved.
: spans(`messaging.destination.name = ${value}`)
case "rpc":
return namedBySystem
? spans(`rpc.system = ${value}`, "rpc.service !exists")
: spans(`rpc.service = ${value}`)
case "http":
return spans(`server.address = ${value}`)
}
}
13 changes: 4 additions & 9 deletions apps/web/src/components/services/service-dependencies-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 {
serviceName: string
Expand Down Expand Up @@ -118,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, ""),
})
}

Expand Down Expand Up @@ -148,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, ""),
})
}

Expand All @@ -160,12 +160,7 @@ export function ServiceDependenciesTab({
const callCount = Number(edge.callCount ?? 0)
const estimated = Number(edge.estimatedCallCount ?? callCount)
const system = edge.targetSystem ? String(edge.targetSystem) : ""
const whereClause =
kind === "messaging"
? `SpanKind = 'Producer' AND messaging.destination = ${quoteWhereValue(target)}`
: kind === "rpc"
? `SpanKind = 'Client' AND rpc.service = ${quoteWhereValue(target)}`
: `SpanKind = 'Client' AND (server.address = ${quoteWhereValue(target)} OR http.host = ${quoteWhereValue(target)})`
const whereClause = dependencyDrillWhereClause(kind, target, system)

out.push({
id: `${kind}:${target}`,
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/traces/trace-peek-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -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"]}
/>

Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/routes/traces/$traceId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
25 changes: 25 additions & 0 deletions packages/domain/src/tinybird/semconv-renames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,28 @@ export function containerRuntimeExpr(resourceAttributes: MapColumnLike): Expr<st
resourceAttributes.get("container.runtime"),
)
}

/**
* HTTP request method expression for read paths that group by it.
*
* Stable HTTP semconv renamed `http.method` to `http.request.method`; a group-by
* on the legacy key alone put every span from current instrumentation into the
* `""` bucket. The precedence is legacy-first on purpose: it must match
* `trace_list_mv`'s pre-extracted `HttpMethod` and the query engine's span filter
* aliases, so a group, its facet count and the filter it drills into agree on a
* span that carries both keys with different values.
*/
export function httpRequestMethodExpr(spanAttributes: MapColumnLike): Expr<string> {
const legacy = spanAttributes.get("http.method")
return CH.if_(legacy.neq(""), legacy, spanAttributes.get("http.request.method"))
}

/**
* HTTP response status code expression, legacy-first for the same reason as
* {@link httpRequestMethodExpr}: it has to agree with `trace_list_mv`'s
* pre-extracted `HttpStatusCode`.
*/
export function httpResponseStatusCodeExpr(spanAttributes: MapColumnLike): Expr<string> {
const legacy = spanAttributes.get("http.status_code")
return CH.if_(legacy.neq(""), legacy, spanAttributes.get("http.response.status_code"))
}
1 change: 1 addition & 0 deletions packages/domain/src/where-clause.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/domain/src/where-clause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 != ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -298,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 } = {}) {
Expand Down
Loading
Loading