From a44b6880940636fbb882ad6ba4a2e4077d682c27 Mon Sep 17 00:00:00 2001 From: nccapo Date: Thu, 5 Mar 2026 18:53:17 +0400 Subject: [PATCH 1/2] add: domain core types --- go.mod | 1 + go.sum | 3 + internal/domain/domain_test.go | 416 +++++++++++++++++++++++++++++++++ internal/domain/event.go | 26 +++ internal/domain/frame.go | 22 ++ internal/domain/group.go | 76 ++++++ internal/domain/level.go | 61 +++++ internal/domain/podcrash.go | 84 +++++++ internal/domain/project.go | 11 + 9 files changed, 700 insertions(+) create mode 100644 internal/domain/domain_test.go create mode 100644 internal/domain/event.go create mode 100644 internal/domain/frame.go create mode 100644 internal/domain/group.go create mode 100644 internal/domain/level.go create mode 100644 internal/domain/podcrash.go create mode 100644 internal/domain/project.go diff --git a/go.mod b/go.mod index fad0ef4..1c39193 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,6 @@ require github.com/spf13/cobra v1.10.2 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..1a17bb8 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..5f8f402 --- /dev/null +++ b/internal/domain/domain_test.go @@ -0,0 +1,416 @@ +package domain + +import ( + "encoding/json" + "testing" + "time" + + "github.com/oklog/ulid/v2" +) + +// helpers + +func mustNewULID(t *testing.T) ulid.ULID { + t.Helper() + id, err := ulid.New(ulid.Now(), ulid.DefaultEntropy()) + if err != nil { + t.Fatalf("ulid.New: %v", err) + } + return id +} + +func roundtrip[T any](t *testing.T, v T) T { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out T + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// Level + +func TestLevel_MarshalUnmarshal(t *testing.T) { + cases := []struct { + level Level + want string + }{ + {LevelInfo, `"info"`}, + {LevelWarning, `"warning"`}, + {LevelError, `"error"`}, + {LevelPanic, `"panic"`}, + } + + for _, tc := range cases { + t.Run(tc.level.String(), func(t *testing.T) { + data, err := json.Marshal(tc.level) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(data) != tc.want { + t.Errorf("got %s, want %s", data, tc.want) + } + + var got Level + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != tc.level { + t.Errorf("roundtrip: got %v, want %v", got, tc.level) + } + }) + } +} + +func TestLevel_Unknown(t *testing.T) { + var l Level + if err := json.Unmarshal([]byte(`"bogus"`), &l); err == nil { + t.Error("expected error for unknown level string") + } +} + +func TestParseLevel(t *testing.T) { + cases := []struct { + in string + want Level + }{ + {"info", LevelInfo}, + {"warning", LevelWarning}, + {"error", LevelError}, + {"panic", LevelPanic}, + } + for _, tc := range cases { + got, err := ParseLevel(tc.in) + if err != nil { + t.Errorf("ParseLevel(%q): %v", tc.in, err) + } + if got != tc.want { + t.Errorf("ParseLevel(%q) = %v, want %v", tc.in, got, tc.want) + } + } + if _, err := ParseLevel("bogus"); err == nil { + t.Error("expected error for unknown level") + } +} + +// GroupStatus + +func TestGroupStatus_MarshalUnmarshal(t *testing.T) { + cases := []struct { + status GroupStatus + want string + }{ + {GroupStatusOpen, `"open"`}, + {GroupStatusResolved, `"resolved"`}, + {GroupStatusIgnored, `"ignored"`}, + } + + for _, tc := range cases { + t.Run(tc.status.String(), func(t *testing.T) { + data, err := json.Marshal(tc.status) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(data) != tc.want { + t.Errorf("got %s, want %s", data, tc.want) + } + + var got GroupStatus + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != tc.status { + t.Errorf("roundtrip: got %v, want %v", got, tc.status) + } + }) + } +} + +// CrashType + +func TestCrashType_MarshalUnmarshal(t *testing.T) { + cases := []struct { + ct CrashType + want string + }{ + {CrashTypeOOMKill, `"oomkill"`}, + {CrashTypeCrashLoop, `"crashloop"`}, + {CrashTypeEviction, `"eviction"`}, + {CrashTypeInitFail, `"init_fail"`}, + {CrashTypeRestartLimit, `"restart_limit"`}, + } + + for _, tc := range cases { + t.Run(tc.ct.String(), func(t *testing.T) { + data, err := json.Marshal(tc.ct) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(data) != tc.want { + t.Errorf("got %s, want %s", data, tc.want) + } + + var got CrashType + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != tc.ct { + t.Errorf("roundtrip: got %v, want %v", got, tc.ct) + } + }) + } +} + +// Event + +func TestEvent_RoundtripJSON(t *testing.T) { + groupID := mustNewULID(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + orig := Event{ + ID: mustNewULID(t), + ProjectID: "proj-1", + Timestamp: now, + Level: LevelError, + Message: "something went wrong", + Fingerprint: "abc123", + GroupID: groupID, + StackTrace: []Frame{ + { + File: "main.go", + Function: "main.handler", + Line: 42, + InApp: true, + Source: []SourceLine{ + {Line: 41, Source: "func handler() {"}, + {Line: 42, Source: "\treturn errors.New(\"oops\")"}, + {Line: 43, Source: "}"}, + }, + }, + }, + ErrorChain: []ChainedError{ + {Type: "*errors.errorString", Message: "something went wrong"}, + {Type: "*os.PathError", Message: "file not found"}, + }, + Tags: map[string]string{"env": "prod", "region": "us-east-1"}, + Context: map[string]any{"user_id": "u-99", "request_id": "req-42"}, + Service: "api", + Version: "1.2.3", + Hostname: "api-pod-abc", + Namespace: "default", + } + + got := roundtrip(t, orig) + + if got.ID != orig.ID { + t.Errorf("ID: got %v, want %v", got.ID, orig.ID) + } + if got.ProjectID != orig.ProjectID { + t.Errorf("ProjectID: got %v, want %v", got.ProjectID, orig.ProjectID) + } + if !got.Timestamp.Equal(orig.Timestamp) { + t.Errorf("Timestamp: got %v, want %v", got.Timestamp, orig.Timestamp) + } + if got.Level != orig.Level { + t.Errorf("Level: got %v, want %v", got.Level, orig.Level) + } + if got.Message != orig.Message { + t.Errorf("Message: got %v, want %v", got.Message, orig.Message) + } + if got.Fingerprint != orig.Fingerprint { + t.Errorf("Fingerprint: got %v, want %v", got.Fingerprint, orig.Fingerprint) + } + if got.GroupID != orig.GroupID { + t.Errorf("GroupID: got %v, want %v", got.GroupID, orig.GroupID) + } + if len(got.StackTrace) != 1 { + t.Fatalf("StackTrace len: got %d, want 1", len(got.StackTrace)) + } + if got.StackTrace[0].File != "main.go" || got.StackTrace[0].Line != 42 { + t.Errorf("StackTrace[0]: got %+v", got.StackTrace[0]) + } + if len(got.StackTrace[0].Source) != 3 { + t.Errorf("Source lines: got %d, want 3", len(got.StackTrace[0].Source)) + } + if len(got.ErrorChain) != 2 { + t.Fatalf("ErrorChain len: got %d, want 2", len(got.ErrorChain)) + } + if got.Tags["env"] != "prod" { + t.Errorf("Tags[env]: got %v, want prod", got.Tags["env"]) + } + if got.Service != orig.Service { + t.Errorf("Service: got %v, want %v", got.Service, orig.Service) + } +} + +func TestEvent_ZeroValue(t *testing.T) { + var e Event + got := roundtrip(t, e) + if got.Level != LevelInfo { + t.Errorf("zero Level: got %v, want info", got.Level) + } +} + +// ErrorGroup + +func TestErrorGroup_RoundtripJSON(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + lastEvent := mustNewULID(t) + + orig := ErrorGroup{ + ID: mustNewULID(t), + ProjectID: "proj-1", + Fingerprint: "fp-xyz", + Title: "nil pointer dereference", + Level: LevelPanic, + FirstSeen: now, + LastSeen: now, + Count: 42, + Status: GroupStatusOpen, + Service: "worker", + LastEvent: lastEvent, + } + + got := roundtrip(t, orig) + + if got.ID != orig.ID { + t.Errorf("ID: got %v, want %v", got.ID, orig.ID) + } + if got.Count != 42 { + t.Errorf("Count: got %v, want 42", got.Count) + } + if got.Status != GroupStatusOpen { + t.Errorf("Status: got %v, want open", got.Status) + } + if got.Level != LevelPanic { + t.Errorf("Level: got %v, want panic", got.Level) + } + if got.LastEvent != lastEvent { + t.Errorf("LastEvent: got %v, want %v", got.LastEvent, lastEvent) + } +} + +// PodCrash + +func TestPodCrash_RoundtripJSON(t *testing.T) { + linkedGroup := mustNewULID(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + orig := PodCrash{ + ID: mustNewULID(t), + Timestamp: now, + Namespace: "production", + PodName: "api-pod-abc123", + Container: "api", + CrashType: CrashTypeOOMKill, + ExitCode: 137, + Restarts: 5, + MemoryLimit: "256Mi", + MemoryUsage: "260Mi", + LastLogs: "fatal: out of memory\n", + NodeName: "node-1", + LinkedGroup: &linkedGroup, + } + + got := roundtrip(t, orig) + + if got.ID != orig.ID { + t.Errorf("ID: got %v, want %v", got.ID, orig.ID) + } + if got.CrashType != CrashTypeOOMKill { + t.Errorf("CrashType: got %v, want oomkill", got.CrashType) + } + if got.ExitCode != 137 { + t.Errorf("ExitCode: got %v, want 137", got.ExitCode) + } + if got.Restarts != 5 { + t.Errorf("Restarts: got %v, want 5", got.Restarts) + } + if got.LinkedGroup == nil || *got.LinkedGroup != linkedGroup { + t.Errorf("LinkedGroup: got %v, want %v", got.LinkedGroup, linkedGroup) + } +} + +func TestPodCrash_NilLinkedGroup(t *testing.T) { + orig := PodCrash{ + ID: mustNewULID(t), + Timestamp: time.Now().UTC(), + CrashType: CrashTypeCrashLoop, + } + got := roundtrip(t, orig) + if got.LinkedGroup != nil { + t.Errorf("LinkedGroup should be nil, got %v", got.LinkedGroup) + } +} + +// Project + +func TestProject_RoundtripJSON(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + + orig := Project{ + ID: "proj-1", + Name: "My Service", + DSNKey: "abcdef1234567890abcdef1234567890", + CreatedAt: now, + } + + got := roundtrip(t, orig) + + if got.ID != orig.ID { + t.Errorf("ID: got %v, want %v", got.ID, orig.ID) + } + if got.Name != orig.Name { + t.Errorf("Name: got %v, want %v", got.Name, orig.Name) + } + if got.DSNKey != orig.DSNKey { + t.Errorf("DSNKey: got %v, want %v", got.DSNKey, orig.DSNKey) + } + if !got.CreatedAt.Equal(orig.CreatedAt) { + t.Errorf("CreatedAt: got %v, want %v", got.CreatedAt, orig.CreatedAt) + } +} + +// Frame and ChainedError + +func TestFrame_RoundtripJSON(t *testing.T) { + orig := Frame{ + File: "internal/handler/api.go", + Function: "(*Server).handleEvent", + Line: 99, + InApp: true, + Source: []SourceLine{ + {Line: 98, Source: "if err != nil {"}, + {Line: 99, Source: "\treturn err"}, + {Line: 100, Source: "}"}, + }, + } + got := roundtrip(t, orig) + if got.Function != orig.Function { + t.Errorf("Function: got %v, want %v", got.Function, orig.Function) + } + if !got.InApp { + t.Error("InApp should be true") + } + if len(got.Source) != 3 { + t.Errorf("Source len: got %d, want 3", len(got.Source)) + } +} + +func TestChainedError_RoundtripJSON(t *testing.T) { + orig := ChainedError{ + Type: "*fmt.wrapError", + Message: "wrap: base error", + } + got := roundtrip(t, orig) + if got.Type != orig.Type { + t.Errorf("Type: got %v, want %v", got.Type, orig.Type) + } + if got.Message != orig.Message { + t.Errorf("Message: got %v, want %v", got.Message, orig.Message) + } +} diff --git a/internal/domain/event.go b/internal/domain/event.go new file mode 100644 index 0000000..81b90da --- /dev/null +++ b/internal/domain/event.go @@ -0,0 +1,26 @@ +package domain + +import ( + "time" + + "github.com/oklog/ulid/v2" +) + +// Event is a single captured error or message from an application. +type Event struct { + ID ulid.ULID `json:"id"` + ProjectID string `json:"project_id"` + Timestamp time.Time `json:"timestamp"` + Level Level `json:"level"` + Message string `json:"message"` + Fingerprint string `json:"fingerprint,omitempty"` + GroupID ulid.ULID `json:"group_id,omitempty"` + StackTrace []Frame `json:"stack_trace,omitempty"` + ErrorChain []ChainedError `json:"error_chain,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Context map[string]any `json:"context,omitempty"` + Service string `json:"service,omitempty"` + Version string `json:"version,omitempty"` + Hostname string `json:"hostname,omitempty"` + Namespace string `json:"namespace,omitempty"` +} diff --git a/internal/domain/frame.go b/internal/domain/frame.go new file mode 100644 index 0000000..58cdf9e --- /dev/null +++ b/internal/domain/frame.go @@ -0,0 +1,22 @@ +package domain + +// SourceLine is a single line of source code with its line number. +type SourceLine struct { + Line int `json:"line"` + Source string `json:"source"` +} + +// Frame is a single stack frame. +type Frame struct { + File string `json:"file"` + Function string `json:"function"` + Line int `json:"line"` + InApp bool `json:"in_app"` + Source []SourceLine `json:"source,omitempty"` +} + +// ChainedError represents one error in a Go error chain (errors.Unwrap). +type ChainedError struct { + Type string `json:"type"` + Message string `json:"message"` +} diff --git a/internal/domain/group.go b/internal/domain/group.go new file mode 100644 index 0000000..e9dfac0 --- /dev/null +++ b/internal/domain/group.go @@ -0,0 +1,76 @@ +package domain + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/oklog/ulid/v2" +) + +// GroupStatus represents the lifecycle state of an error group. +type GroupStatus int8 + +const ( + GroupStatusOpen GroupStatus = iota // open + GroupStatusResolved // resolved + GroupStatusIgnored // ignored +) + +var groupStatusStrings = map[GroupStatus]string{ + GroupStatusOpen: "open", + GroupStatusResolved: "resolved", + GroupStatusIgnored: "ignored", +} + +var stringGroupStatuses = map[string]GroupStatus{ + "open": GroupStatusOpen, + "resolved": GroupStatusResolved, + "ignored": GroupStatusIgnored, +} + +func (s GroupStatus) String() string { + if str, ok := groupStatusStrings[s]; ok { + return str + } + return "unknown" +} + +func ParseGroupStatus(s string) (GroupStatus, error) { + if gs, ok := stringGroupStatuses[s]; ok { + return gs, nil + } + return GroupStatusOpen, fmt.Errorf("unknown group status: %q", s) +} + +func (s GroupStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} + +func (s *GroupStatus) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + parsed, err := ParseGroupStatus(str) + if err != nil { + return err + } + *s = parsed + return nil +} + +// ErrorGroup aggregates Events that share the same fingerprint. +type ErrorGroup struct { + ID ulid.ULID `json:"id"` + ProjectID string `json:"project_id"` + Fingerprint string `json:"fingerprint"` + Title string `json:"title"` + Level Level `json:"level"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + Count int64 `json:"count"` + Status GroupStatus `json:"status"` + Service string `json:"service,omitempty"` + LastEvent ulid.ULID `json:"last_event,omitempty"` +} diff --git a/internal/domain/level.go b/internal/domain/level.go new file mode 100644 index 0000000..7e8c52d --- /dev/null +++ b/internal/domain/level.go @@ -0,0 +1,61 @@ +package domain + +import ( + "encoding/json" + "fmt" +) + +// Level represents the severity of an error event. +type Level int8 + +const ( + LevelInfo Level = iota // info + LevelWarning // warning + LevelError // error + LevelPanic // panic +) + +var levelStrings = map[Level]string{ + LevelInfo: "info", + LevelWarning: "warning", + LevelError: "error", + LevelPanic: "panic", +} + +var stringLevels = map[string]Level{ + "info": LevelInfo, + "warning": LevelWarning, + "error": LevelError, + "panic": LevelPanic, +} + +func (l Level) String() string { + if s, ok := levelStrings[l]; ok { + return s + } + return "unknown" +} + +func ParseLevel(s string) (Level, error) { + if l, ok := stringLevels[s]; ok { + return l, nil + } + return LevelInfo, fmt.Errorf("unknown level: %q", s) +} + +func (l Level) MarshalJSON() ([]byte, error) { + return json.Marshal(l.String()) +} + +func (l *Level) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsed, err := ParseLevel(s) + if err != nil { + return err + } + *l = parsed + return nil +} diff --git a/internal/domain/podcrash.go b/internal/domain/podcrash.go new file mode 100644 index 0000000..b6ab7c0 --- /dev/null +++ b/internal/domain/podcrash.go @@ -0,0 +1,84 @@ +package domain + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/oklog/ulid/v2" +) + +// CrashType categorises the reason a pod was terminated. +type CrashType int8 + +const ( + CrashTypeOOMKill CrashType = iota // oomkill + CrashTypeCrashLoop // crashloop + CrashTypeEviction // eviction + CrashTypeInitFail // init_fail + CrashTypeRestartLimit // restart_limit +) + +var crashTypeStrings = map[CrashType]string{ + CrashTypeOOMKill: "oomkill", + CrashTypeCrashLoop: "crashloop", + CrashTypeEviction: "eviction", + CrashTypeInitFail: "init_fail", + CrashTypeRestartLimit: "restart_limit", +} + +var stringCrashTypes = map[string]CrashType{ + "oomkill": CrashTypeOOMKill, + "crashloop": CrashTypeCrashLoop, + "eviction": CrashTypeEviction, + "init_fail": CrashTypeInitFail, + "restart_limit": CrashTypeRestartLimit, +} + +func (c CrashType) String() string { + if s, ok := crashTypeStrings[c]; ok { + return s + } + return "unknown" +} + +func ParseCrashType(s string) (CrashType, error) { + if ct, ok := stringCrashTypes[s]; ok { + return ct, nil + } + return CrashTypeOOMKill, fmt.Errorf("unknown crash type: %q", s) +} + +func (c CrashType) MarshalJSON() ([]byte, error) { + return json.Marshal(c.String()) +} + +func (c *CrashType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsed, err := ParseCrashType(s) + if err != nil { + return err + } + *c = parsed + return nil +} + +// PodCrash records a Kubernetes pod termination event detected by the watcher. +type PodCrash struct { + ID ulid.ULID `json:"id"` + Timestamp time.Time `json:"timestamp"` + Namespace string `json:"namespace"` + PodName string `json:"pod_name"` + Container string `json:"container"` + CrashType CrashType `json:"crash_type"` + ExitCode int `json:"exit_code"` + Restarts int32 `json:"restarts"` + MemoryLimit string `json:"memory_limit,omitempty"` + MemoryUsage string `json:"memory_usage,omitempty"` + LastLogs string `json:"last_logs,omitempty"` + NodeName string `json:"node_name,omitempty"` + LinkedGroup *ulid.ULID `json:"linked_group,omitempty"` +} diff --git a/internal/domain/project.go b/internal/domain/project.go new file mode 100644 index 0000000..c9f5d90 --- /dev/null +++ b/internal/domain/project.go @@ -0,0 +1,11 @@ +package domain + +import "time" + +// Project is a logical grouping of events identified by a unique DSN key. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + DSNKey string `json:"dsn_key"` + CreatedAt time.Time `json:"created_at"` +} From 7b6ec811a720f0cc6f72a4bb0a3d458c3b2e3a5d Mon Sep 17 00:00:00 2001 From: nccapo Date: Thu, 5 Mar 2026 19:01:04 +0400 Subject: [PATCH 2/2] fix workflow branch naming --- .github/workflows/ci.yml | 4 +- LICENSE | 21 ++++ README.md | 263 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 LICENSE create mode 100644 README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ce3705..feb5197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["main"] + branches: ["master"] pull_request: - branches: ["main"] + branches: ["master"] jobs: test: diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b28c6f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 syst3mctl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..12745c0 --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +# crashctl + +[![CI](https://github.com/syst3mctl/crashctl/actions/workflows/ci.yml/badge.svg)](https://github.com/syst3mctl/crashctl/actions/workflows/ci.yml) +[![Go Version](https://img.shields.io/badge/go-1.23%2B-00ADD8?logo=go)](https://go.dev) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Go Report Card](https://goreportcard.com/badge/github.com/syst3mctl/crashctl)](https://goreportcard.com/report/github.com/syst3mctl/crashctl) + +**Self-hosted error tracking with built-in Kubernetes crash detection.** + +crashctl is a single static binary that captures application errors, groups them by root cause, and automatically detects Kubernetes pod crashes — OOMKills, CrashLoopBackOffs, evictions — without any agents or sidecars. Everything ships in one binary: HTTP ingest API, web UI, K8s watcher, and Prometheus metrics endpoint. + +--- + +## Why crashctl + +Most error trackers are SaaS tools that require sending production data to a third party, or self-hosted tools that need Redis, Postgres, and a message queue just to get started. crashctl needs nothing but a volume and a K8s ServiceAccount. + +The differentiator is the **Kubernetes crash watcher**: crashctl watches the K8s API directly and correlates pod crashes (OOMKill, CrashLoopBackOff) with SDK-reported errors. When a pod crashes, you see both the K8s event and the Go panic that caused it — linked automatically. + +| | crashctl | Sentry (self-hosted) | GlitchTip | +|---|---|---|---| +| Single binary | ✅ | ❌ | ❌ | +| No external dependencies | ✅ | ❌ | ❌ | +| K8s OOMKill detection | ✅ | ❌ | ❌ | +| CrashLoopBackOff detection | ✅ | ❌ | ❌ | +| Crash → error linking | ✅ | ❌ | ❌ | +| Embedded web UI | ✅ | ✅ | ✅ | +| Prometheus metrics | ✅ | ✅ | ❌ | + +--- + +## Features + +- **Error grouping** — SHA-256 fingerprint of normalized Go stack frames. Identical panics produce one group with an accurate occurrence count, regardless of goroutine IDs or memory addresses. +- **Kubernetes crash detection** — SharedInformer watches pods across all (or configured) namespaces. Detects OOMKill, CrashLoopBackOff, failed init containers, evictions, and restart threshold breaches. +- **Crash-to-error correlation** — Links a `PodCrash` to its `ErrorGroup` using pod hostname + time window matching. The error detail page shows the K8s crash; the crash detail page links back to the error group. +- **Go SDK** — `sdk.CaptureError`, `sdk.Recover`, and HTTP middleware for chi, gin, and `net/http`. Async send with exponential backoff. Never blocks your application. +- **Web UI** — Go templates + htmx. No React, no npm, no build step. The binary is fully self-contained. +- **Prometheus metrics** — `/metrics` endpoint with counters for events, groups, pod crashes, and ingestion latency. +- **Webhook alerting** — Slack, Discord, and generic HTTP webhooks for new error groups, pod crashes, and regressions. + +--- + +## Quick Start + +### Docker + +```bash +docker run -d \ + --name crashctl \ + -p 9090:9090 \ + -v crashctl-data:/data/crashctl \ + syst3mctl/crashctl:latest +``` + +Open `http://localhost:9090`. Create a project: + +```bash +docker exec crashctl /crashctl project create --name "my-service" +# DSN: http://localhost:9090 | Key: +``` + +### Kubernetes (Helm) + +```bash +helm install crashctl oci://ghcr.io/syst3mctl/charts/crashctl \ + --namespace monitoring \ + --create-namespace \ + --set config.kubernetes.namespaces="{default,production}" +``` + +The Helm chart configures the ServiceAccount with the required RBAC (get/list/watch on pods and pod logs) automatically. + +### Binary + +```bash +curl -L https://github.com/syst3mctl/crashctl/releases/latest/download/crashctl-linux-amd64 \ + -o /usr/local/bin/crashctl && chmod +x /usr/local/bin/crashctl + +crashctl serve --config crashctl.yaml +``` + +--- + +## SDK + +```bash +go get github.com/syst3mctl/crashctl/sdk +``` + +```go +package main + +import ( + "github.com/syst3mctl/crashctl/sdk" +) + +func main() { + sdk.Init(sdk.Config{ + DSN: "http://localhost:9090", + DSNKey: "your-project-key", + Service: "my-service", + Version: "1.0.0", + }) + defer sdk.Flush(5 * time.Second) + + // Capture errors + if err := doWork(); err != nil { + sdk.CaptureError(err, sdk.WithTag("job", "nightly-sync")) + } + + // Capture panics in goroutines + go func() { + defer sdk.Recover() + riskyOperation() + }() +} +``` + +### HTTP Middleware + +```go +// net/http +mux.Handle("/", crashmiddleware.HTTPMiddleware(yourHandler)) + +// chi +r.Use(crashmiddleware.ChiMiddleware) + +// gin +r.Use(crashmiddleware.GinMiddleware()) +``` + +Middleware automatically captures panics with the full HTTP request context (method, path, status code, client IP) and re-panics so your existing recovery handler still fires. + +--- + +## Configuration + +```yaml +# crashctl.yaml +server: + listen: ":9090" + base_url: "https://crashctl.example.com" + +storage: + driver: badger + badger: + path: /data/crashctl + +retention: + max_age: 720h # 30 days + cleanup_interval: 1h + +kubernetes: + enabled: true + namespaces: [] # empty = all namespaces + restart_threshold: 5 + +alerting: + webhooks: + - name: team-slack + url: https://hooks.slack.com/services/XXX + type: slack + events: [new_group, pod_crash, regression] +``` + +All keys can be overridden with `CRASHCTL_` environment variables (e.g. `CRASHCTL_SERVER_LISTEN=:8080`) or CLI flags. Priority: flags > env vars > config file > defaults. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ crashctl binary │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ HTTP API │ │ Web UI │ │ K8s Watcher │ │ +│ │ │ │ (htmx + │ │ (SharedInformer) │ │ +│ │ POST /events │ │ templates) │ │ │ │ +│ │ GET /health │ │ │ │ OOMKill │ │ +│ │ GET /metrics│ │ /errors │ │ CrashLoopBackOff │ │ +│ └──────┬───────┘ │ /errors/:id │ │ Eviction │ │ +│ │ │ /crashes │ │ Init fail │ │ +│ ┌──────▼───────┐ │ /crashes/:id │ └────────┬──────────┘ │ +│ │ Grouping │ └──────────────┘ │ │ +│ │ (SHA-256 │ │ │ +│ │ fingerprint)│ ┌──────────────────────────▼──────────┐ │ +│ └──────┬───────┘ │ BadgerDB │ │ +│ └──────────► events / groups / crashes / projects│ │ +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +The storage layer is behind a `Store` interface — a BadgerDB implementation ships in the binary; a PostgreSQL implementation is on the roadmap. + +### Key schema (BadgerDB) + +| Prefix | Key | Value | +|---|---|---| +| `e:` | `e:{projectID}:{timestamp_ns}:{eventID}` | JSON `Event` | +| `g:` | `g:{projectID}:{groupID}` | JSON `ErrorGroup` | +| `f:` | `f:{projectID}:{fingerprint}` | `groupID` bytes | +| `c:` | `c:{namespace}:{timestamp_ns}:{crashID}` | JSON `PodCrash` | +| `p:` | `p:{projectID}` | JSON `Project` | + +ULID keys are lexicographically sortable, so prefix range scans return results in chronological order with no secondary index. + +--- + +## CLI + +``` +crashctl serve Start the server (web UI + API + K8s watcher) +crashctl project create Create a project and print its DSN key +crashctl project list List all projects +crashctl cleanup Manually trigger retention cleanup +crashctl version Print version, commit, and build date +``` + +--- + +## Metrics + +| Metric | Type | Labels | +|---|---|---| +| `crashctl_events_total` | Counter | `project`, `level`, `service` | +| `crashctl_groups_active` | Gauge | `project`, `status` | +| `crashctl_pod_crashes_total` | Counter | `namespace`, `crash_type` | +| `crashctl_ingestion_duration_seconds` | Histogram | — | +| `crashctl_storage_size_bytes` | Gauge | — | + +--- + +## Development + +**Requirements:** Go 1.23+ + +```bash +git clone https://github.com/syst3mctl/crashctl +cd crashctl + +make build # Build binary +make test # Run tests with race detector +make lint # golangci-lint +make dev # go run ./cmd/crashctl serve +``` + +--- + +## Roadmap + +- **PostgreSQL backend** — for teams with existing PG infrastructure +- **Sentry SDK compatibility** — accept Sentry wire format for zero-code migration +- **OpenTelemetry ingestion** — accept OTel error spans and enrich with K8s context +- **Grafana dashboard templates** — pre-built dashboards for crashctl metrics +- **Multi-user auth** — OIDC/SSO for team access control + +--- + +## License + +[MIT](LICENSE) © 2026 syst3mctl