@replayio-app-building/netlify-recorder 0.10.0 → 0.12.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 +7 -2
- package/dist/index.d.ts +70 -8
- package/dist/index.js +190 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -237,7 +237,6 @@ const handler: Handler = async (event) => {
|
|
|
237
237
|
|
|
238
238
|
const recordingId = await ensureRequestRecording(requestId, {
|
|
239
239
|
repositoryUrl: process.env.APP_REPOSITORY_URL!,
|
|
240
|
-
replayApiKey: process.env.RECORD_REPLAY_API_KEY!,
|
|
241
240
|
lookupRequest: async (id) => {
|
|
242
241
|
const [row] = await sql`
|
|
243
242
|
SELECT blob_url, commit_sha, branch_name, handler_path
|
|
@@ -312,6 +311,10 @@ Self-hosted recording requires these environment variables:
|
|
|
312
311
|
|
|
313
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.
|
|
314
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
|
+
|
|
315
318
|
**Parameters:**
|
|
316
319
|
- `handler` — Your async handler function `(event, context?) => Promise<{ statusCode, headers?, body? }>`
|
|
317
320
|
- `options.callbacks` — Either `remoteCallbacks(serviceUrl)` or custom `{ uploadBlob, storeRequestData }` callbacks
|
|
@@ -322,6 +325,8 @@ Wraps a Netlify handler function with automatic request recording. This is the r
|
|
|
322
325
|
|
|
323
326
|
**Returns:** A wrapped handler function with the same signature.
|
|
324
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
|
+
|
|
325
330
|
### `startRequest(event): RequestContext`
|
|
326
331
|
|
|
327
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.
|
|
@@ -353,6 +358,7 @@ Logs a `console.warn` when the total duration exceeds 2 seconds or when individu
|
|
|
353
358
|
- `options.commitSha` — Override `COMMIT_SHA` env var
|
|
354
359
|
- `options.branchName` — Override `BRANCH_NAME` env var
|
|
355
360
|
- `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
|
|
361
|
+
- `options.requestId` — Pre-generated request ID (used by `createRecordingRequestHandler` in the `waitUntil` flow)
|
|
356
362
|
|
|
357
363
|
### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`
|
|
358
364
|
|
|
@@ -368,7 +374,6 @@ Spawns a container via `@replayio/app-building` to create a Replay recording fro
|
|
|
368
374
|
**Parameters:**
|
|
369
375
|
- `requestId` — The request to create a recording for
|
|
370
376
|
- `options.repositoryUrl` — Git repository URL for the container to clone
|
|
371
|
-
- `options.replayApiKey` — Replay API key for recording upload
|
|
372
377
|
- `options.lookupRequest(id)` — Fetches `{ blobUrl, commitSha, branchName, handlerPath }` from the database
|
|
373
378
|
- `options.updateStatus(id, status, recordingId?)` — Updates the request status in the database
|
|
374
379
|
|
package/dist/index.d.ts
CHANGED
|
@@ -95,13 +95,22 @@ interface BlobData {
|
|
|
95
95
|
interface FinishRequestCallbacks {
|
|
96
96
|
/** Uploads serialized captured data and returns the blob URL. */
|
|
97
97
|
uploadBlob: (data: string) => Promise<string>;
|
|
98
|
-
/**
|
|
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
|
+
*/
|
|
99
106
|
storeRequestData: (data: {
|
|
100
107
|
blobUrl: string;
|
|
101
108
|
commitSha: string;
|
|
102
109
|
branchName: string;
|
|
103
110
|
repositoryUrl: string;
|
|
104
111
|
handlerPath: string;
|
|
112
|
+
/** Pre-generated request ID. Use as the row ID when provided. */
|
|
113
|
+
requestId?: string;
|
|
105
114
|
}) => Promise<string>;
|
|
106
115
|
}
|
|
107
116
|
/**
|
|
@@ -126,7 +135,6 @@ interface ContainerInfraConfig {
|
|
|
126
135
|
}
|
|
127
136
|
interface EnsureRecordingOptions {
|
|
128
137
|
repositoryUrl: string;
|
|
129
|
-
replayApiKey: string;
|
|
130
138
|
/** Infrastructure credentials for starting the recording container. */
|
|
131
139
|
infraConfig?: ContainerInfraConfig;
|
|
132
140
|
/** Webhook URL the container can POST log entries to (optional). */
|
|
@@ -200,6 +208,15 @@ interface FinishRequestOptions {
|
|
|
200
208
|
branchName?: string;
|
|
201
209
|
/** Override REPLAY_REPOSITORY_URL env var. */
|
|
202
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;
|
|
203
220
|
}
|
|
204
221
|
/**
|
|
205
222
|
* Called at the end of the handler execution.
|
|
@@ -232,9 +249,14 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
|
|
|
232
249
|
* after, capturing all outbound network calls and environment variable reads.
|
|
233
250
|
* On error, interceptors are cleaned up and the error is re-thrown.
|
|
234
251
|
*
|
|
235
|
-
* **
|
|
236
|
-
* (
|
|
237
|
-
* `X-Replay-Request-Id` header
|
|
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.
|
|
238
260
|
*
|
|
239
261
|
* For v2 handlers the request body is read from a clone internally — your
|
|
240
262
|
* handler still receives the original request with an unconsumed body.
|
|
@@ -380,8 +402,6 @@ interface SpawnRecordingContainerOptions {
|
|
|
380
402
|
branchName: string;
|
|
381
403
|
/** Git repository URL for the app. */
|
|
382
404
|
repositoryUrl: string;
|
|
383
|
-
/** Replay API key for uploading recordings. */
|
|
384
|
-
replayApiKey: string;
|
|
385
405
|
/** Infrastructure credentials for Fly.io + Infisical. */
|
|
386
406
|
infraConfig: ContainerInfraConfig;
|
|
387
407
|
/** Optional webhook URL the container can POST log events to. */
|
|
@@ -401,4 +421,46 @@ interface SpawnRecordingContainerOptions {
|
|
|
401
421
|
*/
|
|
402
422
|
declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
|
|
403
423
|
|
|
404
|
-
|
|
424
|
+
type SqlFunction = (...args: any[]) => Promise<any[]>;
|
|
425
|
+
/**
|
|
426
|
+
* Creates the `audit_log` table and a generic PL/pgSQL trigger function
|
|
427
|
+
* (`audit_trigger_function`) that records INSERT, UPDATE, and DELETE
|
|
428
|
+
* operations on any monitored table.
|
|
429
|
+
*
|
|
430
|
+
* Call this once during schema initialization.
|
|
431
|
+
*/
|
|
432
|
+
declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
|
|
433
|
+
/**
|
|
434
|
+
* Creates a trigger on the specified table that calls
|
|
435
|
+
* `audit_trigger_function` for INSERT, UPDATE, and DELETE operations.
|
|
436
|
+
*
|
|
437
|
+
* Throws if `tableName` is `'audit_log'` (cannot monitor itself).
|
|
438
|
+
*/
|
|
439
|
+
declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string): Promise<void>;
|
|
440
|
+
/**
|
|
441
|
+
* Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
442
|
+
*/
|
|
443
|
+
declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
|
|
444
|
+
/**
|
|
445
|
+
* Wraps a Neon SQL tagged-template function so that after each query,
|
|
446
|
+
* any new `audit_log` rows (with NULL `replay_request_id`) are tagged
|
|
447
|
+
* with the current request ID and an incrementing call index.
|
|
448
|
+
*
|
|
449
|
+
* The request ID is read from module-level state set by
|
|
450
|
+
* `createRecordingRequestHandler`. If no request is active, the
|
|
451
|
+
* wrapper passes calls through without tagging.
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* ```ts
|
|
455
|
+
* import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
|
|
456
|
+
*
|
|
457
|
+
* const sql = createAuditedSql(getSql());
|
|
458
|
+
* await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
|
|
459
|
+
* // audit_log entries from this INSERT are now tagged with the request ID
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
declare function createAuditedSql(sql: SqlFunction): SqlFunction;
|
|
463
|
+
|
|
464
|
+
declare function getCurrentRequestId(): string | null;
|
|
465
|
+
|
|
466
|
+
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, createAuditedSql, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, 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
|
}
|
|
@@ -427,7 +453,8 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
427
453
|
commitSha,
|
|
428
454
|
branchName,
|
|
429
455
|
repositoryUrl,
|
|
430
|
-
handlerPath
|
|
456
|
+
handlerPath,
|
|
457
|
+
requestId: options?.requestId
|
|
431
458
|
});
|
|
432
459
|
const storeDuration = Date.now() - storeStart;
|
|
433
460
|
if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
|
|
@@ -450,17 +477,58 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
450
477
|
};
|
|
451
478
|
}
|
|
452
479
|
|
|
480
|
+
// src/createRecordingRequestHandler.ts
|
|
481
|
+
import crypto from "crypto";
|
|
482
|
+
|
|
483
|
+
// src/requestState.ts
|
|
484
|
+
var _currentRequestId = null;
|
|
485
|
+
function setCurrentRequestId(id) {
|
|
486
|
+
_currentRequestId = id;
|
|
487
|
+
}
|
|
488
|
+
function getCurrentRequestId() {
|
|
489
|
+
return _currentRequestId;
|
|
490
|
+
}
|
|
491
|
+
|
|
453
492
|
// src/createRecordingRequestHandler.ts
|
|
454
493
|
function createRecordingRequestHandler(handler, options) {
|
|
455
494
|
return async (event, context) => {
|
|
495
|
+
const requestId = crypto.randomUUID();
|
|
496
|
+
setCurrentRequestId(requestId);
|
|
456
497
|
const reqContext = startRequest(event);
|
|
498
|
+
let response;
|
|
457
499
|
try {
|
|
458
|
-
|
|
459
|
-
return await finishRequest(reqContext, options.callbacks, response, options);
|
|
500
|
+
response = await handler(event, context);
|
|
460
501
|
} catch (err) {
|
|
502
|
+
setCurrentRequestId(null);
|
|
461
503
|
reqContext.cleanup();
|
|
462
504
|
throw err;
|
|
463
505
|
}
|
|
506
|
+
reqContext.cleanup();
|
|
507
|
+
setCurrentRequestId(null);
|
|
508
|
+
const responseWithHeader = {
|
|
509
|
+
...response,
|
|
510
|
+
headers: {
|
|
511
|
+
...response.headers,
|
|
512
|
+
"X-Replay-Request-Id": requestId
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
const finishOpts = { ...options, requestId };
|
|
516
|
+
const ctx = context;
|
|
517
|
+
if (ctx && typeof ctx.waitUntil === "function") {
|
|
518
|
+
ctx.waitUntil(
|
|
519
|
+
finishRequest(reqContext, options.callbacks, response, finishOpts).catch(
|
|
520
|
+
(err) => {
|
|
521
|
+
console.error(
|
|
522
|
+
`netlify-recorder: background finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
|
|
523
|
+
err
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
);
|
|
528
|
+
return responseWithHeader;
|
|
529
|
+
}
|
|
530
|
+
await finishRequest(reqContext, options.callbacks, response, finishOpts);
|
|
531
|
+
return responseWithHeader;
|
|
464
532
|
};
|
|
465
533
|
}
|
|
466
534
|
|
|
@@ -491,7 +559,8 @@ function remoteCallbacks(serviceUrl) {
|
|
|
491
559
|
handlerPath: metadata.handlerPath,
|
|
492
560
|
commitSha: metadata.commitSha,
|
|
493
561
|
branchName: metadata.branchName,
|
|
494
|
-
repositoryUrl: metadata.repositoryUrl
|
|
562
|
+
repositoryUrl: metadata.repositoryUrl,
|
|
563
|
+
requestId: metadata.requestId
|
|
495
564
|
})
|
|
496
565
|
}
|
|
497
566
|
);
|
|
@@ -515,7 +584,6 @@ async function spawnRecordingContainer(options) {
|
|
|
515
584
|
commitSha,
|
|
516
585
|
branchName,
|
|
517
586
|
repositoryUrl,
|
|
518
|
-
replayApiKey,
|
|
519
587
|
infraConfig,
|
|
520
588
|
logWebhookUrl,
|
|
521
589
|
onLog
|
|
@@ -605,7 +673,7 @@ async function spawnRecordingContainer(options) {
|
|
|
605
673
|
`DB operations that were not in the original blob. Do NOT try to fix these.`,
|
|
606
674
|
``,
|
|
607
675
|
`=== Step 6: Upload the recording ===`,
|
|
608
|
-
`RECORD_REPLAY_API_KEY
|
|
676
|
+
`exec-secrets RECORD_REPLAY_API_KEY -- npx replayio upload --all 2>&1`,
|
|
609
677
|
``,
|
|
610
678
|
`Find the recording ID (UUID) in the upload output and print:`,
|
|
611
679
|
` recording: <recording-id>`,
|
|
@@ -671,7 +739,7 @@ async function spawnRecordingContainer(options) {
|
|
|
671
739
|
|
|
672
740
|
// src/ensureRequestRecording.ts
|
|
673
741
|
async function ensureRequestRecording(requestId, options) {
|
|
674
|
-
const { repositoryUrl,
|
|
742
|
+
const { repositoryUrl, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
|
|
675
743
|
const emit = async (level, message) => {
|
|
676
744
|
if (onLog) {
|
|
677
745
|
try {
|
|
@@ -696,7 +764,6 @@ async function ensureRequestRecording(requestId, options) {
|
|
|
696
764
|
commitSha: requestData.commitSha,
|
|
697
765
|
branchName: requestData.branchName,
|
|
698
766
|
repositoryUrl,
|
|
699
|
-
replayApiKey,
|
|
700
767
|
infraConfig,
|
|
701
768
|
logWebhookUrl: webhookUrl,
|
|
702
769
|
onLog
|
|
@@ -879,6 +946,12 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
879
946
|
this.headers = new H(init?.headers);
|
|
880
947
|
this.ok = this.status >= 200 && this.status < 300;
|
|
881
948
|
}
|
|
949
|
+
static json(data, init) {
|
|
950
|
+
const body = JSON.stringify(data);
|
|
951
|
+
const headers = init?.headers ?? {};
|
|
952
|
+
headers["content-type"] = "application/json";
|
|
953
|
+
return new ResponseShim(body, { ...init, headers });
|
|
954
|
+
}
|
|
882
955
|
async text() {
|
|
883
956
|
return this._body ?? "";
|
|
884
957
|
}
|
|
@@ -1002,17 +1075,18 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
1002
1075
|
}
|
|
1003
1076
|
}
|
|
1004
1077
|
} finally {
|
|
1005
|
-
const
|
|
1078
|
+
const consumedCount = networkHandle.consumedCount();
|
|
1006
1079
|
const total = networkHandle.totalCount();
|
|
1007
|
-
if (
|
|
1080
|
+
if (consumedCount < total) {
|
|
1008
1081
|
result.unconsumedNetworkCalls = true;
|
|
1082
|
+
const unconsumed = networkHandle.unconsumedIndices();
|
|
1009
1083
|
const details = [
|
|
1010
|
-
`Consumed ${
|
|
1084
|
+
`Consumed ${consumedCount} of ${total} calls. ${unconsumed.length} call(s) were never replayed.`
|
|
1011
1085
|
];
|
|
1012
1086
|
console.error(
|
|
1013
|
-
`ERROR: Not all recorded network calls were consumed during replay. Consumed ${
|
|
1087
|
+
`ERROR: Not all recorded network calls were consumed during replay. Consumed ${consumedCount} of ${total} calls. ${unconsumed.length} call(s) were never replayed.`
|
|
1014
1088
|
);
|
|
1015
|
-
for (
|
|
1089
|
+
for (const i of unconsumed) {
|
|
1016
1090
|
const call = blobData.capturedData.networkCalls[i];
|
|
1017
1091
|
if (call) {
|
|
1018
1092
|
details.push(`Unconsumed: ${call.method} ${call.url} => ${call.responseStatus}`);
|
|
@@ -1031,11 +1105,107 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
1031
1105
|
}
|
|
1032
1106
|
return result;
|
|
1033
1107
|
}
|
|
1108
|
+
|
|
1109
|
+
// src/databaseAudit.ts
|
|
1110
|
+
async function databaseAuditEnsureLogTable(sql) {
|
|
1111
|
+
await sql`
|
|
1112
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
1113
|
+
id BIGSERIAL PRIMARY KEY,
|
|
1114
|
+
table_name TEXT NOT NULL,
|
|
1115
|
+
record_id TEXT NOT NULL,
|
|
1116
|
+
action TEXT NOT NULL,
|
|
1117
|
+
old_data JSONB,
|
|
1118
|
+
new_data JSONB,
|
|
1119
|
+
changed_fields TEXT[],
|
|
1120
|
+
performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
1121
|
+
replay_request_id TEXT,
|
|
1122
|
+
replay_request_call_index INTEGER
|
|
1123
|
+
)
|
|
1124
|
+
`;
|
|
1125
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_table_name ON audit_log (table_name)`;
|
|
1126
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_replay_request_id ON audit_log (replay_request_id)`;
|
|
1127
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_performed_at ON audit_log (performed_at DESC)`;
|
|
1128
|
+
await sql`
|
|
1129
|
+
CREATE OR REPLACE FUNCTION audit_trigger_function()
|
|
1130
|
+
RETURNS TRIGGER AS $$
|
|
1131
|
+
DECLARE
|
|
1132
|
+
changed_cols TEXT[];
|
|
1133
|
+
BEGIN
|
|
1134
|
+
IF TG_OP = 'INSERT' THEN
|
|
1135
|
+
INSERT INTO audit_log (table_name, record_id, action, new_data)
|
|
1136
|
+
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW));
|
|
1137
|
+
RETURN NEW;
|
|
1138
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
1139
|
+
SELECT ARRAY_AGG(n.key) INTO changed_cols
|
|
1140
|
+
FROM jsonb_each_text(to_jsonb(NEW)) n
|
|
1141
|
+
LEFT JOIN jsonb_each_text(to_jsonb(OLD)) o ON n.key = o.key
|
|
1142
|
+
WHERE o.value IS DISTINCT FROM n.value;
|
|
1143
|
+
|
|
1144
|
+
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields)
|
|
1145
|
+
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]));
|
|
1146
|
+
RETURN NEW;
|
|
1147
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
1148
|
+
INSERT INTO audit_log (table_name, record_id, action, old_data)
|
|
1149
|
+
VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD));
|
|
1150
|
+
RETURN OLD;
|
|
1151
|
+
END IF;
|
|
1152
|
+
RETURN NULL;
|
|
1153
|
+
END;
|
|
1154
|
+
$$ LANGUAGE plpgsql
|
|
1155
|
+
`;
|
|
1156
|
+
}
|
|
1157
|
+
async function databaseAuditMonitorTable(sql, tableName) {
|
|
1158
|
+
if (tableName === "audit_log") {
|
|
1159
|
+
throw new Error("Cannot monitor the audit_log table itself");
|
|
1160
|
+
}
|
|
1161
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
|
1162
|
+
throw new Error(`Invalid table name: ${tableName}`);
|
|
1163
|
+
}
|
|
1164
|
+
const triggerName = `audit_trigger_${tableName}`;
|
|
1165
|
+
await sql(
|
|
1166
|
+
`DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`,
|
|
1167
|
+
[]
|
|
1168
|
+
);
|
|
1169
|
+
await sql(
|
|
1170
|
+
`CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`,
|
|
1171
|
+
[]
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
async function databaseAuditDumpLogTable(sql) {
|
|
1175
|
+
const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
|
|
1176
|
+
return rows;
|
|
1177
|
+
}
|
|
1178
|
+
function createAuditedSql(sql) {
|
|
1179
|
+
let callIndex = 0;
|
|
1180
|
+
const requestId = getCurrentRequestId();
|
|
1181
|
+
const auditedSql = async (strings, ...values) => {
|
|
1182
|
+
const result = await sql(strings, ...values);
|
|
1183
|
+
callIndex++;
|
|
1184
|
+
if (requestId) {
|
|
1185
|
+
try {
|
|
1186
|
+
await sql`
|
|
1187
|
+
UPDATE audit_log
|
|
1188
|
+
SET replay_request_id = ${requestId}, replay_request_call_index = ${callIndex}
|
|
1189
|
+
WHERE replay_request_id IS NULL
|
|
1190
|
+
`;
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
console.warn("netlify-recorder: failed to update audit log:", err);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return result;
|
|
1196
|
+
};
|
|
1197
|
+
return auditedSql;
|
|
1198
|
+
}
|
|
1034
1199
|
export {
|
|
1200
|
+
createAuditedSql,
|
|
1035
1201
|
createRecordingRequestHandler,
|
|
1036
1202
|
createRequestRecording,
|
|
1203
|
+
databaseAuditDumpLogTable,
|
|
1204
|
+
databaseAuditEnsureLogTable,
|
|
1205
|
+
databaseAuditMonitorTable,
|
|
1037
1206
|
ensureRequestRecording,
|
|
1038
1207
|
finishRequest,
|
|
1208
|
+
getCurrentRequestId,
|
|
1039
1209
|
readInfraConfigFromEnv,
|
|
1040
1210
|
redactBlobData,
|
|
1041
1211
|
remoteCallbacks,
|