@sentry/roach 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,1076 @@
1
- # Temporary Holding Version
1
+ # Roach
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Roach is a recording HTTPS proxy for tests. It records chosen requests and
4
+ replays them, so a second run of a test suite makes no live calls. The
5
+ proxy owns every read and write of the recordings. A test runner only gives
6
+ it rules and tells it when each test starts and ends.
7
+
8
+ Roach is a shared service (`packages/service`): one deployed proxy for many
9
+ projects and CI runs. A client points `HTTPS_PROXY` at it and trusts its
10
+ certificate authority. It installs nothing else. The recordings are in a
11
+ GCS bucket, which deletes each one 30 days after it was written. See
12
+ [Service](#service) and [Deploy](#deploy).
13
+
14
+ Roach started in
15
+ the evals of
16
+ [`getsentry/junior`](https://github.com/getsentry/junior/tree/main/packages/junior-evals).
17
+ It uses Node built-ins and the `openssl` command. The npm package has no
18
+ dependencies. Only the service uses the Sentry SDK (`@sentry/node`), and the
19
+ service is a private package in `packages/service` that is not published.
20
+
21
+ ## Use with vitest
22
+
23
+ Install `@sentry/roach`, add a `roach.json` (see [Config file](#config-file)),
24
+ and add two lines to the vitest config:
25
+
26
+ ```ts
27
+ export default defineConfig({
28
+ test: {
29
+ globalSetup: ["@sentry/roach/vitest"],
30
+ setupFiles: ["@sentry/roach/vitest/setup"],
31
+ },
32
+ });
33
+ ```
34
+
35
+ The global setup (`packages/roach/src/vitest.ts`) starts one run for the invocation and
36
+ gives its proxy variables to the test workers. The setup file
37
+ (`packages/roach/src/vitest-setup.ts`) opens one session for each test, so test files
38
+ record in parallel workers at the same time. A passed test writes its new
39
+ recordings. A failed test drops them. In `replay` mode, a test with a miss
40
+ fails. At the end, the global setup writes the stats of the run to stdout
41
+ and to the job summary. After you add Roach, do the check in
42
+ [Find changing values](#find-changing-values).
43
+
44
+ - `ROACH_TOKEN`: the write token. Only the global setup reads it. Without
45
+ it, the run can only replay, as in jobs of forks.
46
+ - `ROACH_URL`: the service. Default: `https://roach-proxy.getsentry.net`.
47
+ - `ROACH_MODE`: `auto`, `replay`, `record`, or `off`. Default: `auto` with
48
+ the token, `replay` without it. With `off`, the global setup starts no run,
49
+ so requests go direct and need no service and no token.
50
+
51
+ A project that has its own global setup calls `startRoach(project)` from
52
+ `@sentry/roach/vitest` in it, and returns its teardown.
53
+
54
+ - It needs Node 24.14 or later (`http.setGlobalProxyFromEnv`), except in
55
+ `off` mode.
56
+ - Put `@sentry/roach/vitest/setup` before a setup file that starts an HTTP
57
+ mock, such as MSW or nock. The setup file keeps the request functions of
58
+ `node:http` and `node:https` as it loads, so its calls to the service do
59
+ not go to the mock.
60
+ - The tests of one file share one process, so they run one at a time. Do
61
+ not use `test.concurrent`.
62
+ - The setup file sets a new global `fetch` dispatcher for each test. A
63
+ dispatcher that you set before, such as one with `compose()` interceptors,
64
+ does not apply in the test. Set it again in a `beforeEach` of a setup
65
+ file that comes after `@sentry/roach/vitest/setup` in `setupFiles`, with
66
+ `sequence.setupFiles: "list"`. Restore it with `onTestFinished`.
67
+ - A client that gives `fetch` its own undici dispatcher, such as
68
+ `@vercel/sandbox`, does not use the proxy. Its requests go direct.
69
+ - The package has TypeScript sources. Vitest compiles them, but Node does
70
+ not strip types in `node_modules`, so plain Node scripts cannot import it.
71
+
72
+ ## Use the service
73
+
74
+ ```ts
75
+ import { startRemoteRun, connectRoach } from "@sentry/roach/client";
76
+ import { describeRecordingStats } from "@sentry/roach/report";
77
+ import { VALUE_PATTERNS } from "@sentry/roach/values";
78
+
79
+ // Once per CI job. Without `token`, only `replay` mode is allowed.
80
+ const run = await startRemoteRun(
81
+ { url: "https://roach.example.com", token: process.env.ROACH_TOKEN },
82
+ {
83
+ tenant: process.env.GITHUB_REPOSITORY!, // such as getsentry/junior
84
+ mode: "auto",
85
+ name: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
86
+ rules: [
87
+ {
88
+ name: "model",
89
+ match: { method: "POST", url: "https://ai-gateway.vercel.sh/" },
90
+ keyHeaders: ["ai-model-id"],
91
+ values: { uuid: VALUE_PATTERNS.uuid, time: VALUE_PATTERNS.isoTime },
92
+ },
93
+ ],
94
+ },
95
+ );
96
+ spawn("vitest", { env: { ...process.env, ...run.env } });
97
+
98
+ // Once per test, in a worker that has the `controlUrl` and `token` of the
99
+ // run. Send the requests of the test through `session.url`.
100
+ const session = await connectRoach({ controlUrl, token }).startSession(
101
+ testName,
102
+ );
103
+ const { missed } = await session.end(passed);
104
+ // In `replay` mode, fail the test when `missed` is not 0.
105
+
106
+ // At the end of the job.
107
+ console.log(describeRecordingStats(await run.close()));
108
+ ```
109
+
110
+ `run.env` has `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`,
111
+ and `NODE_EXTRA_CA_CERTS`. Node 24 reads the last two only at startup. The
112
+ CA file also has the certificates of `NODE_EXTRA_CA_CERTS` of the caller.
113
+ A client that does not use `client.ts` can do the same with plain HTTP. See
114
+ [Control API](#control-api).
115
+
116
+ ## Use the GitHub Action
117
+
118
+ A repository adds one step and a `roach.json` file (see
119
+ [Config file](#config-file)):
120
+
121
+ ```yaml
122
+ - uses: getsentry/roach@v0
123
+ with:
124
+ token: ${{ secrets.ROACH_TOKEN }} # without it, the run can only replay
125
+ run: pnpm test
126
+ ```
127
+
128
+ The action (`packages/roach/src/action.ts`) starts a run with the token in `auto` mode, or
129
+ without it in `replay` mode. It runs `run` in bash with the proxy variables
130
+ of the run, then ends the run and writes its stats to the job summary. The
131
+ `url` input sets the service. Default: `https://roach-proxy.getsentry.net`.
132
+
133
+ - Only `run` uses the proxy. Later steps, such as cache and artifact
134
+ uploads, do not. `run` does not get the write token or the `ACTIONS_*`
135
+ variables of the runner, such as `ACTIONS_RUNTIME_TOKEN` and
136
+ `ACTIONS_ID_TOKEN_REQUEST_TOKEN`. So `run` cannot get an OIDC token.
137
+ - `run` is one session. When it fails, the run drops its new recordings. A
138
+ miss in `replay` mode fails the step. The summary names the job, not the
139
+ test, of each miss. For a session per test, use
140
+ [vitest](#use-with-vitest) in `run`.
141
+ - `NODE_EXTRA_CA_CERTS` works only for Node. Python and `curl` need a CA
142
+ bundle that also has the system certificates.
143
+
144
+ ## Config file
145
+
146
+ The action and the vitest integration read `roach.json` in the working
147
+ directory or the closest parent directory (`packages/roach/src/config.ts`):
148
+
149
+ ```json
150
+ {
151
+ "allow": ["https://api.anthropic.com", "https://openrouter.ai"],
152
+ "rules": [
153
+ {
154
+ "name": "model",
155
+ "match": {
156
+ "method": "POST",
157
+ "url": [
158
+ "https://api.anthropic.com/v1/",
159
+ "https://openrouter.ai/api/v1/"
160
+ ]
161
+ },
162
+ "values": {
163
+ "uuid": { "builtin": "uuid" },
164
+ "build": "build-[0-9]+"
165
+ }
166
+ }
167
+ ]
168
+ }
169
+ ```
170
+
171
+ - `allow` and `rules` are those of the run. See [Config](#config).
172
+ - A value is a regular expression source, or `{ "builtin": "<name>" }`
173
+ for a pattern of `VALUE_PATTERNS` in `values.ts`.
174
+ - `secretEnv`: environment variables that hold credentials that no request
175
+ header has, such as a token that goes to a model in a prompt. Their
176
+ values are the `secrets` of the run. Do not list API keys that go in a
177
+ header: the proxy redacts those on its own. See
178
+ [Config](#config).
179
+ - `tenant`: default `GITHUB_REPOSITORY`. Set it, so local runs use the
180
+ recordings of CI.
181
+ - `noProxy`: hosts that do not use the proxy. Default: loopback.
182
+
183
+ The `name` of the run is `<GITHUB_RUN_ID>-<GITHUB_RUN_ATTEMPT>` in GitHub
184
+ Actions. The run also sends `ci`: `GITHUB_RUN_ID`, `GITHUB_JOB`,
185
+ `GITHUB_REPOSITORY`, and `GITHUB_REF`. Only the spans in Sentry use it.
186
+
187
+ ## Config
188
+
189
+ A run takes `RunConfig` (`packages/roach/src/types.ts`): `tenant`, `mode`, `rules`,
190
+ `allow`, `name`, `secrets`, and `ci`.
191
+
192
+ - `mode`: `auto` replays recordings and records misses. `replay` replays
193
+ recordings and fails a miss with HTTP 412, so nothing goes live. `record`
194
+ sends every request live and records it again. `off` records and replays
195
+ nothing.
196
+ - `allow`: the only origins that the proxy sends requests to, 1 to 32 of
197
+ them. It refuses all other origins with HTTP 403. The upstream host
198
+ always comes from this list, not from the request. The service sends
199
+ requests only to public addresses, so an origin that resolves to a
200
+ private, loopback, or link-local address also gets HTTP 403. So does an
201
+ IPv6 address with an IPv4 address in it, such as `[::ffff:808:808]` or
202
+ `[::ffff:0:7f00:1]`. The stats
203
+ of the run count the refused requests by origin, in `refused.notAllowed`
204
+ and `refused.notPublic`. The run report shows them after `Refused:`, so
205
+ look there first when requests fail with HTTP 403. A tunnel to a port
206
+ other than 443 shows as `host:port`. The stats name 32 origins for each
207
+ reason, and count the others as `other origins`.
208
+ - `rules`: the traffic to record. `name` starts with a letter or a digit,
209
+ has at most 64 characters, and uses letters, digits, `_`, `.`, and `-`.
210
+ `match` has `method`, `url`, and `headers`. `url` is a prefix,
211
+ or a list of 1 to 32 prefixes. A URL that starts with one of them
212
+ matches. Use a list when the same API is on many hosts, such as one
213
+ rule with the same `values` for Anthropic, OpenRouter, and OpenAI. The
214
+ key does not use `match`, so a change to `url` keeps the keys. When you
215
+ merge rules into one rule, some keys get a new rule name, so their
216
+ requests record again one time. `keyHeaders` adds request headers to
217
+ the key. `values` names the values that change from run to run. A run
218
+ can have 64 rules, and a rule can have 32 values.
219
+ - Credentials: the proxy never writes the `secrets` of the service config
220
+ or of the run. Use `secrets` of the run for the credentials of a CI job
221
+ that a response can show but no request header has, such as a token that
222
+ goes to a model in a prompt. The service keeps them only in memory, for
223
+ the run. A run can have 32 of them, each of at most 16 Ki characters. It
224
+ also learns the value of each request header and query parameter whose
225
+ name can mean a credential: a name with `auth`, `token`, `secret`,
226
+ `key`, `cookie`, `session`, `password`, `credential`, or `signature`.
227
+ The match is broad on purpose. It does not learn `x-session-affinity`,
228
+ because that header has a conversation id and not a credential.
229
+ Recordings show `<<redacted>>` in place of each known value. The URL of
230
+ a recording, a request sample, and the text of a miss in Sentry also show
231
+ `<<redacted>>` for the value of each such query parameter, at any length
232
+ (see [Misses](#misses)). They also show it for a parameter whose value is
233
+ the value of a credential field of the request body (see below). The
234
+ other parameters do not change.
235
+ A run without the token learns at most 1,024 values, with a total of
236
+ 1 Mi characters. A run with the token learns at most 16,384 values, with
237
+ a total of 16 Mi characters, for credentials that change on each
238
+ request, such as AWS signatures. The whole value and each part of it
239
+ count, also for the `secrets` of the run. The proxy also redacts each
240
+ part with `\/` for `/`, as some servers write it in JSON, and the whole
241
+ value when a `/` is only in parts shorter than 12 characters. These
242
+ forms do not count. A run whose `secrets` go past
243
+ the limit gets HTTP 400 when it starts. Past the limit, a request with a
244
+ new credential gets HTTP 431, and the proxy does not send or record it.
245
+ A request with known credentials goes on. Start a new run to learn more.
246
+ - Compressed bodies: the proxy asks the upstream for a body without a
247
+ content encoding. When the upstream sends `gzip`, `deflate`, `br`, or
248
+ `zstd` all the same, the proxy decodes the body before it redacts and records
249
+ it. It decodes 64 MiB at most. When the body does not decode, or when
250
+ it decodes to more than 64 MiB, the proxy does not record it. It
251
+ writes a log line, and the client gets the body as it came.
252
+ - Bodies that are not text: a recording keeps such a body, such as
253
+ `application/octet-stream`, as base64. The proxy redacts the known
254
+ values in its bytes before it encodes it. Only the bytes of a known
255
+ value change, so a binary body stays binary. The client gets the same
256
+ bytes as a replay gives.
257
+ - Credentials that the upstream makes: in a response body, the proxy also
258
+ redacts the value of each field that holds a credential that a server
259
+ mints, such as the token of an OAuth server. In JSON, only the fields
260
+ of the object at the top count, because a server puts a minted
261
+ credential there. In an `application/x-www-form-urlencoded` body, the
262
+ form fields count. The fields are `access_token`, `refresh_token`, `id_token`,
263
+ `session_token`, `auth_token`, and `client_secret`. A bare `token`
264
+ field counts only when its value starts like a credential: a GitHub
265
+ token (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, or `github_pat_`), a JWT
266
+ (`eyJ` and two dots), or a Slack token (`xoxa-`, `xoxb-`, `xoxp-`,
267
+ `xoxr-`, or `xoxs-`). Model APIs use `token` for text, such as in log
268
+ probabilities, so the length alone never counts. The names compare
269
+ without case, `_`, and `-`, so `accessToken` is the same as
270
+ `access_token`.
271
+ - The list is short, and only the top of a JSON body counts, on
272
+ purpose. A model can repeat a part of its response in a later
273
+ request, such as the input of a tool call. A redacted value there
274
+ gives a new key, so the request misses on replay. So the proxy does
275
+ not redact a field such as `password` in a response, or a field at a
276
+ lower depth, or text in a string, such as `?access_token=` in a
277
+ command.
278
+ - A recording shows `<<redacted>>` in place of the value. The proxy
279
+ replaces only the value after the name of its field. All other text
280
+ of the body keeps its bytes, also the same text at other places, and
281
+ the spaces and the order of the fields. So a JSON body stays valid
282
+ JSON. A byte order mark at the start and unicode escapes in the value
283
+ do not stop it. The proxy also does this for a body that is valid
284
+ UTF-8 JSON when its content type is not JSON, or when it has none.
285
+ When a minted value stays in the body after this, the proxy does not
286
+ record the response. It writes a log line, and the client gets the
287
+ body as it came.
288
+ - The proxy does not find these fields in text that is not one JSON
289
+ value, such as the `data:` lines of a stream (`text/event-stream`), or
290
+ JSON that is not valid. It keeps them there. It still redacts each
291
+ known value in such a body. When a field name of the top object comes
292
+ two times, it reads only the last value, as `JSON.parse` does.
293
+ A recording keeps an `application/x-www-form-urlencoded` body as
294
+ text. The `location` header of a recording shows `<<redacted>>` for
295
+ each known value, and for each of these fields in its query and its
296
+ fragment, such as `#access_token=`. Other parameters do not change,
297
+ such as `keyword` or `pageToken`, so a replay can follow the redirect.
298
+ - A live response gives the client the real value, so the run can use
299
+ the credential in its next requests. From then on, the run also
300
+ redacts that value where it knows credentials, when it is 12
301
+ characters or longer. These values do not count toward the limit
302
+ above, because only a run that records gets them, from an upstream
303
+ that `allow` names.
304
+ - A replay gives `<<redacted>>` in place of the value. A client that
305
+ only sends the credential back in a header, such as `Authorization`,
306
+ replays without a change, because the key does not use that header.
307
+ A client that reads the credential, such as one that decodes a JWT
308
+ `id_token`, or that sends it in a body, in the URL query, or in a key
309
+ header, does not work on replay. Do not record such requests with a rule.
310
+ - Credentials in a request body: a request sample and the text of a miss
311
+ in Sentry show `<<redacted>>` for the fields above, and also for
312
+ `bearer_token`, `client_assertion`, `password`, `api_key`,
313
+ `secret_key`, `private_key`, and `secret_access_key`. In JSON, these
314
+ fields count at each depth. In an `application/x-www-form-urlencoded`
315
+ body, the form fields count, also with a nested name such as
316
+ `user[password]`, `password[]`, or `user[password][0]`. No key uses
317
+ this text, so this list can be longer. When the value of such a JSON
318
+ field is an object or an array, each string in it shows `<<redacted>>`,
319
+ at any depth. When the value is a number, or has a number in it, the
320
+ request sample has no text, and the URL of the recording shows
321
+ `<<redacted>>` for a parameter with that number. The proxy replaces the value at the place
322
+ of each field, and each other string that is the same value, also each
323
+ string of an object or an array value. A value pattern can change
324
+ the text of a key, so then the proxy checks the result. It parses the
325
+ body again, and the body must be JSON. Each field must hold
326
+ `<<redacted>>` at its place, and no string can be one of the values.
327
+ Only the body counts, not a key header. When a check fails, the request
328
+ sample has no text, and the proxy writes a log line with the key. This
329
+ occurs when a value pattern takes a quote, or a name of a field, away.
330
+ A request with credential fields also gets no text when the name of a
331
+ value of its rule has `"`, `\`, `&`, `=`, `<`, `>`, or a control
332
+ character, because its `<<name>>` can add a field. A miss has no change
333
+ then.
334
+ - The proxy cannot find the fields of a body that is not a JSON object or
335
+ array or a form, such as JSON with a comma at the end, JSON lines, a
336
+ multipart body, or a JSON string. It also cannot find them in a form body
337
+ that has no credential field, such as JSON or XML with a form content
338
+ type. So the request sample of such a body has no text when the body has
339
+ the name of a field above anywhere, or the start of a bare `token` above.
340
+ The name can be in any case, with `_`, `-`, or nothing between its words,
341
+ such as `accessToken`, and with JSON unicode escapes or URL escapes in
342
+ it. Each test is one search of the body, so a long body costs little.
343
+ In a form with credential fields, the request sample also has no text
344
+ when a field that is not a credential field can show one, such as
345
+ `user.password=`, or a bare `token` or another field with JSON with URL
346
+ escapes in its value. A value that is only such a name, such as
347
+ `grant_type=password`, does not count. It also has no text when a part
348
+ of a nested name, such as `S` of `S[password]`, is a credential value,
349
+ or when a name has the start of a bare `token`.
350
+ - The proxy parses a JSON body to find the fields, so a long string value
351
+ cannot stop it. It does not find a field in JSON text that is itself a
352
+ string, such as the escaped arguments of a tool call, or a copy of a
353
+ value inside other text, such as in a prompt or with URL escapes. The URL
354
+ of a recording keeps a copy of a value of the body when the proxy cannot
355
+ find the fields of the body.
356
+ - Keys: redaction never changes the key of a request. The key is the hash
357
+ of the request as the client sent it, so recordings from before stay
358
+ valid.
359
+
360
+ Requests that match no rule go live without a change, and their responses
361
+ stream. Each response has an `x-roach` header: `replayed`, `live`,
362
+ `missed`, or `passthrough`.
363
+
364
+ ## Recordings
365
+
366
+ The key of a recording is `<rule>/<hash>.json`, under `<tenant>/` in the
367
+ store. The hash is the hash of the rule name, the method, the URL, the key
368
+ headers, and the body. A JSON body has sorted keys. A recording keeps the
369
+ response, the session that recorded it, and the method and the redacted
370
+ URL of the request. Of the response headers, it keeps only `content-type`
371
+ and `location`. It does not keep the request body. A request sample keeps
372
+ it. See [Misses](#misses).
373
+
374
+ - Many sessions can be open at once. A request belongs to the session of
375
+ its proxy URL (`url` of the session). A request through the proxy URL of
376
+ the run belongs to the open session when only one is open. The proxy
377
+ keeps the new recordings of a session in memory. A passed session writes them, and a failed session
378
+ drops them, so a bad sample is never replayed.
379
+ - A request belongs to the session that was open when it started. If it
380
+ ends after its session ended, it follows the result of that session.
381
+ - A request outside a session is written at once.
382
+ - A 429 or 5xx response is never recorded. A replay does not write the
383
+ file again.
384
+
385
+ ## Changing values
386
+
387
+ Ids and times change on each run, and a response often repeats them. For
388
+ example, the model archives the memory with the id that its request
389
+ showed. `values` of a rule maps a name to a regular expression source.
390
+ `VALUE_PATTERNS` in `packages/roach/src/values.ts` has `uuid`, `isoTime`, `date`, `epochMs`,
391
+ `sha256`, and `gitCommit`. When two patterns match at the same place, the first one wins.
392
+ The proxy matches values in JSON text. Start a custom pattern with
393
+ `NOT_AFTER_WORD`, not `\b`: after an escape such as `\n`, `\b` sees the `n`
394
+ as part of a word and does not match.
395
+
396
+ - The key sees each value as `<<name>>`. So two runs with other ids or
397
+ times use the same recording.
398
+ - When the proxy records a response, it writes each value of the request as
399
+ `<<name:n>>`: the n-th value of that name in the request.
400
+ - On replay, the proxy writes the n-th value of the current request there.
401
+ A replayed response thus uses the ids of this run.
402
+ - A value that the response makes itself, such as a date that a model
403
+ calculates, stays as it was recorded.
404
+ - A session remembers the values of its requests. In a later request of
405
+ the same session, such a value is also a placeholder where no pattern
406
+ finds it. For example, a pattern finds a short commit id only in the
407
+ output of `git push`, but the model then quotes it alone in its reply. A
408
+ pattern wins at the same place. A session remembers all values of its
409
+ requests until it ends. It does not drop a value, because that would
410
+ change the keys of later requests. Only a value of over 32,767
411
+ characters stays out: V8 cannot run a pattern with it, so it would fail
412
+ each later request of the session. A run without the token has a limit
413
+ (see below): past it, the request fails.
414
+ - Values in thinking blocks of the request do not count for `n`, and the
415
+ proxy does not look for remembered values in them. A replay keeps the
416
+ recorded text of a thinking block, so when a later request sends it back,
417
+ its values come from the recording run.
418
+
419
+ Model streams send a tool call in many small deltas, so a value can be
420
+ split over two events. Before it records a `text/event-stream`, the proxy
421
+ merges the deltas of each Anthropic Messages content block into one event.
422
+ Thinking blocks keep their recorded text, because their signature covers
423
+ it, and the provider refuses a changed thinking block.
424
+
425
+ Keep the patterns narrow. A broad pattern makes requests that differ in a
426
+ real way use the same recording.
427
+
428
+ The service takes any pattern that compiles in 1 s, up to 2048
429
+ characters. A run without the token must use the same patterns as the run
430
+ that recorded, or its keys do not match. Each key has a time limit: 1 s,
431
+ and 0.5 s for each MiB of the body. The built-in patterns take about 60
432
+ to 110 ms for each MiB. A request whose key takes longer fails with HTTP 400.
433
+ So does a JSON body with arrays or objects nested over 64 levels deep.
434
+
435
+ All runs share one process. Anyone can start a run without the token, so
436
+ the service makes the keys of these runs in a worker process, one key at
437
+ a time. So a slow key does not stop the other work of the service. Some
438
+ work, such as `JSON.parse()` of a large body, cannot stop at the time
439
+ limit. When a key goes 1 s past its time limit, the service stops the
440
+ worker and starts a new one. The worker has 256 MiB of heap. A key that
441
+ needs more stops the worker, and its request fails with HTTP 400. The key
442
+ of a 16 MiB body of small text blocks needs less. A run with the token
443
+ makes its keys in the main process, so it has no memory limit of the
444
+ worker. The key of a 64 MiB body can need about 1 GiB.
445
+
446
+ The limits never change a key: a request gets the key that it always
447
+ had, or it fails. A run without the token has smaller limits:
448
+
449
+ - A key has at most 5 s.
450
+ - A request can have at most 100,000 changing values. A request with more
451
+ fails with HTTP 400, and the run goes on.
452
+ - The sessions of the run remember at most 10,000 values, with 256 Ki
453
+ characters, together. A request that would add more fails with HTTP
454
+ 400, and the run ends. A session forgets its values when it ends. The
455
+ model requests of Junior have about 500 values with 10 K characters for
456
+ each session.
457
+ - The keys of the run and the redaction of its misses (see
458
+ [Misses](#misses)) have 30 s in total. In the 14 days to 2026-11-05, the
459
+ keys of the largest run took 9.5 s (p99 9.1 s, about 1,000 keys).
460
+ - When a key goes past its time limit, its worker stops, or the run goes
461
+ past 30 s, the run ends. Its other requests then fail with HTTP 410 or 407.
462
+
463
+ Avoid nested quantifiers, such as `(a+)+`.
464
+
465
+ ## Misses
466
+
467
+ The proxy adds each request without a recording to `stats().misses`, with
468
+ its session and key. The stats keep the first 500 misses of a run.
469
+ `describeRecordingMisses()` in `packages/roach/src/report.ts` gives the
470
+ first miss of each test, which is the one to fix. The run report of the
471
+ vitest integration and of the GitHub Action shows these misses.
472
+
473
+ The proxy also finds where the first miss of each session changed
474
+ (`packages/service/src/misses.ts`). Sentry gets the text of the change, on the `roach.request`
475
+ span of the miss (see [Sentry](#sentry)). The report of a run with the
476
+ write token tells only where the change is:
477
+
478
+ ```text
479
+ - a.fixture.ts > passes: model/074d….json
480
+ Changed at character 65 of the request of model/8b96….json. See miss.recorded and miss.request on the roach.request span in Sentry.
481
+ ```
482
+
483
+ The report goes to CI logs and job summaries, and these can be public. So
484
+ no report, log, or stats of a run show the text of a request. Only Sentry
485
+ shows it.
486
+
487
+ - When the proxy records a request, it also writes a request sample to
488
+ `_requests/<rule>/<hash>.json`. The sample has the text that the key
489
+ hashes: the rule, the method, the URL, the key headers, and the body
490
+ with `<<name>>` for each changing value. The proxy redacts credentials
491
+ in it, as in a recording, the value of each credential query parameter,
492
+ and the value of each credential field of the body, such as
493
+ `client_secret` (see [Config](#config)). It keeps the first 1,048,576
494
+ characters (UTF-16 code units) of the text.
495
+ - The hash of a sample is the place of the request: its session (test)
496
+ and the number of requests of the rule that the session sent before
497
+ it. So a sample is the last recorded request at the same place of the
498
+ same test. A replay writes no sample.
499
+ - At the first miss of a session, the proxy reads the sample of the place
500
+ of the request. It finds the first character that is not the same.
501
+ Sentry gets 40 characters before it, and at most 120 characters from it,
502
+ of each text. This is one read for each session, in every run.
503
+ - Sentry gets the change of every run, with or without the token. So find
504
+ the change of a miss of a fork in Sentry.
505
+ - Only runs with the write token get `change` in their stats and report.
506
+ It has the key of the recording that the sample came from (`file`), and
507
+ the offset (`at`). It has no text. A run without the token, such as CI of
508
+ a fork, gets no `change`. Reads are public, and the offset of the first
509
+ change would let anyone guess the requests of a tenant back, one
510
+ character at a time.
511
+ - A miss has no change when there is no sample, such as for a recording
512
+ from an older version, when the sample has no text (`withheld`, see
513
+ [Config](#config)), or when the texts are the same in the part that the
514
+ sample keeps. A sample with no text still takes the place of the older
515
+ sample, so a miss never compares with a request that is not the last.
516
+ - The proxy redacts the text of the miss before it compares it. This has
517
+ the time limit of a key, and for a run without the token it runs in the
518
+ worker process of the keys (see [Changing values](#changing-values)).
519
+ Past the limit, the miss has no change.
520
+ - When the requests of a test run in parallel, their order can change.
521
+ Then a sample can come from another request of the test.
522
+
523
+ ## Find changing values
524
+
525
+ Do this check when you add Roach to a project. Do it again when a test
526
+ that passed does not replay on the next run.
527
+
528
+ A value that changes on each run and that no pattern finds gives a new key
529
+ on each run. In `auto` mode, that request goes live and records again on
530
+ each run. The run is slow, and it costs as much as a run without Roach.
531
+ The requests after it in the test also go live, because the live response
532
+ is not the same as the recorded one. No error shows this. The tests pass.
533
+
534
+ 1. Run the suite two times with the token. On the second run, each test
535
+ that passed must replay all of its requests. Look at `live` in the stats
536
+ of the second run. It must be 0, or near 0. In CI, also compare the
537
+ time of each test. A test that replays takes about as long as its code
538
+ without the network. [`TELEMETRY.md`](TELEMETRY.md) shows how to find
539
+ the runs and keys that stay live.
540
+ 2. Find the first live request of a test that did not replay. Run only
541
+ that test with `ROACH_MODE=replay`. It needs no token. The test fails
542
+ at the first miss, and the run report shows its key. Fix the first miss
543
+ of a test before the others. The others often come from it.
544
+ 3. Find the `roach.request` span of the first miss in Sentry, by its
545
+ `roach.run.id`, `roach.session`, and `has:miss.changed_at`. Read `miss.recorded` and `miss.request`. See
546
+ [Misses](#misses) and [`TELEMETRY.md`](TELEMETRY.md). They show the
547
+ changing value, with the text before it. This works for every run, with
548
+ or without the token. When the miss has no change, get the body of that
549
+ request from two runs, and compare them. For example, add this file to `setupFiles`
550
+ after `@sentry/roach/vitest/setup`, and run the test two times:
551
+
552
+ ```ts
553
+ // Local only. Writes each fetch request to DUMP_FILE.
554
+ import { appendFileSync } from "node:fs";
555
+ import { expect } from "vitest";
556
+
557
+ const dumpFile = process.env.DUMP_FILE ?? "requests.jsonl";
558
+ const fetchBefore = globalThis.fetch;
559
+ globalThis.fetch = async (input, init) => {
560
+ const request = new Request(input, init);
561
+ const record = {
562
+ test: expect.getState().currentTestName,
563
+ method: request.method,
564
+ url: request.url,
565
+ body: await request.clone().text(),
566
+ };
567
+ appendFileSync(dumpFile, JSON.stringify(record) + "\n");
568
+ return fetchBefore(request);
569
+ };
570
+ ```
571
+
572
+ The first difference between the two bodies is the changing value.
573
+ This file sees only `fetch`. A client that uses `node:http` directly
574
+ needs its own hook.
575
+
576
+ 4. Add a narrow pattern for the value to `values` of the rule. See
577
+ [Changing values](#changing-values). Then do step 1 again.
578
+
579
+ Values that changed in real projects:
580
+
581
+ - Ids that the code under test makes, such as UUIDs and random short ids.
582
+ For example, Warden gives each finding a random `XXX-XXX` id, and the
583
+ next model request quotes it.
584
+ - Paths of temporary directories, commit SHAs of a repository that a test
585
+ makes, and times and dates.
586
+ - The local environment. Global git config, such as a hook that adds a
587
+ commit trailer, commit signing, or `user.name`, changes the commits that
588
+ a test makes. `HOME`, the user name, the locale, and the time zone can
589
+ also show in a request. Then local keys are not the same as CI keys, so
590
+ a local `replay` misses while CI replays. Make the test harness ignore
591
+ them. For git, set `GIT_CONFIG_GLOBAL=/dev/null` and
592
+ `GIT_CONFIG_NOSYSTEM=1`, and set the author and committer in the test.
593
+ - Order. The proxy sorts JSON object keys, but not arrays. An array whose
594
+ order depends on timing, such as the results of parallel tool calls,
595
+ gives a new key when the order changes.
596
+
597
+ ## Service
598
+
599
+ `packages/service/src/cli.ts service <config.json>` starts the service. The
600
+ config is `RoachServiceConfig` in `packages/service/src/service.ts`. `deploy/gcp/` writes it for you.
601
+
602
+ - **Tenants.** A tenant is a GitHub repository, as `owner/repo`, such as
603
+ `getsentry/junior`. In GitHub Actions, use `GITHUB_REPOSITORY`. The
604
+ service has no list of tenants, so a new project needs no change to the
605
+ service. The recordings of a tenant are under `<owner>/<repo>/` in the
606
+ bucket. Tenants never share recordings.
607
+ - **One write token.** All tenants share one write token. The config keeps
608
+ only its SHA-256, in `writeTokenHash`. The tenant name is only a label,
609
+ not proof of identity: a job with the token can write the recordings of
610
+ any tenant. So give the token only to repositories and workflows that
611
+ you trust, and never to a workflow that runs code from a fork.
612
+ - **Reads are public.** Anyone can start a `replay` run of a tenant, so CI
613
+ jobs of forks replay without a secret. Such a run never writes, and it
614
+ refuses a request that no rule records, so nothing goes live. The stats
615
+ count these requests in `refused.replayOnly`. Every other mode needs
616
+ the write token. So treat each recording as readable by anyone who can
617
+ make its request. Do not record responses that must stay private. For
618
+ what a run without the token sees of a miss, see [Misses](#misses).
619
+ - **Runs.** Each run has its own id, token, mode, rules, sessions, and
620
+ stats. A proxied request names its run in `Proxy-Authorization`, as
621
+ `Basic base64(<id>:<token>)`. The proxy URL of the run has them, so
622
+ `HTTPS_PROXY` sends them. A request without them gets HTTP 407.
623
+ - **Config of a run.** Each run sends its own `allow` and `rules`, with
624
+ their value patterns. A tenant that calls a new origin or uses a new
625
+ pattern changes only its own config. The service needs no change and no
626
+ restart. Two checks keep one run from harming the others:
627
+ - The proxy sends requests only to public addresses
628
+ (`packages/service/src/addresses.ts`).
629
+ It checks the address of each connection after DNS, so a host name
630
+ cannot point a run at the metadata server, at the VPC, or at the VM.
631
+ Only tests and local development set `privateNetworks`.
632
+ - A key has a time limit. The service makes the keys of runs without
633
+ the token in a worker process with a memory limit
634
+ (`packages/service/src/key-worker.ts`). So a slow pattern or a large body fails only its
635
+ own request, and it does not stop the service.
636
+ - **Sentry.** With `sentryDsn`, each read and write of a recording sends
637
+ a span to Sentry. Each proxied request sends a span and metrics. Each
638
+ unexpected error sends an error event. See [Sentry](#sentry).
639
+ - **Errors.** An error that is the fault of the client, such as HTTP 400,
640
+ 409, 413, or 429, keeps its message. Any other error, such as a failure
641
+ of the upstream or of GCS, gets HTTP 502 for a proxied request or HTTP
642
+ 500 for a control call. Its text is only `internal error, Sentry event
643
+ <id>`, because the message of the error can have a response body. Only
644
+ Sentry gets the error. The service log gets its class, its code, and
645
+ the HTTP status of a failed GCS call.
646
+ - **Certificate authority.** `ca` holds the PEM certificate and key that
647
+ clients trust. Keep it across restarts. Host certificates live for 7
648
+ days, and the service makes new ones before they expire.
649
+
650
+ ### Sentry
651
+
652
+ With `sentryDsn`, the service sends metrics, spans, and unexpected errors
653
+ to Sentry. It keeps all traces. `TELEMETRY.md` has the queries. Each
654
+ attribute of Roach starts with `roach.`. Metrics have only attributes with
655
+ few values: `roach.tenant`, `roach.mode`, `roach.rule`, `roach.origin`,
656
+ `roach.result`, and `roach.reason`.
657
+
658
+ | Metric | Type | Value |
659
+ | ------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
660
+ | `roach.request` | counter | 1 for each proxied request. `roach.result` is `replayed`, `live`, `missed`, `passthrough`, `refused`, `busy` (HTTP 503: the body did not fit in the budget), `timeout` (HTTP 408: the body did not come in time), or `aborted` (the client closed the request before its body came). A `passthrough`, `refused`, `busy`, `timeout`, or `aborted` request has no `roach.rule`. A `refused` request has `roach.reason`, and `other` for an origin not in `allow`. |
661
+ | `roach.request.duration` | distribution (ms) | The time to answer a request that a rule records, by `roach.result`. |
662
+ | `roach.recording.write` | counter | Recordings by `roach.rule`. `roach.result` is `written` (the proxy sent them to the store) or `dropped` (their session failed). |
663
+
664
+ Each proxied request is one trace. Each span has `roach.tenant`,
665
+ `roach.mode`, `roach.run.id` (the `name` of the run, else its id), and the
666
+ `roach.ci.*` attributes of the CI job of the run: `roach.ci.run_id`,
667
+ `roach.ci.job`, `roach.ci.repo`, and `roach.ci.ref`.
668
+
669
+ - `roach.run.start`: a new run, with `roach.write` (the run has the write
670
+ token).
671
+ - `roach.request`: one proxied request, named `<method> <origin>`, with
672
+ `roach.origin`, `roach.rule`, `roach.result`, `roach.session` (the name
673
+ of the session, such as the test), `http.request.method`, and
674
+ `http.response.status_code`. A refused request also has `roach.reason`.
675
+ When a replayed or live response has token usage, the span also has the
676
+ `gen_ai` attributes of the model call
677
+ (`packages/service/src/usage.ts`). Sentry then calculates its cost in USD as
678
+ `gen_ai.cost.total_tokens`. The span of the first miss of a session also
679
+ has `miss.sample` (`found`, `withheld`, or `none`), in every run. When
680
+ the proxy finds a change, the span also has `miss.changed_at` (the
681
+ offset of the first different character), `miss.compared` (the key of
682
+ the recording), and the redacted text of the change: `miss.recorded` and
683
+ `miss.request`.
684
+ Only Sentry gets these texts ([Misses](#misses)). It contains:
685
+ - `roach.key`: the key of the request, with `roach.rule` and
686
+ `roach.key`.
687
+ - `roach.store.read`: a read of a recording, with `roach.key` and
688
+ `roach.result`: `replayed` (the store has the recording) or `missed`.
689
+ The first miss of a session also reads a request sample. Its
690
+ `roach.key` starts with `_requests/`.
691
+ - `roach.upstream`: the live request, with `roach.origin`,
692
+ `http.request.method`, and `http.response.status_code`.
693
+ - `roach.store.write`: a write of recordings, with `roach.recordings` and
694
+ `roach.written`. A session writes when it ends, so this span is usually
695
+ its own trace. A second span writes the request samples of the same
696
+ recordings.
697
+ - `roach.run.end`: the end of a run, with its totals: `roach.replayed`,
698
+ `roach.live`, `roach.missed`, `roach.passthrough`, `roach.refused`,
699
+ `roach.written`, `roach.dropped`, and `roach.run.duration_ms`.
700
+
701
+ An error event has the tags of the spans of its run. An error of the
702
+ control API has no run tags.
703
+
704
+ Telemetry never has a credential. It has no body and no path or query of
705
+ a URL, except `miss.recorded` and `miss.request`. These have at most 160
706
+ characters of the redacted text of a request, which can include a part of
707
+ its URL, headers, or body. The log of the service names only the key of a
708
+ miss, not its change.
709
+ From a model call, it has only the model name and the token counts.
710
+ So the service turns off the request data and the HTTP spans of the SDK,
711
+ removes the path from the name of an error event, and adds no trace
712
+ headers to the requests that it sends. An error event has the message of
713
+ the error, which can have a response body.
714
+
715
+ [`TELEMETRY.md`](TELEMETRY.md) shows how to get the hit rate, the misses,
716
+ the time that replays save, the dropped recordings, and the keys that
717
+ change on each run.
718
+
719
+ ### Limits
720
+
721
+ - Runs live in memory in one process. A restart ends the open runs, and
722
+ their CI jobs fail. A run with the token that is open for 6 hours ends
723
+ as failed.
724
+ - A run without the token ends after 2 hours, or after 60 minutes without
725
+ a proxied request or a control call. Its proxy URL then gets HTTP 407,
726
+ and its control API gets HTTP 404. The GitHub Action starts the run
727
+ before the command of the job, so a step that takes longer than
728
+ 60 minutes without a request to the service fails.
729
+ - The service can have 100 open runs without a token, for all tenants
730
+ together, and 10 for each client address. All addresses of one IPv6
731
+ /64 count as one client, and an IPv4 address in IPv6 form, such as
732
+ `::ffff:192.0.2.1`, counts as its IPv4 address. Another one gets HTTP
733
+ 429 until one ends. Runs with the token have no cap. With `proxyProtocol`, the address of the
734
+ client comes from the PROXY protocol v1 header of the load balancer.
735
+ A connection without the header uses the address of the connection.
736
+ - A proxied request body over 64 MiB gets HTTP 413, and over 16 MiB for a
737
+ run without the token. A control API body over 1 MiB gets HTTP 413
738
+ before the service parses it.
739
+ - The requests of all runs without the token can hold 128 MiB of bodies
740
+ at one time, and the runs of one client 16 MiB. The client of a run is
741
+ the client that started it, grouped as for the limit of runs. A request
742
+ holds its body until it finishes, also while its key waits for the key
743
+ worker. A request whose body does not fit gets HTTP 503 with
744
+ `Retry-After: 1`, and the run goes on. The clients of model APIs send a
745
+ 503 again. A client that does not gets the 503, and its test fails.
746
+ Runs with the token have no such limit.
747
+ - A body of a run without the token that does not come in 30 s gets
748
+ HTTP 408, and its connection closes. The run goes on.
749
+ - With these limits, the service and the key worker use at most about
750
+ 1.1 GB of the 2 GB of the VM. That is for request bodies of runs with
751
+ the token of a normal size. A body of 64 MiB from a run with the token,
752
+ at the same time as runs without the token fill the budget, took about
753
+ 2.0 GB.
754
+ - Keys and the redaction of misses have time limits. See
755
+ [Changing values](#changing-values).
756
+ - Request headers that do not come in 10 s get HTTP 408, and a request
757
+ whose body does not come in 300 s too. This is also true in a tunnel
758
+ and with `proxyProtocol`. With `proxyProtocol`, a connection that sends
759
+ no byte, or no full PROXY header, in 10 s closes.
760
+ - A `CONNECT` tunnel that sends no byte for 10 s after the `200` closes.
761
+ - A connection or tunnel that sends and gets no byte for 10 minutes
762
+ closes, also while a request waits for its response. This is true for
763
+ each request, with or without TLS. Between two requests, a connection
764
+ closes after 6 s without a new request (the `keepAliveTimeout` of Node,
765
+ 5 s, and 1 s that Node adds), and the client opens a new one.
766
+ - The credentials that a run learns and the values that the sessions of
767
+ a run without the token remember have limits. See [Config](#config) and
768
+ [Changing values](#changing-values).
769
+ - A gap: a client with an IPv6 /48 has 65,536 /64s, so it can open runs
770
+ until the service has 100 runs without a token. Then other clients get
771
+ HTTP 429 until its runs end. Its runs can also hold all of the budget of
772
+ bodies. A body gets HTTP 408 after 30 s, but the client can start the
773
+ next one at once, so it holds the budget for as long as it sends.
774
+ - GCS deletes each recording 30 days after it was written, also when runs
775
+ still replay it. Then `auto` mode records it again, and `replay` mode
776
+ fails the request with HTTP 412.
777
+ - A write replaces the recording with the same key.
778
+
779
+ ## Deploy
780
+
781
+ `deploy/gcp/` is the production setup, in Terraform. It makes:
782
+
783
+ - a GCS bucket that deletes each recording after `recording_days` (30),
784
+ - the certificate authority and the write token,
785
+ - the service config, in Secret Manager,
786
+ - one Container-Optimized OS VM that runs the image
787
+ `ghcr.io/getsentry/roach:main`, with a service account that can only use
788
+ the bucket and the secret,
789
+ - an SSL proxy load balancer with a Google-managed certificate. It ends TLS
790
+ and passes the raw TCP stream, with `CONNECT`, to the VM. Each
791
+ connection starts with a PROXY protocol v1 header with the address of
792
+ the client (`proxy_header`). The config sets `proxyProtocol`, so the
793
+ service limits runs without a token by that address. The health check
794
+ sends the header too,
795
+ - a service account for the `Deploy` workflow, which GitHub Actions uses
796
+ through workload identity federation (`deploy.tf`). It can only reset
797
+ the VM.
798
+
799
+ The `Image` workflow builds the image on each pull request and pushes it as
800
+ `:<sha>` on `main`. The VM pulls `:main` at each start.
801
+
802
+ The `Deploy` workflow deploys `main` when it is green. After the `Image`
803
+ workflow of a commit on `main`, it waits for `CI` of the same commit. When
804
+ both pass and the commit is still the head of `main`, it moves `:main` to
805
+ `:<sha>`, resets the VM, and waits until the service answers. A reset ends
806
+ the open runs, so their CI jobs fail. A red commit does not deploy, and an
807
+ older commit never replaces a newer one.
808
+
809
+ Sometimes GitHub starts no workflow for a push to `main`. Then the head of
810
+ `main` has no CI and no image, and it does not deploy. Run both by hand:
811
+
812
+ ```sh
813
+ gh workflow run ci.yml --repo getsentry/roach --ref main
814
+ gh workflow run image.yml --repo getsentry/roach --ref main
815
+ ```
816
+
817
+ The `Deploy` workflow accepts these runs like push runs.
818
+
819
+ ### Set up
820
+
821
+ 1. Make the package `ghcr.io/getsentry/roach` public once, after the first
822
+ push to `main`, so the VM can pull it without credentials.
823
+ 2. `deploy/gcp/roach.tfvars` has the values of the Sentry deployment: the
824
+ GCP project `roach-511216`, the domain, and the DSN of the `sentry/roach`
825
+ project. Another deployment changes these values. It has no origins or
826
+ patterns: runs send them.
827
+ 3. The Terraform state is in the private bucket that `backend "gcs"` in
828
+ `versions.tf` names. The state holds the CA key and the write token, so
829
+ the bucket must stay private. Make the bucket before the first
830
+ `terraform init`.
831
+ 4. Log in with `gcloud auth application-default login`, then apply:
832
+
833
+ ```sh
834
+ cd deploy/gcp
835
+ terraform init
836
+ terraform apply -var-file=roach.tfvars
837
+ ```
838
+
839
+ 5. Point an A record of `domain` at the `ip_address` output. The
840
+ certificate works some minutes after DNS does.
841
+ 6. Put the write token in one GitHub organization secret, `ROACH_TOKEN`.
842
+ Give it access only to the repositories that you trust. This command
843
+ does not show the token:
844
+
845
+ ```sh
846
+ terraform output -raw write_token \
847
+ | gh secret set ROACH_TOKEN --org getsentry --visibility selected \
848
+ --repos getsentry/junior
849
+ ```
850
+
851
+ 7. Check it: `curl https://<domain>/__roach/ca.pem` returns the CA
852
+ certificate.
853
+ 8. Let the `Deploy` workflow reset the VM. Set two variables of the
854
+ repository from the outputs. Until they are set, the workflow only
855
+ moves `:main`, and the VM gets it at its next start:
856
+
857
+ ```sh
858
+ gh variable set GCP_WORKLOAD_IDENTITY_PROVIDER --repo getsentry/roach \
859
+ --body "$(terraform output -raw deploy_workload_identity_provider)"
860
+ gh variable set GCP_DEPLOY_SERVICE_ACCOUNT --repo getsentry/roach \
861
+ --body "$(terraform output -raw deploy_service_account)"
862
+ ```
863
+
864
+ Another deployment also changes `deploy_repository` and
865
+ `deploy_repository_id` in its tfvars, and the `env` of
866
+ `.github/workflows/deploy.yml`.
867
+
868
+ To deploy by hand, restart the VM:
869
+ `gcloud compute instances reset roach --zone <zone>`. It pulls `:main`,
870
+ the last green commit.
871
+
872
+ The service reads its config only when it starts. When a change to the
873
+ service config must reach the VM before a change to the load balancer,
874
+ such as `proxyProtocol` before `proxy_header`, apply in this order. Run
875
+ the commands in `deploy/gcp`:
876
+
877
+ 1. Merge the change, and let the `Deploy` workflow deploy the image.
878
+ 2. Apply only the config secret:
879
+
880
+ ```sh
881
+ terraform apply -var-file=roach.tfvars \
882
+ -target=google_secret_manager_secret_version.config
883
+ ```
884
+
885
+ 3. Reset the VM, so that it reads the new config:
886
+
887
+ ```sh
888
+ gcloud compute instances reset roach --project roach-511216 --zone us-east1-b
889
+ ```
890
+
891
+ 4. When the service answers, apply the rest:
892
+
893
+ ```sh
894
+ terraform apply -var-file=roach.tfvars
895
+ ```
896
+
897
+ Step 1 and step 3 each reset the VM, and a reset ends the open runs, so
898
+ their CI jobs fail. Do step 3 when few runs are open, and do step 4 at
899
+ once after it.
900
+
901
+ With `proxyProtocol`, the service reads a PROXY header when a connection
902
+ has one, and it uses the address of the connection when it does not. The
903
+ load balancer writes its header before the first byte of the client, so a
904
+ client cannot put its own header first. A client can send its own header
905
+ only when the service has `proxyProtocol` and the load balancer does not
906
+ send the header: from step 3 to step 4 above. So do step 4 at once. A
907
+ false header changes only the address for the limit of runs for each
908
+ client, and the firewall lets only the Google ranges connect to the VM.
909
+
910
+ To add a project, add its repository to the repositories of the
911
+ `ROACH_TOKEN` organization secret, in the GitHub settings of the
912
+ organization. Its CI jobs set `tenant` to `GITHUB_REPOSITORY`, and send
913
+ their own origins and patterns. The service needs no change.
914
+
915
+ To change the write token, run
916
+ `terraform apply -var-file=roach.tfvars -replace=random_password.write_token`,
917
+ set the secret again, and restart the VM.
918
+
919
+ ## Command line
920
+
921
+ ```sh
922
+ node packages/service/src/cli.ts service <config.json>
923
+ ```
924
+
925
+ It starts the service and prints its URL as one JSON line.
926
+
927
+ ## Control API
928
+
929
+ The calls of a run are under `/__roach/runs/<id>`. They need
930
+ `Authorization: Bearer <run token>`. `client.ts` calls them.
931
+
932
+ - `POST /session` with `{"name": "..."}`: open a session. Returns
933
+ `{"url": "..."}`, the proxy URL of the session. Its user is
934
+ `<run id>-<session id>`. An open session with the same name fails
935
+ first. A run can have 64 open sessions. Another one gets HTTP 429. A
936
+ service before 0.2.0 returns no URL. Then `client.ts` fails and tells
937
+ you to deploy a newer service.
938
+ - `POST /session/end` with `{"name": "...", "passed": true}`: end it.
939
+ Returns `{"missed": 0}`, the misses that `replay` mode failed. Returns
940
+ HTTP 409 when the session is not open, and HTTP 500 when the store fails
941
+ (see "Errors" in [Service](#service)).
942
+ - `GET /stats`: the totals of the run.
943
+
944
+ The service also has:
945
+
946
+ - `POST /__roach/runs` with a `RunConfig`: start a run. Send
947
+ `Authorization: Bearer <write token>` for any mode but `replay`. Returns
948
+ HTTP 201 with the `id`, `token`, proxy `url`, `controlUrl`, and `caCert`
949
+ of the run. A body that is not JSON or a config that is not valid gets
950
+ HTTP 400, a body over 1 MiB gets HTTP 413, a mode other than
951
+ `replay` without the token gets HTTP 401, and a run past the limit of
952
+ runs without a token gets HTTP 429 (see [Limits](#limits)).
953
+ - `DELETE /__roach/runs/<id>`: end the run. Returns its stats.
954
+ - `GET /__roach/ca.pem`: the CA certificate. No token.
955
+
956
+ A call of a run with a wrong token gets HTTP 404, as a call of a run that
957
+ does not exist does.
958
+
959
+ ## Release
960
+
961
+ Run the `Release` workflow, and choose `minor`, `patch`, or `major`. Craft
962
+ (`.craft.yml`) makes a `release/<version>` branch and asks for approval in
963
+ [getsentry/publish](https://github.com/getsentry/publish). When a release
964
+ manager accepts it, Craft:
965
+
966
+ - copies the image `ghcr.io/getsentry/roach:<sha>` to `:<version>`.
967
+ - makes the GitHub release `v<version>`, with the changelog, and moves the
968
+ `v<major>` tag. Repositories use the action as `getsentry/roach@v<major>`.
969
+ - publishes `@sentry/roach` to npm: the tarball that the `package` job of
970
+ `CI` uploads for the release commit. Craft publishes only
971
+ `sentry-roach-<version>.tgz`, and CI makes sure that it has `LICENSE` and
972
+ `README.md` (`.github/scripts/check-tarball.sh`). On the release branch, Craft runs
973
+ `.github/scripts/bump-version.sh`, which writes the version to
974
+ `packages/roach/package.json`. The workspace root and `packages/service`
975
+ are private and have no version.
976
+
977
+ The action runs `packages/roach/src/action.ts` from the tag, and the
978
+ package has the sources, so a release has no build step.
979
+
980
+ Craft takes the last version from the newest `v<version>` tag in the
981
+ history of `main`. Do not rewrite `main`, or the tag leaves its history and
982
+ Craft bumps from 0.0.0. The workflow deletes the floating `v<major>` tags in
983
+ its checkout first (`.github/scripts/drop-floating-tags.sh`), because
984
+ `git describe` picks them over the release tag on the same commit.
985
+
986
+ The `Release` workflow uses the token of Sentry Release Bot. A push with
987
+ `GITHUB_TOKEN` starts no workflows, so the release branch gets no checks and
988
+ no image, and Craft cannot publish it.
989
+
990
+ ## Development
991
+
992
+ ```sh
993
+ pnpm install
994
+ pnpm check # format, lint, types, unused code, and tests
995
+ ```
996
+
997
+ CI runs the tests only with coverage (`pnpm test:coverage`), and packs
998
+ `@sentry/roach`. It does not run `pnpm check`, so run it before you push. `AGENTS.md` has the conventions and the commands for one
999
+ file.
1000
+
1001
+ - `packages/roach/tests/package.test.ts` checks what the npm package
1002
+ loads and ships.
1003
+ - `packages/service/tests/recording.test.ts` tests modes, sessions,
1004
+ changing values, and misses.
1005
+ - `packages/service/tests/redaction.test.ts` tests the redaction of
1006
+ credentials in recordings and request samples.
1007
+ - `packages/service/tests/service.test.ts` tests the service in the test
1008
+ process: access, limits, and the control API.
1009
+ - `packages/service/tests/vitest.test.ts` runs vitest with the integration on a small
1010
+ project, against a service in the test process.
1011
+ - `packages/service/tests/deployed.test.ts` runs the service as it runs in production: from
1012
+ the command line, behind a TLS server that does what the load balancer
1013
+ does, with a local GCS server, metadata server, and Sentry server. Each
1014
+ CI job runs the GitHub Action, and its command has only the proxy
1015
+ variables of its run.
1016
+
1017
+ ## Files
1018
+
1019
+ The repository is a pnpm workspace with two packages. The root package is
1020
+ private and has only the tools: oxlint, oxfmt, TypeScript, vitest, and knip.
1021
+ `pnpm-workspace.yaml` has the catalog of the versions that the packages
1022
+ share. The production setup is in `deploy/gcp/` and `Dockerfile`.
1023
+
1024
+ - `packages/roach` is the npm package `@sentry/roach`: the client, the
1025
+ vitest integration, and the action. It has no runtime dependencies, and it
1026
+ ships only `src/`. `tests/package.test.ts` fails when an export or the
1027
+ action loads a package that is not a Node built-in or vitest.
1028
+ - `packages/service` is the private package `@sentry/roach-service`: the
1029
+ shared service and the proxy. It imports `@sentry/roach` as a workspace
1030
+ package (`workspace:*`). It is the only package that depends on
1031
+ `@sentry/node`. Its tests run the client, the vitest integration, and the
1032
+ action against a real service.
1033
+
1034
+ In `packages/roach/src/`:
1035
+
1036
+ - `types.ts`: rules, modes, runs, sessions, and the results of a run.
1037
+ - `values.ts`: changing values and their placeholders.
1038
+ - `client.ts`: starts a run and calls the control API.
1039
+ - `config.ts`: `roach.json`, for the action and vitest.
1040
+ - `vitest.ts`: the vitest global setup: one run for each invocation.
1041
+ - `vitest-setup.ts`: the vitest setup file: one session for each test.
1042
+ - `report.ts`: text reports of a run.
1043
+ - `action.ts`: the GitHub Action (`action.yml` at the root).
1044
+
1045
+ In `packages/service/src/`:
1046
+
1047
+ - `service.ts`: the shared service: tenants, runs, access, and metrics.
1048
+ - `cli.ts`: the command line, which the image runs.
1049
+ - `server.ts`: sockets, HTTPS interception, the allow list, and the control
1050
+ API.
1051
+ - `bodies.ts`: request bodies, their size limits, and the budget that the
1052
+ bodies of runs without the token share.
1053
+ - `clients.ts`: the PROXY protocol header and the address of a client.
1054
+ - `run-config.ts`: the check of the run config of a client, with its
1055
+ limits.
1056
+ - `errors.ts`: the errors that are the fault of the client, and the log
1057
+ line and the client text of an unexpected error.
1058
+ - `addresses.ts`: the public addresses that the proxy can send requests to.
1059
+ - `recorder.ts`: modes, sessions, replay, and misses.
1060
+ - `recording-file.ts`: a recording file from a live response, the response
1061
+ of a replay, and the decoding of compressed bodies.
1062
+ - `request-key.ts`: the key of a request.
1063
+ - `key-worker.ts`: the worker process that makes the keys of runs without
1064
+ the token and redacts the text of their misses, with a time limit and a
1065
+ memory limit.
1066
+ - `misses.ts`: request samples and the first change of a miss.
1067
+ - `store.ts`: the formats of recordings and request samples, and the
1068
+ store contract.
1069
+ - `recordings.ts`: the file store, for tests and development.
1070
+ - `gcs.ts`: the GCS store.
1071
+ - `streams.ts`: merges the deltas of a recorded model stream.
1072
+ - `usage.ts`: the token usage of a model response, for Sentry.
1073
+ - `secrets.ts`: redaction of credentials.
1074
+ - `json-strings.ts`: the parse of a JSON body, and the one-pass scan of
1075
+ the JSON strings of a body, for `secrets.ts`.
1076
+ - `certificates.ts`: the certificate authority for HTTPS.