@zintrust/trace 0.5.2 → 0.5.4

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 {
@@ -151,15 +152,209 @@ const fitPatchToBudget = (patch) => {
151
152
  content: fitContentToBudget(patch.content),
152
153
  };
153
154
  };
155
+ const startedWorkerKeys = new Set();
156
+ const closePort = (port) => {
157
+ if (typeof port.close === 'function') {
158
+ port.close();
159
+ }
160
+ };
161
+ const scheduleTask = (task) => {
162
+ if (typeof MessageChannel === 'function') {
163
+ const channel = new MessageChannel();
164
+ channel.port1.onmessage = () => {
165
+ channel.port1.onmessage = null;
166
+ closePort(channel.port1);
167
+ closePort(channel.port2);
168
+ void task().catch(() => undefined);
169
+ };
170
+ channel.port2.postMessage(undefined);
171
+ return;
172
+ }
173
+ Promise.resolve()
174
+ .then(() => {
175
+ void task().catch(() => undefined);
176
+ })
177
+ .catch(() => undefined);
178
+ };
179
+ const getReplacementContent = (content) => {
180
+ return {
181
+ __traceNotice: REPLACED_CONTENT_MESSAGE,
182
+ dropped: true,
183
+ valueType: describeValueType(content),
184
+ };
185
+ };
186
+ const replaceEntryContent = (entry) => ({
187
+ ...entry,
188
+ content: getReplacementContent(entry.content),
189
+ });
190
+ const replacePatchContent = (patch) => {
191
+ if (patch.content === undefined)
192
+ return patch;
193
+ return {
194
+ ...patch,
195
+ content: getReplacementContent(patch.content),
196
+ };
197
+ };
198
+ const shouldReplaceContent = (content) => {
199
+ return serializedSize(content) > DEFAULT_MAX_ENTRY_BYTES;
200
+ };
201
+ const hasQueueDispatch = (config) => {
202
+ const driver = config.contentDispatch.driver?.trim();
203
+ return typeof driver === 'string' && driver !== '';
204
+ };
205
+ const getCoreRuntime = async () => {
206
+ try {
207
+ const mod = (await import('@zintrust/core'));
208
+ return {
209
+ Queue: mod.Queue ?? null,
210
+ TimeoutManager: mod.TimeoutManager ?? null,
211
+ };
212
+ }
213
+ catch {
214
+ return {
215
+ Queue: null,
216
+ TimeoutManager: null,
217
+ };
218
+ }
219
+ };
220
+ const getQueueWorkerApi = async () => {
221
+ try {
222
+ const mod = (await import('@zintrust/workers'));
223
+ return typeof mod.createQueueWorker === 'function' ? mod : null;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ };
229
+ const enqueueTraceDispatch = async (config, payload, runtime) => {
230
+ const driverName = config.contentDispatch.driver?.trim();
231
+ if (driverName === undefined || driverName === '')
232
+ return false;
233
+ const coreRuntime = runtime?.queue !== undefined || runtime?.timeoutManager !== undefined
234
+ ? {
235
+ Queue: runtime?.queue ?? null,
236
+ TimeoutManager: runtime?.timeoutManager ?? null,
237
+ }
238
+ : await getCoreRuntime();
239
+ const queueApi = coreRuntime.Queue;
240
+ if (queueApi === null)
241
+ return false;
242
+ try {
243
+ const driver = queueApi.get(driverName);
244
+ const timeoutMs = Math.max(1, config.contentDispatch.enqueueTimeoutMs);
245
+ if (coreRuntime.TimeoutManager === null) {
246
+ await driver.enqueue(config.contentDispatch.queueName, payload);
247
+ }
248
+ else {
249
+ await coreRuntime.TimeoutManager.withTimeout(() => driver.enqueue(config.contentDispatch.queueName, payload), timeoutMs, 'trace-content-dispatch-enqueue');
250
+ }
251
+ return true;
252
+ }
253
+ catch {
254
+ return false;
255
+ }
256
+ };
257
+ const persistWriteFallback = async (storage, entry) => {
258
+ await storage.writeEntry(shouldReplaceContent(entry.content) ? replaceEntryContent(entry) : entry);
259
+ };
260
+ const persistUpdateFallback = async (storage, uuid, patch) => {
261
+ await storage.updateEntry(uuid, patch.content !== undefined && shouldReplaceContent(patch.content)
262
+ ? replacePatchContent(patch)
263
+ : patch);
264
+ };
265
+ const processQueuedMessage = async (storage, message) => {
266
+ if (message.operation === 'write') {
267
+ await storage.writeEntry(fitEntryToBudget(message.entry));
268
+ return;
269
+ }
270
+ await storage.updateEntry(message.uuid, fitPatchToBudget(message.patch));
271
+ };
272
+ const ensureWorkerTimer = (_key, timer) => {
273
+ const unrefable = timer;
274
+ if (typeof unrefable.unref === 'function') {
275
+ unrefable.unref();
276
+ }
277
+ };
278
+ const startInternalDispatchWorker = (storage, config, runtime) => {
279
+ if (!hasQueueDispatch(config) || config.contentDispatch.worker.enabled !== true)
280
+ return;
281
+ const driverName = config.contentDispatch.driver?.trim() ?? '';
282
+ const key = `${driverName}:${config.contentDispatch.queueName}`;
283
+ if (startedWorkerKeys.has(key))
284
+ return;
285
+ startedWorkerKeys.add(key);
286
+ scheduleTask(async () => {
287
+ const workersApi = runtime?.queueWorkerApi ?? (await getQueueWorkerApi());
288
+ if (workersApi === null) {
289
+ startedWorkerKeys.delete(key);
290
+ return;
291
+ }
292
+ let running = false;
293
+ const runWorker = async () => {
294
+ if (running)
295
+ return;
296
+ running = true;
297
+ try {
298
+ const worker = workersApi.createQueueWorker({
299
+ kindLabel: 'trace-content-dispatch',
300
+ defaultQueueName: config.contentDispatch.queueName,
301
+ maxAttempts: 1,
302
+ getLogFields: () => ({
303
+ queueName: config.contentDispatch.queueName,
304
+ driverName,
305
+ }),
306
+ handle: async (payload) => {
307
+ await processQueuedMessage(storage, payload);
308
+ },
309
+ });
310
+ await worker.runOnce({
311
+ queueName: config.contentDispatch.queueName,
312
+ driverName,
313
+ maxDurationMs: Math.max(1, config.contentDispatch.worker.maxDurationMs),
314
+ concurrency: Math.max(1, config.contentDispatch.worker.concurrency),
315
+ });
316
+ }
317
+ finally {
318
+ running = false;
319
+ }
320
+ };
321
+ await runWorker();
322
+ const intervalMs = Math.max(100, config.contentDispatch.worker.intervalMs);
323
+ ensureWorkerTimer(key, setInterval(() => {
324
+ void runWorker();
325
+ }, intervalMs));
326
+ });
327
+ };
328
+ const dispatchWrite = (storage, config, entry, runtime) => {
329
+ scheduleTask(async () => {
330
+ if (hasQueueDispatch(config)) {
331
+ const enqueued = await enqueueTraceDispatch(config, { operation: 'write', entry }, runtime);
332
+ if (enqueued)
333
+ return;
334
+ }
335
+ await persistWriteFallback(storage, entry);
336
+ });
337
+ };
338
+ const dispatchUpdate = (storage, config, uuid, patch, runtime) => {
339
+ scheduleTask(async () => {
340
+ if (hasQueueDispatch(config)) {
341
+ const enqueued = await enqueueTraceDispatch(config, { operation: 'update', uuid, patch }, runtime);
342
+ if (enqueued)
343
+ return;
344
+ }
345
+ await persistUpdateFallback(storage, uuid, patch);
346
+ });
347
+ };
154
348
  export const TraceContentBudget = Object.freeze({
155
- wrapStorage(storage) {
349
+ wrapStorage(storage, config, runtime) {
350
+ startInternalDispatchWorker(storage, config, runtime);
156
351
  return Object.freeze({
157
352
  ...storage,
158
353
  writeEntry: async (entry) => {
159
- await storage.writeEntry(fitEntryToBudget(entry));
354
+ dispatchWrite(storage, config, entry, runtime);
160
355
  },
161
356
  updateEntry: async (uuid, patch) => {
162
- await storage.updateEntry(uuid, fitPatchToBudget(patch));
357
+ dispatchUpdate(storage, config, uuid, patch, runtime);
163
358
  },
164
359
  });
165
360
  },
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/trace",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Trace assistant for ZinTrust: logs requests, queries, exceptions, jobs, and more.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@zintrust/core": "^0.5.1"
43
+ "@zintrust/core": "^0.5.2"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"
@@ -56,4 +56,4 @@
56
56
  "build": "tsc -p tsconfig.json && tsc -p tsconfig.migrations.json && node ../../scripts/fix-dist-esm-imports.mjs dist",
57
57
  "prepublishOnly": "npm run build"
58
58
  }
59
- }
59
+ }
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  TraceClientRequestCaptureRule,
7
7
  TraceClientRequestWatcherToggle,
8
8
  TraceConfigOverrides,
9
+ TraceContentDispatchConfig,
9
10
  TraceFilterRule,
10
11
  TraceRequestWatcherConfig,
11
12
  TraceWatcherToggle,
@@ -233,6 +234,25 @@ const mergeWatchers = (
233
234
  };
234
235
  };
235
236
 
237
+ const mergeContentDispatch = (
238
+ base: TraceContentDispatchConfig,
239
+ override?: TraceConfigOverrides['contentDispatch']
240
+ ): TraceContentDispatchConfig => {
241
+ const workerOverride = override?.worker;
242
+
243
+ return {
244
+ ...base,
245
+ ...override,
246
+ worker:
247
+ workerOverride === undefined
248
+ ? base.worker
249
+ : {
250
+ ...base.worker,
251
+ ...workerOverride,
252
+ },
253
+ };
254
+ };
255
+
236
256
  const DEFAULTS: ITraceConfig = Object.freeze({
237
257
  enabled: false,
238
258
  connection: undefined,
@@ -243,6 +263,17 @@ const DEFAULTS: ITraceConfig = Object.freeze({
243
263
  captureCachePayloads: false,
244
264
  captureQueryBindings: true,
245
265
  logMinLevel: 'info',
266
+ contentDispatch: {
267
+ driver: undefined,
268
+ queueName: 'trace-content',
269
+ enqueueTimeoutMs: 25,
270
+ worker: {
271
+ enabled: true,
272
+ intervalMs: 1000,
273
+ maxDurationMs: 250,
274
+ concurrency: 1,
275
+ },
276
+ },
246
277
  watchers: {},
247
278
  redaction: {
248
279
  keys: [
@@ -310,6 +341,7 @@ export const TraceConfig = Object.freeze({
310
341
  return Object.freeze({
311
342
  ...DEFAULTS,
312
343
  ...overrides,
344
+ contentDispatch: mergeContentDispatch(DEFAULTS.contentDispatch, overrides.contentDispatch),
313
345
  watchers: mergeWatchers(DEFAULTS.watchers, overrides.watchers),
314
346
  redaction: {
315
347
  keys: mergeStringLists(DEFAULTS.redaction.keys, overrides.redaction?.keys),
package/src/index.ts CHANGED
@@ -94,6 +94,8 @@ export type {
94
94
  RequestContent,
95
95
  ScheduleContent,
96
96
  TraceConfigOverrides,
97
+ TraceContentDispatchConfig,
98
+ TraceContentDispatchWorkerConfig,
97
99
  ViewContent,
98
100
  WatcherToggles,
99
101
  } from './types';
package/src/register.ts CHANGED
@@ -309,6 +309,28 @@ if (!traceAlreadyInitialized && Env) {
309
309
  const logMinLevelRaw = Env.get('TRACE_LOG_LEVEL', '').trim();
310
310
  const captureCachePayloadsRaw = Env.get('TRACE_CACHE_PAYLOADS', '').trim();
311
311
  const captureQueryBindingsRaw = Env.get('TRACE_QUERY_BINDINGS', '').trim();
312
+ const contentDispatchDriverRaw = Env.get('TRACE_CONTENT_QUEUE_DRIVER', '').trim();
313
+ const contentDispatchQueueRaw = Env.get('TRACE_CONTENT_QUEUE_NAME', '').trim();
314
+ const contentDispatchEnqueueTimeoutRaw = Env.get(
315
+ 'TRACE_CONTENT_QUEUE_ENQUEUE_TIMEOUT_MS',
316
+ ''
317
+ ).trim();
318
+ const contentDispatchWorkerEnabledRaw = Env.get(
319
+ 'TRACE_CONTENT_QUEUE_WORKER_ENABLED',
320
+ ''
321
+ ).trim();
322
+ const contentDispatchWorkerIntervalRaw = Env.get(
323
+ 'TRACE_CONTENT_QUEUE_WORKER_INTERVAL_MS',
324
+ ''
325
+ ).trim();
326
+ const contentDispatchWorkerDurationRaw = Env.get(
327
+ 'TRACE_CONTENT_QUEUE_WORKER_MAX_DURATION_MS',
328
+ ''
329
+ ).trim();
330
+ const contentDispatchWorkerConcurrencyRaw = Env.get(
331
+ 'TRACE_CONTENT_QUEUE_WORKER_CONCURRENCY',
332
+ ''
333
+ ).trim();
312
334
  const redactionKeys = parseEnvList(Env.get('TRACE_REDACT_KEYS', ''));
313
335
  const redactionHeaders = parseEnvList(Env.get('TRACE_REDACT_HEADERS', ''));
314
336
  const redactionBody = parseEnvList(Env.get('TRACE_REDACT_BODY', ''));
@@ -335,6 +357,33 @@ if (!traceAlreadyInitialized && Env) {
335
357
  parseEnvBool(captureCachePayloadsRaw) ?? startupOverrides?.captureCachePayloads;
336
358
  const captureQueryBindings =
337
359
  parseEnvBool(captureQueryBindingsRaw) ?? startupOverrides?.captureQueryBindings;
360
+ const contentDispatchDriver =
361
+ contentDispatchDriverRaw === ''
362
+ ? startupOverrides?.contentDispatch?.driver
363
+ : contentDispatchDriverRaw;
364
+ const contentDispatchQueueName =
365
+ contentDispatchQueueRaw === ''
366
+ ? startupOverrides?.contentDispatch?.queueName
367
+ : contentDispatchQueueRaw;
368
+ const contentDispatchEnqueueTimeout =
369
+ contentDispatchEnqueueTimeoutRaw === ''
370
+ ? startupOverrides?.contentDispatch?.enqueueTimeoutMs
371
+ : Number.parseInt(contentDispatchEnqueueTimeoutRaw, 10);
372
+ const contentDispatchWorkerEnabled =
373
+ parseEnvBool(contentDispatchWorkerEnabledRaw) ??
374
+ startupOverrides?.contentDispatch?.worker?.enabled;
375
+ const contentDispatchWorkerInterval =
376
+ contentDispatchWorkerIntervalRaw === ''
377
+ ? startupOverrides?.contentDispatch?.worker?.intervalMs
378
+ : Number.parseInt(contentDispatchWorkerIntervalRaw, 10);
379
+ const contentDispatchWorkerDuration =
380
+ contentDispatchWorkerDurationRaw === ''
381
+ ? startupOverrides?.contentDispatch?.worker?.maxDurationMs
382
+ : Number.parseInt(contentDispatchWorkerDurationRaw, 10);
383
+ const contentDispatchWorkerConcurrency =
384
+ contentDispatchWorkerConcurrencyRaw === ''
385
+ ? startupOverrides?.contentDispatch?.worker?.concurrency
386
+ : Number.parseInt(contentDispatchWorkerConcurrencyRaw, 10);
338
387
  const redaction = buildTraceRedactionOverrides({
339
388
  startupOverrides,
340
389
  redactionBody,
@@ -342,6 +391,9 @@ if (!traceAlreadyInitialized && Env) {
342
391
  redactionKeys,
343
392
  redactionQuery,
344
393
  });
394
+ const defaultContentDispatch = TraceConfig.defaults().contentDispatch;
395
+ const startupContentDispatch = startupOverrides?.contentDispatch;
396
+ const startupContentDispatchWorker = startupContentDispatch?.worker;
345
397
 
346
398
  const config = TraceConfig.merge({
347
399
  ...startupOverrides,
@@ -356,6 +408,46 @@ if (!traceAlreadyInitialized && Env) {
356
408
  : {}),
357
409
  ...(typeof captureCachePayloads === 'boolean' ? { captureCachePayloads } : {}),
358
410
  ...(typeof captureQueryBindings === 'boolean' ? { captureQueryBindings } : {}),
411
+ contentDispatch: {
412
+ ...defaultContentDispatch,
413
+ ...startupContentDispatch,
414
+ ...(typeof contentDispatchDriver === 'string' && contentDispatchDriver !== ''
415
+ ? { driver: contentDispatchDriver }
416
+ : {}),
417
+ ...(typeof contentDispatchQueueName === 'string' && contentDispatchQueueName !== ''
418
+ ? { queueName: contentDispatchQueueName }
419
+ : {}),
420
+ ...(typeof contentDispatchEnqueueTimeout === 'number' &&
421
+ Number.isFinite(contentDispatchEnqueueTimeout)
422
+ ? { enqueueTimeoutMs: contentDispatchEnqueueTimeout }
423
+ : {}),
424
+ worker: {
425
+ ...defaultContentDispatch.worker,
426
+ ...startupContentDispatchWorker,
427
+ enabled:
428
+ typeof contentDispatchWorkerEnabled === 'boolean'
429
+ ? contentDispatchWorkerEnabled
430
+ : (startupContentDispatchWorker?.enabled ?? defaultContentDispatch.worker.enabled),
431
+ intervalMs:
432
+ typeof contentDispatchWorkerInterval === 'number' &&
433
+ Number.isFinite(contentDispatchWorkerInterval)
434
+ ? contentDispatchWorkerInterval
435
+ : (startupContentDispatchWorker?.intervalMs ??
436
+ defaultContentDispatch.worker.intervalMs),
437
+ maxDurationMs:
438
+ typeof contentDispatchWorkerDuration === 'number' &&
439
+ Number.isFinite(contentDispatchWorkerDuration)
440
+ ? contentDispatchWorkerDuration
441
+ : (startupContentDispatchWorker?.maxDurationMs ??
442
+ defaultContentDispatch.worker.maxDurationMs),
443
+ concurrency:
444
+ typeof contentDispatchWorkerConcurrency === 'number' &&
445
+ Number.isFinite(contentDispatchWorkerConcurrency)
446
+ ? contentDispatchWorkerConcurrency
447
+ : (startupContentDispatchWorker?.concurrency ??
448
+ defaultContentDispatch.worker.concurrency),
449
+ },
450
+ },
359
451
  logMinLevel,
360
452
  ...(redaction === undefined ? {} : { redaction }),
361
453
  });
@@ -384,7 +476,8 @@ if (!traceAlreadyInitialized && Env) {
384
476
  TraceContentRedaction.wrapStorage(
385
477
  TraceEntryFiltering.wrapStorage(TraceStorage.resolveStorage(storageDb), config),
386
478
  config.redaction
387
- )
479
+ ),
480
+ config
388
481
  ),
389
482
  {
390
483
  connectionName: resolvedConnectionName,
@@ -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
 
@@ -208,18 +209,342 @@ const fitPatchToBudget = (
208
209
  };
209
210
  };
210
211
 
212
+ type TraceDispatchMessage =
213
+ | { operation: 'write'; entry: ITraceEntry }
214
+ | {
215
+ operation: 'update';
216
+ uuid: string;
217
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>;
218
+ };
219
+
220
+ type QueueApi = {
221
+ get(name?: string): {
222
+ enqueue<T = unknown>(queue: string, payload: T): Promise<string>;
223
+ };
224
+ };
225
+
226
+ type TimeoutManagerApi = {
227
+ withTimeout<T>(
228
+ operation: () => Promise<T>,
229
+ timeoutMs: number,
230
+ operationName: string,
231
+ timeoutHandler?: () => Promise<T>
232
+ ): Promise<T>;
233
+ };
234
+
235
+ type QueueWorkerApi = {
236
+ createQueueWorker<TPayload>(options: {
237
+ kindLabel: string;
238
+ defaultQueueName: string;
239
+ maxAttempts: number;
240
+ getLogFields?: (payload: {
241
+ id: string;
242
+ payload: TPayload;
243
+ attempts: number;
244
+ }) => Record<string, unknown>;
245
+ handle(payload: TPayload): Promise<void>;
246
+ }): {
247
+ runOnce(options?: {
248
+ queueName?: string;
249
+ driverName?: string;
250
+ maxItems?: number;
251
+ maxDurationMs?: number;
252
+ concurrency?: number;
253
+ }): Promise<number>;
254
+ };
255
+ };
256
+
257
+ type UnrefableTimer = ReturnType<typeof setInterval> & { unref?: () => void };
258
+
259
+ type TraceContentBudgetRuntime = {
260
+ queue?: QueueApi | null;
261
+ timeoutManager?: TimeoutManagerApi | null;
262
+ queueWorkerApi?: QueueWorkerApi | null;
263
+ };
264
+
265
+ const startedWorkerKeys = new Set<string>();
266
+
267
+ const closePort = (port: MessagePort): void => {
268
+ if (typeof port.close === 'function') {
269
+ port.close();
270
+ }
271
+ };
272
+
273
+ const scheduleTask = (task: () => Promise<void>): void => {
274
+ if (typeof MessageChannel === 'function') {
275
+ const channel = new MessageChannel();
276
+
277
+ channel.port1.onmessage = (): void => {
278
+ channel.port1.onmessage = null;
279
+ closePort(channel.port1);
280
+ closePort(channel.port2);
281
+ void task().catch(() => undefined);
282
+ };
283
+
284
+ channel.port2.postMessage(undefined);
285
+ return;
286
+ }
287
+
288
+ Promise.resolve()
289
+ .then(() => {
290
+ void task().catch(() => undefined);
291
+ })
292
+ .catch(() => undefined);
293
+ };
294
+
295
+ const getReplacementContent = (content: unknown): Record<string, unknown> => {
296
+ return {
297
+ __traceNotice: REPLACED_CONTENT_MESSAGE,
298
+ dropped: true,
299
+ valueType: describeValueType(content),
300
+ };
301
+ };
302
+
303
+ const replaceEntryContent = (entry: ITraceEntry): ITraceEntry => ({
304
+ ...entry,
305
+ content: getReplacementContent(entry.content),
306
+ });
307
+
308
+ const replacePatchContent = (
309
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
310
+ ): Partial<Pick<ITraceEntry, 'content' | 'isLatest'>> => {
311
+ if (patch.content === undefined) return patch;
312
+
313
+ return {
314
+ ...patch,
315
+ content: getReplacementContent(patch.content),
316
+ };
317
+ };
318
+
319
+ const shouldReplaceContent = (content: unknown): boolean => {
320
+ return serializedSize(content) > DEFAULT_MAX_ENTRY_BYTES;
321
+ };
322
+
323
+ const hasQueueDispatch = (config: ITraceConfig): boolean => {
324
+ const driver = config.contentDispatch.driver?.trim();
325
+ return typeof driver === 'string' && driver !== '';
326
+ };
327
+
328
+ const getCoreRuntime = async (): Promise<{
329
+ Queue: QueueApi | null;
330
+ TimeoutManager: TimeoutManagerApi | null;
331
+ }> => {
332
+ try {
333
+ const mod = (await import('@zintrust/core')) as unknown as {
334
+ Queue?: QueueApi;
335
+ TimeoutManager?: TimeoutManagerApi;
336
+ };
337
+ return {
338
+ Queue: mod.Queue ?? null,
339
+ TimeoutManager: mod.TimeoutManager ?? null,
340
+ };
341
+ } catch {
342
+ return {
343
+ Queue: null,
344
+ TimeoutManager: null,
345
+ };
346
+ }
347
+ };
348
+
349
+ const getQueueWorkerApi = async (): Promise<QueueWorkerApi | null> => {
350
+ try {
351
+ const mod = (await import('@zintrust/workers')) as unknown as QueueWorkerApi;
352
+ return typeof mod.createQueueWorker === 'function' ? mod : null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ };
357
+
358
+ const enqueueTraceDispatch = async (
359
+ config: ITraceConfig,
360
+ payload: TraceDispatchMessage,
361
+ runtime?: TraceContentBudgetRuntime
362
+ ): Promise<boolean> => {
363
+ const driverName = config.contentDispatch.driver?.trim();
364
+ if (driverName === undefined || driverName === '') return false;
365
+
366
+ const coreRuntime =
367
+ runtime?.queue !== undefined || runtime?.timeoutManager !== undefined
368
+ ? {
369
+ Queue: runtime?.queue ?? null,
370
+ TimeoutManager: runtime?.timeoutManager ?? null,
371
+ }
372
+ : await getCoreRuntime();
373
+ const queueApi = coreRuntime.Queue;
374
+ if (queueApi === null) return false;
375
+
376
+ try {
377
+ const driver = queueApi.get(driverName);
378
+ const timeoutMs = Math.max(1, config.contentDispatch.enqueueTimeoutMs);
379
+ if (coreRuntime.TimeoutManager === null) {
380
+ await driver.enqueue(config.contentDispatch.queueName, payload);
381
+ } else {
382
+ await coreRuntime.TimeoutManager.withTimeout(
383
+ () => driver.enqueue(config.contentDispatch.queueName, payload),
384
+ timeoutMs,
385
+ 'trace-content-dispatch-enqueue'
386
+ );
387
+ }
388
+
389
+ return true;
390
+ } catch {
391
+ return false;
392
+ }
393
+ };
394
+
395
+ const persistWriteFallback = async (storage: ITraceStorage, entry: ITraceEntry): Promise<void> => {
396
+ await storage.writeEntry(
397
+ shouldReplaceContent(entry.content) ? replaceEntryContent(entry) : entry
398
+ );
399
+ };
400
+
401
+ const persistUpdateFallback = async (
402
+ storage: ITraceStorage,
403
+ uuid: string,
404
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
405
+ ): Promise<void> => {
406
+ await storage.updateEntry(
407
+ uuid,
408
+ patch.content !== undefined && shouldReplaceContent(patch.content)
409
+ ? replacePatchContent(patch)
410
+ : patch
411
+ );
412
+ };
413
+
414
+ const processQueuedMessage = async (
415
+ storage: ITraceStorage,
416
+ message: TraceDispatchMessage
417
+ ): Promise<void> => {
418
+ if (message.operation === 'write') {
419
+ await storage.writeEntry(fitEntryToBudget(message.entry));
420
+ return;
421
+ }
422
+
423
+ await storage.updateEntry(message.uuid, fitPatchToBudget(message.patch));
424
+ };
425
+
426
+ const ensureWorkerTimer = (_key: string, timer: ReturnType<typeof setInterval>): void => {
427
+ const unrefable = timer as UnrefableTimer;
428
+ if (typeof unrefable.unref === 'function') {
429
+ unrefable.unref();
430
+ }
431
+ };
432
+
433
+ const startInternalDispatchWorker = (
434
+ storage: ITraceStorage,
435
+ config: ITraceConfig,
436
+ runtime?: TraceContentBudgetRuntime
437
+ ): void => {
438
+ if (!hasQueueDispatch(config) || config.contentDispatch.worker.enabled !== true) return;
439
+
440
+ const driverName = config.contentDispatch.driver?.trim() ?? '';
441
+ const key = `${driverName}:${config.contentDispatch.queueName}`;
442
+ if (startedWorkerKeys.has(key)) return;
443
+ startedWorkerKeys.add(key);
444
+
445
+ scheduleTask(async () => {
446
+ const workersApi = runtime?.queueWorkerApi ?? (await getQueueWorkerApi());
447
+ if (workersApi === null) {
448
+ startedWorkerKeys.delete(key);
449
+ return;
450
+ }
451
+
452
+ let running = false;
453
+ const runWorker = async (): Promise<void> => {
454
+ if (running) return;
455
+ running = true;
456
+ try {
457
+ const worker = workersApi.createQueueWorker<TraceDispatchMessage>({
458
+ kindLabel: 'trace-content-dispatch',
459
+ defaultQueueName: config.contentDispatch.queueName,
460
+ maxAttempts: 1,
461
+ getLogFields: () => ({
462
+ queueName: config.contentDispatch.queueName,
463
+ driverName,
464
+ }),
465
+ handle: async (payload) => {
466
+ await processQueuedMessage(storage, payload);
467
+ },
468
+ });
469
+
470
+ await worker.runOnce({
471
+ queueName: config.contentDispatch.queueName,
472
+ driverName,
473
+ maxDurationMs: Math.max(1, config.contentDispatch.worker.maxDurationMs),
474
+ concurrency: Math.max(1, config.contentDispatch.worker.concurrency),
475
+ });
476
+ } finally {
477
+ running = false;
478
+ }
479
+ };
480
+
481
+ await runWorker();
482
+
483
+ const intervalMs = Math.max(100, config.contentDispatch.worker.intervalMs);
484
+ ensureWorkerTimer(
485
+ key,
486
+ setInterval(() => {
487
+ void runWorker();
488
+ }, intervalMs)
489
+ );
490
+ });
491
+ };
492
+
493
+ const dispatchWrite = (
494
+ storage: ITraceStorage,
495
+ config: ITraceConfig,
496
+ entry: ITraceEntry,
497
+ runtime?: TraceContentBudgetRuntime
498
+ ): void => {
499
+ scheduleTask(async () => {
500
+ if (hasQueueDispatch(config)) {
501
+ const enqueued = await enqueueTraceDispatch(config, { operation: 'write', entry }, runtime);
502
+ if (enqueued) return;
503
+ }
504
+
505
+ await persistWriteFallback(storage, entry);
506
+ });
507
+ };
508
+
509
+ const dispatchUpdate = (
510
+ storage: ITraceStorage,
511
+ config: ITraceConfig,
512
+ uuid: string,
513
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>,
514
+ runtime?: TraceContentBudgetRuntime
515
+ ): void => {
516
+ scheduleTask(async () => {
517
+ if (hasQueueDispatch(config)) {
518
+ const enqueued = await enqueueTraceDispatch(
519
+ config,
520
+ { operation: 'update', uuid, patch },
521
+ runtime
522
+ );
523
+ if (enqueued) return;
524
+ }
525
+
526
+ await persistUpdateFallback(storage, uuid, patch);
527
+ });
528
+ };
529
+
211
530
  export const TraceContentBudget = Object.freeze({
212
- wrapStorage(storage: ITraceStorage): ITraceStorage {
531
+ wrapStorage(
532
+ storage: ITraceStorage,
533
+ config: ITraceConfig,
534
+ runtime?: TraceContentBudgetRuntime
535
+ ): ITraceStorage {
536
+ startInternalDispatchWorker(storage, config, runtime);
537
+
213
538
  return Object.freeze({
214
539
  ...storage,
215
540
  writeEntry: async (entry: ITraceEntry): Promise<void> => {
216
- await storage.writeEntry(fitEntryToBudget(entry));
541
+ dispatchWrite(storage, config, entry, runtime);
217
542
  },
218
543
  updateEntry: async (
219
544
  uuid: string,
220
545
  patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
221
546
  ): Promise<void> => {
222
- await storage.updateEntry(uuid, fitPatchToBudget(patch));
547
+ dispatchUpdate(storage, config, uuid, patch, runtime);
223
548
  },
224
549
  });
225
550
  },
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
  }