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

+15
View File
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"git.tornberg.me/go-otel/internal/issues"
"git.tornberg.me/go-otel/internal/storage"
"git.tornberg.me/go-otel/internal/websocket"
@@ -30,6 +31,7 @@ type Collector struct {
subscribers map[chan websocket.HistoryEntry]func(websocket.HistoryEntry) bool
subMutex sync.Mutex
waitLimiter *ipRateLimiter
issues *issues.Tracker
}
// New creates a new Collector instance.
@@ -42,9 +44,20 @@ func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store
subscribers: make(map[chan websocket.HistoryEntry]func(websocket.HistoryEntry) bool),
// Allow bursts of a few immediate waits, then steady-state ~1 req/sec per IP.
waitLimiter: newIPRateLimiter(1, 5),
issues: issues.NewTracker(),
}
}
// ListIssues returns deduplicated error issues, most recently seen first.
func (c *Collector) ListIssues(limit int) []issues.Issue {
return c.issues.List(limit)
}
// GetIssue returns a single issue by id.
func (c *Collector) GetIssue(id string) (issues.Issue, bool) {
return c.issues.Get(id)
}
// SetBroadcaster updates the broadcaster used by the collector.
func (c *Collector) SetBroadcaster(broadcaster websocket.Broadcaster) {
c.broadcaster = broadcaster
@@ -191,6 +204,7 @@ func (c *Collector) ConsumeTraces(ctx context.Context, td ptrace.Traces) error {
// Store and broadcast raw bytes as json.RawMessage to avoid unsafe interface{} usage
c.addToHistory(websocket.DataTypeTraces, buf)
extractSpanErrors(c.issues, td, time.Now().UnixNano())
entry := websocket.Entry{
Type: string(websocket.DataTypeTraces),
@@ -233,6 +247,7 @@ func (c *Collector) ConsumeLogs(ctx context.Context, ld plog.Logs) error {
// Store and broadcast raw bytes as json.RawMessage to avoid unsafe interface{} usage
c.addToHistory(websocket.DataTypeLogs, buf)
extractLogErrors(c.issues, ld, time.Now().UnixNano())
entry := websocket.Entry{
Type: string(websocket.DataTypeLogs),