mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
Sentinel reads the errors /v1/event lands, and takes none itself
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Failing after 5m15s
Hanzo CI/CD / test-amd64 (push) Failing after 17m40s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 17m53s
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Failing after 5m15s
Hanzo CI/CD / test-amd64 (push) Failing after 17m40s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 17m53s
The event plane folded every occurrence into event.fact, and the Sentry module still read and wrote event.error. Its reads now select the error signal of event.fact, mapping issue, env, origin and the severity band. Errors enter through /v1/event only. The Sentry envelope and store routes, DSN keys and their rotation leave the module; the consumer of event.error files each fact through Ingest(org, product), which keeps the issue lifecycle and returns the issues a batch created.
This commit is contained in:
+3
-32
@@ -30,8 +30,8 @@ package o11y
|
||||
// AND at the runtime, which is one rule enforced twice, not two rules.
|
||||
//
|
||||
// Anonymous does NOT authorize. It says a principal is not the ADMISSION test;
|
||||
// the op's own credential still is — a DSN key on the ingest wires, a
|
||||
// service-account key on service_accounts/me, the share's scope on a public
|
||||
// the op's own credential still is — a service-account key on
|
||||
// service_accounts/me, the share's scope on a public
|
||||
// dashboard, a session cookie on /users/me (which answers 401 without one, and
|
||||
// that is the honest answer rather than the host's 403 for a route the runtime
|
||||
// would have served).
|
||||
@@ -53,7 +53,7 @@ func Anonymous(method, path string) bool {
|
||||
if openOps[method+" "+path] {
|
||||
return true
|
||||
}
|
||||
return publicDashboardRead(method, path) || IngestWire(method, path)
|
||||
return publicDashboardRead(method, path)
|
||||
}
|
||||
|
||||
// openOps is every FIXED-path op the runtime serves without a principal. Each
|
||||
@@ -118,32 +118,3 @@ func publicDashboardRead(method, path string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IngestWire reports whether method+path is a Sentry-compatible error-ingest
|
||||
// WRITE — the one foreign protocol this surface RECEIVES. The caller is a Sentry
|
||||
// SDK presenting a DSN public key, not a Hanzo session, and the runtime verifies
|
||||
// that key itself (constant-time HMAC, fail-closed) and derives the org from the
|
||||
// project segment. Refusing it for lacking a principal it can never carry would
|
||||
// take every SDK in the field off the air.
|
||||
//
|
||||
// POST /v1/event/{project}/envelope|store (the ingest endpoint a minted DSN names)
|
||||
// POST /v1/o11y/api/{project}/envelope|store (the suffix a stock SDK appends)
|
||||
//
|
||||
// It matches by METHOD + PREFIX + SUFFIX and never by a bare prefix, so nothing
|
||||
// else on either root is reachable through it: the faces stay gated — issues,
|
||||
// discover, logs, traces, stats — and so does /v1/event itself, the product
|
||||
// event endpoint beside this one. The trailing slash is the protocol's form; the
|
||||
// slash-less variant is tolerated defensively. Exported because the EDGE needs
|
||||
// the identical answer: the gateway waives its JWT check on exactly these, and a
|
||||
// request the gateway lets through tokenless must not then be refused here for
|
||||
// having no token.
|
||||
func IngestWire(method, path string) bool {
|
||||
if method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(path, eventRoot+"/") && !strings.HasPrefix(path, o11yRoot+"/api/") {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(path, "/envelope/") || strings.HasSuffix(path, "/envelope") ||
|
||||
strings.HasSuffix(path, "/store/") || strings.HasSuffix(path, "/store")
|
||||
}
|
||||
|
||||
+7
-12
@@ -96,7 +96,7 @@ func TestEveryExemptOpIsNamed(t *testing.T) {
|
||||
method, path, _ := strings.Cut(route, " ")
|
||||
switch {
|
||||
case !o11y.Anonymous(method, path), named[route]:
|
||||
case strings.HasPrefix(path, "/v1/o11y/public/dashboards/"), o11y.IngestWire(method, path):
|
||||
case strings.HasPrefix(path, "/v1/o11y/public/dashboards/"):
|
||||
default:
|
||||
t.Errorf("%s is exempt from the principal gate but is not named in fixedExemptions", route)
|
||||
}
|
||||
@@ -116,10 +116,6 @@ func TestPublicOpsAreExempt(t *testing.T) {
|
||||
"GET /v1/o11y/users/me",
|
||||
"GET /v1/o11y/public/dashboards/d1",
|
||||
"GET /v1/o11y/public/dashboards/d1/widgets/0/query_range",
|
||||
"POST /v1/event/proj/envelope/",
|
||||
"POST /v1/event/proj/store",
|
||||
"POST /v1/o11y/api/proj/envelope/",
|
||||
"POST /v1/o11y/api/proj/store",
|
||||
} {
|
||||
method, path, _ := strings.Cut(route, " ")
|
||||
if !o11y.Anonymous(method, path) {
|
||||
@@ -140,15 +136,14 @@ func TestTenantOpsStayGated(t *testing.T) {
|
||||
"GET /v1/o11y/public/dashboards", // the collection is not a share
|
||||
"GET /v1/o11y/public/dashboards//", // an empty share id
|
||||
"GET /v1/o11y/public/dashboards/d1/widgets/0/query_rangex",
|
||||
"GET /v1/event/proj/envelope/", // ingest is a WRITE; a GET is not it
|
||||
"POST /v1/o11y/api/v3/issues", // a read API under the DSN prefix
|
||||
"POST /v1/o11y/sentinel/issues", // the face is a face, whatever the suffix
|
||||
// Ingest moved off the face. The old spelling must buy nothing, or
|
||||
// the move left an unauthenticated hole where the route used to be.
|
||||
// Errors enter through /v1/event only. No Sentry-wire spelling is open,
|
||||
// here or on any root.
|
||||
"POST /v1/event/proj/envelope/",
|
||||
"POST /v1/event/proj/store",
|
||||
"POST /v1/o11y/api/proj/envelope/",
|
||||
"POST /v1/o11y/api/proj/store",
|
||||
"POST /v1/o11y/sentinel/proj/envelope/",
|
||||
"POST /v1/o11y/sentinel/proj/store",
|
||||
"POST /v1/o11y/api/proj/envelopes",
|
||||
"POST /v1/event", // the product event endpoint is not the Sentry wire
|
||||
"GET /v1/o11y/version/", // a route that does not exist is not public
|
||||
"GET /V1/O11Y/VERSION", // and the match is exact, never folded
|
||||
} {
|
||||
|
||||
+1
-2
@@ -12,8 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// Every o11y route is /v1/o11y/…: the host is api.hanzo.ai, so a path never
|
||||
// says api again. The one /api/ segment served is the Sentry SDK's envelope
|
||||
// suffix under /v1/o11y/api/, which a stock SDK appends to its DSN.
|
||||
// says api again.
|
||||
|
||||
func TestNoRouteIsServedOutsideV1(t *testing.T) {
|
||||
for route := range registered(t, mounted(t)) {
|
||||
|
||||
@@ -59,7 +59,7 @@ func answer(method, template string) http.Handler {
|
||||
//
|
||||
// That pass-through is the point. Every route registered this way is one whose
|
||||
// answer cannot be named: an unbounded log tail, a long poll, a chunked export, a
|
||||
// 303 whose whole content is a Location, a Sentry envelope frame. relay buffers a
|
||||
// 303 whose whole content is a Location. relay buffers a
|
||||
// complete answer before it can decode one, so a typed op would hang on the first
|
||||
// tail and would return a progress report only after the query it reports on had
|
||||
// finished. Here the runtime's handler writes to the caller's own
|
||||
|
||||
@@ -152,12 +152,12 @@ func Use(app *zip.App) error {
|
||||
// The TYPED span-mapper ops — the ingest-time rules that move or copy span
|
||||
// attributes into resource attributes; see spanmappers.go.
|
||||
mountSpanMappers(app)
|
||||
// And the seven that cannot be typed, each named and justified.
|
||||
// And the three that cannot be typed, each named and justified.
|
||||
mountHatches(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountHatches registers the SEVEN routes that cannot be typed ops, one route
|
||||
// mountHatches registers the THREE routes that cannot be typed ops, one route
|
||||
// literal each, with the reason next to it. This list is meant to shrink and is
|
||||
// meant to be hard to grow: adding to it costs a justification in review, where
|
||||
// a catch-all cost nothing.
|
||||
@@ -169,13 +169,7 @@ func Use(app *zip.App) error {
|
||||
// than being absent from it — a generated client that trusts a false contract
|
||||
// fails at the customer, not at review.
|
||||
//
|
||||
// Two of these seven sit OUTSIDE /v1/o11y — the ingest routes on eventRoot —
|
||||
// which is the second thing a wildcard hides. A catch-all on one root cannot
|
||||
// reach a route on another, so it does not merely obscure which routes are
|
||||
// un-typed, it obscures which are missing: both were unreachable from the
|
||||
// composed binary until every route was named.
|
||||
//
|
||||
// It was three, and the third is gone rather than moved: /ws/query_progress was
|
||||
// The stream list was three, and the third is gone rather than moved: /ws/query_progress was
|
||||
// the same read as o11yRoot+"/query_progress" over an Upgrade, and one read at
|
||||
// two addresses is one address too many. The Upgrade is a property of the
|
||||
// request, so both protocols answer at the /v1/o11y address now.
|
||||
@@ -190,24 +184,4 @@ func mountHatches(app *zip.App) {
|
||||
route(app, http.MethodGet, o11yRoot+"/query_progress") // progress: a long poll, or a websocket when the caller upgrades
|
||||
route(app, http.MethodPost, o11yRoot+"/export_raw_data") // chunked CSV/JSONL attachment, X-Response-Complete trailer
|
||||
|
||||
// ── 2. A FOREIGN PROTOCOL WE RECEIVE ─────────────────────────────────────
|
||||
// Sentry-compatible ingest, and the only thing in this table that is not a
|
||||
// face. The body is an application/x-sentry-envelope frame, not JSON, and
|
||||
// the caller is a Sentry SDK authenticating with a DSN public key rather
|
||||
// than a Hanzo session.
|
||||
//
|
||||
// SO IT ANSWERS AT THE INGEST ENDPOINT, NOT UNDER A PRODUCT'S ROOT (relay.go).
|
||||
// eventRoot is what a minted DSN spells, so a beacon has exactly one address
|
||||
// to knock on and the face beside it keeps its own gate.
|
||||
//
|
||||
// The /api/ segment is NOT ours to name: a stock SDK appends its own fixed
|
||||
// /api/<project>/envelope/ suffix to whatever DSN path it is given, so
|
||||
// renaming it would break every SDK in the field. We RECEIVE this shape; we
|
||||
// do not publish it.
|
||||
// A declared address never ends in a slash, and fiber matches the SDK's
|
||||
// trailing one against it either way.
|
||||
route(app, http.MethodPost, eventRoot+"/:project/envelope") // the endpoint a minted DSN names
|
||||
route(app, http.MethodPost, eventRoot+"/:project/store") // the same endpoint, single-event form
|
||||
route(app, http.MethodPost, o11yRoot+"/api/:project_id/envelope") // the suffix a stock SDK appends
|
||||
route(app, http.MethodPost, o11yRoot+"/api/:project_id/store") // the same suffix, single-event form
|
||||
}
|
||||
|
||||
@@ -9,29 +9,14 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
)
|
||||
|
||||
// addErrorTrackingRoutes serves error/crash tracking (Sentry-class Issues) under
|
||||
// /v1/o11y. Two families:
|
||||
// addErrorTrackingRoutes serves the Issues list/detail/update the console Errors tab
|
||||
// consumes at /v1/o11y/errortracking/issues[/{id}]: Hanzo IAM authz, org-scoped.
|
||||
// Errors enter through /v1/event only, so there is no ingest here.
|
||||
//
|
||||
// - INGEST (public, DSN-authenticated in-handler): the Sentry wire endpoints
|
||||
// POST /v1/o11y/api/{project}/envelope and POST /v1/o11y/api/{project}/store.
|
||||
// They are wrapped with OpenAccess (no IAM) because the Sentry SDK presents a DSN
|
||||
// key, not a Hanzo session; the handler verifies that key. A Sentry DSN of
|
||||
// https://<key>@<host>/v1/o11y/<org> makes the SDK POST to exactly these paths:
|
||||
// the SDK appends its own fixed /api/<project>/envelope/ suffix to the DSN path.
|
||||
// So the /api/ segment here is the Sentry wire contract — a foreign protocol we
|
||||
// receive, like /.well-known — and the route literal is the public path verbatim,
|
||||
// with nothing rewriting it in between.
|
||||
//
|
||||
// - READ (Hanzo IAM authz, org-scoped): the Issues list/detail/update the console
|
||||
// Errors tab consumes at /v1/o11y/errortracking/issues[/{id}].
|
||||
//
|
||||
// The three READ routes are ALSO typed ops at the module's mount seam
|
||||
// (sentryerrors.go, on the /v1/o11y group), which is what carries them into the
|
||||
// document, the SDK, the CLI and the agent surface. That is a second DISPATCH, never
|
||||
// a second implementation: the ops answer by handing the call to THIS router, so the
|
||||
// handlers below stay the one place the reads are performed. The two INGEST routes
|
||||
// stay ONLY here — OpenAccess, DSN-authenticated, opaque Sentry-envelope body — a
|
||||
// deliberate escape hatch out of the document.
|
||||
// The three routes are ALSO typed ops at the module's mount seam (sentryerrors.go,
|
||||
// on the /v1/o11y group), which is what carries them into the document, the SDK,
|
||||
// the CLI and the agent surface. That is a second DISPATCH, never a second
|
||||
// implementation: the ops answer by handing the call to THIS router.
|
||||
func (provider *provider) addErrorTrackingRoutes(router routing.Router) {
|
||||
h := provider.errorTrackingHandler
|
||||
|
||||
@@ -41,22 +26,6 @@ func (provider *provider) addErrorTrackingRoutes(router routing.Router) {
|
||||
fn http.HandlerFunc
|
||||
def handler.OpenAPIDef
|
||||
}{
|
||||
{http.MethodPost, "/v1/o11y/api/{project_id}/envelope", provider.authzMiddleware.OpenAccess(h.EnvelopeIngest), handler.OpenAPIDef{
|
||||
ID: "IngestErrorEnvelope", Tags: []string{"errortracking"}, Summary: "Ingest a Sentry envelope",
|
||||
Description: "Sentry-envelope-compatible ingest. Authenticated by the DSN public key (X-Sentry-Auth or ?sentry_key), not a Hanzo session.",
|
||||
RequestContentType: "application/x-sentry-envelope",
|
||||
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}},
|
||||
{http.MethodPost, "/v1/o11y/api/{project_id}/store", provider.authzMiddleware.OpenAccess(h.StoreIngest), handler.OpenAPIDef{
|
||||
ID: "IngestErrorStore", Tags: []string{"errortracking"}, Summary: "Ingest a legacy Sentry store event",
|
||||
Description: "Legacy single-event Sentry ingest. Authenticated by the DSN public key.",
|
||||
RequestContentType: "application/json",
|
||||
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}},
|
||||
{http.MethodGet, "/v1/o11y/errortracking/issues", provider.authzMiddleware.ViewAccess(h.ListIssues), handler.OpenAPIDef{
|
||||
ID: "ListIssues", Tags: []string{"errortracking"}, Summary: "List error issues",
|
||||
Description: "Lists grouped error issues (by fingerprint) for the caller's org with status, level, counts and first/last-seen.",
|
||||
|
||||
@@ -58,8 +58,9 @@ import (
|
||||
// sign-in callbacks, and five SSO auth-domain routes. addSessionRoutes and
|
||||
// addAuthDomainRoutes are not merely uncalled — the files are deleted, which is
|
||||
// the only shape of removal this census can tell apart from the defect it
|
||||
// exists to catch.
|
||||
const wantAPIServerRoutes = 197
|
||||
// exists to catch. Five more left with Sentry-wire ingest (four) and DSN-key
|
||||
// rotation (one): errors enter through /v1/event only.
|
||||
const wantAPIServerRoutes = 192
|
||||
|
||||
// every handler field satisfied by an embedded nil interface: the route table is
|
||||
// a fact about REGISTRATION, and registration only takes method values off these
|
||||
|
||||
@@ -10,54 +10,20 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
|
||||
)
|
||||
|
||||
// The ingest {project} path var is constrained to a UUID — {project:guid} — so a
|
||||
// project segment is a UUID and nothing else, and a garbage segment is a 404 from the
|
||||
// router rather than a body handed to the DSN verifier. The constraint is the router's
|
||||
// own `guid` — a UUID parse, not a hand-written character class that has to be read to
|
||||
// know what it accepts.
|
||||
|
||||
// addSentryRoutes registers TWO families, and they answer on two roots because they
|
||||
// answer two different callers:
|
||||
// addSentryRoutes registers the FACE, /v1/o11y/sentinel — Sentinel, the
|
||||
// Sentry-parity product read by a signed-in person: projects, issues, discover,
|
||||
// events, logs, traces, stats. Hanzo IAM authz, every one scoped to the caller's
|
||||
// org from the validated claims. There is no ingest here: errors enter through
|
||||
// /v1/event only.
|
||||
//
|
||||
// - The FACE, /v1/o11y/sentinel — Hanzo Sentry, the Sentry-parity product read by a
|
||||
// signed-in person: projects, issues, discover, events, logs, traces, stats. Hanzo
|
||||
// IAM authz, every one scoped to the caller's org from the validated claims.
|
||||
// - INGEST, POST /v1/event/{project}/envelope and /store — a keyed beacon. It is
|
||||
// the address a minted DSN spells (implsentry's mintDSN), so the address a client
|
||||
// is told and the address this router opens are one string. OpenAccess (no IAM):
|
||||
// a Sentry SDK presents a DSN key, not a Hanzo session; the handler verifies that
|
||||
// key against the project's rotation watermark.
|
||||
//
|
||||
// ONE WIRE, ONE PUBLIC ADDRESS. Ingest used to sit under the face's root as well,
|
||||
// which put one wire at two addresses and put an unauthenticated write inside a
|
||||
// principal-gated subtree — so the face's gate had to carry an exemption shaped like
|
||||
// the ingest paths, and the exemption is what anything under that root had to be
|
||||
// checked against. Off the face there is nothing to exempt.
|
||||
//
|
||||
// Both are literal paths on the SAME router the o11y read plane uses, so no
|
||||
// /v1/o11y→/api rewrite applies (see createPublicServer: the path that arrives is the
|
||||
// path that matches).
|
||||
//
|
||||
// FIVE of the read routes — discover, logs, traces, traces/{id}, stats — are ALSO
|
||||
// declared as typed ops at the module's mount seam (telemetry.go in the repo root),
|
||||
// which is what carries them into the composed document, the SDK, the CLI and the
|
||||
// agent surface. That is a second DISPATCH, never a second implementation: the ops
|
||||
// answer by handing the call to this router, so the handlers below stay the one
|
||||
// place the reads are performed. Both are needed — the ops serve the composed
|
||||
// binary, this router serves the standalone process, which has no native router to
|
||||
// register an op on — so deleting either half drops one of the two deployments.
|
||||
//
|
||||
// The OTHER ten reads/writes of this face — projects (list/create/get/delete/
|
||||
// rotate-key), issues (list/get/update/events) and one event — are ALSO typed ops
|
||||
// at that seam (sentryerrors.go), the same second DISPATCH into this router. The two
|
||||
// INGEST routes below are named at that seam as HATCHES rather than ops (mount.go's
|
||||
// mountHatches): OpenAccess, DSN-authenticated, carrying an opaque Sentry-envelope
|
||||
// body a typed relay would corrupt, so they are named but not typed and stay out of
|
||||
// the document by design.
|
||||
// These are literal paths on the SAME router the o11y read plane uses, so no
|
||||
// rewrite applies (see createPublicServer: the path that arrives is the path that
|
||||
// matches). The module's mount seam declares them again as typed ops
|
||||
// (sentryerrors.go, telemetry.go) that dispatch into this router, so the handlers
|
||||
// below stay the one place the reads are performed.
|
||||
func (provider *provider) addSentryRoutes(router routing.Router) {
|
||||
h := provider.sentryHandler
|
||||
|
||||
// The FACE.
|
||||
staticRoutes := []struct {
|
||||
method string
|
||||
path string
|
||||
@@ -87,12 +53,6 @@ func (provider *provider) addSentryRoutes(router routing.Router) {
|
||||
SuccessStatusCode: http.StatusNoContent, ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
}},
|
||||
{http.MethodPost, "/v1/o11y/sentinel/projects/{id}/keys/rotate", provider.authzMiddleware.EditAccess(h.RotateProjectKey), handler.OpenAPIDef{
|
||||
ID: "SentryRotateProjectKey", Tags: []string{"sentry"}, Summary: "Rotate a project's DSN key",
|
||||
Response: new(sentrytypes.GettableProject), ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
}},
|
||||
{http.MethodGet, "/v1/o11y/sentinel/issues", provider.authzMiddleware.ViewAccess(h.ListIssues), handler.OpenAPIDef{
|
||||
ID: "SentryListIssues", Tags: []string{"sentry"}, Summary: "List error issues",
|
||||
RequestQuery: new(errortrackingtypes.IssuesQuery), Response: new(errortrackingtypes.GettableIssues),
|
||||
@@ -155,30 +115,4 @@ func (provider *provider) addSentryRoutes(router routing.Router) {
|
||||
router.Handle(rt.method, rt.path, handler.New(rt.fn, rt.def))
|
||||
}
|
||||
|
||||
// INGEST — on its own root, UUID-constrained project segment.
|
||||
ingestRoutes := []struct {
|
||||
path string
|
||||
fn http.HandlerFunc
|
||||
def handler.OpenAPIDef
|
||||
}{
|
||||
{"/v1/event/{project:guid}/envelope", provider.authzMiddleware.OpenAccess(h.EnvelopeIngest), handler.OpenAPIDef{
|
||||
ID: "SentryIngestEnvelope", Tags: []string{"sentry"}, Summary: "Ingest a Sentry envelope",
|
||||
Description: "Sentry-envelope-compatible ingest. Authenticated by the DSN public key (X-Sentry-Auth or ?sentry_key), not a Hanzo session.",
|
||||
RequestContentType: "application/x-sentry-envelope",
|
||||
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}},
|
||||
{"/v1/event/{project:guid}/store", provider.authzMiddleware.OpenAccess(h.StoreIngest), handler.OpenAPIDef{
|
||||
ID: "SentryIngestStore", Tags: []string{"sentry"}, Summary: "Ingest a legacy Sentry store event",
|
||||
Description: "Legacy single-event Sentry ingest. Authenticated by the DSN public key.",
|
||||
RequestContentType: "application/json",
|
||||
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}},
|
||||
}
|
||||
for _, rt := range ingestRoutes {
|
||||
router.Post(rt.path, handler.New(rt.fn, rt.def))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,8 +266,8 @@ func toUUID(kind, value string) valuer.UUID {
|
||||
}
|
||||
|
||||
// OrgUUID maps a Hanzo org slug to its o11y org UUID — the SAME deterministic
|
||||
// mapping a session takes, so a KEYED (pk-) ingest that has only the org slug
|
||||
// resolves to the exact org a logged-in session would. Exported for the keyed
|
||||
// ingest path (implsentry.ResolveIngest), which has no session to read `owner`
|
||||
// from. One mapping, one place: this is the whole tenant identity, no state.
|
||||
// mapping a session takes, so a caller that has only the org slug resolves to the
|
||||
// exact org a logged-in session would. Exported for the event.error consumer
|
||||
// (implsentry.Ingest), which reads the slug off the fact and has no session. One
|
||||
// mapping, one place: this is the whole tenant identity, no state.
|
||||
func OrgUUID(slug string) valuer.UUID { return toUUID("org", slug) }
|
||||
|
||||
@@ -8,33 +8,25 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// Module is the native error/crash tracking surface (Sentry-class Issues) folded
|
||||
// into the o11y plane. Occurrences are OTel exception data in the telemetry store;
|
||||
// this module owns the grouped-Issue lifecycle over that data and the ingest that
|
||||
// normalizes Sentry-SDK reports into it.
|
||||
// Module is the grouped-Issue lifecycle over the error facts of the event plane
|
||||
// (event.fact, signal 'error'). Occurrences are those facts; this module owns only
|
||||
// what cannot be derived from them: status, assignee, first/last seen, count and
|
||||
// regression, keyed (org, fingerprint).
|
||||
type Module interface {
|
||||
// Ingest groups a BATCH of normalized occurrences into the caller's org (resolved
|
||||
// from the DSN by the handler): occurrences are collapsed by fingerprint and
|
||||
// upserted in one transaction under the per-org issue ceiling, bounding the write
|
||||
// amplification of a single request. Returns issues written.
|
||||
Ingest(ctx context.Context, orgID valuer.UUID, occs []*errortrackingtypes.Occurrence) (int, error)
|
||||
// Ingest groups a BATCH of occurrences into the org's issues: they are collapsed
|
||||
// by fingerprint and upserted in one transaction under the per-org issue ceiling,
|
||||
// bounding the write amplification of a single batch. It returns the issues the
|
||||
// batch CREATED, so a caller can announce a new failure exactly once.
|
||||
Ingest(ctx context.Context, orgID valuer.UUID, occs []*errortrackingtypes.Occurrence) ([]*errortrackingtypes.Issue, error)
|
||||
|
||||
ListIssues(ctx context.Context, orgID valuer.UUID, q *errortrackingtypes.IssuesQuery) ([]*errortrackingtypes.Issue, int, error)
|
||||
GetIssue(ctx context.Context, orgID, id valuer.UUID) (*errortrackingtypes.GettableIssue, error)
|
||||
UpdateIssue(ctx context.Context, orgID, id valuer.UUID, in *errortrackingtypes.UpdateIssue) (*errortrackingtypes.Issue, error)
|
||||
}
|
||||
|
||||
// Handler is the HTTP surface. The ingest endpoints are PUBLIC (OpenAccess) and
|
||||
// authenticate the Sentry DSN key in-handler; the read endpoints are behind the
|
||||
// shared Hanzo IAM authz middleware and are org-scoped from the validated claims.
|
||||
// Handler is the HTTP surface: behind the shared Hanzo IAM authz middleware and
|
||||
// org-scoped from the validated claims. Errors enter through /v1/event only.
|
||||
type Handler interface {
|
||||
// EnvelopeIngest accepts the modern Sentry envelope wire format
|
||||
// (POST /api/{project}/envelope/).
|
||||
EnvelopeIngest(rw http.ResponseWriter, r *http.Request)
|
||||
// StoreIngest accepts the legacy single-event wire format
|
||||
// (POST /api/{project}/store/).
|
||||
StoreIngest(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
ListIssues(rw http.ResponseWriter, r *http.Request)
|
||||
GetIssue(rw http.ResponseWriter, r *http.Request)
|
||||
UpdateIssue(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// The ingest endpoints authenticate with the Sentry-native DSN model: the caller
|
||||
// presents a public key that proves it holds an org's ingest credential. We make
|
||||
// that key STATELESS and KMS-backed rather than adding a per-key secret table:
|
||||
//
|
||||
// publicKey(org, v) = "<v>:" + hex(HMAC-SHA256(platformIngestSecret, "org:"+org+":v"+v))
|
||||
//
|
||||
// The platform secret comes from KMS (never plaintext, never committed). The key
|
||||
// carries its VERSION so ONE org can be rotated in isolation: bump that org's
|
||||
// min-version (RevocationStore.Rotate) and only its below-min DSNs stop verifying —
|
||||
// no global secret roll. Verifying is a version check + a constant-time compare.
|
||||
//
|
||||
// The org travels in the DSN project segment; the key proves the caller may write
|
||||
// to THAT org at THAT version. Resolution reuses iamidentn's exact UUIDv5 mapping so
|
||||
// the row written here is read back by exactly that tenant.
|
||||
|
||||
const defaultKeyVersion = 1
|
||||
|
||||
// orgUUIDFromProject maps a DSN project segment to the o11y org UUID. It mirrors
|
||||
// iamidentn.toUUID("org", …) BYTE-FOR-BYTE (a raw UUID passes through; a slug is
|
||||
// UUIDv5 over the URL namespace with the "hanzo:o11y:org:" prefix) so ingest and
|
||||
// the IAM read path resolve the SAME tenant id.
|
||||
func orgUUIDFromProject(project string) (valuer.UUID, bool) {
|
||||
project = strings.TrimSpace(project)
|
||||
if project == "" {
|
||||
return valuer.UUID{}, false
|
||||
}
|
||||
if u, err := valuer.NewUUID(project); err == nil {
|
||||
return u, true
|
||||
}
|
||||
derived := uuid.NewSHA1(uuid.NameSpaceURL, []byte("hanzo:o11y:org:"+project))
|
||||
return valuer.MustNewUUID(derived.String()), true
|
||||
}
|
||||
|
||||
// publicKeyForVersion derives the versioned ingest public key for a project.
|
||||
func publicKeyForVersion(secret []byte, project string, version int) string {
|
||||
m := hmac.New(sha256.New, secret)
|
||||
m.Write([]byte("org:" + strings.TrimSpace(project) + ":v" + strconv.Itoa(version)))
|
||||
return strconv.Itoa(version) + ":" + hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
// publicKeyFor derives the default (v1) key.
|
||||
func publicKeyFor(secret []byte, project string) string {
|
||||
return publicKeyForVersion(secret, project, defaultKeyVersion)
|
||||
}
|
||||
|
||||
// verifyKey constant-time compares a presented "<v>:<hmac>" key against the expected
|
||||
// one for its project, rejecting versions below the org's revocation watermark. An
|
||||
// empty secret or key, a malformed version, or a below-min version never verify
|
||||
// (fail closed).
|
||||
func verifyKey(secret []byte, project, presented string, minVersion int) bool {
|
||||
if len(secret) == 0 || presented == "" {
|
||||
return false
|
||||
}
|
||||
i := strings.IndexByte(presented, ':')
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
version, err := strconv.Atoi(presented[:i])
|
||||
if err != nil || version <= 0 {
|
||||
return false
|
||||
}
|
||||
if version < minVersion {
|
||||
return false // revoked by rotation
|
||||
}
|
||||
expected := publicKeyForVersion(secret, project, version)
|
||||
return hmac.Equal([]byte(expected), []byte(presented))
|
||||
}
|
||||
|
||||
// sentryKeyFromRequest extracts the presented public key from the Sentry auth
|
||||
// surface, in precedence order: the X-Sentry-Auth header, then the ?sentry_key
|
||||
// query param. (The envelope-header DSN is intentionally NOT trusted as an auth
|
||||
// source — it is client body, not a credential channel.)
|
||||
func sentryKeyFromRequest(r *http.Request) string {
|
||||
if k := parseSentryAuthHeader(r.Header.Get("X-Sentry-Auth")); k != "" {
|
||||
return k
|
||||
}
|
||||
return strings.TrimSpace(r.URL.Query().Get("sentry_key"))
|
||||
}
|
||||
|
||||
// parseSentryAuthHeader pulls sentry_key out of a header like:
|
||||
//
|
||||
// Sentry sentry_version=7, sentry_key=1:abc123, sentry_client=sentry.python/1.2
|
||||
func parseSentryAuthHeader(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
h = strings.TrimPrefix(h, "Sentry ")
|
||||
for _, part := range strings.Split(h, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) == 2 && strings.TrimSpace(kv[0]) == "sentry_key" {
|
||||
return strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MintDSN builds the default-version DSN an operator hands to an app to report into
|
||||
// an org. host is the ingest origin (e.g. "o11y.hanzo.ai"); the SDK derives its
|
||||
// endpoint as https://<host>/v1/o11y/api/<org>/envelope/, which the existing
|
||||
// /v1/o11y mount forwards to this module's ingest route.
|
||||
func MintDSN(secret []byte, host, org string) string {
|
||||
return MintDSNVersion(secret, host, org, defaultKeyVersion)
|
||||
}
|
||||
|
||||
// MintDSNVersion builds a DSN at a specific key version (used after rotating an org).
|
||||
func MintDSNVersion(secret []byte, host, org string, version int) string {
|
||||
return "https://" + publicKeyForVersion(secret, org, version) + "@" + host + "/v1/o11y/" + org
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testSecret = []byte("kms-platform-ingest-secret")
|
||||
|
||||
func TestVerifyKey_RoundTrip(t *testing.T) {
|
||||
key := publicKeyFor(testSecret, "acme")
|
||||
assert.True(t, verifyKey(testSecret, "acme", key, 0), "the derived key must verify for its project")
|
||||
}
|
||||
|
||||
func TestVerifyKey_RejectsWrongProject(t *testing.T) {
|
||||
key := publicKeyFor(testSecret, "acme")
|
||||
assert.False(t, verifyKey(testSecret, "evil", key, 0), "a key minted for acme must not verify for another project")
|
||||
}
|
||||
|
||||
func TestVerifyKey_RejectsWrongSecret(t *testing.T) {
|
||||
key := publicKeyFor(testSecret, "acme")
|
||||
assert.False(t, verifyKey([]byte("different-secret"), "acme", key, 0))
|
||||
}
|
||||
|
||||
func TestVerifyKey_FailsClosed(t *testing.T) {
|
||||
assert.False(t, verifyKey(nil, "acme", "anything", 0), "no secret => fail closed")
|
||||
assert.False(t, verifyKey(testSecret, "acme", "", 0), "no presented key => fail closed")
|
||||
}
|
||||
|
||||
// The MOST important parity test: the org UUID the ingest path derives from a DSN
|
||||
// project MUST equal iamidentn.toUUID("org", slug) — otherwise a row written by
|
||||
// ingest would be invisible to the org's IAM-authenticated reads. This replicates
|
||||
// iamidentn's exact formula and asserts equality.
|
||||
func TestOrgUUIDFromProject_MatchesIAMMapping(t *testing.T) {
|
||||
for _, slug := range []string{"hanzo", "acme", "zoo"} {
|
||||
want := uuid.NewSHA1(uuid.NameSpaceURL, []byte("hanzo:o11y:org:"+slug))
|
||||
got, ok := orgUUIDFromProject(slug)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, want.String(), got.String(), "ingest org UUID must match the IAM read-path UUID for slug %q", slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgUUIDFromProject_RawUUIDPassthrough(t *testing.T) {
|
||||
u := valuer.GenerateUUID()
|
||||
got, ok := orgUUIDFromProject(u.String())
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, u.String(), got.String(), "a project that is already a UUID is used as-is")
|
||||
}
|
||||
|
||||
func TestOrgUUIDFromProject_EmptyRejected(t *testing.T) {
|
||||
_, ok := orgUUIDFromProject("")
|
||||
assert.False(t, ok)
|
||||
_, ok = orgUUIDFromProject(" ")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSentryKeyFromRequest_Header(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/v1/o11y/api/acme/envelope/", nil)
|
||||
r.Header.Set("X-Sentry-Auth", "Sentry sentry_version=7, sentry_key=pubkey123, sentry_client=sentry.python/1.40")
|
||||
assert.Equal(t, "pubkey123", sentryKeyFromRequest(r))
|
||||
}
|
||||
|
||||
func TestSentryKeyFromRequest_QueryFallback(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/v1/o11y/api/acme/envelope/?sentry_key=qkey456", nil)
|
||||
assert.Equal(t, "qkey456", sentryKeyFromRequest(r))
|
||||
}
|
||||
|
||||
func TestSentryKeyFromRequest_HeaderWins(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/v1/o11y/api/acme/envelope/?sentry_key=qkey", nil)
|
||||
r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=hkey")
|
||||
assert.Equal(t, "hkey", sentryKeyFromRequest(r))
|
||||
}
|
||||
|
||||
func TestMintDSN(t *testing.T) {
|
||||
dsn := MintDSN(testSecret, "o11y.hanzo.ai", "acme")
|
||||
assert.Equal(t, "https://"+publicKeyFor(testSecret, "acme")+"@o11y.hanzo.ai/v1/o11y/acme", dsn)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
)
|
||||
|
||||
// maxDecodedBytes caps the decompressed payload so a small gzip bomb cannot
|
||||
// exhaust memory on this public endpoint. 24 MiB comfortably fits large stack
|
||||
// traces / batched envelopes while staying bounded.
|
||||
const maxDecodedBytes = 24 << 20
|
||||
|
||||
// maxEventsPerEnvelope caps the events extracted from ONE request, so a single
|
||||
// envelope cannot fan out into an unbounded number of issue upserts (amplification
|
||||
// backpressure). Real SDKs send one event per envelope; batching senders stay well
|
||||
// under this.
|
||||
const maxEventsPerEnvelope = 1000
|
||||
|
||||
// decodeBody transparently inflates gzip / zlib / raw-deflate request bodies (the
|
||||
// Content-Encodings Sentry SDKs use), bounded by maxDecodedBytes. Identity/unknown
|
||||
// encodings pass through unchanged.
|
||||
func decodeBody(body []byte, encoding string) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "identity":
|
||||
return body, nil
|
||||
case "gzip", "x-gzip":
|
||||
r, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
return io.ReadAll(io.LimitReader(r, maxDecodedBytes))
|
||||
case "deflate":
|
||||
if r, err := zlib.NewReader(bytes.NewReader(body)); err == nil {
|
||||
defer func() { _ = r.Close() }()
|
||||
return io.ReadAll(io.LimitReader(r, maxDecodedBytes))
|
||||
}
|
||||
// Some clients send headerless raw DEFLATE.
|
||||
fr := flate.NewReader(bytes.NewReader(body))
|
||||
defer func() { _ = fr.Close() }()
|
||||
return io.ReadAll(io.LimitReader(fr, maxDecodedBytes))
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseStoreBody decodes a legacy `/store/` payload: a single event JSON.
|
||||
func parseStoreBody(body []byte) ([]*errortrackingtypes.SentryEvent, error) {
|
||||
body = bytes.TrimSpace(body)
|
||||
if len(body) == 0 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "empty store payload")
|
||||
}
|
||||
var ev errortrackingtypes.SentryEvent
|
||||
if err := json.Unmarshal(body, &ev); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "invalid store event json")
|
||||
}
|
||||
return []*errortrackingtypes.SentryEvent{&ev}, nil
|
||||
}
|
||||
|
||||
// parseEnvelope decodes a Sentry envelope and returns every `event`-type item.
|
||||
// The envelope is newline-framed: a header line, then repeating (item-header,
|
||||
// payload) pairs where a payload is either length-delimited (per its header) or
|
||||
// runs to the next newline. Non-event items (transaction/session/attachment/…)
|
||||
// are skipped. Malformed tails are tolerated — we return what parsed cleanly.
|
||||
func parseEnvelope(body []byte) ([]*errortrackingtypes.SentryEvent, error) {
|
||||
pos := 0
|
||||
readLine := func() ([]byte, bool) {
|
||||
if pos >= len(body) {
|
||||
return nil, false
|
||||
}
|
||||
if nl := bytes.IndexByte(body[pos:], '\n'); nl >= 0 {
|
||||
line := body[pos : pos+nl]
|
||||
pos += nl + 1
|
||||
return line, true
|
||||
}
|
||||
line := body[pos:]
|
||||
pos = len(body)
|
||||
return line, true
|
||||
}
|
||||
|
||||
// Envelope header (event_id / dsn / sent_at) — required to be present but not
|
||||
// otherwise consumed here.
|
||||
if _, ok := readLine(); !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "empty envelope")
|
||||
}
|
||||
|
||||
var events []*errortrackingtypes.SentryEvent
|
||||
for pos < len(body) {
|
||||
if len(events) >= maxEventsPerEnvelope {
|
||||
break // amplification cap: ignore the tail of an oversized envelope
|
||||
}
|
||||
hdrLine, ok := readLine()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if len(bytes.TrimSpace(hdrLine)) == 0 {
|
||||
continue
|
||||
}
|
||||
var ih errortrackingtypes.EnvelopeItemHeader
|
||||
if err := json.Unmarshal(hdrLine, &ih); err != nil {
|
||||
break // corrupt framing; stop rather than misread payloads as headers
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
// Use the declared length ONLY when it is in-bounds — never add an
|
||||
// attacker-supplied int to pos (a huge/negative length would overflow past
|
||||
// the clamp and panic the slice). Out-of-range → treat as newline-delimited.
|
||||
if ih.Length != nil && *ih.Length >= 0 && *ih.Length <= len(body)-pos {
|
||||
end := pos + *ih.Length
|
||||
payload = body[pos:end]
|
||||
pos = end
|
||||
if pos < len(body) && body[pos] == '\n' {
|
||||
pos++
|
||||
}
|
||||
} else {
|
||||
payload, ok = readLine()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ih.Type == "event" {
|
||||
var ev errortrackingtypes.SentryEvent
|
||||
if err := json.Unmarshal(payload, &ev); err == nil {
|
||||
events = append(events, &ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseEnvelope_NewlineDelimitedEvent(t *testing.T) {
|
||||
body := []byte(`{"event_id":"9ec79c33","dsn":"https://k@h/1"}
|
||||
{"type":"event"}
|
||||
{"event_id":"9ec79c33","exception":{"values":[{"type":"ZeroDivisionError","value":"division by zero"}]}}
|
||||
`)
|
||||
events, err := parseEnvelope(body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
require.NotNil(t, events[0].Exception)
|
||||
assert.Equal(t, "ZeroDivisionError", events[0].Exception.Values[0].Type)
|
||||
}
|
||||
|
||||
func TestParseEnvelope_LengthDelimitedEvent(t *testing.T) {
|
||||
payload := `{"event_id":"abc","exception":{"values":[{"type":"E","value":"boom"}]}}`
|
||||
body := []byte(fmt.Sprintf("{\"event_id\":\"abc\"}\n{\"type\":\"event\",\"length\":%d}\n%s\n", len(payload), payload))
|
||||
events, err := parseEnvelope(body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, "boom", events[0].Exception.Values[0].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvelope_SkipsNonEventItems(t *testing.T) {
|
||||
body := []byte(`{"event_id":"x"}
|
||||
{"type":"session"}
|
||||
{"sid":"s1","status":"ok"}
|
||||
{"type":"event"}
|
||||
{"event_id":"x","exception":{"values":[{"type":"E"}]}}
|
||||
{"type":"transaction"}
|
||||
{"spans":[]}
|
||||
`)
|
||||
events, err := parseEnvelope(body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1, "only the event item is extracted")
|
||||
assert.Equal(t, "E", events[0].Exception.Values[0].Type)
|
||||
}
|
||||
|
||||
func TestParseEnvelope_MultipleEvents(t *testing.T) {
|
||||
body := []byte(`{"event_id":"x"}
|
||||
{"type":"event"}
|
||||
{"exception":{"values":[{"type":"A"}]}}
|
||||
{"type":"event"}
|
||||
{"exception":{"values":[{"type":"B"}]}}
|
||||
`)
|
||||
events, err := parseEnvelope(body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 2)
|
||||
assert.Equal(t, "A", events[0].Exception.Values[0].Type)
|
||||
assert.Equal(t, "B", events[1].Exception.Values[0].Type)
|
||||
}
|
||||
|
||||
func TestParseEnvelope_EmptyFails(t *testing.T) {
|
||||
_, err := parseEnvelope(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseStoreBody(t *testing.T) {
|
||||
events, err := parseStoreBody([]byte(`{"event_id":"z","exception":{"values":[{"type":"RuntimeError","value":"nope"}]}}`))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, "RuntimeError", events[0].Exception.Values[0].Type)
|
||||
}
|
||||
|
||||
func TestParseStoreBody_EmptyFails(t *testing.T) {
|
||||
_, err := parseStoreBody([]byte(" "))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDecodeBody_Identity(t *testing.T) {
|
||||
got, err := decodeBody([]byte("hello"), "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", string(got))
|
||||
}
|
||||
|
||||
func TestDecodeBody_Gzip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := gzip.NewWriter(&buf)
|
||||
_, _ = w.Write([]byte("compressed payload"))
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
got, err := decodeBody(buf.Bytes(), "gzip")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "compressed payload", string(got))
|
||||
}
|
||||
|
||||
func TestDecodeBody_Deflate(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := zlib.NewWriter(&buf)
|
||||
_, _ = w.Write([]byte("zlib payload"))
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
got, err := decodeBody(buf.Bytes(), "deflate")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "zlib payload", string(got))
|
||||
}
|
||||
|
||||
// End-to-end through decode+parse: a gzipped envelope decodes then parses.
|
||||
func TestDecodeThenParse_GzippedEnvelope(t *testing.T) {
|
||||
raw := `{"event_id":"x"}
|
||||
{"type":"event"}
|
||||
{"exception":{"values":[{"type":"OutOfMemory"}]}}
|
||||
`
|
||||
var buf bytes.Buffer
|
||||
w := gzip.NewWriter(&buf)
|
||||
_, _ = w.Write([]byte(raw))
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
decoded, err := decodeBody(buf.Bytes(), "gzip")
|
||||
require.NoError(t, err)
|
||||
events, err := parseEnvelope(decoded)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, "OutOfMemory", events[0].Exception.Values[0].Type)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
)
|
||||
|
||||
// The grouping algorithm is a from-scratch, deterministic reimplementation of the
|
||||
// public Sentry grouping MODEL (exception type + normalized crash frame, with a
|
||||
// message fallback), not a port of any upstream code. It runs at ingest so the
|
||||
// Issues list is a plain org-scoped SELECT rather than an aggregation over an
|
||||
// org-less exception table.
|
||||
|
||||
// defaultFingerprintToken is the Sentry sentinel that expands to the computed
|
||||
// default fingerprint inside a client-supplied fingerprint array.
|
||||
const defaultFingerprintToken = "{{ default }}"
|
||||
|
||||
var (
|
||||
reUUID = regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`)
|
||||
reHexAddr = regexp.MustCompile(`\b0x[0-9a-fA-F]+\b`)
|
||||
reLongHex = regexp.MustCompile(`\b[0-9a-fA-F]{8,}\b`)
|
||||
reNumber = regexp.MustCompile(`\b\d[\d.,_]*\b`)
|
||||
reQuoted = regexp.MustCompile(`'[^']*'|"[^"]*"`)
|
||||
reWhitespace = regexp.MustCompile(`\s+`)
|
||||
reFuncNoise = regexp.MustCompile(`0x[0-9a-fA-F]+`)
|
||||
)
|
||||
|
||||
// computeFingerprint returns the stable 64-hex-char group key for an occurrence.
|
||||
// A client-supplied fingerprint is honored (the Sentry contract), with the
|
||||
// "{{ default }}" token expanded to the computed default parts.
|
||||
func computeFingerprint(occ *errortrackingtypes.Occurrence, custom []string) string {
|
||||
def := defaultFingerprintParts(occ)
|
||||
|
||||
var parts []string
|
||||
if len(custom) > 0 {
|
||||
for _, c := range custom {
|
||||
if strings.TrimSpace(c) == defaultFingerprintToken {
|
||||
parts = append(parts, def...)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, c)
|
||||
}
|
||||
} else {
|
||||
parts = def
|
||||
}
|
||||
|
||||
return hashParts(parts)
|
||||
}
|
||||
|
||||
// defaultFingerprintParts builds the canonical grouping components: exception
|
||||
// type + the normalized crash frame, falling back to a normalized message and
|
||||
// then the transaction, so an occurrence with no useful signal still groups
|
||||
// stably instead of collapsing every error into one bucket.
|
||||
func defaultFingerprintParts(occ *errortrackingtypes.Occurrence) []string {
|
||||
parts := make([]string, 0, 2)
|
||||
if occ.Type != "" {
|
||||
parts = append(parts, "type:"+occ.Type)
|
||||
}
|
||||
|
||||
if frame := pickCrashFrame(occ.Frames); frame != nil {
|
||||
if sig := normalizeFrame(frame); sig != "" {
|
||||
parts = append(parts, "frame:"+sig)
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
if occ.Value != "" {
|
||||
parts = append(parts, "value:"+normalizeMessage(occ.Value))
|
||||
return parts
|
||||
}
|
||||
if occ.Transaction != "" {
|
||||
parts = append(parts, "txn:"+occ.Transaction)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "level:"+occ.Level)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// pickCrashFrame returns the frame the error occurred in: the innermost (last)
|
||||
// in-app frame if any, else the innermost frame. Sentry orders frames caller→callee,
|
||||
// so the crash site is the last element.
|
||||
func pickCrashFrame(frames []errortrackingtypes.Frame) *errortrackingtypes.Frame {
|
||||
if len(frames) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := len(frames) - 1; i >= 0; i-- {
|
||||
if frames[i].InApp {
|
||||
return &frames[i]
|
||||
}
|
||||
}
|
||||
return &frames[len(frames)-1]
|
||||
}
|
||||
|
||||
// normalizeFrame renders a frame to a host-independent signature: normalized
|
||||
// function name at its module (or normalized filename). Line/column numbers and
|
||||
// absolute paths are dropped so the same logical crash site groups across
|
||||
// deploys, releases and machines.
|
||||
func normalizeFrame(f *errortrackingtypes.Frame) string {
|
||||
fn := normalizeFunction(f.Function)
|
||||
|
||||
loc := f.Module
|
||||
if loc == "" {
|
||||
loc = normalizeFilename(f.Filename)
|
||||
}
|
||||
if loc == "" {
|
||||
loc = normalizeFilename(f.AbsPath)
|
||||
}
|
||||
|
||||
switch {
|
||||
case fn != "" && loc != "":
|
||||
return fn + "@" + loc
|
||||
case fn != "":
|
||||
return fn
|
||||
default:
|
||||
return loc
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFunction(fn string) string {
|
||||
fn = strings.TrimSpace(fn)
|
||||
if fn == "" {
|
||||
return ""
|
||||
}
|
||||
// Drop runtime address noise inside anonymous/closure names.
|
||||
fn = reFuncNoise.ReplaceAllString(fn, "")
|
||||
return strings.TrimSpace(fn)
|
||||
}
|
||||
|
||||
// normalizeFilename keeps the basename and its immediate parent (enough to be
|
||||
// unique in practice) and masks content-hash / versioned segments so bundled
|
||||
// asset names like app.9f3a2b1c.js group across builds.
|
||||
func normalizeFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
if i := strings.IndexAny(name, "?#"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
segs := strings.Split(strings.Trim(name, "/"), "/")
|
||||
// Keep the last two path segments.
|
||||
if len(segs) > 2 {
|
||||
segs = segs[len(segs)-2:]
|
||||
}
|
||||
for i, s := range segs {
|
||||
s = reLongHex.ReplaceAllString(s, "*")
|
||||
s = reNumber.ReplaceAllString(s, "*")
|
||||
segs[i] = s
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// normalizeMessage collapses variadic detail (ids, numbers, hex, quoted literals)
|
||||
// so "user 123 missing" and "user 456 missing" land in one issue.
|
||||
func normalizeMessage(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
msg = reUUID.ReplaceAllString(msg, "<uuid>")
|
||||
msg = reHexAddr.ReplaceAllString(msg, "<hex>")
|
||||
msg = reQuoted.ReplaceAllString(msg, "<str>")
|
||||
msg = reLongHex.ReplaceAllString(msg, "<hex>")
|
||||
msg = reNumber.ReplaceAllString(msg, "<num>")
|
||||
msg = reWhitespace.ReplaceAllString(msg, " ")
|
||||
return strings.TrimSpace(msg)
|
||||
}
|
||||
|
||||
func hashParts(parts []string) string {
|
||||
h := sha256.New()
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
h.Write([]byte(p))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func frame(fn, module, file string, inApp bool) errortrackingtypes.Frame {
|
||||
return errortrackingtypes.Frame{Function: fn, Module: module, Filename: file, InApp: inApp}
|
||||
}
|
||||
|
||||
// Same crash site (type + top frame) groups even when the message varies.
|
||||
func TestFingerprint_GroupsBySameCrashFrame(t *testing.T) {
|
||||
a := &errortrackingtypes.Occurrence{Type: "ValueError", Value: "id 12 bad", Frames: []errortrackingtypes.Frame{frame("handle", "app.svc", "svc.py", true)}}
|
||||
b := &errortrackingtypes.Occurrence{Type: "ValueError", Value: "id 999 bad", Frames: []errortrackingtypes.Frame{frame("handle", "app.svc", "svc.py", true)}}
|
||||
assert.Equal(t, computeFingerprint(a, nil), computeFingerprint(b, nil), "same type+frame must group regardless of message")
|
||||
}
|
||||
|
||||
func TestFingerprint_DistinctForDifferentTypes(t *testing.T) {
|
||||
a := &errortrackingtypes.Occurrence{Type: "ValueError", Frames: []errortrackingtypes.Frame{frame("handle", "app.svc", "svc.py", true)}}
|
||||
b := &errortrackingtypes.Occurrence{Type: "KeyError", Frames: []errortrackingtypes.Frame{frame("handle", "app.svc", "svc.py", true)}}
|
||||
assert.NotEqual(t, computeFingerprint(a, nil), computeFingerprint(b, nil))
|
||||
}
|
||||
|
||||
// The crash frame is the innermost in-app frame (Sentry orders caller→callee).
|
||||
func TestFingerprint_PicksInnermostInAppFrame(t *testing.T) {
|
||||
frames := []errortrackingtypes.Frame{
|
||||
frame("main", "app", "main.go", true),
|
||||
frame("libcall", "vendor.lib", "lib.go", false),
|
||||
frame("crashHere", "app.worker", "worker.go", true),
|
||||
frame("runtimePanic", "runtime", "panic.go", false),
|
||||
}
|
||||
got := pickCrashFrame(frames)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "crashHere", got.Function, "innermost in-app frame is the crash site, not the runtime frame")
|
||||
}
|
||||
|
||||
// Message-only errors group by normalized message (numbers/uuids masked).
|
||||
func TestFingerprint_MessageFallbackMasksVariadic(t *testing.T) {
|
||||
a := &errortrackingtypes.Occurrence{Type: "Message", Value: "user 123 not found in shard 7"}
|
||||
b := &errortrackingtypes.Occurrence{Type: "Message", Value: "user 456 not found in shard 9"}
|
||||
assert.Equal(t, computeFingerprint(a, nil), computeFingerprint(b, nil))
|
||||
|
||||
c := &errortrackingtypes.Occurrence{Type: "Message", Value: "totally different failure"}
|
||||
assert.NotEqual(t, computeFingerprint(a, nil), computeFingerprint(c, nil))
|
||||
}
|
||||
|
||||
func TestFingerprint_CustomHonored(t *testing.T) {
|
||||
a := &errortrackingtypes.Occurrence{Type: "ValueError", Value: "x"}
|
||||
b := &errortrackingtypes.Occurrence{Type: "KeyError", Value: "y"}
|
||||
// Same explicit fingerprint => same group despite different types.
|
||||
assert.Equal(t, computeFingerprint(a, []string{"my-group"}), computeFingerprint(b, []string{"my-group"}))
|
||||
assert.NotEqual(t, computeFingerprint(a, []string{"g1"}), computeFingerprint(a, []string{"g2"}))
|
||||
}
|
||||
|
||||
// "{{ default }}" expands to the computed default, so ["{{ default }}", "tenant"]
|
||||
// subdivides the default group by tenant.
|
||||
func TestFingerprint_DefaultTokenExpands(t *testing.T) {
|
||||
occ := &errortrackingtypes.Occurrence{Type: "ValueError", Frames: []errortrackingtypes.Frame{frame("h", "m", "f.go", true)}}
|
||||
base := computeFingerprint(occ, nil)
|
||||
withToken := computeFingerprint(occ, []string{defaultFingerprintToken})
|
||||
assert.Equal(t, base, withToken, "bare {{ default }} equals the default fingerprint")
|
||||
|
||||
subdivided := computeFingerprint(occ, []string{defaultFingerprintToken, "tenant-a"})
|
||||
assert.NotEqual(t, base, subdivided, "adding a component must change the group")
|
||||
}
|
||||
|
||||
func TestNormalizeMessage_Masks(t *testing.T) {
|
||||
assert.Equal(t, normalizeMessage("id 42 at 0xdeadbeef"), normalizeMessage("id 7 at 0xcafef00d"))
|
||||
assert.Equal(t,
|
||||
normalizeMessage("row 550e8400-e29b-41d4-a716-446655440000 gone"),
|
||||
normalizeMessage("row 550e8400-e29b-41d4-a716-000000000000 gone"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestFingerprint_IsHex(t *testing.T) {
|
||||
fp := computeFingerprint(&errortrackingtypes.Occurrence{Type: "E"}, nil)
|
||||
assert.Len(t, fp, 64)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package implerrortracking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -17,129 +16,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
viewTimeout = 30 * time.Second
|
||||
writeTimeout = 15 * time.Second
|
||||
ingestTimeout = 15 * time.Second
|
||||
|
||||
// maxCompressedBody bounds the raw request body before decompression; the
|
||||
// decoded payload is separately bounded by maxDecodedBytes and the event count
|
||||
// by maxEventsPerEnvelope.
|
||||
maxCompressedBody = 6 << 20
|
||||
viewTimeout = 30 * time.Second
|
||||
writeTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// eventParser turns a decoded ingest body into events; the two wire formats differ
|
||||
// only here (envelope framing vs a single store event).
|
||||
type eventParser func([]byte) ([]*errortrackingtypes.SentryEvent, error)
|
||||
|
||||
type handler struct {
|
||||
module errortracking.Module
|
||||
// ingestSecret is the KMS-sourced platform ingest secret used to verify DSN
|
||||
// public keys. Empty => ingest is disabled (fail closed), reads still work.
|
||||
ingestSecret []byte
|
||||
capturePII bool
|
||||
revocations RevocationStore
|
||||
limiter *rateLimiter
|
||||
}
|
||||
|
||||
// NewHandler builds the HTTP surface. ingestSecret is the KMS-synced platform
|
||||
// error-ingest secret (empty => ingest fails closed 503); capturePII retains
|
||||
// end-user PII when true (default false = scrub); revocations resolves per-org key
|
||||
// rotation (nil => none).
|
||||
func NewHandler(module errortracking.Module, ingestSecret []byte, capturePII bool, revocations RevocationStore) errortracking.Handler {
|
||||
if revocations == nil {
|
||||
revocations = NoopRevocations{}
|
||||
}
|
||||
return &handler{
|
||||
module: module,
|
||||
ingestSecret: ingestSecret,
|
||||
capturePII: capturePII,
|
||||
revocations: revocations,
|
||||
limiter: newRateLimiter(ingestRatePerSec, ingestBurst),
|
||||
}
|
||||
}
|
||||
|
||||
// --- ingest (public, DSN-authenticated) ---
|
||||
|
||||
func (h *handler) EnvelopeIngest(rw http.ResponseWriter, r *http.Request) {
|
||||
h.ingest(rw, r, parseEnvelope)
|
||||
}
|
||||
|
||||
func (h *handler) StoreIngest(rw http.ResponseWriter, r *http.Request) {
|
||||
h.ingest(rw, r, parseStoreBody)
|
||||
}
|
||||
|
||||
// ingest is the shared pipeline: enabled-check → resolve org from the DSN project →
|
||||
// verify the DSN key at its version (rejecting revoked versions, constant-time) →
|
||||
// per-org rate limit → bounded read+decode → parse (event-count capped) → normalize
|
||||
// (scrub) → group+upsert the whole batch in one transaction. Every failure fails
|
||||
// closed and never leaks internal detail to the untrusted client.
|
||||
func (h *handler) ingest(rw http.ResponseWriter, r *http.Request, parse eventParser) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), ingestTimeout)
|
||||
defer cancel()
|
||||
|
||||
if len(h.ingestSecret) == 0 {
|
||||
http.Error(rw, "error ingest is not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
project := coretypes.Param(r, "project_id")
|
||||
orgID, ok := orgUUIDFromProject(project)
|
||||
if !ok {
|
||||
http.Error(rw, "missing project", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
minVersion := h.revocations.MinVersion(ctx, orgID)
|
||||
if !verifyKey(h.ingestSecret, project, sentryKeyFromRequest(r), minVersion) {
|
||||
// Sentry SDKs treat 401 as "bad DSN" and drop the event (no retry storm).
|
||||
http.Error(rw, "invalid ingest key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.limiter.allow(orgID) {
|
||||
rw.Header().Set("Retry-After", "1")
|
||||
http.Error(rw, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, maxCompressedBody)
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(rw, "payload too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
decoded, err := decodeBody(raw, r.Header.Get("Content-Encoding"))
|
||||
if err != nil {
|
||||
http.Error(rw, "cannot decode body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
events, err := parse(decoded)
|
||||
if err != nil {
|
||||
http.Error(rw, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
opts := ingestOpts{capturePII: h.capturePII}
|
||||
occs := make([]*errortrackingtypes.Occurrence, 0, len(events))
|
||||
lastID := ""
|
||||
for _, ev := range events {
|
||||
occ := normalizeEvent(ev, opts)
|
||||
if occ.Fingerprint == "" {
|
||||
continue
|
||||
}
|
||||
occs = append(occs, occ)
|
||||
lastID = occ.EventID
|
||||
}
|
||||
|
||||
if _, err := h.module.Ingest(ctx, orgID, occs); err != nil {
|
||||
// A store failure is ours, not the client's — 500 so the SDK retries.
|
||||
http.Error(rw, "ingest failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Sentry SDKs only require 200; echo the last event id for parity.
|
||||
render.Success(rw, http.StatusOK, map[string]string{"id": lastID})
|
||||
// NewHandler builds the HTTP surface: the org-scoped issue reads and the lifecycle
|
||||
// write. Errors enter through /v1/event only, so there is no ingest here.
|
||||
func NewHandler(module errortracking.Module) errortracking.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// --- reads (Hanzo IAM authz, org-scoped) ---
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking"
|
||||
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newIngestFixture(t *testing.T, secret []byte) (errortracking.Handler, errortracking.Module) {
|
||||
t.Helper()
|
||||
mod := NewModule(NewStore(newTestStore(t)), NewNoopSink())
|
||||
return NewHandler(mod, secret, false, nil), mod
|
||||
}
|
||||
|
||||
func envelopeFor(project string) []byte {
|
||||
return []byte(`{"event_id":"deadbeef","dsn":"https://k@h/` + project + `"}
|
||||
{"type":"event"}
|
||||
{"event_id":"deadbeef","platform":"python","exception":{"values":[{"type":"ValueError","value":"bad input","stacktrace":{"frames":[{"function":"handle","module":"app.svc","in_app":true}]}}]}}
|
||||
`)
|
||||
}
|
||||
|
||||
func ingestReq(project, key string, body []byte) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/o11y/api/"+project+"/envelope/", bytes.NewReader(body))
|
||||
if key != "" {
|
||||
req.Header.Set("X-Sentry-Auth", "Sentry sentry_version=7, sentry_key="+key)
|
||||
}
|
||||
return coretypes.SetRoute(req, "/v1/o11y/api/{project_id}/envelope/")
|
||||
}
|
||||
|
||||
func TestIngest_EndToEnd_ValidKeyStoresIssueUnderResolvedOrg(t *testing.T) {
|
||||
secret := []byte("kms-secret")
|
||||
h, mod := newIngestFixture(t, secret)
|
||||
|
||||
key := publicKeyFor(secret, "acme")
|
||||
w := httptest.NewRecorder()
|
||||
h.EnvelopeIngest(w, ingestReq("acme", key, envelopeFor("acme")))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
orgID, _ := orgUUIDFromProject("acme")
|
||||
list, total, err := mod.ListIssues(context.Background(), orgID, &errortrackingtypes.IssuesQuery{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, total)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, "ValueError", list[0].Type)
|
||||
assert.Equal(t, "bad input", list[0].Value)
|
||||
assert.Equal(t, "python", list[0].Platform)
|
||||
}
|
||||
|
||||
func TestIngest_RejectsBadKey(t *testing.T) {
|
||||
secret := []byte("kms-secret")
|
||||
h, mod := newIngestFixture(t, secret)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.EnvelopeIngest(w, ingestReq("acme", "wrong-key", envelopeFor("acme")))
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
orgID, _ := orgUUIDFromProject("acme")
|
||||
_, total, err := mod.ListIssues(context.Background(), orgID, &errortrackingtypes.IssuesQuery{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, total, "a rejected ingest must persist nothing")
|
||||
}
|
||||
|
||||
func TestIngest_MissingKeyRejected(t *testing.T) {
|
||||
secret := []byte("kms-secret")
|
||||
h, _ := newIngestFixture(t, secret)
|
||||
w := httptest.NewRecorder()
|
||||
h.EnvelopeIngest(w, ingestReq("acme", "", envelopeFor("acme")))
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
// A key minted for org "acme" must not authorize writing to a different project.
|
||||
func TestIngest_CrossOrgKeyRejected(t *testing.T) {
|
||||
secret := []byte("kms-secret")
|
||||
h, mod := newIngestFixture(t, secret)
|
||||
|
||||
acmeKey := publicKeyFor(secret, "acme")
|
||||
w := httptest.NewRecorder()
|
||||
// Present acme's key but target project "victim".
|
||||
h.EnvelopeIngest(w, ingestReq("victim", acmeKey, envelopeFor("victim")))
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
victimOrg, _ := orgUUIDFromProject("victim")
|
||||
_, total, err := mod.ListIssues(context.Background(), victimOrg, &errortrackingtypes.IssuesQuery{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, total, "acme's key must not write into victim's org")
|
||||
}
|
||||
|
||||
func TestIngest_DisabledWithoutSecret(t *testing.T) {
|
||||
h, _ := newIngestFixture(t, nil) // no KMS secret => ingest disabled
|
||||
w := httptest.NewRecorder()
|
||||
h.EnvelopeIngest(w, ingestReq("acme", "anything", envelopeFor("acme")))
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
func TestIngest_LegacyStoreEndpoint(t *testing.T) {
|
||||
secret := []byte("kms-secret")
|
||||
h, mod := newIngestFixture(t, secret)
|
||||
key := publicKeyFor(secret, "acme")
|
||||
|
||||
body := []byte(`{"event_id":"1","exception":{"values":[{"type":"KeyError","value":"missing"}]}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/o11y/api/acme/store/", bytes.NewReader(body))
|
||||
req.Header.Set("X-Sentry-Auth", "Sentry sentry_key="+key)
|
||||
req = coretypes.SetRoute(req, "/v1/o11y/api/{project_id}/store/")
|
||||
w := httptest.NewRecorder()
|
||||
h.StoreIngest(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
orgID, _ := orgUUIDFromProject("acme")
|
||||
_, total, err := mod.ListIssues(context.Background(), orgID, &errortrackingtypes.IssuesQuery{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, total)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package implerrortracking
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -26,9 +25,9 @@ func TestIngest_CollapsesDuplicateFingerprints(t *testing.T) {
|
||||
for i := 0; i < 5000; i++ {
|
||||
occs = append(occs, occ("fp-flood", "TypeError", "boom", time.Now().UTC()))
|
||||
}
|
||||
written, err := mod.Ingest(ctx, orgA, occs)
|
||||
created, err := mod.Ingest(ctx, orgA, occs)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, written, "5000 identical events must become ONE upsert, not 5000")
|
||||
assert.Len(t, created, 1, "5000 identical events must become ONE new issue, not 5000")
|
||||
|
||||
list, total, err := mod.ListIssues(ctx, orgA, &errortrackingtypes.IssuesQuery{})
|
||||
require.NoError(t, err)
|
||||
@@ -36,20 +35,6 @@ func TestIngest_CollapsesDuplicateFingerprints(t *testing.T) {
|
||||
assert.Equal(t, int64(5000), list[0].Count, "count still reflects every event")
|
||||
}
|
||||
|
||||
// parseEnvelope refuses to extract more than the per-request cap, so one request
|
||||
// cannot fan out into unbounded upserts.
|
||||
func TestParseEnvelope_CapsEventCount(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"event_id":"x"}` + "\n")
|
||||
for i := 0; i < maxEventsPerEnvelope+500; i++ {
|
||||
b.WriteString(`{"type":"event"}` + "\n")
|
||||
b.WriteString(`{"exception":{"values":[{"type":"E"}]}}` + "\n")
|
||||
}
|
||||
events, err := parseEnvelope([]byte(b.String()))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, events, maxEventsPerEnvelope, "event extraction is capped")
|
||||
}
|
||||
|
||||
// The per-org issue ceiling admits only `ceiling` NEW fingerprints; existing ones
|
||||
// keep bumping past the cap.
|
||||
func TestStore_CeilingCapsNewFingerprints(t *testing.T) {
|
||||
@@ -71,9 +56,9 @@ func TestStore_CeilingCapsNewFingerprints(t *testing.T) {
|
||||
batch = append(batch, iss)
|
||||
}
|
||||
|
||||
written, err := s.UpsertIssues(ctx, org, batch, 5)
|
||||
created, err := s.UpsertIssues(ctx, org, batch, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, written, "only ceiling-many NEW fingerprints are admitted")
|
||||
assert.Len(t, created, 5, "only ceiling-many NEW fingerprints are admitted")
|
||||
|
||||
_, total, err := s.ListIssues(ctx, org, &errortrackingtypes.IssuesQuery{})
|
||||
require.NoError(t, err)
|
||||
@@ -83,121 +68,14 @@ func TestStore_CeilingCapsNewFingerprints(t *testing.T) {
|
||||
for _, iss := range batch {
|
||||
iss.ID = valuer.GenerateUUID()
|
||||
}
|
||||
_, err = s.UpsertIssues(ctx, org, batch, 5)
|
||||
again, err := s.UpsertIssues(ctx, org, batch, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, again, "a second sight of an existing fingerprint creates nothing")
|
||||
_, total2, err := s.ListIssues(ctx, org, &errortrackingtypes.IssuesQuery{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, total2, "ceiling still holds; existing issues just bumped")
|
||||
}
|
||||
|
||||
// --- MEDIUM-1: a hostile envelope item length never panics ---
|
||||
|
||||
func TestParseEnvelope_HugeLengthNoPanic(t *testing.T) {
|
||||
body := []byte(`{"event_id":"x"}` + "\n" +
|
||||
`{"type":"event","length":9223372036854775807}` + "\n" +
|
||||
`{"exception":{"values":[{"type":"E"}]}}` + "\n")
|
||||
assert.NotPanics(t, func() {
|
||||
_, _ = parseEnvelope(body)
|
||||
}, "a MaxInt64 length must not overflow the slice bound")
|
||||
}
|
||||
|
||||
func TestParseEnvelope_NegativeLengthNoPanic(t *testing.T) {
|
||||
body := []byte(`{"event_id":"x"}` + "\n" +
|
||||
`{"type":"event","length":-1}` + "\n" +
|
||||
`{"exception":{"values":[{"type":"E"}]}}` + "\n")
|
||||
assert.NotPanics(t, func() {
|
||||
events, _ := parseEnvelope(body)
|
||||
// Falls back to newline-delimited framing, so the event is still read.
|
||||
require.Len(t, events, 1)
|
||||
})
|
||||
}
|
||||
|
||||
// --- HIGH-1: per-org rate limiter ---
|
||||
|
||||
func TestRateLimiter_AllowsBurstThenLimits(t *testing.T) {
|
||||
l := newRateLimiter(0.0001, 3) // ~no refill within the test window
|
||||
org := valuer.GenerateUUID()
|
||||
assert.True(t, l.allow(org))
|
||||
assert.True(t, l.allow(org))
|
||||
assert.True(t, l.allow(org))
|
||||
assert.False(t, l.allow(org), "burst exhausted → limited")
|
||||
|
||||
// A different org has its own bucket.
|
||||
assert.True(t, l.allow(valuer.GenerateUUID()))
|
||||
}
|
||||
|
||||
// --- MEDIUM-2: secret redaction (always) + PII scrub (default) ---
|
||||
|
||||
func TestSanitize_AlwaysRedactsSecrets(t *testing.T) {
|
||||
cases := []string{
|
||||
"key sk-abcdef0123456789ABCDEF leaked",
|
||||
"aws AKIAIOSFODNN7EXAMPLE creds",
|
||||
"Authorization: Bearer abcdef123456ghijkl",
|
||||
"postgres://user:s3cr3tpw@db:5432/app",
|
||||
"card 4111 1111 1111 1111 charged",
|
||||
"hanzo hk-0123456789abcdef0123 token",
|
||||
}
|
||||
for _, c := range cases {
|
||||
// Even with PII capture ON, secrets are still removed.
|
||||
got := sanitize(c, true)
|
||||
assert.Contains(t, got, redactedMark, "secret must be redacted: %q -> %q", c, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubPII_MasksEmailAndIP_ByDefault(t *testing.T) {
|
||||
scrubbed := sanitize("user [email protected] from 10.0.0.5 failed", false)
|
||||
assert.NotContains(t, scrubbed, "[email protected]")
|
||||
assert.NotContains(t, scrubbed, "10.0.0.5")
|
||||
assert.Contains(t, scrubbed, emailMark)
|
||||
assert.Contains(t, scrubbed, ipMark)
|
||||
|
||||
// With capture ON, PII is retained (but secrets still go).
|
||||
kept := sanitize("user [email protected] from 10.0.0.5 failed", true)
|
||||
assert.Contains(t, kept, "[email protected]")
|
||||
assert.Contains(t, kept, "10.0.0.5")
|
||||
}
|
||||
|
||||
// The normalizer scrubs by default (fail-secure) — the stored value and the sample
|
||||
// carry no secret/PII.
|
||||
func TestNormalize_ScrubsByDefault(t *testing.T) {
|
||||
e := mustEvent(t, `{"event_id":"a","exception":{"values":[{"type":"AuthError","value":"token sk-DEADBEEFdeadbeef012345 for [email protected]"}]}}`)
|
||||
occ := normalizeEvent(e) // default opts → scrub
|
||||
assert.NotContains(t, occ.Value, "sk-DEADBEEFdeadbeef012345")
|
||||
assert.NotContains(t, occ.Value, "[email protected]")
|
||||
}
|
||||
|
||||
// --- MEDIUM-3: versioned, per-org-revocable DSN keys ---
|
||||
|
||||
func TestVerifyKey_VersionedAndRevocation(t *testing.T) {
|
||||
secret := []byte("kms")
|
||||
v1 := publicKeyForVersion(secret, "acme", 1)
|
||||
v2 := publicKeyForVersion(secret, "acme", 2)
|
||||
require.True(t, strings.HasPrefix(v1, "1:"))
|
||||
require.True(t, strings.HasPrefix(v2, "2:"))
|
||||
|
||||
// Below the org's min-version is rejected; at/above verifies.
|
||||
assert.True(t, verifyKey(secret, "acme", v1, 0), "v1 valid when nothing revoked")
|
||||
assert.False(t, verifyKey(secret, "acme", v1, 2), "v1 revoked once min-version is 2")
|
||||
assert.True(t, verifyKey(secret, "acme", v2, 2), "v2 still valid at min-version 2")
|
||||
// A malformed version prefix fails closed.
|
||||
assert.False(t, verifyKey(secret, "acme", "notanumber:"+v1, 0))
|
||||
assert.False(t, verifyKey(secret, "acme", "0:"+strings.TrimPrefix(v1, "1:"), 0))
|
||||
}
|
||||
|
||||
func TestSQLRevocations_RotateIsolatesOneOrg(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
r := NewSQLRevocations(store)
|
||||
orgA := valuer.GenerateUUID()
|
||||
orgB := valuer.GenerateUUID()
|
||||
|
||||
assert.Equal(t, 0, r.MinVersion(ctx, orgA), "default min-version is 0")
|
||||
|
||||
require.NoError(t, r.Rotate(ctx, orgA, 2))
|
||||
assert.Equal(t, 2, r.MinVersion(ctx, orgA), "rotated org sees its new watermark")
|
||||
assert.Equal(t, 0, r.MinVersion(ctx, orgB), "other orgs are untouched (isolated rotation)")
|
||||
}
|
||||
|
||||
// --- LOW-1: optimistic concurrency on lifecycle update ---
|
||||
|
||||
func TestStore_OptimisticUpdateConflict(t *testing.T) {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// RevocationStore resolves an org's minimum-acceptable DSN key version, enabling
|
||||
// PER-ORG key rotation without a global secret roll: bump ONE org's min-version and
|
||||
// only that org's older-version DSNs stop verifying. Default 0 = no revocation.
|
||||
type RevocationStore interface {
|
||||
MinVersion(ctx context.Context, orgID valuer.UUID) int
|
||||
}
|
||||
|
||||
// NoopRevocations never revokes (every org's min-version is 0). Used where key
|
||||
// rotation state isn't wired (tests, standalone).
|
||||
type NoopRevocations struct{}
|
||||
|
||||
func (NoopRevocations) MinVersion(context.Context, valuer.UUID) int { return 0 }
|
||||
|
||||
const revocationCacheTTL = 30 * time.Second
|
||||
|
||||
// ingestRevocation is one org's rotation watermark.
|
||||
type ingestRevocation struct {
|
||||
bun.BaseModel `bun:"table:o11y_ingest_revocations,alias:o11y_ingest_revocations"`
|
||||
|
||||
OrgID valuer.UUID `bun:"org_id,pk,type:text"`
|
||||
MinVersion int64 `bun:"min_version,notnull,default:0"`
|
||||
UpdatedAt time.Time `bun:"updated_at,notnull"`
|
||||
}
|
||||
|
||||
// sqlRevocations is the table-backed store with a wholesale in-memory cache (the
|
||||
// table is small — one row per rotated org). A read miss or TTL lapse reloads the
|
||||
// whole table; on a load error the last-good cache is kept (fail-open to the last
|
||||
// known state, never crash the hot ingest path).
|
||||
type sqlRevocations struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]int
|
||||
loadedAt time.Time
|
||||
}
|
||||
|
||||
func NewSQLRevocations(sqlstore sqlstore.SQLStore) *sqlRevocations {
|
||||
return &sqlRevocations{sqlstore: sqlstore, ttl: revocationCacheTTL, cache: map[string]int{}}
|
||||
}
|
||||
|
||||
func (r *sqlRevocations) MinVersion(ctx context.Context, orgID valuer.UUID) int {
|
||||
r.mu.RLock()
|
||||
fresh := !r.loadedAt.IsZero() && time.Since(r.loadedAt) < r.ttl
|
||||
v := r.cache[orgID.String()]
|
||||
r.mu.RUnlock()
|
||||
if fresh {
|
||||
return v
|
||||
}
|
||||
r.reload(ctx)
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.cache[orgID.String()]
|
||||
}
|
||||
|
||||
func (r *sqlRevocations) reload(ctx context.Context) {
|
||||
var rows []ingestRevocation
|
||||
if err := r.sqlstore.BunDBCtx(ctx).NewSelect().Model(&rows).Scan(ctx); err != nil {
|
||||
return // keep stale cache on error
|
||||
}
|
||||
m := make(map[string]int, len(rows))
|
||||
for _, row := range rows {
|
||||
m[row.OrgID.String()] = int(row.MinVersion)
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cache = m
|
||||
r.loadedAt = time.Now()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Rotate raises an org's minimum key version, revoking every DSN issued below it.
|
||||
// The operator then mints a fresh DSN at the new version.
|
||||
func (r *sqlRevocations) Rotate(ctx context.Context, orgID valuer.UUID, minVersion int) error {
|
||||
rev := &ingestRevocation{OrgID: orgID, MinVersion: int64(minVersion), UpdatedAt: time.Now().UTC()}
|
||||
_, err := r.sqlstore.BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rev).
|
||||
On("CONFLICT (org_id) DO UPDATE").
|
||||
Set("min_version = EXCLUDED.min_version").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Exec(ctx)
|
||||
r.mu.Lock()
|
||||
r.loadedAt = time.Time{} // force reload on next read
|
||||
r.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// Per-org token-bucket rate limit on the public ingest path — sustained-flood
|
||||
// backpressure that complements the per-request event cap and per-org issue ceiling
|
||||
// (which bound a SINGLE request). It is applied AFTER DSN verification, so a bucket
|
||||
// is only ever created for an authenticated org (a forged project fails HMAC first
|
||||
// and never allocates state). In-process/per-replica by design; a cross-replica
|
||||
// distributed quota is a fast-follow.
|
||||
const (
|
||||
ingestRatePerSec = 50 // steady-state events/requests per org per replica
|
||||
ingestBurst = 100 // burst allowance
|
||||
)
|
||||
|
||||
type tokenBucket struct {
|
||||
mu sync.Mutex
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (b *tokenBucket) allow(rate, burst float64, now time.Time) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.tokens += now.Sub(b.last).Seconds() * rate
|
||||
if b.tokens > burst {
|
||||
b.tokens = burst
|
||||
}
|
||||
b.last = now
|
||||
if b.tokens >= 1 {
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type rateLimiter struct {
|
||||
rate float64
|
||||
burst float64
|
||||
buckets sync.Map // orgID string -> *tokenBucket
|
||||
}
|
||||
|
||||
func newRateLimiter(rate, burst float64) *rateLimiter {
|
||||
return &rateLimiter{rate: rate, burst: burst}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) allow(org valuer.UUID) bool {
|
||||
v, _ := l.buckets.LoadOrStore(org.String(), &tokenBucket{tokens: l.burst, last: time.Now()})
|
||||
return v.(*tokenBucket).allow(l.rate, l.burst, time.Now())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
@@ -24,7 +25,6 @@ const (
|
||||
|
||||
type module struct {
|
||||
store errortrackingtypes.Store
|
||||
sink OccurrenceSink
|
||||
retention time.Duration
|
||||
}
|
||||
|
||||
@@ -38,13 +38,10 @@ func WithRetention(d time.Duration) Option {
|
||||
return func(m *module) { m.retention = d }
|
||||
}
|
||||
|
||||
// NewModule wires the issue store and the (default no-op) occurrence sink. When a
|
||||
// positive retention is configured it starts the TTL sweeper.
|
||||
func NewModule(store errortrackingtypes.Store, sink OccurrenceSink, opts ...Option) errortracking.Module {
|
||||
if sink == nil {
|
||||
sink = NoopSink{}
|
||||
}
|
||||
m := &module{store: store, sink: sink}
|
||||
// NewModule wires the issue store. When a positive retention is configured it
|
||||
// starts the TTL sweeper.
|
||||
func NewModule(store errortrackingtypes.Store, opts ...Option) errortracking.Module {
|
||||
m := &module{store: store}
|
||||
for _, o := range opts {
|
||||
o(m)
|
||||
}
|
||||
@@ -54,19 +51,19 @@ func NewModule(store errortrackingtypes.Store, sink OccurrenceSink, opts ...Opti
|
||||
return m
|
||||
}
|
||||
|
||||
// Ingest groups a whole request's occurrences into their issues. Occurrences are
|
||||
// FIRST collapsed by fingerprint (a flood of identical events becomes one upsert
|
||||
// with an incremented count) and then written in ONE transaction under the per-org
|
||||
// ceiling — so a single request can never fan out into a transaction-per-event
|
||||
// write storm. The occurrence sink is fail-soft (owns its own errors).
|
||||
func (m *module) Ingest(ctx context.Context, orgID valuer.UUID, occs []*errortrackingtypes.Occurrence) (int, error) {
|
||||
// Ingest groups a batch of occurrences into their issues. Occurrences are FIRST
|
||||
// collapsed by fingerprint (a flood of identical events becomes one upsert with an
|
||||
// incremented count) and then written in ONE transaction under the per-org ceiling —
|
||||
// so a single batch can never fan out into a transaction-per-event write storm. The
|
||||
// occurrences themselves are the event plane's error facts; nothing here copies them.
|
||||
func (m *module) Ingest(ctx context.Context, orgID valuer.UUID, occs []*errortrackingtypes.Occurrence) ([]*errortrackingtypes.Issue, error) {
|
||||
if orgID.IsZero() {
|
||||
return 0, errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "ingest has no org")
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "ingest has no org")
|
||||
}
|
||||
|
||||
groups := aggregateByFingerprint(occs)
|
||||
if len(groups) == 0 {
|
||||
return 0, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
@@ -74,17 +71,7 @@ func (m *module) Ingest(ctx context.Context, orgID valuer.UUID, occs []*errortra
|
||||
for fp, g := range groups {
|
||||
issues = append(issues, issueFromGroup(orgID, fp, g, now))
|
||||
}
|
||||
|
||||
written, err := m.store.UpsertIssues(ctx, orgID, issues, maxIssuesPerOrg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Reused occurrence store; bounded to one write per distinct fingerprint and
|
||||
// fail-soft — the issues are already durable.
|
||||
for _, g := range groups {
|
||||
_ = m.sink.Write(ctx, orgID, g.sample)
|
||||
}
|
||||
return written, nil
|
||||
return m.store.UpsertIssues(ctx, orgID, issues, maxIssuesPerOrg)
|
||||
}
|
||||
|
||||
func (m *module) ListIssues(ctx context.Context, orgID valuer.UUID, q *errortrackingtypes.IssuesQuery) ([]*errortrackingtypes.Issue, int, error) {
|
||||
@@ -212,10 +199,10 @@ func issueFromGroup(orgID valuer.UUID, fingerprint string, g *occurrenceGroup, n
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
OrgID: orgID,
|
||||
Fingerprint: fingerprint,
|
||||
Type: firstNonEmpty(g.sample.Type, "Error"),
|
||||
Type: cmp.Or(g.sample.Type, "Error"),
|
||||
Value: g.sample.Value,
|
||||
Culprit: g.sample.Culprit,
|
||||
Level: firstNonEmpty(g.sample.Level, errortrackingtypes.DefaultLevel),
|
||||
Level: cmp.Or(g.sample.Level, errortrackingtypes.DefaultLevel),
|
||||
Platform: g.sample.Platform,
|
||||
Status: errortrackingtypes.StatusUnresolved,
|
||||
FirstSeen: first,
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
)
|
||||
|
||||
const (
|
||||
maxValueLen = 8192
|
||||
maxCulpritLen = 512
|
||||
maxFrames = 250
|
||||
)
|
||||
|
||||
// ingestOpts carries the request-scoped ingest policy into the (otherwise pure)
|
||||
// normalizer — currently just whether to retain end-user PII.
|
||||
type ingestOpts struct {
|
||||
capturePII bool
|
||||
}
|
||||
|
||||
// normalizeEvent turns a decoded Sentry event into the canonical Occurrence and
|
||||
// stamps its fingerprint. It is total: any missing/odd field degrades to a safe
|
||||
// default rather than erroring, because an ingest endpoint must never 500 on a
|
||||
// malformed client payload. Secrets are always redacted and PII scrubbed (unless
|
||||
// capture is enabled) BEFORE the value enters the fingerprint. The default (no
|
||||
// opts) is fail-secure: scrub.
|
||||
func normalizeEvent(e *errortrackingtypes.SentryEvent, opts ...ingestOpts) *errortrackingtypes.Occurrence {
|
||||
var o ingestOpts
|
||||
if len(opts) > 0 {
|
||||
o = opts[0]
|
||||
}
|
||||
occ := &errortrackingtypes.Occurrence{
|
||||
EventID: e.EventID,
|
||||
Level: firstNonEmpty(strings.ToLower(e.Level), errortrackingtypes.DefaultLevel),
|
||||
Platform: e.Platform,
|
||||
Timestamp: parseTimestamp(e.Timestamp),
|
||||
Environment: e.Environment,
|
||||
Release: e.Release,
|
||||
ServerName: e.ServerName,
|
||||
Transaction: e.Transaction,
|
||||
Tags: parseTags(e.Tags),
|
||||
}
|
||||
|
||||
if val := primaryException(e.Exception); val != nil {
|
||||
occ.Type = strings.TrimSpace(val.Type)
|
||||
occ.Value = truncate(val.Value, maxValueLen)
|
||||
if val.Stacktrace != nil {
|
||||
occ.Frames = convertFrames(val.Stacktrace.Frames)
|
||||
}
|
||||
}
|
||||
|
||||
// Message-only event (no exception): the message IS the grouping value.
|
||||
if occ.Type == "" && occ.Value == "" {
|
||||
if msg := parseMessage(e.Message); msg != "" {
|
||||
occ.Type = "Message"
|
||||
occ.Value = truncate(msg, maxValueLen)
|
||||
}
|
||||
}
|
||||
|
||||
occ.ServiceName = serviceName(e)
|
||||
occ.TraceID, occ.SpanID = traceContext(e.Contexts)
|
||||
occ.User = convertUser(e.User)
|
||||
occ.Culprit = truncate(culprit(occ, e), maxCulpritLen)
|
||||
|
||||
// Redact secrets (always) + PII (unless captured) before the value is hashed, so
|
||||
// grouping is stable and nothing sensitive reaches the fingerprint or storage.
|
||||
sanitizeOccurrence(occ, o.capturePII)
|
||||
|
||||
occ.Fingerprint = computeFingerprint(occ, e.Fingerprint)
|
||||
return occ
|
||||
}
|
||||
|
||||
// primaryException returns the thrown exception — the last value with content —
|
||||
// following the Sentry convention that chained causes precede the raised error.
|
||||
func primaryException(ex *errortrackingtypes.SentryException) *errortrackingtypes.SentryExceptionValue {
|
||||
if ex == nil || len(ex.Values) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := len(ex.Values) - 1; i >= 0; i-- {
|
||||
v := ex.Values[i]
|
||||
if v.Type != "" || v.Value != "" || v.Stacktrace != nil {
|
||||
return &ex.Values[i]
|
||||
}
|
||||
}
|
||||
return &ex.Values[len(ex.Values)-1]
|
||||
}
|
||||
|
||||
func convertFrames(in []errortrackingtypes.SentryFrame) []errortrackingtypes.Frame {
|
||||
if len(in) > maxFrames {
|
||||
in = in[len(in)-maxFrames:]
|
||||
}
|
||||
out := make([]errortrackingtypes.Frame, 0, len(in))
|
||||
for _, f := range in {
|
||||
out = append(out, errortrackingtypes.Frame{
|
||||
Function: f.Function,
|
||||
Module: f.Module,
|
||||
Filename: f.Filename,
|
||||
AbsPath: f.AbsPath,
|
||||
Lineno: f.Lineno,
|
||||
Colno: f.Colno,
|
||||
InApp: f.InApp != nil && *f.InApp,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertUser(u *errortrackingtypes.SentryUser) *errortrackingtypes.EventUser {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
if u.ID == "" && u.Email == "" && u.Username == "" && u.IPAddress == "" {
|
||||
return nil
|
||||
}
|
||||
return &errortrackingtypes.EventUser{ID: u.ID, Email: u.Email, Username: u.Username, IP: u.IPAddress}
|
||||
}
|
||||
|
||||
// culprit is the human-readable location shown on the issue: the transaction if
|
||||
// set, else the crash frame rendered readably, else the logger.
|
||||
func culprit(occ *errortrackingtypes.Occurrence, e *errortrackingtypes.SentryEvent) string {
|
||||
if e.Transaction != "" {
|
||||
return e.Transaction
|
||||
}
|
||||
if f := pickCrashFrame(occ.Frames); f != nil {
|
||||
loc := firstNonEmpty(f.Module, baseName(f.Filename), baseName(f.AbsPath))
|
||||
switch {
|
||||
case f.Function != "" && loc != "":
|
||||
return f.Function + " in " + loc
|
||||
case f.Function != "":
|
||||
return f.Function
|
||||
default:
|
||||
return loc
|
||||
}
|
||||
}
|
||||
return e.Logger
|
||||
}
|
||||
|
||||
// serviceName resolves the service the error belongs to: an explicit `server_name`
|
||||
// tag / the `service_name` tag / the SDK-reported server, defaulting to the SDK name.
|
||||
func serviceName(e *errortrackingtypes.SentryEvent) string {
|
||||
tags := parseTags(e.Tags)
|
||||
if v := tags["service_name"]; v != "" {
|
||||
return v
|
||||
}
|
||||
if v := tags["server_name"]; v != "" {
|
||||
return v
|
||||
}
|
||||
if e.ServerName != "" {
|
||||
return e.ServerName
|
||||
}
|
||||
if e.SDK != nil {
|
||||
return e.SDK.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// traceContext extracts distributed-trace linkage so an error can be pivoted to
|
||||
// its trace in the same o11y plane.
|
||||
func traceContext(contexts map[string]json.RawMessage) (traceID, spanID string) {
|
||||
raw, ok := contexts["trace"]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
var tc struct {
|
||||
TraceID string `json:"trace_id"`
|
||||
SpanID string `json:"span_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tc); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return tc.TraceID, tc.SpanID
|
||||
}
|
||||
|
||||
// parseTimestamp accepts the two shapes the SDKs emit: a unix-seconds number
|
||||
// (possibly fractional) or an ISO-8601 string. Unparseable → now.
|
||||
func parseTimestamp(raw json.RawMessage) time.Time {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
if s[0] == '"' {
|
||||
var str string
|
||||
if err := json.Unmarshal(raw, &str); err == nil {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} {
|
||||
if t, err := time.Parse(layout, str); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil && f > 0 {
|
||||
sec := int64(f)
|
||||
nsec := int64((f - float64(sec)) * 1e9)
|
||||
return time.Unix(sec, nsec).UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// parseMessage accepts the top-level message as a string or {message,formatted}.
|
||||
func parseMessage(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
if s[0] == '"' {
|
||||
var str string
|
||||
_ = json.Unmarshal(raw, &str)
|
||||
return str
|
||||
}
|
||||
var m errortrackingtypes.SentryMessage
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
return firstNonEmpty(m.Formatted, m.Message)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseTags accepts the two tag encodings: {k:v} or [[k,v],...]. Values are
|
||||
// coerced to strings; non-scalar values are dropped.
|
||||
func parseTags(raw json.RawMessage) map[string]string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
switch s[0] {
|
||||
case '{':
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
for k, v := range m {
|
||||
if sv := scalarString(v); sv != "" {
|
||||
out[k] = sv
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
var pairs [][]any
|
||||
if err := json.Unmarshal(raw, &pairs); err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if len(p) == 2 {
|
||||
if k := scalarString(p[0]); k != "" {
|
||||
out[k] = scalarString(p[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scalarString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(x)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func baseName(path string) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
path = strings.TrimRight(path, "/")
|
||||
if i := strings.LastIndex(path, "/"); i >= 0 {
|
||||
return path[i+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mustEvent(t *testing.T, j string) *errortrackingtypes.SentryEvent {
|
||||
t.Helper()
|
||||
var e errortrackingtypes.SentryEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(j), &e))
|
||||
return &e
|
||||
}
|
||||
|
||||
func TestNormalize_UnixTimestamp(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","timestamp":1700000000.5,"exception":{"values":[{"type":"E","value":"v"}]}}`))
|
||||
assert.Equal(t, int64(1700000000), occ.Timestamp.Unix())
|
||||
}
|
||||
|
||||
func TestNormalize_ISOTimestamp(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","timestamp":"2023-11-14T22:13:20Z","exception":{"values":[{"type":"E"}]}}`))
|
||||
assert.Equal(t, 2023, occ.Timestamp.Year())
|
||||
assert.Equal(t, time.November, occ.Timestamp.Month())
|
||||
}
|
||||
|
||||
func TestNormalize_MissingTimestampDefaultsNow(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","exception":{"values":[{"type":"E"}]}}`))
|
||||
assert.WithinDuration(t, time.Now().UTC(), occ.Timestamp, 5*time.Second)
|
||||
}
|
||||
|
||||
func TestNormalize_MessageString(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","message":"plain failure"}`))
|
||||
assert.Equal(t, "Message", occ.Type)
|
||||
assert.Equal(t, "plain failure", occ.Value)
|
||||
}
|
||||
|
||||
func TestNormalize_MessageObject(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","message":{"message":"raw %s","formatted":"raw boom"}}`))
|
||||
assert.Equal(t, "raw boom", occ.Value, "formatted preferred over template")
|
||||
}
|
||||
|
||||
func TestNormalize_TagsMap(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","message":"m","tags":{"env":"prod","code":500}}`))
|
||||
assert.Equal(t, "prod", occ.Tags["env"])
|
||||
assert.Equal(t, "500", occ.Tags["code"], "numeric tag coerced to string")
|
||||
}
|
||||
|
||||
func TestNormalize_TagsArray(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","message":"m","tags":[["env","staging"],["region","sfo"]]}`))
|
||||
assert.Equal(t, "staging", occ.Tags["env"])
|
||||
assert.Equal(t, "sfo", occ.Tags["region"])
|
||||
}
|
||||
|
||||
func TestNormalize_PrimaryExceptionIsLast(t *testing.T) {
|
||||
// Chained: cause first, thrown last. Sentry treats the last value as primary.
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","exception":{"values":[
|
||||
{"type":"IOError","value":"disk"},
|
||||
{"type":"ServiceError","value":"upstream failed","stacktrace":{"frames":[{"function":"call","module":"svc","in_app":true}]}}
|
||||
]}}`))
|
||||
assert.Equal(t, "ServiceError", occ.Type)
|
||||
assert.Equal(t, "upstream failed", occ.Value)
|
||||
require.Len(t, occ.Frames, 1)
|
||||
assert.Equal(t, "call", occ.Frames[0].Function)
|
||||
}
|
||||
|
||||
func TestNormalize_CulpritFromTransaction(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","transaction":"GET /users","exception":{"values":[{"type":"E"}]}}`))
|
||||
assert.Equal(t, "GET /users", occ.Culprit)
|
||||
}
|
||||
|
||||
func TestNormalize_CulpritFromCrashFrame(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","exception":{"values":[{"type":"E","stacktrace":{"frames":[
|
||||
{"function":"outer","module":"a","in_app":true},
|
||||
{"function":"boom","filename":"/srv/app/w.py","in_app":true}
|
||||
]}}]}}`))
|
||||
assert.Equal(t, "boom in w.py", occ.Culprit)
|
||||
}
|
||||
|
||||
func TestNormalize_LevelDefaultsError(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","exception":{"values":[{"type":"E"}]}}`))
|
||||
assert.Equal(t, "error", occ.Level)
|
||||
}
|
||||
|
||||
func TestNormalize_TraceContext(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","exception":{"values":[{"type":"E"}]},"contexts":{"trace":{"trace_id":"abc123","span_id":"def456"}}}`))
|
||||
assert.Equal(t, "abc123", occ.TraceID)
|
||||
assert.Equal(t, "def456", occ.SpanID)
|
||||
}
|
||||
|
||||
// An empty/garbage event must not panic and must still yield a fingerprint.
|
||||
func TestNormalize_EmptyEventSafe(t *testing.T) {
|
||||
occ := normalizeEvent(&errortrackingtypes.SentryEvent{})
|
||||
require.NotNil(t, occ)
|
||||
assert.NotEmpty(t, occ.Fingerprint)
|
||||
assert.Equal(t, "error", occ.Level)
|
||||
}
|
||||
|
||||
func TestNormalize_StampsFingerprint(t *testing.T) {
|
||||
occ := normalizeEvent(mustEvent(t, `{"event_id":"a","exception":{"values":[{"type":"E","value":"v"}]}}`))
|
||||
assert.Len(t, occ.Fingerprint, 64)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
)
|
||||
|
||||
// Error payloads routinely embed secrets (API keys, bearer/JWT tokens, DB
|
||||
// connection strings, private keys, PANs) and end-user PII (emails, IPs). We treat
|
||||
// storing those verbatim as a leak-at-rest. Two layers, mirroring the llmobs
|
||||
// capture-messages precedent (default-secure):
|
||||
//
|
||||
// - Secret patterns are ALWAYS redacted — there is no mode in which we persist an
|
||||
// sk-… key or a password in a DSN. Non-negotiable.
|
||||
// - PII (email/IP) is scrubbed UNLESS the operator opts in via
|
||||
// O11Y_ERRORTRACKING_CAPTURE_PII (default false → scrub). Fail-secure.
|
||||
//
|
||||
// Redaction runs before the value enters the fingerprint, so two errors that differ
|
||||
// only by an embedded secret/email still group together.
|
||||
|
||||
const (
|
||||
redactedMark = "[redacted]"
|
||||
emailMark = "[email]"
|
||||
ipMark = "[ip]"
|
||||
)
|
||||
|
||||
var secretPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----`),
|
||||
regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}`), // JWT
|
||||
regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*`), // bearer token
|
||||
regexp.MustCompile(`\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}`), // openai-style
|
||||
regexp.MustCompile(`\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}`), // stripe
|
||||
regexp.MustCompile(`\bhk-[A-Za-z0-9]{16,}`), // hanzo key
|
||||
regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), // aws access key id
|
||||
regexp.MustCompile(`\bASIA[0-9A-Z]{16}\b`), // aws sts key id
|
||||
regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{20,}`), // google api key
|
||||
regexp.MustCompile(`\bgh[posru]_[A-Za-z0-9]{20,}`), // github token
|
||||
regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), // slack token
|
||||
regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.-]*://[^\s:@/]+:[^\s@/]+@`), // creds in a URL/DSN
|
||||
regexp.MustCompile(`\b(?:\d[ -]?){13,19}\b`), // PAN-like digit run
|
||||
}
|
||||
|
||||
var (
|
||||
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
|
||||
reIPv4 = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
||||
reIPv6 = regexp.MustCompile(`\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b`)
|
||||
)
|
||||
|
||||
// redactSecrets removes known secret shapes. Always applied.
|
||||
func redactSecrets(s string) string {
|
||||
for _, re := range secretPatterns {
|
||||
s = re.ReplaceAllString(s, redactedMark)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// scrubPII masks emails and IPs. Applied unless PII capture is enabled.
|
||||
func scrubPII(s string) string {
|
||||
s = reEmail.ReplaceAllString(s, emailMark)
|
||||
s = reIPv6.ReplaceAllString(s, ipMark)
|
||||
s = reIPv4.ReplaceAllString(s, ipMark)
|
||||
return s
|
||||
}
|
||||
|
||||
// sanitize applies the redaction policy to a free-text field.
|
||||
func sanitize(s string, capturePII bool) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = redactSecrets(s)
|
||||
if !capturePII {
|
||||
s = scrubPII(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sanitizeOccurrence redacts the fields that carry attacker/user-controlled text.
|
||||
// Frames (code locations) are left intact; value/tags/user are the leak surface.
|
||||
func sanitizeOccurrence(occ *errortrackingtypes.Occurrence, capturePII bool) {
|
||||
occ.Value = sanitize(occ.Value, capturePII)
|
||||
for k, v := range occ.Tags {
|
||||
occ.Tags[k] = sanitize(v, capturePII)
|
||||
}
|
||||
if occ.User != nil {
|
||||
occ.User.Email = sanitize(occ.User.Email, capturePII)
|
||||
occ.User.Username = sanitize(occ.User.Username, capturePII)
|
||||
if !capturePII {
|
||||
occ.User.IP = ""
|
||||
}
|
||||
if occ.User.ID == "" && occ.User.Email == "" && occ.User.Username == "" && occ.User.IP == "" {
|
||||
occ.User = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package implerrortracking
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// OccurrenceSink is the OPTIONAL bridge that would also persist each occurrence to
|
||||
// the shared telemetry store (event.log as an ERROR-severity record) for OTel-native
|
||||
// drill-down. It is deliberately a seam, and the default is NoopSink:
|
||||
//
|
||||
// - Authoritative storage is o11y_issues — the grouped issue plus the latest
|
||||
// occurrence sample. Error capture is fully viewable with the no-op sink; the
|
||||
// sink is enrichment, not the source of truth.
|
||||
// - When a real sink is wired, its write is fail-soft at the call site: a
|
||||
// telemetry-store hiccup must never drop the durable issue upsert.
|
||||
//
|
||||
// NOTE (honest status): the Datastore logs sink is NOT implemented in this build —
|
||||
// only NoopSink exists. A raw logs_v2 INSERT couples to resource-fingerprint /
|
||||
// ts-bucket schema that must be byte-verified against a LIVE datastore, not
|
||||
// reconstructed, so it is a deliberate fast-follow. Today occurrences are NOT
|
||||
// written to the telemetry store; the count and the latest sample live on the
|
||||
// issue row.
|
||||
type OccurrenceSink interface {
|
||||
Write(ctx context.Context, orgID valuer.UUID, occ *errortrackingtypes.Occurrence) error
|
||||
}
|
||||
|
||||
// NoopSink discards occurrences. It is a complete, correct sink for the MVP: the
|
||||
// authoritative issue (with its latest sample) is persisted independently.
|
||||
type NoopSink struct{}
|
||||
|
||||
func NewNoopSink() OccurrenceSink { return NoopSink{} }
|
||||
|
||||
func (NoopSink) Write(context.Context, valuer.UUID, *errortrackingtypes.Occurrence) error {
|
||||
return nil
|
||||
}
|
||||
@@ -36,20 +36,20 @@ func NewStore(sqlstore sqlstore.SQLStore) errortrackingtypes.Store {
|
||||
// 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) {
|
||||
func (s *store) UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*errortrackingtypes.Issue, ceiling int) ([]*errortrackingtypes.Issue, error) {
|
||||
if len(issues) == 0 {
|
||||
return 0, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, 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
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Which of the batch's fingerprints already exist for this org (they bump even at
|
||||
@@ -68,14 +68,14 @@ func (s *store) UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*e
|
||||
Where("org_id = ?", orgID).
|
||||
Where("fingerprint IN (?)", bun.In(fps)).
|
||||
Scan(ctx, &rows); err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
existing[r.Fingerprint] = true
|
||||
}
|
||||
|
||||
headroom := ceiling - current
|
||||
written := 0
|
||||
var created []*errortrackingtypes.Issue
|
||||
for _, issue := range issues {
|
||||
if !existing[issue.Fingerprint] {
|
||||
if headroom <= 0 {
|
||||
@@ -83,6 +83,7 @@ func (s *store) UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*e
|
||||
}
|
||||
headroom--
|
||||
existing[issue.Fingerprint] = true
|
||||
created = append(created, issue)
|
||||
}
|
||||
|
||||
if _, err := tx.NewInsert().
|
||||
@@ -100,7 +101,7 @@ func (s *store) UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*e
|
||||
Set("sample_event = EXCLUDED.sample_event").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Exec(ctx); err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
@@ -112,15 +113,14 @@ func (s *store) UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*e
|
||||
Where("fingerprint = ?", issue.Fingerprint).
|
||||
Where("status = ?", errortrackingtypes.StatusResolved).
|
||||
Exec(ctx); err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
written++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
return written, nil
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *store) ListIssues(ctx context.Context, orgID valuer.UUID, q *errortrackingtypes.IssuesQuery) ([]*errortrackingtypes.Issue, int, error) {
|
||||
|
||||
@@ -46,17 +46,12 @@ func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_o11y_issues_org_fingerprint ON o11y_issues (org_id, fingerprint)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*ingestRevocation)(nil)).
|
||||
IfNotExists().
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T) (errortracking.Module, valuer.UUID, valuer.UUID) {
|
||||
t.Helper()
|
||||
m := NewModule(NewStore(newTestStore(t)), NewNoopSink())
|
||||
m := NewModule(NewStore(newTestStore(t)))
|
||||
return m, valuer.GenerateUUID(), valuer.GenerateUUID()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package implsentry
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking/implerrortracking"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// mintDSN builds the DSN an operator hands to an app to report into a project. The
|
||||
// key is the reused errortracking derivation keyed on the PROJECT id + version; the
|
||||
// endpoint is the CLEAN Hanzo route — no /api/ segment:
|
||||
//
|
||||
// https://<version>:<hmac>@<host>/v1/event/<projectID>
|
||||
//
|
||||
// THE INGEST ADDRESS IS /v1/event, the same one the product event stream
|
||||
// uses. @hanzo/event derives its ingest URL from the DSN's OWN path — base plus
|
||||
// /envelope/ — so this line decides where every reporting client posts, and it is
|
||||
// the one place the server states it.
|
||||
//
|
||||
// The product FACE — issues, projects, stats, traces, the reads a console makes —
|
||||
// is /v1/o11y/sentinel. Ingest and face are different nouns and do not share a root:
|
||||
// ingest is keyed by a DSN and answers a beacon, the face is keyed by a session
|
||||
// and answers a person.
|
||||
func mintDSN(secret []byte, host string, projectID valuer.UUID, version int) string {
|
||||
key := implerrortracking.PublicKeyForVersion(secret, projectID.String(), version)
|
||||
return "https://" + key + "@" + host + "/v1/event/" + projectID.String()
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package implsentry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking/implerrortracking"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// extractKey pulls the "<v>:<hmac>" credential out of a minted DSN.
|
||||
func extractKey(t *testing.T, dsn string) string {
|
||||
t.Helper()
|
||||
at := strings.Index(dsn, "@")
|
||||
require.Greater(t, at, len("https://"))
|
||||
return dsn[len("https://"):at]
|
||||
}
|
||||
|
||||
func TestMintDSN_ShapeAndVerify(t *testing.T) {
|
||||
secret := []byte("platform-ingest-secret")
|
||||
proj := valuer.GenerateUUID()
|
||||
|
||||
dsn := mintDSN(secret, "api.hanzo.ai", proj, 1)
|
||||
// CLEAN path — no /api/ segment.
|
||||
assert.True(t, strings.HasPrefix(dsn, "https://"))
|
||||
// THE INGEST ADDRESS, not the product face. A DSN addresses /v1/event — the one
|
||||
// address every beacon lands on — while /v1/o11y/sentinel is where a signed-in person
|
||||
// reads issues back. A DSN minted on the face would send the error plane to a
|
||||
// second public spelling of one wire, which is the whole reason they converged.
|
||||
assert.Contains(t, dsn, "@api.hanzo.ai/v1/event/"+proj.String())
|
||||
assert.NotContains(t, dsn, "/v1/o11y/sentinel/")
|
||||
assert.NotContains(t, dsn, "/api/")
|
||||
|
||||
// The embedded key verifies for THIS project at v1.
|
||||
key := extractKey(t, dsn)
|
||||
assert.True(t, implerrortracking.VerifyKey(secret, proj.String(), key, 1))
|
||||
}
|
||||
|
||||
func TestMintDSN_ProjectDomainSeparation(t *testing.T) {
|
||||
secret := []byte("platform-ingest-secret")
|
||||
a, b := valuer.GenerateUUID(), valuer.GenerateUUID()
|
||||
|
||||
keyA := extractKey(t, mintDSN(secret, "h", a, 1))
|
||||
// A's key must NOT verify for B — the project id is part of the HMAC domain.
|
||||
assert.False(t, implerrortracking.VerifyKey(secret, b.String(), keyA, 1))
|
||||
assert.True(t, implerrortracking.VerifyKey(secret, a.String(), keyA, 1))
|
||||
}
|
||||
|
||||
func TestMintDSN_RotationWatermark(t *testing.T) {
|
||||
secret := []byte("s")
|
||||
proj := valuer.GenerateUUID()
|
||||
|
||||
v1 := extractKey(t, mintDSN(secret, "h", proj, 1))
|
||||
v2 := extractKey(t, mintDSN(secret, "h", proj, 2))
|
||||
|
||||
// After rotation to v2, a v1 key no longer verifies (minVersion=2); v2 does.
|
||||
assert.False(t, implerrortracking.VerifyKey(secret, proj.String(), v1, 2))
|
||||
assert.True(t, implerrortracking.VerifyKey(secret, proj.String(), v2, 2))
|
||||
// A wrong secret never verifies.
|
||||
assert.False(t, implerrortracking.VerifyKey([]byte("other"), proj.String(), v2, 1))
|
||||
}
|
||||
@@ -9,46 +9,60 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
|
||||
)
|
||||
|
||||
// This file is the PURE query layer over event.error: every function is a
|
||||
// This file is the PURE query layer over the error facts of event.fact: every function is a
|
||||
// bytes-and-args builder with no connection, so the security-critical invariants —
|
||||
// (1) org AND product (the project) are always the leading, BOUND predicates, (2) the
|
||||
// (1) org, the error signal AND product (the project) are always the leading
|
||||
// predicates, org and product BOUND, (2) the
|
||||
// time window is always bound, (3) every column/aggregation/interval a client can name
|
||||
// is resolved through a fixed ALLOWLIST and never interpolated — are exhaustively
|
||||
// unit-testable in isolation. The IO layer (eventstore.go) only executes what these
|
||||
// return.
|
||||
//
|
||||
// The Sentry vocabulary maps onto the shared envelope rather than a private schema:
|
||||
// The Sentry vocabulary maps onto the shared envelope rather than a private schema
|
||||
// (deploy/datastore/migrations/0002_event_fact.sql):
|
||||
//
|
||||
// org -> org the tenant
|
||||
// (signal) -> signal always 'error' — the slice of the plane this face reads
|
||||
// project -> product which surface produced the fact
|
||||
// event id -> id
|
||||
// timestamp -> time
|
||||
// received -> ingested_at
|
||||
// fingerprint -> group what event.error is sorted by, after org
|
||||
// fingerprint -> issue the grouping key the ingest computed
|
||||
// level -> severity read back as its band's word (levelExpr)
|
||||
// type -> class
|
||||
// culprit -> site
|
||||
// culprit -> origin the place of the fault
|
||||
// environment -> env
|
||||
// transaction -> path
|
||||
// user id -> person_id
|
||||
// tags -> attributes
|
||||
//
|
||||
// so a Sentry read and an analytics read name the same fact the same way.
|
||||
|
||||
// group is a reserved word in the SQL dialect, so the sort-key column is always
|
||||
// written quoted. It is referenced often enough to be worth one name.
|
||||
const groupCol = "`group`"
|
||||
// issueCol is the grouping column. It is referenced often enough to be worth one name.
|
||||
const issueCol = "issue"
|
||||
|
||||
// errorSignal is the plane's discriminator for this face's facts. It is a constant
|
||||
// of this file, never client input, so it is written into the SQL rather than bound.
|
||||
const errorSignal = "signal = 'error'"
|
||||
|
||||
// levelExpr reads the stored severity number back as the Sentry level word, on the
|
||||
// same bands the ingest maps a word onto a number (cloud apps/event severityOf):
|
||||
// 1-4 trace, 5-8 debug, 9-12 info, 13-16 warning, 17-20 error, 21+ fatal.
|
||||
const levelExpr = "multiIf(severity = 0, '', severity < 5, 'trace', severity < 9, 'debug', " +
|
||||
"severity < 13, 'info', severity < 17, 'warning', severity < 21, 'error', 'fatal')"
|
||||
|
||||
// eventColumns is the allowlist mapping an API field name to its physical column
|
||||
// expression. A field NOT in this map is rejected — a client field name never reaches
|
||||
// the SQL as an identifier. All values are constants defined here, never request data.
|
||||
var eventColumns = map[string]colKind{
|
||||
"timestamp": {"time", kindTime},
|
||||
"level": {"level", kindString},
|
||||
"level": {levelExpr, kindString},
|
||||
"type": {"class", kindString},
|
||||
"message": {"message", kindString},
|
||||
"culprit": {"site", kindString},
|
||||
"fingerprint": {groupCol, kindString},
|
||||
"culprit": {"origin", kindString},
|
||||
"fingerprint": {issueCol, kindString},
|
||||
"handled": {"handled", kindBool},
|
||||
"environment": {"environment", kindString},
|
||||
"environment": {"env", kindString},
|
||||
"release": {"release", kindString},
|
||||
"service_name": {"service", kindString},
|
||||
"transaction": {"path", kindString},
|
||||
@@ -74,7 +88,7 @@ var eventAggs = map[string]colKind{
|
||||
"count": {"count()", kindUint},
|
||||
"users": {"count(DISTINCT person_id)", kindUint},
|
||||
"traces": {"count(DISTINCT trace_id)", kindUint},
|
||||
"fingerprints": {"count(DISTINCT " + groupCol + ")", kindUint},
|
||||
"fingerprints": {"count(DISTINCT " + issueCol + ")", kindUint},
|
||||
"first_seen": {"min(time)", kindTime},
|
||||
"last_seen": {"max(time)", kindTime},
|
||||
}
|
||||
@@ -131,12 +145,13 @@ func resolveWindow(period string, now time.Time) sentrytypes.Window {
|
||||
return sentrytypes.Window{From: now.Add(-d), To: now}
|
||||
}
|
||||
|
||||
// scope is the mandatory (org, project) + window prefix shared by every read: two
|
||||
// bound tenant predicates FIRST — matching event.error's (org, group, time) sort key
|
||||
// on its leading column — then the bound time bounds. It returns the WHERE fragment
|
||||
// and its args in order, so no read can omit the tenant boundary.
|
||||
// scope is the mandatory (org, project) + window prefix shared by every read: the
|
||||
// bound tenant predicate FIRST — matching event.fact's (org, time) sort key on its
|
||||
// leading column — then the error signal, the bound product, and the bound time
|
||||
// bounds. It returns the WHERE fragment and its args in order, so no read can omit
|
||||
// the tenant boundary or read a fact of another signal.
|
||||
func scope(orgID, projectID string, w sentrytypes.Window) (string, []any) {
|
||||
return "org = ? AND product = ? AND time >= ? AND time <= ?",
|
||||
return "org = ? AND " + errorSignal + " AND product = ? AND time >= ? AND time <= ?",
|
||||
[]any{orgID, projectID, w.From, w.To}
|
||||
}
|
||||
|
||||
@@ -240,21 +255,27 @@ func buildFilters(filters []sentrytypes.DiscoverFilter) (string, []any, error) {
|
||||
// occurrences / logs). Order MUST match scanEvent in eventstore.go. The five
|
||||
// frames.* arrays are parallel — one element per stack frame — and are zipped back
|
||||
// into []Frame on read.
|
||||
const selectColumns = "org, product, id, time, ingested_at, level, class, message, site, " +
|
||||
groupCol + ", handled, environment, release, service, path, trace_id, span_id, " +
|
||||
"attributes['platform'], attributes['server'], person_id, attributes['user_email'], " +
|
||||
"attributes['user_ip'], attributes, " +
|
||||
"frames.function, frames.file, frames.line, frames.column, frames.own"
|
||||
var selectColumns = strings.Join(selectList, ", ")
|
||||
|
||||
// selectList is selectColumns one expression per entry, so the projection's width is
|
||||
// countable even though levelExpr carries commas of its own.
|
||||
var selectList = []string{
|
||||
"org", "product", "id", "time", "ingested_at", levelExpr, "class", "message", "origin",
|
||||
issueCol, "handled", "env", "release", "service", "path", "trace_id", "span_id",
|
||||
"attributes['platform']", "attributes['server']", "person_id", "attributes['user_email']",
|
||||
"attributes['user_ip']", "attributes",
|
||||
"frames.function", "frames.file", "frames.line", "frames.column", "frames.own",
|
||||
}
|
||||
|
||||
func buildGetEvent(db, table, orgID, projectID, eventID string) (string, []any) {
|
||||
// Tenant boundary FIRST (org, then project — a project is the DSN-bearing
|
||||
// Tenant boundary FIRST (org, the error signal, then project — a project is the
|
||||
// isolation unit), then event id; a foreign org/project/event returns zero rows.
|
||||
return fmt.Sprintf("SELECT %s FROM %s.%s WHERE org = ? AND product = ? AND id = ? ORDER BY time DESC LIMIT 1", selectColumns, db, table),
|
||||
return fmt.Sprintf("SELECT %s FROM %s.%s WHERE org = ? AND %s AND product = ? AND id = ? ORDER BY time DESC LIMIT 1", selectColumns, db, table, errorSignal),
|
||||
[]any{orgID, projectID, eventID}
|
||||
}
|
||||
|
||||
func buildListForFingerprint(db, table, orgID, projectID, fingerprint string, limit int) (string, []any) {
|
||||
return fmt.Sprintf("SELECT %s FROM %s.%s WHERE org = ? AND product = ? AND %s = ? ORDER BY time DESC LIMIT ?", selectColumns, db, table, groupCol),
|
||||
return fmt.Sprintf("SELECT %s FROM %s.%s WHERE org = ? AND %s AND product = ? AND %s = ? ORDER BY time DESC LIMIT ?", selectColumns, db, table, errorSignal, issueCol),
|
||||
[]any{orgID, projectID, fingerprint, clampLimit(limit, defaultReadLimit, maxReadLimit)}
|
||||
}
|
||||
|
||||
@@ -264,7 +285,7 @@ func buildListForFingerprint(db, table, orgID, projectID, fingerprint string, li
|
||||
// the bound org+product predicates: a caller only ever sees their OWN project's events
|
||||
// for a trace.
|
||||
func buildListForTrace(db, table, orgID, projectID, traceID string, limit int) (string, []any) {
|
||||
return fmt.Sprintf("SELECT %s FROM %s.%s WHERE org = ? AND product = ? AND trace_id = ? ORDER BY time ASC LIMIT ?", selectColumns, db, table),
|
||||
return fmt.Sprintf("SELECT %s FROM %s.%s WHERE org = ? AND %s AND product = ? AND trace_id = ? ORDER BY time ASC LIMIT ?", selectColumns, db, table, errorSignal),
|
||||
[]any{orgID, projectID, traceID, clampLimit(limit, defaultReadLimit, maxReadLimit)}
|
||||
}
|
||||
|
||||
@@ -281,7 +302,7 @@ func buildListLogs(db, table, orgID, projectID, query string, w sentrytypes.Wind
|
||||
|
||||
func buildDistinctFingerprints(db, table, orgID, projectID string, w sentrytypes.Window) (string, []any) {
|
||||
where, args := scope(orgID, projectID, w)
|
||||
return fmt.Sprintf("SELECT DISTINCT %s FROM %s.%s WHERE %s AND %s != ''", groupCol, db, table, where, groupCol), args
|
||||
return fmt.Sprintf("SELECT DISTINCT %s FROM %s.%s WHERE %s AND %s != ''", issueCol, db, table, where, issueCol), args
|
||||
}
|
||||
|
||||
func buildListTraces(db, table, orgID, projectID string, w sentrytypes.Window, limit int) (string, []any) {
|
||||
@@ -297,8 +318,8 @@ func buildListTraces(db, table, orgID, projectID string, w sentrytypes.Window, l
|
||||
var statsFields = map[string]string{
|
||||
"": "",
|
||||
"events": "",
|
||||
"errors": " AND level IN ('error','fatal')",
|
||||
"warnings": " AND level = 'warning'",
|
||||
"errors": " AND severity >= 17",
|
||||
"warnings": " AND severity >= 13 AND severity < 17",
|
||||
}
|
||||
|
||||
// buildStats assembles the bucketed event-count timeseries. The bucket width is
|
||||
|
||||
@@ -20,7 +20,7 @@ func testWindow() sentrytypes.Window {
|
||||
// interpolated). org leads event.error's sort key, so the boundary is also the fast path.
|
||||
func TestScopeIsTenantFirst(t *testing.T) {
|
||||
where, args := scope("org-A", "proj-1", testWindow())
|
||||
assert.Equal(t, "org = ? AND product = ? AND time >= ? AND time <= ?", where)
|
||||
assert.Equal(t, "org = ? AND signal = 'error' AND product = ? AND time >= ? AND time <= ?", where)
|
||||
require.Len(t, args, 4)
|
||||
assert.Equal(t, "org-A", args[0])
|
||||
assert.Equal(t, "proj-1", args[1])
|
||||
@@ -40,9 +40,9 @@ func TestBuildDiscover_ScopedAndBound(t *testing.T) {
|
||||
// org + project are the FIRST two bound args, always.
|
||||
assert.Equal(t, "org-A", args[0])
|
||||
assert.Equal(t, "proj-1", args[1])
|
||||
assert.Contains(t, sql, "org = ? AND product = ?")
|
||||
assert.Contains(t, sql, "org = ? AND signal = 'error' AND product = ?")
|
||||
// The filter value is bound, never inlined.
|
||||
assert.Contains(t, sql, "environment = ?")
|
||||
assert.Contains(t, sql, "env = ?")
|
||||
assert.Contains(t, args, "prod")
|
||||
// Fixed aggregation expressions, aliased by key.
|
||||
assert.Contains(t, sql, "count() AS count")
|
||||
@@ -100,8 +100,8 @@ func TestBuildStats_ScopedAndFieldAllowlist(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "org-A", args[0])
|
||||
assert.Equal(t, "proj-1", args[1])
|
||||
assert.Contains(t, sql, "org = ? AND product = ?")
|
||||
assert.Contains(t, sql, "level IN ('error','fatal')")
|
||||
assert.Contains(t, sql, "org = ? AND signal = 'error' AND product = ?")
|
||||
assert.Contains(t, sql, "severity >= 17")
|
||||
|
||||
_, _, err = buildStats("db", "t", "org", "proj", "sneaky') OR 1=1 --", testWindow())
|
||||
require.Error(t, err, "unknown stats field must be rejected")
|
||||
@@ -110,35 +110,35 @@ func TestBuildStats_ScopedAndFieldAllowlist(t *testing.T) {
|
||||
func TestRowReads_AreOrgAndProjectScoped(t *testing.T) {
|
||||
// Event detail is org+project bound (a project is an isolation unit).
|
||||
get, gArgs := buildGetEvent("db", "t", "org-A", "proj-1", "evt-1")
|
||||
assert.Contains(t, get, "org = ? AND product = ? AND id = ?")
|
||||
assert.Contains(t, get, "org = ? AND signal = 'error' AND product = ? AND id = ?")
|
||||
assert.Equal(t, "org-A", gArgs[0])
|
||||
assert.Equal(t, "proj-1", gArgs[1])
|
||||
|
||||
// Issue occurrences are org+project bound.
|
||||
fp, fArgs := buildListForFingerprint("db", "t", "org-A", "proj-1", "fp-1", 10)
|
||||
assert.Contains(t, fp, "org = ? AND product = ? AND `group` = ?")
|
||||
assert.Contains(t, fp, "org = ? AND signal = 'error' AND product = ? AND issue = ?")
|
||||
assert.Equal(t, "org-A", fArgs[0])
|
||||
assert.Equal(t, "proj-1", fArgs[1])
|
||||
|
||||
// Trace detail reads the errors on the trace (org+project+trace bound), not spans.
|
||||
ft, ftArgs := buildListForTrace("db", "t", "org-A", "proj-1", "trace-xyz", 10)
|
||||
assert.Contains(t, ft, "org = ? AND product = ? AND trace_id = ?")
|
||||
assert.Contains(t, ft, "org = ? AND signal = 'error' AND product = ? AND trace_id = ?")
|
||||
assert.Equal(t, "org-A", ftArgs[0])
|
||||
assert.Equal(t, "proj-1", ftArgs[1])
|
||||
assert.Equal(t, "trace-xyz", ftArgs[2])
|
||||
|
||||
logs, lArgs := buildListLogs("db", "t", "org-A", "proj-1", "boom", testWindow(), 10)
|
||||
assert.Contains(t, logs, "org = ? AND product = ?")
|
||||
assert.Contains(t, logs, "org = ? AND signal = 'error' AND product = ?")
|
||||
assert.Equal(t, "org-A", lArgs[0])
|
||||
assert.Equal(t, "proj-1", lArgs[1])
|
||||
assert.Contains(t, logs, "message LIKE ? OR class LIKE ?")
|
||||
|
||||
tr, tArgs := buildListTraces("db", "t", "org-A", "proj-1", testWindow(), 10)
|
||||
assert.Contains(t, tr, "org = ? AND product = ?")
|
||||
assert.Contains(t, tr, "org = ? AND signal = 'error' AND product = ?")
|
||||
assert.Equal(t, "org-A", tArgs[0])
|
||||
|
||||
df, dArgs := buildDistinctFingerprints("db", "t", "org-A", "proj-1", testWindow())
|
||||
assert.Contains(t, df, "org = ? AND product = ?")
|
||||
assert.Contains(t, df, "org = ? AND signal = 'error' AND product = ?")
|
||||
assert.Equal(t, "org-A", dArgs[0])
|
||||
}
|
||||
|
||||
|
||||
@@ -2,75 +2,42 @@ package implsentry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hanzo-ds/go/lib/driver"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/telemetrystore"
|
||||
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// Sentry error events are rows of event.error — the ONE error table, sharing the
|
||||
// envelope with event.event / event.log / event.span. The database is named for what
|
||||
// it holds and the table for what it is, so a query reads FROM error.
|
||||
// Sentry error events are the error facts of event.fact — the ONE occurrence table,
|
||||
// discriminated by signal. The database is named for what it holds and the table for
|
||||
// what it is, so a query reads FROM fact WHERE signal = 'error'.
|
||||
const (
|
||||
defaultEventsDB = "event"
|
||||
defaultEventsTable = "error"
|
||||
defaultEventsTable = "fact"
|
||||
)
|
||||
|
||||
// errorKind is the envelope's kind discriminator for a captured exception. event.error
|
||||
// holds errors; kind says which sort of error a row is.
|
||||
const errorKind = "exception"
|
||||
|
||||
// insertColumns is the fixed-order column list the batch sink appends to. Order is
|
||||
// identical to the Append call in Insert. Columns the Sentry wire has no value for
|
||||
// (session_id, distinct_id, anonymous_id, url, el) are omitted so the table's own
|
||||
// defaults apply.
|
||||
const insertColumns = "org, product, id, time, ingested_at, name, kind, level, class, " +
|
||||
"message, site, " + groupCol + ", handled, environment, release, service, path, " +
|
||||
"trace_id, span_id, person_id, attributes, " +
|
||||
"frames.function, frames.file, frames.line, frames.column, frames.own"
|
||||
|
||||
// eventStore is the datastore-backed EventStore over event.error.
|
||||
// eventStore is the datastore-backed EventStore over the error facts of event.fact.
|
||||
//
|
||||
// It does NOT create its schema. The event plane is applied as one schema; a reader
|
||||
// that also runs CREATE DATABASE / CREATE TABLE quietly resurrects whatever it names
|
||||
// the moment the process restarts, which is how a dropped database comes back from the
|
||||
// dead. Schema is a deploy artifact, this is a client.
|
||||
// It reads only. The ingest that accepted an error wrote its fact; a second writer
|
||||
// here would be a second copy of the same failure. It does NOT create its schema
|
||||
// either: schema is a deploy artifact, this is a client.
|
||||
type eventStore struct {
|
||||
store telemetrystore.TelemetryStore
|
||||
scope Scope
|
||||
db string
|
||||
table string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Option configures the event store.
|
||||
type Option func(*eventStore)
|
||||
|
||||
func WithDatabase(db string) Option { return func(s *eventStore) { s.db = db } }
|
||||
func WithTable(t string) Option { return func(s *eventStore) { s.table = t } }
|
||||
|
||||
// NewEventStore builds the events plane over the shared datastore connection.
|
||||
// NewEventStore builds the reads over the shared datastore connection.
|
||||
//
|
||||
// `scope` is REQUIRED, not an Option, because it is the tenant boundary: it is what
|
||||
// `scope` is REQUIRED, not an option, because it is the tenant boundary: it is what
|
||||
// turns the ids this process works in into the names the plane stores (see plane.go).
|
||||
// A store that could be built without one could write a row under a spelling no
|
||||
// reader binds, which is exactly the split this seam exists to close.
|
||||
func NewEventStore(store telemetrystore.TelemetryStore, scope Scope, opts ...Option) sentrytypes.EventStore {
|
||||
s := &eventStore{
|
||||
store: store,
|
||||
scope: scope,
|
||||
db: defaultEventsDB,
|
||||
table: defaultEventsTable,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
return s
|
||||
// A store that could be built without one could read under a spelling no writer
|
||||
// uses, which is exactly the split this seam exists to close.
|
||||
func NewEventStore(store telemetrystore.TelemetryStore, scope Scope) sentrytypes.EventStore {
|
||||
return &eventStore{store: store, scope: scope, db: defaultEventsDB, table: defaultEventsTable}
|
||||
}
|
||||
|
||||
// names is every operation's first act: the (org, product) this one is about, in the
|
||||
@@ -84,44 +51,6 @@ func (s *eventStore) names(ctx context.Context, orgID, projectID valuer.UUID) (s
|
||||
return s.scope(ctx, orgID, projectID)
|
||||
}
|
||||
|
||||
func (s *eventStore) Insert(ctx context.Context, orgID, projectID valuer.UUID, events []*sentrytypes.Event) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
org, product, err := s.names(ctx, orgID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
batch, err := s.store.Datastore().PrepareBatch(ctx,
|
||||
fmt.Sprintf("INSERT INTO %s.%s (%s)", s.db, s.table, insertColumns), driver.WithReleaseConnection())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = batch.Abort() }()
|
||||
|
||||
received := s.now()
|
||||
for _, e := range events {
|
||||
if err := batch.Append(row(org, product, received, e)...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return batch.Send()
|
||||
}
|
||||
|
||||
// row is ONE occurrence as the plane stores it, in insertColumns order. Pure, so the
|
||||
// row a write produces can be asserted without a sink — and the two facts that make a
|
||||
// row legible, the tenant's NAME and the product's, are read off the result rather
|
||||
// than inferred from the call.
|
||||
func row(org, product string, received time.Time, e *sentrytypes.Event) []any {
|
||||
fn, file, line, col, own := unzipFrames(e.Frames)
|
||||
return []any{
|
||||
org, product, e.EventID, e.Timestamp, received, e.Type, errorKind, e.Level, e.Type,
|
||||
e.Message, e.Culprit, e.Fingerprint, e.Handled, e.Environment, e.Release,
|
||||
e.ServiceName, e.Transaction, e.TraceID, e.SpanID, e.UserID, attributesOf(e),
|
||||
fn, file, line, col, own,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *eventStore) Discover(ctx context.Context, orgID, projectID valuer.UUID, req *sentrytypes.DiscoverRequest, w sentrytypes.Window) (*sentrytypes.DiscoverResult, error) {
|
||||
org, product, nameErr := s.names(ctx, orgID, projectID)
|
||||
if nameErr != nil {
|
||||
@@ -293,39 +222,6 @@ func (s *eventStore) queryEvents(ctx context.Context, sql string, args []any) ([
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// attributesOf folds an event's tags plus the envelope-adjacent values that live in
|
||||
// the attributes map (platform, server, user contact) into the one map column. Tags
|
||||
// are copied, never mutated in place, so the caller's map is untouched.
|
||||
func attributesOf(e *sentrytypes.Event) map[string]string {
|
||||
attrs := make(map[string]string, len(e.Tags)+4)
|
||||
for k, v := range e.Tags {
|
||||
attrs[k] = v
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
"platform": e.Platform,
|
||||
"server": e.ServerName,
|
||||
"user_email": e.UserEmail,
|
||||
"user_ip": e.UserIP,
|
||||
} {
|
||||
if v != "" {
|
||||
attrs[k] = v
|
||||
}
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// unzipFrames splits []Frame into the five parallel arrays event.error stores. A nil
|
||||
// slice yields empty (not nil) arrays so a batch column is never null.
|
||||
func unzipFrames(frames []sentrytypes.Frame) (fn, file []string, line, col []uint32, own []bool) {
|
||||
fn, file = make([]string, len(frames)), make([]string, len(frames))
|
||||
line, col = make([]uint32, len(frames)), make([]uint32, len(frames))
|
||||
own = make([]bool, len(frames))
|
||||
for i, f := range frames {
|
||||
fn[i], file[i], line[i], col[i], own[i] = f.Function, f.File, f.Line, f.Column, f.Own
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// zipFrames rebuilds []Frame from the parallel arrays, tolerating a short array (a
|
||||
// row written before a column existed reads as a zero value, never a panic).
|
||||
func zipFrames(fn, file []string, line, col []uint32, own []bool) []sentrytypes.Frame {
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestEventStore_ReadsWithoutIssuingDDL(t *testing.T) {
|
||||
mock.ExpectQuery("SELECT DISTINCT").
|
||||
WithArgs(nil, nil, nil, nil). // nil = match-any (dsmock.matchArg); 4 = the tenant+window scope
|
||||
WillReturnRows(dsmock.NewRows(
|
||||
[]dsmock.ColumnType{{Name: "group", Type: "String"}},
|
||||
[]dsmock.ColumnType{{Name: "issue", Type: "String"}},
|
||||
[][]any{{"fp-1"}, {"fp-2"}},
|
||||
))
|
||||
|
||||
@@ -47,36 +47,31 @@ func TestEventStore_ReadsWithoutIssuingDDL(t *testing.T) {
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
// TestEventStoreTargetsEventError pins WHICH table the plane reads and writes. The
|
||||
// database is named for what it holds and the table for what it is.
|
||||
func TestEventStoreTargetsEventError(t *testing.T) {
|
||||
// TestEventStoreTargetsEventFact pins WHICH table the plane reads: the one fact
|
||||
// table, whose error slice every read selects by signal.
|
||||
func TestEventStoreTargetsEventFact(t *testing.T) {
|
||||
s := NewEventStore(nil, namedScope("acme", "docs")).(*eventStore)
|
||||
assert.Equal(t, "event", s.db)
|
||||
assert.Equal(t, "error", s.table)
|
||||
assert.Equal(t, "fact", s.table)
|
||||
}
|
||||
|
||||
// TestInsertMatchesAppend pins the insert-sink invariant: the INSERT column list and
|
||||
// the batch.Append argument list are the same length and order, and the read
|
||||
// projection scans exactly as many columns as it selects — so a written row reads back
|
||||
// field-for-field.
|
||||
func TestInsertMatchesAppend(t *testing.T) {
|
||||
assert.Equal(t, 26, countCols(insertColumns), "insert writes 26 columns")
|
||||
assert.Equal(t, 28, countCols(selectColumns),
|
||||
// TestProjectionMatchesScan pins the read invariant: queryEvents scans exactly as many
|
||||
// targets as selectColumns selects.
|
||||
func TestProjectionMatchesScan(t *testing.T) {
|
||||
assert.Len(t, selectList, 28,
|
||||
"the read projection selects the 5 frame arrays plus the attribute-backed fields")
|
||||
assert.Equal(t, strings.Join(selectList, ", "), selectColumns)
|
||||
}
|
||||
|
||||
// countCols counts a comma-separated column list. Every expression in these lists is
|
||||
// a bare column or a constant map/array subscript, none of which contain a comma.
|
||||
func countCols(list string) int { return strings.Count(list, ",") + 1 }
|
||||
|
||||
// Frames survive the round trip through the five parallel arrays event.error stores.
|
||||
func TestFramesRoundTrip(t *testing.T) {
|
||||
in := []sentrytypes.Frame{
|
||||
// Frames read back from the five parallel arrays event.fact stores.
|
||||
func TestFramesRead(t *testing.T) {
|
||||
want := []sentrytypes.Frame{
|
||||
{Function: "handle", File: "app/svc.py", Line: 42, Column: 7, Own: true},
|
||||
{Function: "connect", File: "inpage.js", Line: 1, Column: 84179, Own: false},
|
||||
}
|
||||
fn, file, line, col, own := unzipFrames(in)
|
||||
assert.Equal(t, in, zipFrames(fn, file, line, col, own))
|
||||
got := zipFrames([]string{"handle", "connect"}, []string{"app/svc.py", "inpage.js"},
|
||||
[]uint32{42, 1}, []uint32{7, 84179}, []bool{true, false})
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
// A short parallel array must read as a zero value, never panic — the shape a row
|
||||
@@ -91,27 +86,6 @@ func TestFramesTolerateShortArrays(t *testing.T) {
|
||||
// No frames means no frames — not one empty frame.
|
||||
func TestFramesEmpty(t *testing.T) {
|
||||
assert.Nil(t, zipFrames(nil, nil, nil, nil, nil))
|
||||
fn, file, line, col, own := unzipFrames(nil)
|
||||
assert.Empty(t, fn)
|
||||
assert.Empty(t, file)
|
||||
assert.Empty(t, line)
|
||||
assert.Empty(t, col)
|
||||
assert.Empty(t, own)
|
||||
}
|
||||
|
||||
// attributesOf folds tags plus the envelope-adjacent values into the one map column,
|
||||
// without mutating the caller's tag map.
|
||||
func TestAttributesOfDoesNotMutateTags(t *testing.T) {
|
||||
tags := map[string]string{"release_channel": "beta"}
|
||||
e := &sentrytypes.Event{Tags: tags, Platform: "python", ServerName: "web-1"}
|
||||
|
||||
attrs := attributesOf(e)
|
||||
assert.Equal(t, "beta", attrs["release_channel"])
|
||||
assert.Equal(t, "python", attrs["platform"])
|
||||
assert.Equal(t, "web-1", attrs["server"])
|
||||
assert.NotContains(t, attrs, "user_email", "an empty value is not stored")
|
||||
|
||||
assert.Equal(t, map[string]string{"release_channel": "beta"}, tags, "the caller's map is untouched")
|
||||
}
|
||||
|
||||
var _ sentrytypes.EventStore = (*eventStore)(nil)
|
||||
|
||||
@@ -2,7 +2,6 @@ package implsentry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/http/binding"
|
||||
"github.com/hanzoai/o11y/pkg/http/render"
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking/implerrortracking"
|
||||
"github.com/hanzoai/o11y/pkg/modules/sentry"
|
||||
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
||||
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
||||
@@ -20,105 +18,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
viewTimeout = 30 * time.Second
|
||||
writeTimeout = 15 * time.Second
|
||||
ingestTimeout = 15 * time.Second
|
||||
viewTimeout = 30 * time.Second
|
||||
writeTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// eventParser turns a decoded ingest body into events; the two wire formats differ
|
||||
// only here (reused engine parsers).
|
||||
type eventParser func([]byte) ([]*errortrackingtypes.SentryEvent, error)
|
||||
|
||||
type handler struct {
|
||||
module sentry.Module
|
||||
ingestEnabled bool
|
||||
capturePII bool
|
||||
module sentry.Module
|
||||
}
|
||||
|
||||
// NewHandler builds the /v1/o11y/sentinel HTTP surface. ingestEnabled reflects whether the
|
||||
// KMS ingest secret is configured (empty => ingest fails closed 503, reads still
|
||||
// work); capturePII retains end-user PII on ingest when true (default false = scrub).
|
||||
func NewHandler(module sentry.Module, ingestEnabled, capturePII bool) sentry.Handler {
|
||||
return &handler{module: module, ingestEnabled: ingestEnabled, capturePII: capturePII}
|
||||
}
|
||||
|
||||
// --- ingest (public, DSN-authenticated) ---
|
||||
|
||||
func (h *handler) EnvelopeIngest(rw http.ResponseWriter, r *http.Request) {
|
||||
h.ingest(rw, r, implerrortracking.ParseEnvelope)
|
||||
}
|
||||
|
||||
func (h *handler) StoreIngest(rw http.ResponseWriter, r *http.Request) {
|
||||
h.ingest(rw, r, implerrortracking.ParseStoreBody)
|
||||
}
|
||||
|
||||
// ingest is the shared pipeline: enabled-check → parse the project id → resolve org +
|
||||
// verify the DSN key against the project watermark (fail-closed) → per-project rate
|
||||
// limit → bounded read+decode → parse (event-count capped) → normalize (scrub) →
|
||||
// persist to the events plane AND the issue lifecycle. Every failure fails closed and
|
||||
// leaks no internal detail to the untrusted client. Reuses the errortracking engine
|
||||
// verbatim for decode/parse/normalize/key-verify/rate-limit.
|
||||
func (h *handler) ingest(rw http.ResponseWriter, r *http.Request, parse eventParser) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), ingestTimeout)
|
||||
defer cancel()
|
||||
|
||||
if !h.ingestEnabled {
|
||||
http.Error(rw, "sentry ingest is not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
projectID, err := valuer.NewUUID(coretypes.Param(r, "project"))
|
||||
if err != nil {
|
||||
http.Error(rw, "invalid project", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, ok := h.module.ResolveIngest(ctx, projectID, implerrortracking.SentryKeyFromRequest(r))
|
||||
if !ok {
|
||||
// Sentry SDKs treat 401 as "bad DSN" and drop the event (no retry storm).
|
||||
http.Error(rw, "invalid ingest key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.module.RateAllow(projectID) {
|
||||
rw.Header().Set("Retry-After", "1")
|
||||
http.Error(rw, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, implerrortracking.MaxCompressedBody)
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(rw, "payload too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
decoded, err := implerrortracking.DecodeBody(raw, r.Header.Get("Content-Encoding"))
|
||||
if err != nil {
|
||||
http.Error(rw, "cannot decode body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
events, err := parse(decoded)
|
||||
if err != nil {
|
||||
http.Error(rw, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
occs := make([]*errortrackingtypes.Occurrence, 0, len(events))
|
||||
lastID := ""
|
||||
for _, ev := range events {
|
||||
occ := implerrortracking.NormalizeEvent(ev, h.capturePII)
|
||||
if occ.Fingerprint == "" {
|
||||
continue
|
||||
}
|
||||
occs = append(occs, occ)
|
||||
lastID = occ.EventID
|
||||
}
|
||||
|
||||
if err := h.module.Ingest(ctx, orgID, projectID, occs); err != nil {
|
||||
http.Error(rw, "ingest failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusOK, map[string]string{"id": lastID})
|
||||
// NewHandler builds the /v1/o11y/sentinel HTTP surface. Errors enter through
|
||||
// /v1/event only; this face reads and manages what the event plane holds.
|
||||
func NewHandler(module sentry.Module) sentry.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// --- projects ---
|
||||
@@ -181,27 +92,6 @@ func (h *handler) GetProject(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *handler) RotateProjectKey(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), writeTimeout)
|
||||
defer cancel()
|
||||
orgID, err := orgFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
id, err := idFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
p, err := h.module.RotateProjectKey(ctx, orgID, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *handler) DeleteProject(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), writeTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/http/routing"
|
||||
@@ -21,10 +20,9 @@ import (
|
||||
// the rest, so this fake says exactly which method the route reaches.
|
||||
type stubSentry struct {
|
||||
sentry.Module
|
||||
gotEventID string
|
||||
gotEventOrg valuer.UUID
|
||||
gotProject valuer.UUID
|
||||
gotIngestProj valuer.UUID
|
||||
gotEventID string
|
||||
gotEventOrg valuer.UUID
|
||||
gotProject valuer.UUID
|
||||
}
|
||||
|
||||
func (s *stubSentry) GetEvent(_ context.Context, orgID, projectID valuer.UUID, eventID string) (*sentrytypes.Event, error) {
|
||||
@@ -32,13 +30,6 @@ func (s *stubSentry) GetEvent(_ context.Context, orgID, projectID valuer.UUID, e
|
||||
return &sentrytypes.Event{}, nil
|
||||
}
|
||||
|
||||
// ResolveIngest fails closed, which is what an unknown key does in production —
|
||||
// the read under test happens before it.
|
||||
func (s *stubSentry) ResolveIngest(_ context.Context, projectID valuer.UUID, _ string) (valuer.UUID, bool) {
|
||||
s.gotIngestProj = projectID
|
||||
return valuer.UUID{}, false
|
||||
}
|
||||
|
||||
// The event route carries BOTH kinds of value: the event id is a PATH segment and
|
||||
// the project is a QUERY value. Conflating them would read the wrong half of the
|
||||
// URL and answer another project's event, so one request pins both.
|
||||
@@ -47,7 +38,7 @@ func TestGetEventReadsThePathIDAndTheQueryProject(t *testing.T) {
|
||||
org, project := valuer.GenerateUUID(), valuer.GenerateUUID()
|
||||
|
||||
router := routing.Serve(http.MethodGet, "/v1/o11y/sentinel/events/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewHandler(module, true, false).GetEvent(w, r.WithContext(authtypes.NewContextWithClaims(r.Context(), authtypes.Claims{OrgID: org.String()})))
|
||||
NewHandler(module).GetEvent(w, r.WithContext(authtypes.NewContextWithClaims(r.Context(), authtypes.Claims{OrgID: org.String()})))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/o11y/sentinel/events/ev-42?project="+project.String(), http.NoBody)
|
||||
@@ -60,48 +51,3 @@ func TestGetEventReadsThePathIDAndTheQueryProject(t *testing.T) {
|
||||
assert.Equal(t, project, module.gotProject)
|
||||
assert.Equal(t, org, module.gotEventOrg)
|
||||
}
|
||||
|
||||
// ingestRoute is the template the runtime registers, guid constraint and all.
|
||||
// It is spelled here exactly as sentry.go spells it: a stale copy is not a
|
||||
// smaller version of this test, it is a different route (see the constraint
|
||||
// test below).
|
||||
const ingestRoute = "/v1/event/{project:guid}/envelope/"
|
||||
|
||||
// The ingest route is the one segment read that decides TENANCY: the project in
|
||||
// the path is what the DSN key is verified against, so reading the wrong one
|
||||
// would verify a key against the wrong project.
|
||||
func TestIngestVerifiesTheProjectTheRouterMatched(t *testing.T) {
|
||||
module := &stubSentry{}
|
||||
project := valuer.GenerateUUID()
|
||||
|
||||
router := routing.Serve(http.MethodPost, ingestRoute, http.HandlerFunc(NewHandler(module, true, false).EnvelopeIngest))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/event/"+project.String()+"/envelope/", strings.NewReader("{}")))
|
||||
|
||||
// Fails closed on the key, which is the stub's answer — and it can only have
|
||||
// been asked about the project the router matched.
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "invalid ingest key")
|
||||
assert.Equal(t, project, module.gotIngestProj)
|
||||
}
|
||||
|
||||
// The constraint is what keeps the project segment a project id, so a non-uuid
|
||||
// segment must not reach the handler at all. This is pinned because the
|
||||
// constraint is silently OPTIONAL: the router names its constraints, an
|
||||
// unrecognised name is DROPPED rather than refused, and a dropped constraint
|
||||
// matches everything — a route that answers /v1/event/anything/envelope/ with
|
||||
// 418 instead of 404, handing the DSN verifier a project that cannot exist.
|
||||
// That is why the template above is the runtime's own `guid` and is asserted
|
||||
// here rather than trusted.
|
||||
func TestIngestConstraintRefusesANonUUIDProject(t *testing.T) {
|
||||
module := &stubSentry{}
|
||||
|
||||
router := routing.Serve(http.MethodPost, ingestRoute, http.HandlerFunc(NewHandler(module, true, false).EnvelopeIngest))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/event/anything/envelope/", strings.NewReader("{}")))
|
||||
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
|
||||
assert.Equal(t, valuer.UUID{}, module.gotIngestProj, "a segment the constraint refuses must not reach the handler")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/identn/iamidentn"
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking"
|
||||
"github.com/hanzoai/o11y/pkg/modules/errortracking/implerrortracking"
|
||||
"github.com/hanzoai/o11y/pkg/modules/sentry"
|
||||
"github.com/hanzoai/o11y/pkg/types"
|
||||
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
|
||||
@@ -17,160 +16,80 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// IngestKeyResolver maps a presented publishable key (pk-…) to the org slug it
|
||||
// attributes to. It is the embedding host's job — the host dials IAM (org keys)
|
||||
// and/or the projects app (per-project keys), and o11y must not import cloud — so
|
||||
// the host injects it at mount via SetIngestKeyResolver. Returns ok=false for a
|
||||
// key that names no org.
|
||||
type IngestKeyResolver func(ctx context.Context, key string) (orgSlug string, ok bool)
|
||||
|
||||
var (
|
||||
ingestKeyMu sync.RWMutex
|
||||
ingestKeyResolver IngestKeyResolver
|
||||
)
|
||||
|
||||
// SetIngestKeyResolver installs the host's pk- → org resolver. Mirrors o11y's
|
||||
// SetHandler seam exactly: set once at mount, read per request. Nil (the default)
|
||||
// leaves keyed ingest fail-closed.
|
||||
func SetIngestKeyResolver(fn IngestKeyResolver) {
|
||||
ingestKeyMu.Lock()
|
||||
ingestKeyResolver = fn
|
||||
ingestKeyMu.Unlock()
|
||||
}
|
||||
|
||||
func getIngestKeyResolver() IngestKeyResolver {
|
||||
ingestKeyMu.RLock()
|
||||
defer ingestKeyMu.RUnlock()
|
||||
return ingestKeyResolver
|
||||
}
|
||||
|
||||
// nowUTC is the ONE package time source for lifecycle writes (overridable in tests).
|
||||
var nowUTC = func() time.Time { return time.Now().UTC() }
|
||||
|
||||
// Config wires the sentry module's non-store dependencies.
|
||||
type Config struct {
|
||||
// IngestSecret is the KMS-sourced platform DSN-signing secret (the SAME secret the
|
||||
// errortracking ingest path verifies against). Empty => ingest fails closed.
|
||||
IngestSecret []byte
|
||||
// Host is the DSN endpoint origin (e.g. "api.hanzo.ai"); the minted DSN points at
|
||||
// https://<key>@<host>/v1/event/<project>.
|
||||
Host string
|
||||
// CapturePII retains end-user PII (email/ip) when true; default false = scrub.
|
||||
CapturePII bool
|
||||
}
|
||||
|
||||
type module struct {
|
||||
projects sentrytypes.ProjectStore
|
||||
events sentrytypes.EventStore
|
||||
issues errortracking.Module // reused grouped-issue lifecycle (o11y_issues)
|
||||
limiter *implerrortracking.RateLimiter
|
||||
cfg Config
|
||||
|
||||
// known memoizes (org, product) pairs whose project exists, so a steady stream of
|
||||
// errors from one surface costs no project read.
|
||||
known sync.Map
|
||||
}
|
||||
|
||||
// NewModule composes the sentry product face over the reused engine, the projects
|
||||
// store, the event.error plane and the reused issue lifecycle. Trace/event/issue reads
|
||||
// are all org+project scoped over event.error; the event.span plane is not read here
|
||||
// (see TraceDetail).
|
||||
func NewModule(projects sentrytypes.ProjectStore, events sentrytypes.EventStore, issues errortracking.Module, cfg Config) sentry.Module {
|
||||
return &module{
|
||||
projects: projects,
|
||||
events: events,
|
||||
issues: issues,
|
||||
limiter: implerrortracking.NewRateLimiter(implerrortracking.IngestRatePerSec, implerrortracking.IngestBurst),
|
||||
cfg: cfg,
|
||||
}
|
||||
// NewModule composes the Sentinel face over the projects store, the error facts of
|
||||
// the event plane and the reused issue lifecycle. Trace/event/issue reads are all
|
||||
// org+project scoped over event.fact; the event.span plane is not read here (see
|
||||
// TraceDetail).
|
||||
func NewModule(projects sentrytypes.ProjectStore, events sentrytypes.EventStore, issues errortracking.Module) sentry.Module {
|
||||
return &module{projects: projects, events: events, issues: issues}
|
||||
}
|
||||
|
||||
// --- ingest ---
|
||||
|
||||
// Ingest persists a request's occurrences for (org, project): the columnar events
|
||||
// plane (queryable, high-volume) AND the grouped-issue lifecycle (reused verbatim).
|
||||
// The events write is fail-soft — a datastore hiccup must not drop the durable issue
|
||||
// upsert — so its error is logged-and-swallowed here (the issue path is the source of
|
||||
// truth for the Issues UI).
|
||||
func (m *module) Ingest(ctx context.Context, orgID, projectID valuer.UUID, occs []*errortrackingtypes.Occurrence) error {
|
||||
if orgID.IsZero() || projectID.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, sentrytypes.ErrCodeSentryInvalidInput, "ingest has no org/project")
|
||||
// Ingest files a batch of error facts from ONE (org, product) into the org's issues
|
||||
// and returns the issues the batch created. The facts already sit on the event plane,
|
||||
// written by the ingest that accepted them, so nothing here copies them; this owns the
|
||||
// lifecycle only. The product's project is born on first sight with slug = product —
|
||||
// the name the plane stores (plane.go) — so a surface shows up the moment it fails.
|
||||
// A fact that names no product still files its issue; it has no project to list.
|
||||
func (m *module) Ingest(ctx context.Context, org, product string, occs []*errortrackingtypes.Occurrence) ([]*errortrackingtypes.Issue, error) {
|
||||
if org == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, sentrytypes.ErrCodeSentryInvalidInput, "ingest has no org")
|
||||
}
|
||||
events := make([]*sentrytypes.Event, 0, len(occs))
|
||||
for _, occ := range occs {
|
||||
if occ == nil || occ.Fingerprint == "" {
|
||||
continue
|
||||
orgID := iamidentn.OrgUUID(org)
|
||||
m.ensureProject(ctx, orgID, product)
|
||||
return m.issues.Ingest(ctx, orgID, occs)
|
||||
}
|
||||
|
||||
// ensureProject provisions the product's project under the org, idempotently and
|
||||
// fail-soft: a create race or a store miss must never fail the lifecycle write (the
|
||||
// org is the authoritative scope; a missing project row only hides the surface from
|
||||
// the project selector until the next error re-attempts it).
|
||||
func (m *module) ensureProject(ctx context.Context, orgID valuer.UUID, product string) {
|
||||
slug := slugify(product)
|
||||
if orgID.IsZero() || slug == "" || reservedSlugs[slug] {
|
||||
return
|
||||
}
|
||||
key := orgID.String() + "/" + slug
|
||||
if _, ok := m.known.Load(key); ok {
|
||||
return
|
||||
}
|
||||
ps, err := m.projects.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range ps {
|
||||
if p.Slug == slug {
|
||||
m.known.Store(key, struct{}{})
|
||||
return
|
||||
}
|
||||
events = append(events, eventFromOccurrence(orgID, projectID, occ))
|
||||
}
|
||||
// Events plane (fail-soft) FIRST, then the durable issue lifecycle (authoritative).
|
||||
_ = m.events.Insert(ctx, orgID, projectID, events)
|
||||
if _, err := m.issues.Ingest(ctx, orgID, occs); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveIngest attributes a keyed ingest to its org. ONE auth path: the presented
|
||||
// publishable key (pk-…) — the SAME key /v1/event accepts — resolves to an org via
|
||||
// the host-injected resolver, and the project named in the DSN is born under that
|
||||
// org on first sight (the zero-onboarding move o11y already makes for a tenant). So
|
||||
// a surface declares nothing: the org key that already feeds analytics + insights
|
||||
// feeds errors too. Fail-closed — no resolver, or a key that names no org, returns
|
||||
// ok=false.
|
||||
//
|
||||
// The prior per-project DSN watermark is GONE: it was a second attribution model
|
||||
// braided beside the org key (its own secret, its own per-project keys, provisioned
|
||||
// out of band), and it is exactly what left this plane dark. One key, not two.
|
||||
func (m *module) ResolveIngest(ctx context.Context, projectID valuer.UUID, presentedKey string) (valuer.UUID, bool) {
|
||||
resolve := getIngestKeyResolver()
|
||||
if resolve == nil {
|
||||
return valuer.UUID{}, false
|
||||
}
|
||||
orgSlug, ok := resolve(ctx, presentedKey)
|
||||
if !ok || orgSlug == "" {
|
||||
return valuer.UUID{}, false
|
||||
}
|
||||
orgID := iamidentn.OrgUUID(orgSlug)
|
||||
// A project that already exists and has been disabled stops accepting ingest,
|
||||
// which is what disabling one is for and what ProjectStatus documents. Only an
|
||||
// existing row can say this: a project unseen until now is provisioned active
|
||||
// just below, and a store that cannot answer must not silently admit ingest.
|
||||
if _, _, status, found, err := m.projects.Resolve(ctx, projectID); err == nil && found &&
|
||||
status != sentrytypes.ProjectActive {
|
||||
return valuer.UUID{}, false
|
||||
}
|
||||
m.ensureProject(ctx, orgID, projectID)
|
||||
return orgID, true
|
||||
}
|
||||
|
||||
// ensureProject provisions the DSN's project under the org on first keyed ingest,
|
||||
// so a surface's errors land under a stable project without anyone creating one —
|
||||
// the same auto-provision o11y does for a tenant on first session. Idempotent and
|
||||
// fail-soft: a create race or a store miss must never fail the ingest (the org is
|
||||
// the authoritative scope; a missing project row only affects Issues-UI grouping,
|
||||
// which the next occurrence re-attempts).
|
||||
func (m *module) ensureProject(ctx context.Context, orgID, projectID valuer.UUID) {
|
||||
if orgID.IsZero() || projectID.IsZero() {
|
||||
return
|
||||
}
|
||||
if _, _, _, found, _ := m.projects.Resolve(ctx, projectID); found {
|
||||
return
|
||||
}
|
||||
short := projectID.String()
|
||||
if len(short) > 8 {
|
||||
short = short[:8]
|
||||
}
|
||||
now := nowUTC()
|
||||
_ = m.projects.Create(ctx, &sentrytypes.Project{
|
||||
Identifiable: types.Identifiable{ID: projectID},
|
||||
if err := m.projects.Create(ctx, &sentrytypes.Project{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
OrgID: orgID,
|
||||
Name: short,
|
||||
Slug: "p-" + short,
|
||||
Name: product,
|
||||
Slug: slug,
|
||||
Status: sentrytypes.ProjectActive,
|
||||
KeyVersion: 1,
|
||||
})
|
||||
}); err == nil {
|
||||
m.known.Store(key, struct{}{})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) RateAllow(projectID valuer.UUID) bool { return m.limiter.Allow(projectID) }
|
||||
|
||||
// --- projects ---
|
||||
|
||||
func (m *module) CreateProject(ctx context.Context, orgID valuer.UUID, in *sentrytypes.PostableProject) (*sentrytypes.GettableProject, error) {
|
||||
@@ -194,7 +113,6 @@ func (m *module) CreateProject(ctx context.Context, orgID valuer.UUID, in *sentr
|
||||
Slug: slug,
|
||||
Platform: strings.TrimSpace(in.Platform),
|
||||
Status: sentrytypes.ProjectActive,
|
||||
KeyVersion: 1,
|
||||
}
|
||||
if err := m.projects.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
@@ -222,33 +140,15 @@ func (m *module) GetProject(ctx context.Context, orgID, id valuer.UUID) (*sentry
|
||||
return m.gettable(p), nil
|
||||
}
|
||||
|
||||
func (m *module) RotateProjectKey(ctx context.Context, orgID, id valuer.UUID) (*sentrytypes.GettableProject, error) {
|
||||
version, err := m.projects.Rotate(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := m.projects.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.KeyVersion = version
|
||||
return m.gettable(p), nil
|
||||
}
|
||||
|
||||
// DeleteProject removes the project, org-scoped. Once gone, ResolveIngest can no
|
||||
// longer map its id to a tenant, so its DSN fails closed exactly like an unknown
|
||||
// one — no separate revocation step. Retained events are untouched by design.
|
||||
// DeleteProject removes the project, org-scoped. Retained events are untouched by
|
||||
// design: deleting a project must not double as a history wipe.
|
||||
func (m *module) DeleteProject(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return m.projects.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
// gettable derives the project's current DSN (never stored) and returns the API view.
|
||||
// gettable returns the API view of a project.
|
||||
func (m *module) gettable(p *sentrytypes.Project) *sentrytypes.GettableProject {
|
||||
dsn := ""
|
||||
if len(m.cfg.IngestSecret) > 0 {
|
||||
dsn = mintDSN(m.cfg.IngestSecret, m.cfg.Host, p.ID, p.KeyVersion)
|
||||
}
|
||||
return &sentrytypes.GettableProject{Project: p, DSN: dsn}
|
||||
return &sentrytypes.GettableProject{Project: p}
|
||||
}
|
||||
|
||||
// --- issues (reused errortracking lifecycle, org-scoped) ---
|
||||
@@ -376,94 +276,11 @@ func (m *module) requireProject(ctx context.Context, orgID valuer.UUID, raw stri
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// eventFromOccurrence maps a normalized Occurrence to an event.error row for (org,
|
||||
// project). Stack frames become the five parallel frames.* arrays, so the crash site
|
||||
// is queryable rather than buried in a JSON blob.
|
||||
func eventFromOccurrence(orgID, projectID valuer.UUID, occ *errortrackingtypes.Occurrence) *sentrytypes.Event {
|
||||
e := &sentrytypes.Event{
|
||||
OrgID: orgID.String(),
|
||||
ProjectID: projectID.String(),
|
||||
EventID: occ.EventID,
|
||||
Timestamp: occ.Timestamp,
|
||||
Level: occ.Level,
|
||||
Type: occ.Type,
|
||||
Message: firstNonEmpty(occ.Value, occ.Type),
|
||||
Culprit: occ.Culprit,
|
||||
Fingerprint: occ.Fingerprint,
|
||||
Handled: handledFromTags(occ.Tags),
|
||||
Platform: occ.Platform,
|
||||
Environment: occ.Environment,
|
||||
Release: occ.Release,
|
||||
ServiceName: occ.ServiceName,
|
||||
Transaction: occ.Transaction,
|
||||
TraceID: occ.TraceID,
|
||||
SpanID: occ.SpanID,
|
||||
ServerName: occ.ServerName,
|
||||
Tags: occ.Tags,
|
||||
Frames: framesFromOccurrence(occ.Frames),
|
||||
}
|
||||
if occ.Timestamp.IsZero() {
|
||||
e.Timestamp = nowUTC()
|
||||
}
|
||||
if occ.User != nil {
|
||||
e.UserID = occ.User.ID
|
||||
e.UserEmail = occ.User.Email
|
||||
e.UserIP = occ.User.IP
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// framesFromOccurrence narrows a normalized stack to the frame fields event.error
|
||||
// stores. Filename is preferred over the absolute path: it is what a stack trace reads
|
||||
// as, and it does not leak a build machine's directory layout.
|
||||
func framesFromOccurrence(in []errortrackingtypes.Frame) []sentrytypes.Frame {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]sentrytypes.Frame, len(in))
|
||||
for i, f := range in {
|
||||
out[i] = sentrytypes.Frame{
|
||||
Function: f.Function,
|
||||
File: firstNonEmpty(f.Filename, f.AbsPath),
|
||||
Line: clampLine(f.Lineno),
|
||||
Column: clampLine(f.Colno),
|
||||
Own: f.InApp,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clampLine narrows a wire line/column to the stored unsigned column, mapping a
|
||||
// negative or absent value to zero ("unknown") rather than wrapping it.
|
||||
func clampLine(n int) uint32 {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return uint32(n)
|
||||
}
|
||||
|
||||
// handledFromTags reads the SDK's exception-mechanism flag, which normalization
|
||||
// carries as a tag. Absent means the report arrived through the normal capture path,
|
||||
// which is handled; only an explicit "false" marks a crash.
|
||||
func handledFromTags(tags map[string]string) bool {
|
||||
return tags["handled"] != "false"
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// reservedSlugs are the static /v1/o11y/sentinel resource words a project slug may not take,
|
||||
// so a slug can never be confused with a route (belt-and-suspenders alongside the
|
||||
// UUID-constrained ingest route and static-before-wildcard registration).
|
||||
// reservedSlugs are the static /v1/o11y/sentinel resource words a project slug may not
|
||||
// take, so a slug can never be confused with a route.
|
||||
var reservedSlugs = map[string]bool{
|
||||
"projects": true, "issues": true, "discover": true, "events": true,
|
||||
"logs": true, "traces": true, "stats": true, "envelope": true, "store": true,
|
||||
"logs": true, "traces": true, "stats": true,
|
||||
}
|
||||
|
||||
// slugify lowercases and reduces a name to a URL-safe slug (a-z0-9 and single dashes).
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeEvents is an in-memory EventStore: it records every write keyed by (org,
|
||||
// project) so a test can assert BOTH that ingest reached the events plane AND that a
|
||||
// read is only ever asked for the caller's own tenant.
|
||||
// fakeEvents is an in-memory EventStore standing in for the error facts of the event
|
||||
// plane, keyed by (org, project), so a test can seed what the ingest would have
|
||||
// written and assert that a read is only ever asked for the caller's own tenant.
|
||||
type fakeEvents struct {
|
||||
inserts map[[2]string][]*sentrytypes.Event
|
||||
lastOrg string
|
||||
@@ -33,9 +33,14 @@ func newFakeEvents() *fakeEvents {
|
||||
|
||||
func (f *fakeEvents) key(o, p valuer.UUID) [2]string { return [2]string{o.String(), p.String()} }
|
||||
|
||||
func (f *fakeEvents) Insert(_ context.Context, o, p valuer.UUID, e []*sentrytypes.Event) error {
|
||||
f.inserts[f.key(o, p)] = append(f.inserts[f.key(o, p)], e...)
|
||||
return nil
|
||||
// seed puts facts on the plane the way the ingest that accepted them would have.
|
||||
func (f *fakeEvents) seed(o, p valuer.UUID, occs ...*errortrackingtypes.Occurrence) {
|
||||
for _, occ := range occs {
|
||||
f.inserts[f.key(o, p)] = append(f.inserts[f.key(o, p)], &sentrytypes.Event{
|
||||
OrgID: o.String(), ProjectID: p.String(), EventID: occ.EventID,
|
||||
Fingerprint: occ.Fingerprint, TraceID: occ.TraceID, Timestamp: occ.Timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
func (f *fakeEvents) Discover(_ context.Context, o, p valuer.UUID, _ *sentrytypes.DiscoverRequest, _ sentrytypes.Window) (*sentrytypes.DiscoverResult, error) {
|
||||
f.lastOrg, f.lastProj, f.discovers = o.String(), p.String(), f.discovers+1
|
||||
@@ -94,8 +99,6 @@ func (f *fakeEvents) Stats(_ context.Context, o, p valuer.UUID, _ string, _ sent
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
const testSecret = "platform-ingest-secret"
|
||||
|
||||
type harness struct {
|
||||
mod sentry.Module
|
||||
events *fakeEvents
|
||||
@@ -106,9 +109,9 @@ func newModuleHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
store := newModuleSQLStore(t)
|
||||
projects := NewProjectStore(store)
|
||||
issues := errortracking.Module(implerrortracking.NewModule(implerrortracking.NewStore(store), implerrortracking.NewNoopSink()))
|
||||
issues := errortracking.Module(implerrortracking.NewModule(implerrortracking.NewStore(store)))
|
||||
events := newFakeEvents()
|
||||
mod := NewModule(projects, events, issues, Config{IngestSecret: []byte(testSecret), Host: "api.hanzo.ai"})
|
||||
mod := NewModule(projects, events, issues)
|
||||
return &harness{mod: mod, events: events, projects: projects}
|
||||
}
|
||||
|
||||
@@ -142,114 +145,52 @@ func occTrace(fp, eventID, traceID string) *errortrackingtypes.Occurrence {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngest_WritesEventsAndIssues proves the dual write: one Ingest lands the
|
||||
// occurrence on BOTH the columnar events plane and the grouped-issue lifecycle.
|
||||
func TestIngest_WritesEventsAndIssues(t *testing.T) {
|
||||
// ingest does what the event.error consumer does: the facts are already on the
|
||||
// plane (seeded here), and Ingest files them into the org's issues.
|
||||
func ingest(t *testing.T, h *harness, org string, project *sentrytypes.GettableProject, occs ...*errortrackingtypes.Occurrence) []*errortrackingtypes.Issue {
|
||||
t.Helper()
|
||||
h.events.seed(iamidentn.OrgUUID(org), project.Project.ID, occs...)
|
||||
created, err := h.mod.Ingest(context.Background(), org, project.Slug, occs)
|
||||
require.NoError(t, err)
|
||||
return created
|
||||
}
|
||||
|
||||
// TestIngest_FilesIssuesAndReportsTheNewOnes: Ingest groups a batch into the org's
|
||||
// issues, reports an issue as created exactly once, and provisions the product's
|
||||
// project under the slug the plane stores — so a surface appears on its first error.
|
||||
func TestIngest_FilesIssuesAndReportsTheNewOnes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := newModuleHarness(t)
|
||||
org := valuer.GenerateUUID()
|
||||
proj := mustProject(t, h, org, "web")
|
||||
pid := proj.Project.ID
|
||||
|
||||
require.NoError(t, h.mod.Ingest(ctx, org, pid, []*errortrackingtypes.Occurrence{occ("fp-1", "e1"), occ("fp-1", "e2")}))
|
||||
created, err := h.mod.Ingest(ctx, "acme", "docs", []*errortrackingtypes.Occurrence{occ("fp-1", "e1"), occ("fp-1", "e2")})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, created, 1, "two facts of one failure are one new issue")
|
||||
assert.Equal(t, "fp-1", created[0].Fingerprint)
|
||||
|
||||
// Events plane got both occurrences under (org, project).
|
||||
assert.Len(t, h.events.inserts[[2]string{org.String(), pid.String()}], 2)
|
||||
|
||||
// Issue lifecycle grouped them into one issue for the org.
|
||||
org := iamidentn.OrgUUID("acme")
|
||||
issues, err := h.mod.ListIssues(ctx, org, nil, &errortrackingtypes.IssuesQuery{}, testWindow())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issues.Items, 1)
|
||||
assert.Equal(t, "fp-1", issues.Items[0].Fingerprint)
|
||||
assert.Equal(t, int64(2), issues.Items[0].Count)
|
||||
}
|
||||
|
||||
// withIngestKeyResolver installs a resolver for one test and puts back whatever
|
||||
// was there. The resolver is package state read per request, so a test that left
|
||||
// its own behind would decide the answer for every test after it.
|
||||
func withIngestKeyResolver(t *testing.T, fn IngestKeyResolver) {
|
||||
t.Helper()
|
||||
prev := getIngestKeyResolver()
|
||||
SetIngestKeyResolver(fn)
|
||||
t.Cleanup(func() { SetIngestKeyResolver(prev) })
|
||||
}
|
||||
|
||||
const testOrgSlug = "acme"
|
||||
|
||||
// resolverFor answers only for key, and names no org for anything else — a stand-in
|
||||
// for the host asking IAM whether a publishable key belongs to somebody.
|
||||
func resolverFor(key string) IngestKeyResolver {
|
||||
return func(_ context.Context, presented string) (string, bool) {
|
||||
if presented == key {
|
||||
return testOrgSlug, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveIngest_FailsClosed is the ingest gate. Attribution is the org's
|
||||
// publishable key — the same one /v1/event takes — resolved by the host, so what
|
||||
// has to hold is that every way of not presenting a good one stays shut: no
|
||||
// resolver installed at all, a key the resolver rejects, an empty key, and a
|
||||
// resolver that answers ok with no org. Only a key naming an org resolves, and the
|
||||
// project in the DSN is born under that org on first sight.
|
||||
func TestResolveIngest_FailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := newModuleHarness(t)
|
||||
pid := valuer.GenerateUUID()
|
||||
const goodKey = "pk-live-good"
|
||||
|
||||
// The default. A host that never installed a resolver ingests nothing.
|
||||
_, ok := h.mod.ResolveIngest(ctx, pid, goodKey)
|
||||
assert.False(t, ok, "no resolver installed must not resolve")
|
||||
|
||||
withIngestKeyResolver(t, resolverFor(goodKey))
|
||||
|
||||
gotOrg, ok := h.mod.ResolveIngest(ctx, pid, goodKey)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, iamidentn.OrgUUID(testOrgSlug), gotOrg, "resolves to the org the key names")
|
||||
|
||||
// The DSN's project is provisioned under that org, so nobody had to create it.
|
||||
_, _, status, found, err := h.projects.Resolve(ctx, pid)
|
||||
projects, err := h.mod.ListProjects(ctx, org)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found, "first keyed ingest provisions the project")
|
||||
assert.Equal(t, sentrytypes.ProjectActive, status)
|
||||
require.Len(t, projects.Items, 1)
|
||||
assert.Equal(t, "docs", projects.Items[0].Slug, "the project is the product, by the plane's name")
|
||||
|
||||
// A key the resolver does not recognise.
|
||||
_, ok = h.mod.ResolveIngest(ctx, pid, "pk-live-someone-else")
|
||||
assert.False(t, ok)
|
||||
again, err := h.mod.Ingest(ctx, "acme", "docs", []*errortrackingtypes.Occurrence{occ("fp-1", "e3")})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, again, "a known failure is not announced twice")
|
||||
|
||||
// No key at all.
|
||||
_, ok = h.mod.ResolveIngest(ctx, pid, "")
|
||||
assert.False(t, ok)
|
||||
_, err = h.mod.Ingest(ctx, "", "docs", nil)
|
||||
require.Error(t, err, "an ingest that names no org is refused")
|
||||
|
||||
// A resolver that says yes but names no org is not an authorisation.
|
||||
withIngestKeyResolver(t, func(context.Context, string) (string, bool) { return "", true })
|
||||
_, ok = h.mod.ResolveIngest(ctx, pid, goodKey)
|
||||
assert.False(t, ok, "ok with an empty org slug must not resolve")
|
||||
}
|
||||
|
||||
// TestResolveIngest_DisabledProjectFailsClosed covers the other half: the key is
|
||||
// good and the org is real, and the project still refuses because it was disabled.
|
||||
// Turning a project off is how ingest is stopped without rotating an org's key.
|
||||
func TestResolveIngest_DisabledProjectFailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := newModuleHarness(t)
|
||||
org := iamidentn.OrgUUID(testOrgSlug)
|
||||
proj := mustProject(t, h, org, "web")
|
||||
pid := proj.Project.ID
|
||||
const goodKey = "pk-live-good"
|
||||
|
||||
withIngestKeyResolver(t, resolverFor(goodKey))
|
||||
|
||||
// Active first, so the disabled result below is the disabling and nothing else.
|
||||
_, ok := h.mod.ResolveIngest(ctx, pid, goodKey)
|
||||
require.True(t, ok, "an active project resolves with a good key")
|
||||
|
||||
disableProject(t, h, org, pid)
|
||||
|
||||
_, ok = h.mod.ResolveIngest(ctx, pid, goodKey)
|
||||
assert.False(t, ok, "a disabled project must not resolve even with a good key")
|
||||
created, err = h.mod.Ingest(ctx, "acme", "", []*errortrackingtypes.Occurrence{occ("fp-2", "e4")})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, created, 1, "a fact with no product still files its issue")
|
||||
projects, err = h.mod.ListProjects(ctx, org)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, projects.Items, 1, "and lists no project for it")
|
||||
}
|
||||
|
||||
// TestReads_ForeignProjectDenied is the mandatory read isolation: a project id that
|
||||
@@ -286,12 +227,12 @@ func TestReads_ForeignProjectDenied(t *testing.T) {
|
||||
func TestListIssues_ProjectFilterViaEventsPlane(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := newModuleHarness(t)
|
||||
org := valuer.GenerateUUID()
|
||||
web := mustProject(t, h, org, "web").Project.ID
|
||||
api := mustProject(t, h, org, "api").Project.ID
|
||||
org := iamidentn.OrgUUID("acme")
|
||||
webP, apiP := mustProject(t, h, org, "web"), mustProject(t, h, org, "api")
|
||||
web := webP.Project.ID
|
||||
|
||||
require.NoError(t, h.mod.Ingest(ctx, org, web, []*errortrackingtypes.Occurrence{occ("fp-web", "e1")}))
|
||||
require.NoError(t, h.mod.Ingest(ctx, org, api, []*errortrackingtypes.Occurrence{occ("fp-api", "e2")}))
|
||||
ingest(t, h, "acme", webP, occ("fp-web", "e1"))
|
||||
ingest(t, h, "acme", apiP, occ("fp-api", "e2"))
|
||||
|
||||
// Whole-org list sees BOTH issues.
|
||||
all, err := h.mod.ListIssues(ctx, org, nil, &errortrackingtypes.IssuesQuery{}, testWindow())
|
||||
@@ -319,15 +260,15 @@ func TestListIssues_ProjectFilterViaEventsPlane(t *testing.T) {
|
||||
func TestTraceDetail_CrossTenantTraceIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := newModuleHarness(t)
|
||||
orgA, orgB := valuer.GenerateUUID(), valuer.GenerateUUID()
|
||||
pA := mustProject(t, h, orgA, "a").Project.ID
|
||||
pB := mustProject(t, h, orgB, "b").Project.ID
|
||||
orgA, orgB := iamidentn.OrgUUID("acme"), iamidentn.OrgUUID("globex")
|
||||
projA, projB := mustProject(t, h, orgA, "a"), mustProject(t, h, orgB, "b")
|
||||
pA, pB := projA.Project.ID, projB.Project.ID
|
||||
|
||||
const victimTrace = "VICTIM-TRACE-DEADBEEF"
|
||||
// org A's real event on the victim trace.
|
||||
require.NoError(t, h.mod.Ingest(ctx, orgA, pA, []*errortrackingtypes.Occurrence{occTrace("fp-a", "a-secret", victimTrace)}))
|
||||
ingest(t, h, "acme", projA, occTrace("fp-a", "a-secret", victimTrace))
|
||||
// org B forges an event CLAIMING the same trace id in ITS OWN project.
|
||||
require.NoError(t, h.mod.Ingest(ctx, orgB, pB, []*errortrackingtypes.Occurrence{occTrace("fp-b", "b-own", victimTrace)}))
|
||||
ingest(t, h, "globex", projB, occTrace("fp-b", "b-own", victimTrace))
|
||||
|
||||
// org B reads the trace in its own project: sees ONLY its own event, never org A's.
|
||||
detail, err := h.mod.TraceDetail(ctx, orgB, pB, victimTrace)
|
||||
@@ -350,10 +291,10 @@ func TestTraceDetail_CrossTenantTraceIsolation(t *testing.T) {
|
||||
func TestGetEvent_ProjectScoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := newModuleHarness(t)
|
||||
org := valuer.GenerateUUID()
|
||||
web := mustProject(t, h, org, "web").Project.ID
|
||||
api := mustProject(t, h, org, "api").Project.ID
|
||||
require.NoError(t, h.mod.Ingest(ctx, org, web, []*errortrackingtypes.Occurrence{occ("fp", "evt-web")}))
|
||||
org := iamidentn.OrgUUID("acme")
|
||||
webP := mustProject(t, h, org, "web")
|
||||
web, api := webP.Project.ID, mustProject(t, h, org, "api").Project.ID
|
||||
ingest(t, h, "acme", webP, occ("fp", "evt-web"))
|
||||
|
||||
// Correct project → found.
|
||||
got, err := h.mod.GetEvent(ctx, org, web, "evt-web")
|
||||
@@ -377,11 +318,11 @@ func TestGetEvent_ProjectScoped(t *testing.T) {
|
||||
func TestIssueEvents_ProjectScoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := newModuleHarness(t)
|
||||
org := valuer.GenerateUUID()
|
||||
web := mustProject(t, h, org, "web").Project.ID
|
||||
api := mustProject(t, h, org, "api").Project.ID
|
||||
require.NoError(t, h.mod.Ingest(ctx, org, web, []*errortrackingtypes.Occurrence{occ("fp-shared", "e-web")}))
|
||||
require.NoError(t, h.mod.Ingest(ctx, org, api, []*errortrackingtypes.Occurrence{occ("fp-shared", "e-api")}))
|
||||
org := iamidentn.OrgUUID("acme")
|
||||
webP, apiP := mustProject(t, h, org, "web"), mustProject(t, h, org, "api")
|
||||
web := webP.Project.ID
|
||||
ingest(t, h, "acme", webP, occ("fp-shared", "e-web"))
|
||||
ingest(t, h, "acme", apiP, occ("fp-shared", "e-api"))
|
||||
|
||||
// One org-scoped issue exists for fp-shared; find it.
|
||||
issues, err := h.mod.ListIssues(ctx, org, nil, &errortrackingtypes.IssuesQuery{}, testWindow())
|
||||
@@ -401,15 +342,3 @@ func TestIssueEvents_ProjectScoped(t *testing.T) {
|
||||
_, err = h.mod.IssueEvents(ctx, org, issueID, foreign, 0)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// disableProject flips a project's status to disabled via a direct store write.
|
||||
func disableProject(t *testing.T, h *harness, org, id valuer.UUID) {
|
||||
t.Helper()
|
||||
ps := h.projects.(*projectStore)
|
||||
_, err := ps.sqlstore.BunDB().NewUpdate().
|
||||
Model((*sentrytypes.Project)(nil)).
|
||||
Set("status = ?", sentrytypes.ProjectDisabled).
|
||||
Where("org_id = ?", org).Where("id = ?", id).
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// THE PLANE'S VOCABULARY, said once.
|
||||
//
|
||||
// `event.error` is a slice of the ONE event plane, and the plane names a tenant by its
|
||||
// The error facts are a slice of the ONE event plane (event.fact, signal 'error'), and the plane names a tenant by its
|
||||
// IAM organization SLUG and a surface by its PRODUCT — the strings every other writer
|
||||
// on it emits (`hanzo`, `docs`) and every reader binds. This module works in UUIDs,
|
||||
// because that is what the HTTP surface and the project rows are keyed on. The two
|
||||
@@ -47,13 +47,10 @@ import (
|
||||
// path for it (sentry.Module has Create/Get/List/Rotate/Delete and no Update), so it
|
||||
// is stable enough to key rows on.
|
||||
//
|
||||
// A CONSEQUENCE WORTH SAYING OUT LOUD: because cloud writes `product = 'docs'` for the
|
||||
// same org, a Sentry project slugged `docs` now reads cloud's errors for that surface
|
||||
// as well as its own. That is the one table doing what it is for — same tenant, same
|
||||
// surface, same kind of fact — and it cannot cross a tenant, because `org` leads every
|
||||
// predicate. It is a UNION, not a leak. If a project ever needs to see only what its
|
||||
// own DSN captured, that is a narrower on how the fact ARRIVED, and the honest place
|
||||
// for it is a column that says so, not a second spelling of the surface.
|
||||
// A CONSEQUENCE WORTH SAYING OUT LOUD: a Sentry project slugged `docs` reads every
|
||||
// error fact the org's `docs` surface reported. That is the one table doing what it is
|
||||
// for — same tenant, same surface, same kind of fact — and it cannot cross a tenant,
|
||||
// because `org` leads every predicate.
|
||||
|
||||
// Scope resolves the identifiers a caller works in to the names the plane stores. It
|
||||
// is the ONE place a uuid becomes a name; everything above it speaks uuid and
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
@@ -45,26 +44,6 @@ func TestTheUUIDIsDerivedFromTheSlug(t *testing.T) {
|
||||
assert.Equal(t, "dfb7a19b-108f-5150-8131-7d207488bf48", orgUUID("admin"))
|
||||
}
|
||||
|
||||
// TestRowWritesTheNamesNotTheIDs is the regression gate on the writer. It asserts the
|
||||
// ROW, not the call: whatever ids the caller works in, what lands in `org` and
|
||||
// `product` is what the plane calls them, and no uuid spelling appears anywhere in it.
|
||||
func TestRowWritesTheNamesNotTheIDs(t *testing.T) {
|
||||
orgID := valuer.MustNewUUID(orgUUID("hanzo"))
|
||||
projectID := valuer.GenerateUUID()
|
||||
|
||||
got := row("hanzo", "docs", time.Unix(0, 0).UTC(), &sentrytypes.Event{EventID: "e-1"})
|
||||
|
||||
require.Len(t, got, countCols(insertColumns), "a row is exactly the insert column list")
|
||||
assert.Equal(t, "hanzo", got[0], "org is the IAM org slug")
|
||||
assert.Equal(t, "docs", got[1], "product is the project's slug")
|
||||
for _, cell := range got {
|
||||
if s, ok := cell.(string); ok {
|
||||
assert.NotEqual(t, orgID.String(), s, "no uuid spelling of the tenant reaches the plane")
|
||||
assert.NotEqual(t, projectID.String(), s, "no uuid spelling of the product reaches the plane")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnnamedTenantFailsClosed: an id the plane has no name for must refuse the
|
||||
// operation. Writing it under a placeholder is exactly the commingling this seam
|
||||
// exists to prevent — a bucket nobody owns is a bucket several tenants share.
|
||||
@@ -74,23 +53,19 @@ func TestUnnamedTenantFailsClosed(t *testing.T) {
|
||||
scope: func(context.Context, valuer.UUID, valuer.UUID) (string, string, error) { return "", "", boom },
|
||||
db: defaultEventsDB,
|
||||
table: defaultEventsTable,
|
||||
now: func() time.Time { return time.Unix(0, 0).UTC() },
|
||||
}
|
||||
// store is nil, so reaching the sink at all would panic rather than pass.
|
||||
require.Error(t, s.Insert(context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID(),
|
||||
[]*sentrytypes.Event{{EventID: "e-1"}}))
|
||||
|
||||
// store is nil, so reaching the datastore at all would panic rather than pass.
|
||||
_, err := s.Discover(context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID(),
|
||||
&sentrytypes.DiscoverRequest{}, testWindow())
|
||||
require.Error(t, err, "reads fail closed for the same reason writes do")
|
||||
require.Error(t, err, "a read under an unnamed tenant fails closed")
|
||||
}
|
||||
|
||||
// TestAStoreWithoutAScopeCannotWrite: the resolver is a constructor argument, but a
|
||||
// zero-value store must still refuse rather than write a nameless row.
|
||||
func TestAStoreWithoutAScopeCannotWrite(t *testing.T) {
|
||||
s := &eventStore{db: defaultEventsDB, table: defaultEventsTable, now: time.Now}
|
||||
require.Error(t, s.Insert(context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID(),
|
||||
[]*sentrytypes.Event{{EventID: "e-1"}}))
|
||||
// TestAStoreWithoutAScopeCannotRead: the resolver is a constructor argument, but a
|
||||
// zero-value store must still refuse rather than read under no name.
|
||||
func TestAStoreWithoutAScopeCannotRead(t *testing.T) {
|
||||
s := &eventStore{db: defaultEventsDB, table: defaultEventsTable}
|
||||
_, err := s.GetEvent(context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID(), "e-1")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestScopeResolvesFromTheRecordsThatOwnTheNames pins NewScope: the org's name comes
|
||||
|
||||
@@ -2,8 +2,6 @@ package implsentry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
stderrors "errors"
|
||||
|
||||
"github.com/hanzoai/o11y/pkg/errors"
|
||||
"github.com/hanzoai/o11y/pkg/sqlstore"
|
||||
@@ -59,31 +57,6 @@ func (s *projectStore) Get(ctx context.Context, orgID, id valuer.UUID) (*sentryt
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Rotate bumps the project's key watermark (invalidating below-version DSNs). The
|
||||
// bump is org-scoped and the loaded row's version drives the returned new version;
|
||||
// zero rows affected means the project does not belong to the caller's org.
|
||||
func (s *projectStore) Rotate(ctx context.Context, orgID, id valuer.UUID) (int, error) {
|
||||
p, err := s.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.sqlstore.BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model((*sentrytypes.Project)(nil)).
|
||||
Set("key_version = key_version + 1").
|
||||
Set("updated_at = ?", nowUTC()).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return 0, errors.Newf(errors.TypeNotFound, sentrytypes.ErrCodeSentryNotFound, "project %s not found in the org", id)
|
||||
}
|
||||
return p.KeyVersion + 1, nil
|
||||
}
|
||||
|
||||
// Delete removes the project, org-scoped. Zero rows affected means it does not
|
||||
// belong to the caller's org (or never existed) — reported as not-found so a caller
|
||||
// can never probe another tenant's ids by watching for a different error.
|
||||
@@ -105,24 +78,3 @@ func (s *projectStore) Delete(ctx context.Context, orgID, id valuer.UUID) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve is the ingest-time lookup: it maps a project id to its owning org, current
|
||||
// key version and status WITHOUT an org filter (ingest carries no IAM principal — the
|
||||
// DSN key is the credential, verified by the caller). Fail-closed: an unknown project
|
||||
// returns found=false, so a forged/unknown DSN never resolves to a tenant.
|
||||
func (s *projectStore) Resolve(ctx context.Context, id valuer.UUID) (valuer.UUID, int, sentrytypes.ProjectStatus, bool, error) {
|
||||
p := new(sentrytypes.Project)
|
||||
err := s.sqlstore.BunDB().
|
||||
NewSelect().
|
||||
Model(p).
|
||||
Column("org_id", "key_version", "status").
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, sql.ErrNoRows) {
|
||||
return valuer.UUID{}, 0, "", false, nil // fail-closed: unknown project never resolves
|
||||
}
|
||||
return valuer.UUID{}, 0, "", false, err
|
||||
}
|
||||
return p.OrgID, p.KeyVersion, p.Status, true, nil
|
||||
}
|
||||
|
||||
@@ -52,11 +52,10 @@ func newProject(orgID valuer.UUID, name, slug string) *sentrytypes.Project {
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Status: sentrytypes.ProjectActive,
|
||||
KeyVersion: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectStore_CRUDAndRotate(t *testing.T) {
|
||||
func TestProjectStore_CRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewProjectStore(newTestSQLStore(t))
|
||||
org := valuer.GenerateUUID()
|
||||
@@ -67,31 +66,15 @@ func TestProjectStore_CRUDAndRotate(t *testing.T) {
|
||||
got, err := store.Get(ctx, org, p.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "web", got.Slug)
|
||||
assert.Equal(t, 1, got.KeyVersion)
|
||||
assert.Equal(t, sentrytypes.ProjectActive, got.Status)
|
||||
|
||||
list, err := store.List(ctx, org)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
|
||||
// Rotate bumps the key watermark.
|
||||
v, err := store.Rotate(ctx, org, p.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, v)
|
||||
got, err = store.Get(ctx, org, p.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, got.KeyVersion)
|
||||
|
||||
// Resolve (ingest path) returns the owning org + watermark.
|
||||
rOrg, ver, status, found, err := store.Resolve(ctx, p.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, org, rOrg)
|
||||
assert.Equal(t, 2, ver)
|
||||
assert.Equal(t, sentrytypes.ProjectActive, status)
|
||||
}
|
||||
|
||||
// TestProjectStore_TenantIsolation is the mandatory two-org isolation test: org B can
|
||||
// neither read nor rotate org A's project, and each org lists only its own.
|
||||
// TestProjectStore_TenantIsolation is the mandatory two-org isolation test: org B cannot
|
||||
// read org A's project, and each org lists only its own.
|
||||
func TestProjectStore_TenantIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewProjectStore(newTestSQLStore(t))
|
||||
@@ -106,14 +89,6 @@ func TestProjectStore_TenantIsolation(t *testing.T) {
|
||||
_, err := store.Get(ctx, orgB, pa.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
// org B cannot ROTATE org A's project.
|
||||
_, err = store.Rotate(ctx, orgB, pa.ID)
|
||||
require.Error(t, err)
|
||||
// ...and org A's key is untouched by B's attempt.
|
||||
stillA, err := store.Get(ctx, orgA, pa.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, stillA.KeyVersion)
|
||||
|
||||
// Each org lists only its own.
|
||||
la, _ := store.List(ctx, orgA)
|
||||
lb, _ := store.List(ctx, orgB)
|
||||
@@ -123,14 +98,6 @@ func TestProjectStore_TenantIsolation(t *testing.T) {
|
||||
assert.Equal(t, pb.ID, lb[0].ID)
|
||||
}
|
||||
|
||||
func TestProjectStore_ResolveUnknownFailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewProjectStore(newTestSQLStore(t))
|
||||
_, _, _, found, err := store.Resolve(ctx, valuer.GenerateUUID())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found, "an unknown project must resolve to found=false, never a tenant")
|
||||
}
|
||||
|
||||
func TestProjectStore_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewProjectStore(newTestSQLStore(t))
|
||||
@@ -170,23 +137,3 @@ func TestProjectStore_DeleteIsOrgScoped(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "a-app", got.Slug)
|
||||
}
|
||||
|
||||
func TestProjectStore_DeletedProjectStopsResolving(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewProjectStore(newTestSQLStore(t))
|
||||
org := valuer.GenerateUUID()
|
||||
|
||||
p := newProject(org, "Revoked", "revoked")
|
||||
require.NoError(t, store.Create(ctx, p))
|
||||
|
||||
_, _, _, found, err := store.Resolve(ctx, p.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
|
||||
// Deleting revokes the DSN: ingest resolution fails closed with no separate
|
||||
// revocation step, exactly as it does for an id that never existed.
|
||||
require.NoError(t, store.Delete(ctx, org, p.ID))
|
||||
_, _, _, found, err = store.Resolve(ctx, p.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Package sentry is Hanzo Sentry — the Sentry-parity error/log/trace product face
|
||||
// under /v1/o11y/sentinel. It is a COMPOSITION, not a refork: the ingest engine (envelope
|
||||
// parse, fingerprint, DSN verify, scrub, rate-limit) is the reused errortracking
|
||||
// engine; identity is Hanzo IAM; storage is the ONE datastore (columnar events) plus
|
||||
// o11y_issues (grouped-issue lifecycle, reused verbatim). This package owns only the
|
||||
// product surface: projects, the events plane, and the read/query shapes that give
|
||||
// Discover / logs / traces / stats their Sentry semantics.
|
||||
// Package sentry is Sentinel — the Sentry-parity error/log/trace product face under
|
||||
// /v1/o11y/sentinel. It is a COMPOSITION, not a refork: errors enter through /v1/event
|
||||
// only and land on the ONE event plane (event.fact, signal 'error'); a consumer of the
|
||||
// event.error subject hands each batch to Ingest, which keeps the grouped-issue
|
||||
// lifecycle (o11y_issues, reused verbatim); identity is Hanzo IAM. This package owns
|
||||
// only the product surface: projects, the reads over the error facts, and the query
|
||||
// shapes that give Discover / logs / traces / stats their Sentry semantics.
|
||||
package sentry
|
||||
|
||||
import (
|
||||
@@ -16,34 +16,26 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// Module is the org-scoped business surface. Ingest is the only method that takes a
|
||||
// project (resolved from the DSN, no principal); every other method is scoped to the
|
||||
// caller's org and validates any project against it.
|
||||
// Module is the org-scoped business surface. Ingest is the only method that takes
|
||||
// the plane's names (an org slug and a product) rather than ids: its caller is the
|
||||
// event.error consumer, which reads them off the fact. Every other method is scoped to
|
||||
// the caller's org and validates any project against it.
|
||||
type Module interface {
|
||||
// Ingest persists a request's occurrences for (org, project): the columnar events
|
||||
// plane (Insert) AND the grouped-issue lifecycle (reused errortracking upsert).
|
||||
Ingest(ctx context.Context, orgID, projectID valuer.UUID, occs []*errortrackingtypes.Occurrence) error
|
||||
// Ingest files one (org, product)'s error facts into the org's issues and returns
|
||||
// the issues it created. An empty product files the issues and lists no project. The facts are already on the event plane; this writes the
|
||||
// lifecycle only. Each occurrence's Fingerprint is the fact's `issue`.
|
||||
Ingest(ctx context.Context, org, product string, occs []*errortrackingtypes.Occurrence) ([]*errortrackingtypes.Issue, error)
|
||||
|
||||
// Projects — org-scoped CRUD + DSN rotation. Create/Get/List/Rotate all stamp or
|
||||
// filter org_id; the DSN is derived, never stored.
|
||||
// Projects — org-scoped CRUD. A project is a product: its slug is the product
|
||||
// name the event plane stores, and Ingest creates one on a product's first error.
|
||||
CreateProject(ctx context.Context, orgID valuer.UUID, in *sentrytypes.PostableProject) (*sentrytypes.GettableProject, error)
|
||||
ListProjects(ctx context.Context, orgID valuer.UUID) (*sentrytypes.GettableProjects, error)
|
||||
GetProject(ctx context.Context, orgID, id valuer.UUID) (*sentrytypes.GettableProject, error)
|
||||
RotateProjectKey(ctx context.Context, orgID, id valuer.UUID) (*sentrytypes.GettableProject, error)
|
||||
|
||||
// DeleteProject removes a project. Its DSN stops resolving immediately, so ingest
|
||||
// for that id fails closed exactly as an unknown project does. Retained events are
|
||||
// not touched — deleting a project must not double as a history wipe.
|
||||
// DeleteProject removes a project. Retained events are not touched — deleting a
|
||||
// project must not double as a history wipe.
|
||||
DeleteProject(ctx context.Context, orgID, id valuer.UUID) error
|
||||
|
||||
// ResolveIngest maps a DSN project id to its owning org, verifying the presented
|
||||
// DSN key against the project's rotation watermark. Fail-closed: an unknown,
|
||||
// disabled or below-watermark project/key returns ok=false.
|
||||
ResolveIngest(ctx context.Context, projectID valuer.UUID, presentedKey string) (orgID valuer.UUID, ok bool)
|
||||
|
||||
// RateAllow reports whether the project is within its ingest rate budget.
|
||||
RateAllow(projectID valuer.UUID) bool
|
||||
|
||||
// Issues — reused errortracking lifecycle, org-scoped, optionally narrowed to a
|
||||
// project via the events-plane fingerprint projection.
|
||||
ListIssues(ctx context.Context, orgID valuer.UUID, projectID *valuer.UUID, q *errortrackingtypes.IssuesQuery, w sentrytypes.Window) (*errortrackingtypes.GettableIssues, error)
|
||||
@@ -62,20 +54,14 @@ type Module interface {
|
||||
Stats(ctx context.Context, orgID, projectID valuer.UUID, field, period string) ([]sentrytypes.StatsPoint, error)
|
||||
}
|
||||
|
||||
// Handler is the HTTP surface of Hanzo Sentry: the product face under /v1/o11y/sentinel,
|
||||
// behind Hanzo IAM authz and org-scoped from the validated claims, plus the ingest
|
||||
// endpoint under /v1/event, which is PUBLIC and DSN-authenticated in-handler.
|
||||
// Handler is the HTTP surface of Sentinel: the product face under /v1/o11y/sentinel,
|
||||
// behind Hanzo IAM authz and org-scoped from the validated claims.
|
||||
type Handler interface {
|
||||
// Ingest (public, DSN-auth): POST /v1/event/{project}/envelope|store/.
|
||||
EnvelopeIngest(http.ResponseWriter, *http.Request)
|
||||
StoreIngest(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Projects.
|
||||
ListProjects(http.ResponseWriter, *http.Request)
|
||||
CreateProject(http.ResponseWriter, *http.Request)
|
||||
GetProject(http.ResponseWriter, *http.Request)
|
||||
DeleteProject(http.ResponseWriter, *http.Request)
|
||||
RotateProjectKey(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Issues.
|
||||
ListIssues(http.ResponseWriter, *http.Request)
|
||||
|
||||
+2
-29
@@ -147,39 +147,12 @@ func NewHandlers(
|
||||
RulerHandler: o11yruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
|
||||
LLMObsHandler: impllmobs.NewHandler(modules.LLMObs),
|
||||
ErrorTrackingHandler: implerrortracking.NewHandler(modules.ErrorTracking, errorTrackingIngestSecret(), errorTrackingCapturePII(), modules.ErrorTrackingRevocations),
|
||||
SentryHandler: implsentry.NewHandler(modules.Sentry, len(errorTrackingIngestSecret()) > 0, errorTrackingCapturePII()),
|
||||
ErrorTrackingHandler: implerrortracking.NewHandler(modules.ErrorTracking),
|
||||
SentryHandler: implsentry.NewHandler(modules.Sentry),
|
||||
StatsHandler: statsreporter.NewHandler(statsAggregator),
|
||||
}
|
||||
}
|
||||
|
||||
// errorTrackingIngestSecret is the platform secret used to verify Sentry DSN keys
|
||||
// on the public error-ingest endpoints. It is sourced from KMS (synced to this env
|
||||
// var via a KMSSecret CRD) — never committed, never plaintext at rest. When unset,
|
||||
// the ingest endpoints fail closed (503) while the IAM-scoped read endpoints keep
|
||||
// working.
|
||||
func errorTrackingIngestSecret() []byte {
|
||||
return []byte(os.Getenv("O11Y_ERRORTRACKING_INGEST_SECRET"))
|
||||
}
|
||||
|
||||
// errorTrackingCapturePII reports whether the error-ingest path retains end-user
|
||||
// PII (email/IP). Default false = scrub (fail-secure), mirroring the llmobs
|
||||
// O11Y_GENAI_CAPTURE_MESSAGES precedent. Secrets are always redacted regardless.
|
||||
func errorTrackingCapturePII() bool {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("O11Y_ERRORTRACKING_CAPTURE_PII")))
|
||||
return v == "true" || v == "1" || v == "yes"
|
||||
}
|
||||
|
||||
// sentryIngestHost is the DSN endpoint origin the Sentry product mints into project
|
||||
// DSNs (https://<key>@<host>/v1/event/<project>). Defaults to the public API host;
|
||||
// O11Y_SENTRY_INGEST_HOST overrides per deployment/brand.
|
||||
func sentryIngestHost() string {
|
||||
if v := strings.TrimSpace(os.Getenv("O11Y_SENTRY_INGEST_HOST")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "api.hanzo.ai"
|
||||
}
|
||||
|
||||
// errorTrackingRetention is the age past which resolved-or-stale issues are swept.
|
||||
// Default 90 days; O11Y_ERRORTRACKING_RETENTION_DAYS overrides; 0 disables the sweep.
|
||||
func errorTrackingRetention() time.Duration {
|
||||
|
||||
+32
-43
@@ -94,13 +94,9 @@ type Modules struct {
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
LLMObs llmobs.Module
|
||||
ErrorTracking errortracking.Module
|
||||
// ErrorTrackingRevocations backs per-org DSN-key rotation; the handler consults
|
||||
// it on every ingest. Built here because it needs the sqlstore.
|
||||
ErrorTrackingRevocations implerrortracking.RevocationStore
|
||||
// Sentry is the /v1/o11y/sentinel product face: it COMPOSES the reused errortracking
|
||||
// engine + issue lifecycle, the columnar events plane (telemetryStore) and the
|
||||
// reused tracedetail read. Built here because it needs BOTH the sqlstore (projects)
|
||||
// and the telemetryStore (events plane).
|
||||
// Sentry is the /v1/o11y/sentinel product face: it COMPOSES the reused issue
|
||||
// lifecycle and the error facts of the event plane (telemetryStore). Built here
|
||||
// because it needs BOTH the sqlstore (projects) and the telemetryStore.
|
||||
Sentry sentry.Module
|
||||
Tag tag.Module
|
||||
}
|
||||
@@ -139,7 +135,6 @@ func NewModules(
|
||||
// reconstruct them — one issue lifecycle, one trace read, two product faces.
|
||||
errorTrackingModule := implerrortracking.NewModule(
|
||||
implerrortracking.NewStore(sqlstore),
|
||||
implerrortracking.NewNoopSink(),
|
||||
implerrortracking.WithRetention(errorTrackingRetention()),
|
||||
)
|
||||
traceDetailModule := impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail)
|
||||
@@ -151,43 +146,37 @@ func NewModules(
|
||||
sentryProjects,
|
||||
implsentry.NewEventStore(telemetryStore, implsentry.NewScope(orgGetter, sentryProjects)),
|
||||
errorTrackingModule,
|
||||
implsentry.Config{
|
||||
IngestSecret: errorTrackingIngestSecret(),
|
||||
Host: sentryIngestHost(),
|
||||
CapturePII: errorTrackingCapturePII(),
|
||||
},
|
||||
)
|
||||
|
||||
return Modules{
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(sqlstore),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: serviceAccount,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: traceDetailModule,
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl),
|
||||
LLMObs: impllmobs.NewModule(querier, impllmobs.NewStore(sqlstore)),
|
||||
ErrorTracking: errorTrackingModule,
|
||||
ErrorTrackingRevocations: implerrortracking.NewSQLRevocations(sqlstore),
|
||||
Sentry: sentryModule,
|
||||
Tag: tagModule,
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(sqlstore),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: serviceAccount,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: traceDetailModule,
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl),
|
||||
LLMObs: impllmobs.NewModule(querier, impllmobs.NewStore(sqlstore)),
|
||||
ErrorTracking: errorTrackingModule,
|
||||
Sentry: sentryModule,
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package errortrackingtypes
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// The types below are the subset of the Sentry SDK wire payload the shim consumes:
|
||||
// the JSON of a legacy `/store/` body and of an `event`-type item inside an
|
||||
// `/envelope/`. They are a from-scratch reimplementation of the PUBLIC, documented
|
||||
// Sentry ingest protocol (develop.sentry.dev) — no upstream (FSL-licensed) code is
|
||||
// used. Only the fields error-grouping needs are modeled; everything else is ignored.
|
||||
|
||||
// SentryEvent is one error event as sent by any Sentry SDK.
|
||||
type SentryEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
Timestamp json.RawMessage `json:"timestamp"` // unix-seconds number OR ISO-8601 string
|
||||
Platform string `json:"platform"`
|
||||
Level string `json:"level"`
|
||||
Logger string `json:"logger"`
|
||||
ServerName string `json:"server_name"`
|
||||
Environment string `json:"environment"`
|
||||
Release string `json:"release"`
|
||||
Transaction string `json:"transaction"`
|
||||
Fingerprint []string `json:"fingerprint"`
|
||||
Message json.RawMessage `json:"message"` // string OR {message,formatted,params}
|
||||
Exception *SentryException `json:"exception"`
|
||||
Tags json.RawMessage `json:"tags"` // {k:v} OR [[k,v],...]
|
||||
User *SentryUser `json:"user"`
|
||||
Contexts map[string]json.RawMessage `json:"contexts"`
|
||||
SDK *SentrySDK `json:"sdk"`
|
||||
}
|
||||
|
||||
// SentryException wraps one or more exception values (chained exceptions). The
|
||||
// LAST value is the primary/thrown exception.
|
||||
type SentryException struct {
|
||||
Values []SentryExceptionValue `json:"values"`
|
||||
}
|
||||
|
||||
type SentryExceptionValue struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Module string `json:"module"`
|
||||
Stacktrace *SentryStacktrace `json:"stacktrace"`
|
||||
}
|
||||
|
||||
// SentryStacktrace lists frames oldest-first; the crashing frame is last.
|
||||
type SentryStacktrace struct {
|
||||
Frames []SentryFrame `json:"frames"`
|
||||
}
|
||||
|
||||
type SentryFrame struct {
|
||||
Filename string `json:"filename"`
|
||||
Function string `json:"function"`
|
||||
Module string `json:"module"`
|
||||
AbsPath string `json:"abs_path"`
|
||||
Lineno int `json:"lineno"`
|
||||
Colno int `json:"colno"`
|
||||
InApp *bool `json:"in_app"`
|
||||
}
|
||||
|
||||
type SentryUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
}
|
||||
|
||||
type SentrySDK struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// SentryMessage is the object form of the top-level `message` field.
|
||||
type SentryMessage struct {
|
||||
Message string `json:"message"`
|
||||
Formatted string `json:"formatted"`
|
||||
}
|
||||
|
||||
// EnvelopeHeader is the first line of a Sentry envelope. Only DSN is load-bearing
|
||||
// (a fallback source for the ingest key when the X-Sentry-Auth header is absent).
|
||||
type EnvelopeHeader struct {
|
||||
EventID string `json:"event_id"`
|
||||
DSN string `json:"dsn"`
|
||||
SentAt string `json:"sent_at"`
|
||||
}
|
||||
|
||||
// EnvelopeItemHeader precedes each envelope item; Type selects the item and Length
|
||||
// (when present) frames a binary/opaque payload exactly.
|
||||
type EnvelopeItemHeader struct {
|
||||
Type string `json:"type"`
|
||||
Length *int `json:"length"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
@@ -15,8 +15,9 @@ type Store interface {
|
||||
// fingerprint, with Count = occurrences-in-batch) in ONE transaction. New
|
||||
// fingerprints are admitted only while the org is under `ceiling` (the per-org
|
||||
// issue cap — backpressure against a fingerprint-explosion DoS); existing issues
|
||||
// always bump count/last-seen and reopen-on-regression. Returns issues written.
|
||||
UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*Issue, ceiling int) (int, error)
|
||||
// always bump count/last-seen and reopen-on-regression. Returns the issues the
|
||||
// batch created.
|
||||
UpsertIssues(ctx context.Context, orgID valuer.UUID, issues []*Issue, ceiling int) ([]*Issue, error)
|
||||
|
||||
ListIssues(ctx context.Context, orgID valuer.UUID, q *IssuesQuery) ([]*Issue, int, error)
|
||||
GetIssue(ctx context.Context, orgID, id valuer.UUID) (*Issue, error)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Sentry-parity error/log/trace product face served under /v1/o11y/sentinel. It COMPOSES
|
||||
// the shared observability substrate rather than reforking it:
|
||||
//
|
||||
// - Projects are the DSN-bearing unit under an IAM org (relational lifecycle).
|
||||
// - Raw error EVENTS are columnar rows on the ONE datastore (high-volume, queried
|
||||
// by Discover / events / stats / logs / traces).
|
||||
// - Projects are the surfaces (products) under an IAM org (relational lifecycle).
|
||||
// - Raw error EVENTS are the error facts of the ONE event plane (event.fact,
|
||||
// signal 'error'), queried by Discover / events / stats / logs / traces.
|
||||
// - Grouped ISSUE lifecycle stays in o11y_issues (errortracking, reused verbatim).
|
||||
//
|
||||
// Every read is org-scoped from the validated IAM principal; the client never names
|
||||
@@ -29,8 +29,7 @@ var (
|
||||
ErrCodeSentryConflict = errors.MustNewCode("sentry_conflict")
|
||||
)
|
||||
|
||||
// ProjectStatus is a project's lifecycle state. A revoked/archived project fails
|
||||
// ingest closed (its DSN stops verifying) without deleting its history.
|
||||
// ProjectStatus is a project's lifecycle state.
|
||||
type ProjectStatus string
|
||||
|
||||
const (
|
||||
@@ -38,11 +37,9 @@ const (
|
||||
ProjectDisabled ProjectStatus = "disabled"
|
||||
)
|
||||
|
||||
// Project is a DSN-bearing unit under an IAM org. It is a thin relational row: the
|
||||
// DSN itself is NOT stored — it is derived on demand from the platform ingest secret
|
||||
// (KMS) + the project id + KeyVersion, so rotating a project is a single-row bump
|
||||
// with no secret at rest. Tenancy: OrgID is the mandatory boundary; every store
|
||||
// query filters org_id.
|
||||
// Project is one surface (a product) under an IAM org: a thin relational row whose
|
||||
// slug is the product name the event plane stores. Tenancy: OrgID is the mandatory
|
||||
// boundary; every store query filters org_id.
|
||||
type Project struct {
|
||||
bun.BaseModel `bun:"table:o11y_sentry_projects,alias:o11y_sentry_projects" json:"-"`
|
||||
|
||||
@@ -55,22 +52,15 @@ type Project struct {
|
||||
Slug string `bun:"slug,type:text,notnull" json:"slug"`
|
||||
Platform string `bun:"platform,type:text" json:"platform,omitempty"`
|
||||
Status ProjectStatus `bun:"status,type:text,notnull,default:'active'" json:"status"`
|
||||
|
||||
// KeyVersion is the per-project DSN rotation watermark. A DSN key is
|
||||
// "<version>:<hmac>"; a key whose version is below KeyVersion no longer verifies.
|
||||
// Rotation bumps this by one — isolated to THIS project, no global secret roll and
|
||||
// no shared revocation table.
|
||||
KeyVersion int `bun:"key_version,type:bigint,notnull,default:1" json:"-"`
|
||||
}
|
||||
|
||||
// GettableProject is the API view of a project including its freshly-derived DSN.
|
||||
// GettableProject is the API view of a project.
|
||||
type GettableProject struct {
|
||||
*Project
|
||||
DSN string `json:"dsn"`
|
||||
}
|
||||
|
||||
// PostableProject creates a project. Only Name (and optional Slug/Platform) are
|
||||
// client-supplied; org/id/dsn/key are server-assigned.
|
||||
// client-supplied; org and id are server-assigned.
|
||||
type PostableProject struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
|
||||
@@ -6,40 +6,24 @@ import (
|
||||
"github.com/hanzoai/o11y/pkg/valuer"
|
||||
)
|
||||
|
||||
// ProjectStore persists o11y_sentry_projects. Every method is org-scoped EXCEPT
|
||||
// Resolve, the ingest-time lookup that maps an unguessable project id to its owning
|
||||
// org + key watermark (ingest carries no IAM principal — it authenticates the DSN).
|
||||
// ProjectStore persists o11y_sentry_projects. Every method is org-scoped.
|
||||
type ProjectStore interface {
|
||||
Create(ctx context.Context, p *Project) error
|
||||
List(ctx context.Context, orgID valuer.UUID) ([]*Project, error)
|
||||
Get(ctx context.Context, orgID, id valuer.UUID) (*Project, error)
|
||||
|
||||
// Rotate bumps the project's KeyVersion (invalidating below-watermark DSNs) and
|
||||
// returns the new version, org-scoped and idempotent-safe.
|
||||
Rotate(ctx context.Context, orgID, id valuer.UUID) (int, error)
|
||||
|
||||
// Delete removes a project, org-scoped. Its DSN stops resolving, so ingest for
|
||||
// that id fails closed exactly as an unknown project does. Retained events are
|
||||
// NOT touched: they live in the columnar plane keyed by (org, project), and
|
||||
// deleting a project must not silently destroy history.
|
||||
// Delete removes a project, org-scoped. Retained events are NOT touched: they
|
||||
// live on the event plane keyed by (org, product), and deleting a project must
|
||||
// not silently destroy history.
|
||||
Delete(ctx context.Context, orgID, id valuer.UUID) error
|
||||
|
||||
// Resolve maps a project id to its owning org, current key version and status —
|
||||
// the ONLY non-org-scoped read, used by the public DSN-authenticated ingest path.
|
||||
// Fail-closed: an unknown project returns found=false.
|
||||
Resolve(ctx context.Context, id valuer.UUID) (orgID valuer.UUID, keyVersion int, status ProjectStatus, found bool, err error)
|
||||
}
|
||||
|
||||
// EventStore is the columnar events plane on the ONE datastore. Insert is the finished
|
||||
// ingest sink; the reads back Discover / event detail / issue occurrences / logs /
|
||||
// traces / stats. Every read takes the org (mandatory tenant boundary) and a project
|
||||
// as separate, server-validated arguments — no query shape carries a client-named
|
||||
// tenant.
|
||||
// EventStore reads the error facts of the ONE event plane (event.fact, signal
|
||||
// 'error'): Discover / event detail / issue occurrences / logs / traces / stats. It
|
||||
// writes nothing — /v1/event is the only way an error enters. Every read takes the
|
||||
// org (mandatory tenant boundary) and a project as separate, server-validated
|
||||
// arguments — no query shape carries a client-named tenant.
|
||||
type EventStore interface {
|
||||
// Insert writes a batch of occurrences for one (org, project). Fail-soft is the
|
||||
// caller's contract: the durable issue upsert must not depend on this write.
|
||||
Insert(ctx context.Context, orgID, projectID valuer.UUID, events []*Event) error
|
||||
|
||||
// Discover runs a bounded, allowlist-checked aggregation scoped to (org, project).
|
||||
Discover(ctx context.Context, orgID, projectID valuer.UUID, req *DiscoverRequest, w Window) (*DiscoverResult, error)
|
||||
|
||||
|
||||
@@ -62,26 +62,18 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The three public roots this package answers on, each spelled ONCE.
|
||||
//
|
||||
// Two of them are FACES and one is INGEST, and that is the whole reason there
|
||||
// are three. A face is read by a signed-in person and answers about what is
|
||||
// already stored; ingest is called by a keyed beacon that holds no session
|
||||
// and carries something in. Different nouns, different callers, different
|
||||
// credentials — so the same wire does not get to answer at both.
|
||||
// The two public roots this package answers on, each spelled ONCE. Both are
|
||||
// FACES, read by a signed-in person about what is already stored. Nothing here
|
||||
// takes data in: /v1/event is the one ingest, and it is not this package's.
|
||||
//
|
||||
// - o11yRoot is the observability face: metrics, traces, logs, dashboards,
|
||||
// alerts, identity, access, infra, LLM observability — everything the
|
||||
// console calls.
|
||||
// - sentinelRoot is the error-tracking face: projects, issues, occurrences,
|
||||
// discover and the event-rate stats.
|
||||
// - eventRoot is the ingest endpoint. It is the address a minted DSN spells
|
||||
// (implsentry's mintDSN), so the address a client is TOLD about and the one
|
||||
// this service SERVES are one string.
|
||||
const (
|
||||
o11yRoot = "/v1/o11y"
|
||||
sentinelRoot = "/v1/o11y/sentinel"
|
||||
eventRoot = "/v1/event"
|
||||
)
|
||||
|
||||
// relay hands one typed op's call to the runtime handler at the op's OWN address
|
||||
|
||||
+9
-14
@@ -59,11 +59,11 @@ func assertRoutes(t *testing.T, want map[string]bool, prefix string) {
|
||||
}
|
||||
}
|
||||
|
||||
// THE WHOLE SURFACE, counted. 329 is the number of method+path pairs the o11y
|
||||
// THE WHOLE SURFACE, counted. 324 is the number of method+path pairs the o11y
|
||||
// runtime registers on its gorilla/mux tree (pkg/query-service/app/routes_*.go
|
||||
// and pkg/apiserver/o11yapiserver/*.go). Mount registers the same 329 — 319 as
|
||||
// and pkg/apiserver/o11yapiserver/*.go). Mount registers the same 324 — 318 as
|
||||
// typed ops that carry a named In, a named Out and their prose into the
|
||||
// document, 7 as named escape hatches, 3 as native service probes.
|
||||
// document, 3 as named escape hatches, 3 as native service probes.
|
||||
//
|
||||
// If this number moves, one of two things happened and both need a human: a
|
||||
// route was added to the runtime and not named here (it would 404 in the
|
||||
@@ -74,8 +74,8 @@ func TestEveryRouteIsNamedAndCounted(t *testing.T) {
|
||||
all := registered(t, app)
|
||||
|
||||
const (
|
||||
wantTyped = 319 // typed ops: in the OpenAPI document and the MCP tool list
|
||||
wantHatches = 7 // mount.go mountHatches, each with its reason
|
||||
wantTyped = 318 // typed ops: in the OpenAPI document and the MCP tool list
|
||||
wantHatches = 3 // mount.go mountHatches, each with its reason
|
||||
wantProbes = 3 // health.go livez/healthz/readyz, native with fall-through
|
||||
)
|
||||
if len(all) != wantTyped+wantHatches+wantProbes {
|
||||
@@ -190,7 +190,7 @@ func setRuntime(t *testing.T, fn http.HandlerFunc) {
|
||||
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
||||
}
|
||||
|
||||
// THE SEVEN. Each hatch is registered at its exact method and path, and each
|
||||
// THE THREE. Each hatch is registered at its exact method and path, and each
|
||||
// delegates with the path UNTOUCHED — a rewrite at this seam can only ever move
|
||||
// a request off its own route, and a mangler is invisible to the compiler, so
|
||||
// this is the only thing that catches one coming back.
|
||||
@@ -199,13 +199,8 @@ func TestHatchesDelegateVerbatim(t *testing.T) {
|
||||
{http.MethodGet, "/v1/o11y/logs/livetail"},
|
||||
{http.MethodGet, "/v1/o11y/query_progress"},
|
||||
{http.MethodPost, "/v1/o11y/export_raw_data"},
|
||||
|
||||
{http.MethodPost, "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
|
||||
{http.MethodPost, "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
|
||||
{http.MethodPost, "/v1/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"},
|
||||
{http.MethodPost, "/v1/o11y/api/6ba7b810-9dad-11d1-80b4-00c04fd430c8/store/"},
|
||||
}
|
||||
if len(hatches) != 7 {
|
||||
if len(hatches) != 3 {
|
||||
t.Fatalf("the census itself is wrong: %d", len(hatches))
|
||||
}
|
||||
|
||||
@@ -274,8 +269,8 @@ func TestEveryOperationDeclaresItsOwnId(t *testing.T) {
|
||||
t.Errorf("%d of %d ops declare no id, so composition would rename them: %v",
|
||||
len(undeclared), len(app.Registry()), undeclared)
|
||||
}
|
||||
if len(app.Registry()) != 319 {
|
||||
t.Errorf("registry holds %d ops, want the 319 of TestEveryRouteIsNamedAndCounted", len(app.Registry()))
|
||||
if len(app.Registry()) != 318 {
|
||||
t.Errorf("registry holds %d ops, want the 318 of TestEveryRouteIsNamedAndCounted", len(app.Registry()))
|
||||
}
|
||||
|
||||
// The property itself, on the real mount: nothing this service publishes
|
||||
|
||||
+12
-35
@@ -26,24 +26,15 @@ package o11y
|
||||
// pkg/query-service/app/routes_errors.go):
|
||||
//
|
||||
// - Sentry projects: ViewAccess on the reads (list, get), EditAccess on the
|
||||
// writes (create, delete, rotate-key).
|
||||
// writes (create, delete).
|
||||
// - Sentry issues: ViewAccess on list/get/events, EditAccess on update.
|
||||
// - Sentry event detail: ViewAccess.
|
||||
// - Error-tracking issues: ViewAccess on list/get, EditAccess on update.
|
||||
// - Legacy exceptions (listErrors, countErrors, errorFromErrorID,
|
||||
// errorFromGroupID, nextPrevErrorIDs): ViewAccess, every one.
|
||||
//
|
||||
// The FOUR ingest routes — POST /v1/event/{project}/envelope|store and POST
|
||||
// /v1/o11y/api/{project}/envelope|store — are the deliberate escape hatches,
|
||||
// NOT typed here and not on this face at all: ingest is a keyed beacon and a
|
||||
// face is read by a person, so ingest has its own root (relay.go's eventRoot,
|
||||
// the address a minted DSN spells). They are OpenAccess (a Sentry SDK presents a DSN key,
|
||||
// not a Hanzo session) and carry an OPAQUE Sentry-envelope body — a foreign wire
|
||||
// protocol we receive verbatim, like /.well-known. A typed relay JSON-encodes
|
||||
// its input, which would corrupt that raw envelope, so they stay on the runtime
|
||||
// mux byte-identical (pkg/apiserver/o11yapiserver/{sentry,errortracking}.go pin
|
||||
// them) and out of the document, which cannot describe an opaque proxy honestly.
|
||||
// This is the same call telemetry.go's livetail note makes for a stream.
|
||||
// There is no ingest here: errors enter through /v1/event only, and a consumer of
|
||||
// the event.error subject files them into issues.
|
||||
//
|
||||
// Collection routes register before their parameterised siblings so an id can
|
||||
// never shadow a collection — specific-beats-wildcard is what the router does
|
||||
@@ -66,7 +57,6 @@ func mountSentryErrors(app *zip.App) {
|
||||
gsentry.Post("/projects", sentryCreateProject)
|
||||
gsentry.Get("/projects/:id", sentryGetProject)
|
||||
gsentry.Delete("/projects/:id", sentryDeleteProject)
|
||||
gsentry.Post("/projects/:id/keys/rotate", sentryRotateProjectKey)
|
||||
gsentry.Get("/issues", sentryListIssues)
|
||||
gsentry.Get("/issues/:id", sentryGetIssue)
|
||||
gsentry.Put("/issues/:id", sentryUpdateIssue)
|
||||
@@ -86,8 +76,8 @@ func mountSentryErrors(app *zip.App) {
|
||||
|
||||
// ── sentry projects ─────────────────────────────────────────────────────────
|
||||
|
||||
// sentryListProjects lists the caller's org's Sentry projects, each with its
|
||||
// freshly-derived DSN.
|
||||
// sentryListProjects lists the caller's org's Sentry projects — one per product
|
||||
// that has reported an error, plus any created by hand.
|
||||
//
|
||||
// Callers need the viewer role; the runtime's own gate enforces it.
|
||||
func sentryListProjects(ctx context.Context, _ *struct{}) (*O11ySentryProjectsOut, error) {
|
||||
@@ -96,8 +86,9 @@ func sentryListProjects(ctx context.Context, _ *struct{}) (*O11ySentryProjectsOu
|
||||
}
|
||||
|
||||
// sentryCreateProject creates a Sentry project under the caller's org and
|
||||
// returns it, DSN included. Only the name, and optionally a slug and platform,
|
||||
// are the caller's to set; the org, id and key are server-assigned.
|
||||
// returns it. Only the name, and optionally a slug and platform, are the caller's
|
||||
// to set; the org and id are server-assigned. The slug is the product name the
|
||||
// event plane stores, so a project reads that product's errors.
|
||||
//
|
||||
// Callers need the editor role; the runtime's own gate enforces it.
|
||||
func sentryCreateProject(ctx context.Context, in *O11ySentryPostableProject) (*O11ySentryProjectOut, error) {
|
||||
@@ -105,7 +96,7 @@ func sentryCreateProject(ctx context.Context, in *O11ySentryPostableProject) (*O
|
||||
return out, relay(ctx, nil, in, out)
|
||||
}
|
||||
|
||||
// sentryGetProject returns one Sentry project of the caller's org, DSN included.
|
||||
// sentryGetProject returns one Sentry project of the caller's org.
|
||||
//
|
||||
// Callers need the viewer role; the runtime's own gate enforces it.
|
||||
func sentryGetProject(ctx context.Context, in *O11ySentryProjectRef) (*O11ySentryProjectOut, error) {
|
||||
@@ -113,9 +104,8 @@ func sentryGetProject(ctx context.Context, in *O11ySentryProjectRef) (*O11ySentr
|
||||
return out, relay(ctx, nil, nil, out)
|
||||
}
|
||||
|
||||
// sentryDeleteProject deletes one Sentry project of the caller's org. Its DSN
|
||||
// stops resolving immediately, so ingest for that id fails closed exactly as an
|
||||
// unknown project does; retained events are not touched. Answers 204.
|
||||
// sentryDeleteProject deletes one Sentry project of the caller's org; retained
|
||||
// events are not touched. Answers 204.
|
||||
//
|
||||
// Callers need the editor role; the runtime's own gate enforces it.
|
||||
func sentryDeleteProject(ctx context.Context, in *O11ySentryProjectRef) (*struct{}, error) {
|
||||
@@ -127,16 +117,6 @@ func sentryDeleteProject(ctx context.Context, in *O11ySentryProjectRef) (*struct
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// sentryRotateProjectKey rotates a project's DSN key — bumping its rotation
|
||||
// watermark so keys below it stop verifying — and returns the project with its
|
||||
// new DSN.
|
||||
//
|
||||
// Callers need the editor role; the runtime's own gate enforces it.
|
||||
func sentryRotateProjectKey(ctx context.Context, in *O11ySentryProjectRef) (*O11ySentryProjectOut, error) {
|
||||
out := new(O11ySentryProjectOut)
|
||||
return out, relay(ctx, nil, nil, out)
|
||||
}
|
||||
|
||||
// ── sentry issues ───────────────────────────────────────────────────────────
|
||||
|
||||
// sentryListIssues lists the caller's org's grouped error issues, optionally
|
||||
@@ -534,8 +514,7 @@ type O11ySentryProjectOut struct {
|
||||
Data O11ySentryProject `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// O11ySentryProject is a Sentry project — a DSN-bearing unit under an org. The
|
||||
// DSN is derived on demand, never stored.
|
||||
// O11ySentryProject is a Sentry project — one product under an org.
|
||||
type O11ySentryProject struct {
|
||||
// ID is the project id.
|
||||
ID string `json:"id"`
|
||||
@@ -551,8 +530,6 @@ type O11ySentryProject struct {
|
||||
Platform string `json:"platform,omitempty"`
|
||||
// Status is the project's lifecycle state: active or disabled.
|
||||
Status string `json:"status"`
|
||||
// DSN is the project's freshly-derived ingest DSN.
|
||||
DSN string `json:"dsn"`
|
||||
}
|
||||
|
||||
// O11yErrorIssuesOut is the {status, data} envelope around a page of grouped
|
||||
|
||||
@@ -376,34 +376,6 @@ func TestTheFaceIsGatedWholesale(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ingest reaches the runtime through its OWN named route, path
|
||||
// untouched, while the typed reads on the face dispatch as ops.
|
||||
func TestTheIngestDoorIsANamedHatch(t *testing.T) {
|
||||
app := mounted(t)
|
||||
|
||||
var saw string
|
||||
o11y.SetRuntime(o11y.Whole(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
saw = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"id":"e1"}`)
|
||||
})))
|
||||
t.Cleanup(func() { o11y.SetRuntime(nil) })
|
||||
|
||||
const target = "/v1/event/6ba7b810-9dad-11d1-80b4-00c04fd430c8/envelope/"
|
||||
if status, body := call(t, app, member(http.MethodPost, target, strings.NewReader("{}"))); status != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
if saw != target {
|
||||
t.Fatalf("runtime received %q, want %q verbatim", saw, target)
|
||||
}
|
||||
|
||||
// ...and the typed reads next to it still dispatch as ops.
|
||||
runtime(t, map[string]any{"items": []*sentrytypes.Event{}})
|
||||
if status, body := call(t, app, member(http.MethodGet, "/v1/o11y/sentinel/logs?project=p1", nil)); status != http.StatusOK {
|
||||
t.Fatalf("the typed op did not answer: status=%d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// THE POINT OF THE PORT: the five reads are in the document now, each with its
|
||||
// operation id, its prose and its inputs. A route behind the wildcard had none
|
||||
// of that — no SDK method, no command, no agent tool, no reference page — which
|
||||
|
||||
Vendored
-5
@@ -176,10 +176,6 @@ PATCH /v1/o11y/gateway/ingestion_keys/limits/:limitId
|
||||
PATCH /v1/o11y/rules/:id
|
||||
PATCH /v1/o11y/span_mapper_groups/:groupId
|
||||
PATCH /v1/o11y/span_mapper_groups/:groupId/span_mappers/:mapperId
|
||||
POST /v1/event/:project/envelope
|
||||
POST /v1/event/:project/store
|
||||
POST /v1/o11y/api/:project_id/envelope
|
||||
POST /v1/o11y/api/:project_id/store
|
||||
POST /v1/o11y/authz/check
|
||||
POST /v1/o11y/auto_complete/attribute_values
|
||||
POST /v1/o11y/channels
|
||||
@@ -263,7 +259,6 @@ POST /v1/o11y/rules/:id/history/top_contributors
|
||||
POST /v1/o11y/rules/test
|
||||
POST /v1/o11y/sentinel/discover
|
||||
POST /v1/o11y/sentinel/projects
|
||||
POST /v1/o11y/sentinel/projects/:id/keys/rotate
|
||||
POST /v1/o11y/service/entry_point_operations
|
||||
POST /v1/o11y/service/top_level_operations
|
||||
POST /v1/o11y/service/top_operations
|
||||
|
||||
+4
-23
@@ -102,7 +102,7 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("DELETE /v1/o11y/sentinel/projects/:id", zip.Doc{
|
||||
Description: "Deletes one Sentry project of the caller's org. Its DSN\nstops resolving immediately, so ingest for that id fails closed exactly as an\nunknown project does; retained events are not touched. Answers 204.\n\nCallers need the editor role; the runtime's own gate enforces it.",
|
||||
Description: "Deletes one Sentry project of the caller's org; retained\nevents are not touched. Answers 204.\n\nCallers need the editor role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
"O11ySentryProjectRef.id": "ID is the project id.",
|
||||
},
|
||||
@@ -2437,10 +2437,9 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/sentinel/projects", zip.Doc{
|
||||
Description: "Lists the caller's org's Sentry projects, each with its\nfreshly-derived DSN.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Description: "Lists the caller's org's Sentry projects — one per product\nthat has reported an error, plus any created by hand.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
"O11ySentryProject.createdAt": "CreatedAt is when the project was created.",
|
||||
"O11ySentryProject.dsn": "DSN is the project's freshly-derived ingest DSN.",
|
||||
"O11ySentryProject.id": "ID is the project id.",
|
||||
"O11ySentryProject.name": "Name is the project's display name.",
|
||||
"O11ySentryProject.platform": "Platform is the reporting runtime, e.g. go, python, javascript.",
|
||||
@@ -2454,10 +2453,9 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/o11y/sentinel/projects/:id", zip.Doc{
|
||||
Description: "Returns one Sentry project of the caller's org, DSN included.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Description: "Returns one Sentry project of the caller's org.\n\nCallers need the viewer role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
"O11ySentryProject.createdAt": "CreatedAt is when the project was created.",
|
||||
"O11ySentryProject.dsn": "DSN is the project's freshly-derived ingest DSN.",
|
||||
"O11ySentryProject.id": "ID is the project id.",
|
||||
"O11ySentryProject.name": "Name is the project's display name.",
|
||||
"O11ySentryProject.platform": "Platform is the reporting runtime, e.g. go, python, javascript.",
|
||||
@@ -4487,13 +4485,12 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/sentinel/projects", zip.Doc{
|
||||
Description: "Creates a Sentry project under the caller's org and\nreturns it, DSN included. Only the name, and optionally a slug and platform,\nare the caller's to set; the org, id and key are server-assigned.\n\nCallers need the editor role; the runtime's own gate enforces it.",
|
||||
Description: "Creates a Sentry project under the caller's org and\nreturns it. Only the name, and optionally a slug and platform, are the caller's\nto set; the org and id are server-assigned. The slug is the product name the\nevent plane stores, so a project reads that product's errors.\n\nCallers need the editor role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
"O11ySentryPostableProject.name": "Name is the project's display name. Required.",
|
||||
"O11ySentryPostableProject.platform": "Platform is the reporting runtime, e.g. go, python, javascript.",
|
||||
"O11ySentryPostableProject.slug": "Slug is the project's short name. Server-assigned from Name when empty.",
|
||||
"O11ySentryProject.createdAt": "CreatedAt is when the project was created.",
|
||||
"O11ySentryProject.dsn": "DSN is the project's freshly-derived ingest DSN.",
|
||||
"O11ySentryProject.id": "ID is the project id.",
|
||||
"O11ySentryProject.name": "Name is the project's display name.",
|
||||
"O11ySentryProject.platform": "Platform is the reporting runtime, e.g. go, python, javascript.",
|
||||
@@ -4504,22 +4501,6 @@ func init() {
|
||||
"O11ySentryProjectOut.status": "Status is \"success\".",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/o11y/sentinel/projects/:id/keys/rotate", zip.Doc{
|
||||
Description: "Rotates a project's DSN key — bumping its rotation\nwatermark so keys below it stop verifying — and returns the project with its\nnew DSN.\n\nCallers need the editor role; the runtime's own gate enforces it.",
|
||||
Fields: map[string]string{
|
||||
"O11ySentryProject.createdAt": "CreatedAt is when the project was created.",
|
||||
"O11ySentryProject.dsn": "DSN is the project's freshly-derived ingest DSN.",
|
||||
"O11ySentryProject.id": "ID is the project id.",
|
||||
"O11ySentryProject.name": "Name is the project's display name.",
|
||||
"O11ySentryProject.platform": "Platform is the reporting runtime, e.g. go, python, javascript.",
|
||||
"O11ySentryProject.slug": "Slug is the project's short name.",
|
||||
"O11ySentryProject.status": "Status is the project's lifecycle state: active or disabled.",
|
||||
"O11ySentryProject.updatedAt": "UpdatedAt is when the project last changed.",
|
||||
"O11ySentryProjectOut.data": "Data is the project.",
|
||||
"O11ySentryProjectOut.status": "Status is \"success\".",
|
||||
"O11ySentryProjectRef.id": "ID is the project id.",
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/o11y/channels/:id", zip.Doc{
|
||||
Description: "Replaces a notification channel's receiver, by id. Admin gate.",
|
||||
Fields: map[string]string{
|
||||
|
||||
Reference in New Issue
Block a user