@autter/otlp-ingester 1.5.0 → 1.6.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 +1 -0
- package/dist/breadcrumbs.d.ts +41 -0
- package/dist/breadcrumbs.js +151 -0
- package/dist/clickhouse.d.ts +10 -6
- package/dist/clickhouse.js +26 -4
- package/dist/compat-manifest.json +29 -0
- package/dist/compat.d.ts +3 -1
- package/dist/compat.js +3 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +1 -0
- package/dist/context.js +4 -1
- package/dist/fingerprint-override.d.ts +42 -0
- package/dist/fingerprint-override.js +76 -0
- package/dist/fingerprint-v3.d.ts +54 -0
- package/dist/fingerprint-v3.js +96 -0
- package/dist/fingerprint.d.ts +25 -0
- package/dist/fingerprint.js +1 -1
- package/dist/logs.js +2 -0
- package/dist/memory.d.ts +2 -2
- package/dist/migrations.d.ts +10 -0
- package/dist/migrations.js +32 -1
- package/dist/normalize-browser.d.ts +55 -22
- package/dist/normalize-browser.js +24 -1
- package/dist/normalize-otlp.js +4 -0
- package/dist/server.js +73 -11
- package/dist/sink.d.ts +5 -1
- package/dist/sink.js +13 -2
- package/dist/source-maps-lookup.d.ts +31 -0
- package/dist/source-maps-lookup.js +64 -0
- package/dist/source-maps.d.ts +63 -2
- package/dist/source-maps.js +121 -13
- package/dist/stack-fixtures.d.ts +10 -2
- package/dist/stack-fixtures.js +13 -3
- package/dist/stack-frames.d.ts +48 -0
- package/dist/stack-frames.js +441 -0
- package/dist/symbolicate.d.ts +79 -0
- package/dist/symbolicate.js +257 -0
- package/dist/types.d.ts +68 -0
- package/package.json +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { normalizeMessage, occurrenceFingerprint } from "./fingerprint.js";
|
|
3
|
+
import { manualFingerprint } from "./fingerprint-override.js";
|
|
4
|
+
/**
|
|
5
|
+
* `frames-v3` — the structural issue identity (contract §2). Byte-for-byte
|
|
6
|
+
* shared with the platform mirror (`runtime-frames-fingerprint.ts`); the
|
|
7
|
+
* vectors in fingerprint-v3-vectors.json pin both.
|
|
8
|
+
*
|
|
9
|
+
* It hashes service + error type + up to five in-app frames reduced to
|
|
10
|
+
* `function@file`, where file is a lowercased basename with build hashes
|
|
11
|
+
* (`index-5f3a9c1e.js` → `index-*.js`) templated out. Line and column
|
|
12
|
+
* numbers, route, source (browser vs server) and release are never part of
|
|
13
|
+
* it, so one bug stays one issue across deploys, pages, preview domains and
|
|
14
|
+
* tiers. The normalised message is hashed only when no frame was picked.
|
|
15
|
+
*/
|
|
16
|
+
export const FRAMES_V3 = "frames-v3";
|
|
17
|
+
const MAX_PICKED_FRAMES = 5;
|
|
18
|
+
/** §2.3 — the function half of a frame token. */
|
|
19
|
+
export function frameTokenFunction(frame) {
|
|
20
|
+
let fn = frame.function ?? "";
|
|
21
|
+
fn = fn.replace(/^async /, "");
|
|
22
|
+
fn = fn.replace(/^new /, "");
|
|
23
|
+
fn = fn.replace(/^(?:Object|Module)\./, "");
|
|
24
|
+
if (frame.resolved === false && fn.length <= 2)
|
|
25
|
+
fn = "";
|
|
26
|
+
if (fn === "<anonymous>" || fn === "anonymous")
|
|
27
|
+
fn = "";
|
|
28
|
+
return fn;
|
|
29
|
+
}
|
|
30
|
+
/** §2.3 — the file half of a frame token. */
|
|
31
|
+
export function frameTokenFile(frame) {
|
|
32
|
+
let file = frame.filename ?? frame.module ?? "";
|
|
33
|
+
const cut = file.search(/[?#]/);
|
|
34
|
+
if (cut !== -1)
|
|
35
|
+
file = file.slice(0, cut);
|
|
36
|
+
file = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1);
|
|
37
|
+
file = file.replace(/[A-Za-z0-9]{8,}/g, (run) => /[0-9]/.test(run) || run === run.toUpperCase() ? "*" : run);
|
|
38
|
+
return file.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
export function frameToken(frame) {
|
|
41
|
+
return `${frameTokenFunction(frame)}@${frameTokenFile(frame)}`;
|
|
42
|
+
}
|
|
43
|
+
/** §2.1–2.2 — in-app frames in order, else only the first frame; at most 5. */
|
|
44
|
+
export function pickFrames(frames) {
|
|
45
|
+
const inApp = frames.filter((frame) => frame.inApp === true);
|
|
46
|
+
const picked = inApp.length > 0 ? inApp : frames.slice(0, 1);
|
|
47
|
+
return picked.slice(0, MAX_PICKED_FRAMES);
|
|
48
|
+
}
|
|
49
|
+
export function framesV3Fingerprint(input) {
|
|
50
|
+
const tokens = pickFrames(input.frames).map(frameToken);
|
|
51
|
+
const message = tokens.length === 0 ? normalizeMessage(input.message) : "";
|
|
52
|
+
const fingerprint = createHash("sha256")
|
|
53
|
+
.update([FRAMES_V3, input.service, input.errorType, tokens.join("\u0001"), message].join("\u0000"))
|
|
54
|
+
.digest("hex")
|
|
55
|
+
.slice(0, 32);
|
|
56
|
+
return {
|
|
57
|
+
fingerprint,
|
|
58
|
+
record: {
|
|
59
|
+
scheme: FRAMES_V3,
|
|
60
|
+
parts: [input.errorType, ...tokens, message].filter((part) => part !== ""),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The candidate list the sink carries (contract §1/§4), highest precedence
|
|
66
|
+
* first:
|
|
67
|
+
* manual-v1 (SDK override) → code-v1 (valid error code) → frames-v3 →
|
|
68
|
+
* message-v1 (legacy).
|
|
69
|
+
* A coded error's legacy identity IS code-v1, which outranks every auto
|
|
70
|
+
* scheme, so its list is [manual-v1?, code-v1]; an uncoded one is
|
|
71
|
+
* [manual-v1?, frames-v3, message-v1]. Either way the list ends with the
|
|
72
|
+
* legacy fingerprint, so the platform can always fall back to it.
|
|
73
|
+
*/
|
|
74
|
+
export function occurrenceIdentity(input, frames) {
|
|
75
|
+
const legacy = occurrenceFingerprint(input);
|
|
76
|
+
const candidates = [];
|
|
77
|
+
const manual = manualFingerprint(input);
|
|
78
|
+
if (manual)
|
|
79
|
+
candidates.push({ scheme: "manual-v1", fingerprint: manual });
|
|
80
|
+
let record = null;
|
|
81
|
+
if (legacy.scheme === "code-v1") {
|
|
82
|
+
candidates.push({ scheme: "code-v1", fingerprint: legacy.fingerprint });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const v3 = framesV3Fingerprint({
|
|
86
|
+
service: input.service,
|
|
87
|
+
errorType: input.errorType,
|
|
88
|
+
message: input.message,
|
|
89
|
+
frames,
|
|
90
|
+
});
|
|
91
|
+
record = v3.record;
|
|
92
|
+
candidates.push({ scheme: FRAMES_V3, fingerprint: v3.fingerprint });
|
|
93
|
+
candidates.push({ scheme: "message-v1", fingerprint: legacy.fingerprint });
|
|
94
|
+
}
|
|
95
|
+
return { legacy, candidates, record };
|
|
96
|
+
}
|
package/dist/fingerprint.d.ts
CHANGED
|
@@ -2,6 +2,31 @@ import type { RuntimeOccurrenceInput } from "./types.js";
|
|
|
2
2
|
export declare function normalizeMessage(message: string): string;
|
|
3
3
|
/** Replace id-like path segments so /orders/812 and /orders/44 group. */
|
|
4
4
|
export declare function normalizeRoute(route: string | null): string;
|
|
5
|
+
/**
|
|
6
|
+
* Backend exceptions reach us in many native stack formats. The fingerprint
|
|
7
|
+
* groups by the top stack frames, so a format we cannot parse yields an EMPTY
|
|
8
|
+
* frame list — and then every error sharing a message collapses into one
|
|
9
|
+
* issue (two unrelated Go panics, say, become a single group). To keep
|
|
10
|
+
* unrelated defects apart, each officially-supported language is parsed and
|
|
11
|
+
* every frame reduced to a stable `function (file)` token that preserves the
|
|
12
|
+
* function, module and file.
|
|
13
|
+
*
|
|
14
|
+
* Volatile parts are stripped: line/column numbers, `+0x` offsets, pointer
|
|
15
|
+
* arguments and memory addresses. Line numbers are parsed but deliberately
|
|
16
|
+
* left OUT of the token — they shift on every re-deploy, and the JS path has
|
|
17
|
+
* always dropped them for exactly this reason, so the same defect stays one
|
|
18
|
+
* issue across rebuilds. The raw stack is retained verbatim on its own column.
|
|
19
|
+
*
|
|
20
|
+
* JavaScript/TypeScript, Firefox `@` frames and Python `File` frames keep
|
|
21
|
+
* their historical normalisation byte-for-byte (parseScriptFrames) so existing
|
|
22
|
+
* issues never regroup.
|
|
23
|
+
*/
|
|
24
|
+
export type StackLanguage = "go" | "rust" | "jvm" | "dotnet" | "script";
|
|
25
|
+
/**
|
|
26
|
+
* Classify a whole stack by language. Signatures are chosen to be unique to
|
|
27
|
+
* each runtime so a JS or Python stack always falls through to "script".
|
|
28
|
+
*/
|
|
29
|
+
export declare function detectStackLanguage(lines: string[]): StackLanguage;
|
|
5
30
|
export declare function normalizeStackFrames(stack: string | null, topN?: number): string[];
|
|
6
31
|
/**
|
|
7
32
|
* Error codes (`autter.error.code`) — namespaced, stable, low-cardinality
|
package/dist/fingerprint.js
CHANGED
|
@@ -70,7 +70,7 @@ const RUST_AT_RS_RE = /^\s*at\s+\S+\.rs:\d+/;
|
|
|
70
70
|
* Classify a whole stack by language. Signatures are chosen to be unique to
|
|
71
71
|
* each runtime so a JS or Python stack always falls through to "script".
|
|
72
72
|
*/
|
|
73
|
-
function detectStackLanguage(lines) {
|
|
73
|
+
export function detectStackLanguage(lines) {
|
|
74
74
|
let hasDotnet = false;
|
|
75
75
|
let hasJvm = false;
|
|
76
76
|
let hasRust = false;
|
package/dist/logs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { decodeOtlpAttributes, sanitizeRuntimeContext, } from "./context.js";
|
|
3
3
|
import { declaredLink, liftErrorFields } from "./error-fields.js";
|
|
4
|
+
import { fingerprintOverrideOf } from "./fingerprint-override.js";
|
|
4
5
|
import { normalizeRoute } from "./fingerprint.js";
|
|
5
6
|
export function normalizeLogs(request) {
|
|
6
7
|
if (!request || !Array.isArray(request.resourceLogs))
|
|
@@ -122,6 +123,7 @@ export function normalizeLogs(request) {
|
|
|
122
123
|
attributes,
|
|
123
124
|
occurredAt,
|
|
124
125
|
...declared,
|
|
126
|
+
...fingerprintOverrideOf(attributes["autter.fingerprint"]),
|
|
125
127
|
}
|
|
126
128
|
: undefined;
|
|
127
129
|
rows.push({
|
package/dist/memory.d.ts
CHANGED
|
@@ -24,16 +24,16 @@ export declare const platformEventSchema: z.ZodObject<{
|
|
|
24
24
|
service: string;
|
|
25
25
|
environment: string;
|
|
26
26
|
release: string;
|
|
27
|
+
occurredAt: string;
|
|
27
28
|
kind: "oom_kill" | "restart";
|
|
28
29
|
instanceId: string;
|
|
29
30
|
platform: "ecs" | "kubernetes" | "other";
|
|
30
|
-
occurredAt: string;
|
|
31
31
|
}, {
|
|
32
32
|
service: string;
|
|
33
|
+
occurredAt: string;
|
|
33
34
|
kind: "oom_kill" | "restart";
|
|
34
35
|
instanceId: string;
|
|
35
36
|
platform: "ecs" | "kubernetes" | "other";
|
|
36
|
-
occurredAt: string;
|
|
37
37
|
environment?: string | undefined;
|
|
38
38
|
release?: string | undefined;
|
|
39
39
|
}>;
|
package/dist/migrations.d.ts
CHANGED
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
/** Migration 0013 columns — shared with the baseline DDL in clickhouse.ts. */
|
|
36
36
|
export declare const OCCURRENCE_CODE_COLUMNS: string[];
|
|
37
37
|
export declare const OCCURRENCE_REQUEST_ID_INDEX = "INDEX idx_occ_request_id request_id TYPE bloom_filter GRANULARITY 4";
|
|
38
|
+
/**
|
|
39
|
+
* Migration 0016 columns — shared with the baseline DDL in clickhouse.ts.
|
|
40
|
+
* `frames`: the structured (symbolicated) frames as JSON, '' when none.
|
|
41
|
+
* `fingerprints`: every identity candidate (manual-v1, code-v1, frames-v3,
|
|
42
|
+
* message-v1 — highest precedence first, ending with the legacy one), so
|
|
43
|
+
* readers find an issue keyed by ANY scheme with
|
|
44
|
+
* `(fingerprint = X OR has(fingerprints, X))`; rows written before 0016
|
|
45
|
+
* read `[]` and still match on the legacy `fingerprint`.
|
|
46
|
+
*/
|
|
47
|
+
export declare const OCCURRENCE_GROUPING_COLUMNS: string[];
|
|
38
48
|
export interface Migration {
|
|
39
49
|
/** Unique, ordered id: "<serial>-<slug>". Never reuse or reorder. */
|
|
40
50
|
id: string;
|
package/dist/migrations.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { latencyTableDDL } from "./latency.js";
|
|
36
36
|
import { profileTableDDL } from "./profiles.js";
|
|
37
|
-
import { sourceMapTableDDL } from "./source-maps.js";
|
|
37
|
+
import { SOURCE_MAP_DEBUG_COLUMNS, SOURCE_MAP_DEBUG_ID_INDEX, sourceMapTableDDL, } from "./source-maps.js";
|
|
38
38
|
import { LOG_REQUEST_COLUMNS, LOG_REQUEST_ID_INDEX, logTableDDL, requestRollupTableDDL, requestRollupViewDDL, } from "./logs.js";
|
|
39
39
|
import { memoryTableDDL, platformEventTableDDL } from "./memory.js";
|
|
40
40
|
import { ingesterInfoTableDDL, sdkVersionTableDDL } from "./sdk-versions.js";
|
|
@@ -49,6 +49,19 @@ export const OCCURRENCE_CODE_COLUMNS = [
|
|
|
49
49
|
"fingerprint_scheme LowCardinality(String) DEFAULT 'message-v1'",
|
|
50
50
|
];
|
|
51
51
|
export const OCCURRENCE_REQUEST_ID_INDEX = "INDEX idx_occ_request_id request_id TYPE bloom_filter GRANULARITY 4";
|
|
52
|
+
/**
|
|
53
|
+
* Migration 0016 columns — shared with the baseline DDL in clickhouse.ts.
|
|
54
|
+
* `frames`: the structured (symbolicated) frames as JSON, '' when none.
|
|
55
|
+
* `fingerprints`: every identity candidate (manual-v1, code-v1, frames-v3,
|
|
56
|
+
* message-v1 — highest precedence first, ending with the legacy one), so
|
|
57
|
+
* readers find an issue keyed by ANY scheme with
|
|
58
|
+
* `(fingerprint = X OR has(fingerprints, X))`; rows written before 0016
|
|
59
|
+
* read `[]` and still match on the legacy `fingerprint`.
|
|
60
|
+
*/
|
|
61
|
+
export const OCCURRENCE_GROUPING_COLUMNS = [
|
|
62
|
+
"frames String DEFAULT '' CODEC(ZSTD(3))",
|
|
63
|
+
"fingerprints Array(String) DEFAULT []",
|
|
64
|
+
];
|
|
52
65
|
export const MIGRATIONS = [
|
|
53
66
|
// 0001 intentionally reserved as the baseline marker: databases created
|
|
54
67
|
// before the migration runner existed record it as applied without
|
|
@@ -171,6 +184,24 @@ export const MIGRATIONS = [
|
|
|
171
184
|
// Version compatibility: SDK name/version seen per service, and this
|
|
172
185
|
// ingester's own version + schema level for the Autter dashboard.
|
|
173
186
|
{ id: "0015-runtime-compat", statements: [sdkVersionTableDDL("{db}"), ingesterInfoTableDDL("{db}")] },
|
|
187
|
+
// Structured stacks and multi-scheme identity (frames-v3): the platform
|
|
188
|
+
// resolves an issue from any candidate, so occurrence lookups by an
|
|
189
|
+
// issue's fingerprint must match every candidate a row carried.
|
|
190
|
+
{
|
|
191
|
+
id: "0016-occurrence-frames-fingerprints",
|
|
192
|
+
statements: OCCURRENCE_GROUPING_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
|
|
193
|
+
},
|
|
194
|
+
// Source map debug ids (EI-13): the bundle's debug id, so a map matches
|
|
195
|
+
// by the bytes it was built from whatever release the SDK reports, plus
|
|
196
|
+
// the stored map's sha256 so CI skips maps it already uploaded. The
|
|
197
|
+
// sorting key is unchanged: uploads still need a release.
|
|
198
|
+
{
|
|
199
|
+
id: "0017-source-maps-debug-id",
|
|
200
|
+
statements: [
|
|
201
|
+
...SOURCE_MAP_DEBUG_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_source_maps ADD COLUMN IF NOT EXISTS ${column}`),
|
|
202
|
+
`ALTER TABLE {db}.runtime_source_maps ADD ${SOURCE_MAP_DEBUG_ID_INDEX.replace("INDEX ", "INDEX IF NOT EXISTS ")}`,
|
|
203
|
+
],
|
|
204
|
+
},
|
|
174
205
|
];
|
|
175
206
|
/** The tracking table itself — created by the runner before anything else. */
|
|
176
207
|
export function migrationsTableDDL(db) {
|
|
@@ -34,56 +34,85 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
34
34
|
* instead of rejecting the whole beacon.
|
|
35
35
|
*/
|
|
36
36
|
context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
37
|
+
/** runtime-browser `/breadcrumbs`: steps before a failure. Validated entry by entry in breadcrumbs.ts; a malformed list is dropped, never the beacon. */
|
|
38
|
+
breadcrumbs: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>>;
|
|
39
|
+
/** runtime-browser ≥1.6.0: script URL → debug id for the scripts in this
|
|
40
|
+
* event's stack (EI-13). Malformed values are dropped, never the beacon. */
|
|
41
|
+
debugIds: z.ZodCatch<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
42
|
+
/**
|
|
43
|
+
* Fingerprint override (runtime-browser 1.6.0+): one string or a list.
|
|
44
|
+
* Bounded and scrubbed in fingerprint-override.ts; a malformed value is
|
|
45
|
+
* dropped on its own.
|
|
46
|
+
*/
|
|
47
|
+
fingerprint: z.ZodCatch<z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>>>;
|
|
48
|
+
/**
|
|
49
|
+
* runtime-browser ≥1.6.0: W3C trace id of the request behind the event
|
|
50
|
+
* (the `traceparent` it sent), joining it to the server trace. A malformed
|
|
51
|
+
* id is dropped on its own instead of rejecting the beacon.
|
|
52
|
+
*/
|
|
53
|
+
traceId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
37
54
|
}, "strip", z.ZodTypeAny, {
|
|
38
|
-
type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
39
55
|
message: string;
|
|
56
|
+
type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
40
57
|
timestamp: string;
|
|
41
|
-
|
|
58
|
+
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
59
|
+
errorType?: string | undefined;
|
|
60
|
+
stack?: string | undefined;
|
|
42
61
|
route?: string | undefined;
|
|
62
|
+
traceId?: string | undefined;
|
|
63
|
+
breadcrumbs?: unknown[] | undefined;
|
|
64
|
+
debugIds?: Record<string, unknown> | undefined;
|
|
65
|
+
durationMs?: number | undefined;
|
|
43
66
|
filename?: string | undefined;
|
|
44
67
|
id?: string | undefined;
|
|
45
|
-
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
46
68
|
name?: string | undefined;
|
|
47
|
-
stack?: string | undefined;
|
|
48
|
-
errorType?: string | undefined;
|
|
49
69
|
line?: number | undefined;
|
|
50
70
|
column?: number | undefined;
|
|
51
71
|
context?: Record<string, unknown> | undefined;
|
|
72
|
+
fingerprint?: string | string[] | undefined;
|
|
52
73
|
}, {
|
|
53
|
-
type: "
|
|
74
|
+
type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
54
75
|
timestamp: string;
|
|
55
|
-
durationMs?: number | undefined;
|
|
56
|
-
route?: string | undefined;
|
|
57
76
|
message?: string | undefined;
|
|
77
|
+
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
78
|
+
errorType?: string | undefined;
|
|
79
|
+
stack?: string | undefined;
|
|
80
|
+
route?: string | undefined;
|
|
81
|
+
traceId?: unknown;
|
|
82
|
+
breadcrumbs?: unknown;
|
|
83
|
+
debugIds?: unknown;
|
|
84
|
+
durationMs?: number | undefined;
|
|
58
85
|
filename?: string | undefined;
|
|
59
86
|
id?: string | undefined;
|
|
60
|
-
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
61
87
|
name?: string | undefined;
|
|
62
|
-
stack?: string | undefined;
|
|
63
|
-
errorType?: string | undefined;
|
|
64
88
|
line?: number | undefined;
|
|
65
89
|
column?: number | undefined;
|
|
66
90
|
context?: Record<string, unknown> | undefined;
|
|
91
|
+
fingerprint?: unknown;
|
|
67
92
|
}>, "many">;
|
|
68
93
|
}, "strip", z.ZodTypeAny, {
|
|
69
94
|
service: string;
|
|
70
95
|
environment: string;
|
|
71
96
|
version: 1;
|
|
72
97
|
events: {
|
|
73
|
-
type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
74
98
|
message: string;
|
|
99
|
+
type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
75
100
|
timestamp: string;
|
|
76
|
-
|
|
101
|
+
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
102
|
+
errorType?: string | undefined;
|
|
103
|
+
stack?: string | undefined;
|
|
77
104
|
route?: string | undefined;
|
|
105
|
+
traceId?: string | undefined;
|
|
106
|
+
breadcrumbs?: unknown[] | undefined;
|
|
107
|
+
debugIds?: Record<string, unknown> | undefined;
|
|
108
|
+
durationMs?: number | undefined;
|
|
78
109
|
filename?: string | undefined;
|
|
79
110
|
id?: string | undefined;
|
|
80
|
-
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
81
111
|
name?: string | undefined;
|
|
82
|
-
stack?: string | undefined;
|
|
83
|
-
errorType?: string | undefined;
|
|
84
112
|
line?: number | undefined;
|
|
85
113
|
column?: number | undefined;
|
|
86
114
|
context?: Record<string, unknown> | undefined;
|
|
115
|
+
fingerprint?: string | string[] | undefined;
|
|
87
116
|
}[];
|
|
88
117
|
release?: string | undefined;
|
|
89
118
|
sessionId?: string | undefined;
|
|
@@ -93,20 +122,24 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
93
122
|
environment: string;
|
|
94
123
|
version: 1;
|
|
95
124
|
events: {
|
|
96
|
-
type: "
|
|
125
|
+
type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
|
|
97
126
|
timestamp: string;
|
|
98
|
-
durationMs?: number | undefined;
|
|
99
|
-
route?: string | undefined;
|
|
100
127
|
message?: string | undefined;
|
|
128
|
+
severity?: "info" | "warning" | "error" | "fatal" | undefined;
|
|
129
|
+
errorType?: string | undefined;
|
|
130
|
+
stack?: string | undefined;
|
|
131
|
+
route?: string | undefined;
|
|
132
|
+
traceId?: unknown;
|
|
133
|
+
breadcrumbs?: unknown;
|
|
134
|
+
debugIds?: unknown;
|
|
135
|
+
durationMs?: number | undefined;
|
|
101
136
|
filename?: string | undefined;
|
|
102
137
|
id?: string | undefined;
|
|
103
|
-
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
104
138
|
name?: string | undefined;
|
|
105
|
-
stack?: string | undefined;
|
|
106
|
-
errorType?: string | undefined;
|
|
107
139
|
line?: number | undefined;
|
|
108
140
|
column?: number | undefined;
|
|
109
141
|
context?: Record<string, unknown> | undefined;
|
|
142
|
+
fingerprint?: unknown;
|
|
110
143
|
}[];
|
|
111
144
|
release?: string | undefined;
|
|
112
145
|
sessionId?: string | undefined;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { liftErrorFields } from "./error-fields.js";
|
|
3
|
+
import { fingerprintOverrideOf } from "./fingerprint-override.js";
|
|
3
4
|
import { sanitizeRuntimeContext } from "./context.js";
|
|
4
5
|
import { normalizeRoute } from "./fingerprint.js";
|
|
5
6
|
import { scrubText } from "./redact.js";
|
|
7
|
+
import { breadcrumbsField } from "./breadcrumbs.js";
|
|
8
|
+
import { browserDebugIds } from "./source-maps.js";
|
|
6
9
|
import { asSeverity, } from "./types.js";
|
|
7
10
|
/**
|
|
8
11
|
* The compact browser payload (`version: 1`) emitted by
|
|
@@ -46,6 +49,23 @@ const browserEventSchema = z.object({
|
|
|
46
49
|
* instead of rejecting the whole beacon.
|
|
47
50
|
*/
|
|
48
51
|
context: z.record(z.unknown()).optional(),
|
|
52
|
+
/** runtime-browser `/breadcrumbs`: steps before a failure. Validated entry by entry in breadcrumbs.ts; a malformed list is dropped, never the beacon. */
|
|
53
|
+
breadcrumbs: z.array(z.unknown()).optional().catch(undefined),
|
|
54
|
+
/** runtime-browser ≥1.6.0: script URL → debug id for the scripts in this
|
|
55
|
+
* event's stack (EI-13). Malformed values are dropped, never the beacon. */
|
|
56
|
+
debugIds: z.record(z.string().max(1000), z.unknown()).optional().catch(undefined),
|
|
57
|
+
/**
|
|
58
|
+
* Fingerprint override (runtime-browser 1.6.0+): one string or a list.
|
|
59
|
+
* Bounded and scrubbed in fingerprint-override.ts; a malformed value is
|
|
60
|
+
* dropped on its own.
|
|
61
|
+
*/
|
|
62
|
+
fingerprint: z.union([z.string().max(2000), z.array(z.string().max(2000)).max(50)]).optional().catch(undefined),
|
|
63
|
+
/**
|
|
64
|
+
* runtime-browser ≥1.6.0: W3C trace id of the request behind the event
|
|
65
|
+
* (the `traceparent` it sent), joining it to the server trace. A malformed
|
|
66
|
+
* id is dropped on its own instead of rejecting the beacon.
|
|
67
|
+
*/
|
|
68
|
+
traceId: z.string().regex(/^(?!0{32})[0-9a-f]{32}$/).optional().catch(undefined),
|
|
49
69
|
});
|
|
50
70
|
export const browserPayloadSchema = z.object({
|
|
51
71
|
version: z.literal(1),
|
|
@@ -156,7 +176,7 @@ export function normalizeBrowserPayload(payload) {
|
|
|
156
176
|
route: event.route ? (event.route.split("?")[0] ?? null) : null,
|
|
157
177
|
method: null,
|
|
158
178
|
statusCode: null,
|
|
159
|
-
traceId: null,
|
|
179
|
+
traceId: event.traceId ?? null,
|
|
160
180
|
sessionId: payload.sessionId ?? null,
|
|
161
181
|
attributes: {
|
|
162
182
|
...(event.filename ? { filename: event.filename.split("?")[0] } : {}),
|
|
@@ -166,6 +186,9 @@ export function normalizeBrowserPayload(payload) {
|
|
|
166
186
|
},
|
|
167
187
|
occurredAt,
|
|
168
188
|
...liftErrorFields(event.context),
|
|
189
|
+
...breadcrumbsField(event.breadcrumbs),
|
|
190
|
+
...browserDebugIds(event.debugIds),
|
|
191
|
+
...fingerprintOverrideOf(event.fingerprint),
|
|
169
192
|
});
|
|
170
193
|
}
|
|
171
194
|
return { occurrences, metricPoints: [...rollups.values()] };
|
package/dist/normalize-otlp.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { decodeOtlpAttributes, sanitizeRuntimeContext } from "./context.js";
|
|
2
2
|
import { liftErrorFields, validRequestId } from "./error-fields.js";
|
|
3
|
+
import { fingerprintOverrideOf } from "./fingerprint-override.js";
|
|
3
4
|
import { scrubText } from "./redact.js";
|
|
4
5
|
import { normalizeRoute } from "./fingerprint.js";
|
|
5
6
|
import { extractLlmCall } from "./llm.js";
|
|
7
|
+
import { BREADCRUMBS_ATTRIBUTE, breadcrumbsField } from "./breadcrumbs.js";
|
|
6
8
|
import { asSeverity, } from "./types.js";
|
|
7
9
|
const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
|
|
8
10
|
function attrMap(attributes) {
|
|
@@ -172,6 +174,8 @@ export function normalizeTraces(request) {
|
|
|
172
174
|
// Event attributes first: one span can carry several
|
|
173
175
|
// exceptions, each with its own code.
|
|
174
176
|
...liftErrorFields(eventAttrs, attrs),
|
|
177
|
+
...breadcrumbsField(eventAttrs.get(BREADCRUMBS_ATTRIBUTE), attrs.get(BREADCRUMBS_ATTRIBUTE)),
|
|
178
|
+
...fingerprintOverrideOf(eventAttrs.get("autter.fingerprint") ?? attrs.get("autter.fingerprint")),
|
|
175
179
|
});
|
|
176
180
|
}
|
|
177
181
|
const failedOutcomes = (span.events ?? []).filter((event) => {
|
package/dist/server.js
CHANGED
|
@@ -2,7 +2,10 @@ import express from "express";
|
|
|
2
2
|
import { KeyResolver, RateLimiter } from "./auth.js";
|
|
3
3
|
import { ClickHouseStore } from "./clickhouse.js";
|
|
4
4
|
import { normalizeLatencyHistograms } from "./latency.js";
|
|
5
|
-
import { deriveFields,
|
|
5
|
+
import { deriveFields, occurrenceIdFor } from "./fingerprint.js";
|
|
6
|
+
import { occurrenceIdentity } from "./fingerprint-v3.js";
|
|
7
|
+
import { parseStackFrames } from "./stack-frames.js";
|
|
8
|
+
import { SourceMapCache, Symbolicator } from "./symbolicate.js";
|
|
6
9
|
import { logPromotionCandidates, promotionLookups, promotionRollups, } from "./log-promotion.js";
|
|
7
10
|
import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
|
|
8
11
|
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
@@ -10,7 +13,7 @@ import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest } from "./o
|
|
|
10
13
|
import { normalizeLogs } from "./logs.js";
|
|
11
14
|
import { decodeProfile } from "./profiles.js";
|
|
12
15
|
import { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
|
|
13
|
-
import { validateSourceMap } from "./source-maps.js";
|
|
16
|
+
import { validateSourceMap, validateSourceMapCheck } from "./source-maps.js";
|
|
14
17
|
import { SinkForwarder } from "./sink.js";
|
|
15
18
|
import { configureRedaction, scrubOccurrence } from "./redact.js";
|
|
16
19
|
import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
|
|
@@ -22,6 +25,9 @@ const INGESTER_INFO_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
|
22
25
|
export function createIngesterApp(config) {
|
|
23
26
|
configureRedaction(config);
|
|
24
27
|
const store = new ClickHouseStore(config);
|
|
28
|
+
// Browser JS frames are resolved through uploaded source maps before they
|
|
29
|
+
// are fingerprinted (frames-v3), with parsed maps cached per release+file.
|
|
30
|
+
const symbolicator = new Symbolicator((key) => store.lookupSourceMap(key), new SourceMapCache({ maxBytes: (config.sourceMapCacheMb ?? 64) * 1024 * 1024 }));
|
|
25
31
|
// Fingerprinted occurrences feed the consumer's issue grouping, metric
|
|
26
32
|
// points feed the request/error-rate rollups, LLM calls feed spend
|
|
27
33
|
// watching. Delivery is at-least-once with bounded retries — see sink.ts.
|
|
@@ -203,28 +209,64 @@ export function createIngesterApp(config) {
|
|
|
203
209
|
}
|
|
204
210
|
return ctx;
|
|
205
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Structured frames for the frames-v3 identity and the platform's stack
|
|
214
|
+
* view: parsed innermost-first, then — browser only, where bundles are
|
|
215
|
+
* minified — resolved through the release's source maps. Symbolication
|
|
216
|
+
* never fails ingest (it falls back to the parsed frames).
|
|
217
|
+
*/
|
|
218
|
+
async function framesFor(ctx, input) {
|
|
219
|
+
let frames;
|
|
220
|
+
try {
|
|
221
|
+
frames = parseStackFrames(input.stack);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
console.warn("stack parse failed — no frames:", err instanceof Error ? err.message : err);
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
if (input.source !== "browser" || frames.length === 0)
|
|
228
|
+
return frames;
|
|
229
|
+
return symbolicator.symbolicate(frames, {
|
|
230
|
+
orgId: ctx.orgId,
|
|
231
|
+
repositoryId: ctx.repositoryId,
|
|
232
|
+
release: input.release,
|
|
233
|
+
debugIds: input.debugIds ?? null,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
206
236
|
/** Ids are content-derived (occurrenceIdFor), NOT random: an exporter
|
|
207
237
|
* that retries a batch — after a 503 from a partially-failed ClickHouse
|
|
208
238
|
* write, or when only our 2xx got lost — must produce the same ids, so
|
|
209
239
|
* the sink consumer's per-occurrence dedupe holds across transport
|
|
210
|
-
* retries and duplicated ClickHouse rows stay identifiable.
|
|
211
|
-
|
|
212
|
-
|
|
240
|
+
* retries and duplicated ClickHouse rows stay identifiable.
|
|
241
|
+
*
|
|
242
|
+
* `fingerprint` / `fingerprintScheme` stay the legacy identity
|
|
243
|
+
* (message-v1 or code-v1) byte-for-byte; `fingerprints` carries every
|
|
244
|
+
* candidate (manual-v1, code-v1, frames-v3, message-v1) for the platform
|
|
245
|
+
* to resolve the issue from. */
|
|
246
|
+
async function fingerprintAll(ctx, inputs) {
|
|
247
|
+
return Promise.all(inputs.map(async (raw, index) => {
|
|
213
248
|
// Second line of defence, before fingerprinting, storage, and the
|
|
214
249
|
// sink (which feeds LLM fix generation): scrub secrets from the
|
|
215
250
|
// free-text fields old SDKs and third-party OTLP senders pass
|
|
216
251
|
// through verbatim. Attributes were already sanitised by the
|
|
217
252
|
// normalisers (sanitizeRuntimeContext).
|
|
218
253
|
const input = scrubOccurrence(raw);
|
|
219
|
-
const
|
|
254
|
+
const frames = await framesFor(ctx, input);
|
|
255
|
+
const identity = occurrenceIdentity(input, frames);
|
|
256
|
+
const { fingerprint, scheme } = identity.legacy;
|
|
257
|
+
// Debug ids only locate source maps; they are not stored or forwarded.
|
|
258
|
+
const { debugIds: _debugIds, ...stored } = input;
|
|
220
259
|
return {
|
|
221
|
-
...
|
|
260
|
+
...stored,
|
|
222
261
|
occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
|
|
223
262
|
fingerprint,
|
|
224
263
|
fingerprintScheme: scheme,
|
|
225
264
|
...deriveFields(input),
|
|
265
|
+
...(frames.length > 0 ? { frames } : {}),
|
|
266
|
+
fingerprints: identity.candidates,
|
|
267
|
+
...(identity.record ? { fingerprintRecord: identity.record } : {}),
|
|
226
268
|
};
|
|
227
|
-
});
|
|
269
|
+
}));
|
|
228
270
|
}
|
|
229
271
|
/** Fingerprint log-promotion candidates and drop the ones whose trace
|
|
230
272
|
* already produced an occurrence (in this batch, or within ±60 s). Ids
|
|
@@ -234,7 +276,7 @@ export function createIngesterApp(config) {
|
|
|
234
276
|
const candidates = logPromotionCandidates(logs);
|
|
235
277
|
if (candidates.length === 0)
|
|
236
278
|
return [];
|
|
237
|
-
const fingerprinted = fingerprintAll(ctx, candidates);
|
|
279
|
+
const fingerprinted = await fingerprintAll(ctx, candidates);
|
|
238
280
|
const seen = new Set();
|
|
239
281
|
const ownIds = fingerprinted.map((o) => o.occurrenceId);
|
|
240
282
|
for (const lookup of promotionLookups(candidates)) {
|
|
@@ -308,6 +350,8 @@ export function createIngesterApp(config) {
|
|
|
308
350
|
res.status(400).json({ error: "invalid source map" });
|
|
309
351
|
return;
|
|
310
352
|
}
|
|
353
|
+
// Idempotent: the same release + path replaces the earlier row
|
|
354
|
+
// (ReplacingMergeTree), so CI retries and re-runs are harmless.
|
|
311
355
|
try {
|
|
312
356
|
await store.insertSourceMap(ctx, sourceMap);
|
|
313
357
|
res.status(202).json({ accepted: true });
|
|
@@ -316,6 +360,24 @@ export function createIngesterApp(config) {
|
|
|
316
360
|
storageError(res, err);
|
|
317
361
|
}
|
|
318
362
|
});
|
|
363
|
+
/** Which of these maps does this tenant already have? Lets
|
|
364
|
+
* `autter-runtime sourcemaps upload` skip unchanged maps (1.6.0+). */
|
|
365
|
+
app.post("/v1/sourcemaps/check", async (req, res) => {
|
|
366
|
+
const ctx = await authenticate(req, res, "otlp");
|
|
367
|
+
if (!ctx)
|
|
368
|
+
return;
|
|
369
|
+
const check = validateSourceMapCheck(req.body);
|
|
370
|
+
if (!check) {
|
|
371
|
+
res.status(400).json({ error: "invalid source map check" });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
res.status(200).json({ present: await store.sourceMapsPresent(ctx, check) });
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
storageError(res, err);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
319
381
|
/** ECS/Kubernetes event forwarders use the same server key as OTLP. */
|
|
320
382
|
app.post("/v1/platform-events", async (req, res) => {
|
|
321
383
|
const ctx = await authenticate(req, res, "otlp");
|
|
@@ -357,7 +419,7 @@ export function createIngesterApp(config) {
|
|
|
357
419
|
request = req.body;
|
|
358
420
|
}
|
|
359
421
|
const { occurrences, spans, metricPoints, llmCalls } = normalizeTraces(request);
|
|
360
|
-
const fingerprinted = fingerprintAll(ctx, occurrences);
|
|
422
|
+
const fingerprinted = await fingerprintAll(ctx, occurrences);
|
|
361
423
|
// ClickHouse has no cross-table transaction, so these four inserts can
|
|
362
424
|
// partially commit. Recovery boundary: any failure → 503 → the exporter
|
|
363
425
|
// retries the whole batch. Deterministic occurrence ids make the retry
|
|
@@ -473,7 +535,7 @@ export function createIngesterApp(config) {
|
|
|
473
535
|
return;
|
|
474
536
|
}
|
|
475
537
|
const { occurrences, metricPoints } = normalizeBrowserPayload(parsed.data);
|
|
476
|
-
const fingerprinted = fingerprintAll(ctx, occurrences);
|
|
538
|
+
const fingerprinted = await fingerprintAll(ctx, occurrences);
|
|
477
539
|
try {
|
|
478
540
|
await Promise.all([
|
|
479
541
|
store.insertOccurrences(ctx, fingerprinted),
|
package/dist/sink.d.ts
CHANGED
|
@@ -107,6 +107,10 @@ export declare class SinkForwarder {
|
|
|
107
107
|
* requestId, traceId, route, method, statusCode, fingerprintScheme. The
|
|
108
108
|
* request-context four were previously spread as `null` when unknown and
|
|
109
109
|
* are now simply left out, matching the documented optional-string shape.
|
|
110
|
-
*
|
|
110
|
+
* 1.6.0 adds `frames` (innermost first, ≤ 50), `fingerprints` (every
|
|
111
|
+
* identity candidate, highest precedence first, ending with the legacy
|
|
112
|
+
* `fingerprint`) and `fingerprintRecord`; `fingerprint`/`fingerprintScheme`
|
|
113
|
+
* keep their legacy meaning. Older consumers ignore unknown keys, so the
|
|
114
|
+
* version stays 1.
|
|
111
115
|
*/
|
|
112
116
|
export declare function sinkOccurrence(o: RuntimeOccurrence): Record<string, unknown>;
|