feat(errortracking): Sentry-class error tracking folded into the o11y plane

New pkg/modules/errortracking + pkg/types/errortrackingtypes: grouped-error
Issues over the ONE o11y plane. Occurrences stay in the telemetry store; only
non-derivable lifecycle (status/assignee/first-last-seen/count/regression) is
the net-new o11y_issues table.

- Fingerprint grouping at ingest (exception.type + normalized crash frame,
  message fallback; honors client fingerprint + {{ default }}).
- Sentry-envelope + legacy /store/ ingest shim -> normalize -> upsert issue.
  Public routes (OpenAccess) authenticated by the DSN public key
  (HMAC-SHA256 over the org, KMS platform secret, constant-time, fail-closed);
  org resolved from the DSN project via iamidentn's exact UUIDv5 so ingest and
  the IAM read align. No gateway change: DSN path /v1/o11y/<org> makes the SDK
  POST /v1/o11y/api/<org>/envelope/, which the existing mount forwards.
- Read routes (ListIssues/GetIssue/UpdateIssue) behind Hanzo IAM authz,
  org-scoped in SQL EXACTLY like llmobs (Where org_id = ?, fail-closed).
- Occurrence sink seam (default no-op; ClickHouse o11y_logs writer gated).
- 100_add_error_tracking migration: o11y_issues + unique (org_id,fingerprint).
- Tests (54): two-org isolation, upsert grouping, regression reopen, DSN auth
  (accept/reject/cross-org/disabled), fingerprint stability, envelope/store
  parsing (gzip/deflate), normalize (ts/message/tags/exception polymorphism).
This commit is contained in:
Hanzo Dev
2026-07-10 14:54:25 -07:00
parent 93f62dc326
commit 861971d4ca
26 changed files with 2512 additions and 0 deletions
@@ -0,0 +1,83 @@
package o11yapiserver
import (
"net/http"
"github.com/gorilla/mux"
"github.com/hanzoai/o11y/pkg/http/handler"
"github.com/hanzoai/o11y/pkg/types"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
)
// addErrorTrackingRoutes serves error/crash tracking (Sentry-class Issues) under
// /v1/o11y. Two families:
//
// - INGEST (public, DSN-authenticated in-handler): the Sentry wire endpoints
// POST /api/{project}/envelope/ and POST /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
// /v1/o11y/api/<org>/envelope/, which the existing /v1/o11y mount forwards here
// — no gateway change. The literal /api/ segment is the fixed Sentry wire
// contract, not a Hanzo-designed route.
//
// - READ (Hanzo IAM authz, org-scoped): the Issues list/detail/update the console
// Errors tab consumes at /v1/o11y/errortracking/issues[/{id}].
func (provider *provider) addErrorTrackingRoutes(router *mux.Router) error {
h := provider.errorTrackingHandler
routes := []struct {
method string
path string
fn http.HandlerFunc
def handler.OpenAPIDef
}{
{http.MethodPost, "/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, "/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, "/api/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.",
RequestQuery: new(errortrackingtypes.IssuesQuery),
Response: new(errortrackingtypes.GettableIssues),
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest}, SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodGet, "/api/errortracking/issues/{id}", provider.authzMiddleware.ViewAccess(h.GetIssue), handler.OpenAPIDef{
ID: "GetIssue", Tags: []string{"errortracking"}, Summary: "Get an error issue",
Description: "Returns a single issue with its latest occurrence sample.",
Response: new(errortrackingtypes.GettableIssue),
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound}, SecuritySchemes: newSecuritySchemes(types.RoleViewer),
}},
{http.MethodPost, "/api/errortracking/issues/{id}", provider.authzMiddleware.EditAccess(h.UpdateIssue), handler.OpenAPIDef{
ID: "UpdateIssue", Tags: []string{"errortracking"}, Summary: "Update an issue's lifecycle",
Description: "Resolve, ignore, reopen or assign an issue.",
Request: new(errortrackingtypes.UpdateIssue), RequestContentType: "application/json",
Response: new(errortrackingtypes.Issue),
ResponseContentType: "application/json", SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, SecuritySchemes: newSecuritySchemes(types.RoleEditor),
}},
}
for _, rt := range routes {
if err := router.Handle(rt.path, handler.New(rt.fn, rt.def)).Methods(rt.method).GetError(); err != nil {
return err
}
}
return nil
}
+10
View File
@@ -18,6 +18,7 @@ import (
"github.com/hanzoai/o11y/pkg/modules/dashboard"
"github.com/hanzoai/o11y/pkg/modules/fields"
"github.com/hanzoai/o11y/pkg/modules/inframonitoring"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"github.com/hanzoai/o11y/pkg/modules/llmobs"
"github.com/hanzoai/o11y/pkg/modules/llmpricingrule"
"github.com/hanzoai/o11y/pkg/modules/metricreductionrule"
@@ -75,6 +76,7 @@ type provider struct {
rulerHandler ruler.Handler
llmPricingRuleHandler llmpricingrule.Handler
llmObsHandler llmobs.Handler
errorTrackingHandler errortracking.Handler
statsHandler statsreporter.Handler
}
@@ -111,6 +113,7 @@ func NewFactory(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
llmObsHandler llmobs.Handler,
errorTrackingHandler errortracking.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("o11y"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -149,6 +152,7 @@ func NewFactory(
rulerHandler,
statsHandler,
llmObsHandler,
errorTrackingHandler,
)
})
}
@@ -189,6 +193,7 @@ func newProvider(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
llmObsHandler llmobs.Handler,
errorTrackingHandler errortracking.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/apiserver/o11yapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -227,6 +232,7 @@ func newProvider(
rulerHandler: rulerHandler,
llmPricingRuleHandler: llmPricingRuleHandler,
llmObsHandler: llmObsHandler,
errorTrackingHandler: errorTrackingHandler,
statsHandler: statsHandler,
}
@@ -352,6 +358,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addErrorTrackingRoutes(router); err != nil {
return err
}
if err := provider.addTraceDetailRoutes(router); err != nil {
return err
}
@@ -0,0 +1,40 @@
package errortracking
import (
"context"
"net/http"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"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.
type Module interface {
// Ingest groups one normalized occurrence into the caller's org (resolved from
// the DSN by the handler), upserting the issue and — fail-soft — persisting the
// occurrence to the reused occurrence store.
Ingest(ctx context.Context, orgID valuer.UUID, occ *errortrackingtypes.Occurrence) 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.
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)
}
@@ -0,0 +1,100 @@
package implerrortracking
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"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 key table for the MVP:
//
// publicKey(org) = HMAC-SHA256(platformIngestSecret, "org:"+org)
//
// The platform secret comes from KMS (never plaintext, never committed). Verifying
// is a constant-time compare; there is nothing to look up, and rotating the KMS
// secret revokes every DSN at once. Per-org revocable keys are the fast-follow.
//
// The org travels in the DSN project segment; the key proves the caller may write
// to THAT org. Resolution reuses iamidentn's exact UUIDv5 mapping so the row
// written here is read back by exactly that tenant.
// 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
}
// publicKeyFor derives the deterministic ingest public key for a project.
func publicKeyFor(secret []byte, project string) string {
m := hmac.New(sha256.New, secret)
m.Write([]byte("org:" + strings.TrimSpace(project)))
return hex.EncodeToString(m.Sum(nil))
}
// verifyKey constant-time compares a presented key against the expected one for a
// project. An empty secret or key never verifies (fail closed).
func verifyKey(secret []byte, project, presented string) bool {
if len(secret) == 0 || presented == "" {
return false
}
want := publicKeyFor(secret, project)
return hmac.Equal([]byte(want), []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=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 DSN an operator hands to an app to report into a given org.
// host is the ingest origin (e.g. "o11y.hanzo.ai"); the resulting DSN is
//
// https://<publicKey>@<host>/v1/o11y/<org>
//
// from which the Sentry SDK derives its endpoint as
// https://<host>/v1/o11y/api/<org>/envelope/ — which the existing /v1/o11y mount
// forwards to this module's /api/{project}/envelope/ route. No gateway change.
func MintDSN(secret []byte, host, org string) string {
return "https://" + publicKeyFor(secret, org) + "@" + host + "/v1/o11y/" + org
}
@@ -0,0 +1,82 @@
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), "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), "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))
}
func TestVerifyKey_FailsClosed(t *testing.T) {
assert.False(t, verifyKey(nil, "acme", "anything"), "no secret => fail closed")
assert.False(t, verifyKey(testSecret, "acme", ""), "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", "/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", "/api/acme/envelope/?sentry_key=qkey456", nil)
assert.Equal(t, "qkey456", sentryKeyFromRequest(r))
}
func TestSentryKeyFromRequest_HeaderWins(t *testing.T) {
r := httptest.NewRequest("POST", "/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)
}
@@ -0,0 +1,129 @@
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
// 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) {
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
if ih.Length != nil && *ih.Length >= 0 {
end := pos + *ih.Length
if end > len(body) {
end = len(body)
}
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
}
@@ -0,0 +1,126 @@
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)
}
@@ -0,0 +1,184 @@
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))
}
@@ -0,0 +1,82 @@
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)
}
@@ -0,0 +1,209 @@
package implerrortracking
import (
"context"
"io"
"net/http"
"time"
"github.com/gorilla/mux"
"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"
"github.com/hanzoai/o11y/pkg/types/authtypes"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
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.
maxCompressedBody = 6 << 20
)
// 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
}
// NewHandler builds the HTTP surface. ingestSecret should be the KMS-synced
// platform error-ingest secret; when empty the ingest routes fail closed (503).
func NewHandler(module errortracking.Module, ingestSecret []byte) errortracking.Handler {
return &handler{module: module, ingestSecret: ingestSecret}
}
// --- 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 for both wire formats: enabled-check → resolve org
// from the DSN project → verify the DSN key (constant-time) → bounded read+decode →
// parse → normalize+group each event under the resolved org. Every failure mode
// 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 := mux.Vars(r)["project_id"]
orgID, ok := orgUUIDFromProject(project)
if !ok {
http.Error(rw, "missing project", http.StatusBadRequest)
return
}
if !verifyKey(h.ingestSecret, project, sentryKeyFromRequest(r)) {
// Sentry SDKs treat 401 as "bad DSN" and drop the event (no retry storm).
http.Error(rw, "invalid ingest key", http.StatusUnauthorized)
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
}
lastID := ""
for _, ev := range events {
occ := normalizeEvent(ev)
if occ.Fingerprint == "" {
continue
}
if err := h.module.Ingest(ctx, orgID, occ); err != nil {
// A store failure is ours, not the client's — 500 so the SDK retries.
http.Error(rw, "ingest failed", http.StatusInternalServerError)
return
}
lastID = occ.EventID
}
// Sentry SDKs only require 200; echo the last event id for parity.
render.Success(rw, http.StatusOK, map[string]string{"id": lastID})
}
// --- reads (Hanzo IAM authz, org-scoped) ---
func (h *handler) ListIssues(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), viewTimeout)
defer cancel()
orgID, err := orgFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
var q errortrackingtypes.IssuesQuery
if err := binding.Query.BindQuery(r.URL.Query(), &q); err != nil {
render.Error(rw, err)
return
}
items, total, err := h.module.ListIssues(ctx, orgID, &q)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, &errortrackingtypes.GettableIssues{
Items: items, Total: total, Offset: clampOffset(q.Offset), Limit: clampLimit(q.Limit),
})
}
func (h *handler) GetIssue(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), viewTimeout)
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
}
issue, err := h.module.GetIssue(ctx, orgID, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, issue)
}
func (h *handler) UpdateIssue(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
}
req := new(errortrackingtypes.UpdateIssue)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
issue, err := h.module.UpdateIssue(ctx, orgID, id, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, issue)
}
// --- shared helpers ---
func orgFromContext(ctx context.Context) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return valuer.UUID{}, err
}
return valuer.MustNewUUID(claims.OrgID), nil
}
func idFromPath(r *http.Request) (valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
return valuer.UUID{}, errors.Wrapf(err, errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "id is not a valid uuid")
}
return id, nil
}
@@ -0,0 +1,120 @@
package implerrortracking
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"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), 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, "/api/"+project+"/envelope/", bytes.NewReader(body))
if key != "" {
req.Header.Set("X-Sentry-Auth", "Sentry sentry_version=7, sentry_key="+key)
}
return mux.SetURLVars(req, map[string]string{"project_id": project})
}
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, "/api/acme/store/", bytes.NewReader(body))
req.Header.Set("X-Sentry-Auth", "Sentry sentry_key="+key)
req = mux.SetURLVars(req, map[string]string{"project_id": "acme"})
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)
}
@@ -0,0 +1,134 @@
package implerrortracking
import (
"context"
"encoding/json"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"github.com/hanzoai/o11y/pkg/types"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
type module struct {
store errortrackingtypes.Store
sink OccurrenceSink
}
// NewModule wires the issue store and the (default no-op) occurrence sink into the
// error-tracking module.
func NewModule(store errortrackingtypes.Store, sink OccurrenceSink) errortracking.Module {
if sink == nil {
sink = NoopSink{}
}
return &module{store: store, sink: sink}
}
// Ingest groups one occurrence into its issue for the given org and persists the
// occurrence to the reused telemetry store. The issue upsert is authoritative; the
// sink is fail-soft (it owns its own error handling) so telemetry-store trouble can
// never drop error capture.
func (m *module) Ingest(ctx context.Context, orgID valuer.UUID, occ *errortrackingtypes.Occurrence) error {
if occ == nil || occ.Fingerprint == "" {
return errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "occurrence has no fingerprint")
}
if orgID.IsZero() {
return errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "occurrence has no org")
}
if err := m.store.UpsertIssue(ctx, issueFromOccurrence(orgID, occ)); err != nil {
return err
}
_ = m.sink.Write(ctx, orgID, occ)
return nil
}
func (m *module) ListIssues(ctx context.Context, orgID valuer.UUID, q *errortrackingtypes.IssuesQuery) ([]*errortrackingtypes.Issue, int, error) {
return m.store.ListIssues(ctx, orgID, q)
}
func (m *module) GetIssue(ctx context.Context, orgID, id valuer.UUID) (*errortrackingtypes.GettableIssue, error) {
issue, err := m.store.GetIssue(ctx, orgID, id)
if err != nil {
return nil, err
}
g := &errortrackingtypes.GettableIssue{Issue: issue}
if issue.SampleEvent != "" {
var occ errortrackingtypes.Occurrence
if err := json.Unmarshal([]byte(issue.SampleEvent), &occ); err == nil {
g.LatestEvent = &occ
}
}
return g, nil
}
// UpdateIssue applies a lifecycle transition (resolve/ignore/reopen/assign),
// scoped to the caller's org, deriving resolved-at and clearing the regression
// flag consistently with the new status.
func (m *module) UpdateIssue(ctx context.Context, orgID, id valuer.UUID, in *errortrackingtypes.UpdateIssue) (*errortrackingtypes.Issue, error) {
if err := in.Validate(); err != nil {
return nil, err
}
issue, err := m.store.GetIssue(ctx, orgID, id)
if err != nil {
return nil, err
}
now := nowUTC()
if in.Status != nil {
status := errortrackingtypes.IssueStatus(*in.Status)
issue.Status = status
switch status {
case errortrackingtypes.StatusResolved:
issue.ResolvedAt = &now
issue.Regressed = false
case errortrackingtypes.StatusUnresolved:
issue.ResolvedAt = nil
issue.Regressed = false
case errortrackingtypes.StatusIgnored:
// muted: leave resolved_at untouched
}
}
if in.Assignee != nil {
issue.Assignee = *in.Assignee
}
issue.UpdatedAt = now
if err := m.store.UpdateIssue(ctx, issue); err != nil {
return nil, err
}
return issue, nil
}
// issueFromOccurrence builds the fresh-insert issue row. On an upsert conflict the
// store keeps first-seen/status/id and only advances count/last-seen/sample, so
// the fields here that describe "first sight" are used solely on first insert.
func issueFromOccurrence(orgID valuer.UUID, occ *errortrackingtypes.Occurrence) *errortrackingtypes.Issue {
now := nowUTC()
ts := occ.Timestamp
if ts.IsZero() {
ts = now
}
sample, _ := json.Marshal(occ)
return &errortrackingtypes.Issue{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
OrgID: orgID,
Fingerprint: occ.Fingerprint,
Type: firstNonEmpty(occ.Type, "Error"),
Value: occ.Value,
Culprit: occ.Culprit,
Level: firstNonEmpty(occ.Level, errortrackingtypes.DefaultLevel),
Platform: occ.Platform,
Status: errortrackingtypes.StatusUnresolved,
FirstSeen: ts,
LastSeen: ts,
Count: 1,
Environment: occ.Environment,
Release: occ.Release,
ServiceName: occ.ServiceName,
SampleEvent: string(sample),
}
}
@@ -0,0 +1,279 @@
package implerrortracking
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
)
const (
maxValueLen = 8192
maxCulpritLen = 512
maxFrames = 250
)
// normalizeEvent turns a decoded Sentry event into the canonical Occurrence and
// stamps its fingerprint. It is pure and 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.
func normalizeEvent(e *errortrackingtypes.SentryEvent) *errortrackingtypes.Occurrence {
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)
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]
}
@@ -0,0 +1,106 @@
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)
}
@@ -0,0 +1,35 @@
package implerrortracking
import (
"context"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
// OccurrenceSink persists a normalized occurrence to the REUSED telemetry store
// (o11y_logs as an ERROR-severity record) so OTel-native drill-down and the
// unified plane see the same events. It is deliberately a seam:
//
// - The issue list/detail never depends on it — o11y_issues carries the latest
// sample, so error capture is fully viewable with the no-op sink.
// - The write is best-effort at the call site (fail-soft): a telemetry-store
// hiccup must never drop the authoritative issue upsert.
//
// The default is NoopSink. The ClickHouse logs sink is enabled explicitly once its
// insert is byte-verified against a live datastore (a raw logs_v2 INSERT couples
// to resource-fingerprint/ts-bucket schema that must be confirmed live, not
// reconstructed) — see chsink.go.
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
}
@@ -0,0 +1,188 @@
package implerrortracking
import (
"context"
"time"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
const (
defaultIssueLimit = 50
maxIssueLimit = 100
)
type store struct {
sqlstore sqlstore.SQLStore
}
// NewStore backs the o11y_issues lifecycle table. Every method is org-scoped.
func NewStore(sqlstore sqlstore.SQLStore) errortrackingtypes.Store {
return &store{sqlstore: sqlstore}
}
// UpsertIssue inserts the fingerprint bucket on first sight, otherwise atomically
// bumps the running count, advances last-seen and refreshes the display fields +
// latest sample. First-seen and lifecycle status are preserved on update. A
// separate, idempotent statement reopens a RESOLVED issue as a regression on
// recurrence (an IGNORED issue stays muted). Both run in one transaction.
//
// The SET expressions are dialect-portable: `count = count + 1` and `EXCLUDED.x`
// resolve identically on SQLite and PostgreSQL, and the boolean/en'um writes go
// through bound placeholders — no dialect-specific literals.
func (s *store) UpsertIssue(ctx context.Context, issue *errortrackingtypes.Issue) error {
tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.NewInsert().
Model(issue).
On("CONFLICT (org_id, fingerprint) DO UPDATE").
Set("count = count + 1").
Set("last_seen = EXCLUDED.last_seen").
Set("value = EXCLUDED.value").
Set("level = EXCLUDED.level").
Set("culprit = EXCLUDED.culprit").
Set("platform = EXCLUDED.platform").
Set("environment = EXCLUDED.environment").
Set("release = EXCLUDED.release").
Set("service_name = EXCLUDED.service_name").
Set("sample_event = EXCLUDED.sample_event").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)
if err != nil {
return err
}
if _, err = tx.NewUpdate().
Model((*errortrackingtypes.Issue)(nil)).
Set("status = ?", errortrackingtypes.StatusUnresolved).
Set("regressed = ?", true).
Set("updated_at = ?", issue.LastSeen).
Where("org_id = ?", issue.OrgID).
Where("fingerprint = ?", issue.Fingerprint).
Where("status = ?", errortrackingtypes.StatusResolved).
Exec(ctx); err != nil {
return err
}
return tx.Commit()
}
func (s *store) ListIssues(ctx context.Context, orgID valuer.UUID, q *errortrackingtypes.IssuesQuery) ([]*errortrackingtypes.Issue, int, error) {
issues := make([]*errortrackingtypes.Issue, 0)
// MANDATORY tenant boundary, first predicate. Every issue row belongs to exactly
// one org; org_id is the o11y org UUID that both ingest (from the DSN) and this
// read (from the validated claims) resolve to. There is no code path that lists
// issues without this filter.
query := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&issues).
Where("org_id = ?", orgID)
if q.Status != "" {
query = query.Where("status = ?", q.Status)
}
if q.Level != "" {
query = query.Where("level = ?", q.Level)
}
if q.Environment != "" {
query = query.Where("environment = ?", q.Environment)
}
if q.ServiceName != "" {
query = query.Where("service_name = ?", q.ServiceName)
}
if q.Query != "" {
like := "%" + q.Query + "%"
query = query.Where("(type LIKE ? OR value LIKE ? OR culprit LIKE ?)", like, like, like)
}
count, err := query.
OrderExpr(sortColumn(q.Sort)).
Offset(clampOffset(q.Offset)).
Limit(clampLimit(q.Limit)).
ScanAndCount(ctx)
if err != nil {
return nil, 0, err
}
return issues, count, nil
}
func (s *store) GetIssue(ctx context.Context, orgID, id valuer.UUID) (*errortrackingtypes.Issue, error) {
issue := new(errortrackingtypes.Issue)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(issue).
Where("org_id = ?", orgID).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, errortrackingtypes.ErrCodeErrorTrackingNotFound, "issue %s not found in the org", id)
}
return issue, nil
}
// UpdateIssue writes the mutable lifecycle columns for one issue, scoped by
// (org_id, id). The caller has already loaded the issue for this org, so a
// zero-row result means it vanished (racy delete) — reported as not-found.
func (s *store) UpdateIssue(ctx context.Context, issue *errortrackingtypes.Issue) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(issue).
Column("status", "assignee", "resolved_at", "regressed", "updated_at").
Where("org_id = ?", issue.OrgID).
Where("id = ?", issue.ID).
Exec(ctx)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return errors.Newf(errors.TypeNotFound, errortrackingtypes.ErrCodeErrorTrackingNotFound, "issue %s not found in the org", issue.ID)
}
return nil
}
// sortColumn maps the API sort key to a safe ORDER BY expression (never user text).
func sortColumn(sort string) string {
switch sort {
case "firstSeen":
return "first_seen DESC"
case "count":
return "count DESC"
default:
return "last_seen DESC"
}
}
func clampLimit(limit int) int {
if limit <= 0 {
return defaultIssueLimit
}
if limit > maxIssueLimit {
return maxIssueLimit
}
return limit
}
func clampOffset(offset int) int {
if offset < 0 {
return 0
}
return offset
}
// nowUTC is the single time source for lifecycle writes (overridable in tests).
var nowUTC = func() time.Time { return time.Now().UTC() }
@@ -0,0 +1,193 @@
package implerrortracking
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/hanzoai/o11y/pkg/factory/factorytest"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/sqlstore/sqlitesqlstore"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestStore builds a real in-memory-ish sqlite store with the o11y_issues table
// and its (org_id, fingerprint) unique index — the same shape the migration ships,
// so ON CONFLICT upserts behave exactly as in production.
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: dbPath,
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*errortrackingtypes.Issue)(nil)).
IfNotExists().
Exec(context.Background())
require.NoError(t, err)
_, 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)
return store
}
func newTestModule(t *testing.T) (errortracking.Module, valuer.UUID, valuer.UUID) {
t.Helper()
m := NewModule(NewStore(newTestStore(t)), NewNoopSink())
return m, valuer.GenerateUUID(), valuer.GenerateUUID()
}
func occ(fp, typ, val string, ts time.Time) *errortrackingtypes.Occurrence {
return &errortrackingtypes.Occurrence{
Fingerprint: fp,
Type: typ,
Value: val,
Level: "error",
Timestamp: ts,
EventID: "evt-" + val,
}
}
// TestStore_TwoOrgIsolation is the load-bearing tenancy proof: two orgs report the
// SAME fingerprint; each org sees ONLY its own issue, and neither can read the
// other's issue by id. A cross-tenant leak here is a security bug, so this test is
// the org-scope gate.
func TestStore_TwoOrgIsolation(t *testing.T) {
ctx := context.Background()
mod, orgA, orgB := newTestModule(t)
now := time.Now().UTC()
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp-shared", "TypeError", "from-A", now)))
require.NoError(t, mod.Ingest(ctx, orgB, occ("fp-shared", "TypeError", "from-B", now)))
aList, aTotal, err := mod.ListIssues(ctx, orgA, &errortrackingtypes.IssuesQuery{})
require.NoError(t, err)
require.Equal(t, 1, aTotal)
require.Len(t, aList, 1)
assert.Equal(t, "from-A", aList[0].Value, "org A must see only its own occurrence value")
assert.Equal(t, orgA, aList[0].OrgID)
bList, bTotal, err := mod.ListIssues(ctx, orgB, &errortrackingtypes.IssuesQuery{})
require.NoError(t, err)
require.Equal(t, 1, bTotal)
require.Len(t, bList, 1)
assert.Equal(t, "from-B", bList[0].Value, "org B must see only its own occurrence value")
assert.Equal(t, orgB, bList[0].OrgID)
// Cross-org read by id must fail closed (not found), never return the row.
_, err = mod.GetIssue(ctx, orgB, aList[0].ID)
require.Error(t, err, "org B must NOT be able to read org A's issue by id")
// And org B cannot mutate org A's issue.
reopen := "resolved"
_, err = mod.UpdateIssue(ctx, orgB, aList[0].ID, &errortrackingtypes.UpdateIssue{Status: &reopen})
require.Error(t, err, "org B must NOT be able to update org A's issue")
// Org A's issue is untouched by B's attempts.
got, err := mod.GetIssue(ctx, orgA, aList[0].ID)
require.NoError(t, err)
assert.Equal(t, errortrackingtypes.StatusUnresolved, got.Issue.Status)
}
// TestStore_UpsertGroupsByFingerprint proves the fingerprint bucket: repeated
// occurrences of the same (org, fingerprint) collapse into one issue with a
// running count and advancing last-seen, while first-seen is preserved.
func TestStore_UpsertGroupsByFingerprint(t *testing.T) {
ctx := context.Background()
mod, orgA, _ := newTestModule(t)
first := time.Now().UTC().Add(-time.Hour)
later := time.Now().UTC()
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp1", "ValueError", "boom", first)))
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp1", "ValueError", "boom", later)))
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp1", "ValueError", "boom", later)))
list, total, err := mod.ListIssues(ctx, orgA, &errortrackingtypes.IssuesQuery{})
require.NoError(t, err)
require.Equal(t, 1, total, "same fingerprint must not create new issues")
require.Len(t, list, 1)
assert.Equal(t, int64(3), list[0].Count, "count must track occurrences")
assert.WithinDuration(t, first, list[0].FirstSeen, time.Second, "first-seen preserved")
assert.WithinDuration(t, later, list[0].LastSeen, time.Second, "last-seen advanced")
// A different fingerprint is a different issue.
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp2", "KeyError", "missing", later)))
_, total2, err := mod.ListIssues(ctx, orgA, &errortrackingtypes.IssuesQuery{})
require.NoError(t, err)
assert.Equal(t, 2, total2)
}
// TestStore_RegressionReopensResolved proves regression detection: a resolved
// issue that recurs flips back to unresolved and is flagged regressed; an ignored
// issue stays muted.
func TestStore_RegressionReopensResolved(t *testing.T) {
ctx := context.Background()
mod, orgA, _ := newTestModule(t)
now := time.Now().UTC()
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp-reg", "TypeError", "x", now)))
list, _, err := mod.ListIssues(ctx, orgA, &errortrackingtypes.IssuesQuery{})
require.NoError(t, err)
id := list[0].ID
resolved := string(errortrackingtypes.StatusResolved)
updated, err := mod.UpdateIssue(ctx, orgA, id, &errortrackingtypes.UpdateIssue{Status: &resolved})
require.NoError(t, err)
require.Equal(t, errortrackingtypes.StatusResolved, updated.Status)
require.NotNil(t, updated.ResolvedAt)
// Recurrence after resolution => regression.
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp-reg", "TypeError", "x", now.Add(time.Minute))))
got, err := mod.GetIssue(ctx, orgA, id)
require.NoError(t, err)
assert.Equal(t, errortrackingtypes.StatusUnresolved, got.Issue.Status, "resolved issue must reopen on recurrence")
assert.True(t, got.Issue.Regressed, "reopened issue must be flagged as a regression")
assert.Equal(t, int64(2), got.Issue.Count)
// Ignored issues stay muted on recurrence.
ignored := string(errortrackingtypes.StatusIgnored)
_, err = mod.UpdateIssue(ctx, orgA, id, &errortrackingtypes.UpdateIssue{Status: &ignored})
require.NoError(t, err)
require.NoError(t, mod.Ingest(ctx, orgA, occ("fp-reg", "TypeError", "x", now.Add(2*time.Minute))))
got, err = mod.GetIssue(ctx, orgA, id)
require.NoError(t, err)
assert.Equal(t, errortrackingtypes.StatusIgnored, got.Issue.Status, "ignored issue must stay muted")
}
// TestModule_GetIssueParsesLatestEvent proves the detail view is fully served from
// SQL (the stored sample), independent of any occurrence-store read path.
func TestModule_GetIssueParsesLatestEvent(t *testing.T) {
ctx := context.Background()
mod, orgA, _ := newTestModule(t)
now := time.Now().UTC()
o := occ("fp-detail", "RuntimeError", "kaput", now)
o.Culprit = "handler in server.go"
require.NoError(t, mod.Ingest(ctx, orgA, o))
list, _, err := mod.ListIssues(ctx, orgA, &errortrackingtypes.IssuesQuery{})
require.NoError(t, err)
detail, err := mod.GetIssue(ctx, orgA, list[0].ID)
require.NoError(t, err)
require.NotNil(t, detail.LatestEvent, "detail must carry the latest occurrence sample")
assert.Equal(t, "kaput", detail.LatestEvent.Value)
assert.Equal(t, "handler in server.go", detail.LatestEvent.Culprit)
}
+15
View File
@@ -1,6 +1,8 @@
package o11y
import (
"os"
"github.com/hanzoai/o11y/pkg/alertmanager"
"github.com/hanzoai/o11y/pkg/alertmanager/o11yalertmanager"
"github.com/hanzoai/o11y/pkg/analytics"
@@ -18,6 +20,8 @@ import (
"github.com/hanzoai/o11y/pkg/modules/cloudintegration/implcloudintegration"
"github.com/hanzoai/o11y/pkg/modules/dashboard"
"github.com/hanzoai/o11y/pkg/modules/dashboard/impldashboard"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"github.com/hanzoai/o11y/pkg/modules/errortracking/implerrortracking"
"github.com/hanzoai/o11y/pkg/modules/fields"
"github.com/hanzoai/o11y/pkg/modules/fields/implfields"
"github.com/hanzoai/o11y/pkg/modules/inframonitoring"
@@ -87,6 +91,7 @@ type Handlers struct {
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
LLMObsHandler llmobs.Handler
ErrorTrackingHandler errortracking.Handler
StatsHandler statsreporter.Handler
}
@@ -136,6 +141,16 @@ func NewHandlers(
RulerHandler: o11yruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
LLMObsHandler: impllmobs.NewHandler(modules.LLMObs),
ErrorTrackingHandler: implerrortracking.NewHandler(modules.ErrorTracking, errorTrackingIngestSecret()),
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"))
}
+4
View File
@@ -15,6 +15,8 @@ import (
"github.com/hanzoai/o11y/pkg/modules/authdomain/implauthdomain"
"github.com/hanzoai/o11y/pkg/modules/cloudintegration"
"github.com/hanzoai/o11y/pkg/modules/dashboard"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"github.com/hanzoai/o11y/pkg/modules/errortracking/implerrortracking"
"github.com/hanzoai/o11y/pkg/modules/inframonitoring"
"github.com/hanzoai/o11y/pkg/modules/inframonitoring/implinframonitoring"
"github.com/hanzoai/o11y/pkg/modules/llmobs"
@@ -97,6 +99,7 @@ type Modules struct {
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
LLMObs llmobs.Module
ErrorTracking errortracking.Module
Tag tag.Module
}
@@ -165,6 +168,7 @@ func NewModules(
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl),
LLMObs: impllmobs.NewModule(querier, impllmobs.NewStore(sqlstore)),
ErrorTracking: implerrortracking.NewModule(implerrortracking.NewStore(sqlstore), implerrortracking.NewNoopSink()),
Tag: tagModule,
}
}
+2
View File
@@ -22,6 +22,7 @@ import (
"github.com/hanzoai/o11y/pkg/modules/dashboard"
"github.com/hanzoai/o11y/pkg/modules/fields"
"github.com/hanzoai/o11y/pkg/modules/inframonitoring"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"github.com/hanzoai/o11y/pkg/modules/llmobs"
"github.com/hanzoai/o11y/pkg/modules/llmpricingrule"
"github.com/hanzoai/o11y/pkg/modules/metricreductionrule"
@@ -89,6 +90,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ ruler.Handler }{},
struct{ statsreporter.Handler }{},
struct{ llmobs.Handler }{},
struct{ errortracking.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err
+2
View File
@@ -220,6 +220,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddLLMObsFactory(sqlstore, sqlschema),
sqlmigration.NewAddErrorTrackingFactory(sqlstore, sqlschema),
)
}
@@ -319,6 +320,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RulerHandler,
handlers.StatsHandler,
handlers.LLMObsHandler,
handlers.ErrorTrackingHandler,
),
)
}
@@ -0,0 +1,94 @@
package sqlmigration
import (
"context"
"github.com/hanzoai/o11y/pkg/factory"
"github.com/hanzoai/o11y/pkg/sqlschema"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// addErrorTracking creates the one net-new table backing error/crash tracking:
// o11y_issues (grouped-error lifecycle). Occurrences stay in the telemetry store
// (o11y_traces / o11y_logs); only non-derivable lifecycle state lives here. The
// unique index on (org_id, fingerprint) is the grouping key the ingest upsert
// conflicts on; (org_id, last_seen) serves the default list ordering.
type addErrorTracking struct {
sqlschema sqlschema.SQLSchema
sqlstore sqlstore.SQLStore
}
func NewAddErrorTrackingFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_error_tracking"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
return &addErrorTracking{sqlschema: sqlschema, sqlstore: sqlstore}, nil
})
}
func (migration *addErrorTracking) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addErrorTracking) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
orgFK := func(col string) *sqlschema.ForeignKeyConstraint {
return &sqlschema.ForeignKeyConstraint{
ReferencingColumnName: sqlschema.ColumnName(col),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
}
}
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "o11y_issues",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "fingerprint", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "type", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "value", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "culprit", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "level", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'error'"},
{Name: "platform", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "status", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'unresolved'"},
{Name: "assignee", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "first_seen", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "last_seen", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "count", DataType: sqlschema.DataTypeBigInt, Nullable: false, Default: "0"},
{Name: "resolved_at", DataType: sqlschema.DataTypeTimestamp, Nullable: true},
{Name: "regressed", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
{Name: "environment", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "release", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "service_name", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "sample_event", DataType: sqlschema.DataTypeText, Nullable: true},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{orgFK("org_id")},
})
// The grouping key (ingest upserts ON CONFLICT here) and the list-ordering index.
sqls = append(sqls,
[]byte(`CREATE UNIQUE INDEX IF NOT EXISTS uq_o11y_issues_org_fingerprint ON o11y_issues (org_id, fingerprint)`),
[]byte(`CREATE INDEX IF NOT EXISTS idx_o11y_issues_org_last_seen ON o11y_issues (org_id, last_seen)`),
)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addErrorTracking) Down(context.Context, *bun.DB) error {
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package errortrackingtypes
import (
"time"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/types"
"github.com/hanzoai/o11y/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodeErrorTrackingInvalidInput = errors.MustNewCode("errortracking_invalid_input")
ErrCodeErrorTrackingNotFound = errors.MustNewCode("errortracking_not_found")
ErrCodeErrorTrackingUnauthorized = errors.MustNewCode("errortracking_unauthorized")
ErrCodeErrorTrackingDisabled = errors.MustNewCode("errortracking_disabled")
)
// IssueStatus is the lifecycle state of an issue (Sentry-class).
type IssueStatus string
const (
StatusUnresolved IssueStatus = "unresolved"
StatusResolved IssueStatus = "resolved"
StatusIgnored IssueStatus = "ignored"
)
// Valid reports whether s is one of the three known lifecycle states.
func (s IssueStatus) Valid() bool {
switch s {
case StatusUnresolved, StatusResolved, StatusIgnored:
return true
}
return false
}
// Default severity level for an issue when the SDK sends none.
const DefaultLevel = "error"
// Issue is the grouped error — a fingerprint bucket. It is the ONE net-new table
// backing error tracking. Occurrences live in the telemetry store (o11y_traces /
// o11y_logs); only the lifecycle state that CANNOT be derived from telemetry —
// status, assignee, first/last-seen, running count, regression — lives here.
// Grouping is done at INGEST (the shim computes the fingerprint), so the Issues
// list is a plain org-scoped SELECT, never an unscoped scan over an org-less
// exception table.
//
// Tenancy: OrgID is the mandatory boundary. It is the o11y org UUID — identical
// to the claims.OrgID the read path derives from the gateway-asserted X-Org-Id,
// and to the UUID the ingest path derives from the DSN project via the SAME
// UUIDv5 mapping — so a row written by ingest is found by exactly one tenant's
// read. Every store query filters `org_id = ?`; there is no code path that reads
// issues across orgs.
type Issue struct {
bun.BaseModel `bun:"table:o11y_issues,alias:o11y_issues" json:"-"`
types.Identifiable
types.TimeAuditable
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"-"`
Fingerprint string `bun:"fingerprint,type:text,notnull" json:"fingerprint"`
Type string `bun:"type,type:text,notnull" json:"type"`
Value string `bun:"value,type:text" json:"value"`
Culprit string `bun:"culprit,type:text" json:"culprit"`
Level string `bun:"level,type:text,notnull,default:'error'" json:"level"`
Platform string `bun:"platform,type:text" json:"platform,omitempty"`
Status IssueStatus `bun:"status,type:text,notnull,default:'unresolved'" json:"status"`
Assignee string `bun:"assignee,type:text" json:"assignee,omitempty"`
FirstSeen time.Time `bun:"first_seen,notnull" json:"firstSeen"`
LastSeen time.Time `bun:"last_seen,notnull" json:"lastSeen"`
Count int64 `bun:"count,notnull,default:0" json:"count"`
ResolvedAt *time.Time `bun:"resolved_at" json:"resolvedAt,omitempty"`
Regressed bool `bun:"regressed,notnull,default:false" json:"regressed"`
Environment string `bun:"environment,type:text" json:"environment,omitempty"`
Release string `bun:"release,type:text" json:"release,omitempty"`
ServiceName string `bun:"service_name,type:text" json:"serviceName,omitempty"`
// SampleEvent is the latest normalized Occurrence, stored as JSON so the issue
// detail is fully viewable straight from SQL — no dependency on the (fast-follow)
// occurrence-in-logs read path. Never serialized on the list; parsed into
// GettableIssue.LatestEvent on detail.
SampleEvent string `bun:"sample_event,type:text" json:"-"`
}
// IssuesQuery is the filter for GET /v1/o11y/errortracking/issues. It carries NO
// org field on purpose — the tenant is passed as a separate, server-validated
// argument to the store (mirroring llmobs ScoresQuery), so no client query param
// can widen the scope.
type IssuesQuery struct {
Status string `query:"status" json:"status"`
Level string `query:"level" json:"level"`
Environment string `query:"environment" json:"environment"`
ServiceName string `query:"serviceName" json:"serviceName"`
Query string `query:"query" json:"query"`
Sort string `query:"sort" json:"sort"`
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit" json:"limit"`
}
type GettableIssues struct {
Items []*Issue `json:"items" required:"true"`
Total int `json:"total" required:"true"`
Offset int `json:"offset" required:"true"`
Limit int `json:"limit" required:"true"`
}
// GettableIssue is the issue detail: the lifecycle row plus its latest occurrence
// (parsed from SampleEvent) so the drill-down renders without the occurrence store.
type GettableIssue struct {
Issue *Issue `json:"issue" required:"true"`
LatestEvent *Occurrence `json:"latestEvent,omitempty"`
}
// UpdateIssue is the PATCH body to change lifecycle state (resolve / ignore /
// reopen / assign). Nil fields are left unchanged.
type UpdateIssue struct {
Status *string `json:"status,omitempty"`
Assignee *string `json:"assignee,omitempty"`
}
// Validate enforces the minimal invariants of a lifecycle update.
func (u *UpdateIssue) Validate() error {
if u == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeErrorTrackingInvalidInput, "update payload is null")
}
if u.Status != nil && !IssueStatus(*u.Status).Valid() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeErrorTrackingInvalidInput, "invalid status %q (want unresolved|resolved|ignored)", *u.Status)
}
return nil
}
@@ -0,0 +1,50 @@
package errortrackingtypes
import "time"
// Occurrence is a single normalized error event (one exception instance). It is
// derived from a Sentry event (envelope / legacy store item) or, later, from an
// OTel exception span-event. It is the OTel-shaped occurrence the shim persists
// to o11y_logs (the reused occurrence store) and the "latest event" sample kept
// on the issue for the detail view. Purely a value — no store, no tags of its own.
type Occurrence struct {
EventID string `json:"eventId"`
Fingerprint string `json:"fingerprint"`
Type string `json:"type"`
Value string `json:"value"`
Culprit string `json:"culprit"`
Level string `json:"level"`
Platform string `json:"platform,omitempty"`
Timestamp time.Time `json:"timestamp"`
Environment string `json:"environment,omitempty"`
Release string `json:"release,omitempty"`
ServiceName string `json:"serviceName,omitempty"`
ServerName string `json:"serverName,omitempty"`
Transaction string `json:"transaction,omitempty"`
TraceID string `json:"traceId,omitempty"`
SpanID string `json:"spanId,omitempty"`
Frames []Frame `json:"frames,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
User *EventUser `json:"user,omitempty"`
}
// Frame is a single stack frame, normalized from the Sentry frame shape. Frames
// are stored innermost-last (crash site last), matching the Sentry convention.
type Frame struct {
Function string `json:"function,omitempty"`
Module string `json:"module,omitempty"`
Filename string `json:"filename,omitempty"`
AbsPath string `json:"absPath,omitempty"`
Lineno int `json:"lineno,omitempty"`
Colno int `json:"colno,omitempty"`
InApp bool `json:"inApp"`
}
// EventUser is the reporting end-user context (used for "users affected"); PII is
// the caller's responsibility — we store only what the SDK sent.
type EventUser struct {
ID string `json:"id,omitempty"`
Email string `json:"email,omitempty"`
Username string `json:"username,omitempty"`
IP string `json:"ipAddress,omitempty"`
}
+91
View File
@@ -0,0 +1,91 @@
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"`
}
+24
View File
@@ -0,0 +1,24 @@
package errortrackingtypes
import (
"context"
"github.com/hanzoai/o11y/pkg/valuer"
)
// Store persists the one net-new table (o11y_issues). Every method is org-scoped:
// writes stamp org_id, reads filter it. There is deliberately no "list all issues"
// or cross-org accessor.
type Store interface {
// UpsertIssue groups an occurrence into its issue: inserts the fingerprint
// bucket on first sight, otherwise atomically bumps count/last-seen/latest
// sample and reopens a resolved issue as a regression. Keyed by (org_id,
// fingerprint).
UpsertIssue(ctx context.Context, issue *Issue) error
ListIssues(ctx context.Context, orgID valuer.UUID, q *IssuesQuery) ([]*Issue, int, error)
GetIssue(ctx context.Context, orgID, id valuer.UUID) (*Issue, error)
// UpdateIssue applies a lifecycle change to a single issue scoped by (org_id, id).
UpdateIssue(ctx context.Context, issue *Issue) error
}