@replayio-app-building/netlify-recorder 0.18.0 → 0.20.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
@@ -114,47 +114,40 @@ export default createRecordingRequestHandler(
114
114
 
115
115
  ### 4. Create recordings via the Netlify Recorder service
116
116
 
117
- When you want to turn a captured request into a Replay recording, POST to the Netlify Recorder service's `create-recording` endpoint. You need to provide a `blobDataUrl` a publicly accessible URL where the recording container can fetch the captured blob data.
118
-
119
- If your app exposes the blob data via an endpoint (e.g. using `backendRequestsGetBlobData`), construct the URL and pass it:
117
+ Use `ensureRequestRecording` to turn a captured request into a Replay recording. It checks the `backend_requests` table first — if a recording already exists, it returns the recording ID immediately without calling the service. Otherwise it dispatches the work to the Netlify Recorder service and updates the row status to `"queued"`.
120
118
 
121
119
  ```typescript
120
+ import { ensureRequestRecording } from "@replayio-app-building/netlify-recorder";
121
+
122
122
  const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";
123
- const blobDataUrl = `https://your-app.netlify.app/api/get-backend-request-blob?requestId=${requestId}`;
124
123
 
125
- const response = await fetch(
126
- `${RECORDER_URL}/api/create-recording`,
127
- {
128
- method: "POST",
129
- headers: { "Content-Type": "application/json" },
130
- body: JSON.stringify({
131
- blobDataUrl,
132
- handlerPath: "netlify/functions/my-handler",
133
- commitSha: "abc123",
134
- branchName: "main",
135
- repositoryUrl: "https://github.com/org/repo.git", // optional
136
- webhookUrl: "https://your-app.netlify.app/api/on-recording-complete", // optional
137
- }),
138
- }
139
- );
124
+ const recordingId = await ensureRequestRecording(sql, requestId, {
125
+ recorderUrl: RECORDER_URL,
126
+ });
140
127
 
141
- const { requestId: serviceRequestId } = await response.json();
128
+ if (recordingId) {
129
+ console.log(`Recording already exists: ${recordingId}`);
130
+ } else {
131
+ console.log("Recording queued — check back later or use a webhook");
132
+ }
142
133
  ```
143
134
 
144
- The `create-recording` endpoint accepts:
135
+ The function is idempotent — calling it multiple times for the same request is safe:
136
+ - **`status: "recorded"`** — returns the `recording_id` immediately
137
+ - **`status: "queued"` or `"processing"`** — returns `null` without re-queuing
138
+ - **`status: "captured"` or `"failed"`** — calls the service, updates status to `"queued"`, returns `null`
145
139
 
146
- | Parameter | Required | Description |
147
- |---|---|---|
148
- | `blobDataUrl` | Yes | URL where the recording container can fetch the captured blob JSON |
149
- | `handlerPath` | Yes | Path to the handler file (e.g. `netlify/functions/my-handler`) |
150
- | `commitSha` | Yes | Git commit SHA of the deployed code |
151
- | `branchName` | Yes | Git branch of the deployed code |
152
- | `repositoryUrl` | No | Git repository URL for the container to clone |
153
- | `webhookUrl` | No | URL to POST the result to when the recording completes or fails |
140
+ By default, the blob data URL points to `${recorderUrl}/api/get-backend-request-blob?requestId=${requestId}`. If your app serves blob data from a different endpoint, pass a custom `blobDataUrl`:
154
141
 
155
- The service dispatches the work to a recording container, which fetches the blob data from `blobDataUrl`, replays the handler execution under `replay-node`, and produces a Replay recording.
142
+ ```typescript
143
+ const recordingId = await ensureRequestRecording(sql, requestId, {
144
+ recorderUrl: RECORDER_URL,
145
+ blobDataUrl: `https://your-app.netlify.app/api/get-blob?id=${requestId}`,
146
+ webhookUrl: "https://your-app.netlify.app/api/on-recording-complete", // optional
147
+ });
148
+ ```
156
149
 
157
- If `webhookUrl` is provided, the service will POST the result to that URL when the recording completes (or fails):
150
+ When `webhookUrl` is provided, the service POSTs the result when the recording completes:
158
151
 
159
152
  On success:
160
153
  ```json
@@ -410,6 +403,21 @@ Updates the status of a request. Optionally sets `recording_id` (on success) or
410
403
  - `recordingId` — Replay recording ID (optional, for `"recorded"` status)
411
404
  - `errorMessage` — Error details (optional, for `"failed"` status)
412
405
 
406
+ ### `ensureRequestRecording(sql, requestId, options): Promise<string | null>`
407
+
408
+ Ensures a Replay recording exists (or is being created) for a backend request. Looks up the request in `backend_requests`, returns the `recording_id` if already recorded, or calls the Netlify Recorder service to queue a recording. Idempotent — safe to call multiple times for the same request.
409
+
410
+ **Parameters:**
411
+ - `sql` — A Neon SQL tagged-template function
412
+ - `requestId` — The backend request UUID
413
+ - `options.recorderUrl` — Base URL of the Netlify Recorder service
414
+ - `options.blobDataUrl` — Override the URL where the recording container fetches the blob JSON (defaults to `${recorderUrl}/api/get-backend-request-blob?requestId=${requestId}`)
415
+ - `options.webhookUrl` — URL to POST the result to when the recording completes or fails
416
+
417
+ **Returns:** The recording ID (`string`) if the request is already recorded, or `null` if the recording was queued or is in progress.
418
+
419
+ **Throws:** If the request ID is not found in `backend_requests`, or if the service call fails.
420
+
413
421
  ### `createRequestRecording(blobUrlOrData, handlerPath, requestInfo): Promise<RecordingResult>`
414
422
 
415
423
  Called inside a recording container running under `replay-node`. Downloads the captured data blob (or accepts pre-parsed `BlobData`), installs replay-mode interceptors that return pre-recorded responses instead of making real calls, and executes the original handler so `replay-node` can record the execution.
@@ -456,13 +464,14 @@ Creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger func
456
464
  **Parameters:**
457
465
  - `sql` — A Neon SQL tagged-template function
458
466
 
459
- ### `databaseAuditMonitorTable(sql, tableName): Promise<void>`
467
+ ### `databaseAuditMonitorTable(sql, tableName, primaryKeyColumn?): Promise<void>`
460
468
 
461
469
  Attaches a trigger to the specified table that logs INSERT, UPDATE, and DELETE operations to the `audit_log` table. Throws if `tableName` is `'audit_log'`.
462
470
 
463
471
  **Parameters:**
464
472
  - `sql` — A Neon SQL tagged-template function
465
473
  - `tableName` — Name of the table to monitor (must match `^[a-zA-Z_][a-zA-Z0-9_]*$`)
474
+ - `primaryKeyColumn` — Name of the primary key column (default: `"id"`, must match `^[a-zA-Z_][a-zA-Z0-9_]*$`)
466
475
 
467
476
  ### `databaseAuditDumpLogTable(sql): Promise<Record<string, unknown>[]>`
468
477
 
package/dist/index.d.ts CHANGED
@@ -292,6 +292,30 @@ declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, sta
292
292
  * captured request data directly in the `backend_requests` table.
293
293
  */
294
294
  declare function databaseCallbacks(sql: SqlFunction$1): FinishRequestCallbacks;
295
+ interface EnsureRequestRecordingOptions {
296
+ /** Base URL of the Netlify Recorder service (e.g. "https://netlify-recorder-bm4wmw.netlify.app"). */
297
+ recorderUrl: string;
298
+ /**
299
+ * Full URL where the recording container can fetch the blob JSON for this request.
300
+ * Defaults to `${recorderUrl}/api/get-backend-request-blob?requestId=${requestId}`.
301
+ */
302
+ blobDataUrl?: string;
303
+ /** URL to POST the result to when the recording completes or fails. */
304
+ webhookUrl?: string;
305
+ }
306
+ /**
307
+ * Ensures a Replay recording exists (or is being created) for a backend request.
308
+ *
309
+ * 1. Looks up the request in `backend_requests`.
310
+ * 2. If a recording already exists (`status === "recorded"`), returns the recording ID immediately.
311
+ * 3. If the request is already queued or processing, returns `null` without re-queuing.
312
+ * 4. Otherwise, calls the Netlify Recorder service's `create-recording` endpoint,
313
+ * updates the row to `"queued"`, and returns `null`.
314
+ *
315
+ * This function is idempotent — calling it multiple times for the same request
316
+ * is safe. Once the recording completes, subsequent calls return the recording ID.
317
+ */
318
+ declare function ensureRequestRecording(sql: SqlFunction$1, requestId: string, options: EnsureRequestRecordingOptions): Promise<string | null>;
295
319
 
296
320
  type SqlFunction = (...args: any[]) => Promise<any[]>;
297
321
  /**
@@ -308,7 +332,7 @@ declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
308
332
  *
309
333
  * Throws if `tableName` is `'audit_log'` (cannot monitor itself).
310
334
  */
311
- declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string): Promise<void>;
335
+ declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string, primaryKeyColumn?: string): Promise<void>;
312
336
  /**
313
337
  * Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
314
338
  */
@@ -316,4 +340,4 @@ declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<str
316
340
 
317
341
  declare function getCurrentRequestId(): string | null;
318
342
 
319
- export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobData, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, finishRequest, getCurrentRequestId, redactBlobData, startRequest };
343
+ export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobData, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, startRequest };
package/dist/index.js CHANGED
@@ -1034,6 +1034,40 @@ function databaseCallbacks(sql) {
1034
1034
  }
1035
1035
  };
1036
1036
  }
1037
+ async function ensureRequestRecording(sql, requestId, options) {
1038
+ const request = await backendRequestsGet(sql, requestId);
1039
+ if (!request) {
1040
+ throw new Error(`Backend request ${requestId} not found`);
1041
+ }
1042
+ if (request.status === "recorded" && request.recording_id) {
1043
+ return request.recording_id;
1044
+ }
1045
+ if (request.status === "queued" || request.status === "processing") {
1046
+ return null;
1047
+ }
1048
+ const recorderUrl = options.recorderUrl.replace(/\/+$/, "");
1049
+ const blobDataUrl = options.blobDataUrl ?? `${recorderUrl}/api/get-backend-request-blob?requestId=${requestId}`;
1050
+ const res = await fetch(`${recorderUrl}/api/create-recording`, {
1051
+ method: "POST",
1052
+ headers: { "Content-Type": "application/json" },
1053
+ body: JSON.stringify({
1054
+ blobDataUrl,
1055
+ handlerPath: request.handler_path,
1056
+ commitSha: request.commit_sha,
1057
+ branchName: request.branch_name,
1058
+ repositoryUrl: request.repository_url ?? void 0,
1059
+ webhookUrl: options.webhookUrl
1060
+ })
1061
+ });
1062
+ if (!res.ok) {
1063
+ const errBody = await res.text().catch(() => "(unreadable)");
1064
+ throw new Error(
1065
+ `Netlify Recorder create-recording failed: ${res.status} ${errBody}`
1066
+ );
1067
+ }
1068
+ await backendRequestsUpdateStatus(sql, requestId, "queued");
1069
+ return null;
1070
+ }
1037
1071
 
1038
1072
  // src/databaseAudit.ts
1039
1073
  async function databaseAuditEnsureLogTable(sql) {
@@ -1061,7 +1095,12 @@ async function databaseAuditEnsureLogTable(sql) {
1061
1095
  changed_cols TEXT[];
1062
1096
  req_id TEXT;
1063
1097
  call_idx INTEGER;
1098
+ pk_col TEXT;
1099
+ pk_val TEXT;
1064
1100
  BEGIN
1101
+ -- Primary key column name passed as trigger argument; default to 'id'
1102
+ pk_col := COALESCE(TG_ARGV[0], 'id');
1103
+
1065
1104
  -- Read application context injected by the network interceptor
1066
1105
  req_id := COALESCE(current_setting('app.replay_request_id', true), '');
1067
1106
  IF req_id = '' THEN req_id := NULL; END IF;
@@ -1073,21 +1112,24 @@ async function databaseAuditEnsureLogTable(sql) {
1073
1112
  END;
1074
1113
 
1075
1114
  IF TG_OP = 'INSERT' THEN
1115
+ EXECUTE format('SELECT ($1).%I::TEXT', pk_col) USING NEW INTO pk_val;
1076
1116
  INSERT INTO audit_log (table_name, record_id, action, new_data, replay_request_id, replay_request_call_index)
1077
- VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW), req_id, call_idx);
1117
+ VALUES (TG_TABLE_NAME, pk_val, 'INSERT', to_jsonb(NEW), req_id, call_idx);
1078
1118
  RETURN NEW;
1079
1119
  ELSIF TG_OP = 'UPDATE' THEN
1120
+ EXECUTE format('SELECT ($1).%I::TEXT', pk_col) USING NEW INTO pk_val;
1080
1121
  SELECT ARRAY_AGG(n.key) INTO changed_cols
1081
1122
  FROM jsonb_each_text(to_jsonb(NEW)) n
1082
1123
  LEFT JOIN jsonb_each_text(to_jsonb(OLD)) o ON n.key = o.key
1083
1124
  WHERE o.value IS DISTINCT FROM n.value;
1084
1125
 
1085
1126
  INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields, replay_request_id, replay_request_call_index)
1086
- VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
1127
+ VALUES (TG_TABLE_NAME, pk_val, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
1087
1128
  RETURN NEW;
1088
1129
  ELSIF TG_OP = 'DELETE' THEN
1130
+ EXECUTE format('SELECT ($1).%I::TEXT', pk_col) USING OLD INTO pk_val;
1089
1131
  INSERT INTO audit_log (table_name, record_id, action, old_data, replay_request_id, replay_request_call_index)
1090
- VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD), req_id, call_idx);
1132
+ VALUES (TG_TABLE_NAME, pk_val, 'DELETE', to_jsonb(OLD), req_id, call_idx);
1091
1133
  RETURN OLD;
1092
1134
  END IF;
1093
1135
  RETURN NULL;
@@ -1095,19 +1137,22 @@ async function databaseAuditEnsureLogTable(sql) {
1095
1137
  $$ LANGUAGE plpgsql
1096
1138
  `;
1097
1139
  }
1098
- async function databaseAuditMonitorTable(sql, tableName) {
1140
+ async function databaseAuditMonitorTable(sql, tableName, primaryKeyColumn = "id") {
1099
1141
  if (tableName === "audit_log") {
1100
1142
  throw new Error("Cannot monitor the audit_log table itself");
1101
1143
  }
1102
1144
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
1103
1145
  throw new Error(`Invalid table name: ${tableName}`);
1104
1146
  }
1147
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(primaryKeyColumn)) {
1148
+ throw new Error(`Invalid primary key column name: ${primaryKeyColumn}`);
1149
+ }
1105
1150
  const triggerName = `audit_trigger_${tableName}`;
1106
1151
  await sql(
1107
1152
  `DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`
1108
1153
  );
1109
1154
  await sql(
1110
- `CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`
1155
+ `CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function('${primaryKeyColumn}')`
1111
1156
  );
1112
1157
  }
1113
1158
  async function databaseAuditDumpLogTable(sql) {
@@ -1127,6 +1172,7 @@ export {
1127
1172
  databaseAuditEnsureLogTable,
1128
1173
  databaseAuditMonitorTable,
1129
1174
  databaseCallbacks,
1175
+ ensureRequestRecording,
1130
1176
  finishRequest,
1131
1177
  getCurrentRequestId,
1132
1178
  redactBlobData,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {