Skip to content

Commit a9eded2

Browse files
Stephen Belangerclaude
andcommitted
refactor(e2e): move cassette filters per-scenario, harden seinfeld defaults
- Replace global cassette-filters.mjs registry with per-scenario cassette-filter.mjs files; cassette-preload.mjs now dynamically imports them from the scenario dir - Default redact to 'paranoid' in seinfeld recorder (was opt-in) - Gate provider key placeholder injection on replay mode only (not record/passthrough) - Delete obsolete cassette-filters.mjs and record-cassettes.mjs helper scripts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 486eef1 commit a9eded2

11 files changed

Lines changed: 144 additions & 128 deletions

File tree

dev-packages/seinfeld/README.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,16 @@ Generic VCR/cassette library for Node.js, built on [MSW](https://mswjs.io). Reco
55
## Features
66

77
- **Normalizers** (always-on, lossy) transform requests before matching. They strip volatile fields like `Authorization` headers, dynamic IDs (`experimental_generateMessageId`), or query nonces so two structurally-identical requests still match across runs. Their output is internal — never serialized.
8-
- **Redactors** (opt-in) transform what gets persisted to disk. They mask credentials before the cassette hits version control. Disabled by default; cassettes contain the real on-the-wire bytes unless you opt in.
8+
- **Redactors** transform what gets persisted to disk. They mask credentials before the cassette hits version control. The `'paranoid'` preset is applied by default; pass `redact: []` to disable.
99

1010
## Security note
1111

12-
> **Cassettes contain real request and response bytes by default, including `Authorization` headers.** This is the safer default for fidelity (downstream consumers see real responses) but it means you must either (a) enable redaction, (b) write a custom `RedactionConfig`, or (c) add cassette files to `.gitignore` if they may contain credentials.
13-
14-
Three body-redaction gaps are worth knowing:
12+
Three body-redaction gaps are worth knowing even with the default `'paranoid'` preset:
1513

1614
1. **Non-canonical content-type** — some servers return JSON with `Content-Type: text/plain`. `redactBodyFields` covers this because seinfeld attempts to parse `text` bodies as JSON before masking.
1715
2. **SSE event data** — streaming endpoints (OpenAI, Anthropic) emit JSON in `data:` lines. `redactBodyFields` applies to parseable `data:` lines; `redactBodyText` handles non-JSON SSE content.
1816
3. **Plain-text credentials** — form-encoded bodies, XML, or log-like text are opaque to field-path rules. Use `redactBodyText` with a regex.
1917

20-
For cassettes committed to version control, use the `'paranoid'` preset, which covers all three paths:
21-
22-
```ts
23-
createCassette({ name: "demo", redact: "paranoid" });
24-
```
25-
2618
`'paranoid'` redacts credential headers, common credential field names at any JSON depth (`apiKey`, `token`, `secret`, `password`, `authorization`), and Bearer / `sk-` style tokens in text bodies.
2719

2820
To detect misconfigurations at record time, add `strict: true`:

dev-packages/seinfeld/src/recorder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export interface CassetteOptions {
348348
store?: CassetteStore;
349349
/** Filter spec (matching-only normalization). */
350350
filters?: FilterSpec;
351-
/** Redaction spec (applied to persisted bytes). Defaults to none. */
351+
/** Redaction spec (applied to persisted bytes). Defaults to `'paranoid'`. Pass `[]` to disable. */
352352
redact?: RedactionSpec;
353353
/** Hosts to intercept. Other hosts pass through. Defaults to all hosts. */
354354
hosts?: Array<string | RegExp>;
@@ -403,7 +403,7 @@ export function createCassette(options: CassetteOptions): Cassette {
403403
options.store ?? createJsonFileStore({ rootDir: DEFAULT_CASSETTE_DIR }),
404404
matcher: options.matcher ?? createDefaultMatcher(),
405405
filters: options.filters,
406-
redact: options.redact,
406+
redact: options.redact ?? "paranoid",
407407
hosts: options.hosts,
408408
passthroughHosts: options.passthroughHosts,
409409
threshold: resolveThreshold(options.externalBlobThreshold),

e2e/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ unset ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY COHERE_API_KEY GROQ_API_KE
178178
pnpm --filter=@braintrust/js-e2e-tests run test:e2e:hermetic
179179
```
180180

181-
If a scenario records but later replay fails because of volatile fields in the request body (e.g. AI-SDK's generated message ids), add or update the filter for that scenario in `e2e/helpers/cassette-filters.mjs`, then re-record.
181+
If a scenario records but later replay fails because of volatile fields in the request body (e.g. AI-SDK's generated message ids), add or update `<scenario-dir>/cassette-filter.mjs` for that scenario, then re-record.
182182

183183
### In-scope scenarios
184184

e2e/helpers/cassette-filters.mjs

Lines changed: 0 additions & 94 deletions
This file was deleted.

e2e/helpers/cassette-preload.mjs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,22 @@
88
* BRAINTRUST_E2E_CASSETTE_MODE — replay | record | passthrough
99
* BRAINTRUST_E2E_CASSETTE_VARIANT — variant key (cassette name, no extension)
1010
* BRAINTRUST_E2E_MOCK_HOST — host:port of the Braintrust mock server (always passthrough)
11-
* BRAINTRUST_E2E_CASSETTE_NORMALIZER — name of the request-body filter to use
1211
*
1312
* The preload exits silently if the cassette path env var is not set, so
1413
* it's safe to install for scenarios that haven't migrated yet (the
1514
* harness only sets the env vars for opted-in scenarios).
15+
*
16+
* Per-scenario request-body filters live in `<scenario-dir>/cassette-filter.mjs`
17+
* (optional). The file should export a named `filter` conforming to the
18+
* seinfeld `FilterSpec` type. If absent, the seinfeld `"default"` preset is used.
1619
*/
20+
import * as path from "node:path";
1721
import { createCassette, createJsonFileStore } from "@braintrust/seinfeld";
18-
import { CASSETTE_FILTERS } from "./cassette-filters.mjs";
1922

2023
const CASSETTE_DIR = process.env.BRAINTRUST_E2E_CASSETTE_PATH;
2124
const MODE_RAW = process.env.BRAINTRUST_E2E_CASSETTE_MODE ?? "replay";
2225
const VARIANT_KEY = process.env.BRAINTRUST_E2E_CASSETTE_VARIANT ?? "default";
2326
const MOCK_HOST = process.env.BRAINTRUST_E2E_MOCK_HOST;
24-
const NORMALIZER_NAME = process.env.BRAINTRUST_E2E_CASSETTE_NORMALIZER;
2527

2628
if (CASSETTE_DIR) {
2729
await bootCassettePreload(CASSETTE_DIR);
@@ -32,16 +34,14 @@ if (CASSETTE_DIR) {
3234
*/
3335
async function bootCassettePreload(cassetteDir) {
3436
const mode = resolveMode(MODE_RAW);
35-
const filters =
36-
CASSETTE_FILTERS[NORMALIZER_NAME ?? ""] ?? CASSETTE_FILTERS["default"];
37+
const filters = await loadScenarioFilter(cassetteDir);
3738
const passthroughHosts = MOCK_HOST ? [MOCK_HOST] : [];
3839

3940
const cassette = createCassette({
4041
name: VARIANT_KEY,
4142
mode,
4243
store: createJsonFileStore({ rootDir: cassetteDir }),
4344
filters,
44-
redact: "paranoid",
4545
passthroughHosts,
4646
onMiss: (req) => {
4747
process.stderr.write(`[cassette] MISS: ${req.method} ${req.url}\n`);
@@ -62,6 +62,28 @@ async function bootCassettePreload(cassetteDir) {
6262
});
6363
}
6464

65+
/**
66+
* Try to load a per-scenario cassette filter from `<scenario-dir>/cassette-filter.mjs`.
67+
* Falls back to the seinfeld `"default"` preset if the file is absent.
68+
*
69+
* @param {string} cassetteDir Absolute path to the __cassettes__ directory.
70+
* @returns {Promise<import("@braintrust/seinfeld").FilterSpec>}
71+
*/
72+
async function loadScenarioFilter(cassetteDir) {
73+
// cassetteDir is <scenario>/__cassettes__ — parent is the scenario root.
74+
const scenarioDir = path.resolve(cassetteDir, "..");
75+
const filterPath = path.join(scenarioDir, "cassette-filter.mjs");
76+
try {
77+
const mod = await import(filterPath);
78+
if (mod.filter !== undefined) {
79+
return mod.filter;
80+
}
81+
} catch {
82+
// File absent or not a valid module — fall through to default.
83+
}
84+
return "default";
85+
}
86+
6587
/**
6688
* @param {string} raw
6789
* @returns {import('@braintrust/seinfeld').CassetteMode}

e2e/helpers/scenario-harness.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,6 @@ export interface ScenarioCassetteConfig {
4949
* Defaults to `runContext.variantKey ?? "default"`.
5050
*/
5151
variantKey?: string;
52-
/**
53-
* Name of the request-body normalizer registered in
54-
* `e2e/helpers/cassette/normalizers/index.mjs`. Falls back to a
55-
* scenario-name based lookup if omitted.
56-
*/
57-
normalizerName?: string;
5852
}
5953

6054
export interface ScenarioRunContext {
@@ -271,20 +265,15 @@ interface CassetteWiring {
271265
cassetteDir: string;
272266
variantKey: string;
273267
mockHost: string;
274-
normalizerName?: string;
275268
}
276269

277270
function getCassetteEnv(wiring: CassetteWiring): Record<string, string> {
278-
const env: Record<string, string> = {
271+
return {
279272
BRAINTRUST_E2E_CASSETTE_PATH: wiring.cassetteDir,
280273
BRAINTRUST_E2E_CASSETTE_MODE: process.env[CASSETTE_MODE_ENV] ?? "replay",
281274
BRAINTRUST_E2E_CASSETTE_VARIANT: wiring.variantKey,
282275
BRAINTRUST_E2E_MOCK_HOST: wiring.mockHost,
283276
};
284-
if (wiring.normalizerName) {
285-
env.BRAINTRUST_E2E_CASSETTE_NORMALIZER = wiring.normalizerName;
286-
}
287-
return env;
288277
}
289278

290279
/**
@@ -679,15 +668,18 @@ export async function withScenarioHarness(
679668
return {};
680669
}
681670

682-
const normalizerName = config.normalizerName ?? scenarioName;
671+
const isReplayMode = !isRecordingMode && cassetteModeRaw !== "passthrough";
683672

684673
return {
685-
...getProviderKeyPlaceholders(),
674+
// Only inject placeholder keys in replay mode. In record mode the
675+
// subprocess needs the real provider keys to make live API calls;
676+
// injecting a fake key causes a confusing "invalid key" error instead
677+
// of the clear "missing key" error the SDK would otherwise produce.
678+
...(isReplayMode ? getProviderKeyPlaceholders() : {}),
686679
...getCassetteEnv({
687680
cassetteDir: path.dirname(cassettePath),
688681
variantKey,
689682
mockHost: urlToHostHeader(server.url),
690-
normalizerName,
691683
}),
692684
};
693685
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// @ts-check
2+
/** @type {import("@braintrust/seinfeld").FilterSpec} */
3+
export const filter = [
4+
"default",
5+
{
6+
ignoreBodyFields: [
7+
// Ignore all body fields — deterministic call order makes callIndex
8+
// the sole discriminator, which is stable across SDK releases.
9+
"**",
10+
// AI SDK volatile fields (change per-run)
11+
"experimental_generateMessageId",
12+
"messageId",
13+
"messages.*.id",
14+
"messages.*.experimental_messageId",
15+
"input.*.id",
16+
"input.*.experimental_messageId",
17+
// OpenAI Responses API fields added as defaults in newer client versions.
18+
// These don't affect request semantics but change between SDK releases.
19+
"store",
20+
"background",
21+
"truncation",
22+
"instructions",
23+
"moderation",
24+
"reasoning",
25+
"reasoning.effort",
26+
"reasoning.summary",
27+
"safety_identifier",
28+
"service_tier",
29+
"text",
30+
"text.format",
31+
"text.format.type",
32+
"text.verbosity",
33+
"metadata",
34+
"top_logprobs",
35+
"top_p",
36+
"presence_penalty",
37+
"frequency_penalty",
38+
"parallel_tool_calls",
39+
"max_tool_calls",
40+
"prompt_cache_key",
41+
"prompt_cache_retention",
42+
"previous_response_id",
43+
"user",
44+
"include",
45+
],
46+
},
47+
];
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// @ts-check
2+
// Same AI SDK volatile fields as ai-sdk-instrumentation.
3+
export { filter } from "../ai-sdk-instrumentation/cassette-filter.mjs";
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// @ts-check
2+
/** @type {import("@braintrust/seinfeld").FilterSpec} */
3+
export const filter = [
4+
"default",
5+
{
6+
/**
7+
* Mistral's client generates a unique `name` field per session
8+
* (e.g. "braintrust-e2e-<uuid>"). Normalize it so cassette matching
9+
* isn't broken by the per-run suffix.
10+
*
11+
* @param {import("@braintrust/seinfeld").RecordedRequest} req
12+
* @returns {import("@braintrust/seinfeld").RecordedRequest}
13+
*/
14+
normalizeRequest(req) {
15+
if (
16+
req.body.kind !== "json" ||
17+
req.body.value === null ||
18+
typeof req.body.value !== "object" ||
19+
Array.isArray(req.body.value)
20+
) {
21+
return req;
22+
}
23+
const value = /** @type {Record<string, unknown>} */ (req.body.value);
24+
if (
25+
typeof value["name"] === "string" &&
26+
/** @type {string} */ (value["name"]).startsWith("braintrust-e2e-")
27+
) {
28+
return {
29+
...req,
30+
body: {
31+
kind: "json",
32+
value: { ...value, name: "braintrust-e2e-<placeholder>" },
33+
},
34+
};
35+
}
36+
return req;
37+
},
38+
},
39+
];
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// @ts-check
2+
// Same body-agnostic matching as openrouter-instrumentation.
3+
export { filter } from "../openrouter-instrumentation/cassette-filter.mjs";

0 commit comments

Comments
 (0)