@zintrust/trace 0.4.95 → 0.5.0

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.
@@ -27,6 +27,87 @@ const describeValueType = (value: unknown): string => {
27
27
  return typeof value;
28
28
  };
29
29
 
30
+ type TracePathSegment = string | number;
31
+
32
+ type TracePathCandidate = {
33
+ path: TracePathSegment[];
34
+ size: number;
35
+ };
36
+
37
+ const chooseLargerCandidate = (
38
+ left: TracePathCandidate | null,
39
+ right: TracePathCandidate | null
40
+ ): TracePathCandidate | null => {
41
+ if (left === null) return right;
42
+ if (right === null) return left;
43
+ return right.size > left.size ? right : left;
44
+ };
45
+
46
+ const fallbackCandidate = (value: unknown, path: TracePathSegment[]): TracePathCandidate | null => {
47
+ return path.length === 0 ? null : { path, size: serializedSize(value) };
48
+ };
49
+
50
+ const findLargestDroppablePathInArray = (
51
+ value: unknown[],
52
+ path: TracePathSegment[]
53
+ ): TracePathCandidate | null => {
54
+ let best: TracePathCandidate | null = null;
55
+
56
+ for (const [index, item] of value.entries()) {
57
+ best = chooseLargerCandidate(best, findLargestDroppablePath(item, [...path, index]));
58
+ }
59
+
60
+ return best ?? fallbackCandidate(value, path);
61
+ };
62
+
63
+ const findLargestDroppablePathInObject = (
64
+ value: Record<string, unknown>,
65
+ path: TracePathSegment[]
66
+ ): TracePathCandidate | null => {
67
+ let best: TracePathCandidate | null = null;
68
+
69
+ for (const [key, entryValue] of Object.entries(value)) {
70
+ if (key === '__traceNotice') continue;
71
+ best = chooseLargerCandidate(best, findLargestDroppablePath(entryValue, [...path, key]));
72
+ }
73
+
74
+ return best ?? fallbackCandidate(value, path);
75
+ };
76
+
77
+ const findLargestDroppablePath = (
78
+ value: unknown,
79
+ path: TracePathSegment[] = []
80
+ ): TracePathCandidate | null => {
81
+ if (Array.isArray(value)) return findLargestDroppablePathInArray(value, path);
82
+ if (typeof value === 'object' && value !== null) {
83
+ return findLargestDroppablePathInObject(value as Record<string, unknown>, path);
84
+ }
85
+
86
+ return fallbackCandidate(value, path);
87
+ };
88
+
89
+ const replaceAtPath = (value: unknown, path: TracePathSegment[], replacement: unknown): unknown => {
90
+ if (path.length === 0) return replacement;
91
+
92
+ const [segment, ...rest] = path;
93
+
94
+ if (Array.isArray(value) && typeof segment === 'number') {
95
+ const next = value.slice();
96
+ next[segment] = replaceAtPath(next[segment], rest, replacement);
97
+ return next;
98
+ }
99
+
100
+ if (typeof value === 'object' && value !== null && typeof segment === 'string') {
101
+ const current = value as Record<string, unknown>;
102
+ return {
103
+ ...current,
104
+ [segment]: replaceAtPath(current[segment], rest, replacement),
105
+ };
106
+ }
107
+
108
+ return value;
109
+ };
110
+
30
111
  const compactValue = (value: unknown, depth: number): unknown => {
31
112
  if (depth >= DEFAULT_MAX_DEPTH) {
32
113
  return DROPPED_FIELD_MESSAGE;
@@ -69,19 +150,19 @@ const compactValue = (value: unknown, depth: number): unknown => {
69
150
  return Object.fromEntries(compactedEntries);
70
151
  };
71
152
 
72
- const compactTopLevelObjectToBudget = (value: Record<string, unknown>): Record<string, unknown> => {
73
- const compacted: Record<string, unknown> = {
74
- ...value,
75
- __traceNotice: COMPACTED_CONTENT_MESSAGE,
76
- };
77
-
78
- const keysByDescendingSize = Object.keys(compacted)
79
- .filter((key) => key !== '__traceNotice')
80
- .sort((left, right) => serializedSize(compacted[right]) - serializedSize(compacted[left]));
81
-
82
- for (const key of keysByDescendingSize) {
83
- if (serializedSize(compacted) <= DEFAULT_MAX_ENTRY_BYTES) break;
84
- compacted[key] = DROPPED_FIELD_MESSAGE;
153
+ const compactStructuredValueToBudget = (value: unknown): unknown => {
154
+ let compacted: unknown =
155
+ typeof value === 'object' && value !== null && !Array.isArray(value)
156
+ ? {
157
+ ...(value as Record<string, unknown>),
158
+ __traceNotice: COMPACTED_CONTENT_MESSAGE,
159
+ }
160
+ : value;
161
+
162
+ while (serializedSize(compacted) > DEFAULT_MAX_ENTRY_BYTES) {
163
+ const candidate = findLargestDroppablePath(compacted);
164
+ if (candidate === null) break;
165
+ compacted = replaceAtPath(compacted, candidate.path, DROPPED_FIELD_MESSAGE);
85
166
  }
86
167
 
87
168
  return compacted;
@@ -97,10 +178,10 @@ const fitContentToBudget = (content: unknown): unknown => {
97
178
  return compacted;
98
179
  }
99
180
 
100
- if (typeof compacted === 'object' && compacted !== null && !Array.isArray(compacted)) {
101
- const topLevelCompacted = compactTopLevelObjectToBudget(compacted as Record<string, unknown>);
102
- if (serializedSize(topLevelCompacted) <= DEFAULT_MAX_ENTRY_BYTES) {
103
- return topLevelCompacted;
181
+ if (typeof compacted === 'object' && compacted !== null) {
182
+ const budgetCompacted = compactStructuredValueToBudget(compacted);
183
+ if (serializedSize(budgetCompacted) <= DEFAULT_MAX_ENTRY_BYTES) {
184
+ return budgetCompacted;
104
185
  }
105
186
  }
106
187
 
package/src/types.ts CHANGED
@@ -209,6 +209,7 @@ export interface ViewContent {
209
209
  }
210
210
 
211
211
  export interface ClientRequestContent {
212
+ source?: string;
212
213
  method: string;
213
214
  url: string;
214
215
  requestHeaders: Record<string, string>;
@@ -222,6 +223,7 @@ export interface ClientRequestContent {
222
223
  }
223
224
 
224
225
  export interface ClientRequestTraceInput {
226
+ source?: string;
225
227
  method: string;
226
228
  url: string;
227
229
  requestHeaders: Record<string, string>;
@@ -315,6 +317,13 @@ export type TraceFilterRule = {
315
317
  exclude?: string[];
316
318
  };
317
319
 
320
+ export type TraceClientRequestCaptureRule = TraceFilterRule & {
321
+ requestHeaders?: boolean;
322
+ requestBody?: boolean;
323
+ responseHeaders?: boolean;
324
+ responseBody?: boolean;
325
+ };
326
+
318
327
  export type TraceRequestWatcherConfig = TraceFilterRule & {
319
328
  all?: TraceFilterRule;
320
329
  get?: TraceFilterRule;
@@ -324,8 +333,13 @@ export type TraceRequestWatcherConfig = TraceFilterRule & {
324
333
  delete?: TraceFilterRule;
325
334
  };
326
335
 
336
+ export type TraceClientRequestWatcherConfig = TraceClientRequestCaptureRule & {
337
+ sources?: Record<string, TraceClientRequestCaptureRule>;
338
+ };
339
+
327
340
  export type TraceWatcherToggle = boolean | TraceFilterRule;
328
341
  export type TraceRequestWatcherToggle = boolean | TraceRequestWatcherConfig;
342
+ export type TraceClientRequestWatcherToggle = boolean | TraceClientRequestWatcherConfig;
329
343
 
330
344
  export type WatcherToggles = {
331
345
  request?: TraceRequestWatcherToggle;
@@ -347,7 +361,7 @@ export type WatcherToggles = {
347
361
  batch?: TraceWatcherToggle;
348
362
  dump?: TraceWatcherToggle;
349
363
  view?: TraceWatcherToggle;
350
- clientRequest?: TraceWatcherToggle;
364
+ clientRequest?: TraceClientRequestWatcherToggle;
351
365
  };
352
366
 
353
367
  export interface ITraceConfig {
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ITraceConfig,
3
3
  ITraceEntry,
4
+ TraceClientRequestWatcherConfig,
4
5
  TraceFilterRule,
5
6
  TraceRequestWatcherConfig,
6
7
  WatcherToggles,
@@ -22,6 +23,7 @@ const normalizeTerms = (terms?: string[]): string[] => {
22
23
 
23
24
  const matchesRule = (haystack: string, rule?: TraceFilterRule): boolean => {
24
25
  if (!rule) return true;
26
+ if (rule.enabled === false) return false;
25
27
 
26
28
  const include = normalizeTerms(rule.include);
27
29
  const exclude = normalizeTerms(rule.exclude);
@@ -86,6 +88,20 @@ const getRequestMethodRule = (
86
88
  return watcher.all;
87
89
  };
88
90
 
91
+ const getClientRequestSourceRule = (
92
+ watcher: TraceClientRequestWatcherConfig,
93
+ entry: ITraceEntry
94
+ ): TraceFilterRule | undefined => {
95
+ if (entry.type !== EntryType.CLIENT_REQUEST) return undefined;
96
+
97
+ const content = isObjectValue(entry.content) ? entry.content : undefined;
98
+ const sourceValue = content?.['source'];
99
+ const source = typeof sourceValue === 'string' ? sourceValue.trim().toLowerCase() : '';
100
+
101
+ if (source === '') return undefined;
102
+ return watcher.sources?.[source];
103
+ };
104
+
89
105
  export const TraceEntryFilter = Object.freeze({
90
106
  shouldCapture(entry: ITraceEntry, config: ITraceConfig): boolean {
91
107
  const watcherKey = watcherKeyByEntryType[entry.type];
@@ -103,6 +119,13 @@ export const TraceEntryFilter = Object.freeze({
103
119
  if (!matchesRule(haystack, methodRule)) return false;
104
120
  }
105
121
 
122
+ if (watcherKey === 'clientRequest') {
123
+ const clientRequestWatcher = watcher as TraceClientRequestWatcherConfig;
124
+ const sourceRule = getClientRequestSourceRule(clientRequestWatcher, entry);
125
+ if (sourceRule?.enabled === false) return false;
126
+ if (!matchesRule(haystack, sourceRule)) return false;
127
+ }
128
+
106
129
  return true;
107
130
  },
108
131
  });
@@ -1,11 +1,14 @@
1
+ import { generateUuid } from '@zintrust/core';
1
2
  import { TraceContext } from '../context';
2
- import type {
3
- ClientRequestContent,
4
- ClientRequestTraceInput,
5
- ITraceWatcher,
6
- ITraceWatcherConfig,
3
+ import {
4
+ EntryType,
5
+ type ClientRequestContent,
6
+ type ClientRequestTraceInput,
7
+ type ITraceWatcher,
8
+ type ITraceWatcherConfig,
9
+ type TraceClientRequestCaptureRule,
10
+ type TraceClientRequestWatcherConfig,
7
11
  } from '../types';
8
- import { EntryType } from '../types';
9
12
  import { AuthTag } from '../utils/authTag';
10
13
  import { redactHeaders, redactUnknown } from '../utils/redact';
11
14
  import { RequestFilter } from '../utils/requestFilter';
@@ -14,8 +17,137 @@ let _storage: ITraceWatcherConfig['storage'] | null = null;
14
17
  let _redactHeaderNames: string[] = [];
15
18
  let _redactBodyFields: string[] = [];
16
19
  let _ignoreRoutes: 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
+ };
17
148
 
18
149
  const emit = ({
150
+ source,
19
151
  method,
20
152
  url,
21
153
  requestHeaders,
@@ -28,29 +160,31 @@ const emit = ({
28
160
  }: ClientRequestTraceInput): void => {
29
161
  if (!_storage) return;
30
162
  if (RequestFilter.shouldIgnoreCurrentRequest(_ignoreRoutes)) return;
163
+ const normalizedSource = resolveSource(source);
164
+ const sourceRule = resolveSourceRule(normalizedSource);
165
+ if (sourceRule?.enabled === false) return;
31
166
  const tags = AuthTag.append([method.toUpperCase()]);
32
167
  if ((responseStatus ?? 0) >= 400 || error) tags.push('failed');
33
- const content: ClientRequestContent = {
34
- method: method.toUpperCase(),
35
- url,
36
- requestHeaders: redactHeaders(requestHeaders, _redactHeaderNames),
37
- ...(requestBody === undefined
38
- ? {}
39
- : { requestBody: redactUnknown(requestBody, _redactBodyFields) }),
40
- ...(responseStatus === undefined ? {} : { responseStatus }),
41
- ...(responseHeaders === undefined
42
- ? {}
43
- : { responseHeaders: redactHeaders(responseHeaders, _redactHeaderNames) }),
44
- ...(responseBody === undefined
45
- ? {}
46
- : { responseBody: redactUnknown(responseBody, _redactBodyFields) }),
47
- ...(typeof error === 'string' && error !== '' ? { error } : {}),
48
- duration,
49
- hostname: TraceContext.getHostname(),
50
- };
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
+ );
51
185
  _storage
52
186
  .writeEntry({
53
- uuid: crypto.randomUUID(),
187
+ uuid: generateUuid(),
54
188
  batchId: TraceContext.getBatchId(),
55
189
  type: EntryType.CLIENT_REQUEST,
56
190
  content,
@@ -64,13 +198,18 @@ const emit = ({
64
198
  export const HttpClientWatcher: ITraceWatcher & { emit: typeof emit } = Object.freeze({
65
199
  emit,
66
200
  register({ storage, config }: ITraceWatcherConfig): () => void {
67
- if (config.watchers.clientRequest === false) return () => undefined;
201
+ if (!isWatcherEnabled(config.watchers.clientRequest)) return () => undefined;
68
202
  _storage = storage;
203
+ _clientRequestWatcher =
204
+ typeof config.watchers.clientRequest === 'object' && config.watchers.clientRequest !== null
205
+ ? config.watchers.clientRequest
206
+ : undefined;
69
207
  _redactHeaderNames = [...(config.redaction?.keys ?? []), ...(config.redaction?.headers ?? [])];
70
208
  _redactBodyFields = [...(config.redaction?.keys ?? []), ...(config.redaction?.body ?? [])];
71
209
  _ignoreRoutes = config.ignoreRoutes;
72
210
  return () => {
73
211
  _storage = null;
212
+ _clientRequestWatcher = undefined;
74
213
  _redactBodyFields = [];
75
214
  _ignoreRoutes = [];
76
215
  };