Files
zeekay 31aa82a3c8 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).
2026-07-10 14:54:25 -07:00

83 lines
4.0 KiB
Go

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)
}