Files
o11y/pkg/querier/narrow_window_traceid_test.go
zeekayandHanzo Dev f337a4b21e one /v1/o11y surface: kill /api and every version segment, unbreak main, migrate consumers to the event plane
MAIN HAD NOT COMPILED SINCE 2026-07-30. Commit 5e3210f759 rewrote tables.go in
three telemetry packages and the sentrytypes Event struct without updating their
consumers, while its message claimed the read paths 'build clean' — proven false
with a detached worktree at clean HEAD. That is why the deployed backend is
SHA-pinned and stale: CI could not build main at all.

THE RENAME: /api/v1|v2|v3|v4/<rest> -> /v1/o11y/<rest> (290 replacements; the
host already says api, so the path never does, and one live generation carries
no version segments). 21 duplicate v1 registrations DELETED with their v2 twins
kept — gorilla/mux is first-match, so after the collapse the v1 line would have
shadowed v2 silently, with no error, forever: rules (6), dashboards (6),
services/top_operations/entry_point_operations/ttl (5), features, query_range
v3+v4 -> v5, metrics/dashboards. 16 orphaned handler funcs (430 lines) deleted.

CONSUMERS MIGRATED to the one-way table names, never the reverse: tables.go's
deletion of the versioned constants was deliberate (stated three times), and the
new blocks are positional 1:1 rewrites of the old, so every resolution is a
constant the same commit declared — SpanIndexV3->span, LogsV2->log,
SamplesV4->metric, TimeseriesV4->series, UpdatedMetadata->descriptor, ambiguity
on operation-vs-trace settled by tables.go's own inline comments.

sentrytypes.Frame/Event.Frames/Event.Handled RECONSTRUCTED, not guessed: pinned
independently by eventsql.go's projection, eventstore.go's scan targets and
module.go's clampLine/InApp, and asserted literally by the pre-existing
TestFramesRoundTrip. TraceSummary.Sample -> Message (event.error has no sample
column). Event.Sample deleted: a field that always read "" while claiming to
hold the occurrence JSON.

PATH MANGLERS KILLED, both deployment shapes: mount.go rewriteExternalPath+
setPath deleted (it mapped /v1/o11y/services -> /api/services => 404 on the
cloud-binary path — proven with a real test before deletion); server.go
StripPrefix removed (it reduced /v1/o11y/services -> /services => the web
catch-all answered index.html on the standalone path). versionless.go +
versionless_test.go + both call sites deleted: its regex matched 0 of the
registered routes and walked the router at every startup to register nothing —
the second spelling of one path, gone with the mechanism that needed it.

FRONTEND: prependBase '/api/v1/' -> '/v1/o11y/' (both baseURL branches), 17
/api/v1/assistant literals renamed (note: no assistant route exists in pkg/ —
client-only surface, a real product gap, not papered over), 21 MSW mocks moved
so they intercept the paths the app now requests.

GATE, all executed: go build ./... green; go test ./... green (exit 0);
0 /api/v[0-9] references in pkg code (frontend's 5 are query-builder escaping
fixtures); 0 duplicate (method,path) registrations; /v1/o11y/services survives
both mount.go and server.go untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-31 14:07:44 -07:00

150 lines
6.8 KiB
Go

package querier
import (
"context"
"io"
"log/slog"
"testing"
sqlmock "github.com/DATA-DOG/go-sqlmock"
dsmock "github.com/hanzo-ds/mock"
"github.com/hanzoai/o11y/pkg/telemetrystore"
"github.com/hanzoai/o11y/pkg/telemetrystore/telemetrystoretest"
qbtypes "github.com/hanzoai/o11y/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// realTraceID is the live trace 9aa37a283590028516f27b45e3a4406a whose single
// gen_ai span (org=hanzo, model enso-flash, 211 tokens, $0.01) proved the read leg
// held the row all along — the detail view rendered "no observations" purely because
// the trace_id filter was a `$N` placeholder the trace-summary optimizer never
// resolved, short-circuiting the span query to empty before it ran.
const realTraceID = "9aa37a283590028516f27b45e3a4406a"
// llmobsDetailFilter is exactly what impllmobs.genAIFilter emits for an
// observations/traces DETAIL request: org bound as $1, trace_id bound as $2.
const llmobsDetailFilter = "gen_ai.system EXISTS AND gen_ai.hanzo.org_id = $1 AND trace_id = $2"
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func newTraceStore(t *testing.T) *telemetrystoretest.Provider {
t.Helper()
return telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
}
// TestNarrowWindowParameterizedTraceID is the regression test for the fleet-wide
// "trace shows, no observations" bug. The llmobs span views parameterize the
// trace_id predicate as `trace_id = $2` and carry the real id in the request
// Variables map; narrowWindowByTraceID reads the filter TEXT, so before the fix it
// looked up the literal "$2" in the trace summary table, found nothing, and — for
// SignalTraces — short-circuited the whole span query to empty (overlap=false).
func TestNarrowWindowParameterizedTraceID(t *testing.T) {
// The trace's nanosecond bounds; narrowWindowByTraceID pads by 1s and clamps the
// ms window to them.
const startNano = int64(1_700_000_000_000_000_000)
const endNano = int64(1_700_000_001_200_000_000)
fromMS := uint64(1_699_999_990_000)
toMS := uint64(1_700_000_010_000)
summaryCols := []dsmock.ColumnType{
{Name: "count", Type: "UInt64"},
{Name: "start", Type: "Int64"},
{Name: "end", Type: "Int64"},
}
// A bound placeholder resolves to the real id, so the trace_summary lookup runs,
// finds the trace, and the window is clamped to its bounds — the query is NOT
// short-circuited. (Both org=$1 and trace_id=$2 are bound, as in production.)
t.Run("bound placeholder runs the lookup and narrows the window", func(t *testing.T) {
store := newTraceStore(t)
store.Mock().ExpectQueryRow(`event\.trace`).
WillReturnRow(dsmock.NewRow(summaryCols, []any{uint64(1), startNano, endNano}))
q := &builderQuery[qbtypes.TraceAggregation]{
logger: discardLogger(),
telemetryStore: store,
spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: llmobsDetailFilter},
},
variables: map[string]qbtypes.VariableItem{
"1": {Type: qbtypes.DynamicVariableType, Value: "hanzo"},
"2": {Type: qbtypes.DynamicVariableType, Value: realTraceID},
},
}
gotFrom, gotTo, overlap, warning := q.narrowWindowByTraceID(context.Background(), fromMS, toMS)
require.True(t, overlap, "a resolvable parameterized trace_id must NOT short-circuit the span query")
assert.Empty(t, warning)
assert.Equal(t, uint64((startNano-1_000_000_000)/1_000_000), gotFrom, "from clamped to trace start")
assert.Equal(t, uint64((endNano+1_000_000_000)/1_000_000), gotTo, "to clamped to trace end")
require.NoError(t, store.Mock().ExpectationsWereMet(), "trace_summary lookup must run for a bound trace_id")
})
// An UNBOUND `$N` placeholder cannot be resolved to a concrete id. The fix skips
// the optimization entirely (overlap=true → the fully-substituted main query
// runs). Fail-before: the old code extracts the literal "$2", queries
// trace_summary (count=0 here) and short-circuits to empty (overlap=false) — this
// assertion fails. Pass-after: the lookup is skipped, so overlap=true and the mock
// is never called.
t.Run("unbound placeholder skips the optimization instead of short-circuiting", func(t *testing.T) {
store := newTraceStore(t)
// Only the pre-fix path reaches this; it simulates "$2" not existing in
// trace_summary. We deliberately do NOT assert ExpectationsWereMet, since the
// fixed path skips the query.
store.Mock().ExpectQueryRow(`event\.trace`).
WillReturnRow(dsmock.NewRow(summaryCols, []any{uint64(0), int64(0), int64(0)}))
q := &builderQuery[qbtypes.TraceAggregation]{
logger: discardLogger(),
telemetryStore: store,
spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: llmobsDetailFilter},
},
variables: map[string]qbtypes.VariableItem{}, // trace_id $2 is NOT bound
}
_, _, overlap, _ := q.narrowWindowByTraceID(context.Background(), fromMS, toMS)
require.True(t, overlap, "an unresolved $N placeholder must skip the optimization, never short-circuit to empty")
})
}
// TestResolveTraceIDVars covers the placeholder-resolution helper directly: this is
// the seam the whole bug turned on — the extractor hands back "$2", and this resolves
// it against the bound variables into the real id that reaches trace_summary.
func TestResolveTraceIDVars(t *testing.T) {
vars := map[string]qbtypes.VariableItem{
"2": {Type: qbtypes.DynamicVariableType, Value: realTraceID},
"tid": {Type: qbtypes.DynamicVariableType, Value: "abc123"},
"ids": {Type: qbtypes.DynamicVariableType, Value: []any{"a1", "b2"}},
}
q := &builderQuery[qbtypes.TraceAggregation]{variables: vars}
tests := []struct {
name string
in []string
want []string
resolved bool
}{
{name: "positional placeholder resolves to real id", in: []string{"$2"}, want: []string{realTraceID}, resolved: true},
{name: "named placeholder resolves", in: []string{"$tid"}, want: []string{"abc123"}, resolved: true},
{name: "list placeholder expands", in: []string{"$ids"}, want: []string{"a1", "b2"}, resolved: true},
{name: "inline literal passes through", in: []string{"deadbeef"}, want: []string{"deadbeef"}, resolved: true},
{name: "mixed literal and placeholder", in: []string{"lit", "$2"}, want: []string{"lit", realTraceID}, resolved: true},
{name: "unbound placeholder skips optimization", in: []string{"$9"}, want: nil, resolved: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := q.resolveTraceIDVars(tt.in)
assert.Equal(t, tt.resolved, ok)
if tt.resolved {
assert.Equal(t, tt.want, got)
}
})
}
}