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.
- package/dist/attribute-redacting-processor.cjs +8 -8
- package/dist/attribute-redacting-processor.js +1 -1
- package/dist/attributes.cjs +21 -21
- package/dist/attributes.js +2 -2
- package/dist/auto.cjs +3 -3
- package/dist/auto.js +2 -2
- package/dist/{chunk-MYWQELNY.js → chunk-32AXF4MA.js} +30 -8
- package/dist/chunk-32AXF4MA.js.map +1 -0
- package/dist/{chunk-6X2GG65S.cjs → chunk-3MZJ7Y24.cjs} +5 -5
- package/dist/{chunk-6X2GG65S.cjs.map → chunk-3MZJ7Y24.cjs.map} +1 -1
- package/dist/{chunk-DDXIUZEG.js → chunk-454CH4OV.js} +3 -3
- package/dist/{chunk-DDXIUZEG.js.map → chunk-454CH4OV.js.map} +1 -1
- package/dist/{chunk-MXO6LXV5.cjs → chunk-4RA6HIYF.cjs} +5 -5
- package/dist/{chunk-MXO6LXV5.cjs.map → chunk-4RA6HIYF.cjs.map} +1 -1
- package/dist/{chunk-6TFJF7SS.js → chunk-4TAQQZDU.js} +3 -3
- package/dist/{chunk-6TFJF7SS.js.map → chunk-4TAQQZDU.js.map} +1 -1
- package/dist/{chunk-LIYNUGML.cjs → chunk-DQSVSGK3.cjs} +23 -32
- package/dist/chunk-DQSVSGK3.cjs.map +1 -0
- package/dist/{chunk-PEEUMQ3R.js → chunk-FZROHTZZ.js} +3 -3
- package/dist/{chunk-PEEUMQ3R.js.map → chunk-FZROHTZZ.js.map} +1 -1
- package/dist/{chunk-DQ2SUROF.cjs → chunk-M3LFHHTN.cjs} +4 -4
- package/dist/{chunk-DQ2SUROF.cjs.map → chunk-M3LFHHTN.cjs.map} +1 -1
- package/dist/{chunk-ZPERWNOP.cjs → chunk-MQH5OOZK.cjs} +17 -17
- package/dist/{chunk-ZPERWNOP.cjs.map → chunk-MQH5OOZK.cjs.map} +1 -1
- package/dist/{chunk-NXLRY2CE.cjs → chunk-NEIB3TLD.cjs} +10 -8
- package/dist/chunk-NEIB3TLD.cjs.map +1 -0
- package/dist/{chunk-MHPYLMQS.js → chunk-OACAWYLR.js} +4 -4
- package/dist/{chunk-MHPYLMQS.js.map → chunk-OACAWYLR.js.map} +1 -1
- package/dist/{chunk-52ALHU7T.js → chunk-OPCTN527.js} +3 -3
- package/dist/{chunk-52ALHU7T.js.map → chunk-OPCTN527.js.map} +1 -1
- package/dist/{chunk-YPQMAE6U.cjs → chunk-QICFEFD6.cjs} +7 -7
- package/dist/{chunk-YPQMAE6U.cjs.map → chunk-QICFEFD6.cjs.map} +1 -1
- package/dist/{chunk-45B2GD4P.cjs → chunk-QJYWKAC5.cjs} +32 -10
- package/dist/chunk-QJYWKAC5.cjs.map +1 -0
- package/dist/{chunk-JVWJDHDB.js → chunk-RUPKBKUF.js} +10 -8
- package/dist/chunk-RUPKBKUF.js.map +1 -0
- package/dist/{chunk-FTBBBPT6.js → chunk-TGV2XF57.js} +13 -22
- package/dist/chunk-TGV2XF57.js.map +1 -0
- package/dist/{chunk-T7CPAGOI.js → chunk-U4D5IBSB.js} +4 -4
- package/dist/chunk-U4D5IBSB.js.map +1 -0
- package/dist/{chunk-KPDIEVVV.cjs → chunk-U72TGONP.cjs} +32 -32
- package/dist/chunk-U72TGONP.cjs.map +1 -0
- package/dist/correlation-id.cjs +11 -11
- package/dist/correlation-id.js +3 -3
- package/dist/decorators.cjs +5 -5
- package/dist/decorators.js +4 -4
- package/dist/event-subscriber.d.cts +15 -1
- package/dist/event-subscriber.d.ts +15 -1
- package/dist/event.cjs +7 -7
- package/dist/event.js +4 -4
- package/dist/functional.cjs +12 -12
- package/dist/functional.js +4 -4
- package/dist/http.cjs +4 -4
- package/dist/http.js +3 -3
- package/dist/index.cjs +280 -94
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +209 -4
- package/dist/index.d.ts +209 -4
- package/dist/index.js +191 -14
- package/dist/index.js.map +1 -1
- package/dist/{init-BSyIyDs5.d.ts → init-DyE43paw.d.ts} +7 -2
- package/dist/{init-D9Bxx39e.d.cts → init-gyesUMwz.d.cts} +7 -2
- package/dist/instrumentation.cjs +9 -9
- package/dist/instrumentation.js +2 -2
- package/dist/messaging.cjs +8 -8
- package/dist/messaging.js +5 -5
- package/dist/semantic-helpers.cjs +9 -9
- package/dist/semantic-helpers.js +5 -5
- package/dist/webhook.cjs +6 -6
- package/dist/webhook.js +4 -4
- package/dist/workflow-distributed.cjs +6 -6
- package/dist/workflow-distributed.js +4 -4
- package/dist/workflow.cjs +9 -9
- package/dist/workflow.js +5 -5
- package/dist/yaml-config.d.cts +1 -1
- package/dist/yaml-config.d.ts +1 -1
- package/package.json +1 -1
- package/skills/build-audit-trails/SKILL.md +150 -5
- package/skills/build-audit-trails/references/audit-queries.md +73 -0
- package/skills/build-audit-trails/references/framework-wiring.md +187 -0
- package/skills/review-otel-patterns/SKILL.md +41 -0
- package/src/attribute-redacting-processor.ts +12 -9
- package/src/define-event.test.ts +41 -0
- package/src/define-event.ts +77 -0
- package/src/error-catalog.test.ts +128 -0
- package/src/error-catalog.ts +259 -0
- package/src/event-queue.ts +4 -0
- package/src/event-subscriber.ts +15 -0
- package/src/functional.ts +2 -1
- package/src/gen-ai-cost.test.ts +81 -0
- package/src/gen-ai-cost.ts +145 -0
- package/src/index.ts +35 -0
- package/src/init-auto-redactor.test.ts +53 -0
- package/src/init.ts +46 -7
- package/src/track.ts +3 -0
- package/src/validation.test.ts +7 -3
- package/src/validation.ts +19 -21
- package/dist/chunk-45B2GD4P.cjs.map +0 -1
- package/dist/chunk-FTBBBPT6.js.map +0 -1
- package/dist/chunk-JVWJDHDB.js.map +0 -1
- package/dist/chunk-KPDIEVVV.cjs.map +0 -1
- package/dist/chunk-LIYNUGML.cjs.map +0 -1
- package/dist/chunk-MYWQELNY.js.map +0 -1
- package/dist/chunk-NXLRY2CE.cjs.map +0 -1
- 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
|
-
*
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
|
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
|
|
package/src/validation.test.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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;
|