Files
go-otel/internal/issues/tracker.go
T
2026-09-17 20:42:19 +00:00

166 lines
4.2 KiB
Go

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