@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 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
- /** Stores request metadata in the database and returns the request ID. */
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
- * **Important:** You must use the response returned by the wrapped handler
236
- * (not your original response object), because it includes the
237
- * `X-Replay-Request-Id` header set by `finishRequest`.
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
- let replayCallIndex = 0;
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 idx = replayCallIndex++;
45
- const call = calls[idx];
46
- if (!call) {
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 ${idx + 1}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus}`
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 replayCallIndex;
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
- const response = await handler(event, context);
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=${replayApiKey} npx replayio upload --all 2>&1`,
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, replayApiKey, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
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 consumed = networkHandle.consumedCount();
1064
+ const consumedCount = networkHandle.consumedCount();
1006
1065
  const total = networkHandle.totalCount();
1007
- if (consumed < total) {
1066
+ if (consumedCount < total) {
1008
1067
  result.unconsumedNetworkCalls = true;
1068
+ const unconsumed = networkHandle.unconsumedIndices();
1009
1069
  const details = [
1010
- `Consumed ${consumed} of ${total} calls. ${total - consumed} call(s) were never replayed.`
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 ${consumed} of ${total} calls. ${total - consumed} call(s) were never replayed.`
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 (let i = consumed; i < total; i++) {
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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {