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.
- package/README.md +131 -0
- package/dist/cli.cjs +111 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +14 -0
- package/dist/cli.d.cts.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -0
- package/dist/contract-DGjxR9nb.d.cts +123 -0
- package/dist/contract-DGjxR9nb.d.cts.map +1 -0
- package/dist/contract-DGjxR9nb.d.ts +123 -0
- package/dist/contract-DGjxR9nb.d.ts.map +1 -0
- package/dist/diff-BQPh72vY.d.cts +89 -0
- package/dist/diff-BQPh72vY.d.cts.map +1 -0
- package/dist/diff-D7qkNn0-.d.ts +89 -0
- package/dist/diff-D7qkNn0-.d.ts.map +1 -0
- package/dist/diff.cjs +185 -0
- package/dist/diff.cjs.map +1 -0
- package/dist/diff.d.cts +2 -0
- package/dist/diff.d.ts +2 -0
- package/dist/diff.js +181 -0
- package/dist/diff.js.map +1 -0
- package/dist/index.cjs +63 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +33 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/processor-CK7LAdaa.d.ts +100 -0
- package/dist/processor-CK7LAdaa.d.ts.map +1 -0
- package/dist/processor-CkBkzK6y.d.cts +100 -0
- package/dist/processor-CkBkzK6y.d.cts.map +1 -0
- package/dist/processor-D93TAXvZ.cjs +366 -0
- package/dist/processor-D93TAXvZ.cjs.map +1 -0
- package/dist/processor-FmvKYllX.js +306 -0
- package/dist/processor-FmvKYllX.js.map +1 -0
- package/dist/processor.cjs +5 -0
- package/dist/processor.d.cts +2 -0
- package/dist/processor.d.ts +2 -0
- package/dist/processor.js +3 -0
- package/dist/snapshot-CyWGJaJT.cjs +119 -0
- package/dist/snapshot-CyWGJaJT.cjs.map +1 -0
- package/dist/snapshot-h8pb_Up_.js +89 -0
- package/dist/snapshot-h8pb_Up_.js.map +1 -0
- package/package.json +80 -0
- package/src/attrs.ts +23 -0
- package/src/cli.ts +117 -0
- package/src/contract.test.ts +67 -0
- package/src/contract.ts +231 -0
- package/src/diff.ts +282 -0
- package/src/index.ts +88 -0
- package/src/processor.test.ts +74 -0
- package/src/processor.ts +152 -0
- package/src/redaction.ts +64 -0
- package/src/snapshot.test.ts +88 -0
- package/src/snapshot.ts +119 -0
- package/src/validate.test.ts +100 -0
- 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
|
+
});
|
package/src/contract.ts
ADDED
|
@@ -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
|
+
}
|