autotel 3.1.1 → 3.3.0

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.
Files changed (105) hide show
  1. package/dist/attribute-redacting-processor.cjs +8 -8
  2. package/dist/attribute-redacting-processor.js +1 -1
  3. package/dist/attributes.cjs +21 -21
  4. package/dist/attributes.js +2 -2
  5. package/dist/auto.cjs +3 -3
  6. package/dist/auto.js +2 -2
  7. package/dist/{chunk-MYWQELNY.js → chunk-32AXF4MA.js} +30 -8
  8. package/dist/chunk-32AXF4MA.js.map +1 -0
  9. package/dist/{chunk-6X2GG65S.cjs → chunk-3MZJ7Y24.cjs} +5 -5
  10. package/dist/{chunk-6X2GG65S.cjs.map → chunk-3MZJ7Y24.cjs.map} +1 -1
  11. package/dist/{chunk-DDXIUZEG.js → chunk-454CH4OV.js} +3 -3
  12. package/dist/{chunk-DDXIUZEG.js.map → chunk-454CH4OV.js.map} +1 -1
  13. package/dist/{chunk-MXO6LXV5.cjs → chunk-4RA6HIYF.cjs} +5 -5
  14. package/dist/{chunk-MXO6LXV5.cjs.map → chunk-4RA6HIYF.cjs.map} +1 -1
  15. package/dist/{chunk-6TFJF7SS.js → chunk-4TAQQZDU.js} +3 -3
  16. package/dist/{chunk-6TFJF7SS.js.map → chunk-4TAQQZDU.js.map} +1 -1
  17. package/dist/{chunk-LIYNUGML.cjs → chunk-DQSVSGK3.cjs} +23 -32
  18. package/dist/chunk-DQSVSGK3.cjs.map +1 -0
  19. package/dist/{chunk-PEEUMQ3R.js → chunk-FZROHTZZ.js} +3 -3
  20. package/dist/{chunk-PEEUMQ3R.js.map → chunk-FZROHTZZ.js.map} +1 -1
  21. package/dist/{chunk-DQ2SUROF.cjs → chunk-M3LFHHTN.cjs} +4 -4
  22. package/dist/{chunk-DQ2SUROF.cjs.map → chunk-M3LFHHTN.cjs.map} +1 -1
  23. package/dist/{chunk-ZPERWNOP.cjs → chunk-MQH5OOZK.cjs} +17 -17
  24. package/dist/{chunk-ZPERWNOP.cjs.map → chunk-MQH5OOZK.cjs.map} +1 -1
  25. package/dist/{chunk-NXLRY2CE.cjs → chunk-NEIB3TLD.cjs} +10 -8
  26. package/dist/chunk-NEIB3TLD.cjs.map +1 -0
  27. package/dist/{chunk-MHPYLMQS.js → chunk-OACAWYLR.js} +4 -4
  28. package/dist/{chunk-MHPYLMQS.js.map → chunk-OACAWYLR.js.map} +1 -1
  29. package/dist/{chunk-52ALHU7T.js → chunk-OPCTN527.js} +3 -3
  30. package/dist/{chunk-52ALHU7T.js.map → chunk-OPCTN527.js.map} +1 -1
  31. package/dist/{chunk-YPQMAE6U.cjs → chunk-QICFEFD6.cjs} +7 -7
  32. package/dist/{chunk-YPQMAE6U.cjs.map → chunk-QICFEFD6.cjs.map} +1 -1
  33. package/dist/{chunk-45B2GD4P.cjs → chunk-QJYWKAC5.cjs} +32 -10
  34. package/dist/chunk-QJYWKAC5.cjs.map +1 -0
  35. package/dist/{chunk-JVWJDHDB.js → chunk-RUPKBKUF.js} +10 -8
  36. package/dist/chunk-RUPKBKUF.js.map +1 -0
  37. package/dist/{chunk-FTBBBPT6.js → chunk-TGV2XF57.js} +13 -22
  38. package/dist/chunk-TGV2XF57.js.map +1 -0
  39. package/dist/{chunk-T7CPAGOI.js → chunk-U4D5IBSB.js} +4 -4
  40. package/dist/chunk-U4D5IBSB.js.map +1 -0
  41. package/dist/{chunk-KPDIEVVV.cjs → chunk-U72TGONP.cjs} +32 -32
  42. package/dist/chunk-U72TGONP.cjs.map +1 -0
  43. package/dist/correlation-id.cjs +11 -11
  44. package/dist/correlation-id.js +3 -3
  45. package/dist/decorators.cjs +5 -5
  46. package/dist/decorators.js +4 -4
  47. package/dist/event-subscriber.d.cts +15 -1
  48. package/dist/event-subscriber.d.ts +15 -1
  49. package/dist/event.cjs +7 -7
  50. package/dist/event.js +4 -4
  51. package/dist/functional.cjs +12 -12
  52. package/dist/functional.js +4 -4
  53. package/dist/http.cjs +4 -4
  54. package/dist/http.js +3 -3
  55. package/dist/index.cjs +280 -94
  56. package/dist/index.cjs.map +1 -1
  57. package/dist/index.d.cts +209 -4
  58. package/dist/index.d.ts +209 -4
  59. package/dist/index.js +191 -14
  60. package/dist/index.js.map +1 -1
  61. package/dist/{init-BSyIyDs5.d.ts → init-DyE43paw.d.ts} +7 -2
  62. package/dist/{init-D9Bxx39e.d.cts → init-gyesUMwz.d.cts} +7 -2
  63. package/dist/instrumentation.cjs +9 -9
  64. package/dist/instrumentation.js +2 -2
  65. package/dist/messaging.cjs +8 -8
  66. package/dist/messaging.js +5 -5
  67. package/dist/semantic-helpers.cjs +9 -9
  68. package/dist/semantic-helpers.js +5 -5
  69. package/dist/webhook.cjs +6 -6
  70. package/dist/webhook.js +4 -4
  71. package/dist/workflow-distributed.cjs +6 -6
  72. package/dist/workflow-distributed.js +4 -4
  73. package/dist/workflow.cjs +9 -9
  74. package/dist/workflow.js +5 -5
  75. package/dist/yaml-config.d.cts +1 -1
  76. package/dist/yaml-config.d.ts +1 -1
  77. package/package.json +1 -1
  78. package/skills/build-audit-trails/SKILL.md +150 -5
  79. package/skills/build-audit-trails/references/audit-queries.md +73 -0
  80. package/skills/build-audit-trails/references/framework-wiring.md +187 -0
  81. package/skills/review-otel-patterns/SKILL.md +41 -0
  82. package/src/attribute-redacting-processor.ts +12 -9
  83. package/src/define-event.test.ts +41 -0
  84. package/src/define-event.ts +77 -0
  85. package/src/error-catalog.test.ts +128 -0
  86. package/src/error-catalog.ts +259 -0
  87. package/src/event-queue.ts +4 -0
  88. package/src/event-subscriber.ts +15 -0
  89. package/src/functional.ts +2 -1
  90. package/src/gen-ai-cost.test.ts +81 -0
  91. package/src/gen-ai-cost.ts +145 -0
  92. package/src/index.ts +35 -0
  93. package/src/init-auto-redactor.test.ts +53 -0
  94. package/src/init.ts +46 -7
  95. package/src/track.ts +3 -0
  96. package/src/validation.test.ts +7 -3
  97. package/src/validation.ts +19 -21
  98. package/dist/chunk-45B2GD4P.cjs.map +0 -1
  99. package/dist/chunk-FTBBBPT6.js.map +0 -1
  100. package/dist/chunk-JVWJDHDB.js.map +0 -1
  101. package/dist/chunk-KPDIEVVV.cjs.map +0 -1
  102. package/dist/chunk-LIYNUGML.cjs.map +0 -1
  103. package/dist/chunk-MYWQELNY.js.map +0 -1
  104. package/dist/chunk-NXLRY2CE.cjs.map +0 -1
  105. package/dist/chunk-T7CPAGOI.js.map +0 -1
@@ -7,6 +7,7 @@ description: >
7
7
  missing span attributes, manual exporter setup, broken context propagation, exposed PII, and ad-hoc
8
8
  error handling. Covers spans, metrics, logs, structured errors, the autotel processor pipeline
9
9
  (tail-sampling, attribute redaction, span-name normalisation, filtering, baggage),
10
+ built-in enrichers (user agent, geo, request size) and custom `defineEnricher`,
10
11
  `defineWorkerFetch` for Cloudflare async drains, multi-vendor OTLP backends (Honeycomb, Datadog,
11
12
  Grafana Cloud, Sentry, Axiom, HyperDX), `composeSpanProcessors` / `composeSubscribers` /
12
13
  `composePostProcessors` for pipelines, AI SDK observability with gen-ai semantic conventions, and
@@ -232,6 +233,46 @@ All options work with `init()`, framework adapters, and `wrapModule` / `defineWo
232
233
 
233
234
  ---
234
235
 
236
+ ## Built-in enrichers
237
+
238
+ Enrichers turn raw request data into standard, low-cardinality span attributes. Import the helpers from `autotel/enrichers` and spread their output onto the active span or request logger. Each returns `undefined` when there is nothing to add, so spreading is safe.
239
+
240
+ | Helper | Returns attributes | Source |
241
+ | -------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------- |
242
+ | `userAgent(headers)` | `user_agent.raw`, `user_agent.browser`, `user_agent.os`, `user_agent.device` | `user-agent` request header |
243
+ | `geo(headers)` | `geo.country`, `geo.region`, `geo.city`, `geo.latitude`, `geo.longitude` | Vercel / Cloudflare geo headers |
244
+ | `requestSize(reqHeaders, resHeaders?)` | `http.request.body.size`, `http.response.body.size` | `content-length` headers |
245
+
246
+ ```typescript
247
+ import { userAgent, geo, requestSize } from 'autotel/enrichers';
248
+
249
+ export const handler = trace((ctx) => async (request: Request) => {
250
+ ctx.setAttributes({
251
+ ...userAgent(request.headers),
252
+ ...geo(request.headers),
253
+ ...requestSize(request.headers),
254
+ });
255
+ // ... handle request
256
+ });
257
+ ```
258
+
259
+ For your own derived fields on a request's wide event, build a reusable enricher with `defineEnricher` (from `autotel`) instead of scattering ad-hoc field writes. `compute` returns an object that is merged into the named `field`; return `undefined` to skip. Keep the output low-cardinality (bucket or hash high-cardinality values):
260
+
261
+ ```typescript
262
+ import { defineEnricher } from 'autotel';
263
+
264
+ // Merge a derived, low-cardinality object into event.user on each request.
265
+ const enrichTier = defineEnricher<{ user?: { plan?: string } }, { tier: string }>({
266
+ name: 'user-tier',
267
+ field: 'user',
268
+ compute: ({ event }) => ({ tier: event.user?.plan ?? 'anonymous' }),
269
+ });
270
+ ```
271
+
272
+ **Review checks:** raw `User-Agent` strings stored verbatim (use `userAgent()` to parse), geo data hand-parsed per framework (use `geo()`), and high-cardinality values (full URLs, emails, ids) set directly as attributes instead of being bucketed or hashed.
273
+
274
+ ---
275
+
235
276
  ## Backends (multi-vendor OTLP)
236
277
 
237
278
  Switch backends with **no code changes** — autotel speaks OTLP HTTP/JSON and HTTP/protobuf out of the box.
@@ -424,19 +424,22 @@ function createRedactorFromConfig(
424
424
  .map((vp) => [cloneRegex(vp.pattern), vp.mask!]);
425
425
 
426
426
  return (key: string, value: AttributeValue): AttributeValue => {
427
- // Check if key matches any sensitive key pattern
428
- for (const pattern of keyPatterns) {
429
- pattern.lastIndex = 0;
430
- if (pattern.test(key)) {
427
+ // Key-pattern and path-based redaction only applies to string values.
428
+ // Numbers, booleans and other non-string attributes are not credentials;
429
+ // replacing them with the string '[REDACTED]' silently changes their
430
+ // type and corrupts downstream consumers (LLM token counters etc.).
431
+ if (typeof value === 'string') {
432
+ for (const pattern of keyPatterns) {
433
+ pattern.lastIndex = 0;
434
+ if (pattern.test(key)) {
435
+ return defaultReplacement;
436
+ }
437
+ }
438
+ if (pathSet.has(key)) {
431
439
  return defaultReplacement;
432
440
  }
433
441
  }
434
442
 
435
- // Check if key matches any path-based redaction
436
- if (pathSet.has(key)) {
437
- return defaultReplacement;
438
- }
439
-
440
443
  // For non-string values, return as-is
441
444
  if (typeof value !== 'string') {
442
445
  if (Array.isArray(value)) {
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { defineEvent } from './define-event';
3
+
4
+ describe('defineEvent', () => {
5
+ it('validates payload and exposes schema metadata when provided', () => {
6
+ const event = defineEvent(
7
+ 'order.placed',
8
+ {
9
+ safeParse(input: unknown) {
10
+ if (
11
+ typeof input === 'object' &&
12
+ input !== null &&
13
+ 'orderId' in input &&
14
+ typeof (input as Record<string, unknown>).orderId === 'string'
15
+ ) {
16
+ return {
17
+ success: true as const,
18
+ data: input as { orderId: string },
19
+ };
20
+ }
21
+ return { success: false as const, error: new Error('invalid') };
22
+ },
23
+ },
24
+ {
25
+ toJsonSchema: () => ({
26
+ type: 'object',
27
+ properties: { orderId: { type: 'string' } },
28
+ required: ['orderId'],
29
+ }),
30
+ },
31
+ );
32
+
33
+ expect(event.name).toBe('order.placed');
34
+ expect(event.schemaMetadata?.source).toBe('zod');
35
+ expect(event.schemaMetadata?.hash).toMatch(/^[a-f0-9]{64}$/);
36
+ expect(() => event.track({ orderId: 'o-1' })).not.toThrow();
37
+ expect(() => event.track({} as { orderId: string })).toThrow(
38
+ /Schema validation failed/,
39
+ );
40
+ });
41
+ });
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { track } from './track';
3
+ import type { EventSchemaMetadata } from './event-subscriber';
4
+
5
+ type SafeParseResult<T> =
6
+ | { success: true; data: T }
7
+ | { success: false; error: unknown };
8
+
9
+ export interface SchemaLike<T> {
10
+ safeParse(input: unknown): SafeParseResult<T>;
11
+ }
12
+
13
+ export interface DefineEventOptions<S> {
14
+ toJsonSchema?: (schema: S) => unknown;
15
+ }
16
+
17
+ export interface DefinedEvent<Name extends string, Payload> {
18
+ readonly name: Name;
19
+ readonly schemaMetadata?: EventSchemaMetadata;
20
+ track(payload: Payload): void;
21
+ }
22
+
23
+ export function defineEvent<
24
+ Name extends string,
25
+ Payload,
26
+ S extends SchemaLike<Payload>,
27
+ >(
28
+ name: Name,
29
+ schema: S,
30
+ options: DefineEventOptions<S> = {},
31
+ ): DefinedEvent<Name, Payload> {
32
+ const jsonSchema = options.toJsonSchema?.(schema);
33
+ const schemaMetadata = jsonSchema
34
+ ? {
35
+ source: 'zod' as const,
36
+ jsonSchema,
37
+ hash: hashSchema(jsonSchema),
38
+ }
39
+ : undefined;
40
+
41
+ return {
42
+ name,
43
+ schemaMetadata,
44
+ track(payload: Payload) {
45
+ const parsed = schema.safeParse(payload);
46
+ if (!parsed.success) {
47
+ throw new Error(
48
+ `Invalid payload for event "${name}". Schema validation failed.`,
49
+ );
50
+ }
51
+ track(
52
+ name,
53
+ parsed.data,
54
+ schemaMetadata ? { schema: schemaMetadata } : undefined,
55
+ );
56
+ },
57
+ };
58
+ }
59
+
60
+ function hashSchema(schema: unknown): string {
61
+ return createHash('sha256').update(stableStringify(schema)).digest('hex');
62
+ }
63
+
64
+ function stableStringify(value: unknown): string {
65
+ if (value === null || value === undefined || typeof value !== 'object') {
66
+ return JSON.stringify(value);
67
+ }
68
+ if (Array.isArray(value)) {
69
+ return '[' + value.map((v) => stableStringify(v)).join(',') + ']';
70
+ }
71
+ const obj = value as Record<string, unknown>;
72
+ const body = Object.keys(obj)
73
+ .sort()
74
+ .map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k]))
75
+ .join(',');
76
+ return '{' + body + '}';
77
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ defineErrorCatalog,
4
+ defineAuditCatalog,
5
+ isCatalogError,
6
+ getCatalogCode,
7
+ } from './error-catalog';
8
+
9
+ describe('defineErrorCatalog', () => {
10
+ const billing = defineErrorCatalog('billing', {
11
+ PAYMENT_DECLINED: {
12
+ status: 402,
13
+ message: 'Card declined',
14
+ why: 'The issuer rejected the charge',
15
+ fix: 'Try a different payment method',
16
+ link: 'https://docs.example.com/billing',
17
+ },
18
+ INSUFFICIENT_FUNDS: {
19
+ status: 402,
20
+ message: ({ available, required }: { available: number; required: number }) =>
21
+ `Insufficient funds: $${available} of $${required}`,
22
+ why: ({ required }: { available: number; required: number }) =>
23
+ `Needs $${required}`,
24
+ },
25
+ LEGACY: {
26
+ code: 'BILLING_LEGACY_42',
27
+ message: 'Legacy failure',
28
+ },
29
+ });
30
+
31
+ it('builds a structured error from a static entry', () => {
32
+ const err = billing.PAYMENT_DECLINED();
33
+ expect(err).toBeInstanceOf(Error);
34
+ expect(err.message).toBe('Card declined');
35
+ expect(err.status).toBe(402);
36
+ expect(err.why).toBe('The issuer rejected the charge');
37
+ expect(err.fix).toBe('Try a different payment method');
38
+ expect(err.link).toBe('https://docs.example.com/billing');
39
+ expect(err.code).toBe('billing.PAYMENT_DECLINED');
40
+ expect(err.name).toBe('PAYMENT_DECLINED');
41
+ });
42
+
43
+ it('interpolates typed params in message and why', () => {
44
+ const err = billing.INSUFFICIENT_FUNDS({ available: 5, required: 100 });
45
+ expect(err.message).toBe('Insufficient funds: $5 of $100');
46
+ expect(err.why).toBe('Needs $100');
47
+ });
48
+
49
+ it('honors a custom code', () => {
50
+ const err = billing.LEGACY();
51
+ expect(err.code).toBe('BILLING_LEGACY_42');
52
+ });
53
+
54
+ it('exposes the code on the builder', () => {
55
+ expect(billing.PAYMENT_DECLINED.code).toBe('billing.PAYMENT_DECLINED');
56
+ expect(billing.LEGACY.code).toBe('BILLING_LEGACY_42');
57
+ });
58
+
59
+ it('matches its own errors and rejects others', () => {
60
+ const declined = billing.PAYMENT_DECLINED();
61
+ const funds = billing.INSUFFICIENT_FUNDS({ available: 1, required: 2 });
62
+ expect(billing.PAYMENT_DECLINED.match(declined)).toBe(true);
63
+ expect(billing.PAYMENT_DECLINED.match(funds)).toBe(false);
64
+ expect(billing.PAYMENT_DECLINED.match(new Error('nope'))).toBe(false);
65
+ expect(billing.PAYMENT_DECLINED.match(null)).toBe(false);
66
+ });
67
+
68
+ it('tags errors so isCatalogError / getCatalogCode work', () => {
69
+ const err = billing.PAYMENT_DECLINED();
70
+ expect(isCatalogError(err)).toBe(true);
71
+ expect(isCatalogError(new Error('plain'))).toBe(false);
72
+ expect(getCatalogCode(err)).toBe('billing.PAYMENT_DECLINED');
73
+ expect(getCatalogCode(new Error('plain'))).toBeUndefined();
74
+ });
75
+
76
+ it('passes cause, details, and internal through build options', () => {
77
+ const cause = new Error('stripe boom');
78
+ const err = billing.PAYMENT_DECLINED({
79
+ cause,
80
+ details: { attempt: 2 },
81
+ internal: { stripeId: 'ch_1' },
82
+ });
83
+ expect(err.cause).toBe(cause);
84
+ expect(err.details).toEqual({ attempt: 2 });
85
+ expect(err.internal).toEqual({ stripeId: 'ch_1' });
86
+ });
87
+
88
+ it('accepts options as the second arg for param entries', () => {
89
+ const cause = new Error('root');
90
+ const err = billing.INSUFFICIENT_FUNDS(
91
+ { available: 5, required: 100 },
92
+ { cause },
93
+ );
94
+ expect(err.message).toBe('Insufficient funds: $5 of $100');
95
+ expect(err.cause).toBe(cause);
96
+ });
97
+ });
98
+
99
+ describe('defineAuditCatalog', () => {
100
+ const audit = defineAuditCatalog('user', {
101
+ LOGIN: { message: 'User logged in' },
102
+ ROLE_CHANGED: {
103
+ severity: 'critical',
104
+ message: ({ role }: { role: string }) => `Role set to ${role}`,
105
+ },
106
+ DELETED: { action: 'user.account.deleted', severity: 'warn' },
107
+ });
108
+
109
+ it('produces typed action descriptors with defaults', () => {
110
+ const action = audit.LOGIN();
111
+ expect(action.action).toBe('user.LOGIN');
112
+ expect(action.severity).toBe('info');
113
+ expect(action.message).toBe('User logged in');
114
+ });
115
+
116
+ it('interpolates params and respects severity', () => {
117
+ const action = audit.ROLE_CHANGED({ role: 'admin' });
118
+ expect(action.action).toBe('user.ROLE_CHANGED');
119
+ expect(action.severity).toBe('critical');
120
+ expect(action.message).toBe('Role set to admin');
121
+ });
122
+
123
+ it('honors a custom action name', () => {
124
+ expect(audit.DELETED.action).toBe('user.account.deleted');
125
+ expect(audit.DELETED.severity).toBe('warn');
126
+ expect(audit.DELETED().message).toBeUndefined();
127
+ });
128
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Typed error and audit catalogs.
3
+ *
4
+ * Group related errors into one catalog and get a refactor-safe builder per
5
+ * code, with autocomplete at every call site and typed message parameters.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { defineErrorCatalog } from 'autotel';
10
+ *
11
+ * export const billing = defineErrorCatalog('billing', {
12
+ * PAYMENT_DECLINED: {
13
+ * status: 402,
14
+ * message: 'Card declined',
15
+ * why: 'The issuer rejected the charge',
16
+ * fix: 'Try a different payment method',
17
+ * },
18
+ * INSUFFICIENT_FUNDS: {
19
+ * status: 402,
20
+ * message: ({ available, required }: { available: number; required: number }) =>
21
+ * `Insufficient funds: $${available} of $${required}`,
22
+ * },
23
+ * });
24
+ *
25
+ * throw billing.PAYMENT_DECLINED({ cause: stripeError });
26
+ * throw billing.INSUFFICIENT_FUNDS({ available: 5, required: 100 });
27
+ *
28
+ * // In a catch block — refactor-safe, no magic strings:
29
+ * if (billing.PAYMENT_DECLINED.match(err)) { ... }
30
+ * ```
31
+ */
32
+
33
+ import { createStructuredError, type StructuredError } from './structured-error';
34
+
35
+ const catalogCodeKey = Symbol.for('autotel.catalog.code');
36
+
37
+ /** Definition of a single error in a catalog. */
38
+ export interface ErrorCatalogEntry {
39
+ /**
40
+ * Human-readable message. Use a function to interpolate typed parameters;
41
+ * the parameter type flows through to the call site.
42
+ */
43
+ message: string | ((params: never) => string);
44
+ /** HTTP status to surface to clients. */
45
+ status?: number;
46
+ /** Stable error code. Defaults to `${namespace}.${KEY}`. */
47
+ code?: string | number;
48
+ /** Why it happened. A function receives the same params as `message`. */
49
+ why?: string | ((params: never) => string);
50
+ /** What the caller should do next. */
51
+ fix?: string;
52
+ /** Docs or runbook link. */
53
+ link?: string;
54
+ /** Error name. Defaults to the catalog key. */
55
+ name?: string;
56
+ }
57
+
58
+ /** Per-call options passed alongside (or instead of) typed params. */
59
+ export interface ErrorBuildOptions {
60
+ cause?: unknown;
61
+ details?: Record<string, unknown>;
62
+ /** Backend-only context. Never serialized to clients. */
63
+ internal?: Record<string, unknown>;
64
+ }
65
+
66
+ type ParamsOf<E> = E extends { message: (params: infer P) => string }
67
+ ? P
68
+ : E extends { why: (params: infer P) => string }
69
+ ? P
70
+ : void;
71
+
72
+ type BuilderArgs<E extends ErrorCatalogEntry> = ParamsOf<E> extends void
73
+ ? [options?: ErrorBuildOptions]
74
+ : [params: ParamsOf<E>, options?: ErrorBuildOptions];
75
+
76
+ /** A callable error factory produced by {@link defineErrorCatalog}. */
77
+ export interface ErrorBuilder<E extends ErrorCatalogEntry> {
78
+ (...args: BuilderArgs<E>): StructuredError;
79
+ /** Stable code assigned to every error from this entry. */
80
+ readonly code: string | number;
81
+ /** True when `error` was produced by this catalog entry. */
82
+ match(error: unknown): boolean;
83
+ }
84
+
85
+ export type ErrorCatalog<T extends Record<string, ErrorCatalogEntry>> = {
86
+ readonly [K in keyof T]: ErrorBuilder<T[K]>;
87
+ };
88
+
89
+ function readCatalogCode(error: unknown): string | number | undefined {
90
+ if (error === null || typeof error !== 'object') return undefined;
91
+ return (error as Record<symbol, unknown>)[catalogCodeKey] as
92
+ | string
93
+ | number
94
+ | undefined;
95
+ }
96
+
97
+ /** True when `error` was produced by any autotel error catalog. */
98
+ export function isCatalogError(error: unknown): error is StructuredError {
99
+ return readCatalogCode(error) !== undefined;
100
+ }
101
+
102
+ /** Returns the catalog code of `error`, or `undefined` if it has none. */
103
+ export function getCatalogCode(error: unknown): string | number | undefined {
104
+ return readCatalogCode(error);
105
+ }
106
+
107
+ /**
108
+ * Define a typed error catalog. Returns an object whose keys are error
109
+ * builders. Each builder produces a {@link StructuredError} carrying the
110
+ * entry's message, status, code, why, fix, and link.
111
+ */
112
+ export function defineErrorCatalog<
113
+ const T extends Record<string, ErrorCatalogEntry>,
114
+ >(namespace: string, entries: T): ErrorCatalog<T> {
115
+ const catalog: Record<string, ErrorBuilder<ErrorCatalogEntry>> = {};
116
+
117
+ for (const [key, entry] of Object.entries(entries) as [
118
+ string,
119
+ ErrorCatalogEntry,
120
+ ][]) {
121
+ const code = entry.code ?? `${namespace}.${key}`;
122
+ const usesParams =
123
+ typeof entry.message === 'function' || typeof entry.why === 'function';
124
+
125
+ const builder = ((
126
+ paramsOrOptions?: unknown,
127
+ maybeOptions?: ErrorBuildOptions,
128
+ ): StructuredError => {
129
+ const params = usesParams ? paramsOrOptions : undefined;
130
+ const options = (
131
+ usesParams ? maybeOptions : paramsOrOptions
132
+ ) as ErrorBuildOptions | undefined;
133
+
134
+ const message =
135
+ typeof entry.message === 'function'
136
+ ? (entry.message as (p: unknown) => string)(params)
137
+ : entry.message;
138
+ const why =
139
+ typeof entry.why === 'function'
140
+ ? (entry.why as (p: unknown) => string)(params)
141
+ : entry.why;
142
+
143
+ const error = createStructuredError({
144
+ message,
145
+ name: entry.name ?? key,
146
+ code,
147
+ ...(entry.status === undefined ? {} : { status: entry.status }),
148
+ ...(why === undefined ? {} : { why }),
149
+ ...(entry.fix === undefined ? {} : { fix: entry.fix }),
150
+ ...(entry.link === undefined ? {} : { link: entry.link }),
151
+ ...(options?.cause === undefined ? {} : { cause: options.cause }),
152
+ ...(options?.details === undefined ? {} : { details: options.details }),
153
+ ...(options?.internal === undefined
154
+ ? {}
155
+ : { internal: options.internal }),
156
+ });
157
+
158
+ Object.defineProperty(error, catalogCodeKey, {
159
+ value: code,
160
+ enumerable: false,
161
+ writable: false,
162
+ configurable: true,
163
+ });
164
+
165
+ return error;
166
+ }) as ErrorBuilder<ErrorCatalogEntry>;
167
+
168
+ Object.defineProperty(builder, 'code', {
169
+ value: code,
170
+ enumerable: true,
171
+ });
172
+ Object.defineProperty(builder, 'match', {
173
+ value: (error: unknown): boolean => readCatalogCode(error) === code,
174
+ enumerable: false,
175
+ });
176
+
177
+ catalog[key] = builder;
178
+ }
179
+
180
+ return Object.freeze(catalog) as ErrorCatalog<T>;
181
+ }
182
+
183
+ /** Severity of an audit action. */
184
+ export type AuditSeverity = 'info' | 'warn' | 'critical';
185
+
186
+ /** Definition of a single action in an audit catalog. */
187
+ export interface AuditCatalogEntry {
188
+ /** Human-readable description. Use a function for typed params. */
189
+ message?: string | ((params: never) => string);
190
+ /** Stable action name. Defaults to `${namespace}.${KEY}`. */
191
+ action?: string;
192
+ /** Severity of the action. Defaults to `'info'`. */
193
+ severity?: AuditSeverity;
194
+ }
195
+
196
+ /** A resolved audit action descriptor produced by an audit catalog. */
197
+ export interface AuditAction {
198
+ readonly action: string;
199
+ readonly severity: AuditSeverity;
200
+ readonly message?: string;
201
+ }
202
+
203
+ type AuditDescriptorArgs<E extends AuditCatalogEntry> = ParamsOf<E> extends void
204
+ ? []
205
+ : [params: ParamsOf<E>];
206
+
207
+ /** A callable audit-action descriptor produced by {@link defineAuditCatalog}. */
208
+ export interface AuditDescriptor<E extends AuditCatalogEntry> {
209
+ (...args: AuditDescriptorArgs<E>): AuditAction;
210
+ readonly action: string;
211
+ readonly severity: AuditSeverity;
212
+ }
213
+
214
+ export type AuditCatalog<T extends Record<string, AuditCatalogEntry>> = {
215
+ readonly [K in keyof T]: AuditDescriptor<T[K]>;
216
+ };
217
+
218
+ /**
219
+ * Define a typed audit catalog. Returns typed action descriptors you can pass
220
+ * to `track()` or audit helpers without scattering magic strings.
221
+ */
222
+ export function defineAuditCatalog<
223
+ const T extends Record<string, AuditCatalogEntry>,
224
+ >(namespace: string, entries: T): AuditCatalog<T> {
225
+ const catalog: Record<string, AuditDescriptor<AuditCatalogEntry>> = {};
226
+
227
+ for (const [key, entry] of Object.entries(entries) as [
228
+ string,
229
+ AuditCatalogEntry,
230
+ ][]) {
231
+ const action = entry.action ?? `${namespace}.${key}`;
232
+ const severity: AuditSeverity = entry.severity ?? 'info';
233
+
234
+ const descriptor = ((params?: unknown): AuditAction => {
235
+ const message =
236
+ typeof entry.message === 'function'
237
+ ? (entry.message as (p: unknown) => string)(params)
238
+ : entry.message;
239
+ return Object.freeze({
240
+ action,
241
+ severity,
242
+ ...(message === undefined ? {} : { message }),
243
+ });
244
+ }) as AuditDescriptor<AuditCatalogEntry>;
245
+
246
+ Object.defineProperty(descriptor, 'action', {
247
+ value: action,
248
+ enumerable: true,
249
+ });
250
+ Object.defineProperty(descriptor, 'severity', {
251
+ value: severity,
252
+ enumerable: true,
253
+ });
254
+
255
+ catalog[key] = descriptor;
256
+ }
257
+
258
+ return Object.freeze(catalog) as AuditCatalog<T>;
259
+ }
@@ -22,6 +22,7 @@ import type {
22
22
  EventSubscriber,
23
23
  EventAttributes,
24
24
  AutotelEventContext,
25
+ EventSchemaMetadata,
25
26
  } from './event-subscriber';
26
27
  import { getLogger } from './init';
27
28
  import { getConfig as getRuntimeConfig } from './config';
@@ -38,6 +39,8 @@ export interface EventData {
38
39
  _traceId?: string;
39
40
  /** Autotel context for trace correlation (passed to subscribers) */
40
41
  autotel?: AutotelEventContext;
42
+ /** Optional schema metadata for contract-aware subscribers. */
43
+ schema?: EventSchemaMetadata;
41
44
  }
42
45
 
43
46
  /**
@@ -591,6 +594,7 @@ export class EventQueue {
591
594
  try {
592
595
  await subscriber.trackEvent(event.name, event.attributes, {
593
596
  autotel: event.autotel,
597
+ schema: event.schema,
594
598
  });
595
599
  this.recordDelivered(event, subscriberName, startTime);
596
600
  return { subscriberName, success: true };
@@ -101,12 +101,27 @@ export interface AutotelEventContext {
101
101
  linked_trace_ids?: string[];
102
102
  }
103
103
 
104
+ /**
105
+ * Optional machine-readable schema metadata attached to an event payload.
106
+ * Intended for contract-aware subscribers (e.g. architecture snapshot capture).
107
+ */
108
+ export interface EventSchemaMetadata {
109
+ /** Schema source format used at the call site. */
110
+ source: 'zod';
111
+ /** JSON Schema representation of the payload contract. */
112
+ jsonSchema: unknown;
113
+ /** Stable schema hash for change detection and cache keys. */
114
+ hash: string;
115
+ }
116
+
104
117
  /**
105
118
  * Options for event tracking methods
106
119
  */
107
120
  export interface EventTrackingOptions {
108
121
  /** Autotel trace context to include in the event */
109
122
  autotel?: AutotelEventContext;
123
+ /** Optional event payload schema metadata */
124
+ schema?: EventSchemaMetadata;
110
125
  }
111
126
 
112
127
  /**
package/src/functional.ts CHANGED
@@ -210,7 +210,8 @@ function hasImmediateExecutionMark(fn: unknown): boolean {
210
210
  */
211
211
  export function markAsImmediate<F>(fn: F): F {
212
212
  if (typeof fn === 'function') {
213
- (fn as unknown as ImmediateExecutionFlag)[IMMEDIATE_EXECUTION_SYMBOL] = true;
213
+ (fn as unknown as ImmediateExecutionFlag)[IMMEDIATE_EXECUTION_SYMBOL] =
214
+ true;
214
215
  }
215
216
  return fn;
216
217
  }