@sentry/roach 0.0.0-stage → 0.2.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.
@@ -0,0 +1,464 @@
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>;
@@ -0,0 +1,42 @@
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/report.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Text reports of a Roach run, for logs and CI summaries.
3
+ */
4
+ import { appendFile } from "node:fs/promises";
5
+ import type { RecordingMiss, RecordingStats } from "./types.ts";
6
+
7
+ /** Describe the totals of a run in one line. */
8
+ export function describeRecordingStats(stats: RecordingStats): string {
9
+ const rules = Object.entries(stats.counts)
10
+ .map(
11
+ ([rule, { live, missed, replayed }]) =>
12
+ `${rule} ${replayed} replayed, ${live} live${missed ? `, ${missed} missed` : ""}`,
13
+ )
14
+ .join("; ");
15
+ const passthrough =
16
+ Object.entries(stats.passthrough)
17
+ .map(([origin, count]) => `${origin} ${count}`)
18
+ .join(", ") || "none";
19
+ return `${rules}. ${stats.written} recordings new or changed, ${stats.discarded} dropped from failed sessions. Not recorded: ${passthrough}.`;
20
+ }
21
+
22
+ /**
23
+ * Describe the first request without a recording of each test, one line
24
+ * each. The first miss of a test is the one to fix. Each later miss of the
25
+ * test usually follows from it, because the live response differs from
26
+ * the recording.
27
+ */
28
+ export function describeRecordingMisses(misses: RecordingMiss[]): string[] {
29
+ const seen = new Set<string | undefined>();
30
+ return misses
31
+ .filter((miss) => {
32
+ const first = !seen.has(miss.session);
33
+ seen.add(miss.session);
34
+ return first;
35
+ })
36
+ .map((miss) => `${miss.session ?? "outside a test"}: ${miss.file}`);
37
+ }
38
+
39
+ /**
40
+ * Write the report of a run to stdout, and in GitHub Actions also to the
41
+ * job summary. `title` names the run, such as `Roach (auto)`.
42
+ */
43
+ export async function writeRunReport(
44
+ title: string,
45
+ stats: RecordingStats,
46
+ ): Promise<void> {
47
+ const report = [
48
+ `${title}: ${describeRecordingStats(stats)}`,
49
+ ...describeRecordingMisses(stats.misses).map((miss) => `- ${miss}`),
50
+ ].join("\n");
51
+ process.stdout.write(`${report}\n`);
52
+ if (process.env.GITHUB_STEP_SUMMARY) {
53
+ await appendFile(process.env.GITHUB_STEP_SUMMARY, `${report}\n`);
54
+ }
55
+ }