@zintrust/trace 0.5.2 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +222 -65
- 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 +360 -96
- 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 {
|
|
@@ -21,58 +22,6 @@ const describeValueType = (value) => {
|
|
|
21
22
|
return 'null';
|
|
22
23
|
return typeof value;
|
|
23
24
|
};
|
|
24
|
-
const chooseLargerCandidate = (left, right) => {
|
|
25
|
-
if (left === null)
|
|
26
|
-
return right;
|
|
27
|
-
if (right === null)
|
|
28
|
-
return left;
|
|
29
|
-
return right.size > left.size ? right : left;
|
|
30
|
-
};
|
|
31
|
-
const fallbackCandidate = (value, path) => {
|
|
32
|
-
return path.length === 0 ? null : { path, size: serializedSize(value) };
|
|
33
|
-
};
|
|
34
|
-
const findLargestDroppablePathInArray = (value, path) => {
|
|
35
|
-
let best = null;
|
|
36
|
-
for (const [index, item] of value.entries()) {
|
|
37
|
-
best = chooseLargerCandidate(best, findLargestDroppablePath(item, [...path, index]));
|
|
38
|
-
}
|
|
39
|
-
return best ?? fallbackCandidate(value, path);
|
|
40
|
-
};
|
|
41
|
-
const findLargestDroppablePathInObject = (value, path) => {
|
|
42
|
-
let best = null;
|
|
43
|
-
for (const [key, entryValue] of Object.entries(value)) {
|
|
44
|
-
if (key === '__traceNotice')
|
|
45
|
-
continue;
|
|
46
|
-
best = chooseLargerCandidate(best, findLargestDroppablePath(entryValue, [...path, key]));
|
|
47
|
-
}
|
|
48
|
-
return best ?? fallbackCandidate(value, path);
|
|
49
|
-
};
|
|
50
|
-
const findLargestDroppablePath = (value, path = []) => {
|
|
51
|
-
if (Array.isArray(value))
|
|
52
|
-
return findLargestDroppablePathInArray(value, path);
|
|
53
|
-
if (typeof value === 'object' && value !== null) {
|
|
54
|
-
return findLargestDroppablePathInObject(value, path);
|
|
55
|
-
}
|
|
56
|
-
return fallbackCandidate(value, path);
|
|
57
|
-
};
|
|
58
|
-
const replaceAtPath = (value, path, replacement) => {
|
|
59
|
-
if (path.length === 0)
|
|
60
|
-
return replacement;
|
|
61
|
-
const [segment, ...rest] = path;
|
|
62
|
-
if (Array.isArray(value) && typeof segment === 'number') {
|
|
63
|
-
const next = value.slice();
|
|
64
|
-
next[segment] = replaceAtPath(next[segment], rest, replacement);
|
|
65
|
-
return next;
|
|
66
|
-
}
|
|
67
|
-
if (typeof value === 'object' && value !== null && typeof segment === 'string') {
|
|
68
|
-
const current = value;
|
|
69
|
-
return {
|
|
70
|
-
...current,
|
|
71
|
-
[segment]: replaceAtPath(current[segment], rest, replacement),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
return value;
|
|
75
|
-
};
|
|
76
25
|
const compactValue = (value, depth) => {
|
|
77
26
|
if (depth >= DEFAULT_MAX_DEPTH) {
|
|
78
27
|
return DROPPED_FIELD_MESSAGE;
|
|
@@ -105,17 +54,28 @@ const compactValue = (value, depth) => {
|
|
|
105
54
|
return Object.fromEntries(compactedEntries);
|
|
106
55
|
};
|
|
107
56
|
const compactStructuredValueToBudget = (value) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
57
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
const compacted = {
|
|
61
|
+
...value,
|
|
62
|
+
__traceNotice: COMPACTED_CONTENT_MESSAGE,
|
|
63
|
+
};
|
|
64
|
+
const topLevelCandidates = Object.entries(compacted)
|
|
65
|
+
.filter(([key]) => key !== '__traceNotice')
|
|
66
|
+
.map(([key, entryValue]) => ({ key, size: serializedSize(entryValue) }))
|
|
67
|
+
.sort((left, right) => right.size - left.size);
|
|
68
|
+
let droppedCount = 0;
|
|
69
|
+
for (const candidate of topLevelCandidates) {
|
|
70
|
+
if (serializedSize(compacted) <= DEFAULT_MAX_ENTRY_BYTES) {
|
|
117
71
|
break;
|
|
118
|
-
|
|
72
|
+
}
|
|
73
|
+
compacted[candidate.key] = DROPPED_FIELD_MESSAGE;
|
|
74
|
+
droppedCount += 1;
|
|
75
|
+
}
|
|
76
|
+
if (droppedCount > 0) {
|
|
77
|
+
compacted['__traceNotice'] =
|
|
78
|
+
`${COMPACTED_CONTENT_MESSAGE} ${String(droppedCount)} top-level field(s) were dropped.`;
|
|
119
79
|
}
|
|
120
80
|
return compacted;
|
|
121
81
|
};
|
|
@@ -151,15 +111,212 @@ const fitPatchToBudget = (patch) => {
|
|
|
151
111
|
content: fitContentToBudget(patch.content),
|
|
152
112
|
};
|
|
153
113
|
};
|
|
114
|
+
const startedWorkerKeys = new Set();
|
|
115
|
+
const closePort = (port) => {
|
|
116
|
+
if (typeof port.close === 'function') {
|
|
117
|
+
port.close();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const scheduleTask = async (task) => {
|
|
121
|
+
return await new Promise((resolve, reject) => {
|
|
122
|
+
const runTask = () => {
|
|
123
|
+
void task().then(resolve).catch(reject);
|
|
124
|
+
};
|
|
125
|
+
if (typeof MessageChannel === 'function') {
|
|
126
|
+
const channel = new MessageChannel();
|
|
127
|
+
channel.port1.onmessage = () => {
|
|
128
|
+
channel.port1.onmessage = null;
|
|
129
|
+
closePort(channel.port1);
|
|
130
|
+
closePort(channel.port2);
|
|
131
|
+
runTask();
|
|
132
|
+
};
|
|
133
|
+
channel.port2.postMessage(undefined);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
Promise.resolve().then(runTask).catch(reject);
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
const getReplacementContent = (content) => {
|
|
140
|
+
return {
|
|
141
|
+
__traceNotice: REPLACED_CONTENT_MESSAGE,
|
|
142
|
+
dropped: true,
|
|
143
|
+
valueType: describeValueType(content),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
const replaceEntryContent = (entry) => ({
|
|
147
|
+
...entry,
|
|
148
|
+
content: getReplacementContent(entry.content),
|
|
149
|
+
});
|
|
150
|
+
const replacePatchContent = (patch) => {
|
|
151
|
+
if (patch.content === undefined)
|
|
152
|
+
return patch;
|
|
153
|
+
return {
|
|
154
|
+
...patch,
|
|
155
|
+
content: getReplacementContent(patch.content),
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
const shouldReplaceContent = (content) => {
|
|
159
|
+
return serializedSize(content) > DEFAULT_MAX_ENTRY_BYTES;
|
|
160
|
+
};
|
|
161
|
+
const hasQueueDispatch = (config) => {
|
|
162
|
+
const driver = config.contentDispatch.driver?.trim();
|
|
163
|
+
return typeof driver === 'string' && driver !== '';
|
|
164
|
+
};
|
|
165
|
+
const getCoreRuntime = async () => {
|
|
166
|
+
try {
|
|
167
|
+
const mod = (await import('@zintrust/core'));
|
|
168
|
+
return {
|
|
169
|
+
Queue: mod.Queue ?? null,
|
|
170
|
+
TimeoutManager: mod.TimeoutManager ?? null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return {
|
|
175
|
+
Queue: null,
|
|
176
|
+
TimeoutManager: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const getQueueWorkerApi = async () => {
|
|
181
|
+
try {
|
|
182
|
+
const mod = (await import('@zintrust/workers'));
|
|
183
|
+
return typeof mod.createQueueWorker === 'function' ? mod : null;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const enqueueTraceDispatch = async (config, payload, runtime) => {
|
|
190
|
+
const driverName = config.contentDispatch.driver?.trim();
|
|
191
|
+
if (driverName === undefined || driverName === '')
|
|
192
|
+
return false;
|
|
193
|
+
const coreRuntime = runtime?.queue !== undefined || runtime?.timeoutManager !== undefined
|
|
194
|
+
? {
|
|
195
|
+
Queue: runtime?.queue ?? null,
|
|
196
|
+
TimeoutManager: runtime?.timeoutManager ?? null,
|
|
197
|
+
}
|
|
198
|
+
: await getCoreRuntime();
|
|
199
|
+
const queueApi = coreRuntime.Queue;
|
|
200
|
+
if (queueApi === null)
|
|
201
|
+
return false;
|
|
202
|
+
try {
|
|
203
|
+
const driver = queueApi.get(driverName);
|
|
204
|
+
const timeoutMs = Math.max(1, config.contentDispatch.enqueueTimeoutMs);
|
|
205
|
+
if (coreRuntime.TimeoutManager === null) {
|
|
206
|
+
await driver.enqueue(config.contentDispatch.queueName, payload);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
await coreRuntime.TimeoutManager.withTimeout(() => driver.enqueue(config.contentDispatch.queueName, payload), timeoutMs, 'trace-content-dispatch-enqueue');
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const persistWriteFallback = async (storage, entry) => {
|
|
218
|
+
await storage.writeEntry(shouldReplaceContent(entry.content) ? replaceEntryContent(entry) : entry);
|
|
219
|
+
};
|
|
220
|
+
const persistUpdateFallback = async (storage, uuid, patch) => {
|
|
221
|
+
await storage.updateEntry(uuid, patch.content !== undefined && shouldReplaceContent(patch.content)
|
|
222
|
+
? replacePatchContent(patch)
|
|
223
|
+
: patch);
|
|
224
|
+
};
|
|
225
|
+
const processQueuedMessage = async (storage, message) => {
|
|
226
|
+
if (message.operation === 'write') {
|
|
227
|
+
await storage.writeEntry(fitEntryToBudget(message.entry));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
await storage.updateEntry(message.uuid, fitPatchToBudget(message.patch));
|
|
231
|
+
};
|
|
232
|
+
const ensureWorkerTimer = (_key, timer) => {
|
|
233
|
+
const unrefable = timer;
|
|
234
|
+
if (typeof unrefable.unref === 'function') {
|
|
235
|
+
unrefable.unref();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const startInternalDispatchWorker = (storage, config, runtime) => {
|
|
239
|
+
if (!hasQueueDispatch(config) || config.contentDispatch.worker.enabled !== true)
|
|
240
|
+
return;
|
|
241
|
+
const driverName = config.contentDispatch.driver?.trim() ?? '';
|
|
242
|
+
const key = `${driverName}:${config.contentDispatch.queueName}`;
|
|
243
|
+
if (startedWorkerKeys.has(key))
|
|
244
|
+
return;
|
|
245
|
+
startedWorkerKeys.add(key);
|
|
246
|
+
void scheduleTask(async () => {
|
|
247
|
+
const workersApi = runtime?.queueWorkerApi ?? (await getQueueWorkerApi());
|
|
248
|
+
if (workersApi === null) {
|
|
249
|
+
startedWorkerKeys.delete(key);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let running = false;
|
|
253
|
+
const runWorker = async () => {
|
|
254
|
+
if (running)
|
|
255
|
+
return;
|
|
256
|
+
running = true;
|
|
257
|
+
try {
|
|
258
|
+
const worker = workersApi.createQueueWorker({
|
|
259
|
+
kindLabel: 'trace-content-dispatch',
|
|
260
|
+
defaultQueueName: config.contentDispatch.queueName,
|
|
261
|
+
maxAttempts: 1,
|
|
262
|
+
getLogFields: () => ({
|
|
263
|
+
queueName: config.contentDispatch.queueName,
|
|
264
|
+
driverName,
|
|
265
|
+
}),
|
|
266
|
+
handle: async (payload) => {
|
|
267
|
+
await processQueuedMessage(storage, payload);
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
await worker.runOnce({
|
|
271
|
+
queueName: config.contentDispatch.queueName,
|
|
272
|
+
driverName,
|
|
273
|
+
maxDurationMs: Math.max(1, config.contentDispatch.worker.maxDurationMs),
|
|
274
|
+
concurrency: Math.max(1, config.contentDispatch.worker.concurrency),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
running = false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
await runWorker();
|
|
282
|
+
const intervalMs = Math.max(100, config.contentDispatch.worker.intervalMs);
|
|
283
|
+
ensureWorkerTimer(key, setInterval(() => {
|
|
284
|
+
void runWorker();
|
|
285
|
+
}, intervalMs));
|
|
286
|
+
}).catch(() => {
|
|
287
|
+
startedWorkerKeys.delete(key);
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
const dispatchWrite = async (storage, config, entry, runtime) => {
|
|
291
|
+
await scheduleTask(async () => {
|
|
292
|
+
if (hasQueueDispatch(config)) {
|
|
293
|
+
const enqueued = await enqueueTraceDispatch(config, { operation: 'write', entry }, runtime);
|
|
294
|
+
if (enqueued)
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await persistWriteFallback(storage, entry);
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
const dispatchUpdate = async (storage, config, uuid, patch, runtime) => {
|
|
301
|
+
await scheduleTask(async () => {
|
|
302
|
+
if (hasQueueDispatch(config)) {
|
|
303
|
+
const enqueued = await enqueueTraceDispatch(config, { operation: 'update', uuid, patch }, runtime);
|
|
304
|
+
if (enqueued)
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await persistUpdateFallback(storage, uuid, patch);
|
|
308
|
+
});
|
|
309
|
+
};
|
|
154
310
|
export const TraceContentBudget = Object.freeze({
|
|
155
|
-
wrapStorage(storage) {
|
|
311
|
+
wrapStorage(storage, config, runtime) {
|
|
312
|
+
startInternalDispatchWorker(storage, config, runtime);
|
|
156
313
|
return Object.freeze({
|
|
157
314
|
...storage,
|
|
158
315
|
writeEntry: async (entry) => {
|
|
159
|
-
await storage
|
|
316
|
+
await dispatchWrite(storage, config, entry, runtime);
|
|
160
317
|
},
|
|
161
318
|
updateEntry: async (uuid, patch) => {
|
|
162
|
-
await storage
|
|
319
|
+
await dispatchUpdate(storage, config, uuid, patch, runtime);
|
|
163
320
|
},
|
|
164
321
|
});
|
|
165
322
|
},
|
package/dist/types.d.ts
CHANGED
|
@@ -304,6 +304,18 @@ export type TraceRequestWatcherConfig = TraceFilterRule & {
|
|
|
304
304
|
export type TraceClientRequestWatcherConfig = TraceClientRequestCaptureRule & {
|
|
305
305
|
sources?: Record<string, TraceClientRequestCaptureRule>;
|
|
306
306
|
};
|
|
307
|
+
export type TraceContentDispatchWorkerConfig = {
|
|
308
|
+
enabled: boolean;
|
|
309
|
+
intervalMs: number;
|
|
310
|
+
maxDurationMs: number;
|
|
311
|
+
concurrency: number;
|
|
312
|
+
};
|
|
313
|
+
export type TraceContentDispatchConfig = {
|
|
314
|
+
driver?: string;
|
|
315
|
+
queueName: string;
|
|
316
|
+
enqueueTimeoutMs: number;
|
|
317
|
+
worker: TraceContentDispatchWorkerConfig;
|
|
318
|
+
};
|
|
307
319
|
export type TraceWatcherToggle = boolean | TraceFilterRule;
|
|
308
320
|
export type TraceRequestWatcherToggle = boolean | TraceRequestWatcherConfig;
|
|
309
321
|
export type TraceClientRequestWatcherToggle = boolean | TraceClientRequestWatcherConfig;
|
|
@@ -339,6 +351,7 @@ export interface ITraceConfig {
|
|
|
339
351
|
captureCachePayloads: boolean;
|
|
340
352
|
captureQueryBindings: boolean;
|
|
341
353
|
logMinLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
354
|
+
contentDispatch: TraceContentDispatchConfig;
|
|
342
355
|
watchers: WatcherToggles;
|
|
343
356
|
redaction: RedactionConfig;
|
|
344
357
|
}
|