@autter/otlp-ingester 1.6.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.
package/README.md CHANGED
@@ -75,7 +75,7 @@ the Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIB
75
75
  | `SINK_MAX_ATTEMPTS` | `12` | Delivery attempts per batch (1–60 s backoff) |
76
76
  | `SINK_MAX_BUFFERED_BATCHES` | `1000` | Retry buffer cap; oldest drops are logged |
77
77
  | `SINK_MAX_BUFFERED_MB` | `64` | Sink retry buffer cap (memory) |
78
- | `SOURCE_MAP_CACHE_MB` | `64` | Memory budget (estimated decoded size) for parsed source maps used to symbolicate browser frames at ingest; least recently used maps are evicted |
78
+ | `SOURCE_MAP_CACHE_MB` | `64` | Memory budget (decoded size, function scope tables included) for parsed source maps used to symbolicate browser frames at ingest; least recently used maps are evicted, and a map that needs more than half of it is used without being kept |
79
79
  | `MAX_BODY_BYTES` | `1048576` | Request body cap |
80
80
  | `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
81
81
  | `CLIENT_RATE_LIMIT_PER_MINUTE` | `120` | Per-key fixed window (client keys) |
@@ -19,6 +19,9 @@ const INSERT_SETTINGS = {
19
19
  async_insert: 1,
20
20
  wait_for_async_insert: 1,
21
21
  };
22
+ /** Source maps above this go in with a plain insert (below ClickHouse's
23
+ * default async_insert_max_data_size of 10 MiB). */
24
+ const ASYNC_INSERT_MAX_MAP_CHARS = 8 * 1024 * 1024;
22
25
  /** "YYYY-MM-DD hh:mm:ss.sss" (UTC) — the DateTime64 query-parameter form. */
23
26
  function chDateTime(date) {
24
27
  return date.toISOString().replace("T", " ").replace("Z", "");
@@ -474,7 +477,15 @@ export class ClickHouseStore {
474
477
  await this.ensureSchema();
475
478
  await this.getClient().insert({
476
479
  table: this.table("runtime_source_maps"), format: "JSONEachRow",
477
- clickhouse_settings: INSERT_SETTINGS,
480
+ // One row of up to 32 MiB. Parallel parsing cuts input into
481
+ // 10 MiB chunks, refuses single JSON rows far past that, and gains
482
+ // nothing on one row. A map past the async-insert buffer (10 MiB
483
+ // by default) is inserted directly rather than buffered.
484
+ clickhouse_settings: {
485
+ ...INSERT_SETTINGS,
486
+ input_format_parallel_parsing: 0,
487
+ ...(sourceMap.map.length > ASYNC_INSERT_MAX_MAP_CHARS ? { async_insert: 0 } : {}),
488
+ },
478
489
  values: [{ org_id: ctx.orgId, repository_id: ctx.repositoryId, release: sourceMap.release,
479
490
  filename: sourceMap.filename, map: sourceMap.map, debug_id: sourceMap.debugId,
480
491
  content_hash: sourceMap.contentHash }],
package/dist/config.d.ts CHANGED
@@ -55,9 +55,9 @@ export interface IngesterConfig {
55
55
  redactValuePatterns?: string[];
56
56
  redactKeyPatterns?: string[];
57
57
  /**
58
- * In-memory budget (MiB, estimated decoded size) for parsed source maps
59
- * used to symbolicate browser frames at ingest (SOURCE_MAP_CACHE_MB,
60
- * default 64).
58
+ * In-memory budget (MiB, decoded size) for parsed source maps used to
59
+ * symbolicate browser frames at ingest (SOURCE_MAP_CACHE_MB, default 64).
60
+ * A map that needs more than half of it is used without being kept.
61
61
  */
62
62
  sourceMapCacheMb?: number;
63
63
  }
@@ -64,10 +64,10 @@ export declare const browserPayloadSchema: z.ZodObject<{
64
64
  debugIds?: Record<string, unknown> | undefined;
65
65
  durationMs?: number | undefined;
66
66
  filename?: string | undefined;
67
- id?: string | undefined;
68
- name?: string | undefined;
69
- line?: number | undefined;
70
67
  column?: number | undefined;
68
+ line?: number | undefined;
69
+ name?: string | undefined;
70
+ id?: string | undefined;
71
71
  context?: Record<string, unknown> | undefined;
72
72
  fingerprint?: string | string[] | undefined;
73
73
  }, {
@@ -83,10 +83,10 @@ export declare const browserPayloadSchema: z.ZodObject<{
83
83
  debugIds?: unknown;
84
84
  durationMs?: number | undefined;
85
85
  filename?: string | undefined;
86
- id?: string | undefined;
87
- name?: string | undefined;
88
- line?: number | undefined;
89
86
  column?: number | undefined;
87
+ line?: number | undefined;
88
+ name?: string | undefined;
89
+ id?: string | undefined;
90
90
  context?: Record<string, unknown> | undefined;
91
91
  fingerprint?: unknown;
92
92
  }>, "many">;
@@ -107,10 +107,10 @@ export declare const browserPayloadSchema: z.ZodObject<{
107
107
  debugIds?: Record<string, unknown> | undefined;
108
108
  durationMs?: number | undefined;
109
109
  filename?: string | undefined;
110
- id?: string | undefined;
111
- name?: string | undefined;
112
- line?: number | undefined;
113
110
  column?: number | undefined;
111
+ line?: number | undefined;
112
+ name?: string | undefined;
113
+ id?: string | undefined;
114
114
  context?: Record<string, unknown> | undefined;
115
115
  fingerprint?: string | string[] | undefined;
116
116
  }[];
@@ -134,10 +134,10 @@ export declare const browserPayloadSchema: z.ZodObject<{
134
134
  debugIds?: unknown;
135
135
  durationMs?: number | undefined;
136
136
  filename?: string | undefined;
137
- id?: string | undefined;
138
- name?: string | undefined;
139
- line?: number | undefined;
140
137
  column?: number | undefined;
138
+ line?: number | undefined;
139
+ name?: string | undefined;
140
+ id?: string | undefined;
141
141
  context?: Record<string, unknown> | undefined;
142
142
  fingerprint?: unknown;
143
143
  }[];
package/dist/server.js CHANGED
@@ -13,7 +13,7 @@ import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest } from "./o
13
13
  import { normalizeLogs } from "./logs.js";
14
14
  import { decodeProfile } from "./profiles.js";
15
15
  import { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
16
- import { validateSourceMap, validateSourceMapCheck } from "./source-maps.js";
16
+ import { SOURCE_MAP_MAX_BODY_BYTES, validateSourceMap, validateSourceMapCheck } from "./source-maps.js";
17
17
  import { SinkForwarder } from "./sink.js";
18
18
  import { configureRedaction, scrubOccurrence } from "./redact.js";
19
19
  import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
@@ -84,7 +84,40 @@ export function createIngesterApp(config) {
84
84
  next();
85
85
  });
86
86
  app.use(express.raw({ limit: "1mb", type: ["application/x-pprof"] }));
87
- app.use("/v1/sourcemaps", express.json({ limit: "5mb" }));
87
+ // A check names at most 500 maps: well under 1 MB, whatever MAX_BODY_BYTES is.
88
+ app.use("/v1/sourcemaps/check", express.json({ limit: "1mb" }));
89
+ // Source map uploads (up to 32 MiB of JSON, usually gzipped) are routed
90
+ // before the small-body parsers below, and read their body only once the
91
+ // key is checked: an anonymous client can't make the ingester inflate and
92
+ // parse one. body-parser inflates gzip/deflate and applies the limit to
93
+ // the inflated bytes, so a small compressed body can't expand past it.
94
+ const sourceMapBody = express.json({ limit: SOURCE_MAP_MAX_BODY_BYTES });
95
+ app.post("/v1/sourcemaps", async (req, res, next) => {
96
+ // On failure the body is never read (Node discards it).
97
+ const ctx = await authenticate(req, res, "otlp");
98
+ if (!ctx)
99
+ return;
100
+ sourceMapBody(req, res, async (err) => {
101
+ if (err) {
102
+ next(err);
103
+ return;
104
+ }
105
+ const sourceMap = validateSourceMap(req.body);
106
+ if (!sourceMap) {
107
+ res.status(400).json({ error: "invalid source map" });
108
+ return;
109
+ }
110
+ // Idempotent: the same release + path replaces the earlier row
111
+ // (ReplacingMergeTree), so CI retries and re-runs are harmless.
112
+ try {
113
+ await store.insertSourceMap(ctx, sourceMap);
114
+ res.status(202).json({ accepted: true });
115
+ }
116
+ catch (storeErr) {
117
+ storageError(res, storeErr);
118
+ }
119
+ });
120
+ });
88
121
  app.use(express.json({
89
122
  limit: config.maxBodyBytes,
90
123
  type: ["application/json"],
@@ -341,25 +374,6 @@ export function createIngesterApp(config) {
341
374
  storageError(res, err);
342
375
  }
343
376
  });
344
- app.post("/v1/sourcemaps", async (req, res) => {
345
- const ctx = await authenticate(req, res, "otlp");
346
- if (!ctx)
347
- return;
348
- const sourceMap = validateSourceMap(req.body);
349
- if (!sourceMap) {
350
- res.status(400).json({ error: "invalid source map" });
351
- return;
352
- }
353
- // Idempotent: the same release + path replaces the earlier row
354
- // (ReplacingMergeTree), so CI retries and re-runs are harmless.
355
- try {
356
- await store.insertSourceMap(ctx, sourceMap);
357
- res.status(202).json({ accepted: true });
358
- }
359
- catch (err) {
360
- storageError(res, err);
361
- }
362
- });
363
377
  /** Which of these maps does this tenant already have? Lets
364
378
  * `autter-runtime sourcemaps upload` skip unchanged maps (1.6.0+). */
365
379
  app.post("/v1/sourcemaps/check", async (req, res) => {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Position lookups in a source map's `mappings`, without the memory of a
3
+ * general-purpose decoder.
4
+ *
5
+ * @jridgewell/trace-mapping decodes `mappings` into one small array per
6
+ * segment: about 14 bytes of heap per byte of JSON (a 5 MB map takes 67 MB).
7
+ * Symbolication only needs "which original position is at or before this
8
+ * generated one", so a map that is kept in the cache is decoded into one
9
+ * Int32Array (5 ints per segment: about 3 bytes per byte of JSON), and a map
10
+ * too big to keep is not decoded at all: its frames are resolved in one
11
+ * streaming pass over the string.
12
+ *
13
+ * Lookup semantics match trace-mapping's `originalPositionFor` (greatest
14
+ * lower bound): the first segment that starts at the frame's column, else
15
+ * the last segment before it on the same line; a segment without a source
16
+ * maps to nothing.
17
+ */
18
+ /** An original position. Lines and columns are 0-based; `name` is -1 when the segment has none. */
19
+ export interface MappedSegment {
20
+ source: number;
21
+ line: number;
22
+ column: number;
23
+ name: number;
24
+ }
25
+ /** A 0-based generated (line, column). */
26
+ export interface GeneratedPosition {
27
+ line: number;
28
+ column: number;
29
+ }
30
+ /**
31
+ * Walk a mappings-style string — `;` between generated lines, `,` between
32
+ * groups of base64 VLQ values — calling `visit(line, values, count)` with
33
+ * each group's raw (relative) values. `values` is reused between calls and
34
+ * holds the first 6; `count` is how many the group had. Throws on
35
+ * malformed VLQ. Also reads `x_autter_scopes` (source-map-scopes.ts).
36
+ */
37
+ export declare function forEachVlqGroup(text: string, visit: (line: number, values: readonly number[], count: number) => void): void;
38
+ /** Heap a DecodedMappings of `mappings` would take, without decoding it. */
39
+ export declare function decodedMappingsBytes(mappings: string): number;
40
+ export declare class DecodedMappings {
41
+ /** FIELDS ints per segment, each line's segments sorted by generated column. */
42
+ private readonly segments;
43
+ /** Index of each line's first segment; one extra entry closes the last line. */
44
+ private readonly lineStarts;
45
+ private constructor();
46
+ static decode(mappings: string): DecodedMappings;
47
+ get bytes(): number;
48
+ /** Generators almost always emit a line's segments in column order; sort the rare line that isn't (stable, as trace-mapping does). */
49
+ private sortLine;
50
+ /** The original position at a 0-based generated (line, column), or null. */
51
+ find(line: number, column: number): MappedSegment | null;
52
+ }
53
+ /**
54
+ * Resolve several positions in one pass over `mappings` without decoding
55
+ * it — for maps too big to keep. Same results as DecodedMappings.find.
56
+ */
57
+ export declare function findInMappings(mappings: string, positions: readonly GeneratedPosition[]): Array<MappedSegment | null>;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Position lookups in a source map's `mappings`, without the memory of a
3
+ * general-purpose decoder.
4
+ *
5
+ * @jridgewell/trace-mapping decodes `mappings` into one small array per
6
+ * segment: about 14 bytes of heap per byte of JSON (a 5 MB map takes 67 MB).
7
+ * Symbolication only needs "which original position is at or before this
8
+ * generated one", so a map that is kept in the cache is decoded into one
9
+ * Int32Array (5 ints per segment: about 3 bytes per byte of JSON), and a map
10
+ * too big to keep is not decoded at all: its frames are resolved in one
11
+ * streaming pass over the string.
12
+ *
13
+ * Lookup semantics match trace-mapping's `originalPositionFor` (greatest
14
+ * lower bound): the first segment that starts at the frame's column, else
15
+ * the last segment before it on the same line; a segment without a source
16
+ * maps to nothing.
17
+ */
18
+ /** generated column, source, original line, original column, name. */
19
+ const FIELDS = 5;
20
+ const NONE = -1;
21
+ const BASE64 = new Int8Array(128).fill(-1);
22
+ for (const [i, ch] of [..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"].entries()) {
23
+ BASE64[ch.charCodeAt(0)] = i;
24
+ }
25
+ /**
26
+ * Walk a mappings-style string — `;` between generated lines, `,` between
27
+ * groups of base64 VLQ values — calling `visit(line, values, count)` with
28
+ * each group's raw (relative) values. `values` is reused between calls and
29
+ * holds the first 6; `count` is how many the group had. Throws on
30
+ * malformed VLQ. Also reads `x_autter_scopes` (source-map-scopes.ts).
31
+ */
32
+ export function forEachVlqGroup(text, visit) {
33
+ const values = [0, 0, 0, 0, 0, 0];
34
+ let line = 0;
35
+ let i = 0;
36
+ while (i < text.length) {
37
+ const ch = text.charCodeAt(i);
38
+ if (ch === 59 /* ; */) {
39
+ line++;
40
+ i++;
41
+ continue;
42
+ }
43
+ if (ch === 44 /* , */) {
44
+ i++;
45
+ continue;
46
+ }
47
+ let count = 0;
48
+ while (i < text.length) {
49
+ const next = text.charCodeAt(i);
50
+ if (next === 44 || next === 59)
51
+ break;
52
+ let value = 0;
53
+ let shift = 0;
54
+ let digit;
55
+ do {
56
+ const code = text.charCodeAt(i++);
57
+ digit = code < 128 ? BASE64[code] : -1;
58
+ if (digit < 0 || shift > 30)
59
+ throw new Error("invalid VLQ");
60
+ value |= (digit & 31) << shift;
61
+ shift += 5;
62
+ } while (digit & 32);
63
+ if (count < values.length)
64
+ values[count] = value & 1 ? -(value >>> 1) : value >>> 1;
65
+ count++;
66
+ }
67
+ visit(line, values, count);
68
+ }
69
+ }
70
+ /**
71
+ * Walk every segment: `visit(line, generatedColumn, source, originalLine,
72
+ * originalColumn, name)`, with source and name -1 when absent. Throws on
73
+ * malformed VLQ.
74
+ */
75
+ function forEachSegment(mappings, visit) {
76
+ let currentLine = 0;
77
+ let column = 0;
78
+ let source = 0;
79
+ let originalLine = 0;
80
+ let originalColumn = 0;
81
+ let name = 0;
82
+ forEachVlqGroup(mappings, (line, values, count) => {
83
+ // The generated column restarts on each line; the other fields don't.
84
+ if (line !== currentLine) {
85
+ currentLine = line;
86
+ column = 0;
87
+ }
88
+ column += values[0];
89
+ if (count >= 4) {
90
+ source += values[1];
91
+ originalLine += values[2];
92
+ originalColumn += values[3];
93
+ }
94
+ if (count >= 5)
95
+ name += values[4];
96
+ visit(line, column, count >= 4 ? source : NONE, originalLine, originalColumn, count >= 5 ? name : NONE);
97
+ });
98
+ }
99
+ /** Segments and generated lines in `mappings`, from one scan without decoding. */
100
+ function countSegments(mappings) {
101
+ let segments = 0;
102
+ let lines = 1;
103
+ let inSegment = false;
104
+ for (let i = 0; i < mappings.length; i++) {
105
+ const ch = mappings.charCodeAt(i);
106
+ if (ch === 59) {
107
+ lines++;
108
+ inSegment = false;
109
+ }
110
+ else if (ch === 44) {
111
+ inSegment = false;
112
+ }
113
+ else if (!inSegment) {
114
+ segments++;
115
+ inSegment = true;
116
+ }
117
+ }
118
+ return { segments, lines };
119
+ }
120
+ /** Heap a DecodedMappings of `mappings` would take, without decoding it. */
121
+ export function decodedMappingsBytes(mappings) {
122
+ const { segments, lines } = countSegments(mappings);
123
+ return segments * FIELDS * 4 + (lines + 1) * 4 + 64;
124
+ }
125
+ export class DecodedMappings {
126
+ segments;
127
+ lineStarts;
128
+ constructor(
129
+ /** FIELDS ints per segment, each line's segments sorted by generated column. */
130
+ segments,
131
+ /** Index of each line's first segment; one extra entry closes the last line. */
132
+ lineStarts) {
133
+ this.segments = segments;
134
+ this.lineStarts = lineStarts;
135
+ }
136
+ static decode(mappings) {
137
+ const { segments: count, lines } = countSegments(mappings);
138
+ const segments = new Int32Array(count * FIELDS);
139
+ const lineStarts = new Int32Array(lines + 1);
140
+ let n = 0;
141
+ let currentLine = 0;
142
+ forEachSegment(mappings, (line, column, source, originalLine, originalColumn, name) => {
143
+ while (currentLine < line)
144
+ lineStarts[++currentLine] = n;
145
+ const at = n * FIELDS;
146
+ segments[at] = column;
147
+ segments[at + 1] = source;
148
+ segments[at + 2] = originalLine;
149
+ segments[at + 3] = originalColumn;
150
+ segments[at + 4] = name;
151
+ n++;
152
+ });
153
+ while (currentLine < lines)
154
+ lineStarts[++currentLine] = n;
155
+ const decoded = new DecodedMappings(segments, lineStarts);
156
+ for (let line = 0; line < lines; line++)
157
+ decoded.sortLine(line);
158
+ return decoded;
159
+ }
160
+ get bytes() {
161
+ return this.segments.byteLength + this.lineStarts.byteLength + 64;
162
+ }
163
+ /** Generators almost always emit a line's segments in column order; sort the rare line that isn't (stable, as trace-mapping does). */
164
+ sortLine(line) {
165
+ const start = this.lineStarts[line];
166
+ const end = this.lineStarts[line + 1];
167
+ const s = this.segments;
168
+ let sorted = true;
169
+ for (let i = start + 1; i < end && sorted; i++)
170
+ sorted = s[(i - 1) * FIELDS] <= s[i * FIELDS];
171
+ if (sorted)
172
+ return;
173
+ const rows = [];
174
+ for (let i = start; i < end; i++)
175
+ rows.push(Array.from(s.subarray(i * FIELDS, (i + 1) * FIELDS)));
176
+ rows.sort((a, b) => a[0] - b[0]);
177
+ rows.forEach((row, k) => s.set(row, (start + k) * FIELDS));
178
+ }
179
+ /** The original position at a 0-based generated (line, column), or null. */
180
+ find(line, column) {
181
+ if (line < 0 || line + 1 >= this.lineStarts.length)
182
+ return null;
183
+ const s = this.segments;
184
+ let lo = this.lineStarts[line];
185
+ let hi = this.lineStarts[line + 1] - 1;
186
+ let found = -1;
187
+ while (lo <= hi) {
188
+ const mid = (lo + hi) >> 1;
189
+ if (s[mid * FIELDS] <= column) {
190
+ found = mid;
191
+ lo = mid + 1;
192
+ }
193
+ else {
194
+ hi = mid - 1;
195
+ }
196
+ }
197
+ if (found < 0)
198
+ return null;
199
+ // Several segments at exactly this column: the first one (before it,
200
+ // the last one — as trace-mapping does).
201
+ if (s[found * FIELDS] === column) {
202
+ while (found > this.lineStarts[line] && s[(found - 1) * FIELDS] === column)
203
+ found--;
204
+ }
205
+ const at = found * FIELDS;
206
+ if (s[at + 1] === NONE)
207
+ return null;
208
+ return { source: s[at + 1], line: s[at + 2], column: s[at + 3], name: s[at + 4] };
209
+ }
210
+ }
211
+ /**
212
+ * Resolve several positions in one pass over `mappings` without decoding
213
+ * it — for maps too big to keep. Same results as DecodedMappings.find.
214
+ */
215
+ export function findInMappings(mappings, positions) {
216
+ const byLine = new Map();
217
+ positions.forEach((position, index) => {
218
+ const list = byLine.get(position.line);
219
+ if (list)
220
+ list.push(index);
221
+ else
222
+ byLine.set(position.line, [index]);
223
+ });
224
+ const best = positions.map(() => null);
225
+ forEachSegment(mappings, (line, column, source, originalLine, originalColumn, name) => {
226
+ const wanted = byLine.get(line);
227
+ if (!wanted)
228
+ return;
229
+ for (const index of wanted) {
230
+ const target = positions[index].column;
231
+ const current = best[index];
232
+ // Greatest column at or before the position. On a tie the first
233
+ // segment wins at the position itself and the last one before it.
234
+ if (column > target)
235
+ continue;
236
+ if (current && (current.column > column || (current.column === column && column === target)))
237
+ continue;
238
+ best[index] = {
239
+ column,
240
+ segment: source === NONE ? null : { source, line: originalLine, column: originalColumn, name },
241
+ };
242
+ }
243
+ });
244
+ return best.map((found) => found?.segment ?? null);
245
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Function scopes uploaded with a source map (`x_autter_scopes`, written by
3
+ * `autter-runtime sourcemaps upload` in @autter/runtime-cli after 1.0.0):
4
+ * every function of the bundle as served, with its generated range and its
5
+ * ORIGINAL name. A frame is named after the innermost function that
6
+ * contains it — the name of the function it is in, as Sentry's
7
+ * js-source-scopes and PostHog cymbal's `token.scope()` do — instead of the
8
+ * name mapped at its caller's position, which is wrong whenever the call
9
+ * goes through a variable or a framework.
10
+ *
11
+ * Shape: `{ version: 1, names: (string | null)[], scopes: string }`.
12
+ * `scopes` is encoded like `mappings`: base64 VLQ, `;` between generated
13
+ * lines (the line each scope starts on), `,` between the scopes that start
14
+ * on one line, in start order (outer first on a tie). Each scope is 3 or 4
15
+ * values, 0-based and in UTF-16 columns:
16
+ *
17
+ * 1. start column, relative to the previous scope's start on the same
18
+ * line (absolute for a line's first scope);
19
+ * 2. end line, relative to the start line;
20
+ * 3. end column (exclusive), relative to the start column when the scope
21
+ * ends on its start line, else absolute;
22
+ * 4. the name, when the function has one: an index into the map's own
23
+ * `names` followed by `x_autter_scopes.names`, relative to the last
24
+ * name index written (starting from 0). A `null` entry in
25
+ * `x_autter_scopes.names` is a name the CLI could not map back.
26
+ *
27
+ * A 3-value scope is anonymous. Anything else (wrong version, bad VLQ,
28
+ * out-of-range names, ranges that overlap without nesting) makes the whole
29
+ * field unusable, and the map is used as if it had none.
30
+ */
31
+ export declare const SCOPES_KEY = "x_autter_scopes";
32
+ export type ScopeName =
33
+ /** In a function with a known original name. */
34
+ {
35
+ kind: "named";
36
+ name: string;
37
+ }
38
+ /** In an anonymous function, or in no function at all (top-level code). */
39
+ | {
40
+ kind: "anonymous";
41
+ }
42
+ /** In a named function whose original name is unknown. */
43
+ | {
44
+ kind: "unknown";
45
+ };
46
+ export declare class ScopeIndex {
47
+ /** STRIDE ints per scope, sorted by start (outer first on a tie). */
48
+ private readonly table;
49
+ /** The map's own `names`, then `x_autter_scopes.names`. */
50
+ private readonly mapNames;
51
+ private readonly extraNames;
52
+ private constructor();
53
+ /** Number of function scopes. */
54
+ get size(): number;
55
+ /** Estimated memory, for the source map cache's byte budget (the map's
56
+ * own `names` are counted with the map). */
57
+ get bytes(): number;
58
+ /**
59
+ * The parsed `x_autter_scopes` value, or null when it is absent or
60
+ * malformed. `mapNames` is the map's own `names` array, which scope name
61
+ * indexes continue.
62
+ */
63
+ static from(raw: unknown, mapNames?: readonly unknown[]): ScopeIndex | null;
64
+ private nameOf;
65
+ /**
66
+ * The name for a frame at a 0-based generated (line, column): the
67
+ * innermost function containing it. Outside every function (top-level
68
+ * code) it is anonymous.
69
+ */
70
+ nameAt(line: number, column: number): ScopeName;
71
+ }
@@ -0,0 +1,190 @@
1
+ import { forEachVlqGroup } from "./source-map-mappings.js";
2
+ /**
3
+ * Function scopes uploaded with a source map (`x_autter_scopes`, written by
4
+ * `autter-runtime sourcemaps upload` in @autter/runtime-cli after 1.0.0):
5
+ * every function of the bundle as served, with its generated range and its
6
+ * ORIGINAL name. A frame is named after the innermost function that
7
+ * contains it — the name of the function it is in, as Sentry's
8
+ * js-source-scopes and PostHog cymbal's `token.scope()` do — instead of the
9
+ * name mapped at its caller's position, which is wrong whenever the call
10
+ * goes through a variable or a framework.
11
+ *
12
+ * Shape: `{ version: 1, names: (string | null)[], scopes: string }`.
13
+ * `scopes` is encoded like `mappings`: base64 VLQ, `;` between generated
14
+ * lines (the line each scope starts on), `,` between the scopes that start
15
+ * on one line, in start order (outer first on a tie). Each scope is 3 or 4
16
+ * values, 0-based and in UTF-16 columns:
17
+ *
18
+ * 1. start column, relative to the previous scope's start on the same
19
+ * line (absolute for a line's first scope);
20
+ * 2. end line, relative to the start line;
21
+ * 3. end column (exclusive), relative to the start column when the scope
22
+ * ends on its start line, else absolute;
23
+ * 4. the name, when the function has one: an index into the map's own
24
+ * `names` followed by `x_autter_scopes.names`, relative to the last
25
+ * name index written (starting from 0). A `null` entry in
26
+ * `x_autter_scopes.names` is a name the CLI could not map back.
27
+ *
28
+ * A 3-value scope is anonymous. Anything else (wrong version, bad VLQ,
29
+ * out-of-range names, ranges that overlap without nesting) makes the whole
30
+ * field unusable, and the map is used as if it had none.
31
+ */
32
+ export const SCOPES_KEY = "x_autter_scopes";
33
+ const ANONYMOUS = -1;
34
+ /** startLine, startCol, endLine, endCol, nameIndex, parent. */
35
+ const STRIDE = 6;
36
+ const MAX_POSITION = 0x7fffffff;
37
+ const ANONYMOUS_SCOPE = { kind: "anonymous" };
38
+ const UNKNOWN_SCOPE = { kind: "unknown" };
39
+ class MalformedScopes extends Error {
40
+ }
41
+ /** a < b for (line, column) pairs. */
42
+ function before(lineA, colA, lineB, colB) {
43
+ return lineA < lineB || (lineA === lineB && colA < colB);
44
+ }
45
+ /** Decode the `scopes` string into [startLine, startCol, endLine, endCol, nameIndex] rows. */
46
+ function decodeScopes(text, isName) {
47
+ const rows = [];
48
+ let currentLine = 0;
49
+ let startCol = 0;
50
+ let name = 0;
51
+ forEachVlqGroup(text, (line, values, count) => {
52
+ if (count !== 3 && count !== 4)
53
+ throw new MalformedScopes("a scope has 3 or 4 values");
54
+ if (line !== currentLine) {
55
+ currentLine = line;
56
+ startCol = 0;
57
+ }
58
+ startCol += values[0];
59
+ const endLine = line + values[1];
60
+ const endCol = values[1] === 0 ? startCol + values[2] : values[2];
61
+ let nameIndex = ANONYMOUS;
62
+ if (count === 4) {
63
+ name += values[3];
64
+ if (!isName(name))
65
+ throw new MalformedScopes("name out of range");
66
+ nameIndex = name;
67
+ }
68
+ if (startCol < 0 || values[1] < 0 || endCol < 0 || endLine > MAX_POSITION || Math.max(startCol, endCol) > MAX_POSITION) {
69
+ throw new MalformedScopes("position out of range");
70
+ }
71
+ // Non-empty: a function is at least `()=>0`.
72
+ if (!before(line, startCol, endLine, endCol))
73
+ throw new MalformedScopes("empty scope");
74
+ rows.push([line, startCol, endLine, endCol, nameIndex]);
75
+ });
76
+ return rows;
77
+ }
78
+ export class ScopeIndex {
79
+ table;
80
+ mapNames;
81
+ extraNames;
82
+ constructor(
83
+ /** STRIDE ints per scope, sorted by start (outer first on a tie). */
84
+ table,
85
+ /** The map's own `names`, then `x_autter_scopes.names`. */
86
+ mapNames, extraNames) {
87
+ this.table = table;
88
+ this.mapNames = mapNames;
89
+ this.extraNames = extraNames;
90
+ }
91
+ /** Number of function scopes. */
92
+ get size() {
93
+ return this.table.length / STRIDE;
94
+ }
95
+ /** Estimated memory, for the source map cache's byte budget (the map's
96
+ * own `names` are counted with the map). */
97
+ get bytes() {
98
+ let bytes = this.table.byteLength + 64;
99
+ for (const name of this.extraNames)
100
+ bytes += 2 * (name?.length ?? 0) + 32;
101
+ return bytes;
102
+ }
103
+ /**
104
+ * The parsed `x_autter_scopes` value, or null when it is absent or
105
+ * malformed. `mapNames` is the map's own `names` array, which scope name
106
+ * indexes continue.
107
+ */
108
+ static from(raw, mapNames = []) {
109
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
110
+ return null;
111
+ const { version, names, scopes } = raw;
112
+ if (version !== 1 || !Array.isArray(names) || typeof scopes !== "string")
113
+ return null;
114
+ if (!names.every((name) => typeof name === "string" || name === null))
115
+ return null;
116
+ const extraNames = names;
117
+ const isName = (index) => index >= 0 &&
118
+ (index < mapNames.length
119
+ ? typeof mapNames[index] === "string"
120
+ : index - mapNames.length < extraNames.length);
121
+ let entries;
122
+ try {
123
+ entries = decodeScopes(scopes, isName);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ // Encoded in order already; sort anyway so a hand-written field works too.
129
+ entries.sort((a, b) => a[0] - b[0] || a[1] - b[1] || b[2] - a[2] || b[3] - a[3]);
130
+ const table = new Int32Array(entries.length * STRIDE);
131
+ // Open scopes, outermost first: each new scope either nests in the
132
+ // innermost one still open or starts after it has ended.
133
+ const open = [];
134
+ for (let i = 0; i < entries.length; i++) {
135
+ const [startLine, startCol, endLine, endCol, nameIndex] = entries[i];
136
+ while (open.length > 0) {
137
+ const top = open[open.length - 1] * STRIDE;
138
+ if (before(startLine, startCol, table[top + 2], table[top + 3]))
139
+ break;
140
+ open.pop();
141
+ }
142
+ const parent = open.length > 0 ? open[open.length - 1] : -1;
143
+ if (parent >= 0 && before(table[parent * STRIDE + 2], table[parent * STRIDE + 3], endLine, endCol)) {
144
+ return null; // overlaps its parent without nesting in it
145
+ }
146
+ const at = i * STRIDE;
147
+ table[at] = startLine;
148
+ table[at + 1] = startCol;
149
+ table[at + 2] = endLine;
150
+ table[at + 3] = endCol;
151
+ table[at + 4] = nameIndex;
152
+ table[at + 5] = parent;
153
+ open.push(i);
154
+ }
155
+ return new ScopeIndex(table, mapNames, extraNames);
156
+ }
157
+ nameOf(index) {
158
+ if (index === ANONYMOUS)
159
+ return ANONYMOUS_SCOPE;
160
+ const name = index < this.mapNames.length ? this.mapNames[index] : this.extraNames[index - this.mapNames.length];
161
+ return typeof name === "string" ? { kind: "named", name } : UNKNOWN_SCOPE;
162
+ }
163
+ /**
164
+ * The name for a frame at a 0-based generated (line, column): the
165
+ * innermost function containing it. Outside every function (top-level
166
+ * code) it is anonymous.
167
+ */
168
+ nameAt(line, column) {
169
+ const t = this.table;
170
+ // Last scope that starts at or before the position…
171
+ let lo = 0;
172
+ let hi = this.size - 1;
173
+ let found = -1;
174
+ while (lo <= hi) {
175
+ const mid = (lo + hi) >> 1;
176
+ if (before(line, column, t[mid * STRIDE], t[mid * STRIDE + 1]))
177
+ hi = mid - 1;
178
+ else {
179
+ found = mid;
180
+ lo = mid + 1;
181
+ }
182
+ }
183
+ // …or, when it ended before the position, its closest ancestor that hasn't.
184
+ for (let i = found; i >= 0; i = t[i * STRIDE + 5]) {
185
+ if (before(line, column, t[i * STRIDE + 2], t[i * STRIDE + 3]))
186
+ return this.nameOf(t[i * STRIDE + 4]);
187
+ }
188
+ return ANONYMOUS_SCOPE;
189
+ }
190
+ }
@@ -1,5 +1,14 @@
1
- /** Largest stored map (after `sourcesContent` is removed), in characters. */
1
+ /**
2
+ * Largest stored map (after `sourcesContent` is removed), in characters:
3
+ * 32 MiB, room for vendor chunks of about 20 MB of minified code. Storage
4
+ * is a ZSTD-compressed String column, so size only costs disk; a map this
5
+ * big is still symbolicated, without being kept in the parse cache unless
6
+ * SOURCE_MAP_CACHE_MB allows it (symbolicate.ts). Was 5 MiB up to 1.6.0.
7
+ */
2
8
  export declare const SOURCE_MAP_MAX_CHARS: number;
9
+ /** `/v1/sourcemaps` body limit, counted after a gzip or deflate body is
10
+ * inflated: the largest map plus room for the rest of the request. */
11
+ export declare const SOURCE_MAP_MAX_BODY_BYTES: number;
3
12
  /** A debug id: 8-4-4-4-12 lowercase hex. Written by `autter-runtime
4
13
  * sourcemaps inject`, and by Rollup, Turbopack, esbuild and Sentry tooling. */
5
14
  export declare const DEBUG_ID_RE: RegExp;
@@ -1,7 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { scrubText } from "./redact.js";
3
- /** Largest stored map (after `sourcesContent` is removed), in characters. */
4
- export const SOURCE_MAP_MAX_CHARS = 5 * 1024 * 1024;
3
+ /**
4
+ * Largest stored map (after `sourcesContent` is removed), in characters:
5
+ * 32 MiB, room for vendor chunks of about 20 MB of minified code. Storage
6
+ * is a ZSTD-compressed String column, so size only costs disk; a map this
7
+ * big is still symbolicated, without being kept in the parse cache unless
8
+ * SOURCE_MAP_CACHE_MB allows it (symbolicate.ts). Was 5 MiB up to 1.6.0.
9
+ */
10
+ export const SOURCE_MAP_MAX_CHARS = 32 * 1024 * 1024;
11
+ /** `/v1/sourcemaps` body limit, counted after a gzip or deflate body is
12
+ * inflated: the largest map plus room for the rest of the request. */
13
+ export const SOURCE_MAP_MAX_BODY_BYTES = SOURCE_MAP_MAX_CHARS + 1024 * 1024;
5
14
  /** Bundles a map can be uploaded for: `.js`, `.mjs` and `.cjs` (1.6.0+). */
6
15
  const BUNDLE_PATH_RE = /\.[cm]?js$/;
7
16
  /** A debug id: 8-4-4-4-12 lowercase hex. Written by `autter-runtime
@@ -83,7 +92,10 @@ export function validateSourceMap(body) {
83
92
  const debugId = rawDebugId === undefined || rawDebugId === null || rawDebugId === "" ? "" : normalizeDebugId(rawDebugId);
84
93
  if (debugId === null)
85
94
  return null;
86
- // Source contents are unnecessary for position lookup and may contain secrets.
95
+ // Source contents are unnecessary for position lookup and may contain
96
+ // secrets. Everything else is stored as sent, extension fields included:
97
+ // symbolication reads `x_autter_scopes` (function scopes) from it, and the
98
+ // CLI hashes exactly this JSON to skip maps already uploaded.
87
99
  const { sourcesContent: _omitted, ...safe } = parsed;
88
100
  const map = JSON.stringify(safe);
89
101
  if (map.length > SOURCE_MAP_MAX_CHARS)
@@ -1,4 +1,6 @@
1
1
  import { TraceMap } from "@jridgewell/trace-mapping";
2
+ import { type GeneratedPosition, type MappedSegment } from "./source-map-mappings.js";
3
+ import { ScopeIndex } from "./source-map-scopes.js";
2
4
  import type { SourceMapKey } from "./source-maps-lookup.js";
3
5
  import type { StackFrame } from "./types.js";
4
6
  /**
@@ -7,11 +9,17 @@ import type { StackFrame } from "./types.js";
7
9
  * fingerprinted, so `frames-v3` hashes `checkout.tsx`, not
8
10
  * `index-5f3a9c1e.js` — and an issue survives deploys and preview domains.
9
11
  *
10
- * - Function names come from the map's `names`: the name mapped at the
11
- * CALLER's position is the callee's original name (the call site names
12
- * what it calls). Without one, a resolved frame keeps its parsed name
13
- * only when that is not an obviously mangled one (≤ 2 characters), since
14
- * a mangled name changes on every build.
12
+ * - Function names: when the map carries function scopes
13
+ * (`x_autter_scopes`, uploaded by @autter/runtime-cli after 1.0.0), a
14
+ * frame is named after the innermost function containing it, with that
15
+ * function's original name (null in an anonymous function or top-level
16
+ * code) — see source-map-scopes.ts. Maps without scopes keep the earlier
17
+ * rule: the name mapped at the CALLER's position is the callee's original
18
+ * name; without one, a resolved frame keeps its parsed name only when
19
+ * that is not an obviously mangled one (≤ 2 characters), since a mangled
20
+ * name changes on every build. That rule is wrong for calls through a
21
+ * variable (`const run = actions.captured; run()` names the frame `run`)
22
+ * and for handlers a framework calls, which is why scopes exist.
15
23
  * - Sources the map lists in `ignoreList` / `x_google_ignoreList` are not
16
24
  * in-app; a resolved path under node_modules/ etc. is not in-app either.
17
25
  * - Context lines only when the map carries `sourcesContent` (the upload
@@ -19,7 +27,10 @@ import type { StackFrame } from "./types.js";
19
27
  * - Never fails ingest: any error leaves the frame as parsed.
20
28
  *
21
29
  * Parsed maps are cached per (org, repository, release, file) — or per debug
22
- * id — in a byte-bounded LRU, so a busy issue never refetches its map.
30
+ * id — in a byte-bounded LRU, so a busy issue never refetches its map. A map
31
+ * that would take more than half of that budget is used for its lookup and
32
+ * not kept (source-map-mappings.ts reads it without decoding it), so one huge
33
+ * vendor chunk cannot flush every other map.
23
34
  */
24
35
  export type MapFetcher = (key: SourceMapKey) => Promise<string | null>;
25
36
  export interface SymbolicationScope {
@@ -32,7 +43,8 @@ export interface SymbolicationScope {
32
43
  /** Path-only form of a script URL — the key uploads are stored under. */
33
44
  export declare function scriptPath(filename: string): string | null;
34
45
  export interface SourceMapCacheOptions {
35
- /** Budget for the estimated in-memory size of parsed maps (default 64 MiB). */
46
+ /** Budget for the estimated in-memory size of parsed maps (default 64 MiB).
47
+ * A map that would take more than half of it is not cached. */
36
48
  maxBytes?: number;
37
49
  maxEntries?: number;
38
50
  /** How long a found map is reused (default 1 h). */
@@ -43,6 +55,19 @@ export interface SourceMapCacheOptions {
43
55
  errorTtlMs?: number;
44
56
  now?: () => number;
45
57
  }
58
+ /** A parsed map: its metadata, its function scopes, and position lookups. */
59
+ export interface LoadedSourceMap {
60
+ /** Sources, names, ignore list and sourcesContent. Its own `mappings`
61
+ * are left empty: positions go through `lookup`. */
62
+ map: TraceMap;
63
+ scopes: ScopeIndex | null;
64
+ /** Original positions for 0-based generated positions (one call per stack). */
65
+ lookup(positions: readonly GeneratedPosition[]): Array<MappedSegment | null>;
66
+ /** Estimated heap: decoded mappings (when decoded), strings and scopes. */
67
+ bytes: number;
68
+ /** False when the map was too big to keep, so its mappings were not decoded. */
69
+ cacheable: boolean;
70
+ }
46
71
  export declare class SourceMapCache {
47
72
  private readonly entries;
48
73
  private readonly inFlight;
@@ -55,15 +80,28 @@ export declare class SourceMapCache {
55
80
  private readonly now;
56
81
  /** Fetches made (cache misses) — for tests and /healthz-style debugging. */
57
82
  fetches: number;
83
+ /** Maps used without being cached because they were too big to keep. */
84
+ bypassed: number;
58
85
  constructor(options?: SourceMapCacheOptions);
59
86
  static keyOf(key: SourceMapKey): string;
60
87
  get size(): number;
88
+ /** Estimated bytes of the cached maps (what `maxBytes` bounds). */
89
+ get bytes(): number;
61
90
  /** The parsed map for `key` (null when none), fetching at most once at a time. */
62
- get(key: SourceMapKey, fetchMap: MapFetcher): Promise<TraceMap | null>;
91
+ get(key: SourceMapKey, fetchMap: MapFetcher): Promise<LoadedSourceMap | null>;
63
92
  private load;
64
93
  private store;
65
94
  private remove;
66
95
  }
96
+ /**
97
+ * Parse a stored map once: metadata, scopes (null when absent or malformed
98
+ * — a bad scope field never loses the map) and position lookups. When the
99
+ * estimated heap is at most `maxDecodedBytes`, the mappings are decoded
100
+ * once into a compact table; otherwise they are left encoded and each
101
+ * lookup reads them in one pass (`cacheable: false`). Throws when the map
102
+ * itself is unusable.
103
+ */
104
+ export declare function parseSourceMap(json: string, maxDecodedBytes?: number): LoadedSourceMap;
67
105
  export declare class Symbolicator {
68
106
  private readonly fetchMap;
69
107
  readonly cache: SourceMapCache;
@@ -1,4 +1,6 @@
1
- import { isIgnored, originalPositionFor, sourceContentFor, TraceMap, } from "@jridgewell/trace-mapping";
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";
2
4
  import { isInAppPath, stripQueryAndHash } from "./stack-frames.js";
3
5
  const JS_FILE_RE = /\.[cm]?js$/i;
4
6
  const MAX_CONTEXT_CHARS = 300;
@@ -13,10 +15,11 @@ export function scriptPath(filename) {
13
15
  }
14
16
  }
15
17
  /**
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
+ * 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.
18
21
  */
19
- const DECODED_WEIGHT_FACTOR = 8;
22
+ const MAX_SHARE_OF_BUDGET = 0.5;
20
23
  export class SourceMapCache {
21
24
  entries = new Map();
22
25
  inFlight = new Map();
@@ -29,6 +32,8 @@ export class SourceMapCache {
29
32
  now;
30
33
  /** Fetches made (cache misses) — for tests and /healthz-style debugging. */
31
34
  fetches = 0;
35
+ /** Maps used without being cached because they were too big to keep. */
36
+ bypassed = 0;
32
37
  constructor(options = {}) {
33
38
  this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
34
39
  this.maxEntries = options.maxEntries ?? 500;
@@ -46,6 +51,10 @@ export class SourceMapCache {
46
51
  get size() {
47
52
  return this.entries.size;
48
53
  }
54
+ /** Estimated bytes of the cached maps (what `maxBytes` bounds). */
55
+ get bytes() {
56
+ return this.weight;
57
+ }
49
58
  /** The parsed map for `key` (null when none), fetching at most once at a time. */
50
59
  async get(key, fetchMap) {
51
60
  const id = SourceMapCache.keyOf(key);
@@ -81,17 +90,20 @@ export class SourceMapCache {
81
90
  this.store(id, null, 0, this.missTtlMs);
82
91
  return null;
83
92
  }
84
- let map;
93
+ let loaded;
85
94
  try {
86
- map = new TraceMap(json);
95
+ loaded = parseSourceMap(json, this.maxBytes * MAX_SHARE_OF_BUDGET);
87
96
  }
88
97
  catch {
89
98
  // A malformed upload is a miss until it is replaced.
90
99
  this.store(id, null, 0, this.missTtlMs);
91
100
  return null;
92
101
  }
93
- this.store(id, map, json.length * DECODED_WEIGHT_FACTOR, this.hitTtlMs);
94
- return map;
102
+ if (loaded.cacheable)
103
+ this.store(id, loaded, loaded.bytes, this.hitTtlMs);
104
+ else
105
+ this.bypassed += 1;
106
+ return loaded;
95
107
  }
96
108
  store(id, map, weight, ttlMs) {
97
109
  // A map bigger than the whole budget serves this lookup but is not kept:
@@ -115,6 +127,61 @@ export class SourceMapCache {
115
127
  this.weight -= entry.weight;
116
128
  }
117
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
+ }
118
185
  // ── Resolution ────────────────────────────────────────────────────────────
119
186
  /** A name a minifier evidently produced ("a", "Xe", "Object.t"). */
120
187
  function looksMangled(name) {
@@ -201,24 +268,20 @@ export class Symbolicator {
201
268
  if (!keys.some(Boolean))
202
269
  return frames;
203
270
  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
- });
271
+ const resolutions = resolveFrames(frames, maps);
215
272
  if (!resolutions.some(Boolean))
216
273
  return frames;
217
274
  return frames.map((frame, i) => {
218
275
  const resolution = resolutions[i];
219
276
  if (!resolution)
220
277
  return frame;
221
- // Innermost first: the caller is the next 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.
222
285
  const callerName = resolutions[i + 1]?.nameHere ?? null;
223
286
  const parsed = frame.function;
224
287
  const name = callerName ?? (parsed && !looksMangled(parsed) ? parsed : null);
@@ -226,32 +289,75 @@ export class Symbolicator {
226
289
  });
227
290
  }
228
291
  }
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),
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]);
234
309
  });
235
- if (!position.source || position.line == null)
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)
236
336
  return null;
237
- const filename = stripQueryAndHash(position.source);
337
+ const filename = stripQueryAndHash(source);
238
338
  const ignored = (() => {
239
339
  try {
240
- return isIgnored(map, position.source);
340
+ return isIgnored(map, source);
241
341
  }
242
342
  catch {
243
343
  return false;
244
344
  }
245
345
  })();
346
+ const line = segment.line + 1;
246
347
  const resolved = {
247
348
  function: frame.function,
248
349
  module: null,
249
350
  filename,
250
- lineno: position.line,
251
- colno: position.column == null ? null : position.column + 1,
351
+ lineno: line,
352
+ colno: segment.column + 1,
252
353
  inApp: !ignored && isInAppPath(filename),
253
354
  resolved: true,
254
- ...contextFor(map, position.source, position.line),
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,
255
362
  };
256
- return { frame: resolved, nameHere: position.name ?? null };
257
363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "1.6.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",