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