@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 +55 -13
- package/dist/config.js +25 -0
- package/dist/index.d.ts +1 -1
- package/dist/register.js +67 -1
- package/dist/storage/TraceContentBudget.d.ts +37 -2
- package/dist/storage/TraceContentBudget.js +198 -3
- package/dist/types.d.ts +13 -0
- package/package.json +3 -3
- package/src/config.ts +32 -0
- package/src/index.ts +2 -0
- package/src/register.ts +94 -1
- package/src/storage/TraceContentBudget.ts +329 -4
- package/src/types.ts +15 -0
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
|
|
262
|
-
|
|
|
263
|
-
| `enabled`
|
|
264
|
-
| `connection`
|
|
265
|
-
| `
|
|
266
|
-
| `
|
|
267
|
-
| `
|
|
268
|
-
| `
|
|
269
|
-
| `
|
|
270
|
-
| `
|
|
271
|
-
| `
|
|
272
|
-
| `
|
|
273
|
-
| `
|
|
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
|
-
|
|
354
|
+
dispatchWrite(storage, config, entry, runtime);
|
|
160
355
|
},
|
|
161
356
|
updateEntry: async (uuid, patch) => {
|
|
162
|
-
|
|
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.
|
|
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.
|
|
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
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|