@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 +41 -32
- package/dist/index.d.ts +26 -2
- package/dist/index.js +51 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|