@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.
package/README.md CHANGED
@@ -1,3 +1,562 @@
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 (`service.ts`): 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
+ [`getsentry/junior`](https://github.com/getsentry/junior/tree/main/packages/junior-evals/src/roach).
16
+ It uses Node built-ins and the `openssl` command. Only the service uses the
17
+ Sentry SDK (`@sentry/node`).
18
+
19
+ ## Use with vitest
20
+
21
+ Install `@sentry/roach`, add a `roach.json` (see [Config file](#config-file)),
22
+ and add two lines to the vitest config:
23
+
24
+ ```ts
25
+ export default defineConfig({
26
+ test: {
27
+ globalSetup: ["@sentry/roach/vitest"],
28
+ setupFiles: ["@sentry/roach/vitest/setup"],
29
+ },
30
+ });
31
+ ```
32
+
33
+ The global setup (`src/vitest.ts`) starts one run for the invocation and
34
+ gives its proxy variables to the test workers. The setup file
35
+ (`src/vitest-setup.ts`) opens one session for each test, so test files
36
+ record in parallel workers at the same time. A passed test writes its new
37
+ recordings. A failed test drops them. In `replay` mode, a test with a miss
38
+ fails. At the end, the global setup writes the stats of the run to stdout
39
+ and to the job summary.
40
+
41
+ - `ROACH_TOKEN`: the write token. Only the global setup reads it. Without
42
+ it, the run can only replay, as in jobs of forks.
43
+ - `ROACH_URL`: the service. Default: `https://roach-proxy.getsentry.net`.
44
+ - `ROACH_MODE`: `auto`, `replay`, `record`, or `off`. Default: `auto` with
45
+ the token, `replay` without it. With `off`, the global setup starts no run,
46
+ so requests go direct and need no service and no token.
47
+
48
+ A project that has its own global setup calls `startRoach(project)` from
49
+ `@sentry/roach/vitest` in it, and returns its teardown.
50
+
51
+ - It needs Node 24.14 or later (`http.setGlobalProxyFromEnv`).
52
+ - The tests of one file share one process, so they run one at a time. Do
53
+ not use `test.concurrent`.
54
+ - The package has TypeScript sources. Vitest compiles them, but Node does
55
+ not strip types in `node_modules`, so plain Node scripts cannot import it.
56
+
57
+ ## Use the service
58
+
59
+ ```ts
60
+ import { startRemoteRun, connectRoach } from "@sentry/roach/client";
61
+ import { describeRecordingStats } from "@sentry/roach/report";
62
+ import { VALUE_PATTERNS } from "@sentry/roach/values";
63
+
64
+ // Once per CI job. Without `token`, only `replay` mode is allowed.
65
+ const run = await startRemoteRun(
66
+ { url: "https://roach.example.com", token: process.env.ROACH_TOKEN },
67
+ {
68
+ tenant: process.env.GITHUB_REPOSITORY!, // such as getsentry/junior
69
+ mode: "auto",
70
+ name: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
71
+ rules: [
72
+ {
73
+ name: "model",
74
+ match: { method: "POST", url: "https://ai-gateway.vercel.sh/" },
75
+ keyHeaders: ["ai-model-id"],
76
+ values: { uuid: VALUE_PATTERNS.uuid, time: VALUE_PATTERNS.isoTime },
77
+ },
78
+ ],
79
+ },
80
+ );
81
+ spawn("vitest", { env: { ...process.env, ...run.env } });
82
+
83
+ // Once per test, in a worker that has the `controlUrl` and `token` of the
84
+ // run. Send the requests of the test through `session.url`.
85
+ const session = await connectRoach({ controlUrl, token }).startSession(
86
+ testName,
87
+ );
88
+ const { missed } = await session.end(passed);
89
+ // In `replay` mode, fail the test when `missed` is not 0.
90
+
91
+ // At the end of the job.
92
+ console.log(describeRecordingStats(await run.close()));
93
+ ```
94
+
95
+ `run.env` has `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`,
96
+ and `NODE_EXTRA_CA_CERTS`. Node 24 reads the last two only at startup. The
97
+ CA file also has the certificates of `NODE_EXTRA_CA_CERTS` of the caller.
98
+ A client that does not use `client.ts` can do the same with plain HTTP. See
99
+ [Control API](#control-api).
100
+
101
+ ## Use the GitHub Action
102
+
103
+ A repository adds one step and a `roach.json` file (see
104
+ [Config file](#config-file)):
105
+
106
+ ```yaml
107
+ - uses: getsentry/roach@v0
108
+ with:
109
+ token: ${{ secrets.ROACH_TOKEN }} # without it, the run can only replay
110
+ run: pnpm test
111
+ ```
112
+
113
+ The action (`src/action.ts`) starts a run with the token in `auto` mode, or
114
+ without it in `replay` mode. It runs `run` in bash with the proxy variables
115
+ of the run, then ends the run and writes its stats to the job summary.
116
+
117
+ - Only `run` uses the proxy. Later steps, such as cache and artifact
118
+ uploads, do not. `run` does not get the write token.
119
+ - `run` is one session. When it fails, the run drops its new recordings. A
120
+ miss in `replay` mode fails the step. The summary names the job, not the
121
+ test, of each miss. For a session per test, use
122
+ [vitest](#use-with-vitest) in `run`.
123
+ - `NODE_EXTRA_CA_CERTS` works only for Node. Python and `curl` need a CA
124
+ bundle that also has the system certificates.
125
+
126
+ ## Config file
127
+
128
+ The action and the vitest integration read `roach.json` in the working
129
+ directory or the closest parent directory (`src/config.ts`):
130
+
131
+ ```json
132
+ {
133
+ "allow": ["https://api.anthropic.com"],
134
+ "secretEnv": ["ANTHROPIC_API_KEY"],
135
+ "rules": [
136
+ {
137
+ "name": "model",
138
+ "match": { "method": "POST", "url": "https://api.anthropic.com/v1/" },
139
+ "values": {
140
+ "uuid": { "builtin": "uuid" },
141
+ "build": "build-[0-9]+"
142
+ }
143
+ }
144
+ ]
145
+ }
146
+ ```
147
+
148
+ - `allow` and `rules` are those of the run. See [Config](#config).
149
+ - A value is a regular expression source, or `{ "builtin": "<name>" }`
150
+ for a pattern of `VALUE_PATTERNS` in `values.ts`.
151
+ - `secretEnv`: environment variables that hold credentials. Their values
152
+ are the `secrets` of the run.
153
+ - `tenant`: default `GITHUB_REPOSITORY`. Set it, so local runs use the
154
+ recordings of CI.
155
+ - `noProxy`: hosts that do not use the proxy. Default: loopback.
156
+
157
+ The `name` of the run is `<GITHUB_RUN_ID>-<GITHUB_RUN_ATTEMPT>` in GitHub
158
+ Actions.
159
+
160
+ ## Config
161
+
162
+ A run takes `RunConfig` (`src/types.ts`): `tenant`, `mode`, `rules`,
163
+ `allow`, `name`, and `secrets`.
164
+
165
+ - `mode`: `auto` replays recordings and records misses. `replay` replays
166
+ recordings and fails a miss with HTTP 412, so nothing goes live. `record`
167
+ sends every request live and records it again. `off` records and replays
168
+ nothing.
169
+ - `allow`: the only origins that the proxy sends requests to, 1 to 32 of
170
+ them. It refuses all other origins with HTTP 403. The upstream host
171
+ always comes from this list, not from the request. The service sends
172
+ requests only to public addresses, so an origin that resolves to a
173
+ private, loopback, or link-local address also gets HTTP 403.
174
+ - `rules`: the traffic to record. `name` uses letters, digits, `_`, `.`,
175
+ and `-`. `match` has `method`, `url` (a prefix),
176
+ and `headers`. `keyHeaders` adds request headers to the key. `values`
177
+ names the values that change from run to run. A run can have 64 rules,
178
+ and a rule can have 32 values.
179
+ - Credentials: the proxy never writes the `secrets` of the service config
180
+ or of the run. Use `secrets` of the run for the credentials of a CI job
181
+ that a response can show but no request header has, such as a token that
182
+ goes to a model in a prompt. The service keeps them only in memory, for
183
+ the run. A run can have 32 of them. It also learns the value of each request header whose name can mean a credential,
184
+ such as one with `auth`, `token`, `key`, `secret`, `cookie`, or `session`.
185
+ The match is broad on purpose. It does not learn `x-session-affinity`,
186
+ because that header has a conversation id and not a credential.
187
+ Recordings show `<<redacted>>` in place of each known value.
188
+
189
+ Requests that match no rule go live without a change, and their responses
190
+ stream. Each response has an `x-roach` header: `replayed`, `live`,
191
+ `missed`, or `passthrough`.
192
+
193
+ ## Recordings
194
+
195
+ The key of a recording is `<rule>/<hash>.json`, under `<tenant>/` in the
196
+ store. The hash is the hash of the rule, the method, the URL, the key
197
+ headers, and the body. A JSON body has sorted keys. A recording keeps the
198
+ response and the session that recorded it. It does not keep the request
199
+ body.
200
+
201
+ - Many sessions can be open at once. A request belongs to the session of
202
+ its proxy URL (`url` of the session). A request through the proxy URL of
203
+ the run belongs to the open session when only one is open. The proxy
204
+ keeps the new recordings of a session in memory. A passed session writes them, and a failed session
205
+ drops them, so a bad sample is never replayed.
206
+ - A request belongs to the session that was open when it started. If it
207
+ ends after its session ended, it follows the result of that session.
208
+ - A request outside a session is written at once.
209
+ - A 429 or 5xx response is never recorded. A replay does not write the
210
+ file again.
211
+
212
+ ## Changing values
213
+
214
+ Ids and times change on each run, and a response often repeats them. For
215
+ example, the model archives the memory with the id that its request
216
+ showed. `values` of a rule maps a name to a regular expression source.
217
+ `VALUE_PATTERNS` in `values.ts` has `uuid`, `isoTime`, `date`, `epochMs`,
218
+ `sha256`, and `gitCommit`. When two patterns match at the same place, the first one wins.
219
+ The proxy matches values in JSON text. Start a custom pattern with
220
+ `NOT_AFTER_WORD`, not `\b`: after an escape such as `\n`, `\b` sees the `n`
221
+ as part of a word and does not match.
222
+
223
+ - The key sees each value as `<<name>>`. So two runs with other ids or
224
+ times use the same recording.
225
+ - When the proxy records a response, it writes each value of the request as
226
+ `<<name:n>>`: the n-th value of that name in the request.
227
+ - On replay, the proxy writes the n-th value of the current request there.
228
+ A replayed response thus uses the ids of this run.
229
+ - A value that the response makes itself, such as a date that a model
230
+ calculates, stays as it was recorded.
231
+ - A session remembers the values of its requests. In a later request of
232
+ the same session, such a value is also a placeholder where no pattern
233
+ finds it. For example, a pattern finds a short commit id only in the
234
+ output of `git push`, but the model then quotes it alone in its reply. A
235
+ pattern wins at the same place.
236
+ - Values in thinking blocks of the request do not count for `n`, and the
237
+ proxy does not look for remembered values in them. A replay keeps the
238
+ recorded text of a thinking block, so when a later request sends it back,
239
+ its values come from the recording run.
240
+
241
+ Model streams send a tool call in many small deltas, so a value can be
242
+ split over two events. Before it records a `text/event-stream`, the proxy
243
+ merges the deltas of each Anthropic Messages content block into one event.
244
+ Thinking blocks keep their recorded text, because their signature covers
245
+ it, and the provider refuses a changed thinking block.
246
+
247
+ Keep the patterns narrow. A broad pattern makes requests that differ in a
248
+ real way use the same recording.
249
+
250
+ The service takes any pattern that compiles in 1 s, up to 2048
251
+ characters. A run without the token must use the same patterns as the run
252
+ that recorded, or its keys do not match. All runs share one process, so
253
+ the key of each request has a time limit: 1 s, and 0.5 s for each MiB of
254
+ the body. The built-in patterns take less than 0.1 s for each MiB. A
255
+ request whose key takes longer fails with HTTP 400, and other runs go on.
256
+ Avoid nested quantifiers, such as `(a+)+`.
257
+
258
+ ## Misses
259
+
260
+ The proxy adds each request without a recording to `stats().misses`, with
261
+ its session and key. `describeRecordingMisses()` in `report.ts` gives the
262
+ first miss of each test, which is the one to fix.
263
+
264
+ ## Service
265
+
266
+ `cli.ts service <config.json>` starts the service. The config is
267
+ `RoachServiceConfig` in `src/service.ts`. `deploy/gcp/` writes it for you.
268
+
269
+ - **Tenants.** A tenant is a GitHub repository, as `owner/repo`, such as
270
+ `getsentry/junior`. In GitHub Actions, use `GITHUB_REPOSITORY`. The
271
+ service has no list of tenants, so a new project needs no change to the
272
+ service. The recordings of a tenant are under `<owner>/<repo>/` in the
273
+ bucket. Tenants never share recordings.
274
+ - **One write token.** All tenants share one write token. The config keeps
275
+ only its SHA-256, in `writeTokenHash`. The tenant name is only a label,
276
+ not proof of identity: a job with the token can write the recordings of
277
+ any tenant. So give the token only to repositories and workflows that
278
+ you trust, and never to a workflow that runs code from a fork.
279
+ - **Reads are public.** Anyone can start a `replay` run of a tenant, so CI
280
+ jobs of forks replay without a secret. Such a run never writes, and it
281
+ refuses a request that no rule records, so nothing goes live. Every
282
+ other mode needs the write token. So treat each recording as readable by
283
+ anyone who can make its request. Do not record responses that must stay
284
+ private.
285
+ - **Runs.** Each run has its own id, token, mode, rules, sessions, and
286
+ stats. A proxied request names its run in `Proxy-Authorization`, as
287
+ `Basic base64(<id>:<token>)`. The proxy URL of the run has them, so
288
+ `HTTPS_PROXY` sends them. A request without them gets HTTP 407.
289
+ - **Config of a run.** Each run sends its own `allow` and `rules`, with
290
+ their value patterns. A tenant that calls a new origin or uses a new
291
+ pattern changes only its own config. The service needs no change and no
292
+ restart. Two checks keep one run from harming the others:
293
+ - The proxy sends requests only to public addresses (`addresses.ts`).
294
+ It checks the address of each connection after DNS, so a host name
295
+ cannot point a run at the metadata server, at the VPC, or at the VM.
296
+ Only tests and local development set `privateNetworks`.
297
+ - The key of a request has a time limit (`request-key.ts`), so a slow
298
+ pattern fails only its own request.
299
+ - **Sentry.** With `sentryDsn`, each read and write of a recording sends
300
+ a span to Sentry. Each proxied request sends a span and metrics. See
301
+ [Sentry](#sentry).
302
+ - **Certificate authority.** `ca` holds the PEM certificate and key that
303
+ clients trust. Keep it across restarts. Host certificates live for 7
304
+ days, and the service makes new ones before they expire.
305
+
306
+ ### Sentry
307
+
308
+ With `sentryDsn`, the service sends metrics and spans to Sentry. It keeps
309
+ all traces. Metrics have only attributes with few values: `tenant`, `mode`,
310
+ `rule`, `origin`, and `result`. Spans also have `run` (the `name` of the
311
+ run) and `key`.
312
+
313
+ | Metric | Type | Value |
314
+ | ------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
315
+ | `roach.request` | counter | 1 for each proxied request. `result` is the `x-roach` header: `replayed`, `live`, `missed`, or `passthrough`. A `passthrough` request has no `rule`. |
316
+ | `roach.request.duration` | distribution (ms) | The time to answer a request that a rule records, by `result`. |
317
+ | `roach.recording.write` | counter | Recordings by `rule`. `result` is `written` (the proxy sent them to the store) or `dropped` (their session failed). |
318
+
319
+ Each proxied request is one trace. Each span has `tenant`, `mode`, and
320
+ `run`.
321
+
322
+ - `roach.run.start`: a new run, with `write` (the run has the write token).
323
+ - `roach.request`: one proxied request, named `<method> <origin>`, with
324
+ `origin`, `rule`, `result`, and `http.response.status_code`. When a
325
+ replayed or live response has token usage, the span also has the
326
+ `gen_ai` attributes of the model call (`usage.ts`). Sentry then
327
+ calculates its cost in USD as `gen_ai.cost.total_tokens`. It contains:
328
+ - `roach.key`: the key of the request, with `rule` and `key`.
329
+ - `roach.store.read`: a read of a recording, with `key` and `result`:
330
+ `hit` or `miss`.
331
+ - `roach.upstream`: the live request, with `origin` and
332
+ `http.response.status_code`.
333
+ - `roach.store.write`: a write of recordings, with `recordings` and
334
+ `written`. A session writes when it ends, so this span is usually its
335
+ own trace.
336
+
337
+ Telemetry never has a credential, a body, or the path or query of a URL.
338
+ From a model call, it has only the model name and the token counts.
339
+ So the service turns off the request data and the HTTP spans of the SDK,
340
+ and it adds no trace headers to the requests that it sends.
341
+
342
+ To see how well the cache works:
343
+
344
+ - **Hit rate.** Sum `roach.request`, group by `result`, and filter by
345
+ `tenant` and `rule`. The hit rate is `replayed / (replayed + live +
346
+ missed)`.
347
+ - **Misses.** `missed` is a request that failed in `replay` mode. `live`
348
+ in `auto` mode is a new recording. When `live` stays high from run to
349
+ run, a value in the key changes on each run.
350
+ - **Time.** Compare `roach.request.duration` for `live` and `replayed`.
351
+ - **Dropped.** `roach.recording.write` with `result:dropped` counts
352
+ recordings of failed sessions. The next run sends these requests live
353
+ again.
354
+ - **Changing keys.** Search spans for `span.op:roach.key`, group by
355
+ `key`, and count the runs. A key that only one run uses shows that a
356
+ value changes on each run.
357
+
358
+ ### Limits
359
+
360
+ - Runs live in memory in one process. A restart ends the open runs, and
361
+ their CI jobs fail. A run that is open for 6 hours ends as failed.
362
+ - The service can have 100 open runs without a token, for all tenants
363
+ together. Another one gets HTTP 429 until one ends. Runs with the token
364
+ have no cap.
365
+ - A request body over 64 MiB gets HTTP 413.
366
+ - GCS deletes each recording 30 days after it was written, also when runs
367
+ still replay it. Then `auto` mode records it again, and `replay` mode
368
+ fails the request with HTTP 412.
369
+ - A write replaces the recording with the same key.
370
+
371
+ ## Deploy
372
+
373
+ `deploy/gcp/` is the production setup, in Terraform. It makes:
374
+
375
+ - a GCS bucket that deletes each recording after `recording_days` (30),
376
+ - the certificate authority and the write token,
377
+ - the service config, in Secret Manager,
378
+ - one Container-Optimized OS VM that runs the image
379
+ `ghcr.io/getsentry/roach:main`, with a service account that can only use
380
+ the bucket and the secret,
381
+ - an SSL proxy load balancer with a Google-managed certificate. It ends TLS
382
+ and passes the raw TCP stream, with `CONNECT`, to the VM,
383
+ - a service account for the `Deploy` workflow, which GitHub Actions uses
384
+ through workload identity federation (`deploy.tf`). It can only reset
385
+ the VM.
386
+
387
+ The `Image` workflow builds the image on each pull request and pushes it as
388
+ `:<sha>` on `main`. The VM pulls `:main` at each start.
389
+
390
+ The `Deploy` workflow deploys `main` when it is green. After the `Image`
391
+ workflow of a commit on `main`, it waits for `CI` of the same commit. When
392
+ both pass and the commit is still the head of `main`, it moves `:main` to
393
+ `:<sha>`, resets the VM, and waits until the service answers. A reset ends
394
+ the open runs, so their CI jobs fail. A red commit does not deploy, and an
395
+ older commit never replaces a newer one.
396
+
397
+ ### Set up
398
+
399
+ 1. Make the package `ghcr.io/getsentry/roach` public once, after the first
400
+ push to `main`, so the VM can pull it without credentials.
401
+ 2. `deploy/gcp/roach.tfvars` has the values of the Sentry deployment: the
402
+ GCP project `roach-511216`, the domain, and the DSN of the `sentry/roach`
403
+ project. Another deployment changes these values. It has no origins or
404
+ patterns: runs send them.
405
+ 3. The Terraform state is in the private bucket that `backend "gcs"` in
406
+ `versions.tf` names. The state holds the CA key and the write token, so
407
+ the bucket must stay private. Make the bucket before the first
408
+ `terraform init`.
409
+ 4. Log in with `gcloud auth application-default login`, then apply:
410
+
411
+ ```sh
412
+ cd deploy/gcp
413
+ terraform init
414
+ terraform apply -var-file=roach.tfvars
415
+ ```
416
+
417
+ 5. Point an A record of `domain` at the `ip_address` output. The
418
+ certificate works some minutes after DNS does.
419
+ 6. Put the write token in one GitHub organization secret, `ROACH_TOKEN`.
420
+ Give it access only to the repositories that you trust. This command
421
+ does not show the token:
422
+
423
+ ```sh
424
+ terraform output -raw write_token \
425
+ | gh secret set ROACH_TOKEN --org getsentry --visibility selected \
426
+ --repos getsentry/junior
427
+ ```
428
+
429
+ 7. Check it: `curl https://<domain>/__roach/ca.pem` returns the CA
430
+ certificate.
431
+ 8. Let the `Deploy` workflow reset the VM. Set two variables of the
432
+ repository from the outputs. Until they are set, the workflow only
433
+ moves `:main`, and the VM gets it at its next start:
434
+
435
+ ```sh
436
+ gh variable set GCP_WORKLOAD_IDENTITY_PROVIDER --repo getsentry/roach \
437
+ --body "$(terraform output -raw deploy_workload_identity_provider)"
438
+ gh variable set GCP_DEPLOY_SERVICE_ACCOUNT --repo getsentry/roach \
439
+ --body "$(terraform output -raw deploy_service_account)"
440
+ ```
441
+
442
+ Another deployment also changes `deploy_repository` and
443
+ `deploy_repository_id` in its tfvars, and the `env` of
444
+ `.github/workflows/deploy.yml`.
445
+
446
+ To deploy by hand, restart the VM:
447
+ `gcloud compute instances reset roach --zone <zone>`. It pulls `:main`,
448
+ the last green commit.
449
+
450
+ To add a project, add its repository to the repositories of the
451
+ `ROACH_TOKEN` organization secret, in the GitHub settings of the
452
+ organization. Its CI jobs set `tenant` to `GITHUB_REPOSITORY`, and send
453
+ their own origins and patterns. The service needs no change.
454
+
455
+ To change the write token, run
456
+ `terraform apply -var-file=roach.tfvars -replace=random_password.write_token`,
457
+ set the secret again, and restart the VM.
458
+
459
+ ## Command line
460
+
461
+ ```sh
462
+ node src/cli.ts service <config.json>
463
+ ```
464
+
465
+ It starts the service and prints its URL as one JSON line.
466
+
467
+ ## Control API
468
+
469
+ The calls of a run are under `/__roach/runs/<id>`. They need
470
+ `Authorization: Bearer <run token>`. `client.ts` calls them.
471
+
472
+ - `POST /session` with `{"name": "..."}`: open a session. Returns
473
+ `{"url": "..."}`, the proxy URL of the session. Its user is
474
+ `<run id>-<session id>`. An open session with the same name fails
475
+ first. A run can have 64 open sessions. Another one gets HTTP 429.
476
+ - `POST /session/end` with `{"name": "...", "passed": true}`: end it.
477
+ Returns `{"missed": 0}`, the misses that `replay` mode failed. Returns
478
+ HTTP 409 when the session is not open.
479
+ - `GET /stats`: the totals of the run.
480
+
481
+ The service also has:
482
+
483
+ - `POST /__roach/runs` with a `RunConfig`: start a run. Send
484
+ `Authorization: Bearer <write token>` for any mode but `replay`. Returns
485
+ the `id`, `token`, proxy `url`, `controlUrl`, and `caCert` of the run.
486
+ - `DELETE /__roach/runs/<id>`: end the run. Returns its stats.
487
+ - `GET /__roach/ca.pem`: the CA certificate. No token.
488
+
489
+ ## Release
490
+
491
+ Run the `Release` workflow, and choose `minor`, `patch`, or `major`. Craft
492
+ (`.craft.yml`) makes a `release/<version>` branch and asks for approval in
493
+ [getsentry/publish](https://github.com/getsentry/publish). When a release
494
+ manager accepts it, Craft:
495
+
496
+ - copies the image `ghcr.io/getsentry/roach:<sha>` to `:<version>`.
497
+ - makes the GitHub release `v<version>`, with the changelog, and moves the
498
+ `v<major>` tag. Repositories use the action as `getsentry/roach@v<major>`.
499
+ - publishes `@sentry/roach` to npm: the tarball that the `package` job of
500
+ `CI` uploads for the release commit. Craft writes the version to
501
+ `package.json` on the release branch.
502
+
503
+ The action runs `src/action.ts` from the tag, and the package has the
504
+ sources, so a release has no build step.
505
+
506
+ Craft takes the last version from the newest `v<version>` tag in the
507
+ history of `main`. Do not rewrite `main`, or the tag leaves its history and
508
+ Craft bumps from 0.0.0. The workflow deletes the floating `v<major>` tags in
509
+ its checkout first (`.github/scripts/drop-floating-tags.sh`), because
510
+ `git describe` picks them over the release tag on the same commit.
511
+
512
+ The `Release` workflow uses the token of Sentry Release Bot. A push with
513
+ `GITHUB_TOKEN` starts no workflows, so the release branch gets no checks and
514
+ no image, and Craft cannot publish it.
515
+
516
+ ## Development
517
+
518
+ ```sh
519
+ pnpm install
520
+ pnpm check # format, lint, types, unused code, and tests, as CI runs them
521
+ ```
522
+
523
+ `AGENTS.md` has the conventions and the commands for one file.
524
+
525
+ - `tests/recording.test.ts` tests modes, sessions, changing values, and
526
+ misses.
527
+ - `tests/service.test.ts` tests the service in the test process.
528
+ - `tests/vitest.test.ts` runs vitest with the integration on a small
529
+ project, against a service in the test process.
530
+ - `tests/deployed.test.ts` runs the service as it runs in production: from
531
+ the command line, behind a TLS server that does what the load balancer
532
+ does, with a local GCS server, metadata server, and Sentry server. Each
533
+ CI job runs the GitHub Action, and its command has only the proxy
534
+ variables of its run.
535
+
536
+ ## Files
537
+
538
+ The proxy is in `src/`. Tests are in `tests/`. The production setup is in
539
+ `deploy/gcp/` and `Dockerfile`.
540
+
541
+ - `types.ts`: rules, modes, runs, sessions, and the results of a run.
542
+ - `server.ts`: sockets, HTTPS interception, the allow list, and the control
543
+ API.
544
+ - `addresses.ts`: the public addresses that the proxy can send requests to.
545
+ - `service.ts`: the shared service: tenants, runs, access, and metrics.
546
+ - `recorder.ts`: modes, sessions, replay, and misses.
547
+ - `request-key.ts`: the key of a request.
548
+ - `store.ts`: the recording format and the store contract.
549
+ - `recordings.ts`: the file store, for tests and development.
550
+ - `gcs.ts`: the GCS store.
551
+ - `values.ts`: changing values and their placeholders.
552
+ - `streams.ts`: merges the deltas of a recorded model stream.
553
+ - `usage.ts`: the token usage of a model response, for Sentry.
554
+ - `secrets.ts`: redaction of credentials.
555
+ - `certificates.ts`: the certificate authority for HTTPS.
556
+ - `client.ts`: starts a run and calls the control API.
557
+ - `config.ts`: `roach.json`, for the action and vitest.
558
+ - `vitest.ts`: the vitest global setup: one run for each invocation.
559
+ - `vitest-setup.ts`: the vitest setup file: one session for each test.
560
+ - `report.ts`: text reports of a run.
561
+ - `action.ts`: the GitHub Action (`action.yml` at the root).
562
+ - `cli.ts`: the command line.
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "@sentry/roach",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "A recording HTTPS proxy for tests, as a shared service.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/getsentry/roach.git"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ "./client": "./src/client.ts",
16
+ "./report": "./src/report.ts",
17
+ "./service": "./src/service.ts",
18
+ "./types": "./src/types.ts",
19
+ "./values": "./src/values.ts",
20
+ "./vitest": "./src/vitest.ts",
21
+ "./vitest/setup": "./src/vitest-setup.ts"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@sentry/node": "11.6.0"
28
+ },
29
+ "devDependencies": {
30
+ "@sentry/dotagents": "^3.3.0",
31
+ "@types/node": "^24.19.1",
32
+ "@vitest/coverage-v8": "^5.0.3",
33
+ "knip": "^6.40.0",
34
+ "lint-staged": "^17.6.0",
35
+ "oxfmt": "^0.72.0",
36
+ "oxlint": "^1.87.0",
37
+ "oxlint-tsgolint": "^7.0.2003",
38
+ "simple-git-hooks": "^2.14.0",
39
+ "typescript": "^7.0.2",
40
+ "undici": "^8.11.2",
41
+ "vitest": "^5.0.3"
42
+ },
43
+ "peerDependencies": {
44
+ "vitest": ">=3"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "vitest": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "simple-git-hooks": {
52
+ "pre-commit": "pnpm exec lint-staged",
53
+ "pre-push": "pnpm typecheck"
54
+ },
55
+ "lint-staged": {
56
+ "*.{ts,mts,js,mjs}": [
57
+ "oxlint --fix --deny-warnings",
58
+ "oxfmt --no-error-on-unmatched-pattern"
59
+ ],
60
+ "*.{json,jsonc,md,yml,yaml}": "oxfmt --no-error-on-unmatched-pattern"
61
+ },
62
+ "engines": {
63
+ "node": ">=24"
64
+ },
65
+ "scripts": {
66
+ "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm knip && pnpm test",
67
+ "format": "oxfmt",
68
+ "format:check": "oxfmt --check",
69
+ "knip": "knip",
70
+ "lint": "oxlint --deny-warnings",
71
+ "lint:fix": "oxlint --fix",
72
+ "skills:install": "dotagents --project install",
73
+ "test": "vitest run",
74
+ "test:coverage": "vitest run --coverage",
75
+ "typecheck": "tsc -p ."
76
+ }
6
77
  }