@autter/otlp-ingester 1.10.0 → 1.11.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 -1
- package/dist/cause-chain.d.ts +32 -0
- package/dist/cause-chain.js +122 -0
- package/dist/clickhouse.js +31 -2
- package/dist/compat-manifest.json +29 -0
- package/dist/fingerprint-v3.d.ts +63 -7
- package/dist/fingerprint-v3.js +96 -7
- package/dist/ingest-drops.d.ts +10 -0
- package/dist/ingest-drops.js +13 -0
- package/dist/latency.d.ts +42 -0
- package/dist/latency.js +80 -0
- package/dist/log-promotion.js +20 -1
- package/dist/logs.d.ts +3 -3
- package/dist/logs.js +13 -4
- package/dist/migrations.d.ts +3 -2
- package/dist/migrations.js +12 -3
- package/dist/normalize-browser.d.ts +12 -12
- package/dist/normalize-browser.js +17 -3
- package/dist/normalize-otlp.js +39 -5
- package/dist/server.js +56 -18
- package/dist/sink.d.ts +5 -2
- package/dist/sink.js +7 -3
- package/dist/source-maps-lookup.d.ts +14 -1
- package/dist/source-maps-lookup.js +13 -3
- package/dist/source-maps.d.ts +49 -6
- package/dist/source-maps.js +122 -12
- package/dist/symbolicate.d.ts +83 -11
- package/dist/symbolicate.js +183 -31
- package/dist/trace-dedupe.d.ts +1 -1
- package/dist/trace-dedupe.js +3 -1
- package/dist/types.d.ts +26 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILIT
|
|
|
121
121
|
| `SINK_MAX_ATTEMPTS` | `12` | Delivery attempts per batch (1–60 s backoff) |
|
|
122
122
|
| `SINK_MAX_BUFFERED_BATCHES` | `1000` | Retry buffer cap; oldest drops are logged |
|
|
123
123
|
| `SINK_MAX_BUFFERED_MB` | `64` | Sink retry buffer cap (memory) |
|
|
124
|
-
| `SOURCE_MAP_CACHE_MB` | `64` | Memory budget (decoded size, function scope tables included) for parsed source maps used to symbolicate browser frames at ingest; least recently used maps are evicted, and a map that needs more than half of it is used without being kept |
|
|
124
|
+
| `SOURCE_MAP_CACHE_MB` | `64` | Memory budget (decoded size, function scope tables included) for parsed source maps used to symbolicate browser, Node and edge frames at ingest; least recently used maps are evicted, and a map that needs more than half of it is used without being kept. The source contents of cached maps (context lines) are kept apart, one file per entry, under a budget of the same size, so they never change which maps are cached |
|
|
125
125
|
| `MAX_BODY_BYTES` | `1048576` | Request body cap |
|
|
126
126
|
| `LOOP_COLLAPSE_AFTER` | `10` | Identical occurrences stored per minute before the rest of the minute is collapsed into one sample with an exact count; `0` turns loop collapse off |
|
|
127
127
|
| `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The causes of a reported exception (RF-06), for the `cause-v1`
|
|
3
|
+
* fingerprint (fingerprint-v3.ts): its direct cause first, the root cause
|
|
4
|
+
* last, each with its type and the stack text its frames are parsed from.
|
|
5
|
+
*
|
|
6
|
+
* Where the chain comes from:
|
|
7
|
+
* - JVM: the `Caused by:` blocks of the stack (top level only; a
|
|
8
|
+
* `Suppressed:` block and the causes inside it are not part of the chain).
|
|
9
|
+
* - .NET: `Outer ---> Inner ---> Root` in the header and the frame segments
|
|
10
|
+
* between `--- End of inner exception stack trace ---` markers, innermost
|
|
11
|
+
* exception first.
|
|
12
|
+
* - Python: every top-level traceback block of a chained exception ("The
|
|
13
|
+
* above exception was the direct cause…", "During handling of the above
|
|
14
|
+
* exception…"); the first block is the root cause, the last the reported
|
|
15
|
+
* exception.
|
|
16
|
+
* - Otherwise (JavaScript `cause`, and any SDK that sends them): the
|
|
17
|
+
* `exception.cause.N.type` / `exception.cause.N.stacktrace` attributes
|
|
18
|
+
* (runtime-core errorAttributes), N = 1, 2, … until one is missing.
|
|
19
|
+
*
|
|
20
|
+
* The reported exception's own frames are the ones parseStackFrames reads
|
|
21
|
+
* from the whole stack (the first JVM block, the last .NET segment, the last
|
|
22
|
+
* Python block), so a cause never repeats them. Shared parity vectors: the
|
|
23
|
+
* `causes` of stack-parse-vectors.json.
|
|
24
|
+
*/
|
|
25
|
+
export interface CauseText {
|
|
26
|
+
/** The cause's error type as written (`java.lang.IllegalStateException`, `TypeError`). */
|
|
27
|
+
type: string;
|
|
28
|
+
/** Its stack text (frames parse with parseStackFrames), or null when none was sent. */
|
|
29
|
+
stack: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** The causes of an exception, nearest first; [] when it has none. */
|
|
32
|
+
export declare function causeTexts(stack: string | null | undefined, attributes: Record<string, unknown> | null | undefined): CauseText[];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { detectFrameLanguage } from "./stack-frames.js";
|
|
2
|
+
/** The causes of an exception, nearest first; [] when it has none. */
|
|
3
|
+
export function causeTexts(stack, attributes) {
|
|
4
|
+
const fromStack = stack ? stackCauses(stack) : [];
|
|
5
|
+
return fromStack.length > 0 ? fromStack : attributeCauses(attributes);
|
|
6
|
+
}
|
|
7
|
+
function stackCauses(stack) {
|
|
8
|
+
const lines = stack.split(/\r?\n/);
|
|
9
|
+
switch (detectFrameLanguage(lines)) {
|
|
10
|
+
case "jvm":
|
|
11
|
+
return jvmCauses(lines);
|
|
12
|
+
case "dotnet":
|
|
13
|
+
return dotnetCauses(lines);
|
|
14
|
+
case "python":
|
|
15
|
+
return pythonCauses(lines);
|
|
16
|
+
default:
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ── JVM ───────────────────────────────────────────────────────────────────
|
|
21
|
+
const JVM_CAUSED_BY_RE = /^(\s*)Caused by:\s*([^\s:]+)/;
|
|
22
|
+
function jvmCauses(lines) {
|
|
23
|
+
// The chain's "Caused by:" lines are indented no deeper than the header;
|
|
24
|
+
// those of a suppressed exception are indented one level more.
|
|
25
|
+
const header = lines.find((line) => line.trim() !== "") ?? "";
|
|
26
|
+
const indent = header.length - header.trimStart().length;
|
|
27
|
+
const starts = [];
|
|
28
|
+
lines.forEach((line, index) => {
|
|
29
|
+
const match = JVM_CAUSED_BY_RE.exec(line);
|
|
30
|
+
if (match && match[1].length <= indent)
|
|
31
|
+
starts.push({ index, type: match[2] });
|
|
32
|
+
});
|
|
33
|
+
return starts.map((start, k) => ({
|
|
34
|
+
type: start.type,
|
|
35
|
+
stack: lines.slice(start.index, starts[k + 1]?.index ?? lines.length).join("\n"),
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
// ── .NET ──────────────────────────────────────────────────────────────────
|
|
39
|
+
const DOTNET_FRAME_LINE_RE = /^\s*at\s/;
|
|
40
|
+
const DOTNET_END_INNER_RE = /---\s*End of inner exception stack trace\s*---/i;
|
|
41
|
+
function dotnetCauses(lines) {
|
|
42
|
+
const firstFrame = lines.findIndex((line) => DOTNET_FRAME_LINE_RE.test(line));
|
|
43
|
+
if (firstFrame <= 0)
|
|
44
|
+
return [];
|
|
45
|
+
// "Outer: msg ---> Inner: msg ---> Root: msg" (an arrow may start a line).
|
|
46
|
+
const types = lines
|
|
47
|
+
.slice(0, firstFrame)
|
|
48
|
+
.join("\n")
|
|
49
|
+
.split(/\s*--->\s*/)
|
|
50
|
+
.map((part) => /^\s*([^\s:]+)/.exec(part)?.[1] ?? "");
|
|
51
|
+
if (types.length < 2)
|
|
52
|
+
return [];
|
|
53
|
+
// Frame segments: the innermost exception's first, the reported one's last.
|
|
54
|
+
const segments = [[]];
|
|
55
|
+
for (const line of lines.slice(firstFrame)) {
|
|
56
|
+
if (DOTNET_END_INNER_RE.test(line))
|
|
57
|
+
segments.push([]);
|
|
58
|
+
else
|
|
59
|
+
segments[segments.length - 1].push(line);
|
|
60
|
+
}
|
|
61
|
+
const causes = [];
|
|
62
|
+
for (let k = 1; k < types.length; k++) {
|
|
63
|
+
const segment = segments[segments.length - 1 - k];
|
|
64
|
+
causes.push({ type: types[k], stack: segment && segment.length > 0 ? segment.join("\n") : null });
|
|
65
|
+
}
|
|
66
|
+
return causes;
|
|
67
|
+
}
|
|
68
|
+
// ── Python ────────────────────────────────────────────────────────────────
|
|
69
|
+
const PY_TRACEBACK_RE = /^\s*Traceback \(most recent call last\):\s*$/;
|
|
70
|
+
const PY_GROUP_TRACEBACK_RE = /^\s*\+ Exception Group Traceback \(most recent call last\):\s*$/;
|
|
71
|
+
const PY_CHAIN_RE = /^(?:During handling of the above exception, another exception occurred|The above exception was the direct cause of the following exception):\s*$/;
|
|
72
|
+
const PY_GROUP_MARGIN_RE = /^\s*\| ?/;
|
|
73
|
+
const PY_GROUP_SUBEXCEPTIONS_RE = /^\s*\+-/;
|
|
74
|
+
const PY_TYPE_RE = /^([A-Za-z_][\w.]*)(?::|\s*$)/;
|
|
75
|
+
function pythonCauses(lines) {
|
|
76
|
+
const starts = [];
|
|
77
|
+
lines.forEach((line, index) => {
|
|
78
|
+
if (PY_TRACEBACK_RE.test(line))
|
|
79
|
+
starts.push({ index, group: false });
|
|
80
|
+
else if (PY_GROUP_TRACEBACK_RE.test(line))
|
|
81
|
+
starts.push({ index, group: true });
|
|
82
|
+
});
|
|
83
|
+
if (starts.length < 2)
|
|
84
|
+
return [];
|
|
85
|
+
const blocks = starts.map((start, k) => {
|
|
86
|
+
const body = lines.slice(start.index, starts[k + 1]?.index ?? lines.length);
|
|
87
|
+
return { type: pythonExceptionType(body.slice(1), start.group), stack: body.join("\n") };
|
|
88
|
+
});
|
|
89
|
+
// Printed root cause first and the reported exception last.
|
|
90
|
+
return blocks.slice(0, -1).reverse();
|
|
91
|
+
}
|
|
92
|
+
/** The exception line of one traceback block: its first unindented line. */
|
|
93
|
+
function pythonExceptionType(body, group) {
|
|
94
|
+
for (const raw of body) {
|
|
95
|
+
let line = raw;
|
|
96
|
+
if (group) {
|
|
97
|
+
if (PY_GROUP_SUBEXCEPTIONS_RE.test(line))
|
|
98
|
+
break;
|
|
99
|
+
line = line.replace(PY_GROUP_MARGIN_RE, "");
|
|
100
|
+
}
|
|
101
|
+
if (line.trim() === "" || /^\s/.test(line))
|
|
102
|
+
continue;
|
|
103
|
+
if (PY_CHAIN_RE.test(line.trim()))
|
|
104
|
+
break;
|
|
105
|
+
return PY_TYPE_RE.exec(line)?.[1] ?? "";
|
|
106
|
+
}
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
// ── Attributes (JavaScript and any SDK that sends them) ───────────────────
|
|
110
|
+
function attributeCauses(attributes) {
|
|
111
|
+
if (!attributes)
|
|
112
|
+
return [];
|
|
113
|
+
const causes = [];
|
|
114
|
+
for (let n = 1;; n++) {
|
|
115
|
+
const type = attributes[`exception.cause.${n}.type`];
|
|
116
|
+
if (typeof type !== "string")
|
|
117
|
+
break;
|
|
118
|
+
const stack = attributes[`exception.cause.${n}.stacktrace`];
|
|
119
|
+
causes.push({ type, stack: typeof stack === "string" && stack !== "" ? stack : null });
|
|
120
|
+
}
|
|
121
|
+
return causes;
|
|
122
|
+
}
|
package/dist/clickhouse.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createClient, } from "@clickhouse/client";
|
|
2
|
-
import { latencyTableDDL } from "./latency.js";
|
|
2
|
+
import { latencyTableDDL, routeLatencyInputDDL, routeLatencyTableDDL, routeLatencyViewDDL, } from "./latency.js";
|
|
3
3
|
import { profileTableDDL } from "./profiles.js";
|
|
4
4
|
import { memoryTableDDL, platformEventTableDDL } from "./memory.js";
|
|
5
5
|
import { sourceMapTableDDL } from "./source-maps.js";
|
|
@@ -24,7 +24,12 @@ const INSERT_SETTINGS = {
|
|
|
24
24
|
/** Source maps above this go in with a plain insert (below ClickHouse's
|
|
25
25
|
* default async_insert_max_data_size of 10 MiB). */
|
|
26
26
|
const ASYNC_INSERT_MAX_MAP_CHARS = 8 * 1024 * 1024;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Migration 0020 columns: browser occurrences only. Server frames are
|
|
29
|
+
* symbolicated too since RF-05, but an unresolved server `.js` frame is
|
|
30
|
+
* usually readable code (an unbundled Node app), so it would make the EI-14
|
|
31
|
+
* "needs source maps" ratio cry wolf.
|
|
32
|
+
*/
|
|
28
33
|
function occurrenceFrameResolution(o) {
|
|
29
34
|
const counts = o.source === "browser" ? inAppFrameResolution(o.frames) : { resolved: 0, unresolved: 0 };
|
|
30
35
|
return {
|
|
@@ -73,6 +78,10 @@ export class ClickHouseStore {
|
|
|
73
78
|
return [
|
|
74
79
|
`CREATE DATABASE IF NOT EXISTS ${db}`,
|
|
75
80
|
latencyTableDDL(db, metricsTtlDays),
|
|
81
|
+
// Per-route p50/p95/p99 (IP-02): input, rollup, then its view.
|
|
82
|
+
routeLatencyInputDDL(db),
|
|
83
|
+
routeLatencyTableDDL(db, metricsTtlDays),
|
|
84
|
+
routeLatencyViewDDL(db),
|
|
76
85
|
profileTableDDL(db),
|
|
77
86
|
memoryTableDDL(db),
|
|
78
87
|
platformEventTableDDL(db),
|
|
@@ -598,6 +607,26 @@ export class ClickHouseStore {
|
|
|
598
607
|
session_count: Math.max(0, Math.round(p.sessionCount)),
|
|
599
608
|
})),
|
|
600
609
|
});
|
|
610
|
+
// Their percentiles: the view folds these into runtime_route_latency_1m.
|
|
611
|
+
const latency = points.filter((p) => p.latency && p.latency.valuesMs.length > 0);
|
|
612
|
+
if (latency.length === 0)
|
|
613
|
+
return;
|
|
614
|
+
await this.getClient().insert({
|
|
615
|
+
table: this.table("runtime_route_latency_input"),
|
|
616
|
+
format: "JSONEachRow",
|
|
617
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
618
|
+
values: latency.map((p) => ({
|
|
619
|
+
org_id: ctx.orgId,
|
|
620
|
+
repository_id: ctx.repositoryId,
|
|
621
|
+
service: p.service,
|
|
622
|
+
environment: p.environment,
|
|
623
|
+
release: p.release ?? "",
|
|
624
|
+
route: p.route,
|
|
625
|
+
bucket_at: p.bucketAt.toISOString(),
|
|
626
|
+
values_ms: p.latency.valuesMs,
|
|
627
|
+
weights: p.latency.weights.map((weight) => Math.max(0, Math.round(weight))),
|
|
628
|
+
})),
|
|
629
|
+
});
|
|
601
630
|
}
|
|
602
631
|
async insertLatencyHistograms(ctx, points) {
|
|
603
632
|
if (points.length === 0 || !this.configured)
|
|
@@ -177,6 +177,35 @@
|
|
|
177
177
|
"migrations": [],
|
|
178
178
|
"route": "/v1/logs",
|
|
179
179
|
"sdks": { "@autter/runtime-edge": "1.3.0" }
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"id": "caught_errors",
|
|
183
|
+
"title": "Caught errors and deliberate fallbacks recorded as handled occurrences",
|
|
184
|
+
"ingester": "1.11.0",
|
|
185
|
+
"migrations": [],
|
|
186
|
+
"sdks": { "@autter/runtime-node": "1.9.0", "@autter/runtime-edge": "1.4.0", "@autter/runtime-next": "1.9.0", "@autter/runtime-browser": "1.8.0" }
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"id": "route_percentiles",
|
|
190
|
+
"title": "Per-route p95 and p99 latency in the 1-minute rollups",
|
|
191
|
+
"ingester": "1.11.0",
|
|
192
|
+
"migrations": ["0022-route-latency-1m"],
|
|
193
|
+
"sdks": {}
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"id": "server_source_maps",
|
|
197
|
+
"title": "Server bundles symbolicated, with source context lines",
|
|
198
|
+
"ingester": "1.11.0",
|
|
199
|
+
"migrations": [],
|
|
200
|
+
"route": "/v1/sourcemaps",
|
|
201
|
+
"sdks": { "@autter/runtime-cli": "1.3.0", "@autter/runtime-node": "1.9.0", "@autter/runtime-edge": "1.4.0", "@autter/runtime-next": "1.9.0" }
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"id": "cause_fingerprint",
|
|
205
|
+
"title": "Wrapped errors grouped with their root cause (cause-v1)",
|
|
206
|
+
"ingester": "1.11.0",
|
|
207
|
+
"migrations": [],
|
|
208
|
+
"sdks": { "@autter/runtime-node": "1.9.0", "@autter/runtime-edge": "1.4.0", "@autter/runtime-next": "1.9.0" }
|
|
180
209
|
}
|
|
181
210
|
]
|
|
182
211
|
}
|
package/dist/fingerprint-v3.d.ts
CHANGED
|
@@ -30,25 +30,81 @@ export interface FramesV3Result {
|
|
|
30
30
|
record: FingerprintRecord;
|
|
31
31
|
}
|
|
32
32
|
export declare function framesV3Fingerprint(input: FramesV3Input): FramesV3Result;
|
|
33
|
+
/**
|
|
34
|
+
* `cause-v1` — the root-cause identity (RF-06). Byte-for-byte shared with
|
|
35
|
+
* the platform mirror (`runtime-frames-fingerprint.ts`); the `cause` of the
|
|
36
|
+
* entries in fingerprint-v3-vectors.json pins both.
|
|
37
|
+
*
|
|
38
|
+
* It hashes service + the root cause's error type + ONE frame: the
|
|
39
|
+
* innermost in-app frame across the cause chain, looked for from the root
|
|
40
|
+
* cause outward (the root's own innermost in-app frame when it has one,
|
|
41
|
+
* else its nearest wrapper's, …). So `new Error("checkout failed", { cause:
|
|
42
|
+
* fetchError })` and the unwrapped `TypeError: fetch failed` hash alike, as
|
|
43
|
+
* do JVM `Caused by:` chains, .NET inner exceptions and Python chained
|
|
44
|
+
* exceptions with their root. An error without a cause is its own root, so
|
|
45
|
+
* the raw form carries the same candidate. No in-app frame anywhere in the
|
|
46
|
+
* chain: no candidate.
|
|
47
|
+
*
|
|
48
|
+
* It is a lookup candidate only: frames-v3 stays the identity issues are
|
|
49
|
+
* created under (see occurrenceIdentity), and frames-v3 itself is unchanged,
|
|
50
|
+
* so no existing issue regroups.
|
|
51
|
+
*/
|
|
52
|
+
export declare const CAUSE_V1 = "cause-v1";
|
|
53
|
+
/** One exception of a chain: its error type and frames, innermost first. */
|
|
54
|
+
export interface CauseLink {
|
|
55
|
+
type: string;
|
|
56
|
+
frames: StackFrame[];
|
|
57
|
+
}
|
|
58
|
+
export interface CauseV1Input {
|
|
59
|
+
service: string;
|
|
60
|
+
/** The reported exception first, then its cause, …, the root cause last. */
|
|
61
|
+
chain: CauseLink[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The type half of cause-v1: the last segment of a qualified name, so the
|
|
65
|
+
* same class written `java.lang.IllegalStateException` in a JVM `Caused by:`
|
|
66
|
+
* line and in `exception.type`, or `requests.exceptions.ConnectionError` in a
|
|
67
|
+
* Python traceback and `ConnectionError` in OTel's `exception.type`, match.
|
|
68
|
+
*/
|
|
69
|
+
export declare function causeTypeToken(type: string): string;
|
|
70
|
+
export declare function causeV1Fingerprint(input: CauseV1Input): FramesV3Result | null;
|
|
71
|
+
/** The causes of an occurrence with their frames as parsed (not symbolicated). */
|
|
72
|
+
export declare function parsedCauseLinks(input: Pick<RuntimeOccurrenceInput, "stack" | "attributes">): CauseLink[];
|
|
33
73
|
export interface OccurrenceIdentity {
|
|
34
74
|
/** Legacy identity, unchanged: what `fingerprint`/`fingerprintScheme` carry. */
|
|
35
75
|
legacy: {
|
|
36
76
|
fingerprint: string;
|
|
37
77
|
scheme: "message-v1" | "code-v1";
|
|
38
78
|
};
|
|
39
|
-
/** Every applicable scheme,
|
|
79
|
+
/** Every applicable scheme, in precedence order (see occurrenceIdentity). */
|
|
40
80
|
candidates: FingerprintCandidate[];
|
|
41
81
|
/** What went into the frames-v3 fingerprint, when one was computed. */
|
|
42
82
|
record: FingerprintRecord | null;
|
|
83
|
+
/** What went into the cause-v1 fingerprint, when one was computed. */
|
|
84
|
+
causeRecord: FingerprintRecord | null;
|
|
43
85
|
}
|
|
44
86
|
/**
|
|
45
|
-
* The candidate list the sink carries (contract §1/§4),
|
|
46
|
-
* first:
|
|
87
|
+
* The candidate list the sink carries (contract §1/§4), in precedence order:
|
|
47
88
|
* manual-v1 (SDK override) → code-v1 (valid error code) → frames-v3 →
|
|
48
|
-
* message-v1 (legacy).
|
|
89
|
+
* message-v1 (legacy) → cause-v1 (RF-06).
|
|
49
90
|
* A coded error's legacy identity IS code-v1, which outranks every auto
|
|
50
91
|
* scheme, so its list is [manual-v1?, code-v1]; an uncoded one is
|
|
51
|
-
* [manual-v1?, frames-v3, message-v1].
|
|
52
|
-
*
|
|
92
|
+
* [manual-v1?, frames-v3, message-v1, cause-v1?].
|
|
93
|
+
*
|
|
94
|
+
* The consumer takes, among the auto schemes, the first one that already
|
|
95
|
+
* has an issue, else creates the issue under the highest. cause-v1 comes
|
|
96
|
+
* after every existing scheme, so an occurrence of an issue that already
|
|
97
|
+
* exists keeps finding it by frames-v3 or message-v1 first and nothing
|
|
98
|
+
* regroups; cause-v1 is only reached when neither has an issue — which, for
|
|
99
|
+
* a wrapped error whose root cause already has an issue, is exactly when it
|
|
100
|
+
* should join that issue. (Issues are still created under frames-v3: the
|
|
101
|
+
* platform must also key new issues by their cause-v1 fingerprint, or merge
|
|
102
|
+
* it as an alias, for that lookup to find anything. Until it does, cause-v1
|
|
103
|
+
* is carried in shadow and changes no grouping.) The legacy fingerprint is
|
|
104
|
+
* still always in the list, so the platform can fall back to it.
|
|
105
|
+
*
|
|
106
|
+
* `causes` are the reported exception's causes with their (symbolicated)
|
|
107
|
+
* frames, nearest first; omitted, they are read from the stack and
|
|
108
|
+
* attributes and parsed without symbolication.
|
|
53
109
|
*/
|
|
54
|
-
export declare function occurrenceIdentity(input: RuntimeOccurrenceInput, frames: StackFrame[]): OccurrenceIdentity;
|
|
110
|
+
export declare function occurrenceIdentity(input: RuntimeOccurrenceInput, frames: StackFrame[], causes?: CauseLink[]): OccurrenceIdentity;
|
package/dist/fingerprint-v3.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { causeTexts } from "./cause-chain.js";
|
|
2
3
|
import { normalizeMessage, occurrenceFingerprint } from "./fingerprint.js";
|
|
3
4
|
import { manualFingerprint } from "./fingerprint-override.js";
|
|
5
|
+
import { parseStackFrames } from "./stack-frames.js";
|
|
4
6
|
/**
|
|
5
7
|
* `frames-v3` — the structural issue identity (contract §2). Byte-for-byte
|
|
6
8
|
* shared with the platform mirror (`runtime-frames-fingerprint.ts`); the
|
|
@@ -61,23 +63,102 @@ export function framesV3Fingerprint(input) {
|
|
|
61
63
|
},
|
|
62
64
|
};
|
|
63
65
|
}
|
|
66
|
+
// ── cause-v1: a wrapped error groups with its root cause (RF-06) ──────────
|
|
64
67
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
68
|
+
* `cause-v1` — the root-cause identity (RF-06). Byte-for-byte shared with
|
|
69
|
+
* the platform mirror (`runtime-frames-fingerprint.ts`); the `cause` of the
|
|
70
|
+
* entries in fingerprint-v3-vectors.json pins both.
|
|
71
|
+
*
|
|
72
|
+
* It hashes service + the root cause's error type + ONE frame: the
|
|
73
|
+
* innermost in-app frame across the cause chain, looked for from the root
|
|
74
|
+
* cause outward (the root's own innermost in-app frame when it has one,
|
|
75
|
+
* else its nearest wrapper's, …). So `new Error("checkout failed", { cause:
|
|
76
|
+
* fetchError })` and the unwrapped `TypeError: fetch failed` hash alike, as
|
|
77
|
+
* do JVM `Caused by:` chains, .NET inner exceptions and Python chained
|
|
78
|
+
* exceptions with their root. An error without a cause is its own root, so
|
|
79
|
+
* the raw form carries the same candidate. No in-app frame anywhere in the
|
|
80
|
+
* chain: no candidate.
|
|
81
|
+
*
|
|
82
|
+
* It is a lookup candidate only: frames-v3 stays the identity issues are
|
|
83
|
+
* created under (see occurrenceIdentity), and frames-v3 itself is unchanged,
|
|
84
|
+
* so no existing issue regroups.
|
|
85
|
+
*/
|
|
86
|
+
export const CAUSE_V1 = "cause-v1";
|
|
87
|
+
/**
|
|
88
|
+
* The type half of cause-v1: the last segment of a qualified name, so the
|
|
89
|
+
* same class written `java.lang.IllegalStateException` in a JVM `Caused by:`
|
|
90
|
+
* line and in `exception.type`, or `requests.exceptions.ConnectionError` in a
|
|
91
|
+
* Python traceback and `ConnectionError` in OTel's `exception.type`, match.
|
|
92
|
+
*/
|
|
93
|
+
export function causeTypeToken(type) {
|
|
94
|
+
const segments = type.trim().split(/::|\./);
|
|
95
|
+
return segments[segments.length - 1] ?? "";
|
|
96
|
+
}
|
|
97
|
+
export function causeV1Fingerprint(input) {
|
|
98
|
+
const root = input.chain[input.chain.length - 1];
|
|
99
|
+
if (!root)
|
|
100
|
+
return null;
|
|
101
|
+
let token = "";
|
|
102
|
+
for (let i = input.chain.length - 1; i >= 0 && !token; i--) {
|
|
103
|
+
for (const frame of input.chain[i].frames) {
|
|
104
|
+
if (frame.inApp !== true)
|
|
105
|
+
continue;
|
|
106
|
+
const candidate = frameToken(frame);
|
|
107
|
+
// A frame with neither a function nor a file says nothing.
|
|
108
|
+
if (candidate !== "@") {
|
|
109
|
+
token = candidate;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!token)
|
|
115
|
+
return null;
|
|
116
|
+
const type = causeTypeToken(root.type);
|
|
117
|
+
const fingerprint = createHash("sha256")
|
|
118
|
+
.update([CAUSE_V1, input.service, type, token].join("\u0000"))
|
|
119
|
+
.digest("hex")
|
|
120
|
+
.slice(0, 32);
|
|
121
|
+
return { fingerprint, record: { scheme: CAUSE_V1, parts: [type, token].filter((part) => part !== "") } };
|
|
122
|
+
}
|
|
123
|
+
/** The causes of an occurrence with their frames as parsed (not symbolicated). */
|
|
124
|
+
export function parsedCauseLinks(input) {
|
|
125
|
+
return causeTexts(input.stack, input.attributes).map((cause) => ({
|
|
126
|
+
type: cause.type,
|
|
127
|
+
frames: parseStackFrames(cause.stack),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The candidate list the sink carries (contract §1/§4), in precedence order:
|
|
67
132
|
* manual-v1 (SDK override) → code-v1 (valid error code) → frames-v3 →
|
|
68
|
-
* message-v1 (legacy).
|
|
133
|
+
* message-v1 (legacy) → cause-v1 (RF-06).
|
|
69
134
|
* A coded error's legacy identity IS code-v1, which outranks every auto
|
|
70
135
|
* scheme, so its list is [manual-v1?, code-v1]; an uncoded one is
|
|
71
|
-
* [manual-v1?, frames-v3, message-v1].
|
|
72
|
-
*
|
|
136
|
+
* [manual-v1?, frames-v3, message-v1, cause-v1?].
|
|
137
|
+
*
|
|
138
|
+
* The consumer takes, among the auto schemes, the first one that already
|
|
139
|
+
* has an issue, else creates the issue under the highest. cause-v1 comes
|
|
140
|
+
* after every existing scheme, so an occurrence of an issue that already
|
|
141
|
+
* exists keeps finding it by frames-v3 or message-v1 first and nothing
|
|
142
|
+
* regroups; cause-v1 is only reached when neither has an issue — which, for
|
|
143
|
+
* a wrapped error whose root cause already has an issue, is exactly when it
|
|
144
|
+
* should join that issue. (Issues are still created under frames-v3: the
|
|
145
|
+
* platform must also key new issues by their cause-v1 fingerprint, or merge
|
|
146
|
+
* it as an alias, for that lookup to find anything. Until it does, cause-v1
|
|
147
|
+
* is carried in shadow and changes no grouping.) The legacy fingerprint is
|
|
148
|
+
* still always in the list, so the platform can fall back to it.
|
|
149
|
+
*
|
|
150
|
+
* `causes` are the reported exception's causes with their (symbolicated)
|
|
151
|
+
* frames, nearest first; omitted, they are read from the stack and
|
|
152
|
+
* attributes and parsed without symbolication.
|
|
73
153
|
*/
|
|
74
|
-
export function occurrenceIdentity(input, frames) {
|
|
154
|
+
export function occurrenceIdentity(input, frames, causes) {
|
|
75
155
|
const legacy = occurrenceFingerprint(input);
|
|
76
156
|
const candidates = [];
|
|
77
157
|
const manual = manualFingerprint(input);
|
|
78
158
|
if (manual)
|
|
79
159
|
candidates.push({ scheme: "manual-v1", fingerprint: manual });
|
|
80
160
|
let record = null;
|
|
161
|
+
let causeRecord = null;
|
|
81
162
|
if (legacy.scheme === "code-v1") {
|
|
82
163
|
candidates.push({ scheme: "code-v1", fingerprint: legacy.fingerprint });
|
|
83
164
|
}
|
|
@@ -91,6 +172,14 @@ export function occurrenceIdentity(input, frames) {
|
|
|
91
172
|
record = v3.record;
|
|
92
173
|
candidates.push({ scheme: FRAMES_V3, fingerprint: v3.fingerprint });
|
|
93
174
|
candidates.push({ scheme: "message-v1", fingerprint: legacy.fingerprint });
|
|
175
|
+
const cause = causeV1Fingerprint({
|
|
176
|
+
service: input.service,
|
|
177
|
+
chain: [{ type: input.errorType, frames }, ...(causes ?? parsedCauseLinks(input))],
|
|
178
|
+
});
|
|
179
|
+
if (cause) {
|
|
180
|
+
causeRecord = cause.record;
|
|
181
|
+
candidates.push({ scheme: CAUSE_V1, fingerprint: cause.fingerprint });
|
|
182
|
+
}
|
|
94
183
|
}
|
|
95
|
-
return { legacy, candidates, record };
|
|
184
|
+
return { legacy, candidates, record, causeRecord };
|
|
96
185
|
}
|
package/dist/ingest-drops.d.ts
CHANGED
|
@@ -25,6 +25,9 @@ import type { IngestContext } from "./types.js";
|
|
|
25
25
|
* (loop-collapse.ts); its payload was not stored, its count is, on the
|
|
26
26
|
* minute's collapsed sample (`collapsed_count`). `records` counts payloads
|
|
27
27
|
* not stored.
|
|
28
|
+
* `caught_limited` (1.11.0) is the same kind, counted by the SDK: caught
|
|
29
|
+
* errors and deliberate fallbacks its burst limit left out, whose count
|
|
30
|
+
* rides on the next record of their kind (`autter.caught.dropped`).
|
|
28
31
|
* - `suppressed`: occurrences a suppression rule set by the user dropped or
|
|
29
32
|
* sampled out (suppression.ts).
|
|
30
33
|
*
|
|
@@ -163,6 +166,10 @@ export declare const DROP_REASONS: {
|
|
|
163
166
|
readonly collapsed: {
|
|
164
167
|
readonly kind: "collapsed";
|
|
165
168
|
};
|
|
169
|
+
/** Caught errors / deliberate fallbacks an SDK's burst limit left out (`autter.caught.dropped` on the next record of their kind). */
|
|
170
|
+
readonly caught_limited: {
|
|
171
|
+
readonly kind: "collapsed";
|
|
172
|
+
};
|
|
166
173
|
/** Occurrences a `drop` rule matched, or a `sample` rule sampled out. */
|
|
167
174
|
readonly suppressed: {
|
|
168
175
|
readonly kind: "suppressed";
|
|
@@ -185,6 +192,9 @@ export declare function dropStatus(reason: DropReason, route: DropRoute): number
|
|
|
185
192
|
* password in the wrong field) is never hashed or stored.
|
|
186
193
|
*/
|
|
187
194
|
export declare function autterKeyHash(key: string): string;
|
|
195
|
+
/** Records an SDK's burst limit left out, from the `autter.caught.dropped`
|
|
196
|
+
* the records it did send carry (`caught_limited`). */
|
|
197
|
+
export declare function caughtLimitedRecords(attributes: Array<Record<string, unknown> | null | undefined>): number;
|
|
188
198
|
/**
|
|
189
199
|
* Count the length-delimited fields at a nested path of field numbers
|
|
190
200
|
* (one set per level) without decoding the message: tags are read and
|
package/dist/ingest-drops.js
CHANGED
|
@@ -59,6 +59,8 @@ export const DROP_REASONS = {
|
|
|
59
59
|
// ── collapsed: counted on the minute's sample, payload not stored (1.9.0) ──
|
|
60
60
|
/** Occurrences of a looping error past the first LOOP_COLLAPSE_AFTER of the minute. */
|
|
61
61
|
collapsed: { kind: "collapsed" },
|
|
62
|
+
/** Caught errors / deliberate fallbacks an SDK's burst limit left out (`autter.caught.dropped` on the next record of their kind). */
|
|
63
|
+
caught_limited: { kind: "collapsed" },
|
|
62
64
|
// ── suppressed: a suppression rule set by the user left them out (1.9.0) ──
|
|
63
65
|
/** Occurrences a `drop` rule matched, or a `sample` rule sampled out. */
|
|
64
66
|
suppressed: { kind: "suppressed" },
|
|
@@ -113,6 +115,17 @@ function countJson(root, path) {
|
|
|
113
115
|
return level.reduce((sum, node) => sum + arr(field(node, last)).length, 0);
|
|
114
116
|
}
|
|
115
117
|
const METRIC_KINDS = ["gauge", "sum", "histogram", "exponentialHistogram", "summary"];
|
|
118
|
+
/** Records an SDK's burst limit left out, from the `autter.caught.dropped`
|
|
119
|
+
* the records it did send carry (`caught_limited`). */
|
|
120
|
+
export function caughtLimitedRecords(attributes) {
|
|
121
|
+
let total = 0;
|
|
122
|
+
for (const attrs of attributes) {
|
|
123
|
+
const value = Number(attrs?.["autter.caught.dropped"]);
|
|
124
|
+
if (Number.isSafeInteger(value) && value > 0)
|
|
125
|
+
total += Math.min(value, 1_000_000);
|
|
126
|
+
}
|
|
127
|
+
return total;
|
|
128
|
+
}
|
|
116
129
|
function countJsonDataPoints(root) {
|
|
117
130
|
let points = 0;
|
|
118
131
|
for (const resource of arr(field(root, "resourceMetrics")))
|
package/dist/latency.d.ts
CHANGED
|
@@ -13,5 +13,47 @@ export interface LatencyHistogram {
|
|
|
13
13
|
boundsMs: number[];
|
|
14
14
|
counts: number[];
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Per-route latency percentiles in the 1-minute rollups (IP-02).
|
|
18
|
+
*
|
|
19
|
+
* `runtime_metrics_1m` is a SummingMergeTree with an explicit column list,
|
|
20
|
+
* so no percentile can be merged there. Every rollup point also carries
|
|
21
|
+
* latency samples (`RuntimeMetricPoint.latency`): a request's own duration
|
|
22
|
+
* (span- and summary-fed requests) or a delta histogram's buckets
|
|
23
|
+
* (metric-fed), spread evenly over each bucket. They are written to
|
|
24
|
+
* `runtime_route_latency_input` (ENGINE = Null: nothing is stored), whose
|
|
25
|
+
* materialized view folds them into `runtime_route_latency_1m`, keyed like
|
|
26
|
+
* `runtime_metrics_1m`, as a mergeable t-digest:
|
|
27
|
+
*
|
|
28
|
+
* SELECT route, sum(request_count) AS requests,
|
|
29
|
+
* quantilesTDigestWeightedMerge(0.5, 0.95, 0.99)(duration_quantiles) AS q
|
|
30
|
+
* FROM runtime_route_latency_1m
|
|
31
|
+
* WHERE org_id = … AND repository_id = … AND bucket_at >= now() - INTERVAL 1 HOUR
|
|
32
|
+
* GROUP BY route -- q[1] p50, q[2] p95, q[3] p99, in ms
|
|
33
|
+
*
|
|
34
|
+
* Any window and grouping merge exactly as the t-digest does. Like
|
|
35
|
+
* `runtime_metrics_1m`, an exporter retry after a lost 2xx counts twice.
|
|
36
|
+
*/
|
|
37
|
+
export interface LatencySamples {
|
|
38
|
+
/** Durations in ms… */
|
|
39
|
+
valuesMs: number[];
|
|
40
|
+
/** …and how many requests each stands for. */
|
|
41
|
+
weights: number[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Samples for a delta histogram: each bucket's count spread evenly over
|
|
45
|
+
* [previous bound, bound] (at most 8 points), the overflow bucket at the
|
|
46
|
+
* highest bound. Undefined for a histogram without usable buckets.
|
|
47
|
+
*/
|
|
48
|
+
export declare function histogramLatency(boundsMs: readonly number[], counts: readonly number[]): LatencySamples | undefined;
|
|
49
|
+
/** One request's duration as samples. */
|
|
50
|
+
export declare function requestLatency(durationMs: number): LatencySamples | undefined;
|
|
51
|
+
/** Concatenate samples of points folded into one rollup row. */
|
|
52
|
+
export declare function mergeLatency(a: LatencySamples | undefined, b: LatencySamples | undefined): LatencySamples | undefined;
|
|
53
|
+
/** Null input table: rows pass to the view, nothing is stored. */
|
|
54
|
+
export declare function routeLatencyInputDDL(database: string): string;
|
|
55
|
+
export declare function routeLatencyTableDDL(database: string, ttlDays?: number): string;
|
|
56
|
+
/** Needs both tables above; reads nothing older than itself. */
|
|
57
|
+
export declare function routeLatencyViewDDL(database: string): string;
|
|
16
58
|
export declare function latencyTableDDL(database: string, ttlDays?: number): string;
|
|
17
59
|
export declare function normalizeLatencyHistograms(request: OtlpMetricsRequest): LatencyHistogram[];
|