@replayio-app-building/netlify-recorder 0.9.0 → 0.10.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:
@@ -293,8 +326,12 @@ Wraps a Netlify handler function with automatic request recording. This is the r
293
326
 
294
327
  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
328
 
329
+ Accepts either a **v1** `NetlifyEvent` (has `httpMethod`) or a **v2** Web API `Request` (has `method` + `url`). The version is detected automatically.
330
+
331
+ For v2 Request inputs, the body is read from a **clone** — the original request body remains consumable by your handler.
332
+
296
333
  **Parameters:**
297
- - `event` — The Netlify handler event object (passed directly)
334
+ - `event` — A v1 `NetlifyEvent` or v2 `Request` object (passed directly — no need to clone)
298
335
 
299
336
  **Returns:** A `RequestContext` object to pass to `finishRequest`.
300
337
 
@@ -302,6 +339,10 @@ Lower-level API. Begins capturing a Netlify handler execution. Patches `globalTh
302
339
 
303
340
  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
341
 
342
+ **Important:** You must send the returned response to the client — it contains the `X-Replay-Request-Id` header.
343
+
344
+ 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.
345
+
305
346
  **Requires** the following environment variables (or equivalent options overrides): `COMMIT_SHA`, `BRANCH_NAME`, `REPLAY_REPOSITORY_URL`. Throws if any are missing.
306
347
 
307
348
  **Parameters:**
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;
@@ -125,18 +146,46 @@ interface EnsureRecordingOptions {
125
146
 
126
147
  /**
127
148
  * 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
149
  *
134
- * When running inside createRequestRecording (replay mode), the replay
150
+ * Accepts either a **Netlify Functions v1** event (`NetlifyEvent` has
151
+ * `httpMethod`, `path`, etc.) or a **v2 Web API `Request`** object
152
+ * (`NetlifyV2Request`). The version is detected automatically: v1 events
153
+ * have an `httpMethod` property while v2 Request objects have `method` +
154
+ * `url`.
155
+ *
156
+ * **v2 body handling:** For v2 Request inputs the body is read from a
157
+ * **clone** of the request, so the original `Request` body remains
158
+ * consumable by your handler. You do **not** need to call `req.clone()`
159
+ * before passing it in. The body text is resolved asynchronously and
160
+ * captured when `finishRequest` is called.
161
+ *
162
+ * Installs interceptors on `globalThis.fetch` and `process.env` to capture
163
+ * outbound network calls and environment variable reads, and returns a
164
+ * request context that must be passed to `finishRequest`.
165
+ *
166
+ * When running inside `createRequestRecording` (replay mode), the replay
135
167
  * 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).
168
+ * capture interceptors to avoid overwriting the replay layer.
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * // v1 handler
173
+ * export async function handler(event: NetlifyEvent) {
174
+ * const ctx = startRequest(event);
175
+ * // ... your logic ...
176
+ * return finishRequest(ctx, callbacks, response);
177
+ * }
178
+ *
179
+ * // v2 handler — no need to clone the request
180
+ * export default async function handler(req: Request) {
181
+ * const ctx = startRequest(req);
182
+ * const body = await req.json(); // still works — body was NOT consumed
183
+ * // ... your logic ...
184
+ * return finishRequest(ctx, callbacks, response);
185
+ * }
186
+ * ```
138
187
  */
139
- declare function startRequest(event: NetlifyEvent | /* v2 Request */ Record<string, unknown>): RequestContext;
188
+ declare function startRequest(event: NetlifyEvent | NetlifyV2Request): RequestContext;
140
189
 
141
190
  interface FullHandlerResponse {
142
191
  statusCode: number;
@@ -157,6 +206,14 @@ interface FinishRequestOptions {
157
206
  * Restores original globals, serializes all captured data,
158
207
  * uploads it as a JSON blob via the provided callback,
159
208
  * stores the request metadata, and sets the X-Replay-Request-Id header.
209
+ *
210
+ * **Important:** The returned response includes the `X-Replay-Request-Id`
211
+ * header. You must send the returned response to the client — not the
212
+ * original response object you passed in.
213
+ *
214
+ * Logs a warning to `console.warn` when the total finishRequest time or
215
+ * individual callback steps exceed their thresholds, to help diagnose
216
+ * slow blob uploads or database writes.
160
217
  */
161
218
  declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
162
219
 
@@ -175,13 +232,20 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
175
232
  * after, capturing all outbound network calls and environment variable reads.
176
233
  * On error, interceptors are cleaned up and the error is re-thrown.
177
234
  *
178
- * @example
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`.
238
+ *
239
+ * For v2 handlers the request body is read from a clone internally — your
240
+ * handler still receives the original request with an unconsumed body.
241
+ *
242
+ * @example v1 handler (NetlifyEvent)
179
243
  * ```typescript
180
244
  * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
181
245
  *
182
246
  * const handler = createRecordingRequestHandler(
183
247
  * async (event) => {
184
- * const result = await myBusinessLogic();
248
+ * const result = await myBusinessLogic(event.body);
185
249
  * return {
186
250
  * statusCode: 200,
187
251
  * headers: { "Content-Type": "application/json" },
@@ -196,8 +260,30 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
196
260
  *
197
261
  * export { handler };
198
262
  * ```
263
+ *
264
+ * @example v2 handler (Web API Request)
265
+ * ```typescript
266
+ * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
267
+ *
268
+ * export default createRecordingRequestHandler(
269
+ * async (req) => {
270
+ * // Body is still available — startRequest reads from a clone
271
+ * const body = await (req as Request).json();
272
+ * const result = await myBusinessLogic(body);
273
+ * return {
274
+ * statusCode: 200,
275
+ * headers: { "Content-Type": "application/json" },
276
+ * body: JSON.stringify(result),
277
+ * };
278
+ * },
279
+ * {
280
+ * callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
281
+ * handlerPath: "netlify/functions/my-handler",
282
+ * }
283
+ * );
284
+ * ```
199
285
  */
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>;
286
+ declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>;
201
287
 
202
288
  /**
203
289
  * Creates `FinishRequestCallbacks` that send captured data to a remote
@@ -315,4 +401,4 @@ interface SpawnRecordingContainerOptions {
315
401
  */
316
402
  declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
317
403
 
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 };
404
+ 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
@@ -367,7 +367,10 @@ function redactBlobData(blobData) {
367
367
  }
368
368
 
369
369
  // src/finishRequest.ts
370
+ var SLOW_THRESHOLD_MS = 2e3;
371
+ var SLOW_STEP_THRESHOLD_MS = 1e3;
370
372
  async function finishRequest(requestContext, callbacks, response, options) {
373
+ const finishStart = Date.now();
371
374
  requestContext.cleanup();
372
375
  if (globalThis.__REPLAY_RECORDING_MODE__ === true) {
373
376
  return response;
@@ -387,6 +390,7 @@ async function finishRequest(requestContext, callbacks, response, options) {
387
390
  const commitSha = rawCommitSha;
388
391
  const branchName = rawBranchName;
389
392
  const repositoryUrl = rawRepositoryUrl;
393
+ const handlerPath = options?.handlerPath ?? "unknown";
390
394
  if (requestContext._v2BodyPromise && !requestContext.requestInfo.body) {
391
395
  try {
392
396
  const bodyText = await requestContext._v2BodyPromise;
@@ -409,14 +413,34 @@ async function finishRequest(requestContext, callbacks, response, options) {
409
413
  };
410
414
  const blobData = redactBlobData(rawBlobData);
411
415
  const blobContent = JSON.stringify(blobData);
416
+ const uploadStart = Date.now();
412
417
  const blobUrl = await callbacks.uploadBlob(blobContent);
418
+ const uploadDuration = Date.now() - uploadStart;
419
+ if (uploadDuration > SLOW_STEP_THRESHOLD_MS) {
420
+ console.warn(
421
+ `netlify-recorder: uploadBlob took ${uploadDuration}ms (handler: ${handlerPath})`
422
+ );
423
+ }
424
+ const storeStart = Date.now();
413
425
  const storedRequestId = await callbacks.storeRequestData({
414
426
  blobUrl,
415
427
  commitSha,
416
428
  branchName,
417
429
  repositoryUrl,
418
- handlerPath: options?.handlerPath ?? "unknown"
430
+ handlerPath
419
431
  });
432
+ const storeDuration = Date.now() - storeStart;
433
+ if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
434
+ console.warn(
435
+ `netlify-recorder: storeRequestData took ${storeDuration}ms (handler: ${handlerPath})`
436
+ );
437
+ }
438
+ const totalDuration = Date.now() - finishStart;
439
+ if (totalDuration > SLOW_THRESHOLD_MS) {
440
+ console.warn(
441
+ `netlify-recorder: finishRequest took ${totalDuration}ms total (upload: ${uploadDuration}ms, store: ${storeDuration}ms, handler: ${handlerPath})`
442
+ );
443
+ }
420
444
  return {
421
445
  ...response,
422
446
  headers: {
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.10.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {