@zintrust/trace 0.4.94 → 0.4.96

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.
@@ -38,6 +38,7 @@ const SUN_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke
38
38
  const MOON_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path></svg>`;
39
39
 
40
40
  const COPY_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
41
+ const DISCLOSURE_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4l6 6-6 6"></path></svg>`;
41
42
 
42
43
  const JSON_HIGHLIGHT_PATTERN = String.raw`("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")(?=\s*:)|(\s*:)|("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?`;
43
44
  const SQL_HIGHLIGHT_PATTERN = String.raw`(\/\*[\s\S]*?\*\/|--.*$|'(?:''|[^'])*'|\x60[^\x60]+\x60|\b(?:select|from|where|insert|into|values|update|delete|join|left|right|inner|outer|on|and|or|limit|order|by|group|having|as|distinct|null|is|in|like|set|case|when|then|else|end|returning|union|all)\b|-?\d+(?:\.\d+)?)`;
@@ -137,6 +138,10 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
137
138
  const SUN_ICON = __TRACE_SUN_ICON__;
138
139
  const MOON_ICON = __TRACE_MOON_ICON__;
139
140
  const COPY_ICON = __TRACE_COPY_ICON__;
141
+ const DISCLOSURE_ICON = __TRACE_DISCLOSURE_ICON__;
142
+ .panel{border-radius:var(--radius);border:1px solid var(--line);background:var(--surface);box-shadow:var(--shadow);backdrop-filter:blur(16px)}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:16px;margin-bottom:18px}.stat-card{padding:20px;position:relative;overflow:hidden}.stat-card::after{content:'';position:absolute;right:-18px;bottom:-26px;width:92px;height:92px;border-radius:28px;background:linear-gradient(135deg,rgba(56,189,248,.16),rgba(34,197,94,.08));transform:rotate(18deg)}.stat-label{font-size:.74rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);font-weight:800;margin-bottom:12px}.stat-value{font-size:2.25rem;font-weight:800;line-height:1}.stat-meta{margin-top:10px;color:var(--muted);font-size:.9rem}.content-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(320px,.95fr);gap:18px}.side-stack{display:grid;gap:18px}
143
+ .section-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:22px 24px 16px}.section-head h3{margin:0;font-size:1.04rem}.section-head p{margin:6px 0 0;color:var(--muted);font-size:.92rem}.toolbar{display:flex;flex-wrap:wrap;gap:10px;padding:0 24px 18px}.control,.toolbar input,.toolbar select{height:44px;border-radius:13px;border:1px solid var(--line);background:var(--surface-strong);color:var(--text);padding:0 14px;min-width:0}.toolbar input,.toolbar select{flex:1 1 180px}.toolbar input::placeholder{color:var(--muted)}.btn{height:44px;border:none;border-radius:13px;padding:0 16px;cursor:pointer;font-weight:800}.btn-primary{background:linear-gradient(135deg,var(--accent-strong),var(--accent));color:#fff}.btn-danger{background:rgba(239,68,68,.12);color:var(--danger);border:1px solid rgba(239,68,68,.18)}.btn-ghost{background:var(--surface-soft);color:var(--text);border:1px solid var(--line)}
144
+ .activity-list{list-style:none;margin:0;padding:0 24px 24px}.activity-item{padding:14px 0;border-top:1px solid var(--line)}.activity-item:first-child{border-top:none}.activity-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.activity-time{color:var(--muted);font-size:.85rem}.activity-summary{margin-top:8px;color:var(--text);line-height:1.48}.back-link{display:inline-flex;align-items:center;gap:8px;margin:0 0 14px;color:var(--accent);font-weight:800;cursor:pointer}.detail-card{padding:24px}.detail-meta{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0 20px;color:var(--muted);font-size:.9rem;overflow-wrap:anywhere}.detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.detail-stack{display:grid;gap:16px;margin-top:18px}.detail-box{padding:16px;border-radius:16px;background:var(--surface-soft);border:1px solid var(--line)}.detail-box h4{margin:0 0 10px;font-size:.92rem}.detail-box dl{margin:0;display:grid;gap:8px}.detail-box dt{font-size:.76rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:800}.detail-box dd{margin:0;color:var(--text);line-height:1.45;overflow-wrap:anywhere}.trace-tabs{display:flex;gap:10px;flex-wrap:wrap;margin:20px 0 16px}.trace-tab{border:none;border-radius:12px;padding:10px 12px;background:transparent;color:var(--muted);cursor:pointer;box-shadow:inset 0 0 0 1px var(--line);font-weight:800}.trace-tab.active{background:rgba(56,189,248,.12);color:var(--text);box-shadow:inset 0 0 0 1px rgba(56,189,248,.28)}.trace-panel{display:grid;gap:14px}.trace-item{padding:18px;border-radius:16px;background:var(--surface-soft);border:1px solid var(--line)}.trace-item-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.trace-item-summary{margin-top:10px;display:grid;gap:10px}.trace-note{color:var(--muted);line-height:1.6}.trace-disclosure{padding:0;overflow:hidden}.trace-disclosure[open]{padding-bottom:18px}.trace-disclosure .trace-item-summary{margin-top:0}.trace-disclosure-body{display:grid;gap:12px;padding:0 18px}.trace-summary{list-style:none;cursor:pointer;padding:18px}.trace-summary::-webkit-details-marker{display:none}.trace-summary-main{display:grid;gap:10px;min-width:0;flex:1}.trace-summary-copy{display:grid;gap:6px;min-width:0}.trace-summary-copy .summary,.trace-summary-copy .summary-sub{display:block;overflow-wrap:anywhere}.trace-disclosure-body .summary-sub{overflow-wrap:anywhere}.trace-summary-icon{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;color:var(--muted);flex:none;transition:transform .16s ease,color .16s ease}.trace-summary-icon svg{width:14px;height:14px;display:block}.trace-disclosure[open] .trace-summary-icon{transform:rotate(90deg);color:var(--accent)}
140
145
  const JSON_HIGHLIGHT_PATTERN = new RegExp(__TRACE_JSON_REGEX__, 'g');
141
146
  const SQL_HIGHLIGHT_PATTERN = new RegExp(__TRACE_SQL_REGEX__, 'gim');
142
147
  const ENTRY_TYPES = ['request','query','exception','log','job','cache','schedule','mail','auth','event','model','notification','redis','gate','middleware','command','batch','dump','view','client_request'];
@@ -221,6 +226,19 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
221
226
  return ' method-other';
222
227
  };
223
228
 
229
+ const normalizeMethodLabel = (value) => {
230
+ const method = String(value || '').trim().toUpperCase();
231
+ if (method === '') return 'Request';
232
+ return method.charAt(0) + method.slice(1).toLowerCase();
233
+ };
234
+
235
+ const entryTypeLabel = (entry) => {
236
+ if (entry && entry.type === 'request') {
237
+ return normalizeMethodLabel(entry.content && entry.content.method);
238
+ }
239
+ return String(entry && entry.type || '');
240
+ };
241
+
224
242
  const typeClass = (entryOrType, maybeEntry) => {
225
243
  const entry = maybeEntry || (typeof entryOrType === 'object' ? entryOrType : null);
226
244
  const type = entry && entry.type ? entry.type : entryOrType;
@@ -433,7 +451,7 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
433
451
 
434
452
  const entrySummaryText = (entry) => {
435
453
  const content = entry && entry.content ? entry.content : {};
436
- if (entry.type === 'request') return [content.responseStatus || '', content.method || '', content.uri || ''].filter(Boolean).join(' ');
454
+ if (entry.type === 'request') return [content.responseStatus || '', content.uri || ''].filter(Boolean).join(' ');
437
455
  if (entry.type === 'query') return String(content.sql || '').slice(0, 160);
438
456
  if (entry.type === 'exception') return [content.class || '', content.message || ''].filter(Boolean).join(': ');
439
457
  if (entry.type === 'log') return '[' + String(content.level || 'log') + '] ' + String(content.message || '').slice(0, 160);
@@ -630,16 +648,17 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
630
648
  const renderTraceItems = (entries, options = {}) => {
631
649
  if (entries.length === 0) return '<p class="trace-note">No related entries captured.</p>';
632
650
 
633
- const collapsible = options.collapsible === true;
634
- const isInitiallyOpen = options.collapsed !== true;
651
+ const collapsible = options.collapsible !== false;
652
+ const isInitiallyOpen = options.collapsed === false;
635
653
 
636
654
  return '<div class="trace-panel">' + entries.map((entry) => {
637
655
  if (collapsible) {
638
656
  return [
639
657
  '<details class="trace-item trace-disclosure"' + (isInitiallyOpen ? ' open' : '') + '>',
640
658
  '<summary class="trace-item-head trace-summary">',
659
+ '<span class="trace-summary-icon">' + DISCLOSURE_ICON + '</span>',
641
660
  '<span class="trace-summary-main">',
642
- '<span><span class="' + typeClass(entry) + '">' + escapeHtml(entry.type) + '</span></span>',
661
+ '<span><span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span></span>',
643
662
  '<span class="trace-summary-copy">' + entrySummaryInlineHtml(entry) + '</span>',
644
663
  '</span>',
645
664
  '<span class="activity-head">' + durationHtml(entry) + '<span class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</span></span>',
@@ -656,7 +675,7 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
656
675
  '<section class="trace-item">',
657
676
  '<div class="trace-item-head">',
658
677
  '<div>',
659
- '<span class="' + typeClass(entry) + '">' + escapeHtml(entry.type) + '</span>',
678
+ '<span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span>',
660
679
  '</div>',
661
680
  '<div class="activity-head">' + durationHtml(entry) + '<span class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</span></div>',
662
681
  '</div>',
@@ -679,14 +698,16 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
679
698
  { id: 'headers', label: 'Headers' },
680
699
  { id: 'response', label: 'Response' },
681
700
  { id: 'queries', label: 'Queries', count: batchEntriesByType('query').length },
701
+ { id: 'middleware', label: 'Middleware', count: batchEntriesByType('middleware').length },
702
+ { id: 'models', label: 'Models', count: batchEntriesByType('model').length },
682
703
  { id: 'logs', label: 'Logs', count: batchEntriesByType('log').length },
683
704
  { id: 'exceptions', label: 'Exceptions', count: batchEntriesByType('exception').length },
684
705
  { id: 'http', label: 'HTTP', count: batchEntriesByType('client_request').length },
685
706
  { id: 'cache', label: 'Cache', count: batchEntriesByType('cache').length },
686
- { id: 'other', label: 'Other', count: batchEntries().filter((item) => !['request','query','log','exception','client_request','cache'].includes(item.type)).length }
707
+ { id: 'other', label: 'Other', count: batchEntries().filter((item) => !['request','query','middleware','model','log','exception','client_request','cache'].includes(item.type)).length }
687
708
  ];
688
709
  const currentTab = traceTabs.some((tab) => tab.id === state.detailTab) ? state.detailTab : 'summary';
689
- const otherEntries = batchEntries().filter((item) => !['request','query','log','exception','client_request','cache'].includes(item.type));
710
+ const otherEntries = batchEntries().filter((item) => !['request','query','middleware','model','log','exception','client_request','cache'].includes(item.type));
690
711
  const panels = {
691
712
  summary: [
692
713
  '<div class="detail-grid">',
@@ -705,12 +726,17 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
705
726
  renderMetricBox('Tags', [
706
727
  { label: 'Values', value: tagsHtml(entry.tags) || '<span class="activity-time">-</span>' }
707
728
  ]),
729
+ renderMetricBox('Route middleware', [
730
+ { label: 'Attached', value: escapeHtml(Array.isArray(content.middleware) && content.middleware.length > 0 ? content.middleware.join(', ') : 'None') }
731
+ ]),
708
732
  '</div>'
709
733
  ].join(''),
710
734
  payload: detailJson(content.payload || {}, 'Payload Json'),
711
735
  headers: '<div class="detail-stack">' + detailJson(content.headers || {}, 'Request Header Json') + detailJson(content.responseHeaders || {}, 'Response Header Json') + '</div>',
712
736
  response: '<div class="detail-stack"><div class="detail-grid">' + renderMetricBox('Status', [{ label: 'Response status', value: escapeHtml(content.responseStatus || '') }, { label: 'Duration', value: escapeHtml(formatDuration(getEntryDuration(entry))) }]) + '</div>' + (content.responseBody === undefined ? '<p class="trace-note">No response body was captured for this request.</p>' : detailJson(content.responseBody, 'Response Body Json')) + detailJson(content.responseHeaders || {}, 'Response Header Json') + '</div>',
713
- queries: renderTraceItems(batchEntriesByType('query'), { collapsible: true, collapsed: true }),
737
+ queries: renderTraceItems(batchEntriesByType('query')),
738
+ middleware: renderTraceItems(batchEntriesByType('middleware')),
739
+ models: renderTraceItems(batchEntriesByType('model')),
714
740
  logs: renderTraceItems(batchEntriesByType('log')),
715
741
  exceptions: renderTraceItems(batchEntriesByType('exception')),
716
742
  http: renderTraceItems(batchEntriesByType('client_request')),
@@ -722,8 +748,8 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
722
748
  '<span class="back-link" data-action="close-detail"><- Back to entries</span>',
723
749
  '<section class="panel detail-card">',
724
750
  '<div>' + (entry.type === 'request'
725
- ? '<span class="' + typeClass(entry) + '">' + escapeHtml(entry.type) + '</span> <span class="' + typeClass(entry) + '">' + escapeHtml(content.responseStatus || '') + '</span> <span class="mono">' + escapeHtml(content.uri || '') + '</span> ' + tagsHtml(entry.tags)
726
- : '<span class="' + typeClass(entry) + '">' + escapeHtml(entry.type) + '</span> ' + tagsHtml(entry.tags)) + '</div>',
751
+ ? '<span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span> <span class="' + typeClass(entry) + '">' + escapeHtml(content.responseStatus || '') + '</span> <span class="mono">' + escapeHtml(content.uri || '') + '</span> ' + tagsHtml(entry.tags)
752
+ : '<span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span> ' + tagsHtml(entry.tags)) + '</div>',
727
753
  '<div class="detail-meta"><span>UUID <span class="mono">' + escapeHtml(entry.uuid) + '</span></span><span>Batch <span class="mono">' + escapeHtml(entry.batchId || '-') + '</span></span><span>' + durationHtml(entry) + '</span><span>' + escapeHtml(new Date(Number(entry.createdAt)).toISOString()) + '</span></div>',
728
754
  '<div class="trace-tabs">',
729
755
  traceTabs.map((tab) => '<button type="button" class="trace-tab' + (tab.id === currentTab ? ' active' : '') + '" data-action="detail-tab" data-tab="' + escapeHtml(tab.id) + '">' + escapeHtml(tab.label) + (tab.count !== undefined ? ' (' + escapeHtml(tab.count) + ')' : '') + '</button>').join(''),
@@ -749,10 +775,10 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
749
775
  const recentRows = recent.data || [];
750
776
  const recentTable = recentRows.length === 0
751
777
  ? '<div class="empty">No trace entries recorded.</div>'
752
- : '<div class="table-wrap"><table><thead><tr><th>Type</th><th>Summary</th><th>Tags</th><th>Duration</th><th>Happened</th></tr></thead><tbody>' + recentRows.map((entry) => '<tr class="row-button" data-action="show-detail" data-uuid="' + escapeHtml(entry.uuid) + '"><td><span class="' + typeClass(entry) + '">' + escapeHtml(entry.type) + '</span></td><td>' + entrySummaryHtml(entry) + '</td><td>' + tagsHtml(entry.tags) + '</td><td>' + durationHtml(entry) + '</td><td class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</td></tr>').join('') + '</tbody></table></div>';
778
+ : '<div class="table-wrap"><table><thead><tr><th>Type</th><th>Summary</th><th>Tags</th><th>Duration</th><th>Happened</th></tr></thead><tbody>' + recentRows.map((entry) => '<tr class="row-button" data-action="show-detail" data-uuid="' + escapeHtml(entry.uuid) + '"><td><span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span></td><td>' + entrySummaryHtml(entry) + '</td><td>' + tagsHtml(entry.tags) + '</td><td>' + durationHtml(entry) + '</td><td class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</td></tr>').join('') + '</tbody></table></div>';
753
779
  const activityList = recentRows.length === 0
754
780
  ? '<div class="empty">No recent activity.</div>'
755
- : '<ul class="activity-list">' + recentRows.slice(0, 5).map((entry) => '<li class="activity-item"><div class="activity-head"><span class="' + typeClass(entry) + '">' + escapeHtml(entry.type) + '</span>' + durationHtml(entry) + '<span class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</span></div><div class="activity-summary">' + escapeHtml(entrySummaryText(entry)) + '</div></li>').join('') + '</ul>';
781
+ : '<ul class="activity-list">' + recentRows.slice(0, 5).map((entry) => '<li class="activity-item"><div class="activity-head"><span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span>' + durationHtml(entry) + '<span class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</span></div><div class="activity-summary">' + escapeHtml(entrySummaryText(entry)) + '</div></li>').join('') + '</ul>';
756
782
 
757
783
  main.innerHTML = [
758
784
  statsCardsHtml(stats),
@@ -796,7 +822,7 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
796
822
  const total = Number(response.total || 0);
797
823
  const perPage = Number(response.perPage || 50);
798
824
  const totalPages = Math.max(1, Math.ceil(total / perPage));
799
- const rows = data.map((entry) => '<tr class="row-button" data-action="show-detail" data-uuid="' + escapeHtml(entry.uuid) + '"><td><span class="' + typeClass(entry) + '">' + escapeHtml(entry.type) + '</span></td><td>' + entrySummaryHtml(entry) + '</td><td>' + tagsHtml(entry.tags) + '</td><td>' + durationHtml(entry) + '</td><td class="mono">' + batchSnippet(entry.batchId) + '</td><td class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</td></tr>').join('');
825
+ const rows = data.map((entry) => '<tr class="row-button" data-action="show-detail" data-uuid="' + escapeHtml(entry.uuid) + '"><td><span class="' + typeClass(entry) + '">' + escapeHtml(entryTypeLabel(entry)) + '</span></td><td>' + entrySummaryHtml(entry) + '</td><td>' + tagsHtml(entry.tags) + '</td><td>' + durationHtml(entry) + '</td><td class="mono">' + batchSnippet(entry.batchId) + '</td><td class="activity-time">' + escapeHtml(timeSince(entry.createdAt)) + '</td></tr>').join('');
800
826
 
801
827
  main.innerHTML = [
802
828
  '<section class="panel">',
@@ -1063,6 +1089,7 @@ const buildDashboardHtml = (basePath: string, projectName?: string): string => {
1063
1089
  .replace('__TRACE_SUN_ICON__', JSON.stringify(SUN_ICON))
1064
1090
  .replace('__TRACE_MOON_ICON__', JSON.stringify(MOON_ICON))
1065
1091
  .replace('__TRACE_COPY_ICON__', JSON.stringify(COPY_ICON))
1092
+ .replace('__TRACE_DISCLOSURE_ICON__', JSON.stringify(DISCLOSURE_ICON))
1066
1093
  .replace('__TRACE_JSON_REGEX__', JSON.stringify(JSON_HIGHLIGHT_PATTERN))
1067
1094
  .replace('__TRACE_SQL_REGEX__', JSON.stringify(SQL_HIGHLIGHT_PATTERN))
1068
1095
  .replace('__TRACE_BASE_PATH_LABEL__', basePath)
@@ -27,6 +27,87 @@ const describeValueType = (value: unknown): string => {
27
27
  return typeof value;
28
28
  };
29
29
 
30
+ type TracePathSegment = string | number;
31
+
32
+ type TracePathCandidate = {
33
+ path: TracePathSegment[];
34
+ size: number;
35
+ };
36
+
37
+ const chooseLargerCandidate = (
38
+ left: TracePathCandidate | null,
39
+ right: TracePathCandidate | null
40
+ ): TracePathCandidate | null => {
41
+ if (left === null) return right;
42
+ if (right === null) return left;
43
+ return right.size > left.size ? right : left;
44
+ };
45
+
46
+ const fallbackCandidate = (value: unknown, path: TracePathSegment[]): TracePathCandidate | null => {
47
+ return path.length === 0 ? null : { path, size: serializedSize(value) };
48
+ };
49
+
50
+ const findLargestDroppablePathInArray = (
51
+ value: unknown[],
52
+ path: TracePathSegment[]
53
+ ): TracePathCandidate | null => {
54
+ let best: TracePathCandidate | null = null;
55
+
56
+ for (const [index, item] of value.entries()) {
57
+ best = chooseLargerCandidate(best, findLargestDroppablePath(item, [...path, index]));
58
+ }
59
+
60
+ return best ?? fallbackCandidate(value, path);
61
+ };
62
+
63
+ const findLargestDroppablePathInObject = (
64
+ value: Record<string, unknown>,
65
+ path: TracePathSegment[]
66
+ ): TracePathCandidate | null => {
67
+ let best: TracePathCandidate | null = null;
68
+
69
+ for (const [key, entryValue] of Object.entries(value)) {
70
+ if (key === '__traceNotice') continue;
71
+ best = chooseLargerCandidate(best, findLargestDroppablePath(entryValue, [...path, key]));
72
+ }
73
+
74
+ return best ?? fallbackCandidate(value, path);
75
+ };
76
+
77
+ const findLargestDroppablePath = (
78
+ value: unknown,
79
+ path: TracePathSegment[] = []
80
+ ): TracePathCandidate | null => {
81
+ if (Array.isArray(value)) return findLargestDroppablePathInArray(value, path);
82
+ if (typeof value === 'object' && value !== null) {
83
+ return findLargestDroppablePathInObject(value as Record<string, unknown>, path);
84
+ }
85
+
86
+ return fallbackCandidate(value, path);
87
+ };
88
+
89
+ const replaceAtPath = (value: unknown, path: TracePathSegment[], replacement: unknown): unknown => {
90
+ if (path.length === 0) return replacement;
91
+
92
+ const [segment, ...rest] = path;
93
+
94
+ if (Array.isArray(value) && typeof segment === 'number') {
95
+ const next = value.slice();
96
+ next[segment] = replaceAtPath(next[segment], rest, replacement);
97
+ return next;
98
+ }
99
+
100
+ if (typeof value === 'object' && value !== null && typeof segment === 'string') {
101
+ const current = value as Record<string, unknown>;
102
+ return {
103
+ ...current,
104
+ [segment]: replaceAtPath(current[segment], rest, replacement),
105
+ };
106
+ }
107
+
108
+ return value;
109
+ };
110
+
30
111
  const compactValue = (value: unknown, depth: number): unknown => {
31
112
  if (depth >= DEFAULT_MAX_DEPTH) {
32
113
  return DROPPED_FIELD_MESSAGE;
@@ -69,19 +150,19 @@ const compactValue = (value: unknown, depth: number): unknown => {
69
150
  return Object.fromEntries(compactedEntries);
70
151
  };
71
152
 
72
- const compactTopLevelObjectToBudget = (value: Record<string, unknown>): Record<string, unknown> => {
73
- const compacted: Record<string, unknown> = {
74
- ...value,
75
- __traceNotice: COMPACTED_CONTENT_MESSAGE,
76
- };
77
-
78
- const keysByDescendingSize = Object.keys(compacted)
79
- .filter((key) => key !== '__traceNotice')
80
- .sort((left, right) => serializedSize(compacted[right]) - serializedSize(compacted[left]));
81
-
82
- for (const key of keysByDescendingSize) {
83
- if (serializedSize(compacted) <= DEFAULT_MAX_ENTRY_BYTES) break;
84
- compacted[key] = DROPPED_FIELD_MESSAGE;
153
+ const compactStructuredValueToBudget = (value: unknown): unknown => {
154
+ let compacted: unknown =
155
+ typeof value === 'object' && value !== null && !Array.isArray(value)
156
+ ? {
157
+ ...(value as Record<string, unknown>),
158
+ __traceNotice: COMPACTED_CONTENT_MESSAGE,
159
+ }
160
+ : value;
161
+
162
+ while (serializedSize(compacted) > DEFAULT_MAX_ENTRY_BYTES) {
163
+ const candidate = findLargestDroppablePath(compacted);
164
+ if (candidate === null) break;
165
+ compacted = replaceAtPath(compacted, candidate.path, DROPPED_FIELD_MESSAGE);
85
166
  }
86
167
 
87
168
  return compacted;
@@ -97,10 +178,10 @@ const fitContentToBudget = (content: unknown): unknown => {
97
178
  return compacted;
98
179
  }
99
180
 
100
- if (typeof compacted === 'object' && compacted !== null && !Array.isArray(compacted)) {
101
- const topLevelCompacted = compactTopLevelObjectToBudget(compacted as Record<string, unknown>);
102
- if (serializedSize(topLevelCompacted) <= DEFAULT_MAX_ENTRY_BYTES) {
103
- return topLevelCompacted;
181
+ if (typeof compacted === 'object' && compacted !== null) {
182
+ const budgetCompacted = compactStructuredValueToBudget(compacted);
183
+ if (serializedSize(budgetCompacted) <= DEFAULT_MAX_ENTRY_BYTES) {
184
+ return budgetCompacted;
104
185
  }
105
186
  }
106
187
 
package/src/types.ts CHANGED
@@ -209,6 +209,7 @@ export interface ViewContent {
209
209
  }
210
210
 
211
211
  export interface ClientRequestContent {
212
+ source?: string;
212
213
  method: string;
213
214
  url: string;
214
215
  requestHeaders: Record<string, string>;
@@ -222,6 +223,7 @@ export interface ClientRequestContent {
222
223
  }
223
224
 
224
225
  export interface ClientRequestTraceInput {
226
+ source?: string;
225
227
  method: string;
226
228
  url: string;
227
229
  requestHeaders: Record<string, string>;
@@ -315,6 +317,13 @@ export type TraceFilterRule = {
315
317
  exclude?: string[];
316
318
  };
317
319
 
320
+ export type TraceClientRequestCaptureRule = TraceFilterRule & {
321
+ requestHeaders?: boolean;
322
+ requestBody?: boolean;
323
+ responseHeaders?: boolean;
324
+ responseBody?: boolean;
325
+ };
326
+
318
327
  export type TraceRequestWatcherConfig = TraceFilterRule & {
319
328
  all?: TraceFilterRule;
320
329
  get?: TraceFilterRule;
@@ -324,8 +333,13 @@ export type TraceRequestWatcherConfig = TraceFilterRule & {
324
333
  delete?: TraceFilterRule;
325
334
  };
326
335
 
336
+ export type TraceClientRequestWatcherConfig = TraceClientRequestCaptureRule & {
337
+ sources?: Record<string, TraceClientRequestCaptureRule>;
338
+ };
339
+
327
340
  export type TraceWatcherToggle = boolean | TraceFilterRule;
328
341
  export type TraceRequestWatcherToggle = boolean | TraceRequestWatcherConfig;
342
+ export type TraceClientRequestWatcherToggle = boolean | TraceClientRequestWatcherConfig;
329
343
 
330
344
  export type WatcherToggles = {
331
345
  request?: TraceRequestWatcherToggle;
@@ -347,7 +361,7 @@ export type WatcherToggles = {
347
361
  batch?: TraceWatcherToggle;
348
362
  dump?: TraceWatcherToggle;
349
363
  view?: TraceWatcherToggle;
350
- clientRequest?: TraceWatcherToggle;
364
+ clientRequest?: TraceClientRequestWatcherToggle;
351
365
  };
352
366
 
353
367
  export interface ITraceConfig {
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ITraceConfig,
3
3
  ITraceEntry,
4
+ TraceClientRequestWatcherConfig,
4
5
  TraceFilterRule,
5
6
  TraceRequestWatcherConfig,
6
7
  WatcherToggles,
@@ -22,6 +23,7 @@ const normalizeTerms = (terms?: string[]): string[] => {
22
23
 
23
24
  const matchesRule = (haystack: string, rule?: TraceFilterRule): boolean => {
24
25
  if (!rule) return true;
26
+ if (rule.enabled === false) return false;
25
27
 
26
28
  const include = normalizeTerms(rule.include);
27
29
  const exclude = normalizeTerms(rule.exclude);
@@ -86,6 +88,20 @@ const getRequestMethodRule = (
86
88
  return watcher.all;
87
89
  };
88
90
 
91
+ const getClientRequestSourceRule = (
92
+ watcher: TraceClientRequestWatcherConfig,
93
+ entry: ITraceEntry
94
+ ): TraceFilterRule | undefined => {
95
+ if (entry.type !== EntryType.CLIENT_REQUEST) return undefined;
96
+
97
+ const content = isObjectValue(entry.content) ? entry.content : undefined;
98
+ const sourceValue = content?.['source'];
99
+ const source = typeof sourceValue === 'string' ? sourceValue.trim().toLowerCase() : '';
100
+
101
+ if (source === '') return undefined;
102
+ return watcher.sources?.[source];
103
+ };
104
+
89
105
  export const TraceEntryFilter = Object.freeze({
90
106
  shouldCapture(entry: ITraceEntry, config: ITraceConfig): boolean {
91
107
  const watcherKey = watcherKeyByEntryType[entry.type];
@@ -103,6 +119,13 @@ export const TraceEntryFilter = Object.freeze({
103
119
  if (!matchesRule(haystack, methodRule)) return false;
104
120
  }
105
121
 
122
+ if (watcherKey === 'clientRequest') {
123
+ const clientRequestWatcher = watcher as TraceClientRequestWatcherConfig;
124
+ const sourceRule = getClientRequestSourceRule(clientRequestWatcher, entry);
125
+ if (sourceRule?.enabled === false) return false;
126
+ if (!matchesRule(haystack, sourceRule)) return false;
127
+ }
128
+
106
129
  return true;
107
130
  },
108
131
  });
@@ -4,6 +4,8 @@ import type {
4
4
  ClientRequestTraceInput,
5
5
  ITraceWatcher,
6
6
  ITraceWatcherConfig,
7
+ TraceClientRequestCaptureRule,
8
+ TraceClientRequestWatcherConfig,
7
9
  } from '../types';
8
10
  import { EntryType } from '../types';
9
11
  import { AuthTag } from '../utils/authTag';
@@ -14,8 +16,105 @@ let _storage: ITraceWatcherConfig['storage'] | null = null;
14
16
  let _redactHeaderNames: string[] = [];
15
17
  let _redactBodyFields: string[] = [];
16
18
  let _ignoreRoutes: string[] = [];
19
+ let _clientRequestWatcher: TraceClientRequestWatcherConfig | undefined;
20
+
21
+ const isObjectValue = (value: unknown): value is Record<string, unknown> => {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ };
24
+
25
+ const resolveSource = (value: unknown): string | undefined => {
26
+ if (typeof value !== 'string') return undefined;
27
+ const normalized = value.trim().toLowerCase();
28
+ return normalized === '' ? undefined : normalized;
29
+ };
30
+
31
+ const resolveSourceRule = (
32
+ source: string | undefined
33
+ ): TraceClientRequestCaptureRule | undefined => {
34
+ if (source === undefined) return undefined;
35
+ return _clientRequestWatcher?.sources?.[source];
36
+ };
37
+
38
+ const shouldCaptureField = (
39
+ field: keyof Pick<
40
+ TraceClientRequestCaptureRule,
41
+ 'requestHeaders' | 'requestBody' | 'responseHeaders' | 'responseBody'
42
+ >,
43
+ sourceRule: TraceClientRequestCaptureRule | undefined
44
+ ): boolean => {
45
+ const scoped = sourceRule?.[field];
46
+ if (typeof scoped === 'boolean') return scoped;
47
+ const global = _clientRequestWatcher?.[field];
48
+ if (typeof global === 'boolean') return global;
49
+ return true;
50
+ };
51
+
52
+ const buildRequestHeaders = (
53
+ requestHeaders: Record<string, string>,
54
+ sourceRule: TraceClientRequestCaptureRule | undefined
55
+ ): Pick<ClientRequestContent, 'requestHeaders'> => {
56
+ return shouldCaptureField('requestHeaders', sourceRule)
57
+ ? { requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames) }
58
+ : { requestHeaders: {} };
59
+ };
60
+
61
+ const buildRequestBody = (
62
+ requestBody: unknown,
63
+ sourceRule: TraceClientRequestCaptureRule | undefined
64
+ ): Partial<Pick<ClientRequestContent, 'requestBody'>> => {
65
+ if (requestBody === undefined) return {};
66
+ if (!shouldCaptureField('requestBody', sourceRule)) return {};
67
+ return { requestBody: redactUnknown(requestBody, _redactBodyFields) };
68
+ };
69
+
70
+ const buildResponseHeaders = (
71
+ responseHeaders: Record<string, string> | undefined,
72
+ sourceRule: TraceClientRequestCaptureRule | undefined
73
+ ): Partial<Pick<ClientRequestContent, 'responseHeaders'>> => {
74
+ if (responseHeaders === undefined) return {};
75
+ if (!shouldCaptureField('responseHeaders', sourceRule)) return {};
76
+ return { responseHeaders: redactHeaders(responseHeaders, _redactHeaderNames) };
77
+ };
78
+
79
+ const buildResponseBody = (
80
+ responseBody: unknown,
81
+ sourceRule: TraceClientRequestCaptureRule | undefined
82
+ ): Partial<Pick<ClientRequestContent, 'responseBody'>> => {
83
+ if (responseBody === undefined) return {};
84
+ if (!shouldCaptureField('responseBody', sourceRule)) return {};
85
+ return { responseBody: redactUnknown(responseBody, _redactBodyFields) };
86
+ };
87
+
88
+ const buildClientRequestContent = (
89
+ input: ClientRequestTraceInput,
90
+ sourceRule: TraceClientRequestCaptureRule | undefined,
91
+ normalizedSource: string | undefined
92
+ ): ClientRequestContent => {
93
+ return {
94
+ ...(normalizedSource === undefined ? {} : { source: normalizedSource }),
95
+ method: input.method.toUpperCase(),
96
+ url: input.url,
97
+ ...buildRequestHeaders(input.requestHeaders, sourceRule),
98
+ ...buildRequestBody(input.requestBody, sourceRule),
99
+ ...(input.responseStatus === undefined ? {} : { responseStatus: input.responseStatus }),
100
+ ...buildResponseHeaders(input.responseHeaders, sourceRule),
101
+ ...buildResponseBody(input.responseBody, sourceRule),
102
+ ...(typeof input.error === 'string' && input.error !== '' ? { error: input.error } : {}),
103
+ duration: input.duration,
104
+ hostname: TraceContext.getHostname(),
105
+ };
106
+ };
107
+
108
+ const isWatcherEnabled = (
109
+ value: ITraceWatcherConfig['config']['watchers']['clientRequest']
110
+ ): boolean => {
111
+ if (value === false) return false;
112
+ if (isObjectValue(value) && value.enabled === false) return false;
113
+ return true;
114
+ };
17
115
 
18
116
  const emit = ({
117
+ source,
19
118
  method,
20
119
  url,
21
120
  requestHeaders,
@@ -28,26 +127,28 @@ const emit = ({
28
127
  }: ClientRequestTraceInput): void => {
29
128
  if (!_storage) return;
30
129
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes)) return;
130
+ const normalizedSource = resolveSource(source);
131
+ const sourceRule = resolveSourceRule(normalizedSource);
132
+ if (sourceRule?.enabled === false) return;
31
133
  const tags = AuthTag.append([method.toUpperCase()]);
32
134
  if ((responseStatus ?? 0) >= 400 || error) tags.push('failed');
33
- const content: ClientRequestContent = {
34
- method: method.toUpperCase(),
35
- url,
36
- requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames),
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 } : {}),
48
- duration,
49
- hostname: TraceContext.getHostname(),
50
- };
135
+ if (normalizedSource !== undefined) tags.push(normalizedSource);
136
+ const content = buildClientRequestContent(
137
+ {
138
+ source,
139
+ method,
140
+ url,
141
+ requestHeaders,
142
+ responseStatus,
143
+ duration,
144
+ requestBody,
145
+ responseHeaders,
146
+ responseBody,
147
+ error,
148
+ },
149
+ sourceRule,
150
+ normalizedSource
151
+ );
51
152
  _storage
52
153
  .writeEntry({
53
154
  uuid: crypto.randomUUID(),
@@ -64,13 +165,18 @@ const emit = ({
64
165
  export const HttpClientWatcher: ITraceWatcher & { emit: typeof emit } = Object.freeze({
65
166
  emit,
66
167
  register({ storage, config }: ITraceWatcherConfig): () => void {
67
- if (config.watchers.clientRequest === false) return () => undefined;
168
+ if (!isWatcherEnabled(config.watchers.clientRequest)) return () => undefined;
68
169
  _storage = storage;
170
+ _clientRequestWatcher =
171
+ typeof config.watchers.clientRequest === 'object' && config.watchers.clientRequest !== null
172
+ ? (config.watchers.clientRequest as TraceClientRequestWatcherConfig)
173
+ : undefined;
69
174
  _redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
70
175
  _redactBodyFields = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
71
176
  _ignoreRoutes = config.ignoreRoutes;
72
177
  return () => {
73
178
  _storage = null;
179
+ _clientRequestWatcher = undefined;
74
180
  _redactBodyFields = [];
75
181
  _ignoreRoutes = [];
76
182
  };
@@ -26,6 +26,13 @@ const normalizeHeaderValue = (value: string | string[]): string => {
26
26
  return Array.isArray(value) ? value.join(', ') : value;
27
27
  };
28
28
 
29
+ const resolveRouteMiddleware = (req: IRequest): string[] => {
30
+ const middleware = req.context?.['traceRouteMiddleware'];
31
+ return Array.isArray(middleware)
32
+ ? middleware.filter((value): value is string => typeof value === 'string')
33
+ : [];
34
+ };
35
+
29
36
  const resolveRequestPayload = (req: IRequest, config: ITraceConfig): unknown => {
30
37
  const redactFields = [...config.redaction.keys, ...config.redaction.body];
31
38
  const requestBody = typeof req.getBody === 'function' ? req.getBody() : req.body;
@@ -150,7 +157,7 @@ const buildEntry = (
150
157
  responseBody: responseCapture.body,
151
158
  duration: Date.now() - start,
152
159
  memory: TraceContext.getMemory(),
153
- middleware: [],
160
+ middleware: resolveRouteMiddleware(req),
154
161
  hostname: TraceContext.getHostname(),
155
162
  userId: TraceContext.getUserId(),
156
163
  };
@@ -6,6 +6,10 @@ import { RequestFilter } from '../utils/requestFilter';
6
6
  let _storage: ITraceWatcherConfig['storage'] | null = null;
7
7
  let _ignoreRoutes: string[] = [];
8
8
 
9
+ type GlobalMiddlewareTraceState = {
10
+ __zintrust_trace_middleware_emit__?: typeof emit;
11
+ };
12
+
9
13
  const emit = (name: string, event: MiddlewareContent['event'], duration?: number): void => {
10
14
  if (!_storage) return;
11
15
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes)) return;
@@ -34,7 +38,12 @@ export const MiddlewareWatcher: ITraceWatcher & { emit: typeof emit } = Object.f
34
38
  if (config.watchers.middleware === false) return () => undefined;
35
39
  _storage = storage;
36
40
  _ignoreRoutes = config.ignoreRoutes;
41
+ (globalThis as unknown as GlobalMiddlewareTraceState).__zintrust_trace_middleware_emit__ = emit;
37
42
  return () => {
43
+ const globalState = globalThis as unknown as GlobalMiddlewareTraceState;
44
+ if (globalState.__zintrust_trace_middleware_emit__ === emit) {
45
+ delete globalState.__zintrust_trace_middleware_emit__;
46
+ }
38
47
  _storage = null;
39
48
  _ignoreRoutes = [];
40
49
  };