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

127 lines
3.6 KiB
Go

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