@zintrust/trace 0.5.2 → 0.5.5

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.
@@ -1,4 +1,4 @@
1
- import type { ITraceEntry, ITraceStorage } from '../types';
1
+ import type { ITraceConfig, ITraceEntry, ITraceStorage } from '../types';
2
2
 
3
3
  const DEFAULT_MAX_ENTRY_BYTES = 64 * 1024;
4
4
  const DEFAULT_MAX_STRING_BYTES = 16 * 1024;
@@ -10,6 +10,7 @@ const DROPPED_FIELD_MESSAGE =
10
10
  '[trace] Value dropped because the field exceeded the trace storage size limit.';
11
11
  const COMPACTED_CONTENT_MESSAGE =
12
12
  '[trace] Trace content was compacted because it exceeded the trace storage size limit.';
13
+ const REPLACED_CONTENT_MESSAGE = 'Trace content exceeded budget and was replaced.';
13
14
 
14
15
  const encoder = new TextEncoder();
15
16
 
@@ -27,87 +28,6 @@ const describeValueType = (value: unknown): string => {
27
28
  return typeof value;
28
29
  };
29
30
 
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
-
111
31
  const compactValue = (value: unknown, depth: number): unknown => {
112
32
  if (depth >= DEFAULT_MAX_DEPTH) {
113
33
  return DROPPED_FIELD_MESSAGE;
@@ -151,18 +71,34 @@ const compactValue = (value: unknown, depth: number): unknown => {
151
71
  };
152
72
 
153
73
  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;
74
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
75
+ return value;
76
+ }
77
+
78
+ const compacted: Record<string, unknown> = {
79
+ ...(value as Record<string, unknown>),
80
+ __traceNotice: COMPACTED_CONTENT_MESSAGE,
81
+ };
82
+
83
+ const topLevelCandidates = Object.entries(compacted)
84
+ .filter(([key]) => key !== '__traceNotice')
85
+ .map(([key, entryValue]) => ({ key, size: serializedSize(entryValue) }))
86
+ .sort((left, right) => right.size - left.size);
87
+
88
+ let droppedCount = 0;
89
+
90
+ for (const candidate of topLevelCandidates) {
91
+ if (serializedSize(compacted) <= DEFAULT_MAX_ENTRY_BYTES) {
92
+ break;
93
+ }
94
+
95
+ compacted[candidate.key] = DROPPED_FIELD_MESSAGE;
96
+ droppedCount += 1;
97
+ }
161
98
 
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);
99
+ if (droppedCount > 0) {
100
+ compacted['__traceNotice'] =
101
+ `${COMPACTED_CONTENT_MESSAGE} ${String(droppedCount)} top-level field(s) were dropped.`;
166
102
  }
167
103
 
168
104
  return compacted;
@@ -208,18 +144,346 @@ const fitPatchToBudget = (
208
144
  };
209
145
  };
210
146
 
147
+ type TraceDispatchMessage =
148
+ | { operation: 'write'; entry: ITraceEntry }
149
+ | {
150
+ operation: 'update';
151
+ uuid: string;
152
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>;
153
+ };
154
+
155
+ type QueueApi = {
156
+ get(name?: string): {
157
+ enqueue<T = unknown>(queue: string, payload: T): Promise<string>;
158
+ };
159
+ };
160
+
161
+ type TimeoutManagerApi = {
162
+ withTimeout<T>(
163
+ operation: () => Promise<T>,
164
+ timeoutMs: number,
165
+ operationName: string,
166
+ timeoutHandler?: () => Promise<T>
167
+ ): Promise<T>;
168
+ };
169
+
170
+ type QueueWorkerApi = {
171
+ createQueueWorker<TPayload>(options: {
172
+ kindLabel: string;
173
+ defaultQueueName: string;
174
+ maxAttempts: number;
175
+ getLogFields?: (payload: {
176
+ id: string;
177
+ payload: TPayload;
178
+ attempts: number;
179
+ }) => Record<string, unknown>;
180
+ handle(payload: TPayload): Promise<void>;
181
+ }): {
182
+ runOnce(options?: {
183
+ queueName?: string;
184
+ driverName?: string;
185
+ maxItems?: number;
186
+ maxDurationMs?: number;
187
+ concurrency?: number;
188
+ }): Promise<number>;
189
+ };
190
+ };
191
+
192
+ type UnrefableTimer = ReturnType<typeof setInterval> & { unref?: () => void };
193
+
194
+ type TraceContentBudgetRuntime = {
195
+ queue?: QueueApi | null;
196
+ timeoutManager?: TimeoutManagerApi | null;
197
+ queueWorkerApi?: QueueWorkerApi | null;
198
+ };
199
+
200
+ const startedWorkerKeys = new Set<string>();
201
+
202
+ const closePort = (port: MessagePort): void => {
203
+ if (typeof port.close === 'function') {
204
+ port.close();
205
+ }
206
+ };
207
+
208
+ const scheduleTask = async (task: () => Promise<void>): Promise<void> => {
209
+ return await new Promise<void>((resolve, reject) => {
210
+ const runTask = (): void => {
211
+ void task().then(resolve).catch(reject);
212
+ };
213
+
214
+ if (typeof MessageChannel === 'function') {
215
+ const channel = new MessageChannel();
216
+
217
+ channel.port1.onmessage = (): void => {
218
+ channel.port1.onmessage = null;
219
+ closePort(channel.port1);
220
+ closePort(channel.port2);
221
+ runTask();
222
+ };
223
+
224
+ channel.port2.postMessage(undefined);
225
+ return;
226
+ }
227
+
228
+ Promise.resolve().then(runTask).catch(reject);
229
+ });
230
+ };
231
+
232
+ const getReplacementContent = (content: unknown): Record<string, unknown> => {
233
+ return {
234
+ __traceNotice: REPLACED_CONTENT_MESSAGE,
235
+ dropped: true,
236
+ valueType: describeValueType(content),
237
+ };
238
+ };
239
+
240
+ const replaceEntryContent = (entry: ITraceEntry): ITraceEntry => ({
241
+ ...entry,
242
+ content: getReplacementContent(entry.content),
243
+ });
244
+
245
+ const replacePatchContent = (
246
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
247
+ ): Partial<Pick<ITraceEntry, 'content' | 'isLatest'>> => {
248
+ if (patch.content === undefined) return patch;
249
+
250
+ return {
251
+ ...patch,
252
+ content: getReplacementContent(patch.content),
253
+ };
254
+ };
255
+
256
+ const shouldReplaceContent = (content: unknown): boolean => {
257
+ return serializedSize(content) > DEFAULT_MAX_ENTRY_BYTES;
258
+ };
259
+
260
+ const hasQueueDispatch = (config: ITraceConfig): boolean => {
261
+ const driver = config.contentDispatch.driver?.trim();
262
+ return typeof driver === 'string' && driver !== '';
263
+ };
264
+
265
+ const getCoreRuntime = async (): Promise<{
266
+ Queue: QueueApi | null;
267
+ TimeoutManager: TimeoutManagerApi | null;
268
+ }> => {
269
+ try {
270
+ const mod = (await import('@zintrust/core')) as unknown as {
271
+ Queue?: QueueApi;
272
+ TimeoutManager?: TimeoutManagerApi;
273
+ };
274
+ return {
275
+ Queue: mod.Queue ?? null,
276
+ TimeoutManager: mod.TimeoutManager ?? null,
277
+ };
278
+ } catch {
279
+ return {
280
+ Queue: null,
281
+ TimeoutManager: null,
282
+ };
283
+ }
284
+ };
285
+
286
+ const getQueueWorkerApi = async (): Promise<QueueWorkerApi | null> => {
287
+ try {
288
+ const mod = (await import('@zintrust/workers')) as unknown as QueueWorkerApi;
289
+ return typeof mod.createQueueWorker === 'function' ? mod : null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ };
294
+
295
+ const enqueueTraceDispatch = async (
296
+ config: ITraceConfig,
297
+ payload: TraceDispatchMessage,
298
+ runtime?: TraceContentBudgetRuntime
299
+ ): Promise<boolean> => {
300
+ const driverName = config.contentDispatch.driver?.trim();
301
+ if (driverName === undefined || driverName === '') return false;
302
+
303
+ const coreRuntime =
304
+ runtime?.queue !== undefined || runtime?.timeoutManager !== undefined
305
+ ? {
306
+ Queue: runtime?.queue ?? null,
307
+ TimeoutManager: runtime?.timeoutManager ?? null,
308
+ }
309
+ : await getCoreRuntime();
310
+ const queueApi = coreRuntime.Queue;
311
+ if (queueApi === null) return false;
312
+
313
+ try {
314
+ const driver = queueApi.get(driverName);
315
+ const timeoutMs = Math.max(1, config.contentDispatch.enqueueTimeoutMs);
316
+ if (coreRuntime.TimeoutManager === null) {
317
+ await driver.enqueue(config.contentDispatch.queueName, payload);
318
+ } else {
319
+ await coreRuntime.TimeoutManager.withTimeout(
320
+ () => driver.enqueue(config.contentDispatch.queueName, payload),
321
+ timeoutMs,
322
+ 'trace-content-dispatch-enqueue'
323
+ );
324
+ }
325
+
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ };
331
+
332
+ const persistWriteFallback = async (storage: ITraceStorage, entry: ITraceEntry): Promise<void> => {
333
+ await storage.writeEntry(
334
+ shouldReplaceContent(entry.content) ? replaceEntryContent(entry) : entry
335
+ );
336
+ };
337
+
338
+ const persistUpdateFallback = async (
339
+ storage: ITraceStorage,
340
+ uuid: string,
341
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
342
+ ): Promise<void> => {
343
+ await storage.updateEntry(
344
+ uuid,
345
+ patch.content !== undefined && shouldReplaceContent(patch.content)
346
+ ? replacePatchContent(patch)
347
+ : patch
348
+ );
349
+ };
350
+
351
+ const processQueuedMessage = async (
352
+ storage: ITraceStorage,
353
+ message: TraceDispatchMessage
354
+ ): Promise<void> => {
355
+ if (message.operation === 'write') {
356
+ await storage.writeEntry(fitEntryToBudget(message.entry));
357
+ return;
358
+ }
359
+
360
+ await storage.updateEntry(message.uuid, fitPatchToBudget(message.patch));
361
+ };
362
+
363
+ const ensureWorkerTimer = (_key: string, timer: ReturnType<typeof setInterval>): void => {
364
+ const unrefable = timer as UnrefableTimer;
365
+ if (typeof unrefable.unref === 'function') {
366
+ unrefable.unref();
367
+ }
368
+ };
369
+
370
+ const startInternalDispatchWorker = (
371
+ storage: ITraceStorage,
372
+ config: ITraceConfig,
373
+ runtime?: TraceContentBudgetRuntime
374
+ ): void => {
375
+ if (!hasQueueDispatch(config) || config.contentDispatch.worker.enabled !== true) return;
376
+
377
+ const driverName = config.contentDispatch.driver?.trim() ?? '';
378
+ const key = `${driverName}:${config.contentDispatch.queueName}`;
379
+ if (startedWorkerKeys.has(key)) return;
380
+ startedWorkerKeys.add(key);
381
+
382
+ void scheduleTask(async () => {
383
+ const workersApi = runtime?.queueWorkerApi ?? (await getQueueWorkerApi());
384
+ if (workersApi === null) {
385
+ startedWorkerKeys.delete(key);
386
+ return;
387
+ }
388
+
389
+ let running = false;
390
+ const runWorker = async (): Promise<void> => {
391
+ if (running) return;
392
+ running = true;
393
+ try {
394
+ const worker = workersApi.createQueueWorker<TraceDispatchMessage>({
395
+ kindLabel: 'trace-content-dispatch',
396
+ defaultQueueName: config.contentDispatch.queueName,
397
+ maxAttempts: 1,
398
+ getLogFields: () => ({
399
+ queueName: config.contentDispatch.queueName,
400
+ driverName,
401
+ }),
402
+ handle: async (payload) => {
403
+ await processQueuedMessage(storage, payload);
404
+ },
405
+ });
406
+
407
+ await worker.runOnce({
408
+ queueName: config.contentDispatch.queueName,
409
+ driverName,
410
+ maxDurationMs: Math.max(1, config.contentDispatch.worker.maxDurationMs),
411
+ concurrency: Math.max(1, config.contentDispatch.worker.concurrency),
412
+ });
413
+ } finally {
414
+ running = false;
415
+ }
416
+ };
417
+
418
+ await runWorker();
419
+
420
+ const intervalMs = Math.max(100, config.contentDispatch.worker.intervalMs);
421
+ ensureWorkerTimer(
422
+ key,
423
+ setInterval(() => {
424
+ void runWorker();
425
+ }, intervalMs)
426
+ );
427
+ }).catch(() => {
428
+ startedWorkerKeys.delete(key);
429
+ });
430
+ };
431
+
432
+ const dispatchWrite = async (
433
+ storage: ITraceStorage,
434
+ config: ITraceConfig,
435
+ entry: ITraceEntry,
436
+ runtime?: TraceContentBudgetRuntime
437
+ ): Promise<void> => {
438
+ await scheduleTask(async () => {
439
+ if (hasQueueDispatch(config)) {
440
+ const enqueued = await enqueueTraceDispatch(config, { operation: 'write', entry }, runtime);
441
+ if (enqueued) return;
442
+ }
443
+
444
+ await persistWriteFallback(storage, entry);
445
+ });
446
+ };
447
+
448
+ const dispatchUpdate = async (
449
+ storage: ITraceStorage,
450
+ config: ITraceConfig,
451
+ uuid: string,
452
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>,
453
+ runtime?: TraceContentBudgetRuntime
454
+ ): Promise<void> => {
455
+ await scheduleTask(async () => {
456
+ if (hasQueueDispatch(config)) {
457
+ const enqueued = await enqueueTraceDispatch(
458
+ config,
459
+ { operation: 'update', uuid, patch },
460
+ runtime
461
+ );
462
+ if (enqueued) return;
463
+ }
464
+
465
+ await persistUpdateFallback(storage, uuid, patch);
466
+ });
467
+ };
468
+
211
469
  export const TraceContentBudget = Object.freeze({
212
- wrapStorage(storage: ITraceStorage): ITraceStorage {
470
+ wrapStorage(
471
+ storage: ITraceStorage,
472
+ config: ITraceConfig,
473
+ runtime?: TraceContentBudgetRuntime
474
+ ): ITraceStorage {
475
+ startInternalDispatchWorker(storage, config, runtime);
476
+
213
477
  return Object.freeze({
214
478
  ...storage,
215
479
  writeEntry: async (entry: ITraceEntry): Promise<void> => {
216
- await storage.writeEntry(fitEntryToBudget(entry));
480
+ await dispatchWrite(storage, config, entry, runtime);
217
481
  },
218
482
  updateEntry: async (
219
483
  uuid: string,
220
484
  patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
221
485
  ): Promise<void> => {
222
- await storage.updateEntry(uuid, fitPatchToBudget(patch));
486
+ await dispatchUpdate(storage, config, uuid, patch, runtime);
223
487
  },
224
488
  });
225
489
  },
package/src/types.ts CHANGED
@@ -359,6 +359,20 @@ export type TraceClientRequestWatcherConfig = TraceClientRequestCaptureRule & {
359
359
  sources?: Record<string, TraceClientRequestCaptureRule>;
360
360
  };
361
361
 
362
+ export type TraceContentDispatchWorkerConfig = {
363
+ enabled: boolean;
364
+ intervalMs: number;
365
+ maxDurationMs: number;
366
+ concurrency: number;
367
+ };
368
+
369
+ export type TraceContentDispatchConfig = {
370
+ driver?: string;
371
+ queueName: string;
372
+ enqueueTimeoutMs: number;
373
+ worker: TraceContentDispatchWorkerConfig;
374
+ };
375
+
362
376
  export type TraceWatcherToggle = boolean | TraceFilterRule;
363
377
  export type TraceRequestWatcherToggle = boolean | TraceRequestWatcherConfig;
364
378
  export type TraceClientRequestWatcherToggle = boolean | TraceClientRequestWatcherConfig;
@@ -396,6 +410,7 @@ export interface ITraceConfig {
396
410
  captureCachePayloads: boolean;
397
411
  captureQueryBindings: boolean;
398
412
  logMinLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
413
+ contentDispatch: TraceContentDispatchConfig;
399
414
  watchers: WatcherToggles;
400
415
  redaction: RedactionConfig;
401
416
  }