@autter/otlp-ingester 1.4.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 +51 -0
- package/dist/breadcrumbs.d.ts +41 -0
- package/dist/breadcrumbs.js +151 -0
- package/dist/clickhouse.d.ts +42 -5
- package/dist/clickhouse.js +168 -7
- package/dist/compat-manifest.json +123 -0
- package/dist/compat.d.ts +149 -0
- package/dist/compat.js +221 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +19 -0
- package/dist/context.js +24 -11
- package/dist/error-fields.d.ts +41 -0
- package/dist/error-fields.js +103 -0
- 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 +54 -0
- package/dist/fingerprint.js +46 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -3
- package/dist/llm.js +16 -3
- package/dist/log-promotion.d.ts +55 -0
- package/dist/log-promotion.js +119 -0
- package/dist/logs.d.ts +57 -1
- package/dist/logs.js +148 -11
- package/dist/memory.d.ts +2 -2
- package/dist/migrations.d.ts +13 -0
- package/dist/migrations.js +75 -2
- package/dist/normalize-browser.d.ts +67 -22
- package/dist/normalize-browser.js +46 -19
- package/dist/normalize-otlp.js +18 -2
- package/dist/redact.d.ts +33 -0
- package/dist/redact.js +96 -0
- package/dist/sdk-versions.d.ts +58 -0
- package/dist/sdk-versions.js +167 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +219 -15
- package/dist/sink.d.ts +13 -0
- package/dist/sink.js +37 -4
- 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 +86 -0
- package/package.json +2 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { sanitizeRuntimeContext } from "./context.js";
|
|
2
|
+
import { validErrorCode } from "./fingerprint.js";
|
|
3
|
+
/**
|
|
4
|
+
* Declared error metadata on the wire (span/event attributes, log
|
|
5
|
+
* attributes, browser context keys):
|
|
6
|
+
*
|
|
7
|
+
* autter.error.code string matching CODE_PATTERN (≤80) — grouping key
|
|
8
|
+
* autter.error.why declared cause (≤1000 chars)
|
|
9
|
+
* autter.error.fix declared remedy (≤1000 chars)
|
|
10
|
+
* autter.error.link docs link (http(s) only, ≤500 chars)
|
|
11
|
+
* autter.error.expected boolean — expected business failure
|
|
12
|
+
* autter.request.id request id joining the error to its summary
|
|
13
|
+
*
|
|
14
|
+
* Every field is optional and validated independently: a bad value is
|
|
15
|
+
* dropped on its own and never rejects the signal. Values are lifted onto
|
|
16
|
+
* dedicated occurrence fields (and columns) instead of living only in the
|
|
17
|
+
* attributes JSON, because the generic context sanitiser rewrites URLs
|
|
18
|
+
* (query AND fragment stripped) — the declared link keeps its #anchor here.
|
|
19
|
+
*/
|
|
20
|
+
export const REQUEST_ID_PATTERN = /^[\w.-]{8,128}$/;
|
|
21
|
+
const EMAIL_RE = /(?<![A-Z0-9._%+-])[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
22
|
+
function lookup(sources, key) {
|
|
23
|
+
for (const source of sources) {
|
|
24
|
+
if (!source)
|
|
25
|
+
continue;
|
|
26
|
+
const value = source instanceof Map ? source.get(key) : source[key];
|
|
27
|
+
if (value !== undefined && value !== null && value !== "")
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/** First source whose value for `key` passes `valid` (an invalid value on
|
|
33
|
+
* the exception event must not hide a valid one on its span). */
|
|
34
|
+
function lookupValid(sources, key, valid) {
|
|
35
|
+
for (const source of sources) {
|
|
36
|
+
const value = valid(lookup([source], key));
|
|
37
|
+
if (value !== undefined)
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/** Free text through the same scrubber as custom context, then capped. */
|
|
43
|
+
function declaredText(value, max) {
|
|
44
|
+
if (typeof value !== "string")
|
|
45
|
+
return undefined;
|
|
46
|
+
const text = sanitizeRuntimeContext({ text: value.trim() }).text;
|
|
47
|
+
return typeof text === "string" && text ? text.slice(0, max) : undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* http(s) only; credentials and query string removed (a docs link never
|
|
51
|
+
* needs them), fragment kept. Over-long links are dropped rather than
|
|
52
|
+
* truncated — a cut URL is a broken link.
|
|
53
|
+
*/
|
|
54
|
+
export function declaredLink(value) {
|
|
55
|
+
if (typeof value !== "string")
|
|
56
|
+
return undefined;
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(value.trim());
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
65
|
+
return undefined;
|
|
66
|
+
url.username = "";
|
|
67
|
+
url.password = "";
|
|
68
|
+
url.search = "";
|
|
69
|
+
const link = url.toString().replace(EMAIL_RE, "[redacted]");
|
|
70
|
+
return link.length <= 500 ? link : undefined;
|
|
71
|
+
}
|
|
72
|
+
export function validRequestId(value) {
|
|
73
|
+
return typeof value === "string" && REQUEST_ID_PATTERN.test(value)
|
|
74
|
+
? value
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Lift the declared fields from one or more attribute sources; the first
|
|
79
|
+
* source carrying a key wins (e.g. exception event before its span).
|
|
80
|
+
* Returns only the fields that are present and valid.
|
|
81
|
+
*/
|
|
82
|
+
export function liftErrorFields(...sources) {
|
|
83
|
+
const out = {};
|
|
84
|
+
const code = lookupValid(sources, "autter.error.code", validErrorCode);
|
|
85
|
+
if (code)
|
|
86
|
+
out.errorCode = code;
|
|
87
|
+
const why = declaredText(lookup(sources, "autter.error.why"), 1000);
|
|
88
|
+
if (why)
|
|
89
|
+
out.why = why;
|
|
90
|
+
const fix = declaredText(lookup(sources, "autter.error.fix"), 1000);
|
|
91
|
+
if (fix)
|
|
92
|
+
out.fix = fix;
|
|
93
|
+
const link = declaredLink(lookup(sources, "autter.error.link"));
|
|
94
|
+
if (link)
|
|
95
|
+
out.link = link;
|
|
96
|
+
const expected = lookup(sources, "autter.error.expected");
|
|
97
|
+
if (expected === true || expected === "true")
|
|
98
|
+
out.expected = true;
|
|
99
|
+
const requestId = lookupValid(sources, "autter.request.id", validRequestId);
|
|
100
|
+
if (requestId)
|
|
101
|
+
out.requestId = requestId;
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { RuntimeOccurrenceInput } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* SDK fingerprint override (EI-16) → the `manual-v1` issue identity.
|
|
4
|
+
*
|
|
5
|
+
* Where it comes from:
|
|
6
|
+
* - browser events: `fingerprint` (a string or an array of strings), from
|
|
7
|
+
* `captureException(err, { fingerprint })` or `beforeSend`;
|
|
8
|
+
* - OTLP exception events (or their span) and promoted error logs:
|
|
9
|
+
* `autter.fingerprint`, a string or a JSON array of strings (the SDKs always
|
|
10
|
+
* send the JSON array, so a single value never reads as a list by accident).
|
|
11
|
+
*
|
|
12
|
+
* The normalisers carry it as `RuntimeOccurrenceInput.fingerprintOverride`:
|
|
13
|
+
* strings only, empty values dropped, at most 10 values, each scrubbed like
|
|
14
|
+
* the rest of the occurrence and then cut to 200 characters. A malformed
|
|
15
|
+
* override is dropped on its own; it never rejects the signal.
|
|
16
|
+
*
|
|
17
|
+
* `manualFingerprint` hashes it. `manual-v1` takes precedence over every
|
|
18
|
+
* other scheme (rule-v1, code-v1, frames-v3, message-v1) and is only ever
|
|
19
|
+
* computed here, never on the platform:
|
|
20
|
+
*
|
|
21
|
+
* sha256("manual-v1" \0 service \0 values.join("\u0001")).hex()[:32]
|
|
22
|
+
*/
|
|
23
|
+
export declare const MANUAL_FINGERPRINT_SCHEME = "manual-v1";
|
|
24
|
+
export declare const FINGERPRINT_ATTRIBUTE = "autter.fingerprint";
|
|
25
|
+
export declare const MAX_FINGERPRINT_VALUES = 10;
|
|
26
|
+
export declare const MAX_FINGERPRINT_VALUE_LENGTH = 200;
|
|
27
|
+
/**
|
|
28
|
+
* The override values carried on the wire, cleaned: `value` is a string, a
|
|
29
|
+
* JSON array string, or an already-decoded array. Undefined when absent or
|
|
30
|
+
* unusable.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseFingerprintOverride(value: unknown): string[] | undefined;
|
|
33
|
+
/** `{ fingerprintOverride }` to spread into an occurrence, or `{}`. */
|
|
34
|
+
export declare function fingerprintOverrideOf(value: unknown): {
|
|
35
|
+
fingerprintOverride?: string[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The `manual-v1` fingerprint (32 lowercase hex) of an occurrence's override,
|
|
39
|
+
* or null when it has none. The values are bounded again here (at most 10,
|
|
40
|
+
* each ≤ 200 chars), so the hash never depends on how the input was built.
|
|
41
|
+
*/
|
|
42
|
+
export declare function manualFingerprint(input: Pick<RuntimeOccurrenceInput, "service" | "fingerprintOverride">): string | null;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { scrubText } from "./redact.js";
|
|
3
|
+
/**
|
|
4
|
+
* SDK fingerprint override (EI-16) → the `manual-v1` issue identity.
|
|
5
|
+
*
|
|
6
|
+
* Where it comes from:
|
|
7
|
+
* - browser events: `fingerprint` (a string or an array of strings), from
|
|
8
|
+
* `captureException(err, { fingerprint })` or `beforeSend`;
|
|
9
|
+
* - OTLP exception events (or their span) and promoted error logs:
|
|
10
|
+
* `autter.fingerprint`, a string or a JSON array of strings (the SDKs always
|
|
11
|
+
* send the JSON array, so a single value never reads as a list by accident).
|
|
12
|
+
*
|
|
13
|
+
* The normalisers carry it as `RuntimeOccurrenceInput.fingerprintOverride`:
|
|
14
|
+
* strings only, empty values dropped, at most 10 values, each scrubbed like
|
|
15
|
+
* the rest of the occurrence and then cut to 200 characters. A malformed
|
|
16
|
+
* override is dropped on its own; it never rejects the signal.
|
|
17
|
+
*
|
|
18
|
+
* `manualFingerprint` hashes it. `manual-v1` takes precedence over every
|
|
19
|
+
* other scheme (rule-v1, code-v1, frames-v3, message-v1) and is only ever
|
|
20
|
+
* computed here, never on the platform:
|
|
21
|
+
*
|
|
22
|
+
* sha256("manual-v1" \0 service \0 values.join("\u0001")).hex()[:32]
|
|
23
|
+
*/
|
|
24
|
+
export const MANUAL_FINGERPRINT_SCHEME = "manual-v1";
|
|
25
|
+
export const FINGERPRINT_ATTRIBUTE = "autter.fingerprint";
|
|
26
|
+
export const MAX_FINGERPRINT_VALUES = 10;
|
|
27
|
+
export const MAX_FINGERPRINT_VALUE_LENGTH = 200;
|
|
28
|
+
/** Strings only, empty dropped, first 10, each cut to 200 chars; null when none remain. */
|
|
29
|
+
function boundValues(list, scrub) {
|
|
30
|
+
const values = list
|
|
31
|
+
.filter((value) => typeof value === "string" && value !== "")
|
|
32
|
+
.slice(0, MAX_FINGERPRINT_VALUES)
|
|
33
|
+
// Cut with slack, scrub, then cut: a secret straddling the limit is
|
|
34
|
+
// masked whole.
|
|
35
|
+
.map((value) => scrub(value.slice(0, MAX_FINGERPRINT_VALUE_LENGTH + 512)).slice(0, MAX_FINGERPRINT_VALUE_LENGTH))
|
|
36
|
+
.filter((value) => value !== "");
|
|
37
|
+
return values.length > 0 ? values : null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The override values carried on the wire, cleaned: `value` is a string, a
|
|
41
|
+
* JSON array string, or an already-decoded array. Undefined when absent or
|
|
42
|
+
* unusable.
|
|
43
|
+
*/
|
|
44
|
+
export function parseFingerprintOverride(value) {
|
|
45
|
+
let list = value;
|
|
46
|
+
if (typeof value === "string" && /^\s*\[/.test(value)) {
|
|
47
|
+
try {
|
|
48
|
+
list = JSON.parse(value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* not JSON: one plain value */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return boundValues(Array.isArray(list) ? list : [list], scrubText) ?? undefined;
|
|
55
|
+
}
|
|
56
|
+
/** `{ fingerprintOverride }` to spread into an occurrence, or `{}`. */
|
|
57
|
+
export function fingerprintOverrideOf(value) {
|
|
58
|
+
const values = parseFingerprintOverride(value);
|
|
59
|
+
return values ? { fingerprintOverride: values } : {};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The `manual-v1` fingerprint (32 lowercase hex) of an occurrence's override,
|
|
63
|
+
* or null when it has none. The values are bounded again here (at most 10,
|
|
64
|
+
* each ≤ 200 chars), so the hash never depends on how the input was built.
|
|
65
|
+
*/
|
|
66
|
+
export function manualFingerprint(input) {
|
|
67
|
+
if (!Array.isArray(input.fingerprintOverride))
|
|
68
|
+
return null;
|
|
69
|
+
const values = boundValues(input.fingerprintOverride, (text) => text);
|
|
70
|
+
if (!values)
|
|
71
|
+
return null;
|
|
72
|
+
return createHash("sha256")
|
|
73
|
+
.update(`${MANUAL_FINGERPRINT_SCHEME}\u0000${input.service}\u0000${values.join("\u0001")}`)
|
|
74
|
+
.digest("hex")
|
|
75
|
+
.slice(0, 32);
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { FingerprintCandidate, FingerprintRecord, RuntimeOccurrenceInput, StackFrame } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* `frames-v3` — the structural issue identity (contract §2). Byte-for-byte
|
|
4
|
+
* shared with the platform mirror (`runtime-frames-fingerprint.ts`); the
|
|
5
|
+
* vectors in fingerprint-v3-vectors.json pin both.
|
|
6
|
+
*
|
|
7
|
+
* It hashes service + error type + up to five in-app frames reduced to
|
|
8
|
+
* `function@file`, where file is a lowercased basename with build hashes
|
|
9
|
+
* (`index-5f3a9c1e.js` → `index-*.js`) templated out. Line and column
|
|
10
|
+
* numbers, route, source (browser vs server) and release are never part of
|
|
11
|
+
* it, so one bug stays one issue across deploys, pages, preview domains and
|
|
12
|
+
* tiers. The normalised message is hashed only when no frame was picked.
|
|
13
|
+
*/
|
|
14
|
+
export declare const FRAMES_V3 = "frames-v3";
|
|
15
|
+
/** §2.3 — the function half of a frame token. */
|
|
16
|
+
export declare function frameTokenFunction(frame: Pick<StackFrame, "function" | "resolved">): string;
|
|
17
|
+
/** §2.3 — the file half of a frame token. */
|
|
18
|
+
export declare function frameTokenFile(frame: Pick<StackFrame, "filename" | "module">): string;
|
|
19
|
+
export declare function frameToken(frame: StackFrame): string;
|
|
20
|
+
/** §2.1–2.2 — in-app frames in order, else only the first frame; at most 5. */
|
|
21
|
+
export declare function pickFrames(frames: StackFrame[]): StackFrame[];
|
|
22
|
+
export interface FramesV3Input {
|
|
23
|
+
service: string;
|
|
24
|
+
errorType: string;
|
|
25
|
+
message: string;
|
|
26
|
+
frames: StackFrame[];
|
|
27
|
+
}
|
|
28
|
+
export interface FramesV3Result {
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
record: FingerprintRecord;
|
|
31
|
+
}
|
|
32
|
+
export declare function framesV3Fingerprint(input: FramesV3Input): FramesV3Result;
|
|
33
|
+
export interface OccurrenceIdentity {
|
|
34
|
+
/** Legacy identity, unchanged: what `fingerprint`/`fingerprintScheme` carry. */
|
|
35
|
+
legacy: {
|
|
36
|
+
fingerprint: string;
|
|
37
|
+
scheme: "message-v1" | "code-v1";
|
|
38
|
+
};
|
|
39
|
+
/** Every applicable scheme, highest precedence first; ends with the legacy one. */
|
|
40
|
+
candidates: FingerprintCandidate[];
|
|
41
|
+
/** What went into the frames-v3 fingerprint, when one was computed. */
|
|
42
|
+
record: FingerprintRecord | null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The candidate list the sink carries (contract §1/§4), highest precedence
|
|
46
|
+
* first:
|
|
47
|
+
* manual-v1 (SDK override) → code-v1 (valid error code) → frames-v3 →
|
|
48
|
+
* message-v1 (legacy).
|
|
49
|
+
* A coded error's legacy identity IS code-v1, which outranks every auto
|
|
50
|
+
* 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.
|
|
53
|
+
*/
|
|
54
|
+
export declare function occurrenceIdentity(input: RuntimeOccurrenceInput, frames: StackFrame[]): OccurrenceIdentity;
|
|
@@ -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,7 +2,61 @@ 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[];
|
|
31
|
+
/**
|
|
32
|
+
* Error codes (`autter.error.code`) — namespaced, stable, low-cardinality
|
|
33
|
+
* identifiers such as "billing.declined". Anything else (Node's "ECONNRESET",
|
|
34
|
+
* ids, free text) is not a code and the error groups by message as before.
|
|
35
|
+
* Shared spec: runtime-core and the Autter backend validate identically.
|
|
36
|
+
*/
|
|
37
|
+
export declare const CODE_PATTERN: RegExp;
|
|
38
|
+
export declare const CODE_MAX_LENGTH = 80;
|
|
39
|
+
export declare function validErrorCode(value: unknown): string | undefined;
|
|
40
|
+
export type FingerprintScheme = "message-v1" | "code-v1";
|
|
41
|
+
/**
|
|
42
|
+
* Scheme "code-v1": a SOURCE-INDEPENDENT issue identity for coded errors.
|
|
43
|
+
* Source, error type, message, stack and route are deliberately left out so
|
|
44
|
+
* the same code from the trace path, log promotion, the browser or an
|
|
45
|
+
* external connector (Sentry, PostHog, …) lands in ONE issue. Route and
|
|
46
|
+
* message stay as facets on the occurrence rows. Mirrored byte-for-byte by
|
|
47
|
+
* the backend (`runtime-code-fingerprint.ts`) — shared test vectors pin it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function codeFingerprint(service: string, code: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Issue identity plus the scheme that produced it. A valid
|
|
52
|
+
* `autter.error.code` selects "code-v1"; everything else keeps the
|
|
53
|
+
* historical message/stack/route fingerprint ("message-v1") byte-for-byte,
|
|
54
|
+
* so existing issues never regroup.
|
|
55
|
+
*/
|
|
56
|
+
export declare function occurrenceFingerprint(input: RuntimeOccurrenceInput): {
|
|
57
|
+
fingerprint: string;
|
|
58
|
+
scheme: FingerprintScheme;
|
|
59
|
+
};
|
|
6
60
|
export declare function fingerprintOccurrence(input: RuntimeOccurrenceInput): string;
|
|
7
61
|
/**
|
|
8
62
|
* Deterministic per-occurrence identity (as opposed to the fingerprint,
|
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;
|
|
@@ -233,7 +233,52 @@ export function normalizeStackFrames(stack, topN = 5) {
|
|
|
233
233
|
}
|
|
234
234
|
return frames.length > 0 ? frames : fallbackFrames(lines, topN);
|
|
235
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Error codes (`autter.error.code`) — namespaced, stable, low-cardinality
|
|
238
|
+
* identifiers such as "billing.declined". Anything else (Node's "ECONNRESET",
|
|
239
|
+
* ids, free text) is not a code and the error groups by message as before.
|
|
240
|
+
* Shared spec: runtime-core and the Autter backend validate identically.
|
|
241
|
+
*/
|
|
242
|
+
export const CODE_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){0,3}$/;
|
|
243
|
+
export const CODE_MAX_LENGTH = 80;
|
|
244
|
+
export function validErrorCode(value) {
|
|
245
|
+
return typeof value === "string" &&
|
|
246
|
+
value.length <= CODE_MAX_LENGTH &&
|
|
247
|
+
CODE_PATTERN.test(value)
|
|
248
|
+
? value
|
|
249
|
+
: undefined;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Scheme "code-v1": a SOURCE-INDEPENDENT issue identity for coded errors.
|
|
253
|
+
* Source, error type, message, stack and route are deliberately left out so
|
|
254
|
+
* the same code from the trace path, log promotion, the browser or an
|
|
255
|
+
* external connector (Sentry, PostHog, …) lands in ONE issue. Route and
|
|
256
|
+
* message stay as facets on the occurrence rows. Mirrored byte-for-byte by
|
|
257
|
+
* the backend (`runtime-code-fingerprint.ts`) — shared test vectors pin it.
|
|
258
|
+
*/
|
|
259
|
+
export function codeFingerprint(service, code) {
|
|
260
|
+
return createHash("sha256")
|
|
261
|
+
.update(`code-v1\u0000${service}\u0000${code}`)
|
|
262
|
+
.digest("hex")
|
|
263
|
+
.slice(0, 32);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Issue identity plus the scheme that produced it. A valid
|
|
267
|
+
* `autter.error.code` selects "code-v1"; everything else keeps the
|
|
268
|
+
* historical message/stack/route fingerprint ("message-v1") byte-for-byte,
|
|
269
|
+
* so existing issues never regroup.
|
|
270
|
+
*/
|
|
271
|
+
export function occurrenceFingerprint(input) {
|
|
272
|
+
const code = validErrorCode(input.errorCode);
|
|
273
|
+
return code
|
|
274
|
+
? { fingerprint: codeFingerprint(input.service, code), scheme: "code-v1" }
|
|
275
|
+
: { fingerprint: messageFingerprint(input), scheme: "message-v1" };
|
|
276
|
+
}
|
|
236
277
|
export function fingerprintOccurrence(input) {
|
|
278
|
+
return occurrenceFingerprint(input).fingerprint;
|
|
279
|
+
}
|
|
280
|
+
/** Scheme "message-v1" — the pre-1.5.0 fingerprint, unchanged. */
|
|
281
|
+
function messageFingerprint(input) {
|
|
237
282
|
const parts = [
|
|
238
283
|
input.source,
|
|
239
284
|
input.service,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { loadConfig } from "./config.js";
|
|
2
2
|
import { createIngesterApp } from "./server.js";
|
|
3
|
+
import { ingesterVersion } from "./sdk-versions.js";
|
|
3
4
|
const config = loadConfig();
|
|
4
|
-
const { app, store, sink } = createIngesterApp(config);
|
|
5
|
+
const { app, store, sink, reportIngesterInfo } = createIngesterApp(config);
|
|
5
6
|
const server = app.listen(config.port, () => {
|
|
6
|
-
console.log(`autter otlp-ingester listening on :${config.port} ` +
|
|
7
|
+
console.log(`autter otlp-ingester ${ingesterVersion()} listening on :${config.port} ` +
|
|
7
8
|
`(clickhouse: ${config.clickhouseUrl ? "configured" : "NOT configured"})`);
|
|
8
9
|
});
|
|
9
10
|
// Warm the schema at boot so the first ingest request doesn't pay for DDL.
|
|
10
11
|
if (store.configured) {
|
|
11
|
-
store.ensureSchema().catch((err) => {
|
|
12
|
+
store.ensureSchema().then(reportIngesterInfo).catch((err) => {
|
|
12
13
|
console.error("clickhouse schema bootstrap failed (will retry on first ingest):", err?.message ?? err);
|
|
13
14
|
});
|
|
14
15
|
}
|
|
@@ -32,5 +33,6 @@ process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
|
32
33
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
33
34
|
export { createIngesterApp } from "./server.js";
|
|
34
35
|
export { loadConfig } from "./config.js";
|
|
36
|
+
export * from "./compat.js";
|
|
35
37
|
export { SinkForwarder } from "./sink.js";
|
|
36
38
|
export * from "./types.js";
|
package/dist/llm.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { estimateLlmCostUsd } from "./llm-pricing.js";
|
|
2
|
+
import { isSensitiveKey, scrubText } from "./redact.js";
|
|
2
3
|
/**
|
|
3
4
|
* LLM-call extraction from OTLP spans. Three attribute families are
|
|
4
5
|
* recognised, so most stacks work with zero Autter-specific code:
|
|
@@ -101,12 +102,17 @@ export function extractLlmCall(attrs, facts) {
|
|
|
101
102
|
errorType: facts.isError
|
|
102
103
|
? (strAttr(attrs, "error.type") ?? facts.exceptionType ?? "Error").slice(0, 200)
|
|
103
104
|
: "",
|
|
104
|
-
userId: (strAttr(attrs, "autter.user_id", "ai.telemetry.metadata.userId", "ai.telemetry.metadata.user_id", "enduser.id", "user.id") ?? "").slice(0, 200),
|
|
105
|
-
sessionId: (strAttr(attrs, "autter.session_id", "ai.telemetry.metadata.sessionId", "session.id") ?? "").slice(0, 200),
|
|
105
|
+
userId: scrubText(strAttr(attrs, "autter.user_id", "ai.telemetry.metadata.userId", "ai.telemetry.metadata.user_id", "enduser.id", "user.id") ?? "").slice(0, 200),
|
|
106
|
+
sessionId: scrubText(strAttr(attrs, "autter.session_id", "ai.telemetry.metadata.sessionId", "session.id") ?? "").slice(0, 200),
|
|
106
107
|
attributes: llmAttributeSubset(attrs),
|
|
107
108
|
startedAt: facts.startedAt,
|
|
108
109
|
};
|
|
109
110
|
}
|
|
111
|
+
/** Numeric token/usage counters are kept even though their keys contain
|
|
112
|
+
* "token" — cost tracking depends on them. */
|
|
113
|
+
function isUsageCount(key, value) {
|
|
114
|
+
return /tokens?$|token_?count$|usage/i.test(key) && /^\d+(\.\d+)?$/.test(value);
|
|
115
|
+
}
|
|
110
116
|
/**
|
|
111
117
|
* Drill-down context stored as JSON alongside the typed columns: only the
|
|
112
118
|
* LLM-relevant attribute families, size-capped so a prompt accidentally
|
|
@@ -121,7 +127,14 @@ function llmAttributeSubset(attrs) {
|
|
|
121
127
|
!key.startsWith("autter.llm.")) {
|
|
122
128
|
continue;
|
|
123
129
|
}
|
|
124
|
-
|
|
130
|
+
// Prompts/completions (ai.prompt, gen_ai.prompt, …) land here: scrub
|
|
131
|
+
// before the size cap so a secret straddling it can't half-survive.
|
|
132
|
+
const safe = isUsageCount(key, value)
|
|
133
|
+
? value
|
|
134
|
+
: isSensitiveKey(key)
|
|
135
|
+
? "[redacted]"
|
|
136
|
+
: scrubText(value);
|
|
137
|
+
const trimmed = safe.length > 500 ? `${safe.slice(0, 500)}…` : safe;
|
|
125
138
|
size += key.length + trimmed.length;
|
|
126
139
|
if (size > 4000)
|
|
127
140
|
break;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RuntimeLogRecord } from "./logs.js";
|
|
2
|
+
import type { RuntimeMetricPoint, RuntimeOccurrence, RuntimeOccurrenceInput } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Log promotion — errors from SDKs running WITHOUT a trace pipeline.
|
|
5
|
+
*
|
|
6
|
+
* Logger-only mode (`initAutterLogging`) and `@autter/runtime-edge` have no
|
|
7
|
+
* span to record an exception on, so they emit an error log record with
|
|
8
|
+
* `exception.*` and `autter.capture.mode = "log"`. /v1/logs turns those
|
|
9
|
+
* records into ordinary server occurrences (fingerprinted, stored in
|
|
10
|
+
* runtime_error_occurrences, forwarded to the sink) — otherwise their
|
|
11
|
+
* errors would never reach issue grouping.
|
|
12
|
+
*
|
|
13
|
+
* Dedupe, so one failure never becomes two occurrences through two export
|
|
14
|
+
* paths:
|
|
15
|
+
* - in-batch: at most one promoted occurrence per trace id (first wins);
|
|
16
|
+
* - cross-batch: a trace id that already produced an occurrence for this
|
|
17
|
+
* tenant within ±PROMOTION_DEDUPE_WINDOW_MS (ClickHouse lookup) is skipped.
|
|
18
|
+
* Records without a trace id are always promoted. The lookup is
|
|
19
|
+
* best-effort and bounded (see promotionLookups, plus a per-tenant lookup
|
|
20
|
+
* budget in server.ts): when it fails, is skipped or is over budget the
|
|
21
|
+
* record is promoted anyway — a rare duplicate beats a silently dropped
|
|
22
|
+
* error.
|
|
23
|
+
*/
|
|
24
|
+
export declare const PROMOTION_DEDUPE_WINDOW_MS = 60000;
|
|
25
|
+
/** Widest single lookup window. Candidate timestamps come from the client,
|
|
26
|
+
* so one batch could otherwise ask ClickHouse to scan an arbitrary range. */
|
|
27
|
+
export declare const MAX_LOOKUP_SPAN_MS: number;
|
|
28
|
+
/** At most this many lookup windows (queries) per request. */
|
|
29
|
+
export declare const MAX_LOOKUP_WINDOWS = 4;
|
|
30
|
+
/** Candidates outside [now − 24 h, now + 5 min] are never looked up: their
|
|
31
|
+
* trace cannot plausibly have a recent occurrence to dedupe against. */
|
|
32
|
+
export declare const LOOKUP_PAST_MS: number;
|
|
33
|
+
export declare const LOOKUP_FUTURE_MS: number;
|
|
34
|
+
/** Promotion candidates in record order, deduped by trace id in-batch. */
|
|
35
|
+
export declare function logPromotionCandidates(rows: RuntimeLogRecord[]): RuntimeOccurrenceInput[];
|
|
36
|
+
export interface PromotionLookup {
|
|
37
|
+
from: Date;
|
|
38
|
+
to: Date;
|
|
39
|
+
traceIds: string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Bounded dedupe lookups for the candidates that carry a trace id: each
|
|
43
|
+
* window is [oldest − 60 s, newest + 60 s] over candidates taken newest first
|
|
44
|
+
* and spans at most MAX_LOOKUP_SPAN_MS; at most MAX_LOOKUP_WINDOWS windows are
|
|
45
|
+
* planned.
|
|
46
|
+
* Candidates outside the plausible time range or beyond the window cap are
|
|
47
|
+
* not looked up — they are still deduped in-batch and promoted.
|
|
48
|
+
*/
|
|
49
|
+
export declare function promotionLookups(occurrences: RuntimeOccurrenceInput[], now?: number): PromotionLookup[];
|
|
50
|
+
/**
|
|
51
|
+
* One event / one error event per promoted occurrence — the same counting
|
|
52
|
+
* the trace path applies to occurrences outside a server span, so issue
|
|
53
|
+
* counters for logger-only services don't read 0.
|
|
54
|
+
*/
|
|
55
|
+
export declare function promotionRollups(occurrences: RuntimeOccurrence[]): RuntimeMetricPoint[];
|