@zintrust/trace 1.6.4 → 1.6.6
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 +14 -14
- package/dist/storage/TraceContentBudget.js +2 -1
- package/dist/watchers/HttpWatcher.js +23 -8
- package/package.json +3 -2
- package/src/TraceConnection.ts +182 -0
- package/src/cli-register.ts +63 -0
- package/src/config.ts +383 -0
- package/src/context.ts +101 -0
- package/src/dashboard/handlers.ts +353 -0
- package/src/dashboard/routes.ts +114 -0
- package/src/dashboard/ui.ts +1262 -0
- package/src/dashboard/zintrust-debuger.svg +30 -0
- package/src/index.ts +102 -0
- package/src/ingest/TraceIngestGateway.ts +414 -0
- package/src/plugin.ts +9 -0
- package/src/register.ts +702 -0
- package/src/storage/ProxyTraceStorage.ts +190 -0
- package/src/storage/TraceContentBudget.ts +493 -0
- package/src/storage/TraceContentRedaction.ts +44 -0
- package/src/storage/TraceEntryFiltering.ts +50 -0
- package/src/storage/TraceServiceTag.ts +56 -0
- package/src/storage/TraceStorage.ts +543 -0
- package/src/storage/TraceWriteDiagnostics.ts +289 -0
- package/src/storage/index.ts +4 -0
- package/src/types.ts +430 -0
- package/src/ui.ts +9 -0
- package/src/utils/authTag.ts +20 -0
- package/src/utils/entryFilter.ts +131 -0
- package/src/utils/familyHash.ts +8 -0
- package/src/utils/redact.ts +112 -0
- package/src/utils/requestFilter.ts +79 -0
- package/src/utils/stackFrame.ts +44 -0
- package/src/watchers/AuthWatcher.ts +53 -0
- package/src/watchers/BatchWatcher.ts +55 -0
- package/src/watchers/CacheWatcher.ts +72 -0
- package/src/watchers/CommandWatcher.ts +58 -0
- package/src/watchers/DumpWatcher.ts +45 -0
- package/src/watchers/EventWatcher.ts +46 -0
- package/src/watchers/ExceptionWatcher.ts +130 -0
- package/src/watchers/GateWatcher.ts +53 -0
- package/src/watchers/HttpClientWatcher.ts +219 -0
- package/src/watchers/HttpWatcher.ts +249 -0
- package/src/watchers/JobWatcher.ts +124 -0
- package/src/watchers/LogWatcher.ts +120 -0
- package/src/watchers/MailWatcher.ts +65 -0
- package/src/watchers/MiddlewareWatcher.ts +54 -0
- package/src/watchers/ModelWatcher.ts +60 -0
- package/src/watchers/NotificationWatcher.ts +60 -0
- package/src/watchers/QueryWatcher.ts +105 -0
- package/src/watchers/RedisWatcher.ts +42 -0
- package/src/watchers/ScheduleWatcher.ts +57 -0
- package/src/watchers/ViewWatcher.ts +40 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { TraceContext } from '../context';
|
|
2
|
+
import type { EventContent, ITraceWatcher, ITraceWatcherConfig } from '../types';
|
|
3
|
+
import { EntryType } from '../types';
|
|
4
|
+
import { AuthTag } from '../utils/authTag';
|
|
5
|
+
import { RequestFilter } from '../utils/requestFilter';
|
|
6
|
+
|
|
7
|
+
let _storage: ITraceWatcherConfig['storage'] | null = null;
|
|
8
|
+
let _ignoreRoutes: string[] = [];
|
|
9
|
+
let _ignorePaths: string[] = [];
|
|
10
|
+
|
|
11
|
+
const emit = (name: string, listenerCount: number, payload?: unknown): void => {
|
|
12
|
+
if (!_storage) return;
|
|
13
|
+
if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes, _ignorePaths)) return;
|
|
14
|
+
const content: EventContent = {
|
|
15
|
+
name,
|
|
16
|
+
payload,
|
|
17
|
+
listenerCount,
|
|
18
|
+
hostname: TraceContext.getHostname(),
|
|
19
|
+
};
|
|
20
|
+
_storage
|
|
21
|
+
.writeEntry({
|
|
22
|
+
uuid: crypto.randomUUID(),
|
|
23
|
+
batchId: TraceContext.getBatchId(),
|
|
24
|
+
type: EntryType.EVENT,
|
|
25
|
+
content,
|
|
26
|
+
tags: AuthTag.append([name]),
|
|
27
|
+
isLatest: true,
|
|
28
|
+
createdAt: TraceContext.now(),
|
|
29
|
+
})
|
|
30
|
+
.catch(() => undefined);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const EventWatcher: ITraceWatcher & { emit: typeof emit } = Object.freeze({
|
|
34
|
+
emit,
|
|
35
|
+
register({ storage, config }: ITraceWatcherConfig): () => void {
|
|
36
|
+
if (config.watchers.event === false) return () => undefined;
|
|
37
|
+
_storage = storage;
|
|
38
|
+
_ignoreRoutes = config.ignoreRoutes;
|
|
39
|
+
_ignorePaths = config.ignorePaths;
|
|
40
|
+
return () => {
|
|
41
|
+
_storage = null;
|
|
42
|
+
_ignoreRoutes = [];
|
|
43
|
+
_ignorePaths = [];
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExceptionWatcher — captures unhandled exceptions by hooking into the
|
|
3
|
+
* framework error middleware. Core must call ExceptionWatcher.capture()
|
|
4
|
+
* from within its error handler, or the register() side-effect adds a
|
|
5
|
+
* process-level unhandledRejection/uncaughtException listener as fallback.
|
|
6
|
+
*/
|
|
7
|
+
import { TraceContext } from '../context';
|
|
8
|
+
import type { ExceptionContent, ITraceWatcher, ITraceWatcherConfig } from '../types';
|
|
9
|
+
import { EntryType } from '../types';
|
|
10
|
+
import { AuthTag } from '../utils/authTag';
|
|
11
|
+
import { familyHash } from '../utils/familyHash';
|
|
12
|
+
import { RequestFilter } from '../utils/requestFilter';
|
|
13
|
+
import { parseStackFrameLine } from '../utils/stackFrame';
|
|
14
|
+
|
|
15
|
+
type ExceptionCaptureContext = {
|
|
16
|
+
batchId?: string;
|
|
17
|
+
hostname?: string;
|
|
18
|
+
path?: string;
|
|
19
|
+
userId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getLinePreview = (_file: string, _line: number): Record<string, string> => {
|
|
23
|
+
return {};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const buildContent = (err: Error, context?: ExceptionCaptureContext): ExceptionContent => {
|
|
27
|
+
const stack = err.stack ?? '';
|
|
28
|
+
const trace: ExceptionContent['trace'] = stack
|
|
29
|
+
.split('\n')
|
|
30
|
+
.slice(1)
|
|
31
|
+
.map(parseStackFrameLine)
|
|
32
|
+
.filter((x): x is { file: string; line: number } => x !== null)
|
|
33
|
+
.slice(0, 20);
|
|
34
|
+
|
|
35
|
+
const firstFrame = trace[0];
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
class: err.constructor?.name ?? 'Error',
|
|
39
|
+
file: firstFrame?.file ?? 'unknown',
|
|
40
|
+
line: firstFrame?.line ?? 0,
|
|
41
|
+
message: err.message,
|
|
42
|
+
trace,
|
|
43
|
+
linePreview: firstFrame ? getLinePreview(firstFrame.file, firstFrame.line) : {},
|
|
44
|
+
occurrences: 1,
|
|
45
|
+
hostname: context?.hostname ?? TraceContext.getHostname(),
|
|
46
|
+
userId: context?.userId ?? TraceContext.getUserId(),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let _storage: ITraceWatcherConfig['storage'] | null = null;
|
|
51
|
+
let _listenerRefCount = 0;
|
|
52
|
+
let _ignoreRoutes: string[] = [];
|
|
53
|
+
let _ignorePaths: string[] = [];
|
|
54
|
+
|
|
55
|
+
const handleUncaughtException = (error: unknown): void => {
|
|
56
|
+
captureException(error);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleUnhandledRejection = (reason: unknown): void => {
|
|
60
|
+
captureException(reason);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const registerProcessListeners = (): void => {
|
|
64
|
+
if (typeof process === 'undefined') return;
|
|
65
|
+
process.on('uncaughtException', handleUncaughtException);
|
|
66
|
+
process.on('unhandledRejection', handleUnhandledRejection);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const unregisterProcessListeners = (): void => {
|
|
70
|
+
if (typeof process === 'undefined') return;
|
|
71
|
+
process.off('uncaughtException', handleUncaughtException);
|
|
72
|
+
process.off('unhandledRejection', handleUnhandledRejection);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const captureException = (err: unknown, context?: ExceptionCaptureContext): void => {
|
|
76
|
+
const storage = _storage;
|
|
77
|
+
if (!storage) return;
|
|
78
|
+
if (!(err instanceof Error)) return;
|
|
79
|
+
if (context?.path !== undefined) {
|
|
80
|
+
if (RequestFilter.matchesIgnoredPath(context.path, _ignoreRoutes, _ignorePaths)) return;
|
|
81
|
+
} else if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes, _ignorePaths)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const content = buildContent(err, context);
|
|
86
|
+
const hash = familyHash(`${content.class}:${content.file}:${content.line}`);
|
|
87
|
+
const uuid = crypto.randomUUID();
|
|
88
|
+
|
|
89
|
+
storage
|
|
90
|
+
.writeEntry({
|
|
91
|
+
uuid,
|
|
92
|
+
batchId: context?.batchId ?? TraceContext.getBatchId(),
|
|
93
|
+
familyHash: hash,
|
|
94
|
+
type: EntryType.EXCEPTION,
|
|
95
|
+
content,
|
|
96
|
+
tags: AuthTag.append([content.class]),
|
|
97
|
+
isLatest: true,
|
|
98
|
+
createdAt: TraceContext.now(),
|
|
99
|
+
})
|
|
100
|
+
.then(() => storage.markFamilyStale(hash, uuid))
|
|
101
|
+
.catch(() => undefined);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const ExceptionWatcher: ITraceWatcher & {
|
|
105
|
+
capture: (err: unknown, context?: ExceptionCaptureContext) => void;
|
|
106
|
+
} = Object.freeze({
|
|
107
|
+
capture: captureException,
|
|
108
|
+
|
|
109
|
+
register({ storage, config }: ITraceWatcherConfig): () => void {
|
|
110
|
+
if (config.watchers.exception === false) return () => undefined;
|
|
111
|
+
_storage = storage;
|
|
112
|
+
_ignoreRoutes = config.ignoreRoutes;
|
|
113
|
+
_ignorePaths = config.ignorePaths;
|
|
114
|
+
|
|
115
|
+
if (_listenerRefCount === 0) {
|
|
116
|
+
registerProcessListeners();
|
|
117
|
+
}
|
|
118
|
+
_listenerRefCount += 1;
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
_listenerRefCount = Math.max(0, _listenerRefCount - 1);
|
|
122
|
+
if (_listenerRefCount === 0) {
|
|
123
|
+
unregisterProcessListeners();
|
|
124
|
+
}
|
|
125
|
+
_storage = null;
|
|
126
|
+
_ignoreRoutes = [];
|
|
127
|
+
_ignorePaths = [];
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { TraceContext } from '../context';
|
|
2
|
+
import type { GateContent, ITraceWatcher, ITraceWatcherConfig } from '../types';
|
|
3
|
+
import { EntryType } from '../types';
|
|
4
|
+
import { RequestFilter } from '../utils/requestFilter';
|
|
5
|
+
|
|
6
|
+
let _storage: ITraceWatcherConfig['storage'] | null = null;
|
|
7
|
+
let _ignoreRoutes: string[] = [];
|
|
8
|
+
let _ignorePaths: string[] = [];
|
|
9
|
+
|
|
10
|
+
const emit = (
|
|
11
|
+
ability: string,
|
|
12
|
+
result: GateContent['result'],
|
|
13
|
+
userId?: string,
|
|
14
|
+
subject?: string
|
|
15
|
+
): void => {
|
|
16
|
+
if (!_storage) return;
|
|
17
|
+
if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes, _ignorePaths)) return;
|
|
18
|
+
const tags: string[] = [ability, result];
|
|
19
|
+
if (userId) tags.push(`Auth:${userId}`);
|
|
20
|
+
const content: GateContent = {
|
|
21
|
+
ability,
|
|
22
|
+
result,
|
|
23
|
+
userId,
|
|
24
|
+
subject,
|
|
25
|
+
hostname: TraceContext.getHostname(),
|
|
26
|
+
};
|
|
27
|
+
_storage
|
|
28
|
+
.writeEntry({
|
|
29
|
+
uuid: crypto.randomUUID(),
|
|
30
|
+
batchId: TraceContext.getBatchId(),
|
|
31
|
+
type: EntryType.GATE,
|
|
32
|
+
content,
|
|
33
|
+
tags,
|
|
34
|
+
isLatest: true,
|
|
35
|
+
createdAt: TraceContext.now(),
|
|
36
|
+
})
|
|
37
|
+
.catch(() => undefined);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const GateWatcher: ITraceWatcher & { emit: typeof emit } = Object.freeze({
|
|
41
|
+
emit,
|
|
42
|
+
register({ storage, config }: ITraceWatcherConfig): () => void {
|
|
43
|
+
if (config.watchers.gate === false) return () => undefined;
|
|
44
|
+
_storage = storage;
|
|
45
|
+
_ignoreRoutes = config.ignoreRoutes;
|
|
46
|
+
_ignorePaths = config.ignorePaths;
|
|
47
|
+
return () => {
|
|
48
|
+
_storage = null;
|
|
49
|
+
_ignoreRoutes = [];
|
|
50
|
+
_ignorePaths = [];
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { TraceContext } from '../context';
|
|
2
|
+
import {
|
|
3
|
+
EntryType,
|
|
4
|
+
type ClientRequestContent,
|
|
5
|
+
type ClientRequestTraceInput,
|
|
6
|
+
type ITraceWatcher,
|
|
7
|
+
type ITraceWatcherConfig,
|
|
8
|
+
type TraceClientRequestCaptureRule,
|
|
9
|
+
type TraceClientRequestWatcherConfig,
|
|
10
|
+
} from '../types';
|
|
11
|
+
import { AuthTag } from '../utils/authTag';
|
|
12
|
+
import { redactHeaders, redactUnknown } from '../utils/redact';
|
|
13
|
+
import { RequestFilter } from '../utils/requestFilter';
|
|
14
|
+
|
|
15
|
+
let _storage: ITraceWatcherConfig['storage'] | null = null;
|
|
16
|
+
let _redactHeaderNames: string[] = [];
|
|
17
|
+
let _redactBodyFields: string[] = [];
|
|
18
|
+
let _ignoreRoutes: string[] = [];
|
|
19
|
+
let _ignorePaths: string[] = [];
|
|
20
|
+
let _clientRequestWatcher: TraceClientRequestWatcherConfig | undefined;
|
|
21
|
+
|
|
22
|
+
const isObjectValue = (value: unknown): value is Record<string, unknown> => {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const resolveSource = (value: unknown): string | undefined => {
|
|
27
|
+
if (typeof value !== 'string') return undefined;
|
|
28
|
+
const normalized = value.trim().toLowerCase();
|
|
29
|
+
return normalized === '' ? undefined : normalized;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const resolveSourceRule = (
|
|
33
|
+
source: string | undefined
|
|
34
|
+
): TraceClientRequestCaptureRule | undefined => {
|
|
35
|
+
if (source === undefined) return undefined;
|
|
36
|
+
return _clientRequestWatcher?.sources?.[source];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const shouldCaptureField = (
|
|
40
|
+
field: keyof Pick<
|
|
41
|
+
TraceClientRequestCaptureRule,
|
|
42
|
+
'requestHeaders' | 'requestBody' | 'responseHeaders' | 'responseBody'
|
|
43
|
+
>,
|
|
44
|
+
sourceRule: TraceClientRequestCaptureRule | undefined
|
|
45
|
+
): boolean => {
|
|
46
|
+
const scoped = sourceRule?.[field];
|
|
47
|
+
if (typeof scoped === 'boolean') return scoped;
|
|
48
|
+
const global = _clientRequestWatcher?.[field];
|
|
49
|
+
if (typeof global === 'boolean') return global;
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const buildRequestHeaders = (
|
|
54
|
+
requestHeaders: Record<string, string>,
|
|
55
|
+
sourceRule: TraceClientRequestCaptureRule | undefined
|
|
56
|
+
): Pick<ClientRequestContent, 'requestHeaders'> => {
|
|
57
|
+
return shouldCaptureField('requestHeaders', sourceRule)
|
|
58
|
+
? { requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames) }
|
|
59
|
+
: { requestHeaders: {} };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const buildRequestBody = (
|
|
63
|
+
requestBody: unknown,
|
|
64
|
+
sourceRule: TraceClientRequestCaptureRule | undefined
|
|
65
|
+
): Partial<Pick<ClientRequestContent, 'requestBody'>> => {
|
|
66
|
+
if (requestBody === undefined) return {};
|
|
67
|
+
if (!shouldCaptureField('requestBody', sourceRule)) return {};
|
|
68
|
+
return { requestBody: redactUnknown(requestBody, _redactBodyFields) };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const buildResponseHeaders = (
|
|
72
|
+
responseHeaders: Record<string, string> | undefined,
|
|
73
|
+
sourceRule: TraceClientRequestCaptureRule | undefined
|
|
74
|
+
): Partial<Pick<ClientRequestContent, 'responseHeaders'>> => {
|
|
75
|
+
if (responseHeaders === undefined) return {};
|
|
76
|
+
if (!shouldCaptureField('responseHeaders', sourceRule)) return {};
|
|
77
|
+
return { responseHeaders: redactHeaders(responseHeaders, _redactHeaderNames) };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const buildResponseBody = (
|
|
81
|
+
responseBody: unknown,
|
|
82
|
+
sourceRule: TraceClientRequestCaptureRule | undefined
|
|
83
|
+
): Partial<Pick<ClientRequestContent, 'responseBody'>> => {
|
|
84
|
+
if (responseBody === undefined) return {};
|
|
85
|
+
if (!shouldCaptureField('responseBody', sourceRule)) return {};
|
|
86
|
+
return { responseBody: redactUnknown(responseBody, _redactBodyFields) };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const applySource = (content: ClientRequestContent, normalizedSource: string | undefined): void => {
|
|
90
|
+
if (normalizedSource !== undefined) {
|
|
91
|
+
content.source = normalizedSource;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const applyResponseStatus = (
|
|
96
|
+
content: ClientRequestContent,
|
|
97
|
+
responseStatus: number | undefined
|
|
98
|
+
): void => {
|
|
99
|
+
if (responseStatus !== undefined) {
|
|
100
|
+
content.responseStatus = responseStatus;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const applyError = (content: ClientRequestContent, error: unknown): void => {
|
|
105
|
+
if (typeof error === 'string' && error !== '') {
|
|
106
|
+
content.error = error;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const mergePartialContent = (
|
|
111
|
+
content: ClientRequestContent,
|
|
112
|
+
partial: Partial<ClientRequestContent>
|
|
113
|
+
): void => {
|
|
114
|
+
Object.assign(content, partial);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const buildClientRequestContent = (
|
|
118
|
+
input: ClientRequestTraceInput,
|
|
119
|
+
sourceRule: TraceClientRequestCaptureRule | undefined,
|
|
120
|
+
normalizedSource: string | undefined
|
|
121
|
+
): ClientRequestContent => {
|
|
122
|
+
const content: ClientRequestContent = {
|
|
123
|
+
method: input.method.toUpperCase(),
|
|
124
|
+
url: input.url,
|
|
125
|
+
requestHeaders: {},
|
|
126
|
+
duration: input.duration,
|
|
127
|
+
hostname: TraceContext.getHostname(),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
applySource(content, normalizedSource);
|
|
131
|
+
mergePartialContent(content, buildRequestHeaders(input.requestHeaders, sourceRule));
|
|
132
|
+
mergePartialContent(content, buildRequestBody(input.requestBody, sourceRule));
|
|
133
|
+
applyResponseStatus(content, input.responseStatus);
|
|
134
|
+
mergePartialContent(content, buildResponseHeaders(input.responseHeaders, sourceRule));
|
|
135
|
+
mergePartialContent(content, buildResponseBody(input.responseBody, sourceRule));
|
|
136
|
+
applyError(content, input.error);
|
|
137
|
+
|
|
138
|
+
return content;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const isWatcherEnabled = (
|
|
142
|
+
value: ITraceWatcherConfig['config']['watchers']['clientRequest']
|
|
143
|
+
): boolean => {
|
|
144
|
+
if (value === false) return false;
|
|
145
|
+
if (isObjectValue(value) && value.enabled === false) return false;
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const emit = ({
|
|
150
|
+
source,
|
|
151
|
+
method,
|
|
152
|
+
url,
|
|
153
|
+
requestHeaders,
|
|
154
|
+
responseStatus,
|
|
155
|
+
duration,
|
|
156
|
+
requestBody,
|
|
157
|
+
responseHeaders,
|
|
158
|
+
responseBody,
|
|
159
|
+
error,
|
|
160
|
+
}: ClientRequestTraceInput): void => {
|
|
161
|
+
if (!_storage) return;
|
|
162
|
+
if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes, _ignorePaths)) return;
|
|
163
|
+
const normalizedSource = resolveSource(source);
|
|
164
|
+
const sourceRule = resolveSourceRule(normalizedSource);
|
|
165
|
+
if (sourceRule?.enabled === false) return;
|
|
166
|
+
const tags = AuthTag.append([method.toUpperCase()]);
|
|
167
|
+
if ((responseStatus ?? 0) >= 400 || error) tags.push('failed');
|
|
168
|
+
if (normalizedSource !== undefined) tags.push(normalizedSource);
|
|
169
|
+
const content = buildClientRequestContent(
|
|
170
|
+
{
|
|
171
|
+
source,
|
|
172
|
+
method,
|
|
173
|
+
url,
|
|
174
|
+
requestHeaders,
|
|
175
|
+
responseStatus,
|
|
176
|
+
duration,
|
|
177
|
+
requestBody,
|
|
178
|
+
responseHeaders,
|
|
179
|
+
responseBody,
|
|
180
|
+
error,
|
|
181
|
+
},
|
|
182
|
+
sourceRule,
|
|
183
|
+
normalizedSource
|
|
184
|
+
);
|
|
185
|
+
_storage
|
|
186
|
+
.writeEntry({
|
|
187
|
+
uuid: crypto.randomUUID(),
|
|
188
|
+
batchId: TraceContext.getBatchId(),
|
|
189
|
+
type: EntryType.CLIENT_REQUEST,
|
|
190
|
+
content,
|
|
191
|
+
tags,
|
|
192
|
+
isLatest: true,
|
|
193
|
+
createdAt: TraceContext.now(),
|
|
194
|
+
})
|
|
195
|
+
.catch(() => undefined);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const HttpClientWatcher: ITraceWatcher & { emit: typeof emit } = Object.freeze({
|
|
199
|
+
emit,
|
|
200
|
+
register({ storage, config }: ITraceWatcherConfig): () => void {
|
|
201
|
+
if (!isWatcherEnabled(config.watchers.clientRequest)) return () => undefined;
|
|
202
|
+
_storage = storage;
|
|
203
|
+
_clientRequestWatcher =
|
|
204
|
+
typeof config.watchers.clientRequest === 'object' && config.watchers.clientRequest !== null
|
|
205
|
+
? config.watchers.clientRequest
|
|
206
|
+
: undefined;
|
|
207
|
+
_redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
|
|
208
|
+
_redactBodyFields = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
|
|
209
|
+
_ignoreRoutes = config.ignoreRoutes;
|
|
210
|
+
_ignorePaths = config.ignorePaths;
|
|
211
|
+
return () => {
|
|
212
|
+
_storage = null;
|
|
213
|
+
_clientRequestWatcher = undefined;
|
|
214
|
+
_redactBodyFields = [];
|
|
215
|
+
_ignoreRoutes = [];
|
|
216
|
+
_ignorePaths = [];
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HttpWatcher — records inbound HTTP requests as trace entries.
|
|
3
|
+
* Registers as a global middleware via Kernel.registerGlobalMiddleware().
|
|
4
|
+
*/
|
|
5
|
+
import type { IRequest, IResponse } from '@zintrust/core';
|
|
6
|
+
import { Logger } from '@zintrust/core';
|
|
7
|
+
import { TraceContext } from '../context';
|
|
8
|
+
import type { ITraceConfig, ITraceWatcher, ITraceWatcherConfig, RequestContent } from '../types';
|
|
9
|
+
import { EntryType } from '../types';
|
|
10
|
+
import { AuthTag } from '../utils/authTag';
|
|
11
|
+
import { redactHeaders, redactObject, redactUnknown } from '../utils/redact';
|
|
12
|
+
import { RequestFilter } from '../utils/requestFilter';
|
|
13
|
+
|
|
14
|
+
const normalizeHeaders = (headers: IRequest['headers']): Record<string, string> => {
|
|
15
|
+
if (!headers) return {};
|
|
16
|
+
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.entries(headers).flatMap(([key, value]) => {
|
|
19
|
+
if (typeof value === 'string') return [[key, value]];
|
|
20
|
+
if (Array.isArray(value)) return [[key, value.join(', ')]];
|
|
21
|
+
return [];
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const normalizeHeaderValue = (value: string | string[]): string => {
|
|
27
|
+
return Array.isArray(value) ? value.join(', ') : value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const resolveRouteMiddleware = (req: IRequest): string[] => {
|
|
31
|
+
const middleware = req.context?.['traceRouteMiddleware'];
|
|
32
|
+
return Array.isArray(middleware)
|
|
33
|
+
? middleware.filter((value): value is string => typeof value === 'string')
|
|
34
|
+
: [];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const resolveRequestPayload = (req: IRequest, config: ITraceConfig): unknown => {
|
|
38
|
+
const redactFields = [...config.redaction.keys, ...config.redaction.body];
|
|
39
|
+
const requestBody = typeof req.getBody === 'function' ? req.getBody() : req.body;
|
|
40
|
+
|
|
41
|
+
if (requestBody === undefined || requestBody === null) return {};
|
|
42
|
+
if (typeof requestBody === 'object') {
|
|
43
|
+
return redactObject(requestBody as Record<string, unknown>, redactFields);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return redactUnknown(requestBody, redactFields);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type ResponseCapture = {
|
|
50
|
+
headers: Record<string, string>;
|
|
51
|
+
body?: unknown;
|
|
52
|
+
restore(): void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type RawResponseWithLifecycle = ReturnType<IResponse['getRaw']> & {
|
|
56
|
+
once?: (event: 'finish' | 'close', listener: () => void) => unknown;
|
|
57
|
+
off?: (event: 'finish' | 'close', listener: () => void) => unknown;
|
|
58
|
+
writableEnded?: boolean;
|
|
59
|
+
finished?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type CompletionHandlerRegistration = {
|
|
63
|
+
attached: boolean;
|
|
64
|
+
cleanup(): void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const registerCompletionHandler = (
|
|
68
|
+
response: IResponse,
|
|
69
|
+
onComplete: () => void
|
|
70
|
+
): CompletionHandlerRegistration => {
|
|
71
|
+
const raw: RawResponseWithLifecycle = response.getRaw();
|
|
72
|
+
if (typeof raw.once !== 'function') {
|
|
73
|
+
return { attached: false, cleanup: () => undefined };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let completed = false;
|
|
77
|
+
|
|
78
|
+
const cleanup = (): void => {
|
|
79
|
+
if (typeof raw.off === 'function') {
|
|
80
|
+
raw.off('finish', markCompleted);
|
|
81
|
+
raw.off('close', markCompleted);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const markCompleted = (): void => {
|
|
86
|
+
if (completed) return;
|
|
87
|
+
completed = true;
|
|
88
|
+
cleanup();
|
|
89
|
+
onComplete();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
raw.once('finish', markCompleted);
|
|
93
|
+
raw.once('close', markCompleted);
|
|
94
|
+
|
|
95
|
+
return { attached: true, cleanup };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const isResponseComplete = (response: IResponse): boolean => {
|
|
99
|
+
const raw: RawResponseWithLifecycle = response.getRaw();
|
|
100
|
+
return raw.writableEnded === true || raw.finished === true;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const captureResponse = (response: IResponse, config: ITraceConfig): ResponseCapture => {
|
|
104
|
+
const headers: Record<string, string> = {};
|
|
105
|
+
const redactFields = [...config.redaction.keys, ...config.redaction.body];
|
|
106
|
+
|
|
107
|
+
const originalSetHeader = response.setHeader;
|
|
108
|
+
const originalJson = response.json;
|
|
109
|
+
const originalText = response.text;
|
|
110
|
+
const originalHtml = response.html;
|
|
111
|
+
const originalSend = response.send;
|
|
112
|
+
|
|
113
|
+
const capture: ResponseCapture = {
|
|
114
|
+
headers,
|
|
115
|
+
body: undefined,
|
|
116
|
+
restore(): void {
|
|
117
|
+
response.setHeader = originalSetHeader;
|
|
118
|
+
response.json = originalJson;
|
|
119
|
+
response.text = originalText;
|
|
120
|
+
response.html = originalHtml;
|
|
121
|
+
response.send = originalSend;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
response.setHeader = function setHeader(name: string, value: string | string[]): IResponse {
|
|
126
|
+
headers[name] = normalizeHeaderValue(value);
|
|
127
|
+
return originalSetHeader.call(this, name, value);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
response.json = function json(data: unknown): void {
|
|
131
|
+
capture.body = redactUnknown(data, redactFields);
|
|
132
|
+
originalJson.call(this, data);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
response.text = function text(value: string): void {
|
|
136
|
+
capture.body = value;
|
|
137
|
+
originalText.call(this, value);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
response.html = function html(value: string): void {
|
|
141
|
+
capture.body = value;
|
|
142
|
+
originalHtml.call(this, value);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
response.send = function send(data: string | Buffer): void {
|
|
146
|
+
capture.body = typeof data === 'string' ? data : `[binary ${data.length} bytes]`;
|
|
147
|
+
originalSend.call(this, data);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return capture;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const buildEntry = (
|
|
154
|
+
req: IRequest,
|
|
155
|
+
res: IResponse,
|
|
156
|
+
start: number,
|
|
157
|
+
config: ITraceConfig,
|
|
158
|
+
responseCapture: ResponseCapture
|
|
159
|
+
): RequestContent => {
|
|
160
|
+
const headers = redactHeaders(normalizeHeaders(req.headers), [
|
|
161
|
+
...config.redaction.keys,
|
|
162
|
+
...config.redaction.headers,
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
method: req.getMethod(),
|
|
167
|
+
uri: req.getPath(),
|
|
168
|
+
headers,
|
|
169
|
+
payload: resolveRequestPayload(req, config),
|
|
170
|
+
responseStatus: res.getStatus(),
|
|
171
|
+
responseHeaders: redactHeaders(responseCapture.headers, [
|
|
172
|
+
...config.redaction.keys,
|
|
173
|
+
...config.redaction.headers,
|
|
174
|
+
]),
|
|
175
|
+
responseBody: responseCapture.body,
|
|
176
|
+
duration: Date.now() - start,
|
|
177
|
+
memory: TraceContext.getMemory(),
|
|
178
|
+
middleware: resolveRouteMiddleware(req),
|
|
179
|
+
hostname: TraceContext.getHostname(),
|
|
180
|
+
userId: TraceContext.getUserId(),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const shouldIgnore = (req: IRequest, config: ITraceConfig): boolean => {
|
|
185
|
+
return RequestFilter.matchesIgnoredPath(req.getPath(), config);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const isWatcherEnabled = (config: ITraceConfig): boolean => config.watchers.request !== false;
|
|
189
|
+
|
|
190
|
+
export const HttpWatcher: ITraceWatcher = Object.freeze({
|
|
191
|
+
register({ storage, config, registerMiddleware }: ITraceWatcherConfig): () => void {
|
|
192
|
+
if (!isWatcherEnabled(config)) return () => undefined;
|
|
193
|
+
if (!registerMiddleware) return () => undefined;
|
|
194
|
+
|
|
195
|
+
const middleware: Parameters<
|
|
196
|
+
NonNullable<ITraceWatcherConfig['registerMiddleware']>
|
|
197
|
+
>[0] = async (req: unknown, res: unknown, next: () => Promise<void>): Promise<void> => {
|
|
198
|
+
const request = req as IRequest;
|
|
199
|
+
const response = res as IResponse;
|
|
200
|
+
|
|
201
|
+
if (shouldIgnore(request, config)) return next();
|
|
202
|
+
|
|
203
|
+
const start = TraceContext.now();
|
|
204
|
+
const batchId = TraceContext.getBatchId();
|
|
205
|
+
const responseCapture = captureResponse(response, config);
|
|
206
|
+
let didPersist = false;
|
|
207
|
+
|
|
208
|
+
const persistEntry = (): void => {
|
|
209
|
+
if (didPersist) return;
|
|
210
|
+
didPersist = true;
|
|
211
|
+
|
|
212
|
+
const content = buildEntry(request, response, start, config, responseCapture);
|
|
213
|
+
const tags = AuthTag.append([]);
|
|
214
|
+
if (content.responseStatus >= 500) tags.push('failed');
|
|
215
|
+
|
|
216
|
+
responseCapture.restore();
|
|
217
|
+
|
|
218
|
+
const entry = {
|
|
219
|
+
uuid: crypto.randomUUID(),
|
|
220
|
+
batchId,
|
|
221
|
+
type: EntryType.REQUEST,
|
|
222
|
+
content,
|
|
223
|
+
tags,
|
|
224
|
+
isLatest: true,
|
|
225
|
+
createdAt: TraceContext.now(),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
storage.writeEntry(entry).catch((error: unknown) => {
|
|
229
|
+
Logger.warn('[trace] HttpWatcher writeEntry failed', {
|
|
230
|
+
method: content.method,
|
|
231
|
+
uri: content.uri,
|
|
232
|
+
entryUuid: entry.uuid,
|
|
233
|
+
error: error instanceof Error ? error.message : String(error),
|
|
234
|
+
});
|
|
235
|
+
}); // fire-and-forget
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const completionHandler = registerCompletionHandler(response, persistEntry);
|
|
239
|
+
await next();
|
|
240
|
+
|
|
241
|
+
if (!completionHandler.attached || isResponseComplete(response)) {
|
|
242
|
+
persistEntry();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
registerMiddleware(middleware);
|
|
247
|
+
return () => undefined;
|
|
248
|
+
},
|
|
249
|
+
});
|