Skip to content

Commit b4a8daa

Browse files
claudeandrewh
authored andcommitted
Add reader-based replay entry points for non-filesystem callers
Extract the bodies of ScanRecording and ReplayRecording into io.Reader variants (ScanRecordingFrom, ReplayRecordingFrom) and make the path-based functions thin wrappers over them. This lets callers that hold a recording in memory rather than on disk — notably pkg/synth compiled to js/wasm in the browser — drive replay without an os.Open dependency. The serialization side (WriteRecording/ReadRecording) was already reader/writer-based; only scan and replay-emit were path-locked. Behavior is unchanged for existing CLI callers, which still pass a path. Doc comments on the reader variants call out the two-pass dependency: the scan must precede the replay, so a single non-seekable reader can't serve both — in-memory callers hand a fresh reader over the same bytes to each. Adds reader-based test coverage and confirms pkg/synth still builds under GOOS=js GOARCH=wasm. Fixes #216 Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_011drMdKCDHZe6ozhAaa7un9
1 parent 9430a93 commit b4a8daa

3 files changed

Lines changed: 151 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Reader-based replay entry points `ScanRecordingFrom(io.Reader)` and
13+
`ReplayRecordingFrom(ctx, io.Reader, …)` so non-filesystem callers (such as
14+
`pkg/synth` compiled to `js/wasm`) can replay in-memory recordings. The
15+
existing path-based `ScanRecording`/`ReplayRecording` now delegate to them;
16+
no behavior change for CLI callers. (#216)
17+
1218
- Replay mode: `motel import --record <file>` writes a newline-delimited
1319
recording sidecar of the source traces alongside the inferred topology, and a
1420
`mode: replay` config (with `recording: <file>`) re-emits the recorded trace

pkg/synth/replay.go

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -95,19 +95,19 @@ type RecordingInfo struct {
9595
Spans int
9696
}
9797

98-
// ScanRecording reads a recording once to collect the data needed to set up
99-
// replay: the set of services (for provider/resource creation) and the global
100-
// earliest start time (for relative time-shifting).
101-
func ScanRecording(path string) (RecordingInfo, error) {
102-
f, err := os.Open(path)
103-
if err != nil {
104-
return RecordingInfo{}, fmt.Errorf("opening recording: %w", err)
105-
}
106-
defer f.Close() //nolint:errcheck // read-only file, close error is not actionable
107-
98+
// ScanRecordingFrom reads a recording from r once to collect the data needed to
99+
// set up replay: the set of services (for provider/resource creation) and the
100+
// global earliest start time (for relative time-shifting).
101+
//
102+
// Replay is a two-pass operation: this scan must run before ReplayRecordingFrom
103+
// so the earliest start and service set are known up front. A single
104+
// non-seekable reader cannot serve both passes, so callers holding the
105+
// recording in memory should hand a fresh reader (e.g. bytes.NewReader over the
106+
// same bytes) to each function.
107+
func ScanRecordingFrom(r io.Reader) (RecordingInfo, error) {
108108
seen := make(map[string]struct{})
109109
info := RecordingInfo{}
110-
err = ReadRecording(f, func(t RecordedTrace) error {
110+
err := ReadRecording(r, func(t RecordedTrace) error {
111111
info.Traces++
112112
for _, s := range t.Spans {
113113
info.Spans++
@@ -128,6 +128,17 @@ func ScanRecording(path string) (RecordingInfo, error) {
128128
return info, nil
129129
}
130130

131+
// ScanRecording reads a recording from the file at path once to collect the
132+
// data needed to set up replay. It is a thin wrapper over ScanRecordingFrom.
133+
func ScanRecording(path string) (RecordingInfo, error) {
134+
f, err := os.Open(path)
135+
if err != nil {
136+
return RecordingInfo{}, fmt.Errorf("opening recording: %w", err)
137+
}
138+
defer f.Close() //nolint:errcheck // read-only file, close error is not actionable
139+
return ScanRecordingFrom(f)
140+
}
141+
131142
// ReplayOptions controls how a recording is re-emitted.
132143
type ReplayOptions struct {
133144
// Verbatim emits spans with their original recorded timestamps. When false
@@ -230,22 +241,21 @@ func randomReplaySpanID() trace.SpanID {
230241
return sid
231242
}
232243

233-
// ReplayRecording streams the recording at path and emits each trace through
244+
// ReplayRecordingFrom streams the recording from r and emits each trace through
234245
// the given tracers and observers. Timestamps follow opts. Returns aggregate
235246
// emission statistics.
236-
func ReplayRecording(ctx context.Context, path string, tracers TracerSource, observers []SpanObserver, opts ReplayOptions) (*Stats, error) {
237-
f, err := os.Open(path)
238-
if err != nil {
239-
return nil, fmt.Errorf("opening recording: %w", err)
240-
}
241-
defer f.Close() //nolint:errcheck // read-only file, close error is not actionable
242-
247+
//
248+
// Relative time-shifting (the default, non-Verbatim mode) needs opts.Start,
249+
// which comes from a prior ScanRecordingFrom pass over the same recording. A
250+
// single non-seekable reader cannot serve both passes, so in-memory callers
251+
// should hand a fresh reader over the same bytes to each function.
252+
func ReplayRecordingFrom(ctx context.Context, r io.Reader, tracers TracerSource, observers []SpanObserver, opts ReplayOptions) (*Stats, error) {
243253
shift := opts.shift()
244254
var stats Stats
245255
var rstats realtimeStats
246256
start := time.Now()
247257

248-
err = ReadRecording(f, func(t RecordedTrace) error {
258+
err := ReadRecording(r, func(t RecordedTrace) error {
249259
if ctx.Err() != nil {
250260
return ctx.Err()
251261
}
@@ -274,6 +284,18 @@ func ReplayRecording(ctx context.Context, path string, tracers TracerSource, obs
274284
return &stats, nil
275285
}
276286

287+
// ReplayRecording streams the recording at path and emits each trace through
288+
// the given tracers and observers. It is a thin wrapper over
289+
// ReplayRecordingFrom.
290+
func ReplayRecording(ctx context.Context, path string, tracers TracerSource, observers []SpanObserver, opts ReplayOptions) (*Stats, error) {
291+
f, err := os.Open(path)
292+
if err != nil {
293+
return nil, fmt.Errorf("opening recording: %w", err)
294+
}
295+
defer f.Close() //nolint:errcheck // read-only file, close error is not actionable
296+
return ReplayRecordingFrom(ctx, f, tracers, observers, opts)
297+
}
298+
277299
// buildReplayPlans converts a recorded trace into ordered SpanPlans. Spans are
278300
// indexed in tree order (parents before children) so emission can resolve the
279301
// parent context, and each child's window is clamped to sit within its parent

pkg/synth/replay_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"os"
7+
"slices"
78
"strings"
89
"testing"
910
"time"
@@ -303,3 +304,105 @@ func TestReplayRecordingRejectsInvalidPreservedIDs(t *testing.T) {
303304
t.Fatalf("error = %v, want invalid trace ID", err)
304305
}
305306
}
307+
308+
// recordingBytes serializes traces into the newline-delimited recording format,
309+
// mirroring an in-memory (non-filesystem) recording such as the WASM playground
310+
// holds.
311+
func recordingBytes(t *testing.T, traces ...RecordedTrace) []byte {
312+
t.Helper()
313+
var buf bytes.Buffer
314+
w := NewRecordingWriter(&buf)
315+
for _, tr := range traces {
316+
if err := w.Write(tr); err != nil {
317+
t.Fatalf("write recording: %v", err)
318+
}
319+
}
320+
return buf.Bytes()
321+
}
322+
323+
func TestScanRecordingFromCollectsServicesAndStart(t *testing.T) {
324+
rec := sampleRecording()
325+
data := recordingBytes(t, rec)
326+
327+
info, err := ScanRecordingFrom(bytes.NewReader(data))
328+
if err != nil {
329+
t.Fatalf("scan: %v", err)
330+
}
331+
if info.Traces != 1 || info.Spans != 2 {
332+
t.Fatalf("info = %+v, want 1 trace / 2 spans", info)
333+
}
334+
if got, want := info.Services, []string{"api", "db"}; !slices.Equal(got, want) {
335+
t.Errorf("services = %v, want %v", got, want)
336+
}
337+
if !info.Start.Equal(rec.Spans[0].Start) {
338+
t.Errorf("start = %v, want %v", info.Start, rec.Spans[0].Start)
339+
}
340+
}
341+
342+
func TestReplayRecordingFromEmitsViaTracers(t *testing.T) {
343+
data := recordingBytes(t, sampleRecording())
344+
345+
rec := newReplayObserver()
346+
stats, err := ReplayRecordingFrom(context.Background(), bytes.NewReader(data), noopTracers(), []SpanObserver{rec}, ReplayOptions{Verbatim: true})
347+
if err != nil {
348+
t.Fatalf("replay: %v", err)
349+
}
350+
if stats.Traces != 1 || stats.Spans != 2 {
351+
t.Fatalf("stats = %+v, want 1 trace / 2 spans", stats)
352+
}
353+
if stats.Errors != 1 {
354+
t.Errorf("errors = %d, want 1", stats.Errors)
355+
}
356+
if rec.started != 2 || rec.observed != 2 {
357+
t.Errorf("observer fired started=%d observed=%d, want 2/2", rec.started, rec.observed)
358+
}
359+
}
360+
361+
// TestReaderReplayTwoPass exercises the in-memory flow the reader variants
362+
// exist for: a single recording held as bytes, scanned then replayed by handing
363+
// a fresh reader to each pass, with the scan's Start driving relative shifting.
364+
func TestReaderReplayTwoPass(t *testing.T) {
365+
rec := sampleRecording()
366+
data := recordingBytes(t, rec)
367+
anchor := time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC)
368+
369+
info, err := ScanRecordingFrom(bytes.NewReader(data))
370+
if err != nil {
371+
t.Fatalf("scan: %v", err)
372+
}
373+
374+
recorder := tracetest.NewSpanRecorder()
375+
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
376+
t.Cleanup(func() {
377+
if err := provider.Shutdown(context.Background()); err != nil {
378+
t.Fatalf("shutdown tracer provider: %v", err)
379+
}
380+
})
381+
tracers := func(name string) trace.Tracer { return provider.Tracer(name) }
382+
383+
stats, err := ReplayRecordingFrom(context.Background(), bytes.NewReader(data), tracers, nil, ReplayOptions{
384+
Start: info.Start,
385+
Anchor: anchor,
386+
})
387+
if err != nil {
388+
t.Fatalf("replay: %v", err)
389+
}
390+
if stats.Traces != 1 || stats.Spans != 2 {
391+
t.Fatalf("stats = %+v, want 1 trace / 2 spans", stats)
392+
}
393+
394+
spans := recorder.Ended()
395+
if len(spans) != 2 {
396+
t.Fatalf("got %d ended spans, want 2", len(spans))
397+
}
398+
// Relative mode maps the earliest recorded start onto the anchor.
399+
var earliest time.Time
400+
for _, s := range spans {
401+
if earliest.IsZero() || s.StartTime().Before(earliest) {
402+
earliest = s.StartTime()
403+
}
404+
}
405+
if !earliest.Equal(anchor) {
406+
t.Errorf("earliest replayed start = %v, want anchor %v", earliest, anchor)
407+
}
408+
}

0 commit comments

Comments
 (0)