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/src/diff.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autotel-schema — your telemetry surface as a typed, versioned contract.
|
|
3
|
+
*
|
|
4
|
+
* When the primary reader of your telemetry is an agent, your span names and
|
|
5
|
+
* attribute keys are a **public API**. `defineContract()` makes that surface
|
|
6
|
+
* explicit and versionable; `validateSpan` / `SchemaValidationSpanProcessor`
|
|
7
|
+
* check live spans against it; `diffSnapshots` / `hasBreakingChanges` catch
|
|
8
|
+
* breaking trace-surface changes before they ship; `highCardinalityKeys` feeds
|
|
9
|
+
* a redaction allow-list so the fields most useful to an agent reader survive.
|
|
10
|
+
*
|
|
11
|
+
* The contract model is dependency-free and side-effect-free — safe to import
|
|
12
|
+
* anywhere (browser, edge, CLI) without pulling in the OpenTelemetry SDK.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
SCHEMA_ATTRS,
|
|
17
|
+
SNAPSHOT_SPEC,
|
|
18
|
+
} from './attrs.js';
|
|
19
|
+
export type { SchemaAttributeKey } from './attrs.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
ATTRIBUTE_TYPES,
|
|
23
|
+
STABILITIES,
|
|
24
|
+
defineContract,
|
|
25
|
+
resolveAttributeSpec,
|
|
26
|
+
allowsAdditionalAttributes,
|
|
27
|
+
} from './contract.js';
|
|
28
|
+
export type {
|
|
29
|
+
AttributeType,
|
|
30
|
+
Stability,
|
|
31
|
+
AttributeSpec,
|
|
32
|
+
SpanSpec,
|
|
33
|
+
TelemetryContract,
|
|
34
|
+
} from './contract.js';
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
contractToSnapshot,
|
|
38
|
+
serializeSnapshot,
|
|
39
|
+
parseSnapshot,
|
|
40
|
+
} from './snapshot.js';
|
|
41
|
+
export type {
|
|
42
|
+
SnapshotAttribute,
|
|
43
|
+
SnapshotSpan,
|
|
44
|
+
ContractSnapshot,
|
|
45
|
+
} from './snapshot.js';
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
validateSpan,
|
|
49
|
+
hasErrors,
|
|
50
|
+
formatViolation,
|
|
51
|
+
} from './validate.js';
|
|
52
|
+
export type {
|
|
53
|
+
ViolationSeverity,
|
|
54
|
+
ViolationCode,
|
|
55
|
+
SchemaViolation,
|
|
56
|
+
SpanShape,
|
|
57
|
+
ValidateOptions,
|
|
58
|
+
} from './validate.js';
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
SchemaValidationSpanProcessor,
|
|
62
|
+
createSchemaValidationProcessor,
|
|
63
|
+
} from './processor.js';
|
|
64
|
+
export type {
|
|
65
|
+
ReadableSpanLike,
|
|
66
|
+
SpanLike,
|
|
67
|
+
OtelContext,
|
|
68
|
+
SpanProcessorLike,
|
|
69
|
+
SchemaProcessorMode,
|
|
70
|
+
SchemaValidationProcessorOptions,
|
|
71
|
+
} from './processor.js';
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
diffSnapshots,
|
|
75
|
+
hasBreakingChanges,
|
|
76
|
+
formatDiff,
|
|
77
|
+
} from './diff.js';
|
|
78
|
+
export type {
|
|
79
|
+
ChangeKind,
|
|
80
|
+
ChangeType,
|
|
81
|
+
SnapshotChange,
|
|
82
|
+
SnapshotDiff,
|
|
83
|
+
} from './diff.js';
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
highCardinalityKeys,
|
|
87
|
+
isHighCardinalityKey,
|
|
88
|
+
} from './redaction.js';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { defineContract, type TelemetryContract } from './contract.js';
|
|
4
|
+
import {
|
|
5
|
+
createSchemaValidationProcessor,
|
|
6
|
+
SchemaValidationSpanProcessor,
|
|
7
|
+
} from './processor.js';
|
|
8
|
+
import type { SchemaViolation } from './validate.js';
|
|
9
|
+
|
|
10
|
+
const contract: TelemetryContract = defineContract({
|
|
11
|
+
service: 'checkout',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
spans: {
|
|
14
|
+
'checkout.charge': {
|
|
15
|
+
attributes: { 'payment.amount_cents': { type: 'number', required: true } },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function endSpan(p: SchemaValidationSpanProcessor, name: string, attributes: Record<string, unknown>) {
|
|
21
|
+
p.onEnd({ name, attributes });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('SchemaValidationSpanProcessor', () => {
|
|
25
|
+
it('collects violations via onViolation in silent mode', () => {
|
|
26
|
+
const seen: SchemaViolation[] = [];
|
|
27
|
+
const p = createSchemaValidationProcessor({
|
|
28
|
+
contract,
|
|
29
|
+
mode: 'silent',
|
|
30
|
+
enabledInProduction: true,
|
|
31
|
+
onViolation: (v) => seen.push(v),
|
|
32
|
+
});
|
|
33
|
+
endSpan(p, 'checkout.charge', {}); // missing required
|
|
34
|
+
expect(seen).toHaveLength(1);
|
|
35
|
+
expect(seen[0].code).toBe('missing_required');
|
|
36
|
+
expect(p.totalViolations).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('throws on the first error in throw mode', () => {
|
|
40
|
+
const p = createSchemaValidationProcessor({ contract, mode: 'throw', enabledInProduction: true });
|
|
41
|
+
expect(() => endSpan(p, 'checkout.charge', {})).toThrowError(/contract violation/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not throw for a conformant span', () => {
|
|
45
|
+
const p = createSchemaValidationProcessor({ contract, mode: 'throw', enabledInProduction: true });
|
|
46
|
+
expect(() => endSpan(p, 'checkout.charge', { 'payment.amount_cents': 1 })).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('warns through the injected sink, deduplicated within the interval', () => {
|
|
50
|
+
const onWarn = vi.fn();
|
|
51
|
+
const p = createSchemaValidationProcessor({
|
|
52
|
+
contract,
|
|
53
|
+
mode: 'warn',
|
|
54
|
+
enabledInProduction: true,
|
|
55
|
+
onWarn,
|
|
56
|
+
warnIntervalMs: 60_000,
|
|
57
|
+
});
|
|
58
|
+
endSpan(p, 'checkout.charge', {});
|
|
59
|
+
endSpan(p, 'checkout.charge', {}); // identical violation → throttled
|
|
60
|
+
expect(onWarn).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('is disabled in production unless opted in', () => {
|
|
64
|
+
const prev = process.env.NODE_ENV;
|
|
65
|
+
process.env.NODE_ENV = 'production';
|
|
66
|
+
try {
|
|
67
|
+
const p = createSchemaValidationProcessor({ contract, mode: 'throw' });
|
|
68
|
+
expect(() => endSpan(p, 'checkout.charge', {})).not.toThrow();
|
|
69
|
+
expect(p.totalViolations).toBe(0);
|
|
70
|
+
} finally {
|
|
71
|
+
process.env.NODE_ENV = prev;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/processor.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime contract enforcement as an OpenTelemetry SpanProcessor.
|
|
3
|
+
*
|
|
4
|
+
* Wire it into `init({ spanProcessors: [...] })` and every span your service
|
|
5
|
+
* emits is validated against the contract as it ends. In development a typo'd
|
|
6
|
+
* or undeclared attribute surfaces immediately instead of silently drifting
|
|
7
|
+
* the public telemetry API out from under the agents reading it.
|
|
8
|
+
*
|
|
9
|
+
* Fail-open by construction: a bug in validation must never break the app or
|
|
10
|
+
* lose a span. Off in production by default (validation belongs in CI and dev),
|
|
11
|
+
* but `enabledInProduction` is there if you want a sampled canary in prod.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { validateSpan, type SchemaViolation, type ValidateOptions } from './validate.js';
|
|
15
|
+
import type { TelemetryContract } from './contract.js';
|
|
16
|
+
|
|
17
|
+
/** Minimal ReadableSpan shape — matches OTel without a hard SDK dependency. */
|
|
18
|
+
export interface ReadableSpanLike {
|
|
19
|
+
name: string;
|
|
20
|
+
attributes: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SpanLike {
|
|
24
|
+
spanContext(): { traceId: string; spanId: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Opaque parent context — matches OTel SpanProcessor without importing it. */
|
|
28
|
+
export type OtelContext = unknown;
|
|
29
|
+
|
|
30
|
+
export interface SpanProcessorLike {
|
|
31
|
+
onStart(span: SpanLike, parentContext: OtelContext): void;
|
|
32
|
+
onEnd(span: ReadableSpanLike): void;
|
|
33
|
+
shutdown(): Promise<void>;
|
|
34
|
+
forceFlush(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How the processor reacts to a contract violation. */
|
|
38
|
+
export type SchemaProcessorMode = 'warn' | 'throw' | 'silent';
|
|
39
|
+
|
|
40
|
+
export interface SchemaValidationProcessorOptions extends ValidateOptions {
|
|
41
|
+
contract: TelemetryContract;
|
|
42
|
+
/**
|
|
43
|
+
* `warn` (default): log each distinct violation once per interval.
|
|
44
|
+
* `throw`: throw on the first error-severity violation — for tests/CI only.
|
|
45
|
+
* `silent`: collect via `onViolation` without logging.
|
|
46
|
+
*/
|
|
47
|
+
mode?: SchemaProcessorMode;
|
|
48
|
+
/** Called for every violation, before mode handling. */
|
|
49
|
+
onViolation?: (violation: SchemaViolation, span: ReadableSpanLike) => void;
|
|
50
|
+
/** Override the warn sink (defaults to `console.warn`). */
|
|
51
|
+
onWarn?: (message: string) => void;
|
|
52
|
+
/** Run even when `NODE_ENV === 'production'`. Default `false`. */
|
|
53
|
+
enabledInProduction?: boolean;
|
|
54
|
+
/** Throttle window for repeated identical warnings (ms). Default 60s. */
|
|
55
|
+
warnIntervalMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_WARN_INTERVAL_MS = 60_000;
|
|
59
|
+
|
|
60
|
+
function isProduction(): boolean {
|
|
61
|
+
return process.env.NODE_ENV === 'production';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validates each ending span against a {@link TelemetryContract}. Bounded,
|
|
66
|
+
* deduplicated warnings; fail-open on any internal error.
|
|
67
|
+
*/
|
|
68
|
+
export class SchemaValidationSpanProcessor implements SpanProcessorLike {
|
|
69
|
+
private readonly opts: SchemaValidationProcessorOptions;
|
|
70
|
+
private readonly enabled: boolean;
|
|
71
|
+
private readonly warnIntervalMs: number;
|
|
72
|
+
private readonly lastWarnAt = new Map<string, number>();
|
|
73
|
+
private violationCount = 0;
|
|
74
|
+
|
|
75
|
+
constructor(opts: SchemaValidationProcessorOptions) {
|
|
76
|
+
this.opts = opts;
|
|
77
|
+
this.enabled = opts.enabledInProduction === true || !isProduction();
|
|
78
|
+
this.warnIntervalMs = opts.warnIntervalMs ?? DEFAULT_WARN_INTERVAL_MS;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Number of violations seen since startup (across all spans). */
|
|
82
|
+
get totalViolations(): number {
|
|
83
|
+
return this.violationCount;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onStart(_span: SpanLike, _parentContext: OtelContext): void {
|
|
87
|
+
// no-op — validation happens once the span is complete
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onEnd(span: ReadableSpanLike): void {
|
|
91
|
+
if (!this.enabled) return;
|
|
92
|
+
let violations: SchemaViolation[];
|
|
93
|
+
try {
|
|
94
|
+
// Validation itself is fail-open: a bug here must never break export.
|
|
95
|
+
violations = validateSpan(
|
|
96
|
+
{ name: span.name, attributes: span.attributes },
|
|
97
|
+
this.opts.contract,
|
|
98
|
+
{ strictSpanNames: this.opts.strictSpanNames },
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Mode handling runs outside the fail-open guard so `throw` mode propagates.
|
|
104
|
+
for (const violation of violations) {
|
|
105
|
+
this.violationCount++;
|
|
106
|
+
this.opts.onViolation?.(violation, span);
|
|
107
|
+
this.handle(violation);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private handle(violation: SchemaViolation): void {
|
|
112
|
+
const mode = this.opts.mode ?? 'warn';
|
|
113
|
+
if (mode === 'silent') return;
|
|
114
|
+
if (mode === 'throw' && violation.severity === 'error') {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`autotel-schema: contract violation (${violation.code}) on span "${violation.spanName}": ${violation.message}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
this.maybeWarn(violation);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private maybeWarn(violation: SchemaViolation): void {
|
|
123
|
+
const key = `${violation.code}:${violation.spanName}:${violation.attribute ?? ''}`;
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const last = this.lastWarnAt.get(key) ?? 0;
|
|
126
|
+
if (now - last < this.warnIntervalMs) return;
|
|
127
|
+
this.lastWarnAt.set(key, now);
|
|
128
|
+
const suffix = violation.suggestion
|
|
129
|
+
? ` (did you mean "${violation.suggestion}"?)`
|
|
130
|
+
: '';
|
|
131
|
+
const message = `autotel-schema [${violation.severity}] ${violation.code} on "${violation.spanName}"${violation.attribute ? `.${violation.attribute}` : ''}: ${violation.message}${suffix}`;
|
|
132
|
+
if (this.opts.onWarn) {
|
|
133
|
+
this.opts.onWarn(message);
|
|
134
|
+
} else {
|
|
135
|
+
console.warn(message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async forceFlush(): Promise<void> {
|
|
140
|
+
// nothing buffered — validation is synchronous in onEnd
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async shutdown(): Promise<void> {
|
|
144
|
+
this.lastWarnAt.clear();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createSchemaValidationProcessor(
|
|
149
|
+
opts: SchemaValidationProcessorOptions,
|
|
150
|
+
): SchemaValidationSpanProcessor {
|
|
151
|
+
return new SchemaValidationSpanProcessor(opts);
|
|
152
|
+
}
|
package/src/redaction.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cardinality posture helpers.
|
|
3
|
+
*
|
|
4
|
+
* The old cardinality rule — "keep unique-value counts down" — was a constraint
|
|
5
|
+
* invented because dashboards have pixels and a graph with 10k series is
|
|
6
|
+
* unreadable to a human. An agent does not look at the graph; it reads the
|
|
7
|
+
* spans. A high-cardinality field (the user id, the sender domain, the request
|
|
8
|
+
* id) is then the single most useful attribute on a trace when the agent is
|
|
9
|
+
* chasing one specific failure.
|
|
10
|
+
*
|
|
11
|
+
* So the contract lets you mark attributes `highCardinality: true` as a
|
|
12
|
+
* deliberate signal, and this module turns that into a *protect list*: the keys
|
|
13
|
+
* a redactor or span-name normalizer must NOT strip, even when an aggressive
|
|
14
|
+
* default would otherwise drop them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { TelemetryContract } from './contract.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every attribute key in the contract flagged `highCardinality: true`, across
|
|
21
|
+
* both common and per-span attributes. Feed this into a redaction/normalization
|
|
22
|
+
* allow-list so the fields most useful to an agent reader survive.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { init } from 'autotel';
|
|
27
|
+
* import { highCardinalityKeys } from 'autotel-schema';
|
|
28
|
+
* import { contract } from './telemetry.contract';
|
|
29
|
+
*
|
|
30
|
+
* init({
|
|
31
|
+
* service: 'checkout',
|
|
32
|
+
* // keep user.id / request.id intact even under the strict redactor
|
|
33
|
+
* attributeRedactor: { allowKeys: highCardinalityKeys(contract), preset: 'strict' },
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function highCardinalityKeys(contract: TelemetryContract): string[] {
|
|
38
|
+
const keys = new Set<string>();
|
|
39
|
+
for (const [key, spec] of Object.entries(contract.commonAttributes ?? {})) {
|
|
40
|
+
if (spec.highCardinality) keys.add(key);
|
|
41
|
+
}
|
|
42
|
+
for (const spanSpec of Object.values(contract.spans)) {
|
|
43
|
+
for (const [key, spec] of Object.entries(spanSpec.attributes ?? {})) {
|
|
44
|
+
if (spec.highCardinality) keys.add(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...keys].toSorted();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Predicate form of {@link highCardinalityKeys} — `true` when `key` is declared
|
|
52
|
+
* high-cardinality anywhere in the contract. Useful inside a custom
|
|
53
|
+
* `spanNameNormalizer` or redactor callback.
|
|
54
|
+
*/
|
|
55
|
+
export function isHighCardinalityKey(
|
|
56
|
+
contract: TelemetryContract,
|
|
57
|
+
key: string,
|
|
58
|
+
): boolean {
|
|
59
|
+
if (contract.commonAttributes?.[key]?.highCardinality) return true;
|
|
60
|
+
for (const spanSpec of Object.values(contract.spans)) {
|
|
61
|
+
if (spanSpec.attributes?.[key]?.highCardinality) return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|