@sentry/roach 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +644 -130
- package/package.json +4 -35
- package/src/action.ts +5 -3
- package/src/client.ts +33 -10
- package/src/config.ts +52 -5
- package/src/report.ts +38 -7
- package/src/types.ts +53 -2
- package/src/values.ts +54 -16
- package/src/vitest-setup.ts +6 -10
- package/src/vitest.ts +9 -12
- package/src/addresses.ts +0 -103
- package/src/certificates.ts +0 -169
- package/src/cli.ts +0 -35
- package/src/gcs.ts +0 -145
- package/src/recorder.ts +0 -464
- package/src/recordings.ts +0 -42
- package/src/request-key.ts +0 -165
- package/src/secrets.ts +0 -66
- package/src/server.ts +0 -544
- package/src/service.ts +0 -572
- package/src/store.ts +0 -47
- package/src/streams.ts +0 -115
- package/src/usage.ts +0 -120
package/src/recorder.ts
DELETED
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The recording logic of the proxy: modes, sessions, replay, and misses.
|
|
3
|
-
*
|
|
4
|
-
* `server.ts` owns the sockets. It gives the recorder each request that a
|
|
5
|
-
* rule matches, with a function that sends the request live. The recorder
|
|
6
|
-
* decides whether to replay, send live, or fail the request, and it owns
|
|
7
|
-
* every read and write of the recordings. A store keeps them: the file
|
|
8
|
-
* store (`recordings.ts`) or a GCS bucket (`gcs.ts`).
|
|
9
|
-
*
|
|
10
|
-
* A session groups the requests of one test. Many sessions can be open at
|
|
11
|
-
* once, such as one for each test worker. A request names its session in
|
|
12
|
-
* its proxy URL (`service.ts`). A request that names no session belongs to
|
|
13
|
-
* the open session when only one is open. The recorder keeps the new
|
|
14
|
-
* recordings of a session in memory until the session ends. A passed
|
|
15
|
-
* session writes them, and a failed session drops them, so a bad sample is
|
|
16
|
-
* never replayed. A request that ends after its session ended follows the
|
|
17
|
-
* result of that session. A request outside a session is written at once.
|
|
18
|
-
*
|
|
19
|
-
* The recorder never writes a known credential (`secrets.ts`). It reports
|
|
20
|
-
* spans and metrics through the `Telemetry` hooks of `service.ts`.
|
|
21
|
-
*/
|
|
22
|
-
import { randomBytes } from "node:crypto";
|
|
23
|
-
import type { IncomingHttpHeaders } from "node:http";
|
|
24
|
-
import { keyRequest, type KeyedRequest } from "./request-key.ts";
|
|
25
|
-
import { createSecrets } from "./secrets.ts";
|
|
26
|
-
import {
|
|
27
|
-
recordingKey,
|
|
28
|
-
RULE_NAME,
|
|
29
|
-
type Recording,
|
|
30
|
-
type RecordingStore,
|
|
31
|
-
} from "./store.ts";
|
|
32
|
-
import {
|
|
33
|
-
isEventStream,
|
|
34
|
-
mapStreamEvents,
|
|
35
|
-
mergeStreamDeltas,
|
|
36
|
-
} from "./streams.ts";
|
|
37
|
-
import { usageAttributes } from "./usage.ts";
|
|
38
|
-
import type {
|
|
39
|
-
RecordingMiss,
|
|
40
|
-
RecordingMode,
|
|
41
|
-
RecordingRule,
|
|
42
|
-
RecordingStats,
|
|
43
|
-
} from "./types.ts";
|
|
44
|
-
import { fillValues, templateValues, type RequestValues } from "./values.ts";
|
|
45
|
-
|
|
46
|
-
/** A request with its whole body. */
|
|
47
|
-
export interface ProxyRequest {
|
|
48
|
-
method: string;
|
|
49
|
-
url: URL;
|
|
50
|
-
headers: IncomingHttpHeaders;
|
|
51
|
-
body: Buffer;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** A response with its whole body. */
|
|
55
|
-
export interface ProxyResponse {
|
|
56
|
-
status: number;
|
|
57
|
-
headers: IncomingHttpHeaders;
|
|
58
|
-
body: Buffer;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Where the response to a recorded request came from. */
|
|
62
|
-
export type ResponseSource = "live" | "missed" | "replayed";
|
|
63
|
-
|
|
64
|
-
/** The attributes of a span or a metric. */
|
|
65
|
-
type Attributes = Record<string, string | number | boolean>;
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Hooks that report spans and metrics. `service.ts` gives hooks that send
|
|
69
|
-
* them to Sentry, so this file and `server.ts` do not load the SDK. An
|
|
70
|
-
* attribute must never hold a credential, a body, or the path or query of
|
|
71
|
-
* a URL.
|
|
72
|
-
*/
|
|
73
|
-
export interface Telemetry {
|
|
74
|
-
/** Run `fn` in a span. The span ends when `fn` returns or settles. */
|
|
75
|
-
span<T>(
|
|
76
|
-
options: { op: string; name?: string; attributes?: Attributes },
|
|
77
|
-
fn: () => T,
|
|
78
|
-
): T;
|
|
79
|
-
/** Add attributes to the active span. */
|
|
80
|
-
annotate(attributes: Attributes): void;
|
|
81
|
-
/** Add `value` to the counter `name`. */
|
|
82
|
-
count(name: string, value: number, attributes: Attributes): void;
|
|
83
|
-
/** Add a time in milliseconds to the distribution `name`. */
|
|
84
|
-
time(name: string, ms: number, attributes: Attributes): void;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
interface Session {
|
|
88
|
-
/** The id in the proxy URL of the session. */
|
|
89
|
-
id: string;
|
|
90
|
-
name: string;
|
|
91
|
-
/** New recordings, by key. */
|
|
92
|
-
recorded: Map<string, Recording>;
|
|
93
|
-
/** Requests that `replay` mode failed. */
|
|
94
|
-
missed: number;
|
|
95
|
-
/**
|
|
96
|
-
* Changing values of the requests so far, with their names. A later
|
|
97
|
-
* request can repeat one where no pattern finds it (`values.ts`).
|
|
98
|
-
*/
|
|
99
|
-
known: Map<string, string>;
|
|
100
|
-
/** Set when the session ends. */
|
|
101
|
-
passed?: boolean;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const MAX_MISSES = 500;
|
|
105
|
-
/**
|
|
106
|
-
* Ended sessions that a run keeps, so that late requests follow their
|
|
107
|
-
* result. The oldest ones go first, so memory stays bounded.
|
|
108
|
-
*/
|
|
109
|
-
const MAX_ENDED_SESSIONS = 1000;
|
|
110
|
-
/** Open sessions of a run, such as one for each test worker. */
|
|
111
|
-
const MAX_OPEN_SESSIONS = 64;
|
|
112
|
-
/** Response headers that a recording keeps. Other headers change each run. */
|
|
113
|
-
const RECORDED_HEADERS = ["content-type", "location"];
|
|
114
|
-
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
|
115
|
-
|
|
116
|
-
/** Statuses that are never recorded, because they are temporary. */
|
|
117
|
-
const isTemporaryStatus = (status: number) => status === 429 || status >= 500;
|
|
118
|
-
|
|
119
|
-
function matches(
|
|
120
|
-
{ match }: RecordingRule,
|
|
121
|
-
{ method, url, headers }: ProxyRequest,
|
|
122
|
-
): boolean {
|
|
123
|
-
if (match.method && match.method !== method) return false;
|
|
124
|
-
if (match.url && !url.href.startsWith(match.url)) return false;
|
|
125
|
-
return Object.entries(match.headers ?? {}).every(
|
|
126
|
-
([name, value]) => headers[name] === value,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* The response as a recording keeps it. A stream gets one delta per content
|
|
132
|
-
* block, and each value of the request becomes a placeholder.
|
|
133
|
-
*/
|
|
134
|
-
function toRecording(
|
|
135
|
-
request: ProxyRequest,
|
|
136
|
-
keyed: KeyedRequest,
|
|
137
|
-
session: string | undefined,
|
|
138
|
-
response: ProxyResponse,
|
|
139
|
-
): Recording {
|
|
140
|
-
const contentType = response.headers["content-type"];
|
|
141
|
-
const headers: Record<string, string> = {};
|
|
142
|
-
for (const name of RECORDED_HEADERS) {
|
|
143
|
-
const value = response.headers[name];
|
|
144
|
-
if (typeof value === "string") headers[name] = value;
|
|
145
|
-
}
|
|
146
|
-
let body = response.body.toString("base64");
|
|
147
|
-
let bodyEncoding: Recording["response"]["bodyEncoding"] = "base64";
|
|
148
|
-
if (/^text\/|json|xml/i.test(contentType ?? "")) {
|
|
149
|
-
const text = response.body.toString("utf8");
|
|
150
|
-
const template = (part: string) => templateValues(part, keyed.values);
|
|
151
|
-
body = isEventStream(contentType)
|
|
152
|
-
? mapStreamEvents(mergeStreamDeltas(text), template)
|
|
153
|
-
: template(text);
|
|
154
|
-
bodyEncoding = "utf8";
|
|
155
|
-
}
|
|
156
|
-
return {
|
|
157
|
-
session,
|
|
158
|
-
request: { method: request.method, url: request.url.href },
|
|
159
|
-
response: { status: response.status, headers, body, bodyEncoding },
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** The response of a recording, with the values of the current request. */
|
|
164
|
-
function fromRecording(
|
|
165
|
-
{ response }: Recording,
|
|
166
|
-
values: RequestValues,
|
|
167
|
-
): ProxyResponse {
|
|
168
|
-
const body = NULL_BODY_STATUSES.has(response.status)
|
|
169
|
-
? Buffer.alloc(0)
|
|
170
|
-
: response.bodyEncoding === "utf8"
|
|
171
|
-
? Buffer.from(fillValues(response.body, values))
|
|
172
|
-
: Buffer.from(response.body, "base64");
|
|
173
|
-
return { status: response.status, headers: response.headers, body };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** The config of a recorder. */
|
|
177
|
-
export interface RecorderConfig {
|
|
178
|
-
mode: RecordingMode;
|
|
179
|
-
rules: RecordingRule[];
|
|
180
|
-
/** Credentials to redact, in addition to the ones that it learns. */
|
|
181
|
-
secrets?: string[];
|
|
182
|
-
telemetry: Telemetry;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Create the recorder of one run, with the store of its recordings. */
|
|
186
|
-
export function createRecorder(config: RecorderConfig, store: RecordingStore) {
|
|
187
|
-
for (const rule of config.rules) {
|
|
188
|
-
if (!RULE_NAME.test(rule.name)) {
|
|
189
|
-
throw new Error(`Roach rule name is not valid: ${rule.name}`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
/** Sessions by id. An ended session stays, so its late requests follow it. */
|
|
193
|
-
const sessions = new Map<string, Session>();
|
|
194
|
-
const open = () =>
|
|
195
|
-
[...sessions.values()].filter((s) => s.passed === undefined);
|
|
196
|
-
const { telemetry } = config;
|
|
197
|
-
const secrets = createSecrets(config.secrets);
|
|
198
|
-
const stats: RecordingStats = {
|
|
199
|
-
counts: Object.fromEntries(
|
|
200
|
-
config.rules.map((rule) => [
|
|
201
|
-
rule.name,
|
|
202
|
-
{ live: 0, missed: 0, replayed: 0 },
|
|
203
|
-
]),
|
|
204
|
-
),
|
|
205
|
-
misses: [],
|
|
206
|
-
written: 0,
|
|
207
|
-
discarded: 0,
|
|
208
|
-
passthrough: {},
|
|
209
|
-
};
|
|
210
|
-
/** Count recordings by rule in `roach.recording.write`. */
|
|
211
|
-
const countRecordings = (keys: string[], result: "dropped" | "written") => {
|
|
212
|
-
const byRule = new Map<string, number>();
|
|
213
|
-
for (const key of keys) {
|
|
214
|
-
const rule = key.slice(0, key.indexOf("/"));
|
|
215
|
-
byRule.set(rule, (byRule.get(rule) ?? 0) + 1);
|
|
216
|
-
}
|
|
217
|
-
for (const [rule, value] of byRule) {
|
|
218
|
-
telemetry.count("roach.recording.write", value, { rule, result });
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
const write = async (recordings: Array<[string, Recording]>) => {
|
|
222
|
-
stats.written += await store.write(recordings);
|
|
223
|
-
countRecordings(
|
|
224
|
-
recordings.map(([key]) => key),
|
|
225
|
-
"written",
|
|
226
|
-
);
|
|
227
|
-
};
|
|
228
|
-
const drop = (keys: string[]) => {
|
|
229
|
-
stats.discarded += keys.length;
|
|
230
|
-
countRecordings(keys, "dropped");
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const record = async (
|
|
234
|
-
owner: Session | undefined,
|
|
235
|
-
key: string,
|
|
236
|
-
recording: Recording,
|
|
237
|
-
) => {
|
|
238
|
-
if (owner && owner.passed === undefined) {
|
|
239
|
-
owner.recorded.set(key, recording);
|
|
240
|
-
} else if (owner?.passed === false) {
|
|
241
|
-
drop([key]);
|
|
242
|
-
} else {
|
|
243
|
-
await write([[key, recording]]);
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const reportMiss = (rule: RecordingRule, key: string, owner?: Session) => {
|
|
248
|
-
const miss: RecordingMiss = {
|
|
249
|
-
rule: rule.name,
|
|
250
|
-
session: owner?.name,
|
|
251
|
-
file: key,
|
|
252
|
-
};
|
|
253
|
-
if (stats.misses.length < MAX_MISSES) stats.misses.push(miss);
|
|
254
|
-
const where = owner ? ` in "${owner.name}"` : "";
|
|
255
|
-
process.stderr.write(`[roach] No ${rule.name} recording${where}: ${key}\n`);
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
/** End a session. Returns its requests that `replay` mode failed. */
|
|
259
|
-
const finish = async (ended: Session, passed: boolean): Promise<number> => {
|
|
260
|
-
ended.passed = passed;
|
|
261
|
-
const recorded = [...ended.recorded];
|
|
262
|
-
ended.recorded.clear();
|
|
263
|
-
ended.known.clear();
|
|
264
|
-
if (passed) await write(recorded);
|
|
265
|
-
else drop(recorded.map(([key]) => key));
|
|
266
|
-
return ended.missed;
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* The session of a request. `id` comes from its proxy URL. Without it,
|
|
271
|
-
* the request belongs to the open session when only one is open.
|
|
272
|
-
*/
|
|
273
|
-
const sessionOf = (id: string | undefined): Session | undefined => {
|
|
274
|
-
if (id !== undefined) return sessions.get(id);
|
|
275
|
-
const current = open();
|
|
276
|
-
return current.length === 1 ? current[0] : undefined;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
return {
|
|
280
|
-
/** The rule that records a request, or `undefined` when none does. */
|
|
281
|
-
ruleFor(request: ProxyRequest): RecordingRule | undefined {
|
|
282
|
-
if (config.mode === "off") return undefined;
|
|
283
|
-
return config.rules.find((rule) => matches(rule, request));
|
|
284
|
-
},
|
|
285
|
-
|
|
286
|
-
/** Count a request that no rule records, and learn its credentials. */
|
|
287
|
-
countPassthrough(origin: string, headers: IncomingHttpHeaders): void {
|
|
288
|
-
secrets.learn(headers);
|
|
289
|
-
stats.passthrough[origin] = (stats.passthrough[origin] ?? 0) + 1;
|
|
290
|
-
telemetry.annotate({ result: "passthrough" });
|
|
291
|
-
telemetry.count("roach.request", 1, { origin, result: "passthrough" });
|
|
292
|
-
},
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Answer a request that `rule` records. `sendLive` sends it upstream.
|
|
296
|
-
* A live response goes through the same template as a recording, so
|
|
297
|
-
* the client gets the same body from a live run and from a replay.
|
|
298
|
-
*/
|
|
299
|
-
async respond(
|
|
300
|
-
rule: RecordingRule,
|
|
301
|
-
request: ProxyRequest,
|
|
302
|
-
sendLive: () => Promise<ProxyResponse>,
|
|
303
|
-
sessionId?: string,
|
|
304
|
-
): Promise<ProxyResponse & { source: ResponseSource }> {
|
|
305
|
-
const started = performance.now();
|
|
306
|
-
const origin = request.url.origin;
|
|
307
|
-
// A request belongs to the session that was open when it started.
|
|
308
|
-
const owner = sessionOf(sessionId);
|
|
309
|
-
const counts = stats.counts[rule.name]!;
|
|
310
|
-
secrets.learn(request.headers);
|
|
311
|
-
telemetry.annotate({ rule: rule.name });
|
|
312
|
-
const { keyed, key } = telemetry.span(
|
|
313
|
-
{ op: "roach.key", attributes: { rule: rule.name } },
|
|
314
|
-
() => {
|
|
315
|
-
const result = keyRequest(
|
|
316
|
-
rule,
|
|
317
|
-
{
|
|
318
|
-
method: request.method,
|
|
319
|
-
url: request.url.href,
|
|
320
|
-
headers: request.headers,
|
|
321
|
-
body: request.body.toString("utf8"),
|
|
322
|
-
},
|
|
323
|
-
owner?.known,
|
|
324
|
-
);
|
|
325
|
-
const name = recordingKey(rule.name, result.key);
|
|
326
|
-
telemetry.annotate({ key: name });
|
|
327
|
-
return { keyed: result, key: name };
|
|
328
|
-
},
|
|
329
|
-
);
|
|
330
|
-
if (owner) {
|
|
331
|
-
for (const [name, list] of keyed.values) {
|
|
332
|
-
for (const value of list) {
|
|
333
|
-
if (!owner.known.has(value)) owner.known.set(value, name);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
const report = (result: ResponseSource, response?: ProxyResponse) => {
|
|
338
|
-
const attributes = { rule: rule.name, origin, result };
|
|
339
|
-
telemetry.annotate({ result });
|
|
340
|
-
const usage =
|
|
341
|
-
response &&
|
|
342
|
-
usageAttributes(
|
|
343
|
-
request.body.toString("utf8"),
|
|
344
|
-
response.headers["content-type"],
|
|
345
|
-
response.body.toString("utf8"),
|
|
346
|
-
);
|
|
347
|
-
if (usage) telemetry.annotate(usage);
|
|
348
|
-
telemetry.count("roach.request", 1, attributes);
|
|
349
|
-
telemetry.time(
|
|
350
|
-
"roach.request.duration",
|
|
351
|
-
performance.now() - started,
|
|
352
|
-
attributes,
|
|
353
|
-
);
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
if (config.mode !== "record") {
|
|
357
|
-
const recording = await store.read(key);
|
|
358
|
-
if (recording) {
|
|
359
|
-
counts.replayed += 1;
|
|
360
|
-
const response = fromRecording(recording, keyed.values);
|
|
361
|
-
report("replayed", response);
|
|
362
|
-
return { ...response, source: "replayed" };
|
|
363
|
-
}
|
|
364
|
-
reportMiss(rule, key, owner);
|
|
365
|
-
if (config.mode === "replay") {
|
|
366
|
-
counts.missed += 1;
|
|
367
|
-
if (owner) owner.missed += 1;
|
|
368
|
-
report("missed");
|
|
369
|
-
return {
|
|
370
|
-
status: 412,
|
|
371
|
-
headers: { "content-type": "text/plain" },
|
|
372
|
-
body: Buffer.from(
|
|
373
|
-
`Roach: no ${rule.name} recording for this request in replay mode. Run in auto mode to record it.\n`,
|
|
374
|
-
),
|
|
375
|
-
source: "missed",
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
counts.live += 1;
|
|
381
|
-
const live = await telemetry.span(
|
|
382
|
-
{
|
|
383
|
-
op: "roach.upstream",
|
|
384
|
-
name: `${request.method} ${origin}`,
|
|
385
|
-
attributes: { origin, "http.request.method": request.method },
|
|
386
|
-
},
|
|
387
|
-
async () => {
|
|
388
|
-
const response = await sendLive();
|
|
389
|
-
telemetry.annotate({ "http.response.status_code": response.status });
|
|
390
|
-
return response;
|
|
391
|
-
},
|
|
392
|
-
);
|
|
393
|
-
// The client gets the redacted response too, so a live run and a
|
|
394
|
-
// replay give the same body.
|
|
395
|
-
const recording = JSON.parse(
|
|
396
|
-
secrets.redact(
|
|
397
|
-
JSON.stringify(toRecording(request, keyed, owner?.name, live)),
|
|
398
|
-
),
|
|
399
|
-
) as Recording;
|
|
400
|
-
if (!isTemporaryStatus(recording.response.status)) {
|
|
401
|
-
await record(owner, key, recording);
|
|
402
|
-
}
|
|
403
|
-
const response = fromRecording(recording, keyed.values);
|
|
404
|
-
report("live", response);
|
|
405
|
-
return { ...response, source: "live" };
|
|
406
|
-
},
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Open the session of one test, and return its id. An open session with
|
|
410
|
-
* the same name fails first. Throws when the run has too many open
|
|
411
|
-
* sessions.
|
|
412
|
-
*/
|
|
413
|
-
async startSession(name: string): Promise<string> {
|
|
414
|
-
const same = open().find((other) => other.name === name);
|
|
415
|
-
if (same) {
|
|
416
|
-
process.stderr.write(
|
|
417
|
-
`[roach] Session "${name}" did not end, so its new recordings were dropped\n`,
|
|
418
|
-
);
|
|
419
|
-
await finish(same, false);
|
|
420
|
-
}
|
|
421
|
-
if (open().length >= MAX_OPEN_SESSIONS) {
|
|
422
|
-
throw new Error(`A run can have ${MAX_OPEN_SESSIONS} open sessions`);
|
|
423
|
-
}
|
|
424
|
-
const ended = [...sessions.values()].filter(
|
|
425
|
-
(other) => other.passed !== undefined,
|
|
426
|
-
);
|
|
427
|
-
const extra = ended.length - MAX_ENDED_SESSIONS + 1;
|
|
428
|
-
for (const old of ended.slice(0, Math.max(0, extra))) {
|
|
429
|
-
sessions.delete(old.id);
|
|
430
|
-
}
|
|
431
|
-
const id = randomBytes(8).toString("hex");
|
|
432
|
-
sessions.set(id, {
|
|
433
|
-
id,
|
|
434
|
-
name,
|
|
435
|
-
recorded: new Map(),
|
|
436
|
-
missed: 0,
|
|
437
|
-
known: new Map(),
|
|
438
|
-
});
|
|
439
|
-
return id;
|
|
440
|
-
},
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* End the open session `name`. Returns its requests that `replay` mode
|
|
444
|
-
* failed. Throws when no open session has that name.
|
|
445
|
-
*/
|
|
446
|
-
async endSession(name: string, passed: boolean): Promise<number> {
|
|
447
|
-
const ended = open().find((other) => other.name === name);
|
|
448
|
-
if (!ended) throw new Error(`Session "${name}" is not open`);
|
|
449
|
-
return finish(ended, passed);
|
|
450
|
-
},
|
|
451
|
-
|
|
452
|
-
stats(): RecordingStats {
|
|
453
|
-
return stats;
|
|
454
|
-
},
|
|
455
|
-
|
|
456
|
-
/** Fail the open sessions. */
|
|
457
|
-
async close(): Promise<void> {
|
|
458
|
-
for (const session of open()) await finish(session, false);
|
|
459
|
-
},
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/** The recorder of one run. */
|
|
464
|
-
export type Recorder = ReturnType<typeof createRecorder>;
|
package/src/recordings.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The file store. It keeps each recording (`store.ts`) in
|
|
3
|
-
* `<directory>/<key>`. The service uses it when its config has
|
|
4
|
-
* `directory`, such as in tests.
|
|
5
|
-
*/
|
|
6
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
formatRecording,
|
|
10
|
-
type Recording,
|
|
11
|
-
type RecordingStore,
|
|
12
|
-
} from "./store.ts";
|
|
13
|
-
|
|
14
|
-
const readText = (file: string) =>
|
|
15
|
-
readFile(file, "utf8").catch((error: unknown) => {
|
|
16
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
17
|
-
throw error;
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
/** The store of the recordings in `directory`. */
|
|
21
|
-
export function createFileStore(directory: string): RecordingStore {
|
|
22
|
-
return {
|
|
23
|
-
async read(key) {
|
|
24
|
-
const text = await readText(path.join(directory, key));
|
|
25
|
-
return text === undefined ? undefined : (JSON.parse(text) as Recording);
|
|
26
|
-
},
|
|
27
|
-
|
|
28
|
-
async write(recordings) {
|
|
29
|
-
const changed = await Promise.all(
|
|
30
|
-
recordings.map(async ([key, recording]) => {
|
|
31
|
-
const file = path.join(directory, key);
|
|
32
|
-
const content = formatRecording(recording);
|
|
33
|
-
if ((await readText(file)) === content) return false;
|
|
34
|
-
await mkdir(path.dirname(file), { recursive: true });
|
|
35
|
-
await writeFile(file, content);
|
|
36
|
-
return true;
|
|
37
|
-
}),
|
|
38
|
-
);
|
|
39
|
-
return changed.filter(Boolean).length;
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
}
|
package/src/request-key.ts
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The key of a recorded request.
|
|
3
|
-
*
|
|
4
|
-
* The key is the hash of the rule, the method, the URL, the key headers,
|
|
5
|
-
* and the body. A JSON body has sorted object keys, so key order does not
|
|
6
|
-
* matter. Each changing value of the rule is `<<name>>` in the key
|
|
7
|
-
* (`values.ts`).
|
|
8
|
-
*
|
|
9
|
-
* The patterns of `values` come from the client, and all runs share one
|
|
10
|
-
* process. A slow pattern would block every run, so a key has a time limit
|
|
11
|
-
* (`KEY_TIME_LIMIT`).
|
|
12
|
-
*/
|
|
13
|
-
import { createHash } from "node:crypto";
|
|
14
|
-
import type { IncomingHttpHeaders } from "node:http";
|
|
15
|
-
import { THINKING_BLOCK_TYPES } from "./streams.ts";
|
|
16
|
-
import type { RecordingRule } from "./types.ts";
|
|
17
|
-
import {
|
|
18
|
-
extractValues,
|
|
19
|
-
type KnownValues,
|
|
20
|
-
type RequestValues,
|
|
21
|
-
type ValuePatterns,
|
|
22
|
-
withTimeLimit,
|
|
23
|
-
} from "./values.ts";
|
|
24
|
-
|
|
25
|
-
/** Change this to make every recording a miss. */
|
|
26
|
-
const KEY_VERSION = "http-v1";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The time that the key of one request can take: 1 s, and 0.5 s for each
|
|
30
|
-
* MiB of the body. The built-in patterns take less than 0.1 s for each
|
|
31
|
-
* MiB. A key that takes longer fails its request, and other runs go on.
|
|
32
|
-
*/
|
|
33
|
-
const KEY_TIME_LIMIT = { baseMs: 1000, msPerMiB: 500 };
|
|
34
|
-
|
|
35
|
-
/** The parts of a request that make its key. */
|
|
36
|
-
interface RequestToKey {
|
|
37
|
-
method: string;
|
|
38
|
-
url: string;
|
|
39
|
-
headers: IncomingHttpHeaders;
|
|
40
|
-
body: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** A request as the key sees it. */
|
|
44
|
-
export interface KeyedRequest {
|
|
45
|
-
/** The name of the recording file, without `.json`. */
|
|
46
|
-
key: string;
|
|
47
|
-
/**
|
|
48
|
-
* The changing values of the request outside thinking blocks, in their
|
|
49
|
-
* order. They fill the placeholders of a replayed response.
|
|
50
|
-
*/
|
|
51
|
-
values: RequestValues;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** JSON with sorted object keys, so equal bodies give equal keys. */
|
|
55
|
-
function stableStringify(value: unknown): string {
|
|
56
|
-
if (value === null || typeof value !== "object") {
|
|
57
|
-
return JSON.stringify(value);
|
|
58
|
-
}
|
|
59
|
-
if (Array.isArray(value)) {
|
|
60
|
-
return `[${value.map(stableStringify).join(",")}]`;
|
|
61
|
-
}
|
|
62
|
-
const entries = Object.entries(value as Record<string, unknown>)
|
|
63
|
-
.filter(([, item]) => item !== undefined)
|
|
64
|
-
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
65
|
-
return `{${entries
|
|
66
|
-
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
67
|
-
.join(",")}}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function parseJson(body: string): unknown {
|
|
71
|
-
try {
|
|
72
|
-
return body ? JSON.parse(body) : undefined;
|
|
73
|
-
} catch {
|
|
74
|
-
return undefined;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Marks the place of a thinking block in the text of a body. */
|
|
79
|
-
const THINKING_MARK = /"\uE000(\d+)"/g;
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The text of a JSON value with `<<name>>` for each changing value, and the
|
|
83
|
-
* values outside thinking blocks.
|
|
84
|
-
*
|
|
85
|
-
* A replay keeps the recorded text of a thinking block (`streams.ts`), and
|
|
86
|
-
* a later request sends it back. So its values come from the recording
|
|
87
|
-
* run. They are in the key, but they do not fill placeholders, and known
|
|
88
|
-
* values of this run are not looked for in them.
|
|
89
|
-
*/
|
|
90
|
-
function normalizeJson(
|
|
91
|
-
json: unknown,
|
|
92
|
-
patterns: ValuePatterns | undefined,
|
|
93
|
-
known: KnownValues | undefined,
|
|
94
|
-
): { text: string; values: RequestValues } {
|
|
95
|
-
const thinking: string[] = [];
|
|
96
|
-
const mark = (value: unknown): unknown => {
|
|
97
|
-
if (Array.isArray(value)) return value.map(mark);
|
|
98
|
-
if (value === null || typeof value !== "object") return value;
|
|
99
|
-
const record = value as Record<string, unknown>;
|
|
100
|
-
if (THINKING_BLOCK_TYPES.has(String(record.type))) {
|
|
101
|
-
thinking.push(extractValues(stableStringify(record), patterns).text);
|
|
102
|
-
return `\uE000${thinking.length - 1}`;
|
|
103
|
-
}
|
|
104
|
-
return Object.fromEntries(
|
|
105
|
-
Object.entries(record).map(([name, item]) => [name, mark(item)]),
|
|
106
|
-
);
|
|
107
|
-
};
|
|
108
|
-
const { text, values } = extractValues(
|
|
109
|
-
stableStringify(mark(json)),
|
|
110
|
-
patterns,
|
|
111
|
-
known,
|
|
112
|
-
);
|
|
113
|
-
return {
|
|
114
|
-
text: text.replace(THINKING_MARK, (_, index) => thinking[Number(index)]!),
|
|
115
|
-
values,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const sha256 = (text: string) =>
|
|
120
|
-
createHash("sha256").update(text).digest("hex");
|
|
121
|
-
/**
|
|
122
|
-
* The key and the changing values of a request. `known` holds
|
|
123
|
-
* the values of earlier requests in the same session (`values.ts`). It
|
|
124
|
-
* throws an error with HTTP status 400 when it takes too long.
|
|
125
|
-
*/
|
|
126
|
-
export function keyRequest(
|
|
127
|
-
rule: RecordingRule,
|
|
128
|
-
request: RequestToKey,
|
|
129
|
-
known?: KnownValues,
|
|
130
|
-
): KeyedRequest {
|
|
131
|
-
const mib = Buffer.byteLength(request.body) / (1024 * 1024);
|
|
132
|
-
return withTimeLimit(
|
|
133
|
-
Math.ceil(KEY_TIME_LIMIT.baseMs + KEY_TIME_LIMIT.msPerMiB * mib),
|
|
134
|
-
() => keyRequestNow(rule, request, known),
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function keyRequestNow(
|
|
139
|
-
rule: RecordingRule,
|
|
140
|
-
request: RequestToKey,
|
|
141
|
-
known?: KnownValues,
|
|
142
|
-
): KeyedRequest {
|
|
143
|
-
const headers = Object.fromEntries(
|
|
144
|
-
(rule.keyHeaders ?? []).map((name) => [
|
|
145
|
-
name,
|
|
146
|
-
String(request.headers[name] ?? ""),
|
|
147
|
-
]),
|
|
148
|
-
);
|
|
149
|
-
const json = parseJson(request.body);
|
|
150
|
-
const { text, values } =
|
|
151
|
-
json === undefined
|
|
152
|
-
? extractValues(request.body, rule.values, known)
|
|
153
|
-
: normalizeJson(json, rule.values, known);
|
|
154
|
-
const key = sha256(
|
|
155
|
-
[
|
|
156
|
-
KEY_VERSION,
|
|
157
|
-
rule.name,
|
|
158
|
-
request.method,
|
|
159
|
-
request.url,
|
|
160
|
-
...Object.entries(headers).map(([name, value]) => `${name}: ${value}`),
|
|
161
|
-
text,
|
|
162
|
-
].join("\n"),
|
|
163
|
-
);
|
|
164
|
-
return { key, values };
|
|
165
|
-
}
|