@replayio-app-building/netlify-recorder 0.15.6 → 0.15.7

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
@@ -393,7 +393,7 @@ Self-hosted recording requires these environment variables:
393
393
 
394
394
  ## Audit Log Support
395
395
 
396
- 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.
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 audited SQL in your handlers
421
+ #### 3. Use SQL normally in your handlers
422
422
 
423
- 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:
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
- const auditedSql = createAuditedSql(sql);
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 (when using `createAuditedSql`) |
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
- `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.
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, createAuditedSql, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
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 set via SET LOCAL by createAuditedSql
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.15.6",
3
+ "version": "0.15.7",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {