Files
o11y/pkg/telemetrytraces/span_scope_simple_test.go
zeekayandHanzo Dev ded468300a readers: the query builders speak the envelope, not the fork's dead dialect
The traces and logs statement/condition builders emitted the OTLP-fork columns
(timestamp, duration_nano, attributes_string/number/bool, resources_string,
resource_string_service$name, ts_bucket_start, parent_span_id, trace_flags,
scope_name) against event.span / event.log, which carry the 15-column envelope.
Every span lookup and log search failed Code 47 UNKNOWN_IDENTIFIER — executed and
proven before this change, not inferred.

TWO COLUMNS ADDED to event.span and event.log because no rewrite can satisfy them:
  resource_fingerprint String — the builders stamp
    'resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)'
    on the MAIN table, and __resource_filter reads event.span_resource /
    event.log_resource keyed (org, seen_at_ts_bucket_start, fingerprint). The
    fingerprint is a write-time hash of the whole resource label set — derivable
    from NO envelope column. event.log's pre-existing UInt64 'resource' is
    type-incompatible and empty.
  ts_bucket_start UInt64 MATERIALIZED intDiv(toUnixTimestamp(time),1800)*1800 —
    the builders put bucket-range predicates directly on the main table.
    MATERIALIZED-from-time keeps writers 15-column-envelope-only and shares ONE
    bucketing scheme with the four support tables that already speak
    seen_at_ts_bucket_start.
EVERYTHING ELSE REWRITTEN onto the envelope — one naming scheme wins:
timestamp->time, parent_span_id->parent, duration_nano->duration,
kind_string->kind (the live kind IS the string enum, so numeric kind/status_code
predicates translate by enum NAME), status_code_string->status,
has_error->status='error', attributes_string[k]->attributes[k],
attributes_number[k]->toFloat64OrNull(attributes[k]),
attributes_bool[k]->attributes[k]='true', resources_string['service.name'] and
resource_string_service$name->service, http_host->host, the calculated columns
to attribute expressions, and the 28 deprecated camelCase aliases through
oldToNew to the SAME envelope targets — never to table columns.

spantypes TraceDB/TraceSummaryTable move o11y_traces/distributed_trace_summary ->
event/trace; impltracedetail builds its FROM from those constants. Swapping them
ALONE panics TestGetTraceSummary on a nil pointer — the store and its test move
together, which is why they are in this commit and not a smaller one.

Pins moved deliberately, each with the reason in place. live_proof_test.go in
three packages executes the builders' emitted SQL against the real store, so the
next dialect drift fails loudly instead of at runtime.

go build ./... and go test ./... green across every package.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 13:11:33 -07:00

166 lines
5.3 KiB
Go

package telemetrytraces
import (
"context"
"testing"
"github.com/hanzoai/o11y/pkg/datastoresql"
"github.com/hanzo-ds/sqlbuilder"
"github.com/hanzoai/o11y/pkg/instrumentation/instrumentationtest"
"github.com/hanzoai/o11y/pkg/querybuilder"
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSpanScopeFilterExpression(t *testing.T) {
// Test that span scope fields work in filter expressions
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
tests := []struct {
name string
expression string
expectedCondition string
expectError bool
startNs uint64
}{
{
name: "simple isroot filter",
expression: "isroot = true",
expectedCondition: "parent = ''",
startNs: 1761437108000000000,
},
{
name: "simple isentrypoint filter (unbounded)",
expression: "isentrypoint = true",
expectedCondition: "((name, service) GLOBAL IN (SELECT DISTINCT name, serviceName from event.operation)) AND parent != ''",
startNs: 0,
},
{
name: "simple isentrypoint filter (bounded)",
expression: "isentrypoint = true",
expectedCondition: "((name, service) GLOBAL IN (SELECT DISTINCT name, serviceName from event.operation WHERE time >= toDateTime(1761437108))) AND parent != ''",
startNs: 1761437108000000000,
},
{
name: "combined filter with AND",
expression: "isroot = true AND has_error = true",
expectedCondition: "parent = ''",
startNs: 1761437108000000000,
},
{
name: "combined filter with OR",
expression: "isentrypoint = true OR has_error = true",
expectedCondition: "((name, service) GLOBAL IN (SELECT DISTINCT name, serviceName from event.operation WHERE time >= toDateTime(1761437108))) AND parent != ''",
startNs: 1761437108000000000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Parse the expression and build the where clause
sb := sqlbuilder.NewSelectBuilder()
// Prepare field keys for span scope fields
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)
fieldKeys["isroot"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "isroot",
FieldContext: telemetrytypes.FieldContextSpan,
}}
fieldKeys["isentrypoint"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "isentrypoint",
FieldContext: telemetrytypes.FieldContextSpan,
}}
fieldKeys["has_error"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "has_error",
FieldContext: telemetrytypes.FieldContextSpan,
}}
whereClause, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: fieldKeys,
Builder: sb,
StartNs: tt.startNs,
EndNs: 1761458708000000000,
})
if tt.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
require.NotNil(t, whereClause)
// Apply the where clause to the builder and get the SQL
sb.AddWhereClause(whereClause.WhereClause)
whereSQL, _ := sb.BuildWithFlavor(datastoresql.Flavor)
t.Logf("Generated SQL: %s", whereSQL)
assert.Contains(t, whereSQL, tt.expectedCondition)
}
})
}
}
func TestSpanScopeWithResourceFilter(t *testing.T) {
// Test that span scope fields are marked as SkipResourceFilter
tests := []struct {
name string
expression string
}{
{
name: "isroot should skip resource filter",
expression: "isroot = true",
},
{
name: "isentrypoint should skip resource filter",
expression: "isentrypoint = true",
},
{
name: "combined expression should skip resource filter",
expression: "isroot = true AND service.name = 'api'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// For now, just verify the expression parses correctly
// In a real implementation, we'd need to check that the resource filter
// is properly skipped when span scope fields are present
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
// Prepare field keys for the test
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)
fieldKeys["isroot"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "isroot",
FieldContext: telemetrytypes.FieldContextSpan,
}}
fieldKeys["isentrypoint"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "isentrypoint",
FieldContext: telemetrytypes.FieldContextSpan,
}}
fieldKeys["service.name"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
}}
_, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: fieldKeys,
SkipResourceFilter: false, // This would be set by the statement builder
StartNs: 1761437108000000000,
EndNs: 1761458708000000000,
})
assert.NoError(t, err)
})
}
}