autotel-schema 1.0.0 → 2.0.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/index.cjs +139 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +139 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/snapshots/agent-security.snapshot.json +238 -0
- package/src/attrs.ts +0 -23
- package/src/cli.ts +0 -117
- package/src/contract.test.ts +0 -67
- package/src/contract.ts +0 -231
- package/src/diff.ts +0 -282
- package/src/index.ts +0 -88
- package/src/processor.test.ts +0 -74
- package/src/processor.ts +0 -152
- package/src/redaction.ts +0 -64
- package/src/snapshot.test.ts +0 -88
- package/src/snapshot.ts +0 -119
- package/src/validate.test.ts +0 -100
- package/src/validate.ts +0 -237
package/src/cli.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
}
|
package/src/contract.test.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
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
|
-
}
|
package/src/diff.ts
DELETED
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Snapshot diffing — the CI gate that catches breaking changes to your trace
|
|
3
|
-
* surface before they ship.
|
|
4
|
-
*
|
|
5
|
-
* "If you wouldn't ship a rename to your public API without a changelog, don't
|
|
6
|
-
* do it to your traces." This module is what makes that enforceable: classify
|
|
7
|
-
* every change between two snapshots as breaking, additive, or neutral, and let
|
|
8
|
-
* CI fail on the breaking ones.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { ContractSnapshot, SnapshotAttribute } from './snapshot.js';
|
|
12
|
-
|
|
13
|
-
export type ChangeKind = 'breaking' | 'additive' | 'neutral';
|
|
14
|
-
|
|
15
|
-
export type ChangeType =
|
|
16
|
-
| 'span_removed'
|
|
17
|
-
| 'span_added'
|
|
18
|
-
| 'attribute_removed'
|
|
19
|
-
| 'attribute_added'
|
|
20
|
-
| 'type_changed'
|
|
21
|
-
| 'required_added'
|
|
22
|
-
| 'required_removed'
|
|
23
|
-
| 'enum_value_removed'
|
|
24
|
-
| 'enum_value_added'
|
|
25
|
-
| 'stability_downgraded'
|
|
26
|
-
| 'stability_advanced'
|
|
27
|
-
| 'deprecated'
|
|
28
|
-
| 'replacement_documented';
|
|
29
|
-
|
|
30
|
-
export interface SnapshotChange {
|
|
31
|
-
kind: ChangeKind;
|
|
32
|
-
type: ChangeType;
|
|
33
|
-
/** Span the change applies to (`*` = common attributes / contract-wide). */
|
|
34
|
-
span: string;
|
|
35
|
-
attribute?: string;
|
|
36
|
-
message: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface SnapshotDiff {
|
|
40
|
-
service: string;
|
|
41
|
-
previousVersion: string;
|
|
42
|
-
nextVersion: string;
|
|
43
|
-
breaking: SnapshotChange[];
|
|
44
|
-
additive: SnapshotChange[];
|
|
45
|
-
neutral: SnapshotChange[];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* How every cross-stability transition is classified. Keyed `prev->next` so all
|
|
50
|
-
* six transitions are explicit and auditable — no silent fall-through. Same-
|
|
51
|
-
* stability transitions are absent (no change to report).
|
|
52
|
-
*/
|
|
53
|
-
const STABILITY_TRANSITIONS: Record<
|
|
54
|
-
string,
|
|
55
|
-
{ kind: ChangeKind; type: ChangeType }
|
|
56
|
-
> = {
|
|
57
|
-
'stable->experimental': { kind: 'breaking', type: 'stability_downgraded' },
|
|
58
|
-
'stable->deprecated': { kind: 'additive', type: 'deprecated' },
|
|
59
|
-
'experimental->stable': { kind: 'neutral', type: 'stability_advanced' },
|
|
60
|
-
'experimental->deprecated': { kind: 'additive', type: 'deprecated' },
|
|
61
|
-
'deprecated->stable': { kind: 'neutral', type: 'stability_advanced' },
|
|
62
|
-
'deprecated->experimental': { kind: 'breaking', type: 'stability_downgraded' },
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
function stabilityMessage(
|
|
66
|
-
type: ChangeType,
|
|
67
|
-
attribute: string,
|
|
68
|
-
prev: SnapshotAttribute,
|
|
69
|
-
next: SnapshotAttribute,
|
|
70
|
-
): string {
|
|
71
|
-
if (type === 'deprecated') {
|
|
72
|
-
return `attribute "${attribute}" was deprecated${next.replacedBy ? ` (use "${next.replacedBy}")` : ''}`;
|
|
73
|
-
}
|
|
74
|
-
if (type === 'stability_downgraded') {
|
|
75
|
-
return `attribute "${attribute}" stability downgraded ${prev.stability} → ${next.stability}`;
|
|
76
|
-
}
|
|
77
|
-
return `attribute "${attribute}" promoted ${prev.stability} → ${next.stability}`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function push(
|
|
81
|
-
diff: SnapshotDiff,
|
|
82
|
-
change: SnapshotChange,
|
|
83
|
-
): void {
|
|
84
|
-
if (change.kind === 'breaking') diff.breaking.push(change);
|
|
85
|
-
else if (change.kind === 'additive') diff.additive.push(change);
|
|
86
|
-
else diff.neutral.push(change);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function diffAttribute(
|
|
90
|
-
diff: SnapshotDiff,
|
|
91
|
-
span: string,
|
|
92
|
-
attribute: string,
|
|
93
|
-
prev: SnapshotAttribute,
|
|
94
|
-
next: SnapshotAttribute,
|
|
95
|
-
): void {
|
|
96
|
-
if (prev.type !== next.type) {
|
|
97
|
-
push(diff, {
|
|
98
|
-
kind: 'breaking',
|
|
99
|
-
type: 'type_changed',
|
|
100
|
-
span,
|
|
101
|
-
attribute,
|
|
102
|
-
message: `attribute "${attribute}" changed type ${prev.type} → ${next.type}`,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!prev.required && next.required) {
|
|
107
|
-
push(diff, {
|
|
108
|
-
kind: 'breaking',
|
|
109
|
-
type: 'required_added',
|
|
110
|
-
span,
|
|
111
|
-
attribute,
|
|
112
|
-
message: `attribute "${attribute}" became required`,
|
|
113
|
-
});
|
|
114
|
-
} else if (prev.required && !next.required) {
|
|
115
|
-
push(diff, {
|
|
116
|
-
kind: 'additive',
|
|
117
|
-
type: 'required_removed',
|
|
118
|
-
span,
|
|
119
|
-
attribute,
|
|
120
|
-
message: `attribute "${attribute}" is no longer required`,
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Enum: removing a permitted value can break a producer that still emits it.
|
|
125
|
-
if (prev.enum && next.enum) {
|
|
126
|
-
const nextSet = new Set(next.enum);
|
|
127
|
-
const removed = prev.enum.filter((v) => !nextSet.has(v));
|
|
128
|
-
const prevSet = new Set(prev.enum);
|
|
129
|
-
const added = next.enum.filter((v) => !prevSet.has(v));
|
|
130
|
-
if (removed.length > 0) {
|
|
131
|
-
push(diff, {
|
|
132
|
-
kind: 'breaking',
|
|
133
|
-
type: 'enum_value_removed',
|
|
134
|
-
span,
|
|
135
|
-
attribute,
|
|
136
|
-
message: `attribute "${attribute}" dropped enum value(s) ${JSON.stringify(removed)}`,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
if (added.length > 0) {
|
|
140
|
-
push(diff, {
|
|
141
|
-
kind: 'additive',
|
|
142
|
-
type: 'enum_value_added',
|
|
143
|
-
span,
|
|
144
|
-
attribute,
|
|
145
|
-
message: `attribute "${attribute}" added enum value(s) ${JSON.stringify(added)}`,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (prev.stability !== next.stability) {
|
|
151
|
-
const transition =
|
|
152
|
-
STABILITY_TRANSITIONS[`${prev.stability}->${next.stability}`];
|
|
153
|
-
if (transition) {
|
|
154
|
-
push(diff, {
|
|
155
|
-
...transition,
|
|
156
|
-
span,
|
|
157
|
-
attribute,
|
|
158
|
-
message: stabilityMessage(transition.type, attribute, prev, next),
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function diffAttributeMaps(
|
|
165
|
-
diff: SnapshotDiff,
|
|
166
|
-
span: string,
|
|
167
|
-
prev: Record<string, SnapshotAttribute>,
|
|
168
|
-
next: Record<string, SnapshotAttribute>,
|
|
169
|
-
): void {
|
|
170
|
-
for (const [key, prevAttr] of Object.entries(prev)) {
|
|
171
|
-
const nextAttr = next[key];
|
|
172
|
-
if (!nextAttr) {
|
|
173
|
-
// A removed attribute whose replacement is named is a documented
|
|
174
|
-
// migration (still breaking — but reported as such with the pointer).
|
|
175
|
-
push(diff, {
|
|
176
|
-
kind: 'breaking',
|
|
177
|
-
type: prevAttr.replacedBy ? 'replacement_documented' : 'attribute_removed',
|
|
178
|
-
span,
|
|
179
|
-
attribute: key,
|
|
180
|
-
message: prevAttr.replacedBy
|
|
181
|
-
? `attribute "${key}" removed — replaced by "${prevAttr.replacedBy}"`
|
|
182
|
-
: `attribute "${key}" was removed`,
|
|
183
|
-
});
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
diffAttribute(diff, span, key, prevAttr, nextAttr);
|
|
187
|
-
}
|
|
188
|
-
for (const key of Object.keys(next)) {
|
|
189
|
-
if (!prev[key]) {
|
|
190
|
-
const added = next[key];
|
|
191
|
-
// Always `attribute_added` (the event is "new attribute"); severity rides
|
|
192
|
-
// on `kind` — a new *required* attribute breaks existing producers.
|
|
193
|
-
push(diff, {
|
|
194
|
-
kind: added.required ? 'breaking' : 'additive',
|
|
195
|
-
type: 'attribute_added',
|
|
196
|
-
span,
|
|
197
|
-
attribute: key,
|
|
198
|
-
message: added.required
|
|
199
|
-
? `new required attribute "${key}" added`
|
|
200
|
-
: `new attribute "${key}" added`,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Diff two snapshots, classifying every change. The `breaking` array is what a
|
|
208
|
-
* CI gate keys off; `hasBreakingChanges()` is the convenience predicate.
|
|
209
|
-
*/
|
|
210
|
-
export function diffSnapshots(
|
|
211
|
-
previous: ContractSnapshot,
|
|
212
|
-
next: ContractSnapshot,
|
|
213
|
-
): SnapshotDiff {
|
|
214
|
-
const diff: SnapshotDiff = {
|
|
215
|
-
service: next.service,
|
|
216
|
-
previousVersion: previous.version,
|
|
217
|
-
nextVersion: next.version,
|
|
218
|
-
breaking: [],
|
|
219
|
-
additive: [],
|
|
220
|
-
neutral: [],
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
diffAttributeMaps(diff, '*', previous.commonAttributes, next.commonAttributes);
|
|
224
|
-
|
|
225
|
-
for (const [name, prevSpan] of Object.entries(previous.spans)) {
|
|
226
|
-
const nextSpan = next.spans[name];
|
|
227
|
-
if (!nextSpan) {
|
|
228
|
-
push(diff, {
|
|
229
|
-
kind: 'breaking',
|
|
230
|
-
type: 'span_removed',
|
|
231
|
-
span: name,
|
|
232
|
-
message: `span "${name}" was removed`,
|
|
233
|
-
});
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
diffAttributeMaps(diff, name, prevSpan.attributes, nextSpan.attributes);
|
|
237
|
-
}
|
|
238
|
-
for (const name of Object.keys(next.spans)) {
|
|
239
|
-
if (!previous.spans[name]) {
|
|
240
|
-
push(diff, {
|
|
241
|
-
kind: 'additive',
|
|
242
|
-
type: 'span_added',
|
|
243
|
-
span: name,
|
|
244
|
-
message: `new span "${name}" added`,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return diff;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** `true` when the diff contains at least one breaking change. */
|
|
253
|
-
export function hasBreakingChanges(diff: SnapshotDiff): boolean {
|
|
254
|
-
return diff.breaking.length > 0;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Markdown rendering of a diff — for CI logs and PR comments. */
|
|
258
|
-
export function formatDiff(diff: SnapshotDiff): string {
|
|
259
|
-
const lines: string[] = [
|
|
260
|
-
`# Telemetry contract diff: ${diff.service} ${diff.previousVersion} → ${diff.nextVersion}`,
|
|
261
|
-
''];
|
|
262
|
-
const section = (title: string, changes: SnapshotChange[]) => {
|
|
263
|
-
if (changes.length === 0) return;
|
|
264
|
-
lines.push(`## ${title} (${changes.length})`, '');
|
|
265
|
-
for (const c of changes) {
|
|
266
|
-
const where = c.attribute ? `\`${c.span}.${c.attribute}\`` : `\`${c.span}\``;
|
|
267
|
-
lines.push(`- ${where}: ${c.message}`);
|
|
268
|
-
}
|
|
269
|
-
lines.push('');
|
|
270
|
-
};
|
|
271
|
-
section('💥 Breaking', diff.breaking);
|
|
272
|
-
section('➕ Additive', diff.additive);
|
|
273
|
-
section('• Neutral', diff.neutral);
|
|
274
|
-
if (
|
|
275
|
-
diff.breaking.length === 0 &&
|
|
276
|
-
diff.additive.length === 0 &&
|
|
277
|
-
diff.neutral.length === 0
|
|
278
|
-
) {
|
|
279
|
-
lines.push('No changes to the telemetry contract.', '');
|
|
280
|
-
}
|
|
281
|
-
return lines.join('\n');
|
|
282
|
-
}
|