diff --git a/apps/web/src/api/warehouse/traces.test.ts b/apps/web/src/api/warehouse/traces.test.ts index 7d29d99b9..9a8fc0b6f 100644 --- a/apps/web/src/api/warehouse/traces.test.ts +++ b/apps/web/src/api/warehouse/traces.test.ts @@ -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, }, ], }, @@ -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", diff --git a/apps/web/src/api/warehouse/traces.ts b/apps/web/src/api/warehouse/traces.ts index 0fcc626b6..13a7ca2a1 100644 --- a/apps/web/src/api/warehouse/traces.ts +++ b/apps/web/src/api/warehouse/traces.ts @@ -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, @@ -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 { @@ -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 } } @@ -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): Trace { const spanAttrs = (row.spanAttributes ?? {}) as Record const rootSpanAttributes: Record = {} - 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] } @@ -276,6 +297,60 @@ function transformSpanListRow(row: Record): Trace { } } +/** Transform a grouped row from the `groupByTrace` list (one row per TraceId). */ +function transformTraceListRow(row: Record): Trace { + const rootSpanAttributes: Record = {} + 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 }) } @@ -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({ @@ -306,6 +386,7 @@ const listTracesEffect = Effect.fn("QueryEngine.listTraces")(function* ({ data } query: { kind: "list" as const, source: "traces" as const, + groupByTrace, limit, offset, sortBy: input.sortBy, @@ -313,6 +394,7 @@ const listTracesEffect = Effect.fn("QueryEngine.listTraces")(function* ({ data } // 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), @@ -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, + }, } }) diff --git a/apps/web/src/components/dashboard-builder/widgets/list-widget.test.ts b/apps/web/src/components/dashboard-builder/widgets/list-widget.test.ts index 968c1bc56..87044ff65 100644 --- a/apps/web/src/components/dashboard-builder/widgets/list-widget.test.ts +++ b/apps/web/src/components/dashboard-builder/widgets/list-widget.test.ts @@ -24,6 +24,8 @@ 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", @@ -31,18 +33,20 @@ const defaultExecuteQueryEngine = (operation: string) => { 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, }, ], }, diff --git a/apps/web/src/components/traces/traces-filter-sidebar.tsx b/apps/web/src/components/traces/traces-filter-sidebar.tsx index dba38cdb0..393f1f764 100644 --- a/apps/web/src/components/traces/traces-filter-sidebar.tsx +++ b/apps/web/src/components/traces/traces-filter-sidebar.tsx @@ -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) && ( + + onFilterChange("hideNoise", checked ? undefined : false) + } + /> + )} + void waiting: boolean onTraceClick: (trace: Trace) => void + onShowNoise: () => void sortBy: TraceSortKey sortDir: TraceSortDir onSortChange: (key: TraceSortKey) => void @@ -136,7 +138,8 @@ const HEADER_CELL_CLASS = "h-10 px-2 text-left align-middle font-medium text-mut * At a 768px viewport the table has ~480px, which a `md:` media query would wrongly call roomy. * * Budget: Trace ID (100) + Status (80) are always on, leaving `container - 180` for Root Span. - * Duration (100) joins at 480 and Services (160) at 680, each keeping Root Span at ≥200px. + * Duration (100) joins at 480, Spans (70) at 560 and Services (160) at 680, each keeping Root + * Span at ≥200px. */ interface TraceColumnLayout { readonly id: string @@ -159,6 +162,13 @@ const TRACE_COLUMNS: readonly TraceColumnLayout[] = [ skeleton: "w-24", responsive: "hidden @min-[680px]/page:table-cell", }, + { + id: "spanCount", + header: "Spans", + width: 70, + skeleton: "w-8", + responsive: "hidden @min-[560px]/page:table-cell", + }, { id: "durationMs", header: "Duration", @@ -205,9 +215,11 @@ function TracesTableView({ isFetchingNextPage, hasNextPage, isCapped, + hiddenCount, fetchNextPage, waiting, onTraceClick, + onShowNoise, sortBy, sortDir, onSortChange, @@ -240,41 +252,49 @@ function TracesTableView({ { id: "rootSpan", header: "Root Span", - cell: ({ row }) => ( -
- - {/* - * One slot, two sub-lines — switched at the same 480px the Duration column - * uses, so exactly one of them shows the duration. While Duration is hidden - * the absolute timestamp gives way to it (the more useful of the two at a - * glance); the full timestamp stays available on the tooltip. - */} - - - {formatTimestampInTimezone(row.original.startTime, { + cell: ({ row }) => { + const name = row.original.rootSpan.name || row.original.rootSpanName || "Unknown" + // Mobile screen spans are all named `ui.screen`/`screen.load`; the + // identity that distinguishes rows lives in `screen.name`. + const screenName = row.original.rootSpan.attributes["screen.name"] + const displayName = + screenName && !name.includes(screenName) ? `${name} · ${screenName}` : name + return ( +
+ + {/* + * One slot, two sub-lines — switched at the same 480px the Duration column + * uses, so exactly one of them shows the duration. While Duration is hidden + * the absolute timestamp gives way to it (the more useful of the two at a + * glance); the full timestamp stays available on the tooltip. + */} + - - ({formatRelativeTime(row.original.startTime)}) - - - {" · "} - {formatDuration(row.original.durationMs)} + })} + > + + {formatTimestampInTimezone(row.original.startTime, { + timeZone: effectiveTimezone, + })}{" "} + + + ({formatRelativeTime(row.original.startTime)}) + + + {" · "} + {formatDuration(row.original.durationMs)} + - -
- ), +
+ ) + }, }, { id: "services", @@ -301,6 +321,16 @@ function TracesTableView({ ), }, + { + accessorKey: "spanCount", + header: "Spans", + size: 70, + cell: ({ row }) => ( + + {row.original.spanCount.toLocaleString()} + + ), + }, { accessorKey: "durationMs", header: () => ( @@ -502,6 +532,21 @@ function TracesTableView({ {isCapped ? `Showing first ${allData.length.toLocaleString()} traces — narrow filters to continue` : `Showing ${allData.length.toLocaleString()} traces${!hasNextPage ? " (all loaded)" : ""}`} + {/* Hidden rows are never silently dropped — say how many and offer the way back. */} + {hiddenCount > 0 && ( + <> + {" · "} + {hiddenCount.toLocaleString()} single-span noise{" "} + {hiddenCount === 1 ? "trace" : "traces"} hidden{" "} + + + )} ) @@ -512,8 +557,19 @@ export function TracesTable({ filters }: TracesTableProps) { // Bound to the traces route so the sort patch keeps the rest of the search // params typed and intact. const navigateTraces = useNavigate({ from: "/traces/" }) - const { firstPageResult, allData, isFetchingNextPage, hasNextPage, isCapped, fetchNextPage } = - useInfiniteTraces(filters) + const { + firstPageResult, + allData, + isFetchingNextPage, + hasNextPage, + isCapped, + hiddenCount, + fetchNextPage, + } = useInfiniteTraces(filters) + + const onShowNoise = React.useCallback(() => { + navigateTraces({ search: (prev) => ({ ...prev, hideNoise: false }) }) + }, [navigateTraces]) const onTraceClick = React.useCallback( (trace: Trace) => { @@ -552,9 +608,11 @@ export function TracesTable({ filters }: TracesTableProps) { isFetchingNextPage={isFetchingNextPage} hasNextPage={hasNextPage} isCapped={isCapped} + hiddenCount={hiddenCount} fetchNextPage={fetchNextPage} waiting={result.waiting ?? false} onTraceClick={onTraceClick} + onShowNoise={onShowNoise} sortBy={sortBy} sortDir={sortDir} onSortChange={onSortChange} diff --git a/apps/web/src/hooks/use-infinite-traces.ts b/apps/web/src/hooks/use-infinite-traces.ts index c2afe433a..22657c631 100644 --- a/apps/web/src/hooks/use-infinite-traces.ts +++ b/apps/web/src/hooks/use-infinite-traces.ts @@ -19,6 +19,8 @@ export interface UseInfiniteTracesReturn { isFetchingNextPage: boolean hasNextPage: boolean isCapped: boolean + /** Noise traces the server dropped across every loaded page (see `hideNoise`). */ + hiddenCount: number fetchNextPage: () => void } @@ -51,6 +53,8 @@ function buildQueryParams( excludedNamespaces: filters?.excludedNamespaces, excludedHttpMethods: filters?.excludedHttpMethods, excludedHttpStatusCodes: filters?.excludedHttpStatusCodes, + hideNoise: filters?.hideNoise, + minSpanCount: filters?.minSpanCount, sortBy: filters?.sortBy, sortDir: filters?.sortDir, } @@ -98,15 +102,23 @@ export function useInfiniteTraces(filters: TracesSearchParams | undefined): UseI }, [firstPageResult, additionalPages]) const isCapped = allData.length >= MAX_RETAINED_TRACES + const hiddenCount = React.useMemo(() => { + const first = Result.isSuccess(firstPageResult) ? firstPageResult.value.meta.hiddenCount : 0 + return first + additionalPages.reduce((sum, page) => sum + page.meta.hiddenCount, 0) + }, [firstPageResult, additionalPages]) + + // "More pages exist" means the warehouse page came back full BEFORE the + // server-side noise filter ran. `data.length === PAGE_SIZE` would end + // pagination on the first page with any hidden rows. const hasNextPage = React.useMemo(() => { if (isCapped) return false if (paginationStopped) return false if (!Result.isSuccess(firstPageResult)) return false if (additionalPages.length === 0) { - return firstPageResult.value.data.length === PAGE_SIZE + return firstPageResult.value.meta.scannedCount === PAGE_SIZE } const lastPage = additionalPages[additionalPages.length - 1] - return lastPage.data.length === PAGE_SIZE + return lastPage.meta.scannedCount === PAGE_SIZE }, [firstPageResult, additionalPages, paginationStopped, isCapped]) const fetchNextPage = React.useCallback(() => { @@ -115,7 +127,10 @@ export function useInfiniteTraces(filters: TracesSearchParams | undefined): UseI setIsFetchingNextPage(true) const currentKey = filterKeyRef.current - const offset = allData.length + // Offset counts warehouse rows consumed, not rows kept: the server drops + // noise rows after paging, so offsetting by `allData.length` would rescan + // the filtered region and duplicate every kept row in it. + const offset = (additionalPages.length + 1) * PAGE_SIZE mapleRuntime .runPromise(listTraces({ data: { ...queryParams, limit: PAGE_SIZE, offset } })) @@ -137,7 +152,7 @@ export function useInfiniteTraces(filters: TracesSearchParams | undefined): UseI } isFetchingRef.current = false }) - }, [queryParams, allData.length, hasNextPage]) + }, [queryParams, additionalPages.length, hasNextPage]) return { firstPageResult, @@ -145,6 +160,7 @@ export function useInfiniteTraces(filters: TracesSearchParams | undefined): UseI isFetchingNextPage, hasNextPage, isCapped, + hiddenCount, fetchNextPage, } } diff --git a/apps/web/src/routes/traces/index.tsx b/apps/web/src/routes/traces/index.tsx index c36d930f5..74078dabd 100644 --- a/apps/web/src/routes/traces/index.tsx +++ b/apps/web/src/routes/traces/index.tsx @@ -42,6 +42,11 @@ const tracesSearchSchema = Schema.Struct({ deploymentEnvs: OptionalStringArrayParam, namespaces: OptionalStringArrayParam, rootOnly: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + // Server-side drop of single-span non-entry-point traces (ui.screen + // breadcrumbs, orphaned client spans). Defaults on; `hideNoise=false` shows + // everything. + hideNoise: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + minSpanCount: Schema.optional(Schema.Union([Schema.Number, Schema.NumberFromString])), whereClause: Schema.optional(Schema.String), attributeFilters: Schema.optional(Schema.Array(AttributeFilterParam)), resourceAttributeFilters: Schema.optional(Schema.Array(AttributeFilterParam)), diff --git a/packages/domain/src/query-engine.ts b/packages/domain/src/query-engine.ts index baed8edf8..e5cb0aa39 100644 --- a/packages/domain/src/query-engine.ts +++ b/packages/domain/src/query-engine.ts @@ -239,16 +239,27 @@ export type MetricsSparklinesQuery = Schema.Schema.Type= subtractHours(toDateTime('2026-01-01 10:30:00'), 1) + AND Timestamp <= addHours(toDateTime('2026-01-03 14:15:00'), 1) + AND TraceId IN (SELECT traceId FROM (SELECT + TraceId AS traceId, + Timestamp AS ts, + Duration AS d + FROM traces + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) IN ('production') + AND SpanAttributes['user.id'] = 'u1' + AND ParentSpanId = '' + ORDER BY ts DESC, traceId DESC + LIMIT 50)) + GROUP BY traceId + ORDER BY startTime DESC, traceId DESC + LIMIT 50 + FORMAT JSON + +-- spec:traces-list-grouped-attr-fallback:bloom [a9a27f2b] +SELECT + TraceId AS traceId, + argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - min(toUnixTimestamp64Nano(Timestamp)), 1000) AS durationMicros, + intDiv(argMin(Duration, (if(ParentSpanId = '', 0, 1), Timestamp)), 1000) AS rootDurationMicros, + count() AS spanCount, + arrayDistinct(arrayPushFront(arraySort(groupUniqArray(ServiceName)), argMin(ServiceName, (if(ParentSpanId = '', 0, 1), Timestamp)))) AS services, + 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(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, + argMin(SpanAttributes['http.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 + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= subtractHours(toDateTime('2026-01-01 10:30:00'), 1) + AND Timestamp <= addHours(toDateTime('2026-01-03 14:15:00'), 1) + AND TraceId IN (SELECT traceId FROM (SELECT + TraceId AS traceId, + Timestamp AS ts, + Duration AS d + FROM traces + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) IN ('production') + AND ((has(mapKeys(SpanAttributes), 'user.id') AND has(mapValues(SpanAttributes), 'u1')) AND SpanAttributes['user.id'] = 'u1') + AND ParentSpanId = '' + ORDER BY ts DESC, traceId DESC + LIMIT 50)) + GROUP BY traceId + ORDER BY startTime DESC, traceId DESC + LIMIT 50 + FORMAT JSON + +-- spec:traces-list-grouped-attr-fallback:text [22bd2047] +SELECT + TraceId AS traceId, + argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - min(toUnixTimestamp64Nano(Timestamp)), 1000) AS durationMicros, + intDiv(argMin(Duration, (if(ParentSpanId = '', 0, 1), Timestamp)), 1000) AS rootDurationMicros, + count() AS spanCount, + arrayDistinct(arrayPushFront(arraySort(groupUniqArray(ServiceName)), argMin(ServiceName, (if(ParentSpanId = '', 0, 1), Timestamp)))) AS services, + 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(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, + argMin(SpanAttributes['http.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 + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= subtractHours(toDateTime('2026-01-01 10:30:00'), 1) + AND Timestamp <= addHours(toDateTime('2026-01-03 14:15:00'), 1) + AND TraceId IN (SELECT traceId FROM (SELECT + TraceId AS traceId, + Timestamp AS ts, + Duration AS d + FROM traces + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) IN ('production') + AND (has(SpanAttributeItems, concat('user.id', char(31), 'u1')) AND SpanAttributes['user.id'] = 'u1') + AND ParentSpanId = '' + ORDER BY ts DESC, traceId DESC + LIMIT 50)) + GROUP BY traceId + ORDER BY startTime DESC, traceId DESC + LIMIT 50 + FORMAT JSON + +-- spec:traces-list-grouped-duration-sort:baseline [3b6bcadc] +SELECT + TraceId AS traceId, + argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - min(toUnixTimestamp64Nano(Timestamp)), 1000) AS durationMicros, + intDiv(argMin(Duration, (if(ParentSpanId = '', 0, 1), Timestamp)), 1000) AS rootDurationMicros, + count() AS spanCount, + arrayDistinct(arrayPushFront(arraySort(groupUniqArray(ServiceName)), argMin(ServiceName, (if(ParentSpanId = '', 0, 1), Timestamp)))) AS services, + 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(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, + argMin(SpanAttributes['http.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 + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= subtractHours(toDateTime('2026-01-01 10:30:00'), 1) + AND Timestamp <= addHours(toDateTime('2026-01-03 14:15:00'), 1) + AND TraceId IN (SELECT traceId FROM (SELECT + TraceId AS traceId, + Timestamp AS ts, + Duration AS d + FROM trace_list_mv + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND DeploymentEnv = 'production' + ORDER BY d DESC, ts DESC, traceId DESC + LIMIT 50 + OFFSET 100)) + GROUP BY traceId + ORDER BY rootDurationMicros DESC, startTime DESC, traceId DESC + LIMIT 50 + FORMAT JSON + +-- spec:traces-list-grouped:baseline [da486ffd] +SELECT + TraceId AS traceId, + argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - min(toUnixTimestamp64Nano(Timestamp)), 1000) AS durationMicros, + intDiv(argMin(Duration, (if(ParentSpanId = '', 0, 1), Timestamp)), 1000) AS rootDurationMicros, + count() AS spanCount, + arrayDistinct(arrayPushFront(arraySort(groupUniqArray(ServiceName)), argMin(ServiceName, (if(ParentSpanId = '', 0, 1), Timestamp)))) AS services, + 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(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, + argMin(SpanAttributes['http.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 + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= subtractHours(toDateTime('2026-01-01 10:30:00'), 1) + AND Timestamp <= addHours(toDateTime('2026-01-03 14:15:00'), 1) + AND TraceId IN (SELECT traceId FROM (SELECT + TraceId AS traceId, + Timestamp AS ts, + Duration AS d + FROM trace_list_mv + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND DeploymentEnv = 'production' + ORDER BY ts DESC, traceId DESC + LIMIT 50)) + GROUP BY traceId + ORDER BY startTime DESC, traceId DESC + LIMIT 50 + FORMAT JSON + +-- spec:traces-list-grouped:bloom [da486ffd] +SELECT + TraceId AS traceId, + argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - min(toUnixTimestamp64Nano(Timestamp)), 1000) AS durationMicros, + intDiv(argMin(Duration, (if(ParentSpanId = '', 0, 1), Timestamp)), 1000) AS rootDurationMicros, + count() AS spanCount, + arrayDistinct(arrayPushFront(arraySort(groupUniqArray(ServiceName)), argMin(ServiceName, (if(ParentSpanId = '', 0, 1), Timestamp)))) AS services, + 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(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, + argMin(SpanAttributes['http.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 + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= subtractHours(toDateTime('2026-01-01 10:30:00'), 1) + AND Timestamp <= addHours(toDateTime('2026-01-03 14:15:00'), 1) + AND TraceId IN (SELECT traceId FROM (SELECT + TraceId AS traceId, + Timestamp AS ts, + Duration AS d + FROM trace_list_mv + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND DeploymentEnv = 'production' + ORDER BY ts DESC, traceId DESC + LIMIT 50)) + GROUP BY traceId + ORDER BY startTime DESC, traceId DESC + LIMIT 50 + FORMAT JSON + +-- spec:traces-list-grouped:text [da486ffd] +SELECT + TraceId AS traceId, + argMin(Timestamp, (if(ParentSpanId = '', 0, 1), Timestamp)) AS startTime, + fromUnixTimestamp64Nano(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration))) AS endTime, + intDiv(max(toUnixTimestamp64Nano(Timestamp) + toInt64(Duration)) - min(toUnixTimestamp64Nano(Timestamp)), 1000) AS durationMicros, + intDiv(argMin(Duration, (if(ParentSpanId = '', 0, 1), Timestamp)), 1000) AS rootDurationMicros, + count() AS spanCount, + arrayDistinct(arrayPushFront(arraySort(groupUniqArray(ServiceName)), argMin(ServiceName, (if(ParentSpanId = '', 0, 1), Timestamp)))) AS services, + 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(SpanAttributes['http.route'], (if(ParentSpanId = '', 0, 1), Timestamp)) AS rootHttpRoute, + argMin(SpanAttributes['http.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 + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= subtractHours(toDateTime('2026-01-01 10:30:00'), 1) + AND Timestamp <= addHours(toDateTime('2026-01-03 14:15:00'), 1) + AND TraceId IN (SELECT traceId FROM (SELECT + TraceId AS traceId, + Timestamp AS ts, + Duration AS d + FROM trace_list_mv + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND DeploymentEnv = 'production' + ORDER BY ts DESC, traceId DESC + LIMIT 50)) + GROUP BY traceId + ORDER BY startTime DESC, traceId DESC + LIMIT 50 + FORMAT JSON + -- spec:traces-list:baseline [3ec2a2e9] SELECT TraceId AS traceId, diff --git a/packages/query-engine/src/ch/queries/traces.test.ts b/packages/query-engine/src/ch/queries/traces.test.ts index 6929806ac..4fcd61aeb 100644 --- a/packages/query-engine/src/ch/queries/traces.test.ts +++ b/packages/query-engine/src/ch/queries/traces.test.ts @@ -355,13 +355,62 @@ describe("traceListQuery", () => { expect(sql).toContain("FORMAT JSON") }) - it("pages over true roots only, so a multi-service trace yields a single row", () => { + it("pages over the roots-only MV by default, read-in-order on its sort key", () => { const inner = pageSubquery(compileCH(traceListQuery({}), baseParams).sql) + // trace_list_mv stores true roots only, sorted (OrgId, Timestamp, + // TraceId) — no ParentSpanId predicate needed, and "newest N" pages + // without scanning the window like raw `traces` would. + expect(inner).toContain("FROM trace_list_mv") + expect(inner).not.toContain("SpanKind IN ('Server', 'Consumer')") + }) + + it("falls back to raw traces paging when a filter the MV lacks is present", () => { + const { sql } = compileCH( + traceListQuery({ attributeFilters: [{ key: "user.id", value: "u1", mode: "equals" }] }), + baseParams, + ) + const inner = pageSubquery(sql) + + expect(inner).toContain("FROM traces") expect(inner).toContain("ParentSpanId = ''") // NOT the entry-point predicate tracesRootListQuery uses — that matches // one span per service and would re-introduce duplicate rows per trace. expect(inner).not.toContain("SpanKind IN ('Server', 'Consumer')") + expect(inner).toContain("SpanAttributes['user.id'] = 'u1'") + }) + + it("maps HTTP method/status attribute filters onto the MV's pre-extracted columns", () => { + const inner = pageSubquery( + compileCH( + traceListQuery({ + attributeFilters: [ + { key: "http.method", value: "GET", mode: "equals" }, + { key: "http.status_code", values: ["500", "502"], mode: "in" }, + ], + }), + baseParams, + ).sql, + ) + + expect(inner).toContain("FROM trace_list_mv") + expect(inner).toContain("HttpMethod = 'GET'") + expect(inner).toContain("HttpStatusCode IN ('500', '502')") + }) + + it("truncates the ns cursor to the MV's second-granularity Timestamp", () => { + const inner = pageSubquery( + compileCH( + traceListQuery({ + cursor: { timestamp: "2024-01-01 12:00:00.123456789", traceId: "trace123" }, + }), + baseParams, + ).sql, + ) + + expect(inner).toContain("Timestamp < '2024-01-01 12:00:00'") + expect(inner).not.toContain("12:00:00.123456789") + expect(inner).toContain("TraceId < 'trace123'") }) it("reads only TraceId + Timestamp in the paging stage", () => { @@ -448,13 +497,16 @@ describe("traceListQuery", () => { expect(inner).toContain("Timestamp >= '2024-01-01 00:00:00'") expect(inner).toContain("Timestamp <= '2024-01-02 00:00:00'") - // The aggregate is scoped by OrgId + TraceId alone: re-applying the span - // filters there would drop the very children spanCount has to count, and - // a time bound would clip children that outlive the window. + // The aggregate is scoped by OrgId + TraceId + a PADDED window: re-applying + // the span filters there would drop the very children spanCount has to + // count, and an exact time bound would clip children that outlive the + // window — but a completely unbounded aggregate defeats partition pruning + // and times out on prod retention, hence the ±1h pad. const outer = sql.slice(sql.indexOf("FROM trace_detail_spans")).replace(inner, "") expect(outer).toContain("OrgId = 'org_1'") expect(outer).not.toContain("ServiceName = 'api'") - expect(outer).not.toContain("Timestamp >=") + expect(outer).toContain("Timestamp >= subtractHours(toDateTime('2024-01-01 00:00:00'), 1)") + expect(outer).toContain("Timestamp <= addHours(toDateTime('2024-01-02 00:00:00'), 1)") }) }) diff --git a/packages/query-engine/src/ch/queries/traces.ts b/packages/query-engine/src/ch/queries/traces.ts index 9227720b0..b133e3d94 100644 --- a/packages/query-engine/src/ch/queries/traces.ts +++ b/packages/query-engine/src/ch/queries/traces.ts @@ -28,6 +28,7 @@ import { buildProjectedMapExpr, canUseServiceOverviewMv, canUseTracesAggregatesMv, + errorsOnlyCondition, inclusionValues, serviceOverviewWhereConditions, tracesAggregatesWhereConditions, @@ -1328,6 +1329,9 @@ const ROOT_SPAN_ATTR_KEYS = [ "url.path", "server.address", "net.peer.name", + // Mobile screen spans (`ui.screen` / `screen.load`) carry their only useful + // identity here — without it every screen trace renders as the bare span name. + "screen.name", ] as const /** @@ -1403,8 +1407,17 @@ export interface TraceListOpts extends TracesQueryOpts { * `traceId`). Composite because root timestamps are not unique: a bare * `Timestamp < cursor` silently drops every trace sharing the boundary * timestamp. Strictly preferred over `offset` for deep pagination. + * Only valid with the default `timestamp` sort. */ cursor?: { timestamp: string; traceId: string } + /** + * `durationMs` sorts by the ROOT span's own duration, not the trace's + * wall-clock extent — the wall clock only exists after stage 2 aggregates, + * while pagination must be decided in stage 1 over `traces`. For a root the + * two agree except when a child outlives its parent. + */ + sortBy?: TracesListSortKey + sortDir?: TracesListSortDir } export interface TraceListOutput { @@ -1415,6 +1428,8 @@ export interface TraceListOutput { readonly endTime: string /** Wall-clock extent of the whole trace, not the root span's own duration. */ readonly durationMicros: number + /** The root span's own duration — the `durationMs` sort key (see `TraceListOpts.sortBy`). */ + readonly rootDurationMicros: number /** Every span in the trace, not just the ones matching the filters. */ readonly spanCount: number /** Root service first, remaining participants sorted. */ @@ -1442,6 +1457,123 @@ const arrayDistinct = (arr: CH.Expr>): CH.Expr): CH.Expr => compileFnCall("fromUnixTimestamp64Nano", nanos) +const subtractHours = (d: CH.Expr, hours: CH.Expr): CH.Expr => + compileFnCall("subtractHours", d, hours) + +const addHours = (d: CH.Expr, hours: CH.Expr): CH.Expr => + compileFnCall("addHours", d, hours) + +/** + * Attribute-filter keys the trace-list MV pre-extracts into columns. The MV + * coalesces both semconv spellings at write time, so either key lands on the + * same column. + */ +const TRACE_LIST_MV_ATTR_COLUMNS = new Map([ + ["http.method", "HttpMethod"], + ["http.request.method", "HttpMethod"], + ["http.status_code", "HttpStatusCode"], + ["http.response.status_code", "HttpStatusCode"], +]) + +/** + * Whether `traceListQuery`'s paging stage can run over `trace_list_mv` instead + * of raw `traces`. The MV's sort key is `(OrgId, Timestamp, TraceId)`, so + * "newest N roots" is a read-in-order index walk instead of a full window scan + * — the raw table's `(OrgId, ServiceName, SpanName, Timestamp)` key cannot + * serve a time-ordered scan without reading the whole window. + * + * The MV stores roots only (exactly this query's population) but has no + * attribute maps: only HTTP method/status filters that map onto its + * pre-extracted columns are expressible; anything else falls back to raw. + */ +export function canUseTraceListMvStage1(opts: TraceListOpts): boolean { + if (opts.resourceAttributeFilters?.length) return false + if (opts.commitShas?.length) return false + for (const af of opts.attributeFilters ?? []) { + if (!TRACE_LIST_MV_ATTR_COLUMNS.has(af.key)) return false + const expressible = + (af.mode === "equals" && af.value !== undefined) || (af.mode === "in" && !!af.values?.length) + if (!expressible) return false + } + return true +} + +/** + * `tracesBaseWhereConditions` re-expressed over the MV's pre-extracted columns. + * `SpanName` here is already the display spelling the facet sidebar shows + * (the MV normalizes `http.server GET` → `GET /route` at write time), so a + * facet click matches without the raw-or-display OR the base builder needs. + */ +function traceListMvWhereConditions( + $: ColumnAccessor, + opts: TraceListOpts, +): Array { + const mm = opts.matchModes + const services = inclusionValues(opts.serviceName, opts.serviceNames) + const spanNames = inclusionValues(opts.spanName, opts.spanNames) + const conditions: Array = [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTime("startTime")), + $.Timestamp.lte(param.dateTime("endTime")), + CH.when(services, (v: readonly string[]) => + matchOrIn($.ServiceName, v, mm?.serviceName === "contains"), + ), + CH.when(spanNames, (v: readonly string[]) => matchOrIn($.SpanName, v, mm?.spanName === "contains")), + CH.when(opts.statusCode, (v: string) => $.StatusCode.eq(v)), + errorsOnlyCondition($.StatusCode, opts.errorsOnly), + ] + + if (opts.minDurationMs != null) { + conditions.push($.Duration.gte(opts.minDurationMs * 1000000)) + } + if (opts.maxDurationMs != null) { + conditions.push($.Duration.lte(opts.maxDurationMs * 1000000)) + } + if (opts.environments?.length) { + conditions.push( + matchOrIn( + $.DeploymentEnv, + opts.environments, + mm?.deploymentEnv === "contains" && opts.environments.length === 1, + ), + ) + } + if (opts.namespaces?.length) { + conditions.push( + matchOrIn( + $.ServiceNamespace, + opts.namespaces, + mm?.serviceNamespace === "contains" && opts.namespaces.length === 1, + ), + ) + } + for (const af of opts.attributeFilters ?? []) { + const column = TRACE_LIST_MV_ATTR_COLUMNS.get(af.key) + if (!column) continue // unreachable behind canUseTraceListMvStage1 + const col = $[column] + if (af.mode === "equals" && af.value !== undefined) { + conditions.push(af.negated ? col.neq(af.value) : col.eq(af.value)) + } else if (af.mode === "in" && af.values?.length) { + const inCond = CH.inList(col, af.values) + conditions.push(af.negated ? CH.not(inCond) : inCond) + } + } + if (opts.excludedServiceNames?.length) { + conditions.push(CH.notInList($.ServiceName, opts.excludedServiceNames)) + } + if (opts.excludedSpanNames?.length) { + conditions.push(CH.notInList($.SpanName, opts.excludedSpanNames)) + } + if (opts.excludedEnvironments?.length) { + conditions.push(CH.notInList($.DeploymentEnv, opts.excludedEnvironments)) + } + if (opts.excludedNamespaces?.length) { + conditions.push(CH.notInList($.ServiceNamespace, opts.excludedNamespaces)) + } + + return conditions +} + /** * Two-stage **trace**-level list: exactly one row per TraceId, carrying the real * span count, every participating service, and the trace's wall-clock duration. @@ -1459,38 +1591,76 @@ const fromUnixTimestamp64Nano = (nanos: CH.Expr): CH.Expr => * `limit` trace ids into primary-key seeks; the heavy SpanAttributes lookups are * materialized only there, for at most one page of traces. * - * Stage 2 is deliberately not time-bounded: a trace's children can outlive the - * requested window, and clipping them would undercount `spanCount` at the window - * edge. The TraceId seek is what keeps it cheap, not partition pruning. + * Stage 2 is bounded by the requested window padded by ±1h, not the exact + * window: a trace's children can outlive it, and clipping them exactly would + * undercount `spanCount` at the window edge. The pad has to exist at all + * because an unbounded stage 2 defeats partition pruning — the PK analysis + * touches every retained partition and times out on prod-sized retention. */ export function traceListQuery(opts: TraceListOpts) { const limit = opts.limit ?? 25 const offset = opts.offset ?? 0 const cursor = opts.cursor + const sortBy = opts.sortBy ?? "timestamp" + const sortDir = opts.sortDir ?? "desc" - let page = from(Traces) - .select(($) => ({ traceId: $.TraceId, ts: $.Timestamp })) - .where(($) => [ - ...buildWhereConditions($, opts), - $.ParentSpanId.eq(""), - cursor - ? $.Timestamp.lt(cursor.timestamp).or( - $.Timestamp.eq(cursor.timestamp).and($.TraceId.lt(cursor.traceId)), - ) - : undefined, - ]) - .orderBy(["ts", "desc"], ["traceId", "desc"]) - .limit(limit) - if (offset > 0) { - page = page.offset(offset) + let pageSql: string + if (canUseTraceListMvStage1(opts)) { + // `trace_list_mv` is sorted `(OrgId, Timestamp, TraceId)`, so this pages + // read-in-order instead of scanning the window. Its Timestamp is + // second-granularity (`toDateTime`): the ns cursor from stage-2 + // `startTime` must be truncated to match, and the `(ts, traceId)` tuple + // ordering is what keeps pages disjoint despite the truncation ties. + const secCursor = cursor + ? { timestamp: cursor.timestamp.slice(0, 19), traceId: cursor.traceId } + : undefined + const mvBase = from(TraceListMv) + .select(($) => ({ traceId: $.TraceId, ts: $.Timestamp, d: $.Duration })) + .where(($) => [ + ...traceListMvWhereConditions($, opts), + secCursor + ? $.Timestamp.lt(secCursor.timestamp).or( + $.Timestamp.eq(secCursor.timestamp).and($.TraceId.lt(secCursor.traceId)), + ) + : undefined, + ]) + let page = ( + sortBy === "durationMs" + ? mvBase.orderBy(["d", sortDir], ["ts", sortDir], ["traceId", "desc"]) + : mvBase.orderBy(["ts", sortDir], ["traceId", "desc"]) + ).limit(limit) + if (offset > 0) { + page = page.offset(offset) + } + pageSql = compileCH(page, {}, { skipFormat: true }).sql + } else { + const pageBase = from(Traces) + .select(($) => ({ traceId: $.TraceId, ts: $.Timestamp, d: $.Duration })) + .where(($) => [ + ...buildWhereConditions($, opts), + $.ParentSpanId.eq(""), + cursor + ? $.Timestamp.lt(cursor.timestamp).or( + $.Timestamp.eq(cursor.timestamp).and($.TraceId.lt(cursor.traceId)), + ) + : undefined, + ]) + let page = ( + sortBy === "durationMs" + ? pageBase.orderBy(["d", sortDir], ["ts", sortDir], ["traceId", "desc"]) + : pageBase.orderBy(["ts", sortDir], ["traceId", "desc"]) + ).limit(limit) + if (offset > 0) { + page = page.offset(offset) + } + pageSql = compileCH(page, {}, { skipFormat: true }).sql } - const pageSql = compileCH(page, {}, { skipFormat: true }).sql // Lexicographic tuple ordering: true root first, earliest span as the // tiebreaker for the (malformed) traces that ship no root at all. const rootOrder = CH.rawExpr("(if(ParentSpanId = '', 0, 1), Timestamp)") - return from(TraceDetailSpans) + const aggregated = from(TraceDetailSpans) .select(($) => { const rootServiceName = argMin($.ServiceName, rootOrder) const startNanos = CH.toUnixTimestamp64Nano($.Timestamp) @@ -1500,6 +1670,10 @@ export function traceListQuery(opts: TraceListOpts) { startTime: argMin($.Timestamp, rootOrder), endTime: fromUnixTimestamp64Nano(endNanos), durationMicros: CH.intDiv(endNanos.sub(CH.min_(startNanos)), 1000), + // Stage 1's duration sort key, re-derived so stage 2 can return the + // page in the same order (the wall-clock extent above only exists + // after this aggregation, so it cannot drive pagination). + rootDurationMicros: CH.intDiv(argMin($.Duration, rootOrder), 1000), spanCount: CH.count(), services: arrayDistinct( arrayPushFront(arraySort(CH.groupUniqArray($.ServiceName)), rootServiceName), @@ -1521,10 +1695,23 @@ export function traceListQuery(opts: TraceListOpts) { }) .where(($) => [ $.OrgId.eq(param.string("orgId")), + // Padded, not exact: children can start slightly before their root + // (clock skew) or outlive the window, but they cannot drift a full + // hour — the same ±1h convention as the trace-detail partition hint + // (`computeTraceTimeWindow`). Without any bound this scans every + // retained partition for the PK analysis and times out on prod + // (measured: 12h window, unbounded >10s; bounded <10s). + $.Timestamp.gte(subtractHours(CH.toDateTime(param.dateTime("startTime")), CH.lit(1))), + $.Timestamp.lte(addHours(CH.toDateTime(param.dateTime("endTime")), CH.lit(1))), CH.rawCond(`TraceId IN (SELECT traceId FROM (${pageSql}))`), ]) .groupBy("traceId") - .orderBy(["startTime", "desc"], ["traceId", "desc"]) + + return ( + sortBy === "durationMs" + ? aggregated.orderBy(["rootDurationMicros", sortDir], ["startTime", sortDir], ["traceId", "desc"]) + : aggregated.orderBy(["startTime", sortDir], ["traceId", "desc"]) + ) .limit(limit) .format("JSON") } diff --git a/packages/query-engine/src/runtime/query-engine.ts b/packages/query-engine/src/runtime/query-engine.ts index 70e16221b..6a08d2947 100644 --- a/packages/query-engine/src/runtime/query-engine.ts +++ b/packages/query-engine/src/runtime/query-engine.ts @@ -255,6 +255,26 @@ function traceServicePartitionWindow( } } +/** + * `traceListQuery` ships its projected root-attribute map as a JSON string + * (`toJSONString` — Map columns can't survive an `argMin`). Decode defensively: + * a malformed value degrades to an empty map, never a thrown defect. + */ +function parseProjectedAttributes(raw: unknown): Record { + if (typeof raw !== "string" || raw.length === 0) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {} + const out: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string" && value.length > 0) out[key] = value + } + return out + } catch { + return {} + } +} + function servicesForTraceRow( rowServiceName: string, enrichedServices: readonly string[] | undefined, @@ -1626,6 +1646,51 @@ export const makeQueryEngineExecute = (warehouse: QueryEn const opts = extractTracesOpts(request.query.filters as Record) const requestedColumns = (tracesQuery as { columns?: readonly string[] }).columns + if (tracesQuery.groupByTrace) { + const rows = yield* executeCHQuery( + warehouse, + tenant, + (capabilities) => + CH.traceListQuery({ + ...opts, + // Stage 1 pins `ParentSpanId = ''` itself; the broader + // entry-point predicate would only widen the OR for nothing. + // (`rootOnly` compiles via `whenTrue`, so `false` = no clause.) + rootOnly: false, + attributeIndexMode: attributeIndexMode(capabilities, "traces"), + // The root-only predicate is as selective as the clamp's + // "indexed filter" tier, so grouped pages keep the 200 cap. + limit: Math.min(tracesQuery.limit ?? 25, 200), + offset: tracesQuery.offset, + sortBy: tracesQuery.sortBy, + sortDir: tracesQuery.sortDir, + }), + { orgId: tenant.orgId, startTime: request.startTime, endTime: request.endTime }, + "traceList", + "list", + ) + + return new QueryEngineExecuteResponse({ + result: { + kind: "list", + source: "traces", + data: rows.map((row) => ({ + traceId: row.traceId, + startTime: String(row.startTime), + endTime: String(row.endTime), + durationMs: Number(row.durationMicros) / 1000, + spanCount: Number(row.spanCount), + services: row.services.map(String), + rootSpanName: row.rootSpanName, + rootSpanKind: row.rootSpanKind, + rootSpanStatusCode: row.rootSpanStatusCode, + rootSpanAttributes: parseProjectedAttributes(row.rootSpanAttributes), + hasError: Number(row.hasError) === 1, + })), + }, + }) + } + // Graceful limit clamping: cap at 200, auto-reduce to 50 when no indexed filters const hasIndexedFilter = !!( opts.serviceName || diff --git a/packages/query-engine/src/sql-catalog.ts b/packages/query-engine/src/sql-catalog.ts index 237f75cea..654d36703 100644 --- a/packages/query-engine/src/sql-catalog.ts +++ b/packages/query-engine/src/sql-catalog.ts @@ -686,6 +686,40 @@ export const querySpecFixtures: ReadonlyArray = [ query: { kind: "list", source: "traces", limit: 50, filters: TRACES_FILTERS }, allCapabilities: true, }, + { + label: "traces-list-grouped", + query: { kind: "list", source: "traces", groupByTrace: true, limit: 50, filters: TRACES_FILTERS }, + allCapabilities: true, + }, + { + // An attribute filter the MV cannot express — covers the raw-`traces` + // stage-1 fallback of traceListQuery. + label: "traces-list-grouped-attr-fallback", + query: { + kind: "list", + source: "traces", + groupByTrace: true, + limit: 50, + filters: { + ...TRACES_FILTERS, + attributeFilters: [{ key: "user.id", value: "u1", mode: "equals" }], + }, + }, + allCapabilities: true, + }, + { + label: "traces-list-grouped-duration-sort", + query: { + kind: "list", + source: "traces", + groupByTrace: true, + limit: 50, + offset: 100, + sortBy: "durationMs", + sortDir: "desc", + filters: TRACES_FILTERS, + }, + }, // NB: `{kind: "list", source: "logs"}` is a declared QuerySpec variant that // `QueryEngineService.execute` does not implement — log lists only reach the // warehouse through the `list_logs` pipe, covered above.