@replayio-app-building/netlify-recorder 0.15.6 → 0.15.8
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 +9 -24
- package/dist/index.d.ts +1 -21
- package/dist/index.js +93 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ export default createRecordingRequestHandler(
|
|
|
113
113
|
|
|
114
114
|
> **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.
|
|
115
115
|
|
|
116
|
-
###
|
|
116
|
+
### 3. Create recordings
|
|
117
117
|
|
|
118
118
|
When you want to turn a captured request into a Replay recording, POST to the service's `create-recording` endpoint with the request ID. If the request was created with a secret, you must include it:
|
|
119
119
|
|
|
@@ -146,7 +146,7 @@ On failure:
|
|
|
146
146
|
{ "status": "failed", "error": "Error message" }
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
###
|
|
149
|
+
### 4. Check recording status
|
|
150
150
|
|
|
151
151
|
You can poll the recording status at any time. If the request was created with a secret, you must include it:
|
|
152
152
|
|
|
@@ -161,7 +161,7 @@ const { status, recordingId } = await res.json();
|
|
|
161
161
|
|
|
162
162
|
Requests created with a secret return 403 if the secret is missing or incorrect.
|
|
163
163
|
|
|
164
|
-
###
|
|
164
|
+
### 5. Access control with secrets
|
|
165
165
|
|
|
166
166
|
You can restrict access to captured requests by setting a `secret` string. When a secret is set, the request is only accessible via API calls that provide the same secret value. This lets each app isolate its requests from other apps sharing the same Netlify Recorder service.
|
|
167
167
|
|
|
@@ -393,7 +393,7 @@ Self-hosted recording requires these environment variables:
|
|
|
393
393
|
|
|
394
394
|
## Audit Log Support
|
|
395
395
|
|
|
396
|
-
The package
|
|
396
|
+
The package automatically tracks database mutations (INSERT, UPDATE, DELETE) in an `audit_log` table and links each change to the Replay request that caused it. When your handler is wrapped with `createRecordingRequestHandler`, all Neon SQL queries are automatically tagged with the request ID — no changes to your SQL code required.
|
|
397
397
|
|
|
398
398
|
### Setup
|
|
399
399
|
|
|
@@ -418,23 +418,19 @@ await databaseAuditMonitorTable(sql, "users");
|
|
|
418
418
|
await databaseAuditMonitorTable(sql, "orders");
|
|
419
419
|
```
|
|
420
420
|
|
|
421
|
-
#### 3. Use
|
|
421
|
+
#### 3. Use SQL normally in your handlers
|
|
422
422
|
|
|
423
|
-
|
|
423
|
+
No special SQL wrapper is needed. Any Neon SQL query inside a handler wrapped with `createRecordingRequestHandler` is automatically tagged with the replay request ID:
|
|
424
424
|
|
|
425
425
|
```typescript
|
|
426
426
|
import {
|
|
427
427
|
createRecordingRequestHandler,
|
|
428
428
|
remoteCallbacks,
|
|
429
|
-
createAuditedSql,
|
|
430
429
|
} from "@replayio-app-building/netlify-recorder";
|
|
431
430
|
|
|
432
431
|
export default createRecordingRequestHandler(
|
|
433
432
|
async (req) => {
|
|
434
|
-
|
|
435
|
-
// Queries through auditedSql automatically tag audit_log rows
|
|
436
|
-
// with the replay request ID — no extra code needed.
|
|
437
|
-
await auditedSql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
|
|
433
|
+
await sql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
|
|
438
434
|
|
|
439
435
|
return { statusCode: 200, body: "OK" };
|
|
440
436
|
},
|
|
@@ -467,12 +463,12 @@ Each entry contains:
|
|
|
467
463
|
| `new_data` | New row data (INSERT/UPDATE only) |
|
|
468
464
|
| `changed_fields` | Array of column names that changed (UPDATE only) |
|
|
469
465
|
| `performed_at` | Timestamp of the change |
|
|
470
|
-
| `replay_request_id` | The Replay request ID that caused the change
|
|
466
|
+
| `replay_request_id` | The Replay request ID that caused the change |
|
|
471
467
|
| `replay_request_call_index` | The sequential query index within the request |
|
|
472
468
|
|
|
473
469
|
### How it works
|
|
474
470
|
|
|
475
|
-
`
|
|
471
|
+
The network interceptor detects Neon SQL HTTP requests (which use `fetch` internally) and automatically wraps each query in a transaction with `SET LOCAL` statements that inject the current request ID and call index. The PostgreSQL trigger function reads these via `current_setting()` and stamps audit rows atomically. Outside a handler context, queries execute normally without audit metadata.
|
|
476
472
|
|
|
477
473
|
---
|
|
478
474
|
|
|
@@ -581,17 +577,6 @@ Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
|
581
577
|
**Parameters:**
|
|
582
578
|
- `sql` — A Neon SQL tagged-template function
|
|
583
579
|
|
|
584
|
-
### `createAuditedSql(sql): SqlFunction`
|
|
585
|
-
|
|
586
|
-
Wraps a Neon SQL tagged-template function so that each query runs inside a transaction that injects the current Replay request ID and call index via `SET LOCAL`. The audit trigger reads these values and stamps audit rows automatically.
|
|
587
|
-
|
|
588
|
-
Falls back to non-audited execution when no request context is active or if the transaction fails.
|
|
589
|
-
|
|
590
|
-
**Parameters:**
|
|
591
|
-
- `sql` — A Neon SQL tagged-template function (must have a `.transaction()` method, as provided by the Neon HTTP driver)
|
|
592
|
-
|
|
593
|
-
**Returns:** A wrapped SQL function with the same tagged-template interface.
|
|
594
|
-
|
|
595
580
|
## Environment Variables
|
|
596
581
|
|
|
597
582
|
### Required for all setups (read by `finishRequest`)
|
package/dist/index.d.ts
CHANGED
|
@@ -448,27 +448,7 @@ declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string):
|
|
|
448
448
|
* Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
449
449
|
*/
|
|
450
450
|
declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
|
|
451
|
-
/**
|
|
452
|
-
* Wraps a Neon SQL tagged-template function so that each query runs
|
|
453
|
-
* inside a transaction that first calls `set_config()` to inject the
|
|
454
|
-
* current request ID and call index. The `audit_trigger_function`
|
|
455
|
-
* reads these via `current_setting()` and stamps audit rows atomically
|
|
456
|
-
* — no separate UPDATE required.
|
|
457
|
-
*
|
|
458
|
-
* Requires the Neon HTTP driver's `.transaction()` method so that
|
|
459
|
-
* `set_config` and the user's query share one transaction.
|
|
460
|
-
*
|
|
461
|
-
* @example
|
|
462
|
-
* ```ts
|
|
463
|
-
* import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
|
|
464
|
-
*
|
|
465
|
-
* const sql = createAuditedSql(getSql());
|
|
466
|
-
* await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
|
|
467
|
-
* // audit_log row created by the trigger already has the request ID
|
|
468
|
-
* ```
|
|
469
|
-
*/
|
|
470
|
-
declare function createAuditedSql(sql: SqlFunction): SqlFunction;
|
|
471
451
|
|
|
472
452
|
declare function getCurrentRequestId(): string | null;
|
|
473
453
|
|
|
474
|
-
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,
|
|
454
|
+
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, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,41 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
5
5
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
+
// src/requestState.ts
|
|
9
|
+
var _currentRequestId = null;
|
|
10
|
+
var _callIndex = 0;
|
|
11
|
+
function setCurrentRequestId(id) {
|
|
12
|
+
_currentRequestId = id;
|
|
13
|
+
_callIndex = 0;
|
|
14
|
+
}
|
|
15
|
+
function getCurrentRequestId() {
|
|
16
|
+
return _currentRequestId;
|
|
17
|
+
}
|
|
18
|
+
function incrementCallIndex() {
|
|
19
|
+
return ++_callIndex;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
// src/interceptors/network.ts
|
|
23
|
+
function isNeonQuery(obj) {
|
|
24
|
+
return typeof obj === "object" && obj !== null && "query" in obj && "params" in obj;
|
|
25
|
+
}
|
|
26
|
+
function isNeonSqlRequest(url, body) {
|
|
27
|
+
if (!body) return false;
|
|
28
|
+
try {
|
|
29
|
+
const u = new URL(url);
|
|
30
|
+
if (!u.pathname.endsWith("/sql")) return false;
|
|
31
|
+
const parsed = JSON.parse(body);
|
|
32
|
+
return isNeonQuery(parsed) || Array.isArray(parsed) && parsed.length > 0 && isNeonQuery(parsed[0]);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function buildSetConfigQueries(requestId, callIndex) {
|
|
38
|
+
return [
|
|
39
|
+
{ query: "SELECT set_config('app.replay_request_id', $1, true)", params: [requestId] },
|
|
40
|
+
{ query: "SELECT set_config('app.replay_call_index', $1, true)", params: [String(callIndex)] }
|
|
41
|
+
];
|
|
42
|
+
}
|
|
9
43
|
function installNetworkInterceptor(mode, calls) {
|
|
10
44
|
const originalFetch = globalThis.fetch;
|
|
11
45
|
const consumed = /* @__PURE__ */ new Set();
|
|
@@ -20,6 +54,20 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
20
54
|
});
|
|
21
55
|
}
|
|
22
56
|
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
57
|
+
const requestId = getCurrentRequestId();
|
|
58
|
+
if (requestId && method.toUpperCase() === "POST" && isNeonSqlRequest(url, requestBody)) {
|
|
59
|
+
return await handleNeonSqlRequest(
|
|
60
|
+
originalFetch,
|
|
61
|
+
input,
|
|
62
|
+
init,
|
|
63
|
+
url,
|
|
64
|
+
method,
|
|
65
|
+
requestHeaders,
|
|
66
|
+
requestBody,
|
|
67
|
+
requestId,
|
|
68
|
+
calls
|
|
69
|
+
);
|
|
70
|
+
}
|
|
23
71
|
const response = await originalFetch(input, init);
|
|
24
72
|
const responseBody = await response.clone().text();
|
|
25
73
|
const responseHeaders = {};
|
|
@@ -121,6 +169,50 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
121
169
|
}
|
|
122
170
|
};
|
|
123
171
|
}
|
|
172
|
+
async function handleNeonSqlRequest(originalFetch, input, init, url, method, requestHeaders, requestBody, requestId, calls) {
|
|
173
|
+
const parsed = JSON.parse(requestBody);
|
|
174
|
+
const wasTransaction = Array.isArray(parsed);
|
|
175
|
+
const callIndex = incrementCallIndex();
|
|
176
|
+
const setConfigs = buildSetConfigQueries(requestId, callIndex);
|
|
177
|
+
const txBody = wasTransaction ? [...setConfigs, ...parsed] : [...setConfigs, parsed];
|
|
178
|
+
const modifiedInit = {
|
|
179
|
+
...init,
|
|
180
|
+
body: JSON.stringify(txBody)
|
|
181
|
+
};
|
|
182
|
+
const response = await originalFetch(input, modifiedInit);
|
|
183
|
+
const rawBody = await response.clone().text();
|
|
184
|
+
const responseHeaders = {};
|
|
185
|
+
response.headers.forEach((v, k) => {
|
|
186
|
+
responseHeaders[k] = v;
|
|
187
|
+
});
|
|
188
|
+
let recordedBody = rawBody;
|
|
189
|
+
if (response.ok) {
|
|
190
|
+
try {
|
|
191
|
+
const json = JSON.parse(rawBody);
|
|
192
|
+
if (json.results && Array.isArray(json.results)) {
|
|
193
|
+
const stripped = json.results.slice(setConfigs.length);
|
|
194
|
+
const unwrapped = wasTransaction ? { results: stripped } : stripped[0] ?? json;
|
|
195
|
+
recordedBody = JSON.stringify(unwrapped);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
calls.push({
|
|
201
|
+
url,
|
|
202
|
+
method,
|
|
203
|
+
requestHeaders,
|
|
204
|
+
requestBody,
|
|
205
|
+
responseStatus: response.status,
|
|
206
|
+
responseHeaders,
|
|
207
|
+
responseBody: recordedBody,
|
|
208
|
+
timestamp: Date.now()
|
|
209
|
+
});
|
|
210
|
+
return new Response(recordedBody, {
|
|
211
|
+
status: response.status,
|
|
212
|
+
statusText: response.statusText,
|
|
213
|
+
headers: responseHeaders
|
|
214
|
+
});
|
|
215
|
+
}
|
|
124
216
|
|
|
125
217
|
// src/interceptors/environment.ts
|
|
126
218
|
function installEnvironmentInterceptor(mode, reads) {
|
|
@@ -484,17 +576,6 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
484
576
|
|
|
485
577
|
// src/createRecordingRequestHandler.ts
|
|
486
578
|
import crypto from "crypto";
|
|
487
|
-
|
|
488
|
-
// src/requestState.ts
|
|
489
|
-
var _currentRequestId = null;
|
|
490
|
-
function setCurrentRequestId(id) {
|
|
491
|
-
_currentRequestId = id;
|
|
492
|
-
}
|
|
493
|
-
function getCurrentRequestId() {
|
|
494
|
-
return _currentRequestId;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// src/createRecordingRequestHandler.ts
|
|
498
579
|
function createRecordingRequestHandler(handler, options) {
|
|
499
580
|
return async (event, context) => {
|
|
500
581
|
const requestId = crypto.randomUUID();
|
|
@@ -1149,7 +1230,7 @@ async function databaseAuditEnsureLogTable(sql) {
|
|
|
1149
1230
|
req_id TEXT;
|
|
1150
1231
|
call_idx INTEGER;
|
|
1151
1232
|
BEGIN
|
|
1152
|
-
-- Read application context
|
|
1233
|
+
-- Read application context injected by the network interceptor
|
|
1153
1234
|
req_id := COALESCE(current_setting('app.replay_request_id', true), '');
|
|
1154
1235
|
IF req_id = '' THEN req_id := NULL; END IF;
|
|
1155
1236
|
|
|
@@ -1201,30 +1282,7 @@ async function databaseAuditDumpLogTable(sql) {
|
|
|
1201
1282
|
const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
|
|
1202
1283
|
return rows;
|
|
1203
1284
|
}
|
|
1204
|
-
function createAuditedSql(sql) {
|
|
1205
|
-
let callIndex = 0;
|
|
1206
|
-
const sqlWithTx = sql;
|
|
1207
|
-
const auditedSql = async (strings, ...values) => {
|
|
1208
|
-
const requestId = getCurrentRequestId();
|
|
1209
|
-
callIndex++;
|
|
1210
|
-
if (requestId && typeof sqlWithTx.transaction === "function") {
|
|
1211
|
-
try {
|
|
1212
|
-
const results = await sqlWithTx.transaction([
|
|
1213
|
-
sql`SELECT set_config('app.replay_request_id', ${requestId}, true)`,
|
|
1214
|
-
sql`SELECT set_config('app.replay_call_index', ${String(callIndex)}, true)`,
|
|
1215
|
-
sql(strings, ...values)
|
|
1216
|
-
]);
|
|
1217
|
-
return results[2];
|
|
1218
|
-
} catch (err) {
|
|
1219
|
-
console.warn("netlify-recorder: audited transaction failed, falling back:", err);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
return await sql(strings, ...values);
|
|
1223
|
-
};
|
|
1224
|
-
return auditedSql;
|
|
1225
|
-
}
|
|
1226
1285
|
export {
|
|
1227
|
-
createAuditedSql,
|
|
1228
1286
|
createRecordingRequestHandler,
|
|
1229
1287
|
createRequestRecording,
|
|
1230
1288
|
databaseAuditDumpLogTable,
|