@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.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A debug assistant for ZinTrust. Records HTTP requests, database queries, exceptions, jobs, cache operations, scheduled tasks, mail, auth events, and more — all surfaced through a built-in web dashboard.
4
4
 
5
+ Docs: https://zintrust.com/package-trace
6
+
5
7
  Works with both `zin s` (Node.js) and `zin s --wg` (Cloudflare Workers).
6
8
 
7
9
  ---
@@ -33,8 +35,25 @@ TRACE_QUERY_CONNECTION=main # optional — app DB to observe for SQL traces
33
35
  TRACE_PRUNE_HOURS=24 # how long entries are kept (default: 24)
34
36
  TRACE_SLOW_QUERY_MS=100 # slow-query threshold in ms (default: 100)
35
37
  TRACE_LOG_LEVEL=info # minimum log level captured (default: info)
38
+ TRACE_CACHE_PAYLOADS=false # optional — include cache payload values in trace entries
39
+ TRACE_QUERY_BINDINGS=true # optional — include SQL binding values in query traces
40
+ TRACE_CONTENT_QUEUE_DRIVER= # optional — any registered async queue driver for trace offload
41
+ TRACE_CONTENT_QUEUE_NAME=trace-content
42
+ TRACE_CONTENT_QUEUE_ENQUEUE_TIMEOUT_MS=25
43
+ TRACE_CONTENT_QUEUE_WORKER_ENABLED=true
44
+ TRACE_CONTENT_QUEUE_WORKER_INTERVAL_MS=1000
45
+ TRACE_CONTENT_QUEUE_WORKER_MAX_DURATION_MS=250
46
+ TRACE_CONTENT_QUEUE_WORKER_CONCURRENCY=1
47
+ TRACE_REDACT_KEYS=password,token,secret
48
+ TRACE_REDACT_HEADERS=authorization,cookie
49
+ TRACE_REDACT_BODY=password,token,secret
50
+ TRACE_REDACT_QUERY=
36
51
  ```
37
52
 
53
+ When `TRACE_CONTENT_QUEUE_DRIVER` is set, trace writes enqueue through that registered queue driver and an internal trace worker drains them outside the live request path. When it is unset, oversized content is replaced with `Trace content exceeded budget and was replaced.` before persistence instead of running the heavy compaction loop inline.
54
+
55
+ This currently works with any queue driver already registered in ZinTrust. First-class Cloudflare Queue support still requires a dedicated queue driver and queue-runtime registration for that transport.
56
+
38
57
  ### 2. Enable the plugin in `zintrust.plugins.*`
39
58
 
40
59
  The supported setup is to opt in through your ZinTrust plugin files, not a custom `src/start.ts` import.
@@ -82,6 +101,19 @@ export default {
82
101
  pruneAfterHours: Env.getInt('TRACE_PRUNE_HOURS', 24),
83
102
  slowQueryThreshold: Env.getInt('TRACE_SLOW_QUERY_MS', 100),
84
103
  logMinLevel: Env.get('TRACE_LOG_LEVEL', 'info') as TraceConfigOverrides['logMinLevel'],
104
+ captureCachePayloads: Env.getBool('TRACE_CACHE_PAYLOADS', false),
105
+ captureQueryBindings: Env.getBool('TRACE_QUERY_BINDINGS', true),
106
+ contentDispatch: {
107
+ driver: Env.get('TRACE_CONTENT_QUEUE_DRIVER', '') || undefined,
108
+ queueName: Env.get('TRACE_CONTENT_QUEUE_NAME', 'trace-content'),
109
+ enqueueTimeoutMs: Env.getInt('TRACE_CONTENT_QUEUE_ENQUEUE_TIMEOUT_MS', 25),
110
+ worker: {
111
+ enabled: Env.getBool('TRACE_CONTENT_QUEUE_WORKER_ENABLED', true),
112
+ intervalMs: Env.getInt('TRACE_CONTENT_QUEUE_WORKER_INTERVAL_MS', 1000),
113
+ maxDurationMs: Env.getInt('TRACE_CONTENT_QUEUE_WORKER_MAX_DURATION_MS', 250),
114
+ concurrency: Env.getInt('TRACE_CONTENT_QUEUE_WORKER_CONCURRENCY', 1),
115
+ },
116
+ },
85
117
  watchers: {
86
118
  request: {
87
119
  get: { exclude: ['report'] },
@@ -258,19 +290,29 @@ ExceptionWatcher.register({ storage, config, db });
258
290
 
259
291
  `TraceConfig.merge(overrides?)` accepts the following options:
260
292
 
261
- | Option | Type | Default | Description |
262
- | -------------------- | --------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
263
- | `enabled` | `boolean` | `false` | Master switch — no watchers activate when `false` |
264
- | `connection` | `string \| undefined` | `undefined` | Named DB connection for storing entries; uses `'default'` if omitted |
265
- | `pruneAfterHours` | `number` | `24` | Entries older than this are pruned |
266
- | `slowQueryThreshold` | `number` | `100` | Queries taking longer (ms) are flagged as slow |
267
- | `logMinLevel` | `'debug' \| 'info' \| 'warn' \| 'error' \| 'fatal'` | `'info'` | Minimum log severity captured |
268
- | `ignoreRoutes` | `string[]` | `['/trace', '/health', '/ping']` | Routes excluded from HTTP watcher |
269
- | `watchers` | `Record<string, boolean \| { include?, exclude? }>` | `{}` | Per-watcher enable/disable flags plus contains-based include/exclude filters |
270
- | `redaction.keys` | `string[]` | common auth/card/session keys | Extra sensitive keys redacted recursively before trace persistence |
271
- | `redaction.headers` | `string[]` | `['authorization', 'cookie', ...]` | Request header names to redact |
272
- | `redaction.body` | `string[]` | `['password', 'token', ...]` | Request body keys to redact |
273
- | `redaction.query` | `string[]` | `[]` | Query-string keys to redact |
293
+ | Option | Type | Default | Description |
294
+ | -------------------------------------- | --------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------ |
295
+ | `enabled` | `boolean` | `false` | Master switch — no watchers activate when `false` |
296
+ | `connection` | `string \| undefined` | `undefined` | Named DB connection for storing entries; uses `'default'` if omitted |
297
+ | `observeConnection` | `string \| undefined` | `undefined` | Separate observed DB connection for query tracing when storage uses another DB |
298
+ | `pruneAfterHours` | `number` | `24` | Entries older than this are pruned |
299
+ | `slowQueryThreshold` | `number` | `100` | Queries taking longer (ms) are flagged as slow |
300
+ | `logMinLevel` | `'debug' \| 'info' \| 'warn' \| 'error' \| 'fatal'` | `'info'` | Minimum log severity captured |
301
+ | `captureCachePayloads` | `boolean` | `false` | Include cache payload values in cache trace entries |
302
+ | `captureQueryBindings` | `boolean` | `true` | Include SQL binding values in query trace entries |
303
+ | `contentDispatch.driver` | `string \| undefined` | `undefined` | Registered queue driver used for async trace content offload |
304
+ | `contentDispatch.queueName` | `string` | `'trace-content'` | Queue name used for offloaded trace content writes |
305
+ | `contentDispatch.enqueueTimeoutMs` | `number` | `25` | Max enqueue wait before trace falls back to fail-open persistence |
306
+ | `contentDispatch.worker.enabled` | `boolean` | `true` | Enables the internal trace queue drain worker |
307
+ | `contentDispatch.worker.intervalMs` | `number` | `1000` | Poll interval for the internal trace queue drain worker |
308
+ | `contentDispatch.worker.maxDurationMs` | `number` | `250` | Max runtime per drain pass before the worker yields |
309
+ | `contentDispatch.worker.concurrency` | `number` | `1` | Number of concurrent queue-drain loops for the internal trace worker |
310
+ | `ignoreRoutes` | `string[]` | `['/trace', '/health', '/ping']` | Routes excluded from HTTP watcher |
311
+ | `watchers` | `Record<string, boolean \| { include?, exclude? }>` | `{}` | Per-watcher enable/disable flags plus contains-based include/exclude filters |
312
+ | `redaction.keys` | `string[]` | common auth/card/session keys | Extra sensitive keys redacted recursively before trace persistence |
313
+ | `redaction.headers` | `string[]` | `['authorization', 'cookie', ...]` | Request header names to redact |
314
+ | `redaction.body` | `string[]` | `['password', 'token', ...]` | Request body keys to redact |
315
+ | `redaction.query` | `string[]` | `[]` | Query-string keys to redact |
274
316
 
275
317
  Request watcher filters can also be scoped per method. Matching is contains-based against the stored trace content, so values like `report`, `auth`, or `trace` match any request or entry whose content includes those fragments.
276
318
 
package/dist/config.js CHANGED
@@ -150,6 +150,19 @@ const mergeWatchers = (base, override) => {
150
150
  clientRequest: mergeClientRequestWatcherToggle(base.clientRequest, override.clientRequest),
151
151
  };
152
152
  };
153
+ const mergeContentDispatch = (base, override) => {
154
+ const workerOverride = override?.worker;
155
+ return {
156
+ ...base,
157
+ ...override,
158
+ worker: workerOverride === undefined
159
+ ? base.worker
160
+ : {
161
+ ...base.worker,
162
+ ...workerOverride,
163
+ },
164
+ };
165
+ };
153
166
  const DEFAULTS = Object.freeze({
154
167
  enabled: false,
155
168
  connection: undefined,
@@ -160,6 +173,17 @@ const DEFAULTS = Object.freeze({
160
173
  captureCachePayloads: false,
161
174
  captureQueryBindings: true,
162
175
  logMinLevel: 'info',
176
+ contentDispatch: {
177
+ driver: undefined,
178
+ queueName: 'trace-content',
179
+ enqueueTimeoutMs: 25,
180
+ worker: {
181
+ enabled: true,
182
+ intervalMs: 1000,
183
+ maxDurationMs: 250,
184
+ concurrency: 1,
185
+ },
186
+ },
163
187
  watchers: {},
164
188
  redaction: {
165
189
  keys: [
@@ -222,6 +246,7 @@ export const TraceConfig = Object.freeze({
222
246
  return Object.freeze({
223
247
  ...DEFAULTS,
224
248
  ...overrides,
249
+ contentDispatch: mergeContentDispatch(DEFAULTS.contentDispatch, overrides.contentDispatch),
225
250
  watchers: mergeWatchers(DEFAULTS.watchers, overrides.watchers),
226
251
  redaction: {
227
252
  keys: mergeStringLists(DEFAULTS.redaction.keys, overrides.redaction?.keys),
package/dist/index.d.ts CHANGED
@@ -40,4 +40,4 @@ export declare const captureTraceException: (error: unknown, context?: {
40
40
  userId?: string;
41
41
  }) => void;
42
42
  export { EntryType } from './types';
43
- export type { AuthContent, BatchContent, CacheContent, ClientRequestContent, CommandContent, DumpContent, EntryTypeValue, EventContent, ExceptionContent, GateContent, ITraceConfig, ITraceEntry, ITraceWatcher, ITraceWatcherConfig, JobContent, LogContent, MailContent, MiddlewareContent, ModelContent, NotificationContent, QueryContent, RedactionConfig, RedisContent, RequestContent, ScheduleContent, TraceConfigOverrides, ViewContent, WatcherToggles, } from './types';
43
+ export type { AuthContent, BatchContent, CacheContent, ClientRequestContent, CommandContent, DumpContent, EntryTypeValue, EventContent, ExceptionContent, GateContent, ITraceConfig, ITraceEntry, ITraceWatcher, ITraceWatcherConfig, JobContent, LogContent, MailContent, MiddlewareContent, ModelContent, NotificationContent, QueryContent, RedactionConfig, RedisContent, RequestContent, ScheduleContent, TraceConfigOverrides, TraceContentDispatchConfig, TraceContentDispatchWorkerConfig, ViewContent, WatcherToggles, } from './types';
package/dist/register.js CHANGED
@@ -206,6 +206,13 @@ if (!traceAlreadyInitialized && Env) {
206
206
  const logMinLevelRaw = Env.get('TRACE_LOG_LEVEL', '').trim();
207
207
  const captureCachePayloadsRaw = Env.get('TRACE_CACHE_PAYLOADS', '').trim();
208
208
  const captureQueryBindingsRaw = Env.get('TRACE_QUERY_BINDINGS', '').trim();
209
+ const contentDispatchDriverRaw = Env.get('TRACE_CONTENT_QUEUE_DRIVER', '').trim();
210
+ const contentDispatchQueueRaw = Env.get('TRACE_CONTENT_QUEUE_NAME', '').trim();
211
+ const contentDispatchEnqueueTimeoutRaw = Env.get('TRACE_CONTENT_QUEUE_ENQUEUE_TIMEOUT_MS', '').trim();
212
+ const contentDispatchWorkerEnabledRaw = Env.get('TRACE_CONTENT_QUEUE_WORKER_ENABLED', '').trim();
213
+ const contentDispatchWorkerIntervalRaw = Env.get('TRACE_CONTENT_QUEUE_WORKER_INTERVAL_MS', '').trim();
214
+ const contentDispatchWorkerDurationRaw = Env.get('TRACE_CONTENT_QUEUE_WORKER_MAX_DURATION_MS', '').trim();
215
+ const contentDispatchWorkerConcurrencyRaw = Env.get('TRACE_CONTENT_QUEUE_WORKER_CONCURRENCY', '').trim();
209
216
  const redactionKeys = parseEnvList(Env.get('TRACE_REDACT_KEYS', ''));
210
217
  const redactionHeaders = parseEnvList(Env.get('TRACE_REDACT_HEADERS', ''));
211
218
  const redactionBody = parseEnvList(Env.get('TRACE_REDACT_BODY', ''));
@@ -221,6 +228,26 @@ if (!traceAlreadyInitialized && Env) {
221
228
  const logMinLevel = (logMinLevelRaw === '' ? startupOverrides?.logMinLevel : logMinLevelRaw);
222
229
  const captureCachePayloads = parseEnvBool(captureCachePayloadsRaw) ?? startupOverrides?.captureCachePayloads;
223
230
  const captureQueryBindings = parseEnvBool(captureQueryBindingsRaw) ?? startupOverrides?.captureQueryBindings;
231
+ const contentDispatchDriver = contentDispatchDriverRaw === ''
232
+ ? startupOverrides?.contentDispatch?.driver
233
+ : contentDispatchDriverRaw;
234
+ const contentDispatchQueueName = contentDispatchQueueRaw === ''
235
+ ? startupOverrides?.contentDispatch?.queueName
236
+ : contentDispatchQueueRaw;
237
+ const contentDispatchEnqueueTimeout = contentDispatchEnqueueTimeoutRaw === ''
238
+ ? startupOverrides?.contentDispatch?.enqueueTimeoutMs
239
+ : Number.parseInt(contentDispatchEnqueueTimeoutRaw, 10);
240
+ const contentDispatchWorkerEnabled = parseEnvBool(contentDispatchWorkerEnabledRaw) ??
241
+ startupOverrides?.contentDispatch?.worker?.enabled;
242
+ const contentDispatchWorkerInterval = contentDispatchWorkerIntervalRaw === ''
243
+ ? startupOverrides?.contentDispatch?.worker?.intervalMs
244
+ : Number.parseInt(contentDispatchWorkerIntervalRaw, 10);
245
+ const contentDispatchWorkerDuration = contentDispatchWorkerDurationRaw === ''
246
+ ? startupOverrides?.contentDispatch?.worker?.maxDurationMs
247
+ : Number.parseInt(contentDispatchWorkerDurationRaw, 10);
248
+ const contentDispatchWorkerConcurrency = contentDispatchWorkerConcurrencyRaw === ''
249
+ ? startupOverrides?.contentDispatch?.worker?.concurrency
250
+ : Number.parseInt(contentDispatchWorkerConcurrencyRaw, 10);
224
251
  const redaction = buildTraceRedactionOverrides({
225
252
  startupOverrides,
226
253
  redactionBody,
@@ -228,6 +255,9 @@ if (!traceAlreadyInitialized && Env) {
228
255
  redactionKeys,
229
256
  redactionQuery,
230
257
  });
258
+ const defaultContentDispatch = TraceConfig.defaults().contentDispatch;
259
+ const startupContentDispatch = startupOverrides?.contentDispatch;
260
+ const startupContentDispatchWorker = startupContentDispatch?.worker;
231
261
  const config = TraceConfig.merge({
232
262
  ...startupOverrides,
233
263
  enabled,
@@ -241,6 +271,42 @@ if (!traceAlreadyInitialized && Env) {
241
271
  : {}),
242
272
  ...(typeof captureCachePayloads === 'boolean' ? { captureCachePayloads } : {}),
243
273
  ...(typeof captureQueryBindings === 'boolean' ? { captureQueryBindings } : {}),
274
+ contentDispatch: {
275
+ ...defaultContentDispatch,
276
+ ...startupContentDispatch,
277
+ ...(typeof contentDispatchDriver === 'string' && contentDispatchDriver !== ''
278
+ ? { driver: contentDispatchDriver }
279
+ : {}),
280
+ ...(typeof contentDispatchQueueName === 'string' && contentDispatchQueueName !== ''
281
+ ? { queueName: contentDispatchQueueName }
282
+ : {}),
283
+ ...(typeof contentDispatchEnqueueTimeout === 'number' &&
284
+ Number.isFinite(contentDispatchEnqueueTimeout)
285
+ ? { enqueueTimeoutMs: contentDispatchEnqueueTimeout }
286
+ : {}),
287
+ worker: {
288
+ ...defaultContentDispatch.worker,
289
+ ...startupContentDispatchWorker,
290
+ enabled: typeof contentDispatchWorkerEnabled === 'boolean'
291
+ ? contentDispatchWorkerEnabled
292
+ : (startupContentDispatchWorker?.enabled ?? defaultContentDispatch.worker.enabled),
293
+ intervalMs: typeof contentDispatchWorkerInterval === 'number' &&
294
+ Number.isFinite(contentDispatchWorkerInterval)
295
+ ? contentDispatchWorkerInterval
296
+ : (startupContentDispatchWorker?.intervalMs ??
297
+ defaultContentDispatch.worker.intervalMs),
298
+ maxDurationMs: typeof contentDispatchWorkerDuration === 'number' &&
299
+ Number.isFinite(contentDispatchWorkerDuration)
300
+ ? contentDispatchWorkerDuration
301
+ : (startupContentDispatchWorker?.maxDurationMs ??
302
+ defaultContentDispatch.worker.maxDurationMs),
303
+ concurrency: typeof contentDispatchWorkerConcurrency === 'number' &&
304
+ Number.isFinite(contentDispatchWorkerConcurrency)
305
+ ? contentDispatchWorkerConcurrency
306
+ : (startupContentDispatchWorker?.concurrency ??
307
+ defaultContentDispatch.worker.concurrency),
308
+ },
309
+ },
244
310
  logMinLevel,
245
311
  ...(redaction === undefined ? {} : { redaction }),
246
312
  });
@@ -257,7 +323,7 @@ if (!traceAlreadyInitialized && Env) {
257
323
  envKey: 'TRACE_QUERY_CONNECTION',
258
324
  });
259
325
  await assertTraceStorageReady(core, storageDb, resolvedConnectionName);
260
- const storage = TraceWriteDiagnostics.wrapStorage(TraceContentBudget.wrapStorage(TraceContentRedaction.wrapStorage(TraceEntryFiltering.wrapStorage(TraceStorage.resolveStorage(storageDb), config), config.redaction)), {
326
+ const storage = TraceWriteDiagnostics.wrapStorage(TraceContentBudget.wrapStorage(TraceContentRedaction.wrapStorage(TraceEntryFiltering.wrapStorage(TraceStorage.resolveStorage(storageDb), config), config.redaction), config), {
261
327
  connectionName: resolvedConnectionName,
262
328
  logger: core.Logger,
263
329
  });
@@ -1,4 +1,39 @@
1
- import type { ITraceStorage } from '../types';
1
+ import type { ITraceConfig, ITraceStorage } from '../types';
2
+ type QueueApi = {
3
+ get(name?: string): {
4
+ enqueue<T = unknown>(queue: string, payload: T): Promise<string>;
5
+ };
6
+ };
7
+ type TimeoutManagerApi = {
8
+ withTimeout<T>(operation: () => Promise<T>, timeoutMs: number, operationName: string, timeoutHandler?: () => Promise<T>): Promise<T>;
9
+ };
10
+ type QueueWorkerApi = {
11
+ createQueueWorker<TPayload>(options: {
12
+ kindLabel: string;
13
+ defaultQueueName: string;
14
+ maxAttempts: number;
15
+ getLogFields?: (payload: {
16
+ id: string;
17
+ payload: TPayload;
18
+ attempts: number;
19
+ }) => Record<string, unknown>;
20
+ handle(payload: TPayload): Promise<void>;
21
+ }): {
22
+ runOnce(options?: {
23
+ queueName?: string;
24
+ driverName?: string;
25
+ maxItems?: number;
26
+ maxDurationMs?: number;
27
+ concurrency?: number;
28
+ }): Promise<number>;
29
+ };
30
+ };
31
+ type TraceContentBudgetRuntime = {
32
+ queue?: QueueApi | null;
33
+ timeoutManager?: TimeoutManagerApi | null;
34
+ queueWorkerApi?: QueueWorkerApi | null;
35
+ };
2
36
  export declare const TraceContentBudget: Readonly<{
3
- wrapStorage(storage: ITraceStorage): ITraceStorage;
37
+ wrapStorage(storage: ITraceStorage, config: ITraceConfig, runtime?: TraceContentBudgetRuntime): ITraceStorage;
4
38
  }>;
39
+ export {};
@@ -5,6 +5,7 @@ const DEFAULT_MAX_OBJECT_ENTRIES = 40;
5
5
  const DEFAULT_MAX_DEPTH = 6;
6
6
  const DROPPED_FIELD_MESSAGE = '[trace] Value dropped because the field exceeded the trace storage size limit.';
7
7
  const COMPACTED_CONTENT_MESSAGE = '[trace] Trace content was compacted because it exceeded the trace storage size limit.';
8
+ const REPLACED_CONTENT_MESSAGE = 'Trace content exceeded budget and was replaced.';
8
9
  const encoder = new TextEncoder();
9
10
  const serializedSize = (value) => {
10
11
  try {
@@ -21,58 +22,6 @@ const describeValueType = (value) => {
21
22
  return 'null';
22
23
  return typeof value;
23
24
  };
24
- const chooseLargerCandidate = (left, right) => {
25
- if (left === null)
26
- return right;
27
- if (right === null)
28
- return left;
29
- return right.size > left.size ? right : left;
30
- };
31
- const fallbackCandidate = (value, path) => {
32
- return path.length === 0 ? null : { path, size: serializedSize(value) };
33
- };
34
- const findLargestDroppablePathInArray = (value, path) => {
35
- let best = null;
36
- for (const [index, item] of value.entries()) {
37
- best = chooseLargerCandidate(best, findLargestDroppablePath(item, [...path, index]));
38
- }
39
- return best ?? fallbackCandidate(value, path);
40
- };
41
- const findLargestDroppablePathInObject = (value, path) => {
42
- let best = null;
43
- for (const [key, entryValue] of Object.entries(value)) {
44
- if (key === '__traceNotice')
45
- continue;
46
- best = chooseLargerCandidate(best, findLargestDroppablePath(entryValue, [...path, key]));
47
- }
48
- return best ?? fallbackCandidate(value, path);
49
- };
50
- const findLargestDroppablePath = (value, path = []) => {
51
- if (Array.isArray(value))
52
- return findLargestDroppablePathInArray(value, path);
53
- if (typeof value === 'object' && value !== null) {
54
- return findLargestDroppablePathInObject(value, path);
55
- }
56
- return fallbackCandidate(value, path);
57
- };
58
- const replaceAtPath = (value, path, replacement) => {
59
- if (path.length === 0)
60
- return replacement;
61
- const [segment, ...rest] = path;
62
- if (Array.isArray(value) && typeof segment === 'number') {
63
- const next = value.slice();
64
- next[segment] = replaceAtPath(next[segment], rest, replacement);
65
- return next;
66
- }
67
- if (typeof value === 'object' && value !== null && typeof segment === 'string') {
68
- const current = value;
69
- return {
70
- ...current,
71
- [segment]: replaceAtPath(current[segment], rest, replacement),
72
- };
73
- }
74
- return value;
75
- };
76
25
  const compactValue = (value, depth) => {
77
26
  if (depth >= DEFAULT_MAX_DEPTH) {
78
27
  return DROPPED_FIELD_MESSAGE;
@@ -105,17 +54,28 @@ const compactValue = (value, depth) => {
105
54
  return Object.fromEntries(compactedEntries);
106
55
  };
107
56
  const compactStructuredValueToBudget = (value) => {
108
- let compacted = typeof value === 'object' && value !== null && !Array.isArray(value)
109
- ? {
110
- ...value,
111
- __traceNotice: COMPACTED_CONTENT_MESSAGE,
112
- }
113
- : value;
114
- while (serializedSize(compacted) > DEFAULT_MAX_ENTRY_BYTES) {
115
- const candidate = findLargestDroppablePath(compacted);
116
- if (candidate === null)
57
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
58
+ return value;
59
+ }
60
+ const compacted = {
61
+ ...value,
62
+ __traceNotice: COMPACTED_CONTENT_MESSAGE,
63
+ };
64
+ const topLevelCandidates = Object.entries(compacted)
65
+ .filter(([key]) => key !== '__traceNotice')
66
+ .map(([key, entryValue]) => ({ key, size: serializedSize(entryValue) }))
67
+ .sort((left, right) => right.size - left.size);
68
+ let droppedCount = 0;
69
+ for (const candidate of topLevelCandidates) {
70
+ if (serializedSize(compacted) <= DEFAULT_MAX_ENTRY_BYTES) {
117
71
  break;
118
- compacted = replaceAtPath(compacted, candidate.path, DROPPED_FIELD_MESSAGE);
72
+ }
73
+ compacted[candidate.key] = DROPPED_FIELD_MESSAGE;
74
+ droppedCount += 1;
75
+ }
76
+ if (droppedCount > 0) {
77
+ compacted['__traceNotice'] =
78
+ `${COMPACTED_CONTENT_MESSAGE} ${String(droppedCount)} top-level field(s) were dropped.`;
119
79
  }
120
80
  return compacted;
121
81
  };
@@ -151,15 +111,212 @@ const fitPatchToBudget = (patch) => {
151
111
  content: fitContentToBudget(patch.content),
152
112
  };
153
113
  };
114
+ const startedWorkerKeys = new Set();
115
+ const closePort = (port) => {
116
+ if (typeof port.close === 'function') {
117
+ port.close();
118
+ }
119
+ };
120
+ const scheduleTask = async (task) => {
121
+ return await new Promise((resolve, reject) => {
122
+ const runTask = () => {
123
+ void task().then(resolve).catch(reject);
124
+ };
125
+ if (typeof MessageChannel === 'function') {
126
+ const channel = new MessageChannel();
127
+ channel.port1.onmessage = () => {
128
+ channel.port1.onmessage = null;
129
+ closePort(channel.port1);
130
+ closePort(channel.port2);
131
+ runTask();
132
+ };
133
+ channel.port2.postMessage(undefined);
134
+ return;
135
+ }
136
+ Promise.resolve().then(runTask).catch(reject);
137
+ });
138
+ };
139
+ const getReplacementContent = (content) => {
140
+ return {
141
+ __traceNotice: REPLACED_CONTENT_MESSAGE,
142
+ dropped: true,
143
+ valueType: describeValueType(content),
144
+ };
145
+ };
146
+ const replaceEntryContent = (entry) => ({
147
+ ...entry,
148
+ content: getReplacementContent(entry.content),
149
+ });
150
+ const replacePatchContent = (patch) => {
151
+ if (patch.content === undefined)
152
+ return patch;
153
+ return {
154
+ ...patch,
155
+ content: getReplacementContent(patch.content),
156
+ };
157
+ };
158
+ const shouldReplaceContent = (content) => {
159
+ return serializedSize(content) > DEFAULT_MAX_ENTRY_BYTES;
160
+ };
161
+ const hasQueueDispatch = (config) => {
162
+ const driver = config.contentDispatch.driver?.trim();
163
+ return typeof driver === 'string' && driver !== '';
164
+ };
165
+ const getCoreRuntime = async () => {
166
+ try {
167
+ const mod = (await import('@zintrust/core'));
168
+ return {
169
+ Queue: mod.Queue ?? null,
170
+ TimeoutManager: mod.TimeoutManager ?? null,
171
+ };
172
+ }
173
+ catch {
174
+ return {
175
+ Queue: null,
176
+ TimeoutManager: null,
177
+ };
178
+ }
179
+ };
180
+ const getQueueWorkerApi = async () => {
181
+ try {
182
+ const mod = (await import('@zintrust/workers'));
183
+ return typeof mod.createQueueWorker === 'function' ? mod : null;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ };
189
+ const enqueueTraceDispatch = async (config, payload, runtime) => {
190
+ const driverName = config.contentDispatch.driver?.trim();
191
+ if (driverName === undefined || driverName === '')
192
+ return false;
193
+ const coreRuntime = runtime?.queue !== undefined || runtime?.timeoutManager !== undefined
194
+ ? {
195
+ Queue: runtime?.queue ?? null,
196
+ TimeoutManager: runtime?.timeoutManager ?? null,
197
+ }
198
+ : await getCoreRuntime();
199
+ const queueApi = coreRuntime.Queue;
200
+ if (queueApi === null)
201
+ return false;
202
+ try {
203
+ const driver = queueApi.get(driverName);
204
+ const timeoutMs = Math.max(1, config.contentDispatch.enqueueTimeoutMs);
205
+ if (coreRuntime.TimeoutManager === null) {
206
+ await driver.enqueue(config.contentDispatch.queueName, payload);
207
+ }
208
+ else {
209
+ await coreRuntime.TimeoutManager.withTimeout(() => driver.enqueue(config.contentDispatch.queueName, payload), timeoutMs, 'trace-content-dispatch-enqueue');
210
+ }
211
+ return true;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ };
217
+ const persistWriteFallback = async (storage, entry) => {
218
+ await storage.writeEntry(shouldReplaceContent(entry.content) ? replaceEntryContent(entry) : entry);
219
+ };
220
+ const persistUpdateFallback = async (storage, uuid, patch) => {
221
+ await storage.updateEntry(uuid, patch.content !== undefined && shouldReplaceContent(patch.content)
222
+ ? replacePatchContent(patch)
223
+ : patch);
224
+ };
225
+ const processQueuedMessage = async (storage, message) => {
226
+ if (message.operation === 'write') {
227
+ await storage.writeEntry(fitEntryToBudget(message.entry));
228
+ return;
229
+ }
230
+ await storage.updateEntry(message.uuid, fitPatchToBudget(message.patch));
231
+ };
232
+ const ensureWorkerTimer = (_key, timer) => {
233
+ const unrefable = timer;
234
+ if (typeof unrefable.unref === 'function') {
235
+ unrefable.unref();
236
+ }
237
+ };
238
+ const startInternalDispatchWorker = (storage, config, runtime) => {
239
+ if (!hasQueueDispatch(config) || config.contentDispatch.worker.enabled !== true)
240
+ return;
241
+ const driverName = config.contentDispatch.driver?.trim() ?? '';
242
+ const key = `${driverName}:${config.contentDispatch.queueName}`;
243
+ if (startedWorkerKeys.has(key))
244
+ return;
245
+ startedWorkerKeys.add(key);
246
+ void scheduleTask(async () => {
247
+ const workersApi = runtime?.queueWorkerApi ?? (await getQueueWorkerApi());
248
+ if (workersApi === null) {
249
+ startedWorkerKeys.delete(key);
250
+ return;
251
+ }
252
+ let running = false;
253
+ const runWorker = async () => {
254
+ if (running)
255
+ return;
256
+ running = true;
257
+ try {
258
+ const worker = workersApi.createQueueWorker({
259
+ kindLabel: 'trace-content-dispatch',
260
+ defaultQueueName: config.contentDispatch.queueName,
261
+ maxAttempts: 1,
262
+ getLogFields: () => ({
263
+ queueName: config.contentDispatch.queueName,
264
+ driverName,
265
+ }),
266
+ handle: async (payload) => {
267
+ await processQueuedMessage(storage, payload);
268
+ },
269
+ });
270
+ await worker.runOnce({
271
+ queueName: config.contentDispatch.queueName,
272
+ driverName,
273
+ maxDurationMs: Math.max(1, config.contentDispatch.worker.maxDurationMs),
274
+ concurrency: Math.max(1, config.contentDispatch.worker.concurrency),
275
+ });
276
+ }
277
+ finally {
278
+ running = false;
279
+ }
280
+ };
281
+ await runWorker();
282
+ const intervalMs = Math.max(100, config.contentDispatch.worker.intervalMs);
283
+ ensureWorkerTimer(key, setInterval(() => {
284
+ void runWorker();
285
+ }, intervalMs));
286
+ }).catch(() => {
287
+ startedWorkerKeys.delete(key);
288
+ });
289
+ };
290
+ const dispatchWrite = async (storage, config, entry, runtime) => {
291
+ await scheduleTask(async () => {
292
+ if (hasQueueDispatch(config)) {
293
+ const enqueued = await enqueueTraceDispatch(config, { operation: 'write', entry }, runtime);
294
+ if (enqueued)
295
+ return;
296
+ }
297
+ await persistWriteFallback(storage, entry);
298
+ });
299
+ };
300
+ const dispatchUpdate = async (storage, config, uuid, patch, runtime) => {
301
+ await scheduleTask(async () => {
302
+ if (hasQueueDispatch(config)) {
303
+ const enqueued = await enqueueTraceDispatch(config, { operation: 'update', uuid, patch }, runtime);
304
+ if (enqueued)
305
+ return;
306
+ }
307
+ await persistUpdateFallback(storage, uuid, patch);
308
+ });
309
+ };
154
310
  export const TraceContentBudget = Object.freeze({
155
- wrapStorage(storage) {
311
+ wrapStorage(storage, config, runtime) {
312
+ startInternalDispatchWorker(storage, config, runtime);
156
313
  return Object.freeze({
157
314
  ...storage,
158
315
  writeEntry: async (entry) => {
159
- await storage.writeEntry(fitEntryToBudget(entry));
316
+ await dispatchWrite(storage, config, entry, runtime);
160
317
  },
161
318
  updateEntry: async (uuid, patch) => {
162
- await storage.updateEntry(uuid, fitPatchToBudget(patch));
319
+ await dispatchUpdate(storage, config, uuid, patch, runtime);
163
320
  },
164
321
  });
165
322
  },
package/dist/types.d.ts CHANGED
@@ -304,6 +304,18 @@ export type TraceRequestWatcherConfig = TraceFilterRule & {
304
304
  export type TraceClientRequestWatcherConfig = TraceClientRequestCaptureRule & {
305
305
  sources?: Record<string, TraceClientRequestCaptureRule>;
306
306
  };
307
+ export type TraceContentDispatchWorkerConfig = {
308
+ enabled: boolean;
309
+ intervalMs: number;
310
+ maxDurationMs: number;
311
+ concurrency: number;
312
+ };
313
+ export type TraceContentDispatchConfig = {
314
+ driver?: string;
315
+ queueName: string;
316
+ enqueueTimeoutMs: number;
317
+ worker: TraceContentDispatchWorkerConfig;
318
+ };
307
319
  export type TraceWatcherToggle = boolean | TraceFilterRule;
308
320
  export type TraceRequestWatcherToggle = boolean | TraceRequestWatcherConfig;
309
321
  export type TraceClientRequestWatcherToggle = boolean | TraceClientRequestWatcherConfig;
@@ -339,6 +351,7 @@ export interface ITraceConfig {
339
351
  captureCachePayloads: boolean;
340
352
  captureQueryBindings: boolean;
341
353
  logMinLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
354
+ contentDispatch: TraceContentDispatchConfig;
342
355
  watchers: WatcherToggles;
343
356
  redaction: RedactionConfig;
344
357
  }