@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 +50 -4
- package/dist/index.d.ts +123 -17
- package/dist/index.js +104 -20
- package/package.json +1 -1
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` —
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
137
|
-
*
|
|
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 |
|
|
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
|
-
*
|
|
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 |
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
1064
|
+
const consumedCount = networkHandle.consumedCount();
|
|
982
1065
|
const total = networkHandle.totalCount();
|
|
983
|
-
if (
|
|
1066
|
+
if (consumedCount < total) {
|
|
984
1067
|
result.unconsumedNetworkCalls = true;
|
|
1068
|
+
const unconsumed = networkHandle.unconsumedIndices();
|
|
985
1069
|
const details = [
|
|
986
|
-
`Consumed ${
|
|
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 ${
|
|
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 (
|
|
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}`);
|