From 154c7c88a91a8afdc8d08178e7e39cd72a5d0e37 Mon Sep 17 00:00:00 2001 From: FrameAutomata Date: Fri, 28 Aug 2026 17:48:12 -0500 Subject: [PATCH] fix: match the endpoint method filter case-insensitively getHTTPEndpoint concatenates http.request.method verbatim, so rows like "get /api/lowercase" genuinely exist. The dropdown only ever offers the 7 canonical uppercase methods and normalizeMethodFilter upper-cases what it is given, so no selection could reach those rows on any backend: they were in the list, counted in the unfiltered total, and invisible the moment a method was picked. Upper-cases the column rather than trusting the value's case, so the result cannot depend on how the caller cased its argument: embedded SUBSTR(endpoint, 1, N) = :method -> UPPER(SUBSTR(endpoint, 1, N)) = :method ClickHouse startsWith(endpoint, ?) -> startsWith(upper(endpoint), ?) SUBSTR stays rather than LIKE: methodFilter reaches SQL as a value, and LIKE would read % and _ in it as wildcards. upper() rather than upperUTF8() on ClickHouse because SQLite's UPPER is ASCII-only, and ASCII is the whole of the method-token range RFC 9110 allows -- so all three backends answer identically. This replaces the exclusion TestEndpointRepository_FindGroupedByEndpoint_ MethodFilter asserted. That assertion was deliberate when written (c7a5dcae aligned the embedded backends onto ClickHouse's long-standing case sensitivity), so it is worth being explicit that this is a decision reversed, not a bug: the reachability problem it left is #321. "GETAWAY /api/cars" still must not match -- the comparison is a whole space-terminated token, not a string prefix -- and that stays asserted. Verified against a running backend with GET/get/Get/GETAWAY/POST rows seeded: /api/endpoints/grouped and /api/endpoints/chart both return the same three GET-family rows for "GET", "get" and "GeT", POST returns one, and GETAWAY is excluded from all of them. Closes #321. Co-Authored-By: Claude Opus 5 (1M context) --- .../clickhouse/endpoint.repository.go | 5 +- .../telemetry/endpoint_repository_test.go | 49 ++++++++++++++++--- .../telemetry/shared/sqlfilter.go | 12 ++++- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/backend/app/repositories/telemetry/clickhouse/endpoint.repository.go b/backend/app/repositories/telemetry/clickhouse/endpoint.repository.go index 3aac4022..b272081f 100644 --- a/backend/app/repositories/telemetry/clickhouse/endpoint.repository.go +++ b/backend/app/repositories/telemetry/clickhouse/endpoint.repository.go @@ -112,7 +112,10 @@ func endpointFilterClause(col, search, rootFilter, methodFilter string) (string, clause += " AND " + col + "is_root = 0" } if methodFilter != "" { - clause += " AND startsWith(" + col + "endpoint, ?)" + // upper() not upperUTF8(): ASCII is the whole of RFC 9110's method token + // range, and the embedded backends' UPPER() is ASCII-only, so this keeps + // all three answering identically. + clause += " AND startsWith(upper(" + col + "endpoint), ?)" args = append(args, shared.MethodPrefix(methodFilter)) } return clause, args diff --git a/backend/app/repositories/telemetry/endpoint_repository_test.go b/backend/app/repositories/telemetry/endpoint_repository_test.go index d4488e1d..4523731e 100644 --- a/backend/app/repositories/telemetry/endpoint_repository_test.go +++ b/backend/app/repositories/telemetry/endpoint_repository_test.go @@ -204,19 +204,56 @@ func TestEndpointRepository_FindGroupedByEndpoint_MethodFilter(t *testing.T) { t.Fatalf("InsertAsync failed: %v", err) } + // "GETAWAY /api/cars" must not match: the filter compares a whole + // space-terminated token, not a prefix of the endpoint string. + // "get /api/lowercase" must match. getHTTPEndpoint concatenates + // http.request.method verbatim, so lowercase rows genuinely exist, and the + // dropdown only ever offers the 7 canonical uppercase methods -- a + // case-sensitive comparison made those rows unreachable from the UI (#321). stats, total, err := EndpointRepository.FindGroupedByEndpoint(ctx, projectId, now.Add(-time.Hour), now.Add(time.Hour), 1, 10, "count", "desc", "", "", "get") if err != nil { t.Fatalf("FindGroupedByEndpoint with method filter failed: %v", err) } - if total != 1 { - t.Errorf("expected 1 matching endpoint, got %d", total) + if total != 2 { + t.Errorf("expected 2 matching endpoints, got %d", total) } - if len(stats) != 1 { - t.Fatalf("expected 1 grouped stat, got %d", len(stats)) + matched := make(map[string]bool, len(stats)) + for _, s := range stats { + matched[s.Endpoint] = true } - if stats[0].Endpoint != "GET /api/users" { - t.Errorf("expected 'GET /api/users', got %q", stats[0].Endpoint) + if len(stats) != 2 || !matched["GET /api/users"] || !matched["get /api/lowercase"] { + t.Fatalf("expected GET /api/users and get /api/lowercase, got %v", matched) + } +} + +// The filter is applied by upper-casing the column, not the caller's argument, +// so it holds however the request cased its method. +func TestEndpointRepository_FindGroupedByEndpoint_MethodFilterCaseInsensitive(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + endpoints := []models.Endpoint{ + makeEndpoint(projectId, "GET /api/users", 100*time.Millisecond, 200, now), + makeEndpoint(projectId, "get /api/lowercase", 100*time.Millisecond, 200, now.Add(time.Minute)), + makeEndpoint(projectId, "Get /api/mixed", 100*time.Millisecond, 200, now.Add(2*time.Minute)), + makeEndpoint(projectId, "POST /api/users", 200*time.Millisecond, 201, now.Add(3*time.Minute)), + } + + if err := EndpointRepository.InsertAsync(ctx, endpoints); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + for _, method := range []string{"GET", "get", "GeT"} { + _, total, err := EndpointRepository.FindGroupedByEndpoint(ctx, projectId, now.Add(-time.Hour), now.Add(time.Hour), 1, 10, "count", "desc", "", "", method) + if err != nil { + t.Fatalf("methodFilter %q failed: %v", method, err) + } + if total != 3 { + t.Errorf("methodFilter %q: expected 3 matching endpoints, got %d", method, total) + } } } diff --git a/backend/app/repositories/telemetry/shared/sqlfilter.go b/backend/app/repositories/telemetry/shared/sqlfilter.go index 45143d2c..54ab057d 100644 --- a/backend/app/repositories/telemetry/shared/sqlfilter.go +++ b/backend/app/repositories/telemetry/shared/sqlfilter.go @@ -19,16 +19,26 @@ func RootFilterClause(qualifiedCol, rootFilter string) string { } } +// MethodPrefix is the uppercase "GET " form every backend compares against. +// The stored method is whatever the instrumented service reported -- +// getHTTPEndpoint concatenates http.request.method verbatim -- so the column +// side is upper-cased at query time rather than the value being trusted. func MethodPrefix(method string) string { return strings.ToUpper(method) + " " } +// MethodFilterClause matches the method case-insensitively. SUBSTR rather than +// LIKE because methodFilter reaches SQL as a value and LIKE would read % and _ +// in it as wildcards; UPPER around the column rather than a second bound +// parameter so the comparison cannot depend on how the caller cased its input. +// UPPER is ASCII-only in SQLite, which is exactly the range RFC 9110 allows a +// method token. func MethodFilterClause(qualifiedCol, method string) (clause string, param string) { if method == "" { return "", "" } prefix := MethodPrefix(method) - return fmt.Sprintf(" AND SUBSTR(%s, 1, %d) = :method", qualifiedCol, len(prefix)), prefix + return fmt.Sprintf(" AND UPPER(SUBSTR(%s, 1, %d)) = :method", qualifiedCol, len(prefix)), prefix } // SortedKeys returns map keys in stable order so generated SQL and its