adonisjs-server-stats 1.16.1 → 1.17.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.
Files changed (88) hide show
  1. package/README.md +14 -2
  2. package/dist/core/core/dashboard-api.d.ts +6 -4
  3. package/dist/core/index.js +9 -6
  4. package/dist/core/types.d.ts +24 -1
  5. package/dist/react/{CacheSection-DkLtukWz.js → CacheSection-C-X2Tnca.js} +48 -44
  6. package/dist/react/{CacheTab-DWeXnEBI.js → CacheTab-D55mfmeE.js} +1 -1
  7. package/dist/react/{ConfigSection-DaVbFPDg.js → ConfigSection-C-HGnZfd.js} +1 -1
  8. package/dist/react/{ConfigTab-D4640WJZ.js → ConfigTab-CNnsOXBh.js} +1 -1
  9. package/dist/react/{CustomPaneTab-DglmAVMC.js → CustomPaneTab-D-267UVd.js} +1 -1
  10. package/dist/react/{EmailsSection-vYx6ExTb.js → EmailsSection-Bqw4DI4r.js} +65 -57
  11. package/dist/react/{EmailsTab-DKDrRmRH.js → EmailsTab-CkHOhj2F.js} +1 -1
  12. package/dist/react/{EventsSection-CapH5xut.js → EventsSection-CMeyjzVC.js} +1 -1
  13. package/dist/react/{EventsTab-BNVEySAm.js → EventsTab-DgS2v1HX.js} +1 -1
  14. package/dist/react/{JobsSection-ZIQsJWid.js → JobsSection-oWH6pbrG.js} +1 -1
  15. package/dist/react/{JobsTab-DXQzXrDt.js → JobsTab-zdxX_as-.js} +1 -1
  16. package/dist/react/{LogEntryRow-BWkHE51-.js → LogEntryRow-Cn8Ee_X0.js} +1 -1
  17. package/dist/react/{LogsSection-ConXdBkL.js → LogsSection-XmiI2MMs.js} +2 -2
  18. package/dist/react/{LogsTab-CJM47LPn.js → LogsTab-ixusY4v7.js} +2 -2
  19. package/dist/react/{OverviewSection-DOMu2qvl.js → OverviewSection-CvhObTLP.js} +119 -111
  20. package/dist/react/{QueriesSection-DFsaOSJI.js → QueriesSection-BvledwBI.js} +1 -1
  21. package/dist/react/{QueriesTab-CY9CG_7L.js → QueriesTab-smQQJvJa.js} +1 -1
  22. package/dist/react/{RequestsSection-CC-eVHsl.js → RequestsSection-DKuYpViv.js} +2 -2
  23. package/dist/react/{RoutesSection-p1DMq41y.js → RoutesSection-5RNVurQ6.js} +1 -1
  24. package/dist/react/{RoutesTab-Cnqy8UcK.js → RoutesTab-CpXNO-Rx.js} +1 -1
  25. package/dist/react/{SplitPaneWrapper-XgkA0QxE.js → SplitPaneWrapper-DQvJnsuk.js} +1 -1
  26. package/dist/react/{TimelineTab-1YOERxe5.js → TimelineTab-LHQHHKkO.js} +2 -2
  27. package/dist/react/components/Dashboard/overview-data-context.d.ts +15 -0
  28. package/dist/react/components/Dashboard/sections/OverviewSection.d.ts +9 -0
  29. package/dist/react/{index-DOSlCpZ9.js → index-LlFvooUN.js} +148 -141
  30. package/dist/react/index.js +1 -1
  31. package/dist/src/collectors/app_collector.d.ts +11 -0
  32. package/dist/src/collectors/app_collector.js +11 -0
  33. package/dist/src/collectors/redis_collector.js +13 -13
  34. package/dist/src/dashboard/cache_handlers.d.ts +17 -3
  35. package/dist/src/dashboard/cache_handlers.js +14 -19
  36. package/dist/src/dashboard/coalesce_cache.d.ts +2 -8
  37. package/dist/src/dashboard/coalesce_cache.js +25 -0
  38. package/dist/src/dashboard/dashboard_controller.d.ts +1 -0
  39. package/dist/src/dashboard/dashboard_controller.js +8 -3
  40. package/dist/src/dashboard/dashboard_store.d.ts +0 -1
  41. package/dist/src/dashboard/dashboard_store.js +14 -6
  42. package/dist/src/dashboard/filter_handlers.js +26 -4
  43. package/dist/src/dashboard/flush_manager.d.ts +1 -0
  44. package/dist/src/dashboard/flush_manager.js +6 -1
  45. package/dist/src/dashboard/format_helpers.d.ts +0 -4
  46. package/dist/src/dashboard/format_helpers.js +0 -19
  47. package/dist/src/dashboard/integrations/config_inspector.js +6 -3
  48. package/dist/src/dashboard/migrator_tables.js +4 -0
  49. package/dist/src/dashboard/paginate_helper.js +21 -11
  50. package/dist/src/dashboard/sensitive_patterns.d.ts +37 -0
  51. package/dist/src/dashboard/sensitive_patterns.js +146 -5
  52. package/dist/src/dashboard/write_queue.d.ts +20 -21
  53. package/dist/src/dashboard/write_queue.js +37 -60
  54. package/dist/src/debug/query_collector.js +6 -1
  55. package/dist/src/debug/trace_collector.js +26 -13
  56. package/dist/src/edge/client/dashboard.js +2 -2
  57. package/dist/src/edge/client-vue/dashboard.js +5 -5
  58. package/dist/src/edge/views/dashboard.edge +1 -1
  59. package/dist/src/edge/views/debug-panel.edge +1 -1
  60. package/dist/src/edge/views/stats-bar.edge +1 -1
  61. package/dist/src/middleware/request_tracking_middleware.js +7 -2
  62. package/dist/src/provider/dashboard_init.d.ts +9 -1
  63. package/dist/src/provider/dashboard_init.js +10 -0
  64. package/dist/src/provider/email_helpers.js +20 -5
  65. package/dist/src/provider/server_stats_provider.d.ts +9 -0
  66. package/dist/src/provider/server_stats_provider.js +26 -7
  67. package/dist/src/provider/shutdown_helpers.js +3 -1
  68. package/dist/src/provider/toolbar_setup.js +21 -15
  69. package/dist/src/types.d.ts +24 -1
  70. package/dist/vue/CacheSection-Bm0goqX6.js +161 -0
  71. package/dist/vue/{ConfigSection-BiRAiaHj.js → ConfigSection-BrYslIMN.js} +1 -1
  72. package/dist/vue/{EmailsSection-Dl44qyqY.js → EmailsSection-B8N5vaWA.js} +89 -85
  73. package/dist/vue/{EventsSection-CWjeitjU.js → EventsSection-D2Y_xt0m.js} +1 -1
  74. package/dist/vue/{JobsSection-DOBb4LjZ.js → JobsSection-cEKqELLC.js} +1 -1
  75. package/dist/vue/{LogsSection-CXx-HOWJ.js → LogsSection-C3H2c0S3.js} +1 -1
  76. package/dist/vue/{OverviewSection-CyfNQ8uV.js → OverviewSection-I2swuHDa.js} +1 -1
  77. package/dist/vue/{QueriesSection-CXBsFp-y.js → QueriesSection-CF1IE3SN.js} +1 -1
  78. package/dist/vue/{RequestsSection-YTIaZGZd.js → RequestsSection-CuwgXzmU.js} +1 -1
  79. package/dist/vue/{RoutesSection-CDKMey49.js → RoutesSection-BhJCF7Rn.js} +1 -1
  80. package/dist/vue/composables/useDashboardData.d.ts +1 -1
  81. package/dist/vue/{index-14x39RI_.js → index-DUsImuIN.js} +95 -95
  82. package/dist/vue/index.js +1 -1
  83. package/package.json +12 -3
  84. package/dist/src/dashboard/explain_query.d.ts +0 -8
  85. package/dist/src/dashboard/explain_query.js +0 -22
  86. package/dist/src/provider/boot_initializer.d.ts +0 -28
  87. package/dist/src/provider/boot_initializer.js +0 -35
  88. package/dist/vue/CacheSection-CxEBVVkF.js +0 -156
@@ -9,9 +9,22 @@
9
9
  */
10
10
  // Custom word boundaries: `\b` does not match between `CLIENT` and `SECRET` in
11
11
  // `GOOGLE_CLIENT_SECRET`, because `_` is a word character. These treat `_`,
12
- // `.`, and `-` as separators, and also match a bare token on its own.
12
+ // `.`, and `-` as separators, and also match a bare token on its own. The
13
+ // trailing boundary tolerates a plural `s` so `tokens`/`secrets` match too.
14
+ // camelCase is handled by normalizing the name BEFORE testing (see
15
+ // `splitCamelCase`), not by the boundaries themselves.
13
16
  const B = '(?:^|[_.\\-])'; // boundary before
14
- const A = '(?:$|[_.\\-])'; // boundary after
17
+ const A = 's?(?:$|[_.\\-])'; // boundary after (optional plural)
18
+ /**
19
+ * Insert `_` at lower/digit→UPPER transitions so the separator-based patterns
20
+ * above see camelCase the same way they see snake_case. Without this,
21
+ * `passwordHash` and `clientSecret` — the *normal* shape for AdonisJS config
22
+ * keys and camelCase column strategies — sail past every pattern while their
23
+ * snake_case twins are redacted.
24
+ */
25
+ function splitCamelCase(name) {
26
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
27
+ }
15
28
  /**
16
29
  * Names that identify a credential — applies to env vars, config keys, and SQL
17
30
  * identifiers alike.
@@ -23,6 +36,9 @@ export const SECRET_NAME_PATTERNS = [
23
36
  new RegExp(`${B}credential${A}`, 'i'),
24
37
  new RegExp(`${B}private${A}`, 'i'),
25
38
  new RegExp(`${B}auth${A}`, 'i'),
39
+ // HTTP credential carriers — log records embed request headers
40
+ new RegExp(`${B}authorization${A}`, 'i'),
41
+ new RegExp(`${B}cookie${A}`, 'i'),
26
42
  // API keys: `api_key`, `apiKey`, `API_KEY`
27
43
  /api[_-]?key/i,
28
44
  // `_KEY` at end or `_KEY_` in middle (AWS_ACCESS_KEY_ID, ENCRYPTION_KEY, etc.)
@@ -41,8 +57,18 @@ export const SECRET_NAME_PATTERNS = [
41
57
  new RegExp(`${B}encryption${A}`, 'i'),
42
58
  // App key / app secret
43
59
  /app[_-]key/i,
44
- // One-time codes
60
+ // One-time codes and second factors
45
61
  new RegExp(`${B}otp${A}`, 'i'),
62
+ new RegExp(`${B}totp${A}`, 'i'),
63
+ new RegExp(`${B}mfa${A}`, 'i'),
64
+ // Password abbreviations
65
+ new RegExp(`${B}passwd${A}`, 'i'),
66
+ new RegExp(`${B}pwd${A}`, 'i'),
67
+ // Card / identity numbers
68
+ new RegExp(`${B}cvv${A}`, 'i'),
69
+ new RegExp(`${B}cvc${A}`, 'i'),
70
+ new RegExp(`${B}pin${A}`, 'i'),
71
+ new RegExp(`${B}ssn${A}`, 'i'),
46
72
  ];
47
73
  /**
48
74
  * Names that matter for env vars and config keys but NOT for SQL identifiers.
@@ -60,11 +86,15 @@ export const CONFIG_ONLY_NAME_PATTERNS = [
60
86
  ];
61
87
  /** Whether a name identifies a credential. */
62
88
  export function isSecretName(name) {
63
- return SECRET_NAME_PATTERNS.some((pattern) => pattern.test(name));
89
+ const normalized = splitCamelCase(name);
90
+ return SECRET_NAME_PATTERNS.some((pattern) => pattern.test(normalized));
64
91
  }
65
92
  /** Whether a name is sensitive in a config/env context (credentials plus contact/service names). */
66
93
  export function isSensitiveConfigName(name) {
67
- return isSecretName(name) || CONFIG_ONLY_NAME_PATTERNS.some((pattern) => pattern.test(name));
94
+ if (isSecretName(name))
95
+ return true;
96
+ const normalized = splitCamelCase(name);
97
+ return CONFIG_ONLY_NAME_PATTERNS.some((pattern) => pattern.test(normalized));
68
98
  }
69
99
  /** Identifier-ish tokens in a SQL statement: table names, column names, aliases. */
70
100
  const SQL_IDENTIFIER_RE = /[A-Za-z_][A-Za-z0-9_$]*/g;
@@ -116,3 +146,114 @@ export function looksLikeCredentialValue(value) {
116
146
  return true;
117
147
  return CREDENTIAL_VALUE_PATTERNS.some((pattern) => pattern.test(value));
118
148
  }
149
+ // ---------------------------------------------------------------------------
150
+ // Binding hygiene
151
+ // ---------------------------------------------------------------------------
152
+ /** Max length for a stored string binding before it is truncated. */
153
+ const MAX_BINDING_LEN = 256;
154
+ /** Placeholder stored in place of a binding that looks like a credential. */
155
+ const REDACTED_BINDING = '[redacted]';
156
+ /**
157
+ * Redact and truncate SQL bindings before persistence.
158
+ *
159
+ * Two independent rules, because neither catches everything on its own:
160
+ *
161
+ * 1. **By statement.** If the SQL mentions a credential-shaped identifier
162
+ * (`password`, `remember_token`, `otp`, ...), every binding for that
163
+ * statement is redacted. Positional bindings cannot be mapped back to
164
+ * columns reliably, so this is all-or-nothing per statement — coarse, but
165
+ * it is the only thing that catches a short secret like a 6-digit OTP.
166
+ * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
167
+ * digests, and URLs with embedded credentials are redacted wherever they
168
+ * appear, regardless of the statement.
169
+ *
170
+ * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
171
+ * oversized payload cannot bloat the row.
172
+ *
173
+ * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
174
+ * the query pane stays useful for debugging.
175
+ */
176
+ export function sanitizeBindings(bindings, sqlText) {
177
+ const redactAll = sqlText !== undefined && sqlMentionsSecret(sqlText);
178
+ return sanitizeBindingValue(bindings, redactAll, true);
179
+ }
180
+ /**
181
+ * Redact a structured record (a pino log entry, an event payload) with the
182
+ * same key/shape rules as bindings but WITHOUT length truncation — log data
183
+ * carries stack traces and payload context that must stay whole.
184
+ */
185
+ export function sanitizeRecordValues(record) {
186
+ return sanitizeBindingValue(record, false, false);
187
+ }
188
+ function sanitizeBindingValue(value, redactAll, truncate) {
189
+ if (Array.isArray(value))
190
+ return value.map((v) => sanitizeBindingValue(v, redactAll, truncate));
191
+ if (value !== null && typeof value === 'object') {
192
+ return sanitizeNamedBindings(value, redactAll, truncate);
193
+ }
194
+ if (typeof value === 'string')
195
+ return sanitizeStringBinding(value, redactAll, truncate);
196
+ // A statement touching a secret column may bind it as a non-string — a numeric
197
+ // OTP, for instance — so redact those too rather than only masking strings.
198
+ // Booleans and null carry nothing worth hiding.
199
+ const redactable = value !== null && value !== undefined && typeof value !== 'boolean';
200
+ return redactAll && redactable ? REDACTED_BINDING : value;
201
+ }
202
+ /** Named bindings carry their own key, so use it when it is telling. */
203
+ function sanitizeNamedBindings(value, redactAll, truncate) {
204
+ const out = {};
205
+ for (const [key, nested] of Object.entries(value)) {
206
+ out[key] = sanitizeBindingValue(nested, redactAll || isSecretName(key), truncate);
207
+ }
208
+ return out;
209
+ }
210
+ /** Matches a value this module already truncated, so a second pass is a no-op. */
211
+ const TRUNCATION_MARKER_RE = /…\[truncated \d+ chars\]$/;
212
+ function sanitizeStringBinding(value, redactAll, truncate) {
213
+ if (redactAll || looksLikeCredentialValue(value))
214
+ return REDACTED_BINDING;
215
+ if (truncate && value.length > MAX_BINDING_LEN && !TRUNCATION_MARKER_RE.test(value)) {
216
+ return value.slice(0, MAX_BINDING_LEN) + `…[truncated ${value.length} chars]`;
217
+ }
218
+ return value;
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // URL hygiene
222
+ // ---------------------------------------------------------------------------
223
+ /**
224
+ * Redact credential-bearing query parameters from a URL before it is stored.
225
+ *
226
+ * Request URLs are persisted with their query strings (useful for debugging
227
+ * pagination and filters), but a `?token=…` password-reset link or a
228
+ * `?signature=…` signed URL must not land in SQLite in the clear. Parameters
229
+ * are redacted by name (see {@link isSecretName}) or when the value itself
230
+ * looks like a credential; everything else passes through untouched.
231
+ */
232
+ export function sanitizeUrlQuery(url) {
233
+ const qIdx = url.indexOf('?');
234
+ if (qIdx === -1)
235
+ return url;
236
+ const sanitized = url
237
+ .slice(qIdx + 1)
238
+ .split('&')
239
+ .map((pair) => {
240
+ if (pair === '')
241
+ return pair;
242
+ const eq = pair.indexOf('=');
243
+ const name = eq === -1 ? pair : pair.slice(0, eq);
244
+ const rawValue = eq === -1 ? '' : pair.slice(eq + 1);
245
+ let value = rawValue;
246
+ try {
247
+ value = decodeURIComponent(rawValue);
248
+ }
249
+ catch {
250
+ // Malformed escape — judge the raw text instead.
251
+ }
252
+ if (isSecretName(name) || (value !== '' && looksLikeCredentialValue(value))) {
253
+ return `${name}=[redacted]`;
254
+ }
255
+ return pair;
256
+ })
257
+ .join('&');
258
+ return url.slice(0, qIdx) + '?' + sanitized;
259
+ }
@@ -5,11 +5,18 @@
5
5
  * objects. They are pure (no I/O, no Knex dependency) so they can
6
6
  * be tested in isolation.
7
7
  */
8
+ import { sanitizeBindings } from './sensitive_patterns.js';
8
9
  import type { EventRecord, EmailRecord } from '../debug/types.js';
9
10
  import type { PersistRequestInput } from './dashboard_types.js';
10
11
  import type { Knex } from 'knex';
11
12
  export declare function hasWarned(path: string): boolean;
12
13
  export declare function markWarned(path: string): void;
14
+ /**
15
+ * Forget which write paths have warned. Called when a DashboardStore stops so
16
+ * a re-initialized store in the same process warns afresh instead of
17
+ * inheriting a spent latch from its predecessor.
18
+ */
19
+ export declare function resetWriteWarnings(): void;
13
20
  /**
14
21
  * Normalize a SQL query by replacing literal values with `?` placeholders.
15
22
  * Used for grouping identical query patterns.
@@ -20,27 +27,7 @@ export declare function markWarned(path: string): void;
20
27
  * merged together.
21
28
  */
22
29
  export declare function normalizeSql(sql: string): string;
23
- /**
24
- * Redact and truncate SQL bindings before persistence.
25
- *
26
- * Two independent rules, because neither catches everything on its own:
27
- *
28
- * 1. **By statement.** If the SQL mentions a credential-shaped identifier
29
- * (`password`, `remember_token`, `otp`, ...), every binding for that
30
- * statement is redacted. Positional bindings cannot be mapped back to
31
- * columns reliably, so this is all-or-nothing per statement — coarse, but
32
- * it is the only thing that catches a short secret like a 6-digit OTP.
33
- * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
34
- * digests, and URLs with embedded credentials are redacted wherever they
35
- * appear, regardless of the statement.
36
- *
37
- * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
38
- * oversized payload cannot bloat the row.
39
- *
40
- * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
41
- * the query pane stays useful for debugging.
42
- */
43
- export declare function sanitizeBindings(bindings: unknown, sqlText?: string): unknown;
30
+ export { sanitizeBindings };
44
31
  export interface PreparedQuery {
45
32
  sql_text: string;
46
33
  sql_normalized: string;
@@ -100,6 +87,13 @@ export interface EventRow {
100
87
  export declare function prepareRequestRows(requests: PersistRequestInput[]): PreparedRequest[];
101
88
  /**
102
89
  * Transform raw log entries into SQLite-ready row objects.
90
+ *
91
+ * The full pino record lands in `data`, and log lines routinely carry bound
92
+ * request context — authorization headers, cookies, tokens in payloads — so
93
+ * the record is run through the shared redaction rules (secret-named keys and
94
+ * credential-shaped values) before it is stringified. The `message` column is
95
+ * kept verbatim: it is free text, and the shape rules are anchored to whole
96
+ * values, so prose stays readable.
103
97
  */
104
98
  export declare function prepareLogRows(logs: Record<string, unknown>[]): PreparedLog[];
105
99
  /**
@@ -118,6 +112,11 @@ export declare function buildEmailRow(record: EmailRecord): EmailRow;
118
112
  export declare function buildEventRows(events: EventRecord[]): EventRow[];
119
113
  /**
120
114
  * Insert rows into a table in batches of 50.
115
+ *
116
+ * Yields to the event loop between chunks: better-sqlite3 is fully
117
+ * synchronous, so without a real macrotask hop a large batch would execute
118
+ * back-to-back on microtask continuations and stall the host app — the exact
119
+ * failure mode `migrator_tables.ts` documents and guards against.
121
120
  */
122
121
  export declare function batchInsert(trx: Knex.Transaction, table: string, rows: Record<string, unknown>[]): Promise<void>;
123
122
  export declare function flushRequests(trx: Knex.Transaction, preparedRequests: PreparedRequest[]): Promise<void>;
@@ -6,7 +6,8 @@
6
6
  * be tested in isolation.
7
7
  */
8
8
  import { round } from '../utils/math_helpers.js';
9
- import { isSecretName, looksLikeCredentialValue, sqlMentionsSecret } from './sensitive_patterns.js';
9
+ import { yieldToEventLoop } from './migrator_tables.js';
10
+ import { sanitizeBindings, sanitizeRecordValues } from './sensitive_patterns.js';
10
11
  // ---------------------------------------------------------------------------
11
12
  // Warn-once tracking for write-path catch blocks
12
13
  // ---------------------------------------------------------------------------
@@ -17,6 +18,14 @@ export function hasWarned(path) {
17
18
  export function markWarned(path) {
18
19
  warnedWritePaths.add(path);
19
20
  }
21
+ /**
22
+ * Forget which write paths have warned. Called when a DashboardStore stops so
23
+ * a re-initialized store in the same process warns afresh instead of
24
+ * inheriting a spent latch from its predecessor.
25
+ */
26
+ export function resetWriteWarnings() {
27
+ warnedWritePaths.clear();
28
+ }
20
29
  // ---------------------------------------------------------------------------
21
30
  // SQL normalization
22
31
  // ---------------------------------------------------------------------------
@@ -37,65 +46,11 @@ export function normalizeSql(sql) {
37
46
  .trim();
38
47
  }
39
48
  // ---------------------------------------------------------------------------
40
- // Binding hygiene
49
+ // Binding hygiene — implementation lives in sensitive_patterns.ts so the
50
+ // in-memory QueryCollector can sanitize at capture time without importing the
51
+ // write path. Re-exported here for existing consumers.
41
52
  // ---------------------------------------------------------------------------
42
- /** Max length for a persisted string binding before it is truncated. */
43
- const MAX_BINDING_LEN = 256;
44
- /** Placeholder stored in place of a binding that looks like a credential. */
45
- const REDACTED_BINDING = '[redacted]';
46
- /**
47
- * Redact and truncate SQL bindings before persistence.
48
- *
49
- * Two independent rules, because neither catches everything on its own:
50
- *
51
- * 1. **By statement.** If the SQL mentions a credential-shaped identifier
52
- * (`password`, `remember_token`, `otp`, ...), every binding for that
53
- * statement is redacted. Positional bindings cannot be mapped back to
54
- * columns reliably, so this is all-or-nothing per statement — coarse, but
55
- * it is the only thing that catches a short secret like a 6-digit OTP.
56
- * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
57
- * digests, and URLs with embedded credentials are redacted wherever they
58
- * appear, regardless of the statement.
59
- *
60
- * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
61
- * oversized payload cannot bloat the row.
62
- *
63
- * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
64
- * the query pane stays useful for debugging.
65
- */
66
- export function sanitizeBindings(bindings, sqlText) {
67
- const redactAll = sqlText !== undefined && sqlMentionsSecret(sqlText);
68
- return sanitizeBindingValue(bindings, redactAll);
69
- }
70
- function sanitizeBindingValue(value, redactAll) {
71
- if (Array.isArray(value))
72
- return value.map((v) => sanitizeBindingValue(v, redactAll));
73
- if (value !== null && typeof value === 'object')
74
- return sanitizeNamedBindings(value, redactAll);
75
- if (typeof value === 'string')
76
- return sanitizeStringBinding(value, redactAll);
77
- // A statement touching a secret column may bind it as a non-string — a numeric
78
- // OTP, for instance — so redact those too rather than only masking strings.
79
- // Booleans and null carry nothing worth hiding.
80
- const redactable = value !== null && value !== undefined && typeof value !== 'boolean';
81
- return redactAll && redactable ? REDACTED_BINDING : value;
82
- }
83
- /** Named bindings carry their own key, so use it when it is telling. */
84
- function sanitizeNamedBindings(value, redactAll) {
85
- const out = {};
86
- for (const [key, nested] of Object.entries(value)) {
87
- out[key] = sanitizeBindingValue(nested, redactAll || isSecretName(key));
88
- }
89
- return out;
90
- }
91
- function sanitizeStringBinding(value, redactAll) {
92
- if (redactAll || looksLikeCredentialValue(value))
93
- return REDACTED_BINDING;
94
- if (value.length > MAX_BINDING_LEN) {
95
- return value.slice(0, MAX_BINDING_LEN) + `…[truncated ${value.length} chars]`;
96
- }
97
- return value;
98
- }
53
+ export { sanitizeBindings };
99
54
  // ---------------------------------------------------------------------------
100
55
  // Pure data-prep functions
101
56
  // ---------------------------------------------------------------------------
@@ -135,6 +90,13 @@ export function prepareRequestRows(requests) {
135
90
  }
136
91
  /**
137
92
  * Transform raw log entries into SQLite-ready row objects.
93
+ *
94
+ * The full pino record lands in `data`, and log lines routinely carry bound
95
+ * request context — authorization headers, cookies, tokens in payloads — so
96
+ * the record is run through the shared redaction rules (secret-named keys and
97
+ * credential-shaped values) before it is stringified. The `message` column is
98
+ * kept verbatim: it is free text, and the shape rules are anchored to whole
99
+ * values, so prose stays readable.
138
100
  */
139
101
  export function prepareLogRows(logs) {
140
102
  return logs.map((entry) => {
@@ -145,7 +107,7 @@ export function prepareLogRows(logs) {
145
107
  request_id: entry.request_id || entry.requestId || entry['x-request-id']
146
108
  ? String(entry.request_id || entry.requestId || entry['x-request-id'])
147
109
  : null,
148
- data: JSON.stringify(entry),
110
+ data: JSON.stringify(sanitizeRecordValues(entry)),
149
111
  };
150
112
  });
151
113
  }
@@ -187,9 +149,16 @@ export function buildEventRows(events) {
187
149
  // ---------------------------------------------------------------------------
188
150
  /**
189
151
  * Insert rows into a table in batches of 50.
152
+ *
153
+ * Yields to the event loop between chunks: better-sqlite3 is fully
154
+ * synchronous, so without a real macrotask hop a large batch would execute
155
+ * back-to-back on microtask continuations and stall the host app — the exact
156
+ * failure mode `migrator_tables.ts` documents and guards against.
190
157
  */
191
158
  export async function batchInsert(trx, table, rows) {
192
159
  for (let i = 0; i < rows.length; i += 50) {
160
+ if (i > 0)
161
+ await yieldToEventLoop();
193
162
  await trx(table).insert(rows.slice(i, i + 50));
194
163
  }
195
164
  }
@@ -233,7 +202,15 @@ async function insertOneRequest(trx, prepared) {
233
202
  }
234
203
  }
235
204
  export async function flushRequests(trx, preparedRequests) {
205
+ let sinceYield = 0;
236
206
  for (const prepared of preparedRequests) {
207
+ // Up to ~4 synchronous statements per request; a full backlog is hundreds
208
+ // of them in one flush. Yield periodically so the host app's I/O keeps
209
+ // being serviced mid-flush (see batchInsert for why awaits alone don't).
210
+ if (++sinceYield >= 10) {
211
+ sinceYield = 0;
212
+ await yieldToEventLoop();
213
+ }
237
214
  try {
238
215
  await insertOneRequest(trx, prepared);
239
216
  }
@@ -1,3 +1,4 @@
1
+ import { sanitizeBindings } from '../dashboard/sensitive_patterns.js';
1
2
  import { isExcludedRequest } from '../middleware/request_tracking_middleware.js';
2
3
  import { round } from '../utils/math_helpers.js';
3
4
  import { RingBuffer } from './ring_buffer.js';
@@ -14,7 +15,11 @@ function buildQueryRecord(data, id) {
14
15
  return {
15
16
  id,
16
17
  sql: data.sql || '',
17
- bindings: data.bindings || [],
18
+ // Sanitized at capture time so the in-memory debug panel never shows a
19
+ // credential the persisted dashboard would have redacted. The write path
20
+ // sanitizes again (idempotently) as defense in depth for records loaded
21
+ // from an older on-disk dump.
22
+ bindings: sanitizeBindings(data.bindings || [], data.sql || '') ?? [],
18
23
  duration: round(parseDuration(data.duration)),
19
24
  method: data.method || 'unknown',
20
25
  model: data.model || null,
@@ -7,11 +7,16 @@ import { RingBuffer } from './ring_buffer.js';
7
7
  */
8
8
  const globalRef = { current: null };
9
9
  /**
10
- * Tracks whether console.warn is currently patched by a TraceCollector, so a
11
- * second instance (or a skipped stop()) cannot double-wrap it and leak the
12
- * patch permanently.
10
+ * Tracks which TraceCollector currently owns the console.warn patch, so a
11
+ * second instance cannot double-wrap it and leak the patch permanently. The
12
+ * owner (not a boolean) matters: if a prior instance never ran stop(), its
13
+ * wrapper checks the *old* AsyncLocalStorage and silently stops attaching
14
+ * warnings to traces — a newcomer must be able to detect that and take over.
13
15
  */
14
- const consoleWarnPatch = { active: false };
16
+ const consoleWarnPatch = {
17
+ owner: null,
18
+ original: null,
19
+ };
15
20
  /**
16
21
  * Wrap an async function in a traced span.
17
22
  *
@@ -157,12 +162,18 @@ export class TraceCollector {
157
162
  };
158
163
  emitter.on('db:query', this.dbHandler);
159
164
  }
160
- // Intercept console.warn to capture warnings per-request. Guard against
161
- // double-wrapping: if another instance (or a prior start() without a
162
- // matching stop()) already patched console.warn, don't wrap it again —
163
- // that would capture the patched fn as the "original" and leak the patch.
164
- if (!consoleWarnPatch.active) {
165
- consoleWarnPatch.active = true;
165
+ // Intercept console.warn to capture warnings per-request. If a prior
166
+ // instance still owns the patch (its stop() was skipped), restore its
167
+ // original first and take over — deferring to the stale owner would leave
168
+ // a wrapper bound to a dead AsyncLocalStorage, silently detaching
169
+ // warnings from every trace. Never wrap the wrapper: the restore below
170
+ // guarantees `console.warn` is the real one before we capture it.
171
+ if (consoleWarnPatch.owner !== this) {
172
+ if (consoleWarnPatch.owner && consoleWarnPatch.original) {
173
+ console.warn = consoleWarnPatch.original;
174
+ }
175
+ consoleWarnPatch.owner = this;
176
+ consoleWarnPatch.original = console.warn;
166
177
  this.originalConsoleWarn = console.warn;
167
178
  console.warn = (...args) => {
168
179
  const ctx = this.als.getStore();
@@ -178,10 +189,12 @@ export class TraceCollector {
178
189
  if (this.emitter && this.dbHandler) {
179
190
  this.emitter.off('db:query', this.dbHandler);
180
191
  }
181
- // Only restore console.warn if this instance was the one that patched it.
182
- if (this.originalConsoleWarn) {
192
+ // Only restore console.warn if this instance still owns the patch — a
193
+ // newer instance may have taken over, and restoring would clobber it.
194
+ if (consoleWarnPatch.owner === this && this.originalConsoleWarn) {
183
195
  console.warn = this.originalConsoleWarn;
184
- consoleWarnPatch.active = false;
196
+ consoleWarnPatch.owner = null;
197
+ consoleWarnPatch.original = null;
185
198
  }
186
199
  this.dbHandler = null;
187
200
  this.emitter = null;