@replayio-app-building/netlify-recorder 0.16.0 → 0.17.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 +11 -203
- package/dist/index.d.ts +182 -47
- package/dist/index.js +301 -136
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @replayio-app-building/netlify-recorder
|
|
2
2
|
|
|
3
|
-
Capture and replay Netlify function executions as [Replay](https://replay.io) recordings. This package intercepts outbound network calls and environment variable reads during handler execution, stores the captured data
|
|
3
|
+
Capture and replay Netlify function executions as [Replay](https://replay.io) recordings. This package intercepts outbound network calls and environment variable reads during handler execution, stores the captured data via the Netlify Recorder service, and can later reproduce the exact execution as a Replay recording for debugging and analysis.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,23 +8,11 @@ Capture and replay Netlify function executions as [Replay](https://replay.io) re
|
|
|
8
8
|
npm install @replayio-app-building/netlify-recorder
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
12
|
-
|
|
13
|
-
There are two ways to use this package:
|
|
14
|
-
|
|
15
|
-
- **Option A: Use the Netlify Recorder service (recommended)** — The service handles blob storage, request tracking, and recording creation. Your app just wraps its handlers and calls `remoteCallbacks()`. No database or container infrastructure needed.
|
|
16
|
-
|
|
17
|
-
- **Option B: Self-hosted** — You manage your own blob storage, database tables, and recording containers. Full control, but requires more setup.
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## Option A: Using the Netlify Recorder Service
|
|
22
|
-
|
|
23
|
-
The Netlify Recorder app (`https://netlify-recorder-bm4wmw.netlify.app`) provides a hosted service that stores captured request data, manages a pool of recording containers, and creates Replay recordings on demand. Your app needs zero database or infrastructure setup.
|
|
11
|
+
## Setup
|
|
24
12
|
|
|
25
13
|
### 1. Set required environment variables
|
|
26
14
|
|
|
27
|
-
`finishRequest` needs to know which repository, branch, and commit your deployed code belongs to. Set these
|
|
15
|
+
`finishRequest` needs to know which repository, branch, and commit your deployed code belongs to. Set these environment variables on your Netlify site:
|
|
28
16
|
|
|
29
17
|
| Variable | Description | How to set |
|
|
30
18
|
|---|---|---|
|
|
@@ -115,7 +103,7 @@ export default createRecordingRequestHandler(
|
|
|
115
103
|
|
|
116
104
|
### 3. Create recordings
|
|
117
105
|
|
|
118
|
-
When you want to turn a captured request into a Replay recording, POST to the service's `create-recording` endpoint with the request ID. If the request was created with a secret, you must include it:
|
|
106
|
+
When you want to turn a captured request into a Replay recording, POST to the Netlify Recorder service's `create-recording` endpoint with the request ID. If the request was created with a secret, you must include it:
|
|
119
107
|
|
|
120
108
|
```typescript
|
|
121
109
|
const response = await fetch(
|
|
@@ -132,7 +120,7 @@ const response = await fetch(
|
|
|
132
120
|
);
|
|
133
121
|
```
|
|
134
122
|
|
|
135
|
-
The service looks up the stored blob data, dispatches the work to a
|
|
123
|
+
The service looks up the stored blob data, dispatches the work to a recording container, and creates the recording.
|
|
136
124
|
|
|
137
125
|
If `webhookUrl` is provided, the service will POST the result to that URL when the recording completes (or fails):
|
|
138
126
|
|
|
@@ -225,7 +213,7 @@ Requests created without a secret remain accessible without one (backward compat
|
|
|
225
213
|
|
|
226
214
|
Store your secret as a `NETLIFY_RECORDER_SECRET` environment variable. To keep it secure:
|
|
227
215
|
|
|
228
|
-
1. **Netlify site environment:** Add `NETLIFY_RECORDER_SECRET` in your Netlify site's environment variables (Site settings
|
|
216
|
+
1. **Netlify site environment:** Add `NETLIFY_RECORDER_SECRET` in your Netlify site's environment variables (Site settings > Environment variables). This makes it available to all deployed functions.
|
|
229
217
|
|
|
230
218
|
2. **Branch-level secret** (for CI/development): If you're using the Netlify Recorder agent infrastructure, store the secret as a branch secret so deploy scripts and background agents can access it:
|
|
231
219
|
|
|
@@ -239,158 +227,6 @@ Use a strong random string (e.g. `openssl rand -base64 32`) and rotate it if com
|
|
|
239
227
|
|
|
240
228
|
---
|
|
241
229
|
|
|
242
|
-
## Option B: Self-Hosted
|
|
243
|
-
|
|
244
|
-
If you need full control, you can manage your own blob storage, database, and recording containers. This requires more setup but gives you complete ownership of the data pipeline.
|
|
245
|
-
|
|
246
|
-
### 1. Set required environment variables
|
|
247
|
-
|
|
248
|
-
Same as Option A — you must set `REPLAY_REPOSITORY_URL`, `COMMIT_SHA`, and `BRANCH_NAME` on your Netlify site. See the table in Option A, Step 1 above.
|
|
249
|
-
|
|
250
|
-
### 2. Wrap your Netlify function
|
|
251
|
-
|
|
252
|
-
Use `createRecordingRequestHandler` with custom callbacks that write to your own storage and database:
|
|
253
|
-
|
|
254
|
-
```typescript
|
|
255
|
-
import { createRecordingRequestHandler } from "@replayio-app-building/netlify-recorder";
|
|
256
|
-
|
|
257
|
-
const handler = createRecordingRequestHandler(
|
|
258
|
-
async (event) => {
|
|
259
|
-
const result = await myBusinessLogic();
|
|
260
|
-
|
|
261
|
-
return {
|
|
262
|
-
statusCode: 200,
|
|
263
|
-
headers: { "Content-Type": "application/json" },
|
|
264
|
-
body: JSON.stringify(result),
|
|
265
|
-
};
|
|
266
|
-
},
|
|
267
|
-
{
|
|
268
|
-
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
269
|
-
callbacks: {
|
|
270
|
-
uploadBlob: async (data) => {
|
|
271
|
-
// Upload the JSON string to your blob storage (S3, R2, etc.)
|
|
272
|
-
const res = await fetch("https://storage.example.com/upload", {
|
|
273
|
-
method: "PUT",
|
|
274
|
-
body: data,
|
|
275
|
-
});
|
|
276
|
-
const { url } = await res.json();
|
|
277
|
-
return url;
|
|
278
|
-
},
|
|
279
|
-
storeRequestData: async ({ blobUrl, commitSha, branchName, repositoryUrl, handlerPath, secret }) => {
|
|
280
|
-
const [row] = await sql`
|
|
281
|
-
INSERT INTO requests (blob_url, commit_sha, branch_name, repository_url, handler_path, secret, status)
|
|
282
|
-
VALUES (${blobUrl}, ${commitSha}, ${branchName}, ${repositoryUrl}, ${handlerPath}, ${secret}, 'captured')
|
|
283
|
-
RETURNING id
|
|
284
|
-
`;
|
|
285
|
-
return row.id;
|
|
286
|
-
},
|
|
287
|
-
},
|
|
288
|
-
}
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
export { handler };
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
### 3. Create a requests database table
|
|
295
|
-
|
|
296
|
-
```sql
|
|
297
|
-
CREATE TABLE IF NOT EXISTS requests (
|
|
298
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
299
|
-
blob_url TEXT,
|
|
300
|
-
commit_sha TEXT,
|
|
301
|
-
branch_name TEXT,
|
|
302
|
-
repository_url TEXT,
|
|
303
|
-
handler_path TEXT,
|
|
304
|
-
secret TEXT,
|
|
305
|
-
recording_id TEXT,
|
|
306
|
-
status TEXT NOT NULL DEFAULT 'captured'
|
|
307
|
-
CHECK (status IN ('captured', 'processing', 'recorded', 'failed')),
|
|
308
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
309
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
CREATE INDEX IF NOT EXISTS idx_requests_secret ON requests (secret) WHERE secret IS NOT NULL;
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
### 4. Create a background function to produce recordings
|
|
316
|
-
|
|
317
|
-
```typescript
|
|
318
|
-
import { ensureRequestRecording } from "@replayio-app-building/netlify-recorder";
|
|
319
|
-
import type { Handler } from "@netlify/functions";
|
|
320
|
-
|
|
321
|
-
const handler: Handler = async (event) => {
|
|
322
|
-
const { requestId } = JSON.parse(event.body ?? "{}");
|
|
323
|
-
|
|
324
|
-
const recordingId = await ensureRequestRecording(requestId, {
|
|
325
|
-
repositoryUrl: process.env.APP_REPOSITORY_URL!,
|
|
326
|
-
lookupRequest: async (id) => {
|
|
327
|
-
const [row] = await sql`
|
|
328
|
-
SELECT blob_url, commit_sha, branch_name, handler_path
|
|
329
|
-
FROM requests WHERE id = ${id}
|
|
330
|
-
`;
|
|
331
|
-
return {
|
|
332
|
-
blobUrl: row.blob_url,
|
|
333
|
-
commitSha: row.commit_sha,
|
|
334
|
-
branchName: row.branch_name ?? "main",
|
|
335
|
-
handlerPath: row.handler_path,
|
|
336
|
-
};
|
|
337
|
-
},
|
|
338
|
-
updateStatus: async (id, status, recordingId) => {
|
|
339
|
-
await sql`
|
|
340
|
-
UPDATE requests
|
|
341
|
-
SET status = ${status},
|
|
342
|
-
recording_id = ${recordingId ?? null},
|
|
343
|
-
updated_at = NOW()
|
|
344
|
-
WHERE id = ${id}
|
|
345
|
-
`;
|
|
346
|
-
},
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
return {
|
|
350
|
-
statusCode: 200,
|
|
351
|
-
body: JSON.stringify({ recordingId }),
|
|
352
|
-
};
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
export { handler };
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
### 5. Create a container script
|
|
359
|
-
|
|
360
|
-
This script runs inside the recording container under `replay-node`:
|
|
361
|
-
|
|
362
|
-
```typescript
|
|
363
|
-
// scripts/create-request-recording.ts
|
|
364
|
-
import { createRequestRecording } from "@replayio-app-building/netlify-recorder";
|
|
365
|
-
|
|
366
|
-
const args = process.argv.slice(2);
|
|
367
|
-
const blobUrl = args[args.indexOf("--blob-url") + 1]!;
|
|
368
|
-
const handlerPath = args[args.indexOf("--handler-path") + 1]!;
|
|
369
|
-
|
|
370
|
-
await createRequestRecording(blobUrl, handlerPath, {
|
|
371
|
-
method: "POST",
|
|
372
|
-
url: handlerPath,
|
|
373
|
-
headers: {},
|
|
374
|
-
});
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
### Required infrastructure
|
|
378
|
-
|
|
379
|
-
Self-hosted recording requires these environment variables:
|
|
380
|
-
|
|
381
|
-
| Variable | Description |
|
|
382
|
-
|---|---|
|
|
383
|
-
| `INFISICAL_CLIENT_ID` | Infisical service account client ID |
|
|
384
|
-
| `INFISICAL_CLIENT_SECRET` | Infisical service account client secret |
|
|
385
|
-
| `INFISICAL_PROJECT_ID` | Infisical project ID |
|
|
386
|
-
| `INFISICAL_ENVIRONMENT` | Infisical environment (e.g. `production`) |
|
|
387
|
-
| `FLY_API_TOKEN` | Fly.io API token for container management |
|
|
388
|
-
| `FLY_APP_NAME` | Fly.io app name for container deployment |
|
|
389
|
-
| `APP_REPOSITORY_URL` | Git repository URL for container cloning |
|
|
390
|
-
| `RECORD_REPLAY_API_KEY` | Replay API key for recording upload |
|
|
391
|
-
|
|
392
|
-
---
|
|
393
|
-
|
|
394
230
|
## Audit Log Support
|
|
395
231
|
|
|
396
232
|
The package automatically tracks database mutations (INSERT, UPDATE, DELETE) in an `audit_log` table and links each change to the Replay request that caused it. When your handler is wrapped with `createRecordingRequestHandler`, all Neon SQL queries are automatically tagged with the request ID — no changes to your SQL code required.
|
|
@@ -484,7 +320,7 @@ When `context.waitUntil` is not available (v1 handlers or missing context), the
|
|
|
484
320
|
|
|
485
321
|
**Parameters:**
|
|
486
322
|
- `handler` — Your async handler function `(event, context?) => Promise<{ statusCode, headers?, body? }>`
|
|
487
|
-
- `options.callbacks` —
|
|
323
|
+
- `options.callbacks` — `remoteCallbacks(serviceUrl)` for sending captured data to the Netlify Recorder service
|
|
488
324
|
- `options.handlerPath` — Path to the handler file (used for recording metadata)
|
|
489
325
|
- `options.commitSha` — Override `COMMIT_SHA` env var
|
|
490
326
|
- `options.branchName` — Override `BRANCH_NAME` env var
|
|
@@ -520,7 +356,7 @@ Logs a `console.warn` when the total duration exceeds 2 seconds or when individu
|
|
|
520
356
|
|
|
521
357
|
**Parameters:**
|
|
522
358
|
- `requestContext` — The context returned by `startRequest`
|
|
523
|
-
- `callbacks` —
|
|
359
|
+
- `callbacks` — `remoteCallbacks(serviceUrl)` for sending captured data to the Netlify Recorder service
|
|
524
360
|
- `response` — The handler's response object (`{ statusCode, headers?, body? }`)
|
|
525
361
|
- `options.handlerPath` — Path to the handler file (used for recording metadata)
|
|
526
362
|
- `options.commitSha` — Override `COMMIT_SHA` env var
|
|
@@ -531,30 +367,11 @@ Logs a `console.warn` when the total duration exceeds 2 seconds or when individu
|
|
|
531
367
|
|
|
532
368
|
### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`
|
|
533
369
|
|
|
534
|
-
Creates callbacks for `finishRequest` that send captured data to the Netlify Recorder service. The service handles blob storage and request tracking
|
|
370
|
+
Creates callbacks for `finishRequest` that send captured data to the Netlify Recorder service. The service handles blob storage and request tracking.
|
|
535
371
|
|
|
536
372
|
**Parameters:**
|
|
537
373
|
- `serviceUrl` — Base URL of the Netlify Recorder service (e.g. `"https://netlify-recorder-bm4wmw.netlify.app"`)
|
|
538
374
|
|
|
539
|
-
### `ensureRequestRecording(requestId, options): Promise<string>`
|
|
540
|
-
|
|
541
|
-
Spawns a container via `@replayio/app-building` to create a Replay recording from captured request data. Returns the recording ID. Only needed for self-hosted setups (Option B).
|
|
542
|
-
|
|
543
|
-
**Parameters:**
|
|
544
|
-
- `requestId` — The request to create a recording for
|
|
545
|
-
- `options.repositoryUrl` — Git repository URL for the container to clone
|
|
546
|
-
- `options.lookupRequest(id)` — Fetches `{ blobUrl, commitSha, branchName, handlerPath }` from the database
|
|
547
|
-
- `options.updateStatus(id, status, recordingId?)` — Updates the request status in the database
|
|
548
|
-
|
|
549
|
-
### `createRequestRecording(blobUrl, handlerPath, requestInfo): Promise<void>`
|
|
550
|
-
|
|
551
|
-
Called inside a container running under `replay-node`. Downloads the captured data blob, installs replay-mode interceptors (which return pre-recorded responses instead of making real calls), and executes the original handler so replay-node can record the execution. Only needed for self-hosted setups (Option B).
|
|
552
|
-
|
|
553
|
-
**Parameters:**
|
|
554
|
-
- `blobUrl` — URL to the captured data blob
|
|
555
|
-
- `handlerPath` — Path to the handler module to execute
|
|
556
|
-
- `requestInfo` — The original request info to replay
|
|
557
|
-
|
|
558
375
|
### `databaseAuditEnsureLogTable(sql): Promise<void>`
|
|
559
376
|
|
|
560
377
|
Creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger function (`audit_trigger_function`). Call once during schema initialization.
|
|
@@ -579,8 +396,6 @@ Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
|
579
396
|
|
|
580
397
|
## Environment Variables
|
|
581
398
|
|
|
582
|
-
### Required for all setups (read by `finishRequest`)
|
|
583
|
-
|
|
584
399
|
These must be set on your Netlify site. Your deploy script should resolve them from git and push them to the Netlify environment before deploying. `finishRequest` will throw an error if any are missing.
|
|
585
400
|
|
|
586
401
|
| Variable | Description | How to resolve |
|
|
@@ -590,15 +405,8 @@ These must be set on your Netlify site. Your deploy script should resolve them f
|
|
|
590
405
|
| `REPLAY_REPOSITORY_URL` | Git repository URL (no embedded credentials) | `git remote get-url origin` (strip tokens) |
|
|
591
406
|
| `NETLIFY_RECORDER_SECRET` | Secret for access control (strongly recommended) | `openssl rand -base64 32` — store in Netlify site env vars |
|
|
592
407
|
|
|
593
|
-
### Required for self-hosted recording (Option B)
|
|
594
|
-
|
|
595
|
-
| Variable | Description |
|
|
596
|
-
|---|---|
|
|
597
|
-
| `RECORD_REPLAY_API_KEY` | Replay API key for uploading recordings |
|
|
598
|
-
| `APP_REPOSITORY_URL` | Git repository URL for container cloning |
|
|
599
|
-
|
|
600
408
|
## How It Works
|
|
601
409
|
|
|
602
|
-
1. **Capture phase** (`createRecordingRequestHandler` or `startRequest` / `finishRequest`): When a Netlify function handles a request, the recording layer patches `globalThis.fetch` and `process.env` with Proxies that record every outbound network call and environment variable read. When the handler completes, the originals are restored, the captured data is serialized to JSON, and sent to
|
|
410
|
+
1. **Capture phase** (`createRecordingRequestHandler` or `startRequest` / `finishRequest`): When a Netlify function handles a request, the recording layer patches `globalThis.fetch` and `process.env` with Proxies that record every outbound network call and environment variable read. When the handler completes, the originals are restored, the captured data is serialized to JSON, and sent to the Netlify Recorder service via `remoteCallbacks`.
|
|
603
411
|
|
|
604
|
-
2. **Recording phase**: The
|
|
412
|
+
2. **Recording phase**: The Netlify Recorder service dispatches the captured data to a recording container. Inside the container, the captured blob is downloaded, replay-mode interceptors return the pre-recorded responses instead of making real calls, and the original handler is re-executed under `replay-node`. Since replay-node records all execution, this produces a Replay recording of the exact same handler execution.
|
package/dist/index.d.ts
CHANGED
|
@@ -93,24 +93,66 @@ interface BlobData {
|
|
|
93
93
|
handlerResponse?: HandlerResponse$1;
|
|
94
94
|
}
|
|
95
95
|
interface FinishRequestCallbacks {
|
|
96
|
+
/** Uploads serialized captured data and returns the blob URL. */
|
|
97
|
+
uploadBlob: (data: string) => Promise<string>;
|
|
96
98
|
/**
|
|
97
|
-
* Stores
|
|
99
|
+
* Stores request metadata in the database and returns the request ID.
|
|
98
100
|
*
|
|
99
101
|
* When `requestId` is provided (from `createRecordingRequestHandler`'s
|
|
100
102
|
* `waitUntil` flow), the callback should use it as the row ID so the
|
|
101
|
-
* client-facing header and the stored record match.
|
|
102
|
-
* callback generates its own ID.
|
|
103
|
+
* client-facing header and the stored record match. When omitted, the
|
|
104
|
+
* callback generates its own ID (backward-compatible).
|
|
103
105
|
*/
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
storeRequestData: (data: {
|
|
107
|
+
blobUrl: string;
|
|
106
108
|
commitSha: string;
|
|
107
109
|
branchName: string;
|
|
108
110
|
repositoryUrl: string;
|
|
109
111
|
handlerPath: string;
|
|
110
112
|
/** Pre-generated request ID. Use as the row ID when provided. */
|
|
111
113
|
requestId?: string;
|
|
114
|
+
/** Optional secret that restricts access to this request. */
|
|
115
|
+
secret?: string;
|
|
112
116
|
}) => Promise<string>;
|
|
113
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Infrastructure credentials required to start a recording container.
|
|
120
|
+
*
|
|
121
|
+
* The container is started on Fly.io via the `@replayio/app-building` package.
|
|
122
|
+
* It requires Infisical credentials (for secrets management inside the
|
|
123
|
+
* container) and a Fly.io token + app name.
|
|
124
|
+
*
|
|
125
|
+
* These must be set as environment variables on the Netlify site:
|
|
126
|
+
* INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET,
|
|
127
|
+
* INFISICAL_PROJECT_ID, INFISICAL_ENVIRONMENT,
|
|
128
|
+
* FLY_API_TOKEN, FLY_APP_NAME
|
|
129
|
+
*/
|
|
130
|
+
interface ContainerInfraConfig {
|
|
131
|
+
infisicalClientId: string;
|
|
132
|
+
infisicalClientSecret: string;
|
|
133
|
+
infisicalProjectId: string;
|
|
134
|
+
infisicalEnvironment: string;
|
|
135
|
+
flyToken: string;
|
|
136
|
+
flyApp: string;
|
|
137
|
+
}
|
|
138
|
+
interface EnsureRecordingOptions {
|
|
139
|
+
repositoryUrl: string;
|
|
140
|
+
/** Infrastructure credentials for starting the recording container. */
|
|
141
|
+
infraConfig?: ContainerInfraConfig;
|
|
142
|
+
/** Webhook URL the container can POST log entries to (optional). */
|
|
143
|
+
webhookUrl?: string;
|
|
144
|
+
/** Looks up request metadata by ID. */
|
|
145
|
+
lookupRequest: (requestId: string) => Promise<{
|
|
146
|
+
blobUrl: string;
|
|
147
|
+
commitSha: string;
|
|
148
|
+
branchName: string;
|
|
149
|
+
handlerPath: string;
|
|
150
|
+
}>;
|
|
151
|
+
/** Updates the request status (and optionally recording ID) in the database. */
|
|
152
|
+
updateStatus: (requestId: string, status: string, recordingId?: string) => Promise<void>;
|
|
153
|
+
/** Optional callback for the caller to emit structured log entries. */
|
|
154
|
+
onLog?: (level: "info" | "warn" | "error", message: string) => Promise<void>;
|
|
155
|
+
}
|
|
114
156
|
|
|
115
157
|
/**
|
|
116
158
|
* Called at the beginning of a Netlify handler execution.
|
|
@@ -170,16 +212,32 @@ interface FinishRequestOptions {
|
|
|
170
212
|
repositoryUrl?: string;
|
|
171
213
|
/**
|
|
172
214
|
* Pre-generated request ID. When provided, this ID is passed to the
|
|
173
|
-
* `
|
|
215
|
+
* `storeRequestData` callback so the stored row matches the ID already
|
|
174
216
|
* sent to the client in the `X-Replay-Request-Id` header.
|
|
217
|
+
*
|
|
218
|
+
* Used by `createRecordingRequestHandler` in the `waitUntil` flow where
|
|
219
|
+
* the response is returned before `finishRequest` runs.
|
|
175
220
|
*/
|
|
176
221
|
requestId?: string;
|
|
222
|
+
/**
|
|
223
|
+
* Optional secret string. When set, the stored request is only
|
|
224
|
+
* accessible via API calls that provide the same secret value.
|
|
225
|
+
*/
|
|
226
|
+
secret?: string;
|
|
177
227
|
}
|
|
178
228
|
/**
|
|
179
229
|
* Called at the end of the handler execution.
|
|
180
230
|
* Restores original globals, serializes all captured data,
|
|
181
|
-
*
|
|
182
|
-
* X-Replay-Request-Id header.
|
|
231
|
+
* uploads it as a JSON blob via the provided callback,
|
|
232
|
+
* stores the request metadata, and sets the X-Replay-Request-Id header.
|
|
233
|
+
*
|
|
234
|
+
* **Important:** The returned response includes the `X-Replay-Request-Id`
|
|
235
|
+
* header. You must send the returned response to the client — not the
|
|
236
|
+
* original response object you passed in.
|
|
237
|
+
*
|
|
238
|
+
* Logs a warning to `console.warn` when the total finishRequest time or
|
|
239
|
+
* individual callback steps exceed their thresholds, to help diagnose
|
|
240
|
+
* slow blob uploads or database writes.
|
|
183
241
|
*/
|
|
184
242
|
declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
|
|
185
243
|
|
|
@@ -196,15 +254,80 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
|
|
|
196
254
|
*
|
|
197
255
|
* Automatically calls `startRequest` before the handler and `finishRequest`
|
|
198
256
|
* after, capturing all outbound network calls and environment variable reads.
|
|
199
|
-
*
|
|
257
|
+
* On error, interceptors are cleaned up and the error is re-thrown.
|
|
200
258
|
*
|
|
201
259
|
* **Response timing:** When the Netlify Functions v2 `context` object is
|
|
202
260
|
* available (with `waitUntil`), the response is returned to the client
|
|
203
261
|
* **immediately** with a pre-generated `X-Replay-Request-Id` header. The
|
|
204
|
-
*
|
|
262
|
+
* blob upload and metadata storage continue in the background via
|
|
263
|
+
* `context.waitUntil()`. This avoids adding latency to the client response.
|
|
264
|
+
*
|
|
265
|
+
* When `context.waitUntil` is not available (v1 handlers or missing context),
|
|
266
|
+
* the wrapper falls back to awaiting `finishRequest` before returning.
|
|
267
|
+
*
|
|
268
|
+
* For v2 handlers the request body is read from a clone internally — your
|
|
269
|
+
* handler still receives the original request with an unconsumed body.
|
|
270
|
+
*
|
|
271
|
+
* @example v1 handler (NetlifyEvent)
|
|
272
|
+
* ```typescript
|
|
273
|
+
* import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
|
|
274
|
+
*
|
|
275
|
+
* const handler = createRecordingRequestHandler(
|
|
276
|
+
* async (event) => {
|
|
277
|
+
* const result = await myBusinessLogic(event.body);
|
|
278
|
+
* return {
|
|
279
|
+
* statusCode: 200,
|
|
280
|
+
* headers: { "Content-Type": "application/json" },
|
|
281
|
+
* body: JSON.stringify(result),
|
|
282
|
+
* };
|
|
283
|
+
* },
|
|
284
|
+
* {
|
|
285
|
+
* callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
|
|
286
|
+
* handlerPath: "netlify/functions/my-handler",
|
|
287
|
+
* }
|
|
288
|
+
* );
|
|
289
|
+
*
|
|
290
|
+
* export { handler };
|
|
291
|
+
* ```
|
|
292
|
+
*
|
|
293
|
+
* @example v2 handler (Web API Request)
|
|
294
|
+
* ```typescript
|
|
295
|
+
* import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
|
|
296
|
+
*
|
|
297
|
+
* export default createRecordingRequestHandler(
|
|
298
|
+
* async (req) => {
|
|
299
|
+
* // Body is still available — startRequest reads from a clone
|
|
300
|
+
* const body = await (req as Request).json();
|
|
301
|
+
* const result = await myBusinessLogic(body);
|
|
302
|
+
* return {
|
|
303
|
+
* statusCode: 200,
|
|
304
|
+
* headers: { "Content-Type": "application/json" },
|
|
305
|
+
* body: JSON.stringify(result),
|
|
306
|
+
* };
|
|
307
|
+
* },
|
|
308
|
+
* {
|
|
309
|
+
* callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
|
|
310
|
+
* handlerPath: "netlify/functions/my-handler",
|
|
311
|
+
* }
|
|
312
|
+
* );
|
|
313
|
+
* ```
|
|
205
314
|
*/
|
|
206
315
|
declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>;
|
|
207
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Creates `FinishRequestCallbacks` that send captured data to a remote
|
|
319
|
+
* Netlify Recorder service. This removes the need for the consuming app
|
|
320
|
+
* to set up its own blob storage or database tables.
|
|
321
|
+
*
|
|
322
|
+
* The callbacks upload blob data and store request metadata via the
|
|
323
|
+
* service's `/api/store-request` endpoint, which handles UploadThing
|
|
324
|
+
* storage and database insertion.
|
|
325
|
+
*
|
|
326
|
+
* @param serviceUrl - Base URL of the Netlify Recorder service
|
|
327
|
+
* (e.g. "https://netlify-recorder-bm4wmw.netlify.app")
|
|
328
|
+
*/
|
|
329
|
+
declare function remoteCallbacks(serviceUrl: string): FinishRequestCallbacks;
|
|
330
|
+
|
|
208
331
|
/**
|
|
209
332
|
* Redacts sensitive environment variable values from blob data.
|
|
210
333
|
*
|
|
@@ -225,6 +348,28 @@ declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | N
|
|
|
225
348
|
*/
|
|
226
349
|
declare function redactBlobData(blobData: BlobData): BlobData;
|
|
227
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Called by a background function to convert a request ID into a Replay recording ID.
|
|
353
|
+
*
|
|
354
|
+
* The function:
|
|
355
|
+
* 1. Looks up request metadata (blob URL, commit, handler path).
|
|
356
|
+
* 2. Delegates to `spawnRecordingContainer` which starts a detached Fly.io
|
|
357
|
+
* container, runs the recording script under replay-node, and uploads
|
|
358
|
+
* the resulting recording.
|
|
359
|
+
* 3. Updates the request status with the recording ID.
|
|
360
|
+
*
|
|
361
|
+
* **Required infrastructure:** Infisical credentials and a Fly.io token/app.
|
|
362
|
+
* See `ContainerInfraConfig` in types.ts for details. When these are not
|
|
363
|
+
* configured the function fails with an actionable error message listing
|
|
364
|
+
* the missing environment variables.
|
|
365
|
+
*/
|
|
366
|
+
declare function ensureRequestRecording(requestId: string, options: EnsureRecordingOptions): Promise<string>;
|
|
367
|
+
/**
|
|
368
|
+
* Reads infrastructure config from environment variables.
|
|
369
|
+
* Returns undefined if any required variable is missing.
|
|
370
|
+
*/
|
|
371
|
+
declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
|
|
372
|
+
|
|
228
373
|
interface RecordingResult {
|
|
229
374
|
/** Whether a response mismatch was detected between capture and replay. */
|
|
230
375
|
responseMismatch: boolean;
|
|
@@ -249,49 +394,39 @@ interface RecordingResult {
|
|
|
249
394
|
*/
|
|
250
395
|
declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
|
|
251
396
|
|
|
252
|
-
type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
|
|
253
|
-
interface BackendRequest {
|
|
254
|
-
id: string;
|
|
255
|
-
blob_data: string;
|
|
256
|
-
handler_path: string;
|
|
257
|
-
commit_sha: string;
|
|
258
|
-
branch_name: string;
|
|
259
|
-
repository_url: string | null;
|
|
260
|
-
status: string;
|
|
261
|
-
recording_id: string | null;
|
|
262
|
-
error_message: string | null;
|
|
263
|
-
created_at: string;
|
|
264
|
-
updated_at: string;
|
|
265
|
-
}
|
|
266
397
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* Each package client stores captured request data in its own database
|
|
270
|
-
* using this table. The blob data (captured network calls, env reads, etc.)
|
|
271
|
-
* is stored directly in the `blob_data` column rather than in external
|
|
272
|
-
* blob storage.
|
|
398
|
+
* Options for spawning a recording container from a blob URL.
|
|
399
|
+
* This is the core building block — it knows nothing about request IDs or databases.
|
|
273
400
|
*/
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
401
|
+
interface SpawnRecordingContainerOptions {
|
|
402
|
+
/** URL (or data: URI) of the captured request blob JSON. */
|
|
403
|
+
blobUrl: string;
|
|
404
|
+
/** Handler file path relative to the app root (e.g. "netlify/functions/generate-haiku"). */
|
|
278
405
|
handlerPath: string;
|
|
406
|
+
/** Git commit SHA to check out inside the container. */
|
|
279
407
|
commitSha: string;
|
|
408
|
+
/** Git branch to clone. */
|
|
280
409
|
branchName: string;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
410
|
+
/** Git repository URL for the app. */
|
|
411
|
+
repositoryUrl: string;
|
|
412
|
+
/** Infrastructure credentials for Fly.io + Infisical. */
|
|
413
|
+
infraConfig: ContainerInfraConfig;
|
|
414
|
+
/** Optional webhook URL the container can POST log events to. */
|
|
415
|
+
logWebhookUrl?: string;
|
|
416
|
+
/** Optional callback for structured log entries. */
|
|
417
|
+
onLog?: (level: "info" | "warn" | "error", message: string) => Promise<void>;
|
|
418
|
+
}
|
|
290
419
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
420
|
+
* Spawns a detached Fly.io container that:
|
|
421
|
+
* 1. Clones the app repo at the correct branch
|
|
422
|
+
* 2. Checks out the exact commit
|
|
423
|
+
* 3. Runs `scripts/create-request-recording.ts` under replay-node
|
|
424
|
+
* 4. Uploads the resulting recording
|
|
425
|
+
* 5. Outputs the recording ID
|
|
426
|
+
*
|
|
427
|
+
* Returns the recording ID on success, or throws on failure.
|
|
293
428
|
*/
|
|
294
|
-
declare function
|
|
429
|
+
declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
|
|
295
430
|
|
|
296
431
|
type SqlFunction = (...args: any[]) => Promise<any[]>;
|
|
297
432
|
/**
|
|
@@ -316,4 +451,4 @@ declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<str
|
|
|
316
451
|
|
|
317
452
|
declare function getCurrentRequestId(): string | null;
|
|
318
453
|
|
|
319
|
-
export { type
|
|
454
|
+
export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
|
package/dist/index.js
CHANGED
|
@@ -490,6 +490,7 @@ function redactBlobData(blobData) {
|
|
|
490
490
|
|
|
491
491
|
// src/finishRequest.ts
|
|
492
492
|
var SLOW_THRESHOLD_MS = 2e3;
|
|
493
|
+
var SLOW_STEP_THRESHOLD_MS = 1e3;
|
|
493
494
|
async function finishRequest(requestContext, callbacks, response, options) {
|
|
494
495
|
const finishStart = Date.now();
|
|
495
496
|
requestContext.cleanup();
|
|
@@ -534,20 +535,34 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
534
535
|
};
|
|
535
536
|
const blobData = redactBlobData(rawBlobData);
|
|
536
537
|
const blobContent = JSON.stringify(blobData);
|
|
538
|
+
const uploadStart = Date.now();
|
|
539
|
+
const blobUrl = await callbacks.uploadBlob(blobContent);
|
|
540
|
+
const uploadDuration = Date.now() - uploadStart;
|
|
541
|
+
if (uploadDuration > SLOW_STEP_THRESHOLD_MS) {
|
|
542
|
+
console.warn(
|
|
543
|
+
`netlify-recorder: uploadBlob took ${uploadDuration}ms (handler: ${handlerPath})`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
537
546
|
const storeStart = Date.now();
|
|
538
|
-
const storedRequestId = await callbacks.
|
|
539
|
-
|
|
547
|
+
const storedRequestId = await callbacks.storeRequestData({
|
|
548
|
+
blobUrl,
|
|
540
549
|
commitSha,
|
|
541
550
|
branchName,
|
|
542
551
|
repositoryUrl,
|
|
543
552
|
handlerPath,
|
|
544
|
-
requestId: options?.requestId
|
|
553
|
+
requestId: options?.requestId,
|
|
554
|
+
secret: options?.secret
|
|
545
555
|
});
|
|
546
556
|
const storeDuration = Date.now() - storeStart;
|
|
557
|
+
if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
|
|
558
|
+
console.warn(
|
|
559
|
+
`netlify-recorder: storeRequestData took ${storeDuration}ms (handler: ${handlerPath})`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
547
562
|
const totalDuration = Date.now() - finishStart;
|
|
548
563
|
if (totalDuration > SLOW_THRESHOLD_MS) {
|
|
549
564
|
console.warn(
|
|
550
|
-
`netlify-recorder: finishRequest took ${totalDuration}ms total (store: ${storeDuration}ms, handler: ${handlerPath})`
|
|
565
|
+
`netlify-recorder: finishRequest took ${totalDuration}ms total (upload: ${uploadDuration}ms, store: ${storeDuration}ms, handler: ${handlerPath})`
|
|
551
566
|
);
|
|
552
567
|
}
|
|
553
568
|
return {
|
|
@@ -603,6 +618,284 @@ function createRecordingRequestHandler(handler, options) {
|
|
|
603
618
|
};
|
|
604
619
|
}
|
|
605
620
|
|
|
621
|
+
// src/remoteCallbacks.ts
|
|
622
|
+
function remoteCallbacks(serviceUrl) {
|
|
623
|
+
const base = serviceUrl.replace(/\/+$/, "");
|
|
624
|
+
let pendingBlobData;
|
|
625
|
+
return {
|
|
626
|
+
uploadBlob: async (data) => {
|
|
627
|
+
pendingBlobData = data;
|
|
628
|
+
return "__pending__";
|
|
629
|
+
},
|
|
630
|
+
storeRequestData: async (metadata) => {
|
|
631
|
+
const blobData = pendingBlobData;
|
|
632
|
+
pendingBlobData = void 0;
|
|
633
|
+
if (!blobData) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
"remoteCallbacks: uploadBlob must be called before storeRequestData"
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
const res = await fetch(
|
|
639
|
+
`${base}/api/store-request`,
|
|
640
|
+
{
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: { "Content-Type": "application/json" },
|
|
643
|
+
body: JSON.stringify({
|
|
644
|
+
blobData,
|
|
645
|
+
handlerPath: metadata.handlerPath,
|
|
646
|
+
commitSha: metadata.commitSha,
|
|
647
|
+
branchName: metadata.branchName,
|
|
648
|
+
repositoryUrl: metadata.repositoryUrl,
|
|
649
|
+
requestId: metadata.requestId,
|
|
650
|
+
secret: metadata.secret
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
);
|
|
654
|
+
if (!res.ok) {
|
|
655
|
+
const errBody = await res.text().catch(() => "(unreadable)");
|
|
656
|
+
throw new Error(
|
|
657
|
+
`Netlify Recorder store-request failed: ${res.status} ${errBody}`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
const result = await res.json();
|
|
661
|
+
return result.requestId;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/spawnRecordingContainer.ts
|
|
667
|
+
async function spawnRecordingContainer(options) {
|
|
668
|
+
const {
|
|
669
|
+
blobUrl,
|
|
670
|
+
handlerPath,
|
|
671
|
+
commitSha,
|
|
672
|
+
branchName,
|
|
673
|
+
repositoryUrl,
|
|
674
|
+
infraConfig,
|
|
675
|
+
logWebhookUrl,
|
|
676
|
+
onLog
|
|
677
|
+
} = options;
|
|
678
|
+
const emit = async (level, message) => {
|
|
679
|
+
if (onLog) {
|
|
680
|
+
try {
|
|
681
|
+
await onLog(level, message);
|
|
682
|
+
} catch {
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
await emit("info", "Logging in to Infisical");
|
|
687
|
+
const {
|
|
688
|
+
infisicalLogin,
|
|
689
|
+
startContainer,
|
|
690
|
+
FileContainerRegistry,
|
|
691
|
+
httpGet,
|
|
692
|
+
httpOptsFor
|
|
693
|
+
} = await import("@replayio/app-building");
|
|
694
|
+
const infisicalToken = await infisicalLogin(
|
|
695
|
+
infraConfig.infisicalClientId,
|
|
696
|
+
infraConfig.infisicalClientSecret
|
|
697
|
+
);
|
|
698
|
+
const infisicalConfig = {
|
|
699
|
+
token: infisicalToken,
|
|
700
|
+
projectId: infraConfig.infisicalProjectId,
|
|
701
|
+
environment: infraConfig.infisicalEnvironment
|
|
702
|
+
};
|
|
703
|
+
const registry = new FileContainerRegistry("/tmp/netlify-recorder-containers.json");
|
|
704
|
+
const initialPrompt = [
|
|
705
|
+
`IMPORTANT: Follow these steps EXACTLY. Run each command as shown. Print ALL output.`,
|
|
706
|
+
`Do NOT explore the codebase, read AGENTS.md, or deviate from these steps.`,
|
|
707
|
+
`Do NOT attempt to debug, fix, shim, or work around ANY errors. If a command fails,`,
|
|
708
|
+
`print the full error output and move on to the next step. Errors during handler replay`,
|
|
709
|
+
`(like "No more recorded network calls" or DB errors) are EXPECTED and harmless.`,
|
|
710
|
+
``,
|
|
711
|
+
`=== Step 1: Install dependencies ===`,
|
|
712
|
+
`cd /repo/apps/netlify-recorder && npm install 2>&1`,
|
|
713
|
+
``,
|
|
714
|
+
`=== Step 2: Checkout the exact commit ===`,
|
|
715
|
+
`git fetch origin ${commitSha} 2>&1 || git fetch --all 2>&1`,
|
|
716
|
+
`git checkout ${commitSha} 2>&1`,
|
|
717
|
+
``,
|
|
718
|
+
`=== Step 3: Verify recording script exists ===`,
|
|
719
|
+
`ls -la /repo/apps/netlify-recorder/scripts/create-request-recording.ts`,
|
|
720
|
+
`If the file does NOT exist, print "ERROR: create-request-recording.ts not found" and STOP.`,
|
|
721
|
+
``,
|
|
722
|
+
`=== Step 4: Pre-compile for replay-node (Node v16) ===`,
|
|
723
|
+
`replay-node is Node v16 \u2014 it cannot run TypeScript or use modern APIs directly.`,
|
|
724
|
+
`You MUST compile everything with esbuild first. Run these commands exactly:`,
|
|
725
|
+
``,
|
|
726
|
+
`# Install undici for web API polyfills (fetch, Headers, Response):`,
|
|
727
|
+
`cd /repo/apps/netlify-recorder && npm install undici@5 2>&1`,
|
|
728
|
+
``,
|
|
729
|
+
`# Create the polyfill loader:`,
|
|
730
|
+
`cat > /tmp/web-polyfill.cjs << 'POLYFILL'`,
|
|
731
|
+
`try {`,
|
|
732
|
+
` var u = require("undici");`,
|
|
733
|
+
` if (!globalThis.fetch) globalThis.fetch = u.fetch;`,
|
|
734
|
+
` if (!globalThis.Headers) globalThis.Headers = u.Headers;`,
|
|
735
|
+
` if (!globalThis.Response) globalThis.Response = u.Response;`,
|
|
736
|
+
` if (!globalThis.Request) globalThis.Request = u.Request;`,
|
|
737
|
+
`} catch(e) { console.error("polyfill warning:", e.message); }`,
|
|
738
|
+
`POLYFILL`,
|
|
739
|
+
``,
|
|
740
|
+
`# Compile the recording script (bundles all local TS dependencies):`,
|
|
741
|
+
`npx esbuild scripts/create-request-recording.ts \\`,
|
|
742
|
+
` --bundle --platform=node --target=node16 --format=cjs \\`,
|
|
743
|
+
` --outfile=/tmp/create-recording.cjs 2>&1`,
|
|
744
|
+
``,
|
|
745
|
+
`# Compile the handler (bundles everything including node_modules):`,
|
|
746
|
+
`npx esbuild ${handlerPath}.ts \\`,
|
|
747
|
+
` --bundle --platform=node --target=node16 --format=cjs \\`,
|
|
748
|
+
` --outfile=/tmp/handler.cjs 2>&1`,
|
|
749
|
+
``,
|
|
750
|
+
`=== Step 5: Run under replay-node ===`,
|
|
751
|
+
`This MUST use replay-node so the execution is recorded. Run exactly:`,
|
|
752
|
+
``,
|
|
753
|
+
`cd /repo/apps/netlify-recorder && npx @replayio/node \\`,
|
|
754
|
+
` -r /tmp/web-polyfill.cjs /tmp/create-recording.cjs \\`,
|
|
755
|
+
` --blob-url '${blobUrl}' \\`,
|
|
756
|
+
` --handler-path '/tmp/handler.cjs' 2>&1`,
|
|
757
|
+
``,
|
|
758
|
+
`The output will show captured data being replayed. Errors like "No more recorded`,
|
|
759
|
+
`network calls" or "DATABASE_URL" errors are EXPECTED \u2014 they come from post-handler`,
|
|
760
|
+
`DB operations that were not in the original blob. Do NOT try to fix these.`,
|
|
761
|
+
``,
|
|
762
|
+
`=== Step 6: Upload the recording ===`,
|
|
763
|
+
`exec-secrets RECORD_REPLAY_API_KEY -- npx replayio upload --all 2>&1`,
|
|
764
|
+
``,
|
|
765
|
+
`Find the recording ID (UUID) in the upload output and print:`,
|
|
766
|
+
` recording: <recording-id>`,
|
|
767
|
+
``,
|
|
768
|
+
`Then output <DONE>.`
|
|
769
|
+
].join("\n");
|
|
770
|
+
await emit("info", "Starting detached container on Fly.io");
|
|
771
|
+
const state = await startContainer(
|
|
772
|
+
{
|
|
773
|
+
infisical: infisicalConfig,
|
|
774
|
+
registry,
|
|
775
|
+
flyToken: infraConfig.flyToken,
|
|
776
|
+
flyApp: infraConfig.flyApp,
|
|
777
|
+
detached: true,
|
|
778
|
+
initialPrompt,
|
|
779
|
+
webhookUrl: logWebhookUrl
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
repoUrl: repositoryUrl,
|
|
783
|
+
cloneBranch: branchName
|
|
784
|
+
}
|
|
785
|
+
);
|
|
786
|
+
await emit("info", `Container started: ${state.containerName} at ${state.baseUrl}`);
|
|
787
|
+
const maxWaitMs = 10 * 60 * 1e3;
|
|
788
|
+
const pollIntervalMs = 1e4;
|
|
789
|
+
const deadline = Date.now() + maxWaitMs;
|
|
790
|
+
let containerDone = false;
|
|
791
|
+
while (Date.now() < deadline) {
|
|
792
|
+
try {
|
|
793
|
+
const status = await httpGet(`${state.baseUrl}/status`, httpOptsFor(state));
|
|
794
|
+
if (status?.state === "stopped" || status?.state === "stopping") {
|
|
795
|
+
containerDone = true;
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
} catch {
|
|
799
|
+
containerDone = true;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
803
|
+
}
|
|
804
|
+
if (!containerDone) {
|
|
805
|
+
await emit("warn", "Container did not finish within 10 minutes");
|
|
806
|
+
}
|
|
807
|
+
let recordingId = null;
|
|
808
|
+
try {
|
|
809
|
+
const logs = await httpGet(`${state.baseUrl}/logs?offset=0`, httpOptsFor(state));
|
|
810
|
+
if (typeof logs === "string") {
|
|
811
|
+
const match = logs.match(/recording[:\s]+([a-f0-9-]{36})/i);
|
|
812
|
+
if (match?.[1]) {
|
|
813
|
+
recordingId = match[1];
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
} catch {
|
|
817
|
+
await emit("warn", "Could not read container logs after exit");
|
|
818
|
+
}
|
|
819
|
+
if (!recordingId) {
|
|
820
|
+
await emit("error", "Container completed but no recording ID was found in output");
|
|
821
|
+
throw new Error("Recording creation failed: no recording ID returned from container");
|
|
822
|
+
}
|
|
823
|
+
await emit("info", `Container completed \u2014 recording ID: ${recordingId}`);
|
|
824
|
+
return recordingId;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/ensureRequestRecording.ts
|
|
828
|
+
async function ensureRequestRecording(requestId, options) {
|
|
829
|
+
const { repositoryUrl, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
|
|
830
|
+
const emit = async (level, message) => {
|
|
831
|
+
if (onLog) {
|
|
832
|
+
try {
|
|
833
|
+
await onLog(level, message);
|
|
834
|
+
} catch {
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
await updateStatus(requestId, "processing");
|
|
839
|
+
try {
|
|
840
|
+
if (!infraConfig) {
|
|
841
|
+
const missing = getMissingInfraVars();
|
|
842
|
+
throw new Error(
|
|
843
|
+
`Container infrastructure not configured. Missing environment variables: ${missing.join(", ")}. These must be set on the Netlify site for recording creation to work.`
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
const requestData = await lookupRequest(requestId);
|
|
847
|
+
await emit("info", `Request data retrieved \u2014 handler: ${requestData.handlerPath}, branch: ${requestData.branchName}, commit: ${requestData.commitSha}`);
|
|
848
|
+
const recordingId = await spawnRecordingContainer({
|
|
849
|
+
blobUrl: requestData.blobUrl,
|
|
850
|
+
handlerPath: requestData.handlerPath,
|
|
851
|
+
commitSha: requestData.commitSha,
|
|
852
|
+
branchName: requestData.branchName,
|
|
853
|
+
repositoryUrl,
|
|
854
|
+
infraConfig,
|
|
855
|
+
logWebhookUrl: webhookUrl,
|
|
856
|
+
onLog
|
|
857
|
+
});
|
|
858
|
+
await emit("info", `Recording created successfully: ${recordingId}`);
|
|
859
|
+
await updateStatus(requestId, "recorded", recordingId);
|
|
860
|
+
return recordingId;
|
|
861
|
+
} catch (err) {
|
|
862
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
863
|
+
await emit("error", `Recording creation failed: ${message}`);
|
|
864
|
+
await updateStatus(requestId, "failed");
|
|
865
|
+
throw err;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function getMissingInfraVars() {
|
|
869
|
+
const required = [
|
|
870
|
+
"INFISICAL_CLIENT_ID",
|
|
871
|
+
"INFISICAL_CLIENT_SECRET",
|
|
872
|
+
"INFISICAL_PROJECT_ID",
|
|
873
|
+
"INFISICAL_ENVIRONMENT",
|
|
874
|
+
"FLY_API_TOKEN",
|
|
875
|
+
"FLY_APP_NAME"
|
|
876
|
+
];
|
|
877
|
+
return required.filter((name) => !process.env[name]);
|
|
878
|
+
}
|
|
879
|
+
function readInfraConfigFromEnv() {
|
|
880
|
+
const clientId = process.env.INFISICAL_CLIENT_ID;
|
|
881
|
+
const clientSecret = process.env.INFISICAL_CLIENT_SECRET;
|
|
882
|
+
const projectId = process.env.INFISICAL_PROJECT_ID;
|
|
883
|
+
const environment = process.env.INFISICAL_ENVIRONMENT;
|
|
884
|
+
const flyToken = process.env.FLY_API_TOKEN;
|
|
885
|
+
const flyApp = process.env.FLY_APP_NAME;
|
|
886
|
+
if (!clientId || !clientSecret || !projectId || !environment || !flyToken || !flyApp) {
|
|
887
|
+
return void 0;
|
|
888
|
+
}
|
|
889
|
+
return {
|
|
890
|
+
infisicalClientId: clientId,
|
|
891
|
+
infisicalClientSecret: clientSecret,
|
|
892
|
+
infisicalProjectId: projectId,
|
|
893
|
+
infisicalEnvironment: environment,
|
|
894
|
+
flyToken,
|
|
895
|
+
flyApp
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
606
899
|
// src/createRequestRecording.ts
|
|
607
900
|
async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
608
901
|
let blobData;
|
|
@@ -910,131 +1203,6 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
910
1203
|
return result;
|
|
911
1204
|
}
|
|
912
1205
|
|
|
913
|
-
// src/backendRequests.ts
|
|
914
|
-
async function backendRequestsEnsureTable(sql) {
|
|
915
|
-
await sql`
|
|
916
|
-
CREATE TABLE IF NOT EXISTS backend_requests (
|
|
917
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
918
|
-
blob_data TEXT NOT NULL,
|
|
919
|
-
handler_path TEXT NOT NULL,
|
|
920
|
-
commit_sha TEXT NOT NULL,
|
|
921
|
-
branch_name TEXT NOT NULL DEFAULT 'main',
|
|
922
|
-
repository_url TEXT,
|
|
923
|
-
status TEXT NOT NULL DEFAULT 'captured'
|
|
924
|
-
CHECK (status IN ('captured', 'queued', 'processing', 'recorded', 'failed')),
|
|
925
|
-
recording_id TEXT,
|
|
926
|
-
error_message TEXT,
|
|
927
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
928
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
929
|
-
)
|
|
930
|
-
`;
|
|
931
|
-
await sql`
|
|
932
|
-
CREATE INDEX IF NOT EXISTS idx_backend_requests_status ON backend_requests (status)
|
|
933
|
-
`;
|
|
934
|
-
await sql`
|
|
935
|
-
CREATE INDEX IF NOT EXISTS idx_backend_requests_created_at ON backend_requests (created_at DESC)
|
|
936
|
-
`;
|
|
937
|
-
}
|
|
938
|
-
async function backendRequestsInsert(sql, data) {
|
|
939
|
-
if (data.id) {
|
|
940
|
-
await sql`
|
|
941
|
-
INSERT INTO backend_requests (id, blob_data, handler_path, commit_sha, branch_name, repository_url)
|
|
942
|
-
VALUES (
|
|
943
|
-
${data.id}::uuid,
|
|
944
|
-
${data.blobData},
|
|
945
|
-
${data.handlerPath},
|
|
946
|
-
${data.commitSha},
|
|
947
|
-
${data.branchName},
|
|
948
|
-
${data.repositoryUrl ?? null}
|
|
949
|
-
)
|
|
950
|
-
`;
|
|
951
|
-
return data.id;
|
|
952
|
-
}
|
|
953
|
-
const rows = await sql`
|
|
954
|
-
INSERT INTO backend_requests (blob_data, handler_path, commit_sha, branch_name, repository_url)
|
|
955
|
-
VALUES (
|
|
956
|
-
${data.blobData},
|
|
957
|
-
${data.handlerPath},
|
|
958
|
-
${data.commitSha},
|
|
959
|
-
${data.branchName},
|
|
960
|
-
${data.repositoryUrl ?? null}
|
|
961
|
-
)
|
|
962
|
-
RETURNING id
|
|
963
|
-
`;
|
|
964
|
-
return rows[0]?.id ?? "";
|
|
965
|
-
}
|
|
966
|
-
async function backendRequestsGet(sql, id) {
|
|
967
|
-
const rows = await sql`
|
|
968
|
-
SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
|
|
969
|
-
status, recording_id, error_message, created_at, updated_at
|
|
970
|
-
FROM backend_requests WHERE id = ${id}
|
|
971
|
-
`;
|
|
972
|
-
return rows[0] ?? null;
|
|
973
|
-
}
|
|
974
|
-
async function backendRequestsGetBlobData(sql, id) {
|
|
975
|
-
const rows = await sql`
|
|
976
|
-
SELECT blob_data FROM backend_requests WHERE id = ${id}
|
|
977
|
-
`;
|
|
978
|
-
return rows[0]?.blob_data ?? null;
|
|
979
|
-
}
|
|
980
|
-
async function backendRequestsList(sql, filters) {
|
|
981
|
-
const limit = filters?.limit ?? 50;
|
|
982
|
-
if (filters?.status) {
|
|
983
|
-
const rows2 = await sql`
|
|
984
|
-
SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
|
|
985
|
-
status, recording_id, error_message, created_at, updated_at
|
|
986
|
-
FROM backend_requests
|
|
987
|
-
WHERE status = ${filters.status}
|
|
988
|
-
ORDER BY created_at DESC
|
|
989
|
-
LIMIT ${limit}
|
|
990
|
-
`;
|
|
991
|
-
return rows2;
|
|
992
|
-
}
|
|
993
|
-
const rows = await sql`
|
|
994
|
-
SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
|
|
995
|
-
status, recording_id, error_message, created_at, updated_at
|
|
996
|
-
FROM backend_requests
|
|
997
|
-
ORDER BY created_at DESC
|
|
998
|
-
LIMIT ${limit}
|
|
999
|
-
`;
|
|
1000
|
-
return rows;
|
|
1001
|
-
}
|
|
1002
|
-
async function backendRequestsUpdateStatus(sql, id, status, recordingId, errorMessage) {
|
|
1003
|
-
if (recordingId) {
|
|
1004
|
-
await sql`
|
|
1005
|
-
UPDATE backend_requests
|
|
1006
|
-
SET status = ${status}, recording_id = ${recordingId}, updated_at = NOW()
|
|
1007
|
-
WHERE id = ${id}
|
|
1008
|
-
`;
|
|
1009
|
-
} else if (errorMessage) {
|
|
1010
|
-
await sql`
|
|
1011
|
-
UPDATE backend_requests
|
|
1012
|
-
SET status = ${status}, error_message = ${errorMessage}, updated_at = NOW()
|
|
1013
|
-
WHERE id = ${id}
|
|
1014
|
-
`;
|
|
1015
|
-
} else {
|
|
1016
|
-
await sql`
|
|
1017
|
-
UPDATE backend_requests
|
|
1018
|
-
SET status = ${status}, updated_at = NOW()
|
|
1019
|
-
WHERE id = ${id}
|
|
1020
|
-
`;
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
function databaseCallbacks(sql) {
|
|
1024
|
-
return {
|
|
1025
|
-
storeRequest: async (data) => {
|
|
1026
|
-
return backendRequestsInsert(sql, {
|
|
1027
|
-
id: data.requestId,
|
|
1028
|
-
blobData: data.blobData,
|
|
1029
|
-
handlerPath: data.handlerPath,
|
|
1030
|
-
commitSha: data.commitSha,
|
|
1031
|
-
branchName: data.branchName,
|
|
1032
|
-
repositoryUrl: data.repositoryUrl
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
};
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
1206
|
// src/databaseAudit.ts
|
|
1039
1207
|
async function databaseAuditEnsureLogTable(sql) {
|
|
1040
1208
|
await sql`
|
|
@@ -1115,20 +1283,17 @@ async function databaseAuditDumpLogTable(sql) {
|
|
|
1115
1283
|
return rows;
|
|
1116
1284
|
}
|
|
1117
1285
|
export {
|
|
1118
|
-
backendRequestsEnsureTable,
|
|
1119
|
-
backendRequestsGet,
|
|
1120
|
-
backendRequestsGetBlobData,
|
|
1121
|
-
backendRequestsInsert,
|
|
1122
|
-
backendRequestsList,
|
|
1123
|
-
backendRequestsUpdateStatus,
|
|
1124
1286
|
createRecordingRequestHandler,
|
|
1125
1287
|
createRequestRecording,
|
|
1126
1288
|
databaseAuditDumpLogTable,
|
|
1127
1289
|
databaseAuditEnsureLogTable,
|
|
1128
1290
|
databaseAuditMonitorTable,
|
|
1129
|
-
|
|
1291
|
+
ensureRequestRecording,
|
|
1130
1292
|
finishRequest,
|
|
1131
1293
|
getCurrentRequestId,
|
|
1294
|
+
readInfraConfigFromEnv,
|
|
1132
1295
|
redactBlobData,
|
|
1296
|
+
remoteCallbacks,
|
|
1297
|
+
spawnRecordingContainer,
|
|
1133
1298
|
startRequest
|
|
1134
1299
|
};
|