// Package issues derives deduplicated error "issues" from ingested telemetry, // so the web UI can show a triaged list instead of raw traces and logs. package issues import ( "fmt" "hash/fnv" "sort" "strings" "sync" ) // maxIssues bounds memory use: once exceeded, the least-recently-seen issue // is evicted to make room for a newly seen distinct one. const maxIssues = 2000 // maxSamples bounds how many sample occurrences are kept per issue. const maxSamples = 5 // Sample is one occurrence of an issue. type Sample struct { Timestamp int64 `json:"timestamp"` TraceID string `json:"traceId,omitempty"` SpanID string `json:"spanId,omitempty"` Message string `json:"message"` // URL is the page URL an occurrence happened on, populated only for // browser-originated (otel-rum) errors. URL string `json:"url,omitempty"` } // Issue is a deduplicated group of related errors, fingerprinted by kind, // service and title. type Issue struct { ID string `json:"id"` Title string `json:"title"` Kind string `json:"kind"` // "span_error" or "log_error" Service string `json:"service"` Count int `json:"count"` FirstSeen int64 `json:"firstSeen"` LastSeen int64 `json:"lastSeen"` AffectedServices []string `json:"affectedServices"` Samples []Sample `json:"samples"` } // Tracker incrementally fingerprints and deduplicates errors as they're // recorded. It is safe for concurrent use. type Tracker struct { mu sync.Mutex issues map[string]*Issue } // NewTracker creates an empty Tracker. func NewTracker() *Tracker { return &Tracker{issues: make(map[string]*Issue)} } // Record adds an occurrence of an error, creating a new issue or merging into // an existing one with a matching (kind, service, title) fingerprint. func (t *Tracker) Record(kind, service, title string, sample Sample) { id := fingerprint(kind, service, title) t.mu.Lock() defer t.mu.Unlock() issue, ok := t.issues[id] if !ok { if len(t.issues) >= maxIssues { t.evictOldestLocked() } issue = &Issue{ ID: id, Title: title, Kind: kind, Service: service, FirstSeen: sample.Timestamp, } t.issues[id] = issue } issue.Count++ issue.LastSeen = sample.Timestamp issue.AffectedServices = addUnique(issue.AffectedServices, service) issue.Samples = append(issue.Samples, sample) if len(issue.Samples) > maxSamples { issue.Samples = issue.Samples[len(issue.Samples)-maxSamples:] } } // evictOldestLocked removes the issue with the smallest LastSeen. Callers // must hold t.mu. func (t *Tracker) evictOldestLocked() { var oldestID string var oldest int64 first := true for id, issue := range t.issues { if first || issue.LastSeen < oldest { oldest = issue.LastSeen oldestID = id first = false } } if oldestID != "" { delete(t.issues, oldestID) } } // List returns issues sorted by LastSeen descending (most recently seen // first). A non-positive limit returns all issues. func (t *Tracker) List(limit int) []Issue { t.mu.Lock() defer t.mu.Unlock() result := make([]Issue, 0, len(t.issues)) for _, issue := range t.issues { result = append(result, cloneIssue(issue)) } sort.Slice(result, func(i, j int) bool { return result[i].LastSeen > result[j].LastSeen }) if limit > 0 && len(result) > limit { result = result[:limit] } return result } // Get returns a single issue by id. func (t *Tracker) Get(id string) (Issue, bool) { t.mu.Lock() defer t.mu.Unlock() issue, ok := t.issues[id] if !ok { return Issue{}, false } return cloneIssue(issue), true } // cloneIssue deep-copies the slice fields so returned issues don't share // backing arrays with ones still being mutated by Record. func cloneIssue(i *Issue) Issue { c := *i c.AffectedServices = append([]string(nil), i.AffectedServices...) c.Samples = append([]Sample(nil), i.Samples...) return c } func fingerprint(parts ...string) string { h := fnv.New64a() _, _ = h.Write([]byte(strings.Join(parts, "\x00"))) return fmt.Sprintf("%016x", h.Sum64()) } func addUnique(list []string, v string) []string { if v == "" { return list } for _, existing := range list { if existing == v { return list } } return append(list, v) }