@autter/otlp-ingester 1.9.0 → 1.11.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.
@@ -4,10 +4,19 @@ import { ScopeIndex } from "./source-map-scopes.js";
4
4
  import type { SourceMapKey } from "./source-maps-lookup.js";
5
5
  import type { StackFrame } from "./types.js";
6
6
  /**
7
- * Symbolication at ingest: minified browser frames are resolved through the
8
- * source maps uploaded for the release (`/v1/sourcemaps`) BEFORE they are
9
- * fingerprinted, so `frames-v3` hashes `checkout.tsx`, not
10
- * `index-5f3a9c1e.js` — and an issue survives deploys and preview domains.
7
+ * Symbolication at ingest: minified browser frames, and bundled Node and
8
+ * edge frames (RF-05), are resolved through the source maps uploaded for
9
+ * the release (`/v1/sourcemaps`) BEFORE they are fingerprinted, so
10
+ * `frames-v3` hashes `checkout.tsx`, not `index-5f3a9c1e.js` — and an issue
11
+ * survives deploys and preview domains.
12
+ *
13
+ * - Browser frames find their map by debug id, else by release + the
14
+ * script's path. Server frames (`server: true`) by debug id (server SDKs
15
+ * send `autter.debug_ids`), else by the release's map stored
16
+ * under the longest suffix of the path the process ran the bundle from
17
+ * (source-maps-lookup.ts): the upload knows `/server.js`, the stack says
18
+ * `/app/dist/server.js`. Only in-app server frames are looked up — a
19
+ * `node_modules/` or `node:` frame never belongs to an uploaded bundle.
11
20
  *
12
21
  * - Function names: when the map carries function scopes
13
22
  * (`x_autter_scopes`, uploaded by @autter/runtime-cli after 1.0.0), a
@@ -22,24 +31,38 @@ import type { StackFrame } from "./types.js";
22
31
  * and for handlers a framework calls, which is why scopes exist.
23
32
  * - Sources the map lists in `ignoreList` / `x_google_ignoreList` are not
24
33
  * in-app; a resolved path under node_modules/ etc. is not in-app either.
25
- * - Context lines only when the map carries `sourcesContent` (the upload
26
- * strips it today, so there are none for now).
34
+ * - Context lines (RF-05): an in-app resolved frame gets its original line
35
+ * and up to CONTEXT_LINES lines before and after it, each cut to 300
36
+ * characters and scrubbed like the stack, when the map carries the
37
+ * source's `sourcesContent` (kept at upload for in-app sources,
38
+ * source-maps.ts). Dependency frames get none.
27
39
  * - Never fails ingest: any error leaves the frame as parsed.
28
40
  *
29
41
  * Parsed maps are cached per (org, repository, release, file) — or per debug
30
42
  * id — in a byte-bounded LRU, so a busy issue never refetches its map. A map
31
43
  * that would take more than half of that budget is used for its lookup and
32
44
  * not kept (source-map-mappings.ts reads it without decoding it), so one huge
33
- * vendor chunk cannot flush every other map.
45
+ * vendor chunk cannot flush every other map. A map's source contents are
46
+ * never part of its entry (it caches exactly as a map without them would):
47
+ * they sit in a separate store, one file per entry, bounded the same way and
48
+ * dropped with the map.
34
49
  */
35
50
  export type MapFetcher = (key: SourceMapKey) => Promise<string | null>;
36
51
  export interface SymbolicationScope {
37
52
  orgId: string;
38
53
  repositoryId: string;
39
54
  release: string | null;
40
- /** Script URL → debug id (EI-13), when the browser sent them. */
55
+ /** Script URL (or, on a server, script path) → debug id (EI-13), when the SDK sent them. */
41
56
  debugIds?: Record<string, string> | null;
57
+ /**
58
+ * A Node or edge stack (RF-05): only in-app frames are looked up, their
59
+ * paths are normalised (serverScriptPath), and without a debug id the
60
+ * release's map is found by path suffix.
61
+ */
62
+ server?: boolean;
42
63
  }
64
+ /** Lines kept before and after an in-app frame's own line (RF-05). */
65
+ export declare const CONTEXT_LINES = 5;
43
66
  /**
44
67
  * In-app browser frames a source map could resolve, counted after
45
68
  * symbolication (EI-27): `resolved` ones, and `unresolved` .js/.mjs/.cjs
@@ -55,10 +78,25 @@ export declare function inAppFrameResolution(frames: readonly StackFrame[] | und
55
78
  };
56
79
  /** Path-only form of a script URL — the key uploads are stored under. */
57
80
  export declare function scriptPath(filename: string): string | null;
81
+ /**
82
+ * The path form of a server frame's file, comparable with stored upload
83
+ * paths: `file:///app/dist/index.mjs` → `/app/dist/index.mjs`,
84
+ * `C:\app\dist\index.js` → `/app/dist/index.js`, `index.js` (a Worker
85
+ * script) → `/index.js`. Null when it is not a path.
86
+ */
87
+ export declare function serverScriptPath(filename: string): string | null;
58
88
  export interface SourceMapCacheOptions {
59
89
  /** Budget for the estimated in-memory size of parsed maps (default 64 MiB).
60
90
  * A map that would take more than half of it is not cached. */
61
91
  maxBytes?: number;
92
+ /**
93
+ * Budget for the source contents of cached maps (RF-05; default:
94
+ * `maxBytes`). They are kept apart from the maps, one entry per source
95
+ * file and bounded the same way (least recently used first, a file over
96
+ * half the budget not kept), so a map caches exactly as it would without
97
+ * them.
98
+ */
99
+ maxContentBytes?: number;
62
100
  maxEntries?: number;
63
101
  /** How long a found map is reused (default 1 h). */
64
102
  hitTtlMs?: number;
@@ -70,22 +108,43 @@ export interface SourceMapCacheOptions {
70
108
  }
71
109
  /** A parsed map: its metadata, its function scopes, and position lookups. */
72
110
  export interface LoadedSourceMap {
73
- /** Sources, names, ignore list and sourcesContent. Its own `mappings`
74
- * are left empty: positions go through `lookup`. */
111
+ /** Sources, names and ignore list. Its own `mappings` are left empty
112
+ * (positions go through `lookup`), and it holds no `sourcesContent`
113
+ * (see `contentOf`). */
75
114
  map: TraceMap;
76
115
  scopes: ScopeIndex | null;
77
116
  /** Original positions for 0-based generated positions (one call per stack). */
78
117
  lookup(positions: readonly GeneratedPosition[]): Array<MappedSegment | null>;
79
- /** Estimated heap: decoded mappings (when decoded), strings and scopes. */
118
+ /** Estimated heap: decoded mappings (when decoded), strings and scopes;
119
+ * source contents are not part of it. */
80
120
  bytes: number;
81
121
  /** False when the map was too big to keep, so its mappings were not decoded. */
82
122
  cacheable: boolean;
123
+ /**
124
+ * The stored original source of `sources[index]` (in-app sources only),
125
+ * or null. For a cached map it is read from the cache's separate content
126
+ * store, so a content evicted there gives null (no context lines) until
127
+ * the map is parsed again.
128
+ */
129
+ contentOf(index: number): string | null;
130
+ }
131
+ /** A parsed map and its source contents, which are never part of the map's cache entry. */
132
+ export interface ParsedSourceMap {
133
+ loaded: LoadedSourceMap;
134
+ /** `sourcesContent` as stored (aligned with `sources`), or null. */
135
+ contents: Array<string | null> | null;
83
136
  }
84
137
  export declare class SourceMapCache {
85
138
  private readonly entries;
86
139
  private readonly inFlight;
87
140
  private weight;
141
+ /** Source contents of cached maps, keyed by parse id and source index. */
142
+ private readonly contents;
143
+ private contentWeight;
144
+ /** Distinguishes every parse, so a re-parsed (maybe re-uploaded) map never reads an older parse's contents. */
145
+ private parses;
88
146
  private readonly maxBytes;
147
+ private readonly maxContentBytes;
89
148
  private readonly maxEntries;
90
149
  private readonly hitTtlMs;
91
150
  private readonly missTtlMs;
@@ -100,9 +159,14 @@ export declare class SourceMapCache {
100
159
  get size(): number;
101
160
  /** Estimated bytes of the cached maps (what `maxBytes` bounds). */
102
161
  get bytes(): number;
162
+ /** Estimated bytes of the cached source contents (what `maxContentBytes` bounds). */
163
+ get contentBytes(): number;
103
164
  /** The parsed map for `key` (null when none), fetching at most once at a time. */
104
165
  get(key: SourceMapKey, fetchMap: MapFetcher): Promise<LoadedSourceMap | null>;
105
166
  private load;
167
+ private contentOf;
168
+ private storeContent;
169
+ private removeContent;
106
170
  private store;
107
171
  private remove;
108
172
  }
@@ -113,7 +177,15 @@ export declare class SourceMapCache {
113
177
  * once into a compact table; otherwise they are left encoded and each
114
178
  * lookup reads them in one pass (`cacheable: false`). Throws when the map
115
179
  * itself is unusable.
180
+ *
181
+ * `sourcesContent` (RF-05) is taken out before anything else: the TraceMap
182
+ * never holds it and `bytes` never counts it, so whether a map is decoded
183
+ * and cached is decided exactly as for a map uploaded without contents.
184
+ * They are returned beside the map; SourceMapCache keeps them in their own
185
+ * store.
116
186
  */
187
+ export declare function parseSourceMapWithContents(json: string, maxDecodedBytes?: number): ParsedSourceMap;
188
+ /** parseSourceMapWithContents, the map alone (its contents through `contentOf`). */
117
189
  export declare function parseSourceMap(json: string, maxDecodedBytes?: number): LoadedSourceMap;
118
190
  export declare class Symbolicator {
119
191
  private readonly fetchMap;
@@ -1,10 +1,12 @@
1
- import { isIgnored, sourceContentFor, TraceMap, } from "@jridgewell/trace-mapping";
1
+ import { isIgnored, TraceMap } from "@jridgewell/trace-mapping";
2
2
  import { DecodedMappings, decodedMappingsBytes, findInMappings, } from "./source-map-mappings.js";
3
+ import { scrubText } from "./redact.js";
3
4
  import { SCOPES_KEY, ScopeIndex } from "./source-map-scopes.js";
4
5
  import { isInAppPath, stripQueryAndHash } from "./stack-frames.js";
5
6
  const JS_FILE_RE = /\.[cm]?js$/i;
6
7
  const MAX_CONTEXT_CHARS = 300;
7
- const CONTEXT_LINES = 3;
8
+ /** Lines kept before and after an in-app frame's own line (RF-05). */
9
+ export const CONTEXT_LINES = 5;
8
10
  /**
9
11
  * In-app browser frames a source map could resolve, counted after
10
12
  * symbolication (EI-27): `resolved` ones, and `unresolved` .js/.mjs/.cjs
@@ -36,6 +38,25 @@ export function scriptPath(filename) {
36
38
  return null;
37
39
  }
38
40
  }
41
+ /**
42
+ * The path form of a server frame's file, comparable with stored upload
43
+ * paths: `file:///app/dist/index.mjs` → `/app/dist/index.mjs`,
44
+ * `C:\app\dist\index.js` → `/app/dist/index.js`, `index.js` (a Worker
45
+ * script) → `/index.js`. Null when it is not a path.
46
+ */
47
+ export function serverScriptPath(filename) {
48
+ let path = filename;
49
+ if (/^file:\/\//i.test(path)) {
50
+ try {
51
+ path = new URL(path).pathname;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ path = path.replace(/\\/g, "/").replace(/^\/?[A-Za-z]:(?=\/)/, "");
58
+ return scriptPath(path);
59
+ }
39
60
  /**
40
61
  * The largest share of the budget one map may take. A bigger one is used
41
62
  * for its lookup and dropped: keeping it would evict most other maps, and
@@ -46,7 +67,13 @@ export class SourceMapCache {
46
67
  entries = new Map();
47
68
  inFlight = new Map();
48
69
  weight = 0;
70
+ /** Source contents of cached maps, keyed by parse id and source index. */
71
+ contents = new Map();
72
+ contentWeight = 0;
73
+ /** Distinguishes every parse, so a re-parsed (maybe re-uploaded) map never reads an older parse's contents. */
74
+ parses = 0;
49
75
  maxBytes;
76
+ maxContentBytes;
50
77
  maxEntries;
51
78
  hitTtlMs;
52
79
  missTtlMs;
@@ -58,6 +85,7 @@ export class SourceMapCache {
58
85
  bypassed = 0;
59
86
  constructor(options = {}) {
60
87
  this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
88
+ this.maxContentBytes = options.maxContentBytes ?? this.maxBytes;
61
89
  this.maxEntries = options.maxEntries ?? 500;
62
90
  this.hitTtlMs = options.hitTtlMs ?? 60 * 60_000;
63
91
  this.missTtlMs = options.missTtlMs ?? 5 * 60_000;
@@ -66,9 +94,11 @@ export class SourceMapCache {
66
94
  }
67
95
  static keyOf(key) {
68
96
  const scope = `${key.orgId}\u0000${key.repositoryId}\u0000`;
97
+ // A suffix lookup (server frame) can find another map than an exact one
98
+ // for the same path, so the two never share an entry.
69
99
  return key.debugId
70
100
  ? `${scope}d\u0000${key.debugId}`
71
- : `${scope}r\u0000${key.release ?? ""}\u0000${key.filename}`;
101
+ : `${scope}${key.suffix ? "s" : "r"}\u0000${key.release ?? ""}\u0000${key.filename}`;
72
102
  }
73
103
  get size() {
74
104
  return this.entries.size;
@@ -77,6 +107,10 @@ export class SourceMapCache {
77
107
  get bytes() {
78
108
  return this.weight;
79
109
  }
110
+ /** Estimated bytes of the cached source contents (what `maxContentBytes` bounds). */
111
+ get contentBytes() {
112
+ return this.contentWeight;
113
+ }
80
114
  /** The parsed map for `key` (null when none), fetching at most once at a time. */
81
115
  async get(key, fetchMap) {
82
116
  const id = SourceMapCache.keyOf(key);
@@ -112,28 +146,74 @@ export class SourceMapCache {
112
146
  this.store(id, null, 0, this.missTtlMs);
113
147
  return null;
114
148
  }
115
- let loaded;
149
+ let parsed;
116
150
  try {
117
- loaded = parseSourceMap(json, this.maxBytes * MAX_SHARE_OF_BUDGET);
151
+ parsed = parseSourceMapWithContents(json, this.maxBytes * MAX_SHARE_OF_BUDGET);
118
152
  }
119
153
  catch {
120
154
  // A malformed upload is a miss until it is replaced.
121
155
  this.store(id, null, 0, this.missTtlMs);
122
156
  return null;
123
157
  }
124
- if (loaded.cacheable)
125
- this.store(id, loaded, loaded.bytes, this.hitTtlMs);
126
- else
158
+ const { loaded, contents } = parsed;
159
+ if (!loaded.cacheable) {
160
+ // Used for this lookup only: its contents go with it.
127
161
  this.bypassed += 1;
162
+ return loaded;
163
+ }
164
+ // The map's entry never holds the contents (it weighs what it did
165
+ // before RF-05): they go to their own store, one file per entry.
166
+ const parse = ++this.parses;
167
+ const contentKeys = [];
168
+ contents?.forEach((content, index) => {
169
+ if (typeof content !== "string" || !content)
170
+ return;
171
+ const key = `${parse}\u0000${index}`;
172
+ contentKeys.push(key);
173
+ this.storeContent(key, content);
174
+ });
175
+ loaded.contentOf = (index) => this.contentOf(`${parse}\u0000${index}`);
176
+ this.store(id, loaded, loaded.bytes, this.hitTtlMs, contentKeys);
128
177
  return loaded;
129
178
  }
130
- store(id, map, weight, ttlMs) {
179
+ contentOf(key) {
180
+ const entry = this.contents.get(key);
181
+ if (!entry)
182
+ return null;
183
+ if (entry.expiresAt <= this.now()) {
184
+ this.removeContent(key);
185
+ return null;
186
+ }
187
+ this.contents.delete(key);
188
+ this.contents.set(key, entry);
189
+ return entry.content;
190
+ }
191
+ storeContent(key, content) {
192
+ const weight = 2 * content.length + 64;
193
+ // Bounded like the maps: one file may take at most half the budget.
194
+ if (weight > this.maxContentBytes * MAX_SHARE_OF_BUDGET)
195
+ return;
196
+ this.removeContent(key);
197
+ this.contents.set(key, { content, weight, expiresAt: this.now() + this.hitTtlMs });
198
+ this.contentWeight += weight;
199
+ while (this.contentWeight > this.maxContentBytes && this.contents.size > 1) {
200
+ this.removeContent(this.contents.keys().next().value);
201
+ }
202
+ }
203
+ removeContent(key) {
204
+ const entry = this.contents.get(key);
205
+ if (!entry)
206
+ return;
207
+ this.contents.delete(key);
208
+ this.contentWeight -= entry.weight;
209
+ }
210
+ store(id, map, weight, ttlMs, contentKeys) {
131
211
  // A map bigger than the whole budget serves this lookup but is not kept:
132
212
  // caching it would evict everything else.
133
213
  if (weight > this.maxBytes)
134
214
  return;
135
215
  this.remove(id);
136
- this.entries.set(id, { map, weight, expiresAt: this.now() + ttlMs });
216
+ this.entries.set(id, { map, weight, expiresAt: this.now() + ttlMs, ...(contentKeys?.length ? { contentKeys } : {}) });
137
217
  this.weight += weight;
138
218
  while ((this.weight > this.maxBytes || this.entries.size > this.maxEntries) &&
139
219
  this.entries.size > 1) {
@@ -147,6 +227,8 @@ export class SourceMapCache {
147
227
  return;
148
228
  this.entries.delete(id);
149
229
  this.weight -= entry.weight;
230
+ for (const key of entry.contentKeys ?? [])
231
+ this.removeContent(key);
150
232
  }
151
233
  }
152
234
  /** Rough heap of a list of strings (V8 strings plus array slots). */
@@ -165,15 +247,24 @@ function stringsBytes(list) {
165
247
  * once into a compact table; otherwise they are left encoded and each
166
248
  * lookup reads them in one pass (`cacheable: false`). Throws when the map
167
249
  * itself is unusable.
250
+ *
251
+ * `sourcesContent` (RF-05) is taken out before anything else: the TraceMap
252
+ * never holds it and `bytes` never counts it, so whether a map is decoded
253
+ * and cached is decided exactly as for a map uploaded without contents.
254
+ * They are returned beside the map; SourceMapCache keeps them in their own
255
+ * store.
168
256
  */
169
- export function parseSourceMap(json, maxDecodedBytes = Number.POSITIVE_INFINITY) {
257
+ export function parseSourceMapWithContents(json, maxDecodedBytes = Number.POSITIVE_INFINITY) {
170
258
  const raw = JSON.parse(json);
171
259
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
172
260
  throw new Error("not a source map");
173
- const fields = raw;
261
+ const { sourcesContent, ...fields } = raw;
174
262
  const { mappings } = fields;
175
263
  if (typeof mappings !== "string")
176
264
  throw new Error("no mappings");
265
+ const contents = Array.isArray(sourcesContent)
266
+ ? sourcesContent.map((content) => (typeof content === "string" ? content : null))
267
+ : null;
177
268
  // TraceMap copies only the standard fields, but given an object that has
178
269
  // its private `_decodedMemo` it would return that object as is. It gets
179
270
  // no mappings: decoding them is this module's job.
@@ -190,19 +281,41 @@ export function parseSourceMap(json, maxDecodedBytes = Number.POSITIVE_INFINITY)
190
281
  stringsBytes(map.names) +
191
282
  stringsBytes(map.sources) +
192
283
  stringsBytes(map.resolvedSources) +
193
- stringsBytes(map.sourcesContent) +
194
284
  (scopes?.bytes ?? 0);
285
+ // Standalone use (and a map too big to cache): the contents travel with
286
+ // this parse; SourceMapCache replaces this for the maps it keeps. Built
287
+ // outside this scope so `lookup`'s closure never holds the contents.
288
+ const contentOf = contentsReader(contents);
195
289
  if (bytes <= maxDecodedBytes) {
196
290
  const decoded = DecodedMappings.decode(mappings);
197
291
  return {
198
- map,
199
- scopes,
200
- bytes,
201
- cacheable: true,
202
- lookup: (positions) => positions.map((position) => decoded.find(position.line, position.column)),
292
+ loaded: {
293
+ map,
294
+ scopes,
295
+ bytes,
296
+ cacheable: true,
297
+ lookup: (positions) => positions.map((position) => decoded.find(position.line, position.column)),
298
+ contentOf,
299
+ },
300
+ contents,
203
301
  };
204
302
  }
205
- return { map, scopes, bytes, cacheable: false, lookup: (positions) => findInMappings(mappings, positions) };
303
+ return {
304
+ loaded: { map, scopes, bytes, cacheable: false, lookup: (positions) => findInMappings(mappings, positions), contentOf },
305
+ contents,
306
+ };
307
+ }
308
+ /**
309
+ * `contentOf` over a parse's own contents. A separate function on purpose:
310
+ * closures made in one scope share it, so a reader made next to `lookup`
311
+ * would keep every source content alive for as long as the cached map.
312
+ */
313
+ function contentsReader(contents) {
314
+ return (index) => contents?.[index] ?? null;
315
+ }
316
+ /** parseSourceMapWithContents, the map alone (its contents through `contentOf`). */
317
+ export function parseSourceMap(json, maxDecodedBytes = Number.POSITIVE_INFINITY) {
318
+ return parseSourceMapWithContents(json, maxDecodedBytes).loaded;
206
319
  }
207
320
  // ── Resolution ────────────────────────────────────────────────────────────
208
321
  /** A name a minifier evidently produced ("a", "Xe", "Object.t"). */
@@ -211,25 +324,56 @@ function looksMangled(name) {
211
324
  const last = bare.split(/[.\s]/).filter(Boolean).pop() ?? "";
212
325
  return last.length <= 2;
213
326
  }
214
- function contextFor(map, source, line) {
327
+ /**
328
+ * Lines `index - radius` … `index + radius` (0-based) of `content`, found
329
+ * without splitting the whole file (a busy frame in a big file would split
330
+ * it on every event); `at` is where line `index` sits in `lines`. Null when
331
+ * the file is shorter than `index + 1` lines.
332
+ */
333
+ function linesAround(content, index, radius) {
334
+ if (index < 0)
335
+ return null;
336
+ const first = Math.max(0, index - radius);
337
+ let start = 0;
338
+ for (let line = 0; line < first; line++) {
339
+ const newline = content.indexOf("\n", start);
340
+ if (newline < 0)
341
+ return null;
342
+ start = newline + 1;
343
+ }
344
+ const lines = [];
345
+ for (let line = first; line <= index + radius; line++) {
346
+ const newline = content.indexOf("\n", start);
347
+ const end = newline < 0 ? content.length : newline;
348
+ lines.push(content.slice(start, end).replace(/\r$/, ""));
349
+ if (newline < 0)
350
+ break;
351
+ start = newline + 1;
352
+ }
353
+ const at = index - first;
354
+ return at < lines.length ? { lines, at } : null;
355
+ }
356
+ function contextFor(loaded, sourceIndex, line) {
215
357
  let content = null;
216
358
  try {
217
- content = sourceContentFor(map, source);
359
+ content = loaded.contentOf(sourceIndex);
218
360
  }
219
361
  catch {
220
362
  content = null;
221
363
  }
222
364
  if (!content)
223
365
  return {};
224
- const lines = content.split(/\r?\n/);
225
- const index = line - 1;
226
- if (index < 0 || index >= lines.length)
366
+ const window = linesAround(content, line - 1, CONTEXT_LINES);
367
+ if (!window)
227
368
  return {};
228
- const cut = (value) => value.slice(0, MAX_CONTEXT_CHARS);
369
+ // Source code can hold secrets: scrubbed like the stack. Cut first so the
370
+ // scrub work stays bounded, with slack so a secret straddling the cut is
371
+ // still seen whole.
372
+ const clean = (value) => scrubText(value.slice(0, MAX_CONTEXT_CHARS + 512)).slice(0, MAX_CONTEXT_CHARS);
229
373
  return {
230
- contextLine: cut(lines[index]),
231
- preContext: lines.slice(Math.max(0, index - CONTEXT_LINES), index).map(cut),
232
- postContext: lines.slice(index + 1, index + 1 + CONTEXT_LINES).map(cut),
374
+ contextLine: clean(window.lines[window.at]),
375
+ preContext: window.lines.slice(0, window.at).map(clean),
376
+ postContext: window.lines.slice(window.at + 1).map(clean),
233
377
  };
234
378
  }
235
379
  export class Symbolicator {
@@ -271,7 +415,12 @@ export class Symbolicator {
271
415
  return null;
272
416
  if (!JS_FILE_RE.test(frame.filename))
273
417
  return null;
274
- const path = scriptPath(frame.filename);
418
+ // A server's dependency and runtime frames (node_modules/, node:) run
419
+ // from files no upload covers; looking them up by suffix could even
420
+ // pick an app bundle that happens to share the file name.
421
+ if (scope.server && !frame.inApp)
422
+ return null;
423
+ const path = scope.server ? serverScriptPath(frame.filename) : scriptPath(frame.filename);
275
424
  if (!path)
276
425
  return null;
277
426
  const debugId = this.debugIdFor(frame.filename, scope.debugIds);
@@ -283,6 +432,7 @@ export class Symbolicator {
283
432
  release: scope.release,
284
433
  filename: path,
285
434
  ...(debugId ? { debugId } : {}),
435
+ ...(scope.server ? { suffix: true } : {}),
286
436
  };
287
437
  }
288
438
  async resolveAll(frames, scope) {
@@ -366,15 +516,17 @@ function resolveFrame(loaded, frame, position, segment) {
366
516
  }
367
517
  })();
368
518
  const line = segment.line + 1;
519
+ const inApp = !ignored && isInAppPath(filename);
369
520
  const resolved = {
370
521
  function: frame.function,
371
522
  module: null,
372
523
  filename,
373
524
  lineno: line,
374
525
  colno: segment.column + 1,
375
- inApp: !ignored && isInAppPath(filename),
526
+ inApp,
376
527
  resolved: true,
377
- ...contextFor(map, source, line),
528
+ // Context lines are the evidence for the app's own code (RF-05).
529
+ ...(inApp ? contextFor(loaded, segment.source, line) : {}),
378
530
  };
379
531
  const name = segment.name >= 0 ? map.names[segment.name] : undefined;
380
532
  return {
@@ -43,7 +43,7 @@ import type { RuntimeOccurrenceInput } from "./types.js";
43
43
  * message, severity, time and batch position (so its deterministic id), and
44
44
  * takes from the others what it lacks: attributes (its own win), stack,
45
45
  * route, method, status, request id, declared error fields, fingerprint
46
- * override and breadcrumbs. `autter.merged_recordings` counts the merged
46
+ * override, breadcrumbs and debug ids. `autter.merged_recordings` counts the merged
47
47
  * recordings.
48
48
  *
49
49
  * Rule 2, redundant status-only occurrences. A status-only `HttpServerError`
@@ -46,7 +46,7 @@ import { parseStackFrames } from "./stack-frames.js";
46
46
  * message, severity, time and batch position (so its deterministic id), and
47
47
  * takes from the others what it lacks: attributes (its own win), stack,
48
48
  * route, method, status, request id, declared error fields, fingerprint
49
- * override and breadcrumbs. `autter.merged_recordings` counts the merged
49
+ * override, breadcrumbs and debug ids. `autter.merged_recordings` counts the merged
50
50
  * recordings.
51
51
  *
52
52
  * Rule 2, redundant status-only occurrences. A status-only `HttpServerError`
@@ -245,6 +245,8 @@ function mergeInto(kept, others) {
245
245
  }
246
246
  if (!merged.breadcrumbs?.length && other.breadcrumbs?.length)
247
247
  merged.breadcrumbs = other.breadcrumbs;
248
+ if (!merged.debugIds && other.debugIds)
249
+ merged.debugIds = other.debugIds;
248
250
  }
249
251
  attributes["autter.merged_recordings"] = others.length + 1;
250
252
  // Same bounds as every other occurrence (128 keys, kept's own first).
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Breadcrumb } from "./breadcrumbs.js";
2
+ import type { LatencySamples } from "./latency.js";
2
3
  import type { SuppressionRule } from "./suppression.js";
3
4
  export type RuntimeSource = "browser" | "server";
4
5
  /**
@@ -56,9 +57,10 @@ export interface RuntimeOccurrenceInput {
56
57
  /** Steps before the error, oldest first, ≤ 30 (validated by breadcrumbs.ts). */
57
58
  breadcrumbs?: Breadcrumb[];
58
59
  /**
59
- * Browser only: script URL → debug id for the scripts on the page, used
60
- * to find their source maps (EI-13). Input-only: never stored or sent to
61
- * the sink.
60
+ * Script URL (browser) or script path (server, the SDK's
61
+ * `autter.debug_ids`) → debug id for the bundles in the stack, used to
62
+ * find their source maps (EI-13, RF-05). Input-only: never stored or
63
+ * sent to the sink.
62
64
  */
63
65
  debugIds?: Record<string, string>;
64
66
  }
@@ -79,18 +81,23 @@ export interface StackFrame {
79
81
  inApp: boolean;
80
82
  /** True when a source map resolved the frame. */
81
83
  resolved: boolean;
82
- /** Only when `sourcesContent` supplied it (≤ 300 chars). */
84
+ /**
85
+ * The frame's own source line, on in-app resolved frames whose map
86
+ * carried the source's `sourcesContent` (≤ 300 chars, scrubbed).
87
+ */
83
88
  contextLine?: string;
84
- /** ≤ 3 lines each, ≤ 300 chars per line. */
89
+ /** Up to 5 lines before the frame's line, in file order (RF-05; 3 before), ≤ 300 chars each. */
85
90
  preContext?: string[];
91
+ /** Up to 5 lines after it, in file order, ≤ 300 chars each. */
86
92
  postContext?: string[];
87
93
  }
88
94
  /**
89
95
  * Every grouping scheme an issue identity can come from (contract §1).
90
- * The ingester computes `manual-v1`, `code-v1`, `frames-v3` and
91
- * `message-v1`; `rule-v1` and `external-v1` exist only on the platform.
96
+ * The ingester computes `manual-v1`, `code-v1`, `frames-v3`, `message-v1`
97
+ * and `cause-v1` (RF-06); `rule-v1` and `external-v1` exist only on the
98
+ * platform.
92
99
  */
93
- export type FingerprintSchemeV2 = "manual-v1" | "rule-v1" | "code-v1" | "frames-v3" | "message-v1" | "external-v1";
100
+ export type FingerprintSchemeV2 = "manual-v1" | "rule-v1" | "code-v1" | "frames-v3" | "message-v1" | "cause-v1" | "external-v1";
94
101
  export interface FingerprintCandidate {
95
102
  scheme: FingerprintSchemeV2;
96
103
  /** 32 lowercase hex characters. */
@@ -115,11 +122,12 @@ export interface RuntimeOccurrence extends RuntimeOccurrenceInput {
115
122
  fingerprint: string;
116
123
  /** "code-v1" when grouped by a valid error code, else "message-v1". */
117
124
  fingerprintScheme: "message-v1" | "code-v1";
118
- /** Parsed (and, for browser JS, symbolicated) frames, innermost first, ≤ 50. */
125
+ /** Parsed (and, for bundled JS, symbolicated) frames, innermost first, ≤ 50. */
119
126
  frames?: StackFrame[];
120
127
  /**
121
- * Every applicable identity, highest precedence first (manual-v1,
122
- * code-v1, frames-v3, message-v1); always ends with the legacy one.
128
+ * Every applicable identity, in precedence order (manual-v1, code-v1,
129
+ * frames-v3, message-v1, cause-v1); always includes the legacy one,
130
+ * which is last unless a cause-v1 candidate follows it.
123
131
  */
124
132
  fingerprints?: FingerprintCandidate[];
125
133
  /** What went into the frames-v3 fingerprint, when one was computed. */
@@ -200,6 +208,13 @@ export interface RuntimeMetricPoint {
200
208
  errorCount: number;
201
209
  durationSumMs: number;
202
210
  sessionCount: number;
211
+ /**
212
+ * Latency samples behind `durationSumMs` (IP-02, latency.ts), written to
213
+ * `runtime_route_latency_1m`. Absent when the feed has no durations
214
+ * (errors outside requests, browser usage counters). Not forwarded to
215
+ * the sink.
216
+ */
217
+ latency?: LatencySamples;
203
218
  }
204
219
  /**
205
220
  * The tenant a validated ingest key resolves to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "1.9.0",
3
+ "version": "1.11.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",