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.9 KiB
Go
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
|
|
)
|