autotel-schema 2.0.4 → 3.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/LICENSE +191 -21
- package/README.md +62 -3
- package/dist/contract-Ymh_Q37N.d.cts +305 -0
- package/dist/contract-Ymh_Q37N.d.cts.map +1 -0
- package/dist/contract-Ymh_Q37N.d.ts +305 -0
- package/dist/contract-Ymh_Q37N.d.ts.map +1 -0
- package/dist/{diff-D7qkNn0-.d.ts → diff-B1DoDhUn.d.ts} +2 -2
- package/dist/{diff-D7qkNn0-.d.ts.map → diff-B1DoDhUn.d.ts.map} +1 -1
- package/dist/{diff-BQPh72vY.d.cts → diff-Cjs6OPFN.d.cts} +2 -2
- package/dist/{diff-BQPh72vY.d.cts.map → diff-Cjs6OPFN.d.cts.map} +1 -1
- package/dist/diff.d.cts +1 -1
- package/dist/diff.d.ts +1 -1
- package/dist/index.cjs +8 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/processor-BPp_DewZ.js +635 -0
- package/dist/processor-BPp_DewZ.js.map +1 -0
- package/dist/{processor-CkBkzK6y.d.cts → processor-BsO4WD73.d.cts} +2 -2
- package/dist/{processor-CkBkzK6y.d.cts.map → processor-BsO4WD73.d.cts.map} +1 -1
- package/dist/{processor-CK7LAdaa.d.ts → processor-CIEfOOYi.d.ts} +2 -2
- package/dist/{processor-CK7LAdaa.d.ts.map → processor-CIEfOOYi.d.ts.map} +1 -1
- package/dist/processor-Gu8Yl_dS.cjs +737 -0
- package/dist/processor-Gu8Yl_dS.cjs.map +1 -0
- package/dist/processor.cjs +1 -1
- package/dist/processor.d.cts +1 -1
- package/dist/processor.d.ts +1 -1
- package/dist/processor.js +1 -1
- package/package.json +3 -3
- package/dist/contract-DGjxR9nb.d.cts +0 -123
- package/dist/contract-DGjxR9nb.d.cts.map +0 -1
- package/dist/contract-DGjxR9nb.d.ts +0 -123
- package/dist/contract-DGjxR9nb.d.ts.map +0 -1
- package/dist/processor-D93TAXvZ.cjs +0 -366
- package/dist/processor-D93TAXvZ.cjs.map +0 -1
- package/dist/processor-FmvKYllX.js +0 -306
- package/dist/processor-FmvKYllX.js.map +0 -1
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
//#region src/scenario.ts
|
|
2
|
+
/**
|
|
3
|
+
* Parse a cardinality shorthand: `'exactly 1'`, `'at least 1'`, `'at most 3'`,
|
|
4
|
+
* `'0..1'`, `'2..'`. A canonical {@link Cardinality} passes through.
|
|
5
|
+
*/
|
|
6
|
+
function parseCardinality(input) {
|
|
7
|
+
if (typeof input !== "string") {
|
|
8
|
+
assert$1(Number.isInteger(input.min) && input.min >= 0, `cardinality min must be a non-negative integer, got ${input.min}`);
|
|
9
|
+
assert$1(input.max === void 0 || Number.isInteger(input.max) && input.max >= input.min, `cardinality max must be >= min, got ${input.max}`);
|
|
10
|
+
return input;
|
|
11
|
+
}
|
|
12
|
+
const exact = /^exactly (\d+)$/.exec(input);
|
|
13
|
+
if (exact) return {
|
|
14
|
+
min: Number(exact[1]),
|
|
15
|
+
max: Number(exact[1])
|
|
16
|
+
};
|
|
17
|
+
const atLeast = /^at least (\d+)$/.exec(input);
|
|
18
|
+
if (atLeast) return { min: Number(atLeast[1]) };
|
|
19
|
+
const atMost = /^at most (\d+)$/.exec(input);
|
|
20
|
+
if (atMost) return {
|
|
21
|
+
min: 0,
|
|
22
|
+
max: Number(atMost[1])
|
|
23
|
+
};
|
|
24
|
+
const range = /^(\d+)\.\.(\d*)$/.exec(input);
|
|
25
|
+
if (range) {
|
|
26
|
+
const min = Number(range[1]);
|
|
27
|
+
const max = range[2] === "" ? void 0 : Number(range[2]);
|
|
28
|
+
assert$1(max === void 0 || max >= min, `cardinality "${input}" has max < min`);
|
|
29
|
+
return {
|
|
30
|
+
min,
|
|
31
|
+
max
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`autotel-schema: unparseable cardinality "${input}" (expected "exactly N", "at least N", "at most N", or "N..M")`);
|
|
35
|
+
}
|
|
36
|
+
function assert$1(condition, message) {
|
|
37
|
+
if (!condition) throw new Error(`autotel-schema: ${message}`);
|
|
38
|
+
}
|
|
39
|
+
const COMPLETION_MODES = [
|
|
40
|
+
"root-span-closed",
|
|
41
|
+
"terminal-event",
|
|
42
|
+
"externally-reconciled"
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* Structural validation for one scenario declaration. Called by
|
|
46
|
+
* `defineContract()` so a malformed scenario throws at module load.
|
|
47
|
+
*/
|
|
48
|
+
function validateScenarioSpec(name, spec) {
|
|
49
|
+
const scope = `scenario "${name}"`;
|
|
50
|
+
assert$1(spec.completion && typeof spec.completion === "object", `${scope} must declare a completion boundary`);
|
|
51
|
+
assert$1(COMPLETION_MODES.includes(spec.completion.mode), `${scope} has invalid completion mode "${spec.completion.mode}"`);
|
|
52
|
+
const budget = spec.completion.mode === "externally-reconciled" ? spec.completion.reconciliationDeadlineMs : spec.completion.observationBudgetMs;
|
|
53
|
+
assert$1(typeof budget === "number" && Number.isFinite(budget) && budget > 0, `${scope} completion budget must be a positive number of milliseconds`);
|
|
54
|
+
switch (spec.completion.mode) {
|
|
55
|
+
case "terminal-event":
|
|
56
|
+
assert$1(typeof spec.completion.event === "string" && spec.completion.event.length > 0, `${scope} terminal-event completion must declare a non-empty event`);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
assert$1(spec.events && typeof spec.events === "object" && Object.keys(spec.events).length > 0, `${scope} must declare at least one event`);
|
|
60
|
+
for (const [event, eventSpec] of Object.entries(spec.events)) {
|
|
61
|
+
if (eventSpec.cardinality !== void 0) try {
|
|
62
|
+
parseCardinality(eventSpec.cardinality);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error(`autotel-schema: ${scope} event "${event}": ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
if (eventSpec.status !== void 0) assert$1(eventSpec.status === "ok" || eventSpec.status === "error", `${scope} event "${event}" has invalid status "${eventSpec.status}"`);
|
|
67
|
+
}
|
|
68
|
+
for (const [from, to] of [...spec.edges ?? [], ...spec.optionalEdges ?? []]) assert$1(from in spec.events && to in spec.events, `${scope} edge ["${from}", "${to}"] references an undeclared event — declare both endpoints in events`);
|
|
69
|
+
}
|
|
70
|
+
/** Whether the scenario's completion boundary has closed for these spans. */
|
|
71
|
+
function isScenarioClosed(spec, spans) {
|
|
72
|
+
const { completion } = spec;
|
|
73
|
+
switch (completion.mode) {
|
|
74
|
+
case "externally-reconciled": return false;
|
|
75
|
+
case "root-span-closed": return spans.some((s) => !s.parentSpanId);
|
|
76
|
+
case "terminal-event": return spans.some((s) => s.name === completion.event);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** True when `span` has an ancestor named `ancestorName` within `byId`. */
|
|
80
|
+
function hasAncestorNamed(span, ancestorName, byId) {
|
|
81
|
+
const seen = /* @__PURE__ */ new Set();
|
|
82
|
+
let parentId = span.parentSpanId;
|
|
83
|
+
while (parentId && !seen.has(parentId)) {
|
|
84
|
+
seen.add(parentId);
|
|
85
|
+
const parent = byId.get(parentId);
|
|
86
|
+
if (!parent) return false;
|
|
87
|
+
if (parent.name === ancestorName) return true;
|
|
88
|
+
parentId = parent.parentSpanId;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Pure three-state evaluation of collected spans against a scenario.
|
|
94
|
+
*
|
|
95
|
+
* Definitive at any time (no closure needed): an unexpected error status, a
|
|
96
|
+
* `max` cardinality exceeded. Meaningful only after closure: a missing event,
|
|
97
|
+
* a `min` cardinality not reached, a missing required edge.
|
|
98
|
+
*/
|
|
99
|
+
function evaluateScenario(spec, spans, options) {
|
|
100
|
+
const name = options?.name ?? "scenario";
|
|
101
|
+
const closed = options?.closed ?? isScenarioClosed(spec, spans);
|
|
102
|
+
const violations = [];
|
|
103
|
+
const additions = [];
|
|
104
|
+
const countByName = /* @__PURE__ */ new Map();
|
|
105
|
+
for (const span of spans) countByName.set(span.name, (countByName.get(span.name) ?? 0) + 1);
|
|
106
|
+
for (const span of spans) if (span.status === "error" && spec.events[span.name]?.status !== "error") violations.push({
|
|
107
|
+
code: "unexpected_error",
|
|
108
|
+
event: span.name,
|
|
109
|
+
message: `"${span.name}" ended with status error, which the scenario does not declare`
|
|
110
|
+
});
|
|
111
|
+
for (const [event, eventSpec] of Object.entries(spec.events)) {
|
|
112
|
+
const { min, max } = parseCardinality(eventSpec.cardinality ?? { min: 1 });
|
|
113
|
+
const count = countByName.get(event) ?? 0;
|
|
114
|
+
if (max !== void 0 && count > max) violations.push({
|
|
115
|
+
code: "cardinality_violation",
|
|
116
|
+
event,
|
|
117
|
+
message: `"${event}" observed ${count}×, contract allows at most ${max}`
|
|
118
|
+
});
|
|
119
|
+
if (closed && count < min) violations.push({
|
|
120
|
+
code: count === 0 ? "missing_event" : "cardinality_violation",
|
|
121
|
+
event,
|
|
122
|
+
message: count === 0 ? `"${event}" was not observed and the completion boundary has closed` : `"${event}" observed ${count}×, contract requires at least ${min}`
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (closed) {
|
|
126
|
+
const byId = /* @__PURE__ */ new Map();
|
|
127
|
+
for (const span of spans) byId.set(span.spanId, span);
|
|
128
|
+
for (const [from, to] of spec.edges ?? []) if (!spans.some((s) => s.name === to && hasAncestorNamed(s, from, byId))) violations.push({
|
|
129
|
+
code: "missing_edge",
|
|
130
|
+
edge: [from, to],
|
|
131
|
+
message: `no "${to}" span has ancestor "${from}"`
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
for (const [event, count] of [...countByName.entries()].toSorted()) if (!(event in spec.events)) additions.push({
|
|
135
|
+
code: "undeclared_event",
|
|
136
|
+
event,
|
|
137
|
+
count,
|
|
138
|
+
message: `"${event}" observed ${count}× but not declared — additive, consider adding it to the scenario`
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
scenario: name,
|
|
142
|
+
outcome: violations.length > 0 ? "non-conformant" : closed ? "conformant" : "incomplete",
|
|
143
|
+
closed,
|
|
144
|
+
violations,
|
|
145
|
+
additions,
|
|
146
|
+
spans: [...spans]
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Poll `getSpans` until the scenario's completion boundary closes, a
|
|
151
|
+
* definitive violation appears (fail fast), or the observation budget is
|
|
152
|
+
* spent — then evaluate.
|
|
153
|
+
*
|
|
154
|
+
* The observation budget bounds how long *this checker* waits; it is not a
|
|
155
|
+
* statement that the operation is allowed to take that long. Express a
|
|
156
|
+
* business deadline as its own assertion on the returned spans.
|
|
157
|
+
*
|
|
158
|
+
* An `externally-reconciled` boundary never closes in-process: the check
|
|
159
|
+
* evaluates the current snapshot once and reports `incomplete` unless a
|
|
160
|
+
* definitive violation is already present.
|
|
161
|
+
*/
|
|
162
|
+
async function checkScenario(spec, getSpans, options) {
|
|
163
|
+
const name = options?.name;
|
|
164
|
+
if (spec.completion.mode === "externally-reconciled") return evaluateScenario(spec, await getSpans(), { name });
|
|
165
|
+
const budgetMs = options?.budgetMs ?? spec.completion.observationBudgetMs;
|
|
166
|
+
const pollIntervalMs = options?.pollIntervalMs ?? 25;
|
|
167
|
+
assert$1(Number.isFinite(budgetMs) && budgetMs > 0, "checkScenario budgetMs must be a positive number of milliseconds");
|
|
168
|
+
assert$1(Number.isFinite(pollIntervalMs) && pollIntervalMs >= 0, "checkScenario pollIntervalMs must be a non-negative number of milliseconds");
|
|
169
|
+
const deadline = Date.now() + budgetMs;
|
|
170
|
+
let lastResult = evaluateScenario(spec, [], { name });
|
|
171
|
+
for (;;) {
|
|
172
|
+
const remainingMs = deadline - Date.now();
|
|
173
|
+
if (remainingMs <= 0) return lastResult;
|
|
174
|
+
let timeout;
|
|
175
|
+
let next;
|
|
176
|
+
try {
|
|
177
|
+
next = await Promise.race([Promise.resolve().then(getSpans).then((spans) => ({
|
|
178
|
+
kind: "spans",
|
|
179
|
+
spans
|
|
180
|
+
})), new Promise((resolve) => {
|
|
181
|
+
timeout = setTimeout(() => resolve({ kind: "timeout" }), remainingMs);
|
|
182
|
+
})]);
|
|
183
|
+
} finally {
|
|
184
|
+
if (timeout !== void 0) clearTimeout(timeout);
|
|
185
|
+
}
|
|
186
|
+
if (next.kind === "timeout") return lastResult;
|
|
187
|
+
const { spans } = next;
|
|
188
|
+
const result = evaluateScenario(spec, spans, { name });
|
|
189
|
+
lastResult = result;
|
|
190
|
+
if (result.closed || result.outcome === "non-conformant") return result;
|
|
191
|
+
const waitMs = Math.min(pollIntervalMs, deadline - Date.now());
|
|
192
|
+
if (waitMs <= 0) return result;
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Human-readable summary of a scenario result, for assertion messages. */
|
|
197
|
+
function formatScenarioResult(result) {
|
|
198
|
+
const lines = [`scenario "${result.scenario}": ${result.outcome}${result.closed ? "" : " (completion boundary did not close)"}`];
|
|
199
|
+
for (const v of result.violations) lines.push(` ✗ ${v.message}`);
|
|
200
|
+
for (const a of result.additions) lines.push(` + ${a.message}`);
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Draft a scenario contract from repeated controlled runs (record → propose →
|
|
205
|
+
* commit). Events stable across every run become required with their observed
|
|
206
|
+
* cardinality; variable ones get a range and a review note. The draft is a
|
|
207
|
+
* starting point for human curation, not a finished contract.
|
|
208
|
+
*/
|
|
209
|
+
function proposeScenario(runs, options) {
|
|
210
|
+
assert$1(runs.length > 0, "proposeScenario needs at least one recorded run");
|
|
211
|
+
for (const [index, run] of runs.entries()) assert$1(run.length > 0, `proposeScenario run ${index + 1} has no recorded spans`);
|
|
212
|
+
const name = options?.name ?? "scenario";
|
|
213
|
+
const notes = [];
|
|
214
|
+
const total = runs.length;
|
|
215
|
+
const names = /* @__PURE__ */ new Set();
|
|
216
|
+
const countsPerRun = runs.map((run) => {
|
|
217
|
+
const counts = /* @__PURE__ */ new Map();
|
|
218
|
+
for (const span of run) {
|
|
219
|
+
names.add(span.name);
|
|
220
|
+
counts.set(span.name, (counts.get(span.name) ?? 0) + 1);
|
|
221
|
+
}
|
|
222
|
+
return counts;
|
|
223
|
+
});
|
|
224
|
+
const events = {};
|
|
225
|
+
for (const event of [...names].toSorted()) {
|
|
226
|
+
const counts = countsPerRun.map((c) => c.get(event) ?? 0);
|
|
227
|
+
const min = Math.min(...counts);
|
|
228
|
+
const max = Math.max(...counts);
|
|
229
|
+
const seenIn = counts.filter((c) => c > 0).length;
|
|
230
|
+
events[event] = { cardinality: min === max ? `exactly ${min}` : {
|
|
231
|
+
min,
|
|
232
|
+
max
|
|
233
|
+
} };
|
|
234
|
+
notes.push(min === max ? `${event}: exactly ${min} (${seenIn}/${total} runs)` : `${event}: ${min}..${max} (${seenIn}/${total} runs) — review: variable`);
|
|
235
|
+
if (runs.some((run) => run.some((s) => s.name === event && s.status === "error"))) notes.push(`${event}: observed with status error — review: expected?`);
|
|
236
|
+
}
|
|
237
|
+
const edgeRuns = /* @__PURE__ */ new Map();
|
|
238
|
+
for (const run of runs) {
|
|
239
|
+
const byId = new Map(run.map((s) => [s.spanId, s]));
|
|
240
|
+
const pairs = /* @__PURE__ */ new Set();
|
|
241
|
+
for (const span of run) {
|
|
242
|
+
const parent = span.parentSpanId ? byId.get(span.parentSpanId) : void 0;
|
|
243
|
+
if (parent) pairs.add(`${parent.name}${span.name}`);
|
|
244
|
+
}
|
|
245
|
+
for (const pair of pairs) edgeRuns.set(pair, (edgeRuns.get(pair) ?? 0) + 1);
|
|
246
|
+
}
|
|
247
|
+
const edges = [];
|
|
248
|
+
const optionalEdges = [];
|
|
249
|
+
for (const [pair, seen] of [...edgeRuns.entries()].toSorted()) {
|
|
250
|
+
const [from, to] = pair.split("\0");
|
|
251
|
+
if (seen === total) edges.push([from, to]);
|
|
252
|
+
else {
|
|
253
|
+
optionalEdges.push([from, to]);
|
|
254
|
+
notes.push(`edge ${from} → ${to}: ${seen}/${total} runs — proposed optional`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
let completion;
|
|
258
|
+
const lastNames = /* @__PURE__ */ new Set();
|
|
259
|
+
let maxMakespanMs = 0;
|
|
260
|
+
let hasTiming = true;
|
|
261
|
+
for (const run of runs) {
|
|
262
|
+
let last;
|
|
263
|
+
let start = Number.POSITIVE_INFINITY;
|
|
264
|
+
let end = 0;
|
|
265
|
+
for (const span of run) {
|
|
266
|
+
if (span.startTimeMs === void 0 || span.durationMs === void 0) {
|
|
267
|
+
hasTiming = false;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
const spanEnd = span.startTimeMs + span.durationMs;
|
|
271
|
+
start = Math.min(start, span.startTimeMs);
|
|
272
|
+
if (spanEnd >= end) {
|
|
273
|
+
end = spanEnd;
|
|
274
|
+
last = span;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!hasTiming) break;
|
|
278
|
+
if (last) lastNames.add(last.name);
|
|
279
|
+
maxMakespanMs = Math.max(maxMakespanMs, end - start);
|
|
280
|
+
}
|
|
281
|
+
const observationBudgetMs = hasTiming ? Math.max(1e3, Math.ceil(maxMakespanMs * 3)) : 3e4;
|
|
282
|
+
if (hasTiming && lastNames.size === 1) {
|
|
283
|
+
const [event] = [...lastNames];
|
|
284
|
+
completion = {
|
|
285
|
+
mode: "terminal-event",
|
|
286
|
+
event,
|
|
287
|
+
observationBudgetMs
|
|
288
|
+
};
|
|
289
|
+
notes.push(`completion: terminal-event "${event}" (last to end in all ${total} runs), budget ${observationBudgetMs}ms (3× slowest observed run)`);
|
|
290
|
+
} else {
|
|
291
|
+
completion = {
|
|
292
|
+
mode: "root-span-closed",
|
|
293
|
+
observationBudgetMs
|
|
294
|
+
};
|
|
295
|
+
notes.push(hasTiming ? `completion: root-span-closed (last-ending span varies: ${[...lastNames].toSorted().join(", ")}), budget ${observationBudgetMs}ms` : `completion: root-span-closed, budget ${observationBudgetMs}ms (default — no timing data recorded)`);
|
|
296
|
+
}
|
|
297
|
+
const scenario = {
|
|
298
|
+
description: `Proposed from ${total} recorded run${total === 1 ? "" : "s"} of ${name} — review before committing`,
|
|
299
|
+
completion,
|
|
300
|
+
events,
|
|
301
|
+
...edges.length > 0 ? { edges } : {},
|
|
302
|
+
...optionalEdges.length > 0 ? { optionalEdges } : {}
|
|
303
|
+
};
|
|
304
|
+
validateScenarioSpec(name, scenario);
|
|
305
|
+
return {
|
|
306
|
+
scenario,
|
|
307
|
+
notes
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/contract.ts
|
|
313
|
+
/**
|
|
314
|
+
* Telemetry contract model.
|
|
315
|
+
*
|
|
316
|
+
* The premise: when the primary reader of your telemetry is an agent, your
|
|
317
|
+
* span names and attribute keys are a **public API**. Renaming `fast_path_hit`
|
|
318
|
+
* to `fast_path_taken` in a refactor PR silently breaks every prompt that
|
|
319
|
+
* mentions it — there is no compiler to catch it, because to the compiler these
|
|
320
|
+
* are just strings in a JSON blob.
|
|
321
|
+
*
|
|
322
|
+
* `defineContract()` makes that surface explicit, typed, and versionable: you
|
|
323
|
+
* declare which spans your service emits and which attributes live on them,
|
|
324
|
+
* then validate live spans against it ({@link ./validate}) and diff it across
|
|
325
|
+
* commits to catch breaking changes before they ship ({@link ./diff}).
|
|
326
|
+
*
|
|
327
|
+
* This module is dependency-free and side-effect-free by design — safe to
|
|
328
|
+
* import anywhere (browser, edge, CLI) without pulling in the OpenTelemetry SDK.
|
|
329
|
+
*/
|
|
330
|
+
const ATTRIBUTE_TYPES = [
|
|
331
|
+
"string",
|
|
332
|
+
"number",
|
|
333
|
+
"boolean",
|
|
334
|
+
"string[]",
|
|
335
|
+
"number[]",
|
|
336
|
+
"boolean[]"
|
|
337
|
+
];
|
|
338
|
+
const STABILITIES = [
|
|
339
|
+
"stable",
|
|
340
|
+
"experimental",
|
|
341
|
+
"deprecated"
|
|
342
|
+
];
|
|
343
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
|
|
344
|
+
function assert(condition, message) {
|
|
345
|
+
if (!condition) throw new Error(`autotel-schema: ${message}`);
|
|
346
|
+
}
|
|
347
|
+
function validateAttribute(scope, key, spec) {
|
|
348
|
+
assert(ATTRIBUTE_TYPES.includes(spec.type), `${scope} attribute "${key}" has invalid type "${spec.type}"`);
|
|
349
|
+
if (spec.stability) assert(STABILITIES.includes(spec.stability), `${scope} attribute "${key}" has invalid stability "${spec.stability}"`);
|
|
350
|
+
if (spec.stability === "deprecated") assert(spec.replacedBy !== void 0 || spec.deprecatedReason !== void 0, `${scope} attribute "${key}" is deprecated but has no replacedBy or deprecatedReason`);
|
|
351
|
+
if (spec.enum) assert(spec.enum.length > 0, `${scope} attribute "${key}" declares an empty enum`);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Validate and freeze a telemetry contract. Throws on structural mistakes
|
|
355
|
+
* (bad semver, unknown attribute type, deprecation with no replacement) so the
|
|
356
|
+
* contract fails loudly at module load, not silently at runtime.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* ```ts
|
|
360
|
+
* export const contract = defineContract({
|
|
361
|
+
* service: 'checkout',
|
|
362
|
+
* version: '1.2.0',
|
|
363
|
+
* commonAttributes: {
|
|
364
|
+
* 'user.id': { type: 'string', highCardinality: true, description: 'Authenticated user' },
|
|
365
|
+
* },
|
|
366
|
+
* spans: {
|
|
367
|
+
* 'checkout.charge': {
|
|
368
|
+
* description: 'Charge a payment method',
|
|
369
|
+
* attributes: {
|
|
370
|
+
* 'payment.provider': { type: 'string', required: true, enum: ['stripe', 'paypal'] },
|
|
371
|
+
* 'payment.amount_cents': { type: 'number', required: true },
|
|
372
|
+
* },
|
|
373
|
+
* },
|
|
374
|
+
* },
|
|
375
|
+
* });
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
function defineContract(contract) {
|
|
379
|
+
assert(typeof contract.service === "string" && contract.service.length > 0, "contract.service must be a non-empty string");
|
|
380
|
+
assert(SEMVER_RE.test(contract.version), `contract.version "${contract.version}" is not valid semver (e.g. "1.2.0")`);
|
|
381
|
+
assert(contract.spans && typeof contract.spans === "object", "contract.spans must be an object");
|
|
382
|
+
for (const [spanName, spanSpec] of Object.entries(contract.spans)) {
|
|
383
|
+
if (spanSpec.stability) assert(STABILITIES.includes(spanSpec.stability), `span "${spanName}" has invalid stability "${spanSpec.stability}"`);
|
|
384
|
+
for (const [key, spec] of Object.entries(spanSpec.attributes ?? {})) validateAttribute(`span "${spanName}"`, key, spec);
|
|
385
|
+
}
|
|
386
|
+
for (const [key, spec] of Object.entries(contract.commonAttributes ?? {})) validateAttribute("common", key, spec);
|
|
387
|
+
for (const [name, spec] of Object.entries(contract.scenarios ?? {})) validateScenarioSpec(name, spec);
|
|
388
|
+
return Object.freeze(contract);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Resolve the effective attribute spec for `key` on `spanName`: span-specific
|
|
392
|
+
* attributes win over common attributes. Returns `undefined` when the key is
|
|
393
|
+
* declared nowhere.
|
|
394
|
+
*/
|
|
395
|
+
function resolveAttributeSpec(contract, spanName, key) {
|
|
396
|
+
return contract.spans[spanName]?.attributes?.[key] ?? contract.commonAttributes?.[key];
|
|
397
|
+
}
|
|
398
|
+
/** Whether attributes outside the declared set are tolerated for a span. */
|
|
399
|
+
function allowsAdditionalAttributes(contract, spanName) {
|
|
400
|
+
return contract.spans[spanName]?.additionalAttributes ?? contract.additionalAttributes ?? false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/validate.ts
|
|
405
|
+
/**
|
|
406
|
+
* Pure span-vs-contract validation. No SDK, no side effects — the same engine
|
|
407
|
+
* the runtime processor ({@link ./processor}) and any test harness can call.
|
|
408
|
+
*/
|
|
409
|
+
/** `'empty[]'` is a distinct marker: an empty array satisfies any array type. */
|
|
410
|
+
function actualType(value) {
|
|
411
|
+
if (typeof value === "string") return "string";
|
|
412
|
+
if (typeof value === "number") return "number";
|
|
413
|
+
if (typeof value === "boolean") return "boolean";
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
const first = value.find((v) => v !== null && v !== void 0);
|
|
416
|
+
if (first === void 0) return "empty[]";
|
|
417
|
+
if (typeof first === "string") return "string[]";
|
|
418
|
+
if (typeof first === "number") return "number[]";
|
|
419
|
+
if (typeof first === "boolean") return "boolean[]";
|
|
420
|
+
}
|
|
421
|
+
return "unknown";
|
|
422
|
+
}
|
|
423
|
+
function typeMatches(expected, value) {
|
|
424
|
+
const actual = actualType(value);
|
|
425
|
+
if (actual === "unknown") return false;
|
|
426
|
+
if (actual === "empty[]") return expected.endsWith("[]");
|
|
427
|
+
return actual === expected;
|
|
428
|
+
}
|
|
429
|
+
/** Levenshtein distance — small, allocation-light, good enough for key typos. */
|
|
430
|
+
function editDistance(a, b) {
|
|
431
|
+
const m = a.length;
|
|
432
|
+
const n = b.length;
|
|
433
|
+
if (m === 0) return n;
|
|
434
|
+
if (n === 0) return m;
|
|
435
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
436
|
+
let curr = Array.from({ length: n + 1 });
|
|
437
|
+
for (let i = 1; i <= m; i++) {
|
|
438
|
+
curr[0] = i;
|
|
439
|
+
for (let j = 1; j <= n; j++) {
|
|
440
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
441
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
442
|
+
}
|
|
443
|
+
[prev, curr] = [curr, prev];
|
|
444
|
+
}
|
|
445
|
+
return prev[n];
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Closest declared key to `key`, when one is within a small edit distance.
|
|
449
|
+
* Turns "you emitted an attribute I don't know" into "did you mean `user.id`?".
|
|
450
|
+
*/
|
|
451
|
+
function nearestKey(key, candidates) {
|
|
452
|
+
let best;
|
|
453
|
+
let bestDistance = Infinity;
|
|
454
|
+
const threshold = Math.max(1, Math.floor(key.length / 4) + 1);
|
|
455
|
+
for (const candidate of candidates) {
|
|
456
|
+
const d = editDistance(key, candidate);
|
|
457
|
+
if (d < bestDistance && d <= threshold) {
|
|
458
|
+
best = candidate;
|
|
459
|
+
bestDistance = d;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return best;
|
|
463
|
+
}
|
|
464
|
+
function declaredKeysFor(contract, spanName) {
|
|
465
|
+
return [...Object.keys(contract.spans[spanName]?.attributes ?? {}), ...Object.keys(contract.commonAttributes ?? {})];
|
|
466
|
+
}
|
|
467
|
+
function checkValue(spanName, key, value, spec, out) {
|
|
468
|
+
if (!typeMatches(spec.type, value)) {
|
|
469
|
+
out.push({
|
|
470
|
+
code: "type_mismatch",
|
|
471
|
+
severity: "error",
|
|
472
|
+
spanName,
|
|
473
|
+
attribute: key,
|
|
474
|
+
message: `attribute "${key}" should be ${spec.type} but got ${actualType(value)}`
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (spec.enum && (typeof value === "string" || typeof value === "number") && !spec.enum.includes(value)) out.push({
|
|
479
|
+
code: "enum_violation",
|
|
480
|
+
severity: "error",
|
|
481
|
+
spanName,
|
|
482
|
+
attribute: key,
|
|
483
|
+
message: `attribute "${key}" value ${JSON.stringify(value)} is not one of ${JSON.stringify(spec.enum)}`
|
|
484
|
+
});
|
|
485
|
+
if (spec.stability === "deprecated") {
|
|
486
|
+
const hint = spec.replacedBy ? ` — use "${spec.replacedBy}" instead` : spec.deprecatedReason ? ` — ${spec.deprecatedReason}` : "";
|
|
487
|
+
out.push({
|
|
488
|
+
code: "deprecated_attribute",
|
|
489
|
+
severity: "warning",
|
|
490
|
+
spanName,
|
|
491
|
+
attribute: key,
|
|
492
|
+
message: `attribute "${key}" is deprecated${hint}`,
|
|
493
|
+
suggestion: spec.replacedBy
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Validate one emitted span against the contract, returning every discrepancy.
|
|
499
|
+
* Order is deterministic: required-but-missing first, then per-attribute checks
|
|
500
|
+
* in attribute insertion order.
|
|
501
|
+
*/
|
|
502
|
+
function validateSpan(span, contract, options = {}) {
|
|
503
|
+
const out = [];
|
|
504
|
+
const spanSpec = contract.spans[span.name];
|
|
505
|
+
if (!spanSpec) {
|
|
506
|
+
if (options.strictSpanNames) out.push({
|
|
507
|
+
code: "unknown_span",
|
|
508
|
+
severity: "warning",
|
|
509
|
+
spanName: span.name,
|
|
510
|
+
message: `span "${span.name}" is not declared in the contract`
|
|
511
|
+
});
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
const required = [...Object.entries(spanSpec.attributes ?? {}), ...Object.entries(contract.commonAttributes ?? {})].filter(([, spec]) => spec.required);
|
|
515
|
+
for (const [key] of required) if (!(key in span.attributes)) out.push({
|
|
516
|
+
code: "missing_required",
|
|
517
|
+
severity: "error",
|
|
518
|
+
spanName: span.name,
|
|
519
|
+
attribute: key,
|
|
520
|
+
message: `required attribute "${key}" is missing`
|
|
521
|
+
});
|
|
522
|
+
const allowExtra = allowsAdditionalAttributes(contract, span.name);
|
|
523
|
+
const declared = allowExtra ? [] : declaredKeysFor(contract, span.name);
|
|
524
|
+
for (const [key, value] of Object.entries(span.attributes)) {
|
|
525
|
+
if (value === null || value === void 0) continue;
|
|
526
|
+
const spec = resolveAttributeSpec(contract, span.name, key);
|
|
527
|
+
if (!spec) {
|
|
528
|
+
if (!allowExtra) out.push({
|
|
529
|
+
code: "unknown_attribute",
|
|
530
|
+
severity: "warning",
|
|
531
|
+
spanName: span.name,
|
|
532
|
+
attribute: key,
|
|
533
|
+
message: `attribute "${key}" is not declared on span "${span.name}"`,
|
|
534
|
+
suggestion: nearestKey(key, declared)
|
|
535
|
+
});
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
checkValue(span.name, key, value, spec, out);
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
/** `true` when any violation is `error` severity. */
|
|
543
|
+
function hasErrors(violations) {
|
|
544
|
+
return violations.some((v) => v.severity === "error");
|
|
545
|
+
}
|
|
546
|
+
/** One-line human/agent-readable rendering of a violation. */
|
|
547
|
+
function formatViolation(v) {
|
|
548
|
+
const where = v.attribute ? `${v.spanName}.${v.attribute}` : v.spanName;
|
|
549
|
+
const suffix = v.suggestion ? ` (did you mean "${v.suggestion}"?)` : "";
|
|
550
|
+
return `[${v.severity}] ${v.code} @ ${where}: ${v.message}${suffix}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/processor.ts
|
|
555
|
+
/**
|
|
556
|
+
* Runtime contract enforcement as an OpenTelemetry SpanProcessor.
|
|
557
|
+
*
|
|
558
|
+
* Wire it into `init({ spanProcessors: [...] })` and every span your service
|
|
559
|
+
* emits is validated against the contract as it ends. In development a typo'd
|
|
560
|
+
* or undeclared attribute surfaces immediately instead of silently drifting
|
|
561
|
+
* the public telemetry API out from under the agents reading it.
|
|
562
|
+
*
|
|
563
|
+
* Fail-open by construction: a bug in validation must never break the app or
|
|
564
|
+
* lose a span. Off in production by default (validation belongs in CI and dev),
|
|
565
|
+
* but `enabledInProduction` is there if you want a sampled canary in prod.
|
|
566
|
+
*/
|
|
567
|
+
const DEFAULT_WARN_INTERVAL_MS = 6e4;
|
|
568
|
+
function isProduction() {
|
|
569
|
+
return process.env.NODE_ENV === "production";
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Validates each ending span against a {@link TelemetryContract}. Bounded,
|
|
573
|
+
* deduplicated warnings; fail-open on any internal error.
|
|
574
|
+
*/
|
|
575
|
+
var SchemaValidationSpanProcessor = class {
|
|
576
|
+
opts;
|
|
577
|
+
enabled;
|
|
578
|
+
warnIntervalMs;
|
|
579
|
+
lastWarnAt = /* @__PURE__ */ new Map();
|
|
580
|
+
violationCount = 0;
|
|
581
|
+
constructor(opts) {
|
|
582
|
+
this.opts = opts;
|
|
583
|
+
this.enabled = opts.enabledInProduction === true || !isProduction();
|
|
584
|
+
this.warnIntervalMs = opts.warnIntervalMs ?? DEFAULT_WARN_INTERVAL_MS;
|
|
585
|
+
}
|
|
586
|
+
/** Number of violations seen since startup (across all spans). */
|
|
587
|
+
get totalViolations() {
|
|
588
|
+
return this.violationCount;
|
|
589
|
+
}
|
|
590
|
+
onStart(_span, _parentContext) {}
|
|
591
|
+
onEnd(span) {
|
|
592
|
+
if (!this.enabled) return;
|
|
593
|
+
let violations;
|
|
594
|
+
try {
|
|
595
|
+
violations = validateSpan({
|
|
596
|
+
name: span.name,
|
|
597
|
+
attributes: span.attributes
|
|
598
|
+
}, this.opts.contract, { strictSpanNames: this.opts.strictSpanNames });
|
|
599
|
+
} catch {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
for (const violation of violations) {
|
|
603
|
+
this.violationCount++;
|
|
604
|
+
this.opts.onViolation?.(violation, span);
|
|
605
|
+
this.handle(violation);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
handle(violation) {
|
|
609
|
+
const mode = this.opts.mode ?? "warn";
|
|
610
|
+
if (mode === "silent") return;
|
|
611
|
+
if (mode === "throw" && violation.severity === "error") throw new Error(`autotel-schema: contract violation (${violation.code}) on span "${violation.spanName}": ${violation.message}`);
|
|
612
|
+
this.maybeWarn(violation);
|
|
613
|
+
}
|
|
614
|
+
maybeWarn(violation) {
|
|
615
|
+
const key = `${violation.code}:${violation.spanName}:${violation.attribute ?? ""}`;
|
|
616
|
+
const now = Date.now();
|
|
617
|
+
if (now - (this.lastWarnAt.get(key) ?? 0) < this.warnIntervalMs) return;
|
|
618
|
+
this.lastWarnAt.set(key, now);
|
|
619
|
+
const suffix = violation.suggestion ? ` (did you mean "${violation.suggestion}"?)` : "";
|
|
620
|
+
const message = `autotel-schema [${violation.severity}] ${violation.code} on "${violation.spanName}"${violation.attribute ? `.${violation.attribute}` : ""}: ${violation.message}${suffix}`;
|
|
621
|
+
if (this.opts.onWarn) this.opts.onWarn(message);
|
|
622
|
+
else console.warn(message);
|
|
623
|
+
}
|
|
624
|
+
async forceFlush() {}
|
|
625
|
+
async shutdown() {
|
|
626
|
+
this.lastWarnAt.clear();
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
function createSchemaValidationProcessor(opts) {
|
|
630
|
+
return new SchemaValidationSpanProcessor(opts);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
//#endregion
|
|
634
|
+
export { validateScenarioSpec as _, validateSpan as a, allowsAdditionalAttributes as c, checkScenario as d, evaluateScenario as f, proposeScenario as g, parseCardinality as h, hasErrors as i, defineContract as l, isScenarioClosed as m, createSchemaValidationProcessor as n, ATTRIBUTE_TYPES as o, formatScenarioResult as p, formatViolation as r, STABILITIES as s, SchemaValidationSpanProcessor as t, resolveAttributeSpec as u };
|
|
635
|
+
//# sourceMappingURL=processor-BPp_DewZ.js.map
|