Two defects in c7a5dcae, both in the endpoint filtering/charting path. Found during review of #309 / #312 (unrelated to that work). Both verified by reading the code on c7a5dcae; neither is reproduced against a live dataset yet.
1. SQLite chart top-5 is ranked on unfiltered percentiles (backend parity break)
backend/app/repositories/telemetry/sqlite/endpoint.repository.go:929
getTopEndpointsByMetric filters the candidate list with filterClause:
epRows, err := lit.SelectNamed[distinctEndpointRow](db.TelemetryDB,
`SELECT DISTINCT endpoint FROM endpoints WHERE project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND is_stream = 0`+filterClause,
params)
…but then ranks each candidate with fetchSortedDurations, whose query (:844) carries no is_root, method, or search predicate:
"SELECT duration FROM endpoints WHERE project_id = :project_id AND endpoint = :endpoint AND recorded_at >= :from AND recorded_at <= :to AND is_stream = 0 ORDER BY duration ASC"
So candidates are selected under the filter but ordered by an unfiltered percentile, while getStackedChartWithPercentiles plots only the filtered rows.
Failure scenario. POST /api/endpoints/chart with metricType="p95" and rootFilter="root" on a dual-SQLite deployment. An endpoint whose non-root rows are slow but whose root rows are fast is ranked into the top 5 (or above a genuinely slower endpoint), yet the series plotted for it contains only root rows.
Parity impact. The DuckDB twin does not have this bug — its ranking query carries filterClause inline. The two embedded backends therefore return different top-5 lists for identical data, which is the class of divergence transactional/parity_test.go exists to prevent (though it covers the transactional axis, not this one).
2. LIKE → SUBSTR(...) = made the method filter case-sensitive
backend/app/repositories/telemetry/shared/sqlfilter.go:31
Before c7a5dcae:
return " AND " + qualifiedCol + " LIKE :method"
After:
return fmt.Sprintf(" AND SUBSTR(%s, 1, %d) = :method", qualifiedCol, len(prefix)), prefix
SQLite's LIKE folds ASCII case by default. Equality on SUBSTR is BINARY-collated — the endpoints table declares no COLLATE NOCASE — so the filter is now case-sensitive against a bound param that is always strings.ToUpper(method) + " ".
Why rows can be lowercase. otelcontrollers/trace_converter.go:462 getHTTPEndpoint reads http.request.method / http.method and concatenates it verbatim, with no ToUpper:
method := getStringAttribute(attrs, "http.request.method")
if method == "" {
method = getStringAttribute(attrs, "http.method")
}
...
return method + " " + route
Any instrumentation reporting a lowercase method (the legacy http.method attribute is not constrained the way semconv constrains http.request.method) produces rows stored as get /api/users.
Failure scenario. Selecting GET in the endpoints filter on the default self-hosted backend returns zero rows for those endpoints where it previously returned them — silently, with no error.
The new test at endpoint_repository_test.go:214 codifies the new behaviour, so the regression for already-ingested data isn't called out anywhere.
Two candidate fixes, depending on intent:
- normalise at ingest (
ToUpper in getHTTPEndpoint) — doesn't help rows already stored
- make the comparison case-insensitive (
COLLATE NOCASE on the predicate, or UPPER(SUBSTR(...))) — keeps the index-friendly prefix form while restoring the old semantics
Not included
The same review flagged several lower-confidence items in c7a5dcae I did not verify and am not asserting: a discarded bind-arg list in the ClickHouse FindGroupedByEndpoint, an $effect race on /organization, the endpoints page applying unsubmitted filters to the chart on metric change, gofmt having interleaved project imports into stdlib blocks in three files, and gofmt/go vet running only in the SQLite CI job. Worth a look, but they need confirming before anyone acts on them.
Two defects in
c7a5dcae, both in the endpoint filtering/charting path. Found during review of #309 / #312 (unrelated to that work). Both verified by reading the code onc7a5dcae; neither is reproduced against a live dataset yet.1. SQLite chart top-5 is ranked on unfiltered percentiles (backend parity break)
backend/app/repositories/telemetry/sqlite/endpoint.repository.go:929getTopEndpointsByMetricfilters the candidate list withfilterClause:…but then ranks each candidate with
fetchSortedDurations, whose query (:844) carries nois_root, method, or search predicate:"SELECT duration FROM endpoints WHERE project_id = :project_id AND endpoint = :endpoint AND recorded_at >= :from AND recorded_at <= :to AND is_stream = 0 ORDER BY duration ASC"So candidates are selected under the filter but ordered by an unfiltered percentile, while
getStackedChartWithPercentilesplots only the filtered rows.Failure scenario.
POST /api/endpoints/chartwithmetricType="p95"androotFilter="root"on a dual-SQLite deployment. An endpoint whose non-root rows are slow but whose root rows are fast is ranked into the top 5 (or above a genuinely slower endpoint), yet the series plotted for it contains only root rows.Parity impact. The DuckDB twin does not have this bug — its ranking query carries
filterClauseinline. The two embedded backends therefore return different top-5 lists for identical data, which is the class of divergencetransactional/parity_test.goexists to prevent (though it covers the transactional axis, not this one).2.
LIKE→SUBSTR(...) =made the method filter case-sensitivebackend/app/repositories/telemetry/shared/sqlfilter.go:31Before
c7a5dcae:After:
SQLite's
LIKEfolds ASCII case by default. Equality onSUBSTRis BINARY-collated — theendpointstable declares noCOLLATE NOCASE— so the filter is now case-sensitive against a bound param that is alwaysstrings.ToUpper(method) + " ".Why rows can be lowercase.
otelcontrollers/trace_converter.go:462getHTTPEndpointreadshttp.request.method/http.methodand concatenates it verbatim, with noToUpper:Any instrumentation reporting a lowercase method (the legacy
http.methodattribute is not constrained the way semconv constrainshttp.request.method) produces rows stored asget /api/users.Failure scenario. Selecting
GETin the endpoints filter on the default self-hosted backend returns zero rows for those endpoints where it previously returned them — silently, with no error.The new test at
endpoint_repository_test.go:214codifies the new behaviour, so the regression for already-ingested data isn't called out anywhere.Two candidate fixes, depending on intent:
ToUpperingetHTTPEndpoint) — doesn't help rows already storedCOLLATE NOCASEon the predicate, orUPPER(SUBSTR(...))) — keeps the index-friendly prefix form while restoring the old semanticsNot included
The same review flagged several lower-confidence items in
c7a5dcaeI did not verify and am not asserting: a discarded bind-arg list in the ClickHouseFindGroupedByEndpoint, an$effectrace on/organization, the endpoints page applying unsubmitted filters to the chart on metric change,gofmthaving interleaved project imports into stdlib blocks in three files, andgofmt/go vetrunning only in the SQLite CI job. Worth a look, but they need confirming before anyone acts on them.