autotel-schema 0.1.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 (61) hide show
  1. package/README.md +131 -0
  2. package/dist/cli.cjs +111 -0
  3. package/dist/cli.cjs.map +1 -0
  4. package/dist/cli.d.cts +14 -0
  5. package/dist/cli.d.cts.map +1 -0
  6. package/dist/cli.d.ts +14 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +82 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/contract-DGjxR9nb.d.cts +123 -0
  11. package/dist/contract-DGjxR9nb.d.cts.map +1 -0
  12. package/dist/contract-DGjxR9nb.d.ts +123 -0
  13. package/dist/contract-DGjxR9nb.d.ts.map +1 -0
  14. package/dist/diff-BQPh72vY.d.cts +89 -0
  15. package/dist/diff-BQPh72vY.d.cts.map +1 -0
  16. package/dist/diff-D7qkNn0-.d.ts +89 -0
  17. package/dist/diff-D7qkNn0-.d.ts.map +1 -0
  18. package/dist/diff.cjs +185 -0
  19. package/dist/diff.cjs.map +1 -0
  20. package/dist/diff.d.cts +2 -0
  21. package/dist/diff.d.ts +2 -0
  22. package/dist/diff.js +181 -0
  23. package/dist/diff.js.map +1 -0
  24. package/dist/index.cjs +63 -0
  25. package/dist/index.cjs.map +1 -0
  26. package/dist/index.d.cts +33 -0
  27. package/dist/index.d.cts.map +1 -0
  28. package/dist/index.d.ts +33 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +43 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/processor-CK7LAdaa.d.ts +100 -0
  33. package/dist/processor-CK7LAdaa.d.ts.map +1 -0
  34. package/dist/processor-CkBkzK6y.d.cts +100 -0
  35. package/dist/processor-CkBkzK6y.d.cts.map +1 -0
  36. package/dist/processor-D93TAXvZ.cjs +366 -0
  37. package/dist/processor-D93TAXvZ.cjs.map +1 -0
  38. package/dist/processor-FmvKYllX.js +306 -0
  39. package/dist/processor-FmvKYllX.js.map +1 -0
  40. package/dist/processor.cjs +5 -0
  41. package/dist/processor.d.cts +2 -0
  42. package/dist/processor.d.ts +2 -0
  43. package/dist/processor.js +3 -0
  44. package/dist/snapshot-CyWGJaJT.cjs +119 -0
  45. package/dist/snapshot-CyWGJaJT.cjs.map +1 -0
  46. package/dist/snapshot-h8pb_Up_.js +89 -0
  47. package/dist/snapshot-h8pb_Up_.js.map +1 -0
  48. package/package.json +80 -0
  49. package/src/attrs.ts +23 -0
  50. package/src/cli.ts +117 -0
  51. package/src/contract.test.ts +67 -0
  52. package/src/contract.ts +231 -0
  53. package/src/diff.ts +282 -0
  54. package/src/index.ts +88 -0
  55. package/src/processor.test.ts +74 -0
  56. package/src/processor.ts +152 -0
  57. package/src/redaction.ts +64 -0
  58. package/src/snapshot.test.ts +88 -0
  59. package/src/snapshot.ts +119 -0
  60. package/src/validate.test.ts +100 -0
  61. package/src/validate.ts +237 -0
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "autotel-schema",
3
+ "version": "0.1.0",
4
+ "description": "Your telemetry surface as a typed, versioned contract — declare the spans and attributes your service emits, validate live spans against them, and diff the surface across commits to catch breaking trace changes before they ship.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs"
13
+ },
14
+ "./processor": {
15
+ "types": "./dist/processor.d.ts",
16
+ "import": "./dist/processor.js",
17
+ "require": "./dist/processor.cjs"
18
+ },
19
+ "./diff": {
20
+ "types": "./dist/diff.d.ts",
21
+ "import": "./dist/diff.js",
22
+ "require": "./dist/diff.cjs"
23
+ }
24
+ },
25
+ "bin": {
26
+ "autotel-schema": "./dist/cli.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsdown",
35
+ "dev": "tsdown --watch",
36
+ "lint": "eslint src/**/*.ts",
37
+ "lint:fix": "eslint src/**/*.ts --fix",
38
+ "type-check": "tsc --noEmit",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest",
41
+ "clean": "rimraf dist"
42
+ },
43
+ "keywords": [
44
+ "autotel",
45
+ "opentelemetry",
46
+ "observability",
47
+ "telemetry-contract",
48
+ "schema",
49
+ "span-validation",
50
+ "semantic-conventions",
51
+ "breaking-change-detection",
52
+ "agent-observability"
53
+ ],
54
+ "author": "Jag Reehal <jag@jagreehal.com> (https://jagreehal.com)",
55
+ "license": "MIT",
56
+ "peerDependencies": {
57
+ "autotel": "workspace:*"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "autotel": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^25.9.2",
66
+ "rimraf": "^6.1.3",
67
+ "tsdown": "^0.22.2",
68
+ "typescript": "^6.0.3",
69
+ "vitest": "^4.1.8"
70
+ },
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "https://github.com/jagreehal/autotel",
74
+ "directory": "packages/autotel-schema"
75
+ },
76
+ "bugs": {
77
+ "url": "https://github.com/jagreehal/autotel/issues"
78
+ },
79
+ "homepage": "https://github.com/jagreehal/autotel/tree/main/packages/autotel-schema#readme"
80
+ }
package/src/attrs.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Wire constants for the schema contract — the keys autotel-schema reads from
3
+ * and stamps onto spans. Dependency-free so CLI, browser, and edge code can
4
+ * share the exact same strings the runtime uses.
5
+ */
6
+
7
+ /**
8
+ * Resource/span attributes that announce the contract to a reader. Stamping
9
+ * `telemetry.schema.version` means an agent following a trace knows *which*
10
+ * version of your public telemetry API it is looking at — the difference
11
+ * between "confidently correct" and "confidently wrong" after a rename.
12
+ */
13
+ export const SCHEMA_ATTRS = {
14
+ /** The service this contract describes (mirrors `service.name`). */
15
+ SERVICE: 'telemetry.schema.service',
16
+ /** Semver of the telemetry contract that produced this span. */
17
+ VERSION: 'telemetry.schema.version',
18
+ } as const;
19
+
20
+ export type SchemaAttributeKey = (typeof SCHEMA_ATTRS)[keyof typeof SCHEMA_ATTRS];
21
+
22
+ /** Snapshot file spec marker — bump only on a breaking snapshot format change. */
23
+ export const SNAPSHOT_SPEC = 'autotel-schema-snapshot/v1' as const;
package/src/cli.ts ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * autotel-schema CLI — the CI gate for your telemetry's public API.
4
+ *
5
+ * autotel-schema diff <baseline.json> <current.json> # classify changes
6
+ * autotel-schema check <baseline.json> <current.json> # exit 1 on breaking
7
+ *
8
+ * Both operate on snapshot JSON produced by `serializeSnapshot(contractToSnapshot(contract))`.
9
+ * Commit the baseline; regenerate `current` in CI; gate the merge on `check`.
10
+ */
11
+
12
+ import { readFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { parseSnapshot } from './snapshot.js';
15
+ import {
16
+ diffSnapshots,
17
+ formatDiff,
18
+ hasBreakingChanges,
19
+ type SnapshotDiff,
20
+ } from './diff.js';
21
+
22
+ interface Parsed {
23
+ command: string | undefined;
24
+ baseline: string | undefined;
25
+ current: string | undefined;
26
+ json: boolean;
27
+ }
28
+
29
+ function parseArgs(argv: string[]): Parsed {
30
+ const positional: string[] = [];
31
+ let json = false;
32
+ for (const arg of argv) {
33
+ if (arg === '--json') json = true;
34
+ else if (arg === '-h' || arg === '--help') positional.unshift('help');
35
+ else positional.push(arg);
36
+ }
37
+ return {
38
+ command: positional[0],
39
+ baseline: positional[1],
40
+ current: positional[2],
41
+ json,
42
+ };
43
+ }
44
+
45
+ const USAGE = `autotel-schema — treat your trace surface like a versioned public API
46
+
47
+ Usage:
48
+ autotel-schema diff <baseline.json> <current.json> [--json]
49
+ autotel-schema check <baseline.json> <current.json> [--json]
50
+
51
+ Commands:
52
+ diff Print every change (breaking / additive / neutral). Always exits 0.
53
+ check Like diff, but exits 1 if any breaking change is found (CI gate).
54
+
55
+ Snapshots are produced with serializeSnapshot(contractToSnapshot(contract)).`;
56
+
57
+ function loadDiff(baseline: string, current: string): SnapshotDiff {
58
+ const prev = parseSnapshot(readFileSync(baseline, 'utf8'));
59
+ const next = parseSnapshot(readFileSync(current, 'utf8'));
60
+ return diffSnapshots(prev, next);
61
+ }
62
+
63
+ function emit(diff: SnapshotDiff, json: boolean): void {
64
+ if (json) {
65
+ console.log(JSON.stringify(diff, null, 2));
66
+ } else {
67
+ console.log(formatDiff(diff));
68
+ }
69
+ }
70
+
71
+ export function run(argv: string[]): number {
72
+ const { command, baseline, current, json } = parseArgs(argv);
73
+
74
+ if (!command || command === 'help') {
75
+ console.log(USAGE);
76
+ return command ? 0 : 1;
77
+ }
78
+
79
+ if (command !== 'diff' && command !== 'check') {
80
+ console.error(`autotel-schema: unknown command "${command}"\n\n${USAGE}`);
81
+ return 1;
82
+ }
83
+
84
+ if (!baseline || !current) {
85
+ console.error('autotel-schema: both <baseline.json> and <current.json> are required\n');
86
+ console.error(USAGE);
87
+ return 1;
88
+ }
89
+
90
+ let diff: SnapshotDiff;
91
+ try {
92
+ diff = loadDiff(baseline, current);
93
+ } catch (error) {
94
+ console.error(
95
+ `autotel-schema: ${error instanceof Error ? error.message : String(error)}`,
96
+ );
97
+ return 1;
98
+ }
99
+
100
+ emit(diff, json);
101
+
102
+ if (command === 'check' && hasBreakingChanges(diff)) {
103
+ console.error(
104
+ `\nautotel-schema: ${diff.breaking.length} breaking change(s) to the telemetry contract. ` +
105
+ `Bump the contract major version and update the committed snapshot.`,
106
+ );
107
+ return 1;
108
+ }
109
+ return 0;
110
+ }
111
+
112
+ // Only auto-run when invoked directly as the binary, not when imported in tests.
113
+ // Match the basename so a repo path containing "autotel-schema" can't trigger it.
114
+ const entry = process.argv[1] ? path.basename(process.argv[1]) : '';
115
+ if (entry === 'autotel-schema' || entry === 'cli.js' || entry === 'cli.cjs') {
116
+ process.exit(run(process.argv.slice(2)));
117
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ allowsAdditionalAttributes,
5
+ defineContract,
6
+ resolveAttributeSpec,
7
+ type TelemetryContract,
8
+ } from './contract.js';
9
+
10
+ const base: TelemetryContract = {
11
+ service: 'checkout',
12
+ version: '1.2.0',
13
+ commonAttributes: {
14
+ 'user.id': { type: 'string', highCardinality: true },
15
+ },
16
+ spans: {
17
+ 'checkout.charge': {
18
+ attributes: {
19
+ 'payment.provider': { type: 'string', required: true, enum: ['stripe', 'paypal'] },
20
+ 'payment.amount_cents': { type: 'number', required: true },
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ describe('defineContract', () => {
27
+ it('accepts and freezes a valid contract', () => {
28
+ const c = defineContract(base);
29
+ expect(Object.isFrozen(c)).toBe(true);
30
+ expect(c.service).toBe('checkout');
31
+ });
32
+
33
+ it('rejects an empty service', () => {
34
+ expect(() => defineContract({ ...base, service: '' })).toThrowError(/service/);
35
+ });
36
+
37
+ it('rejects a non-semver version', () => {
38
+ expect(() => defineContract({ ...base, version: 'v1' })).toThrowError(/semver/);
39
+ });
40
+
41
+ it('rejects an unknown attribute type', () => {
42
+ expect(() =>
43
+ defineContract({
44
+ ...base,
45
+ spans: { 'x.y': { attributes: { k: { type: 'uuid' as never } } } },
46
+ }),
47
+ ).toThrow();
48
+ });
49
+ });
50
+
51
+ describe('resolveAttributeSpec', () => {
52
+ it('prefers span-specific over common attributes', () => {
53
+ expect(resolveAttributeSpec(base, 'checkout.charge', 'payment.provider')?.required).toBe(true);
54
+ expect(resolveAttributeSpec(base, 'checkout.charge', 'user.id')?.highCardinality).toBe(true);
55
+ expect(resolveAttributeSpec(base, 'checkout.charge', 'nope')).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe('allowsAdditionalAttributes', () => {
60
+ it('defaults to false (declared-only)', () => {
61
+ expect(allowsAdditionalAttributes(base, 'checkout.charge')).toBe(false);
62
+ });
63
+ it('honors span- then contract-level overrides', () => {
64
+ const loose = { ...base, additionalAttributes: true };
65
+ expect(allowsAdditionalAttributes(loose, 'checkout.charge')).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Telemetry contract model.
3
+ *
4
+ * The premise: when the primary reader of your telemetry is an agent, your
5
+ * span names and attribute keys are a **public API**. Renaming `fast_path_hit`
6
+ * to `fast_path_taken` in a refactor PR silently breaks every prompt that
7
+ * mentions it — there is no compiler to catch it, because to the compiler these
8
+ * are just strings in a JSON blob.
9
+ *
10
+ * `defineContract()` makes that surface explicit, typed, and versionable: you
11
+ * declare which spans your service emits and which attributes live on them,
12
+ * then validate live spans against it ({@link ./validate}) and diff it across
13
+ * commits to catch breaking changes before they ship ({@link ./diff}).
14
+ *
15
+ * This module is dependency-free and side-effect-free by design — safe to
16
+ * import anywhere (browser, edge, CLI) without pulling in the OpenTelemetry SDK.
17
+ */
18
+
19
+ /** Scalar and array attribute types permitted on a span (OTLP value shapes). */
20
+ export type AttributeType =
21
+ | 'string'
22
+ | 'number'
23
+ | 'boolean'
24
+ | 'string[]'
25
+ | 'number[]'
26
+ | 'boolean[]';
27
+
28
+ export const ATTRIBUTE_TYPES: readonly AttributeType[] = [
29
+ 'string',
30
+ 'number',
31
+ 'boolean',
32
+ 'string[]',
33
+ 'number[]',
34
+ 'boolean[]',
35
+ ];
36
+
37
+ /**
38
+ * Lifecycle of a span or attribute, mirroring how the OpenTelemetry semantic
39
+ * conventions stage their own surface. `stable` is a promise to agent readers
40
+ * that the name will not change without a major contract bump.
41
+ */
42
+ export type Stability = 'stable' | 'experimental' | 'deprecated';
43
+
44
+ export const STABILITIES: readonly Stability[] = [
45
+ 'stable',
46
+ 'experimental',
47
+ 'deprecated',
48
+ ];
49
+
50
+ /** Declaration for a single attribute key on a span. */
51
+ export interface AttributeSpec {
52
+ /** OTLP value shape. Validated at runtime against the emitted value. */
53
+ type: AttributeType;
54
+ /** Lifecycle stage. Defaults to `stable`. */
55
+ stability?: Stability;
56
+ /** When `true`, the attribute must be present on every matching span. */
57
+ required?: boolean;
58
+ /** Human/agent-facing description of what the attribute means. */
59
+ description?: string;
60
+ /**
61
+ * Marks an attribute as intentionally high-cardinality (user id, sender
62
+ * domain, request id). For an agent reader these are the single most useful
63
+ * fields on a trace, so {@link ./redaction.highCardinalityKeys} surfaces them
64
+ * as a *protect* list — telling redactors/normalizers NOT to strip them.
65
+ */
66
+ highCardinality?: boolean;
67
+ /** Closed set of permitted values. Reported as `enum_violation` if exceeded. */
68
+ enum?: readonly (string | number)[];
69
+ /** Set when `stability: 'deprecated'`; explains what to use instead. */
70
+ replacedBy?: string;
71
+ /** Free-text note shown alongside deprecation warnings. */
72
+ deprecatedReason?: string;
73
+ }
74
+
75
+ /** Declaration for a single span name your service emits. */
76
+ export interface SpanSpec {
77
+ /** Human/agent-facing description of when this span is produced. */
78
+ description?: string;
79
+ /** Lifecycle stage. Defaults to `stable`. */
80
+ stability?: Stability;
81
+ /** Attributes specific to this span, keyed by attribute name. */
82
+ attributes?: Record<string, AttributeSpec>;
83
+ /**
84
+ * When `true`, attributes not declared here are allowed without an
85
+ * `unknown_attribute` violation. Defaults to the contract-level setting.
86
+ */
87
+ additionalAttributes?: boolean;
88
+ }
89
+
90
+ /** The full telemetry contract for one service. */
91
+ export interface TelemetryContract {
92
+ /** `service.name` this contract describes. */
93
+ service: string;
94
+ /**
95
+ * Semver of the *contract itself* (not the app). Bumped when the trace
96
+ * surface changes; surfaced to readers as the `telemetry.schema.version`
97
+ * resource attribute via {@link ./attrs.SCHEMA_ATTRS}.
98
+ */
99
+ version: string;
100
+ /** Spans this service emits, keyed by span name. */
101
+ spans: Record<string, SpanSpec>;
102
+ /** Attributes permitted on *any* span (e.g. `user.id`, `tenant.id`). */
103
+ commonAttributes?: Record<string, AttributeSpec>;
104
+ /**
105
+ * Default for `SpanSpec.additionalAttributes` when a span does not set it.
106
+ * Defaults to `false` (declared-only — the stricter, agent-friendlier mode).
107
+ */
108
+ additionalAttributes?: boolean;
109
+ }
110
+
111
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
112
+
113
+ function assert(condition: unknown, message: string): asserts condition {
114
+ if (!condition) {
115
+ throw new Error(`autotel-schema: ${message}`);
116
+ }
117
+ }
118
+
119
+ function validateAttribute(
120
+ scope: string,
121
+ key: string,
122
+ spec: AttributeSpec,
123
+ ): void {
124
+ assert(
125
+ ATTRIBUTE_TYPES.includes(spec.type),
126
+ `${scope} attribute "${key}" has invalid type "${spec.type}"`,
127
+ );
128
+ if (spec.stability) {
129
+ assert(
130
+ STABILITIES.includes(spec.stability),
131
+ `${scope} attribute "${key}" has invalid stability "${spec.stability}"`,
132
+ );
133
+ }
134
+ if (spec.stability === 'deprecated') {
135
+ assert(
136
+ spec.replacedBy !== undefined || spec.deprecatedReason !== undefined,
137
+ `${scope} attribute "${key}" is deprecated but has no replacedBy or deprecatedReason`,
138
+ );
139
+ }
140
+ if (spec.enum) {
141
+ assert(
142
+ spec.enum.length > 0,
143
+ `${scope} attribute "${key}" declares an empty enum`,
144
+ );
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Validate and freeze a telemetry contract. Throws on structural mistakes
150
+ * (bad semver, unknown attribute type, deprecation with no replacement) so the
151
+ * contract fails loudly at module load, not silently at runtime.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * export const contract = defineContract({
156
+ * service: 'checkout',
157
+ * version: '1.2.0',
158
+ * commonAttributes: {
159
+ * 'user.id': { type: 'string', highCardinality: true, description: 'Authenticated user' },
160
+ * },
161
+ * spans: {
162
+ * 'checkout.charge': {
163
+ * description: 'Charge a payment method',
164
+ * attributes: {
165
+ * 'payment.provider': { type: 'string', required: true, enum: ['stripe', 'paypal'] },
166
+ * 'payment.amount_cents': { type: 'number', required: true },
167
+ * },
168
+ * },
169
+ * },
170
+ * });
171
+ * ```
172
+ */
173
+ export function defineContract(contract: TelemetryContract): TelemetryContract {
174
+ assert(
175
+ typeof contract.service === 'string' && contract.service.length > 0,
176
+ 'contract.service must be a non-empty string',
177
+ );
178
+ assert(
179
+ SEMVER_RE.test(contract.version),
180
+ `contract.version "${contract.version}" is not valid semver (e.g. "1.2.0")`,
181
+ );
182
+ assert(
183
+ contract.spans && typeof contract.spans === 'object',
184
+ 'contract.spans must be an object',
185
+ );
186
+
187
+ for (const [spanName, spanSpec] of Object.entries(contract.spans)) {
188
+ if (spanSpec.stability) {
189
+ assert(
190
+ STABILITIES.includes(spanSpec.stability),
191
+ `span "${spanName}" has invalid stability "${spanSpec.stability}"`,
192
+ );
193
+ }
194
+ for (const [key, spec] of Object.entries(spanSpec.attributes ?? {})) {
195
+ validateAttribute(`span "${spanName}"`, key, spec);
196
+ }
197
+ }
198
+ for (const [key, spec] of Object.entries(contract.commonAttributes ?? {})) {
199
+ validateAttribute('common', key, spec);
200
+ }
201
+
202
+ return Object.freeze(contract);
203
+ }
204
+
205
+ /**
206
+ * Resolve the effective attribute spec for `key` on `spanName`: span-specific
207
+ * attributes win over common attributes. Returns `undefined` when the key is
208
+ * declared nowhere.
209
+ */
210
+ export function resolveAttributeSpec(
211
+ contract: TelemetryContract,
212
+ spanName: string,
213
+ key: string,
214
+ ): AttributeSpec | undefined {
215
+ return (
216
+ contract.spans[spanName]?.attributes?.[key] ??
217
+ contract.commonAttributes?.[key]
218
+ );
219
+ }
220
+
221
+ /** Whether attributes outside the declared set are tolerated for a span. */
222
+ export function allowsAdditionalAttributes(
223
+ contract: TelemetryContract,
224
+ spanName: string,
225
+ ): boolean {
226
+ return (
227
+ contract.spans[spanName]?.additionalAttributes ??
228
+ contract.additionalAttributes ??
229
+ false
230
+ );
231
+ }