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
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ estimateLLMCost,
4
+ recordLLMCost,
5
+ GEN_AI_COST_ATTRIBUTE,
6
+ } from './gen-ai-cost';
7
+
8
+ describe('estimateLLMCost', () => {
9
+ it('estimates cost from input and output tokens', () => {
10
+ // claude-sonnet-4: $3 / 1M in, $15 / 1M out
11
+ const cost = estimateLLMCost('claude-sonnet-4', {
12
+ inputTokens: 1_000_000,
13
+ outputTokens: 1_000_000,
14
+ });
15
+ expect(cost).toBe(18);
16
+ });
17
+
18
+ it('matches versioned model ids by longest prefix', () => {
19
+ const cost = estimateLLMCost('claude-sonnet-4-6-20251101', {
20
+ inputTokens: 1_000_000,
21
+ outputTokens: 0,
22
+ });
23
+ expect(cost).toBe(3);
24
+ });
25
+
26
+ it('returns undefined for an unknown model', () => {
27
+ expect(
28
+ estimateLLMCost('totally-made-up', { inputTokens: 1000 }),
29
+ ).toBeUndefined();
30
+ });
31
+
32
+ it('bills cached input tokens at the cached rate', () => {
33
+ const pricing = {
34
+ custom: { inputPer1M: 10, outputPer1M: 30, cachedInputPer1M: 1 },
35
+ };
36
+ // 1M input, of which 800k cached: 200k @ $10/M + 800k @ $1/M = 2 + 0.8
37
+ const cost = estimateLLMCost(
38
+ 'custom',
39
+ { inputTokens: 1_000_000, cachedInputTokens: 800_000 },
40
+ { pricing },
41
+ );
42
+ expect(cost).toBeCloseTo(2.8, 6);
43
+ });
44
+
45
+ it('accepts a pricing override and extends the table', () => {
46
+ const cost = estimateLLMCost(
47
+ 'my-model',
48
+ { inputTokens: 500_000, outputTokens: 500_000 },
49
+ { pricing: { 'my-model': { inputPer1M: 4, outputPer1M: 8 } } },
50
+ );
51
+ expect(cost).toBe(6);
52
+ });
53
+
54
+ it('handles partial usage without throwing', () => {
55
+ expect(estimateLLMCost('gpt-4o-mini', {})).toBe(0);
56
+ expect(estimateLLMCost('gpt-4o-mini', { outputTokens: 1_000_000 })).toBe(
57
+ 0.6,
58
+ );
59
+ });
60
+ });
61
+
62
+ describe('recordLLMCost', () => {
63
+ it('sets the cost attribute on the context for a known model', () => {
64
+ const setAttribute = vi.fn();
65
+ const cost = recordLLMCost({ setAttribute }, 'gpt-4o', {
66
+ inputTokens: 1_000_000,
67
+ outputTokens: 0,
68
+ });
69
+ expect(cost).toBe(2.5);
70
+ expect(setAttribute).toHaveBeenCalledWith(GEN_AI_COST_ATTRIBUTE, 2.5);
71
+ });
72
+
73
+ it('sets no attribute for an unknown model', () => {
74
+ const setAttribute = vi.fn();
75
+ const cost = recordLLMCost({ setAttribute }, 'unknown-model', {
76
+ inputTokens: 100,
77
+ });
78
+ expect(cost).toBeUndefined();
79
+ expect(setAttribute).not.toHaveBeenCalled();
80
+ });
81
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Per-model LLM cost estimation.
3
+ *
4
+ * Estimate the USD cost of an LLM call from its token usage and record it as a
5
+ * span attribute (`gen_ai.usage.cost.usd`). Pair with the
6
+ * `gen_ai.client.cost.usd` metric bucket advice in `gen-ai-metrics`.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { trace, recordLLMCost } from 'autotel';
11
+ *
12
+ * export const chat = trace((ctx) => async (prompt: string) => {
13
+ * const res = await client.messages.create({ model, ... });
14
+ * recordLLMCost(ctx, model, {
15
+ * inputTokens: res.usage.input_tokens,
16
+ * outputTokens: res.usage.output_tokens,
17
+ * });
18
+ * return res;
19
+ * });
20
+ * ```
21
+ */
22
+
23
+ import type { TraceContext } from './trace-context';
24
+
25
+ /** Span attribute key autotel sets for an estimated call cost. */
26
+ export const GEN_AI_COST_ATTRIBUTE = 'gen_ai.usage.cost.usd';
27
+
28
+ /** Pricing for a single model, in USD per 1,000,000 tokens. */
29
+ export interface ModelPricing {
30
+ /** USD per 1M input (prompt) tokens. */
31
+ inputPer1M: number;
32
+ /** USD per 1M output (completion) tokens. */
33
+ outputPer1M: number;
34
+ /** USD per 1M cached input tokens. Defaults to {@link ModelPricing.inputPer1M}. */
35
+ cachedInputPer1M?: number;
36
+ }
37
+
38
+ /** Token counts for a single LLM call. */
39
+ export interface TokenUsage {
40
+ inputTokens?: number;
41
+ outputTokens?: number;
42
+ /** Cached input tokens, billed at {@link ModelPricing.cachedInputPer1M}. */
43
+ cachedInputTokens?: number;
44
+ }
45
+
46
+ export interface EstimateCostOptions {
47
+ /** Override or extend {@link MODEL_PRICING}. Keys are matched first. */
48
+ pricing?: Record<string, ModelPricing>;
49
+ }
50
+
51
+ /**
52
+ * Approximate public list prices (USD per 1M tokens) at the time of writing.
53
+ * Prices change; treat these as convenience defaults, not a billing source of
54
+ * truth. Override per call via `options.pricing` or mutate this table at init.
55
+ * Matching is exact first, then by longest key prefix, so versioned model ids
56
+ * (`claude-sonnet-4-6-20251101`) resolve to a base entry (`claude-sonnet-4-6`).
57
+ */
58
+ export const MODEL_PRICING: Record<string, ModelPricing> = {
59
+ // OpenAI
60
+ 'gpt-4o': { inputPer1M: 2.5, outputPer1M: 10 },
61
+ 'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.6 },
62
+ 'gpt-4.1': { inputPer1M: 2, outputPer1M: 8 },
63
+ 'gpt-4.1-mini': { inputPer1M: 0.4, outputPer1M: 1.6 },
64
+ 'gpt-4.1-nano': { inputPer1M: 0.1, outputPer1M: 0.4 },
65
+ 'o3-mini': { inputPer1M: 1.1, outputPer1M: 4.4 },
66
+ // Anthropic Claude
67
+ 'claude-opus-4': { inputPer1M: 15, outputPer1M: 75 },
68
+ 'claude-sonnet-4': { inputPer1M: 3, outputPer1M: 15 },
69
+ 'claude-3-5-sonnet': { inputPer1M: 3, outputPer1M: 15 },
70
+ 'claude-3-5-haiku': { inputPer1M: 0.8, outputPer1M: 4 },
71
+ 'claude-3-opus': { inputPer1M: 15, outputPer1M: 75 },
72
+ 'claude-3-haiku': { inputPer1M: 0.25, outputPer1M: 1.25 },
73
+ // Google Gemini
74
+ 'gemini-1.5-pro': { inputPer1M: 1.25, outputPer1M: 5 },
75
+ 'gemini-1.5-flash': { inputPer1M: 0.075, outputPer1M: 0.3 },
76
+ 'gemini-2.0-flash': { inputPer1M: 0.1, outputPer1M: 0.4 },
77
+ };
78
+
79
+ function resolvePricing(
80
+ table: Record<string, ModelPricing>,
81
+ model: string,
82
+ ): ModelPricing | undefined {
83
+ const exact = table[model];
84
+ if (exact) return exact;
85
+
86
+ let best: ModelPricing | undefined;
87
+ let bestLength = 0;
88
+ for (const key of Object.keys(table)) {
89
+ if (model.startsWith(key) && key.length > bestLength) {
90
+ best = table[key];
91
+ bestLength = key.length;
92
+ }
93
+ }
94
+ return best;
95
+ }
96
+
97
+ function round(value: number): number {
98
+ return Math.round(value * 1e6) / 1e6;
99
+ }
100
+
101
+ /**
102
+ * Estimate the USD cost of an LLM call. Returns `undefined` when the model has
103
+ * no known pricing (supply one via `options.pricing`).
104
+ */
105
+ export function estimateLLMCost(
106
+ model: string,
107
+ usage: TokenUsage,
108
+ options?: EstimateCostOptions,
109
+ ): number | undefined {
110
+ const table = options?.pricing
111
+ ? { ...MODEL_PRICING, ...options.pricing }
112
+ : MODEL_PRICING;
113
+ const price = resolvePricing(table, model);
114
+ if (!price) return undefined;
115
+
116
+ const cachedInput = usage.cachedInputTokens ?? 0;
117
+ const billedInput = Math.max(0, (usage.inputTokens ?? 0) - cachedInput);
118
+ const output = usage.outputTokens ?? 0;
119
+ const cachedRate = price.cachedInputPer1M ?? price.inputPer1M;
120
+
121
+ const cost =
122
+ (billedInput / 1_000_000) * price.inputPer1M +
123
+ (cachedInput / 1_000_000) * cachedRate +
124
+ (output / 1_000_000) * price.outputPer1M;
125
+
126
+ return round(cost);
127
+ }
128
+
129
+ /**
130
+ * Estimate cost and record it on `ctx` as the `gen_ai.usage.cost.usd` span
131
+ * attribute. Returns the estimated cost, or `undefined` when the model is
132
+ * unknown (in which case no attribute is set).
133
+ */
134
+ export function recordLLMCost(
135
+ ctx: Pick<TraceContext, 'setAttribute'>,
136
+ model: string,
137
+ usage: TokenUsage,
138
+ options?: EstimateCostOptions,
139
+ ): number | undefined {
140
+ const cost = estimateLLMCost(model, usage, options);
141
+ if (cost !== undefined) {
142
+ ctx.setAttribute(GEN_AI_COST_ATTRIBUTE, cost);
143
+ }
144
+ return cost;
145
+ }
package/src/index.ts CHANGED
@@ -106,6 +106,12 @@ export {
106
106
 
107
107
  // Global track function
108
108
  export { track, getEventQueue } from './track';
109
+ export {
110
+ defineEvent,
111
+ type SchemaLike,
112
+ type DefineEventOptions,
113
+ type DefinedEvent,
114
+ } from './define-event';
109
115
 
110
116
  // Correlation ID utilities
111
117
  export {
@@ -145,6 +151,23 @@ export {
145
151
  // parseError
146
152
  export { parseError, type ParsedError } from './parse-error';
147
153
 
154
+ // Typed error + audit catalogs
155
+ export {
156
+ defineErrorCatalog,
157
+ defineAuditCatalog,
158
+ isCatalogError,
159
+ getCatalogCode,
160
+ type ErrorCatalog,
161
+ type ErrorCatalogEntry,
162
+ type ErrorBuilder,
163
+ type ErrorBuildOptions,
164
+ type AuditCatalog,
165
+ type AuditCatalogEntry,
166
+ type AuditDescriptor,
167
+ type AuditAction,
168
+ type AuditSeverity,
169
+ } from './error-catalog';
170
+
148
171
  // Attribute flattening
149
172
  export { toAttributeValue, flattenToAttributes } from './flatten-attributes';
150
173
 
@@ -235,6 +258,18 @@ export {
235
258
  type StreamFirstTokenEvent,
236
259
  } from './gen-ai-events';
237
260
 
261
+ // Per-model LLM cost estimation — estimate USD cost from token usage and
262
+ // record it as the gen_ai.usage.cost.usd span attribute.
263
+ export {
264
+ estimateLLMCost,
265
+ recordLLMCost,
266
+ MODEL_PRICING,
267
+ GEN_AI_COST_ATTRIBUTE,
268
+ type ModelPricing,
269
+ type TokenUsage,
270
+ type EstimateCostOptions,
271
+ } from './gen-ai-cost';
272
+
238
273
  // Tracer helpers for custom spans
239
274
  export {
240
275
  getTracer,
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { resolveAttributeRedactor } from './init';
3
+
4
+ const ORIGINAL = process.env.AUTOTEL_REDACT_PII;
5
+
6
+ afterEach(() => {
7
+ if (ORIGINAL === undefined) {
8
+ delete process.env.AUTOTEL_REDACT_PII;
9
+ } else {
10
+ process.env.AUTOTEL_REDACT_PII = ORIGINAL;
11
+ }
12
+ });
13
+
14
+ describe('resolveAttributeRedactor', () => {
15
+ it('auto-enables the default preset in production', () => {
16
+ delete process.env.AUTOTEL_REDACT_PII;
17
+ expect(resolveAttributeRedactor(undefined, 'production')).toBe('default');
18
+ });
19
+
20
+ it('stays off in non-production environments', () => {
21
+ delete process.env.AUTOTEL_REDACT_PII;
22
+ expect(resolveAttributeRedactor(undefined, 'development')).toBeUndefined();
23
+ expect(resolveAttributeRedactor(undefined, 'test')).toBeUndefined();
24
+ });
25
+
26
+ it('honors an explicit preset in any environment', () => {
27
+ expect(resolveAttributeRedactor('strict', 'development')).toBe('strict');
28
+ });
29
+
30
+ it('honors an explicit custom config object', () => {
31
+ const config = { keyPatterns: [/password/i] };
32
+ expect(resolveAttributeRedactor(config, 'production')).toBe(config);
33
+ });
34
+
35
+ it('disables redaction when explicitly set to false, even in production', () => {
36
+ expect(resolveAttributeRedactor(false, 'production')).toBeUndefined();
37
+ });
38
+
39
+ it('lets AUTOTEL_REDACT_PII=off disable auto-enable in production', () => {
40
+ process.env.AUTOTEL_REDACT_PII = 'off';
41
+ expect(resolveAttributeRedactor(undefined, 'production')).toBeUndefined();
42
+ });
43
+
44
+ it('lets AUTOTEL_REDACT_PII select a preset in any environment', () => {
45
+ process.env.AUTOTEL_REDACT_PII = 'strict';
46
+ expect(resolveAttributeRedactor(undefined, 'development')).toBe('strict');
47
+ });
48
+
49
+ it('treats AUTOTEL_REDACT_PII truthy flags as the default preset', () => {
50
+ process.env.AUTOTEL_REDACT_PII = 'true';
51
+ expect(resolveAttributeRedactor(undefined, 'development')).toBe('default');
52
+ });
53
+ });
package/src/init.ts CHANGED
@@ -1023,7 +1023,12 @@ export interface AutotelConfig {
1023
1023
  * Automatically redact PII and sensitive data from span attributes before export.
1024
1024
  * Critical for compliance (GDPR, PCI-DSS, HIPAA) and data security.
1025
1025
  *
1026
- * Can be a preset name or custom configuration:
1026
+ * Auto-enabled in production: when this is left unset and the resolved
1027
+ * environment is `production`, the `'default'` preset is applied. Override
1028
+ * with the `AUTOTEL_REDACT_PII` env var (`off` / `strict` / `pci-dss` / ...)
1029
+ * or pass `false` to disable redaction entirely.
1030
+ *
1031
+ * Can be a preset name, custom configuration, or `false` to disable:
1027
1032
  * - `'default'`: Emails, phones, SSNs, credit cards, sensitive keys (password, secret, token)
1028
1033
  * - `'strict'`: Default + Bearer tokens, JWTs, API keys in values
1029
1034
  * - `'pci-dss'`: Payment card industry focus (credit cards, CVV, card-related keys)
@@ -1064,7 +1069,7 @@ export interface AutotelConfig {
1064
1069
  * })
1065
1070
  * ```
1066
1071
  */
1067
- attributeRedactor?: AttributeRedactorConfig | AttributeRedactorPreset;
1072
+ attributeRedactor?: AttributeRedactorConfig | AttributeRedactorPreset | false;
1068
1073
 
1069
1074
  /**
1070
1075
  * OpenLLMetry integration for LLM observability.
@@ -1264,6 +1269,34 @@ function wrapLogger(
1264
1269
  };
1265
1270
  }
1266
1271
 
1272
+ /**
1273
+ * Resolve the effective attribute redactor. Explicit config wins (`false`
1274
+ * disables). Otherwise the `AUTOTEL_REDACT_PII` env var controls it, and as a
1275
+ * final default PII redaction is auto-enabled in production.
1276
+ */
1277
+ export function resolveAttributeRedactor(
1278
+ explicit: AttributeRedactorConfig | AttributeRedactorPreset | false | undefined,
1279
+ environment: string,
1280
+ ): AttributeRedactorConfig | AttributeRedactorPreset | undefined {
1281
+ if (explicit === false) return undefined;
1282
+ if (explicit !== undefined) return explicit;
1283
+
1284
+ const flag = process.env.AUTOTEL_REDACT_PII?.trim().toLowerCase();
1285
+ if (flag) {
1286
+ if (['off', 'false', '0', 'none', 'disabled'].includes(flag)) {
1287
+ return undefined;
1288
+ }
1289
+ if (flag === 'default' || flag === 'strict' || flag === 'pci-dss') {
1290
+ return flag;
1291
+ }
1292
+ if (['on', 'true', '1', 'enabled'].includes(flag)) {
1293
+ return 'default';
1294
+ }
1295
+ }
1296
+
1297
+ return environment === 'production' ? 'default' : undefined;
1298
+ }
1299
+
1267
1300
  function detectEnvironmentAttributes(): Record<string, string> {
1268
1301
  const attrs: Record<string, string> = {};
1269
1302
 
@@ -1456,10 +1489,15 @@ export function init(cfg: AutotelConfig): void {
1456
1489
  headers: cfg.headers ?? yamlConfig.headers ?? envConfig.headers,
1457
1490
  } as AutotelConfig;
1458
1491
 
1459
- if (mergedConfig.attributeRedactor !== undefined) {
1460
- const normalizedRedactor = normalizeAttributeRedactorConfig(
1461
- mergedConfig.attributeRedactor,
1462
- );
1492
+ const resolvedRedactor = resolveAttributeRedactor(
1493
+ mergedConfig.attributeRedactor,
1494
+ mergedConfig.environment || process.env.NODE_ENV || 'development',
1495
+ );
1496
+ if (resolvedRedactor === undefined) {
1497
+ mergedConfig.attributeRedactor = undefined;
1498
+ } else {
1499
+ const normalizedRedactor =
1500
+ normalizeAttributeRedactorConfig(resolvedRedactor);
1463
1501
  if (!normalizedRedactor) {
1464
1502
  throw new Error('Invalid attributeRedactor config');
1465
1503
  }
@@ -1664,10 +1702,11 @@ export function init(cfg: AutotelConfig): void {
1664
1702
 
1665
1703
  // Step 1: Wrap with AttributeRedactingProcessor (innermost - executes last in onEnd)
1666
1704
  if (mergedConfig.attributeRedactor && spanProcessors.length > 0) {
1705
+ const redactor = mergedConfig.attributeRedactor;
1667
1706
  spanProcessors = spanProcessors.map(
1668
1707
  (processor) =>
1669
1708
  new AttributeRedactingProcessor(processor, {
1670
- redactor: mergedConfig.attributeRedactor!,
1709
+ redactor,
1671
1710
  }),
1672
1711
  );
1673
1712
  }
package/src/track.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from './init';
16
16
  import { validateEvent } from './validation';
17
17
  import { getOrCreateCorrelationId } from './correlation-id';
18
+ import type { EventTrackingOptions } from './event-subscriber';
18
19
  import type { AutotelEventContext } from './event-subscriber';
19
20
 
20
21
  // Global events queue (initialized on first track call)
@@ -167,6 +168,7 @@ function getOrCreateQueue(): EventQueue | null {
167
168
  export function track<Events extends Record<string, any> = Record<string, any>>(
168
169
  event: keyof Events & string,
169
170
  data?: Events[typeof event],
171
+ options?: EventTrackingOptions,
170
172
  ): void {
171
173
  const queue = getOrCreateQueue();
172
174
  if (!queue) return; // No-op if not initialized or no subscribers
@@ -193,6 +195,7 @@ export function track<Events extends Record<string, any> = Record<string, any>>(
193
195
  attributes: enrichedData,
194
196
  timestamp: Date.now(),
195
197
  autotel: autotelContext,
198
+ schema: options?.schema,
196
199
  });
197
200
  }
198
201
 
@@ -298,17 +298,21 @@ describe('Sensitive data patterns', () => {
298
298
  expect(result?.API_KEY).toBe('[REDACTED]');
299
299
  });
300
300
 
301
- it('should redact auth fields', () => {
301
+ it('should redact auth fields (strings only)', () => {
302
302
  const attrs = {
303
303
  auth: 'abc123',
304
304
  authorization: 'Bearer token',
305
- authenticated: true, // Contains "auth" but should still be redacted
305
+ // `authenticated` matches the /auth/i key pattern, but `true` is a
306
+ // boolean status — not a credential — so it passes through unchanged.
307
+ // Redacting it to the string '[REDACTED]' would silently corrupt its
308
+ // type without protecting any secret.
309
+ authenticated: true,
306
310
  };
307
311
 
308
312
  const result = validateAttributes(attrs);
309
313
  expect(result?.auth).toBe('[REDACTED]');
310
314
  expect(result?.authorization).toBe('[REDACTED]');
311
- expect(result?.authenticated).toBe('[REDACTED]');
315
+ expect(result?.authenticated).toBe(true);
312
316
  });
313
317
 
314
318
  it('should not redact non-sensitive fields with similar names', () => {
package/src/validation.ts CHANGED
@@ -130,19 +130,22 @@ export function validateAttributes(
130
130
  );
131
131
  }
132
132
 
133
- // Check for sensitive field
134
- const isSensitive = config.sensitivePatterns.some((pattern) =>
135
- pattern.test(key),
136
- );
133
+ const value = attributes[key];
134
+
135
+ // Redact sensitive *strings* only. Numeric/boolean values are not
136
+ // credentials and replacing them with the literal string '[REDACTED]'
137
+ // both leaks no useful signal and breaks downstream type expectations
138
+ // (e.g. an LLM `promptTokens` counter becoming a string poisons every
139
+ // consumer that treats it as a number).
140
+ const isSensitive =
141
+ typeof value === 'string' &&
142
+ config.sensitivePatterns.some((pattern) => pattern.test(key));
137
143
 
138
144
  if (isSensitive) {
139
- // Redact sensitive data
140
145
  sanitized[key] = '[REDACTED]';
141
146
  continue;
142
147
  }
143
148
 
144
- // Sanitize value
145
- const value = attributes[key];
146
149
  sanitized[key] = sanitizeValue(value, config, 1) as
147
150
  | string
148
151
  | number
@@ -196,20 +199,15 @@ function sanitizeValue(
196
199
  const sanitized: Record<string, unknown> = {};
197
200
  for (const key in value) {
198
201
  if (Object.prototype.hasOwnProperty.call(value, key)) {
199
- // Check for sensitive field in nested objects
200
- const isSensitive = config.sensitivePatterns.some((pattern) =>
201
- pattern.test(key),
202
- );
203
-
204
- if (isSensitive) {
205
- sanitized[key] = '[REDACTED]';
206
- } else {
207
- sanitized[key] = sanitizeValue(
208
- (value as Record<string, unknown>)[key],
209
- config,
210
- depth + 1,
211
- );
212
- }
202
+ const nested = (value as Record<string, unknown>)[key];
203
+ // See top-level branch above: only string values are redacted.
204
+ const isSensitive =
205
+ typeof nested === 'string' &&
206
+ config.sensitivePatterns.some((pattern) => pattern.test(key));
207
+
208
+ sanitized[key] = isSensitive
209
+ ? '[REDACTED]'
210
+ : sanitizeValue(nested, config, depth + 1);
213
211
  }
214
212
  }
215
213
  return sanitized;