@zintrust/trace 0.5.0 → 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.
@@ -42,8 +42,20 @@ const DEFAULT_PER_PAGE = 50;
42
42
  const MAX_PER_PAGE = 100;
43
43
  const DEFAULT_REQUEST_PER_PAGE = 25;
44
44
  const MAX_REQUEST_PER_PAGE = 50;
45
+ const DEFAULT_BATCH_PER_PAGE = 10;
46
+ const MAX_BATCH_PER_PAGE = 25;
45
47
  const SUMMARY_TEXT_LIMIT = 280;
46
48
  const SUMMARY_ARRAY_LIMIT = 10;
49
+ const REQUEST_BATCH_DEFAULT_EXCLUDED_TYPES = [
50
+ 'request',
51
+ 'query',
52
+ 'middleware',
53
+ 'model',
54
+ 'log',
55
+ 'exception',
56
+ 'client_request',
57
+ 'cache',
58
+ ];
47
59
  const truncateText = (value, limit = SUMMARY_TEXT_LIMIT) => value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
48
60
  const compactValue = (value) => {
49
61
  if (typeof value === 'string') {
@@ -149,6 +161,7 @@ export async function listEntries(req, res) {
149
161
  to: getNumericQueryParam(req, 'to'),
150
162
  page: Math.max(1, qpInt(req, 'page', 1)),
151
163
  perPage: resolvePerPage(req, type),
164
+ summary: true,
152
165
  };
153
166
  try {
154
167
  const result = await storage.queryEntries(opts);
@@ -194,8 +207,19 @@ export async function getBatch(req, res) {
194
207
  const batchId = req.getParam('batchId');
195
208
  if (batchId) {
196
209
  try {
197
- const entries = await storage.getBatch(batchId);
198
- res.json({ ok: true, entries });
210
+ const scope = qp(req, 'scope');
211
+ const type = qp(req, 'type');
212
+ const countsOnly = qp(req, 'countsOnly') === 'true';
213
+ const page = Math.max(1, qpInt(req, 'page', 1));
214
+ const perPage = Math.max(1, Math.min(qpInt(req, 'perPage', DEFAULT_BATCH_PER_PAGE), MAX_BATCH_PER_PAGE));
215
+ const result = await storage.queryBatchEntries(batchId, {
216
+ type,
217
+ excludeTypes: scope === 'other' ? REQUEST_BATCH_DEFAULT_EXCLUDED_TYPES : undefined,
218
+ page,
219
+ perPage,
220
+ countsOnly,
221
+ });
222
+ res.json({ ok: true, ...result });
199
223
  return;
200
224
  }
201
225
  catch (err) {
@@ -163,6 +163,28 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
163
163
 
164
164
  let state = createInitialState();
165
165
 
166
+ const DETAIL_BATCH_PAGE_SIZE = 10;
167
+ const DETAIL_BATCH_SCOPE_BY_TAB = Object.freeze({
168
+ queries: { type: 'query' },
169
+ middleware: { type: 'middleware' },
170
+ models: { type: 'model' },
171
+ logs: { type: 'log' },
172
+ exceptions: { type: 'exception' },
173
+ http: { type: 'client_request' },
174
+ cache: { type: 'cache' },
175
+ other: { scope: 'other' }
176
+ });
177
+ const DETAIL_BATCH_COUNT_TYPES = Object.freeze({
178
+ queries: 'query',
179
+ middleware: 'middleware',
180
+ models: 'model',
181
+ logs: 'log',
182
+ exceptions: 'exception',
183
+ http: 'client_request',
184
+ cache: 'cache'
185
+ });
186
+ const DETAIL_BATCH_OTHER_EXCLUDED_TYPES = ['request','query','middleware','model','log','exception','client_request','cache'];
187
+
166
188
  let copySequence = 0;
167
189
  const copyPayloads = new Map();
168
190
 
@@ -319,9 +341,40 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
319
341
  return raw === '' ? '-' : escapeHtml(raw.slice(0, 8));
320
342
  };
321
343
 
322
- const batchEntries = () => Array.isArray(state.detailBatch) ? state.detailBatch : [];
323
- const batchEntriesByType = (type) => batchEntries().filter((entry) => entry.type === type);
324
- const hasRequestTrace = () => Boolean(state.detail && state.detail.type === 'request' && batchEntries().length > 0);
344
+ const createDetailBatchState = (payload, scope = '', loading = false) => ({
345
+ counts: payload && typeof payload.counts === 'object' && payload.counts !== null ? payload.counts : {},
346
+ entries: payload && Array.isArray(payload.entries) ? payload.entries : [],
347
+ total: payload && Number.isFinite(Number(payload.total)) ? Number(payload.total) : 0,
348
+ page: payload && Number.isFinite(Number(payload.page)) && Number(payload.page) > 0 ? Number(payload.page) : 1,
349
+ perPage: payload && Number.isFinite(Number(payload.perPage)) && Number(payload.perPage) > 0 ? Number(payload.perPage) : DETAIL_BATCH_PAGE_SIZE,
350
+ scope,
351
+ loading
352
+ });
353
+
354
+ const detailBatchState = () => {
355
+ const detailBatch = state.detailBatch;
356
+ if (!detailBatch || typeof detailBatch !== 'object' || Array.isArray(detailBatch)) {
357
+ return createDetailBatchState(null);
358
+ }
359
+ return createDetailBatchState(detailBatch, typeof detailBatch.scope === 'string' ? detailBatch.scope : '', detailBatch.loading === true);
360
+ };
361
+
362
+ const batchEntries = () => detailBatchState().entries;
363
+ const batchCounts = () => detailBatchState().counts;
364
+ const batchCount = (type) => {
365
+ const raw = batchCounts()[type];
366
+ return Number.isFinite(Number(raw)) ? Number(raw) : 0;
367
+ };
368
+ const otherBatchCount = () => Object.entries(batchCounts()).reduce((sum, pair) => {
369
+ return DETAIL_BATCH_OTHER_EXCLUDED_TYPES.includes(pair[0]) ? sum : sum + Number(pair[1] || 0);
370
+ }, 0);
371
+ const resolveDetailBatchQuery = (tab) => DETAIL_BATCH_SCOPE_BY_TAB[tab] || null;
372
+ const resolveDetailBatchCount = (tab) => {
373
+ if (tab === 'other') return otherBatchCount();
374
+ const type = DETAIL_BATCH_COUNT_TYPES[tab];
375
+ return typeof type === 'string' ? batchCount(type) : 0;
376
+ };
377
+ const hasRequestTrace = () => Boolean(state.detail && state.detail.type === 'request');
325
378
 
326
379
  const prettyJson = (value) => {
327
380
  try {
@@ -680,6 +733,27 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
680
733
  }).join('') + '</div>';
681
734
  };
682
735
 
736
+ const renderDetailBatchPanel = (tab) => {
737
+ const detailBatch = detailBatchState();
738
+ const count = resolveDetailBatchCount(tab);
739
+ if (detailBatch.loading && detailBatch.scope === tab) {
740
+ return '<p class="trace-note">Loading related entries...</p>';
741
+ }
742
+ if (count === 0) {
743
+ return '<p class="trace-note">No related entries captured.</p>';
744
+ }
745
+ if (detailBatch.scope !== tab) {
746
+ return '<p class="trace-note">Open this tab to load the first page of related entries.</p>';
747
+ }
748
+
749
+ const totalPages = Math.max(1, Math.ceil(detailBatch.total / Math.max(1, detailBatch.perPage)));
750
+
751
+ return [
752
+ renderTraceItems(batchEntries()),
753
+ '<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>'
754
+ ].join('');
755
+ };
756
+
683
757
  const renderRequestTrace = (main) => {
684
758
  const entry = state.detail;
685
759
  const content = entry && entry.content ? entry.content : {};
@@ -688,17 +762,16 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
688
762
  { id: 'payload', label: 'Payload' },
689
763
  { id: 'headers', label: 'Headers' },
690
764
  { id: 'response', label: 'Response' },
691
- { id: 'queries', label: 'Queries', count: batchEntriesByType('query').length },
692
- { id: 'middleware', label: 'Middleware', count: batchEntriesByType('middleware').length },
693
- { id: 'models', label: 'Models', count: batchEntriesByType('model').length },
694
- { id: 'logs', label: 'Logs', count: batchEntriesByType('log').length },
695
- { id: 'exceptions', label: 'Exceptions', count: batchEntriesByType('exception').length },
696
- { id: 'http', label: 'HTTP', count: batchEntriesByType('client_request').length },
697
- { id: 'cache', label: 'Cache', count: batchEntriesByType('cache').length },
698
- { id: 'other', label: 'Other', count: batchEntries().filter((item) => !['request','query','middleware','model','log','exception','client_request','cache'].includes(item.type)).length }
765
+ { id: 'queries', label: 'Queries', count: resolveDetailBatchCount('queries') },
766
+ { id: 'middleware', label: 'Middleware', count: resolveDetailBatchCount('middleware') },
767
+ { id: 'models', label: 'Models', count: resolveDetailBatchCount('models') },
768
+ { id: 'logs', label: 'Logs', count: resolveDetailBatchCount('logs') },
769
+ { id: 'exceptions', label: 'Exceptions', count: resolveDetailBatchCount('exceptions') },
770
+ { id: 'http', label: 'HTTP', count: resolveDetailBatchCount('http') },
771
+ { id: 'cache', label: 'Cache', count: resolveDetailBatchCount('cache') },
772
+ { id: 'other', label: 'Other', count: resolveDetailBatchCount('other') }
699
773
  ];
700
774
  const currentTab = traceTabs.some((tab) => tab.id === state.detailTab) ? state.detailTab : 'summary';
701
- const otherEntries = batchEntries().filter((item) => !['request','query','middleware','model','log','exception','client_request','cache'].includes(item.type));
702
775
  const panels = {
703
776
  summary: [
704
777
  '<div class="detail-grid">',
@@ -725,14 +798,14 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
725
798
  payload: detailJson(content.payload || {}, 'Payload Json'),
726
799
  headers: '<div class="detail-stack">' + detailJson(content.headers || {}, 'Request Header Json') + detailJson(content.responseHeaders || {}, 'Response Header Json') + '</div>',
727
800
  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>',
728
- queries: renderTraceItems(batchEntriesByType('query')),
729
- middleware: renderTraceItems(batchEntriesByType('middleware')),
730
- models: renderTraceItems(batchEntriesByType('model')),
731
- logs: renderTraceItems(batchEntriesByType('log')),
732
- exceptions: renderTraceItems(batchEntriesByType('exception')),
733
- http: renderTraceItems(batchEntriesByType('client_request')),
734
- cache: renderTraceItems(batchEntriesByType('cache')),
735
- other: renderTraceItems(otherEntries)
801
+ queries: renderDetailBatchPanel('queries'),
802
+ middleware: renderDetailBatchPanel('middleware'),
803
+ models: renderDetailBatchPanel('models'),
804
+ logs: renderDetailBatchPanel('logs'),
805
+ exceptions: renderDetailBatchPanel('exceptions'),
806
+ http: renderDetailBatchPanel('http'),
807
+ cache: renderDetailBatchPanel('cache'),
808
+ other: renderDetailBatchPanel('other')
736
809
  };
737
810
 
738
811
  main.innerHTML = [
@@ -952,8 +1025,8 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
952
1025
  const entry = detailResult.entry;
953
1026
  let detailBatch = null;
954
1027
  if (entry.type === 'request' && entry.batchId) {
955
- const batch = await api('/batch/' + encodeURIComponent(entry.batchId));
956
- detailBatch = batch.entries || [];
1028
+ const batch = await api('/batch/' + encodeURIComponent(entry.batchId) + '?countsOnly=true');
1029
+ detailBatch = createDetailBatchState(batch);
957
1030
  }
958
1031
  state = { ...state, detail: entry, detailBatch, detailTab: 'summary', page: 'entries' };
959
1032
  render();
@@ -962,6 +1035,54 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
962
1035
  }
963
1036
  };
964
1037
 
1038
+ const loadDetailBatchTab = async (tab, page = 1) => {
1039
+ const detail = state.detail;
1040
+ if (!detail || detail.type !== 'request' || !detail.batchId) return;
1041
+
1042
+ const query = resolveDetailBatchQuery(tab);
1043
+ if (!query) {
1044
+ state = { ...state, detailTab: tab };
1045
+ render();
1046
+ return;
1047
+ }
1048
+
1049
+ const previous = detailBatchState();
1050
+ state = {
1051
+ ...state,
1052
+ detailTab: tab,
1053
+ detailBatch: {
1054
+ ...previous,
1055
+ scope: tab,
1056
+ page,
1057
+ perPage: DETAIL_BATCH_PAGE_SIZE,
1058
+ loading: true,
1059
+ },
1060
+ };
1061
+ render();
1062
+
1063
+ try {
1064
+ const qs = new URLSearchParams({ page: String(page), perPage: String(DETAIL_BATCH_PAGE_SIZE) });
1065
+ if (query.type) qs.set('type', query.type);
1066
+ if (query.scope) qs.set('scope', query.scope);
1067
+ const batch = await api('/batch/' + encodeURIComponent(detail.batchId) + '?' + qs.toString());
1068
+ state = {
1069
+ ...state,
1070
+ detailTab: tab,
1071
+ detailBatch: createDetailBatchState(batch, tab, false),
1072
+ page: 'entries'
1073
+ };
1074
+ render();
1075
+ } catch (error) {
1076
+ state = {
1077
+ ...state,
1078
+ detailTab: tab,
1079
+ detailBatch: { ...previous, loading: false }
1080
+ };
1081
+ render();
1082
+ window.alert(error.message);
1083
+ }
1084
+ };
1085
+
965
1086
  const addTag = async () => {
966
1087
  const input = document.getElementById('new-tag');
967
1088
  const value = input && 'value' in input ? String(input.value || '').trim() : '';
@@ -1039,10 +1160,27 @@ const DASHBOARD_DOCUMENT = String.raw `<!DOCTYPE html>
1039
1160
  filterByTag(String(target.getAttribute('data-tag') || ''));
1040
1161
  return;
1041
1162
  }
1042
- if (action === 'detail-tab') { state = { ...state, detailTab: String(target.getAttribute('data-tab') || 'summary') }; render(); return; }
1163
+ if (action === 'detail-tab') {
1164
+ const tab = String(target.getAttribute('data-tab') || 'summary');
1165
+ if (Object.prototype.hasOwnProperty.call(DETAIL_BATCH_SCOPE_BY_TAB, tab)) {
1166
+ loadDetailBatchTab(tab, 1);
1167
+ return;
1168
+ }
1169
+ state = { ...state, detailTab: tab };
1170
+ render();
1171
+ return;
1172
+ }
1043
1173
  if (action === 'clear-all') { clearAll(); return; }
1044
1174
  if (action === 'show-detail') { showDetail(String(target.getAttribute('data-uuid') || '')); return; }
1045
1175
  if (action === 'close-detail') { state = { ...state, detail: null, detailBatch: null, detailTab: 'summary' }; render(); return; }
1176
+ if (action === 'detail-batch-prev' || action === 'detail-batch-next') {
1177
+ const detailBatch = detailBatchState();
1178
+ const nextPage = action === 'detail-batch-prev' ? Math.max(1, detailBatch.page - 1) : detailBatch.page + 1;
1179
+ if (detailBatch.scope !== '') {
1180
+ loadDetailBatchTab(detailBatch.scope, nextPage);
1181
+ }
1182
+ return;
1183
+ }
1046
1184
  if (action === 'page-prev') { state = { ...state, entriesPage: Math.max(1, state.entriesPage - 1) }; render(); return; }
1047
1185
  if (action === 'page-next') { state = { ...state, entriesPage: state.entriesPage + 1 }; render(); return; }
1048
1186
  if (action === 'clear-filters') { state = { ...state, detail: null, detailBatch: null, detailTab: 'summary', entriesPage: 1, entriesFilter: { type: '', tag: '', batchId: '' } }; render(); return; }
@@ -3,6 +3,107 @@ const TABLE_ENTRIES = 'zin_trace_entries';
3
3
  const TABLE_TAGS = 'zin_trace_entries_tags';
4
4
  const TABLE_MONITORING = 'zin_trace_monitoring';
5
5
  const generateUuid = () => crypto.randomUUID();
6
+ const decodeJsonStringLiteral = (value) => {
7
+ try {
8
+ return JSON.parse(`"${value}"`);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ };
14
+ const matchJsonStringField = (content, key) => {
15
+ const match = new RegExp(String.raw `"${key}"\s*:\s*"((?:\\.|[^"\\])*)"`, 's').exec(content);
16
+ return match ? decodeJsonStringLiteral(match[1]) : undefined;
17
+ };
18
+ const matchJsonNumberField = (content, key) => {
19
+ const match = new RegExp(String.raw `"${key}"\s*:\s*(-?\d+(?:\.\d+)?)`, 's').exec(content);
20
+ if (!match)
21
+ return undefined;
22
+ const parsed = Number(match[1]);
23
+ return Number.isFinite(parsed) ? parsed : undefined;
24
+ };
25
+ const matchJsonNullOrNumberField = (content, key) => {
26
+ const nullMatch = new RegExp(String.raw `"${key}"\s*:\s*null`, 's').exec(content);
27
+ if (nullMatch)
28
+ return null;
29
+ return matchJsonNumberField(content, key);
30
+ };
31
+ const matchJsonStringArrayField = (content, key) => {
32
+ const match = new RegExp(String.raw `"${key}"\s*:\s*(\[.*?\])`, 's').exec(content);
33
+ if (!match)
34
+ return undefined;
35
+ try {
36
+ const parsed = JSON.parse(match[1]);
37
+ return Array.isArray(parsed)
38
+ ? parsed.filter((value) => typeof value === 'string')
39
+ : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ };
45
+ const compactRequestContent = (content) => {
46
+ const compact = {};
47
+ const method = matchJsonStringField(content, 'method');
48
+ const uri = matchJsonStringField(content, 'uri');
49
+ const responseStatus = matchJsonNumberField(content, 'responseStatus');
50
+ const duration = matchJsonNumberField(content, 'duration');
51
+ const memory = matchJsonNullOrNumberField(content, 'memory');
52
+ const middleware = matchJsonStringArrayField(content, 'middleware');
53
+ const hostname = matchJsonStringField(content, 'hostname');
54
+ const userId = matchJsonStringField(content, 'userId');
55
+ if (method !== undefined)
56
+ compact['method'] = method;
57
+ if (uri !== undefined)
58
+ compact['uri'] = uri;
59
+ if (responseStatus !== undefined)
60
+ compact['responseStatus'] = responseStatus;
61
+ if (duration !== undefined)
62
+ compact['duration'] = duration;
63
+ if (memory !== undefined)
64
+ compact['memory'] = memory;
65
+ if (middleware !== undefined)
66
+ compact['middleware'] = middleware;
67
+ if (hostname !== undefined)
68
+ compact['hostname'] = hostname;
69
+ if (userId !== undefined)
70
+ compact['userId'] = userId;
71
+ return Object.keys(compact).length > 0 ? compact : undefined;
72
+ };
73
+ const compactClientRequestContent = (content) => {
74
+ const compact = {};
75
+ const source = matchJsonStringField(content, 'source');
76
+ const method = matchJsonStringField(content, 'method');
77
+ const url = matchJsonStringField(content, 'url');
78
+ const responseStatus = matchJsonNumberField(content, 'responseStatus');
79
+ const error = matchJsonStringField(content, 'error');
80
+ const duration = matchJsonNumberField(content, 'duration');
81
+ const hostname = matchJsonStringField(content, 'hostname');
82
+ if (source !== undefined)
83
+ compact['source'] = source;
84
+ if (method !== undefined)
85
+ compact['method'] = method;
86
+ if (url !== undefined)
87
+ compact['url'] = url;
88
+ if (responseStatus !== undefined)
89
+ compact['responseStatus'] = responseStatus;
90
+ if (error !== undefined)
91
+ compact['error'] = error;
92
+ if (duration !== undefined)
93
+ compact['duration'] = duration;
94
+ if (hostname !== undefined)
95
+ compact['hostname'] = hostname;
96
+ return Object.keys(compact).length > 0 ? compact : undefined;
97
+ };
98
+ const summarizeEntryContent = (row) => {
99
+ if (row.type === 'request') {
100
+ return compactRequestContent(row.content) ?? JSON.parse(row.content);
101
+ }
102
+ if (row.type === 'client_request') {
103
+ return compactClientRequestContent(row.content) ?? JSON.parse(row.content);
104
+ }
105
+ return JSON.parse(row.content);
106
+ };
6
107
  const buildIgnoreInsert = (db, table, columns, conflictColumns) => {
7
108
  const columnList = columns.join(', ');
8
109
  const placeholders = columns.map(() => '?').join(', ');
@@ -35,12 +136,12 @@ const buildIgnoreInsert = (db, table, columns, conflictColumns) => {
35
136
  }
36
137
  return `INSERT INTO ${table} (${columnList}) VALUES (${placeholders})`;
37
138
  };
38
- const rowToEntry = (row, tags) => ({
139
+ const rowToEntry = (row, tags, summary = false) => ({
39
140
  uuid: row.uuid,
40
141
  batchId: row.batch_id,
41
142
  familyHash: row.family_hash ?? undefined,
42
143
  type: row.type,
43
- content: JSON.parse(row.content),
144
+ content: summary ? summarizeEntryContent(row) : JSON.parse(row.content),
44
145
  tags,
45
146
  isLatest: Boolean(row.is_latest),
46
147
  createdAt: row.created_at,
@@ -81,6 +182,29 @@ const buildEntryFilters = (opts) => {
81
182
  const countParams = opts.tag ? [opts.tag, ...params.slice(1)] : [...params];
82
183
  return { joinClause, whereClause, params, countParams };
83
184
  };
185
+ const buildBatchCounts = async (db, batchId) => {
186
+ const rows = (await db.query(`SELECT type, COUNT(*) as cnt FROM ${TABLE_ENTRIES} WHERE batch_id = ? GROUP BY type`, [batchId]));
187
+ const counts = {};
188
+ for (const row of rows) {
189
+ counts[row.type] = row.cnt;
190
+ }
191
+ return counts;
192
+ };
193
+ const buildBatchEntryFilters = (batchId, opts) => {
194
+ const conditions = ['batch_id = ?'];
195
+ const params = [batchId];
196
+ if (opts.type) {
197
+ conditions.push('type = ?');
198
+ params.push(opts.type);
199
+ }
200
+ const excludeTypes = opts.excludeTypes ?? [];
201
+ if (excludeTypes.length > 0) {
202
+ const placeholders = excludeTypes.map(() => '?').join(', ');
203
+ conditions.push(`type NOT IN (${placeholders})`);
204
+ params.push(...excludeTypes);
205
+ }
206
+ return { whereClause: `WHERE ${conditions.join(' AND ')}`, params };
207
+ };
84
208
  const loadTagsByUuid = async (db, uuids) => {
85
209
  const tagsByUuid = new Map();
86
210
  if (uuids.length === 0)
@@ -143,7 +267,7 @@ const createStorage = (db) => {
143
267
  LIMIT ? OFFSET ?`, [...params, perPage, offset]));
144
268
  const tagsByUuid = await loadTagsByUuid(db, rows.map((row) => row.uuid));
145
269
  return {
146
- data: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [])),
270
+ data: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [], opts.summary)),
147
271
  total,
148
272
  };
149
273
  };
@@ -168,6 +292,35 @@ const createStorage = (db) => {
168
292
  const tagsByUuid = await loadTagsByUuid(db, rows.map((row) => row.uuid));
169
293
  return rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? []));
170
294
  };
295
+ const queryBatchEntries = async (batchId, opts = {}) => {
296
+ const page = opts.page ?? 1;
297
+ const perPage = opts.perPage ?? 10;
298
+ const offset = (page - 1) * perPage;
299
+ const counts = await buildBatchCounts(db, batchId);
300
+ if (opts.countsOnly) {
301
+ const total = Object.values(counts).reduce((sum, value) => sum + Number(value ?? 0), 0);
302
+ return { entries: [], total, counts, page, perPage };
303
+ }
304
+ const { whereClause, params } = buildBatchEntryFilters(batchId, opts);
305
+ const countResult = (await db.queryOne(`SELECT COUNT(*) as cnt FROM ${TABLE_ENTRIES} ${whereClause}`, params));
306
+ const total = countResult?.cnt ?? 0;
307
+ if (total === 0) {
308
+ return { entries: [], total: 0, counts, page, perPage };
309
+ }
310
+ const rows = (await db.query(`SELECT id, uuid, batch_id, family_hash, type, content, is_latest, created_at
311
+ FROM ${TABLE_ENTRIES}
312
+ ${whereClause}
313
+ ORDER BY created_at ASC, id ASC
314
+ LIMIT ? OFFSET ?`, [...params, perPage, offset]));
315
+ const tagsByUuid = await loadTagsByUuid(db, rows.map((row) => row.uuid));
316
+ return {
317
+ entries: rows.map((row) => rowToEntry(row, tagsByUuid.get(row.uuid) ?? [], opts.summary)),
318
+ total,
319
+ counts,
320
+ page,
321
+ perPage,
322
+ };
323
+ };
171
324
  const prune = async (olderThanMs, keepExceptions = false) => {
172
325
  const countResult = (await db.queryOne(`SELECT COUNT(*) as cnt FROM ${TABLE_ENTRIES}
173
326
  WHERE created_at < ?
@@ -208,6 +361,7 @@ const createStorage = (db) => {
208
361
  queryEntries,
209
362
  getEntry,
210
363
  getBatch,
364
+ queryBatchEntries,
211
365
  prune,
212
366
  clear,
213
367
  getMonitoring,
package/dist/types.d.ts CHANGED
@@ -231,6 +231,22 @@ export interface QueryEntriesOptions {
231
231
  to?: number;
232
232
  page?: number;
233
233
  perPage?: number;
234
+ summary?: boolean;
235
+ }
236
+ export interface QueryBatchEntriesOptions {
237
+ type?: EntryTypeValue;
238
+ excludeTypes?: EntryTypeValue[];
239
+ page?: number;
240
+ perPage?: number;
241
+ summary?: boolean;
242
+ countsOnly?: boolean;
243
+ }
244
+ export interface QueryBatchEntriesResult {
245
+ entries: ITraceEntry[];
246
+ total: number;
247
+ counts: Partial<Record<EntryTypeValue, number>>;
248
+ page: number;
249
+ perPage: number;
234
250
  }
235
251
  export interface ITraceStorage {
236
252
  writeEntry(entry: ITraceEntry): Promise<void>;
@@ -242,6 +258,7 @@ export interface ITraceStorage {
242
258
  }>;
243
259
  getEntry(uuid: string): Promise<ITraceEntry | null>;
244
260
  getBatch(batchId: string): Promise<ITraceEntry[]>;
261
+ queryBatchEntries(batchId: string, opts?: QueryBatchEntriesOptions): Promise<QueryBatchEntriesResult>;
245
262
  prune(olderThanMs: number, keepExceptions?: boolean): Promise<number>;
246
263
  clear(): Promise<void>;
247
264
  getMonitoring(): Promise<string[]>;
@@ -1,4 +1,3 @@
1
- import { generateUuid } from '@zintrust/core';
2
1
  import { TraceContext } from '../context.js';
3
2
  import { EntryType, } from '../types.js';
4
3
  import { AuthTag } from '../utils/authTag.js';
@@ -128,7 +127,7 @@ const emit = ({ source, method, url, requestHeaders, responseStatus, duration, r
128
127
  }, sourceRule, normalizedSource);
129
128
  _storage
130
129
  .writeEntry({
131
- uuid: generateUuid(),
130
+ uuid: crypto.randomUUID(),
132
131
  batchId: TraceContext.getBatchId(),
133
132
  type: EntryType.CLIENT_REQUEST,
134
133
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/trace",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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.101"
43
+ "@zintrust/core": "^0.5.0"
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
+ }
@@ -61,8 +61,20 @@ const DEFAULT_PER_PAGE = 50;
61
61
  const MAX_PER_PAGE = 100;
62
62
  const DEFAULT_REQUEST_PER_PAGE = 25;
63
63
  const MAX_REQUEST_PER_PAGE = 50;
64
+ const DEFAULT_BATCH_PER_PAGE = 10;
65
+ const MAX_BATCH_PER_PAGE = 25;
64
66
  const SUMMARY_TEXT_LIMIT = 280;
65
67
  const SUMMARY_ARRAY_LIMIT = 10;
68
+ const REQUEST_BATCH_DEFAULT_EXCLUDED_TYPES: EntryTypeValue[] = [
69
+ 'request',
70
+ 'query',
71
+ 'middleware',
72
+ 'model',
73
+ 'log',
74
+ 'exception',
75
+ 'client_request',
76
+ 'cache',
77
+ ];
66
78
 
67
79
  type CompactTraceEntry = ITraceEntry<Record<string, unknown>> & {
68
80
  hasDetails: true;
@@ -195,6 +207,7 @@ export async function listEntries(req: IRequest, res: IResponse): Promise<void>
195
207
  to: getNumericQueryParam(req, 'to'),
196
208
  page: Math.max(1, qpInt(req, 'page', 1)),
197
209
  perPage: resolvePerPage(req, type),
210
+ summary: true,
198
211
  };
199
212
  try {
200
213
  const result = await storage.queryEntries(opts);
@@ -240,8 +253,22 @@ export async function getBatch(req: IRequest, res: IResponse): Promise<void> {
240
253
  const batchId = req.getParam('batchId');
241
254
  if (batchId) {
242
255
  try {
243
- const entries = await storage.getBatch(batchId);
244
- res.json({ ok: true, entries });
256
+ const scope = qp(req, 'scope');
257
+ const type = qp(req, 'type') as EntryTypeValue | undefined;
258
+ const countsOnly = qp(req, 'countsOnly') === 'true';
259
+ const page = Math.max(1, qpInt(req, 'page', 1));
260
+ const perPage = Math.max(
261
+ 1,
262
+ Math.min(qpInt(req, 'perPage', DEFAULT_BATCH_PER_PAGE), MAX_BATCH_PER_PAGE)
263
+ );
264
+ const result = await storage.queryBatchEntries(batchId, {
265
+ type,
266
+ excludeTypes: scope === 'other' ? REQUEST_BATCH_DEFAULT_EXCLUDED_TYPES : undefined,
267
+ page,
268
+ perPage,
269
+ countsOnly,
270
+ });
271
+ res.json({ ok: true, ...result });
245
272
  return;
246
273
  } catch (err) {
247
274
  res.setStatus(500).json({ error: (err as Error).message });
@@ -169,6 +169,28 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
169
169
 
170
170
  let state = createInitialState();
171
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
+
172
194
  let copySequence = 0;
173
195
  const copyPayloads = new Map();
174
196
 
@@ -325,9 +347,40 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
325
347
  return raw === '' ? '-' : escapeHtml(raw.slice(0, 8));
326
348
  };
327
349
 
328
- const batchEntries = () => Array.isArray(state.detailBatch) ? state.detailBatch : [];
329
- const batchEntriesByType = (type) => batchEntries().filter((entry) => entry.type === type);
330
- 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');
331
384
 
332
385
  const prettyJson = (value) => {
333
386
  try {
@@ -686,6 +739,27 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
686
739
  }).join('') + '</div>';
687
740
  };
688
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
+
689
763
  const renderRequestTrace = (main) => {
690
764
  const entry = state.detail;
691
765
  const content = entry && entry.content ? entry.content : {};
@@ -694,17 +768,16 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
694
768
  { id: 'payload', label: 'Payload' },
695
769
  { id: 'headers', label: 'Headers' },
696
770
  { id: 'response', label: 'Response' },
697
- { id: 'queries', label: 'Queries', count: batchEntriesByType('query').length },
698
- { id: 'middleware', label: 'Middleware', count: batchEntriesByType('middleware').length },
699
- { id: 'models', label: 'Models', count: batchEntriesByType('model').length },
700
- { id: 'logs', label: 'Logs', count: batchEntriesByType('log').length },
701
- { id: 'exceptions', label: 'Exceptions', count: batchEntriesByType('exception').length },
702
- { id: 'http', label: 'HTTP', count: batchEntriesByType('client_request').length },
703
- { id: 'cache', label: 'Cache', count: batchEntriesByType('cache').length },
704
- { 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') }
705
779
  ];
706
780
  const currentTab = traceTabs.some((tab) => tab.id === state.detailTab) ? state.detailTab : 'summary';
707
- const otherEntries = batchEntries().filter((item) => !['request','query','middleware','model','log','exception','client_request','cache'].includes(item.type));
708
781
  const panels = {
709
782
  summary: [
710
783
  '<div class="detail-grid">',
@@ -731,14 +804,14 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
731
804
  payload: detailJson(content.payload || {}, 'Payload Json'),
732
805
  headers: '<div class="detail-stack">' + detailJson(content.headers || {}, 'Request Header Json') + detailJson(content.responseHeaders || {}, 'Response Header Json') + '</div>',
733
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>',
734
- queries: renderTraceItems(batchEntriesByType('query')),
735
- middleware: renderTraceItems(batchEntriesByType('middleware')),
736
- models: renderTraceItems(batchEntriesByType('model')),
737
- logs: renderTraceItems(batchEntriesByType('log')),
738
- exceptions: renderTraceItems(batchEntriesByType('exception')),
739
- http: renderTraceItems(batchEntriesByType('client_request')),
740
- cache: renderTraceItems(batchEntriesByType('cache')),
741
- 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')
742
815
  };
743
816
 
744
817
  main.innerHTML = [
@@ -958,8 +1031,8 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
958
1031
  const entry = detailResult.entry;
959
1032
  let detailBatch = null;
960
1033
  if (entry.type === 'request' && entry.batchId) {
961
- const batch = await api('/batch/' + encodeURIComponent(entry.batchId));
962
- detailBatch = batch.entries || [];
1034
+ const batch = await api('/batch/' + encodeURIComponent(entry.batchId) + '?countsOnly=true');
1035
+ detailBatch = createDetailBatchState(batch);
963
1036
  }
964
1037
  state = { ...state, detail: entry, detailBatch, detailTab: 'summary', page: 'entries' };
965
1038
  render();
@@ -968,6 +1041,54 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
968
1041
  }
969
1042
  };
970
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
+
971
1092
  const addTag = async () => {
972
1093
  const input = document.getElementById('new-tag');
973
1094
  const value = input && 'value' in input ? String(input.value || '').trim() : '';
@@ -1045,10 +1166,27 @@ const DASHBOARD_DOCUMENT = String.raw`<!DOCTYPE html>
1045
1166
  filterByTag(String(target.getAttribute('data-tag') || ''));
1046
1167
  return;
1047
1168
  }
1048
- 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
+ }
1049
1179
  if (action === 'clear-all') { clearAll(); return; }
1050
1180
  if (action === 'show-detail') { showDetail(String(target.getAttribute('data-uuid') || '')); return; }
1051
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
+ }
1052
1190
  if (action === 'page-prev') { state = { ...state, entriesPage: Math.max(1, state.entriesPage - 1) }; render(); return; }
1053
1191
  if (action === 'page-next') { state = { ...state, entriesPage: state.entriesPage + 1 }; render(); return; }
1054
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,4 +1,3 @@
1
- import { generateUuid } from '@zintrust/core';
2
1
  import { TraceContext } from '../context';
3
2
  import {
4
3
  EntryType,
@@ -184,7 +183,7 @@ const emit = ({
184
183
  );
185
184
  _storage
186
185
  .writeEntry({
187
- uuid: generateUuid(),
186
+ uuid: crypto.randomUUID(),
188
187
  batchId: TraceContext.getBatchId(),
189
188
  type: EntryType.CLIENT_REQUEST,
190
189
  content,