Files
o11y/pkg/sqlmigration/101_add_sentry_projects.go
zeekay 6c1edbbafc feat(sentry): Hanzo Sentry product face at /v1/sentry — compose, don't refork
Full Sentry-parity error/log/trace product as a new /v1/sentry API namespace on
the shared o11y substrate. It COMPOSES existing machinery rather than reforking:

- Ingest engine REUSED verbatim (errortracking envelope/parse/fingerprint/DSN/
  scrub/limiter) via a new deliberate exported surface (implerrortracking/reuse.go).
  One engine, two product faces (org-keyed errortracking + project-keyed sentry).
- Projects: relational DSN-bearing unit under an IAM org (o11y_sentry_projects,
  migration 101). DSN derived from KMS secret + project id + key_version, never
  stored; rotation is a single-row watermark bump. Fail-closed ingest Resolve.
- Events plane: columnar datastore table o11y_sentry.o11y_sentry_events (the
  finished sink the errortracking OccurrenceSink note deferred — org+project
  scoped, ts-bucketed). Pure, allowlist-checked SQL builders (Discover/events/
  stats/logs/traces) — org_id+project_id are always the leading bound predicates,
  every column/agg/interval resolved through a fixed allowlist (injection-proof).
- Issues REUSE the o11y_issues lifecycle verbatim; the project filter is the
  events-plane fingerprint projection (server-only IssuesQuery.Fingerprints, no
  query tag). Trace detail REUSES tracedetail, gated by an events-plane tenant check.
- Clean routes: /v1/sentry/* with NO /api/ segment; ingest {project} is UUID-
  constrained and registered after the static resource routes; server.go passes
  /v1/sentry/ straight to the router (no /v1/o11y rewrite).

Tenant isolation is server-side from the validated IAM principal on every read;
a foreign project/trace id returns zero rows, never a leak. Secrets from KMS.

HONEST: the events DDL is unit-tested (clickhouse-go-mock round-trip + pure
builders) but NOT byte-verified against a live datastore (none reachable this
build); on a multi-shard datastore it needs the local+Distributed(ON CLUSTER)
split. See createSchemaDDL caveat.

Tests: 24 sentry tests green incl tenant isolation (TestProjectStore_TenantIsolation,
TestReads_ForeignProjectDenied, TestResolveIngest_FailsClosed, TestListIssues_
ProjectFilterViaEventsPlane, TestTraceDetail_ForeignTraceNotFound) + Discover
injection rejection + DSN rotation watermark.
2026-07-11 11:58:23 -07:00

82 lines
3.2 KiB
Go

package sqlmigration
import (
"context"
"github.com/hanzoai/o11y/pkg/factory"
"github.com/hanzoai/o11y/pkg/sqlschema"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// addSentryProjects creates o11y_sentry_projects — the DSN-bearing unit of the Hanzo
// Sentry product face. It is a thin relational row: the DSN is NEVER stored (it is
// derived from the platform ingest secret + id + key_version), so rotation is a
// single-row bump. Raw events live on the columnar datastore plane and grouped-issue
// lifecycle stays in o11y_issues — this table only names projects and carries the DSN
// key watermark. The unique index on (org_id, slug) is the per-org name key; the index
// on (org_id) serves the org-scoped project list.
type addSentryProjects struct {
sqlschema sqlschema.SQLSchema
sqlstore sqlstore.SQLStore
}
func NewAddSentryProjectsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_sentry_projects"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
return &addSentryProjects{sqlschema: sqlschema, sqlstore: sqlstore}, nil
})
}
func (migration *addSentryProjects) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addSentryProjects) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
orgFK := &sqlschema.ForeignKeyConstraint{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
}
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "o11y_sentry_projects",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "slug", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "platform", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "status", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'active'"},
{Name: "key_version", DataType: sqlschema.DataTypeBigInt, Nullable: false, Default: "1"},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{orgFK},
})
sqls = append(sqls,
[]byte(`CREATE UNIQUE INDEX IF NOT EXISTS uq_o11y_sentry_projects_org_slug ON o11y_sentry_projects (org_id, slug)`),
[]byte(`CREATE INDEX IF NOT EXISTS idx_o11y_sentry_projects_org ON o11y_sentry_projects (org_id)`),
)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addSentryProjects) Down(context.Context, *bun.DB) error {
return nil
}