@replayio-app-building/netlify-recorder 0.19.0 → 0.21.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 CHANGED
@@ -464,13 +464,14 @@ Creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger func
464
464
  **Parameters:**
465
465
  - `sql` — A Neon SQL tagged-template function
466
466
 
467
- ### `databaseAuditMonitorTable(sql, tableName): Promise<void>`
467
+ ### `databaseAuditMonitorTable(sql, tableName, primaryKeyColumn?): Promise<void>`
468
468
 
469
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'`.
470
470
 
471
471
  **Parameters:**
472
472
  - `sql` — A Neon SQL tagged-template function
473
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_]*$`)
474
475
 
475
476
  ### `databaseAuditDumpLogTable(sql): Promise<Record<string, unknown>[]>`
476
477
 
package/dist/index.d.ts CHANGED
@@ -332,7 +332,7 @@ declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
332
332
  *
333
333
  * Throws if `tableName` is `'audit_log'` (cannot monitor itself).
334
334
  */
335
- declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string): Promise<void>;
335
+ declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string, primaryKeyColumn?: string): Promise<void>;
336
336
  /**
337
337
  * Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
338
338
  */
package/dist/index.js CHANGED
@@ -423,13 +423,21 @@ function replaceAll(text, searchValue, mask) {
423
423
  function buildMask(value) {
424
424
  return "*".repeat(value.length);
425
425
  }
426
+ function buildPlaceholder(key) {
427
+ if (key === "DATABASE_URL" || key.endsWith("_DATABASE_URL")) {
428
+ return "postgresql://replay:replay@localhost:5432/replay";
429
+ }
430
+ return `placeholder_${key.toLowerCase()}`;
431
+ }
426
432
  function redactBlobData(blobData) {
427
433
  const redactions = /* @__PURE__ */ new Map();
434
+ const placeholders = /* @__PURE__ */ new Map();
428
435
  const redactedEnvReads = blobData.capturedData.envReads.map(
429
436
  (read) => {
430
437
  if (shouldRedact(read.key, read.value) && read.value !== void 0) {
431
438
  const mask = buildMask(read.value);
432
439
  redactions.set(read.value, mask);
440
+ placeholders.set(read.value, buildPlaceholder(read.key));
433
441
  return { ...read, value: mask };
434
442
  }
435
443
  return { ...read };
@@ -462,11 +470,29 @@ function redactBlobData(blobData) {
462
470
  }
463
471
  return out;
464
472
  }
473
+ const sortedPlaceholders = [...placeholders.entries()].sort(
474
+ (a, b) => b[0].length - a[0].length
475
+ );
476
+ function scrubForRequest(text) {
477
+ if (text === void 0) return void 0;
478
+ let result = text;
479
+ for (const [original, placeholder] of sortedPlaceholders) {
480
+ result = replaceAll(result, original, placeholder);
481
+ }
482
+ return result;
483
+ }
484
+ function scrubHeadersForRequest(headers) {
485
+ const out = {};
486
+ for (const [k, v] of Object.entries(headers)) {
487
+ out[k] = scrubForRequest(v) ?? v;
488
+ }
489
+ return out;
490
+ }
465
491
  const redactedRequestInfo = {
466
492
  ...blobData.requestInfo,
467
- url: scrub(blobData.requestInfo.url) ?? blobData.requestInfo.url,
468
- headers: scrubHeaders(blobData.requestInfo.headers),
469
- body: scrub(blobData.requestInfo.body)
493
+ url: scrubForRequest(blobData.requestInfo.url) ?? blobData.requestInfo.url,
494
+ headers: scrubHeadersForRequest(blobData.requestInfo.headers),
495
+ body: scrubForRequest(blobData.requestInfo.body)
470
496
  };
471
497
  const redactedNetworkCalls = blobData.capturedData.networkCalls.map(
472
498
  (call) => ({
@@ -1095,7 +1121,12 @@ async function databaseAuditEnsureLogTable(sql) {
1095
1121
  changed_cols TEXT[];
1096
1122
  req_id TEXT;
1097
1123
  call_idx INTEGER;
1124
+ pk_col TEXT;
1125
+ pk_val TEXT;
1098
1126
  BEGIN
1127
+ -- Primary key column name passed as trigger argument; default to 'id'
1128
+ pk_col := COALESCE(TG_ARGV[0], 'id');
1129
+
1099
1130
  -- Read application context injected by the network interceptor
1100
1131
  req_id := COALESCE(current_setting('app.replay_request_id', true), '');
1101
1132
  IF req_id = '' THEN req_id := NULL; END IF;
@@ -1107,21 +1138,24 @@ async function databaseAuditEnsureLogTable(sql) {
1107
1138
  END;
1108
1139
 
1109
1140
  IF TG_OP = 'INSERT' THEN
1141
+ EXECUTE format('SELECT ($1).%I::TEXT', pk_col) USING NEW INTO pk_val;
1110
1142
  INSERT INTO audit_log (table_name, record_id, action, new_data, replay_request_id, replay_request_call_index)
1111
- VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW), req_id, call_idx);
1143
+ VALUES (TG_TABLE_NAME, pk_val, 'INSERT', to_jsonb(NEW), req_id, call_idx);
1112
1144
  RETURN NEW;
1113
1145
  ELSIF TG_OP = 'UPDATE' THEN
1146
+ EXECUTE format('SELECT ($1).%I::TEXT', pk_col) USING NEW INTO pk_val;
1114
1147
  SELECT ARRAY_AGG(n.key) INTO changed_cols
1115
1148
  FROM jsonb_each_text(to_jsonb(NEW)) n
1116
1149
  LEFT JOIN jsonb_each_text(to_jsonb(OLD)) o ON n.key = o.key
1117
1150
  WHERE o.value IS DISTINCT FROM n.value;
1118
1151
 
1119
1152
  INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields, replay_request_id, replay_request_call_index)
1120
- VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
1153
+ VALUES (TG_TABLE_NAME, pk_val, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
1121
1154
  RETURN NEW;
1122
1155
  ELSIF TG_OP = 'DELETE' THEN
1156
+ EXECUTE format('SELECT ($1).%I::TEXT', pk_col) USING OLD INTO pk_val;
1123
1157
  INSERT INTO audit_log (table_name, record_id, action, old_data, replay_request_id, replay_request_call_index)
1124
- VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD), req_id, call_idx);
1158
+ VALUES (TG_TABLE_NAME, pk_val, 'DELETE', to_jsonb(OLD), req_id, call_idx);
1125
1159
  RETURN OLD;
1126
1160
  END IF;
1127
1161
  RETURN NULL;
@@ -1129,19 +1163,22 @@ async function databaseAuditEnsureLogTable(sql) {
1129
1163
  $$ LANGUAGE plpgsql
1130
1164
  `;
1131
1165
  }
1132
- async function databaseAuditMonitorTable(sql, tableName) {
1166
+ async function databaseAuditMonitorTable(sql, tableName, primaryKeyColumn = "id") {
1133
1167
  if (tableName === "audit_log") {
1134
1168
  throw new Error("Cannot monitor the audit_log table itself");
1135
1169
  }
1136
1170
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
1137
1171
  throw new Error(`Invalid table name: ${tableName}`);
1138
1172
  }
1173
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(primaryKeyColumn)) {
1174
+ throw new Error(`Invalid primary key column name: ${primaryKeyColumn}`);
1175
+ }
1139
1176
  const triggerName = `audit_trigger_${tableName}`;
1140
1177
  await sql(
1141
1178
  `DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`
1142
1179
  );
1143
1180
  await sql(
1144
- `CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`
1181
+ `CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function('${primaryKeyColumn}')`
1145
1182
  );
1146
1183
  }
1147
1184
  async function databaseAuditDumpLogTable(sql) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {