@replayio-app-building/netlify-recorder 0.16.0 → 0.18.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 +182 -298
- 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 in your app's own database, and can later reproduce the exact execution as a Replay recording for debugging and analysis.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,32 +8,31 @@ 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
|
-
##
|
|
11
|
+
## Setup
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
### 1. Create the backend_requests table
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The package stores captured request data directly in your app's database. Call `backendRequestsEnsureTable` once during schema initialization to create the `backend_requests` table:
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
---
|
|
17
|
+
```typescript
|
|
18
|
+
import { backendRequestsEnsureTable } from "@replayio-app-building/netlify-recorder";
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
await backendRequestsEnsureTable(sql);
|
|
21
|
+
```
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
This creates a table with columns for the serialized blob data, git metadata (commit SHA, branch, repository URL), handler path, recording status, and timestamps.
|
|
24
24
|
|
|
25
|
-
###
|
|
25
|
+
### 2. Set required environment variables
|
|
26
26
|
|
|
27
|
-
`finishRequest` needs to know which repository, branch, and commit your deployed code belongs to. Set these
|
|
27
|
+
`finishRequest` needs to know which repository, branch, and commit your deployed code belongs to. Set these environment variables on your Netlify site:
|
|
28
28
|
|
|
29
29
|
| Variable | Description | How to set |
|
|
30
30
|
|---|---|---|
|
|
31
31
|
| `REPLAY_REPOSITORY_URL` | Your app's git repository URL (e.g. `https://github.com/org/repo.git`) | Set in your deploy script or Netlify site settings |
|
|
32
32
|
| `COMMIT_SHA` | The git commit hash of the deployed code | Set in your deploy script via `git rev-parse HEAD` |
|
|
33
33
|
| `BRANCH_NAME` | The git branch of the deployed code | Set in your deploy script via `git rev-parse --abbrev-ref HEAD` |
|
|
34
|
-
| `NETLIFY_RECORDER_SECRET` | Secret string for access control — restricts who can view and act on your captured requests | Set in Netlify site environment variables or via `set-branch-secret` |
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
All three are **required** — `finishRequest` will throw an error if any are missing. Your deploy script should resolve the git values and set them on the Netlify site before deploying. Example:
|
|
37
36
|
|
|
38
37
|
```typescript
|
|
39
38
|
// In your deploy script:
|
|
@@ -45,19 +44,20 @@ const repositoryUrl = execSync("git remote get-url origin", { encoding: "utf-8"
|
|
|
45
44
|
// Set these on your Netlify site via the Netlify API or CLI
|
|
46
45
|
```
|
|
47
46
|
|
|
48
|
-
###
|
|
47
|
+
### 3. Wrap your Netlify function
|
|
49
48
|
|
|
50
|
-
Use `createRecordingRequestHandler` with `
|
|
49
|
+
Use `createRecordingRequestHandler` with `databaseCallbacks(sql)` to wrap your handler with automatic request capture. The captured data is stored directly in the `backend_requests` table in your database.
|
|
51
50
|
|
|
52
51
|
**v1 handler** (Netlify Functions v1 — `event` with `httpMethod`, `path`, etc.):
|
|
53
52
|
|
|
54
53
|
```typescript
|
|
55
54
|
import {
|
|
56
55
|
createRecordingRequestHandler,
|
|
57
|
-
|
|
56
|
+
databaseCallbacks,
|
|
58
57
|
} from "@replayio-app-building/netlify-recorder";
|
|
58
|
+
import { neon } from "@neondatabase/serverless";
|
|
59
59
|
|
|
60
|
-
const
|
|
60
|
+
const sql = neon(process.env.DATABASE_URL!);
|
|
61
61
|
|
|
62
62
|
const handler = createRecordingRequestHandler(
|
|
63
63
|
async (event) => {
|
|
@@ -70,9 +70,8 @@ const handler = createRecordingRequestHandler(
|
|
|
70
70
|
};
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
|
-
callbacks:
|
|
73
|
+
callbacks: databaseCallbacks(sql),
|
|
74
74
|
handlerPath: "netlify/functions/my-handler",
|
|
75
|
-
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
76
75
|
}
|
|
77
76
|
);
|
|
78
77
|
|
|
@@ -84,10 +83,11 @@ export { handler };
|
|
|
84
83
|
```typescript
|
|
85
84
|
import {
|
|
86
85
|
createRecordingRequestHandler,
|
|
87
|
-
|
|
86
|
+
databaseCallbacks,
|
|
88
87
|
} from "@replayio-app-building/netlify-recorder";
|
|
88
|
+
import { neon } from "@neondatabase/serverless";
|
|
89
89
|
|
|
90
|
-
const
|
|
90
|
+
const sql = neon(process.env.DATABASE_URL!);
|
|
91
91
|
|
|
92
92
|
// The wrapper reads the body from a clone — you can still read req.json() etc.
|
|
93
93
|
export default createRecordingRequestHandler(
|
|
@@ -102,37 +102,57 @@ export default createRecordingRequestHandler(
|
|
|
102
102
|
};
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
|
-
callbacks:
|
|
105
|
+
callbacks: databaseCallbacks(sql),
|
|
106
106
|
handlerPath: "netlify/functions/my-handler",
|
|
107
|
-
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
108
107
|
}
|
|
109
108
|
);
|
|
110
109
|
```
|
|
111
110
|
|
|
112
|
-
`createRecordingRequestHandler` automatically captures all outbound network calls and environment variable reads during your handler's execution, then
|
|
111
|
+
`createRecordingRequestHandler` automatically captures all outbound network calls and environment variable reads during your handler's execution, then stores the captured data in the `backend_requests` table. The response includes an `X-Replay-Request-Id` header with the ID of the stored request.
|
|
113
112
|
|
|
114
113
|
> **Note:** Always use the response returned by the wrapper (or `finishRequest`), not your original response object. The wrapper adds the `X-Replay-Request-Id` header to the response it returns.
|
|
115
114
|
|
|
116
|
-
###
|
|
115
|
+
### 4. Create recordings via the Netlify Recorder service
|
|
116
|
+
|
|
117
|
+
When you want to turn a captured request into a Replay recording, POST to the Netlify Recorder service's `create-recording` endpoint. You need to provide a `blobDataUrl` — a publicly accessible URL where the recording container can fetch the captured blob data.
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
If your app exposes the blob data via an endpoint (e.g. using `backendRequestsGetBlobData`), construct the URL and pass it:
|
|
119
120
|
|
|
120
121
|
```typescript
|
|
122
|
+
const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";
|
|
123
|
+
const blobDataUrl = `https://your-app.netlify.app/api/get-backend-request-blob?requestId=${requestId}`;
|
|
124
|
+
|
|
121
125
|
const response = await fetch(
|
|
122
126
|
`${RECORDER_URL}/api/create-recording`,
|
|
123
127
|
{
|
|
124
128
|
method: "POST",
|
|
125
129
|
headers: { "Content-Type": "application/json" },
|
|
126
130
|
body: JSON.stringify({
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
blobDataUrl,
|
|
132
|
+
handlerPath: "netlify/functions/my-handler",
|
|
133
|
+
commitSha: "abc123",
|
|
134
|
+
branchName: "main",
|
|
135
|
+
repositoryUrl: "https://github.com/org/repo.git", // optional
|
|
129
136
|
webhookUrl: "https://your-app.netlify.app/api/on-recording-complete", // optional
|
|
130
137
|
}),
|
|
131
138
|
}
|
|
132
139
|
);
|
|
140
|
+
|
|
141
|
+
const { requestId: serviceRequestId } = await response.json();
|
|
133
142
|
```
|
|
134
143
|
|
|
135
|
-
The
|
|
144
|
+
The `create-recording` endpoint accepts:
|
|
145
|
+
|
|
146
|
+
| Parameter | Required | Description |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `blobDataUrl` | Yes | URL where the recording container can fetch the captured blob JSON |
|
|
149
|
+
| `handlerPath` | Yes | Path to the handler file (e.g. `netlify/functions/my-handler`) |
|
|
150
|
+
| `commitSha` | Yes | Git commit SHA of the deployed code |
|
|
151
|
+
| `branchName` | Yes | Git branch of the deployed code |
|
|
152
|
+
| `repositoryUrl` | No | Git repository URL for the container to clone |
|
|
153
|
+
| `webhookUrl` | No | URL to POST the result to when the recording completes or fails |
|
|
154
|
+
|
|
155
|
+
The service dispatches the work to a recording container, which fetches the blob data from `blobDataUrl`, replays the handler execution under `replay-node`, and produces a Replay recording.
|
|
136
156
|
|
|
137
157
|
If `webhookUrl` is provided, the service will POST the result to that URL when the recording completes (or fails):
|
|
138
158
|
|
|
@@ -146,249 +166,32 @@ On failure:
|
|
|
146
166
|
{ "status": "failed", "error": "Error message" }
|
|
147
167
|
```
|
|
148
168
|
|
|
149
|
-
###
|
|
150
|
-
|
|
151
|
-
You can poll the recording status at any time. If the request was created with a secret, you must include it:
|
|
152
|
-
|
|
153
|
-
```typescript
|
|
154
|
-
const res = await fetch(
|
|
155
|
-
`${RECORDER_URL}/api/get-request?requestId=${requestId}&secret=${secret}`
|
|
156
|
-
);
|
|
157
|
-
const { status, recordingId } = await res.json();
|
|
158
|
-
// status: "captured" | "processing" | "recorded" | "failed"
|
|
159
|
-
// recordingId: string | null
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Requests created with a secret return 403 if the secret is missing or incorrect.
|
|
163
|
-
|
|
164
|
-
### 5. Access control with secrets
|
|
165
|
-
|
|
166
|
-
You can restrict access to captured requests by setting a `secret` string. When a secret is set, the request is only accessible via API calls that provide the same secret value. This lets each app isolate its requests from other apps sharing the same Netlify Recorder service.
|
|
167
|
-
|
|
168
|
-
#### Setting a secret
|
|
169
|
-
|
|
170
|
-
Pass `secret` in the options when wrapping your handler:
|
|
171
|
-
|
|
172
|
-
```typescript
|
|
173
|
-
export default createRecordingRequestHandler(
|
|
174
|
-
async (req) => {
|
|
175
|
-
const result = await myBusinessLogic();
|
|
176
|
-
return { statusCode: 200, body: JSON.stringify(result) };
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
callbacks: remoteCallbacks(RECORDER_URL),
|
|
180
|
-
handlerPath: "netlify/functions/my-handler",
|
|
181
|
-
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
182
|
-
}
|
|
183
|
-
);
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
#### Listing requests by secret
|
|
187
|
-
|
|
188
|
-
Use the `requests` endpoint with a `secret` parameter to retrieve all requests associated with your secret, with optional filtering by status, handler path, and time range:
|
|
189
|
-
|
|
190
|
-
```typescript
|
|
191
|
-
const res = await fetch(
|
|
192
|
-
`${RECORDER_URL}/api/requests?secret=${secret}&status=recorded&handlerPath=netlify/functions/my-handler&after=2025-01-01T00:00:00Z&before=2025-12-31T23:59:59Z`
|
|
193
|
-
);
|
|
194
|
-
const { rows, total, page, limit } = await res.json();
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Query parameters:
|
|
198
|
-
|
|
199
|
-
| Parameter | Description |
|
|
200
|
-
|---|---|
|
|
201
|
-
| `secret` | **(required)** The secret string used when creating the requests |
|
|
202
|
-
| `id` | Search by request ID prefix |
|
|
203
|
-
| `status` | Filter by status: `captured`, `queued`, `processing`, `recorded`, `failed`, `all` |
|
|
204
|
-
| `handlerPath` | Filter by handler path (exact match) |
|
|
205
|
-
| `after` | Only include requests created at or after this ISO timestamp |
|
|
206
|
-
| `before` | Only include requests created at or before this ISO timestamp |
|
|
207
|
-
| `page` | Page number (default 1) |
|
|
208
|
-
| `limit` | Page size (default 20, max 100) |
|
|
209
|
-
|
|
210
|
-
Without a `secret` parameter, only requests created without a secret are returned.
|
|
211
|
-
|
|
212
|
-
#### Checking request status with a secret
|
|
213
|
-
|
|
214
|
-
When a request was created with a secret, you must include the secret when polling its status:
|
|
215
|
-
|
|
216
|
-
```typescript
|
|
217
|
-
const res = await fetch(
|
|
218
|
-
`${RECORDER_URL}/api/get-request?requestId=${requestId}&secret=${secret}`
|
|
219
|
-
);
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
Requests created without a secret remain accessible without one (backward compatible).
|
|
223
|
-
|
|
224
|
-
#### Managing your secret
|
|
225
|
-
|
|
226
|
-
Store your secret as a `NETLIFY_RECORDER_SECRET` environment variable. To keep it secure:
|
|
227
|
-
|
|
228
|
-
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
|
-
|
|
230
|
-
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
|
-
|
|
232
|
-
```bash
|
|
233
|
-
set-branch-secret NETLIFY_RECORDER_SECRET "your-secret-value"
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
3. **Local development:** Add `NETLIFY_RECORDER_SECRET` to your `.env` file (make sure `.env` is in `.gitignore`).
|
|
237
|
-
|
|
238
|
-
Use a strong random string (e.g. `openssl rand -base64 32`) and rotate it if compromised. All requests created under the old secret remain accessible only with the old secret value — there is no migration mechanism, so plan rotations during low-traffic windows.
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
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
|
-
```
|
|
169
|
+
### 5. Manage stored requests
|
|
314
170
|
|
|
315
|
-
|
|
171
|
+
Use the `backendRequests*` helpers to query and manage captured requests in your database:
|
|
316
172
|
|
|
317
173
|
```typescript
|
|
318
|
-
import {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
};
|
|
174
|
+
import {
|
|
175
|
+
backendRequestsGet,
|
|
176
|
+
backendRequestsList,
|
|
177
|
+
backendRequestsUpdateStatus,
|
|
178
|
+
} from "@replayio-app-building/netlify-recorder";
|
|
354
179
|
|
|
355
|
-
|
|
356
|
-
|
|
180
|
+
// Get a single request by ID
|
|
181
|
+
const request = await backendRequestsGet(sql, requestId);
|
|
182
|
+
// request.status: "captured" | "queued" | "processing" | "recorded" | "failed"
|
|
183
|
+
// request.recording_id: string | null
|
|
357
184
|
|
|
358
|
-
|
|
185
|
+
// List requests with optional filters
|
|
186
|
+
const requests = await backendRequestsList(sql, { status: "captured", limit: 20 });
|
|
359
187
|
|
|
360
|
-
|
|
188
|
+
// Update status after recording completes
|
|
189
|
+
await backendRequestsUpdateStatus(sql, requestId, "recorded", recordingId);
|
|
361
190
|
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
});
|
|
191
|
+
// Update status on failure
|
|
192
|
+
await backendRequestsUpdateStatus(sql, requestId, "failed", undefined, "Error message");
|
|
375
193
|
```
|
|
376
194
|
|
|
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
195
|
---
|
|
393
196
|
|
|
394
197
|
## Audit Log Support
|
|
@@ -425,7 +228,7 @@ No special SQL wrapper is needed. Any Neon SQL query inside a handler wrapped wi
|
|
|
425
228
|
```typescript
|
|
426
229
|
import {
|
|
427
230
|
createRecordingRequestHandler,
|
|
428
|
-
|
|
231
|
+
databaseCallbacks,
|
|
429
232
|
} from "@replayio-app-building/netlify-recorder";
|
|
430
233
|
|
|
431
234
|
export default createRecordingRequestHandler(
|
|
@@ -435,9 +238,8 @@ export default createRecordingRequestHandler(
|
|
|
435
238
|
return { statusCode: 200, body: "OK" };
|
|
436
239
|
},
|
|
437
240
|
{
|
|
438
|
-
callbacks:
|
|
241
|
+
callbacks: databaseCallbacks(sql),
|
|
439
242
|
handlerPath: "netlify/functions/create-order",
|
|
440
|
-
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
441
243
|
}
|
|
442
244
|
);
|
|
443
245
|
```
|
|
@@ -478,22 +280,21 @@ The network interceptor detects Neon SQL HTTP requests (which use `fetch` intern
|
|
|
478
280
|
|
|
479
281
|
Wraps a Netlify handler function with automatic request recording. This is the recommended way to integrate — it handles `startRequest`/`finishRequest` and error cleanup internally.
|
|
480
282
|
|
|
481
|
-
**Response timing:** When the Netlify Functions v2 `context` object is available (with `waitUntil`), the response is returned to the client **immediately** with a pre-generated `X-Replay-Request-Id` header. The
|
|
283
|
+
**Response timing:** When the Netlify Functions v2 `context` object is available (with `waitUntil`), the response is returned to the client **immediately** with a pre-generated `X-Replay-Request-Id` header. The data storage continues in the background via `context.waitUntil()`, adding zero latency to the client response.
|
|
482
284
|
|
|
483
285
|
When `context.waitUntil` is not available (v1 handlers or missing context), the wrapper falls back to awaiting `finishRequest` before returning.
|
|
484
286
|
|
|
485
287
|
**Parameters:**
|
|
486
288
|
- `handler` — Your async handler function `(event, context?) => Promise<{ statusCode, headers?, body? }>`
|
|
487
|
-
- `options.callbacks` —
|
|
289
|
+
- `options.callbacks` — `databaseCallbacks(sql)` to store captured data in the `backend_requests` table
|
|
488
290
|
- `options.handlerPath` — Path to the handler file (used for recording metadata)
|
|
489
291
|
- `options.commitSha` — Override `COMMIT_SHA` env var
|
|
490
292
|
- `options.branchName` — Override `BRANCH_NAME` env var
|
|
491
293
|
- `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
|
|
492
|
-
- `options.secret` — Optional secret string. When set, the stored request is only accessible via API calls that provide the same secret value.
|
|
493
294
|
|
|
494
295
|
**Returns:** A wrapped handler function with the same signature.
|
|
495
296
|
|
|
496
|
-
**Callbacks note:** When using the `waitUntil` flow, `
|
|
297
|
+
**Callbacks note:** When using the `waitUntil` flow, `storeRequest` receives a `requestId` field in its data parameter. Callbacks should use this as the row ID so the stored record matches the ID already sent to the client.
|
|
497
298
|
|
|
498
299
|
### `startRequest(event): RequestContext`
|
|
499
300
|
|
|
@@ -510,51 +311,144 @@ For v2 Request inputs, the body is read from a **clone** — the original reques
|
|
|
510
311
|
|
|
511
312
|
### `finishRequest(requestContext, callbacks, response, options?): Promise<HandlerResponse>`
|
|
512
313
|
|
|
513
|
-
Lower-level API. Finalizes the request capture. Restores original `fetch` and `process.env`, serializes the captured data,
|
|
314
|
+
Lower-level API. Finalizes the request capture. Restores original `fetch` and `process.env`, serializes the captured data, stores it via the callback, and returns the response with `X-Replay-Request-Id` header set.
|
|
514
315
|
|
|
515
316
|
**Important:** You must send the returned response to the client — it contains the `X-Replay-Request-Id` header.
|
|
516
317
|
|
|
517
|
-
Logs a `console.warn` when the total duration exceeds 2 seconds
|
|
318
|
+
Logs a `console.warn` when the total duration exceeds 2 seconds to help diagnose slow operations.
|
|
518
319
|
|
|
519
320
|
**Requires** the following environment variables (or equivalent options overrides): `COMMIT_SHA`, `BRANCH_NAME`, `REPLAY_REPOSITORY_URL`. Throws if any are missing.
|
|
520
321
|
|
|
521
322
|
**Parameters:**
|
|
522
323
|
- `requestContext` — The context returned by `startRequest`
|
|
523
|
-
- `callbacks` —
|
|
324
|
+
- `callbacks` — `databaseCallbacks(sql)` or a custom `{ storeRequest }` callback
|
|
524
325
|
- `response` — The handler's response object (`{ statusCode, headers?, body? }`)
|
|
525
326
|
- `options.handlerPath` — Path to the handler file (used for recording metadata)
|
|
526
327
|
- `options.commitSha` — Override `COMMIT_SHA` env var
|
|
527
328
|
- `options.branchName` — Override `BRANCH_NAME` env var
|
|
528
329
|
- `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
|
|
529
330
|
- `options.requestId` — Pre-generated request ID (used by `createRecordingRequestHandler` in the `waitUntil` flow)
|
|
530
|
-
- `options.secret` — Optional secret string. When set, the stored request is only accessible via API calls that provide the same secret value.
|
|
531
331
|
|
|
532
|
-
### `
|
|
332
|
+
### `databaseCallbacks(sql): FinishRequestCallbacks`
|
|
333
|
+
|
|
334
|
+
Creates a `FinishRequestCallbacks` object that stores captured request data directly in the `backend_requests` table. This is the standard way to provide callbacks to `createRecordingRequestHandler` or `finishRequest`.
|
|
335
|
+
|
|
336
|
+
**Parameters:**
|
|
337
|
+
- `sql` — A Neon SQL tagged-template function
|
|
338
|
+
|
|
339
|
+
**Returns:** An object with a `storeRequest` method that inserts rows into `backend_requests`.
|
|
340
|
+
|
|
341
|
+
### `backendRequestsEnsureTable(sql): Promise<void>`
|
|
342
|
+
|
|
343
|
+
Creates the `backend_requests` table and its indexes. Call once during schema initialization.
|
|
344
|
+
|
|
345
|
+
The table schema:
|
|
346
|
+
|
|
347
|
+
| Column | Type | Description |
|
|
348
|
+
|---|---|---|
|
|
349
|
+
| `id` | UUID (PK) | Auto-generated request ID |
|
|
350
|
+
| `blob_data` | TEXT | Serialized JSON of captured request data |
|
|
351
|
+
| `handler_path` | TEXT | Path to the handler file |
|
|
352
|
+
| `commit_sha` | TEXT | Git commit SHA |
|
|
353
|
+
| `branch_name` | TEXT | Git branch name (default: `'main'`) |
|
|
354
|
+
| `repository_url` | TEXT | Git repository URL (nullable) |
|
|
355
|
+
| `status` | TEXT | `'captured'`, `'queued'`, `'processing'`, `'recorded'`, or `'failed'` |
|
|
356
|
+
| `recording_id` | TEXT | Replay recording ID (set when status is `'recorded'`) |
|
|
357
|
+
| `error_message` | TEXT | Error details (set when status is `'failed'`) |
|
|
358
|
+
| `created_at` | TIMESTAMPTZ | Row creation time |
|
|
359
|
+
| `updated_at` | TIMESTAMPTZ | Last update time |
|
|
360
|
+
|
|
361
|
+
**Parameters:**
|
|
362
|
+
- `sql` — A Neon SQL tagged-template function
|
|
363
|
+
|
|
364
|
+
### `backendRequestsInsert(sql, data): Promise<string>`
|
|
365
|
+
|
|
366
|
+
Inserts a new row into `backend_requests` and returns the generated (or provided) ID.
|
|
367
|
+
|
|
368
|
+
**Parameters:**
|
|
369
|
+
- `sql` — A Neon SQL tagged-template function
|
|
370
|
+
- `data.blobData` — Serialized blob JSON string
|
|
371
|
+
- `data.handlerPath` — Handler file path
|
|
372
|
+
- `data.commitSha` — Git commit SHA
|
|
373
|
+
- `data.branchName` — Git branch name
|
|
374
|
+
- `data.repositoryUrl` — Git repository URL (optional)
|
|
375
|
+
- `data.id` — Pre-generated UUID (optional; auto-generated if omitted)
|
|
376
|
+
|
|
377
|
+
### `backendRequestsGet(sql, id): Promise<BackendRequest | null>`
|
|
378
|
+
|
|
379
|
+
Retrieves a single request by ID, or `null` if not found.
|
|
380
|
+
|
|
381
|
+
**Parameters:**
|
|
382
|
+
- `sql` — A Neon SQL tagged-template function
|
|
383
|
+
- `id` — The request UUID
|
|
384
|
+
|
|
385
|
+
### `backendRequestsGetBlobData(sql, id): Promise<string | null>`
|
|
386
|
+
|
|
387
|
+
Retrieves only the `blob_data` column for a request, or `null` if not found. Use this to serve blob data to the recording container without fetching the full row.
|
|
388
|
+
|
|
389
|
+
**Parameters:**
|
|
390
|
+
- `sql` — A Neon SQL tagged-template function
|
|
391
|
+
- `id` — The request UUID
|
|
392
|
+
|
|
393
|
+
### `backendRequestsList(sql, filters?): Promise<BackendRequest[]>`
|
|
533
394
|
|
|
534
|
-
|
|
395
|
+
Lists requests ordered by `created_at` DESC, with optional filters.
|
|
535
396
|
|
|
536
397
|
**Parameters:**
|
|
537
|
-
- `
|
|
398
|
+
- `sql` — A Neon SQL tagged-template function
|
|
399
|
+
- `filters.status` — Filter by status (e.g. `"captured"`, `"recorded"`)
|
|
400
|
+
- `filters.limit` — Maximum rows to return (default: 50)
|
|
538
401
|
|
|
539
|
-
### `
|
|
402
|
+
### `backendRequestsUpdateStatus(sql, id, status, recordingId?, errorMessage?): Promise<void>`
|
|
540
403
|
|
|
541
|
-
|
|
404
|
+
Updates the status of a request. Optionally sets `recording_id` (on success) or `error_message` (on failure).
|
|
542
405
|
|
|
543
406
|
**Parameters:**
|
|
544
|
-
- `
|
|
545
|
-
- `
|
|
546
|
-
- `
|
|
547
|
-
- `
|
|
407
|
+
- `sql` — A Neon SQL tagged-template function
|
|
408
|
+
- `id` — The request UUID
|
|
409
|
+
- `status` — New status string
|
|
410
|
+
- `recordingId` — Replay recording ID (optional, for `"recorded"` status)
|
|
411
|
+
- `errorMessage` — Error details (optional, for `"failed"` status)
|
|
548
412
|
|
|
549
|
-
### `createRequestRecording(
|
|
413
|
+
### `createRequestRecording(blobUrlOrData, handlerPath, requestInfo): Promise<RecordingResult>`
|
|
550
414
|
|
|
551
|
-
Called inside a container running under `replay-node`. Downloads the captured data blob, installs replay-mode interceptors
|
|
415
|
+
Called inside a recording container running under `replay-node`. Downloads the captured data blob (or accepts pre-parsed `BlobData`), installs replay-mode interceptors that return pre-recorded responses instead of making real calls, and executes the original handler so `replay-node` can record the execution.
|
|
416
|
+
|
|
417
|
+
Returns a `RecordingResult` with mismatch detection:
|
|
418
|
+
|
|
419
|
+
| Field | Type | Description |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| `responseMismatch` | boolean | Whether the replay response differs from the captured response |
|
|
422
|
+
| `mismatchDetails` | string? | Description of the mismatch |
|
|
423
|
+
| `replayResponse` | HandlerResponse? | The response produced during replay |
|
|
424
|
+
| `capturedResponse` | HandlerResponse? | The original captured response |
|
|
425
|
+
| `unconsumedNetworkCalls` | boolean | Whether some recorded network calls were not replayed |
|
|
426
|
+
| `unconsumedNetworkDetails` | string? | Details about unconsumed calls |
|
|
552
427
|
|
|
553
428
|
**Parameters:**
|
|
554
|
-
- `
|
|
429
|
+
- `blobUrlOrData` — URL to the captured data blob, or pre-parsed `BlobData` object
|
|
555
430
|
- `handlerPath` — Path to the handler module to execute
|
|
556
431
|
- `requestInfo` — The original request info to replay
|
|
557
432
|
|
|
433
|
+
### `getCurrentRequestId(): string | null`
|
|
434
|
+
|
|
435
|
+
Returns the request ID for the currently executing handler, or `null` if no handler is active. Useful for correlating logs or database operations with the current request.
|
|
436
|
+
|
|
437
|
+
### `redactBlobData(data): BlobData`
|
|
438
|
+
|
|
439
|
+
Redacts sensitive environment variable values from captured blob data before storage. Applied automatically by `finishRequest` — you only need to call this directly if using the lower-level APIs.
|
|
440
|
+
|
|
441
|
+
Redaction rules:
|
|
442
|
+
- Allow-listed keys (standard system/runtime variables like `NODE_ENV`, `PATH`, `COMMIT_SHA`) are never redacted
|
|
443
|
+
- Values that are `undefined` or 8 characters or shorter are kept as-is
|
|
444
|
+
- All other values are replaced with `*` repeated to the same length
|
|
445
|
+
- Redacted values are also scrubbed from all other string fields in the blob (request headers, network call bodies, etc.) to prevent leakage through embedded values
|
|
446
|
+
|
|
447
|
+
**Parameters:**
|
|
448
|
+
- `data` — A `BlobData` object
|
|
449
|
+
|
|
450
|
+
**Returns:** A new `BlobData` object with sensitive values masked.
|
|
451
|
+
|
|
558
452
|
### `databaseAuditEnsureLogTable(sql): Promise<void>`
|
|
559
453
|
|
|
560
454
|
Creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger function (`audit_trigger_function`). Call once during schema initialization.
|
|
@@ -579,8 +473,6 @@ Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
|
579
473
|
|
|
580
474
|
## Environment Variables
|
|
581
475
|
|
|
582
|
-
### Required for all setups (read by `finishRequest`)
|
|
583
|
-
|
|
584
476
|
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
477
|
|
|
586
478
|
| Variable | Description | How to resolve |
|
|
@@ -588,17 +480,9 @@ These must be set on your Netlify site. Your deploy script should resolve them f
|
|
|
588
480
|
| `COMMIT_SHA` | Git commit hash of the deployed code | `git rev-parse HEAD` |
|
|
589
481
|
| `BRANCH_NAME` | Git branch of the deployed code | `git rev-parse --abbrev-ref HEAD` |
|
|
590
482
|
| `REPLAY_REPOSITORY_URL` | Git repository URL (no embedded credentials) | `git remote get-url origin` (strip tokens) |
|
|
591
|
-
| `NETLIFY_RECORDER_SECRET` | Secret for access control (strongly recommended) | `openssl rand -base64 32` — store in Netlify site env vars |
|
|
592
|
-
|
|
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
483
|
|
|
600
484
|
## How It Works
|
|
601
485
|
|
|
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
|
|
486
|
+
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. Sensitive environment variable values are automatically redacted. When the handler completes, the originals are restored, the captured data is serialized to JSON, and stored in the `backend_requests` table in your database via `databaseCallbacks`.
|
|
603
487
|
|
|
604
|
-
2. **Recording phase**:
|
|
488
|
+
2. **Recording phase**: To create a Replay recording, POST to the Netlify Recorder service's `create-recording` endpoint with a `blobDataUrl` pointing to the captured data. The service dispatches the work to a recording container, which fetches the blob data from the URL, installs replay-mode interceptors that return pre-recorded responses instead of making real calls, and re-executes the handler under `replay-node`. Since `replay-node` records all execution, this produces a Replay recording of the exact same handler execution. The recording result includes mismatch detection — the service compares the replay response against the originally captured response to flag any divergence.
|