@zintrust/trace 0.4.96 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-manifest.json +16 -16
- package/dist/config.js +1 -1
- package/dist/dashboard/handlers.js +133 -6
- package/dist/dashboard/ui.js +161 -26
- package/dist/storage/TraceStorage.js +157 -3
- package/dist/types.d.ts +17 -0
- package/dist/watchers/HttpClientWatcher.d.ts +1 -1
- package/dist/watchers/HttpClientWatcher.js +29 -9
- package/package.json +3 -3
- package/src/config.ts +1 -1
- package/src/dashboard/handlers.ts +163 -7
- package/src/dashboard/ui.ts +161 -26
- package/src/storage/TraceStorage.ts +194 -4
- package/src/types.ts +19 -0
- package/src/watchers/HttpClientWatcher.ts +49 -17
|
@@ -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,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ClientRequestTraceInput, type ITraceWatcher } from '../types';
|
|
2
2
|
declare const emit: ({ source, method, url, requestHeaders, responseStatus, duration, requestBody, responseHeaders, responseBody, error, }: ClientRequestTraceInput) => void;
|
|
3
3
|
export declare const HttpClientWatcher: ITraceWatcher & {
|
|
4
4
|
emit: typeof emit;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TraceContext } from '../context.js';
|
|
2
|
-
import { EntryType } from '../types.js';
|
|
2
|
+
import { EntryType, } from '../types.js';
|
|
3
3
|
import { AuthTag } from '../utils/authTag.js';
|
|
4
4
|
import { redactHeaders, redactUnknown } from '../utils/redact.js';
|
|
5
5
|
import { RequestFilter } from '../utils/requestFilter.js';
|
|
@@ -57,20 +57,40 @@ const buildResponseBody = (responseBody, sourceRule) => {
|
|
|
57
57
|
return {};
|
|
58
58
|
return { responseBody: redactUnknown(responseBody, _redactBodyFields) };
|
|
59
59
|
};
|
|
60
|
+
const applySource = (content, normalizedSource) => {
|
|
61
|
+
if (normalizedSource !== undefined) {
|
|
62
|
+
content.source = normalizedSource;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const applyResponseStatus = (content, responseStatus) => {
|
|
66
|
+
if (responseStatus !== undefined) {
|
|
67
|
+
content.responseStatus = responseStatus;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const applyError = (content, error) => {
|
|
71
|
+
if (typeof error === 'string' && error !== '') {
|
|
72
|
+
content.error = error;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const mergePartialContent = (content, partial) => {
|
|
76
|
+
Object.assign(content, partial);
|
|
77
|
+
};
|
|
60
78
|
const buildClientRequestContent = (input, sourceRule, normalizedSource) => {
|
|
61
|
-
|
|
62
|
-
...(normalizedSource === undefined ? {} : { source: normalizedSource }),
|
|
79
|
+
const content = {
|
|
63
80
|
method: input.method.toUpperCase(),
|
|
64
81
|
url: input.url,
|
|
65
|
-
|
|
66
|
-
...buildRequestBody(input.requestBody, sourceRule),
|
|
67
|
-
...(input.responseStatus === undefined ? {} : { responseStatus: input.responseStatus }),
|
|
68
|
-
...buildResponseHeaders(input.responseHeaders, sourceRule),
|
|
69
|
-
...buildResponseBody(input.responseBody, sourceRule),
|
|
70
|
-
...(typeof input.error === 'string' && input.error !== '' ? { error: input.error } : {}),
|
|
82
|
+
requestHeaders: {},
|
|
71
83
|
duration: input.duration,
|
|
72
84
|
hostname: TraceContext.getHostname(),
|
|
73
85
|
};
|
|
86
|
+
applySource(content, normalizedSource);
|
|
87
|
+
mergePartialContent(content, buildRequestHeaders(input.requestHeaders, sourceRule));
|
|
88
|
+
mergePartialContent(content, buildRequestBody(input.requestBody, sourceRule));
|
|
89
|
+
applyResponseStatus(content, input.responseStatus);
|
|
90
|
+
mergePartialContent(content, buildResponseHeaders(input.responseHeaders, sourceRule));
|
|
91
|
+
mergePartialContent(content, buildResponseBody(input.responseBody, sourceRule));
|
|
92
|
+
applyError(content, input.error);
|
|
93
|
+
return content;
|
|
74
94
|
};
|
|
75
95
|
const isWatcherEnabled = (value) => {
|
|
76
96
|
if (value === false)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zintrust/trace",
|
|
3
|
-
"version": "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.
|
|
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
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -134,7 +134,7 @@ const collectClientRequestSourceKeys = (
|
|
|
134
134
|
): string[] => {
|
|
135
135
|
const overrideSources = override?.sources ?? {};
|
|
136
136
|
const sourceKeys = new Set<string>([
|
|
137
|
-
...Object.keys(isObjectValue(base) ? base.sources ?? {} : {}),
|
|
137
|
+
...Object.keys(isObjectValue(base) ? (base.sources ?? {}) : {}),
|
|
138
138
|
...Object.keys(overrideSources),
|
|
139
139
|
]);
|
|
140
140
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* No auth in this layer — caller mounts middleware as needed.
|
|
4
4
|
*/
|
|
5
5
|
import type { IRequest, IResponse } from '@zintrust/core';
|
|
6
|
-
import type { EntryTypeValue, ITraceStorage } from '../types';
|
|
6
|
+
import type { EntryTypeValue, ITraceEntry, ITraceStorage } from '../types';
|
|
7
7
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Storage holder (set once from routes.ts)
|
|
@@ -57,6 +57,140 @@ const getNumericQueryParam = (req: IRequest, key: string): number | undefined =>
|
|
|
57
57
|
return undefined;
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
const DEFAULT_PER_PAGE = 50;
|
|
61
|
+
const MAX_PER_PAGE = 100;
|
|
62
|
+
const DEFAULT_REQUEST_PER_PAGE = 25;
|
|
63
|
+
const MAX_REQUEST_PER_PAGE = 50;
|
|
64
|
+
const DEFAULT_BATCH_PER_PAGE = 10;
|
|
65
|
+
const MAX_BATCH_PER_PAGE = 25;
|
|
66
|
+
const SUMMARY_TEXT_LIMIT = 280;
|
|
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
|
+
];
|
|
78
|
+
|
|
79
|
+
type CompactTraceEntry = ITraceEntry<Record<string, unknown>> & {
|
|
80
|
+
hasDetails: true;
|
|
81
|
+
contentBytes?: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const truncateText = (value: string, limit = SUMMARY_TEXT_LIMIT): string =>
|
|
85
|
+
value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
|
|
86
|
+
|
|
87
|
+
const compactValue = (value: unknown): unknown => {
|
|
88
|
+
if (typeof value === 'string') {
|
|
89
|
+
return truncateText(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
typeof value === 'number' ||
|
|
94
|
+
typeof value === 'boolean' ||
|
|
95
|
+
value === null ||
|
|
96
|
+
value === undefined
|
|
97
|
+
) {
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
return value.slice(0, SUMMARY_ARRAY_LIMIT).map((item) => {
|
|
103
|
+
if (typeof item === 'string') {
|
|
104
|
+
return truncateText(item);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof item === 'number' || typeof item === 'boolean' || item === null) {
|
|
108
|
+
return item;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return '[complex]';
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const pickCompactContent = (content: unknown, keys: readonly string[]): Record<string, unknown> => {
|
|
119
|
+
if (typeof content !== 'object' || content === null || Array.isArray(content)) {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const source = content as Record<string, unknown>;
|
|
124
|
+
const compact: Record<string, unknown> = {};
|
|
125
|
+
|
|
126
|
+
for (const key of keys) {
|
|
127
|
+
const value = compactValue(source[key]);
|
|
128
|
+
if (value !== undefined) {
|
|
129
|
+
compact[key] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return compact;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const COMPACT_ENTRY_KEYS: Record<EntryTypeValue, readonly string[]> = {
|
|
137
|
+
request: [
|
|
138
|
+
'method',
|
|
139
|
+
'uri',
|
|
140
|
+
'responseStatus',
|
|
141
|
+
'duration',
|
|
142
|
+
'memory',
|
|
143
|
+
'middleware',
|
|
144
|
+
'hostname',
|
|
145
|
+
'userId',
|
|
146
|
+
],
|
|
147
|
+
query: ['connection', 'sql', 'time', 'duration', 'slow', 'hash', 'hostname'],
|
|
148
|
+
exception: ['class', 'file', 'line', 'message', 'occurrences', 'hostname', 'userId'],
|
|
149
|
+
log: ['level', 'message', 'hostname'],
|
|
150
|
+
job: ['status', 'connection', 'queue', 'name', 'tries', 'timeout', 'hostname'],
|
|
151
|
+
cache: ['operation', 'key', 'hit', 'store', 'payloadLogged', 'ttl', 'duration', 'hostname'],
|
|
152
|
+
schedule: ['name', 'expression', 'status', 'duration', 'hostname'],
|
|
153
|
+
mail: ['to', 'subject', 'template', 'hostname'],
|
|
154
|
+
auth: ['event', 'userId', 'hostname'],
|
|
155
|
+
event: ['name', 'listenerCount', 'hostname'],
|
|
156
|
+
model: ['action', 'model', 'id', 'hostname'],
|
|
157
|
+
notification: ['channels', 'notifiable', 'notification', 'message', 'hostname'],
|
|
158
|
+
redis: ['command', 'duration', 'hostname'],
|
|
159
|
+
gate: ['ability', 'result', 'userId', 'subject', 'hostname'],
|
|
160
|
+
middleware: ['name', 'event', 'duration', 'hostname'],
|
|
161
|
+
command: ['name', 'exitCode', 'duration', 'hostname'],
|
|
162
|
+
batch: ['name', 'total', 'processed', 'failed', 'status', 'hostname'],
|
|
163
|
+
dump: ['file', 'line', 'hostname'],
|
|
164
|
+
view: ['template', 'duration', 'hostname'],
|
|
165
|
+
client_request: ['source', 'method', 'url', 'responseStatus', 'error', 'duration', 'hostname'],
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const compactEntryContent = (entry: ITraceEntry): Record<string, unknown> =>
|
|
169
|
+
pickCompactContent(entry.content, COMPACT_ENTRY_KEYS[entry.type]);
|
|
170
|
+
|
|
171
|
+
const estimateContentBytes = (content: unknown): number | undefined => {
|
|
172
|
+
try {
|
|
173
|
+
return new TextEncoder().encode(JSON.stringify(content)).length;
|
|
174
|
+
} catch {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const compactListEntry = (entry: ITraceEntry): CompactTraceEntry => ({
|
|
180
|
+
...entry,
|
|
181
|
+
content: compactEntryContent(entry),
|
|
182
|
+
hasDetails: true,
|
|
183
|
+
contentBytes: estimateContentBytes(entry.content),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const resolvePerPage = (req: IRequest, type?: EntryTypeValue): number => {
|
|
187
|
+
const isRequestList = type === 'request';
|
|
188
|
+
const fallback = isRequestList ? DEFAULT_REQUEST_PER_PAGE : DEFAULT_PER_PAGE;
|
|
189
|
+
const limit = isRequestList ? MAX_REQUEST_PER_PAGE : MAX_PER_PAGE;
|
|
190
|
+
|
|
191
|
+
return Math.max(1, Math.min(qpInt(req, 'perPage', fallback), limit));
|
|
192
|
+
};
|
|
193
|
+
|
|
60
194
|
// ---------------------------------------------------------------------------
|
|
61
195
|
// Entry handlers
|
|
62
196
|
// ---------------------------------------------------------------------------
|
|
@@ -64,18 +198,26 @@ const getNumericQueryParam = (req: IRequest, key: string): number | undefined =>
|
|
|
64
198
|
export async function listEntries(req: IRequest, res: IResponse): Promise<void> {
|
|
65
199
|
const storage = getStorage(res);
|
|
66
200
|
if (storage !== null) {
|
|
201
|
+
const type = qp(req, 'type') as EntryTypeValue | undefined;
|
|
67
202
|
const opts = {
|
|
68
|
-
type
|
|
203
|
+
type,
|
|
69
204
|
tag: qp(req, 'tag'),
|
|
70
205
|
batchId: qp(req, 'batchId'),
|
|
71
206
|
from: getNumericQueryParam(req, 'from'),
|
|
72
207
|
to: getNumericQueryParam(req, 'to'),
|
|
73
|
-
page: qpInt(req, 'page', 1),
|
|
74
|
-
perPage:
|
|
208
|
+
page: Math.max(1, qpInt(req, 'page', 1)),
|
|
209
|
+
perPage: resolvePerPage(req, type),
|
|
210
|
+
summary: true,
|
|
75
211
|
};
|
|
76
212
|
try {
|
|
77
213
|
const result = await storage.queryEntries(opts);
|
|
78
|
-
res.json({
|
|
214
|
+
res.json({
|
|
215
|
+
ok: true,
|
|
216
|
+
data: result.data.map(compactListEntry),
|
|
217
|
+
total: result.total,
|
|
218
|
+
page: opts.page,
|
|
219
|
+
perPage: opts.perPage,
|
|
220
|
+
});
|
|
79
221
|
} catch (err) {
|
|
80
222
|
res.setStatus(500).json({ error: (err as Error).message });
|
|
81
223
|
}
|
|
@@ -111,8 +253,22 @@ export async function getBatch(req: IRequest, res: IResponse): Promise<void> {
|
|
|
111
253
|
const batchId = req.getParam('batchId');
|
|
112
254
|
if (batchId) {
|
|
113
255
|
try {
|
|
114
|
-
const
|
|
115
|
-
|
|
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 });
|
|
116
272
|
return;
|
|
117
273
|
} catch (err) {
|
|
118
274
|
res.setStatus(500).json({ error: (err as Error).message });
|