@autter/otlp-ingester 1.5.0 → 1.6.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.
@@ -0,0 +1,257 @@
1
+ import { isIgnored, originalPositionFor, sourceContentFor, TraceMap, } from "@jridgewell/trace-mapping";
2
+ import { isInAppPath, stripQueryAndHash } from "./stack-frames.js";
3
+ const JS_FILE_RE = /\.[cm]?js$/i;
4
+ const MAX_CONTEXT_CHARS = 300;
5
+ const CONTEXT_LINES = 3;
6
+ /** Path-only form of a script URL — the key uploads are stored under. */
7
+ export function scriptPath(filename) {
8
+ try {
9
+ return new URL(filename, "https://autter.invalid").pathname;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /**
16
+ * Decoded mappings take several times the JSON's size; the budget counts an
17
+ * estimate so a handful of large bundles cannot exhaust a small container.
18
+ */
19
+ const DECODED_WEIGHT_FACTOR = 8;
20
+ export class SourceMapCache {
21
+ entries = new Map();
22
+ inFlight = new Map();
23
+ weight = 0;
24
+ maxBytes;
25
+ maxEntries;
26
+ hitTtlMs;
27
+ missTtlMs;
28
+ errorTtlMs;
29
+ now;
30
+ /** Fetches made (cache misses) — for tests and /healthz-style debugging. */
31
+ fetches = 0;
32
+ constructor(options = {}) {
33
+ this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
34
+ this.maxEntries = options.maxEntries ?? 500;
35
+ this.hitTtlMs = options.hitTtlMs ?? 60 * 60_000;
36
+ this.missTtlMs = options.missTtlMs ?? 5 * 60_000;
37
+ this.errorTtlMs = options.errorTtlMs ?? 30_000;
38
+ this.now = options.now ?? Date.now;
39
+ }
40
+ static keyOf(key) {
41
+ const scope = `${key.orgId}\u0000${key.repositoryId}\u0000`;
42
+ return key.debugId
43
+ ? `${scope}d\u0000${key.debugId}`
44
+ : `${scope}r\u0000${key.release ?? ""}\u0000${key.filename}`;
45
+ }
46
+ get size() {
47
+ return this.entries.size;
48
+ }
49
+ /** The parsed map for `key` (null when none), fetching at most once at a time. */
50
+ async get(key, fetchMap) {
51
+ const id = SourceMapCache.keyOf(key);
52
+ const hit = this.entries.get(id);
53
+ if (hit) {
54
+ if (hit.expiresAt > this.now()) {
55
+ // Refresh recency: Map iteration order is insertion order.
56
+ this.entries.delete(id);
57
+ this.entries.set(id, hit);
58
+ return hit.map;
59
+ }
60
+ this.remove(id);
61
+ }
62
+ const pending = this.inFlight.get(id);
63
+ if (pending)
64
+ return pending;
65
+ const load = this.load(id, key, fetchMap).finally(() => this.inFlight.delete(id));
66
+ this.inFlight.set(id, load);
67
+ return load;
68
+ }
69
+ async load(id, key, fetchMap) {
70
+ this.fetches += 1;
71
+ let json;
72
+ try {
73
+ json = await fetchMap(key);
74
+ }
75
+ catch (err) {
76
+ console.warn("source map lookup failed — frames stay unresolved:", err instanceof Error ? err.message : err);
77
+ this.store(id, null, 0, this.errorTtlMs);
78
+ return null;
79
+ }
80
+ if (!json) {
81
+ this.store(id, null, 0, this.missTtlMs);
82
+ return null;
83
+ }
84
+ let map;
85
+ try {
86
+ map = new TraceMap(json);
87
+ }
88
+ catch {
89
+ // A malformed upload is a miss until it is replaced.
90
+ this.store(id, null, 0, this.missTtlMs);
91
+ return null;
92
+ }
93
+ this.store(id, map, json.length * DECODED_WEIGHT_FACTOR, this.hitTtlMs);
94
+ return map;
95
+ }
96
+ store(id, map, weight, ttlMs) {
97
+ // A map bigger than the whole budget serves this lookup but is not kept:
98
+ // caching it would evict everything else.
99
+ if (weight > this.maxBytes)
100
+ return;
101
+ this.remove(id);
102
+ this.entries.set(id, { map, weight, expiresAt: this.now() + ttlMs });
103
+ this.weight += weight;
104
+ while ((this.weight > this.maxBytes || this.entries.size > this.maxEntries) &&
105
+ this.entries.size > 1) {
106
+ const oldest = this.entries.keys().next().value;
107
+ this.remove(oldest);
108
+ }
109
+ }
110
+ remove(id) {
111
+ const entry = this.entries.get(id);
112
+ if (!entry)
113
+ return;
114
+ this.entries.delete(id);
115
+ this.weight -= entry.weight;
116
+ }
117
+ }
118
+ // ── Resolution ────────────────────────────────────────────────────────────
119
+ /** A name a minifier evidently produced ("a", "Xe", "Object.t"). */
120
+ function looksMangled(name) {
121
+ const bare = name.replace(/^(?:async\s+)?(?:new\s+)?/, "");
122
+ const last = bare.split(/[.\s]/).filter(Boolean).pop() ?? "";
123
+ return last.length <= 2;
124
+ }
125
+ function contextFor(map, source, line) {
126
+ let content = null;
127
+ try {
128
+ content = sourceContentFor(map, source);
129
+ }
130
+ catch {
131
+ content = null;
132
+ }
133
+ if (!content)
134
+ return {};
135
+ const lines = content.split(/\r?\n/);
136
+ const index = line - 1;
137
+ if (index < 0 || index >= lines.length)
138
+ return {};
139
+ const cut = (value) => value.slice(0, MAX_CONTEXT_CHARS);
140
+ return {
141
+ contextLine: cut(lines[index]),
142
+ preContext: lines.slice(Math.max(0, index - CONTEXT_LINES), index).map(cut),
143
+ postContext: lines.slice(index + 1, index + 1 + CONTEXT_LINES).map(cut),
144
+ };
145
+ }
146
+ export class Symbolicator {
147
+ fetchMap;
148
+ cache;
149
+ constructor(fetchMap, cache = new SourceMapCache()) {
150
+ this.fetchMap = fetchMap;
151
+ this.cache = cache;
152
+ }
153
+ /**
154
+ * Resolve the JS frames of one stack. Returns a new array; frames that
155
+ * cannot be resolved (no map, no position, non-JS) are returned as given.
156
+ */
157
+ async symbolicate(frames, scope) {
158
+ if (frames.length === 0)
159
+ return frames;
160
+ try {
161
+ return await this.resolveAll(frames, scope);
162
+ }
163
+ catch (err) {
164
+ console.warn("symbolication failed — using parsed frames:", err instanceof Error ? err.message : err);
165
+ return frames;
166
+ }
167
+ }
168
+ debugIdFor(filename, debugIds) {
169
+ if (!debugIds)
170
+ return null;
171
+ const direct = debugIds[filename];
172
+ if (typeof direct === "string" && direct)
173
+ return direct;
174
+ for (const [url, id] of Object.entries(debugIds)) {
175
+ if (typeof id === "string" && id && stripQueryAndHash(url) === filename)
176
+ return id;
177
+ }
178
+ return null;
179
+ }
180
+ keyFor(frame, scope) {
181
+ if (!frame.filename || frame.lineno == null || frame.lineno < 1)
182
+ return null;
183
+ if (!JS_FILE_RE.test(frame.filename))
184
+ return null;
185
+ const path = scriptPath(frame.filename);
186
+ if (!path)
187
+ return null;
188
+ const debugId = this.debugIdFor(frame.filename, scope.debugIds);
189
+ if (!debugId && !scope.release)
190
+ return null;
191
+ return {
192
+ orgId: scope.orgId,
193
+ repositoryId: scope.repositoryId,
194
+ release: scope.release,
195
+ filename: path,
196
+ ...(debugId ? { debugId } : {}),
197
+ };
198
+ }
199
+ async resolveAll(frames, scope) {
200
+ const keys = frames.map((frame) => this.keyFor(frame, scope));
201
+ if (!keys.some(Boolean))
202
+ return frames;
203
+ const maps = await Promise.all(keys.map((key) => (key ? this.cache.get(key, this.fetchMap) : Promise.resolve(null))));
204
+ const resolutions = frames.map((frame, i) => {
205
+ const map = maps[i];
206
+ if (!map)
207
+ return null;
208
+ try {
209
+ return resolveFrame(map, frame);
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ });
215
+ if (!resolutions.some(Boolean))
216
+ return frames;
217
+ return frames.map((frame, i) => {
218
+ const resolution = resolutions[i];
219
+ if (!resolution)
220
+ return frame;
221
+ // Innermost first: the caller is the next frame.
222
+ const callerName = resolutions[i + 1]?.nameHere ?? null;
223
+ const parsed = frame.function;
224
+ const name = callerName ?? (parsed && !looksMangled(parsed) ? parsed : null);
225
+ return { ...resolution.frame, function: name };
226
+ });
227
+ }
228
+ }
229
+ function resolveFrame(map, frame) {
230
+ const position = originalPositionFor(map, {
231
+ line: frame.lineno,
232
+ // Stack columns are 1-based, source map columns 0-based.
233
+ column: Math.max(0, (frame.colno ?? 1) - 1),
234
+ });
235
+ if (!position.source || position.line == null)
236
+ return null;
237
+ const filename = stripQueryAndHash(position.source);
238
+ const ignored = (() => {
239
+ try {
240
+ return isIgnored(map, position.source);
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ })();
246
+ const resolved = {
247
+ function: frame.function,
248
+ module: null,
249
+ filename,
250
+ lineno: position.line,
251
+ colno: position.column == null ? null : position.column + 1,
252
+ inApp: !ignored && isInAppPath(filename),
253
+ resolved: true,
254
+ ...contextFor(map, position.source, position.line),
255
+ };
256
+ return { frame: resolved, nameHere: position.name ?? null };
257
+ }
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.0",
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"