autotel-schema 0.2.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/index.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
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';
|
package/src/processor.test.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
}
|
package/src/snapshot.test.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { defineContract, type TelemetryContract } from './contract.js';
|
|
4
|
-
import {
|
|
5
|
-
diffSnapshots,
|
|
6
|
-
formatDiff,
|
|
7
|
-
hasBreakingChanges,
|
|
8
|
-
} from './diff.js';
|
|
9
|
-
import { highCardinalityKeys, isHighCardinalityKey } from './redaction.js';
|
|
10
|
-
import {
|
|
11
|
-
contractToSnapshot,
|
|
12
|
-
parseSnapshot,
|
|
13
|
-
serializeSnapshot,
|
|
14
|
-
} from './snapshot.js';
|
|
15
|
-
|
|
16
|
-
const v1: TelemetryContract = defineContract({
|
|
17
|
-
service: 'checkout',
|
|
18
|
-
version: '1.0.0',
|
|
19
|
-
commonAttributes: { 'user.id': { type: 'string', highCardinality: true } },
|
|
20
|
-
spans: {
|
|
21
|
-
'checkout.charge': {
|
|
22
|
-
attributes: {
|
|
23
|
-
'payment.provider': { type: 'string', required: true },
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('snapshot round-trip', () => {
|
|
30
|
-
it('is deterministic regardless of key insertion order', () => {
|
|
31
|
-
const reordered = defineContract({
|
|
32
|
-
version: '1.0.0',
|
|
33
|
-
service: 'checkout',
|
|
34
|
-
spans: {
|
|
35
|
-
'checkout.charge': {
|
|
36
|
-
attributes: { 'payment.provider': { type: 'string', required: true } },
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
commonAttributes: { 'user.id': { type: 'string', highCardinality: true } },
|
|
40
|
-
});
|
|
41
|
-
expect(serializeSnapshot(contractToSnapshot(v1))).toBe(
|
|
42
|
-
serializeSnapshot(contractToSnapshot(reordered)),
|
|
43
|
-
);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('serializes and parses back', () => {
|
|
47
|
-
const snap = contractToSnapshot(v1);
|
|
48
|
-
expect(parseSnapshot(serializeSnapshot(snap))).toEqual(snap);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('rejects an unknown snapshot spec', () => {
|
|
52
|
-
expect(() => parseSnapshot('{"spec":"nope/v9","service":"x","version":"1.0.0"}')).toThrowError(
|
|
53
|
-
/unexpected snapshot spec/,
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('diffSnapshots', () => {
|
|
59
|
-
it('classifies a removed span as breaking', () => {
|
|
60
|
-
const v2 = defineContract({ ...v1, version: '2.0.0', spans: {} });
|
|
61
|
-
const diff = diffSnapshots(contractToSnapshot(v1), contractToSnapshot(v2));
|
|
62
|
-
expect(hasBreakingChanges(diff)).toBe(true);
|
|
63
|
-
expect(diff.breaking.some((c) => c.type === 'span_removed')).toBe(true);
|
|
64
|
-
expect(formatDiff(diff)).toMatch(/1\.0\.0 → 2\.0\.0/);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('classifies a new span as additive, not breaking', () => {
|
|
68
|
-
const v2 = defineContract({
|
|
69
|
-
...v1,
|
|
70
|
-
version: '1.1.0',
|
|
71
|
-
spans: {
|
|
72
|
-
...v1.spans,
|
|
73
|
-
'checkout.refund': { attributes: {} },
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
const diff = diffSnapshots(contractToSnapshot(v1), contractToSnapshot(v2));
|
|
77
|
-
expect(hasBreakingChanges(diff)).toBe(false);
|
|
78
|
-
expect(diff.additive.some((c) => c.type === 'span_added')).toBe(true);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe('redaction helpers', () => {
|
|
83
|
-
it('collects high-cardinality keys across common + span attributes', () => {
|
|
84
|
-
expect(highCardinalityKeys(v1)).toEqual(['user.id']);
|
|
85
|
-
expect(isHighCardinalityKey(v1, 'user.id')).toBe(true);
|
|
86
|
-
expect(isHighCardinalityKey(v1, 'payment.provider')).toBe(false);
|
|
87
|
-
});
|
|
88
|
-
});
|
package/src/snapshot.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Snapshots — the serializable form of a contract that gets committed and
|
|
3
|
-
* diffed across versions. Checking a snapshot into the repo turns "did this
|
|
4
|
-
* refactor rename a span?" into a reviewable line in a PR instead of a silent
|
|
5
|
-
* break the agent reader discovers at 3am.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { SNAPSHOT_SPEC } from './attrs.js';
|
|
9
|
-
import type {
|
|
10
|
-
AttributeSpec,
|
|
11
|
-
AttributeType,
|
|
12
|
-
Stability,
|
|
13
|
-
TelemetryContract,
|
|
14
|
-
} from './contract.js';
|
|
15
|
-
|
|
16
|
-
/** Flattened, fully-resolved attribute record in a snapshot. */
|
|
17
|
-
export interface SnapshotAttribute {
|
|
18
|
-
type: AttributeType;
|
|
19
|
-
stability: Stability;
|
|
20
|
-
required: boolean;
|
|
21
|
-
highCardinality: boolean;
|
|
22
|
-
enum?: readonly (string | number)[];
|
|
23
|
-
replacedBy?: string;
|
|
24
|
-
description?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface SnapshotSpan {
|
|
28
|
-
stability: Stability;
|
|
29
|
-
additionalAttributes: boolean;
|
|
30
|
-
description?: string;
|
|
31
|
-
attributes: Record<string, SnapshotAttribute>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Canonical, comparable representation of a {@link TelemetryContract}. */
|
|
35
|
-
export interface ContractSnapshot {
|
|
36
|
-
spec: typeof SNAPSHOT_SPEC;
|
|
37
|
-
service: string;
|
|
38
|
-
version: string;
|
|
39
|
-
commonAttributes: Record<string, SnapshotAttribute>;
|
|
40
|
-
spans: Record<string, SnapshotSpan>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function normalizeAttribute(spec: AttributeSpec): SnapshotAttribute {
|
|
44
|
-
const out: SnapshotAttribute = {
|
|
45
|
-
type: spec.type,
|
|
46
|
-
stability: spec.stability ?? 'stable',
|
|
47
|
-
required: spec.required ?? false,
|
|
48
|
-
highCardinality: spec.highCardinality ?? false,
|
|
49
|
-
};
|
|
50
|
-
if (spec.enum) out.enum = [...spec.enum];
|
|
51
|
-
if (spec.replacedBy) out.replacedBy = spec.replacedBy;
|
|
52
|
-
if (spec.description) out.description = spec.description;
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function sortRecord<T>(record: Record<string, T>): Record<string, T> {
|
|
57
|
-
const out: Record<string, T> = {};
|
|
58
|
-
for (const key of Object.keys(record).toSorted()) {
|
|
59
|
-
out[key] = record[key];
|
|
60
|
-
}
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Produce a deterministic, JSON-serializable snapshot from a contract. Keys are
|
|
66
|
-
* sorted so two snapshots of the same logical contract are byte-identical —
|
|
67
|
-
* important for clean `git diff`s and stable CI comparisons.
|
|
68
|
-
*/
|
|
69
|
-
export function contractToSnapshot(
|
|
70
|
-
contract: TelemetryContract,
|
|
71
|
-
): ContractSnapshot {
|
|
72
|
-
const commonAttributes: Record<string, SnapshotAttribute> = {};
|
|
73
|
-
for (const [key, spec] of Object.entries(contract.commonAttributes ?? {})) {
|
|
74
|
-
commonAttributes[key] = normalizeAttribute(spec);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const spans: Record<string, SnapshotSpan> = {};
|
|
78
|
-
for (const [name, spanSpec] of Object.entries(contract.spans)) {
|
|
79
|
-
const attributes: Record<string, SnapshotAttribute> = {};
|
|
80
|
-
for (const [key, spec] of Object.entries(spanSpec.attributes ?? {})) {
|
|
81
|
-
attributes[key] = normalizeAttribute(spec);
|
|
82
|
-
}
|
|
83
|
-
const span: SnapshotSpan = {
|
|
84
|
-
stability: spanSpec.stability ?? 'stable',
|
|
85
|
-
additionalAttributes:
|
|
86
|
-
spanSpec.additionalAttributes ?? contract.additionalAttributes ?? false,
|
|
87
|
-
attributes: sortRecord(attributes),
|
|
88
|
-
};
|
|
89
|
-
if (spanSpec.description) span.description = spanSpec.description;
|
|
90
|
-
spans[name] = span;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
spec: SNAPSHOT_SPEC,
|
|
95
|
-
service: contract.service,
|
|
96
|
-
version: contract.version,
|
|
97
|
-
commonAttributes: sortRecord(commonAttributes),
|
|
98
|
-
spans: sortRecord(spans),
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Pretty, deterministic JSON for writing a snapshot to disk. */
|
|
103
|
-
export function serializeSnapshot(snapshot: ContractSnapshot): string {
|
|
104
|
-
return JSON.stringify(snapshot, null, 2) + '\n';
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Parse and structurally validate a snapshot read from disk. */
|
|
108
|
-
export function parseSnapshot(json: string): ContractSnapshot {
|
|
109
|
-
const data = JSON.parse(json) as ContractSnapshot;
|
|
110
|
-
if (data.spec !== SNAPSHOT_SPEC) {
|
|
111
|
-
throw new Error(
|
|
112
|
-
`autotel-schema: unexpected snapshot spec "${data.spec}" (expected "${SNAPSHOT_SPEC}")`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
if (typeof data.service !== 'string' || typeof data.version !== 'string') {
|
|
116
|
-
throw new Error('autotel-schema: snapshot is missing service/version');
|
|
117
|
-
}
|
|
118
|
-
return data;
|
|
119
|
-
}
|
package/src/validate.test.ts
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { defineContract, type TelemetryContract } from './contract.js';
|
|
4
|
-
import {
|
|
5
|
-
formatViolation,
|
|
6
|
-
hasErrors,
|
|
7
|
-
validateSpan,
|
|
8
|
-
} from './validate.js';
|
|
9
|
-
|
|
10
|
-
const contract: TelemetryContract = defineContract({
|
|
11
|
-
service: 'checkout',
|
|
12
|
-
version: '1.0.0',
|
|
13
|
-
commonAttributes: { 'user.id': { type: 'string' } },
|
|
14
|
-
spans: {
|
|
15
|
-
'checkout.charge': {
|
|
16
|
-
attributes: {
|
|
17
|
-
'payment.provider': { type: 'string', required: true, enum: ['stripe', 'paypal'] },
|
|
18
|
-
'payment.amount_cents': { type: 'number', required: true },
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe('validateSpan', () => {
|
|
25
|
-
it('passes a fully-conformant span', () => {
|
|
26
|
-
const v = validateSpan(
|
|
27
|
-
{
|
|
28
|
-
name: 'checkout.charge',
|
|
29
|
-
attributes: { 'payment.provider': 'stripe', 'payment.amount_cents': 999 },
|
|
30
|
-
},
|
|
31
|
-
contract,
|
|
32
|
-
);
|
|
33
|
-
expect(v).toEqual([]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('flags a missing required attribute as an error', () => {
|
|
37
|
-
const v = validateSpan(
|
|
38
|
-
{ name: 'checkout.charge', attributes: { 'payment.provider': 'stripe' } },
|
|
39
|
-
contract,
|
|
40
|
-
);
|
|
41
|
-
expect(v).toHaveLength(1);
|
|
42
|
-
expect(v[0]).toMatchObject({ code: 'missing_required', attribute: 'payment.amount_cents' });
|
|
43
|
-
expect(hasErrors(v)).toBe(true);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('flags a wrong type', () => {
|
|
47
|
-
const v = validateSpan(
|
|
48
|
-
{
|
|
49
|
-
name: 'checkout.charge',
|
|
50
|
-
attributes: { 'payment.provider': 'stripe', 'payment.amount_cents': '999' },
|
|
51
|
-
},
|
|
52
|
-
contract,
|
|
53
|
-
);
|
|
54
|
-
expect(v.some((x) => x.code === 'type_mismatch')).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('flags an enum violation', () => {
|
|
58
|
-
const v = validateSpan(
|
|
59
|
-
{
|
|
60
|
-
name: 'checkout.charge',
|
|
61
|
-
attributes: { 'payment.provider': 'bitcoin', 'payment.amount_cents': 1 },
|
|
62
|
-
},
|
|
63
|
-
contract,
|
|
64
|
-
);
|
|
65
|
-
expect(v.some((x) => x.code === 'enum_violation')).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('warns on an undeclared attribute and suggests a near key', () => {
|
|
69
|
-
const v = validateSpan(
|
|
70
|
-
{
|
|
71
|
-
name: 'checkout.charge',
|
|
72
|
-
attributes: {
|
|
73
|
-
'payment.provider': 'stripe',
|
|
74
|
-
'payment.amount_cents': 1,
|
|
75
|
-
'payment.providr': 'x', // typo of a declared key
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
contract,
|
|
79
|
-
);
|
|
80
|
-
const unknown = v.find((x) => x.code === 'unknown_attribute');
|
|
81
|
-
expect(unknown?.severity).toBe('warning');
|
|
82
|
-
expect(unknown?.suggestion).toBe('payment.provider');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('ignores unknown spans unless strictSpanNames is set', () => {
|
|
86
|
-
expect(validateSpan({ name: 'mystery', attributes: {} }, contract)).toEqual([]);
|
|
87
|
-
const strict = validateSpan({ name: 'mystery', attributes: {} }, contract, {
|
|
88
|
-
strictSpanNames: true,
|
|
89
|
-
});
|
|
90
|
-
expect(strict[0]?.code).toBe('unknown_span');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('formats a violation legibly', () => {
|
|
94
|
-
const v = validateSpan(
|
|
95
|
-
{ name: 'checkout.charge', attributes: { 'payment.provider': 'stripe' } },
|
|
96
|
-
contract,
|
|
97
|
-
);
|
|
98
|
-
expect(formatViolation(v[0])).toMatch(/\[error\] missing_required @ checkout.charge.payment.amount_cents/);
|
|
99
|
-
});
|
|
100
|
-
});
|