mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
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]>
150 lines
6.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|