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:
1 parent
f77b89a779
commit
0d274ff89c
10 files changed
+627
-6
No files matched your search
@@ -6,27 +6,51 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.tornberg.me/go-otel/internal/storage"
|
||||
"git.tornberg.me/go-otel/internal/websocket"
|
||||
"go.opentelemetry.io/collector/pdata/pcommon"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
"go.opentelemetry.io/collector/pdata/ptrace"
|
||||
)
|
||||
|
||||
type noopBroadcaster struct{}
|
||||
|
||||
func (noopBroadcaster) Broadcast(entry *websocket.Entry) {}
|
||||
|
||||
// mockStore is used by tests only; addToHistory appends to the store
|
||||
// asynchronously, so access must be synchronized even in single-threaded
|
||||
// test flows that poll for the async write to land.
|
||||
type mockStore struct {
|
||||
mu sync.Mutex
|
||||
entries []websocket.HistoryEntry
|
||||
}
|
||||
|
||||
func (m *mockStore) Append(ctx context.Context, entry websocket.HistoryEntry) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.entries = append(m.entries, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore) len() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.entries)
|
||||
}
|
||||
|
||||
func (m *mockStore) GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.entries, nil
|
||||
}
|
||||
|
||||
func (m *mockStore) Search(ctx context.Context, opts storage.SearchOptions, limit int) ([]websocket.HistoryEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var filtered []websocket.HistoryEntry
|
||||
for _, e := range m.entries {
|
||||
if opts.Type != "" && e.Type != opts.Type {
|
||||
@@ -53,7 +77,7 @@ func TestCollectorWithStore(t *testing.T) {
|
||||
case <-timeout:
|
||||
t.Fatal("timed out waiting for entry to be appended to store")
|
||||
default:
|
||||
if len(store.entries) > 0 {
|
||||
if store.len() > 0 {
|
||||
goto OK
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -61,8 +85,9 @@ func TestCollectorWithStore(t *testing.T) {
|
||||
}
|
||||
|
||||
OK:
|
||||
if store.entries[0].Type != "logs" {
|
||||
t.Errorf("expected type 'logs', got %s", store.entries[0].Type)
|
||||
entries, _ := store.GetHistory(context.Background(), 0)
|
||||
if entries[0].Type != "logs" {
|
||||
t.Errorf("expected type 'logs', got %s", entries[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,3 +192,129 @@ func TestHandleWaitRateLimited(t *testing.T) {
|
||||
t.Fatalf("expected rate limiting to eventually return 429, last code was %d", lastCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeTracesRecordsIssue(t *testing.T) {
|
||||
coll := New(noopBroadcaster{}, 10, nil)
|
||||
|
||||
td := ptrace.NewTraces()
|
||||
rs := td.ResourceSpans().AppendEmpty()
|
||||
rs.Resource().Attributes().PutStr("service.name", "checkout")
|
||||
span := rs.ScopeSpans().AppendEmpty().Spans().AppendEmpty()
|
||||
span.SetName("charge-card")
|
||||
span.Status().SetCode(ptrace.StatusCodeError)
|
||||
span.Status().SetMessage("card declined")
|
||||
span.SetTraceID(pcommon.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16})
|
||||
|
||||
if err := coll.ConsumeTraces(context.Background(), td); err != nil {
|
||||
t.Fatalf("ConsumeTraces failed: %v", err)
|
||||
}
|
||||
|
||||
list := coll.ListIssues(10)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(list))
|
||||
}
|
||||
if list[0].Service != "checkout" {
|
||||
t.Errorf("expected service 'checkout', got %s", list[0].Service)
|
||||
}
|
||||
if list[0].Kind != "span_error" {
|
||||
t.Errorf("expected kind 'span_error', got %s", list[0].Kind)
|
||||
}
|
||||
if !strings.Contains(list[0].Title, "card declined") {
|
||||
t.Errorf("expected title to contain status message, got %q", list[0].Title)
|
||||
}
|
||||
if list[0].Samples[0].TraceID == "" {
|
||||
t.Errorf("expected sample to carry the trace id")
|
||||
}
|
||||
|
||||
// A non-error span must not create an issue.
|
||||
okSpan := rs.ScopeSpans().At(0).Spans().AppendEmpty()
|
||||
okSpan.SetName("healthy-op")
|
||||
okSpan.Status().SetCode(ptrace.StatusCodeOk)
|
||||
if err := coll.ConsumeTraces(context.Background(), td); err != nil {
|
||||
t.Fatalf("ConsumeTraces failed: %v", err)
|
||||
}
|
||||
|
||||
// Re-sending the same error span dedupes into the existing issue.
|
||||
list = coll.ListIssues(10)
|
||||
if len(list) != 1 || list[0].Count != 2 {
|
||||
t.Fatalf("expected deduped issue with count 2, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLogsRecordsIssue(t *testing.T) {
|
||||
coll := New(noopBroadcaster{}, 10, nil)
|
||||
|
||||
ld := plog.NewLogs()
|
||||
rl := ld.ResourceLogs().AppendEmpty()
|
||||
rl.Resource().Attributes().PutStr("service.name", "api")
|
||||
record := rl.ScopeLogs().AppendEmpty().LogRecords().AppendEmpty()
|
||||
record.SetSeverityNumber(plog.SeverityNumberError)
|
||||
record.Body().SetStr("panic: nil pointer dereference")
|
||||
|
||||
// An info-level record must not create an issue.
|
||||
infoRecord := rl.ScopeLogs().At(0).LogRecords().AppendEmpty()
|
||||
infoRecord.SetSeverityNumber(plog.SeverityNumberInfo)
|
||||
infoRecord.Body().SetStr("request handled")
|
||||
|
||||
if err := coll.ConsumeLogs(context.Background(), ld); err != nil {
|
||||
t.Fatalf("ConsumeLogs failed: %v", err)
|
||||
}
|
||||
|
||||
list := coll.ListIssues(10)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(list))
|
||||
}
|
||||
if list[0].Kind != "log_error" {
|
||||
t.Errorf("expected kind 'log_error', got %s", list[0].Kind)
|
||||
}
|
||||
if list[0].Title != "panic: nil pointer dereference" {
|
||||
t.Errorf("unexpected title: %q", list[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleIssuesAndByID(t *testing.T) {
|
||||
coll := New(noopBroadcaster{}, 10, nil)
|
||||
|
||||
ld := plog.NewLogs()
|
||||
rl := ld.ResourceLogs().AppendEmpty()
|
||||
rl.Resource().Attributes().PutStr("service.name", "api")
|
||||
record := rl.ScopeLogs().AppendEmpty().LogRecords().AppendEmpty()
|
||||
record.SetSeverityNumber(plog.SeverityNumberError)
|
||||
record.Body().SetStr("boom")
|
||||
|
||||
if err := coll.ConsumeLogs(context.Background(), ld); err != nil {
|
||||
t.Fatalf("ConsumeLogs failed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/issues", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
coll.HandleIssues(rec, req)
|
||||
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("failed to decode issues response: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 issue in response, got %d", len(list))
|
||||
}
|
||||
id, _ := list[0]["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("expected issue to have an id, got %+v", list[0])
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/issues/"+id, nil)
|
||||
req.SetPathValue("id", id)
|
||||
rec = httptest.NewRecorder()
|
||||
coll.HandleIssueByID(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for known issue id, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/issues/missing", nil)
|
||||
req.SetPathValue("id", "missing")
|
||||
rec = httptest.NewRecorder()
|
||||
coll.HandleIssueByID(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for unknown issue id, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user