@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/LICENSE +201 -0
- package/README.md +561 -2
- package/package.json +74 -3
- package/src/action.ts +81 -0
- package/src/addresses.ts +103 -0
- package/src/certificates.ts +169 -0
- package/src/cli.ts +35 -0
- package/src/client.ts +211 -0
- package/src/config.ts +141 -0
- package/src/gcs.ts +145 -0
- package/src/recorder.ts +464 -0
- package/src/recordings.ts +42 -0
- package/src/report.ts +55 -0
- package/src/request-key.ts +165 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +544 -0
- package/src/service.ts +572 -0
- package/src/store.ts +47 -0
- package/src/streams.ts +115 -0
- package/src/types.ts +130 -0
- package/src/usage.ts +120 -0
- package/src/values.ts +229 -0
- package/src/vitest-setup.ts +66 -0
- package/src/vitest.ts +108 -0
package/README.md
CHANGED
|
@@ -1,3 +1,562 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Roach
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|