From a03a5bd8a31c4de79baf5e14fee36e5718833466 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 20 Mar 2026 17:25:03 +0000 Subject: [PATCH 01/21] feat: add failing test stubs for events package --- server/lib/events/events_test.go | 474 +++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 server/lib/events/events_test.go diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go new file mode 100644 index 00000000..30cd5528 --- /dev/null +++ b/server/lib/events/events_test.go @@ -0,0 +1,474 @@ +package events + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBrowserEvent: construct BrowserEvent with all SCHEMA-01 fields; marshal to JSON; +// assert all snake_case keys present. +func TestBrowserEvent(t *testing.T) { + ev := BrowserEvent{ + CaptureSessionID: "test-session-id", + Seq: 1, + Ts: 1234567890000, + Type: "console_log", + TargetID: "target-1", + CDPSessionID: "cdp-session-1", + FrameID: "frame-1", + ParentFrameID: "parent-frame-1", + URL: "https://example.com", + Data: json.RawMessage(`{"message":"hello"}`), + Truncated: false, + } + + b, err := json.Marshal(ev) + require.NoError(t, err) + + s := string(b) + assert.Contains(t, s, `"capture_session_id"`) + assert.Contains(t, s, `"seq"`) + assert.Contains(t, s, `"ts"`) + assert.Contains(t, s, `"type"`) + assert.Contains(t, s, `"target_id"`) + assert.Contains(t, s, `"cdp_session_id"`) + assert.Contains(t, s, `"frame_id"`) + assert.Contains(t, s, `"parent_frame_id"`) + assert.Contains(t, s, `"url"`) + assert.Contains(t, s, `"data"`) +} + +// TestBrowserEventData: embed a pre-serialized JSON object in Data field; marshal outer event; +// assert Data appears verbatim (no double-encoding). +func TestBrowserEventData(t *testing.T) { + rawData := json.RawMessage(`{"key":"value","num":42}`) + ev := BrowserEvent{ + CaptureSessionID: "test-session", + Seq: 1, + Ts: 1000, + Type: "cdp_event", + Data: rawData, + } + + b, err := json.Marshal(ev) + require.NoError(t, err) + + s := string(b) + // Data must appear verbatim — no double-encoding (should not be escaped string) + assert.Contains(t, s, `"data":{"key":"value","num":42}`) + assert.NotContains(t, s, `"data":"{`) // would indicate double-encoding +} + +// TestCategoryFor: table-driven; assert prefix routing is correct. +func TestCategoryFor(t *testing.T) { + cases := []struct { + eventType string + expected EventCategory + }{ + {"console_log", CategoryConsole}, + {"network_request", CategoryNetwork}, + {"liveview_click", CategoryLiveview}, + {"captcha_solve", CategoryCaptcha}, + {"cdp_nav", CategoryCDP}, + {"unknown_type", CategoryCDP}, + } + + for _, tc := range cases { + t.Run(tc.eventType, func(t *testing.T) { + got := CategoryFor(tc.eventType) + assert.Equal(t, tc.expected, got) + }) + } +} + +// TestRingBuffer: publish 3 events; reader reads all 3 in order. +func TestRingBuffer(t *testing.T) { + rb := NewRingBuffer(10) + reader := rb.NewReader() + + events := []BrowserEvent{ + {Seq: 1, Type: "cdp_event_1"}, + {Seq: 2, Type: "cdp_event_2"}, + {Seq: 3, Type: "cdp_event_3"}, + } + + for _, ev := range events { + rb.Publish(ev) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + for i, expected := range events { + got, err := reader.Read(ctx) + require.NoError(t, err, "reading event %d", i) + assert.Equal(t, expected.Type, got.Type) + } +} + +// TestRingBufferOverflow: ring capacity 2; publish 3 events with no reader; +// assert write returns immediately (no block); reader receives events_dropped then newest events. +func TestRingBufferOverflow(t *testing.T) { + rb := NewRingBuffer(2) + + // Publish 3 events with no reader — must not block + done := make(chan struct{}) + go func() { + rb.Publish(BrowserEvent{Seq: 1, Type: "cdp_event_1"}) + rb.Publish(BrowserEvent{Seq: 2, Type: "cdp_event_2"}) + rb.Publish(BrowserEvent{Seq: 3, Type: "cdp_event_3"}) + close(done) + }() + + select { + case <-done: + // good — did not block + case <-time.After(500 * time.Millisecond): + t.Fatal("Publish blocked with no readers") + } + + // Create reader after overflow; should get events_dropped then available events + reader := rb.NewReader() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + first, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, "events_dropped", first.Type) +} + +// TestEventsDropped: ring capacity 2; reader gets notify channel; publish 3 events; +// reader reads; assert first result is events_dropped BrowserEvent. +func TestEventsDropped(t *testing.T) { + rb := NewRingBuffer(2) + reader := rb.NewReader() + + // Publish 3 events, overflowing the ring (capacity 2) + rb.Publish(BrowserEvent{Seq: 1, Type: "cdp_event_1"}) + rb.Publish(BrowserEvent{Seq: 2, Type: "cdp_event_2"}) + rb.Publish(BrowserEvent{Seq: 3, Type: "cdp_event_3"}) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + first, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, "events_dropped", first.Type) + + // Data must be valid JSON with a "dropped" count + require.NotNil(t, first.Data) + assert.True(t, json.Valid(first.Data)) + assert.Contains(t, string(first.Data), `"dropped"`) +} + +// TestConcurrentReaders: 3 readers subscribe before publish; publish 5 events; +// each reader independently reads all 5; no reader affects another. +func TestConcurrentReaders(t *testing.T) { + rb := NewRingBuffer(20) + + numReaders := 3 + numEvents := 5 + + readers := make([]*Reader, numReaders) + for i := range readers { + readers[i] = rb.NewReader() + } + + // Publish events after readers are created + for i := 0; i < numEvents; i++ { + rb.Publish(BrowserEvent{Seq: uint64(i + 1), Type: "cdp_event"}) + } + + var wg sync.WaitGroup + results := make([][]BrowserEvent, numReaders) + + for i, r := range readers { + wg.Add(1) + go func(idx int, reader *Reader) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var evs []BrowserEvent + for j := 0; j < numEvents; j++ { + ev, err := reader.Read(ctx) + require.NoError(t, err) + evs = append(evs, ev) + } + results[idx] = evs + }(i, r) + } + + wg.Wait() + + // Each reader must have received all 5 events + for i, evs := range results { + assert.Len(t, evs, numEvents, "reader %d", i) + for j, ev := range evs { + assert.Equal(t, uint64(j+1), ev.Seq, "reader %d event %d", i, j) + } + } +} + +// TestFileWriter: per-category JSONL appender tests. +func TestFileWriter(t *testing.T) { + t.Run("writes_to_correct_file", func(t *testing.T) { + dir := t.TempDir() + fw := NewFileWriter(dir) + defer fw.Close() + + ev := BrowserEvent{ + CaptureSessionID: "sess-1", + Seq: 1, + Ts: 1000, + Type: "console_log", + Data: json.RawMessage(`{"message":"hello"}`), + } + require.NoError(t, fw.Write(ev)) + + data, err := os.ReadFile(filepath.Join(dir, "console.log")) + require.NoError(t, err) + + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + require.Len(t, lines, 1) + assert.True(t, json.Valid([]byte(lines[0]))) + assert.Contains(t, lines[0], `"capture_session_id"`) + assert.Contains(t, lines[0], `"console_log"`) + }) + + t.Run("category_routing", func(t *testing.T) { + dir := t.TempDir() + fw := NewFileWriter(dir) + defer fw.Close() + + typeToFile := map[string]string{ + "console_log": "console.log", + "network_request": "network.log", + "liveview_click": "liveview.log", + "captcha_solve": "captcha.log", + "cdp_navigation": "cdp.log", + } + + for typ := range typeToFile { + require.NoError(t, fw.Write(BrowserEvent{Type: typ, Seq: 1, Ts: 1})) + } + + for typ, file := range typeToFile { + data, err := os.ReadFile(filepath.Join(dir, file)) + require.NoError(t, err, "missing file for type %s", typ) + assert.True(t, json.Valid(bytes.TrimRight(data, "\n"))) + } + }) + + t.Run("concurrent_writes", func(t *testing.T) { + dir := t.TempDir() + fw := NewFileWriter(dir) + defer fw.Close() + + const goroutines = 10 + const eventsPerGoroutine = 100 + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < eventsPerGoroutine; j++ { + ev := BrowserEvent{ + Seq: uint64(i*eventsPerGoroutine + j), + Type: "console_log", + Ts: 1, + } + require.NoError(t, fw.Write(ev)) + } + }(i) + } + wg.Wait() + + data, err := os.ReadFile(filepath.Join(dir, "console.log")) + require.NoError(t, err) + + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + assert.Len(t, lines, goroutines*eventsPerGoroutine) + for _, line := range lines { + assert.True(t, json.Valid([]byte(line)), "invalid JSON line: %s", line) + } + }) + + t.Run("lazy_open", func(t *testing.T) { + dir := t.TempDir() + fw := NewFileWriter(dir) + defer fw.Close() + + // No writes yet — directory should be empty. + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries, "files opened before first Write") + + require.NoError(t, fw.Write(BrowserEvent{Type: "console_log", Seq: 1, Ts: 1})) + + entries, err = os.ReadDir(dir) + require.NoError(t, err) + assert.Len(t, entries, 1, "expected exactly one file after first Write") + assert.Equal(t, "console.log", entries[0].Name()) + }) +} + +// TestPipeline: Pipeline glue type tests. +func TestPipeline(t *testing.T) { + newPipeline := func(t *testing.T) (*Pipeline, string) { + t.Helper() + dir := t.TempDir() + rb := NewRingBuffer(100) + fw := NewFileWriter(dir) + p := NewPipeline(rb, fw) + t.Cleanup(func() { p.Close() }) + return p, dir + } + + t.Run("publish_increments_seq", func(t *testing.T) { + p, _ := newPipeline(t) + reader := p.NewReader() + + for i := 0; i < 3; i++ { + p.Publish(BrowserEvent{Type: "cdp_event", Ts: 1}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + for want := uint64(1); want <= 3; want++ { + ev, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, want, ev.Seq, "expected seq %d got %d", want, ev.Seq) + } + }) + + t.Run("publish_sets_ts", func(t *testing.T) { + p, _ := newPipeline(t) + reader := p.NewReader() + + before := time.Now().UnixMilli() + p.Publish(BrowserEvent{Type: "cdp_event"}) // Ts == 0 + after := time.Now().UnixMilli() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ev, err := reader.Read(ctx) + require.NoError(t, err) + assert.GreaterOrEqual(t, ev.Ts, before) + assert.LessOrEqual(t, ev.Ts, after) + }) + + t.Run("publish_writes_file", func(t *testing.T) { + p, dir := newPipeline(t) + + p.Publish(BrowserEvent{Type: "console_log", Ts: 1}) + + data, err := os.ReadFile(filepath.Join(dir, "console.log")) + require.NoError(t, err) + + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + require.Len(t, lines, 1) + assert.True(t, json.Valid([]byte(lines[0]))) + assert.Contains(t, lines[0], `"console_log"`) + }) + + t.Run("publish_writes_ring", func(t *testing.T) { + p, _ := newPipeline(t) + + // Subscribe reader BEFORE publish. + reader := p.NewReader() + p.Publish(BrowserEvent{Type: "cdp_event", Ts: 1}) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ev, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, "cdp_event", ev.Type) + }) + + t.Run("start_sets_capture_session_id", func(t *testing.T) { + p, _ := newPipeline(t) + p.Start("test-uuid") + + reader := p.NewReader() + p.Publish(BrowserEvent{Type: "cdp_event", Ts: 1}) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ev, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, "test-uuid", ev.CaptureSessionID) + }) + + t.Run("truncation_applied", func(t *testing.T) { + p, dir := newPipeline(t) + reader := p.NewReader() + + largeData := strings.Repeat("x", 1_100_000) + rawData, err := json.Marshal(map[string]string{"payload": largeData}) + require.NoError(t, err) + + p.Publish(BrowserEvent{ + Type: "cdp_event", + Ts: 1, + Data: json.RawMessage(rawData), + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Ring buffer event must have Truncated==true. + ev, err := reader.Read(ctx) + require.NoError(t, err) + assert.True(t, ev.Truncated) + + // File must contain valid JSON with truncated==true. + data, err := os.ReadFile(filepath.Join(dir, "cdp.log")) + require.NoError(t, err) + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + require.Len(t, lines, 1) + assert.True(t, json.Valid([]byte(lines[0]))) + assert.Contains(t, lines[0], `"truncated":true`) + }) +} + +// TestTruncation: construct event with Data = 1.1MB JSON bytes; call truncateIfNeeded; +// assert Truncated==true and json.Valid(result.Data)==true and len(marshal(result)) <= 1_000_000. +func TestTruncation(t *testing.T) { + // Build a Data field that is ~1.1MB + largeData := strings.Repeat("x", 1_100_000) + rawData, err := json.Marshal(map[string]string{"payload": largeData}) + require.NoError(t, err) + + ev := BrowserEvent{ + CaptureSessionID: "test-session", + Seq: 1, + Ts: 1000, + Type: "cdp_event", + Data: json.RawMessage(rawData), + } + + result := truncateIfNeeded(ev) + + assert.True(t, result.Truncated) + assert.True(t, json.Valid(result.Data)) + + marshaled, err := json.Marshal(result) + require.NoError(t, err) + assert.LessOrEqual(t, len(marshaled), 1_000_000) +} From 42f415f52064a49ac807503f985b1f873be0f8a6 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 20 Mar 2026 17:25:09 +0000 Subject: [PATCH 02/21] feat: add BrowserEvent struct, CategoryFor, and truncateIfNeeded --- server/lib/events/event.go | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 server/lib/events/event.go diff --git a/server/lib/events/event.go b/server/lib/events/event.go new file mode 100644 index 00000000..eb9c5674 --- /dev/null +++ b/server/lib/events/event.go @@ -0,0 +1,68 @@ +package events + +import ( + "encoding/json" + "strings" +) + +// maxS2RecordBytes is the S2 record size limit (SCHEMA-04). +const maxS2RecordBytes = 1_000_000 + +// EventCategory maps event type prefixes to log file names. +type EventCategory string + +const ( + CategoryCDP EventCategory = "cdp" + CategoryConsole EventCategory = "console" + CategoryNetwork EventCategory = "network" + CategoryLiveview EventCategory = "liveview" + CategoryCaptcha EventCategory = "captcha" +) + +// BrowserEvent is the canonical event structure for the browser capture pipeline. +type BrowserEvent struct { + CaptureSessionID string `json:"capture_session_id"` + Seq uint64 `json:"seq"` + Ts int64 `json:"ts"` + Type string `json:"type"` + TargetID string `json:"target_id,omitempty"` + CDPSessionID string `json:"cdp_session_id,omitempty"` + FrameID string `json:"frame_id,omitempty"` + ParentFrameID string `json:"parent_frame_id,omitempty"` + URL string `json:"url,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// CategoryFor returns the log category for a given event type. +// Event types follow the pattern "_", e.g. "console_log", +// "network_request", "cdp_navigation". Types not matching a known prefix +// fall through to CategoryCDP as a safe default. +func CategoryFor(eventType string) EventCategory { + prefix, _, _ := strings.Cut(eventType, "_") + switch prefix { + case "console": + return CategoryConsole + case "network": + return CategoryNetwork + case "liveview": + return CategoryLiveview + case "captcha": + return CategoryCaptcha + default: + return CategoryCDP + } +} + +// truncateIfNeeded returns a copy of ev with Data replaced with json.RawMessage("null") +// and Truncated set to true if the marshaled size exceeds maxS2RecordBytes. +// Per RESEARCH pitfall 3: never attempt byte-slice truncation of the Data field. +func truncateIfNeeded(ev BrowserEvent) BrowserEvent { + candidate, err := json.Marshal(ev) + if err != nil || len(candidate) <= maxS2RecordBytes { + return ev + } + ev.Data = json.RawMessage("null") + ev.Truncated = true + return ev +} From fa67dfff38682d44e2ec6c3d1dcce0186e489f7e Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 20 Mar 2026 17:25:11 +0000 Subject: [PATCH 03/21] feat: add RingBuffer with closed-channel broadcast fan-out --- server/lib/events/ringbuffer.go | 110 ++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 server/lib/events/ringbuffer.go diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go new file mode 100644 index 00000000..3911912e --- /dev/null +++ b/server/lib/events/ringbuffer.go @@ -0,0 +1,110 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + "sync" +) + +// RingBuffer is a fixed-capacity circular buffer with closed-channel broadcast fan-out. +// Writers never block regardless of reader count or speed. +// Readers track their position by seq value (not ring index) and receive an +// events_dropped synthetic BrowserEvent when they fall behind the oldest retained event. +type RingBuffer struct { + mu sync.RWMutex + buf []BrowserEvent + head int // next write position (mod cap) + count int // items currently stored (0..cap) + written uint64 // total ever published (monotonic) + notify chan struct{} +} + +// NewRingBuffer creates a new RingBuffer with the given capacity. +func NewRingBuffer(capacity int) *RingBuffer { + return &RingBuffer{ + buf: make([]BrowserEvent, capacity), + notify: make(chan struct{}), + } +} + +// Publish adds an event to the ring buffer, evicting the oldest entry on overflow. +// Closes the current notify channel (waking all waiting readers) and replaces it +// with a new one — outside the lock to avoid blocking under contention. +func (rb *RingBuffer) Publish(ev BrowserEvent) { + rb.mu.Lock() + rb.buf[rb.head] = ev + rb.head = (rb.head + 1) % len(rb.buf) + if rb.count < len(rb.buf) { + rb.count++ + } + rb.written++ + old := rb.notify + rb.notify = make(chan struct{}) + rb.mu.Unlock() + close(old) // outside lock to avoid blocking under contention +} + +// oldestSeq returns the seq of the oldest event still in the ring. +// Must be called under at least a read lock. +func (rb *RingBuffer) oldestSeq() uint64 { + if rb.written <= uint64(len(rb.buf)) { + return 0 + } + return rb.written - uint64(len(rb.buf)) +} + +// NewReader returns a Reader positioned at seq 0. +// If the ring has already published events, the reader will receive an +// events_dropped BrowserEvent on the first Read call if it has fallen behind +// the oldest retained event. +func (rb *RingBuffer) NewReader() *Reader { + return &Reader{rb: rb, nextSeq: 0} +} + +// Reader tracks an independent read position in a RingBuffer. +type Reader struct { + rb *RingBuffer + nextSeq uint64 +} + +// Read blocks until the next event is available or ctx is cancelled. +// Returns (event, nil) for a normal event. +// Returns (events_dropped BrowserEvent, nil) if the reader has fallen behind +// the ring's oldest retained event — the dropped count is in Data as valid JSON. +func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { + for { + r.rb.mu.RLock() + notify := r.rb.notify + oldest := r.rb.oldestSeq() + written := r.rb.written + + // Reader fell behind — synthesize events_dropped before advancing. + if r.nextSeq < oldest { + dropped := oldest - r.nextSeq + r.nextSeq = oldest + r.rb.mu.RUnlock() + data := json.RawMessage(fmt.Sprintf(`{"dropped":%d}`, dropped)) + return BrowserEvent{Type: "events_dropped", Data: data}, nil + } + + // Event is available — read it. + if r.nextSeq < written { + idx := int(r.nextSeq % uint64(len(r.rb.buf))) + ev := r.rb.buf[idx] + r.nextSeq++ + r.rb.mu.RUnlock() + return ev, nil + } + + // No event yet — wait for notification. + r.rb.mu.RUnlock() + + select { + case <-ctx.Done(): + return BrowserEvent{}, ctx.Err() + case <-notify: + // new event available; loop to read it + } + } +} From 115c7209cbb5645a08e938efd97463747db06cfa Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 20 Mar 2026 17:25:16 +0000 Subject: [PATCH 04/21] feat: add FileWriter per-category JSONL appender --- server/lib/events/filewriter.go | 78 +++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 server/lib/events/filewriter.go diff --git a/server/lib/events/filewriter.go b/server/lib/events/filewriter.go new file mode 100644 index 00000000..4b40d204 --- /dev/null +++ b/server/lib/events/filewriter.go @@ -0,0 +1,78 @@ +package events + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// FileWriter is a per-category JSONL appender. It opens each log file lazily on +// first write (O_APPEND|O_CREATE|O_WRONLY) and serialises concurrent writes +// within a category with a single mutex. +type FileWriter struct { + mu sync.Mutex + files map[EventCategory]*os.File + dir string +} + +// NewFileWriter returns a FileWriter that writes to dir. +// No files are opened until the first Write call. +func NewFileWriter(dir string) *FileWriter { + return &FileWriter{dir: dir, files: make(map[EventCategory]*os.File)} +} + +// Write serialises ev to JSON and appends it as a single JSONL line to the +// per-category log file. The mutex is held for the entire open+marshal+write +// sequence to prevent TOCTOU races and to guarantee whole-line atomicity for +// events larger than PIPE_BUF. +func (fw *FileWriter) Write(ev BrowserEvent) error { + cat := CategoryFor(ev.Type) + + fw.mu.Lock() + defer fw.mu.Unlock() + + // Lazy open. + f, ok := fw.files[cat] + if !ok { + path := filepath.Join(fw.dir, string(cat)+".log") + var err error + f, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("filewriter: open %s: %w", path, err) + } + fw.files[cat] = f + } + + data, err := json.Marshal(ev) + if err != nil { + return fmt.Errorf("filewriter: marshal: %w", err) + } + + var buf bytes.Buffer + buf.Write(data) + buf.WriteByte('\n') + + if _, err := f.Write(buf.Bytes()); err != nil { + return fmt.Errorf("filewriter: write: %w", err) + } + + return nil +} + +// Close closes all open log file descriptors. The first encountered error is +// returned; subsequent files are still closed. +func (fw *FileWriter) Close() error { + fw.mu.Lock() + defer fw.mu.Unlock() + + var firstErr error + for _, f := range fw.files { + if err := f.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} From f07e40d2fce59b7539e53778e5401f3cb476a6dc Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 20 Mar 2026 17:25:20 +0000 Subject: [PATCH 05/21] feat: add Pipeline glue type sequencing truncation, file write, and ring publish --- server/lib/events/pipeline.go | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 server/lib/events/pipeline.go diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go new file mode 100644 index 00000000..11661150 --- /dev/null +++ b/server/lib/events/pipeline.go @@ -0,0 +1,67 @@ +package events + +import ( + "sync/atomic" + "time" +) + +// Pipeline glues a RingBuffer and a FileWriter into a single write path. +// A single call to Publish stamps the event with a monotonic sequence number, +// applies truncation, durably appends it to the per-category log file, and +// then makes it available to ring buffer readers. +type Pipeline struct { + ring *RingBuffer + files *FileWriter + seq atomic.Uint64 + captureSessionID atomic.Value // stores string +} + +// NewPipeline returns a Pipeline backed by the supplied ring and file writer. +func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { + p := &Pipeline{ring: ring, files: files} + p.captureSessionID.Store("") + return p +} + +// Start sets the capture session ID that will be stamped on every subsequent +// published event. It may be called at any time; the change is immediately +// visible to concurrent Publish calls. +func (p *Pipeline) Start(captureSessionID string) { + p.captureSessionID.Store(captureSessionID) +} + +// Publish stamps, truncates, files, and broadcasts a single event. +// +// Ordering: +// 1. Stamp CaptureSessionID, Seq, Ts (Ts only if caller left it zero) +// 2. Apply truncateIfNeeded (SCHEMA-04) — must happen before both sinks +// 3. Write to FileWriter (durable before in-memory) +// 4. Publish to RingBuffer (in-memory fan-out) +// +// Errors from FileWriter.Write are silently dropped; the ring buffer always +// receives the event even if the file write fails. +func (p *Pipeline) Publish(ev BrowserEvent) { + ev.CaptureSessionID = p.captureSessionID.Load().(string) + ev.Seq = p.seq.Add(1) // starts at 1 + if ev.Ts == 0 { + ev.Ts = time.Now().UnixMilli() + } + ev = truncateIfNeeded(ev) + + // File write first — durable before in-memory. + _ = p.files.Write(ev) + + // Ring buffer last — readers see the event after the file is written. + p.ring.Publish(ev) +} + +// NewReader returns a Reader positioned at the start of the ring buffer. +func (p *Pipeline) NewReader() *Reader { + return p.ring.NewReader() +} + +// Close closes the underlying FileWriter, flushing and releasing all open +// file descriptors. +func (p *Pipeline) Close() error { + return p.files.Close() +} From 18fdb6d0f7476f2893edc5b4ed34f51039e9bdfb Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 27 Mar 2026 11:37:14 +0000 Subject: [PATCH 06/21] review: fix truncateIfNeeded branch split, atomic.Pointer[string], Reader godoc, and test correctness --- server/lib/events/event.go | 82 +++++--- server/lib/events/events_test.go | 330 ++++++++++++++++++------------- server/lib/events/pipeline.go | 15 +- 3 files changed, 252 insertions(+), 175 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index eb9c5674..b174147e 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -2,29 +2,66 @@ package events import ( "encoding/json" - "strings" ) -// maxS2RecordBytes is the S2 record size limit (SCHEMA-04). +// maxS2RecordBytes is the S2 event pipeline maximum record size (1 MB). +// Events exceeding this limit have their Data field replaced with null and +// Truncated set to true before being written to the file and ring sinks. const maxS2RecordBytes = 1_000_000 -// EventCategory maps event type prefixes to log file names. +// EventCategory is a first-class envelope field that determines log file routing. type EventCategory string const ( - CategoryCDP EventCategory = "cdp" - CategoryConsole EventCategory = "console" - CategoryNetwork EventCategory = "network" - CategoryLiveview EventCategory = "liveview" - CategoryCaptcha EventCategory = "captcha" + CategoryConsole EventCategory = "console" + CategoryNetwork EventCategory = "network" + CategoryPage EventCategory = "page" + CategoryInteraction EventCategory = "interaction" + CategoryLiveview EventCategory = "liveview" + CategoryCaptcha EventCategory = "captcha" + CategorySystem EventCategory = "system" +) + +// SourceKind identifies the provenance of an event — which subsystem produced it. +type SourceKind string + +const ( + SourceCDP SourceKind = "cdp" + SourceKernelAPI SourceKind = "kernel_api" + SourceExtension SourceKind = "extension" + SourceLocalProcess SourceKind = "local_process" +) + +// DetailLevel controls the verbosity of the event payload. +type DetailLevel string + +const ( + DetailMinimal DetailLevel = "minimal" + DetailDefault DetailLevel = "default" + DetailVerbose DetailLevel = "verbose" + DetailRaw DetailLevel = "raw" ) // BrowserEvent is the canonical event structure for the browser capture pipeline. +// +// The envelope is designed so that capture config and subscription selectors +// can operate on stable, first-class fields (Category, SourceKind, DetailLevel) +// without parsing the Type string. Type carries semantic identity (e.g. +// "console.log", "network.request"); SourceEvent carries the raw upstream +// event name (e.g. "Runtime.consoleAPICalled") for diagnostics. +// +// DetailLevel is always serialised (no omitempty). Pipeline.Publish defaults it +// to DetailDefault; callers constructing events outside a Pipeline should set it +// explicitly. type BrowserEvent struct { CaptureSessionID string `json:"capture_session_id"` Seq uint64 `json:"seq"` Ts int64 `json:"ts"` Type string `json:"type"` + Category EventCategory `json:"category"` + SourceKind SourceKind `json:"source_kind"` + SourceEvent string `json:"source_event,omitempty"` + DetailLevel DetailLevel `json:"detail_level"` TargetID string `json:"target_id,omitempty"` CDPSessionID string `json:"cdp_session_id,omitempty"` FrameID string `json:"frame_id,omitempty"` @@ -34,32 +71,17 @@ type BrowserEvent struct { Truncated bool `json:"truncated,omitempty"` } -// CategoryFor returns the log category for a given event type. -// Event types follow the pattern "_", e.g. "console_log", -// "network_request", "cdp_navigation". Types not matching a known prefix -// fall through to CategoryCDP as a safe default. -func CategoryFor(eventType string) EventCategory { - prefix, _, _ := strings.Cut(eventType, "_") - switch prefix { - case "console": - return CategoryConsole - case "network": - return CategoryNetwork - case "liveview": - return CategoryLiveview - case "captcha": - return CategoryCaptcha - default: - return CategoryCDP - } -} - // truncateIfNeeded returns a copy of ev with Data replaced with json.RawMessage("null") // and Truncated set to true if the marshaled size exceeds maxS2RecordBytes. -// Per RESEARCH pitfall 3: never attempt byte-slice truncation of the Data field. +// Never attempt byte-slice truncation of the Data field — partial JSON is invalid. func truncateIfNeeded(ev BrowserEvent) BrowserEvent { candidate, err := json.Marshal(ev) - if err != nil || len(candidate) <= maxS2RecordBytes { + if err != nil { + // Marshal should never fail for BrowserEvent (all fields are JSON-safe), + // but if it does return ev unchanged rather than silently nulling Data. + return ev + } + if len(candidate) <= maxS2RecordBytes { return ev } ev.Data = json.RawMessage("null") diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index 30cd5528..deb390c0 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -15,37 +15,44 @@ import ( "github.com/stretchr/testify/require" ) -// TestBrowserEvent: construct BrowserEvent with all SCHEMA-01 fields; marshal to JSON; -// assert all snake_case keys present. -func TestBrowserEvent(t *testing.T) { +// TestBrowserEventSerialization: round-trip marshal/unmarshal verifying all SCHEMA-01 +// envelope fields serialize with correct JSON keys and values, including provenance. +func TestBrowserEventSerialization(t *testing.T) { ev := BrowserEvent{ CaptureSessionID: "test-session-id", Seq: 1, Ts: 1234567890000, - Type: "console_log", + Type: "console.log", + Category: CategoryConsole, + SourceKind: SourceCDP, + SourceEvent: "Runtime.consoleAPICalled", + DetailLevel: DetailDefault, TargetID: "target-1", CDPSessionID: "cdp-session-1", FrameID: "frame-1", ParentFrameID: "parent-frame-1", URL: "https://example.com", Data: json.RawMessage(`{"message":"hello"}`), - Truncated: false, } b, err := json.Marshal(ev) require.NoError(t, err) - s := string(b) - assert.Contains(t, s, `"capture_session_id"`) - assert.Contains(t, s, `"seq"`) - assert.Contains(t, s, `"ts"`) - assert.Contains(t, s, `"type"`) - assert.Contains(t, s, `"target_id"`) - assert.Contains(t, s, `"cdp_session_id"`) - assert.Contains(t, s, `"frame_id"`) - assert.Contains(t, s, `"parent_frame_id"`) - assert.Contains(t, s, `"url"`) - assert.Contains(t, s, `"data"`) + var decoded map[string]any + require.NoError(t, json.Unmarshal(b, &decoded)) + + assert.Equal(t, "console.log", decoded["type"]) + assert.Equal(t, "console", decoded["category"]) + assert.Equal(t, "cdp", decoded["source_kind"]) + assert.Equal(t, "Runtime.consoleAPICalled", decoded["source_event"]) + assert.Equal(t, "default", decoded["detail_level"]) + assert.Equal(t, "test-session-id", decoded["capture_session_id"]) + assert.Equal(t, float64(1), decoded["seq"]) + assert.Equal(t, "target-1", decoded["target_id"]) + assert.Equal(t, "cdp-session-1", decoded["cdp_session_id"]) + assert.Equal(t, "frame-1", decoded["frame_id"]) + assert.Equal(t, "parent-frame-1", decoded["parent_frame_id"]) + assert.Equal(t, "https://example.com", decoded["url"]) } // TestBrowserEventData: embed a pre-serialized JSON object in Data field; marshal outer event; @@ -56,7 +63,9 @@ func TestBrowserEventData(t *testing.T) { CaptureSessionID: "test-session", Seq: 1, Ts: 1000, - Type: "cdp_event", + Type: "page.navigation", + Category: CategoryPage, + SourceKind: SourceCDP, Data: rawData, } @@ -64,31 +73,28 @@ func TestBrowserEventData(t *testing.T) { require.NoError(t, err) s := string(b) - // Data must appear verbatim — no double-encoding (should not be escaped string) assert.Contains(t, s, `"data":{"key":"value","num":42}`) assert.NotContains(t, s, `"data":"{`) // would indicate double-encoding } -// TestCategoryFor: table-driven; assert prefix routing is correct. -func TestCategoryFor(t *testing.T) { - cases := []struct { - eventType string - expected EventCategory - }{ - {"console_log", CategoryConsole}, - {"network_request", CategoryNetwork}, - {"liveview_click", CategoryLiveview}, - {"captcha_solve", CategoryCaptcha}, - {"cdp_nav", CategoryCDP}, - {"unknown_type", CategoryCDP}, +// TestBrowserEventOmitEmpty: source_event is omitted when empty; detail_level always present. +func TestBrowserEventOmitEmpty(t *testing.T) { + ev := BrowserEvent{ + CaptureSessionID: "sess", + Seq: 1, + Ts: 1000, + Type: "console.log", + Category: CategoryConsole, + SourceKind: SourceCDP, } - for _, tc := range cases { - t.Run(tc.eventType, func(t *testing.T) { - got := CategoryFor(tc.eventType) - assert.Equal(t, tc.expected, got) - }) - } + b, err := json.Marshal(ev) + require.NoError(t, err) + + s := string(b) + assert.NotContains(t, s, `"source_event"`) + // detail_level is always serialized (not omitempty) — zero value is "" + assert.Contains(t, s, `"detail_level"`) } // TestRingBuffer: publish 3 events; reader reads all 3 in order. @@ -97,9 +103,9 @@ func TestRingBuffer(t *testing.T) { reader := rb.NewReader() events := []BrowserEvent{ - {Seq: 1, Type: "cdp_event_1"}, - {Seq: 2, Type: "cdp_event_2"}, - {Seq: 3, Type: "cdp_event_3"}, + {Seq: 1, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}, + {Seq: 2, Type: "network.request", Category: CategoryNetwork, SourceKind: SourceCDP}, + {Seq: 3, Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP}, } for _, ev := range events { @@ -113,62 +119,113 @@ func TestRingBuffer(t *testing.T) { got, err := reader.Read(ctx) require.NoError(t, err, "reading event %d", i) assert.Equal(t, expected.Type, got.Type) + assert.Equal(t, expected.Category, got.Category) } } -// TestRingBufferOverflow: ring capacity 2; publish 3 events with no reader; -// assert write returns immediately (no block); reader receives events_dropped then newest events. -func TestRingBufferOverflow(t *testing.T) { +// TestRingBufferOverflowNoBlock: writer never blocks even with no readers; +// late-joining reader gets events.dropped with correct envelope fields. +func TestRingBufferOverflowNoBlock(t *testing.T) { rb := NewRingBuffer(2) - // Publish 3 events with no reader — must not block done := make(chan struct{}) go func() { - rb.Publish(BrowserEvent{Seq: 1, Type: "cdp_event_1"}) - rb.Publish(BrowserEvent{Seq: 2, Type: "cdp_event_2"}) - rb.Publish(BrowserEvent{Seq: 3, Type: "cdp_event_3"}) + rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) close(done) }() select { case <-done: - // good — did not block - case <-time.After(500 * time.Millisecond): + case <-time.After(5 * time.Millisecond): t.Fatal("Publish blocked with no readers") } - // Create reader after overflow; should get events_dropped then available events reader := rb.NewReader() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() first, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "events_dropped", first.Type) + assert.Equal(t, "events.dropped", first.Type) + assert.Equal(t, CategorySystem, first.Category) + assert.Equal(t, SourceKernelAPI, first.SourceKind) } -// TestEventsDropped: ring capacity 2; reader gets notify channel; publish 3 events; -// reader reads; assert first result is events_dropped BrowserEvent. -func TestEventsDropped(t *testing.T) { +// TestRingBufferOverflowExistingReader: reader created before overflow +// gets events.dropped with exact count, then continues reading. +func TestRingBufferOverflowExistingReader(t *testing.T) { rb := NewRingBuffer(2) reader := rb.NewReader() - // Publish 3 events, overflowing the ring (capacity 2) - rb.Publish(BrowserEvent{Seq: 1, Type: "cdp_event_1"}) - rb.Publish(BrowserEvent{Seq: 2, Type: "cdp_event_2"}) - rb.Publish(BrowserEvent{Seq: 3, Type: "cdp_event_3"}) + rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() first, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "events_dropped", first.Type) + assert.Equal(t, "events.dropped", first.Type) + assert.Equal(t, CategorySystem, first.Category) - // Data must be valid JSON with a "dropped" count require.NotNil(t, first.Data) assert.True(t, json.Valid(first.Data)) - assert.Contains(t, string(first.Data), `"dropped"`) + assert.JSONEq(t, `{"dropped":1}`, string(first.Data)) + + // After the drop sentinel the reader continues with the surviving events + // (seq 2 and 3, which fit in the capacity-2 buffer). + second, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), second.Seq) + + third, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(3), third.Seq) +} + +// TestConcurrentPublishRead: readers blocked on Read while a writer publishes +// concurrently — exercises locking and notify paths under go test -race. +func TestConcurrentPublishRead(t *testing.T) { + const numEvents = 20 + rb := NewRingBuffer(32) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + reader := rb.NewReader() + + var wg sync.WaitGroup + + // Reader goroutine: reads numEvents events. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < numEvents; i++ { + _, err := reader.Read(ctx) + if !assert.NoError(t, err) { + return + } + } + }() + + // Writer goroutine: publishes numEvents events. + wg.Add(1) + go func() { + defer wg.Done() + for i := 1; i <= numEvents; i++ { + rb.Publish(BrowserEvent{ + Seq: uint64(i), + Type: "console.log", + Category: CategoryConsole, + SourceKind: SourceCDP, + }) + } + }() + + wg.Wait() } // TestConcurrentReaders: 3 readers subscribe before publish; publish 5 events; @@ -184,9 +241,8 @@ func TestConcurrentReaders(t *testing.T) { readers[i] = rb.NewReader() } - // Publish events after readers are created for i := 0; i < numEvents; i++ { - rb.Publish(BrowserEvent{Seq: uint64(i + 1), Type: "cdp_event"}) + rb.Publish(BrowserEvent{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) } var wg sync.WaitGroup @@ -202,7 +258,9 @@ func TestConcurrentReaders(t *testing.T) { var evs []BrowserEvent for j := 0; j < numEvents; j++ { ev, err := reader.Read(ctx) - require.NoError(t, err) + if !assert.NoError(t, err) { + break + } evs = append(evs, ev) } results[idx] = evs @@ -211,7 +269,6 @@ func TestConcurrentReaders(t *testing.T) { wg.Wait() - // Each reader must have received all 5 events for i, evs := range results { assert.Len(t, evs, numEvents, "reader %d", i) for j, ev := range evs { @@ -222,52 +279,51 @@ func TestConcurrentReaders(t *testing.T) { // TestFileWriter: per-category JSONL appender tests. func TestFileWriter(t *testing.T) { - t.Run("writes_to_correct_file", func(t *testing.T) { + t.Run("category_routing", func(t *testing.T) { dir := t.TempDir() fw := NewFileWriter(dir) defer fw.Close() - ev := BrowserEvent{ - CaptureSessionID: "sess-1", - Seq: 1, - Ts: 1000, - Type: "console_log", - Data: json.RawMessage(`{"message":"hello"}`), + eventsToFile := []struct { + ev BrowserEvent + file string + category string + }{ + {BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "console.log", "console"}, + {BrowserEvent{Type: "network.request", Category: CategoryNetwork, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "network.log", "network"}, + {BrowserEvent{Type: "liveview.click", Category: CategoryLiveview, SourceKind: SourceKernelAPI, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, + {BrowserEvent{Type: "captcha.solve", Category: CategoryCaptcha, SourceKind: SourceExtension, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, + {BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "page.log", "page"}, + {BrowserEvent{Type: "input.click", Category: CategoryInteraction, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, + {BrowserEvent{Type: "monitor.connected", Category: CategorySystem, SourceKind: SourceKernelAPI, Seq: 1, Ts: 1}, "system.log", "system"}, } - require.NoError(t, fw.Write(ev)) - data, err := os.ReadFile(filepath.Join(dir, "console.log")) - require.NoError(t, err) + for _, e := range eventsToFile { + require.NoError(t, fw.Write(e.ev)) + } - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - require.Len(t, lines, 1) - assert.True(t, json.Valid([]byte(lines[0]))) - assert.Contains(t, lines[0], `"capture_session_id"`) - assert.Contains(t, lines[0], `"console_log"`) + for _, e := range eventsToFile { + data, err := os.ReadFile(filepath.Join(dir, e.file)) + require.NoError(t, err, "missing file %s for type %s", e.file, e.ev.Type) + + line := bytes.TrimRight(data, "\n") + require.True(t, json.Valid(line), "invalid JSON in %s", e.file) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(line, &decoded)) + assert.Equal(t, e.category, decoded["category"], "wrong category in %s", e.file) + assert.Equal(t, string(e.ev.SourceKind), decoded["source_kind"], "wrong source_kind in %s", e.file) + } }) - t.Run("category_routing", func(t *testing.T) { + t.Run("empty_category_rejected", func(t *testing.T) { dir := t.TempDir() fw := NewFileWriter(dir) defer fw.Close() - typeToFile := map[string]string{ - "console_log": "console.log", - "network_request": "network.log", - "liveview_click": "liveview.log", - "captcha_solve": "captcha.log", - "cdp_navigation": "cdp.log", - } - - for typ := range typeToFile { - require.NoError(t, fw.Write(BrowserEvent{Type: typ, Seq: 1, Ts: 1})) - } - - for typ, file := range typeToFile { - data, err := os.ReadFile(filepath.Join(dir, file)) - require.NoError(t, err, "missing file for type %s", typ) - assert.True(t, json.Valid(bytes.TrimRight(data, "\n"))) - } + err := fw.Write(BrowserEvent{Type: "mystery", Category: "", SourceKind: SourceCDP, Seq: 1, Ts: 1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty category") }) t.Run("concurrent_writes", func(t *testing.T) { @@ -285,9 +341,11 @@ func TestFileWriter(t *testing.T) { defer wg.Done() for j := 0; j < eventsPerGoroutine; j++ { ev := BrowserEvent{ - Seq: uint64(i*eventsPerGoroutine + j), - Type: "console_log", - Ts: 1, + Seq: uint64(i*eventsPerGoroutine + j), + Type: "console.log", + Category: CategoryConsole, + SourceKind: SourceCDP, + Ts: 1, } require.NoError(t, fw.Write(ev)) } @@ -310,12 +368,11 @@ func TestFileWriter(t *testing.T) { fw := NewFileWriter(dir) defer fw.Close() - // No writes yet — directory should be empty. entries, err := os.ReadDir(dir) require.NoError(t, err) assert.Empty(t, entries, "files opened before first Write") - require.NoError(t, fw.Write(BrowserEvent{Type: "console_log", Seq: 1, Ts: 1})) + require.NoError(t, fw.Write(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Seq: 1, Ts: 1})) entries, err = os.ReadDir(dir) require.NoError(t, err) @@ -341,7 +398,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() for i := 0; i < 3; i++ { - p.Publish(BrowserEvent{Type: "cdp_event", Ts: 1}) + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Ts: 1}) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -359,7 +416,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() before := time.Now().UnixMilli() - p.Publish(BrowserEvent{Type: "cdp_event"}) // Ts == 0 + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP}) // Ts == 0 after := time.Now().UnixMilli() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -374,7 +431,7 @@ func TestPipeline(t *testing.T) { t.Run("publish_writes_file", func(t *testing.T) { p, dir := newPipeline(t) - p.Publish(BrowserEvent{Type: "console_log", Ts: 1}) + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1}) data, err := os.ReadFile(filepath.Join(dir, "console.log")) require.NoError(t, err) @@ -382,22 +439,22 @@ func TestPipeline(t *testing.T) { lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") require.Len(t, lines, 1) assert.True(t, json.Valid([]byte(lines[0]))) - assert.Contains(t, lines[0], `"console_log"`) + assert.Contains(t, lines[0], `"console.log"`) }) t.Run("publish_writes_ring", func(t *testing.T) { p, _ := newPipeline(t) - // Subscribe reader BEFORE publish. reader := p.NewReader() - p.Publish(BrowserEvent{Type: "cdp_event", Ts: 1}) + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() ev, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "cdp_event", ev.Type) + assert.Equal(t, "page.navigation", ev.Type) + assert.Equal(t, CategoryPage, ev.Category) }) t.Run("start_sets_capture_session_id", func(t *testing.T) { @@ -405,7 +462,7 @@ func TestPipeline(t *testing.T) { p.Start("test-uuid") reader := p.NewReader() - p.Publish(BrowserEvent{Type: "cdp_event", Ts: 1}) + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -424,51 +481,48 @@ func TestPipeline(t *testing.T) { require.NoError(t, err) p.Publish(BrowserEvent{ - Type: "cdp_event", - Ts: 1, - Data: json.RawMessage(rawData), + Type: "page.navigation", + Category: CategoryPage, + SourceKind: SourceCDP, + Ts: 1, + Data: json.RawMessage(rawData), }) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - // Ring buffer event must have Truncated==true. ev, err := reader.Read(ctx) require.NoError(t, err) assert.True(t, ev.Truncated) + assert.True(t, json.Valid(ev.Data)) + + marshaled, err := json.Marshal(ev) + require.NoError(t, err) + assert.LessOrEqual(t, len(marshaled), maxS2RecordBytes) - // File must contain valid JSON with truncated==true. - data, err := os.ReadFile(filepath.Join(dir, "cdp.log")) + data, err := os.ReadFile(filepath.Join(dir, "page.log")) require.NoError(t, err) lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") require.Len(t, lines, 1) - assert.True(t, json.Valid([]byte(lines[0]))) assert.Contains(t, lines[0], `"truncated":true`) }) -} -// TestTruncation: construct event with Data = 1.1MB JSON bytes; call truncateIfNeeded; -// assert Truncated==true and json.Valid(result.Data)==true and len(marshal(result)) <= 1_000_000. -func TestTruncation(t *testing.T) { - // Build a Data field that is ~1.1MB - largeData := strings.Repeat("x", 1_100_000) - rawData, err := json.Marshal(map[string]string{"payload": largeData}) - require.NoError(t, err) + t.Run("defaults_detail_level", func(t *testing.T) { + p, _ := newPipeline(t) + reader := p.NewReader() - ev := BrowserEvent{ - CaptureSessionID: "test-session", - Seq: 1, - Ts: 1000, - Type: "cdp_event", - Data: json.RawMessage(rawData), - } + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1}) - result := truncateIfNeeded(ev) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() - assert.True(t, result.Truncated) - assert.True(t, json.Valid(result.Data)) + ev, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, DetailDefault, ev.DetailLevel) - marshaled, err := json.Marshal(result) - require.NoError(t, err) - assert.LessOrEqual(t, len(marshaled), 1_000_000) + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1, DetailLevel: DetailVerbose}) + ev2, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, DetailVerbose, ev2.DetailLevel) + }) } diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index 11661150..ed2f3a58 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -13,13 +13,14 @@ type Pipeline struct { ring *RingBuffer files *FileWriter seq atomic.Uint64 - captureSessionID atomic.Value // stores string + captureSessionID atomic.Pointer[string] } // NewPipeline returns a Pipeline backed by the supplied ring and file writer. func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { p := &Pipeline{ring: ring, files: files} - p.captureSessionID.Store("") + empty := "" + p.captureSessionID.Store(&empty) return p } @@ -27,7 +28,7 @@ func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { // published event. It may be called at any time; the change is immediately // visible to concurrent Publish calls. func (p *Pipeline) Start(captureSessionID string) { - p.captureSessionID.Store(captureSessionID) + p.captureSessionID.Store(&captureSessionID) } // Publish stamps, truncates, files, and broadcasts a single event. @@ -41,17 +42,17 @@ func (p *Pipeline) Start(captureSessionID string) { // Errors from FileWriter.Write are silently dropped; the ring buffer always // receives the event even if the file write fails. func (p *Pipeline) Publish(ev BrowserEvent) { - ev.CaptureSessionID = p.captureSessionID.Load().(string) + ev.CaptureSessionID = *p.captureSessionID.Load() ev.Seq = p.seq.Add(1) // starts at 1 if ev.Ts == 0 { ev.Ts = time.Now().UnixMilli() } + if ev.DetailLevel == "" { + ev.DetailLevel = DetailDefault + } ev = truncateIfNeeded(ev) - // File write first — durable before in-memory. _ = p.files.Write(ev) - - // Ring buffer last — readers see the event after the file is written. p.ring.Publish(ev) } From 997edb4fdb8b6201b7c7b6cac4af5fbab5a0d756 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 27 Mar 2026 11:37:16 +0000 Subject: [PATCH 07/21] review: remove dead RingBuffer count field, fix FileWriter mutex doc, add concurrent publish+read race test --- server/lib/events/filewriter.go | 22 +++++++++------------- server/lib/events/ringbuffer.go | 14 +++++++------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/server/lib/events/filewriter.go b/server/lib/events/filewriter.go index 4b40d204..3d01b76c 100644 --- a/server/lib/events/filewriter.go +++ b/server/lib/events/filewriter.go @@ -1,7 +1,6 @@ package events import ( - "bytes" "encoding/json" "fmt" "os" @@ -10,8 +9,8 @@ import ( ) // FileWriter is a per-category JSONL appender. It opens each log file lazily on -// first write (O_APPEND|O_CREATE|O_WRONLY) and serialises concurrent writes -// within a category with a single mutex. +// first write (O_APPEND|O_CREATE|O_WRONLY) and serialises all concurrent writes +// with a single mutex. type FileWriter struct { mu sync.Mutex files map[EventCategory]*os.File @@ -25,16 +24,17 @@ func NewFileWriter(dir string) *FileWriter { } // Write serialises ev to JSON and appends it as a single JSONL line to the -// per-category log file. The mutex is held for the entire open+marshal+write -// sequence to prevent TOCTOU races and to guarantee whole-line atomicity for -// events larger than PIPE_BUF. +// per-category log file. The mutex guarantees whole-line atomicity across +// concurrent callers. func (fw *FileWriter) Write(ev BrowserEvent) error { - cat := CategoryFor(ev.Type) + cat := ev.Category + if cat == "" { + return fmt.Errorf("filewriter: event %q has empty category", ev.Type) + } fw.mu.Lock() defer fw.mu.Unlock() - // Lazy open. f, ok := fw.files[cat] if !ok { path := filepath.Join(fw.dir, string(cat)+".log") @@ -51,11 +51,7 @@ func (fw *FileWriter) Write(ev BrowserEvent) error { return fmt.Errorf("filewriter: marshal: %w", err) } - var buf bytes.Buffer - buf.Write(data) - buf.WriteByte('\n') - - if _, err := f.Write(buf.Bytes()); err != nil { + if _, err := f.Write(append(data, '\n')); err != nil { return fmt.Errorf("filewriter: write: %w", err) } diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 3911912e..384025c8 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -15,7 +15,6 @@ type RingBuffer struct { mu sync.RWMutex buf []BrowserEvent head int // next write position (mod cap) - count int // items currently stored (0..cap) written uint64 // total ever published (monotonic) notify chan struct{} } @@ -35,9 +34,6 @@ func (rb *RingBuffer) Publish(ev BrowserEvent) { rb.mu.Lock() rb.buf[rb.head] = ev rb.head = (rb.head + 1) % len(rb.buf) - if rb.count < len(rb.buf) { - rb.count++ - } rb.written++ old := rb.notify rb.notify = make(chan struct{}) @@ -54,7 +50,7 @@ func (rb *RingBuffer) oldestSeq() uint64 { return rb.written - uint64(len(rb.buf)) } -// NewReader returns a Reader positioned at seq 0. +// NewReader returns a Reader positioned at publish index 0 (the very beginning of the ring). // If the ring has already published events, the reader will receive an // events_dropped BrowserEvent on the first Read call if it has fallen behind // the oldest retained event. @@ -63,9 +59,13 @@ func (rb *RingBuffer) NewReader() *Reader { } // Reader tracks an independent read position in a RingBuffer. +// A Reader must not be used concurrently from multiple goroutines. +// +// nextSeq is a monotonic count of publishes consumed by this reader — it is +// an index into the ring, not the BrowserEvent.Seq field. type Reader struct { rb *RingBuffer - nextSeq uint64 + nextSeq uint64 // publish index, not BrowserEvent.Seq } // Read blocks until the next event is available or ctx is cancelled. @@ -85,7 +85,7 @@ func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { r.nextSeq = oldest r.rb.mu.RUnlock() data := json.RawMessage(fmt.Sprintf(`{"dropped":%d}`, dropped)) - return BrowserEvent{Type: "events_dropped", Data: data}, nil + return BrowserEvent{Type: "events.dropped", Category: CategorySystem, SourceKind: SourceKernelAPI, Data: data}, nil } // Event is available — read it. From e5153da1bc2e3d5c8dfcea4dfd84fa8c61b33cc3 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 27 Mar 2026 12:30:00 +0000 Subject: [PATCH 08/21] chore: clean up maxS2RecordBytes comment --- server/lib/events/event.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index b174147e..2b1569fc 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -4,9 +4,7 @@ import ( "encoding/json" ) -// maxS2RecordBytes is the S2 event pipeline maximum record size (1 MB). -// Events exceeding this limit have their Data field replaced with null and -// Truncated set to true before being written to the file and ring sinks. +// maxS2RecordBytes is the maximum record size for the S2 event pipeline (1 MB). const maxS2RecordBytes = 1_000_000 // EventCategory is a first-class envelope field that determines log file routing. From 1644fe726cb9f9de3a69e053f226dad4bbe76364 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 27 Mar 2026 12:30:03 +0000 Subject: [PATCH 09/21] fix: serialise Pipeline.Publish to guarantee monotonic seq delivery order --- server/lib/events/events_test.go | 36 +++++++++++++++++++++++++++++++- server/lib/events/pipeline.go | 9 +++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index deb390c0..dc6f05ec 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestBrowserEventSerialization: round-trip marshal/unmarshal verifying all SCHEMA-01 +// TestBrowserEventSerialization: round-trip marshal/unmarshal verifying all // envelope fields serialize with correct JSON keys and values, including provenance. func TestBrowserEventSerialization(t *testing.T) { ev := BrowserEvent{ @@ -393,6 +393,40 @@ func TestPipeline(t *testing.T) { return p, dir } + t.Run("concurrent_publish_seq_order", func(t *testing.T) { + const goroutines = 8 + const eventsEach = 50 + const total = goroutines * eventsEach + + // Ring must hold all events so no drop sentinels are emitted. + rb := NewRingBuffer(total) + fw := NewFileWriter(t.TempDir()) + p := NewPipeline(rb, fw) + t.Cleanup(func() { p.Close() }) + reader := p.NewReader() + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < eventsEach; j++ { + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1}) + } + }() + } + wg.Wait() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for want := uint64(1); want <= total; want++ { + ev, err := reader.Read(ctx) + require.NoError(t, err) + assert.Equal(t, want, ev.Seq, "events must arrive in seq order") + } + }) + t.Run("publish_increments_seq", func(t *testing.T) { p, _ := newPipeline(t) reader := p.NewReader() diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index ed2f3a58..b7184abc 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -1,6 +1,7 @@ package events import ( + "sync" "sync/atomic" "time" ) @@ -10,6 +11,7 @@ import ( // applies truncation, durably appends it to the per-category log file, and // then makes it available to ring buffer readers. type Pipeline struct { + mu sync.Mutex ring *RingBuffer files *FileWriter seq atomic.Uint64 @@ -35,13 +37,18 @@ func (p *Pipeline) Start(captureSessionID string) { // // Ordering: // 1. Stamp CaptureSessionID, Seq, Ts (Ts only if caller left it zero) -// 2. Apply truncateIfNeeded (SCHEMA-04) — must happen before both sinks +// 2. Apply truncateIfNeeded — must happen before both sinks // 3. Write to FileWriter (durable before in-memory) // 4. Publish to RingBuffer (in-memory fan-out) // +// The mutex serialises concurrent callers so that seq assignment and sink +// delivery are atomic — readers always see events in seq order. // Errors from FileWriter.Write are silently dropped; the ring buffer always // receives the event even if the file write fails. func (p *Pipeline) Publish(ev BrowserEvent) { + p.mu.Lock() + defer p.mu.Unlock() + ev.CaptureSessionID = *p.captureSessionID.Load() ev.Seq = p.seq.Add(1) // starts at 1 if ev.Ts == 0 { From 36cff2dd9f0df9ab3c54261ad0966e48726629d1 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 30 Mar 2026 15:01:13 +0000 Subject: [PATCH 10/21] review --- server/lib/events/event.go | 48 ++++++-------- server/lib/events/events_test.go | 108 ++++++++++++++----------------- server/lib/events/filewriter.go | 20 ++---- server/lib/events/pipeline.go | 27 +++----- server/lib/events/ringbuffer.go | 24 ++----- 5 files changed, 89 insertions(+), 138 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index 2b1569fc..53dfba67 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -7,7 +7,7 @@ import ( // maxS2RecordBytes is the maximum record size for the S2 event pipeline (1 MB). const maxS2RecordBytes = 1_000_000 -// EventCategory is a first-class envelope field that determines log file routing. +// EventCategory determines type of logging type EventCategory string const ( @@ -20,17 +20,15 @@ const ( CategorySystem EventCategory = "system" ) -// SourceKind identifies the provenance of an event — which subsystem produced it. -type SourceKind string +type Source string const ( - SourceCDP SourceKind = "cdp" - SourceKernelAPI SourceKind = "kernel_api" - SourceExtension SourceKind = "extension" - SourceLocalProcess SourceKind = "local_process" + SourceCDP Source = "cdp" + SourceKernelAPI Source = "kernel_api" + SourceExtension Source = "extension" + SourceLocalProcess Source = "local_process" ) -// DetailLevel controls the verbosity of the event payload. type DetailLevel string const ( @@ -41,23 +39,13 @@ const ( ) // BrowserEvent is the canonical event structure for the browser capture pipeline. -// -// The envelope is designed so that capture config and subscription selectors -// can operate on stable, first-class fields (Category, SourceKind, DetailLevel) -// without parsing the Type string. Type carries semantic identity (e.g. -// "console.log", "network.request"); SourceEvent carries the raw upstream -// event name (e.g. "Runtime.consoleAPICalled") for diagnostics. -// -// DetailLevel is always serialised (no omitempty). Pipeline.Publish defaults it -// to DetailDefault; callers constructing events outside a Pipeline should set it -// explicitly. type BrowserEvent struct { CaptureSessionID string `json:"capture_session_id"` Seq uint64 `json:"seq"` Ts int64 `json:"ts"` Type string `json:"type"` Category EventCategory `json:"category"` - SourceKind SourceKind `json:"source_kind"` + Source Source `json:"source"` SourceEvent string `json:"source_event,omitempty"` DetailLevel DetailLevel `json:"detail_level"` TargetID string `json:"target_id,omitempty"` @@ -69,20 +57,20 @@ type BrowserEvent struct { Truncated bool `json:"truncated,omitempty"` } -// truncateIfNeeded returns a copy of ev with Data replaced with json.RawMessage("null") -// and Truncated set to true if the marshaled size exceeds maxS2RecordBytes. -// Never attempt byte-slice truncation of the Data field — partial JSON is invalid. -func truncateIfNeeded(ev BrowserEvent) BrowserEvent { - candidate, err := json.Marshal(ev) +// truncateIfNeeded marshals ev and returns the (possibly truncated) event together +func truncateIfNeeded(ev BrowserEvent) (BrowserEvent, []byte) { + data, err := json.Marshal(ev) if err != nil { - // Marshal should never fail for BrowserEvent (all fields are JSON-safe), - // but if it does return ev unchanged rather than silently nulling Data. - return ev + return ev, data } - if len(candidate) <= maxS2RecordBytes { - return ev + if len(data) <= maxS2RecordBytes { + return ev, data } ev.Data = json.RawMessage("null") ev.Truncated = true - return ev + data, err = json.Marshal(ev) + if err != nil { + return ev, nil + } + return ev, data } diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index dc6f05ec..09c82e0c 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -15,8 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestBrowserEventSerialization: round-trip marshal/unmarshal verifying all -// envelope fields serialize with correct JSON keys and values, including provenance. func TestBrowserEventSerialization(t *testing.T) { ev := BrowserEvent{ CaptureSessionID: "test-session-id", @@ -24,7 +22,7 @@ func TestBrowserEventSerialization(t *testing.T) { Ts: 1234567890000, Type: "console.log", Category: CategoryConsole, - SourceKind: SourceCDP, + Source: SourceCDP, SourceEvent: "Runtime.consoleAPICalled", DetailLevel: DetailDefault, TargetID: "target-1", @@ -43,7 +41,7 @@ func TestBrowserEventSerialization(t *testing.T) { assert.Equal(t, "console.log", decoded["type"]) assert.Equal(t, "console", decoded["category"]) - assert.Equal(t, "cdp", decoded["source_kind"]) + assert.Equal(t, "cdp", decoded["source"]) assert.Equal(t, "Runtime.consoleAPICalled", decoded["source_event"]) assert.Equal(t, "default", decoded["detail_level"]) assert.Equal(t, "test-session-id", decoded["capture_session_id"]) @@ -55,8 +53,6 @@ func TestBrowserEventSerialization(t *testing.T) { assert.Equal(t, "https://example.com", decoded["url"]) } -// TestBrowserEventData: embed a pre-serialized JSON object in Data field; marshal outer event; -// assert Data appears verbatim (no double-encoding). func TestBrowserEventData(t *testing.T) { rawData := json.RawMessage(`{"key":"value","num":42}`) ev := BrowserEvent{ @@ -65,7 +61,7 @@ func TestBrowserEventData(t *testing.T) { Ts: 1000, Type: "page.navigation", Category: CategoryPage, - SourceKind: SourceCDP, + Source: SourceCDP, Data: rawData, } @@ -74,10 +70,9 @@ func TestBrowserEventData(t *testing.T) { s := string(b) assert.Contains(t, s, `"data":{"key":"value","num":42}`) - assert.NotContains(t, s, `"data":"{`) // would indicate double-encoding + assert.NotContains(t, s, `"data":"{`) } -// TestBrowserEventOmitEmpty: source_event is omitted when empty; detail_level always present. func TestBrowserEventOmitEmpty(t *testing.T) { ev := BrowserEvent{ CaptureSessionID: "sess", @@ -85,7 +80,7 @@ func TestBrowserEventOmitEmpty(t *testing.T) { Ts: 1000, Type: "console.log", Category: CategoryConsole, - SourceKind: SourceCDP, + Source: SourceCDP, } b, err := json.Marshal(ev) @@ -93,19 +88,18 @@ func TestBrowserEventOmitEmpty(t *testing.T) { s := string(b) assert.NotContains(t, s, `"source_event"`) - // detail_level is always serialized (not omitempty) — zero value is "" assert.Contains(t, s, `"detail_level"`) } -// TestRingBuffer: publish 3 events; reader reads all 3 in order. +// TestRingBuffer: publish 3 events; reader reads all 3 in order func TestRingBuffer(t *testing.T) { rb := NewRingBuffer(10) reader := rb.NewReader() events := []BrowserEvent{ - {Seq: 1, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}, - {Seq: 2, Type: "network.request", Category: CategoryNetwork, SourceKind: SourceCDP}, - {Seq: 3, Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP}, + {Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}, + {Seq: 2, Type: "network.request", Category: CategoryNetwork, Source: SourceCDP}, + {Seq: 3, Type: "page.navigation", Category: CategoryPage, Source: SourceCDP}, } for _, ev := range events { @@ -123,16 +117,15 @@ func TestRingBuffer(t *testing.T) { } } -// TestRingBufferOverflowNoBlock: writer never blocks even with no readers; -// late-joining reader gets events.dropped with correct envelope fields. +// TestRingBufferOverflowNoBlock: writer never blocks even with no readers func TestRingBufferOverflowNoBlock(t *testing.T) { rb := NewRingBuffer(2) done := make(chan struct{}) go func() { - rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) close(done) }() @@ -150,18 +143,16 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { require.NoError(t, err) assert.Equal(t, "events.dropped", first.Type) assert.Equal(t, CategorySystem, first.Category) - assert.Equal(t, SourceKernelAPI, first.SourceKind) + assert.Equal(t, SourceKernelAPI, first.Source) } -// TestRingBufferOverflowExistingReader: reader created before overflow -// gets events.dropped with exact count, then continues reading. func TestRingBufferOverflowExistingReader(t *testing.T) { rb := NewRingBuffer(2) reader := rb.NewReader() - rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -176,7 +167,6 @@ func TestRingBufferOverflowExistingReader(t *testing.T) { assert.JSONEq(t, `{"dropped":1}`, string(first.Data)) // After the drop sentinel the reader continues with the surviving events - // (seq 2 and 3, which fit in the capacity-2 buffer). second, err := reader.Read(ctx) require.NoError(t, err) assert.Equal(t, uint64(2), second.Seq) @@ -186,8 +176,6 @@ func TestRingBufferOverflowExistingReader(t *testing.T) { assert.Equal(t, uint64(3), third.Seq) } -// TestConcurrentPublishRead: readers blocked on Read while a writer publishes -// concurrently — exercises locking and notify paths under go test -race. func TestConcurrentPublishRead(t *testing.T) { const numEvents = 20 rb := NewRingBuffer(32) @@ -199,7 +187,6 @@ func TestConcurrentPublishRead(t *testing.T) { var wg sync.WaitGroup - // Reader goroutine: reads numEvents events. wg.Add(1) go func() { defer wg.Done() @@ -211,7 +198,6 @@ func TestConcurrentPublishRead(t *testing.T) { } }() - // Writer goroutine: publishes numEvents events. wg.Add(1) go func() { defer wg.Done() @@ -220,7 +206,7 @@ func TestConcurrentPublishRead(t *testing.T) { Seq: uint64(i), Type: "console.log", Category: CategoryConsole, - SourceKind: SourceCDP, + Source: SourceCDP, }) } }() @@ -228,8 +214,6 @@ func TestConcurrentPublishRead(t *testing.T) { wg.Wait() } -// TestConcurrentReaders: 3 readers subscribe before publish; publish 5 events; -// each reader independently reads all 5; no reader affects another. func TestConcurrentReaders(t *testing.T) { rb := NewRingBuffer(20) @@ -242,7 +226,7 @@ func TestConcurrentReaders(t *testing.T) { } for i := 0; i < numEvents; i++ { - rb.Publish(BrowserEvent{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP}) + rb.Publish(BrowserEvent{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) } var wg sync.WaitGroup @@ -289,17 +273,19 @@ func TestFileWriter(t *testing.T) { file string category string }{ - {BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "console.log", "console"}, - {BrowserEvent{Type: "network.request", Category: CategoryNetwork, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "network.log", "network"}, - {BrowserEvent{Type: "liveview.click", Category: CategoryLiveview, SourceKind: SourceKernelAPI, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, - {BrowserEvent{Type: "captcha.solve", Category: CategoryCaptcha, SourceKind: SourceExtension, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, - {BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "page.log", "page"}, - {BrowserEvent{Type: "input.click", Category: CategoryInteraction, SourceKind: SourceCDP, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, - {BrowserEvent{Type: "monitor.connected", Category: CategorySystem, SourceKind: SourceKernelAPI, Seq: 1, Ts: 1}, "system.log", "system"}, + {BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1}, "console.log", "console"}, + {BrowserEvent{Type: "network.request", Category: CategoryNetwork, Source: SourceCDP, Seq: 1, Ts: 1}, "network.log", "network"}, + {BrowserEvent{Type: "liveview.click", Category: CategoryLiveview, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, + {BrowserEvent{Type: "captcha.solve", Category: CategoryCaptcha, Source: SourceExtension, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, + {BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Seq: 1, Ts: 1}, "page.log", "page"}, + {BrowserEvent{Type: "input.click", Category: CategoryInteraction, Source: SourceCDP, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, + {BrowserEvent{Type: "monitor.connected", Category: CategorySystem, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "system.log", "system"}, } for _, e := range eventsToFile { - require.NoError(t, fw.Write(e.ev)) + data, err := json.Marshal(e.ev) + require.NoError(t, err) + require.NoError(t, fw.Write(e.ev, data)) } for _, e := range eventsToFile { @@ -312,7 +298,7 @@ func TestFileWriter(t *testing.T) { var decoded map[string]any require.NoError(t, json.Unmarshal(line, &decoded)) assert.Equal(t, e.category, decoded["category"], "wrong category in %s", e.file) - assert.Equal(t, string(e.ev.SourceKind), decoded["source_kind"], "wrong source_kind in %s", e.file) + assert.Equal(t, string(e.ev.Source), decoded["source"], "wrong source in %s", e.file) } }) @@ -321,7 +307,9 @@ func TestFileWriter(t *testing.T) { fw := NewFileWriter(dir) defer fw.Close() - err := fw.Write(BrowserEvent{Type: "mystery", Category: "", SourceKind: SourceCDP, Seq: 1, Ts: 1}) + ev := BrowserEvent{Type: "mystery", Category: "", Source: SourceCDP, Seq: 1, Ts: 1} + data, _ := json.Marshal(ev) + err := fw.Write(ev, data) require.Error(t, err) assert.Contains(t, err.Error(), "empty category") }) @@ -344,10 +332,12 @@ func TestFileWriter(t *testing.T) { Seq: uint64(i*eventsPerGoroutine + j), Type: "console.log", Category: CategoryConsole, - SourceKind: SourceCDP, + Source: SourceCDP, Ts: 1, } - require.NoError(t, fw.Write(ev)) + evData, err := json.Marshal(ev) + require.NoError(t, err) + require.NoError(t, fw.Write(ev, evData)) } }(i) } @@ -372,7 +362,10 @@ func TestFileWriter(t *testing.T) { require.NoError(t, err) assert.Empty(t, entries, "files opened before first Write") - require.NoError(t, fw.Write(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Seq: 1, Ts: 1})) + lazyEv := BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1} + lazyData, err := json.Marshal(lazyEv) + require.NoError(t, err) + require.NoError(t, fw.Write(lazyEv, lazyData)) entries, err = os.ReadDir(dir) require.NoError(t, err) @@ -381,7 +374,6 @@ func TestFileWriter(t *testing.T) { }) } -// TestPipeline: Pipeline glue type tests. func TestPipeline(t *testing.T) { newPipeline := func(t *testing.T) (*Pipeline, string) { t.Helper() @@ -411,7 +403,7 @@ func TestPipeline(t *testing.T) { go func() { defer wg.Done() for j := 0; j < eventsEach; j++ { - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1}) + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) } }() } @@ -432,7 +424,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() for i := 0; i < 3; i++ { - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Ts: 1}) + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -450,7 +442,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() before := time.Now().UnixMilli() - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP}) // Ts == 0 + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP}) // Ts == 0 after := time.Now().UnixMilli() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -465,7 +457,7 @@ func TestPipeline(t *testing.T) { t.Run("publish_writes_file", func(t *testing.T) { p, dir := newPipeline(t) - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1}) + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) data, err := os.ReadFile(filepath.Join(dir, "console.log")) require.NoError(t, err) @@ -480,7 +472,7 @@ func TestPipeline(t *testing.T) { p, _ := newPipeline(t) reader := p.NewReader() - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Ts: 1}) + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -496,7 +488,7 @@ func TestPipeline(t *testing.T) { p.Start("test-uuid") reader := p.NewReader() - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, SourceKind: SourceCDP, Ts: 1}) + p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -517,7 +509,7 @@ func TestPipeline(t *testing.T) { p.Publish(BrowserEvent{ Type: "page.navigation", Category: CategoryPage, - SourceKind: SourceCDP, + Source: SourceCDP, Ts: 1, Data: json.RawMessage(rawData), }) @@ -545,7 +537,7 @@ func TestPipeline(t *testing.T) { p, _ := newPipeline(t) reader := p.NewReader() - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1}) + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -554,7 +546,7 @@ func TestPipeline(t *testing.T) { require.NoError(t, err) assert.Equal(t, DetailDefault, ev.DetailLevel) - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, SourceKind: SourceCDP, Ts: 1, DetailLevel: DetailVerbose}) + p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1, DetailLevel: DetailVerbose}) ev2, err := reader.Read(ctx) require.NoError(t, err) assert.Equal(t, DetailVerbose, ev2.DetailLevel) diff --git a/server/lib/events/filewriter.go b/server/lib/events/filewriter.go index 3d01b76c..87bad172 100644 --- a/server/lib/events/filewriter.go +++ b/server/lib/events/filewriter.go @@ -1,7 +1,6 @@ package events import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -10,23 +9,20 @@ import ( // FileWriter is a per-category JSONL appender. It opens each log file lazily on // first write (O_APPEND|O_CREATE|O_WRONLY) and serialises all concurrent writes -// with a single mutex. +// with a single mutex type FileWriter struct { mu sync.Mutex files map[EventCategory]*os.File dir string } -// NewFileWriter returns a FileWriter that writes to dir. -// No files are opened until the first Write call. +// NewFileWriter returns a FileWriter that writes to dir func NewFileWriter(dir string) *FileWriter { return &FileWriter{dir: dir, files: make(map[EventCategory]*os.File)} } -// Write serialises ev to JSON and appends it as a single JSONL line to the -// per-category log file. The mutex guarantees whole-line atomicity across -// concurrent callers. -func (fw *FileWriter) Write(ev BrowserEvent) error { +// Write appends data as a single JSONL line to the per-category log file for ev +func (fw *FileWriter) Write(ev BrowserEvent, data []byte) error { cat := ev.Category if cat == "" { return fmt.Errorf("filewriter: event %q has empty category", ev.Type) @@ -46,11 +42,6 @@ func (fw *FileWriter) Write(ev BrowserEvent) error { fw.files[cat] = f } - data, err := json.Marshal(ev) - if err != nil { - return fmt.Errorf("filewriter: marshal: %w", err) - } - if _, err := f.Write(append(data, '\n')); err != nil { return fmt.Errorf("filewriter: write: %w", err) } @@ -58,8 +49,7 @@ func (fw *FileWriter) Write(ev BrowserEvent) error { return nil } -// Close closes all open log file descriptors. The first encountered error is -// returned; subsequent files are still closed. +// Close closes all open log file descriptors func (fw *FileWriter) Close() error { fw.mu.Lock() defer fw.mu.Unlock() diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index b7184abc..c6f93dcb 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -1,15 +1,13 @@ package events import ( + "log/slog" "sync" "sync/atomic" "time" ) -// Pipeline glues a RingBuffer and a FileWriter into a single write path. -// A single call to Publish stamps the event with a monotonic sequence number, -// applies truncation, durably appends it to the per-category log file, and -// then makes it available to ring buffer readers. +// Pipeline glues a RingBuffer and a FileWriter into a single write path type Pipeline struct { mu sync.Mutex ring *RingBuffer @@ -18,7 +16,6 @@ type Pipeline struct { captureSessionID atomic.Pointer[string] } -// NewPipeline returns a Pipeline backed by the supplied ring and file writer. func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { p := &Pipeline{ring: ring, files: files} empty := "" @@ -27,8 +24,7 @@ func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { } // Start sets the capture session ID that will be stamped on every subsequent -// published event. It may be called at any time; the change is immediately -// visible to concurrent Publish calls. +// published event func (p *Pipeline) Start(captureSessionID string) { p.captureSessionID.Store(&captureSessionID) } @@ -40,36 +36,33 @@ func (p *Pipeline) Start(captureSessionID string) { // 2. Apply truncateIfNeeded — must happen before both sinks // 3. Write to FileWriter (durable before in-memory) // 4. Publish to RingBuffer (in-memory fan-out) -// -// The mutex serialises concurrent callers so that seq assignment and sink -// delivery are atomic — readers always see events in seq order. -// Errors from FileWriter.Write are silently dropped; the ring buffer always -// receives the event even if the file write fails. func (p *Pipeline) Publish(ev BrowserEvent) { p.mu.Lock() defer p.mu.Unlock() ev.CaptureSessionID = *p.captureSessionID.Load() - ev.Seq = p.seq.Add(1) // starts at 1 + ev.Seq = p.seq.Add(1) if ev.Ts == 0 { ev.Ts = time.Now().UnixMilli() } if ev.DetailLevel == "" { ev.DetailLevel = DetailDefault } - ev = truncateIfNeeded(ev) + ev, data := truncateIfNeeded(ev) - _ = p.files.Write(ev) + if err := p.files.Write(ev, data); err != nil { + slog.Error("pipeline: file write failed", "seq", ev.Seq, "category", ev.Category, "err", err) + } p.ring.Publish(ev) } -// NewReader returns a Reader positioned at the start of the ring buffer. +// NewReader returns a Reader positioned at the start of the ring buffer func (p *Pipeline) NewReader() *Reader { return p.ring.NewReader() } // Close closes the underlying FileWriter, flushing and releasing all open -// file descriptors. +// file descriptors func (p *Pipeline) Close() error { return p.files.Close() } diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 384025c8..8d0b48ab 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -19,7 +19,6 @@ type RingBuffer struct { notify chan struct{} } -// NewRingBuffer creates a new RingBuffer with the given capacity. func NewRingBuffer(capacity int) *RingBuffer { return &RingBuffer{ buf: make([]BrowserEvent, capacity), @@ -29,7 +28,7 @@ func NewRingBuffer(capacity int) *RingBuffer { // Publish adds an event to the ring buffer, evicting the oldest entry on overflow. // Closes the current notify channel (waking all waiting readers) and replaces it -// with a new one — outside the lock to avoid blocking under contention. +// with a new one, outside the lock to avoid blocking under contention func (rb *RingBuffer) Publish(ev BrowserEvent) { rb.mu.Lock() rb.buf[rb.head] = ev @@ -41,8 +40,7 @@ func (rb *RingBuffer) Publish(ev BrowserEvent) { close(old) // outside lock to avoid blocking under contention } -// oldestSeq returns the seq of the oldest event still in the ring. -// Must be called under at least a read lock. +// oldestSeq returns the seq of the oldest event still in the ring func (rb *RingBuffer) oldestSeq() uint64 { if rb.written <= uint64(len(rb.buf)) { return 0 @@ -50,28 +48,21 @@ func (rb *RingBuffer) oldestSeq() uint64 { return rb.written - uint64(len(rb.buf)) } -// NewReader returns a Reader positioned at publish index 0 (the very beginning of the ring). +// NewReader returns a Reader positioned at publish index 0 // If the ring has already published events, the reader will receive an // events_dropped BrowserEvent on the first Read call if it has fallen behind -// the oldest retained event. +// the oldest retained event func (rb *RingBuffer) NewReader() *Reader { return &Reader{rb: rb, nextSeq: 0} } // Reader tracks an independent read position in a RingBuffer. -// A Reader must not be used concurrently from multiple goroutines. -// -// nextSeq is a monotonic count of publishes consumed by this reader — it is -// an index into the ring, not the BrowserEvent.Seq field. type Reader struct { rb *RingBuffer nextSeq uint64 // publish index, not BrowserEvent.Seq } -// Read blocks until the next event is available or ctx is cancelled. -// Returns (event, nil) for a normal event. -// Returns (events_dropped BrowserEvent, nil) if the reader has fallen behind -// the ring's oldest retained event — the dropped count is in Data as valid JSON. +// Read blocks until the next event is available or ctx is cancelled func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { for { r.rb.mu.RLock() @@ -79,16 +70,14 @@ func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { oldest := r.rb.oldestSeq() written := r.rb.written - // Reader fell behind — synthesize events_dropped before advancing. if r.nextSeq < oldest { dropped := oldest - r.nextSeq r.nextSeq = oldest r.rb.mu.RUnlock() data := json.RawMessage(fmt.Sprintf(`{"dropped":%d}`, dropped)) - return BrowserEvent{Type: "events.dropped", Category: CategorySystem, SourceKind: SourceKernelAPI, Data: data}, nil + return BrowserEvent{Type: "events.dropped", Category: CategorySystem, Source: SourceKernelAPI, Data: data}, nil } - // Event is available — read it. if r.nextSeq < written { idx := int(r.nextSeq % uint64(len(r.rb.buf))) ev := r.rb.buf[idx] @@ -97,7 +86,6 @@ func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { return ev, nil } - // No event yet — wait for notification. r.rb.mu.RUnlock() select { From 339d7d396fb90fafd94dbd739e9dc2d038c2f14b Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:04:59 +0000 Subject: [PATCH 11/21] refactor: rename BrowserEvent to Event, DetailDefault to DetailStandard Event is the agreed portable name. DetailStandard avoids Go keyword ambiguity with "default". --- server/lib/events/event.go | 8 ++-- server/lib/events/events_test.go | 80 ++++++++++++++++---------------- server/lib/events/filewriter.go | 2 +- server/lib/events/pipeline.go | 4 +- server/lib/events/ringbuffer.go | 18 +++---- 5 files changed, 56 insertions(+), 56 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index 53dfba67..3d88369c 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -33,13 +33,13 @@ type DetailLevel string const ( DetailMinimal DetailLevel = "minimal" - DetailDefault DetailLevel = "default" + DetailStandard DetailLevel = "standard" DetailVerbose DetailLevel = "verbose" DetailRaw DetailLevel = "raw" ) -// BrowserEvent is the canonical event structure for the browser capture pipeline. -type BrowserEvent struct { +// Event is the canonical event structure for the capture pipeline. +type Event struct { CaptureSessionID string `json:"capture_session_id"` Seq uint64 `json:"seq"` Ts int64 `json:"ts"` @@ -58,7 +58,7 @@ type BrowserEvent struct { } // truncateIfNeeded marshals ev and returns the (possibly truncated) event together -func truncateIfNeeded(ev BrowserEvent) (BrowserEvent, []byte) { +func truncateIfNeeded(ev Event) (Event, []byte) { data, err := json.Marshal(ev) if err != nil { return ev, data diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index 09c82e0c..c60252a3 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -15,8 +15,8 @@ import ( "github.com/stretchr/testify/require" ) -func TestBrowserEventSerialization(t *testing.T) { - ev := BrowserEvent{ +func TestEventSerialization(t *testing.T) { + ev := Event{ CaptureSessionID: "test-session-id", Seq: 1, Ts: 1234567890000, @@ -24,7 +24,7 @@ func TestBrowserEventSerialization(t *testing.T) { Category: CategoryConsole, Source: SourceCDP, SourceEvent: "Runtime.consoleAPICalled", - DetailLevel: DetailDefault, + DetailLevel: DetailStandard, TargetID: "target-1", CDPSessionID: "cdp-session-1", FrameID: "frame-1", @@ -43,7 +43,7 @@ func TestBrowserEventSerialization(t *testing.T) { assert.Equal(t, "console", decoded["category"]) assert.Equal(t, "cdp", decoded["source"]) assert.Equal(t, "Runtime.consoleAPICalled", decoded["source_event"]) - assert.Equal(t, "default", decoded["detail_level"]) + assert.Equal(t, "standard", decoded["detail_level"]) assert.Equal(t, "test-session-id", decoded["capture_session_id"]) assert.Equal(t, float64(1), decoded["seq"]) assert.Equal(t, "target-1", decoded["target_id"]) @@ -53,9 +53,9 @@ func TestBrowserEventSerialization(t *testing.T) { assert.Equal(t, "https://example.com", decoded["url"]) } -func TestBrowserEventData(t *testing.T) { +func TestEventData(t *testing.T) { rawData := json.RawMessage(`{"key":"value","num":42}`) - ev := BrowserEvent{ + ev := Event{ CaptureSessionID: "test-session", Seq: 1, Ts: 1000, @@ -73,8 +73,8 @@ func TestBrowserEventData(t *testing.T) { assert.NotContains(t, s, `"data":"{`) } -func TestBrowserEventOmitEmpty(t *testing.T) { - ev := BrowserEvent{ +func TestEventOmitEmpty(t *testing.T) { + ev := Event{ CaptureSessionID: "sess", Seq: 1, Ts: 1000, @@ -96,7 +96,7 @@ func TestRingBuffer(t *testing.T) { rb := NewRingBuffer(10) reader := rb.NewReader() - events := []BrowserEvent{ + events := []Event{ {Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}, {Seq: 2, Type: "network.request", Category: CategoryNetwork, Source: SourceCDP}, {Seq: 3, Type: "page.navigation", Category: CategoryPage, Source: SourceCDP}, @@ -123,9 +123,9 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { done := make(chan struct{}) go func() { - rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) close(done) }() @@ -150,9 +150,9 @@ func TestRingBufferOverflowExistingReader(t *testing.T) { rb := NewRingBuffer(2) reader := rb.NewReader() - rb.Publish(BrowserEvent{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(BrowserEvent{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -202,7 +202,7 @@ func TestConcurrentPublishRead(t *testing.T) { go func() { defer wg.Done() for i := 1; i <= numEvents; i++ { - rb.Publish(BrowserEvent{ + rb.Publish(Event{ Seq: uint64(i), Type: "console.log", Category: CategoryConsole, @@ -226,11 +226,11 @@ func TestConcurrentReaders(t *testing.T) { } for i := 0; i < numEvents; i++ { - rb.Publish(BrowserEvent{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) } var wg sync.WaitGroup - results := make([][]BrowserEvent, numReaders) + results := make([][]Event, numReaders) for i, r := range readers { wg.Add(1) @@ -239,7 +239,7 @@ func TestConcurrentReaders(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - var evs []BrowserEvent + var evs []Event for j := 0; j < numEvents; j++ { ev, err := reader.Read(ctx) if !assert.NoError(t, err) { @@ -269,17 +269,17 @@ func TestFileWriter(t *testing.T) { defer fw.Close() eventsToFile := []struct { - ev BrowserEvent + ev Event file string category string }{ - {BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1}, "console.log", "console"}, - {BrowserEvent{Type: "network.request", Category: CategoryNetwork, Source: SourceCDP, Seq: 1, Ts: 1}, "network.log", "network"}, - {BrowserEvent{Type: "liveview.click", Category: CategoryLiveview, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, - {BrowserEvent{Type: "captcha.solve", Category: CategoryCaptcha, Source: SourceExtension, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, - {BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Seq: 1, Ts: 1}, "page.log", "page"}, - {BrowserEvent{Type: "input.click", Category: CategoryInteraction, Source: SourceCDP, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, - {BrowserEvent{Type: "monitor.connected", Category: CategorySystem, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "system.log", "system"}, + {Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1}, "console.log", "console"}, + {Event{Type: "network.request", Category: CategoryNetwork, Source: SourceCDP, Seq: 1, Ts: 1}, "network.log", "network"}, + {Event{Type: "liveview.click", Category: CategoryLiveview, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, + {Event{Type: "captcha.solve", Category: CategoryCaptcha, Source: SourceExtension, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, + {Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Seq: 1, Ts: 1}, "page.log", "page"}, + {Event{Type: "input.click", Category: CategoryInteraction, Source: SourceCDP, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, + {Event{Type: "monitor.connected", Category: CategorySystem, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "system.log", "system"}, } for _, e := range eventsToFile { @@ -307,7 +307,7 @@ func TestFileWriter(t *testing.T) { fw := NewFileWriter(dir) defer fw.Close() - ev := BrowserEvent{Type: "mystery", Category: "", Source: SourceCDP, Seq: 1, Ts: 1} + ev := Event{Type: "mystery", Category: "", Source: SourceCDP, Seq: 1, Ts: 1} data, _ := json.Marshal(ev) err := fw.Write(ev, data) require.Error(t, err) @@ -328,7 +328,7 @@ func TestFileWriter(t *testing.T) { go func(i int) { defer wg.Done() for j := 0; j < eventsPerGoroutine; j++ { - ev := BrowserEvent{ + ev := Event{ Seq: uint64(i*eventsPerGoroutine + j), Type: "console.log", Category: CategoryConsole, @@ -362,7 +362,7 @@ func TestFileWriter(t *testing.T) { require.NoError(t, err) assert.Empty(t, entries, "files opened before first Write") - lazyEv := BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1} + lazyEv := Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1} lazyData, err := json.Marshal(lazyEv) require.NoError(t, err) require.NoError(t, fw.Write(lazyEv, lazyData)) @@ -403,7 +403,7 @@ func TestPipeline(t *testing.T) { go func() { defer wg.Done() for j := 0; j < eventsEach; j++ { - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) } }() } @@ -424,7 +424,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() for i := 0; i < 3; i++ { - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -442,7 +442,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() before := time.Now().UnixMilli() - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP}) // Ts == 0 + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP}) // Ts == 0 after := time.Now().UnixMilli() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -457,7 +457,7 @@ func TestPipeline(t *testing.T) { t.Run("publish_writes_file", func(t *testing.T) { p, dir := newPipeline(t) - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) data, err := os.ReadFile(filepath.Join(dir, "console.log")) require.NoError(t, err) @@ -472,7 +472,7 @@ func TestPipeline(t *testing.T) { p, _ := newPipeline(t) reader := p.NewReader() - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -488,7 +488,7 @@ func TestPipeline(t *testing.T) { p.Start("test-uuid") reader := p.NewReader() - p.Publish(BrowserEvent{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -506,7 +506,7 @@ func TestPipeline(t *testing.T) { rawData, err := json.Marshal(map[string]string{"payload": largeData}) require.NoError(t, err) - p.Publish(BrowserEvent{ + p.Publish(Event{ Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, @@ -537,16 +537,16 @@ func TestPipeline(t *testing.T) { p, _ := newPipeline(t) reader := p.NewReader() - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() ev, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, DetailDefault, ev.DetailLevel) + assert.Equal(t, DetailStandard, ev.DetailLevel) - p.Publish(BrowserEvent{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1, DetailLevel: DetailVerbose}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1, DetailLevel: DetailVerbose}) ev2, err := reader.Read(ctx) require.NoError(t, err) assert.Equal(t, DetailVerbose, ev2.DetailLevel) diff --git a/server/lib/events/filewriter.go b/server/lib/events/filewriter.go index 87bad172..cab3133a 100644 --- a/server/lib/events/filewriter.go +++ b/server/lib/events/filewriter.go @@ -22,7 +22,7 @@ func NewFileWriter(dir string) *FileWriter { } // Write appends data as a single JSONL line to the per-category log file for ev -func (fw *FileWriter) Write(ev BrowserEvent, data []byte) error { +func (fw *FileWriter) Write(ev Event, data []byte) error { cat := ev.Category if cat == "" { return fmt.Errorf("filewriter: event %q has empty category", ev.Type) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index c6f93dcb..1c3d31e8 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -36,7 +36,7 @@ func (p *Pipeline) Start(captureSessionID string) { // 2. Apply truncateIfNeeded — must happen before both sinks // 3. Write to FileWriter (durable before in-memory) // 4. Publish to RingBuffer (in-memory fan-out) -func (p *Pipeline) Publish(ev BrowserEvent) { +func (p *Pipeline) Publish(ev Event) { p.mu.Lock() defer p.mu.Unlock() @@ -46,7 +46,7 @@ func (p *Pipeline) Publish(ev BrowserEvent) { ev.Ts = time.Now().UnixMilli() } if ev.DetailLevel == "" { - ev.DetailLevel = DetailDefault + ev.DetailLevel = DetailStandard } ev, data := truncateIfNeeded(ev) diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 8d0b48ab..44e54f39 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -10,10 +10,10 @@ import ( // RingBuffer is a fixed-capacity circular buffer with closed-channel broadcast fan-out. // Writers never block regardless of reader count or speed. // Readers track their position by seq value (not ring index) and receive an -// events_dropped synthetic BrowserEvent when they fall behind the oldest retained event. +// events_dropped synthetic Event when they fall behind the oldest retained event. type RingBuffer struct { mu sync.RWMutex - buf []BrowserEvent + buf []Event head int // next write position (mod cap) written uint64 // total ever published (monotonic) notify chan struct{} @@ -21,7 +21,7 @@ type RingBuffer struct { func NewRingBuffer(capacity int) *RingBuffer { return &RingBuffer{ - buf: make([]BrowserEvent, capacity), + buf: make([]Event, capacity), notify: make(chan struct{}), } } @@ -29,7 +29,7 @@ func NewRingBuffer(capacity int) *RingBuffer { // Publish adds an event to the ring buffer, evicting the oldest entry on overflow. // Closes the current notify channel (waking all waiting readers) and replaces it // with a new one, outside the lock to avoid blocking under contention -func (rb *RingBuffer) Publish(ev BrowserEvent) { +func (rb *RingBuffer) Publish(ev Event) { rb.mu.Lock() rb.buf[rb.head] = ev rb.head = (rb.head + 1) % len(rb.buf) @@ -50,7 +50,7 @@ func (rb *RingBuffer) oldestSeq() uint64 { // NewReader returns a Reader positioned at publish index 0 // If the ring has already published events, the reader will receive an -// events_dropped BrowserEvent on the first Read call if it has fallen behind +// events_dropped Event on the first Read call if it has fallen behind // the oldest retained event func (rb *RingBuffer) NewReader() *Reader { return &Reader{rb: rb, nextSeq: 0} @@ -59,11 +59,11 @@ func (rb *RingBuffer) NewReader() *Reader { // Reader tracks an independent read position in a RingBuffer. type Reader struct { rb *RingBuffer - nextSeq uint64 // publish index, not BrowserEvent.Seq + nextSeq uint64 // publish index, not Event.Seq } // Read blocks until the next event is available or ctx is cancelled -func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { +func (r *Reader) Read(ctx context.Context) (Event, error) { for { r.rb.mu.RLock() notify := r.rb.notify @@ -75,7 +75,7 @@ func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { r.nextSeq = oldest r.rb.mu.RUnlock() data := json.RawMessage(fmt.Sprintf(`{"dropped":%d}`, dropped)) - return BrowserEvent{Type: "events.dropped", Category: CategorySystem, Source: SourceKernelAPI, Data: data}, nil + return Event{Type: "events.dropped", Category: CategorySystem, Source: SourceKernelAPI, Data: data}, nil } if r.nextSeq < written { @@ -90,7 +90,7 @@ func (r *Reader) Read(ctx context.Context) (BrowserEvent, error) { select { case <-ctx.Done(): - return BrowserEvent{}, ctx.Err() + return Event{}, ctx.Err() case <-notify: // new event available; loop to read it } From b370416b5683a926fd78d51f064f317f33bc8891 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:06:32 +0000 Subject: [PATCH 12/21] refactor: restructure Source as nested object with Kind, Event, Metadata Moves CDP-specific fields (target_id, cdp_session_id, frame_id, parent_frame_id) under source.metadata. Top-level Event schema now contains only stable cross-producer fields. --- server/lib/events/event.go | 23 ++++--- server/lib/events/events_test.go | 111 +++++++++++++++++-------------- server/lib/events/ringbuffer.go | 2 +- 3 files changed, 74 insertions(+), 62 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index 3d88369c..358288a7 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -20,15 +20,23 @@ const ( CategorySystem EventCategory = "system" ) -type Source string +type SourceKind string const ( - SourceCDP Source = "cdp" - SourceKernelAPI Source = "kernel_api" - SourceExtension Source = "extension" - SourceLocalProcess Source = "local_process" + KindCDP SourceKind = "cdp" + KindKernelAPI SourceKind = "kernel_api" + KindExtension SourceKind = "extension" + KindLocalProcess SourceKind = "local_process" ) +// Source captures provenance: which producer emitted the event and any +// producer-specific context (e.g. CDP target/session/frame IDs). +type Source struct { + Kind SourceKind `json:"kind"` + Event string `json:"event,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + type DetailLevel string const ( @@ -46,12 +54,7 @@ type Event struct { Type string `json:"type"` Category EventCategory `json:"category"` Source Source `json:"source"` - SourceEvent string `json:"source_event,omitempty"` DetailLevel DetailLevel `json:"detail_level"` - TargetID string `json:"target_id,omitempty"` - CDPSessionID string `json:"cdp_session_id,omitempty"` - FrameID string `json:"frame_id,omitempty"` - ParentFrameID string `json:"parent_frame_id,omitempty"` URL string `json:"url,omitempty"` Data json.RawMessage `json:"data,omitempty"` Truncated bool `json:"truncated,omitempty"` diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index c60252a3..0099f154 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -22,15 +22,19 @@ func TestEventSerialization(t *testing.T) { Ts: 1234567890000, Type: "console.log", Category: CategoryConsole, - Source: SourceCDP, - SourceEvent: "Runtime.consoleAPICalled", - DetailLevel: DetailStandard, - TargetID: "target-1", - CDPSessionID: "cdp-session-1", - FrameID: "frame-1", - ParentFrameID: "parent-frame-1", - URL: "https://example.com", - Data: json.RawMessage(`{"message":"hello"}`), + Source: Source{ + Kind: KindCDP, + Event: "Runtime.consoleAPICalled", + Metadata: map[string]string{ + "target_id": "target-1", + "cdp_session_id": "cdp-session-1", + "frame_id": "frame-1", + "parent_frame_id": "parent-frame-1", + }, + }, + DetailLevel: DetailStandard, + URL: "https://example.com", + Data: json.RawMessage(`{"message":"hello"}`), } b, err := json.Marshal(ev) @@ -41,16 +45,19 @@ func TestEventSerialization(t *testing.T) { assert.Equal(t, "console.log", decoded["type"]) assert.Equal(t, "console", decoded["category"]) - assert.Equal(t, "cdp", decoded["source"]) - assert.Equal(t, "Runtime.consoleAPICalled", decoded["source_event"]) assert.Equal(t, "standard", decoded["detail_level"]) assert.Equal(t, "test-session-id", decoded["capture_session_id"]) assert.Equal(t, float64(1), decoded["seq"]) - assert.Equal(t, "target-1", decoded["target_id"]) - assert.Equal(t, "cdp-session-1", decoded["cdp_session_id"]) - assert.Equal(t, "frame-1", decoded["frame_id"]) - assert.Equal(t, "parent-frame-1", decoded["parent_frame_id"]) assert.Equal(t, "https://example.com", decoded["url"]) + + src, ok := decoded["source"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "cdp", src["kind"]) + assert.Equal(t, "Runtime.consoleAPICalled", src["event"]) + meta, ok := src["metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "target-1", meta["target_id"]) + assert.Equal(t, "cdp-session-1", meta["cdp_session_id"]) } func TestEventData(t *testing.T) { @@ -61,8 +68,8 @@ func TestEventData(t *testing.T) { Ts: 1000, Type: "page.navigation", Category: CategoryPage, - Source: SourceCDP, - Data: rawData, + Source: Source{Kind: KindCDP}, + Data: rawData, } b, err := json.Marshal(ev) @@ -80,14 +87,14 @@ func TestEventOmitEmpty(t *testing.T) { Ts: 1000, Type: "console.log", Category: CategoryConsole, - Source: SourceCDP, + Source: Source{Kind: KindCDP}, } b, err := json.Marshal(ev) require.NoError(t, err) s := string(b) - assert.NotContains(t, s, `"source_event"`) + assert.NotContains(t, s, `"event"`) assert.Contains(t, s, `"detail_level"`) } @@ -97,9 +104,9 @@ func TestRingBuffer(t *testing.T) { reader := rb.NewReader() events := []Event{ - {Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}, - {Seq: 2, Type: "network.request", Category: CategoryNetwork, Source: SourceCDP}, - {Seq: 3, Type: "page.navigation", Category: CategoryPage, Source: SourceCDP}, + {Seq: 1, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}, + {Seq: 2, Type: "network.request", Category: CategoryNetwork, Source: Source{Kind: KindCDP}}, + {Seq: 3, Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}, } for _, ev := range events { @@ -123,9 +130,9 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { done := make(chan struct{}) go func() { - rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) + rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) + rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) close(done) }() @@ -143,16 +150,16 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { require.NoError(t, err) assert.Equal(t, "events.dropped", first.Type) assert.Equal(t, CategorySystem, first.Category) - assert.Equal(t, SourceKernelAPI, first.Source) + assert.Equal(t, KindKernelAPI, first.Source.Kind) } func TestRingBufferOverflowExistingReader(t *testing.T) { rb := NewRingBuffer(2) reader := rb.NewReader() - rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) - rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) + rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) + rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -206,7 +213,7 @@ func TestConcurrentPublishRead(t *testing.T) { Seq: uint64(i), Type: "console.log", Category: CategoryConsole, - Source: SourceCDP, + Source: Source{Kind: KindCDP}, }) } }() @@ -226,7 +233,7 @@ func TestConcurrentReaders(t *testing.T) { } for i := 0; i < numEvents; i++ { - rb.Publish(Event{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, Source: SourceCDP}) + rb.Publish(Event{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) } var wg sync.WaitGroup @@ -273,13 +280,13 @@ func TestFileWriter(t *testing.T) { file string category string }{ - {Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1}, "console.log", "console"}, - {Event{Type: "network.request", Category: CategoryNetwork, Source: SourceCDP, Seq: 1, Ts: 1}, "network.log", "network"}, - {Event{Type: "liveview.click", Category: CategoryLiveview, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, - {Event{Type: "captcha.solve", Category: CategoryCaptcha, Source: SourceExtension, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, - {Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Seq: 1, Ts: 1}, "page.log", "page"}, - {Event{Type: "input.click", Category: CategoryInteraction, Source: SourceCDP, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, - {Event{Type: "monitor.connected", Category: CategorySystem, Source: SourceKernelAPI, Seq: 1, Ts: 1}, "system.log", "system"}, + {Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "console.log", "console"}, + {Event{Type: "network.request", Category: CategoryNetwork, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "network.log", "network"}, + {Event{Type: "liveview.click", Category: CategoryLiveview, Source: Source{Kind: KindKernelAPI}, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, + {Event{Type: "captcha.solve", Category: CategoryCaptcha, Source: Source{Kind: KindExtension}, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, + {Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "page.log", "page"}, + {Event{Type: "input.click", Category: CategoryInteraction, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, + {Event{Type: "monitor.connected", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Seq: 1, Ts: 1}, "system.log", "system"}, } for _, e := range eventsToFile { @@ -298,7 +305,9 @@ func TestFileWriter(t *testing.T) { var decoded map[string]any require.NoError(t, json.Unmarshal(line, &decoded)) assert.Equal(t, e.category, decoded["category"], "wrong category in %s", e.file) - assert.Equal(t, string(e.ev.Source), decoded["source"], "wrong source in %s", e.file) + srcMap, ok := decoded["source"].(map[string]any) + require.True(t, ok, "source should be an object in %s", e.file) + assert.Equal(t, string(e.ev.Source.Kind), srcMap["kind"], "wrong source kind in %s", e.file) } }) @@ -307,7 +316,7 @@ func TestFileWriter(t *testing.T) { fw := NewFileWriter(dir) defer fw.Close() - ev := Event{Type: "mystery", Category: "", Source: SourceCDP, Seq: 1, Ts: 1} + ev := Event{Type: "mystery", Category: "", Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1} data, _ := json.Marshal(ev) err := fw.Write(ev, data) require.Error(t, err) @@ -332,7 +341,7 @@ func TestFileWriter(t *testing.T) { Seq: uint64(i*eventsPerGoroutine + j), Type: "console.log", Category: CategoryConsole, - Source: SourceCDP, + Source: Source{Kind: KindCDP}, Ts: 1, } evData, err := json.Marshal(ev) @@ -362,7 +371,7 @@ func TestFileWriter(t *testing.T) { require.NoError(t, err) assert.Empty(t, entries, "files opened before first Write") - lazyEv := Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Seq: 1, Ts: 1} + lazyEv := Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1} lazyData, err := json.Marshal(lazyEv) require.NoError(t, err) require.NoError(t, fw.Write(lazyEv, lazyData)) @@ -403,7 +412,7 @@ func TestPipeline(t *testing.T) { go func() { defer wg.Done() for j := 0; j < eventsEach; j++ { - p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) } }() } @@ -424,7 +433,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() for i := 0; i < 3; i++ { - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -442,7 +451,7 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() before := time.Now().UnixMilli() - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP}) // Ts == 0 + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}) // Ts == 0 after := time.Now().UnixMilli() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -457,7 +466,7 @@ func TestPipeline(t *testing.T) { t.Run("publish_writes_file", func(t *testing.T) { p, dir := newPipeline(t) - p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) data, err := os.ReadFile(filepath.Join(dir, "console.log")) require.NoError(t, err) @@ -472,7 +481,7 @@ func TestPipeline(t *testing.T) { p, _ := newPipeline(t) reader := p.NewReader() - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -488,7 +497,7 @@ func TestPipeline(t *testing.T) { p.Start("test-uuid") reader := p.NewReader() - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -509,7 +518,7 @@ func TestPipeline(t *testing.T) { p.Publish(Event{ Type: "page.navigation", Category: CategoryPage, - Source: SourceCDP, + Source: Source{Kind: KindCDP}, Ts: 1, Data: json.RawMessage(rawData), }) @@ -537,7 +546,7 @@ func TestPipeline(t *testing.T) { p, _ := newPipeline(t) reader := p.NewReader() - p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -546,7 +555,7 @@ func TestPipeline(t *testing.T) { require.NoError(t, err) assert.Equal(t, DetailStandard, ev.DetailLevel) - p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: SourceCDP, Ts: 1, DetailLevel: DetailVerbose}) + p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1, DetailLevel: DetailVerbose}) ev2, err := reader.Read(ctx) require.NoError(t, err) assert.Equal(t, DetailVerbose, ev2.DetailLevel) diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 44e54f39..385be3bf 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -75,7 +75,7 @@ func (r *Reader) Read(ctx context.Context) (Event, error) { r.nextSeq = oldest r.rb.mu.RUnlock() data := json.RawMessage(fmt.Sprintf(`{"dropped":%d}`, dropped)) - return Event{Type: "events.dropped", Category: CategorySystem, Source: SourceKernelAPI, Data: data}, nil + return Event{Type: "events.dropped", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Data: data}, nil } if r.nextSeq < written { From 41a7aeeb64fdaf655c73ca296cb37ab5b41e5bbc Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:09:01 +0000 Subject: [PATCH 13/21] refactor: extract Envelope wrapper, move seq and capture_session_id out of Event Event is now purely producer-emitted content. Pipeline-assigned metadata (seq, capture_session_id) lives on the Envelope. truncateIfNeeded operates on the full Envelope. Pipeline type comment now documents lifecycle semantics. --- server/lib/events/event.go | 48 +++--- server/lib/events/events_test.go | 251 +++++++++++++++++-------------- server/lib/events/filewriter.go | 8 +- server/lib/events/pipeline.go | 38 ++--- server/lib/events/ringbuffer.go | 40 +++-- 5 files changed, 201 insertions(+), 184 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index 358288a7..9dab2ffc 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -46,34 +46,40 @@ const ( DetailRaw DetailLevel = "raw" ) -// Event is the canonical event structure for the capture pipeline. +// Event is the portable event schema. It contains only producer-emitted content; +// pipeline metadata (seq, capture session) lives on the Envelope. type Event struct { - CaptureSessionID string `json:"capture_session_id"` - Seq uint64 `json:"seq"` - Ts int64 `json:"ts"` - Type string `json:"type"` - Category EventCategory `json:"category"` - Source Source `json:"source"` - DetailLevel DetailLevel `json:"detail_level"` - URL string `json:"url,omitempty"` - Data json.RawMessage `json:"data,omitempty"` - Truncated bool `json:"truncated,omitempty"` + Ts int64 `json:"ts"` + Type string `json:"type"` + Category EventCategory `json:"category"` + Source Source `json:"source"` + DetailLevel DetailLevel `json:"detail_level"` + URL string `json:"url,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Truncated bool `json:"truncated,omitempty"` } -// truncateIfNeeded marshals ev and returns the (possibly truncated) event together -func truncateIfNeeded(ev Event) (Event, []byte) { - data, err := json.Marshal(ev) +// Envelope wraps an Event with pipeline-assigned metadata. +type Envelope struct { + CaptureSessionID string `json:"capture_session_id"` + Seq uint64 `json:"seq"` + Event Event `json:"event"` +} + +// truncateIfNeeded marshals env and returns the (possibly truncated) envelope +func truncateIfNeeded(env Envelope) (Envelope, []byte) { + data, err := json.Marshal(env) if err != nil { - return ev, data + return env, data } if len(data) <= maxS2RecordBytes { - return ev, data + return env, data } - ev.Data = json.RawMessage("null") - ev.Truncated = true - data, err = json.Marshal(ev) + env.Event.Data = json.RawMessage("null") + env.Event.Truncated = true + data, err = json.Marshal(env) if err != nil { - return ev, nil + return env, nil } - return ev, data + return env, data } diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index 0099f154..54fdae5b 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -17,11 +17,9 @@ import ( func TestEventSerialization(t *testing.T) { ev := Event{ - CaptureSessionID: "test-session-id", - Seq: 1, - Ts: 1234567890000, - Type: "console.log", - Category: CategoryConsole, + Ts: 1234567890000, + Type: "console.log", + Category: CategoryConsole, Source: Source{ Kind: KindCDP, Event: "Runtime.consoleAPICalled", @@ -46,8 +44,6 @@ func TestEventSerialization(t *testing.T) { assert.Equal(t, "console.log", decoded["type"]) assert.Equal(t, "console", decoded["category"]) assert.Equal(t, "standard", decoded["detail_level"]) - assert.Equal(t, "test-session-id", decoded["capture_session_id"]) - assert.Equal(t, float64(1), decoded["seq"]) assert.Equal(t, "https://example.com", decoded["url"]) src, ok := decoded["source"].(map[string]any) @@ -60,14 +56,37 @@ func TestEventSerialization(t *testing.T) { assert.Equal(t, "cdp-session-1", meta["cdp_session_id"]) } +func TestEnvelopeSerialization(t *testing.T) { + env := Envelope{ + CaptureSessionID: "test-session-id", + Seq: 1, + Event: Event{ + Ts: 1000, + Type: "console.log", + Category: CategoryConsole, + Source: Source{Kind: KindCDP}, + }, + } + + b, err := json.Marshal(env) + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(b, &decoded)) + + assert.Equal(t, "test-session-id", decoded["capture_session_id"]) + assert.Equal(t, float64(1), decoded["seq"]) + inner, ok := decoded["event"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "console.log", inner["type"]) +} + func TestEventData(t *testing.T) { rawData := json.RawMessage(`{"key":"value","num":42}`) ev := Event{ - CaptureSessionID: "test-session", - Seq: 1, - Ts: 1000, - Type: "page.navigation", - Category: CategoryPage, + Ts: 1000, + Type: "page.navigation", + Category: CategoryPage, Source: Source{Kind: KindCDP}, Data: rawData, } @@ -82,11 +101,9 @@ func TestEventData(t *testing.T) { func TestEventOmitEmpty(t *testing.T) { ev := Event{ - CaptureSessionID: "sess", - Seq: 1, - Ts: 1000, - Type: "console.log", - Category: CategoryConsole, + Ts: 1000, + Type: "console.log", + Category: CategoryConsole, Source: Source{Kind: KindCDP}, } @@ -98,29 +115,37 @@ func TestEventOmitEmpty(t *testing.T) { assert.Contains(t, s, `"detail_level"`) } -// TestRingBuffer: publish 3 events; reader reads all 3 in order +func mkEnv(seq uint64, ev Event) Envelope { + return Envelope{Seq: seq, Event: ev} +} + +func cdpEvent(typ string, cat EventCategory) Event { + return Event{Type: typ, Category: cat, Source: Source{Kind: KindCDP}} +} + +// TestRingBuffer: publish 3 envelopes; reader reads all 3 in order func TestRingBuffer(t *testing.T) { rb := NewRingBuffer(10) reader := rb.NewReader() - events := []Event{ - {Seq: 1, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}, - {Seq: 2, Type: "network.request", Category: CategoryNetwork, Source: Source{Kind: KindCDP}}, - {Seq: 3, Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}, + envelopes := []Envelope{ + mkEnv(1, cdpEvent("console.log", CategoryConsole)), + mkEnv(2, cdpEvent("network.request", CategoryNetwork)), + mkEnv(3, cdpEvent("page.navigation", CategoryPage)), } - for _, ev := range events { - rb.Publish(ev) + for _, env := range envelopes { + rb.Publish(env) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - for i, expected := range events { + for i, expected := range envelopes { got, err := reader.Read(ctx) require.NoError(t, err, "reading event %d", i) - assert.Equal(t, expected.Type, got.Type) - assert.Equal(t, expected.Category, got.Category) + assert.Equal(t, expected.Event.Type, got.Event.Type) + assert.Equal(t, expected.Event.Category, got.Event.Category) } } @@ -130,9 +155,9 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { done := make(chan struct{}) go func() { - rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) - rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) - rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) + rb.Publish(mkEnv(1, cdpEvent("console.log", CategoryConsole))) + rb.Publish(mkEnv(2, cdpEvent("console.log", CategoryConsole))) + rb.Publish(mkEnv(3, cdpEvent("console.log", CategoryConsole))) close(done) }() @@ -148,32 +173,31 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { first, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "events.dropped", first.Type) - assert.Equal(t, CategorySystem, first.Category) - assert.Equal(t, KindKernelAPI, first.Source.Kind) + assert.Equal(t, "events.dropped", first.Event.Type) + assert.Equal(t, CategorySystem, first.Event.Category) + assert.Equal(t, KindKernelAPI, first.Event.Source.Kind) } func TestRingBufferOverflowExistingReader(t *testing.T) { rb := NewRingBuffer(2) reader := rb.NewReader() - rb.Publish(Event{Seq: 1, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) - rb.Publish(Event{Seq: 2, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) - rb.Publish(Event{Seq: 3, Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) + rb.Publish(mkEnv(1, cdpEvent("console.log", CategoryConsole))) + rb.Publish(mkEnv(2, cdpEvent("console.log", CategoryConsole))) + rb.Publish(mkEnv(3, cdpEvent("console.log", CategoryConsole))) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() first, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "events.dropped", first.Type) - assert.Equal(t, CategorySystem, first.Category) + assert.Equal(t, "events.dropped", first.Event.Type) + assert.Equal(t, CategorySystem, first.Event.Category) - require.NotNil(t, first.Data) - assert.True(t, json.Valid(first.Data)) - assert.JSONEq(t, `{"dropped":1}`, string(first.Data)) + require.NotNil(t, first.Event.Data) + assert.True(t, json.Valid(first.Event.Data)) + assert.JSONEq(t, `{"dropped":1}`, string(first.Event.Data)) - // After the drop sentinel the reader continues with the surviving events second, err := reader.Read(ctx) require.NoError(t, err) assert.Equal(t, uint64(2), second.Seq) @@ -209,12 +233,7 @@ func TestConcurrentPublishRead(t *testing.T) { go func() { defer wg.Done() for i := 1; i <= numEvents; i++ { - rb.Publish(Event{ - Seq: uint64(i), - Type: "console.log", - Category: CategoryConsole, - Source: Source{Kind: KindCDP}, - }) + rb.Publish(mkEnv(uint64(i), cdpEvent("console.log", CategoryConsole))) } }() @@ -233,11 +252,11 @@ func TestConcurrentReaders(t *testing.T) { } for i := 0; i < numEvents; i++ { - rb.Publish(Event{Seq: uint64(i + 1), Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}}) + rb.Publish(mkEnv(uint64(i+1), cdpEvent("console.log", CategoryConsole))) } var wg sync.WaitGroup - results := make([][]Event, numReaders) + results := make([][]Envelope, numReaders) for i, r := range readers { wg.Add(1) @@ -246,24 +265,24 @@ func TestConcurrentReaders(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - var evs []Event + var envs []Envelope for j := 0; j < numEvents; j++ { - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) if !assert.NoError(t, err) { break } - evs = append(evs, ev) + envs = append(envs, env) } - results[idx] = evs + results[idx] = envs }(i, r) } wg.Wait() - for i, evs := range results { - assert.Len(t, evs, numEvents, "reader %d", i) - for j, ev := range evs { - assert.Equal(t, uint64(j+1), ev.Seq, "reader %d event %d", i, j) + for i, envs := range results { + assert.Len(t, envs, numEvents, "reader %d", i) + for j, env := range envs { + assert.Equal(t, uint64(j+1), env.Seq, "reader %d event %d", i, j) } } } @@ -275,39 +294,41 @@ func TestFileWriter(t *testing.T) { fw := NewFileWriter(dir) defer fw.Close() - eventsToFile := []struct { - ev Event + envsToFile := []struct { + env Envelope file string category string }{ - {Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "console.log", "console"}, - {Event{Type: "network.request", Category: CategoryNetwork, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "network.log", "network"}, - {Event{Type: "liveview.click", Category: CategoryLiveview, Source: Source{Kind: KindKernelAPI}, Seq: 1, Ts: 1}, "liveview.log", "liveview"}, - {Event{Type: "captcha.solve", Category: CategoryCaptcha, Source: Source{Kind: KindExtension}, Seq: 1, Ts: 1}, "captcha.log", "captcha"}, - {Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "page.log", "page"}, - {Event{Type: "input.click", Category: CategoryInteraction, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1}, "interaction.log", "interaction"}, - {Event{Type: "monitor.connected", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Seq: 1, Ts: 1}, "system.log", "system"}, + {Envelope{Seq: 1, Event: Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}}, "console.log", "console"}, + {Envelope{Seq: 2, Event: Event{Type: "network.request", Category: CategoryNetwork, Source: Source{Kind: KindCDP}, Ts: 1}}, "network.log", "network"}, + {Envelope{Seq: 3, Event: Event{Type: "liveview.click", Category: CategoryLiveview, Source: Source{Kind: KindKernelAPI}, Ts: 1}}, "liveview.log", "liveview"}, + {Envelope{Seq: 4, Event: Event{Type: "captcha.solve", Category: CategoryCaptcha, Source: Source{Kind: KindExtension}, Ts: 1}}, "captcha.log", "captcha"}, + {Envelope{Seq: 5, Event: Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}}, "page.log", "page"}, + {Envelope{Seq: 6, Event: Event{Type: "input.click", Category: CategoryInteraction, Source: Source{Kind: KindCDP}, Ts: 1}}, "interaction.log", "interaction"}, + {Envelope{Seq: 7, Event: Event{Type: "monitor.connected", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Ts: 1}}, "system.log", "system"}, } - for _, e := range eventsToFile { - data, err := json.Marshal(e.ev) + for _, e := range envsToFile { + data, err := json.Marshal(e.env) require.NoError(t, err) - require.NoError(t, fw.Write(e.ev, data)) + require.NoError(t, fw.Write(e.env, data)) } - for _, e := range eventsToFile { + for _, e := range envsToFile { data, err := os.ReadFile(filepath.Join(dir, e.file)) - require.NoError(t, err, "missing file %s for type %s", e.file, e.ev.Type) + require.NoError(t, err, "missing file %s for type %s", e.file, e.env.Event.Type) line := bytes.TrimRight(data, "\n") require.True(t, json.Valid(line), "invalid JSON in %s", e.file) var decoded map[string]any require.NoError(t, json.Unmarshal(line, &decoded)) - assert.Equal(t, e.category, decoded["category"], "wrong category in %s", e.file) - srcMap, ok := decoded["source"].(map[string]any) + inner, ok := decoded["event"].(map[string]any) + require.True(t, ok) + assert.Equal(t, e.category, inner["category"], "wrong category in %s", e.file) + srcMap, ok := inner["source"].(map[string]any) require.True(t, ok, "source should be an object in %s", e.file) - assert.Equal(t, string(e.ev.Source.Kind), srcMap["kind"], "wrong source kind in %s", e.file) + assert.Equal(t, string(e.env.Event.Source.Kind), srcMap["kind"], "wrong source kind in %s", e.file) } }) @@ -316,9 +337,9 @@ func TestFileWriter(t *testing.T) { fw := NewFileWriter(dir) defer fw.Close() - ev := Event{Type: "mystery", Category: "", Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1} - data, _ := json.Marshal(ev) - err := fw.Write(ev, data) + env := Envelope{Seq: 1, Event: Event{Type: "mystery", Category: "", Source: Source{Kind: KindCDP}, Ts: 1}} + data, _ := json.Marshal(env) + err := fw.Write(env, data) require.Error(t, err) assert.Contains(t, err.Error(), "empty category") }) @@ -337,16 +358,13 @@ func TestFileWriter(t *testing.T) { go func(i int) { defer wg.Done() for j := 0; j < eventsPerGoroutine; j++ { - ev := Event{ - Seq: uint64(i*eventsPerGoroutine + j), - Type: "console.log", - Category: CategoryConsole, - Source: Source{Kind: KindCDP}, - Ts: 1, + env := Envelope{ + Seq: uint64(i*eventsPerGoroutine + j), + Event: Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}, } - evData, err := json.Marshal(ev) + envData, err := json.Marshal(env) require.NoError(t, err) - require.NoError(t, fw.Write(ev, evData)) + require.NoError(t, fw.Write(env, envData)) } }(i) } @@ -371,10 +389,10 @@ func TestFileWriter(t *testing.T) { require.NoError(t, err) assert.Empty(t, entries, "files opened before first Write") - lazyEv := Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Seq: 1, Ts: 1} - lazyData, err := json.Marshal(lazyEv) + env := Envelope{Seq: 1, Event: Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}} + envData, err := json.Marshal(env) require.NoError(t, err) - require.NoError(t, fw.Write(lazyEv, lazyData)) + require.NoError(t, fw.Write(env, envData)) entries, err = os.ReadDir(dir) require.NoError(t, err) @@ -399,7 +417,6 @@ func TestPipeline(t *testing.T) { const eventsEach = 50 const total = goroutines * eventsEach - // Ring must hold all events so no drop sentinels are emitted. rb := NewRingBuffer(total) fw := NewFileWriter(t.TempDir()) p := NewPipeline(rb, fw) @@ -412,7 +429,7 @@ func TestPipeline(t *testing.T) { go func() { defer wg.Done() for j := 0; j < eventsEach; j++ { - p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) + p.Publish(cdpEvent("console.log", CategoryConsole)) } }() } @@ -422,9 +439,9 @@ func TestPipeline(t *testing.T) { defer cancel() for want := uint64(1); want <= total; want++ { - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, want, ev.Seq, "events must arrive in seq order") + assert.Equal(t, want, env.Seq, "events must arrive in seq order") } }) @@ -440,9 +457,9 @@ func TestPipeline(t *testing.T) { defer cancel() for want := uint64(1); want <= 3; want++ { - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, want, ev.Seq, "expected seq %d got %d", want, ev.Seq) + assert.Equal(t, want, env.Seq, "expected seq %d got %d", want, env.Seq) } }) @@ -451,16 +468,16 @@ func TestPipeline(t *testing.T) { reader := p.NewReader() before := time.Now().UnixMilli() - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}) // Ts == 0 + p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}) after := time.Now().UnixMilli() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) require.NoError(t, err) - assert.GreaterOrEqual(t, ev.Ts, before) - assert.LessOrEqual(t, ev.Ts, after) + assert.GreaterOrEqual(t, env.Event.Ts, before) + assert.LessOrEqual(t, env.Event.Ts, after) }) t.Run("publish_writes_file", func(t *testing.T) { @@ -486,10 +503,10 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "page.navigation", ev.Type) - assert.Equal(t, CategoryPage, ev.Category) + assert.Equal(t, "page.navigation", env.Event.Type) + assert.Equal(t, CategoryPage, env.Event.Category) }) t.Run("start_sets_capture_session_id", func(t *testing.T) { @@ -502,9 +519,9 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "test-uuid", ev.CaptureSessionID) + assert.Equal(t, "test-uuid", env.CaptureSessionID) }) t.Run("truncation_applied", func(t *testing.T) { @@ -516,22 +533,22 @@ func TestPipeline(t *testing.T) { require.NoError(t, err) p.Publish(Event{ - Type: "page.navigation", - Category: CategoryPage, - Source: Source{Kind: KindCDP}, - Ts: 1, - Data: json.RawMessage(rawData), + Type: "page.navigation", + Category: CategoryPage, + Source: Source{Kind: KindCDP}, + Ts: 1, + Data: json.RawMessage(rawData), }) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) require.NoError(t, err) - assert.True(t, ev.Truncated) - assert.True(t, json.Valid(ev.Data)) + assert.True(t, env.Event.Truncated) + assert.True(t, json.Valid(env.Event.Data)) - marshaled, err := json.Marshal(ev) + marshaled, err := json.Marshal(env) require.NoError(t, err) assert.LessOrEqual(t, len(marshaled), maxS2RecordBytes) @@ -551,13 +568,13 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - ev, err := reader.Read(ctx) + env, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, DetailStandard, ev.DetailLevel) + assert.Equal(t, DetailStandard, env.Event.DetailLevel) p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1, DetailLevel: DetailVerbose}) - ev2, err := reader.Read(ctx) + env2, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, DetailVerbose, ev2.DetailLevel) + assert.Equal(t, DetailVerbose, env2.Event.DetailLevel) }) } diff --git a/server/lib/events/filewriter.go b/server/lib/events/filewriter.go index cab3133a..6ce5ff5f 100644 --- a/server/lib/events/filewriter.go +++ b/server/lib/events/filewriter.go @@ -21,11 +21,11 @@ func NewFileWriter(dir string) *FileWriter { return &FileWriter{dir: dir, files: make(map[EventCategory]*os.File)} } -// Write appends data as a single JSONL line to the per-category log file for ev -func (fw *FileWriter) Write(ev Event, data []byte) error { - cat := ev.Category +// Write appends data as a single JSONL line to the per-category log file. +func (fw *FileWriter) Write(env Envelope, data []byte) error { + cat := env.Event.Category if cat == "" { - return fmt.Errorf("filewriter: event %q has empty category", ev.Type) + return fmt.Errorf("filewriter: event %q has empty category", env.Event.Type) } fw.mu.Lock() diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index 1c3d31e8..403a1df0 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -7,7 +7,10 @@ import ( "time" ) -// Pipeline glues a RingBuffer and a FileWriter into a single write path +// Pipeline is a single-use write path that wraps events in envelopes and fans +// them out to a FileWriter (durable) and RingBuffer (in-memory). Call Start +// once with a capture session ID, then Publish concurrently. Close flushes the +// FileWriter; there is no restart or terminal event. type Pipeline struct { mu sync.Mutex ring *RingBuffer @@ -23,46 +26,43 @@ func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { return p } -// Start sets the capture session ID that will be stamped on every subsequent -// published event +// Start sets the capture session ID stamped on every subsequent envelope. func (p *Pipeline) Start(captureSessionID string) { p.captureSessionID.Store(&captureSessionID) } -// Publish stamps, truncates, files, and broadcasts a single event. -// -// Ordering: -// 1. Stamp CaptureSessionID, Seq, Ts (Ts only if caller left it zero) -// 2. Apply truncateIfNeeded — must happen before both sinks -// 3. Write to FileWriter (durable before in-memory) -// 4. Publish to RingBuffer (in-memory fan-out) +// Publish wraps ev in an Envelope, truncates if needed, then writes to +// FileWriter (durable) before RingBuffer (in-memory fan-out). func (p *Pipeline) Publish(ev Event) { p.mu.Lock() defer p.mu.Unlock() - ev.CaptureSessionID = *p.captureSessionID.Load() - ev.Seq = p.seq.Add(1) if ev.Ts == 0 { ev.Ts = time.Now().UnixMilli() } if ev.DetailLevel == "" { ev.DetailLevel = DetailStandard } - ev, data := truncateIfNeeded(ev) - if err := p.files.Write(ev, data); err != nil { - slog.Error("pipeline: file write failed", "seq", ev.Seq, "category", ev.Category, "err", err) + env := Envelope{ + CaptureSessionID: *p.captureSessionID.Load(), + Seq: p.seq.Add(1), + Event: ev, } - p.ring.Publish(ev) + env, data := truncateIfNeeded(env) + + if err := p.files.Write(env, data); err != nil { + slog.Error("pipeline: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) + } + p.ring.Publish(env) } -// NewReader returns a Reader positioned at the start of the ring buffer +// NewReader returns a Reader positioned at the start of the ring buffer. func (p *Pipeline) NewReader() *Reader { return p.ring.NewReader() } -// Close closes the underlying FileWriter, flushing and releasing all open -// file descriptors +// Close flushes and releases all open file descriptors. func (p *Pipeline) Close() error { return p.files.Close() } diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 385be3bf..7a2cb522 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -9,11 +9,9 @@ import ( // RingBuffer is a fixed-capacity circular buffer with closed-channel broadcast fan-out. // Writers never block regardless of reader count or speed. -// Readers track their position by seq value (not ring index) and receive an -// events_dropped synthetic Event when they fall behind the oldest retained event. type RingBuffer struct { mu sync.RWMutex - buf []Event + buf []Envelope head int // next write position (mod cap) written uint64 // total ever published (monotonic) notify chan struct{} @@ -21,26 +19,23 @@ type RingBuffer struct { func NewRingBuffer(capacity int) *RingBuffer { return &RingBuffer{ - buf: make([]Event, capacity), + buf: make([]Envelope, capacity), notify: make(chan struct{}), } } -// Publish adds an event to the ring buffer, evicting the oldest entry on overflow. -// Closes the current notify channel (waking all waiting readers) and replaces it -// with a new one, outside the lock to avoid blocking under contention -func (rb *RingBuffer) Publish(ev Event) { +// Publish adds an envelope to the ring, evicting the oldest on overflow. +func (rb *RingBuffer) Publish(env Envelope) { rb.mu.Lock() - rb.buf[rb.head] = ev + rb.buf[rb.head] = env rb.head = (rb.head + 1) % len(rb.buf) rb.written++ old := rb.notify rb.notify = make(chan struct{}) rb.mu.Unlock() - close(old) // outside lock to avoid blocking under contention + close(old) } -// oldestSeq returns the seq of the oldest event still in the ring func (rb *RingBuffer) oldestSeq() uint64 { if rb.written <= uint64(len(rb.buf)) { return 0 @@ -48,10 +43,7 @@ func (rb *RingBuffer) oldestSeq() uint64 { return rb.written - uint64(len(rb.buf)) } -// NewReader returns a Reader positioned at publish index 0 -// If the ring has already published events, the reader will receive an -// events_dropped Event on the first Read call if it has fallen behind -// the oldest retained event +// NewReader returns a Reader positioned at publish index 0. func (rb *RingBuffer) NewReader() *Reader { return &Reader{rb: rb, nextSeq: 0} } @@ -59,11 +51,12 @@ func (rb *RingBuffer) NewReader() *Reader { // Reader tracks an independent read position in a RingBuffer. type Reader struct { rb *RingBuffer - nextSeq uint64 // publish index, not Event.Seq + nextSeq uint64 } -// Read blocks until the next event is available or ctx is cancelled -func (r *Reader) Read(ctx context.Context) (Event, error) { +// Read blocks until the next envelope is available or ctx is cancelled. +// When the reader has fallen behind, a synthetic drop event is returned. +func (r *Reader) Read(ctx context.Context) (Envelope, error) { for { r.rb.mu.RLock() notify := r.rb.notify @@ -75,24 +68,25 @@ func (r *Reader) Read(ctx context.Context) (Event, error) { r.nextSeq = oldest r.rb.mu.RUnlock() data := json.RawMessage(fmt.Sprintf(`{"dropped":%d}`, dropped)) - return Event{Type: "events.dropped", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Data: data}, nil + return Envelope{ + Event: Event{Type: "events.dropped", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Data: data}, + }, nil } if r.nextSeq < written { idx := int(r.nextSeq % uint64(len(r.rb.buf))) - ev := r.rb.buf[idx] + env := r.rb.buf[idx] r.nextSeq++ r.rb.mu.RUnlock() - return ev, nil + return env, nil } r.rb.mu.RUnlock() select { case <-ctx.Done(): - return Event{}, ctx.Err() + return Envelope{}, ctx.Err() case <-notify: - // new event available; loop to read it } } } From 9f4c808712c040e03f4908188fcabbfe58e93b8f Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:14:41 +0000 Subject: [PATCH 14/21] refactor: unify seq as universal cursor, add NewReader(afterSeq) Ring buffer now indexes by envelope.Seq directly, removing the separate head/written counters. NewReader takes an explicit afterSeq for resume support. Renamed notify to readerWake for clarity. --- server/lib/events/events_test.go | 24 ++++++------- server/lib/events/pipeline.go | 4 +-- server/lib/events/ringbuffer.go | 62 +++++++++++++++++++------------- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index 54fdae5b..de957672 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -126,7 +126,7 @@ func cdpEvent(typ string, cat EventCategory) Event { // TestRingBuffer: publish 3 envelopes; reader reads all 3 in order func TestRingBuffer(t *testing.T) { rb := NewRingBuffer(10) - reader := rb.NewReader() + reader := rb.NewReader(0) envelopes := []Envelope{ mkEnv(1, cdpEvent("console.log", CategoryConsole)), @@ -167,7 +167,7 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { t.Fatal("Publish blocked with no readers") } - reader := rb.NewReader() + reader := rb.NewReader(0) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -180,7 +180,7 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { func TestRingBufferOverflowExistingReader(t *testing.T) { rb := NewRingBuffer(2) - reader := rb.NewReader() + reader := rb.NewReader(0) rb.Publish(mkEnv(1, cdpEvent("console.log", CategoryConsole))) rb.Publish(mkEnv(2, cdpEvent("console.log", CategoryConsole))) @@ -214,7 +214,7 @@ func TestConcurrentPublishRead(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - reader := rb.NewReader() + reader := rb.NewReader(0) var wg sync.WaitGroup @@ -248,7 +248,7 @@ func TestConcurrentReaders(t *testing.T) { readers := make([]*Reader, numReaders) for i := range readers { - readers[i] = rb.NewReader() + readers[i] = rb.NewReader(0) } for i := 0; i < numEvents; i++ { @@ -421,7 +421,7 @@ func TestPipeline(t *testing.T) { fw := NewFileWriter(t.TempDir()) p := NewPipeline(rb, fw) t.Cleanup(func() { p.Close() }) - reader := p.NewReader() + reader := p.NewReader(0) var wg sync.WaitGroup for i := 0; i < goroutines; i++ { @@ -447,7 +447,7 @@ func TestPipeline(t *testing.T) { t.Run("publish_increments_seq", func(t *testing.T) { p, _ := newPipeline(t) - reader := p.NewReader() + reader := p.NewReader(0) for i := 0; i < 3; i++ { p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) @@ -465,7 +465,7 @@ func TestPipeline(t *testing.T) { t.Run("publish_sets_ts", func(t *testing.T) { p, _ := newPipeline(t) - reader := p.NewReader() + reader := p.NewReader(0) before := time.Now().UnixMilli() p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}) @@ -497,7 +497,7 @@ func TestPipeline(t *testing.T) { t.Run("publish_writes_ring", func(t *testing.T) { p, _ := newPipeline(t) - reader := p.NewReader() + reader := p.NewReader(0) p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -513,7 +513,7 @@ func TestPipeline(t *testing.T) { p, _ := newPipeline(t) p.Start("test-uuid") - reader := p.NewReader() + reader := p.NewReader(0) p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -526,7 +526,7 @@ func TestPipeline(t *testing.T) { t.Run("truncation_applied", func(t *testing.T) { p, dir := newPipeline(t) - reader := p.NewReader() + reader := p.NewReader(0) largeData := strings.Repeat("x", 1_100_000) rawData, err := json.Marshal(map[string]string{"payload": largeData}) @@ -561,7 +561,7 @@ func TestPipeline(t *testing.T) { t.Run("defaults_detail_level", func(t *testing.T) { p, _ := newPipeline(t) - reader := p.NewReader() + reader := p.NewReader(0) p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index 403a1df0..ba7a7660 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -58,8 +58,8 @@ func (p *Pipeline) Publish(ev Event) { } // NewReader returns a Reader positioned at the start of the ring buffer. -func (p *Pipeline) NewReader() *Reader { - return p.ring.NewReader() +func (p *Pipeline) NewReader(afterSeq uint64) *Reader { + return p.ring.NewReader(afterSeq) } // Close flushes and releases all open file descriptors. diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 7a2cb522..d7e31a41 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -10,42 +10,47 @@ import ( // RingBuffer is a fixed-capacity circular buffer with closed-channel broadcast fan-out. // Writers never block regardless of reader count or speed. type RingBuffer struct { - mu sync.RWMutex - buf []Envelope - head int // next write position (mod cap) - written uint64 // total ever published (monotonic) - notify chan struct{} + mu sync.RWMutex + buf []Envelope + cap uint64 + latestSeq uint64 // highest envelope.Seq published + readerWake chan struct{} // closed-and-replaced on each Publish to wake blocked readers } func NewRingBuffer(capacity int) *RingBuffer { return &RingBuffer{ - buf: make([]Envelope, capacity), - notify: make(chan struct{}), + buf: make([]Envelope, capacity), + cap: uint64(capacity), + readerWake: make(chan struct{}), } } // Publish adds an envelope to the ring, evicting the oldest on overflow. func (rb *RingBuffer) Publish(env Envelope) { rb.mu.Lock() - rb.buf[rb.head] = env - rb.head = (rb.head + 1) % len(rb.buf) - rb.written++ - old := rb.notify - rb.notify = make(chan struct{}) + rb.buf[env.Seq%rb.cap] = env + rb.latestSeq = env.Seq + old := rb.readerWake + rb.readerWake = make(chan struct{}) rb.mu.Unlock() close(old) } func (rb *RingBuffer) oldestSeq() uint64 { - if rb.written <= uint64(len(rb.buf)) { - return 0 + if rb.latestSeq <= rb.cap { + return 1 } - return rb.written - uint64(len(rb.buf)) + return rb.latestSeq - rb.cap + 1 } -// NewReader returns a Reader positioned at publish index 0. -func (rb *RingBuffer) NewReader() *Reader { - return &Reader{rb: rb, nextSeq: 0} +// NewReader returns a Reader. afterSeq == 0 starts from the oldest available +// envelope; afterSeq > 0 resumes after that seq. +func (rb *RingBuffer) NewReader(afterSeq uint64) *Reader { + nextSeq := afterSeq + 1 + if afterSeq == 0 { + nextSeq = 1 + } + return &Reader{rb: rb, nextSeq: nextSeq} } // Reader tracks an independent read position in a RingBuffer. @@ -59,9 +64,19 @@ type Reader struct { func (r *Reader) Read(ctx context.Context) (Envelope, error) { for { r.rb.mu.RLock() - notify := r.rb.notify + wake := r.rb.readerWake + latest := r.rb.latestSeq oldest := r.rb.oldestSeq() - written := r.rb.written + + if latest == 0 { + r.rb.mu.RUnlock() + select { + case <-ctx.Done(): + return Envelope{}, ctx.Err() + case <-wake: + continue + } + } if r.nextSeq < oldest { dropped := oldest - r.nextSeq @@ -73,9 +88,8 @@ func (r *Reader) Read(ctx context.Context) (Envelope, error) { }, nil } - if r.nextSeq < written { - idx := int(r.nextSeq % uint64(len(r.rb.buf))) - env := r.rb.buf[idx] + if r.nextSeq <= latest { + env := r.rb.buf[r.nextSeq%r.rb.cap] r.nextSeq++ r.rb.mu.RUnlock() return env, nil @@ -86,7 +100,7 @@ func (r *Reader) Read(ctx context.Context) (Envelope, error) { select { case <-ctx.Done(): return Envelope{}, ctx.Err() - case <-notify: + case <-wake: } } } From 6c82459c969fa2b54178d6d1b695deab272a1a2c Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:16:20 +0000 Subject: [PATCH 15/21] refactor: return ReadResult instead of synthetic drop events Drops are now stream metadata (ReadResult.Dropped) rather than fake events smuggled into the Event schema. Transport layer decides how to surface gaps on the wire. --- server/lib/events/events_test.go | 70 ++++++++++++++------------------ server/lib/events/ringbuffer.go | 34 ++++++++-------- 2 files changed, 49 insertions(+), 55 deletions(-) diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index de957672..38a2eb4c 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -15,6 +15,15 @@ import ( "github.com/stretchr/testify/require" ) +// readEnvelope is a test helper that calls Read and asserts a non-drop result. +func readEnvelope(t *testing.T, r *Reader, ctx context.Context) Envelope { + t.Helper() + res, err := r.Read(ctx) + require.NoError(t, err) + require.NotNil(t, res.Envelope, "expected envelope, got drop") + return *res.Envelope +} + func TestEventSerialization(t *testing.T) { ev := Event{ Ts: 1234567890000, @@ -142,10 +151,9 @@ func TestRingBuffer(t *testing.T) { defer cancel() for i, expected := range envelopes { - got, err := reader.Read(ctx) - require.NoError(t, err, "reading event %d", i) - assert.Equal(t, expected.Event.Type, got.Event.Type) - assert.Equal(t, expected.Event.Category, got.Event.Category) + got := readEnvelope(t, reader, ctx) + assert.Equal(t, expected.Event.Type, got.Event.Type, "event %d", i) + assert.Equal(t, expected.Event.Category, got.Event.Category, "event %d", i) } } @@ -171,11 +179,10 @@ func TestRingBufferOverflowNoBlock(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - first, err := reader.Read(ctx) + res, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "events.dropped", first.Event.Type) - assert.Equal(t, CategorySystem, first.Event.Category) - assert.Equal(t, KindKernelAPI, first.Event.Source.Kind) + assert.Nil(t, res.Envelope, "expected drop, not envelope") + assert.True(t, res.Dropped > 0) } func TestRingBufferOverflowExistingReader(t *testing.T) { @@ -189,21 +196,17 @@ func TestRingBufferOverflowExistingReader(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - first, err := reader.Read(ctx) + // First read should be a drop notification + res, err := reader.Read(ctx) require.NoError(t, err) - assert.Equal(t, "events.dropped", first.Event.Type) - assert.Equal(t, CategorySystem, first.Event.Category) - - require.NotNil(t, first.Event.Data) - assert.True(t, json.Valid(first.Event.Data)) - assert.JSONEq(t, `{"dropped":1}`, string(first.Event.Data)) + assert.Nil(t, res.Envelope) + assert.Equal(t, uint64(1), res.Dropped) - second, err := reader.Read(ctx) - require.NoError(t, err) + // After the drop the reader continues with the surviving envelopes + second := readEnvelope(t, reader, ctx) assert.Equal(t, uint64(2), second.Seq) - third, err := reader.Read(ctx) - require.NoError(t, err) + third := readEnvelope(t, reader, ctx) assert.Equal(t, uint64(3), third.Seq) } @@ -267,10 +270,7 @@ func TestConcurrentReaders(t *testing.T) { var envs []Envelope for j := 0; j < numEvents; j++ { - env, err := reader.Read(ctx) - if !assert.NoError(t, err) { - break - } + env := readEnvelope(t, reader, ctx) envs = append(envs, env) } results[idx] = envs @@ -439,8 +439,7 @@ func TestPipeline(t *testing.T) { defer cancel() for want := uint64(1); want <= total; want++ { - env, err := reader.Read(ctx) - require.NoError(t, err) + env := readEnvelope(t, reader, ctx) assert.Equal(t, want, env.Seq, "events must arrive in seq order") } }) @@ -457,8 +456,7 @@ func TestPipeline(t *testing.T) { defer cancel() for want := uint64(1); want <= 3; want++ { - env, err := reader.Read(ctx) - require.NoError(t, err) + env := readEnvelope(t, reader, ctx) assert.Equal(t, want, env.Seq, "expected seq %d got %d", want, env.Seq) } }) @@ -474,8 +472,7 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - env, err := reader.Read(ctx) - require.NoError(t, err) + env := readEnvelope(t, reader, ctx) assert.GreaterOrEqual(t, env.Event.Ts, before) assert.LessOrEqual(t, env.Event.Ts, after) }) @@ -503,8 +500,7 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - env, err := reader.Read(ctx) - require.NoError(t, err) + env := readEnvelope(t, reader, ctx) assert.Equal(t, "page.navigation", env.Event.Type) assert.Equal(t, CategoryPage, env.Event.Category) }) @@ -519,8 +515,7 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - env, err := reader.Read(ctx) - require.NoError(t, err) + env := readEnvelope(t, reader, ctx) assert.Equal(t, "test-uuid", env.CaptureSessionID) }) @@ -543,8 +538,7 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - env, err := reader.Read(ctx) - require.NoError(t, err) + env := readEnvelope(t, reader, ctx) assert.True(t, env.Event.Truncated) assert.True(t, json.Valid(env.Event.Data)) @@ -568,13 +562,11 @@ func TestPipeline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - env, err := reader.Read(ctx) - require.NoError(t, err) + env := readEnvelope(t, reader, ctx) assert.Equal(t, DetailStandard, env.Event.DetailLevel) p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1, DetailLevel: DetailVerbose}) - env2, err := reader.Read(ctx) - require.NoError(t, err) + env2 := readEnvelope(t, reader, ctx) assert.Equal(t, DetailVerbose, env2.Event.DetailLevel) }) } diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index d7e31a41..41659e94 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -2,19 +2,17 @@ package events import ( "context" - "encoding/json" - "fmt" "sync" ) // RingBuffer is a fixed-capacity circular buffer with closed-channel broadcast fan-out. // Writers never block regardless of reader count or speed. type RingBuffer struct { - mu sync.RWMutex - buf []Envelope - cap uint64 - latestSeq uint64 // highest envelope.Seq published - readerWake chan struct{} // closed-and-replaced on each Publish to wake blocked readers + mu sync.RWMutex + buf []Envelope + cap uint64 + latestSeq uint64 // highest envelope.Seq published + readerWake chan struct{} // closed-and-replaced on each Publish to wake blocked readers } func NewRingBuffer(capacity int) *RingBuffer { @@ -53,6 +51,14 @@ func (rb *RingBuffer) NewReader(afterSeq uint64) *Reader { return &Reader{rb: rb, nextSeq: nextSeq} } +// ReadResult is returned by Reader.Read. Exactly one of Envelope or Dropped is +// set: Envelope is non-nil for a normal read, Dropped is non-zero when the +// reader fell behind and events were lost. +type ReadResult struct { + Envelope *Envelope + Dropped uint64 +} + // Reader tracks an independent read position in a RingBuffer. type Reader struct { rb *RingBuffer @@ -60,8 +66,7 @@ type Reader struct { } // Read blocks until the next envelope is available or ctx is cancelled. -// When the reader has fallen behind, a synthetic drop event is returned. -func (r *Reader) Read(ctx context.Context) (Envelope, error) { +func (r *Reader) Read(ctx context.Context) (ReadResult, error) { for { r.rb.mu.RLock() wake := r.rb.readerWake @@ -72,7 +77,7 @@ func (r *Reader) Read(ctx context.Context) (Envelope, error) { r.rb.mu.RUnlock() select { case <-ctx.Done(): - return Envelope{}, ctx.Err() + return ReadResult{}, ctx.Err() case <-wake: continue } @@ -82,24 +87,21 @@ func (r *Reader) Read(ctx context.Context) (Envelope, error) { dropped := oldest - r.nextSeq r.nextSeq = oldest r.rb.mu.RUnlock() - data := json.RawMessage(fmt.Sprintf(`{"dropped":%d}`, dropped)) - return Envelope{ - Event: Event{Type: "events.dropped", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Data: data}, - }, nil + return ReadResult{Dropped: dropped}, nil } if r.nextSeq <= latest { env := r.rb.buf[r.nextSeq%r.rb.cap] r.nextSeq++ r.rb.mu.RUnlock() - return env, nil + return ReadResult{Envelope: &env}, nil } r.rb.mu.RUnlock() select { case <-ctx.Done(): - return Envelope{}, ctx.Err() + return ReadResult{}, ctx.Err() case <-wake: } } From 6506ed7f82e00bdc3408b90f5112e0995f973633 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 31 Mar 2026 20:45:26 +0000 Subject: [PATCH 16/21] test: add NewReader resume tests for mid-stream, at-latest, and evicted cases --- server/lib/events/events_test.go | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index 38a2eb4c..b9da4634 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -210,6 +210,47 @@ func TestRingBufferOverflowExistingReader(t *testing.T) { assert.Equal(t, uint64(3), third.Seq) } +func TestNewReaderResume(t *testing.T) { + rb := NewRingBuffer(10) + for i := uint64(1); i <= 5; i++ { + rb.Publish(mkEnv(i, cdpEvent("console.log", CategoryConsole))) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + t.Run("resume_mid_stream", func(t *testing.T) { + reader := rb.NewReader(3) + env := readEnvelope(t, reader, ctx) + assert.Equal(t, uint64(4), env.Seq) + }) + + t.Run("resume_at_latest", func(t *testing.T) { + reader := rb.NewReader(5) + // Nothing to read — should block until ctx cancels + shortCtx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) + defer cancel() + _, err := reader.Read(shortCtx) + assert.ErrorIs(t, err, context.DeadlineExceeded) + }) + + t.Run("resume_before_oldest_triggers_drop", func(t *testing.T) { + small := NewRingBuffer(3) + for i := uint64(1); i <= 5; i++ { + small.Publish(mkEnv(i, cdpEvent("console.log", CategoryConsole))) + } + // oldest in ring is seq 3, requesting resume after seq 1 + reader := small.NewReader(1) + res, err := reader.Read(ctx) + require.NoError(t, err) + assert.Nil(t, res.Envelope) + assert.Equal(t, uint64(1), res.Dropped) + + env := readEnvelope(t, reader, ctx) + assert.Equal(t, uint64(3), env.Seq) + }) +} + func TestConcurrentPublishRead(t *testing.T) { const numEvents = 20 rb := NewRingBuffer(32) From 8ecd49206726598ca30acf528b7ca2304903d50e Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 11:51:39 +0000 Subject: [PATCH 17/21] review: fmt --- server/lib/events/event.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index 9dab2ffc..5ddb86ce 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -40,10 +40,10 @@ type Source struct { type DetailLevel string const ( - DetailMinimal DetailLevel = "minimal" + DetailMinimal DetailLevel = "minimal" DetailStandard DetailLevel = "standard" - DetailVerbose DetailLevel = "verbose" - DetailRaw DetailLevel = "raw" + DetailVerbose DetailLevel = "verbose" + DetailRaw DetailLevel = "raw" ) // Event is the portable event schema. It contains only producer-emitted content; From e572e7bab87278ff43dc045fadded6f629cf04ce Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 11:59:16 +0000 Subject: [PATCH 18/21] fix: guard against nil marshal data and oversized non-data envelopes truncateIfNeeded now warns if the envelope still exceeds the 1MB limit after nulling data (e.g. huge url or source.metadata). Pipeline.Publish skips the file write when marshal returns nil to avoid writing corrupt bare-newline JSONL lines. --- server/lib/events/event.go | 10 ++++++++-- server/lib/events/pipeline.go | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index 5ddb86ce..d464480f 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -2,6 +2,7 @@ package events import ( "encoding/json" + "log/slog" ) // maxS2RecordBytes is the maximum record size for the S2 event pipeline (1 MB). @@ -66,11 +67,13 @@ type Envelope struct { Event Event `json:"event"` } -// truncateIfNeeded marshals env and returns the (possibly truncated) envelope +// truncateIfNeeded marshals env and returns the (possibly truncated) envelope. +// If the envelope still exceeds maxS2RecordBytes after nulling data (e.g. huge +// url or source.metadata), it is returned as-is — callers must handle nil data. func truncateIfNeeded(env Envelope) (Envelope, []byte) { data, err := json.Marshal(env) if err != nil { - return env, data + return env, nil } if len(data) <= maxS2RecordBytes { return env, data @@ -81,5 +84,8 @@ func truncateIfNeeded(env Envelope) (Envelope, []byte) { if err != nil { return env, nil } + if len(data) > maxS2RecordBytes { + slog.Warn("truncateIfNeeded: envelope exceeds limit even without data", "seq", env.Seq, "size", len(data)) + } return env, data } diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index ba7a7660..e69c254f 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -51,7 +51,9 @@ func (p *Pipeline) Publish(ev Event) { } env, data := truncateIfNeeded(env) - if err := p.files.Write(env, data); err != nil { + if data == nil { + slog.Error("pipeline: marshal failed, skipping file write", "seq", env.Seq, "category", env.Event.Category) + } else if err := p.files.Write(env, data); err != nil { slog.Error("pipeline: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) } p.ring.Publish(env) From 6fc54c58336f3f53410329010a7f808b9d3f3904 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 18:00:20 +0000 Subject: [PATCH 19/21] review: address naming & constructor feedback --- server/lib/events/event.go | 2 +- server/lib/events/events_test.go | 31 +++++++++---------- server/lib/events/pipeline.go | 51 +++++++++++++------------------- 3 files changed, 38 insertions(+), 46 deletions(-) diff --git a/server/lib/events/event.go b/server/lib/events/event.go index d464480f..4db821d4 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -50,7 +50,7 @@ const ( // Event is the portable event schema. It contains only producer-emitted content; // pipeline metadata (seq, capture session) lives on the Envelope. type Event struct { - Ts int64 `json:"ts"` + Ts int64 `json:"ts"` // Unix microseconds (µs since epoch) Type string `json:"type"` Category EventCategory `json:"category"` Source Source `json:"source"` diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index b9da4634..9325c6ea 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -442,13 +442,13 @@ func TestFileWriter(t *testing.T) { }) } -func TestPipeline(t *testing.T) { - newPipeline := func(t *testing.T) (*Pipeline, string) { +func TestCaptureSession(t *testing.T) { + newSession := func(t *testing.T) (*CaptureSession, string) { t.Helper() dir := t.TempDir() rb := NewRingBuffer(100) fw := NewFileWriter(dir) - p := NewPipeline(rb, fw) + p := NewCaptureSession("", rb, fw) t.Cleanup(func() { p.Close() }) return p, dir } @@ -460,7 +460,7 @@ func TestPipeline(t *testing.T) { rb := NewRingBuffer(total) fw := NewFileWriter(t.TempDir()) - p := NewPipeline(rb, fw) + p := NewCaptureSession("", rb, fw) t.Cleanup(func() { p.Close() }) reader := p.NewReader(0) @@ -486,7 +486,7 @@ func TestPipeline(t *testing.T) { }) t.Run("publish_increments_seq", func(t *testing.T) { - p, _ := newPipeline(t) + p, _ := newSession(t) reader := p.NewReader(0) for i := 0; i < 3; i++ { @@ -503,12 +503,12 @@ func TestPipeline(t *testing.T) { }) t.Run("publish_sets_ts", func(t *testing.T) { - p, _ := newPipeline(t) + p, _ := newSession(t) reader := p.NewReader(0) - before := time.Now().UnixMilli() + before := time.Now().UnixMicro() p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}) - after := time.Now().UnixMilli() + after := time.Now().UnixMicro() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -519,7 +519,7 @@ func TestPipeline(t *testing.T) { }) t.Run("publish_writes_file", func(t *testing.T) { - p, dir := newPipeline(t) + p, dir := newSession(t) p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) @@ -533,7 +533,7 @@ func TestPipeline(t *testing.T) { }) t.Run("publish_writes_ring", func(t *testing.T) { - p, _ := newPipeline(t) + p, _ := newSession(t) reader := p.NewReader(0) p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) @@ -546,9 +546,10 @@ func TestPipeline(t *testing.T) { assert.Equal(t, CategoryPage, env.Event.Category) }) - t.Run("start_sets_capture_session_id", func(t *testing.T) { - p, _ := newPipeline(t) - p.Start("test-uuid") + t.Run("constructor_sets_capture_session_id", func(t *testing.T) { + dir := t.TempDir() + p := NewCaptureSession("test-uuid", NewRingBuffer(100), NewFileWriter(dir)) + t.Cleanup(func() { p.Close() }) reader := p.NewReader(0) p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) @@ -561,7 +562,7 @@ func TestPipeline(t *testing.T) { }) t.Run("truncation_applied", func(t *testing.T) { - p, dir := newPipeline(t) + p, dir := newSession(t) reader := p.NewReader(0) largeData := strings.Repeat("x", 1_100_000) @@ -595,7 +596,7 @@ func TestPipeline(t *testing.T) { }) t.Run("defaults_detail_level", func(t *testing.T) { - p, _ := newPipeline(t) + p, _ := newSession(t) reader := p.NewReader(0) p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) diff --git a/server/lib/events/pipeline.go b/server/lib/events/pipeline.go index e69c254f..5889cb03 100644 --- a/server/lib/events/pipeline.go +++ b/server/lib/events/pipeline.go @@ -7,64 +7,55 @@ import ( "time" ) -// Pipeline is a single-use write path that wraps events in envelopes and fans -// them out to a FileWriter (durable) and RingBuffer (in-memory). Call Start -// once with a capture session ID, then Publish concurrently. Close flushes the -// FileWriter; there is no restart or terminal event. -type Pipeline struct { +// CaptureSession is a single-use write path that wraps events in envelopes and +// fans them out to a FileWriter (durable) and RingBuffer (in-memory). Publish +// concurrently; Close flushes the FileWriter. +type CaptureSession struct { mu sync.Mutex ring *RingBuffer files *FileWriter seq atomic.Uint64 - captureSessionID atomic.Pointer[string] + captureSessionID string } -func NewPipeline(ring *RingBuffer, files *FileWriter) *Pipeline { - p := &Pipeline{ring: ring, files: files} - empty := "" - p.captureSessionID.Store(&empty) - return p -} - -// Start sets the capture session ID stamped on every subsequent envelope. -func (p *Pipeline) Start(captureSessionID string) { - p.captureSessionID.Store(&captureSessionID) +func NewCaptureSession(captureSessionID string, ring *RingBuffer, files *FileWriter) *CaptureSession { + return &CaptureSession{ring: ring, files: files, captureSessionID: captureSessionID} } // Publish wraps ev in an Envelope, truncates if needed, then writes to // FileWriter (durable) before RingBuffer (in-memory fan-out). -func (p *Pipeline) Publish(ev Event) { - p.mu.Lock() - defer p.mu.Unlock() +func (s *CaptureSession) Publish(ev Event) { + s.mu.Lock() + defer s.mu.Unlock() if ev.Ts == 0 { - ev.Ts = time.Now().UnixMilli() + ev.Ts = time.Now().UnixMicro() } if ev.DetailLevel == "" { ev.DetailLevel = DetailStandard } env := Envelope{ - CaptureSessionID: *p.captureSessionID.Load(), - Seq: p.seq.Add(1), + CaptureSessionID: s.captureSessionID, + Seq: s.seq.Add(1), Event: ev, } env, data := truncateIfNeeded(env) if data == nil { - slog.Error("pipeline: marshal failed, skipping file write", "seq", env.Seq, "category", env.Event.Category) - } else if err := p.files.Write(env, data); err != nil { - slog.Error("pipeline: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) + slog.Error("capture_session: marshal failed, skipping file write", "seq", env.Seq, "category", env.Event.Category) + } else if err := s.files.Write(env, data); err != nil { + slog.Error("capture_session: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) } - p.ring.Publish(env) + s.ring.Publish(env) } // NewReader returns a Reader positioned at the start of the ring buffer. -func (p *Pipeline) NewReader(afterSeq uint64) *Reader { - return p.ring.NewReader(afterSeq) +func (s *CaptureSession) NewReader(afterSeq uint64) *Reader { + return s.ring.NewReader(afterSeq) } // Close flushes and releases all open file descriptors. -func (p *Pipeline) Close() error { - return p.files.Close() +func (s *CaptureSession) Close() error { + return s.files.Close() } From d791a9e5fde4290c458b6e380a1b89542b2bada5 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 1 Apr 2026 19:15:00 +0000 Subject: [PATCH 20/21] fix: file rename --- server/lib/events/{pipeline.go => capturesession.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/lib/events/{pipeline.go => capturesession.go} (100%) diff --git a/server/lib/events/pipeline.go b/server/lib/events/capturesession.go similarity index 100% rename from server/lib/events/pipeline.go rename to server/lib/events/capturesession.go From bf091f580e66ecf3cb1358db46c2463e5191f583 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 2 Apr 2026 15:13:59 +0000 Subject: [PATCH 21/21] review: remove redundant atomic and dead branch in events package --- server/lib/events/capturesession.go | 6 +++--- server/lib/events/ringbuffer.go | 6 +----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/server/lib/events/capturesession.go b/server/lib/events/capturesession.go index 5889cb03..a430980a 100644 --- a/server/lib/events/capturesession.go +++ b/server/lib/events/capturesession.go @@ -3,7 +3,6 @@ package events import ( "log/slog" "sync" - "sync/atomic" "time" ) @@ -14,7 +13,7 @@ type CaptureSession struct { mu sync.Mutex ring *RingBuffer files *FileWriter - seq atomic.Uint64 + seq uint64 captureSessionID string } @@ -35,9 +34,10 @@ func (s *CaptureSession) Publish(ev Event) { ev.DetailLevel = DetailStandard } + s.seq++ env := Envelope{ CaptureSessionID: s.captureSessionID, - Seq: s.seq.Add(1), + Seq: s.seq, Event: ev, } env, data := truncateIfNeeded(env) diff --git a/server/lib/events/ringbuffer.go b/server/lib/events/ringbuffer.go index 41659e94..d30a680c 100644 --- a/server/lib/events/ringbuffer.go +++ b/server/lib/events/ringbuffer.go @@ -44,11 +44,7 @@ func (rb *RingBuffer) oldestSeq() uint64 { // NewReader returns a Reader. afterSeq == 0 starts from the oldest available // envelope; afterSeq > 0 resumes after that seq. func (rb *RingBuffer) NewReader(afterSeq uint64) *Reader { - nextSeq := afterSeq + 1 - if afterSeq == 0 { - nextSeq = 1 - } - return &Reader{rb: rb, nextSeq: nextSeq} + return &Reader{rb: rb, nextSeq: afterSeq + 1} } // ReadResult is returned by Reader.Read. Exactly one of Envelope or Dropped is