@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/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
- }
@@ -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
- }