@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 +1 -1
- package/dist/clickhouse.js +12 -1
- package/dist/config.d.ts +3 -3
- package/dist/normalize-browser.d.ts +12 -12
- package/dist/server.js +35 -21
- package/dist/source-map-mappings.d.ts +57 -0
- package/dist/source-map-mappings.js +245 -0
- package/dist/source-map-scopes.d.ts +71 -0
- package/dist/source-map-scopes.js +190 -0
- package/dist/source-maps.d.ts +10 -1
- package/dist/source-maps.js +15 -3
- package/dist/symbolicate.d.ts +46 -8
- package/dist/symbolicate.js +138 -32
- package/package.json +1 -1
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 (
|
|
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) |
|
package/dist/clickhouse.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/source-maps.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
/**
|
|
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;
|
package/dist/source-maps.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { scrubText } from "./redact.js";
|
|
3
|
-
/**
|
|
4
|
-
|
|
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
|
|
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)
|
package/dist/symbolicate.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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<
|
|
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;
|
package/dist/symbolicate.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { isIgnored,
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* The largest share of the budget one map may take. A bigger one is used
|
|
19
|
+
* for its lookup and dropped: keeping it would evict most other maps, and
|
|
20
|
+
* every event in those files would then refetch theirs.
|
|
18
21
|
*/
|
|
19
|
-
const
|
|
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
|
|
93
|
+
let loaded;
|
|
85
94
|
try {
|
|
86
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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(
|
|
337
|
+
const filename = stripQueryAndHash(source);
|
|
238
338
|
const ignored = (() => {
|
|
239
339
|
try {
|
|
240
|
-
return isIgnored(map,
|
|
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:
|
|
251
|
-
colno:
|
|
351
|
+
lineno: line,
|
|
352
|
+
colno: segment.column + 1,
|
|
252
353
|
inApp: !ignored && isInAppPath(filename),
|
|
253
354
|
resolved: true,
|
|
254
|
-
...contextFor(map,
|
|
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.
|
|
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",
|