Files
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

199 lines
6.0 KiB
Go

package implpromote
import (
"context"
"maps"
"slices"
"strings"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/modules/promote"
"github.com/hanzoai/o11y/pkg/telemetrylogs"
"github.com/hanzoai/o11y/pkg/telemetrystore"
"github.com/hanzoai/o11y/pkg/types/ctxtypes"
"github.com/hanzoai/o11y/pkg/types/instrumentationtypes"
"github.com/hanzoai/o11y/pkg/types/promotetypes"
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
schemamigrator "github.com/hanzoai/otel-collector/cmd/o11yschemamigrator/schema_migrator"
)
var (
CodeFailedToCreateIndex = errors.MustNewCode("failed_to_create_index_promoted_paths")
CodeFailedToQueryPromotedPaths = errors.MustNewCode("failed_to_query_promoted_paths")
)
type module struct {
metadataStore telemetrytypes.MetadataStore
telemetryStore telemetrystore.TelemetryStore
}
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore) promote.Module {
return &module{metadataStore: metadataStore, telemetryStore: telemetrystore}
}
func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetypes.PromotePath, error) {
indexes, err := m.metadataStore.ListLogsJSONIndexes(ctx)
if err != nil {
return nil, err
}
aggr := map[string][]promotetypes.WrappedIndex{}
for _, index := range indexes {
aggr[index.Name] = append(aggr[index.Name], promotetypes.WrappedIndex{
FieldDataType: index.FieldDataType,
Type: index.IndexType,
Granularity: index.Granularity,
})
}
promotedPaths, err := m.listPromotedPaths(ctx)
if err != nil {
return nil, err
}
response := []promotetypes.PromotePath{}
for _, path := range promotedPaths {
fullPath := telemetrylogs.BodyPromotedColumnPrefix + path
path = telemetrytypes.BodyJSONStringSearchPrefix + path
item := promotetypes.PromotePath{
Path: path,
Promote: true,
}
indexes, ok := aggr[fullPath]
if ok {
item.Indexes = indexes
delete(aggr, fullPath)
}
response = append(response, item)
}
// add the paths that are not promoted but have indexes
for path, indexes := range aggr {
path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
path = telemetrytypes.BodyJSONStringSearchPrefix + path
response = append(response, promotetypes.PromotePath{
Path: path,
Indexes: indexes,
})
}
return response, nil
}
func (m *module) listPromotedPaths(ctx context.Context) ([]string, error) {
paths, err := m.metadataStore.GetPromotedPaths(ctx)
if err != nil {
return nil, err
}
return slices.Collect(maps.Keys(paths)), nil
}
// PromotePaths inserts provided JSON paths into the promoted paths table for logs queries.
func (m *module) PromotePaths(ctx context.Context, paths []string) error {
if len(paths) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "paths cannot be empty")
}
return m.metadataStore.PromotePaths(ctx, paths...)
}
// createIndexes creates string ngram + token filter indexes on JSON path subcolumns for LIKE queries.
func (m *module) createIndexes(ctx context.Context, indexes []schemamigrator.Index) error {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
instrumentationtypes.CodeNamespace: "promote",
instrumentationtypes.CodeFunctionName: "createIndexes",
})
if len(indexes) == 0 {
return nil
}
for _, index := range indexes {
alterStmt := schemamigrator.AlterTableAddIndex{
Database: telemetrylogs.DBName,
Table: telemetrylogs.LogLocalTableName,
Index: index,
}
op := alterStmt.OnCluster(m.telemetryStore.Cluster())
if err := m.telemetryStore.Datastore().Exec(ctx, op.ToSQL()); err != nil {
return errors.WrapInternalf(err, CodeFailedToCreateIndex, "failed to create index")
}
}
return nil
}
// PromoteAndIndexPaths handles promoting paths and creating indexes in one call.
func (m *module) PromoteAndIndexPaths(
ctx context.Context,
paths ...*promotetypes.PromotePath,
) error {
if len(paths) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "paths cannot be empty")
}
pathsStr := []string{}
// validate the paths
for _, path := range paths {
if err := path.ValidateAndSetDefaults(); err != nil {
return err
}
pathsStr = append(pathsStr, path.Path)
}
existingPromotedPaths, err := m.metadataStore.GetPromotedPaths(ctx, pathsStr...)
if err != nil {
return err
}
var toInsert []string
indexes := []schemamigrator.Index{}
for _, it := range paths {
if it.Promote {
if _, promoted := existingPromotedPaths[it.Path]; !promoted {
toInsert = append(toInsert, it.Path)
}
}
if len(it.Indexes) > 0 {
parentColumn := telemetrylogs.LogsV2BodyV2Column
// if the path is already promoted or is being promoted, add it to the promoted column
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn
}
for _, index := range it.Indexes {
var typeIndex schemamigrator.IndexType
switch {
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeNGramBF)):
typeIndex = schemamigrator.IndexTypeNGramBF
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeTokenBF)):
typeIndex = schemamigrator.IndexTypeTokenBF
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeMinMax)):
typeIndex = schemamigrator.IndexTypeMinMax
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid index type: %s", index.Type)
}
indexes = append(indexes, schemamigrator.Index{
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, index.JSONDataType.StringValue(), typeIndex),
Expression: schemamigrator.JSONSubColumnIndexExpr(parentColumn, it.Path, index.JSONDataType.StringValue()),
Type: index.Type,
Granularity: index.Granularity,
})
}
}
}
if len(toInsert) > 0 {
err := m.PromotePaths(ctx, toInsert)
if err != nil {
return err
}
}
if len(indexes) > 0 {
if err := m.createIndexes(ctx, indexes); err != nil {
return err
}
}
return nil
}