Skip to content

SQLite/DuckDB endpoint-chart parity: top-5 ranked on unfiltered percentiles, and the method filter silently became case-sensitive #313

Description

@FrameAutomata

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. LIKESUBSTR(...) = 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions