@zintrust/trace 0.4.81 → 0.4.82

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.
@@ -1,13 +1,14 @@
1
1
  /**
2
- * MailWatcher — records mail dispatch intent.
3
- * Body is never captured; only to/subject/template.
2
+ * MailWatcher — records mail dispatch intent and rendered content.
4
3
  */
5
4
  import { TraceContext } from '../context.js';
6
5
  import { EntryType } from '../types.js';
6
+ import { redactUnknown } from '../utils/redact.js';
7
7
  import { RequestFilter } from '../utils/requestFilter.js';
8
8
  let _storage = null;
9
+ let _redactionFields = [];
9
10
  let _ignoreRoutes = [];
10
- const emit = (to, subject, template) => {
11
+ const emit = (to, subject, template, text, html) => {
11
12
  if (!_storage)
12
13
  return;
13
14
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes))
@@ -16,6 +17,12 @@ const emit = (to, subject, template) => {
16
17
  to,
17
18
  subject,
18
19
  template,
20
+ ...(typeof text === 'string' && text !== ''
21
+ ? { text: redactUnknown(text, _redactionFields) }
22
+ : {}),
23
+ ...(typeof html === 'string' && html !== ''
24
+ ? { html: redactUnknown(html, _redactionFields) }
25
+ : {}),
19
26
  hostname: TraceContext.getHostname(),
20
27
  };
21
28
  _storage
@@ -36,9 +43,11 @@ export const MailWatcher = Object.freeze({
36
43
  if (config.watchers.mail === false)
37
44
  return () => undefined;
38
45
  _storage = storage;
46
+ _redactionFields = [...config.redaction.keys, ...config.redaction.body];
39
47
  _ignoreRoutes = config.ignoreRoutes;
40
48
  return () => {
41
49
  _storage = null;
50
+ _redactionFields = [];
42
51
  _ignoreRoutes = [];
43
52
  };
44
53
  },
@@ -1,5 +1,5 @@
1
1
  import type { ITraceWatcher } from '../types';
2
- declare const emit: (notification: string, channels: string[], notifiable?: string) => void;
2
+ declare const emit: (notification: string, channels: string[], notifiable?: string, message?: string, payload?: unknown) => void;
3
3
  export declare const NotificationWatcher: ITraceWatcher & {
4
4
  emit: typeof emit;
5
5
  };
@@ -1,10 +1,12 @@
1
1
  import { TraceContext } from '../context.js';
2
2
  import { EntryType } from '../types.js';
3
3
  import { AuthTag } from '../utils/authTag.js';
4
+ import { redactUnknown } from '../utils/redact.js';
4
5
  import { RequestFilter } from '../utils/requestFilter.js';
5
6
  let _storage = null;
7
+ let _redactionFields = [];
6
8
  let _ignoreRoutes = [];
7
- const emit = (notification, channels, notifiable) => {
9
+ const emit = (notification, channels, notifiable, message, payload) => {
8
10
  if (!_storage)
9
11
  return;
10
12
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes))
@@ -13,6 +15,10 @@ const emit = (notification, channels, notifiable) => {
13
15
  notification,
14
16
  channels,
15
17
  notifiable,
18
+ ...(typeof message === 'string' && message !== ''
19
+ ? { message: redactUnknown(message, _redactionFields) }
20
+ : {}),
21
+ ...(payload === undefined ? {} : { payload: redactUnknown(payload, _redactionFields) }),
16
22
  hostname: TraceContext.getHostname(),
17
23
  };
18
24
  _storage
@@ -33,9 +39,11 @@ export const NotificationWatcher = Object.freeze({
33
39
  if (config.watchers.notification === false)
34
40
  return () => undefined;
35
41
  _storage = storage;
42
+ _redactionFields = [...config.redaction.keys, ...config.redaction.body];
36
43
  _ignoreRoutes = config.ignoreRoutes;
37
44
  return () => {
38
45
  _storage = null;
46
+ _redactionFields = [];
39
47
  _ignoreRoutes = [];
40
48
  };
41
49
  },
@@ -35,13 +35,17 @@ export const QueryWatcher = Object.freeze({
35
35
  if (isTraceStorageQuery(query))
36
36
  return;
37
37
  const batchId = TraceContext.getBatchId();
38
- const sql = bindingsInterpolated(query, params);
38
+ const includeBindings = config.captureQueryBindings !== false;
39
+ const sql = includeBindings ? bindingsInterpolated(query, params) : query;
39
40
  const roundedDuration = Math.round(duration * 100) / 100;
40
41
  const hash = TraceStorage.familyHash(query);
41
42
  const slow = roundedDuration >= config.slowQueryThreshold;
42
43
  const content = {
43
44
  connection: 'default',
44
45
  sql,
46
+ statement: query,
47
+ ...(includeBindings ? { bindings: [...params] } : {}),
48
+ bindingsIncluded: includeBindings,
45
49
  time: roundedDuration,
46
50
  duration: roundedDuration,
47
51
  slow,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/trace",
3
- "version": "0.4.81",
3
+ "version": "0.4.82",
4
4
  "description": "Trace assistant for ZinTrust: logs requests, queries, exceptions, jobs, and more.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@zintrust/core": "^0.4.80"
43
+ "@zintrust/core": "^0.4.81"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"
@@ -56,4 +56,4 @@
56
56
  "build": "tsc -p tsconfig.json && tsc -p tsconfig.migrations.json && node ../../scripts/fix-dist-esm-imports.mjs dist",
57
57
  "prepublishOnly": "npm run build"
58
58
  }
59
- }
59
+ }
package/src/config.ts CHANGED
@@ -109,6 +109,8 @@ const DEFAULTS: ITraceConfig = Object.freeze({
109
109
  pruneAfterHours: 24,
110
110
  ignoreRoutes: ['/trace', '/health', '/ping'],
111
111
  slowQueryThreshold: 100,
112
+ captureCachePayloads: false,
113
+ captureQueryBindings: true,
112
114
  logMinLevel: 'info',
113
115
  watchers: {},
114
116
  redaction: {
@@ -47,7 +47,7 @@ const encodeSvgDataUri = (svg: string): string => {
47
47
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(compactSvg)}`;
48
48
  };
49
49
 
50
- const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
50
+ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
51
51
  <html lang="en">
52
52
  <head>
53
53
  <meta charset="UTF-8">
@@ -80,7 +80,7 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
80
80
  .tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:rgba(56,189,248,.12);color:#bae6fd;font-size:.78rem;font-weight:800;margin:0 6px 6px 0;border:1px solid rgba(56,189,248,.18);text-decoration:none}button.tag{cursor:pointer}html[data-theme='light'] .tag{color:#075985}.tag.failed{background:rgba(239,68,68,.14);color:#fecaca;border-color:rgba(239,68,68,.2)}html[data-theme='light'] .tag.failed{color:#b91c1c}.tag.slow{background:rgba(245,158,11,.12);color:#fde68a;border-color:rgba(245,158,11,.18)}html[data-theme='light'] .tag.slow{color:#92400e}.type-pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:.74rem;font-weight:900;text-transform:uppercase;letter-spacing:.08em;border:1px solid transparent}.pill-request{background:rgba(56,189,248,.14);color:#93c5fd}.pill-request.method-get{background:rgba(34,197,94,.16);color:#bbf7d0}.pill-request.method-post{background:rgba(59,130,246,.16);color:#bfdbfe}.pill-request.method-other{background:rgba(245,158,11,.16);color:#fde68a}.pill-query{background:rgba(34,197,94,.12);color:#86efac}.pill-exception{background:rgba(239,68,68,.14);color:#fecaca}.pill-log{background:rgba(168,85,247,.14);color:#ddd6fe}.pill-job,.pill-batch{background:rgba(245,158,11,.14);color:#fde68a}.pill-cache{background:rgba(20,184,166,.12);color:#99f6e4}.pill-schedule,.pill-command{background:rgba(14,165,233,.14);color:#bae6fd}.pill-mail,.pill-notification{background:rgba(236,72,153,.14);color:#fbcfe8}.pill-auth{background:rgba(148,163,184,.16);color:#e2e8f0}.pill-event,.pill-model{background:rgba(74,222,128,.14);color:#bbf7d0}.pill-redis{background:rgba(239,68,68,.12);color:#fecaca}.pill-gate{background:rgba(99,102,241,.14);color:#c7d2fe}.pill-middleware{background:rgba(45,212,191,.12);color:#ccfbf1}.pill-dump,.pill-view{background:rgba(148,163,184,.14);color:#e2e8f0}.pill-client-request{background:rgba(59,130,246,.14);color:#bfdbfe}html[data-theme='light'] .pill-request{color:#1d4ed8}html[data-theme='light'] .pill-request.method-get{color:#166534}html[data-theme='light'] .pill-request.method-post{color:#1d4ed8}html[data-theme='light'] .pill-request.method-other{color:#92400e}html[data-theme='light'] .pill-query{color:#166534}html[data-theme='light'] .pill-exception{color:#b91c1c}html[data-theme='light'] .pill-log{color:#6d28d9}html[data-theme='light'] .pill-job,html[data-theme='light'] .pill-batch{color:#92400e}html[data-theme='light'] .pill-cache{color:#115e59}html[data-theme='light'] .pill-schedule,html[data-theme='light'] .pill-command{color:#0c4a6e}html[data-theme='light'] .pill-mail,html[data-theme='light'] .pill-notification{color:#9d174d}html[data-theme='light'] .pill-auth,html[data-theme='light'] .pill-dump,html[data-theme='light'] .pill-view{color:#334155}html[data-theme='light'] .pill-event,html[data-theme='light'] .pill-model{color:#166534}html[data-theme='light'] .pill-redis{color:#991b1b}html[data-theme='light'] .pill-gate{color:#3730a3}html[data-theme='light'] .pill-middleware{color:#155e75}html[data-theme='light'] .pill-client-request{color:#1d4ed8}
81
81
  .monitoring-wrap{padding:0 24px 24px}.tag-list{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:18px}.tag-item{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;border:1px solid var(--line);background:var(--surface-strong)}.tag-remove{border:none;background:rgba(239,68,68,.14);color:var(--danger);border-radius:999px;width:24px;height:24px;cursor:pointer;font-size:1rem;line-height:1}.helper-text{color:var(--muted);line-height:1.6}
82
82
  .duration-chip{display:inline-flex;align-items:center;padding:5px 9px;border-radius:999px;border:1px solid transparent;font-size:.8rem;font-weight:700;color:var(--text);white-space:nowrap}.duration-chip.vfast{background:rgba(34,197,94,.14);border-color:rgba(34,197,94,.28);color:#bbf7d0}.duration-chip.fast{background:rgba(56,189,248,.12);border-color:rgba(56,189,248,.24);color:#bae6fd}.duration-chip.slow{background:rgba(245,158,11,.12);border-color:rgba(245,158,11,.22);color:#fde68a}.duration-chip.vslow{background:rgba(239,68,68,.14);border-color:rgba(239,68,68,.24);color:#fecaca}html[data-theme='light'] .duration-chip.vfast{color:#166534}html[data-theme='light'] .duration-chip.fast{color:#1d4ed8}html[data-theme='light'] .duration-chip.slow{color:#92400e}html[data-theme='light'] .duration-chip.vslow{color:#b91c1c}
83
- .code-card{border-radius:16px;border:1px solid var(--code-border);background:var(--surface-soft);overflow:hidden}.code-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line)}.code-label{font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);font-weight:800}.copy-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;width:38px;height:38px;border-radius:12px;border:1px solid var(--line);background:var(--surface-strong);color:var(--text);cursor:pointer;transition:border-color .16s ease,color .16s ease}.copy-button:hover{border-color:rgba(56,189,248,.35);color:var(--accent)}.copy-button[data-copied='true']{color:var(--success);border-color:rgba(34,197,94,.28)}.copy-button svg{width:16px;height:16px;display:block}.code-block{margin:0;padding:18px 20px;background:var(--code-bg);color:#dbeafe;border:0;overflow:auto;white-space:pre;line-height:1.72;font-family:var(--mono);font-size:.92rem}.code-block code{font-family:inherit}.tok-key{color:#93c5fd}.tok-string{color:#86efac}.tok-number{color:#f9a8d4}.tok-boolean{color:#facc15}.tok-null{color:#fb7185}.tok-punctuation{color:#94a3b8}.tok-sql-keyword{color:#f472b6;font-weight:700}.tok-sql-identifier{color:#93c5fd}.tok-sql-string{color:#86efac}.tok-sql-number{color:#facc15}.tok-sql-comment{color:#64748b;font-style:italic}html[data-theme='light'] .code-block{color:#0f172a}html[data-theme='light'] .tok-key{color:#1d4ed8}html[data-theme='light'] .tok-string{color:#15803d}html[data-theme='light'] .tok-number{color:#c026d3}html[data-theme='light'] .tok-boolean{color:#b45309}html[data-theme='light'] .tok-null{color:#dc2626}html[data-theme='light'] .tok-punctuation{color:#64748b}html[data-theme='light'] .tok-sql-keyword{color:#db2777}html[data-theme='light'] .tok-sql-identifier{color:#2563eb}html[data-theme='light'] .tok-sql-string{color:#15803d}html[data-theme='light'] .tok-sql-number{color:#b45309}html[data-theme='light'] .tok-sql-comment{color:#6b7280}
83
+ .code-card{border-radius:16px;border:1px solid var(--code-border);background:var(--surface-soft);overflow:hidden}.code-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line)}.code-label{font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);font-weight:800}.copy-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;width:38px;height:38px;border-radius:12px;border:1px solid var(--line);background:var(--surface-strong);color:var(--text);cursor:pointer;transition:border-color .16s ease,color .16s ease}.copy-button:hover{border-color:rgba(56,189,248,.35);color:var(--accent)}.copy-button[data-copied='true']{color:var(--success);border-color:rgba(34,197,94,.28)}.copy-button svg{width:16px;height:16px;display:block}.code-block{margin:0;padding:18px 20px;background:var(--code-bg);color:#dbeafe;border:0;overflow:auto;white-space:pre;line-height:1.72;font-family:var(--mono);font-size:.92rem}.code-block code{font-family:inherit}.html-preview-wrap{padding:14px;background:var(--surface-strong);border-top:1px solid var(--line)}.html-preview{display:block;width:100%;min-height:320px;border:1px solid var(--line);border-radius:14px;background:#fff}.tok-key{color:#93c5fd}.tok-string{color:#86efac}.tok-number{color:#f9a8d4}.tok-boolean{color:#facc15}.tok-null{color:#fb7185}.tok-punctuation{color:#94a3b8}.tok-sql-keyword{color:#f472b6;font-weight:700}.tok-sql-identifier{color:#93c5fd}.tok-sql-string{color:#86efac}.tok-sql-number{color:#facc15}.tok-sql-comment{color:#64748b;font-style:italic}html[data-theme='light'] .code-block{color:#0f172a}html[data-theme='light'] .tok-key{color:#1d4ed8}html[data-theme='light'] .tok-string{color:#15803d}html[data-theme='light'] .tok-number{color:#c026d3}html[data-theme='light'] .tok-boolean{color:#b45309}html[data-theme='light'] .tok-null{color:#dc2626}html[data-theme='light'] .tok-punctuation{color:#64748b}html[data-theme='light'] .tok-sql-keyword{color:#db2777}html[data-theme='light'] .tok-sql-identifier{color:#2563eb}html[data-theme='light'] .tok-sql-string{color:#15803d}html[data-theme='light'] .tok-sql-number{color:#b45309}html[data-theme='light'] .tok-sql-comment{color:#6b7280}
84
84
  @media (max-width:1120px){.content-grid{grid-template-columns:1fr}}@media (max-width:920px){.layout{grid-template-columns:1fr}.sidebar{position:static;height:auto;border-right:none;border-bottom:1px solid var(--line);padding:20px 16px 18px}.brand-row{padding:0 0 16px}.sidebar-status{margin:0 0 16px}.sidebar-group{padding:0}.main{padding:20px}}@media (max-width:640px){.stats-grid{grid-template-columns:1fr}.detail-card{padding:18px}.toolbar,.section-head,.pagination,.activity-list,.monitoring-wrap{padding-left:18px;padding-right:18px}.table-wrap{padding:0 8px 10px}.brand-row{align-items:stretch;gap:14px;padding:0 0 14px}.brand{width:100%;align-items:flex-start}.brand-copy{min-width:0}.brand-copy h1{font-size:1.18rem;line-height:1.12}.brand-copy p{font-size:.82rem;overflow-wrap:anywhere}.icon-button{align-self:flex-end}.sidebar-status{padding:12px}.nav-button{padding:11px 12px}.nav-title{font-size:.95rem}.nav-meta{font-size:.72rem}}@media (max-width:480px){.brand-row{flex-direction:column}.icon-button{align-self:flex-start}.nav-button{align-items:flex-start;flex-direction:column}.nav-meta{font-size:.7rem}}
85
85
  </style>
86
86
  </head>
@@ -202,6 +202,8 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
202
202
  .replace(/"/g, '&quot;')
203
203
  .replace(/'/g, '&#39;');
204
204
 
205
+ const looksLikeHtml = (value) => new RegExp('</?(?:html|body|div|table)\\b|<!doctype\\b', 'i').test(String(value || ''));
206
+
205
207
  const api = async (path, opts) => {
206
208
  const response = await fetch(API + path, opts);
207
209
  if (!response.ok) {
@@ -341,6 +343,28 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
341
343
  ].join('');
342
344
  };
343
345
 
346
+ const renderTextCard = (label, value) => {
347
+ const source = String(value ?? '');
348
+ return renderCodeCard(label, source, escapeHtml(source), 'language-text');
349
+ };
350
+
351
+ const renderHtmlPreview = (label, html) => {
352
+ const source = String(html ?? '');
353
+ const copyId = registerCopyPayload(source);
354
+ return [
355
+ '<section class="code-card">',
356
+ '<div class="code-toolbar">',
357
+ '<span class="code-label">' + escapeHtml(label) + '</span>',
358
+ '<button type="button" class="copy-button" data-action="copy-code" data-copy-id="' + escapeHtml(copyId) + '" title="Copy ' + escapeHtml(label) + '">',
359
+ COPY_ICON,
360
+ '</button>',
361
+ '</div>',
362
+ '<pre class="code-block language-html"><code>' + escapeHtml(source) + '</code></pre>',
363
+ '<div class="html-preview-wrap"><iframe class="html-preview" sandbox="allow-same-origin" srcdoc="' + escapeHtml(source) + '"></iframe></div>',
364
+ '</section>'
365
+ ].join('');
366
+ };
367
+
344
368
  const highlightJson = (value, label = 'JSON') => {
345
369
  const source = prettyJson(value);
346
370
  let output = '';
@@ -392,6 +416,14 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
392
416
 
393
417
  const detailJson = (value, label = 'JSON') => highlightJson(value ?? {}, label);
394
418
 
419
+ const renderPayload = (label, value) => {
420
+ if (value === undefined) return '<p class="trace-note">No ' + escapeHtml(label.toLowerCase()) + ' was captured.</p>';
421
+ if (typeof value === 'string') {
422
+ return looksLikeHtml(value) ? renderHtmlPreview(label, value) : renderTextCard(label, value);
423
+ }
424
+ return detailJson(value, label);
425
+ };
426
+
395
427
  const entrySummaryText = (entry) => {
396
428
  const content = entry && entry.content ? entry.content : {};
397
429
  if (entry.type === 'request') return [content.responseStatus || '', content.method || '', content.uri || ''].filter(Boolean).join(' ');
@@ -399,20 +431,20 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
399
431
  if (entry.type === 'exception') return [content.class || '', content.message || ''].filter(Boolean).join(': ');
400
432
  if (entry.type === 'log') return '[' + String(content.level || 'log') + '] ' + String(content.message || '').slice(0, 160);
401
433
  if (entry.type === 'job') return [content.name || '', content.status || 'queued'].filter(Boolean).join(' · ');
402
- if (entry.type === 'cache') return [content.operation || '', content.key || ''].filter(Boolean).join(' ');
434
+ if (entry.type === 'cache') return [content.operation || '', content.key || '', content.payloadLogged ? '' : '(payload off)'].filter(Boolean).join(' ');
403
435
  if (entry.type === 'schedule') return [content.name || '', content.status || 'ran'].filter(Boolean).join(' · ');
404
436
  if (entry.type === 'mail') return ['To ' + (content.to || 'unknown'), content.subject || 'No subject'].join(' · ');
405
437
  if (entry.type === 'auth') return [content.event || 'auth', content.userId ? '#' + content.userId : ''].filter(Boolean).join(' ');
406
438
  if (entry.type === 'event') return String(content.name || 'event');
407
439
  if (entry.type === 'model') return [content.action || '', content.model || ''].filter(Boolean).join(' ');
408
- if (entry.type === 'notification') return [content.notification || '', (content.channels || []).join(', ')].filter(Boolean).join(' -> ');
440
+ if (entry.type === 'notification') return [content.notification || '', content.message || (content.channels || []).join(', ')].filter(Boolean).join(' -> ');
409
441
  if (entry.type === 'redis') return String(content.command || 'redis');
410
442
  if (entry.type === 'gate') return [content.ability || '', content.result || ''].filter(Boolean).join(' · ');
411
443
  if (entry.type === 'middleware') return [content.name || '', content.event || ''].filter(Boolean).join(' · ');
412
444
  if (entry.type === 'command') return [content.name || '', content.exitCode !== undefined ? 'exit=' + content.exitCode : ''].filter(Boolean).join(' ');
413
445
  if (entry.type === 'batch') return [content.name || '', 'processed ' + (content.processed || 0) + '/' + (content.total || 0)].join(' · ');
414
446
  if (entry.type === 'view') return String(content.template || 'view');
415
- if (entry.type === 'client_request') return [content.method || '', content.url || ''].filter(Boolean).join(' ');
447
+ if (entry.type === 'client_request') return [content.method || '', content.url || '', content.responseStatus ? '[' + content.responseStatus + ']' : content.error ? '[failed]' : ''].filter(Boolean).join(' ');
416
448
  return JSON.stringify(content).slice(0, 160);
417
449
  };
418
450
 
@@ -448,6 +480,7 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
448
480
  { label: 'Connection', value: escapeHtml(content.connection || 'default') },
449
481
  { label: 'Duration', value: escapeHtml(formatDuration(getEntryDuration(entry))) },
450
482
  { label: 'Slow', value: escapeHtml(content.slow ? 'Yes' : 'No') },
483
+ { label: 'Bindings', value: escapeHtml(content.bindingsIncluded === false ? 'Hidden' : 'Included') },
451
484
  { label: 'Hash', value: '<span class="mono">' + escapeHtml(content.hash || '') + '</span>' }
452
485
  ]),
453
486
  renderMetricBox('Runtime', [
@@ -455,7 +488,8 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
455
488
  { label: 'Batch', value: '<span class="mono">' + escapeHtml(entry.batchId || '-') + '</span>' }
456
489
  ]),
457
490
  '</div>',
458
- highlightSql(content.sql || '')
491
+ highlightSql(content.sql || ''),
492
+ content.bindingsIncluded === false ? '<p class="trace-note">SQL bindings were hidden for this entry.</p>' : (Array.isArray(content.bindings) ? detailJson(content.bindings, 'Bindings Json') : '')
459
493
  ].join('');
460
494
  }
461
495
 
@@ -499,7 +533,34 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
499
533
  renderMetricBox('Request', [
500
534
  { label: 'Method', value: escapeHtml(content.method || '') },
501
535
  { label: 'URL', value: '<span class="mono">' + escapeHtml(content.url || '') + '</span>' },
502
- { label: 'Status', value: escapeHtml(content.responseStatus || '') },
536
+ { label: 'Status', value: escapeHtml(content.responseStatus || (content.error ? 'Failed' : 'Pending')) },
537
+ { label: 'Duration', value: escapeHtml(formatDuration(getEntryDuration(entry))) }
538
+ ]),
539
+ renderMetricBox('Runtime', [
540
+ { label: 'Hostname', value: escapeHtml(content.hostname || '') },
541
+ { label: 'Batch', value: '<span class="mono">' + escapeHtml(entry.batchId || '-') + '</span>' },
542
+ { label: 'Error', value: escapeHtml(content.error || '-') }
543
+ ]),
544
+ '</div>',
545
+ '<div class="detail-stack">',
546
+ detailJson(content.requestHeaders || {}, 'Request Header Json'),
547
+ renderPayload('Request Body', content.requestBody),
548
+ detailJson(content.responseHeaders || {}, 'Response Header Json'),
549
+ renderPayload('Response Body', content.responseBody),
550
+ '</div>'
551
+ ].join('');
552
+ }
553
+
554
+ if (entry.type === 'cache') {
555
+ return [
556
+ '<div class="detail-grid">',
557
+ renderMetricBox('Cache', [
558
+ { label: 'Operation', value: escapeHtml(content.operation || '') },
559
+ { label: 'Key', value: '<span class="mono">' + escapeHtml(content.key || '') + '</span>' },
560
+ { label: 'Store', value: escapeHtml(content.store || 'default') },
561
+ { label: 'Hit', value: escapeHtml(content.hit === undefined ? '-' : (content.hit ? 'Yes' : 'No')) },
562
+ { label: 'Payload', value: escapeHtml(content.payloadLogged ? 'Captured' : 'Disabled') },
563
+ { label: 'TTL', value: escapeHtml(content.ttl === undefined ? '-' : String(content.ttl)) },
503
564
  { label: 'Duration', value: escapeHtml(formatDuration(getEntryDuration(entry))) }
504
565
  ]),
505
566
  renderMetricBox('Runtime', [
@@ -507,7 +568,41 @@ const DASHBOARD_DOCUMENT = `<!DOCTYPE html>
507
568
  { label: 'Batch', value: '<span class="mono">' + escapeHtml(entry.batchId || '-') + '</span>' }
508
569
  ]),
509
570
  '</div>',
510
- detailJson(content.requestHeaders || {})
571
+ content.payloadLogged ? renderPayload('Cache Payload', content.payload) : '<p class="trace-note">Cache payload logging is disabled. Set TRACE_CACHE_PAYLOADS=true to include values.</p>'
572
+ ].join('');
573
+ }
574
+
575
+ if (entry.type === 'mail') {
576
+ return [
577
+ '<div class="detail-grid">',
578
+ renderMetricBox('Mail', [
579
+ { label: 'To', value: escapeHtml(content.to || '') },
580
+ { label: 'Subject', value: escapeHtml(content.subject || '') },
581
+ { label: 'Template', value: escapeHtml(content.template || '-') },
582
+ { label: 'Hostname', value: escapeHtml(content.hostname || '') }
583
+ ]),
584
+ '</div>',
585
+ '<div class="detail-stack">',
586
+ renderPayload('Mail Text', content.text),
587
+ renderPayload('Mail Html', content.html),
588
+ '</div>'
589
+ ].join('');
590
+ }
591
+
592
+ if (entry.type === 'notification') {
593
+ return [
594
+ '<div class="detail-grid">',
595
+ renderMetricBox('Notification', [
596
+ { label: 'Notification', value: escapeHtml(content.notification || '') },
597
+ { label: 'Channels', value: escapeHtml((content.channels || []).join(', ') || '-') },
598
+ { label: 'Recipient', value: escapeHtml(content.notifiable || '-') },
599
+ { label: 'Hostname', value: escapeHtml(content.hostname || '') }
600
+ ]),
601
+ '</div>',
602
+ '<div class="detail-stack">',
603
+ renderPayload('Message', content.message),
604
+ content.payload === undefined ? '<p class="trace-note">No additional notification payload was captured.</p>' : detailJson(content.payload, 'Notification Payload Json'),
605
+ '</div>'
511
606
  ].join('');
512
607
  }
513
608
 
package/src/register.ts CHANGED
@@ -141,6 +141,14 @@ const parseEnvList = (rawValue: string): string[] | undefined => {
141
141
  .filter((entry) => entry !== '');
142
142
  };
143
143
 
144
+ const parseEnvBool = (rawValue: string): boolean | undefined => {
145
+ const value = rawValue.trim().toLowerCase();
146
+ if (value === '') return undefined;
147
+ if (['1', 'true', 'yes', 'on'].includes(value)) return true;
148
+ if (['0', 'false', 'no', 'off'].includes(value)) return false;
149
+ return undefined;
150
+ };
151
+
144
152
  const resolveTraceStartupOverrides = (core: CoreApi): TraceConfigOverrides | undefined => {
145
153
  const traceConfigFile = core.StartupConfigFile?.Trace;
146
154
  if (typeof traceConfigFile !== 'string' || traceConfigFile.trim() === '') return undefined;
@@ -201,6 +209,8 @@ if (!traceAlreadyInitialized && Env) {
201
209
  const pruneAfterHoursRaw = Env.get('TRACE_PRUNE_HOURS', '').trim();
202
210
  const slowQueryThresholdRaw = Env.get('TRACE_SLOW_QUERY_MS', '').trim();
203
211
  const logMinLevelRaw = Env.get('TRACE_LOG_LEVEL', '').trim();
212
+ const captureCachePayloadsRaw = Env.get('TRACE_CACHE_PAYLOADS', '').trim();
213
+ const captureQueryBindingsRaw = Env.get('TRACE_QUERY_BINDINGS', '').trim();
204
214
  const redactionKeys = parseEnvList(Env.get('TRACE_REDACT_KEYS', ''));
205
215
  const redactionHeaders = parseEnvList(Env.get('TRACE_REDACT_HEADERS', ''));
206
216
  const redactionBody = parseEnvList(Env.get('TRACE_REDACT_BODY', ''));
@@ -221,6 +231,10 @@ if (!traceAlreadyInitialized && Env) {
221
231
  | 'warn'
222
232
  | 'error'
223
233
  | 'fatal';
234
+ const captureCachePayloads =
235
+ parseEnvBool(captureCachePayloadsRaw) ?? startupOverrides?.captureCachePayloads;
236
+ const captureQueryBindings =
237
+ parseEnvBool(captureQueryBindingsRaw) ?? startupOverrides?.captureQueryBindings;
224
238
  const redaction = buildTraceRedactionOverrides({
225
239
  startupOverrides,
226
240
  redactionBody,
@@ -239,6 +253,8 @@ if (!traceAlreadyInitialized && Env) {
239
253
  ...(typeof slowQueryThreshold === 'number' && Number.isFinite(slowQueryThreshold)
240
254
  ? { slowQueryThreshold }
241
255
  : {}),
256
+ ...(typeof captureCachePayloads === 'boolean' ? { captureCachePayloads } : {}),
257
+ ...(typeof captureQueryBindings === 'boolean' ? { captureQueryBindings } : {}),
242
258
  logMinLevel,
243
259
  ...(redaction === undefined ? {} : { redaction }),
244
260
  });
package/src/types.ts CHANGED
@@ -55,6 +55,9 @@ export interface RequestContent {
55
55
  export interface QueryContent {
56
56
  connection: string;
57
57
  sql: string;
58
+ statement?: string;
59
+ bindings?: unknown[];
60
+ bindingsIncluded?: boolean;
58
61
  time: number;
59
62
  duration: number;
60
63
  slow: boolean;
@@ -97,6 +100,10 @@ export interface CacheContent {
97
100
  operation: 'get' | 'set' | 'delete' | 'clear' | 'has';
98
101
  key: string;
99
102
  hit?: boolean;
103
+ store?: string;
104
+ payload?: unknown;
105
+ payloadLogged?: boolean;
106
+ ttl?: number;
100
107
  duration: number;
101
108
  hostname: string;
102
109
  }
@@ -114,6 +121,8 @@ export interface MailContent {
114
121
  to: string;
115
122
  subject: string;
116
123
  template?: string;
124
+ text?: string;
125
+ html?: string;
117
126
  hostname: string;
118
127
  }
119
128
 
@@ -142,6 +151,8 @@ export interface NotificationContent {
142
151
  channels: string[];
143
152
  notifiable?: string;
144
153
  notification: string;
154
+ message?: string;
155
+ payload?: unknown;
145
156
  hostname: string;
146
157
  }
147
158
 
@@ -201,11 +212,27 @@ export interface ClientRequestContent {
201
212
  method: string;
202
213
  url: string;
203
214
  requestHeaders: Record<string, string>;
204
- responseStatus: number;
215
+ requestBody?: unknown;
216
+ responseStatus?: number;
217
+ responseHeaders?: Record<string, string>;
218
+ responseBody?: unknown;
219
+ error?: string;
205
220
  duration: number;
206
221
  hostname: string;
207
222
  }
208
223
 
224
+ export interface ClientRequestTraceInput {
225
+ method: string;
226
+ url: string;
227
+ requestHeaders: Record<string, string>;
228
+ responseStatus?: number;
229
+ duration: number;
230
+ requestBody?: unknown;
231
+ responseHeaders?: Record<string, string>;
232
+ responseBody?: unknown;
233
+ error?: string;
234
+ }
235
+
209
236
  // ---------------------------------------------------------------------------
210
237
  // Core domain records
211
238
  // ---------------------------------------------------------------------------
@@ -329,6 +356,8 @@ export interface ITraceConfig {
329
356
  pruneAfterHours: number;
330
357
  ignoreRoutes: string[];
331
358
  slowQueryThreshold: number;
359
+ captureCachePayloads: boolean;
360
+ captureQueryBindings: boolean;
332
361
  logMinLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
333
362
  watchers: WatcherToggles;
334
363
  redaction: RedactionConfig;
@@ -6,10 +6,11 @@ import { TraceContext } from '../context';
6
6
  import type { CacheContent, ITraceWatcher, ITraceWatcherConfig } from '../types';
7
7
  import { EntryType } from '../types';
8
8
  import { AuthTag } from '../utils/authTag';
9
- import { redactString } from '../utils/redact';
9
+ import { redactString, redactUnknown } from '../utils/redact';
10
10
  import { RequestFilter } from '../utils/requestFilter';
11
11
 
12
12
  let _storage: ITraceWatcherConfig['storage'] | null = null;
13
+ let _config: ITraceWatcherConfig['config'] | null = null;
13
14
  let _redactionFields: string[] = [];
14
15
  let _ignoreRoutes: string[] = [];
15
16
 
@@ -17,15 +18,23 @@ const emit = (
17
18
  operation: CacheContent['operation'],
18
19
  key: string,
19
20
  duration: number,
20
- hit?: boolean
21
+ hit?: boolean,
22
+ payload?: unknown,
23
+ store?: string,
24
+ ttl?: number
21
25
  ): void => {
22
26
  if (!_storage) return;
23
27
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes)) return;
24
28
  const safeKey = redactString(key, _redactionFields);
29
+ const shouldLogPayload = _config?.captureCachePayloads === true;
25
30
  const content: CacheContent = {
26
31
  operation,
27
32
  key: safeKey,
28
33
  hit,
34
+ ...(typeof store === 'string' && store !== '' ? { store } : {}),
35
+ ...(typeof ttl === 'number' ? { ttl } : {}),
36
+ payloadLogged: shouldLogPayload,
37
+ ...(shouldLogPayload ? { payload: redactUnknown(payload, _redactionFields) } : {}),
29
38
  duration,
30
39
  hostname: TraceContext.getHostname(),
31
40
  };
@@ -48,10 +57,12 @@ export const CacheWatcher: ITraceWatcher & { emit: typeof emit } = Object.freeze
48
57
  register({ storage, config }: ITraceWatcherConfig): () => void {
49
58
  if (config.watchers.cache === false) return () => undefined;
50
59
  _storage = storage;
60
+ _config = config;
51
61
  _redactionFields = config.redaction.query;
52
62
  _ignoreRoutes = config.ignoreRoutes;
53
63
  return () => {
54
64
  _storage = null;
65
+ _config = null;
55
66
  _ignoreRoutes = [];
56
67
  };
57
68
  },
@@ -1,30 +1,50 @@
1
1
  import { TraceContext } from '../context';
2
- import type { ClientRequestContent, ITraceWatcher, ITraceWatcherConfig } from '../types';
2
+ import type {
3
+ ClientRequestContent,
4
+ ClientRequestTraceInput,
5
+ ITraceWatcher,
6
+ ITraceWatcherConfig,
7
+ } from '../types';
3
8
  import { EntryType } from '../types';
4
9
  import { AuthTag } from '../utils/authTag';
5
- import { redactHeaders } from '../utils/redact';
10
+ import { redactHeaders, redactUnknown } from '../utils/redact';
6
11
  import { RequestFilter } from '../utils/requestFilter';
7
12
 
8
13
  let _storage: ITraceWatcherConfig['storage'] | null = null;
9
14
  let _redactHeaderNames: string[] = [];
15
+ let _redactBodyFields: string[] = [];
10
16
  let _ignoreRoutes: string[] = [];
11
17
 
12
- const emit = (
13
- method: string,
14
- url: string,
15
- requestHeaders: Record<string, string>,
16
- responseStatus: number,
17
- duration: number
18
- ): void => {
18
+ const emit = ({
19
+ method,
20
+ url,
21
+ requestHeaders,
22
+ responseStatus,
23
+ duration,
24
+ requestBody,
25
+ responseHeaders,
26
+ responseBody,
27
+ error,
28
+ }: ClientRequestTraceInput): void => {
19
29
  if (!_storage) return;
20
30
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes)) return;
21
31
  const tags = AuthTag.append([method.toUpperCase()]);
22
- if (responseStatus >= 400) tags.push('failed');
32
+ if ((responseStatus ?? 0) >= 400 || error) tags.push('failed');
23
33
  const content: ClientRequestContent = {
24
34
  method: method.toUpperCase(),
25
35
  url,
26
36
  requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames),
27
- responseStatus,
37
+ ...(requestBody === undefined
38
+ ? {}
39
+ : { requestBody: redactUnknown(requestBody, _redactBodyFields) }),
40
+ ...(responseStatus === undefined ? {} : { responseStatus }),
41
+ ...(responseHeaders === undefined
42
+ ? {}
43
+ : { responseHeaders: redactHeaders(responseHeaders, _redactHeaderNames) }),
44
+ ...(responseBody === undefined
45
+ ? {}
46
+ : { responseBody: redactUnknown(responseBody, _redactBodyFields) }),
47
+ ...(typeof error === 'string' && error !== '' ? { error } : {}),
28
48
  duration,
29
49
  hostname: TraceContext.getHostname(),
30
50
  };
@@ -47,9 +67,11 @@ export const HttpClientWatcher: ITraceWatcher & { emit: typeof emit } = Object.f
47
67
  if (config.watchers.clientRequest === false) return () => undefined;
48
68
  _storage = storage;
49
69
  _redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
70
+ _redactBodyFields = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
50
71
  _ignoreRoutes = config.ignoreRoutes;
51
72
  return () => {
52
73
  _storage = null;
74
+ _redactBodyFields = [];
53
75
  _ignoreRoutes = [];
54
76
  };
55
77
  },
@@ -1,22 +1,35 @@
1
1
  /**
2
- * MailWatcher — records mail dispatch intent.
3
- * Body is never captured; only to/subject/template.
2
+ * MailWatcher — records mail dispatch intent and rendered content.
4
3
  */
5
4
  import { TraceContext } from '../context';
6
5
  import type { ITraceWatcher, ITraceWatcherConfig, MailContent } from '../types';
7
6
  import { EntryType } from '../types';
7
+ import { redactUnknown } from '../utils/redact';
8
8
  import { RequestFilter } from '../utils/requestFilter';
9
9
 
10
10
  let _storage: ITraceWatcherConfig['storage'] | null = null;
11
+ let _redactionFields: string[] = [];
11
12
  let _ignoreRoutes: string[] = [];
12
13
 
13
- const emit = (to: string, subject: string, template?: string): void => {
14
+ const emit = (
15
+ to: string,
16
+ subject: string,
17
+ template?: string,
18
+ text?: string,
19
+ html?: string
20
+ ): void => {
14
21
  if (!_storage) return;
15
22
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes)) return;
16
23
  const content: MailContent = {
17
24
  to,
18
25
  subject,
19
26
  template,
27
+ ...(typeof text === 'string' && text !== ''
28
+ ? { text: redactUnknown(text, _redactionFields) as string }
29
+ : {}),
30
+ ...(typeof html === 'string' && html !== ''
31
+ ? { html: redactUnknown(html, _redactionFields) as string }
32
+ : {}),
20
33
  hostname: TraceContext.getHostname(),
21
34
  };
22
35
  _storage
@@ -38,9 +51,11 @@ export const MailWatcher: ITraceWatcher & { emit: typeof emit } = Object.freeze(
38
51
  register({ storage, config }: ITraceWatcherConfig): () => void {
39
52
  if (config.watchers.mail === false) return () => undefined;
40
53
  _storage = storage;
54
+ _redactionFields = [...config.redaction.keys, ...config.redaction.body];
41
55
  _ignoreRoutes = config.ignoreRoutes;
42
56
  return () => {
43
57
  _storage = null;
58
+ _redactionFields = [];
44
59
  _ignoreRoutes = [];
45
60
  };
46
61
  },