@replayio-app-building/netlify-recorder 0.15.1 → 0.15.3
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 +117 -0
- package/dist/index.js +16 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -305,6 +305,90 @@ Self-hosted recording requires these environment variables:
|
|
|
305
305
|
|
|
306
306
|
---
|
|
307
307
|
|
|
308
|
+
## Audit Log Support
|
|
309
|
+
|
|
310
|
+
The package includes helpers that automatically track database mutations (INSERT, UPDATE, DELETE) in an `audit_log` table and link each change to the Replay request that caused it. This makes it easy to trace exactly which incoming request triggered a given database change.
|
|
311
|
+
|
|
312
|
+
### Setup
|
|
313
|
+
|
|
314
|
+
#### 1. Create the audit log table
|
|
315
|
+
|
|
316
|
+
Call `databaseAuditEnsureLogTable` once during schema initialization. This creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger function:
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
import { databaseAuditEnsureLogTable } from "@replayio-app-building/netlify-recorder";
|
|
320
|
+
|
|
321
|
+
await databaseAuditEnsureLogTable(sql);
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
#### 2. Monitor tables
|
|
325
|
+
|
|
326
|
+
For each table you want to audit, call `databaseAuditMonitorTable`. This attaches a trigger that logs every INSERT, UPDATE, and DELETE:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
import { databaseAuditMonitorTable } from "@replayio-app-building/netlify-recorder";
|
|
330
|
+
|
|
331
|
+
await databaseAuditMonitorTable(sql, "users");
|
|
332
|
+
await databaseAuditMonitorTable(sql, "orders");
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
#### 3. Use audited SQL in your handlers
|
|
336
|
+
|
|
337
|
+
Wrap your Neon SQL function with `createAuditedSql` inside handlers that are wrapped with `createRecordingRequestHandler`. This ensures each audit log entry is stamped with the current Replay request ID and a per-request call index:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
import {
|
|
341
|
+
createRecordingRequestHandler,
|
|
342
|
+
remoteCallbacks,
|
|
343
|
+
createAuditedSql,
|
|
344
|
+
} from "@replayio-app-building/netlify-recorder";
|
|
345
|
+
|
|
346
|
+
export default createRecordingRequestHandler(
|
|
347
|
+
async (req) => {
|
|
348
|
+
const auditedSql = createAuditedSql(sql);
|
|
349
|
+
// Queries through auditedSql automatically tag audit_log rows
|
|
350
|
+
// with the replay request ID — no extra code needed.
|
|
351
|
+
await auditedSql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
|
|
352
|
+
|
|
353
|
+
return { statusCode: 200, body: "OK" };
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
callbacks: remoteCallbacks(RECORDER_URL),
|
|
357
|
+
handlerPath: "netlify/functions/create-order",
|
|
358
|
+
}
|
|
359
|
+
);
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
#### 4. Query the audit log
|
|
363
|
+
|
|
364
|
+
Use `databaseAuditDumpLogTable` to retrieve all audit entries (ordered by most recent first):
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
import { databaseAuditDumpLogTable } from "@replayio-app-building/netlify-recorder";
|
|
368
|
+
|
|
369
|
+
const entries = await databaseAuditDumpLogTable(sql);
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Each entry contains:
|
|
373
|
+
|
|
374
|
+
| Field | Description |
|
|
375
|
+
|---|---|
|
|
376
|
+
| `table_name` | The table where the change occurred |
|
|
377
|
+
| `record_id` | The `id` of the affected row |
|
|
378
|
+
| `action` | `INSERT`, `UPDATE`, or `DELETE` |
|
|
379
|
+
| `old_data` | Previous row data (UPDATE/DELETE only) |
|
|
380
|
+
| `new_data` | New row data (INSERT/UPDATE only) |
|
|
381
|
+
| `changed_fields` | Array of column names that changed (UPDATE only) |
|
|
382
|
+
| `performed_at` | Timestamp of the change |
|
|
383
|
+
| `replay_request_id` | The Replay request ID that caused the change (when using `createAuditedSql`) |
|
|
384
|
+
| `replay_request_call_index` | The sequential query index within the request |
|
|
385
|
+
|
|
386
|
+
### How it works
|
|
387
|
+
|
|
388
|
+
`createAuditedSql` wraps each query in a transaction that first calls `SET LOCAL` to inject the current request ID and call index into PostgreSQL session variables. The trigger function reads these via `current_setting()` and stamps audit rows atomically — no separate UPDATE required. If no request context is available (e.g. queries outside a handler), the query executes normally without audit metadata.
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
308
392
|
## API Reference
|
|
309
393
|
|
|
310
394
|
### `createRecordingRequestHandler(handler, options): Handler`
|
|
@@ -386,6 +470,39 @@ Called inside a container running under `replay-node`. Downloads the captured da
|
|
|
386
470
|
- `handlerPath` — Path to the handler module to execute
|
|
387
471
|
- `requestInfo` — The original request info to replay
|
|
388
472
|
|
|
473
|
+
### `databaseAuditEnsureLogTable(sql): Promise<void>`
|
|
474
|
+
|
|
475
|
+
Creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger function (`audit_trigger_function`). Call once during schema initialization.
|
|
476
|
+
|
|
477
|
+
**Parameters:**
|
|
478
|
+
- `sql` — A Neon SQL tagged-template function
|
|
479
|
+
|
|
480
|
+
### `databaseAuditMonitorTable(sql, tableName): Promise<void>`
|
|
481
|
+
|
|
482
|
+
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'`.
|
|
483
|
+
|
|
484
|
+
**Parameters:**
|
|
485
|
+
- `sql` — A Neon SQL tagged-template function
|
|
486
|
+
- `tableName` — Name of the table to monitor (must match `^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
|
487
|
+
|
|
488
|
+
### `databaseAuditDumpLogTable(sql): Promise<Record<string, unknown>[]>`
|
|
489
|
+
|
|
490
|
+
Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
491
|
+
|
|
492
|
+
**Parameters:**
|
|
493
|
+
- `sql` — A Neon SQL tagged-template function
|
|
494
|
+
|
|
495
|
+
### `createAuditedSql(sql): SqlFunction`
|
|
496
|
+
|
|
497
|
+
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.
|
|
498
|
+
|
|
499
|
+
Falls back to non-audited execution when no request context is active or if the transaction fails.
|
|
500
|
+
|
|
501
|
+
**Parameters:**
|
|
502
|
+
- `sql` — A Neon SQL tagged-template function (must have a `.transaction()` method, as provided by the Neon HTTP driver)
|
|
503
|
+
|
|
504
|
+
**Returns:** A wrapped SQL function with the same tagged-template interface.
|
|
505
|
+
|
|
389
506
|
## Environment Variables
|
|
390
507
|
|
|
391
508
|
### Required for all setups (read by `finishRequest`)
|
package/dist/index.js
CHANGED
|
@@ -126,6 +126,10 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
126
126
|
function installEnvironmentInterceptor(mode, reads) {
|
|
127
127
|
const originalEnv = process.env;
|
|
128
128
|
if (mode === "capture") {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
for (const key of Object.keys(originalEnv)) {
|
|
131
|
+
reads.push({ key, value: originalEnv[key], timestamp: now });
|
|
132
|
+
}
|
|
129
133
|
process.env = new Proxy(originalEnv, {
|
|
130
134
|
get(target, prop) {
|
|
131
135
|
const value = target[prop];
|
|
@@ -862,6 +866,16 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
862
866
|
};
|
|
863
867
|
g.File = FileShim;
|
|
864
868
|
}
|
|
869
|
+
if (typeof g.crypto === "undefined") {
|
|
870
|
+
try {
|
|
871
|
+
const nodeCrypto = __require("crypto");
|
|
872
|
+
g.crypto = {
|
|
873
|
+
randomUUID: () => nodeCrypto.randomUUID(),
|
|
874
|
+
getRandomValues: (buf) => nodeCrypto.getRandomValues(buf)
|
|
875
|
+
};
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
}
|
|
865
879
|
if (typeof g.Headers === "undefined") {
|
|
866
880
|
const HeadersShim = class Headers {
|
|
867
881
|
_map;
|
|
@@ -1174,12 +1188,11 @@ async function databaseAuditMonitorTable(sql, tableName) {
|
|
|
1174
1188
|
throw new Error(`Invalid table name: ${tableName}`);
|
|
1175
1189
|
}
|
|
1176
1190
|
const triggerName = `audit_trigger_${tableName}`;
|
|
1177
|
-
const asTemplate = (s) => Object.assign([s], { raw: [s] });
|
|
1178
1191
|
await sql(
|
|
1179
|
-
|
|
1192
|
+
`DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`
|
|
1180
1193
|
);
|
|
1181
1194
|
await sql(
|
|
1182
|
-
|
|
1195
|
+
`CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`
|
|
1183
1196
|
);
|
|
1184
1197
|
}
|
|
1185
1198
|
async function databaseAuditDumpLogTable(sql) {
|