Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6815ae7
cassette: do not embed `sync.Mutex` into the `Cassette`
dnaeon Jun 22, 2026
8e02537
cassette: add a new field for configuring a debug logger
dnaeon Jun 22, 2026
fc89672
recorder: add `WithDebugLogger` option
dnaeon Jun 22, 2026
ee455b6
recorder: WithDebugLogger -> WithDebugWriter
dnaeon Jun 22, 2026
57c1950
cassette: add Cassette.debug() method
dnaeon Jun 22, 2026
10564ab
recorder: use shorter `r` instead of `rec` receiver name
dnaeon Jun 22, 2026
f034660
recorder: implement fmt.Stringer for recorder.Mode
dnaeon Jun 22, 2026
761b22e
recorder: minor style fixes
dnaeon Jun 22, 2026
4beac6b
Add support for emitting debug log events for the recorder and cassette
dnaeon Jun 22, 2026
89a7cdb
recorder: add support for configuring debug logger via `VCR_DEBUG` en…
dnaeon Jun 22, 2026
5b6ff0d
Add tests related to debug logger
dnaeon Jun 22, 2026
0332abb
cassette,recorder: use consistent keys for debug events
dnaeon Jun 23, 2026
46db3c1
recorder: emit debug events when invoking hooks
dnaeon Jun 23, 2026
4dc7be1
Emit debug event when loading a cassette
dnaeon Jun 23, 2026
2509028
cassette: impelement fmt.Stringer for Request and Response
dnaeon Jun 23, 2026
b32ef24
cassette,recorder: additional debug events related to HTTP middleware…
dnaeon Jun 23, 2026
0354b2d
recorder: emit debug events when context is cancelled
dnaeon Jun 23, 2026
70b6a1b
Drop redundant debug events
dnaeon Jun 23, 2026
5382708
examples: add an example for WithDebugWriter option
dnaeon Jun 23, 2026
10d2e9f
README: update read and include full examples
dnaeon Jun 23, 2026
9c2176c
Style fixes, use stdlib functions where applicable
dnaeon Jun 23, 2026
e097afe
Drop redundant fields during in debug events
dnaeon Jun 23, 2026
7ec4b4c
Fix additional nits
dnaeon Jun 23, 2026
f9d1fce
cassette: hint API users to use the recorder.WithDebugWriter option f…
dnaeon Jun 24, 2026
854bb3e
changelog: backfill entries and prepare for new patch release
dnaeon Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 192 additions & 10 deletions pkg/cassette/cassette.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,135 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"reflect"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"

"go.yaml.in/yaml/v4"
)

// debugBodyLimit is the maximum number of body bytes rendered in a debug dump.
// Anything larger is truncated with a "(truncated, N bytes total)" suffix.
const debugBodyLimit = 16 * 1024

// debugSummaryBodyLimit is the maximum number of body bytes rendered in a
// compact one-line request/response summary. Keeps the per-attempt match log
// readable when there are many interactions to compare against.
const debugSummaryBodyLimit = 80

// summarizeBody returns a single-line, bounded rendering of the given body for
// inclusion in a compact request/response summary. Newlines are collapsed and
// the result is truncated to debugSummaryBodyLimit bytes.
func summarizeBody(b string) string {
if b == "" {
return ""
}
s := strings.ReplaceAll(b, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
if len(s) > debugSummaryBodyLimit {
s = s[:debugSummaryBodyLimit] + "..."
}

return s
}

// summarizeCassetteRequest renders a recorded [Request] as a compact,
// single-line summary suitable for grep-friendly debug log attrs.
func summarizeCassetteRequest(req Request) string {
return fmt.Sprintf("%s %s body=%q", req.Method, req.URL, summarizeBody(req.Body))
}

// summarizeCassetteResponse renders a recorded [Response] as a compact,
// single-line summary.
func summarizeCassetteResponse(resp Response) string {
return fmt.Sprintf("%d body=%q", resp.Code, summarizeBody(resp.Body))
}

// formatBody returns a human-readable rendering of the given body bytes for
// inclusion in a debug dump. Binary content is replaced with a "<binary, N
// bytes>" placeholder so that the trace stays readable in a text-based slog
// handler.
func formatBody(b []byte) string {
if len(b) == 0 {
return ""
}
if !isPrintable(b) {
return fmt.Sprintf("<binary, %d bytes>", len(b))
}
if len(b) > debugBodyLimit {
return fmt.Sprintf("%s\n(truncated, %d bytes total)", b[:debugBodyLimit], len(b))
}

return string(b)
}

// isPrintable reports whether b is safe to render as text in a debug trace:
// it must be valid UTF-8 and contain no control characters other than the
// whitespace runes commonly found in HTTP payloads (tab, LF, CR).
func isPrintable(b []byte) bool {
if !utf8.Valid(b) {
return false
}
for _, r := range string(b) {
switch r {
case '\t', '\n', '\r':
continue
}
if !unicode.IsPrint(r) {
return false
}
}

return true
}

// dumpHTTPRequest renders an [*http.Request] in wire format for inclusion in a
// debug trace. The request body is consumed and replaced with a fresh reader
// so subsequent code (the matcher, the round-tripper) can still read it.
func dumpHTTPRequest(r *http.Request) string {
if r.Body == nil || r.Body == http.NoBody {
dump, err := httputil.DumpRequest(r, false)
if err != nil {
return fmt.Sprintf("<dump error: %v>", err)
}

return string(dump)
}
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Sprintf("<body read error: %v>", err)
}
r.Body = io.NopCloser(bytes.NewReader(body))
dump, err := httputil.DumpRequest(r, false)
if err != nil {
return fmt.Sprintf("<dump error: %v>", err)
}

return fmt.Sprintf("%s\n%s", dump, formatBody(body))
}

// dumpCassetteRequest renders a recorded [Request] in a wire-format-like form
// for visual diffing against an [*http.Request] dumped via [dumpHTTPRequest].
func dumpCassetteRequest(req Request) string {
var b strings.Builder
fmt.Fprintf(&b, "%s %s %s\n", req.Method, req.URL, req.Proto)
fmt.Fprintf(&b, "Host: %s\n", req.Host)
if err := req.Headers.Write(&b); err != nil {
fmt.Fprintf(&b, "<header write error: %v>\n", err)
}
b.WriteString("\n")
b.WriteString(formatBody([]byte(req.Body)))

return b.String()
}

const (
// CassetteFormatVersion is the supported cassette version.
CassetteFormatVersion = 2
Expand Down Expand Up @@ -372,7 +491,7 @@ var DefaultMatcher = NewDefaultMatcher()

// Cassette represents a cassette containing recorded interactions.
type Cassette struct {
sync.Mutex `yaml:"-"`
mu sync.Mutex `yaml:"-"`

// Name of the cassette
Name string `yaml:"-"`
Expand Down Expand Up @@ -402,6 +521,9 @@ type Cassette struct {

// MarshalFunc is a custom marshal func.
MarshalFunc MarshalFunc `yaml:"-"`

// DebugLogger is an [slog.Logger] which is used to emit debug events.
DebugLogger *slog.Logger `yaml:"-"`
}

// New creates a new empty cassette
Expand All @@ -415,6 +537,7 @@ func New(name string) *Cassette {
ReplayableInteractions: false,
IsNew: true,
nextInteractionId: 0,
DebugLogger: slog.New(slog.DiscardHandler),
}

return c
Expand Down Expand Up @@ -448,38 +571,79 @@ func LoadWithFS(name string, fs FS) (*Cassette, error) {

// AddInteraction appends a new interaction to the cassette
func (c *Cassette) AddInteraction(i *Interaction) {
c.Lock()
defer c.Unlock()
c.mu.Lock()
defer c.mu.Unlock()
i.ID = c.nextInteractionId
c.nextInteractionId += 1
c.Interactions = append(c.Interactions, i)
c.debug("interaction added",
"interaction_id", i.ID,
"total", len(c.Interactions),
Comment thread
dnaeon marked this conversation as resolved.
Outdated
"request", summarizeCassetteRequest(i.Request),
"response", summarizeCassetteResponse(i.Response),
)
}

// GetInteraction retrieves a recorded request/response interaction
func (c *Cassette) GetInteraction(r *http.Request) (*Interaction, error) {
return c.getInteraction(r)
}

// debug emits a debug event
func (c *Cassette) debug(msg string, args ...any) {
c.DebugLogger.Debug(msg, args...)
}

// getInteraction searches for the interaction corresponding to the given HTTP
// request, by using the configured [MatcherFunc].
func (c *Cassette) getInteraction(r *http.Request) (*Interaction, error) {
c.Lock()
defer c.Unlock()
c.mu.Lock()
defer c.mu.Unlock()
if r.Body == nil {
// causes an error in the matcher when we try to do r.ParseForm if r.Body is nil
// r.ParseForm returns missing form body error
r.Body = http.NoBody
}
c.debug("matching request", "interactions_total", len(c.Interactions))
c.debug("incoming request",
"method", r.Method,
"url", r.URL.String(),
Comment thread
dnaeon marked this conversation as resolved.
Outdated
"host", r.Host,
"dump", dumpHTTPRequest(r),
)
replayed := 0
for _, i := range c.Interactions {
if i.replayed {
replayed++
}
if (c.ReplayableInteractions || !i.replayed) && c.Matcher(r, i.Request) {
eligible := c.ReplayableInteractions || !i.replayed
matched := eligible && c.Matcher(r, i.Request)
Comment thread
dnaeon marked this conversation as resolved.
attrs := []any{
"interaction_id", i.ID,
"already_replayed", i.replayed,
"eligible", eligible,
"matched", matched,
"recorded_summary", summarizeCassetteRequest(i.Request),
}
if !matched && eligible {
attrs = append(attrs, "dump", dumpCassetteRequest(i.Request))
}
c.debug("match attempt", attrs...)
if matched {
i.replayed = true
c.debug("match found",
"interaction_id", i.ID,
"request", summarizeCassetteRequest(i.Request),
)

return i, nil
}
}
c.debug("no match",
"interactions_total", len(c.Interactions),
"already_replayed_count", replayed,
)

return nil, ErrInteractionNotFound
}

Expand All @@ -490,16 +654,22 @@ func (c *Cassette) Save() error {

// SaveWithFS writes the cassette data on abstract filesystem for future re-use
func (c *Cassette) SaveWithFS(fs FS) error {
c.Lock()
defer c.Unlock()
c.mu.Lock()
defer c.mu.Unlock()
c.debug("saving cassette",
"path", c.File,
Comment thread
dnaeon marked this conversation as resolved.
Outdated
"interaction_count", len(c.Interactions),
)

// Filter out interactions which should be discarded. While discarding
// interactions we should also fix the interaction IDs, so that we don't
// introduce gaps in the final results.
nextId := 0
interactions := make([]*Interaction, 0)
for _, i := range c.Interactions {
if !i.DiscardOnSave {
if i.DiscardOnSave {
c.debug("discarding interaction", "interaction_id", i.ID)
} else {
i.ID = nextId
interactions = append(interactions, i)
nextId += 1
Expand All @@ -510,10 +680,22 @@ func (c *Cassette) SaveWithFS(fs FS) error {
// Marshal to YAML and save interactions
data, err := c.MarshalFunc(c)
if err != nil {
c.debug("cassette marshal failed", "error", err)
Comment thread
dnaeon marked this conversation as resolved.
Outdated
return err
}

// Honor the YAML structure specification
// http://www.yaml.org/spec/1.2/spec.html#id2760395
return fs.WriteFile(c.File, append([]byte("---\n"), data...))
payload := append([]byte("---\n"), data...)
if err := fs.WriteFile(c.File, payload); err != nil {
c.debug("cassette write failed", "path", c.File, "error", err)
return err
}
c.debug("cassette saved",
"path", c.File,
Comment thread
dnaeon marked this conversation as resolved.
Outdated
"bytes_written", len(payload),
"interactions_saved", len(c.Interactions),
)

return nil
}
Loading
Loading