@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 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
+ }
@@ -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
- /** Migration 0020 columns: browser occurrences only (server frames are not symbolicated). */
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
  }
@@ -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, highest precedence first; ends with the legacy one. */
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), highest precedence
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]. Either way the list ends with the
52
- * legacy fingerprint, so the platform can always fall back to it.
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;
@@ -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
- * The candidate list the sink carries (contract §1/§4), highest precedence
66
- * first:
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]. Either way the list ends with the
72
- * legacy fingerprint, so the platform can always fall back to it.
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
  }
@@ -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
@@ -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[];