@autter/otlp-ingester 1.6.1 → 1.8.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 +31 -5
- package/dist/auth.d.ts +16 -1
- package/dist/auth.js +34 -10
- package/dist/clickhouse.d.ts +3 -0
- package/dist/clickhouse.js +38 -1
- package/dist/compat-manifest.json +37 -0
- package/dist/config.d.ts +23 -2
- package/dist/config.js +33 -8
- package/dist/context.d.ts +5 -0
- package/dist/context.js +49 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/ingest-drops.d.ts +308 -0
- package/dist/ingest-drops.js +448 -0
- package/dist/log-promotion.d.ts +4 -3
- package/dist/log-promotion.js +5 -5
- package/dist/logs.d.ts +16 -1
- package/dist/logs.js +17 -3
- package/dist/memory.d.ts +9 -1
- package/dist/memory.js +28 -7
- package/dist/migrations.d.ts +8 -0
- package/dist/migrations.js +29 -1
- package/dist/normalize-browser.d.ts +53 -25
- package/dist/normalize-browser.js +56 -10
- package/dist/normalize-otlp.d.ts +6 -0
- package/dist/normalize-otlp.js +275 -108
- package/dist/otlp-proto.d.ts +14 -0
- package/dist/otlp-proto.js +25 -0
- package/dist/profiles.d.ts +4 -0
- package/dist/profiles.js +6 -2
- package/dist/request-spans.d.ts +45 -0
- package/dist/request-spans.js +91 -0
- package/dist/sdk-versions.d.ts +39 -2
- package/dist/sdk-versions.js +49 -8
- package/dist/server.d.ts +10 -0
- package/dist/server.js +345 -105
- package/dist/sink.d.ts +14 -0
- package/dist/sink.js +15 -0
- package/dist/source-maps-lookup.d.ts +6 -3
- package/dist/source-maps-lookup.js +35 -13
- package/dist/source-maps.d.ts +6 -2
- package/dist/source-maps.js +12 -4
- package/dist/stack-frames.d.ts +5 -2
- package/dist/stack-frames.js +55 -9
- package/dist/symbolicate.d.ts +13 -0
- package/dist/symbolicate.js +22 -0
- 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/sink.d.ts
CHANGED
|
@@ -26,6 +26,17 @@ export interface SinkTuning {
|
|
|
26
26
|
/** Deliveries run concurrently while healthy, serially while failing. */
|
|
27
27
|
healthyConcurrency: number;
|
|
28
28
|
}
|
|
29
|
+
/** A batch the sink gave up on (counted in runtime_ingest_drops, route "sink"). */
|
|
30
|
+
export interface SinkDrop {
|
|
31
|
+
orgId: string;
|
|
32
|
+
repositoryId: string;
|
|
33
|
+
/** sink_overflow: buffer overflow, or a batch bigger than the buffer;
|
|
34
|
+
* sink_rejected: the consumer answered a permanent 4xx;
|
|
35
|
+
* sink_gave_up: SINK_MAX_ATTEMPTS deliveries failed. */
|
|
36
|
+
reason: "sink_overflow" | "sink_rejected" | "sink_gave_up";
|
|
37
|
+
/** Occurrences in the batch. */
|
|
38
|
+
occurrences: number;
|
|
39
|
+
}
|
|
29
40
|
/**
|
|
30
41
|
* Operational counters for /healthz. Failure detail is reduced to a fixed
|
|
31
42
|
* category (`timeout`, `connection_error`, `http_<status>`, `error`) — the
|
|
@@ -72,10 +83,13 @@ export declare class SinkForwarder {
|
|
|
72
83
|
private lastFailureAt;
|
|
73
84
|
private lastFailureReason;
|
|
74
85
|
private readonly tuning;
|
|
86
|
+
/** Called for every batch given up on; must not throw (errors are swallowed). */
|
|
87
|
+
onDrop: ((drop: SinkDrop) => void) | null;
|
|
75
88
|
constructor(config: IngesterConfig, fetchImpl?: typeof fetch, tuning?: Partial<SinkTuning>);
|
|
76
89
|
/** Queue a batch for delivery. No-op when there is nothing to send. */
|
|
77
90
|
enqueue(ctx: IngestContext, occurrences: RuntimeOccurrence[], metricPoints?: RuntimeMetricPoint[], llmCalls?: RuntimeLlmCall[]): void;
|
|
78
91
|
stats(): SinkStats;
|
|
92
|
+
private noteDrop;
|
|
79
93
|
pendingCount(): number;
|
|
80
94
|
/** Stop scheduling new deliveries (shutdown). Queued batches are logged
|
|
81
95
|
* by the caller — they are recoverable from ClickHouse, not from here. */
|
package/dist/sink.js
CHANGED
|
@@ -44,6 +44,8 @@ export class SinkForwarder {
|
|
|
44
44
|
lastFailureAt = null;
|
|
45
45
|
lastFailureReason = null;
|
|
46
46
|
tuning;
|
|
47
|
+
/** Called for every batch given up on; must not throw (errors are swallowed). */
|
|
48
|
+
onDrop = null;
|
|
47
49
|
constructor(config, fetchImpl = fetch, tuning = {}) {
|
|
48
50
|
this.config = config;
|
|
49
51
|
this.fetchImpl = fetchImpl;
|
|
@@ -65,6 +67,7 @@ export class SinkForwarder {
|
|
|
65
67
|
if (batch.bytes > this.maxBufferedBytes()) {
|
|
66
68
|
this.droppedOverflow += 1;
|
|
67
69
|
console.warn(`sink batch ${batch.batchId} (org ${batch.orgId}) exceeds the buffer cap (${batch.bytes} bytes) — dropped; replay signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"} from ClickHouse`);
|
|
70
|
+
this.noteDrop(batch, "sink_overflow");
|
|
68
71
|
return;
|
|
69
72
|
}
|
|
70
73
|
this.queue.push(batch);
|
|
@@ -88,6 +91,14 @@ export class SinkForwarder {
|
|
|
88
91
|
oldestQueuedSince: oldest ? new Date(oldest).toISOString() : null,
|
|
89
92
|
};
|
|
90
93
|
}
|
|
94
|
+
noteDrop(batch, reason) {
|
|
95
|
+
try {
|
|
96
|
+
this.onDrop?.({ orgId: batch.orgId, repositoryId: batch.repositoryId, reason, occurrences: batch.occurrences });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Drop accounting must never affect delivery.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
91
102
|
pendingCount() {
|
|
92
103
|
return this.queue.length + this.inFlight;
|
|
93
104
|
}
|
|
@@ -122,6 +133,8 @@ export class SinkForwarder {
|
|
|
122
133
|
return {
|
|
123
134
|
batchId,
|
|
124
135
|
orgId: ctx.orgId,
|
|
136
|
+
repositoryId: ctx.repositoryId,
|
|
137
|
+
occurrences: occurrences.length,
|
|
125
138
|
body,
|
|
126
139
|
bytes: Buffer.byteLength(body),
|
|
127
140
|
seq: ++this.seqCounter,
|
|
@@ -170,6 +183,7 @@ export class SinkForwarder {
|
|
|
170
183
|
break;
|
|
171
184
|
this.droppedOverflow += 1;
|
|
172
185
|
console.warn(`sink buffer overflow: dropped batch ${dropped.batchId} (org ${dropped.orgId}, signals ${dropped.signalsFrom ?? "?"} .. ${dropped.signalsTo ?? "?"}) — replay this range from ClickHouse`);
|
|
186
|
+
this.noteDrop(dropped, "sink_overflow");
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
189
|
/** The oldest batch of the org holding the most buffered bytes. */
|
|
@@ -280,6 +294,7 @@ export class SinkForwarder {
|
|
|
280
294
|
if (permanent || batch.attempts >= this.config.sinkMaxAttempts) {
|
|
281
295
|
this.droppedPermanent += 1;
|
|
282
296
|
console.error(`sink delivery gave up after ${batch.attempts} attempt(s) (${detail}): batch ${batch.batchId} (signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"}) — replay this range from ClickHouse`);
|
|
297
|
+
this.noteDrop(batch, permanent ? "sink_rejected" : "sink_gave_up");
|
|
283
298
|
return;
|
|
284
299
|
}
|
|
285
300
|
this.retried += 1;
|
|
@@ -21,9 +21,12 @@ export declare function lookupSourceMap(client: ClickHouseClient, database: stri
|
|
|
21
21
|
*/
|
|
22
22
|
export declare const SOURCE_MAP_FRESH_DAYS = 7;
|
|
23
23
|
/**
|
|
24
|
-
* Which check entries the tenant already has
|
|
25
|
-
* and content hash, uploaded in the last
|
|
26
|
-
*
|
|
24
|
+
* Which check entries the tenant already has. An entry with a hash: same
|
|
25
|
+
* release, path, debug id and content hash, uploaded in the last
|
|
26
|
+
* SOURCE_MAP_FRESH_DAYS (the upload skips those). An entry with only a
|
|
27
|
+
* debug id: any stored map with that id, whatever its release and path —
|
|
28
|
+
* exactly what lookupSourceMap would find for a stack from that bundle.
|
|
29
|
+
* Returns the indexes of `check.entries` that are present.
|
|
27
30
|
*/
|
|
28
31
|
export declare function presentSourceMaps(client: ClickHouseClient, database: string, tenant: {
|
|
29
32
|
orgId: string;
|
|
@@ -34,13 +34,41 @@ export async function lookupSourceMap(client, database, key) {
|
|
|
34
34
|
*/
|
|
35
35
|
export const SOURCE_MAP_FRESH_DAYS = 7;
|
|
36
36
|
/**
|
|
37
|
-
* Which check entries the tenant already has
|
|
38
|
-
* and content hash, uploaded in the last
|
|
39
|
-
*
|
|
37
|
+
* Which check entries the tenant already has. An entry with a hash: same
|
|
38
|
+
* release, path, debug id and content hash, uploaded in the last
|
|
39
|
+
* SOURCE_MAP_FRESH_DAYS (the upload skips those). An entry with only a
|
|
40
|
+
* debug id: any stored map with that id, whatever its release and path —
|
|
41
|
+
* exactly what lookupSourceMap would find for a stack from that bundle.
|
|
42
|
+
* Returns the indexes of `check.entries` that are present.
|
|
40
43
|
*/
|
|
41
44
|
export async function presentSourceMaps(client, database, tenant, check) {
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
const hashed = check.entries.some((entry) => entry.hash);
|
|
46
|
+
const byId = check.entries.filter((entry) => !entry.hash).map((entry) => entry.debugId);
|
|
47
|
+
const [withHash, ids] = await Promise.all([
|
|
48
|
+
hashed ? presentByHash(client, database, tenant, check) : new Set(),
|
|
49
|
+
byId.length ? presentDebugIds(client, database, tenant, byId) : new Set(),
|
|
50
|
+
]);
|
|
51
|
+
const present = [];
|
|
52
|
+
check.entries.forEach((entry, index) => {
|
|
53
|
+
const found = entry.hash
|
|
54
|
+
? withHash.has(`${entry.filename}\u0000${entry.debugId}\u0000${entry.hash}`)
|
|
55
|
+
: ids.has(entry.debugId);
|
|
56
|
+
if (found)
|
|
57
|
+
present.push(index);
|
|
58
|
+
});
|
|
59
|
+
return present;
|
|
60
|
+
}
|
|
61
|
+
async function presentDebugIds(client, database, tenant, debugIds) {
|
|
62
|
+
const rows = await client.query({
|
|
63
|
+
query: `SELECT DISTINCT debug_id FROM ${database}.runtime_source_maps
|
|
64
|
+
WHERE org_id = {org:String} AND repository_id = {repo:String}
|
|
65
|
+
AND has({debugIds:Array(String)}, debug_id)`,
|
|
66
|
+
query_params: { org: tenant.orgId, repo: tenant.repositoryId, debugIds: [...new Set(debugIds)] },
|
|
67
|
+
format: "JSONEachRow",
|
|
68
|
+
});
|
|
69
|
+
return new Set((await rows.json()).map((row) => row.debug_id));
|
|
70
|
+
}
|
|
71
|
+
async function presentByHash(client, database, tenant, check) {
|
|
44
72
|
const rows = await client.query({
|
|
45
73
|
query: `SELECT DISTINCT filename, debug_id, content_hash FROM ${database}.runtime_source_maps
|
|
46
74
|
WHERE org_id = {org:String} AND repository_id = {repo:String} AND release = {release:String}
|
|
@@ -50,15 +78,9 @@ export async function presentSourceMaps(client, database, tenant, check) {
|
|
|
50
78
|
org: tenant.orgId,
|
|
51
79
|
repo: tenant.repositoryId,
|
|
52
80
|
release: check.release,
|
|
53
|
-
hashes: [...new Set(check.entries.map((entry) => entry.hash))],
|
|
81
|
+
hashes: [...new Set(check.entries.filter((entry) => entry.hash).map((entry) => entry.hash))],
|
|
54
82
|
},
|
|
55
83
|
format: "JSONEachRow",
|
|
56
84
|
});
|
|
57
|
-
|
|
58
|
-
const present = [];
|
|
59
|
-
check.entries.forEach((entry, index) => {
|
|
60
|
-
if (stored.has(`${entry.filename}\u0000${entry.debugId}\u0000${entry.hash}`))
|
|
61
|
-
present.push(index);
|
|
62
|
-
});
|
|
63
|
-
return present;
|
|
85
|
+
return new Set((await rows.json()).map((row) => `${row.filename}\u0000${row.debug_id}\u0000${row.content_hash}`));
|
|
64
86
|
}
|
package/dist/source-maps.d.ts
CHANGED
|
@@ -49,6 +49,7 @@ export declare function validateSourceMap(body: unknown): ValidSourceMap | null;
|
|
|
49
49
|
export interface SourceMapCheckEntry {
|
|
50
50
|
filename: string;
|
|
51
51
|
debugId: string;
|
|
52
|
+
/** sha256 of the stored map, or "" for a check by debug id alone (1.7.0+). */
|
|
52
53
|
hash: string;
|
|
53
54
|
}
|
|
54
55
|
export interface SourceMapCheck {
|
|
@@ -59,9 +60,12 @@ export interface SourceMapCheck {
|
|
|
59
60
|
export declare const SOURCE_MAP_CHECK_MAX = 500;
|
|
60
61
|
/**
|
|
61
62
|
* Validate a `/v1/sourcemaps/check` request:
|
|
62
|
-
* `{ release, files: [{ filename, debugId?, hash }] }` (≤ 500 files). The
|
|
63
|
+
* `{ release, files: [{ filename, debugId?, hash? }] }` (≤ 500 files). The
|
|
63
64
|
* filenames and debug ids are normalised exactly as an upload would store
|
|
64
|
-
* them.
|
|
65
|
+
* them. Each file needs a hash (is this exact map stored? — what `upload`
|
|
66
|
+
* asks to skip maps) or, from 1.7.0, a debug id alone (is any map stored
|
|
67
|
+
* for this bundle? — what `sourcemaps verify` asks after the maps were
|
|
68
|
+
* deleted). Null when invalid.
|
|
65
69
|
*/
|
|
66
70
|
export declare function validateSourceMapCheck(body: unknown): SourceMapCheck | null;
|
|
67
71
|
/** Most scripts one browser event may name in `debugIds`. */
|
package/dist/source-maps.js
CHANGED
|
@@ -106,9 +106,12 @@ export function validateSourceMap(body) {
|
|
|
106
106
|
export const SOURCE_MAP_CHECK_MAX = 500;
|
|
107
107
|
/**
|
|
108
108
|
* Validate a `/v1/sourcemaps/check` request:
|
|
109
|
-
* `{ release, files: [{ filename, debugId?, hash }] }` (≤ 500 files). The
|
|
109
|
+
* `{ release, files: [{ filename, debugId?, hash? }] }` (≤ 500 files). The
|
|
110
110
|
* filenames and debug ids are normalised exactly as an upload would store
|
|
111
|
-
* 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.
|
|
112
115
|
*/
|
|
113
116
|
export function validateSourceMapCheck(body) {
|
|
114
117
|
if (!body || typeof body !== "object")
|
|
@@ -123,7 +126,10 @@ export function validateSourceMapCheck(body) {
|
|
|
123
126
|
if (!raw || typeof raw !== "object")
|
|
124
127
|
return null;
|
|
125
128
|
const file = raw;
|
|
126
|
-
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)))
|
|
127
133
|
return null;
|
|
128
134
|
const filename = sourceMapPath(file.filename);
|
|
129
135
|
if (!filename)
|
|
@@ -131,7 +137,9 @@ export function validateSourceMapCheck(body) {
|
|
|
131
137
|
const debugId = file.debugId === undefined || file.debugId === null || file.debugId === "" ? "" : normalizeDebugId(file.debugId);
|
|
132
138
|
if (debugId === null)
|
|
133
139
|
return null;
|
|
134
|
-
|
|
140
|
+
if (!hash && !debugId)
|
|
141
|
+
return null;
|
|
142
|
+
entries.push({ filename, debugId, hash });
|
|
135
143
|
}
|
|
136
144
|
return { release: input.release, entries };
|
|
137
145
|
}
|
package/dist/stack-frames.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ import type { StackFrame } from "./types.js";
|
|
|
11
11
|
* - V8 (Node, Chrome, Edge): `at fn (file:line:col)` / `at file:line:col`
|
|
12
12
|
* - Firefox / Safari: `fn@url:line:col` / `@url:line:col`
|
|
13
13
|
* - Python: `File "…", line N, in fn` — tracebacks list the OUTERMOST frame
|
|
14
|
-
* first, so the frames are reversed; only the
|
|
15
|
-
* of a chained exception
|
|
14
|
+
* first, so the frames are reversed; only the reported exception's own
|
|
15
|
+
* frames are used: the last top-level block of a chained exception, and
|
|
16
|
+
* for an exception group (3.11+) its own `| `-margined block, never its
|
|
17
|
+
* sub-exceptions
|
|
16
18
|
* - Go: function line + `\t<file>.go:<line> +0x…`, first goroutine only
|
|
17
19
|
* - JVM: `at pkg.Class.method(File.java:N)`, first exception block only
|
|
18
20
|
* (before `Caused by:`)
|
|
@@ -34,6 +36,7 @@ export type FrameLanguage = "javascript" | "python" | "go" | "jvm" | "dotnet" |
|
|
|
34
36
|
* `C:\app\node_modules\x.js` counts too. A frame with no path is in-app.
|
|
35
37
|
*/
|
|
36
38
|
export declare function isInAppPath(path: string | null): boolean;
|
|
39
|
+
export declare function isPythonStdlibPath(path: string | null): boolean;
|
|
37
40
|
/**
|
|
38
41
|
* Contract §3. Source-map ignore lists are applied by symbolicate.ts; the
|
|
39
42
|
* language rules apply only to frames of that language, so a JS object
|
package/dist/stack-frames.js
CHANGED
|
@@ -11,8 +11,10 @@ import { detectStackLanguage } from "./fingerprint.js";
|
|
|
11
11
|
* - V8 (Node, Chrome, Edge): `at fn (file:line:col)` / `at file:line:col`
|
|
12
12
|
* - Firefox / Safari: `fn@url:line:col` / `@url:line:col`
|
|
13
13
|
* - Python: `File "…", line N, in fn` — tracebacks list the OUTERMOST frame
|
|
14
|
-
* first, so the frames are reversed; only the
|
|
15
|
-
* of a chained exception
|
|
14
|
+
* first, so the frames are reversed; only the reported exception's own
|
|
15
|
+
* frames are used: the last top-level block of a chained exception, and
|
|
16
|
+
* for an exception group (3.11+) its own `| `-margined block, never its
|
|
17
|
+
* sub-exceptions
|
|
16
18
|
* - Go: function line + `\t<file>.go:<line> +0x…`, first goroutine only
|
|
17
19
|
* - JVM: `at pkg.Class.method(File.java:N)`, first exception block only
|
|
18
20
|
* (before `Caused by:`)
|
|
@@ -63,6 +65,22 @@ export function isInAppPath(path) {
|
|
|
63
65
|
return false;
|
|
64
66
|
return true;
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Python's standard library: frozen modules (`<frozen runpy>`) and the
|
|
70
|
+
* version directory of an installation (`/usr/local/lib/python3.12/`,
|
|
71
|
+
* `…/Versions/3.14/lib/python3.14/`, `C:\Python312\Lib\`); a
|
|
72
|
+
* virtualenv's packages are `site-packages`, covered by the path rule. Like
|
|
73
|
+
* Go's `/usr/local/go/`, they are the runtime, not the app: an asyncio or
|
|
74
|
+
* socket error's first five frames would otherwise all be stdlib frames.
|
|
75
|
+
*/
|
|
76
|
+
const PY_STDLIB_RE = /\/lib\/(?:python|pypy)\d\.\d+t?\/|\/python\d{2,3}t?\/lib\//i;
|
|
77
|
+
export function isPythonStdlibPath(path) {
|
|
78
|
+
if (!path)
|
|
79
|
+
return false;
|
|
80
|
+
if (path.startsWith("<frozen "))
|
|
81
|
+
return true;
|
|
82
|
+
return PY_STDLIB_RE.test(path.replace(/\\/g, "/"));
|
|
83
|
+
}
|
|
66
84
|
/** Fully qualified name for the language rules (`module.function`). */
|
|
67
85
|
function qualifiedName(frame) {
|
|
68
86
|
if (frame.module && frame.function)
|
|
@@ -84,6 +102,8 @@ export function isInAppFrame(frame, language) {
|
|
|
84
102
|
if ((frame.filename ?? "").replace(/\\/g, "/").includes("/usr/local/go/"))
|
|
85
103
|
return false;
|
|
86
104
|
}
|
|
105
|
+
if (language === "python" && isPythonStdlibPath(frame.filename))
|
|
106
|
+
return false;
|
|
87
107
|
if (language === "jvm" && /^(?:java|javax|kotlin|jdk)\./.test(qualified))
|
|
88
108
|
return false;
|
|
89
109
|
if (language === "dotnet" && /^(?:System|Microsoft)\./.test(qualified))
|
|
@@ -225,15 +245,41 @@ function parseGecko(lines) {
|
|
|
225
245
|
// ── Python ────────────────────────────────────────────────────────────────
|
|
226
246
|
const PY_FILE_RE = /^\s*File "(.+)", line (\d+)(?:, in (.+?))?\s*$/;
|
|
227
247
|
const PY_TRACEBACK_RE = /^\s*Traceback \(most recent call last\):\s*$/;
|
|
248
|
+
// 3.11+: an exception group prints its own frames behind a "| " margin,
|
|
249
|
+
// under this header (nested groups' headers carry the margin instead of "+"),
|
|
250
|
+
// then its sub-exceptions after a "+-+---- 1 ----" separator.
|
|
251
|
+
const PY_GROUP_TRACEBACK_RE = /^\s*\+ Exception Group Traceback \(most recent call last\):\s*$/;
|
|
252
|
+
const PY_GROUP_MARGIN_RE = /^\s*\|/;
|
|
253
|
+
const PY_GROUP_SUBEXCEPTIONS_RE = /^\s*\+-/;
|
|
254
|
+
function isPythonFrameLine(line) {
|
|
255
|
+
return PY_FILE_RE.test(line) || PY_FILE_RE.test(line.replace(PY_GROUP_MARGIN_RE, ""));
|
|
256
|
+
}
|
|
228
257
|
function parsePython(lines) {
|
|
229
|
-
// A chained exception prints one
|
|
230
|
-
// (final)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
258
|
+
// A chained exception prints one block per exception, the reported
|
|
259
|
+
// (final) one last, whether the chain says "During handling of the above
|
|
260
|
+
// exception" or "The above exception was the direct cause". Blocks of
|
|
261
|
+
// sub-exceptions and of groups in the chain carry a margin, so the last
|
|
262
|
+
// unmargined header starts the reported exception's own frames.
|
|
263
|
+
let start = -1;
|
|
264
|
+
let group = false;
|
|
265
|
+
for (let i = 0; i < lines.length; i++) {
|
|
266
|
+
if (PY_TRACEBACK_RE.test(lines[i])) {
|
|
267
|
+
start = i;
|
|
268
|
+
group = false;
|
|
269
|
+
}
|
|
270
|
+
else if (PY_GROUP_TRACEBACK_RE.test(lines[i])) {
|
|
234
271
|
start = i;
|
|
272
|
+
group = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
235
275
|
const outermostFirst = [];
|
|
236
|
-
for (const
|
|
276
|
+
for (const raw of lines.slice(start + 1)) {
|
|
277
|
+
let line = raw;
|
|
278
|
+
if (group) {
|
|
279
|
+
if (PY_GROUP_SUBEXCEPTIONS_RE.test(line))
|
|
280
|
+
break;
|
|
281
|
+
line = line.replace(PY_GROUP_MARGIN_RE, "");
|
|
282
|
+
}
|
|
237
283
|
const m = PY_FILE_RE.exec(line);
|
|
238
284
|
if (!m)
|
|
239
285
|
continue;
|
|
@@ -391,7 +437,7 @@ export function detectFrameLanguage(lines) {
|
|
|
391
437
|
return "dotnet";
|
|
392
438
|
default:
|
|
393
439
|
}
|
|
394
|
-
if (lines.some(
|
|
440
|
+
if (lines.some(isPythonFrameLine))
|
|
395
441
|
return "python";
|
|
396
442
|
if (lines.some((line) => DOTNET_BARE_RE.test(line)) &&
|
|
397
443
|
!lines.some((line) => /:\d+(?::\d+)?\)?\s*$/.test(line))) {
|
package/dist/symbolicate.d.ts
CHANGED
|
@@ -40,6 +40,19 @@ export interface SymbolicationScope {
|
|
|
40
40
|
/** Script URL → debug id (EI-13), when the browser sent them. */
|
|
41
41
|
debugIds?: Record<string, string> | null;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* In-app browser frames a source map could resolve, counted after
|
|
45
|
+
* symbolication (EI-27): `resolved` ones, and `unresolved` .js/.mjs/.cjs
|
|
46
|
+
* frames left as parsed (no map uploaded, no release or debug id, no
|
|
47
|
+
* mapping at that position). Frames a map moved out of the app
|
|
48
|
+
* (node_modules, ignoreList) and non-JS frames count in neither. Stored per
|
|
49
|
+
* occurrence (`in_app_frames_resolved` / `in_app_frames_unresolved`) for the
|
|
50
|
+
* EI-14 rule: over 30% unresolved means the release needs source maps.
|
|
51
|
+
*/
|
|
52
|
+
export declare function inAppFrameResolution(frames: readonly StackFrame[] | undefined): {
|
|
53
|
+
resolved: number;
|
|
54
|
+
unresolved: number;
|
|
55
|
+
};
|
|
43
56
|
/** Path-only form of a script URL — the key uploads are stored under. */
|
|
44
57
|
export declare function scriptPath(filename: string): string | null;
|
|
45
58
|
export interface SourceMapCacheOptions {
|
package/dist/symbolicate.js
CHANGED
|
@@ -5,6 +5,28 @@ import { isInAppPath, stripQueryAndHash } from "./stack-frames.js";
|
|
|
5
5
|
const JS_FILE_RE = /\.[cm]?js$/i;
|
|
6
6
|
const MAX_CONTEXT_CHARS = 300;
|
|
7
7
|
const CONTEXT_LINES = 3;
|
|
8
|
+
/**
|
|
9
|
+
* In-app browser frames a source map could resolve, counted after
|
|
10
|
+
* symbolication (EI-27): `resolved` ones, and `unresolved` .js/.mjs/.cjs
|
|
11
|
+
* frames left as parsed (no map uploaded, no release or debug id, no
|
|
12
|
+
* mapping at that position). Frames a map moved out of the app
|
|
13
|
+
* (node_modules, ignoreList) and non-JS frames count in neither. Stored per
|
|
14
|
+
* occurrence (`in_app_frames_resolved` / `in_app_frames_unresolved`) for the
|
|
15
|
+
* EI-14 rule: over 30% unresolved means the release needs source maps.
|
|
16
|
+
*/
|
|
17
|
+
export function inAppFrameResolution(frames) {
|
|
18
|
+
let resolved = 0;
|
|
19
|
+
let unresolved = 0;
|
|
20
|
+
for (const frame of frames ?? []) {
|
|
21
|
+
if (!frame.inApp)
|
|
22
|
+
continue;
|
|
23
|
+
if (frame.resolved)
|
|
24
|
+
resolved += 1;
|
|
25
|
+
else if (frame.filename && JS_FILE_RE.test(frame.filename))
|
|
26
|
+
unresolved += 1;
|
|
27
|
+
}
|
|
28
|
+
return { resolved, unresolved };
|
|
29
|
+
}
|
|
8
30
|
/** Path-only form of a script URL — the key uploads are stored under. */
|
|
9
31
|
export function scriptPath(filename) {
|
|
10
32
|
try {
|
|
@@ -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;
|