@zintrust/trace 0.4.76 → 0.4.79
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/README.md +101 -15
- package/dist/build-manifest.json +78 -38
- package/dist/config.d.ts +1 -0
- package/dist/config.js +123 -4
- package/dist/dashboard/ui.js +88 -29
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/migrations/20260331000001_create_zin_trace_entries_table.js +1 -1
- package/dist/migrations/20260407193000_widen_trace_created_at_for_sql.d.ts +10 -0
- package/dist/migrations/20260407193000_widen_trace_created_at_for_sql.js +34 -0
- package/dist/migrations/index.js +2 -1
- package/dist/register.js +107 -9
- package/dist/storage/TraceContentRedaction.d.ts +4 -0
- package/dist/storage/TraceContentRedaction.js +33 -0
- package/dist/storage/TraceEntryFiltering.d.ts +4 -0
- package/dist/storage/TraceEntryFiltering.js +13 -0
- package/dist/storage/TraceStorage.js +35 -5
- package/dist/storage/TraceWriteDiagnostics.d.ts +19 -0
- package/dist/storage/TraceWriteDiagnostics.js +98 -0
- package/dist/types.d.ts +38 -21
- package/dist/utils/entryFilter.d.ts +4 -0
- package/dist/utils/entryFilter.js +95 -0
- package/dist/utils/redact.d.ts +1 -0
- package/dist/utils/redact.js +43 -9
- package/dist/watchers/CommandWatcher.js +1 -1
- package/dist/watchers/ExceptionWatcher.d.ts +8 -1
- package/dist/watchers/ExceptionWatcher.js +12 -7
- package/dist/watchers/HttpClientWatcher.js +1 -1
- package/dist/watchers/HttpWatcher.js +112 -21
- package/package.json +2 -2
- package/src/config.ts +152 -5
- package/src/dashboard/routes.ts +6 -2
- package/src/dashboard/ui.ts +88 -29
- package/src/index.ts +10 -0
- package/src/register.ts +137 -10
- package/src/storage/TraceContentRedaction.ts +44 -0
- package/src/storage/TraceEntryFiltering.ts +14 -0
- package/src/storage/TraceStorage.ts +52 -5
- package/src/storage/TraceWriteDiagnostics.ts +174 -0
- package/src/types.ts +41 -21
- package/src/utils/entryFilter.ts +108 -0
- package/src/utils/redact.ts +57 -9
- package/src/watchers/CommandWatcher.ts +1 -1
- package/src/watchers/ExceptionWatcher.ts +21 -8
- package/src/watchers/HttpClientWatcher.ts +1 -1
- package/src/watchers/HttpWatcher.ts +142 -23
- package/src/watchers/LogWatcher.ts +26 -28
|
@@ -3,6 +3,38 @@ 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 buildIgnoreInsert = (db, table, columns, conflictColumns) => {
|
|
7
|
+
const columnList = columns.join(', ');
|
|
8
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
9
|
+
const driver = db.getType?.() ?? 'sqlite';
|
|
10
|
+
if (driver === 'sqlite' || driver === 'd1' || driver === 'd1-remote') {
|
|
11
|
+
return `INSERT OR IGNORE INTO ${table} (${columnList}) VALUES (${placeholders})`;
|
|
12
|
+
}
|
|
13
|
+
if (driver === 'mysql') {
|
|
14
|
+
return `INSERT IGNORE INTO ${table} (${columnList}) VALUES (${placeholders})`;
|
|
15
|
+
}
|
|
16
|
+
if (driver === 'postgresql') {
|
|
17
|
+
return `INSERT INTO ${table} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns.join(', ')}) DO NOTHING`;
|
|
18
|
+
}
|
|
19
|
+
if (driver === 'sqlserver') {
|
|
20
|
+
const sourceColumns = columns.map((_, index) => `v${index + 1}`);
|
|
21
|
+
const selectClause = sourceColumns.map((name) => `? AS ${name}`).join(', ');
|
|
22
|
+
const conflictClause = conflictColumns
|
|
23
|
+
.map((column) => `target.${column} = source.${column}`)
|
|
24
|
+
.join(' AND ');
|
|
25
|
+
const insertValues = columns.map((column) => `source.${column}`).join(', ');
|
|
26
|
+
const sourceProjection = columns
|
|
27
|
+
.map((column, index) => `${sourceColumns[index]} AS ${column}`)
|
|
28
|
+
.join(', ');
|
|
29
|
+
return [
|
|
30
|
+
`MERGE INTO ${table} WITH (HOLDLOCK) AS target`,
|
|
31
|
+
`USING (SELECT ${sourceProjection} FROM (SELECT ${selectClause}) seed) AS source`,
|
|
32
|
+
`ON ${conflictClause}`,
|
|
33
|
+
`WHEN NOT MATCHED THEN INSERT (${columnList}) VALUES (${insertValues});`,
|
|
34
|
+
].join(' ');
|
|
35
|
+
}
|
|
36
|
+
return `INSERT INTO ${table} (${columnList}) VALUES (${placeholders})`;
|
|
37
|
+
};
|
|
6
38
|
const rowToEntry = (row, tags) => ({
|
|
7
39
|
uuid: row.uuid,
|
|
8
40
|
batchId: row.batch_id,
|
|
@@ -16,11 +48,9 @@ const rowToEntry = (row, tags) => ({
|
|
|
16
48
|
const insertTags = async (db, uuid, tags) => {
|
|
17
49
|
if (tags.length === 0)
|
|
18
50
|
return;
|
|
51
|
+
const sql = buildIgnoreInsert(db, TABLE_TAGS, ['entry_uuid', 'tag'], ['entry_uuid', 'tag']);
|
|
19
52
|
await Promise.all(tags.map(async (tag) => {
|
|
20
|
-
await db.execute(
|
|
21
|
-
uuid,
|
|
22
|
-
tag,
|
|
23
|
-
]);
|
|
53
|
+
await db.execute(sql, [uuid, tag]);
|
|
24
54
|
}));
|
|
25
55
|
};
|
|
26
56
|
const buildEntryFilters = (opts) => {
|
|
@@ -158,7 +188,7 @@ const createStorage = (db) => {
|
|
|
158
188
|
return rows.map((row) => row.tag);
|
|
159
189
|
};
|
|
160
190
|
const addMonitoring = async (tag) => {
|
|
161
|
-
await db.execute(
|
|
191
|
+
await db.execute(buildIgnoreInsert(db, TABLE_MONITORING, ['tag'], ['tag']), [tag]);
|
|
162
192
|
};
|
|
163
193
|
const removeMonitoring = async (tag) => {
|
|
164
194
|
await db.execute(`DELETE FROM ${TABLE_MONITORING} WHERE tag = ?`, [tag]);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ITraceStorage } from '../types';
|
|
2
|
+
type TraceLogger = {
|
|
3
|
+
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
4
|
+
};
|
|
5
|
+
type TraceWriteDiagnosticsSnapshot = {
|
|
6
|
+
degraded: boolean;
|
|
7
|
+
lastErrorMessage: string | null;
|
|
8
|
+
lastFailureAt: number | null;
|
|
9
|
+
totalFailures: number;
|
|
10
|
+
};
|
|
11
|
+
export declare const TraceWriteDiagnostics: Readonly<{
|
|
12
|
+
getSnapshot(): TraceWriteDiagnosticsSnapshot;
|
|
13
|
+
reset(): void;
|
|
14
|
+
wrapStorage(storage: ITraceStorage, options: {
|
|
15
|
+
connectionName: string;
|
|
16
|
+
logger?: TraceLogger;
|
|
17
|
+
}): ITraceStorage;
|
|
18
|
+
}>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const LOG_WINDOW_MS = 30_000;
|
|
2
|
+
const diagnosticsState = {
|
|
3
|
+
degraded: false,
|
|
4
|
+
lastErrorMessage: null,
|
|
5
|
+
lastFailureAt: null,
|
|
6
|
+
lastLoggedAtByFingerprint: new Map(),
|
|
7
|
+
totalFailures: 0,
|
|
8
|
+
};
|
|
9
|
+
const getErrorMessage = (error) => {
|
|
10
|
+
if (error instanceof Error && error.message.trim() !== '')
|
|
11
|
+
return error.message;
|
|
12
|
+
if (typeof error === 'string' && error.trim() !== '')
|
|
13
|
+
return error;
|
|
14
|
+
try {
|
|
15
|
+
const serialized = JSON.stringify(error);
|
|
16
|
+
if (typeof serialized === 'string' && serialized !== '')
|
|
17
|
+
return serialized;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// ignore serialization failures
|
|
21
|
+
}
|
|
22
|
+
return 'Unknown trace storage error';
|
|
23
|
+
};
|
|
24
|
+
const buildFingerprint = (context) => {
|
|
25
|
+
return [
|
|
26
|
+
context.connectionName,
|
|
27
|
+
context.operation,
|
|
28
|
+
context.watcherType ?? 'unknown',
|
|
29
|
+
getErrorMessage(context.error),
|
|
30
|
+
].join('|');
|
|
31
|
+
};
|
|
32
|
+
const reportFailure = (logger, context) => {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const errorMessage = getErrorMessage(context.error);
|
|
35
|
+
const fingerprint = buildFingerprint(context);
|
|
36
|
+
const lastLoggedAt = diagnosticsState.lastLoggedAtByFingerprint.get(fingerprint);
|
|
37
|
+
diagnosticsState.degraded = true;
|
|
38
|
+
diagnosticsState.lastErrorMessage = errorMessage;
|
|
39
|
+
diagnosticsState.lastFailureAt = now;
|
|
40
|
+
diagnosticsState.totalFailures += 1;
|
|
41
|
+
if (!logger)
|
|
42
|
+
return;
|
|
43
|
+
if (typeof lastLoggedAt === 'number' && now - lastLoggedAt < LOG_WINDOW_MS)
|
|
44
|
+
return;
|
|
45
|
+
diagnosticsState.lastLoggedAtByFingerprint.set(fingerprint, now);
|
|
46
|
+
logger.warn('[trace] Trace storage write degraded', {
|
|
47
|
+
connectionName: context.connectionName,
|
|
48
|
+
error: errorMessage,
|
|
49
|
+
lastFailureAt: now,
|
|
50
|
+
operation: context.operation,
|
|
51
|
+
totalFailures: diagnosticsState.totalFailures,
|
|
52
|
+
watcherType: context.watcherType ?? null,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const wrapStorageMethod = (method, describeFailure, connectionName, logger) => {
|
|
56
|
+
return async (...args) => {
|
|
57
|
+
try {
|
|
58
|
+
return await method(...args);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
reportFailure(logger, {
|
|
62
|
+
...describeFailure(...args),
|
|
63
|
+
connectionName,
|
|
64
|
+
error,
|
|
65
|
+
});
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
export const TraceWriteDiagnostics = Object.freeze({
|
|
71
|
+
getSnapshot() {
|
|
72
|
+
return {
|
|
73
|
+
degraded: diagnosticsState.degraded,
|
|
74
|
+
lastErrorMessage: diagnosticsState.lastErrorMessage,
|
|
75
|
+
lastFailureAt: diagnosticsState.lastFailureAt,
|
|
76
|
+
totalFailures: diagnosticsState.totalFailures,
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
reset() {
|
|
80
|
+
diagnosticsState.degraded = false;
|
|
81
|
+
diagnosticsState.lastErrorMessage = null;
|
|
82
|
+
diagnosticsState.lastFailureAt = null;
|
|
83
|
+
diagnosticsState.totalFailures = 0;
|
|
84
|
+
diagnosticsState.lastLoggedAtByFingerprint.clear();
|
|
85
|
+
},
|
|
86
|
+
wrapStorage(storage, options) {
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
...storage,
|
|
89
|
+
writeEntry: wrapStorageMethod(storage.writeEntry.bind(storage), (entry) => ({ operation: 'writeEntry', watcherType: entry.type }), options.connectionName, options.logger),
|
|
90
|
+
updateEntry: wrapStorageMethod(storage.updateEntry.bind(storage), (_uuid, _patch) => ({ operation: 'updateEntry' }), options.connectionName, options.logger),
|
|
91
|
+
markFamilyStale: wrapStorageMethod(storage.markFamilyStale.bind(storage), (_familyHash, _exceptUuid) => ({ operation: 'markFamilyStale' }), options.connectionName, options.logger),
|
|
92
|
+
prune: wrapStorageMethod(storage.prune.bind(storage), (_olderThanMs, _keepExceptions) => ({ operation: 'prune' }), options.connectionName, options.logger),
|
|
93
|
+
clear: wrapStorageMethod(storage.clear.bind(storage), () => ({ operation: 'clear' }), options.connectionName, options.logger),
|
|
94
|
+
addMonitoring: wrapStorageMethod(storage.addMonitoring.bind(storage), (_tag) => ({ operation: 'addMonitoring' }), options.connectionName, options.logger),
|
|
95
|
+
removeMonitoring: wrapStorageMethod(storage.removeMonitoring.bind(storage), (_tag) => ({ operation: 'removeMonitoring' }), options.connectionName, options.logger),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -30,9 +30,10 @@ export interface RequestContent {
|
|
|
30
30
|
method: string;
|
|
31
31
|
uri: string;
|
|
32
32
|
headers: Record<string, string>;
|
|
33
|
-
payload:
|
|
33
|
+
payload: unknown;
|
|
34
34
|
responseStatus: number;
|
|
35
35
|
responseHeaders: Record<string, string>;
|
|
36
|
+
responseBody?: unknown;
|
|
36
37
|
duration: number;
|
|
37
38
|
memory: number | null;
|
|
38
39
|
middleware: string[];
|
|
@@ -231,31 +232,47 @@ export interface ITraceWatcher {
|
|
|
231
232
|
register(opts: ITraceWatcherConfig): () => void;
|
|
232
233
|
}
|
|
233
234
|
export type RedactionConfig = {
|
|
235
|
+
keys: string[];
|
|
234
236
|
headers: string[];
|
|
235
237
|
body: string[];
|
|
236
238
|
query: string[];
|
|
237
239
|
};
|
|
240
|
+
export type TraceFilterRule = {
|
|
241
|
+
enabled?: boolean;
|
|
242
|
+
include?: string[];
|
|
243
|
+
exclude?: string[];
|
|
244
|
+
};
|
|
245
|
+
export type TraceRequestWatcherConfig = TraceFilterRule & {
|
|
246
|
+
all?: TraceFilterRule;
|
|
247
|
+
get?: TraceFilterRule;
|
|
248
|
+
post?: TraceFilterRule;
|
|
249
|
+
put?: TraceFilterRule;
|
|
250
|
+
patch?: TraceFilterRule;
|
|
251
|
+
delete?: TraceFilterRule;
|
|
252
|
+
};
|
|
253
|
+
export type TraceWatcherToggle = boolean | TraceFilterRule;
|
|
254
|
+
export type TraceRequestWatcherToggle = boolean | TraceRequestWatcherConfig;
|
|
238
255
|
export type WatcherToggles = {
|
|
239
|
-
request?:
|
|
240
|
-
query?:
|
|
241
|
-
exception?:
|
|
242
|
-
log?:
|
|
243
|
-
job?:
|
|
244
|
-
cache?:
|
|
245
|
-
schedule?:
|
|
246
|
-
mail?:
|
|
247
|
-
auth?:
|
|
248
|
-
event?:
|
|
249
|
-
model?:
|
|
250
|
-
notification?:
|
|
251
|
-
redis?:
|
|
252
|
-
gate?:
|
|
253
|
-
middleware?:
|
|
254
|
-
command?:
|
|
255
|
-
batch?:
|
|
256
|
-
dump?:
|
|
257
|
-
view?:
|
|
258
|
-
clientRequest?:
|
|
256
|
+
request?: TraceRequestWatcherToggle;
|
|
257
|
+
query?: TraceWatcherToggle;
|
|
258
|
+
exception?: TraceWatcherToggle;
|
|
259
|
+
log?: TraceWatcherToggle;
|
|
260
|
+
job?: TraceWatcherToggle;
|
|
261
|
+
cache?: TraceWatcherToggle;
|
|
262
|
+
schedule?: TraceWatcherToggle;
|
|
263
|
+
mail?: TraceWatcherToggle;
|
|
264
|
+
auth?: TraceWatcherToggle;
|
|
265
|
+
event?: TraceWatcherToggle;
|
|
266
|
+
model?: TraceWatcherToggle;
|
|
267
|
+
notification?: TraceWatcherToggle;
|
|
268
|
+
redis?: TraceWatcherToggle;
|
|
269
|
+
gate?: TraceWatcherToggle;
|
|
270
|
+
middleware?: TraceWatcherToggle;
|
|
271
|
+
command?: TraceWatcherToggle;
|
|
272
|
+
batch?: TraceWatcherToggle;
|
|
273
|
+
dump?: TraceWatcherToggle;
|
|
274
|
+
view?: TraceWatcherToggle;
|
|
275
|
+
clientRequest?: TraceWatcherToggle;
|
|
259
276
|
};
|
|
260
277
|
export interface ITraceConfig {
|
|
261
278
|
enabled: boolean;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { EntryType } from '../types.js';
|
|
2
|
+
const isObjectValue = (value) => {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
};
|
|
5
|
+
const normalizeTerms = (terms) => {
|
|
6
|
+
if (!Array.isArray(terms))
|
|
7
|
+
return [];
|
|
8
|
+
return terms
|
|
9
|
+
.filter((term) => typeof term === 'string')
|
|
10
|
+
.map((term) => term.trim().toLowerCase())
|
|
11
|
+
.filter((term) => term !== '');
|
|
12
|
+
};
|
|
13
|
+
const matchesRule = (haystack, rule) => {
|
|
14
|
+
if (!rule)
|
|
15
|
+
return true;
|
|
16
|
+
const include = normalizeTerms(rule.include);
|
|
17
|
+
const exclude = normalizeTerms(rule.exclude);
|
|
18
|
+
if (exclude.some((term) => haystack.includes(term)))
|
|
19
|
+
return false;
|
|
20
|
+
if (include.length === 0)
|
|
21
|
+
return true;
|
|
22
|
+
return include.some((term) => haystack.includes(term));
|
|
23
|
+
};
|
|
24
|
+
const toSearchableText = (entry) => {
|
|
25
|
+
const sections = [entry.type, entry.batchId, ...(entry.tags ?? [])];
|
|
26
|
+
try {
|
|
27
|
+
sections.push(JSON.stringify(entry.content) ?? '');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
sections.push(String(entry.content ?? ''));
|
|
31
|
+
}
|
|
32
|
+
return sections.join(' ').toLowerCase();
|
|
33
|
+
};
|
|
34
|
+
const watcherKeyByEntryType = {
|
|
35
|
+
[EntryType.REQUEST]: 'request',
|
|
36
|
+
[EntryType.QUERY]: 'query',
|
|
37
|
+
[EntryType.EXCEPTION]: 'exception',
|
|
38
|
+
[EntryType.LOG]: 'log',
|
|
39
|
+
[EntryType.JOB]: 'job',
|
|
40
|
+
[EntryType.CACHE]: 'cache',
|
|
41
|
+
[EntryType.SCHEDULE]: 'schedule',
|
|
42
|
+
[EntryType.MAIL]: 'mail',
|
|
43
|
+
[EntryType.AUTH]: 'auth',
|
|
44
|
+
[EntryType.EVENT]: 'event',
|
|
45
|
+
[EntryType.MODEL]: 'model',
|
|
46
|
+
[EntryType.NOTIFICATION]: 'notification',
|
|
47
|
+
[EntryType.REDIS]: 'redis',
|
|
48
|
+
[EntryType.GATE]: 'gate',
|
|
49
|
+
[EntryType.MIDDLEWARE]: 'middleware',
|
|
50
|
+
[EntryType.COMMAND]: 'command',
|
|
51
|
+
[EntryType.BATCH]: 'batch',
|
|
52
|
+
[EntryType.DUMP]: 'dump',
|
|
53
|
+
[EntryType.VIEW]: 'view',
|
|
54
|
+
[EntryType.CLIENT_REQUEST]: 'clientRequest',
|
|
55
|
+
};
|
|
56
|
+
const getRequestMethodRule = (watcher, entry) => {
|
|
57
|
+
if (entry.type !== EntryType.REQUEST)
|
|
58
|
+
return undefined;
|
|
59
|
+
const content = isObjectValue(entry.content) ? entry.content : undefined;
|
|
60
|
+
const methodValue = content?.['method'];
|
|
61
|
+
const method = typeof methodValue === 'string' ? methodValue.trim().toLowerCase() : '';
|
|
62
|
+
if (method === 'get')
|
|
63
|
+
return watcher.get;
|
|
64
|
+
if (method === 'post')
|
|
65
|
+
return watcher.post;
|
|
66
|
+
if (method === 'put')
|
|
67
|
+
return watcher.put;
|
|
68
|
+
if (method === 'patch')
|
|
69
|
+
return watcher.patch;
|
|
70
|
+
if (method === 'delete' || method === 'del')
|
|
71
|
+
return watcher.delete;
|
|
72
|
+
return watcher.all;
|
|
73
|
+
};
|
|
74
|
+
export const TraceEntryFilter = Object.freeze({
|
|
75
|
+
shouldCapture(entry, config) {
|
|
76
|
+
const watcherKey = watcherKeyByEntryType[entry.type];
|
|
77
|
+
const watcher = config.watchers[watcherKey];
|
|
78
|
+
if (watcher === false)
|
|
79
|
+
return false;
|
|
80
|
+
if (!isObjectValue(watcher))
|
|
81
|
+
return true;
|
|
82
|
+
const haystack = toSearchableText(entry);
|
|
83
|
+
if (!matchesRule(haystack, watcher))
|
|
84
|
+
return false;
|
|
85
|
+
if (watcherKey === 'request') {
|
|
86
|
+
const requestWatcher = watcher;
|
|
87
|
+
const methodRule = getRequestMethodRule(requestWatcher, entry);
|
|
88
|
+
if (!matchesRule(haystack, requestWatcher.all))
|
|
89
|
+
return false;
|
|
90
|
+
if (!matchesRule(haystack, methodRule))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
},
|
|
95
|
+
});
|
package/dist/utils/redact.d.ts
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
* Redaction helpers for @zintrust/trace watchers.
|
|
3
3
|
*/
|
|
4
4
|
export declare const redactHeaders: (headers: Record<string, string>, fields: string[]) => Record<string, string>;
|
|
5
|
+
export declare const redactUnknown: (value: unknown, fields: string[]) => unknown;
|
|
5
6
|
export declare const redactObject: (obj: Record<string, unknown>, fields: string[]) => Record<string, unknown>;
|
|
6
7
|
export declare const redactString: (value: string, fields: string[]) => string;
|
package/dist/utils/redact.js
CHANGED
|
@@ -1,7 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Redaction helpers for @zintrust/trace watchers.
|
|
3
3
|
*/
|
|
4
|
-
const REDACTED = '
|
|
4
|
+
const REDACTED = '****';
|
|
5
|
+
const isArrayValue = (value) => Array.isArray(value);
|
|
6
|
+
const isObjectValue = (value) => {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
};
|
|
9
|
+
const normalizeFields = (fields) => {
|
|
10
|
+
const normalized = new Set();
|
|
11
|
+
for (const field of fields) {
|
|
12
|
+
if (typeof field !== 'string')
|
|
13
|
+
continue;
|
|
14
|
+
const key = field.trim().toLowerCase();
|
|
15
|
+
if (key !== '')
|
|
16
|
+
normalized.add(key);
|
|
17
|
+
}
|
|
18
|
+
return normalized;
|
|
19
|
+
};
|
|
20
|
+
const redactUnknownValue = (value, fields, seen) => {
|
|
21
|
+
if (isArrayValue(value)) {
|
|
22
|
+
return value.map((item) => redactUnknownValue(item, fields, seen));
|
|
23
|
+
}
|
|
24
|
+
if (!isObjectValue(value)) {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
if (seen.has(value)) {
|
|
28
|
+
return '[Circular]';
|
|
29
|
+
}
|
|
30
|
+
seen.add(value);
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
33
|
+
out[key] = fields.has(key.toLowerCase())
|
|
34
|
+
? REDACTED
|
|
35
|
+
: redactUnknownValue(entryValue, fields, seen);
|
|
36
|
+
}
|
|
37
|
+
seen.delete(value);
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
5
40
|
const redactQuerySegment = (segment, fields) => {
|
|
6
41
|
const separatorIndex = segment.indexOf('=');
|
|
7
42
|
if (separatorIndex <= 0)
|
|
@@ -13,23 +48,22 @@ const redactQuerySegment = (segment, fields) => {
|
|
|
13
48
|
return `${key}=${REDACTED}`;
|
|
14
49
|
};
|
|
15
50
|
export const redactHeaders = (headers, fields) => {
|
|
16
|
-
const lower =
|
|
51
|
+
const lower = normalizeFields(fields);
|
|
17
52
|
const out = {};
|
|
18
53
|
for (const [k, v] of Object.entries(headers)) {
|
|
19
54
|
out[k] = lower.has(k.toLowerCase()) ? REDACTED : v;
|
|
20
55
|
}
|
|
21
56
|
return out;
|
|
22
57
|
};
|
|
58
|
+
export const redactUnknown = (value, fields) => {
|
|
59
|
+
return redactUnknownValue(value, normalizeFields(fields), new WeakSet());
|
|
60
|
+
};
|
|
23
61
|
export const redactObject = (obj, fields) => {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
27
|
-
out[k] = lower.has(k.toLowerCase()) ? REDACTED : v;
|
|
28
|
-
}
|
|
29
|
-
return out;
|
|
62
|
+
const redacted = redactUnknown(obj, fields);
|
|
63
|
+
return isObjectValue(redacted) ? redacted : {};
|
|
30
64
|
};
|
|
31
65
|
export const redactString = (value, fields) => {
|
|
32
|
-
const lower =
|
|
66
|
+
const lower = normalizeFields(fields);
|
|
33
67
|
if (value === '')
|
|
34
68
|
return value;
|
|
35
69
|
let output = '';
|
|
@@ -39,7 +39,7 @@ export const CommandWatcher = Object.freeze({
|
|
|
39
39
|
if (config.watchers.command === false)
|
|
40
40
|
return () => undefined;
|
|
41
41
|
_storage = storage;
|
|
42
|
-
_redactKeys = config.redaction?.body ?? [];
|
|
42
|
+
_redactKeys = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
|
|
43
43
|
_ignoreRoutes = config.ignoreRoutes;
|
|
44
44
|
return () => {
|
|
45
45
|
_storage = null;
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { ITraceWatcher } from '../types';
|
|
2
|
+
type ExceptionCaptureContext = {
|
|
3
|
+
batchId?: string;
|
|
4
|
+
hostname?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
userId?: string;
|
|
7
|
+
};
|
|
2
8
|
export declare const ExceptionWatcher: ITraceWatcher & {
|
|
3
|
-
capture: (err: unknown) => void;
|
|
9
|
+
capture: (err: unknown, context?: ExceptionCaptureContext) => void;
|
|
4
10
|
};
|
|
11
|
+
export {};
|
|
@@ -13,7 +13,7 @@ import { parseStackFrameLine } from '../utils/stackFrame.js';
|
|
|
13
13
|
const getLinePreview = (_file, _line) => {
|
|
14
14
|
return {};
|
|
15
15
|
};
|
|
16
|
-
const buildContent = (err) => {
|
|
16
|
+
const buildContent = (err, context) => {
|
|
17
17
|
const stack = err.stack ?? '';
|
|
18
18
|
const trace = stack
|
|
19
19
|
.split('\n')
|
|
@@ -30,8 +30,8 @@ const buildContent = (err) => {
|
|
|
30
30
|
trace,
|
|
31
31
|
linePreview: firstFrame ? getLinePreview(firstFrame.file, firstFrame.line) : {},
|
|
32
32
|
occurrences: 1,
|
|
33
|
-
hostname: TraceContext.getHostname(),
|
|
34
|
-
userId: TraceContext.getUserId(),
|
|
33
|
+
hostname: context?.hostname ?? TraceContext.getHostname(),
|
|
34
|
+
userId: context?.userId ?? TraceContext.getUserId(),
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
37
|
let _storage = null;
|
|
@@ -55,21 +55,26 @@ const unregisterProcessListeners = () => {
|
|
|
55
55
|
process.off('uncaughtException', handleUncaughtException);
|
|
56
56
|
process.off('unhandledRejection', handleUnhandledRejection);
|
|
57
57
|
};
|
|
58
|
-
const captureException = (err) => {
|
|
58
|
+
const captureException = (err, context) => {
|
|
59
59
|
const storage = _storage;
|
|
60
60
|
if (!storage)
|
|
61
61
|
return;
|
|
62
62
|
if (!(err instanceof Error))
|
|
63
63
|
return;
|
|
64
|
-
if (
|
|
64
|
+
if (context?.path !== undefined) {
|
|
65
|
+
if (RequestFilter.matchesIgnoredPath(context.path, _ignoreRoutes))
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
else if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes)) {
|
|
65
69
|
return;
|
|
66
|
-
|
|
70
|
+
}
|
|
71
|
+
const content = buildContent(err, context);
|
|
67
72
|
const hash = familyHash(`${content.class}:${content.file}:${content.line}`);
|
|
68
73
|
const uuid = crypto.randomUUID();
|
|
69
74
|
storage
|
|
70
75
|
.writeEntry({
|
|
71
76
|
uuid,
|
|
72
|
-
batchId: TraceContext.getBatchId(),
|
|
77
|
+
batchId: context?.batchId ?? TraceContext.getBatchId(),
|
|
73
78
|
familyHash: hash,
|
|
74
79
|
type: EntryType.EXCEPTION,
|
|
75
80
|
content,
|
|
@@ -40,7 +40,7 @@ export const HttpClientWatcher = Object.freeze({
|
|
|
40
40
|
if (config.watchers.clientRequest === false)
|
|
41
41
|
return () => undefined;
|
|
42
42
|
_storage = storage;
|
|
43
|
-
_redactHeaderNames = config.redaction?.headers ?? [];
|
|
43
|
+
_redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
|
|
44
44
|
_ignoreRoutes = config.ignoreRoutes;
|
|
45
45
|
return () => {
|
|
46
46
|
_storage = null;
|