@zintrust/trace 0.9.3 → 0.9.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.
@@ -0,0 +1,317 @@
1
+ import {
2
+ Env,
3
+ ErrorFactory,
4
+ Router,
5
+ SignedRequest,
6
+ useDatabase,
7
+ type IRequest,
8
+ type IResponse,
9
+ type IRouter,
10
+ type RouteOptions,
11
+ } from '@zintrust/core';
12
+ import { TraceConfig } from '../config';
13
+ import { TraceStorage } from '../storage';
14
+ import type { ITraceEntry, ITraceStorage } from '../types';
15
+
16
+ type TraceIngestGatewaySettings = {
17
+ basePath: string;
18
+ keyId: string;
19
+ secret: string;
20
+ signingWindowMs: number;
21
+ nonceTtlMs: number;
22
+ middleware: ReadonlyArray<string>;
23
+ storage: ITraceStorage;
24
+ };
25
+
26
+ type TraceIngestGatewayOverrides = Partial<
27
+ Omit<TraceIngestGatewaySettings, 'storage'> & { storage: ITraceStorage; connectionName: string }
28
+ >;
29
+
30
+ type TraceGatewayFailure = {
31
+ ok: false;
32
+ error: {
33
+ code: string;
34
+ message: string;
35
+ details?: unknown;
36
+ };
37
+ };
38
+
39
+ type TraceGatewaySuccess = {
40
+ ok: true;
41
+ };
42
+
43
+ const nonces = new Map<string, number>();
44
+
45
+ const nowMs = (): number => Date.now();
46
+
47
+ const normalizePath = (value: string): string => {
48
+ const trimmed = value.trim();
49
+ if (trimmed === '') return '/zin/trace/write';
50
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
51
+ };
52
+
53
+ const parseMiddleware = (value: string): ReadonlyArray<string> =>
54
+ value
55
+ .split(',')
56
+ .map((entry) => entry.trim())
57
+ .filter((entry) => entry.length > 0);
58
+
59
+ const appendSuffix = (path: string, suffix: string): string => {
60
+ const base = normalizePath(path).replace(/\/+$/, '');
61
+ const tail = suffix.startsWith('/') ? suffix : `/${suffix}`;
62
+ return `${base}${tail}`;
63
+ };
64
+
65
+ const cleanupExpiredNonces = (): void => {
66
+ const current = nowMs();
67
+ for (const [nonceKey, expiresAt] of nonces.entries()) {
68
+ if (expiresAt <= current) {
69
+ nonces.delete(nonceKey);
70
+ }
71
+ }
72
+ };
73
+
74
+ const storeNonce = async (keyId: string, nonce: string, ttlMs: number): Promise<boolean> => {
75
+ cleanupExpiredNonces();
76
+ const nonceKey = `${keyId}:${nonce}`;
77
+ if (nonces.has(nonceKey)) return false;
78
+ nonces.set(nonceKey, nowMs() + Math.max(ttlMs, 1));
79
+ return true;
80
+ };
81
+
82
+ const getBodyRecord = (req: IRequest): Record<string, unknown> => {
83
+ const body = req.getBody?.() ?? req.body;
84
+ if (typeof body === 'object' && body !== null && !Array.isArray(body)) {
85
+ return body as Record<string, unknown>;
86
+ }
87
+ return {};
88
+ };
89
+
90
+ const getRawBody = (req: IRequest): string => {
91
+ const rawText = req.context['rawBodyText'];
92
+ if (typeof rawText === 'string') return rawText;
93
+ return JSON.stringify(getBodyRecord(req));
94
+ };
95
+
96
+ const toIncomingHeaders = (req: IRequest): Record<string, string | undefined> => {
97
+ const headers = req.getHeaders();
98
+ const normalize = (value: string | string[] | undefined): string | undefined => {
99
+ if (Array.isArray(value)) return value.join(',');
100
+ return value;
101
+ };
102
+
103
+ return {
104
+ 'x-zt-key-id': normalize(headers['x-zt-key-id']),
105
+ 'x-zt-timestamp': normalize(headers['x-zt-timestamp']),
106
+ 'x-zt-nonce': normalize(headers['x-zt-nonce']),
107
+ 'x-zt-body-sha256': normalize(headers['x-zt-body-sha256']),
108
+ 'x-zt-signature': normalize(headers['x-zt-signature']),
109
+ };
110
+ };
111
+
112
+ const sendFailure = (
113
+ res: IResponse,
114
+ status: number,
115
+ code: string,
116
+ message: string,
117
+ details?: unknown
118
+ ): void => {
119
+ const payload: TraceGatewayFailure = {
120
+ ok: false,
121
+ error: { code, message, details },
122
+ };
123
+ res.status(status).json(payload);
124
+ };
125
+
126
+ const sendSuccess = (res: IResponse): void => {
127
+ const payload: TraceGatewaySuccess = { ok: true };
128
+ res.status(200).json(payload);
129
+ };
130
+
131
+ const verifyRequest = async (
132
+ req: IRequest,
133
+ bodyText: string,
134
+ settings: TraceIngestGatewaySettings,
135
+ path: string
136
+ ): Promise<{ ok: true } | { ok: false; code: string; status: number; message: string }> => {
137
+ if (settings.keyId.trim() === '' || settings.secret.trim() === '') {
138
+ return {
139
+ ok: false,
140
+ code: 'CONFIG_ERROR',
141
+ status: 500,
142
+ message: 'Trace ingest signing credentials are not configured',
143
+ };
144
+ }
145
+
146
+ const verifyResult = await SignedRequest.verify({
147
+ method: req.getMethod(),
148
+ url: new URL(path, 'http://localhost'),
149
+ body: bodyText,
150
+ headers: toIncomingHeaders(req),
151
+ nowMs: nowMs(),
152
+ windowMs: settings.signingWindowMs,
153
+ verifyNonce: async (keyId: string, nonce: string) =>
154
+ storeNonce(keyId, nonce, settings.nonceTtlMs),
155
+ getSecretForKeyId: async (keyId: string) => {
156
+ if (keyId === settings.keyId) return settings.secret;
157
+ return undefined;
158
+ },
159
+ });
160
+
161
+ if (verifyResult.ok === true) return { ok: true };
162
+
163
+ return {
164
+ ok: false,
165
+ code: verifyResult.code,
166
+ status: verifyResult.code === 'EXPIRED' || verifyResult.code === 'REPLAYED' ? 401 : 403,
167
+ message: verifyResult.message,
168
+ };
169
+ };
170
+
171
+ const createWriteHandler = (settings: TraceIngestGatewaySettings, path: string) => {
172
+ return async (req: IRequest, res: IResponse): Promise<void> => {
173
+ const body = getBodyRecord(req);
174
+ const auth = await verifyRequest(req, getRawBody(req), settings, path);
175
+ if (auth.ok === false) {
176
+ sendFailure(res, auth.status, auth.code, auth.message);
177
+ return;
178
+ }
179
+
180
+ const entry = body['entry'];
181
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
182
+ sendFailure(res, 400, 'VALIDATION_ERROR', 'entry must be an object');
183
+ return;
184
+ }
185
+
186
+ await settings.storage.writeEntry(entry as ITraceEntry);
187
+ sendSuccess(res);
188
+ };
189
+ };
190
+
191
+ const createUpdateHandler = (settings: TraceIngestGatewaySettings, path: string) => {
192
+ return async (req: IRequest, res: IResponse): Promise<void> => {
193
+ const body = getBodyRecord(req);
194
+ const auth = await verifyRequest(req, getRawBody(req), settings, path);
195
+ if (auth.ok === false) {
196
+ sendFailure(res, auth.status, auth.code, auth.message);
197
+ return;
198
+ }
199
+
200
+ const uuid = body['uuid'];
201
+ const patch = body['patch'];
202
+ if (typeof uuid !== 'string' || uuid.trim() === '') {
203
+ sendFailure(res, 400, 'VALIDATION_ERROR', 'uuid is required');
204
+ return;
205
+ }
206
+
207
+ if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
208
+ sendFailure(res, 400, 'VALIDATION_ERROR', 'patch must be an object');
209
+ return;
210
+ }
211
+
212
+ await settings.storage.updateEntry(
213
+ uuid,
214
+ patch as Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
215
+ );
216
+ sendSuccess(res);
217
+ };
218
+ };
219
+
220
+ const createMarkFamilyStaleHandler = (settings: TraceIngestGatewaySettings, path: string) => {
221
+ return async (req: IRequest, res: IResponse): Promise<void> => {
222
+ const body = getBodyRecord(req);
223
+ const auth = await verifyRequest(req, getRawBody(req), settings, path);
224
+ if (auth.ok === false) {
225
+ sendFailure(res, auth.status, auth.code, auth.message);
226
+ return;
227
+ }
228
+
229
+ const familyHash = body['familyHash'];
230
+ const exceptUuid = body['exceptUuid'];
231
+
232
+ if (typeof familyHash !== 'string' || familyHash.trim() === '') {
233
+ sendFailure(res, 400, 'VALIDATION_ERROR', 'familyHash is required');
234
+ return;
235
+ }
236
+
237
+ if (typeof exceptUuid !== 'string' || exceptUuid.trim() === '') {
238
+ sendFailure(res, 400, 'VALIDATION_ERROR', 'exceptUuid is required');
239
+ return;
240
+ }
241
+
242
+ await settings.storage.markFamilyStale(familyHash, exceptUuid);
243
+ sendSuccess(res);
244
+ };
245
+ };
246
+
247
+ const resolveStorage = (overrides?: TraceIngestGatewayOverrides): ITraceStorage => {
248
+ if (overrides?.storage !== undefined) return overrides.storage;
249
+
250
+ const connectionName = overrides?.connectionName ?? TraceConfig.merge().connection;
251
+ const db = useDatabase(undefined, connectionName);
252
+ if (db === undefined) {
253
+ throw ErrorFactory.createConfigError('Trace ingest connection is not configured.', {
254
+ connectionName,
255
+ envKey: 'TRACE_DB_CONNECTION',
256
+ });
257
+ }
258
+
259
+ return TraceStorage.resolveStorage(db);
260
+ };
261
+
262
+ const readSettings = (overrides?: TraceIngestGatewayOverrides): TraceIngestGatewaySettings => {
263
+ const configuredSecret = (overrides?.secret ?? Env.get('TRACE_PROXY_SECRET', '')).trim();
264
+ const configuredKeyId = (overrides?.keyId ?? Env.get('TRACE_PROXY_KEY_ID', '')).trim();
265
+
266
+ return {
267
+ basePath: normalizePath(overrides?.basePath ?? Env.get('TRACE_PROXY_PATH', '/zin/trace/write')),
268
+ keyId: configuredKeyId === '' ? (Env.APP_NAME || 'zintrust').trim() : configuredKeyId,
269
+ secret: configuredSecret === '' ? Env.APP_KEY : configuredSecret,
270
+ signingWindowMs:
271
+ overrides?.signingWindowMs ?? Env.getInt('TRACE_PROXY_SIGNING_WINDOW_MS', 60000),
272
+ nonceTtlMs: overrides?.nonceTtlMs ?? Env.getInt('TRACE_PROXY_NONCE_TTL_MS', 120000),
273
+ middleware: overrides?.middleware ?? parseMiddleware(Env.get('TRACE_PROXY_MIDDLEWARE', '')),
274
+ storage: resolveStorage(overrides),
275
+ };
276
+ };
277
+
278
+ export const TraceIngestGateway = Object.freeze({
279
+ create(overrides?: TraceIngestGatewayOverrides): {
280
+ registerRoutes: (router: IRouter) => void;
281
+ } {
282
+ const settings = readSettings(overrides);
283
+ const routeOptions: RouteOptions | undefined =
284
+ settings.middleware.length > 0
285
+ ? ({ middleware: settings.middleware } as RouteOptions)
286
+ : undefined;
287
+ const updatePath = appendSuffix(settings.basePath, '/update');
288
+ const markFamilyStalePath = appendSuffix(settings.basePath, '/mark-family-stale');
289
+
290
+ return {
291
+ registerRoutes(router: IRouter): void {
292
+ Router.post(
293
+ router,
294
+ settings.basePath,
295
+ createWriteHandler(settings, settings.basePath),
296
+ routeOptions
297
+ );
298
+ Router.post(router, updatePath, createUpdateHandler(settings, updatePath), routeOptions);
299
+ Router.post(
300
+ router,
301
+ markFamilyStalePath,
302
+ createMarkFamilyStaleHandler(settings, markFamilyStalePath),
303
+ routeOptions
304
+ );
305
+ },
306
+ };
307
+ },
308
+ });
309
+
310
+ export const registerTraceIngestGateway = (
311
+ router: IRouter,
312
+ overrides?: TraceIngestGatewayOverrides
313
+ ): void => {
314
+ TraceIngestGateway.create(overrides).registerRoutes(router);
315
+ };
316
+
317
+ export default TraceIngestGateway;
package/src/register.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { TraceConfig } from './config';
23
23
  import { TraceContext } from './context';
24
- import { TraceStorage } from './storage';
24
+ import { ProxyTraceStorage, TraceServiceTag, TraceStorage } from './storage';
25
25
  import { TraceContentBudget } from './storage/TraceContentBudget';
26
26
  import { TraceContentRedaction } from './storage/TraceContentRedaction';
27
27
  import { TraceEntryFiltering } from './storage/TraceEntryFiltering';
@@ -212,6 +212,15 @@ if (!traceAlreadyInitialized && Env) {
212
212
  const pruneAfterHoursRaw = Env.get('TRACE_PRUNE_HOURS', '').trim();
213
213
  const slowQueryThresholdRaw = Env.get('TRACE_SLOW_QUERY_MS', '').trim();
214
214
  const logMinLevelRaw = Env.get('TRACE_LOG_LEVEL', '').trim();
215
+ const traceProxyRaw = Env.get('TRACE_PROXY', '').trim();
216
+ const traceProxyUrlRaw = Env.get('TRACE_PROXY_URL', '').trim();
217
+ const traceProxyPathRaw = Env.get('TRACE_PROXY_PATH', '').trim();
218
+ const traceProxyKeyIdRaw = Env.get('TRACE_PROXY_KEY_ID', '').trim();
219
+ const traceProxySecretRaw = Env.get('TRACE_PROXY_SECRET', '').trim();
220
+ const traceProxyTimeoutRaw = Env.get('TRACE_PROXY_TIMEOUT_MS', '').trim();
221
+ const traceServiceTagRaw = Env.get('TRACE_SERVICE_TAG', '').trim();
222
+ const appNameRaw = Env.get('APP_NAME', '').trim();
223
+ const appKeyRaw = Env.get('APP_KEY', '').trim();
215
224
  const captureCachePayloadsRaw = Env.get('TRACE_CACHE_PAYLOADS', '').trim();
216
225
  const captureQueryBindingsRaw = Env.get('TRACE_QUERY_BINDINGS', '').trim();
217
226
  const contentDispatchDriverRaw = Env.get('TRACE_CONTENT_QUEUE_DRIVER', '').trim();
@@ -262,6 +271,26 @@ if (!traceAlreadyInitialized && Env) {
262
271
  parseEnvBool(captureCachePayloadsRaw) ?? startupOverrides?.captureCachePayloads;
263
272
  const captureQueryBindings =
264
273
  parseEnvBool(captureQueryBindingsRaw) ?? startupOverrides?.captureQueryBindings;
274
+ const traceProxyEnabled = parseEnvBool(traceProxyRaw) ?? startupOverrides?.proxy?.enabled;
275
+ const traceProxyUrl = traceProxyUrlRaw === '' ? startupOverrides?.proxy?.url : traceProxyUrlRaw;
276
+ const traceProxyPath =
277
+ traceProxyPathRaw === '' ? startupOverrides?.proxy?.path : traceProxyPathRaw;
278
+ const traceProxyKeyId =
279
+ traceProxyKeyIdRaw === ''
280
+ ? (startupOverrides?.proxy?.keyId ?? appNameRaw)
281
+ : traceProxyKeyIdRaw;
282
+ const traceProxySecret =
283
+ traceProxySecretRaw === ''
284
+ ? (startupOverrides?.proxy?.secret ?? appKeyRaw)
285
+ : traceProxySecretRaw;
286
+ const traceProxyTimeout =
287
+ traceProxyTimeoutRaw === ''
288
+ ? startupOverrides?.proxy?.timeoutMs
289
+ : Number.parseInt(traceProxyTimeoutRaw, 10);
290
+ const traceServiceTag =
291
+ traceServiceTagRaw === ''
292
+ ? (startupOverrides?.serviceTag ?? appNameRaw).trim() || undefined
293
+ : traceServiceTagRaw;
265
294
  const contentDispatchDriver =
266
295
  contentDispatchDriverRaw === ''
267
296
  ? startupOverrides?.contentDispatch?.driver
@@ -305,6 +334,29 @@ if (!traceAlreadyInitialized && Env) {
305
334
  enabled,
306
335
  connection,
307
336
  observeConnection,
337
+ ...(typeof traceServiceTag === 'string' && traceServiceTag !== ''
338
+ ? { serviceTag: traceServiceTag }
339
+ : {}),
340
+ proxy: {
341
+ ...TraceConfig.defaults().proxy,
342
+ ...startupOverrides?.proxy,
343
+ ...(typeof traceProxyEnabled === 'boolean' ? { enabled: traceProxyEnabled } : {}),
344
+ ...(typeof traceProxyUrl === 'string' && traceProxyUrl !== ''
345
+ ? { url: traceProxyUrl }
346
+ : {}),
347
+ ...(typeof traceProxyPath === 'string' && traceProxyPath !== ''
348
+ ? { path: traceProxyPath }
349
+ : {}),
350
+ ...(typeof traceProxyKeyId === 'string' && traceProxyKeyId !== ''
351
+ ? { keyId: traceProxyKeyId }
352
+ : {}),
353
+ ...(typeof traceProxySecret === 'string' && traceProxySecret !== ''
354
+ ? { secret: traceProxySecret }
355
+ : {}),
356
+ ...(typeof traceProxyTimeout === 'number' && Number.isFinite(traceProxyTimeout)
357
+ ? { timeoutMs: traceProxyTimeout }
358
+ : {}),
359
+ },
308
360
  ...(typeof pruneAfterHours === 'number' && Number.isFinite(pruneAfterHours)
309
361
  ? { pruneAfterHours }
310
362
  : {}),
@@ -379,10 +431,23 @@ if (!traceAlreadyInitialized && Env) {
379
431
  });
380
432
  await assertTraceStorageReady(core, storageDb, resolvedConnectionName);
381
433
 
434
+ const resolvedStorage = config.proxy.enabled
435
+ ? ProxyTraceStorage.create({
436
+ baseUrl: config.proxy.url ?? '',
437
+ path: config.proxy.path,
438
+ keyId: config.proxy.keyId ?? '',
439
+ secret: config.proxy.secret ?? '',
440
+ timeoutMs: config.proxy.timeoutMs,
441
+ })
442
+ : TraceStorage.resolveStorage(storageDb);
443
+
382
444
  const storage = TraceWriteDiagnostics.wrapStorage(
383
445
  TraceContentBudget.wrapStorage(
384
446
  TraceContentRedaction.wrapStorage(
385
- TraceEntryFiltering.wrapStorage(TraceStorage.resolveStorage(storageDb), config),
447
+ TraceEntryFiltering.wrapStorage(
448
+ TraceServiceTag.wrapStorage(resolvedStorage, config),
449
+ config
450
+ ),
386
451
  config.redaction
387
452
  ),
388
453
  config
@@ -0,0 +1,182 @@
1
+ import { ErrorFactory, RemoteSignedJson } from '@zintrust/core';
2
+ import type { ITraceEntry, ITraceStorage } from '../types';
3
+
4
+ type ProxyTraceStorageSettings = {
5
+ baseUrl: string;
6
+ path: string;
7
+ keyId: string;
8
+ secret: string;
9
+ timeoutMs: number;
10
+ };
11
+
12
+ type TraceProxyWriteRequest = {
13
+ entry: ITraceEntry;
14
+ };
15
+
16
+ type TraceProxyUpdateRequest = {
17
+ uuid: string;
18
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>;
19
+ };
20
+
21
+ type TraceProxyMarkFamilyStaleRequest = {
22
+ familyHash: string;
23
+ exceptUuid: string;
24
+ };
25
+
26
+ const ensureConfigured = (settings: ProxyTraceStorageSettings): void => {
27
+ if (settings.baseUrl.trim() === '') {
28
+ throw ErrorFactory.createConfigError('TRACE_PROXY_URL is required when TRACE_PROXY=true');
29
+ }
30
+
31
+ if (settings.keyId.trim() === '' || settings.secret.trim() === '') {
32
+ throw ErrorFactory.createConfigError(
33
+ 'TRACE_PROXY signing credentials are required when TRACE_PROXY=true'
34
+ );
35
+ }
36
+ };
37
+
38
+ const normalizePath = (value: string): string => {
39
+ const trimmed = value.trim();
40
+ if (trimmed === '') return '/zin/trace/write';
41
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
42
+ };
43
+
44
+ const createUnsupportedReadError = (): Error =>
45
+ ErrorFactory.createConfigError(
46
+ 'Trace proxy sender storage does not expose dashboard/query operations. Use the trace server for reads.'
47
+ );
48
+
49
+ type ProxyRequestSettings = {
50
+ baseUrl: string;
51
+ keyId: string;
52
+ secret: string;
53
+ timeoutMs: number;
54
+ signaturePathPrefixToStrip: string;
55
+ missingUrlMessage: string;
56
+ missingCredentialsMessage: string;
57
+ messages: {
58
+ unauthorized: string;
59
+ forbidden: string;
60
+ rateLimited: string;
61
+ rejected: string;
62
+ error: string;
63
+ timedOut: string;
64
+ };
65
+ normalizedPath: string;
66
+ };
67
+
68
+ const buildSettings = (settings: ProxyTraceStorageSettings): ProxyRequestSettings => {
69
+ ensureConfigured(settings);
70
+ const normalizedPath = normalizePath(settings.path);
71
+
72
+ return {
73
+ baseUrl: settings.baseUrl,
74
+ keyId: settings.keyId,
75
+ secret: settings.secret,
76
+ timeoutMs: settings.timeoutMs,
77
+ signaturePathPrefixToStrip: new URL(settings.baseUrl).pathname,
78
+ missingUrlMessage: 'TRACE_PROXY_URL is required when TRACE_PROXY=true',
79
+ missingCredentialsMessage: 'TRACE_PROXY signing credentials are required when TRACE_PROXY=true',
80
+ messages: {
81
+ unauthorized: 'Trace proxy rejected the request credentials',
82
+ forbidden: 'Trace proxy rejected the request signature',
83
+ rateLimited: 'Trace proxy rate-limited the request',
84
+ rejected: 'Trace proxy rejected the request payload',
85
+ error: 'Trace proxy request failed',
86
+ timedOut: 'Trace proxy request timed out',
87
+ },
88
+ normalizedPath,
89
+ };
90
+ };
91
+
92
+ const appendSuffix = (path: string, suffix: string): string => {
93
+ const base = normalizePath(path).replace(/\/+$/, '');
94
+ const tail = suffix.startsWith('/') ? suffix : `/${suffix}`;
95
+ return `${base}${tail}`;
96
+ };
97
+
98
+ const unsupportedQueryEntries: ITraceStorage['queryEntries'] = async () => {
99
+ throw createUnsupportedReadError();
100
+ };
101
+
102
+ const unsupportedGetEntry: ITraceStorage['getEntry'] = async () => {
103
+ throw createUnsupportedReadError();
104
+ };
105
+
106
+ const unsupportedGetBatch: ITraceStorage['getBatch'] = async () => {
107
+ throw createUnsupportedReadError();
108
+ };
109
+
110
+ const unsupportedQueryBatchEntries: ITraceStorage['queryBatchEntries'] = async () => {
111
+ throw createUnsupportedReadError();
112
+ };
113
+
114
+ const unsupportedPrune: ITraceStorage['prune'] = async () => {
115
+ throw createUnsupportedReadError();
116
+ };
117
+
118
+ const unsupportedClear: ITraceStorage['clear'] = async () => {
119
+ throw createUnsupportedReadError();
120
+ };
121
+
122
+ const unsupportedGetMonitoring: ITraceStorage['getMonitoring'] = async () => {
123
+ throw createUnsupportedReadError();
124
+ };
125
+
126
+ const unsupportedAddMonitoring: ITraceStorage['addMonitoring'] = async () => {
127
+ throw createUnsupportedReadError();
128
+ };
129
+
130
+ const unsupportedRemoveMonitoring: ITraceStorage['removeMonitoring'] = async () => {
131
+ throw createUnsupportedReadError();
132
+ };
133
+
134
+ const unsupportedStats: ITraceStorage['stats'] = async () => {
135
+ throw createUnsupportedReadError();
136
+ };
137
+
138
+ export const ProxyTraceStorage = Object.freeze({
139
+ create(settings: ProxyTraceStorageSettings): ITraceStorage {
140
+ const normalized = buildSettings(settings);
141
+
142
+ return Object.freeze({
143
+ async writeEntry(entry: ITraceEntry): Promise<void> {
144
+ await RemoteSignedJson.request<{ ok: true }>(normalized, normalized.normalizedPath, {
145
+ entry,
146
+ } satisfies TraceProxyWriteRequest);
147
+ },
148
+
149
+ async updateEntry(
150
+ uuid: string,
151
+ patch: Partial<Pick<ITraceEntry, 'content' | 'isLatest'>>
152
+ ): Promise<void> {
153
+ await RemoteSignedJson.request<{ ok: true }>(
154
+ normalized,
155
+ appendSuffix(normalized.normalizedPath, '/update'),
156
+ { uuid, patch } satisfies TraceProxyUpdateRequest
157
+ );
158
+ },
159
+
160
+ async markFamilyStale(familyHash: string, exceptUuid: string): Promise<void> {
161
+ await RemoteSignedJson.request<{ ok: true }>(
162
+ normalized,
163
+ appendSuffix(normalized.normalizedPath, '/mark-family-stale'),
164
+ { familyHash, exceptUuid } satisfies TraceProxyMarkFamilyStaleRequest
165
+ );
166
+ },
167
+
168
+ queryEntries: unsupportedQueryEntries,
169
+ getEntry: unsupportedGetEntry,
170
+ getBatch: unsupportedGetBatch,
171
+ queryBatchEntries: unsupportedQueryBatchEntries,
172
+ prune: unsupportedPrune,
173
+ clear: unsupportedClear,
174
+ getMonitoring: unsupportedGetMonitoring,
175
+ addMonitoring: unsupportedAddMonitoring,
176
+ removeMonitoring: unsupportedRemoveMonitoring,
177
+ stats: unsupportedStats,
178
+ });
179
+ },
180
+ });
181
+
182
+ export default ProxyTraceStorage;
@@ -285,6 +285,7 @@ const getCoreRuntime = async (): Promise<{
285
285
 
286
286
  const getQueueWorkerApi = async (): Promise<QueueWorkerApi | null> => {
287
287
  try {
288
+ // @ts-ignore
288
289
  const mod = (await import('@zintrust/workers')) as unknown as QueueWorkerApi;
289
290
  return typeof mod.createQueueWorker === 'function' ? mod : null;
290
291
  } catch {
@@ -0,0 +1,56 @@
1
+ import { ErrorFactory } from '@zintrust/core';
2
+ import type { ITraceConfig, ITraceEntry, ITraceStorage } from '../types';
3
+
4
+ const appendServiceTag = (entry: ITraceEntry, serviceTag?: string): ITraceEntry => {
5
+ const normalizedTag = serviceTag?.trim() ?? '';
6
+ if (normalizedTag === '' || entry.tags.includes(normalizedTag)) {
7
+ return entry;
8
+ }
9
+
10
+ return {
11
+ ...entry,
12
+ tags: [...entry.tags, normalizedTag],
13
+ };
14
+ };
15
+
16
+ const unsupportedRead = async <T>(): Promise<T> => {
17
+ throw ErrorFactory.createConfigError(
18
+ 'Trace proxy mode only supports runtime persistence on the sender. Query the trace server database or dashboard directly.'
19
+ );
20
+ };
21
+
22
+ const bindOrUnsupported = <T extends (...args: never[]) => Promise<unknown>>(
23
+ method: T | undefined
24
+ ): T => {
25
+ if (method === undefined) {
26
+ return unsupportedRead as unknown as T;
27
+ }
28
+
29
+ return method;
30
+ };
31
+
32
+ export const TraceServiceTag = Object.freeze({
33
+ wrapStorage(storage: ITraceStorage, config: ITraceConfig): ITraceStorage {
34
+ const writeEntry = async (entry: ITraceEntry): Promise<void> => {
35
+ await storage.writeEntry(appendServiceTag(entry, config.serviceTag));
36
+ };
37
+
38
+ return Object.freeze({
39
+ writeEntry,
40
+ updateEntry: storage.updateEntry.bind(storage),
41
+ markFamilyStale: storage.markFamilyStale.bind(storage),
42
+ queryEntries: bindOrUnsupported(storage.queryEntries?.bind(storage)),
43
+ getEntry: bindOrUnsupported(storage.getEntry?.bind(storage)),
44
+ getBatch: bindOrUnsupported(storage.getBatch?.bind(storage)),
45
+ queryBatchEntries: bindOrUnsupported(storage.queryBatchEntries?.bind(storage)),
46
+ prune: bindOrUnsupported(storage.prune?.bind(storage)),
47
+ clear: bindOrUnsupported(storage.clear?.bind(storage)),
48
+ getMonitoring: bindOrUnsupported(storage.getMonitoring?.bind(storage)),
49
+ addMonitoring: bindOrUnsupported(storage.addMonitoring?.bind(storage)),
50
+ removeMonitoring: bindOrUnsupported(storage.removeMonitoring?.bind(storage)),
51
+ stats: bindOrUnsupported(storage.stats?.bind(storage)),
52
+ });
53
+ },
54
+ });
55
+
56
+ export default TraceServiceTag;
@@ -1,2 +1,4 @@
1
1
  export { TraceStorage } from './TraceStorage';
2
2
  export type { ITraceStorage } from './TraceStorage';
3
+ export { ProxyTraceStorage } from './ProxyTraceStorage';
4
+ export { TraceServiceTag } from './TraceServiceTag';
package/src/types.ts CHANGED
@@ -373,6 +373,15 @@ export type TraceContentDispatchConfig = {
373
373
  worker: TraceContentDispatchWorkerConfig;
374
374
  };
375
375
 
376
+ export type TraceProxyConfig = {
377
+ enabled: boolean;
378
+ url?: string;
379
+ path: string;
380
+ keyId?: string;
381
+ secret?: string;
382
+ timeoutMs: number;
383
+ };
384
+
376
385
  export type TraceWatcherToggle = boolean | TraceFilterRule;
377
386
  export type TraceRequestWatcherToggle = boolean | TraceRequestWatcherConfig;
378
387
  export type TraceClientRequestWatcherToggle = boolean | TraceClientRequestWatcherConfig;
@@ -404,6 +413,8 @@ export interface ITraceConfig {
404
413
  enabled: boolean;
405
414
  connection?: string;
406
415
  observeConnection?: string;
416
+ serviceTag?: string;
417
+ proxy: TraceProxyConfig;
407
418
  pruneAfterHours: number;
408
419
  ignoreRoutes: string[];
409
420
  ignorePaths: string[];