fix: compute list percentiles over the filtered rows (tasks, AI traces, endpoints) - #319
Open
FrameAutomata wants to merge 4 commits into
Open
fix: compute list percentiles over the filtered rows (tasks, AI traces, endpoints)#319FrameAutomata wants to merge 4 commits into
FrameAutomata wants to merge 4 commits into
Conversation
The same split #318 fixes for endpoints was live in two sibling repositories on the SQLite telemetry backend. `FindGroupedByTaskName` and `FindGroupedByTraceName` both apply their search + root filter to the grouping query, then compute percentiles from a second query that carried no filter at all. So a filtered `count` was reported next to unfiltered P50/P95 -- and because both lists sort and paginate on those values (`orderBy=p50_duration|p95_duration|impact` takes the Go-sort path in tasks, and the AI-traces list orders on them directly), the page was ordered by the population the user had just filtered out, not only mislabelled. DuckDB and ClickHouse compute the percentiles inline in the filtered query and were already correct, so this was also a storage-backend parity break: identical data, different numbers and different row order. Both files now route the selecting query and the duration query through one clause builder -- `taskFilterClause` / `aiTraceFilterClause`, mirroring `endpointFilterClause` -- so the two cannot drift apart again, and a predicate added there is picked up by both. In tasks that also removes a second, hand-maintained copy of the search parameter: the group query's parameter map is now copied from the filtered one rather than re-deriving one leg of it. Verified end-to-end, not just in tests: two binaries on the same seeded database populated over real OTLP ingest, queried over HTTP. tasks rootFilter=root main: count=4 p50=5050ms p95=10000ms branch: count=4 p50=100ms p95=100ms ai-traces rootFilter=root main: count=4 p50=15100ms p95=30000ms branch: count=4 p50=200ms p95=200ms `rootFilter=all` is identical on both. Refs #313 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups on the task/AI-trace percentile fix. FindGroupedByTaskName kept two parameter maps, the second a copy of the first plus pagination. The copy is unnecessary: lit's ParseNamedQuery resolves :names against the map and ignores keys the query never references, and the count query consumes params and returns before limit/offset are added, so one map serves both. That also matches the AI-trace path in this same change and the DuckDB twin, and it means a future filter leg reaches the group query without anyone remembering to propagate it. The AI-trace duration query now names its filter clause instead of splicing a params-mutating call into the middle of the argument list that also passes those params -- the form the rest of the package uses. Comments: the new helpers claimed to be "the single definition" of each list's filter, which is not true across backends (the DuckDB twins still inline the same construction). They now describe what they actually do, including the params mutation the signature does not advertise. The comment on fetchSortedTaskDurations restated its own signature and is gone; the tests are the durable record. Also adds the reordering coverage the percentile test's comment claimed but never exercised. orderBy=p95_duration takes the Go-sort path, so unfiltered percentiles reorder the page and not just its numbers; the new test has two tasks that rank one way on all rows and the other way on root rows alone, and it fails on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts caller Review follow-ups. FindGroupedByTraceName sorts and paginates in Go on P50/P95, exactly like the task list, so unfiltered percentiles reorder the page and not just its numbers -- but the AI test only covered the reporting half under orderBy=count, where ordering cannot fail. A regression that reverted only the ai_trace change would have passed. The new test mirrors the task one and fails on main. taskFilterClause sat ~350 lines below its only two callers, while the helpers it mirrors (aiTraceFilterClause, endpointFilterClause) both sit directly above the grouped-list function they serve. Moved, no other change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Builds on 95a65d2, which fixed the endpoint percentile/filter split while this branch was in review. That commit threads rootFilter into fetchSortedDurations and splices shared.RootFilterClause directly. Two follow-ups. First, the duration query now goes through endpointFilterClause like the queries that select the endpoint, instead of picking out the one leg that constrains rows today. There is no behaviour change: with the endpoint pinned by `endpoint = :endpoint`, the search and method legs cannot alter the row set, so only rootFilter is load-bearing. What it buys is that a row-level predicate added to endpointFilterClause reaches the percentile query automatically. Splicing one leg by hand is how the original split happened, and it is the shape the task and AI-trace repositories now share. Second, a regression test for the chart ranking. 95a65d2 fixed getTopEndpointsByMetric along with the grouped list, but only the grouped list got a test. The chart ranks its top 5 on percentiles and then plots only the filtered rows, so ranking on the unfiltered population puts endpoints on the chart that are not slow under the filter. This test pins that; it passes on main, so it guards the fix rather than reporting a live bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FrameAutomata
force-pushed
the
fix/task-ai-trace-percentile-parity
branch
from
August 26, 2026 21:33
2dad44b to
772a089
Compare
This was referenced Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #313. Rebased onto current
main(which now carries95a65d24), and absorbs what was left of #318 — that PR is closed.The bug
Three list views computed their percentiles from a second query that carried no filter, while the grouping query that selected the rows applied a search/root filter. The percentile therefore described a different population than the rows the user asked for.
This is worse than a mislabelled column. All three lists sort and paginate on those percentiles — tasks via the
needsGoSortpath fororderBy=p50_duration|p95_duration|impact, AI traces viaorderByMapdirectly, endpoints viasortEndpointStatsand the chart's top-5 ranking. So the page was ordered by the population the user had just filtered out. Thecountcolumn was already correct, which is exactly why it stayed invisible.SQLite-only: DuckDB and ClickHouse compute the percentiles inline in the filtered aggregate. So it was also a backend parity break — identical data, different numbers and different row order.
What is fixed here vs already on
main95a65d24fixed the endpoints case while this work was in review, threadingrootFilterintofetchSortedDurations. It did not touch tasks or AI traces.main; this PR adds forward-compat + the missing testThe fix
Each file routes the selecting query and the duration query through one clause builder —
taskFilterClause/aiTraceFilterClause/ the existingendpointFilterClause— so the two cannot drift apart, and a predicate added there is picked up by both.The endpoint change carries no behaviour change: with the endpoint pinned by
endpoint = :endpoint, thesearchandmethodlegs cannot alter the row set, sorootFilteris the only load-bearing one. It buys that a future row-level predicate reaches the percentile query automatically. Picking one leg out by hand is how the original split happened, and all three entities now read the same way — which is why it belongs here rather than in a PR of its own.Also drops a second parameter map in
FindGroupedByTaskName: lit'sParseNamedQueryignores keys the query never references, and the count query consumesparamsbefore pagination is added, so one map serves both.Verification
Four regression tests. The three that cover a live bug fail on the pre-fix code with exactly these values:
The fifth —
TestEndpointRepository_GetEndpointStackedChart_RanksOnFilteredRows— passes onmain.95a65d24fixed that path but only tested the grouped list, so this guards the existing fix rather than reporting a live bug. Stating that so a green run is not mistaken for "reproduced and fixed."All tests live in the untagged
telemetrypackage, so they run against all three backends and pass on DuckDB and ClickHouse unchanged — that contrast is the parity assertion.Also reproduced end-to-end, not just in tests. Two binaries on the same seeded SQLite database, populated over real OTLP ingest (root and non-root CONSUMER spans for tasks,
gen_ai.*spans for AI traces), queried over HTTP:mainrootFilter=allcount=8 p50=5050ms p95=10000msrootFilter=rootcount=4 p50=5050ms p95=10000ms❌count=4 p50=100ms p95=100ms✅rootFilter=rootcount=4 p50=15100ms p95=30000ms❌count=4 p50=200ms p95=200ms✅Local:
go test -race -count=1 ./app/..., the DuckDB-tagged suites,go vet ./app/...,gofmt— all clean.Follow-ups, not in scope
idx_endpoints_project_endpointis(project_id, endpoint)with norecorded_at, so per-group duration queries scan a group's whole retained history to answer a short window.(group, duration)partitioned in Go — the shapegetStackedChartWithPercentilesalready uses — removes the drift and the per-group N+1. The tests here protect that refactor.🤖 Generated with Claude Code