Derive deduplicated error issues from traces and logs (Phase 2 backend)

- Add internal/issues.Tracker: fingerprints (kind, service, title) into
  deduplicated Issues with count/firstSeen/lastSeen/affected services and
  bounded sample occurrences, evicting the least-recently-seen issue once
  a cap is reached.
- Add internal/collector/issue_extract.go to pull issues out of ingested
  OTLP data: spans with an error status (preferring exception.type/message
  from an "exception" event, falling back to the span status message), and
  logs at ERROR/FATAL severity (by severity number or text).
- Wire extraction into Collector.ConsumeTraces/ConsumeLogs and expose
  GET /api/issues and GET /api/issues/{id}.
- Fix a pre-existing data race in the collector test's mockStore (unsynced
  access to entries while addToHistory appends asynchronously), found via
  `go test -race` while adding the new issue-tracking tests.
- Update docs/PLAN.md, README.md and agents_example.md for the new API.

The UI "Issues" tab itself lives in the separate frontend project that
builds this server's dist/ bundle; this commit only covers the backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
developerandClaude Sonnet 5 committed 2026-09-17 09:35:39 +00:00
1 parent f77b89a779
commit 0d274ff89c
10 files changed
+627 -6

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
package issues
import "testing"
func TestTrackerDedupesByFingerprint(t *testing.T) {
tracker := NewTracker()
tracker.Record("span_error", "checkout", "charge-card: card declined", Sample{Timestamp: 100, TraceID: "t1"})
tracker.Record("span_error", "checkout", "charge-card: card declined", Sample{Timestamp: 200, TraceID: "t2"})
tracker.Record("span_error", "checkout", "different error", Sample{Timestamp: 150})
list := tracker.List(0)
if len(list) != 2 {
t.Fatalf("expected 2 distinct issues, got %d", len(list))
}
// Most recently seen first.
if list[0].Title != "charge-card: card declined" {
t.Errorf("expected most recent issue first, got %q", list[0].Title)
}
if list[0].Count != 2 {
t.Errorf("expected duplicate occurrences to merge into count 2, got %d", list[0].Count)
}
if list[0].FirstSeen != 100 || list[0].LastSeen != 200 {
t.Errorf("expected firstSeen=100 lastSeen=200, got firstSeen=%d lastSeen=%d", list[0].FirstSeen, list[0].LastSeen)
}
if len(list[0].Samples) != 2 {
t.Errorf("expected 2 samples, got %d", len(list[0].Samples))
}
}
func TestTrackerGet(t *testing.T) {
tracker := NewTracker()
tracker.Record("log_error", "api", "panic: nil pointer", Sample{Timestamp: 100})
list := tracker.List(0)
if len(list) != 1 {
t.Fatalf("expected 1 issue, got %d", len(list))
}
issue, ok := tracker.Get(list[0].ID)
if !ok {
t.Fatalf("expected to find issue %s", list[0].ID)
}
if issue.Service != "api" {
t.Errorf("expected service 'api', got %s", issue.Service)
}
if _, ok := tracker.Get("does-not-exist"); ok {
t.Errorf("expected lookup of unknown id to fail")
}
}
func TestTrackerDifferentServicesDoNotMerge(t *testing.T) {
tracker := NewTracker()
tracker.Record("span_error", "checkout", "timeout", Sample{Timestamp: 100})
tracker.Record("span_error", "billing", "timeout", Sample{Timestamp: 100})
list := tracker.List(0)
if len(list) != 2 {
t.Fatalf("expected same title on different services to stay distinct, got %d issues", len(list))
}
}
func TestTrackerListRespectsLimit(t *testing.T) {
tracker := NewTracker()
for i := 0; i < 5; i++ {
tracker.Record("log_error", "api", string(rune('a'+i)), Sample{Timestamp: int64(i)})
}
list := tracker.List(2)
if len(list) != 2 {
t.Fatalf("expected limit to cap results at 2, got %d", len(list))
}
}