Files
go-otel/internal/collector/collector_test.go
T
developerandClaude Sonnet 5 f77b89a779 Improve agent-facing history endpoints (Phase 1)
- Fix /api/history/{type} to search the full persistent store instead of
  only the bounded in-memory ring buffer, so filtered history isn't
  silently truncated.
- Add structured type/since/until filters to /api/history/search and
  /api/history/wait, alongside the existing free-text query.
- Add GET /api/traces/{traceId} to fetch all entries for a trace in one
  call instead of relying on substring search.
- Rate-limit /api/history/wait per client IP.
- Add docs/PLAN.md tracking the phased improvement plan; update README
  and agents_example.md for the new/changed endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:30:18 +00:00

170 lines
4.6 KiB
Go

package collector
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.tornberg.me/go-otel/internal/storage"
"git.tornberg.me/go-otel/internal/websocket"
)
type mockStore struct {
entries []websocket.HistoryEntry
}
func (m *mockStore) Append(ctx context.Context, entry websocket.HistoryEntry) error {
m.entries = append(m.entries, entry)
return nil
}
func (m *mockStore) GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) {
return m.entries, nil
}
func (m *mockStore) Search(ctx context.Context, opts storage.SearchOptions, limit int) ([]websocket.HistoryEntry, error) {
var filtered []websocket.HistoryEntry
for _, e := range m.entries {
if opts.Type != "" && e.Type != opts.Type {
continue
}
if opts.Query != "" && !strings.Contains(string(e.Data), opts.Query) {
continue
}
filtered = append(filtered, e)
}
return filtered, nil
}
func TestCollectorWithStore(t *testing.T) {
store := &mockStore{}
coll := New(nil, 10, store)
coll.addToHistory(websocket.DataTypeLogs, json.RawMessage(`{"msg": "test"}`))
// Give it a moment since it's asynchronous
timeout := time.After(100 * time.Millisecond)
for {
select {
case <-timeout:
t.Fatal("timed out waiting for entry to be appended to store")
default:
if len(store.entries) > 0 {
goto OK
}
time.Sleep(10 * time.Millisecond)
}
}
OK:
if store.entries[0].Type != "logs" {
t.Errorf("expected type 'logs', got %s", store.entries[0].Type)
}
}
func TestCollectorHistoryLimit(t *testing.T) {
coll := New(nil, 10, nil)
for i := 0; i < 10; i++ {
coll.addToHistory(websocket.DataTypeLogs, json.RawMessage(`{"msg": "test"}`))
}
history := coll.GetHistory(10)
if len(history) != 10 {
t.Errorf("expected 10 entries, got %d", len(history))
}
history = coll.GetHistory(5)
if len(history) != 5 {
t.Errorf("expected 5 entries, got %d", len(history))
}
// Should default to historySize (10)
history = coll.GetHistory(0)
if len(history) != 10 {
t.Errorf("expected default limit to be historySize (10), got %d", len(history))
}
// Should not crash on large limit, just return what it has
history = coll.GetHistory(100)
if len(history) != 10 {
t.Errorf("expected 10 entries even with limit 100, got %d", len(history))
}
}
func TestHandleHistoryByTypeUsesStore(t *testing.T) {
store := &mockStore{
entries: []websocket.HistoryEntry{
{Type: "traces", Timestamp: 1, Data: json.RawMessage(`{"id":1}`)},
{Type: "traces", Timestamp: 2, Data: json.RawMessage(`{"id":2}`)},
{Type: "logs", Timestamp: 3, Data: json.RawMessage(`{"id":3}`)},
},
}
// Ring buffer is much smaller than the store's content, so a correct
// implementation must route through the store rather than the ring buffer.
coll := New(nil, 1, store)
req := httptest.NewRequest(http.MethodGet, "/api/history/traces", nil)
rec := httptest.NewRecorder()
coll.HandleHistoryByType("traces")(rec, req)
var results []websocket.HistoryEntry
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 trace entries from the store, got %d", len(results))
}
}
func TestHandleTraceByID(t *testing.T) {
store := &mockStore{
entries: []websocket.HistoryEntry{
{Type: "traces", Timestamp: 1, Data: json.RawMessage(`{"traceId":"abc123"}`)},
{Type: "logs", Timestamp: 2, Data: json.RawMessage(`{"traceId":"abc123","msg":"related"}`)},
{Type: "traces", Timestamp: 3, Data: json.RawMessage(`{"traceId":"other"}`)},
},
}
coll := New(nil, 10, store)
req := httptest.NewRequest(http.MethodGet, "/api/traces/abc123", nil)
req.SetPathValue("traceId", "abc123")
rec := httptest.NewRecorder()
coll.HandleTraceByID(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var results []websocket.HistoryEntry
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 entries referencing trace abc123, got %d", len(results))
}
}
func TestHandleWaitRateLimited(t *testing.T) {
coll := New(nil, 10, nil)
var lastCode int
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/history/wait?q=nope&timeout=0", nil)
req.RemoteAddr = "203.0.113.1:12345"
rec := httptest.NewRecorder()
coll.HandleWait(rec, req)
lastCode = rec.Code
if lastCode == http.StatusTooManyRequests {
break
}
}
if lastCode != http.StatusTooManyRequests {
t.Fatalf("expected rate limiting to eventually return 429, last code was %d", lastCode)
}
}