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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user