@zintrust/trace 0.4.76 → 0.4.77
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 +80 -23
- package/dist/index.d.ts +2 -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 +37 -20
- 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/HttpClientWatcher.js +1 -1
- package/dist/watchers/HttpWatcher.js +104 -20
- package/dist/watchers/LogWatcher.js +1 -0
- package/package.json +3 -3
- package/src/config.ts +152 -5
- package/src/dashboard/routes.ts +6 -2
- package/src/dashboard/ui.ts +80 -23
- package/src/index.ts +7 -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 +40 -20
- package/src/utils/entryFilter.ts +108 -0
- package/src/utils/redact.ts +57 -9
- package/src/watchers/CommandWatcher.ts +1 -1
- package/src/watchers/HttpClientWatcher.ts +1 -1
- package/src/watchers/HttpWatcher.ts +132 -21
- package/src/watchers/LogWatcher.ts +27 -27
package/src/utils/redact.ts
CHANGED
|
@@ -2,7 +2,55 @@
|
|
|
2
2
|
* Redaction helpers for @zintrust/trace watchers.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
const REDACTED = '
|
|
5
|
+
const REDACTED = '****';
|
|
6
|
+
|
|
7
|
+
const isArrayValue = (value: unknown): value is unknown[] => Array.isArray(value);
|
|
8
|
+
|
|
9
|
+
const isObjectValue = (value: unknown): value is Record<string, unknown> => {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const normalizeFields = (fields: string[]): Set<string> => {
|
|
14
|
+
const normalized = new Set<string>();
|
|
15
|
+
|
|
16
|
+
for (const field of fields) {
|
|
17
|
+
if (typeof field !== 'string') continue;
|
|
18
|
+
const key = field.trim().toLowerCase();
|
|
19
|
+
if (key !== '') normalized.add(key);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return normalized;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const redactUnknownValue = (
|
|
26
|
+
value: unknown,
|
|
27
|
+
fields: Set<string>,
|
|
28
|
+
seen: WeakSet<object>
|
|
29
|
+
): unknown => {
|
|
30
|
+
if (isArrayValue(value)) {
|
|
31
|
+
return value.map((item) => redactUnknownValue(item, fields, seen));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!isObjectValue(value)) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (seen.has(value)) {
|
|
39
|
+
return '[Circular]';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
seen.add(value);
|
|
43
|
+
const out: Record<string, unknown> = {};
|
|
44
|
+
|
|
45
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
46
|
+
out[key] = fields.has(key.toLowerCase())
|
|
47
|
+
? REDACTED
|
|
48
|
+
: redactUnknownValue(entryValue, fields, seen);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
seen.delete(value);
|
|
52
|
+
return out;
|
|
53
|
+
};
|
|
6
54
|
|
|
7
55
|
const redactQuerySegment = (segment: string, fields: Set<string>): string => {
|
|
8
56
|
const separatorIndex = segment.indexOf('=');
|
|
@@ -19,7 +67,7 @@ export const redactHeaders = (
|
|
|
19
67
|
headers: Record<string, string>,
|
|
20
68
|
fields: string[]
|
|
21
69
|
): Record<string, string> => {
|
|
22
|
-
const lower =
|
|
70
|
+
const lower = normalizeFields(fields);
|
|
23
71
|
const out: Record<string, string> = {};
|
|
24
72
|
for (const [k, v] of Object.entries(headers)) {
|
|
25
73
|
out[k] = lower.has(k.toLowerCase()) ? REDACTED : v;
|
|
@@ -27,20 +75,20 @@ export const redactHeaders = (
|
|
|
27
75
|
return out;
|
|
28
76
|
};
|
|
29
77
|
|
|
78
|
+
export const redactUnknown = (value: unknown, fields: string[]): unknown => {
|
|
79
|
+
return redactUnknownValue(value, normalizeFields(fields), new WeakSet<object>());
|
|
80
|
+
};
|
|
81
|
+
|
|
30
82
|
export const redactObject = (
|
|
31
83
|
obj: Record<string, unknown>,
|
|
32
84
|
fields: string[]
|
|
33
85
|
): Record<string, unknown> => {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
37
|
-
out[k] = lower.has(k.toLowerCase()) ? REDACTED : v;
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
86
|
+
const redacted = redactUnknown(obj, fields);
|
|
87
|
+
return isObjectValue(redacted) ? redacted : {};
|
|
40
88
|
};
|
|
41
89
|
|
|
42
90
|
export const redactString = (value: string, fields: string[]): string => {
|
|
43
|
-
const lower =
|
|
91
|
+
const lower = normalizeFields(fields);
|
|
44
92
|
if (value === '') return value;
|
|
45
93
|
|
|
46
94
|
let output = '';
|
|
@@ -45,7 +45,7 @@ export const CommandWatcher: ITraceWatcher & { emit: typeof emit } = Object.free
|
|
|
45
45
|
register({ storage, config }: ITraceWatcherConfig): () => void {
|
|
46
46
|
if (config.watchers.command === false) return () => undefined;
|
|
47
47
|
_storage = storage;
|
|
48
|
-
_redactKeys = config.redaction?.body ?? [];
|
|
48
|
+
_redactKeys = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
|
|
49
49
|
_ignoreRoutes = config.ignoreRoutes;
|
|
50
50
|
return () => {
|
|
51
51
|
_storage = null;
|
|
@@ -46,7 +46,7 @@ export const HttpClientWatcher: ITraceWatcher & { emit: typeof emit } = Object.f
|
|
|
46
46
|
register({ storage, config }: ITraceWatcherConfig): () => void {
|
|
47
47
|
if (config.watchers.clientRequest === false) return () => undefined;
|
|
48
48
|
_storage = storage;
|
|
49
|
-
_redactHeaderNames = config.redaction?.headers ?? [];
|
|
49
|
+
_redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
|
|
50
50
|
_ignoreRoutes = config.ignoreRoutes;
|
|
51
51
|
return () => {
|
|
52
52
|
_storage = null;
|
|
@@ -7,7 +7,7 @@ import { TraceContext } from '../context';
|
|
|
7
7
|
import type { ITraceConfig, ITraceWatcher, ITraceWatcherConfig, RequestContent } from '../types';
|
|
8
8
|
import { EntryType } from '../types';
|
|
9
9
|
import { AuthTag } from '../utils/authTag';
|
|
10
|
-
import { redactHeaders, redactObject } from '../utils/redact';
|
|
10
|
+
import { redactHeaders, redactObject, redactUnknown } from '../utils/redact';
|
|
11
11
|
import { RequestFilter } from '../utils/requestFilter';
|
|
12
12
|
|
|
13
13
|
const normalizeHeaders = (headers: IRequest['headers']): Record<string, string> => {
|
|
@@ -22,15 +22,112 @@ const normalizeHeaders = (headers: IRequest['headers']): Record<string, string>
|
|
|
22
22
|
);
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
const normalizeHeaderValue = (value: string | string[]): string => {
|
|
26
|
+
return Array.isArray(value) ? value.join(', ') : value;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ResponseCapture = {
|
|
30
|
+
headers: Record<string, string>;
|
|
31
|
+
body?: unknown;
|
|
32
|
+
restore(): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type RawResponseWithLifecycle = ReturnType<IResponse['getRaw']> & {
|
|
36
|
+
once?: (event: 'finish' | 'close', listener: () => void) => unknown;
|
|
37
|
+
off?: (event: 'finish' | 'close', listener: () => void) => unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const registerCompletionHandler = (response: IResponse, onComplete: () => void): (() => void) => {
|
|
41
|
+
const raw = response.getRaw() as RawResponseWithLifecycle;
|
|
42
|
+
if (typeof raw.once !== 'function') return () => undefined;
|
|
43
|
+
|
|
44
|
+
let completed = false;
|
|
45
|
+
|
|
46
|
+
const cleanup = (): void => {
|
|
47
|
+
if (typeof raw.off === 'function') {
|
|
48
|
+
raw.off('finish', markCompleted);
|
|
49
|
+
raw.off('close', markCompleted);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const markCompleted = (): void => {
|
|
54
|
+
if (completed) return;
|
|
55
|
+
completed = true;
|
|
56
|
+
cleanup();
|
|
57
|
+
onComplete();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
raw.once('finish', markCompleted);
|
|
61
|
+
raw.once('close', markCompleted);
|
|
62
|
+
|
|
63
|
+
return cleanup;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const captureResponse = (response: IResponse, config: ITraceConfig): ResponseCapture => {
|
|
67
|
+
const headers: Record<string, string> = {};
|
|
68
|
+
const redactFields = [...config.redaction.keys, ...config.redaction.body];
|
|
69
|
+
|
|
70
|
+
const originalSetHeader = response.setHeader;
|
|
71
|
+
const originalJson = response.json;
|
|
72
|
+
const originalText = response.text;
|
|
73
|
+
const originalHtml = response.html;
|
|
74
|
+
const originalSend = response.send;
|
|
75
|
+
|
|
76
|
+
const capture: ResponseCapture = {
|
|
77
|
+
headers,
|
|
78
|
+
body: undefined,
|
|
79
|
+
restore(): void {
|
|
80
|
+
response.setHeader = originalSetHeader;
|
|
81
|
+
response.json = originalJson;
|
|
82
|
+
response.text = originalText;
|
|
83
|
+
response.html = originalHtml;
|
|
84
|
+
response.send = originalSend;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
response.setHeader = function setHeader(name: string, value: string | string[]): IResponse {
|
|
89
|
+
headers[name] = normalizeHeaderValue(value);
|
|
90
|
+
return originalSetHeader.call(this, name, value);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
response.json = function json(data: unknown): void {
|
|
94
|
+
capture.body = redactUnknown(data, redactFields);
|
|
95
|
+
originalJson.call(this, data);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
response.text = function text(value: string): void {
|
|
99
|
+
capture.body = value;
|
|
100
|
+
originalText.call(this, value);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
response.html = function html(value: string): void {
|
|
104
|
+
capture.body = value;
|
|
105
|
+
originalHtml.call(this, value);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
response.send = function send(data: string | Buffer): void {
|
|
109
|
+
capture.body = typeof data === 'string' ? data : `[binary ${data.length} bytes]`;
|
|
110
|
+
originalSend.call(this, data);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return capture;
|
|
114
|
+
};
|
|
115
|
+
|
|
25
116
|
const buildEntry = (
|
|
26
117
|
req: IRequest,
|
|
27
118
|
res: IResponse,
|
|
28
119
|
start: number,
|
|
29
|
-
config: ITraceConfig
|
|
120
|
+
config: ITraceConfig,
|
|
121
|
+
responseCapture: ResponseCapture
|
|
30
122
|
): RequestContent => {
|
|
31
|
-
const headers = redactHeaders(normalizeHeaders(req.headers),
|
|
123
|
+
const headers = redactHeaders(normalizeHeaders(req.headers), [
|
|
124
|
+
...config.redaction.keys,
|
|
125
|
+
...config.redaction.headers,
|
|
126
|
+
]);
|
|
32
127
|
|
|
33
|
-
const payload = req.body
|
|
128
|
+
const payload = req.body
|
|
129
|
+
? redactObject(req.body, [...config.redaction.keys, ...config.redaction.body])
|
|
130
|
+
: {};
|
|
34
131
|
|
|
35
132
|
return {
|
|
36
133
|
method: req.getMethod(),
|
|
@@ -38,7 +135,11 @@ const buildEntry = (
|
|
|
38
135
|
headers,
|
|
39
136
|
payload,
|
|
40
137
|
responseStatus: res.getStatus(),
|
|
41
|
-
responseHeaders:
|
|
138
|
+
responseHeaders: redactHeaders(responseCapture.headers, [
|
|
139
|
+
...config.redaction.keys,
|
|
140
|
+
...config.redaction.headers,
|
|
141
|
+
]),
|
|
142
|
+
responseBody: responseCapture.body,
|
|
42
143
|
duration: Date.now() - start,
|
|
43
144
|
memory: TraceContext.getMemory(),
|
|
44
145
|
middleware: [],
|
|
@@ -68,24 +169,34 @@ export const HttpWatcher: ITraceWatcher = Object.freeze({
|
|
|
68
169
|
|
|
69
170
|
const start = TraceContext.now();
|
|
70
171
|
const batchId = TraceContext.getBatchId();
|
|
172
|
+
const responseCapture = captureResponse(response, config);
|
|
173
|
+
let didPersist = false;
|
|
71
174
|
|
|
72
|
-
|
|
175
|
+
const persistEntry = (): void => {
|
|
176
|
+
if (didPersist) return;
|
|
177
|
+
didPersist = true;
|
|
73
178
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
179
|
+
const content = buildEntry(request, response, start, config, responseCapture);
|
|
180
|
+
const tags = AuthTag.append([]);
|
|
181
|
+
if (content.responseStatus >= 500) tags.push('failed');
|
|
182
|
+
|
|
183
|
+
responseCapture.restore();
|
|
184
|
+
|
|
185
|
+
storage
|
|
186
|
+
.writeEntry({
|
|
187
|
+
uuid: crypto.randomUUID(),
|
|
188
|
+
batchId,
|
|
189
|
+
type: EntryType.REQUEST,
|
|
190
|
+
content,
|
|
191
|
+
tags,
|
|
192
|
+
isLatest: true,
|
|
193
|
+
createdAt: TraceContext.now(),
|
|
194
|
+
})
|
|
195
|
+
.catch(() => undefined); // fire-and-forget
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
registerCompletionHandler(response, persistEntry);
|
|
199
|
+
await next();
|
|
89
200
|
};
|
|
90
201
|
|
|
91
202
|
registerMiddleware(middleware);
|
|
@@ -8,7 +8,7 @@ import { EntryType } from '../types';
|
|
|
8
8
|
import { AuthTag } from '../utils/authTag';
|
|
9
9
|
import { RequestFilter } from '../utils/requestFilter';
|
|
10
10
|
|
|
11
|
-
type LoggerSink = (level: string, message: string, context?: Record<string, unknown>) => void;
|
|
11
|
+
// type LoggerSink = (level: string, message: string, context?: Record<string, unknown>) => void;
|
|
12
12
|
|
|
13
13
|
const LEVEL_PRIORITY: Record<string, number> = {
|
|
14
14
|
debug: 0,
|
|
@@ -24,37 +24,37 @@ export const LogWatcher: ITraceWatcher = Object.freeze({
|
|
|
24
24
|
|
|
25
25
|
const minPriority = LEVEL_PRIORITY[config.logMinLevel] ?? 1;
|
|
26
26
|
|
|
27
|
-
const loggerWithSink = Logger
|
|
28
|
-
addSink?: (fn: LoggerSink) => () => void;
|
|
29
|
-
};
|
|
27
|
+
const loggerWithSink = Logger;
|
|
30
28
|
|
|
31
29
|
if (typeof loggerWithSink.addSink !== 'function') {
|
|
32
30
|
return () => undefined;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
const unsubscribe = loggerWithSink.addSink(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
33
|
+
const unsubscribe = loggerWithSink.addSink(
|
|
34
|
+
(level: string, message: string, context?: Record<string, unknown>) => {
|
|
35
|
+
if ((LEVEL_PRIORITY[level] ?? 0) < minPriority) return;
|
|
36
|
+
if (RequestFilter.shouldIgnoreCurrentRequest(config.ignoreRoutes)) return;
|
|
37
|
+
|
|
38
|
+
const content: LogContent = {
|
|
39
|
+
level,
|
|
40
|
+
message,
|
|
41
|
+
context: context ?? undefined,
|
|
42
|
+
hostname: TraceContext.getHostname(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
storage
|
|
46
|
+
.writeEntry({
|
|
47
|
+
uuid: crypto.randomUUID(),
|
|
48
|
+
batchId: TraceContext.getBatchId(),
|
|
49
|
+
type: EntryType.LOG,
|
|
50
|
+
content,
|
|
51
|
+
tags: AuthTag.append([]),
|
|
52
|
+
isLatest: true,
|
|
53
|
+
createdAt: TraceContext.now(),
|
|
54
|
+
})
|
|
55
|
+
.catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
58
|
|
|
59
59
|
return unsubscribe;
|
|
60
60
|
},
|