Files
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

270 lines
8.0 KiB
Go

package implerrortracking
import (
"context"
"time"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/valuer"
"github.com/uptrace/bun"
)
const (
defaultIssueLimit = 50
maxIssueLimit = 100
)
type store struct {
sqlstore sqlstore.SQLStore
}
// NewStore backs the o11y_issues lifecycle table. Every method is org-scoped.
func NewStore(sqlstore sqlstore.SQLStore) errortrackingtypes.Store {
return &store{sqlstore: sqlstore}
}
// UpsertIssues writes a whole envelope's grouped issues in ONE transaction — the
// batch is already collapsed to one row per fingerprint (Count = occurrences in the
// batch), so a request that reports N events causes at most (distinct fingerprints)
// upserts, not N transactions. New fingerprints are admitted only while the org is
// under `ceiling`; existing issues always bump. This bounds single-request write
// amplification and caps per-org issue growth (fingerprint-explosion backpressure).
//
// Portable SET exprs: `count = count + EXCLUDED.count` and `EXCLUDED.x` behave
// identically on SQLite and PostgreSQL; the boolean/enum writes use bound
// placeholders. A separate idempotent statement reopens a RESOLVED issue on
// recurrence (an IGNORED issue stays muted). Ingest never touches `version`.
func (s *store) UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*errortrackingtypes.Issue, ceiling int) (int, error) {
if len(issues) == 0 {
return 0, nil
}
tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
current, err := tx.NewSelect().Model((*errortrackingtypes.Issue)(nil)).Where("org_id = ?", orgID).Count(ctx)
if err != nil {
return 0, err
}
// Which of the batch's fingerprints already exist for this org (they bump even at
// ceiling); only NEW fingerprints consume headroom.
fps := make([]string, 0, len(issues))
for _, iss := range issues {
fps = append(fps, iss.Fingerprint)
}
existing := map[string]bool{}
var rows []struct {
Fingerprint string `bun:"fingerprint"`
}
if err := tx.NewSelect().
Model((*errortrackingtypes.Issue)(nil)).
Column("fingerprint").
Where("org_id = ?", orgID).
Where("fingerprint IN (?)", bun.In(fps)).
Scan(ctx, &rows); err != nil {
return 0, err
}
for _, r := range rows {
existing[r.Fingerprint] = true
}
headroom := ceiling - current
written := 0
for _, issue := range issues {
if !existing[issue.Fingerprint] {
if headroom <= 0 {
continue // per-org ceiling reached: drop the NEW fingerprint (backpressure)
}
headroom--
existing[issue.Fingerprint] = true
}
if _, err := tx.NewInsert().
Model(issue).
On("CONFLICT (org_id, fingerprint) DO UPDATE").
Set("count = count + EXCLUDED.count").
Set("last_seen = EXCLUDED.last_seen").
Set("value = EXCLUDED.value").
Set("level = EXCLUDED.level").
Set("culprit = EXCLUDED.culprit").
Set("platform = EXCLUDED.platform").
Set("environment = EXCLUDED.environment").
Set("release = EXCLUDED.release").
Set("service_name = EXCLUDED.service_name").
Set("sample_event = EXCLUDED.sample_event").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx); err != nil {
return 0, err
}
if _, err := tx.NewUpdate().
Model((*errortrackingtypes.Issue)(nil)).
Set("status = ?", errortrackingtypes.StatusUnresolved).
Set("regressed = ?", true).
Set("updated_at = ?", issue.LastSeen).
Where("org_id = ?", issue.OrgID).
Where("fingerprint = ?", issue.Fingerprint).
Where("status = ?", errortrackingtypes.StatusResolved).
Exec(ctx); err != nil {
return 0, err
}
written++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return written, nil
}
func (s *store) ListIssues(ctx context.Context, orgID valuer.UUID, q *errortrackingtypes.IssuesQuery) ([]*errortrackingtypes.Issue, int, error) {
issues := make([]*errortrackingtypes.Issue, 0)
// MANDATORY tenant boundary, first predicate. Every issue row belongs to exactly
// one org; there is no code path that lists issues without this filter.
query := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&issues).
Where("org_id = ?", orgID)
if q.Status != "" {
query = query.Where("status = ?", q.Status)
}
if q.Level != "" {
query = query.Where("level = ?", q.Level)
}
if q.Environment != "" {
query = query.Where("environment = ?", q.Environment)
}
if q.ServiceName != "" {
query = query.Where("service_name = ?", q.ServiceName)
}
if q.Query != "" {
like := "%" + q.Query + "%"
query = query.Where("(type LIKE ? OR value LIKE ? OR culprit LIKE ?)", like, like, like)
}
// Server-only narrowing to a set of fingerprints (the Sentry project projection).
// Never client-settable (IssuesQuery.Fingerprints has no query tag), still under
// the mandatory org_id filter above.
if len(q.Fingerprints) > 0 {
query = query.Where("fingerprint IN (?)", bun.In(q.Fingerprints))
}
count, err := query.
OrderExpr(sortColumn(q.Sort)).
Offset(clampOffset(q.Offset)).
Limit(clampLimit(q.Limit)).
ScanAndCount(ctx)
if err != nil {
return nil, 0, err
}
return issues, count, nil
}
func (s *store) GetIssue(ctx context.Context, orgID, id valuer.UUID) (*errortrackingtypes.Issue, error) {
issue := new(errortrackingtypes.Issue)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(issue).
Where("org_id = ?", orgID).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, errortrackingtypes.ErrCodeErrorTrackingNotFound, "issue %s not found in the org", id)
}
return issue, nil
}
// UpdateIssue writes the mutable lifecycle columns with an optimistic-concurrency
// guard: the WHERE pins the loaded version, and the write bumps it. Zero rows means
// either the row vanished (not-found) or another operator wrote first (conflict) —
// distinguished by a cheap existence probe so the caller gets the right status.
func (s *store) UpdateIssue(ctx context.Context, issue *errortrackingtypes.Issue, expectedVersion int64) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*errortrackingtypes.Issue)(nil)).
Set("status = ?", issue.Status).
Set("assignee = ?", issue.Assignee).
Set("resolved_at = ?", issue.ResolvedAt).
Set("regressed = ?", issue.Regressed).
Set("updated_at = ?", issue.UpdatedAt).
Set("version = version + 1").
Where("org_id = ?", issue.OrgID).
Where("id = ?", issue.ID).
Where("version = ?", expectedVersion).
Exec(ctx)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
exists, _ := s.sqlstore.BunDBCtx(ctx).NewSelect().
Model((*errortrackingtypes.Issue)(nil)).
Where("org_id = ?", issue.OrgID).
Where("id = ?", issue.ID).
Exists(ctx)
if exists {
return errors.Newf(errors.TypeAlreadyExists, errortrackingtypes.ErrCodeErrorTrackingConflict, "issue %s was modified concurrently; reload and retry", issue.ID)
}
return errors.Newf(errors.TypeNotFound, errortrackingtypes.ErrCodeErrorTrackingNotFound, "issue %s not found in the org", issue.ID)
}
return nil
}
func (s *store) DeleteStale(ctx context.Context, cutoff time.Time) (int64, error) {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*errortrackingtypes.Issue)(nil)).
Where("last_seen < ?", cutoff).
Exec(ctx)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// sortColumn maps the API sort key to a safe ORDER BY expression (never user text).
func sortColumn(sort string) string {
switch sort {
case "firstSeen":
return "first_seen DESC"
case "count":
return "count DESC"
default:
return "last_seen DESC"
}
}
func clampLimit(limit int) int {
if limit <= 0 {
return defaultIssueLimit
}
if limit > maxIssueLimit {
return maxIssueLimit
}
return limit
}
func clampOffset(offset int) int {
if offset < 0 {
return 0
}
return offset
}
// nowUTC is the single time source for lifecycle writes (overridable in tests).
var nowUTC = func() time.Time { return time.Now().UTC() }