@zintrust/trace 0.5.0 → 0.5.2
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.
- package/dist/build-manifest.json +16 -16
- package/dist/dashboard/handlers.js +26 -2
- package/dist/dashboard/ui.js +161 -23
- package/dist/storage/TraceStorage.js +157 -3
- package/dist/types.d.ts +17 -0
- package/dist/watchers/HttpClientWatcher.js +1 -2
- package/package.json +2 -2
- package/src/dashboard/handlers.ts +29 -2
- package/src/dashboard/ui.ts +161 -23
- package/src/storage/TraceStorage.ts +194 -4
- package/src/types.ts +22 -0
- package/src/watchers/HttpClientWatcher.ts +1 -2
package/dist/build-manifest.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zintrust/trace",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"buildDate": "2026-04-
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"buildDate": "2026-04-12T20:09:35.228Z",
|
|
5
5
|
"buildEnvironment": {
|
|
6
6
|
"node": "v22.22.1",
|
|
7
7
|
"platform": "darwin",
|
|
8
8
|
"arch": "arm64"
|
|
9
9
|
},
|
|
10
10
|
"git": {
|
|
11
|
-
"commit": "
|
|
11
|
+
"commit": "87ea4893",
|
|
12
12
|
"branch": "release"
|
|
13
13
|
},
|
|
14
14
|
"package": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": {
|
|
24
24
|
"build-manifest.json": {
|
|
25
|
-
"size":
|
|
26
|
-
"sha256": "
|
|
25
|
+
"size": 14738,
|
|
26
|
+
"sha256": "918a0394474d1cc1e3f961b7668f185289398fae336bf853d2e8cf6cb3fca28f"
|
|
27
27
|
},
|
|
28
28
|
"cli-register.d.ts": {
|
|
29
29
|
"size": 255,
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"sha256": "430f5b294d960e13e2ec39ed5c22f6655f85c46c2de9455c222505c67298ec6a"
|
|
55
55
|
},
|
|
56
56
|
"dashboard/handlers.js": {
|
|
57
|
-
"size":
|
|
58
|
-
"sha256": "
|
|
57
|
+
"size": 10011,
|
|
58
|
+
"sha256": "a5f7f3d624a844df27ddc9d4908e73d91a4aac9b2aac1aefff1f391913353897"
|
|
59
59
|
},
|
|
60
60
|
"dashboard/routes.d.ts": {
|
|
61
61
|
"size": 997,
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
"sha256": "4862b41e0477f01afa0dbb446d4553b65c22ed774cd1e2db3489059ced392f94"
|
|
71
71
|
},
|
|
72
72
|
"dashboard/ui.js": {
|
|
73
|
-
"size":
|
|
74
|
-
"sha256": "
|
|
73
|
+
"size": 80163,
|
|
74
|
+
"sha256": "bd02523880078843499d751e9ffbbe8e386759456c71c5101a46248a4598f1a6"
|
|
75
75
|
},
|
|
76
76
|
"index.d.ts": {
|
|
77
77
|
"size": 2537,
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
},
|
|
80
80
|
"index.js": {
|
|
81
81
|
"size": 3324,
|
|
82
|
-
"sha256": "
|
|
82
|
+
"sha256": "37c92f5d7e70dcf214608aed08bfb386b2b6b04bdfb04b8cb4309f4e97136ab7"
|
|
83
83
|
},
|
|
84
84
|
"migrations/20260331000001_create_zin_trace_entries_table.d.ts": {
|
|
85
85
|
"size": 304,
|
|
@@ -174,8 +174,8 @@
|
|
|
174
174
|
"sha256": "c9c215aaa414f7b0c1fec6c82b054fc52bdf97af58f96f35c7f96672fb859c31"
|
|
175
175
|
},
|
|
176
176
|
"storage/TraceStorage.js": {
|
|
177
|
-
"size":
|
|
178
|
-
"sha256": "
|
|
177
|
+
"size": 15322,
|
|
178
|
+
"sha256": "95179a66f0774fb6c1622e7477d6e1420e374116800a6a0c60e14de9bd4c4c5c"
|
|
179
179
|
},
|
|
180
180
|
"storage/TraceWriteDiagnostics.d.ts": {
|
|
181
181
|
"size": 581,
|
|
@@ -194,8 +194,8 @@
|
|
|
194
194
|
"sha256": "d916e8e3abb1b1087f6b184851b0e6265e53380d7857b008e745d566aad15d44"
|
|
195
195
|
},
|
|
196
196
|
"types.d.ts": {
|
|
197
|
-
"size":
|
|
198
|
-
"sha256": "
|
|
197
|
+
"size": 9433,
|
|
198
|
+
"sha256": "5b615fa1f89ebd84292e6322193643757bc91c5524068891050b28a9840bd34c"
|
|
199
199
|
},
|
|
200
200
|
"types.js": {
|
|
201
201
|
"size": 696,
|
|
@@ -326,8 +326,8 @@
|
|
|
326
326
|
"sha256": "dfb13bba526d5338e4dcd7a5aa0f72a61a03d9e2f6a250250c0bb8f0054c9712"
|
|
327
327
|
},
|
|
328
328
|
"watchers/HttpClientWatcher.js": {
|
|
329
|
-
"size":
|
|
330
|
-
"sha256": "
|
|
329
|
+
"size": 5787,
|
|
330
|
+
"sha256": "56ee5d97d1ff13be6f80c0a8517a969d46693ac6b4969a692c4a9261c0a17bc1"
|
|
331
331
|
},
|
|
332
332
|
"watchers/HttpWatcher.d.ts": {
|
|
333
333
|
"size": 96,
|
|
@@ -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
|
|
198
|
-
|
|
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) {
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -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
|
|
323
|
-
|
|
324
|
-
|
|
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:
|
|
692
|
-
{ id: 'middleware', label: 'Middleware', count:
|
|
693
|
-
{ id: 'models', label: 'Models', count:
|
|
694
|
-
{ id: 'logs', label: 'Logs', count:
|
|
695
|
-
{ id: 'exceptions', label: 'Exceptions', count:
|
|
696
|
-
{ id: 'http', label: 'HTTP', count:
|
|
697
|
-
{ id: 'cache', label: 'Cache', count:
|
|
698
|
-
{ id: 'other', label: 'Other', count:
|
|
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:
|
|
729
|
-
middleware:
|
|
730
|
-
models:
|
|
731
|
-
logs:
|
|
732
|
-
exceptions:
|
|
733
|
-
http:
|
|
734
|
-
cache:
|
|
735
|
-
other:
|
|
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
|
|
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') {
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.5.2",
|
|
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.
|
|
43
|
+
"@zintrust/core": "^0.5.1"
|
|
44
44
|
},
|
|
45
45
|
"publishConfig": {
|
|
46
46
|
"access": "public"
|
|
@@ -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
|
|
244
|
-
|
|
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 });
|
package/src/dashboard/ui.ts
CHANGED
|
@@ -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
|
|
329
|
-
|
|
330
|
-
|
|
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:
|
|
698
|
-
{ id: 'middleware', label: 'Middleware', count:
|
|
699
|
-
{ id: 'models', label: 'Models', count:
|
|
700
|
-
{ id: 'logs', label: 'Logs', count:
|
|
701
|
-
{ id: 'exceptions', label: 'Exceptions', count:
|
|
702
|
-
{ id: 'http', label: 'HTTP', count:
|
|
703
|
-
{ id: 'cache', label: 'Cache', count:
|
|
704
|
-
{ id: 'other', label: 'Other', count:
|
|
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:
|
|
735
|
-
middleware:
|
|
736
|
-
models:
|
|
737
|
-
logs:
|
|
738
|
-
exceptions:
|
|
739
|
-
http:
|
|
740
|
-
cache:
|
|
741
|
-
other:
|
|
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
|
|
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') {
|
|
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 {
|
|
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,10 @@ 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(
|
|
296
|
+
batchId: string,
|
|
297
|
+
opts?: QueryBatchEntriesOptions
|
|
298
|
+
): Promise<QueryBatchEntriesResult>;
|
|
277
299
|
prune(olderThanMs: number, keepExceptions?: boolean): Promise<number>;
|
|
278
300
|
clear(): Promise<void>;
|
|
279
301
|
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:
|
|
186
|
+
uuid: crypto.randomUUID(),
|
|
188
187
|
batchId: TraceContext.getBatchId(),
|
|
189
188
|
type: EntryType.CLIENT_REQUEST,
|
|
190
189
|
content,
|