@zintrust/trace 0.4.96 → 0.5.1

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.
@@ -139,9 +139,6 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
139
139
  const MOON_ICON = __TRACE_MOON_ICON__;
140
140
  const COPY_ICON = __TRACE_COPY_ICON__;
141
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)}
145
142
  const JSON_HIGHLIGHT_PATTERN = new RegExp(__TRACE_JSON_REGEX__, 'g');
146
143
  const SQL_HIGHLIGHT_PATTERN = new RegExp(__TRACE_SQL_REGEX__, 'gim');
147
144
  const ENTRY_TYPES = ['request','query','exception','log','job','cache','schedule','mail','auth','event','model','notification','redis','gate','middleware','command','batch','dump','view','client_request'];
@@ -172,6 +169,28 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
172
169
 
173
170
  let state = createInitialState();
174
171
 
172
+ const DETAIL_BATCH_PAGE_SIZE = 10;
173
+ const DETAIL_BATCH_SCOPE_BY_TAB = Object.freeze({
174
+ queries: { type: 'query' },
175
+ middleware: { type: 'middleware' },
176
+ models: { type: 'model' },
177
+ logs: { type: 'log' },
178
+ exceptions: { type: 'exception' },
179
+ http: { type: 'client_request' },
180
+ cache: { type: 'cache' },
181
+ other: { scope: 'other' }
182
+ });
183
+ const DETAIL_BATCH_COUNT_TYPES = Object.freeze({
184
+ queries: 'query',
185
+ middleware: 'middleware',
186
+ models: 'model',
187
+ logs: 'log',
188
+ exceptions: 'exception',
189
+ http: 'client_request',
190
+ cache: 'cache'
191
+ });
192
+ const DETAIL_BATCH_OTHER_EXCLUDED_TYPES = ['request','query','middleware','model','log','exception','client_request','cache'];
193
+
175
194
  let copySequence = 0;
176
195
  const copyPayloads = new Map();
177
196
 
@@ -328,9 +347,40 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
328
347
  return raw === '' ? '-' : escapeHtml(raw.slice(0, 8));
329
348
  };
330
349
 
331
- const batchEntries = () => Array.isArray(state.detailBatch) ? state.detailBatch : [];
332
- const batchEntriesByType = (type) => batchEntries().filter((entry) => entry.type === type);
333
- const hasRequestTrace = () => Boolean(state.detail && state.detail.type === 'request' && batchEntries().length > 0);
350
+ const createDetailBatchState = (payload, scope = '', loading = false) => ({
351
+ counts: payload && typeof payload.counts === 'object' && payload.counts !== null ? payload.counts : {},
352
+ entries: payload && Array.isArray(payload.entries) ? payload.entries : [],
353
+ total: payload && Number.isFinite(Number(payload.total)) ? Number(payload.total) : 0,
354
+ page: payload && Number.isFinite(Number(payload.page)) && Number(payload.page) > 0 ? Number(payload.page) : 1,
355
+ perPage: payload && Number.isFinite(Number(payload.perPage)) && Number(payload.perPage) > 0 ? Number(payload.perPage) : DETAIL_BATCH_PAGE_SIZE,
356
+ scope,
357
+ loading
358
+ });
359
+
360
+ const detailBatchState = () => {
361
+ const detailBatch = state.detailBatch;
362
+ if (!detailBatch || typeof detailBatch !== 'object' || Array.isArray(detailBatch)) {
363
+ return createDetailBatchState(null);
364
+ }
365
+ return createDetailBatchState(detailBatch, typeof detailBatch.scope === 'string' ? detailBatch.scope : '', detailBatch.loading === true);
366
+ };
367
+
368
+ const batchEntries = () => detailBatchState().entries;
369
+ const batchCounts = () => detailBatchState().counts;
370
+ const batchCount = (type) => {
371
+ const raw = batchCounts()[type];
372
+ return Number.isFinite(Number(raw)) ? Number(raw) : 0;
373
+ };
374
+ const otherBatchCount = () => Object.entries(batchCounts()).reduce((sum, pair) => {
375
+ return DETAIL_BATCH_OTHER_EXCLUDED_TYPES.includes(pair[0]) ? sum : sum + Number(pair[1] || 0);
376
+ }, 0);
377
+ const resolveDetailBatchQuery = (tab) => DETAIL_BATCH_SCOPE_BY_TAB[tab] || null;
378
+ const resolveDetailBatchCount = (tab) => {
379
+ if (tab === 'other') return otherBatchCount();
380
+ const type = DETAIL_BATCH_COUNT_TYPES[tab];
381
+ return typeof type === 'string' ? batchCount(type) : 0;
382
+ };
383
+ const hasRequestTrace = () => Boolean(state.detail && state.detail.type === 'request');
334
384
 
335
385
  const prettyJson = (value) => {
336
386
  try {
@@ -689,6 +739,27 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
689
739
  }).join('') + '</div>';
690
740
  };
691
741
 
742
+ const renderDetailBatchPanel = (tab) => {
743
+ const detailBatch = detailBatchState();
744
+ const count = resolveDetailBatchCount(tab);
745
+ if (detailBatch.loading && detailBatch.scope === tab) {
746
+ return '<p class="trace-note">Loading related entries...</p>';
747
+ }
748
+ if (count === 0) {
749
+ return '<p class="trace-note">No related entries captured.</p>';
750
+ }
751
+ if (detailBatch.scope !== tab) {
752
+ return '<p class="trace-note">Open this tab to load the first page of related entries.</p>';
753
+ }
754
+
755
+ const totalPages = Math.max(1, Math.ceil(detailBatch.total / Math.max(1, detailBatch.perPage)));
756
+
757
+ return [
758
+ renderTraceItems(batchEntries()),
759
+ '<div class="pagination"><span>Page ' + escapeHtml(detailBatch.page) + ' of ' + escapeHtml(totalPages) + ' · ' + escapeHtml(detailBatch.total) + ' related entries</span><div class="pagination-controls"><button type="button" data-action="detail-batch-prev"' + (detailBatch.page <= 1 ? ' disabled' : '') + '>Previous</button><button type="button" data-action="detail-batch-next"' + (detailBatch.page >= totalPages ? ' disabled' : '') + '>Next</button></div></div>'
760
+ ].join('');
761
+ };
762
+
692
763
  const renderRequestTrace = (main) => {
693
764
  const entry = state.detail;
694
765
  const content = entry && entry.content ? entry.content : {};
@@ -697,17 +768,16 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
697
768
  { id: 'payload', label: 'Payload' },
698
769
  { id: 'headers', label: 'Headers' },
699
770
  { id: 'response', label: 'Response' },
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 },
703
- { id: 'logs', label: 'Logs', count: batchEntriesByType('log').length },
704
- { id: 'exceptions', label: 'Exceptions', count: batchEntriesByType('exception').length },
705
- { id: 'http', label: 'HTTP', count: batchEntriesByType('client_request').length },
706
- { id: 'cache', label: 'Cache', count: batchEntriesByType('cache').length },
707
- { id: 'other', label: 'Other', count: batchEntries().filter((item) => !['request','query','middleware','model','log','exception','client_request','cache'].includes(item.type)).length }
771
+ { id: 'queries', label: 'Queries', count: resolveDetailBatchCount('queries') },
772
+ { id: 'middleware', label: 'Middleware', count: resolveDetailBatchCount('middleware') },
773
+ { id: 'models', label: 'Models', count: resolveDetailBatchCount('models') },
774
+ { id: 'logs', label: 'Logs', count: resolveDetailBatchCount('logs') },
775
+ { id: 'exceptions', label: 'Exceptions', count: resolveDetailBatchCount('exceptions') },
776
+ { id: 'http', label: 'HTTP', count: resolveDetailBatchCount('http') },
777
+ { id: 'cache', label: 'Cache', count: resolveDetailBatchCount('cache') },
778
+ { id: 'other', label: 'Other', count: resolveDetailBatchCount('other') }
708
779
  ];
709
780
  const currentTab = traceTabs.some((tab) => tab.id === state.detailTab) ? state.detailTab : 'summary';
710
- const otherEntries = batchEntries().filter((item) => !['request','query','middleware','model','log','exception','client_request','cache'].includes(item.type));
711
781
  const panels = {
712
782
  summary: [
713
783
  '<div class="detail-grid">',
@@ -734,14 +804,14 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
734
804
  payload: detailJson(content.payload || {}, 'Payload Json'),
735
805
  headers: '<div class="detail-stack">' + detailJson(content.headers || {}, 'Request Header Json') + detailJson(content.responseHeaders || {}, 'Response Header Json') + '</div>',
736
806
  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>',
737
- queries: renderTraceItems(batchEntriesByType('query')),
738
- middleware: renderTraceItems(batchEntriesByType('middleware')),
739
- models: renderTraceItems(batchEntriesByType('model')),
740
- logs: renderTraceItems(batchEntriesByType('log')),
741
- exceptions: renderTraceItems(batchEntriesByType('exception')),
742
- http: renderTraceItems(batchEntriesByType('client_request')),
743
- cache: renderTraceItems(batchEntriesByType('cache')),
744
- other: renderTraceItems(otherEntries)
807
+ queries: renderDetailBatchPanel('queries'),
808
+ middleware: renderDetailBatchPanel('middleware'),
809
+ models: renderDetailBatchPanel('models'),
810
+ logs: renderDetailBatchPanel('logs'),
811
+ exceptions: renderDetailBatchPanel('exceptions'),
812
+ http: renderDetailBatchPanel('http'),
813
+ cache: renderDetailBatchPanel('cache'),
814
+ other: renderDetailBatchPanel('other')
745
815
  };
746
816
 
747
817
  main.innerHTML = [
@@ -961,8 +1031,8 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
961
1031
  const entry = detailResult.entry;
962
1032
  let detailBatch = null;
963
1033
  if (entry.type === 'request' && entry.batchId) {
964
- const batch = await api('/batch/' + encodeURIComponent(entry.batchId));
965
- detailBatch = batch.entries || [];
1034
+ const batch = await api('/batch/' + encodeURIComponent(entry.batchId) + '?countsOnly=true');
1035
+ detailBatch = createDetailBatchState(batch);
966
1036
  }
967
1037
  state = { ...state, detail: entry, detailBatch, detailTab: 'summary', page: 'entries' };
968
1038
  render();
@@ -971,6 +1041,54 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
971
1041
  }
972
1042
  };
973
1043
 
1044
+ const loadDetailBatchTab = async (tab, page = 1) => {
1045
+ const detail = state.detail;
1046
+ if (!detail || detail.type !== 'request' || !detail.batchId) return;
1047
+
1048
+ const query = resolveDetailBatchQuery(tab);
1049
+ if (!query) {
1050
+ state = { ...state, detailTab: tab };
1051
+ render();
1052
+ return;
1053
+ }
1054
+
1055
+ const previous = detailBatchState();
1056
+ state = {
1057
+ ...state,
1058
+ detailTab: tab,
1059
+ detailBatch: {
1060
+ ...previous,
1061
+ scope: tab,
1062
+ page,
1063
+ perPage: DETAIL_BATCH_PAGE_SIZE,
1064
+ loading: true,
1065
+ },
1066
+ };
1067
+ render();
1068
+
1069
+ try {
1070
+ const qs = new URLSearchParams({ page: String(page), perPage: String(DETAIL_BATCH_PAGE_SIZE) });
1071
+ if (query.type) qs.set('type', query.type);
1072
+ if (query.scope) qs.set('scope', query.scope);
1073
+ const batch = await api('/batch/' + encodeURIComponent(detail.batchId) + '?' + qs.toString());
1074
+ state = {
1075
+ ...state,
1076
+ detailTab: tab,
1077
+ detailBatch: createDetailBatchState(batch, tab, false),
1078
+ page: 'entries'
1079
+ };
1080
+ render();
1081
+ } catch (error) {
1082
+ state = {
1083
+ ...state,
1084
+ detailTab: tab,
1085
+ detailBatch: { ...previous, loading: false }
1086
+ };
1087
+ render();
1088
+ window.alert(error.message);
1089
+ }
1090
+ };
1091
+
974
1092
  const addTag = async () => {
975
1093
  const input = document.getElementById('new-tag');
976
1094
  const value = input && 'value' in input ? String(input.value || '').trim() : '';
@@ -1048,10 +1166,27 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
1048
1166
  filterByTag(String(target.getAttribute('data-tag') || ''));
1049
1167
  return;
1050
1168
  }
1051
- if (action === 'detail-tab') { state = { ...state, detailTab: String(target.getAttribute('data-tab') || 'summary') }; render(); return; }
1169
+ if (action === 'detail-tab') {
1170
+ const tab = String(target.getAttribute('data-tab') || 'summary');
1171
+ if (Object.prototype.hasOwnProperty.call(DETAIL_BATCH_SCOPE_BY_TAB, tab)) {
1172
+ loadDetailBatchTab(tab, 1);
1173
+ return;
1174
+ }
1175
+ state = { ...state, detailTab: tab };
1176
+ render();
1177
+ return;
1178
+ }
1052
1179
  if (action === 'clear-all') { clearAll(); return; }
1053
1180
  if (action === 'show-detail') { showDetail(String(target.getAttribute('data-uuid') || '')); return; }
1054
1181
  if (action === 'close-detail') { state = { ...state, detail: null, detailBatch: null, detailTab: 'summary' }; render(); return; }
1182
+ if (action === 'detail-batch-prev' || action === 'detail-batch-next') {
1183
+ const detailBatch = detailBatchState();
1184
+ const nextPage = action === 'detail-batch-prev' ? Math.max(1, detailBatch.page - 1) : detailBatch.page + 1;
1185
+ if (detailBatch.scope !== '') {
1186
+ loadDetailBatchTab(detailBatch.scope, nextPage);
1187
+ }
1188
+ return;
1189
+ }
1055
1190
  if (action === 'page-prev') { state = { ...state, entriesPage: Math.max(1, state.entriesPage - 1) }; render(); return; }
1056
1191
  if (action === 'page-next') { state = { ...state, entriesPage: state.entriesPage + 1 }; render(); return; }
1057
1192
  if (action === 'clear-filters') { state = { ...state, detail: null, detailBatch: null, detailTab: 'summary', entriesPage: 1, entriesFilter: { type: '', tag: '', batchId: '' } }; render(); return; }
@@ -4,7 +4,14 @@
4
4
  * read/write operations to the trace storage facade.
5
5
  */
6
6
  import type { IDatabase } from '@zintrust/core';
7
- import type { EntryTypeValue, ITraceEntry, ITraceStorage, QueryEntriesOptions } from '../types';
7
+ import type {
8
+ EntryTypeValue,
9
+ ITraceEntry,
10
+ ITraceStorage,
11
+ QueryBatchEntriesOptions,
12
+ QueryBatchEntriesResult,
13
+ QueryEntriesOptions,
14
+ } from '../types';
8
15
  import { familyHash } from '../utils/familyHash';
9
16
 
10
17
  const TABLE_ENTRIES = 'zin_trace_entries';
@@ -26,6 +33,102 @@ type EntryRow = {
26
33
 
27
34
  type TagRow = { entry_uuid: string; tag: string };
28
35
 
36
+ const decodeJsonStringLiteral = (value: string): string | undefined => {
37
+ try {
38
+ return JSON.parse(`"${value}"`) as string;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ };
43
+
44
+ const matchJsonStringField = (content: string, key: string): string | undefined => {
45
+ const match = new RegExp(String.raw`"${key}"\s*:\s*"((?:\\.|[^"\\])*)"`, 's').exec(content);
46
+ return match ? decodeJsonStringLiteral(match[1]) : undefined;
47
+ };
48
+
49
+ const matchJsonNumberField = (content: string, key: string): number | undefined => {
50
+ const match = new RegExp(String.raw`"${key}"\s*:\s*(-?\d+(?:\.\d+)?)`, 's').exec(content);
51
+ if (!match) return undefined;
52
+ const parsed = Number(match[1]);
53
+ return Number.isFinite(parsed) ? parsed : undefined;
54
+ };
55
+
56
+ const matchJsonNullOrNumberField = (content: string, key: string): number | null | undefined => {
57
+ const nullMatch = new RegExp(String.raw`"${key}"\s*:\s*null`, 's').exec(content);
58
+ if (nullMatch) return null;
59
+ return matchJsonNumberField(content, key);
60
+ };
61
+
62
+ const matchJsonStringArrayField = (content: string, key: string): string[] | undefined => {
63
+ const match = new RegExp(String.raw`"${key}"\s*:\s*(\[.*?\])`, 's').exec(content);
64
+ if (!match) return undefined;
65
+
66
+ try {
67
+ const parsed = JSON.parse(match[1]) as unknown;
68
+ return Array.isArray(parsed)
69
+ ? parsed.filter((value): value is string => typeof value === 'string')
70
+ : undefined;
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ };
75
+
76
+ const compactRequestContent = (content: string): Record<string, unknown> | undefined => {
77
+ const compact: Record<string, unknown> = {};
78
+ const method = matchJsonStringField(content, 'method');
79
+ const uri = matchJsonStringField(content, 'uri');
80
+ const responseStatus = matchJsonNumberField(content, 'responseStatus');
81
+ const duration = matchJsonNumberField(content, 'duration');
82
+ const memory = matchJsonNullOrNumberField(content, 'memory');
83
+ const middleware = matchJsonStringArrayField(content, 'middleware');
84
+ const hostname = matchJsonStringField(content, 'hostname');
85
+ const userId = matchJsonStringField(content, 'userId');
86
+
87
+ if (method !== undefined) compact['method'] = method;
88
+ if (uri !== undefined) compact['uri'] = uri;
89
+ if (responseStatus !== undefined) compact['responseStatus'] = responseStatus;
90
+ if (duration !== undefined) compact['duration'] = duration;
91
+ if (memory !== undefined) compact['memory'] = memory;
92
+ if (middleware !== undefined) compact['middleware'] = middleware;
93
+ if (hostname !== undefined) compact['hostname'] = hostname;
94
+ if (userId !== undefined) compact['userId'] = userId;
95
+
96
+ return Object.keys(compact).length > 0 ? compact : undefined;
97
+ };
98
+
99
+ const compactClientRequestContent = (content: string): Record<string, unknown> | undefined => {
100
+ const compact: Record<string, unknown> = {};
101
+ const source = matchJsonStringField(content, 'source');
102
+ const method = matchJsonStringField(content, 'method');
103
+ const url = matchJsonStringField(content, 'url');
104
+ const responseStatus = matchJsonNumberField(content, 'responseStatus');
105
+ const error = matchJsonStringField(content, 'error');
106
+ const duration = matchJsonNumberField(content, 'duration');
107
+ const hostname = matchJsonStringField(content, 'hostname');
108
+
109
+ if (source !== undefined) compact['source'] = source;
110
+ if (method !== undefined) compact['method'] = method;
111
+ if (url !== undefined) compact['url'] = url;
112
+ if (responseStatus !== undefined) compact['responseStatus'] = responseStatus;
113
+ if (error !== undefined) compact['error'] = error;
114
+ if (duration !== undefined) compact['duration'] = duration;
115
+ if (hostname !== undefined) compact['hostname'] = hostname;
116
+
117
+ return Object.keys(compact).length > 0 ? compact : undefined;
118
+ };
119
+
120
+ const summarizeEntryContent = (row: EntryRow): unknown => {
121
+ if (row.type === 'request') {
122
+ return compactRequestContent(row.content) ?? (JSON.parse(row.content) as unknown);
123
+ }
124
+
125
+ if (row.type === 'client_request') {
126
+ return compactClientRequestContent(row.content) ?? (JSON.parse(row.content) as unknown);
127
+ }
128
+
129
+ return JSON.parse(row.content) as unknown;
130
+ };
131
+
29
132
  type DatabaseWithDriver = IDatabase & {
30
133
  getType?: () => string;
31
134
  };
@@ -74,12 +177,12 @@ const buildIgnoreInsert = (
74
177
  return `INSERT INTO ${table} (${columnList}) VALUES (${placeholders})`;
75
178
  };
76
179
 
77
- const rowToEntry = (row: EntryRow, tags: string[]): ITraceEntry => ({
180
+ const rowToEntry = (row: EntryRow, tags: string[], summary = false): ITraceEntry => ({
78
181
  uuid: row.uuid,
79
182
  batchId: row.batch_id,
80
183
  familyHash: row.family_hash ?? undefined,
81
184
  type: row.type as EntryTypeValue,
82
- content: JSON.parse(row.content) as unknown,
185
+ content: summary ? summarizeEntryContent(row) : (JSON.parse(row.content) as unknown),
83
186
  tags,
84
187
  isLatest: Boolean(row.is_latest),
85
188
  createdAt: row.created_at,
@@ -132,6 +235,45 @@ const buildEntryFilters = (
132
235
  return { joinClause, whereClause, params, countParams };
133
236
  };
134
237
 
238
+ const buildBatchCounts = async (
239
+ db: IDatabase,
240
+ batchId: string
241
+ ): Promise<Partial<Record<EntryTypeValue, number>>> => {
242
+ const rows = (await db.query(
243
+ `SELECT type, COUNT(*) as cnt FROM ${TABLE_ENTRIES} WHERE batch_id = ? GROUP BY type`,
244
+ [batchId]
245
+ )) as Array<{ type: string; cnt: number }>;
246
+
247
+ const counts: Partial<Record<EntryTypeValue, number>> = {};
248
+ for (const row of rows) {
249
+ counts[row.type as EntryTypeValue] = row.cnt;
250
+ }
251
+
252
+ return counts;
253
+ };
254
+
255
+ const buildBatchEntryFilters = (
256
+ batchId: string,
257
+ opts: QueryBatchEntriesOptions
258
+ ): { whereClause: string; params: unknown[] } => {
259
+ const conditions = ['batch_id = ?'];
260
+ const params: unknown[] = [batchId];
261
+
262
+ if (opts.type) {
263
+ conditions.push('type = ?');
264
+ params.push(opts.type);
265
+ }
266
+
267
+ const excludeTypes = opts.excludeTypes ?? [];
268
+ if (excludeTypes.length > 0) {
269
+ const placeholders = excludeTypes.map(() => '?').join(', ');
270
+ conditions.push(`type NOT IN (${placeholders})`);
271
+ params.push(...excludeTypes);
272
+ }
273
+
274
+ return { whereClause: `WHERE ${conditions.join(' AND ')}`, params };
275
+ };
276
+
135
277
  const loadTagsByUuid = async (db: IDatabase, uuids: string[]): Promise<Map<string, string[]>> => {
136
278
  const tagsByUuid = new Map<string, string[]>();
137
279
  if (uuids.length === 0) return tagsByUuid;
@@ -230,7 +372,7 @@ const createStorage = (db: IDatabase): ITraceStorage => {
230
372
  );
231
373
 
232
374
  return {
233
- data: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [])),
375
+ data: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [], opts.summary)),
234
376
  total,
235
377
  };
236
378
  };
@@ -273,6 +415,53 @@ const createStorage = (db: IDatabase): ITraceStorage => {
273
415
  return rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? []));
274
416
  };
275
417
 
418
+ const queryBatchEntries = async (
419
+ batchId: string,
420
+ opts: QueryBatchEntriesOptions = {}
421
+ ): Promise<QueryBatchEntriesResult> => {
422
+ const page = opts.page ?? 1;
423
+ const perPage = opts.perPage ?? 10;
424
+ const offset = (page - 1) * perPage;
425
+ const counts = await buildBatchCounts(db, batchId);
426
+
427
+ if (opts.countsOnly) {
428
+ const total = Object.values(counts).reduce((sum, value) => sum + Number(value ?? 0), 0);
429
+ return { entries: [], total, counts, page, perPage };
430
+ }
431
+
432
+ const { whereClause, params } = buildBatchEntryFilters(batchId, opts);
433
+ const countResult = (await db.queryOne(
434
+ `SELECT COUNT(*) as cnt FROM ${TABLE_ENTRIES} ${whereClause}`,
435
+ params
436
+ )) as { cnt: number } | undefined;
437
+ const total = countResult?.cnt ?? 0;
438
+ if (total === 0) {
439
+ return { entries: [], total: 0, counts, page, perPage };
440
+ }
441
+
442
+ const rows = (await db.query(
443
+ `SELECT id, uuid, batch_id, family_hash, type, content, is_latest, created_at
444
+ FROM ${TABLE_ENTRIES}
445
+ ${whereClause}
446
+ ORDER BY created_at ASC, id ASC
447
+ LIMIT ? OFFSET ?`,
448
+ [...params, perPage, offset]
449
+ )) as EntryRow[];
450
+
451
+ const tagsByUuid = await loadTagsByUuid(
452
+ db,
453
+ rows.map((row) => row.uuid)
454
+ );
455
+
456
+ return {
457
+ entries: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [], opts.summary)),
458
+ total,
459
+ counts,
460
+ page,
461
+ perPage,
462
+ };
463
+ };
464
+
276
465
  const prune = async (olderThanMs: number, keepExceptions = false): Promise<number> => {
277
466
  const countResult = (await db.queryOne(
278
467
  `SELECT COUNT(*) as cnt FROM ${TABLE_ENTRIES}
@@ -331,6 +520,7 @@ const createStorage = (db: IDatabase): ITraceStorage => {
331
520
  queryEntries,
332
521
  getEntry,
333
522
  getBatch,
523
+ queryBatchEntries,
334
524
  prune,
335
525
  clear,
336
526
  getMonitoring,
package/src/types.ts CHANGED
@@ -262,6 +262,24 @@ export interface QueryEntriesOptions {
262
262
  to?: number;
263
263
  page?: number;
264
264
  perPage?: number;
265
+ summary?: boolean;
266
+ }
267
+
268
+ export interface QueryBatchEntriesOptions {
269
+ type?: EntryTypeValue;
270
+ excludeTypes?: EntryTypeValue[];
271
+ page?: number;
272
+ perPage?: number;
273
+ summary?: boolean;
274
+ countsOnly?: boolean;
275
+ }
276
+
277
+ export interface QueryBatchEntriesResult {
278
+ entries: ITraceEntry[];
279
+ total: number;
280
+ counts: Partial<Record<EntryTypeValue, number>>;
281
+ page: number;
282
+ perPage: number;
265
283
  }
266
284
 
267
285
  export interface ITraceStorage {
@@ -274,6 +292,7 @@ export interface ITraceStorage {
274
292
  queryEntries(opts: QueryEntriesOptions): Promise<{ data: ITraceEntry[]; total: number }>;
275
293
  getEntry(uuid: string): Promise<ITraceEntry | null>;
276
294
  getBatch(batchId: string): Promise<ITraceEntry[]>;
295
+ queryBatchEntries(batchId: string, opts?: QueryBatchEntriesOptions): Promise<QueryBatchEntriesResult>;
277
296
  prune(olderThanMs: number, keepExceptions?: boolean): Promise<number>;
278
297
  clear(): Promise<void>;
279
298
  getMonitoring(): Promise<string[]>;
@@ -1,13 +1,13 @@
1
1
  import { TraceContext } from '../context';
2
- import type {
3
- ClientRequestContent,
4
- ClientRequestTraceInput,
5
- ITraceWatcher,
6
- ITraceWatcherConfig,
7
- TraceClientRequestCaptureRule,
8
- TraceClientRequestWatcherConfig,
2
+ import {
3
+ EntryType,
4
+ type ClientRequestContent,
5
+ type ClientRequestTraceInput,
6
+ type ITraceWatcher,
7
+ type ITraceWatcherConfig,
8
+ type TraceClientRequestCaptureRule,
9
+ type TraceClientRequestWatcherConfig,
9
10
  } from '../types';
10
- import { EntryType } from '../types';
11
11
  import { AuthTag } from '../utils/authTag';
12
12
  import { redactHeaders, redactUnknown } from '../utils/redact';
13
13
  import { RequestFilter } from '../utils/requestFilter';
@@ -85,24 +85,56 @@ const buildResponseBody = (
85
85
  return { responseBody: redactUnknown(responseBody, _redactBodyFields) };
86
86
  };
87
87
 
88
+ const applySource = (content: ClientRequestContent, normalizedSource: string | undefined): void => {
89
+ if (normalizedSource !== undefined) {
90
+ content.source = normalizedSource;
91
+ }
92
+ };
93
+
94
+ const applyResponseStatus = (
95
+ content: ClientRequestContent,
96
+ responseStatus: number | undefined
97
+ ): void => {
98
+ if (responseStatus !== undefined) {
99
+ content.responseStatus = responseStatus;
100
+ }
101
+ };
102
+
103
+ const applyError = (content: ClientRequestContent, error: unknown): void => {
104
+ if (typeof error === 'string' && error !== '') {
105
+ content.error = error;
106
+ }
107
+ };
108
+
109
+ const mergePartialContent = (
110
+ content: ClientRequestContent,
111
+ partial: Partial<ClientRequestContent>
112
+ ): void => {
113
+ Object.assign(content, partial);
114
+ };
115
+
88
116
  const buildClientRequestContent = (
89
117
  input: ClientRequestTraceInput,
90
118
  sourceRule: TraceClientRequestCaptureRule | undefined,
91
119
  normalizedSource: string | undefined
92
120
  ): ClientRequestContent => {
93
- return {
94
- ...(normalizedSource === undefined ? {} : { source: normalizedSource }),
121
+ const content: ClientRequestContent = {
95
122
  method: input.method.toUpperCase(),
96
123
  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 } : {}),
124
+ requestHeaders: {},
103
125
  duration: input.duration,
104
126
  hostname: TraceContext.getHostname(),
105
127
  };
128
+
129
+ applySource(content, normalizedSource);
130
+ mergePartialContent(content, buildRequestHeaders(input.requestHeaders, sourceRule));
131
+ mergePartialContent(content, buildRequestBody(input.requestBody, sourceRule));
132
+ applyResponseStatus(content, input.responseStatus);
133
+ mergePartialContent(content, buildResponseHeaders(input.responseHeaders, sourceRule));
134
+ mergePartialContent(content, buildResponseBody(input.responseBody, sourceRule));
135
+ applyError(content, input.error);
136
+
137
+ return content;
106
138
  };
107
139
 
108
140
  const isWatcherEnabled = (
@@ -169,7 +201,7 @@ export const HttpClientWatcher: ITraceWatcher & { emit: typeof emit } = Object.f
169
201
  _storage = storage;
170
202
  _clientRequestWatcher =
171
203
  typeof config.watchers.clientRequest === 'object' && config.watchers.clientRequest !== null
172
- ? (config.watchers.clientRequest as TraceClientRequestWatcherConfig)
204
+ ? config.watchers.clientRequest
173
205
  : undefined;
174
206
  _redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
175
207
  _redactBodyFields = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];