@replayio-app-building/netlify-recorder 0.9.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
@@ -46,7 +46,9 @@ const repositoryUrl = execSync("git remote get-url origin", { encoding: "utf-8"
46
46
 
47
47
  ### 2. Wrap your Netlify function
48
48
 
49
- Use `createRecordingRequestHandler` with `remoteCallbacks()` to wrap your handler with automatic request capture:
49
+ Use `createRecordingRequestHandler` with `remoteCallbacks()` to wrap your handler with automatic request capture.
50
+
51
+ **v1 handler** (Netlify Functions v1 — `event` with `httpMethod`, `path`, etc.):
50
52
 
51
53
  ```typescript
52
54
  import {
@@ -75,8 +77,39 @@ const handler = createRecordingRequestHandler(
75
77
  export { handler };
76
78
  ```
77
79
 
80
+ **v2 handler** (Netlify Functions v2 — Web API `Request`):
81
+
82
+ ```typescript
83
+ import {
84
+ createRecordingRequestHandler,
85
+ remoteCallbacks,
86
+ } from "@replayio-app-building/netlify-recorder";
87
+
88
+ const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";
89
+
90
+ // The wrapper reads the body from a clone — you can still read req.json() etc.
91
+ export default createRecordingRequestHandler(
92
+ async (req) => {
93
+ const body = await (req as Request).json();
94
+ const result = await myBusinessLogic(body);
95
+
96
+ return {
97
+ statusCode: 200,
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify(result),
100
+ };
101
+ },
102
+ {
103
+ callbacks: remoteCallbacks(RECORDER_URL),
104
+ handlerPath: "netlify/functions/my-handler",
105
+ }
106
+ );
107
+ ```
108
+
78
109
  `createRecordingRequestHandler` automatically captures all outbound network calls and environment variable reads during your handler's execution, then sends the captured data to the Netlify Recorder service. The response includes an `X-Replay-Request-Id` header with the request ID managed by the service.
79
110
 
111
+ > **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.
112
+
80
113
  ### 2. Create recordings
81
114
 
82
115
  When you want to turn a captured request into a Replay recording, POST to the service's `create-recording` endpoint with the request ID:
@@ -204,7 +237,6 @@ const handler: Handler = async (event) => {
204
237
 
205
238
  const recordingId = await ensureRequestRecording(requestId, {
206
239
  repositoryUrl: process.env.APP_REPOSITORY_URL!,
207
- replayApiKey: process.env.RECORD_REPLAY_API_KEY!,
208
240
  lookupRequest: async (id) => {
209
241
  const [row] = await sql`
210
242
  SELECT blob_url, commit_sha, branch_name, handler_path
@@ -279,6 +311,10 @@ Self-hosted recording requires these environment variables:
279
311
 
280
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.
281
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
+
282
318
  **Parameters:**
283
319
  - `handler` — Your async handler function `(event, context?) => Promise<{ statusCode, headers?, body? }>`
284
320
  - `options.callbacks` — Either `remoteCallbacks(serviceUrl)` or custom `{ uploadBlob, storeRequestData }` callbacks
@@ -289,12 +325,18 @@ Wraps a Netlify handler function with automatic request recording. This is the r
289
325
 
290
326
  **Returns:** A wrapped handler function with the same signature.
291
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
+
292
330
  ### `startRequest(event): RequestContext`
293
331
 
294
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.
295
333
 
334
+ Accepts either a **v1** `NetlifyEvent` (has `httpMethod`) or a **v2** Web API `Request` (has `method` + `url`). The version is detected automatically.
335
+
336
+ For v2 Request inputs, the body is read from a **clone** — the original request body remains consumable by your handler.
337
+
296
338
  **Parameters:**
297
- - `event` — The Netlify handler event object (passed directly)
339
+ - `event` — A v1 `NetlifyEvent` or v2 `Request` object (passed directly — no need to clone)
298
340
 
299
341
  **Returns:** A `RequestContext` object to pass to `finishRequest`.
300
342
 
@@ -302,6 +344,10 @@ Lower-level API. Begins capturing a Netlify handler execution. Patches `globalTh
302
344
 
303
345
  Lower-level API. Finalizes the request capture. Restores original `fetch` and `process.env`, serializes the captured data, uploads it via the callbacks, and returns the response with `X-Replay-Request-Id` header set.
304
346
 
347
+ **Important:** You must send the returned response to the client — it contains the `X-Replay-Request-Id` header.
348
+
349
+ Logs a `console.warn` when the total duration exceeds 2 seconds or when individual callback steps (upload, store) exceed 1 second, to help diagnose slow operations.
350
+
305
351
  **Requires** the following environment variables (or equivalent options overrides): `COMMIT_SHA`, `BRANCH_NAME`, `REPLAY_REPOSITORY_URL`. Throws if any are missing.
306
352
 
307
353
  **Parameters:**
@@ -312,6 +358,7 @@ Lower-level API. Finalizes the request capture. Restores original `fetch` and `p
312
358
  - `options.commitSha` — Override `COMMIT_SHA` env var
313
359
  - `options.branchName` — Override `BRANCH_NAME` env var
314
360
  - `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
361
+ - `options.requestId` — Pre-generated request ID (used by `createRecordingRequestHandler` in the `waitUntil` flow)
315
362
 
316
363
  ### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`
317
364
 
@@ -327,7 +374,6 @@ Spawns a container via `@replayio/app-building` to create a Replay recording fro
327
374
  **Parameters:**
328
375
  - `requestId` — The request to create a recording for
329
376
  - `options.repositoryUrl` — Git repository URL for the container to clone
330
- - `options.replayApiKey` — Replay API key for recording upload
331
377
  - `options.lookupRequest(id)` — Fetches `{ blobUrl, commitSha, branchName, handlerPath }` from the database
332
378
  - `options.updateStatus(id, status, recordingId?)` — Updates the request status in the database
333
379
 
package/dist/index.d.ts CHANGED
@@ -28,6 +28,27 @@ interface NetlifyEvent {
28
28
  rawQuery?: string;
29
29
  isBase64Encoded?: boolean;
30
30
  }
31
+ /**
32
+ * Minimal interface for a Netlify Functions v2 Web API Request.
33
+ *
34
+ * This is a subset of the standard `Request` interface — only the properties
35
+ * that `startRequest` actually reads are required. Any spec-compliant
36
+ * `Request` object (including `new Request(...)`) satisfies this interface.
37
+ *
38
+ * `startRequest` reads the body from a **clone**, so the original request
39
+ * remains consumable by your handler. You do **not** need to clone the
40
+ * request before passing it in.
41
+ */
42
+ interface NetlifyV2Request {
43
+ readonly method: string;
44
+ readonly url: string;
45
+ readonly headers: Headers | {
46
+ forEach(cb: (value: string, key: string) => void): void;
47
+ };
48
+ readonly body: ReadableStream<Uint8Array> | null;
49
+ clone(): NetlifyV2Request;
50
+ text(): Promise<string>;
51
+ }
31
52
  interface RequestContext {
32
53
  requestInfo: RequestInfo;
33
54
  capturedData: CapturedData;
@@ -74,13 +95,22 @@ interface BlobData {
74
95
  interface FinishRequestCallbacks {
75
96
  /** Uploads serialized captured data and returns the blob URL. */
76
97
  uploadBlob: (data: string) => Promise<string>;
77
- /** 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
+ */
78
106
  storeRequestData: (data: {
79
107
  blobUrl: string;
80
108
  commitSha: string;
81
109
  branchName: string;
82
110
  repositoryUrl: string;
83
111
  handlerPath: string;
112
+ /** Pre-generated request ID. Use as the row ID when provided. */
113
+ requestId?: string;
84
114
  }) => Promise<string>;
85
115
  }
86
116
  /**
@@ -105,7 +135,6 @@ interface ContainerInfraConfig {
105
135
  }
106
136
  interface EnsureRecordingOptions {
107
137
  repositoryUrl: string;
108
- replayApiKey: string;
109
138
  /** Infrastructure credentials for starting the recording container. */
110
139
  infraConfig?: ContainerInfraConfig;
111
140
  /** Webhook URL the container can POST log entries to (optional). */
@@ -125,18 +154,46 @@ interface EnsureRecordingOptions {
125
154
 
126
155
  /**
127
156
  * Called at the beginning of a Netlify handler execution.
128
- * Accepts either a Netlify Functions v1 event (HandlerEvent with httpMethod,
129
- * path, etc.) or a v2 Web API Request. Extracts all request metadata
130
- * internally, installs interceptors on globalThis.fetch and process.env to
131
- * capture outbound network calls and environment variable reads, and returns
132
- * a request context used by finishRequest.
133
157
  *
134
- * When running inside createRequestRecording (replay mode), the replay
158
+ * Accepts either a **Netlify Functions v1** event (`NetlifyEvent` has
159
+ * `httpMethod`, `path`, etc.) or a **v2 Web API `Request`** object
160
+ * (`NetlifyV2Request`). The version is detected automatically: v1 events
161
+ * have an `httpMethod` property while v2 Request objects have `method` +
162
+ * `url`.
163
+ *
164
+ * **v2 body handling:** For v2 Request inputs the body is read from a
165
+ * **clone** of the request, so the original `Request` body remains
166
+ * consumable by your handler. You do **not** need to call `req.clone()`
167
+ * before passing it in. The body text is resolved asynchronously and
168
+ * captured when `finishRequest` is called.
169
+ *
170
+ * Installs interceptors on `globalThis.fetch` and `process.env` to capture
171
+ * outbound network calls and environment variable reads, and returns a
172
+ * request context that must be passed to `finishRequest`.
173
+ *
174
+ * When running inside `createRequestRecording` (replay mode), the replay
135
175
  * interceptors are already installed. In that case we skip installing
136
- * capture interceptors to avoid overwriting the replay layer (which would
137
- * also crash on Node v16 where Headers/Response don't exist).
176
+ * capture interceptors to avoid overwriting the replay layer.
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * // v1 handler
181
+ * export async function handler(event: NetlifyEvent) {
182
+ * const ctx = startRequest(event);
183
+ * // ... your logic ...
184
+ * return finishRequest(ctx, callbacks, response);
185
+ * }
186
+ *
187
+ * // v2 handler — no need to clone the request
188
+ * export default async function handler(req: Request) {
189
+ * const ctx = startRequest(req);
190
+ * const body = await req.json(); // still works — body was NOT consumed
191
+ * // ... your logic ...
192
+ * return finishRequest(ctx, callbacks, response);
193
+ * }
194
+ * ```
138
195
  */
139
- declare function startRequest(event: NetlifyEvent | /* v2 Request */ Record<string, unknown>): RequestContext;
196
+ declare function startRequest(event: NetlifyEvent | NetlifyV2Request): RequestContext;
140
197
 
141
198
  interface FullHandlerResponse {
142
199
  statusCode: number;
@@ -151,12 +208,29 @@ interface FinishRequestOptions {
151
208
  branchName?: string;
152
209
  /** Override REPLAY_REPOSITORY_URL env var. */
153
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;
154
220
  }
155
221
  /**
156
222
  * Called at the end of the handler execution.
157
223
  * Restores original globals, serializes all captured data,
158
224
  * uploads it as a JSON blob via the provided callback,
159
225
  * stores the request metadata, and sets the X-Replay-Request-Id header.
226
+ *
227
+ * **Important:** The returned response includes the `X-Replay-Request-Id`
228
+ * header. You must send the returned response to the client — not the
229
+ * original response object you passed in.
230
+ *
231
+ * Logs a warning to `console.warn` when the total finishRequest time or
232
+ * individual callback steps exceed their thresholds, to help diagnose
233
+ * slow blob uploads or database writes.
160
234
  */
161
235
  declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
162
236
 
@@ -175,13 +249,25 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
175
249
  * after, capturing all outbound network calls and environment variable reads.
176
250
  * On error, interceptors are cleaned up and the error is re-thrown.
177
251
  *
178
- * @example
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.
260
+ *
261
+ * For v2 handlers the request body is read from a clone internally — your
262
+ * handler still receives the original request with an unconsumed body.
263
+ *
264
+ * @example v1 handler (NetlifyEvent)
179
265
  * ```typescript
180
266
  * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
181
267
  *
182
268
  * const handler = createRecordingRequestHandler(
183
269
  * async (event) => {
184
- * const result = await myBusinessLogic();
270
+ * const result = await myBusinessLogic(event.body);
185
271
  * return {
186
272
  * statusCode: 200,
187
273
  * headers: { "Content-Type": "application/json" },
@@ -196,8 +282,30 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
196
282
  *
197
283
  * export { handler };
198
284
  * ```
285
+ *
286
+ * @example v2 handler (Web API Request)
287
+ * ```typescript
288
+ * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
289
+ *
290
+ * export default createRecordingRequestHandler(
291
+ * async (req) => {
292
+ * // Body is still available — startRequest reads from a clone
293
+ * const body = await (req as Request).json();
294
+ * const result = await myBusinessLogic(body);
295
+ * return {
296
+ * statusCode: 200,
297
+ * headers: { "Content-Type": "application/json" },
298
+ * body: JSON.stringify(result),
299
+ * };
300
+ * },
301
+ * {
302
+ * callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
303
+ * handlerPath: "netlify/functions/my-handler",
304
+ * }
305
+ * );
306
+ * ```
199
307
  */
200
- declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | Record<string, unknown>, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | Record<string, unknown>, context?: unknown) => Promise<HandlerResponse>;
308
+ declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>;
201
309
 
202
310
  /**
203
311
  * Creates `FinishRequestCallbacks` that send captured data to a remote
@@ -294,8 +402,6 @@ interface SpawnRecordingContainerOptions {
294
402
  branchName: string;
295
403
  /** Git repository URL for the app. */
296
404
  repositoryUrl: string;
297
- /** Replay API key for uploading recordings. */
298
- replayApiKey: string;
299
405
  /** Infrastructure credentials for Fly.io + Infisical. */
300
406
  infraConfig: ContainerInfraConfig;
301
407
  /** Optional webhook URL the container can POST log events to. */
@@ -315,4 +421,4 @@ interface SpawnRecordingContainerOptions {
315
421
  */
316
422
  declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
317
423
 
318
- 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 NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRecordingRequestHandler, createRequestRecording, ensureRequestRecording, finishRequest, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
424
+ 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, ensureRequestRecording, finishRequest, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
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
  }
@@ -367,7 +393,10 @@ function redactBlobData(blobData) {
367
393
  }
368
394
 
369
395
  // src/finishRequest.ts
396
+ var SLOW_THRESHOLD_MS = 2e3;
397
+ var SLOW_STEP_THRESHOLD_MS = 1e3;
370
398
  async function finishRequest(requestContext, callbacks, response, options) {
399
+ const finishStart = Date.now();
371
400
  requestContext.cleanup();
372
401
  if (globalThis.__REPLAY_RECORDING_MODE__ === true) {
373
402
  return response;
@@ -387,6 +416,7 @@ async function finishRequest(requestContext, callbacks, response, options) {
387
416
  const commitSha = rawCommitSha;
388
417
  const branchName = rawBranchName;
389
418
  const repositoryUrl = rawRepositoryUrl;
419
+ const handlerPath = options?.handlerPath ?? "unknown";
390
420
  if (requestContext._v2BodyPromise && !requestContext.requestInfo.body) {
391
421
  try {
392
422
  const bodyText = await requestContext._v2BodyPromise;
@@ -409,14 +439,35 @@ async function finishRequest(requestContext, callbacks, response, options) {
409
439
  };
410
440
  const blobData = redactBlobData(rawBlobData);
411
441
  const blobContent = JSON.stringify(blobData);
442
+ const uploadStart = Date.now();
412
443
  const blobUrl = await callbacks.uploadBlob(blobContent);
444
+ const uploadDuration = Date.now() - uploadStart;
445
+ if (uploadDuration > SLOW_STEP_THRESHOLD_MS) {
446
+ console.warn(
447
+ `netlify-recorder: uploadBlob took ${uploadDuration}ms (handler: ${handlerPath})`
448
+ );
449
+ }
450
+ const storeStart = Date.now();
413
451
  const storedRequestId = await callbacks.storeRequestData({
414
452
  blobUrl,
415
453
  commitSha,
416
454
  branchName,
417
455
  repositoryUrl,
418
- handlerPath: options?.handlerPath ?? "unknown"
456
+ handlerPath,
457
+ requestId: options?.requestId
419
458
  });
459
+ const storeDuration = Date.now() - storeStart;
460
+ if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
461
+ console.warn(
462
+ `netlify-recorder: storeRequestData took ${storeDuration}ms (handler: ${handlerPath})`
463
+ );
464
+ }
465
+ const totalDuration = Date.now() - finishStart;
466
+ if (totalDuration > SLOW_THRESHOLD_MS) {
467
+ console.warn(
468
+ `netlify-recorder: finishRequest took ${totalDuration}ms total (upload: ${uploadDuration}ms, store: ${storeDuration}ms, handler: ${handlerPath})`
469
+ );
470
+ }
420
471
  return {
421
472
  ...response,
422
473
  headers: {
@@ -427,16 +478,43 @@ async function finishRequest(requestContext, callbacks, response, options) {
427
478
  }
428
479
 
429
480
  // src/createRecordingRequestHandler.ts
481
+ import crypto from "crypto";
430
482
  function createRecordingRequestHandler(handler, options) {
431
483
  return async (event, context) => {
432
484
  const reqContext = startRequest(event);
485
+ let response;
433
486
  try {
434
- const response = await handler(event, context);
435
- return await finishRequest(reqContext, options.callbacks, response, options);
487
+ response = await handler(event, context);
436
488
  } catch (err) {
437
489
  reqContext.cleanup();
438
490
  throw err;
439
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;
440
518
  };
441
519
  }
442
520
 
@@ -467,7 +545,8 @@ function remoteCallbacks(serviceUrl) {
467
545
  handlerPath: metadata.handlerPath,
468
546
  commitSha: metadata.commitSha,
469
547
  branchName: metadata.branchName,
470
- repositoryUrl: metadata.repositoryUrl
548
+ repositoryUrl: metadata.repositoryUrl,
549
+ requestId: metadata.requestId
471
550
  })
472
551
  }
473
552
  );
@@ -491,7 +570,6 @@ async function spawnRecordingContainer(options) {
491
570
  commitSha,
492
571
  branchName,
493
572
  repositoryUrl,
494
- replayApiKey,
495
573
  infraConfig,
496
574
  logWebhookUrl,
497
575
  onLog
@@ -581,7 +659,7 @@ async function spawnRecordingContainer(options) {
581
659
  `DB operations that were not in the original blob. Do NOT try to fix these.`,
582
660
  ``,
583
661
  `=== Step 6: Upload the recording ===`,
584
- `RECORD_REPLAY_API_KEY=${replayApiKey} npx replayio upload --all 2>&1`,
662
+ `exec-secrets RECORD_REPLAY_API_KEY -- npx replayio upload --all 2>&1`,
585
663
  ``,
586
664
  `Find the recording ID (UUID) in the upload output and print:`,
587
665
  ` recording: <recording-id>`,
@@ -647,7 +725,7 @@ async function spawnRecordingContainer(options) {
647
725
 
648
726
  // src/ensureRequestRecording.ts
649
727
  async function ensureRequestRecording(requestId, options) {
650
- const { repositoryUrl, replayApiKey, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
728
+ const { repositoryUrl, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
651
729
  const emit = async (level, message) => {
652
730
  if (onLog) {
653
731
  try {
@@ -672,7 +750,6 @@ async function ensureRequestRecording(requestId, options) {
672
750
  commitSha: requestData.commitSha,
673
751
  branchName: requestData.branchName,
674
752
  repositoryUrl,
675
- replayApiKey,
676
753
  infraConfig,
677
754
  logWebhookUrl: webhookUrl,
678
755
  onLog
@@ -855,6 +932,12 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
855
932
  this.headers = new H(init?.headers);
856
933
  this.ok = this.status >= 200 && this.status < 300;
857
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
+ }
858
941
  async text() {
859
942
  return this._body ?? "";
860
943
  }
@@ -978,17 +1061,18 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
978
1061
  }
979
1062
  }
980
1063
  } finally {
981
- const consumed = networkHandle.consumedCount();
1064
+ const consumedCount = networkHandle.consumedCount();
982
1065
  const total = networkHandle.totalCount();
983
- if (consumed < total) {
1066
+ if (consumedCount < total) {
984
1067
  result.unconsumedNetworkCalls = true;
1068
+ const unconsumed = networkHandle.unconsumedIndices();
985
1069
  const details = [
986
- `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.`
987
1071
  ];
988
1072
  console.error(
989
- `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.`
990
1074
  );
991
- for (let i = consumed; i < total; i++) {
1075
+ for (const i of unconsumed) {
992
1076
  const call = blobData.capturedData.networkCalls[i];
993
1077
  if (call) {
994
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.9.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": {