@autter/otlp-ingester 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +15 -3
- package/dist/clickhouse.js +12 -1
- package/dist/compat-manifest.json +16 -0
- package/dist/config.d.ts +26 -5
- package/dist/config.js +33 -8
- package/dist/normalize-browser.d.ts +26 -26
- package/dist/normalize-otlp.d.ts +6 -0
- package/dist/normalize-otlp.js +131 -84
- package/dist/server.d.ts +7 -0
- package/dist/server.js +78 -30
- 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-lookup.d.ts +6 -3
- package/dist/source-maps-lookup.js +35 -13
- package/dist/source-maps.d.ts +16 -3
- package/dist/source-maps.js +27 -7
- package/dist/symbolicate.d.ts +46 -8
- package/dist/symbolicate.js +138 -32
- package/dist/trace-dedupe.d.ts +98 -0
- package/dist/trace-dedupe.js +385 -0
- package/dist/types.d.ts +5 -1
- package/package.json +1 -1
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)
|
|
@@ -94,9 +106,12 @@ export function validateSourceMap(body) {
|
|
|
94
106
|
export const SOURCE_MAP_CHECK_MAX = 500;
|
|
95
107
|
/**
|
|
96
108
|
* Validate a `/v1/sourcemaps/check` request:
|
|
97
|
-
* `{ release, files: [{ filename, debugId?, hash }] }` (≤ 500 files). The
|
|
109
|
+
* `{ release, files: [{ filename, debugId?, hash? }] }` (≤ 500 files). The
|
|
98
110
|
* filenames and debug ids are normalised exactly as an upload would store
|
|
99
|
-
* them.
|
|
111
|
+
* them. Each file needs a hash (is this exact map stored? — what `upload`
|
|
112
|
+
* asks to skip maps) or, from 1.7.0, a debug id alone (is any map stored
|
|
113
|
+
* for this bundle? — what `sourcemaps verify` asks after the maps were
|
|
114
|
+
* deleted). Null when invalid.
|
|
100
115
|
*/
|
|
101
116
|
export function validateSourceMapCheck(body) {
|
|
102
117
|
if (!body || typeof body !== "object")
|
|
@@ -111,7 +126,10 @@ export function validateSourceMapCheck(body) {
|
|
|
111
126
|
if (!raw || typeof raw !== "object")
|
|
112
127
|
return null;
|
|
113
128
|
const file = raw;
|
|
114
|
-
if (typeof file.filename !== "string"
|
|
129
|
+
if (typeof file.filename !== "string")
|
|
130
|
+
return null;
|
|
131
|
+
const hash = file.hash === undefined || file.hash === null || file.hash === "" ? "" : file.hash;
|
|
132
|
+
if (typeof hash !== "string" || (hash !== "" && !/^[0-9a-f]{64}$/.test(hash)))
|
|
115
133
|
return null;
|
|
116
134
|
const filename = sourceMapPath(file.filename);
|
|
117
135
|
if (!filename)
|
|
@@ -119,7 +137,9 @@ export function validateSourceMapCheck(body) {
|
|
|
119
137
|
const debugId = file.debugId === undefined || file.debugId === null || file.debugId === "" ? "" : normalizeDebugId(file.debugId);
|
|
120
138
|
if (debugId === null)
|
|
121
139
|
return null;
|
|
122
|
-
|
|
140
|
+
if (!hash && !debugId)
|
|
141
|
+
return null;
|
|
142
|
+
entries.push({ filename, debugId, hash });
|
|
123
143
|
}
|
|
124
144
|
return { release: input.release, entries };
|
|
125
145
|
}
|
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
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { RuntimeOccurrenceInput } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* One error can be recorded on several spans of a trace. In a Next.js app a
|
|
4
|
+
* route handler's error is recorded by @autter/runtime-node on the request's
|
|
5
|
+
* HTTP server span AND by Next's own "executing api route" / render span
|
|
6
|
+
* (OpenTelemetry `recordException`); Express, Fastify, NestJS and gRPC
|
|
7
|
+
* instrumentations do the same, and errored ancestor spans with no event
|
|
8
|
+
* would add a stackless `HttpServerError` / `SpanError` on top. Each of
|
|
9
|
+
* those used to become its own occurrence, so one thrown error was counted
|
|
10
|
+
* two or three times. This pass, run on one export batch, keeps one
|
|
11
|
+
* occurrence per thrown error.
|
|
12
|
+
*
|
|
13
|
+
* Rule 1, duplicate recordings. Two exception occurrences are recordings of
|
|
14
|
+
* the same thrown error when ALL of these hold:
|
|
15
|
+
* - same trace, service, environment and release (a thrown error lives in
|
|
16
|
+
* one process);
|
|
17
|
+
* - on DIFFERENT spans, one an ancestor of the other through parent links
|
|
18
|
+
* present in the batch (an error propagating outward through nested
|
|
19
|
+
* spans). Two events on one span are two recordings made on purpose (a
|
|
20
|
+
* retry loop capturing each attempt on the request span) and are never
|
|
21
|
+
* merged; nor are errors on sibling spans (one span per attempt);
|
|
22
|
+
* - the same error type, or the OpenTelemetry JS `recordException`
|
|
23
|
+
* convention: a third-party recording (no `autter.*` on its event) of an
|
|
24
|
+
* error with a `code` reports the code as `exception.type` ("P2002",
|
|
25
|
+
* "ECONNREFUSED"), so its type may instead equal the other's declared
|
|
26
|
+
* `autter.error.code`, or its stack header may name the other's type;
|
|
27
|
+
* - the same message after scrubbing and `normalizeMessage`;
|
|
28
|
+
* - the same stack: the parsed in-app frames (function, file, line) are
|
|
29
|
+
* equal or one list is a prefix (truncated) or suffix (captured at record
|
|
30
|
+
* time, higher up) of the other. A side without a stack is no evidence
|
|
31
|
+
* against; without in-app frames all frames are compared;
|
|
32
|
+
* - recorded within DUPLICATE_WINDOW_MS of the group's latest recording.
|
|
33
|
+
* Recordings are grouped greedily in time order: each joins the eligible
|
|
34
|
+
* group (no member on its span, every member on its ancestor/descendant
|
|
35
|
+
* chain and matching it) whose latest recording is the most recent, so when
|
|
36
|
+
* every attempt of a retry loop is recorded on a child span and again on the
|
|
37
|
+
* parent, each attempt pairs with its own parent recording.
|
|
38
|
+
*
|
|
39
|
+
* The group keeps the occurrence with the most information: the one the
|
|
40
|
+
* Autter SDK recorded (`autter.*` on the event, or `autter.severity` on the
|
|
41
|
+
* span), then the one carrying runtime-next's `onRequestError` context, then
|
|
42
|
+
* the one on the span closest to the root. It keeps that occurrence's type,
|
|
43
|
+
* message, severity, time and batch position (so its deterministic id), and
|
|
44
|
+
* takes from the others what it lacks: attributes (its own win), stack,
|
|
45
|
+
* route, method, status, request id, declared error fields, fingerprint
|
|
46
|
+
* override and breadcrumbs. `autter.merged_recordings` counts the merged
|
|
47
|
+
* recordings.
|
|
48
|
+
*
|
|
49
|
+
* Rule 2, redundant status-only occurrences. A status-only `HttpServerError`
|
|
50
|
+
* / `SpanError` (an errored span without exception or failed-outcome events)
|
|
51
|
+
* is dropped when the same trace and service in this batch has an exception
|
|
52
|
+
* or failed outcome of severity error or fatal, not `autter.handled`, on an
|
|
53
|
+
* ancestor or descendant of its span: the span failed because of that
|
|
54
|
+
* error. It is kept when it is the trace's only signal, when the only
|
|
55
|
+
* errors are warnings or handled ones, or when no ancestor/descendant link
|
|
56
|
+
* is visible in the batch (sibling branches, a parent exported elsewhere).
|
|
57
|
+
*
|
|
58
|
+
* Scope: one export batch. A request's spans normally end, and are
|
|
59
|
+
* exported, together; when the exporter's timer splits them across batches,
|
|
60
|
+
* the recordings in different batches are still counted separately.
|
|
61
|
+
*/
|
|
62
|
+
/** Two recordings of one thrown error happen as it propagates outward:
|
|
63
|
+
* milliseconds apart, seconds at most for an error handler that awaits. */
|
|
64
|
+
export declare const DUPLICATE_WINDOW_MS = 5000;
|
|
65
|
+
export interface TraceSignal {
|
|
66
|
+
occurrence: RuntimeOccurrenceInput;
|
|
67
|
+
traceId: string;
|
|
68
|
+
spanId: string;
|
|
69
|
+
/** What produced it: an `exception` event, a failed `autter.outcome`
|
|
70
|
+
* event, or the span's error status alone. */
|
|
71
|
+
signal: "exception" | "outcome" | "status";
|
|
72
|
+
/** The Autter SDK recorded it: `autter.*` on the event, or
|
|
73
|
+
* `autter.severity` (captureException's marker) on the span. */
|
|
74
|
+
autterCapture: boolean;
|
|
75
|
+
/** `autter.*` attributes on the event itself. */
|
|
76
|
+
eventAutter: boolean;
|
|
77
|
+
/** runtime-next's `onRequestError` context on the span or event. */
|
|
78
|
+
nextContext: boolean;
|
|
79
|
+
/** `autter.handled` on the event: handled on purpose, not why a span failed. */
|
|
80
|
+
handled: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface SpanLink {
|
|
83
|
+
traceId: string;
|
|
84
|
+
spanId: string;
|
|
85
|
+
parentSpanId: string | null;
|
|
86
|
+
}
|
|
87
|
+
export interface DedupedSignals {
|
|
88
|
+
/** The surviving occurrences, in batch order (merged where rule 1 applied). */
|
|
89
|
+
occurrences: RuntimeOccurrenceInput[];
|
|
90
|
+
/** Input index of each survivor: its position in the batch, which its
|
|
91
|
+
* deterministic occurrence id is derived from. */
|
|
92
|
+
sourceIndexes: number[];
|
|
93
|
+
/** Input indexes each survivor stands for (itself first). */
|
|
94
|
+
members: number[][];
|
|
95
|
+
}
|
|
96
|
+
export declare function hasNextContext(...sources: Array<Map<string, string>>): boolean;
|
|
97
|
+
export declare function hasAutterKey(attrs: Map<string, string>): boolean;
|
|
98
|
+
export declare function dedupeTraceSignals(signals: TraceSignal[], spans: SpanLink[]): DedupedSignals;
|