autotel-subscribers 40.0.0 → 41.0.1

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.
@@ -1,416 +0,0 @@
1
- /**
2
- * ArchitectureSnapshotSubscriber
3
- *
4
- * Captures `track()` events into an in-memory architecture snapshot, then
5
- * writes it to disk. The snapshot is the input to `autotel-eventcatalog`'s
6
- * generator and is designed to be deterministic, reviewable, and committable.
7
- *
8
- * v0 scope: capture event names, observation counts, first/last-seen, sample
9
- * trace IDs, and the dotted field paths present in payloads. Producer /
10
- * consumer / channel attribution is read from a small `_autotel.*` convention
11
- * inside event attributes — that convention is documented in
12
- * `apps/example-eventcatalog`.
13
- *
14
- * @example
15
- * ```typescript
16
- * import { init, track } from 'autotel';
17
- * import { ArchitectureSnapshotSubscriber } from 'autotel-subscribers/architecture';
18
- *
19
- * const snapshot = new ArchitectureSnapshotSubscriber({ service: 'orders' });
20
- *
21
- * init({
22
- * service: 'orders',
23
- * subscribers: [snapshot],
24
- * });
25
- *
26
- * // ... exercise the system (run integration tests, hit endpoints, etc.) ...
27
- *
28
- * await snapshot.writeToFile('./.autotel/snapshot.json');
29
- * ```
30
- */
31
-
32
- import fs from 'node:fs/promises';
33
- import path from 'node:path';
34
- import { EventSubscriber, type EventPayload } from './event-subscriber-base';
35
-
36
- /**
37
- * Public, versioned snapshot format. The generator and any downstream tooling
38
- * target this spec. Bumping the spec version is a breaking change for
39
- * downstream consumers, so add fields rather than rename existing ones.
40
- */
41
- export const ARCHITECTURE_SNAPSHOT_SPEC = 'autotel-architecture/v0.1.0' as const;
42
-
43
- export type ArchitectureSnapshot = {
44
- spec: typeof ARCHITECTURE_SNAPSHOT_SPEC;
45
- generatedAt: string;
46
- service: string;
47
- events: Record<string, EventObservation>;
48
- };
49
-
50
- export type EventObservation = {
51
- name: string;
52
- observedCount: number;
53
- firstSeen: string;
54
- lastSeen: string;
55
- /** Dotted field paths observed in any payload (e.g. `items[].sku`). */
56
- fieldPaths: string[];
57
- /** Up to 3 trace IDs for click-through from the catalog into the backend. */
58
- sampleTraceIds: string[];
59
- /** Channel the event was published on, if the caller provided `_autotel.channel`. */
60
- channel?: string;
61
- /** Service that produced the event, if not the snapshot's own service. */
62
- producer?: string;
63
- /** Services known to consume this event (optional metadata from _autotel.consumers). */
64
- consumers?: string[];
65
- /** Observed runtime types and sample primitive values per field path. */
66
- fieldStats?: Record<string, FieldStats>;
67
- /** Optional contract schema metadata carried from track() call sites. */
68
- schema?: {
69
- source: 'zod';
70
- jsonSchema: unknown;
71
- hash: string;
72
- };
73
- };
74
-
75
- export type FieldStats = {
76
- /** Runtime types observed for this field path (e.g. string, number). */
77
- types: string[];
78
- /** Small set of observed primitive values (for enum/value drift checks). */
79
- sampleValues: Array<string | number | boolean | null>;
80
- };
81
-
82
- export interface ArchitectureSnapshotConfig {
83
- /** Service identifier that appears in the snapshot header. */
84
- service: string;
85
- /** Maximum number of trace IDs to retain per event (default 3). */
86
- maxSampleTraceIds?: number;
87
- }
88
-
89
- const DEFAULT_MAX_SAMPLES = 3;
90
-
91
- export class ArchitectureSnapshotSubscriber extends EventSubscriber {
92
- readonly name = 'ArchitectureSnapshotSubscriber';
93
-
94
- private readonly service: string;
95
- private readonly maxSampleTraceIds: number;
96
- private readonly observations = new Map<string, EventObservation>();
97
-
98
- constructor(config: ArchitectureSnapshotConfig) {
99
- super();
100
- this.service = config.service;
101
- this.maxSampleTraceIds = config.maxSampleTraceIds ?? DEFAULT_MAX_SAMPLES;
102
- }
103
-
104
- protected async sendToDestination(payload: EventPayload): Promise<void> {
105
- // Only `track()` events feed the architecture snapshot. Funnels, outcomes,
106
- // and value metrics belong to product analytics, not the architecture model.
107
- if (payload.type !== 'event') return;
108
-
109
- const existing = this.observations.get(payload.name);
110
- const now = payload.timestamp;
111
- const traceId = payload.autotel?.trace_id;
112
- const attrs = payload.attributes ?? {};
113
- const autotelMeta = readAutotelMeta(attrs);
114
- const cleanAttrs = stripAutotelMeta(attrs);
115
- const fieldPaths = extractFieldPaths(cleanAttrs);
116
- const fieldStats = extractFieldStats(cleanAttrs);
117
-
118
- if (!existing) {
119
- this.observations.set(payload.name, {
120
- name: payload.name,
121
- observedCount: 1,
122
- firstSeen: now,
123
- lastSeen: now,
124
- fieldPaths,
125
- sampleTraceIds: traceId ? [traceId] : [],
126
- channel: autotelMeta.channel,
127
- producer: autotelMeta.producer,
128
- consumers: autotelMeta.consumers,
129
- fieldStats,
130
- schema: payload.schema
131
- ? {
132
- source: payload.schema.source,
133
- jsonSchema: payload.schema.jsonSchema,
134
- hash: payload.schema.hash,
135
- }
136
- : undefined,
137
- });
138
- return;
139
- }
140
-
141
- existing.observedCount += 1;
142
- existing.lastSeen = now;
143
- existing.fieldPaths = mergeUnique(existing.fieldPaths, fieldPaths);
144
- existing.fieldStats = mergeFieldStats(existing.fieldStats ?? {}, fieldStats);
145
-
146
- if (
147
- traceId &&
148
- !existing.sampleTraceIds.includes(traceId) &&
149
- existing.sampleTraceIds.length < this.maxSampleTraceIds
150
- ) {
151
- existing.sampleTraceIds.push(traceId);
152
- }
153
-
154
- existing.channel ??= autotelMeta.channel;
155
- existing.producer ??= autotelMeta.producer;
156
- existing.consumers = mergeUnique(existing.consumers ?? [], autotelMeta.consumers ?? []);
157
- existing.schema ??= payload.schema
158
- ? {
159
- source: payload.schema.source,
160
- jsonSchema: payload.schema.jsonSchema,
161
- hash: payload.schema.hash,
162
- }
163
- : undefined;
164
- }
165
-
166
- /**
167
- * Build the snapshot in memory. Use this in tests or when you want to
168
- * inspect the result before writing it. Field paths and trace IDs are
169
- * sorted so equal inputs always produce byte-identical snapshots.
170
- *
171
- * @param options.now Clock used for the snapshot's `generatedAt` field.
172
- * @param options.freezeTimestamps If supplied, every timestamp in the
173
- * output (`generatedAt`, and each event's `firstSeen` / `lastSeen`)
174
- * is replaced with this value. Use when writing a snapshot intended
175
- * to be committed to a repo as a stable artifact — production code
176
- * should not pass this.
177
- */
178
- toSnapshot(
179
- options: { now?: () => Date; freezeTimestamps?: string } = {},
180
- ): ArchitectureSnapshot {
181
- const { now = () => new Date(), freezeTimestamps } = options;
182
-
183
- const events: Record<string, EventObservation> = {};
184
-
185
- const names = [...this.observations.keys()].toSorted();
186
- for (const name of names) {
187
- const obs = this.observations.get(name);
188
- if (!obs) continue;
189
- events[name] = {
190
- ...obs,
191
- firstSeen: freezeTimestamps ?? obs.firstSeen,
192
- lastSeen: freezeTimestamps ?? obs.lastSeen,
193
- fieldPaths: obs.fieldPaths.toSorted(),
194
- sampleTraceIds: obs.sampleTraceIds.toSorted(),
195
- fieldStats: sortFieldStats(obs.fieldStats),
196
- };
197
- }
198
-
199
- return {
200
- spec: ARCHITECTURE_SNAPSHOT_SPEC,
201
- generatedAt: freezeTimestamps ?? now().toISOString(),
202
- service: this.service,
203
- events,
204
- };
205
- }
206
-
207
- /**
208
- * Write the snapshot to disk. Creates parent directories as needed.
209
- * Files are written with a trailing newline so they diff cleanly in git.
210
- *
211
- * See {@link toSnapshot} for option semantics, including `freezeTimestamps`
212
- * for byte-stable committed artifacts.
213
- */
214
- async writeToFile(
215
- filePath: string,
216
- options: { now?: () => Date; freezeTimestamps?: string } = {},
217
- ): Promise<void> {
218
- await fs.mkdir(path.dirname(filePath), { recursive: true });
219
- const json = JSON.stringify(this.toSnapshot(options), null, 2);
220
- await fs.writeFile(filePath, json + '\n', 'utf8');
221
- }
222
-
223
- /** Reset all accumulated state. Useful between test cases. */
224
- reset(): void {
225
- this.observations.clear();
226
- }
227
- }
228
-
229
- type AutotelMeta = {
230
- channel?: string;
231
- producer?: string;
232
- consumers?: string[];
233
- };
234
-
235
- function readAutotelMeta(attrs: Record<string, unknown>): AutotelMeta {
236
- const meta = attrs._autotel;
237
- if (!meta || typeof meta !== 'object') return {};
238
- const m = meta as Record<string, unknown>;
239
- return {
240
- channel: typeof m.channel === 'string' ? m.channel : undefined,
241
- producer: typeof m.producer === 'string' ? m.producer : undefined,
242
- consumers: Array.isArray(m.consumers)
243
- ? m.consumers.filter((v): v is string => typeof v === 'string').toSorted()
244
- : undefined,
245
- };
246
- }
247
-
248
- /**
249
- * Top-level attribute keys that autotel injects automatically (correlation
250
- * context, baggage, service metadata). These describe the trace, not the
251
- * event payload, so they don't belong in the captured field paths.
252
- */
253
- const AUTOTEL_INJECTED_KEYS = new Set([
254
- '_autotel',
255
- 'traceId',
256
- 'trace_id',
257
- 'spanId',
258
- 'span_id',
259
- 'parentSpanId',
260
- 'parent_span_id',
261
- 'correlationId',
262
- 'correlation_id',
263
- 'service',
264
- 'service.name',
265
- ]);
266
-
267
- function stripAutotelMeta(attrs: Record<string, unknown>): Record<string, unknown> {
268
- const out: Record<string, unknown> = {};
269
- for (const [key, value] of Object.entries(attrs)) {
270
- if (AUTOTEL_INJECTED_KEYS.has(key)) continue;
271
- out[key] = value;
272
- }
273
- return out;
274
- }
275
-
276
- /**
277
- * Walk a JSON-like value and produce a sorted list of dotted field paths.
278
- * Arrays collapse with `[]`, so `items: [{ sku: 'x' }]` yields `items[].sku`.
279
- */
280
- export function extractFieldPaths(value: unknown, prefix = ''): string[] {
281
- const paths = new Set<string>();
282
- walk(value, prefix, paths);
283
- return [...paths].toSorted();
284
- }
285
-
286
- function walk(value: unknown, prefix: string, out: Set<string>): void {
287
- if (value === null || value === undefined) return;
288
- if (Array.isArray(value)) {
289
- const arrayPrefix = prefix + '[]';
290
- for (const item of value) walk(item, arrayPrefix, out);
291
- return;
292
- }
293
- if (typeof value === 'object') {
294
- for (const [key, v] of Object.entries(value)) {
295
- const path = prefix === '' ? key : `${prefix}.${key}`;
296
- out.add(path);
297
- walk(v, path, out);
298
- }
299
- return;
300
- }
301
- // Primitives don't add new paths beyond what their key already added.
302
- }
303
-
304
- function mergeUnique(a: string[], b: string[]): string[] {
305
- if (b.length === 0) return a;
306
- const set = new Set(a);
307
- for (const v of b) set.add(v);
308
- return [...set];
309
- }
310
-
311
- function extractFieldStats(value: unknown, prefix = ''): Record<string, FieldStats> {
312
- const out = new Map<string, { types: Set<string>; sampleValues: Set<string | number | boolean | null> }>();
313
- walkFieldStats(value, prefix, out);
314
- const obj: Record<string, FieldStats> = {};
315
- for (const [path, stats] of out) {
316
- obj[path] = {
317
- types: [...stats.types].toSorted(),
318
- sampleValues: [...stats.sampleValues].toSorted(comparePrimitiveValues),
319
- };
320
- }
321
- return obj;
322
- }
323
-
324
- function walkFieldStats(
325
- value: unknown,
326
- prefix: string,
327
- out: Map<string, { types: Set<string>; sampleValues: Set<string | number | boolean | null> }>,
328
- ): void {
329
- if (value === null || value === undefined) return;
330
- if (Array.isArray(value)) {
331
- const arrayPrefix = prefix + '[]';
332
- for (const item of value) walkFieldStats(item, arrayPrefix, out);
333
- return;
334
- }
335
- if (typeof value === 'object') {
336
- for (const [key, v] of Object.entries(value)) {
337
- const path = prefix === '' ? key : `${prefix}.${key}`;
338
- addPathValue(path, v, out);
339
- walkFieldStats(v, path, out);
340
- }
341
- }
342
- }
343
-
344
- function addPathValue(
345
- path: string,
346
- value: unknown,
347
- out: Map<string, { types: Set<string>; sampleValues: Set<string | number | boolean | null> }>,
348
- ): void {
349
- const existing = out.get(path) ?? { types: new Set<string>(), sampleValues: new Set<string | number | boolean | null>() };
350
- const t = classifyValueType(value);
351
- existing.types.add(t);
352
- if (
353
- (value === null ||
354
- typeof value === 'string' ||
355
- typeof value === 'number' ||
356
- typeof value === 'boolean') &&
357
- existing.sampleValues.size < 20
358
- ) {
359
- existing.sampleValues.add(value);
360
- }
361
- out.set(path, existing);
362
- }
363
-
364
- function classifyValueType(value: unknown): string {
365
- if (value === null) return 'null';
366
- if (Array.isArray(value)) return 'array';
367
- return typeof value;
368
- }
369
-
370
- function mergeFieldStats(
371
- a: Record<string, FieldStats>,
372
- b: Record<string, FieldStats>,
373
- ): Record<string, FieldStats> {
374
- const merged: Record<string, FieldStats> = { ...a };
375
- for (const [path, bs] of Object.entries(b)) {
376
- const prev = merged[path];
377
- if (!prev) {
378
- merged[path] = bs;
379
- continue;
380
- }
381
- const types = new Set([...prev.types, ...bs.types]);
382
- const sampleValues = new Set([...prev.sampleValues, ...bs.sampleValues]);
383
- merged[path] = {
384
- types: [...types].toSorted(),
385
- sampleValues: [...sampleValues]
386
- .toSorted(comparePrimitiveValues)
387
- .slice(0, 20),
388
- };
389
- }
390
- return merged;
391
- }
392
-
393
- function sortFieldStats(
394
- stats: Record<string, FieldStats> | undefined,
395
- ): Record<string, FieldStats> | undefined {
396
- if (!stats) return undefined;
397
- const out: Record<string, FieldStats> = {};
398
- for (const path of Object.keys(stats).toSorted()) {
399
- out[path] = {
400
- types: [...stats[path].types].toSorted(),
401
- sampleValues: [...stats[path].sampleValues].toSorted(
402
- comparePrimitiveValues,
403
- ),
404
- };
405
- }
406
- return out;
407
- }
408
-
409
- function comparePrimitiveValues(
410
- a: string | number | boolean | null,
411
- b: string | number | boolean | null,
412
- ): number {
413
- const sa = JSON.stringify(a);
414
- const sb = JSON.stringify(b);
415
- return sa.localeCompare(sb);
416
- }