@replayio-app-building/netlify-recorder 0.10.0 → 0.11.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 +7 -2
- package/dist/index.d.ts +27 -7
- package/dist/index.js +80 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -237,7 +237,6 @@ const handler: Handler = async (event) => {
|
|
|
237
237
|
|
|
238
238
|
const recordingId = await ensureRequestRecording(requestId, {
|
|
239
239
|
repositoryUrl: process.env.APP_REPOSITORY_URL!,
|
|
240
|
-
replayApiKey: process.env.RECORD_REPLAY_API_KEY!,
|
|
241
240
|
lookupRequest: async (id) => {
|
|
242
241
|
const [row] = await sql`
|
|
243
242
|
SELECT blob_url, commit_sha, branch_name, handler_path
|
|
@@ -312,6 +311,10 @@ Self-hosted recording requires these environment variables:
|
|
|
312
311
|
|
|
313
312
|
Wraps a Netlify handler function with automatic request recording. This is the recommended way to integrate — it handles `startRequest`/`finishRequest` and error cleanup internally.
|
|
314
313
|
|
|
314
|
+
**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 blob upload and metadata storage continue in the background via `context.waitUntil()`, adding zero latency to the client response.
|
|
315
|
+
|
|
316
|
+
When `context.waitUntil` is not available (v1 handlers or missing context), the wrapper falls back to awaiting `finishRequest` before returning.
|
|
317
|
+
|
|
315
318
|
**Parameters:**
|
|
316
319
|
- `handler` — Your async handler function `(event, context?) => Promise<{ statusCode, headers?, body? }>`
|
|
317
320
|
- `options.callbacks` — Either `remoteCallbacks(serviceUrl)` or custom `{ uploadBlob, storeRequestData }` callbacks
|
|
@@ -322,6 +325,8 @@ Wraps a Netlify handler function with automatic request recording. This is the r
|
|
|
322
325
|
|
|
323
326
|
**Returns:** A wrapped handler function with the same signature.
|
|
324
327
|
|
|
328
|
+
**Callbacks note:** When using the `waitUntil` flow, `storeRequestData` 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.
|
|
329
|
+
|
|
325
330
|
### `startRequest(event): RequestContext`
|
|
326
331
|
|
|
327
332
|
Lower-level API. Begins capturing a Netlify handler execution. Patches `globalThis.fetch` and `process.env` to record all outbound network calls and environment variable reads. Use this with `finishRequest` when you need more control than `createRecordingRequestHandler` provides.
|
|
@@ -353,6 +358,7 @@ Logs a `console.warn` when the total duration exceeds 2 seconds or when individu
|
|
|
353
358
|
- `options.commitSha` — Override `COMMIT_SHA` env var
|
|
354
359
|
- `options.branchName` — Override `BRANCH_NAME` env var
|
|
355
360
|
- `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
|
|
361
|
+
- `options.requestId` — Pre-generated request ID (used by `createRecordingRequestHandler` in the `waitUntil` flow)
|
|
356
362
|
|
|
357
363
|
### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`
|
|
358
364
|
|
|
@@ -368,7 +374,6 @@ Spawns a container via `@replayio/app-building` to create a Replay recording fro
|
|
|
368
374
|
**Parameters:**
|
|
369
375
|
- `requestId` — The request to create a recording for
|
|
370
376
|
- `options.repositoryUrl` — Git repository URL for the container to clone
|
|
371
|
-
- `options.replayApiKey` — Replay API key for recording upload
|
|
372
377
|
- `options.lookupRequest(id)` — Fetches `{ blobUrl, commitSha, branchName, handlerPath }` from the database
|
|
373
378
|
- `options.updateStatus(id, status, recordingId?)` — Updates the request status in the database
|
|
374
379
|
|
package/dist/index.d.ts
CHANGED
|
@@ -95,13 +95,22 @@ interface BlobData {
|
|
|
95
95
|
interface FinishRequestCallbacks {
|
|
96
96
|
/** Uploads serialized captured data and returns the blob URL. */
|
|
97
97
|
uploadBlob: (data: string) => Promise<string>;
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Stores request metadata in the database and returns the request ID.
|
|
100
|
+
*
|
|
101
|
+
* When `requestId` is provided (from `createRecordingRequestHandler`'s
|
|
102
|
+
* `waitUntil` flow), the callback should use it as the row ID so the
|
|
103
|
+
* client-facing header and the stored record match. When omitted, the
|
|
104
|
+
* callback generates its own ID (backward-compatible).
|
|
105
|
+
*/
|
|
99
106
|
storeRequestData: (data: {
|
|
100
107
|
blobUrl: string;
|
|
101
108
|
commitSha: string;
|
|
102
109
|
branchName: string;
|
|
103
110
|
repositoryUrl: string;
|
|
104
111
|
handlerPath: string;
|
|
112
|
+
/** Pre-generated request ID. Use as the row ID when provided. */
|
|
113
|
+
requestId?: string;
|
|
105
114
|
}) => Promise<string>;
|
|
106
115
|
}
|
|
107
116
|
/**
|
|
@@ -126,7 +135,6 @@ interface ContainerInfraConfig {
|
|
|
126
135
|
}
|
|
127
136
|
interface EnsureRecordingOptions {
|
|
128
137
|
repositoryUrl: string;
|
|
129
|
-
replayApiKey: string;
|
|
130
138
|
/** Infrastructure credentials for starting the recording container. */
|
|
131
139
|
infraConfig?: ContainerInfraConfig;
|
|
132
140
|
/** Webhook URL the container can POST log entries to (optional). */
|
|
@@ -200,6 +208,15 @@ interface FinishRequestOptions {
|
|
|
200
208
|
branchName?: string;
|
|
201
209
|
/** Override REPLAY_REPOSITORY_URL env var. */
|
|
202
210
|
repositoryUrl?: string;
|
|
211
|
+
/**
|
|
212
|
+
* Pre-generated request ID. When provided, this ID is passed to the
|
|
213
|
+
* `storeRequestData` callback so the stored row matches the ID already
|
|
214
|
+
* sent to the client in the `X-Replay-Request-Id` header.
|
|
215
|
+
*
|
|
216
|
+
* Used by `createRecordingRequestHandler` in the `waitUntil` flow where
|
|
217
|
+
* the response is returned before `finishRequest` runs.
|
|
218
|
+
*/
|
|
219
|
+
requestId?: string;
|
|
203
220
|
}
|
|
204
221
|
/**
|
|
205
222
|
* Called at the end of the handler execution.
|
|
@@ -232,9 +249,14 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
|
|
|
232
249
|
* after, capturing all outbound network calls and environment variable reads.
|
|
233
250
|
* On error, interceptors are cleaned up and the error is re-thrown.
|
|
234
251
|
*
|
|
235
|
-
* **
|
|
236
|
-
* (
|
|
237
|
-
* `X-Replay-Request-Id` header
|
|
252
|
+
* **Response timing:** When the Netlify Functions v2 `context` object is
|
|
253
|
+
* available (with `waitUntil`), the response is returned to the client
|
|
254
|
+
* **immediately** with a pre-generated `X-Replay-Request-Id` header. The
|
|
255
|
+
* blob upload and metadata storage continue in the background via
|
|
256
|
+
* `context.waitUntil()`. This avoids adding latency to the client response.
|
|
257
|
+
*
|
|
258
|
+
* When `context.waitUntil` is not available (v1 handlers or missing context),
|
|
259
|
+
* the wrapper falls back to awaiting `finishRequest` before returning.
|
|
238
260
|
*
|
|
239
261
|
* For v2 handlers the request body is read from a clone internally — your
|
|
240
262
|
* handler still receives the original request with an unconsumed body.
|
|
@@ -380,8 +402,6 @@ interface SpawnRecordingContainerOptions {
|
|
|
380
402
|
branchName: string;
|
|
381
403
|
/** Git repository URL for the app. */
|
|
382
404
|
repositoryUrl: string;
|
|
383
|
-
/** Replay API key for uploading recordings. */
|
|
384
|
-
replayApiKey: string;
|
|
385
405
|
/** Infrastructure credentials for Fly.io + Infisical. */
|
|
386
406
|
infraConfig: ContainerInfraConfig;
|
|
387
407
|
/** Optional webhook URL the container can POST log events to. */
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
8
8
|
// src/interceptors/network.ts
|
|
9
9
|
function installNetworkInterceptor(mode, calls) {
|
|
10
10
|
const originalFetch = globalThis.fetch;
|
|
11
|
-
|
|
11
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
12
12
|
if (mode === "capture") {
|
|
13
13
|
const captureFetch = async (input, init) => {
|
|
14
14
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
@@ -40,16 +40,35 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
40
40
|
};
|
|
41
41
|
globalThis.fetch = captureFetch;
|
|
42
42
|
} else {
|
|
43
|
-
const replayFetch = async () => {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
43
|
+
const replayFetch = async (input, init) => {
|
|
44
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : typeof input === "object" && input !== null && "url" in input ? input.url : String(input);
|
|
45
|
+
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
46
|
+
let matchIdx = -1;
|
|
47
|
+
for (let i = 0; i < calls.length; i++) {
|
|
48
|
+
if (consumed.has(i)) continue;
|
|
49
|
+
const c = calls[i];
|
|
50
|
+
if (c && c.url === url && c.requestBody === requestBody) {
|
|
51
|
+
matchIdx = i;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (matchIdx === -1) {
|
|
56
|
+
for (let i = 0; i < calls.length; i++) {
|
|
57
|
+
if (!consumed.has(i)) {
|
|
58
|
+
matchIdx = i;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const call = calls[matchIdx];
|
|
64
|
+
if (matchIdx === -1 || !call) {
|
|
47
65
|
throw new Error(
|
|
48
66
|
`No more recorded network calls to replay (exhausted ${calls.length} calls)`
|
|
49
67
|
);
|
|
50
68
|
}
|
|
69
|
+
consumed.add(matchIdx);
|
|
51
70
|
console.log(
|
|
52
|
-
` [network-replay] Consumed call ${
|
|
71
|
+
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus}`
|
|
53
72
|
);
|
|
54
73
|
const body = call.responseBody ?? "";
|
|
55
74
|
const status = call.responseStatus;
|
|
@@ -88,10 +107,17 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
88
107
|
globalThis.fetch = originalFetch;
|
|
89
108
|
},
|
|
90
109
|
consumedCount() {
|
|
91
|
-
return
|
|
110
|
+
return consumed.size;
|
|
92
111
|
},
|
|
93
112
|
totalCount() {
|
|
94
113
|
return calls.length;
|
|
114
|
+
},
|
|
115
|
+
unconsumedIndices() {
|
|
116
|
+
const indices = [];
|
|
117
|
+
for (let i = 0; i < calls.length; i++) {
|
|
118
|
+
if (!consumed.has(i)) indices.push(i);
|
|
119
|
+
}
|
|
120
|
+
return indices;
|
|
95
121
|
}
|
|
96
122
|
};
|
|
97
123
|
}
|
|
@@ -427,7 +453,8 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
427
453
|
commitSha,
|
|
428
454
|
branchName,
|
|
429
455
|
repositoryUrl,
|
|
430
|
-
handlerPath
|
|
456
|
+
handlerPath,
|
|
457
|
+
requestId: options?.requestId
|
|
431
458
|
});
|
|
432
459
|
const storeDuration = Date.now() - storeStart;
|
|
433
460
|
if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
|
|
@@ -451,16 +478,43 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
451
478
|
}
|
|
452
479
|
|
|
453
480
|
// src/createRecordingRequestHandler.ts
|
|
481
|
+
import crypto from "crypto";
|
|
454
482
|
function createRecordingRequestHandler(handler, options) {
|
|
455
483
|
return async (event, context) => {
|
|
456
484
|
const reqContext = startRequest(event);
|
|
485
|
+
let response;
|
|
457
486
|
try {
|
|
458
|
-
|
|
459
|
-
return await finishRequest(reqContext, options.callbacks, response, options);
|
|
487
|
+
response = await handler(event, context);
|
|
460
488
|
} catch (err) {
|
|
461
489
|
reqContext.cleanup();
|
|
462
490
|
throw err;
|
|
463
491
|
}
|
|
492
|
+
reqContext.cleanup();
|
|
493
|
+
const requestId = crypto.randomUUID();
|
|
494
|
+
const responseWithHeader = {
|
|
495
|
+
...response,
|
|
496
|
+
headers: {
|
|
497
|
+
...response.headers,
|
|
498
|
+
"X-Replay-Request-Id": requestId
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const finishOpts = { ...options, requestId };
|
|
502
|
+
const ctx = context;
|
|
503
|
+
if (ctx && typeof ctx.waitUntil === "function") {
|
|
504
|
+
ctx.waitUntil(
|
|
505
|
+
finishRequest(reqContext, options.callbacks, response, finishOpts).catch(
|
|
506
|
+
(err) => {
|
|
507
|
+
console.error(
|
|
508
|
+
`netlify-recorder: background finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
|
|
509
|
+
err
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
);
|
|
514
|
+
return responseWithHeader;
|
|
515
|
+
}
|
|
516
|
+
await finishRequest(reqContext, options.callbacks, response, finishOpts);
|
|
517
|
+
return responseWithHeader;
|
|
464
518
|
};
|
|
465
519
|
}
|
|
466
520
|
|
|
@@ -491,7 +545,8 @@ function remoteCallbacks(serviceUrl) {
|
|
|
491
545
|
handlerPath: metadata.handlerPath,
|
|
492
546
|
commitSha: metadata.commitSha,
|
|
493
547
|
branchName: metadata.branchName,
|
|
494
|
-
repositoryUrl: metadata.repositoryUrl
|
|
548
|
+
repositoryUrl: metadata.repositoryUrl,
|
|
549
|
+
requestId: metadata.requestId
|
|
495
550
|
})
|
|
496
551
|
}
|
|
497
552
|
);
|
|
@@ -515,7 +570,6 @@ async function spawnRecordingContainer(options) {
|
|
|
515
570
|
commitSha,
|
|
516
571
|
branchName,
|
|
517
572
|
repositoryUrl,
|
|
518
|
-
replayApiKey,
|
|
519
573
|
infraConfig,
|
|
520
574
|
logWebhookUrl,
|
|
521
575
|
onLog
|
|
@@ -605,7 +659,7 @@ async function spawnRecordingContainer(options) {
|
|
|
605
659
|
`DB operations that were not in the original blob. Do NOT try to fix these.`,
|
|
606
660
|
``,
|
|
607
661
|
`=== Step 6: Upload the recording ===`,
|
|
608
|
-
`RECORD_REPLAY_API_KEY
|
|
662
|
+
`exec-secrets RECORD_REPLAY_API_KEY -- npx replayio upload --all 2>&1`,
|
|
609
663
|
``,
|
|
610
664
|
`Find the recording ID (UUID) in the upload output and print:`,
|
|
611
665
|
` recording: <recording-id>`,
|
|
@@ -671,7 +725,7 @@ async function spawnRecordingContainer(options) {
|
|
|
671
725
|
|
|
672
726
|
// src/ensureRequestRecording.ts
|
|
673
727
|
async function ensureRequestRecording(requestId, options) {
|
|
674
|
-
const { repositoryUrl,
|
|
728
|
+
const { repositoryUrl, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
|
|
675
729
|
const emit = async (level, message) => {
|
|
676
730
|
if (onLog) {
|
|
677
731
|
try {
|
|
@@ -696,7 +750,6 @@ async function ensureRequestRecording(requestId, options) {
|
|
|
696
750
|
commitSha: requestData.commitSha,
|
|
697
751
|
branchName: requestData.branchName,
|
|
698
752
|
repositoryUrl,
|
|
699
|
-
replayApiKey,
|
|
700
753
|
infraConfig,
|
|
701
754
|
logWebhookUrl: webhookUrl,
|
|
702
755
|
onLog
|
|
@@ -879,6 +932,12 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
879
932
|
this.headers = new H(init?.headers);
|
|
880
933
|
this.ok = this.status >= 200 && this.status < 300;
|
|
881
934
|
}
|
|
935
|
+
static json(data, init) {
|
|
936
|
+
const body = JSON.stringify(data);
|
|
937
|
+
const headers = init?.headers ?? {};
|
|
938
|
+
headers["content-type"] = "application/json";
|
|
939
|
+
return new ResponseShim(body, { ...init, headers });
|
|
940
|
+
}
|
|
882
941
|
async text() {
|
|
883
942
|
return this._body ?? "";
|
|
884
943
|
}
|
|
@@ -1002,17 +1061,18 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
1002
1061
|
}
|
|
1003
1062
|
}
|
|
1004
1063
|
} finally {
|
|
1005
|
-
const
|
|
1064
|
+
const consumedCount = networkHandle.consumedCount();
|
|
1006
1065
|
const total = networkHandle.totalCount();
|
|
1007
|
-
if (
|
|
1066
|
+
if (consumedCount < total) {
|
|
1008
1067
|
result.unconsumedNetworkCalls = true;
|
|
1068
|
+
const unconsumed = networkHandle.unconsumedIndices();
|
|
1009
1069
|
const details = [
|
|
1010
|
-
`Consumed ${
|
|
1070
|
+
`Consumed ${consumedCount} of ${total} calls. ${unconsumed.length} call(s) were never replayed.`
|
|
1011
1071
|
];
|
|
1012
1072
|
console.error(
|
|
1013
|
-
`ERROR: Not all recorded network calls were consumed during replay. Consumed ${
|
|
1073
|
+
`ERROR: Not all recorded network calls were consumed during replay. Consumed ${consumedCount} of ${total} calls. ${unconsumed.length} call(s) were never replayed.`
|
|
1014
1074
|
);
|
|
1015
|
-
for (
|
|
1075
|
+
for (const i of unconsumed) {
|
|
1016
1076
|
const call = blobData.capturedData.networkCalls[i];
|
|
1017
1077
|
if (call) {
|
|
1018
1078
|
details.push(`Unconsumed: ${call.method} ${call.url} => ${call.responseStatus}`);
|