@autter/otlp-ingester 1.10.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.
@@ -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.10.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",