@zintrust/trace 0.4.81 → 0.4.83

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.
@@ -5,21 +5,27 @@
5
5
  import { TraceContext } from '../context.js';
6
6
  import { EntryType } from '../types.js';
7
7
  import { AuthTag } from '../utils/authTag.js';
8
- import { redactString } from '../utils/redact.js';
8
+ import { redactString, redactUnknown } from '../utils/redact.js';
9
9
  import { RequestFilter } from '../utils/requestFilter.js';
10
10
  let _storage = null;
11
+ let _config = null;
11
12
  let _redactionFields = [];
12
13
  let _ignoreRoutes = [];
13
- const emit = (operation, key, duration, hit) => {
14
+ const emit = (operation, key, duration, hit, payload, store, ttl) => {
14
15
  if (!_storage)
15
16
  return;
16
17
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes))
17
18
  return;
18
19
  const safeKey = redactString(key, _redactionFields);
20
+ const shouldLogPayload = _config?.captureCachePayloads === true;
19
21
  const content = {
20
22
  operation,
21
23
  key: safeKey,
22
24
  hit,
25
+ ...(typeof store === 'string' && store !== '' ? { store } : {}),
26
+ ...(typeof ttl === 'number' ? { ttl } : {}),
27
+ payloadLogged: shouldLogPayload,
28
+ ...(shouldLogPayload ? { payload: redactUnknown(payload, _redactionFields) } : {}),
23
29
  duration,
24
30
  hostname: TraceContext.getHostname(),
25
31
  };
@@ -41,10 +47,12 @@ export const CacheWatcher = Object.freeze({
41
47
  if (config.watchers.cache === false)
42
48
  return () => undefined;
43
49
  _storage = storage;
50
+ _config = config;
44
51
  _redactionFields = config.redaction.query;
45
52
  _ignoreRoutes = config.ignoreRoutes;
46
53
  return () => {
47
54
  _storage = null;
55
+ _config = null;
48
56
  _ignoreRoutes = [];
49
57
  };
50
58
  },
@@ -1,5 +1,5 @@
1
- import type { ITraceWatcher } from '../types';
2
- declare const emit: (method: string, url: string, requestHeaders: Record<string, string>, responseStatus: number, duration: number) => void;
1
+ import type { ClientRequestTraceInput, ITraceWatcher } from '../types';
2
+ declare const emit: ({ method, url, requestHeaders, responseStatus, duration, requestBody, responseHeaders, responseBody, error, }: ClientRequestTraceInput) => void;
3
3
  export declare const HttpClientWatcher: ITraceWatcher & {
4
4
  emit: typeof emit;
5
5
  };
@@ -1,24 +1,35 @@
1
1
  import { TraceContext } from '../context.js';
2
2
  import { EntryType } from '../types.js';
3
3
  import { AuthTag } from '../utils/authTag.js';
4
- import { redactHeaders } from '../utils/redact.js';
4
+ import { redactHeaders, redactUnknown } from '../utils/redact.js';
5
5
  import { RequestFilter } from '../utils/requestFilter.js';
6
6
  let _storage = null;
7
7
  let _redactHeaderNames = [];
8
+ let _redactBodyFields = [];
8
9
  let _ignoreRoutes = [];
9
- const emit = (method, url, requestHeaders, responseStatus, duration) => {
10
+ const emit = ({ method, url, requestHeaders, responseStatus, duration, requestBody, responseHeaders, responseBody, error, }) => {
10
11
  if (!_storage)
11
12
  return;
12
13
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes))
13
14
  return;
14
15
  const tags = AuthTag.append([method.toUpperCase()]);
15
- if (responseStatus >= 400)
16
+ if ((responseStatus ?? 0) >= 400 || error)
16
17
  tags.push('failed');
17
18
  const content = {
18
19
  method: method.toUpperCase(),
19
20
  url,
20
21
  requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames),
21
- responseStatus,
22
+ ...(requestBody === undefined
23
+ ? {}
24
+ : { requestBody: redactUnknown(requestBody, _redactBodyFields) }),
25
+ ...(responseStatus === undefined ? {} : { responseStatus }),
26
+ ...(responseHeaders === undefined
27
+ ? {}
28
+ : { responseHeaders: redactHeaders(responseHeaders, _redactHeaderNames) }),
29
+ ...(responseBody === undefined
30
+ ? {}
31
+ : { responseBody: redactUnknown(responseBody, _redactBodyFields) }),
32
+ ...(typeof error === 'string' && error !== '' ? { error } : {}),
22
33
  duration,
23
34
  hostname: TraceContext.getHostname(),
24
35
  };
@@ -41,9 +52,11 @@ export const HttpClientWatcher = Object.freeze({
41
52
  return () => undefined;
42
53
  _storage = storage;
43
54
  _redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
55
+ _redactBodyFields = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
44
56
  _ignoreRoutes = config.ignoreRoutes;
45
57
  return () => {
46
58
  _storage = null;
59
+ _redactBodyFields = [];
47
60
  _ignoreRoutes = [];
48
61
  };
49
62
  },
@@ -1,5 +1,5 @@
1
1
  import type { ITraceWatcher } from '../types';
2
- declare const emit: (to: string, subject: string, template?: string) => void;
2
+ declare const emit: (to: string, subject: string, template?: string, text?: string, html?: string) => void;
3
3
  export declare const MailWatcher: ITraceWatcher & {
4
4
  emit: typeof emit;
5
5
  };
@@ -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
  },
@@ -1,2 +1,6 @@
1
1
  import type { ITraceWatcher } from '../types';
2
- export declare const QueryWatcher: ITraceWatcher;
2
+ declare const emit: (query: string, params: unknown[], duration: number, connection?: string) => void;
3
+ export declare const QueryWatcher: ITraceWatcher & {
4
+ emit: typeof emit;
5
+ };
6
+ export {};
@@ -6,6 +6,8 @@ import { TraceStorage } from '../storage/index.js';
6
6
  import { EntryType } from '../types.js';
7
7
  import { AuthTag } from '../utils/authTag.js';
8
8
  import { RequestFilter } from '../utils/requestFilter.js';
9
+ let _storage = null;
10
+ let _config = null;
9
11
  const bindingsInterpolated = (sql, params) => {
10
12
  // Inline params for display only — safe, not for re-execution.
11
13
  let i = 0;
@@ -22,50 +24,64 @@ const isTraceStorageQuery = (sql) => {
22
24
  const normalized = sql.toLowerCase();
23
25
  return normalized.includes('zin_trace_entries') || normalized.includes('zin_trace_monitoring');
24
26
  };
27
+ const emit = (query, params, duration, connection = 'default') => {
28
+ if (_storage === null || _config === null)
29
+ return;
30
+ if (RequestFilter.shouldIgnoreCurrentRequest(_config.ignoreRoutes))
31
+ return;
32
+ if (isTraceStorageQuery(query))
33
+ return;
34
+ const batchId = TraceContext.getBatchId();
35
+ const includeBindings = _config.captureQueryBindings !== false;
36
+ const sql = includeBindings ? bindingsInterpolated(query, params) : query;
37
+ const roundedDuration = Math.round(duration * 100) / 100;
38
+ const hash = TraceStorage.familyHash(query);
39
+ const slow = roundedDuration >= _config.slowQueryThreshold;
40
+ const content = {
41
+ connection,
42
+ sql,
43
+ statement: query,
44
+ ...(includeBindings ? { bindings: [...params] } : {}),
45
+ bindingsIncluded: includeBindings,
46
+ time: roundedDuration,
47
+ duration: roundedDuration,
48
+ slow,
49
+ hash,
50
+ hostname: TraceContext.getHostname(),
51
+ };
52
+ const tags = AuthTag.append([]);
53
+ if (slow)
54
+ tags.push('slow');
55
+ _storage
56
+ .writeEntry({
57
+ uuid: crypto.randomUUID(),
58
+ batchId,
59
+ familyHash: hash,
60
+ type: EntryType.QUERY,
61
+ content,
62
+ tags,
63
+ isLatest: true,
64
+ createdAt: TraceContext.now(),
65
+ })
66
+ .catch(() => undefined);
67
+ };
25
68
  export const QueryWatcher = Object.freeze({
69
+ emit,
26
70
  register({ storage, config, db: injectedDb }) {
27
71
  if (config.watchers.query === false)
28
72
  return () => undefined;
29
73
  if (!injectedDb)
30
74
  return () => undefined; // no db available
75
+ _storage = storage;
76
+ _config = config;
31
77
  const db = injectedDb;
32
78
  const handler = (query, params, duration) => {
33
- if (RequestFilter.shouldIgnoreCurrentRequest(config.ignoreRoutes))
34
- return;
35
- if (isTraceStorageQuery(query))
36
- return;
37
- const batchId = TraceContext.getBatchId();
38
- const sql = bindingsInterpolated(query, params);
39
- const roundedDuration = Math.round(duration * 100) / 100;
40
- const hash = TraceStorage.familyHash(query);
41
- const slow = roundedDuration >= config.slowQueryThreshold;
42
- const content = {
43
- connection: 'default',
44
- sql,
45
- time: roundedDuration,
46
- duration: roundedDuration,
47
- slow,
48
- hash,
49
- hostname: TraceContext.getHostname(),
50
- };
51
- const tags = AuthTag.append([]);
52
- if (slow)
53
- tags.push('slow');
54
- storage
55
- .writeEntry({
56
- uuid: crypto.randomUUID(),
57
- batchId,
58
- familyHash: hash,
59
- type: EntryType.QUERY,
60
- content,
61
- tags,
62
- isLatest: true,
63
- createdAt: TraceContext.now(),
64
- })
65
- .catch(() => undefined);
79
+ emit(query, params, duration);
66
80
  };
67
81
  db.onAfterQuery?.(handler);
68
82
  return () => {
83
+ _storage = null;
84
+ _config = null;
69
85
  db.offAfterQuery?.(handler);
70
86
  };
71
87
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/trace",
3
- "version": "0.4.81",
3
+ "version": "0.4.83",
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
@@ -106,9 +106,12 @@ const mergeWatchers = (
106
106
  const DEFAULTS: ITraceConfig = Object.freeze({
107
107
  enabled: false,
108
108
  connection: undefined,
109
+ observeConnection: undefined,
109
110
  pruneAfterHours: 24,
110
111
  ignoreRoutes: ['/trace', '/health', '/ping'],
111
112
  slowQueryThreshold: 100,
113
+ captureCachePayloads: false,
114
+ captureQueryBindings: true,
112
115
  logMinLevel: 'info',
113
116
  watchers: {},
114
117
  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