autotel 3.2.0 → 3.3.1

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 (76) hide show
  1. package/dist/auto.cjs +2 -2
  2. package/dist/auto.js +1 -1
  3. package/dist/{chunk-RZI5XXAD.js → chunk-2GWM2CIT.js} +3 -3
  4. package/dist/{chunk-RZI5XXAD.js.map → chunk-2GWM2CIT.js.map} +1 -1
  5. package/dist/{chunk-IS2QJ44P.js → chunk-4EXFRREO.js} +3 -3
  6. package/dist/{chunk-IS2QJ44P.js.map → chunk-4EXFRREO.js.map} +1 -1
  7. package/dist/{chunk-FVA2YDEQ.js → chunk-55ALGIAR.js} +4 -4
  8. package/dist/{chunk-FVA2YDEQ.js.map → chunk-55ALGIAR.js.map} +1 -1
  9. package/dist/{chunk-ZKKJQS6R.js → chunk-B4CGFDZQ.js} +29 -7
  10. package/dist/chunk-B4CGFDZQ.js.map +1 -0
  11. package/dist/{chunk-5RZ3NZ2M.cjs → chunk-C4JCSBFO.cjs} +5 -5
  12. package/dist/{chunk-5RZ3NZ2M.cjs.map → chunk-C4JCSBFO.cjs.map} +1 -1
  13. package/dist/{chunk-NN2GODP4.cjs → chunk-DK6VFPVK.cjs} +7 -7
  14. package/dist/{chunk-NN2GODP4.cjs.map → chunk-DK6VFPVK.cjs.map} +1 -1
  15. package/dist/{chunk-EEQHQKPP.cjs → chunk-FMTNB27Z.cjs} +32 -32
  16. package/dist/{chunk-EEQHQKPP.cjs.map → chunk-FMTNB27Z.cjs.map} +1 -1
  17. package/dist/{chunk-UV64CWMA.cjs → chunk-JAX4LFGG.cjs} +13 -13
  18. package/dist/{chunk-UV64CWMA.cjs.map → chunk-JAX4LFGG.cjs.map} +1 -1
  19. package/dist/{chunk-7EVW3Z37.js → chunk-LCXOOJIP.js} +3 -3
  20. package/dist/{chunk-7EVW3Z37.js.map → chunk-LCXOOJIP.js.map} +1 -1
  21. package/dist/{chunk-QVLMGNQF.js → chunk-LKASEUWE.js} +4 -4
  22. package/dist/{chunk-QVLMGNQF.js.map → chunk-LKASEUWE.js.map} +1 -1
  23. package/dist/{chunk-4UUEGERM.cjs → chunk-PWOECUNT.cjs} +17 -17
  24. package/dist/{chunk-4UUEGERM.cjs.map → chunk-PWOECUNT.cjs.map} +1 -1
  25. package/dist/{chunk-KKIYPZOP.cjs → chunk-RYVFCHSO.cjs} +29 -7
  26. package/dist/chunk-RYVFCHSO.cjs.map +1 -0
  27. package/dist/{chunk-NIDUQZIN.js → chunk-VFU663OM.js} +3 -3
  28. package/dist/{chunk-NIDUQZIN.js.map → chunk-VFU663OM.js.map} +1 -1
  29. package/dist/{chunk-RRTFFAG3.cjs → chunk-VUYLXWCB.cjs} +5 -5
  30. package/dist/{chunk-RRTFFAG3.cjs.map → chunk-VUYLXWCB.cjs.map} +1 -1
  31. package/dist/correlation-id.cjs +10 -10
  32. package/dist/correlation-id.js +2 -2
  33. package/dist/decorators.cjs +4 -4
  34. package/dist/decorators.js +3 -3
  35. package/dist/event.cjs +6 -6
  36. package/dist/event.js +3 -3
  37. package/dist/functional.cjs +11 -11
  38. package/dist/functional.js +3 -3
  39. package/dist/http.cjs +3 -3
  40. package/dist/http.js +2 -2
  41. package/dist/index.cjs +215 -70
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/index.d.cts +185 -2
  44. package/dist/index.d.ts +185 -2
  45. package/dist/index.js +149 -12
  46. package/dist/index.js.map +1 -1
  47. package/dist/{init-BSyIyDs5.d.ts → init-DyE43paw.d.ts} +7 -2
  48. package/dist/{init-D9Bxx39e.d.cts → init-gyesUMwz.d.cts} +7 -2
  49. package/dist/instrumentation.cjs +8 -8
  50. package/dist/instrumentation.js +1 -1
  51. package/dist/messaging.cjs +7 -7
  52. package/dist/messaging.js +4 -4
  53. package/dist/semantic-helpers.cjs +8 -8
  54. package/dist/semantic-helpers.js +4 -4
  55. package/dist/webhook.cjs +5 -5
  56. package/dist/webhook.js +3 -3
  57. package/dist/workflow-distributed.cjs +5 -5
  58. package/dist/workflow-distributed.js +3 -3
  59. package/dist/workflow.cjs +8 -8
  60. package/dist/workflow.js +4 -4
  61. package/dist/yaml-config.d.cts +1 -1
  62. package/dist/yaml-config.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/skills/build-audit-trails/SKILL.md +163 -5
  65. package/skills/build-audit-trails/references/audit-queries.md +73 -0
  66. package/skills/build-audit-trails/references/framework-wiring.md +196 -0
  67. package/skills/review-otel-patterns/SKILL.md +44 -0
  68. package/src/error-catalog.test.ts +133 -0
  69. package/src/error-catalog.ts +262 -0
  70. package/src/gen-ai-cost.test.ts +81 -0
  71. package/src/gen-ai-cost.ts +145 -0
  72. package/src/index.ts +29 -0
  73. package/src/init-auto-redactor.test.ts +53 -0
  74. package/src/init.ts +50 -7
  75. package/dist/chunk-KKIYPZOP.cjs.map +0 -1
  76. package/dist/chunk-ZKKJQS6R.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,49 @@ 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<
266
+ { user?: { plan?: string } },
267
+ { tier: string }
268
+ >({
269
+ name: 'user-tier',
270
+ field: 'user',
271
+ compute: ({ event }) => ({ tier: event.user?.plan ?? 'anonymous' }),
272
+ });
273
+ ```
274
+
275
+ **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.
276
+
277
+ ---
278
+
235
279
  ## Backends (multi-vendor OTLP)
236
280
 
237
281
  Switch backends with **no code changes** — autotel speaks OTLP HTTP/JSON and HTTP/protobuf out of the box.
@@ -0,0 +1,133 @@
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: ({
21
+ available,
22
+ required,
23
+ }: {
24
+ available: number;
25
+ required: number;
26
+ }) => `Insufficient funds: $${available} of $${required}`,
27
+ why: ({ required }: { available: number; required: number }) =>
28
+ `Needs $${required}`,
29
+ },
30
+ LEGACY: {
31
+ code: 'BILLING_LEGACY_42',
32
+ message: 'Legacy failure',
33
+ },
34
+ });
35
+
36
+ it('builds a structured error from a static entry', () => {
37
+ const err = billing.PAYMENT_DECLINED();
38
+ expect(err).toBeInstanceOf(Error);
39
+ expect(err.message).toBe('Card declined');
40
+ expect(err.status).toBe(402);
41
+ expect(err.why).toBe('The issuer rejected the charge');
42
+ expect(err.fix).toBe('Try a different payment method');
43
+ expect(err.link).toBe('https://docs.example.com/billing');
44
+ expect(err.code).toBe('billing.PAYMENT_DECLINED');
45
+ expect(err.name).toBe('PAYMENT_DECLINED');
46
+ });
47
+
48
+ it('interpolates typed params in message and why', () => {
49
+ const err = billing.INSUFFICIENT_FUNDS({ available: 5, required: 100 });
50
+ expect(err.message).toBe('Insufficient funds: $5 of $100');
51
+ expect(err.why).toBe('Needs $100');
52
+ });
53
+
54
+ it('honors a custom code', () => {
55
+ const err = billing.LEGACY();
56
+ expect(err.code).toBe('BILLING_LEGACY_42');
57
+ });
58
+
59
+ it('exposes the code on the builder', () => {
60
+ expect(billing.PAYMENT_DECLINED.code).toBe('billing.PAYMENT_DECLINED');
61
+ expect(billing.LEGACY.code).toBe('BILLING_LEGACY_42');
62
+ });
63
+
64
+ it('matches its own errors and rejects others', () => {
65
+ const declined = billing.PAYMENT_DECLINED();
66
+ const funds = billing.INSUFFICIENT_FUNDS({ available: 1, required: 2 });
67
+ expect(billing.PAYMENT_DECLINED.match(declined)).toBe(true);
68
+ expect(billing.PAYMENT_DECLINED.match(funds)).toBe(false);
69
+ expect(billing.PAYMENT_DECLINED.match(new Error('nope'))).toBe(false);
70
+ expect(billing.PAYMENT_DECLINED.match(null)).toBe(false);
71
+ });
72
+
73
+ it('tags errors so isCatalogError / getCatalogCode work', () => {
74
+ const err = billing.PAYMENT_DECLINED();
75
+ expect(isCatalogError(err)).toBe(true);
76
+ expect(isCatalogError(new Error('plain'))).toBe(false);
77
+ expect(getCatalogCode(err)).toBe('billing.PAYMENT_DECLINED');
78
+ expect(getCatalogCode(new Error('plain'))).toBeUndefined();
79
+ });
80
+
81
+ it('passes cause, details, and internal through build options', () => {
82
+ const cause = new Error('stripe boom');
83
+ const err = billing.PAYMENT_DECLINED({
84
+ cause,
85
+ details: { attempt: 2 },
86
+ internal: { stripeId: 'ch_1' },
87
+ });
88
+ expect(err.cause).toBe(cause);
89
+ expect(err.details).toEqual({ attempt: 2 });
90
+ expect(err.internal).toEqual({ stripeId: 'ch_1' });
91
+ });
92
+
93
+ it('accepts options as the second arg for param entries', () => {
94
+ const cause = new Error('root');
95
+ const err = billing.INSUFFICIENT_FUNDS(
96
+ { available: 5, required: 100 },
97
+ { cause },
98
+ );
99
+ expect(err.message).toBe('Insufficient funds: $5 of $100');
100
+ expect(err.cause).toBe(cause);
101
+ });
102
+ });
103
+
104
+ describe('defineAuditCatalog', () => {
105
+ const audit = defineAuditCatalog('user', {
106
+ LOGIN: { message: 'User logged in' },
107
+ ROLE_CHANGED: {
108
+ severity: 'critical',
109
+ message: ({ role }: { role: string }) => `Role set to ${role}`,
110
+ },
111
+ DELETED: { action: 'user.account.deleted', severity: 'warn' },
112
+ });
113
+
114
+ it('produces typed action descriptors with defaults', () => {
115
+ const action = audit.LOGIN();
116
+ expect(action.action).toBe('user.LOGIN');
117
+ expect(action.severity).toBe('info');
118
+ expect(action.message).toBe('User logged in');
119
+ });
120
+
121
+ it('interpolates params and respects severity', () => {
122
+ const action = audit.ROLE_CHANGED({ role: 'admin' });
123
+ expect(action.action).toBe('user.ROLE_CHANGED');
124
+ expect(action.severity).toBe('critical');
125
+ expect(action.message).toBe('Role set to admin');
126
+ });
127
+
128
+ it('honors a custom action name', () => {
129
+ expect(audit.DELETED.action).toBe('user.account.deleted');
130
+ expect(audit.DELETED.severity).toBe('warn');
131
+ expect(audit.DELETED().message).toBeUndefined();
132
+ });
133
+ });
@@ -0,0 +1,262 @@
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 {
34
+ createStructuredError,
35
+ type StructuredError,
36
+ } from './structured-error';
37
+
38
+ const catalogCodeKey = Symbol.for('autotel.catalog.code');
39
+
40
+ /** Definition of a single error in a catalog. */
41
+ export interface ErrorCatalogEntry {
42
+ /**
43
+ * Human-readable message. Use a function to interpolate typed parameters;
44
+ * the parameter type flows through to the call site.
45
+ */
46
+ message: string | ((params: never) => string);
47
+ /** HTTP status to surface to clients. */
48
+ status?: number;
49
+ /** Stable error code. Defaults to `${namespace}.${KEY}`. */
50
+ code?: string | number;
51
+ /** Why it happened. A function receives the same params as `message`. */
52
+ why?: string | ((params: never) => string);
53
+ /** What the caller should do next. */
54
+ fix?: string;
55
+ /** Docs or runbook link. */
56
+ link?: string;
57
+ /** Error name. Defaults to the catalog key. */
58
+ name?: string;
59
+ }
60
+
61
+ /** Per-call options passed alongside (or instead of) typed params. */
62
+ export interface ErrorBuildOptions {
63
+ cause?: unknown;
64
+ details?: Record<string, unknown>;
65
+ /** Backend-only context. Never serialized to clients. */
66
+ internal?: Record<string, unknown>;
67
+ }
68
+
69
+ type ParamsOf<E> = E extends { message: (params: infer P) => string }
70
+ ? P
71
+ : E extends { why: (params: infer P) => string }
72
+ ? P
73
+ : void;
74
+
75
+ type BuilderArgs<E extends ErrorCatalogEntry> =
76
+ ParamsOf<E> extends void
77
+ ? [options?: ErrorBuildOptions]
78
+ : [params: ParamsOf<E>, options?: ErrorBuildOptions];
79
+
80
+ /** A callable error factory produced by {@link defineErrorCatalog}. */
81
+ export interface ErrorBuilder<E extends ErrorCatalogEntry> {
82
+ (...args: BuilderArgs<E>): StructuredError;
83
+ /** Stable code assigned to every error from this entry. */
84
+ readonly code: string | number;
85
+ /** True when `error` was produced by this catalog entry. */
86
+ match(error: unknown): boolean;
87
+ }
88
+
89
+ export type ErrorCatalog<T extends Record<string, ErrorCatalogEntry>> = {
90
+ readonly [K in keyof T]: ErrorBuilder<T[K]>;
91
+ };
92
+
93
+ function readCatalogCode(error: unknown): string | number | undefined {
94
+ if (error === null || typeof error !== 'object') return undefined;
95
+ return (error as Record<symbol, unknown>)[catalogCodeKey] as
96
+ | string
97
+ | number
98
+ | undefined;
99
+ }
100
+
101
+ /** True when `error` was produced by any autotel error catalog. */
102
+ export function isCatalogError(error: unknown): error is StructuredError {
103
+ return readCatalogCode(error) !== undefined;
104
+ }
105
+
106
+ /** Returns the catalog code of `error`, or `undefined` if it has none. */
107
+ export function getCatalogCode(error: unknown): string | number | undefined {
108
+ return readCatalogCode(error);
109
+ }
110
+
111
+ /**
112
+ * Define a typed error catalog. Returns an object whose keys are error
113
+ * builders. Each builder produces a {@link StructuredError} carrying the
114
+ * entry's message, status, code, why, fix, and link.
115
+ */
116
+ export function defineErrorCatalog<
117
+ const T extends Record<string, ErrorCatalogEntry>,
118
+ >(namespace: string, entries: T): ErrorCatalog<T> {
119
+ const catalog: Record<string, ErrorBuilder<ErrorCatalogEntry>> = {};
120
+
121
+ for (const [key, entry] of Object.entries(entries) as [
122
+ string,
123
+ ErrorCatalogEntry,
124
+ ][]) {
125
+ const code = entry.code ?? `${namespace}.${key}`;
126
+ const usesParams =
127
+ typeof entry.message === 'function' || typeof entry.why === 'function';
128
+
129
+ const builder = ((
130
+ paramsOrOptions?: unknown,
131
+ maybeOptions?: ErrorBuildOptions,
132
+ ): StructuredError => {
133
+ const params = usesParams ? paramsOrOptions : undefined;
134
+ const options = (usesParams ? maybeOptions : paramsOrOptions) as
135
+ | ErrorBuildOptions
136
+ | undefined;
137
+
138
+ const message =
139
+ typeof entry.message === 'function'
140
+ ? (entry.message as (p: unknown) => string)(params)
141
+ : entry.message;
142
+ const why =
143
+ typeof entry.why === 'function'
144
+ ? (entry.why as (p: unknown) => string)(params)
145
+ : entry.why;
146
+
147
+ const error = createStructuredError({
148
+ message,
149
+ name: entry.name ?? key,
150
+ code,
151
+ ...(entry.status === undefined ? {} : { status: entry.status }),
152
+ ...(why === undefined ? {} : { why }),
153
+ ...(entry.fix === undefined ? {} : { fix: entry.fix }),
154
+ ...(entry.link === undefined ? {} : { link: entry.link }),
155
+ ...(options?.cause === undefined ? {} : { cause: options.cause }),
156
+ ...(options?.details === undefined ? {} : { details: options.details }),
157
+ ...(options?.internal === undefined
158
+ ? {}
159
+ : { internal: options.internal }),
160
+ });
161
+
162
+ Object.defineProperty(error, catalogCodeKey, {
163
+ value: code,
164
+ enumerable: false,
165
+ writable: false,
166
+ configurable: true,
167
+ });
168
+
169
+ return error;
170
+ }) as ErrorBuilder<ErrorCatalogEntry>;
171
+
172
+ Object.defineProperty(builder, 'code', {
173
+ value: code,
174
+ enumerable: true,
175
+ });
176
+ Object.defineProperty(builder, 'match', {
177
+ value: (error: unknown): boolean => readCatalogCode(error) === code,
178
+ enumerable: false,
179
+ });
180
+
181
+ catalog[key] = builder;
182
+ }
183
+
184
+ return Object.freeze(catalog) as ErrorCatalog<T>;
185
+ }
186
+
187
+ /** Severity of an audit action. */
188
+ export type AuditSeverity = 'info' | 'warn' | 'critical';
189
+
190
+ /** Definition of a single action in an audit catalog. */
191
+ export interface AuditCatalogEntry {
192
+ /** Human-readable description. Use a function for typed params. */
193
+ message?: string | ((params: never) => string);
194
+ /** Stable action name. Defaults to `${namespace}.${KEY}`. */
195
+ action?: string;
196
+ /** Severity of the action. Defaults to `'info'`. */
197
+ severity?: AuditSeverity;
198
+ }
199
+
200
+ /** A resolved audit action descriptor produced by an audit catalog. */
201
+ export interface AuditAction {
202
+ readonly action: string;
203
+ readonly severity: AuditSeverity;
204
+ readonly message?: string;
205
+ }
206
+
207
+ type AuditDescriptorArgs<E extends AuditCatalogEntry> =
208
+ ParamsOf<E> extends void ? [] : [params: ParamsOf<E>];
209
+
210
+ /** A callable audit-action descriptor produced by {@link defineAuditCatalog}. */
211
+ export interface AuditDescriptor<E extends AuditCatalogEntry> {
212
+ (...args: AuditDescriptorArgs<E>): AuditAction;
213
+ readonly action: string;
214
+ readonly severity: AuditSeverity;
215
+ }
216
+
217
+ export type AuditCatalog<T extends Record<string, AuditCatalogEntry>> = {
218
+ readonly [K in keyof T]: AuditDescriptor<T[K]>;
219
+ };
220
+
221
+ /**
222
+ * Define a typed audit catalog. Returns typed action descriptors you can pass
223
+ * to `track()` or audit helpers without scattering magic strings.
224
+ */
225
+ export function defineAuditCatalog<
226
+ const T extends Record<string, AuditCatalogEntry>,
227
+ >(namespace: string, entries: T): AuditCatalog<T> {
228
+ const catalog: Record<string, AuditDescriptor<AuditCatalogEntry>> = {};
229
+
230
+ for (const [key, entry] of Object.entries(entries) as [
231
+ string,
232
+ AuditCatalogEntry,
233
+ ][]) {
234
+ const action = entry.action ?? `${namespace}.${key}`;
235
+ const severity: AuditSeverity = entry.severity ?? 'info';
236
+
237
+ const descriptor = ((params?: unknown): AuditAction => {
238
+ const message =
239
+ typeof entry.message === 'function'
240
+ ? (entry.message as (p: unknown) => string)(params)
241
+ : entry.message;
242
+ return Object.freeze({
243
+ action,
244
+ severity,
245
+ ...(message === undefined ? {} : { message }),
246
+ });
247
+ }) as AuditDescriptor<AuditCatalogEntry>;
248
+
249
+ Object.defineProperty(descriptor, 'action', {
250
+ value: action,
251
+ enumerable: true,
252
+ });
253
+ Object.defineProperty(descriptor, 'severity', {
254
+ value: severity,
255
+ enumerable: true,
256
+ });
257
+
258
+ catalog[key] = descriptor;
259
+ }
260
+
261
+ return Object.freeze(catalog) as AuditCatalog<T>;
262
+ }
@@ -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
+ }