Files
o11y/pkg/modules/errortracking/implerrortracking/reuse.go
T
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.9 KiB
Go

package implerrortracking
import (
"net/http"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
// This file is the DELIBERATE, exported reuse surface of the Sentry-wire ingest
// engine. The Hanzo Sentry product face (pkg/modules/sentry) COMPOSES these
// primitives verbatim — ONE ingest engine, two product faces: errortracking's
// org-keyed DSN path and sentry's project-keyed DSN path. Every symbol here is a
// thin, behavior-preserving alias over the unexported original errortracking's own
// handler already uses, so there is exactly one implementation of decode, parse,
// normalize/scrub, DSN-key derivation and rate limiting. No logic is duplicated and
// the reviewed ingest path stays byte-unchanged.
// DecodeBody decompresses a raw ingest body per Content-Encoding, bounded against a
// decompression bomb (the identical MaxDecodedBytes cap the errortracking path uses).
func DecodeBody(body []byte, encoding string) ([]byte, error) { return decodeBody(body, encoding) }
// ParseEnvelope extracts events from a modern Sentry envelope, capped at
// MaxEventsPerEnvelope so one request cannot fan out unbounded.
func ParseEnvelope(decoded []byte) ([]*errortrackingtypes.SentryEvent, error) {
return parseEnvelope(decoded)
}
// ParseStoreBody extracts the single event of a legacy /store/ body.
func ParseStoreBody(decoded []byte) ([]*errortrackingtypes.SentryEvent, error) {
return parseStoreBody(decoded)
}
// NormalizeEvent turns a decoded Sentry event into the canonical, fingerprinted
// Occurrence. Total (never errors on a malformed client payload) and fail-secure:
// secrets are always redacted and end-user PII is scrubbed unless capturePII is set.
func NormalizeEvent(e *errortrackingtypes.SentryEvent, capturePII bool) *errortrackingtypes.Occurrence {
return normalizeEvent(e, ingestOpts{capturePII: capturePII})
}
// PublicKeyForVersion derives the versioned DSN public key for a DSN path segment.
// The Sentry face passes the PROJECT UUID as the segment (errortracking passes the
// ORG); the HMAC-over-platform-secret derivation is identical.
func PublicKeyForVersion(secret []byte, segment string, version int) string {
return publicKeyForVersion(secret, segment, version)
}
// VerifyKey constant-time verifies a presented "<v>:<hmac>" DSN key for a segment,
// rejecting versions below minVersion (the rotation watermark). Fail-closed on an
// empty secret/key or a malformed/too-low version.
func VerifyKey(secret []byte, segment, presented string, minVersion int) bool {
return verifyKey(secret, segment, presented, minVersion)
}
// SentryKeyFromRequest extracts the presented DSN public key from the Sentry auth
// surface (X-Sentry-Auth header, then ?sentry_key). The envelope-body DSN is never
// trusted as a credential channel.
func SentryKeyFromRequest(r *http.Request) string { return sentryKeyFromRequest(r) }
// RateLimiter is the per-key token-bucket ingest limiter. The Sentry face keys it on
// the project UUID; errortracking keys it on the org UUID.
type RateLimiter = rateLimiter
// NewRateLimiter builds a token-bucket limiter (steady-state events/sec, burst) with
// the SAME policy the errortracking ingest path applies.
func NewRateLimiter(rate, burst float64) *RateLimiter { return newRateLimiter(rate, burst) }
// Allow reports whether the key (org or project UUID) is within its rate budget and
// consumes a token when it is. Buckets are created lazily and only for a key that
// already passed DSN verification.
func (l *rateLimiter) Allow(key valuer.UUID) bool { return l.allow(key) }
// Shared ingest-policy constants, so the Sentry face applies identical budgets and
// payload/event bounds to its own ingest pipeline.
const (
IngestRatePerSec = ingestRatePerSec
IngestBurst = ingestBurst
MaxDecodedBytes = maxDecodedBytes
MaxEventsPerEnvelope = maxEventsPerEnvelope
MaxCompressedBody = maxCompressedBody
)