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
19 changes: 11 additions & 8 deletions apps/web/src/api/warehouse/traces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,24 @@ describe("tinybird traces attribute filter params", () => {
result: {
kind: "list",
source: "traces",
// Grouped (one-row-per-trace) shape — the default list mode.
data: [
{
traceId: "trace-1",
timestamp: "2026-02-01 00:00:00",
startTime: "2026-02-01 00:00:00",
endTime: "2026-02-01 00:00:02",
durationMs: 2000,
serviceName: "checkout",
spanCount: 12,
services: ["gateway", "checkout", "payments"],
spanName: "GET",
spanKind: "SPAN_KIND_SERVER",
statusCode: "Ok",
hasError: 0,
spanAttributes: {
rootSpanName: "GET",
rootSpanKind: "Server",
rootSpanStatusCode: "Ok",
rootSpanAttributes: {
"http.method": "GET",
"http.route": "/checkout",
"http.status_code": "200",
},
hasError: false,
},
],
},
Expand All @@ -142,10 +144,11 @@ describe("tinybird traces attribute filter params", () => {

expect(response.data[0]).toMatchObject({
services: ["gateway", "checkout", "payments"],
spanCount: 12,
rootSpanName: "GET",
rootSpan: {
name: "GET",
kind: "SPAN_KIND_SERVER",
kind: "Server",
statusCode: "Ok",
attributes: {
"http.method": "GET",
Expand Down
126 changes: 111 additions & 15 deletions apps/web/src/api/warehouse/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ const ListTracesInputSchema = Schema.Struct({
attributeFilters: Schema.optional(Schema.Array(AttributeFilterInput)),
resourceAttributeFilters: Schema.optional(Schema.Array(AttributeFilterInput)),
rootOnly: Schema.optional(Schema.Boolean),
/**
* Drop noise traces from the grouped list: single-span traces whose root is
* not an entry-point kind (Server/Consumer) — mobile `ui.screen` breadcrumbs,
* orphaned client spans, SDK self-flushes. Defaults on; ignored when
* `rootOnly` is false (the span-level list has no trace structure to judge).
*/
hideNoise: Schema.optional(Schema.Boolean),
/** Keep only traces with at least this many spans (grouped list only). */
minSpanCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
serviceMatchMode: ContainsMatchMode,
spanNameMatchMode: ContainsMatchMode,
deploymentEnvMatchMode: ContainsMatchMode,
Expand Down Expand Up @@ -114,6 +123,7 @@ const LIST_PROJECTED_COLUMNS = [
"spanAttributes.url.path",
"spanAttributes.server.address",
"spanAttributes.net.peer.name",
"spanAttributes.screen.name",
] as const

interface TraceRootSpanSummary {
Expand Down Expand Up @@ -144,6 +154,15 @@ export interface TracesResponse {
meta: {
limit: number
offset: number
/**
* Rows the warehouse page actually produced before the noise filter ran.
* Pagination must advance by pages, not by `data.length`: a filtered page
* returns fewer rows than it consumed, and `scannedCount === limit` — not
* a full `data` array — is what means "more pages exist".
*/
scannedCount: number
/** Noise traces dropped from this page (`scannedCount - data.length`). */
hiddenCount: number
}
}

Expand Down Expand Up @@ -216,23 +235,25 @@ function buildResourceAttributeFilters(input: ListTracesDecoded): AttributeFilte
return filters
}

const PROJECTED_ATTR_KEYS = [
"http.method",
"http.request.method",
"http.route",
"http.target",
"http.status_code",
"http.response.status_code",
"http.url",
"url.full",
"url.path",
"server.address",
"net.peer.name",
"screen.name",
] as const

/** Transform a list row from tracesListQuery */
function transformSpanListRow(row: Record<string, unknown>): Trace {
const spanAttrs = (row.spanAttributes ?? {}) as Record<string, string>
const rootSpanAttributes: Record<string, string> = {}
const PROJECTED_ATTR_KEYS = [
"http.method",
"http.request.method",
"http.route",
"http.target",
"http.status_code",
"http.response.status_code",
"http.url",
"url.full",
"url.path",
"server.address",
"net.peer.name",
] as const
for (const key of PROJECTED_ATTR_KEYS) {
if (spanAttrs[key]) rootSpanAttributes[key] = spanAttrs[key]
}
Expand Down Expand Up @@ -276,6 +297,60 @@ function transformSpanListRow(row: Record<string, unknown>): Trace {
}
}

/** Transform a grouped row from the `groupByTrace` list (one row per TraceId). */
function transformTraceListRow(row: Record<string, unknown>): Trace {
const rootSpanAttributes: Record<string, string> = {}
if (typeof row.rootSpanAttributes === "object" && row.rootSpanAttributes !== null) {
for (const [key, value] of Object.entries(row.rootSpanAttributes)) {
if (typeof value === "string" && value.length > 0) rootSpanAttributes[key] = value
}
}
const services = Array.isArray(row.services)
? row.services.flatMap((service) => {
const name = String(service)
return name ? [name] : []
})
: []
const rootSpanName = String(row.rootSpanName)
const rootSpanKind = String(row.rootSpanKind)
return {
traceId: toTraceId(String(row.traceId)),
// Grouped rows are whole traces — there is no single span to deep-link.
spanId: "",
isRootSpan: true,
startTime: String(row.startTime),
endTime: String(row.endTime),
durationMs: Number(row.durationMs),
spanCount: Number(row.spanCount),
services,
rootSpan: {
name: rootSpanName,
kind: rootSpanKind,
statusCode: String(row.rootSpanStatusCode),
attributes: rootSpanAttributes,
http: getHttpInfo({
spanName: rootSpanName,
spanAttributes: rootSpanAttributes,
spanKind: rootSpanKind,
}),
},
rootSpanName,
hasError: row.hasError === true || row.hasError === 1,
}
}

const ENTRY_POINT_KINDS = new Set(["Server", "Consumer"])

/**
* A single-span trace whose root is not an entry point carries no structure and
* no request identity — mobile `ui.screen` breadcrumbs, orphaned client spans,
* SDK self-flush spans. Single-span SERVER traces stay: an inbound request with
* no children is thin but real.
*/
function isNoiseTrace(trace: Trace): boolean {
return trace.spanCount <= 1 && !ENTRY_POINT_KINDS.has(trace.rootSpan.kind)
}

export function listTraces({ data }: { data: ListTracesInput }) {
return listTracesEffect({ data })
}
Expand All @@ -295,9 +370,14 @@ const listTracesEffect = Effect.fn("QueryEngine.listTraces")(function* ({ data }
if (input.namespaceMatchMode === "contains") matchModes.serviceNamespace = "contains"

const rootOnly = input.rootOnly ?? true
// The trace-grouped list only lists true roots; `rootOnly: false` is the
// explicit opt-out into the legacy per-span list (Datadog's "all spans" view).
const groupByTrace = rootOnly
const hideNoise = groupByTrace && (input.hideNoise ?? true)

if (input.services?.length) yield* Effect.annotateCurrentSpan("services", input.services.join(","))
yield* Effect.annotateCurrentSpan("rootOnly", rootOnly)
yield* Effect.annotateCurrentSpan("groupByTrace", groupByTrace)
yield* Effect.annotateCurrentSpan("limit", limit)

const request = new QueryEngineExecuteRequest({
Expand All @@ -306,13 +386,15 @@ const listTracesEffect = Effect.fn("QueryEngine.listTraces")(function* ({ data }
query: {
kind: "list" as const,
source: "traces" as const,
groupByTrace,
limit,
offset,
sortBy: input.sortBy,
sortDir: input.sortDir,
// Only project the span attributes the list UI actually renders
// (via transformSpanListRow → getHttpInfo). Avoids reading the full
// SpanAttributes / ResourceAttributes maps — large win on wide traces.
// The grouped path ignores this and ships its own fixed projection.
columns: LIST_PROJECTED_COLUMNS,
filters: {
serviceNames: oneOrMany(input.services, input.service),
Expand Down Expand Up @@ -348,11 +430,25 @@ const listTracesEffect = Effect.fn("QueryEngine.listTraces")(function* ({ data }
)
}

const traces = response.result.data.map(transformSpanListRow)
const scanned = groupByTrace
? response.result.data.map(transformTraceListRow)
: response.result.data.map(transformSpanListRow)

const minSpanCount = groupByTrace ? input.minSpanCount : undefined
const traces = scanned.filter(
(trace) =>
(!hideNoise || !isNoiseTrace(trace)) &&
(minSpanCount === undefined || trace.spanCount >= minSpanCount),
)

return {
data: traces,
meta: { limit, offset },
meta: {
limit,
offset,
scannedCount: scanned.length,
hiddenCount: scanned.length - traces.length,
},
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,29 @@ vi.mock("@/api/warehouse/effect-utils", async () => {
// does not leak between tests.
const defaultExecuteQueryEngine = (operation: string) => {
if (operation.includes("listTraces")) {
// Grouped (one-row-per-trace) shape — the default list mode. See the
// `groupByTrace` branch of the query-engine list dispatch.
return Effect.succeed({
result: {
kind: "list",
source: "traces",
data: [
{
traceId: "t1",
timestamp: "2026-03-28 00:00:00",
startTime: "2026-03-28 00:00:00",
endTime: "2026-03-28 00:00:01",
durationMs: 142,
serviceName: "api-gw",
spanName: "GET /api/users",
spanKind: "SERVER",
statusCode: "Ok",
hasError: 0,
spanAttributes: {
spanCount: 3,
services: ["api-gw"],
rootSpanName: "GET /api/users",
rootSpanKind: "Server",
rootSpanStatusCode: "Ok",
rootSpanAttributes: {
"http.method": "GET",
"http.route": "/api/users",
"http.status_code": "200",
},
hasError: false,
},
],
},
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/traces/traces-filter-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ function TracesFilterSidebarView({
onChange={(checked) => onFilterChange("rootOnly", checked ? undefined : false)}
/>

{/* Only meaningful on the grouped trace list — the span-level
list (rootOnly off) has no trace structure to judge. */}
{(filters.rootOnly ?? true) && (
<SingleCheckboxFilter
title="Hide Single-Span Noise"
checked={filters.hideNoise ?? true}
onChange={(checked) =>
onFilterChange("hideNoise", checked ? undefined : false)
}
/>
)}

<FilterSection
title="Environment"
options={facets.deploymentEnvs ?? []}
Expand Down
Loading
Loading