@autter/otlp-ingester 1.5.0 → 1.6.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.
Files changed (43) hide show
  1. package/README.md +1 -0
  2. package/dist/breadcrumbs.d.ts +41 -0
  3. package/dist/breadcrumbs.js +151 -0
  4. package/dist/clickhouse.d.ts +10 -6
  5. package/dist/clickhouse.js +38 -5
  6. package/dist/compat-manifest.json +29 -0
  7. package/dist/compat.d.ts +3 -1
  8. package/dist/compat.js +3 -1
  9. package/dist/config.d.ts +6 -0
  10. package/dist/config.js +1 -0
  11. package/dist/context.js +4 -1
  12. package/dist/fingerprint-override.d.ts +42 -0
  13. package/dist/fingerprint-override.js +76 -0
  14. package/dist/fingerprint-v3.d.ts +54 -0
  15. package/dist/fingerprint-v3.js +96 -0
  16. package/dist/fingerprint.d.ts +25 -0
  17. package/dist/fingerprint.js +1 -1
  18. package/dist/logs.js +2 -0
  19. package/dist/memory.d.ts +2 -2
  20. package/dist/migrations.d.ts +10 -0
  21. package/dist/migrations.js +32 -1
  22. package/dist/normalize-browser.d.ts +67 -34
  23. package/dist/normalize-browser.js +24 -1
  24. package/dist/normalize-otlp.js +4 -0
  25. package/dist/server.js +94 -18
  26. package/dist/sink.d.ts +5 -1
  27. package/dist/sink.js +13 -2
  28. package/dist/source-map-mappings.d.ts +57 -0
  29. package/dist/source-map-mappings.js +245 -0
  30. package/dist/source-map-scopes.d.ts +71 -0
  31. package/dist/source-map-scopes.js +190 -0
  32. package/dist/source-maps-lookup.d.ts +31 -0
  33. package/dist/source-maps-lookup.js +64 -0
  34. package/dist/source-maps.d.ts +72 -2
  35. package/dist/source-maps.js +134 -14
  36. package/dist/stack-fixtures.d.ts +10 -2
  37. package/dist/stack-fixtures.js +13 -3
  38. package/dist/stack-frames.d.ts +48 -0
  39. package/dist/stack-frames.js +441 -0
  40. package/dist/symbolicate.d.ts +117 -0
  41. package/dist/symbolicate.js +363 -0
  42. package/dist/types.d.ts +68 -0
  43. package/package.json +2 -1
@@ -0,0 +1,363 @@
1
+ import { isIgnored, sourceContentFor, TraceMap, } from "@jridgewell/trace-mapping";
2
+ import { DecodedMappings, decodedMappingsBytes, findInMappings, } from "./source-map-mappings.js";
3
+ import { SCOPES_KEY, ScopeIndex } from "./source-map-scopes.js";
4
+ import { isInAppPath, stripQueryAndHash } from "./stack-frames.js";
5
+ const JS_FILE_RE = /\.[cm]?js$/i;
6
+ const MAX_CONTEXT_CHARS = 300;
7
+ const CONTEXT_LINES = 3;
8
+ /** Path-only form of a script URL — the key uploads are stored under. */
9
+ export function scriptPath(filename) {
10
+ try {
11
+ return new URL(filename, "https://autter.invalid").pathname;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /**
18
+ * The largest share of the budget one map may take. A bigger one is used
19
+ * for its lookup and dropped: keeping it would evict most other maps, and
20
+ * every event in those files would then refetch theirs.
21
+ */
22
+ const MAX_SHARE_OF_BUDGET = 0.5;
23
+ export class SourceMapCache {
24
+ entries = new Map();
25
+ inFlight = new Map();
26
+ weight = 0;
27
+ maxBytes;
28
+ maxEntries;
29
+ hitTtlMs;
30
+ missTtlMs;
31
+ errorTtlMs;
32
+ now;
33
+ /** Fetches made (cache misses) — for tests and /healthz-style debugging. */
34
+ fetches = 0;
35
+ /** Maps used without being cached because they were too big to keep. */
36
+ bypassed = 0;
37
+ constructor(options = {}) {
38
+ this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
39
+ this.maxEntries = options.maxEntries ?? 500;
40
+ this.hitTtlMs = options.hitTtlMs ?? 60 * 60_000;
41
+ this.missTtlMs = options.missTtlMs ?? 5 * 60_000;
42
+ this.errorTtlMs = options.errorTtlMs ?? 30_000;
43
+ this.now = options.now ?? Date.now;
44
+ }
45
+ static keyOf(key) {
46
+ const scope = `${key.orgId}\u0000${key.repositoryId}\u0000`;
47
+ return key.debugId
48
+ ? `${scope}d\u0000${key.debugId}`
49
+ : `${scope}r\u0000${key.release ?? ""}\u0000${key.filename}`;
50
+ }
51
+ get size() {
52
+ return this.entries.size;
53
+ }
54
+ /** Estimated bytes of the cached maps (what `maxBytes` bounds). */
55
+ get bytes() {
56
+ return this.weight;
57
+ }
58
+ /** The parsed map for `key` (null when none), fetching at most once at a time. */
59
+ async get(key, fetchMap) {
60
+ const id = SourceMapCache.keyOf(key);
61
+ const hit = this.entries.get(id);
62
+ if (hit) {
63
+ if (hit.expiresAt > this.now()) {
64
+ // Refresh recency: Map iteration order is insertion order.
65
+ this.entries.delete(id);
66
+ this.entries.set(id, hit);
67
+ return hit.map;
68
+ }
69
+ this.remove(id);
70
+ }
71
+ const pending = this.inFlight.get(id);
72
+ if (pending)
73
+ return pending;
74
+ const load = this.load(id, key, fetchMap).finally(() => this.inFlight.delete(id));
75
+ this.inFlight.set(id, load);
76
+ return load;
77
+ }
78
+ async load(id, key, fetchMap) {
79
+ this.fetches += 1;
80
+ let json;
81
+ try {
82
+ json = await fetchMap(key);
83
+ }
84
+ catch (err) {
85
+ console.warn("source map lookup failed — frames stay unresolved:", err instanceof Error ? err.message : err);
86
+ this.store(id, null, 0, this.errorTtlMs);
87
+ return null;
88
+ }
89
+ if (!json) {
90
+ this.store(id, null, 0, this.missTtlMs);
91
+ return null;
92
+ }
93
+ let loaded;
94
+ try {
95
+ loaded = parseSourceMap(json, this.maxBytes * MAX_SHARE_OF_BUDGET);
96
+ }
97
+ catch {
98
+ // A malformed upload is a miss until it is replaced.
99
+ this.store(id, null, 0, this.missTtlMs);
100
+ return null;
101
+ }
102
+ if (loaded.cacheable)
103
+ this.store(id, loaded, loaded.bytes, this.hitTtlMs);
104
+ else
105
+ this.bypassed += 1;
106
+ return loaded;
107
+ }
108
+ store(id, map, weight, ttlMs) {
109
+ // A map bigger than the whole budget serves this lookup but is not kept:
110
+ // caching it would evict everything else.
111
+ if (weight > this.maxBytes)
112
+ return;
113
+ this.remove(id);
114
+ this.entries.set(id, { map, weight, expiresAt: this.now() + ttlMs });
115
+ this.weight += weight;
116
+ while ((this.weight > this.maxBytes || this.entries.size > this.maxEntries) &&
117
+ this.entries.size > 1) {
118
+ const oldest = this.entries.keys().next().value;
119
+ this.remove(oldest);
120
+ }
121
+ }
122
+ remove(id) {
123
+ const entry = this.entries.get(id);
124
+ if (!entry)
125
+ return;
126
+ this.entries.delete(id);
127
+ this.weight -= entry.weight;
128
+ }
129
+ }
130
+ /** Rough heap of a list of strings (V8 strings plus array slots). */
131
+ function stringsBytes(list) {
132
+ if (!Array.isArray(list))
133
+ return 0;
134
+ let bytes = 16;
135
+ for (const item of list)
136
+ bytes += 8 + (typeof item === "string" ? 2 * item.length + 24 : 0);
137
+ return bytes;
138
+ }
139
+ /**
140
+ * Parse a stored map once: metadata, scopes (null when absent or malformed
141
+ * — a bad scope field never loses the map) and position lookups. When the
142
+ * estimated heap is at most `maxDecodedBytes`, the mappings are decoded
143
+ * once into a compact table; otherwise they are left encoded and each
144
+ * lookup reads them in one pass (`cacheable: false`). Throws when the map
145
+ * itself is unusable.
146
+ */
147
+ export function parseSourceMap(json, maxDecodedBytes = Number.POSITIVE_INFINITY) {
148
+ const raw = JSON.parse(json);
149
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
150
+ throw new Error("not a source map");
151
+ const fields = raw;
152
+ const { mappings } = fields;
153
+ if (typeof mappings !== "string")
154
+ throw new Error("no mappings");
155
+ // TraceMap copies only the standard fields, but given an object that has
156
+ // its private `_decodedMemo` it would return that object as is. It gets
157
+ // no mappings: decoding them is this module's job.
158
+ delete fields._decodedMemo;
159
+ const map = new TraceMap({ ...fields, mappings: "" });
160
+ let scopes = null;
161
+ try {
162
+ scopes = ScopeIndex.from(fields[SCOPES_KEY], map.names);
163
+ }
164
+ catch {
165
+ scopes = null;
166
+ }
167
+ const bytes = decodedMappingsBytes(mappings) +
168
+ stringsBytes(map.names) +
169
+ stringsBytes(map.sources) +
170
+ stringsBytes(map.resolvedSources) +
171
+ stringsBytes(map.sourcesContent) +
172
+ (scopes?.bytes ?? 0);
173
+ if (bytes <= maxDecodedBytes) {
174
+ const decoded = DecodedMappings.decode(mappings);
175
+ return {
176
+ map,
177
+ scopes,
178
+ bytes,
179
+ cacheable: true,
180
+ lookup: (positions) => positions.map((position) => decoded.find(position.line, position.column)),
181
+ };
182
+ }
183
+ return { map, scopes, bytes, cacheable: false, lookup: (positions) => findInMappings(mappings, positions) };
184
+ }
185
+ // ── Resolution ────────────────────────────────────────────────────────────
186
+ /** A name a minifier evidently produced ("a", "Xe", "Object.t"). */
187
+ function looksMangled(name) {
188
+ const bare = name.replace(/^(?:async\s+)?(?:new\s+)?/, "");
189
+ const last = bare.split(/[.\s]/).filter(Boolean).pop() ?? "";
190
+ return last.length <= 2;
191
+ }
192
+ function contextFor(map, source, line) {
193
+ let content = null;
194
+ try {
195
+ content = sourceContentFor(map, source);
196
+ }
197
+ catch {
198
+ content = null;
199
+ }
200
+ if (!content)
201
+ return {};
202
+ const lines = content.split(/\r?\n/);
203
+ const index = line - 1;
204
+ if (index < 0 || index >= lines.length)
205
+ return {};
206
+ const cut = (value) => value.slice(0, MAX_CONTEXT_CHARS);
207
+ return {
208
+ contextLine: cut(lines[index]),
209
+ preContext: lines.slice(Math.max(0, index - CONTEXT_LINES), index).map(cut),
210
+ postContext: lines.slice(index + 1, index + 1 + CONTEXT_LINES).map(cut),
211
+ };
212
+ }
213
+ export class Symbolicator {
214
+ fetchMap;
215
+ cache;
216
+ constructor(fetchMap, cache = new SourceMapCache()) {
217
+ this.fetchMap = fetchMap;
218
+ this.cache = cache;
219
+ }
220
+ /**
221
+ * Resolve the JS frames of one stack. Returns a new array; frames that
222
+ * cannot be resolved (no map, no position, non-JS) are returned as given.
223
+ */
224
+ async symbolicate(frames, scope) {
225
+ if (frames.length === 0)
226
+ return frames;
227
+ try {
228
+ return await this.resolveAll(frames, scope);
229
+ }
230
+ catch (err) {
231
+ console.warn("symbolication failed — using parsed frames:", err instanceof Error ? err.message : err);
232
+ return frames;
233
+ }
234
+ }
235
+ debugIdFor(filename, debugIds) {
236
+ if (!debugIds)
237
+ return null;
238
+ const direct = debugIds[filename];
239
+ if (typeof direct === "string" && direct)
240
+ return direct;
241
+ for (const [url, id] of Object.entries(debugIds)) {
242
+ if (typeof id === "string" && id && stripQueryAndHash(url) === filename)
243
+ return id;
244
+ }
245
+ return null;
246
+ }
247
+ keyFor(frame, scope) {
248
+ if (!frame.filename || frame.lineno == null || frame.lineno < 1)
249
+ return null;
250
+ if (!JS_FILE_RE.test(frame.filename))
251
+ return null;
252
+ const path = scriptPath(frame.filename);
253
+ if (!path)
254
+ return null;
255
+ const debugId = this.debugIdFor(frame.filename, scope.debugIds);
256
+ if (!debugId && !scope.release)
257
+ return null;
258
+ return {
259
+ orgId: scope.orgId,
260
+ repositoryId: scope.repositoryId,
261
+ release: scope.release,
262
+ filename: path,
263
+ ...(debugId ? { debugId } : {}),
264
+ };
265
+ }
266
+ async resolveAll(frames, scope) {
267
+ const keys = frames.map((frame) => this.keyFor(frame, scope));
268
+ if (!keys.some(Boolean))
269
+ return frames;
270
+ const maps = await Promise.all(keys.map((key) => (key ? this.cache.get(key, this.fetchMap) : Promise.resolve(null))));
271
+ const resolutions = resolveFrames(frames, maps);
272
+ if (!resolutions.some(Boolean))
273
+ return frames;
274
+ return frames.map((frame, i) => {
275
+ const resolution = resolutions[i];
276
+ if (!resolution)
277
+ return frame;
278
+ const { scope } = resolution;
279
+ if (scope?.kind === "named")
280
+ return { ...resolution.frame, function: scope.name };
281
+ if (scope?.kind === "anonymous")
282
+ return { ...resolution.frame, function: null };
283
+ // No scopes (an older upload), or a function whose name the CLI
284
+ // could not map: innermost first, so the caller is the next frame.
285
+ const callerName = resolutions[i + 1]?.nameHere ?? null;
286
+ const parsed = frame.function;
287
+ const name = callerName ?? (parsed && !looksMangled(parsed) ? parsed : null);
288
+ return { ...resolution.frame, function: name };
289
+ });
290
+ }
291
+ }
292
+ /** A frame's 0-based generated position (stack lines and columns are 1-based). */
293
+ function generatedPosition(frame) {
294
+ return { line: frame.lineno - 1, column: Math.max(0, (frame.colno ?? 1) - 1) };
295
+ }
296
+ /** Resolve every frame that has a map, with one lookup per map: a map too
297
+ * big to keep is read in a single pass for the whole stack. */
298
+ function resolveFrames(frames, maps) {
299
+ const resolutions = frames.map(() => null);
300
+ const byMap = new Map();
301
+ maps.forEach((loaded, index) => {
302
+ if (!loaded)
303
+ return;
304
+ const indexes = byMap.get(loaded);
305
+ if (indexes)
306
+ indexes.push(index);
307
+ else
308
+ byMap.set(loaded, [index]);
309
+ });
310
+ for (const [loaded, indexes] of byMap) {
311
+ const positions = indexes.map((index) => generatedPosition(frames[index]));
312
+ let segments;
313
+ try {
314
+ segments = loaded.lookup(positions);
315
+ }
316
+ catch {
317
+ continue;
318
+ }
319
+ indexes.forEach((frameIndex, k) => {
320
+ try {
321
+ resolutions[frameIndex] = resolveFrame(loaded, frames[frameIndex], positions[k], segments[k] ?? null);
322
+ }
323
+ catch {
324
+ resolutions[frameIndex] = null;
325
+ }
326
+ });
327
+ }
328
+ return resolutions;
329
+ }
330
+ function resolveFrame(loaded, frame, position, segment) {
331
+ const { map } = loaded;
332
+ if (!segment)
333
+ return null;
334
+ const source = map.resolvedSources[segment.source];
335
+ if (!source)
336
+ return null;
337
+ const filename = stripQueryAndHash(source);
338
+ const ignored = (() => {
339
+ try {
340
+ return isIgnored(map, source);
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ })();
346
+ const line = segment.line + 1;
347
+ const resolved = {
348
+ function: frame.function,
349
+ module: null,
350
+ filename,
351
+ lineno: line,
352
+ colno: segment.column + 1,
353
+ inApp: !ignored && isInAppPath(filename),
354
+ resolved: true,
355
+ ...contextFor(map, source, line),
356
+ };
357
+ const name = segment.name >= 0 ? map.names[segment.name] : undefined;
358
+ return {
359
+ frame: resolved,
360
+ nameHere: typeof name === "string" ? name : null,
361
+ scope: loaded.scopes ? loaded.scopes.nameAt(position.line, position.column) : null,
362
+ };
363
+ }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Breadcrumb } from "./breadcrumbs.js";
1
2
  export type RuntimeSource = "browser" | "server";
2
3
  /**
3
4
  * Signal severity. Errors AND warnings/info land in the same occurrences
@@ -44,6 +45,60 @@ export interface RuntimeOccurrenceInput {
44
45
  expected?: boolean;
45
46
  /** `autter.request.id` — joins the occurrence to its request summary. */
46
47
  requestId?: string;
48
+ /**
49
+ * SDK fingerprint override (browser `fingerprint`, OTLP/log
50
+ * `autter.fingerprint`): ≤10 scrubbed values, each ≤200 chars. Hashed into
51
+ * the `manual-v1` candidate by `manualFingerprint` (fingerprint-override.ts).
52
+ * Omitted when absent.
53
+ */
54
+ fingerprintOverride?: string[];
55
+ /** Steps before the error, oldest first, ≤ 30 (validated by breadcrumbs.ts). */
56
+ breadcrumbs?: Breadcrumb[];
57
+ /**
58
+ * Browser only: script URL → debug id for the scripts on the page, used
59
+ * to find their source maps (EI-13). Input-only: never stored or sent to
60
+ * the sink.
61
+ */
62
+ debugIds?: Record<string, string>;
63
+ }
64
+ /**
65
+ * One stack frame, innermost (crash site) first. The sink wire shape
66
+ * (contract §1) — also stored as JSON in `runtime_error_occurrences.frames`.
67
+ */
68
+ export interface StackFrame {
69
+ /** Resolved name when symbolicated, else as parsed. */
70
+ function: string | null;
71
+ /** Package / module / class when the language has one. */
72
+ module: string | null;
73
+ /** Path as resolved (the source map `sources` entry when resolved), query/hash stripped. */
74
+ filename: string | null;
75
+ lineno: number | null;
76
+ colno: number | null;
77
+ /** False for dependency, runtime and extension frames (stack-frames.ts §3). */
78
+ inApp: boolean;
79
+ /** True when a source map resolved the frame. */
80
+ resolved: boolean;
81
+ /** Only when `sourcesContent` supplied it (≤ 300 chars). */
82
+ contextLine?: string;
83
+ /** ≤ 3 lines each, ≤ 300 chars per line. */
84
+ preContext?: string[];
85
+ postContext?: string[];
86
+ }
87
+ /**
88
+ * Every grouping scheme an issue identity can come from (contract §1).
89
+ * The ingester computes `manual-v1`, `code-v1`, `frames-v3` and
90
+ * `message-v1`; `rule-v1` and `external-v1` exist only on the platform.
91
+ */
92
+ export type FingerprintSchemeV2 = "manual-v1" | "rule-v1" | "code-v1" | "frames-v3" | "message-v1" | "external-v1";
93
+ export interface FingerprintCandidate {
94
+ scheme: FingerprintSchemeV2;
95
+ /** 32 lowercase hex characters. */
96
+ fingerprint: string;
97
+ }
98
+ /** What went into a fingerprint, for the "why these were grouped" UI. */
99
+ export interface FingerprintRecord {
100
+ scheme: string;
101
+ parts: string[];
47
102
  }
48
103
  /**
49
104
  * Derived, aggregation-ready fields — computed once at ingest (from the
@@ -52,9 +107,22 @@ export interface RuntimeOccurrenceInput {
52
107
  */
53
108
  export interface RuntimeOccurrence extends RuntimeOccurrenceInput {
54
109
  occurrenceId: string;
110
+ /**
111
+ * The LEGACY identity, byte-for-byte as before 1.6.0 so an older
112
+ * platform groups exactly as it did: "code-v1" or "message-v1".
113
+ */
55
114
  fingerprint: string;
56
115
  /** "code-v1" when grouped by a valid error code, else "message-v1". */
57
116
  fingerprintScheme: "message-v1" | "code-v1";
117
+ /** Parsed (and, for browser JS, symbolicated) frames, innermost first, ≤ 50. */
118
+ frames?: StackFrame[];
119
+ /**
120
+ * Every applicable identity, highest precedence first (manual-v1,
121
+ * code-v1, frames-v3, message-v1); always ends with the legacy one.
122
+ */
123
+ fingerprints?: FingerprintCandidate[];
124
+ /** What went into the frames-v3 fingerprint, when one was computed. */
125
+ fingerprintRecord?: FingerprintRecord;
58
126
  /** normalizeRoute(route) — "/users/:id/orders/:id"; GROUP BY-safe. */
59
127
  routeNormalized: string;
60
128
  /** normalizeMessage(message) — ids/numbers/strings templated out. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Self-hostable OTLP + browser-error ingest service for Autter Runtime: normalises telemetry into a per-repo ClickHouse data model",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@clickhouse/client": "^1.12.0",
22
+ "@jridgewell/trace-mapping": "^0.3.31",
22
23
  "express": "^4.21.2",
23
24
  "protobufjs": "^7.6.5",
24
25
  "zod": "^3.24.1"