@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.
Files changed (52) hide show
  1. package/README.md +51 -0
  2. package/dist/breadcrumbs.d.ts +41 -0
  3. package/dist/breadcrumbs.js +151 -0
  4. package/dist/clickhouse.d.ts +42 -5
  5. package/dist/clickhouse.js +168 -7
  6. package/dist/compat-manifest.json +123 -0
  7. package/dist/compat.d.ts +149 -0
  8. package/dist/compat.js +221 -0
  9. package/dist/config.d.ts +23 -0
  10. package/dist/config.js +19 -0
  11. package/dist/context.js +24 -11
  12. package/dist/error-fields.d.ts +41 -0
  13. package/dist/error-fields.js +103 -0
  14. package/dist/fingerprint-override.d.ts +42 -0
  15. package/dist/fingerprint-override.js +76 -0
  16. package/dist/fingerprint-v3.d.ts +54 -0
  17. package/dist/fingerprint-v3.js +96 -0
  18. package/dist/fingerprint.d.ts +54 -0
  19. package/dist/fingerprint.js +46 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +5 -3
  22. package/dist/llm.js +16 -3
  23. package/dist/log-promotion.d.ts +55 -0
  24. package/dist/log-promotion.js +119 -0
  25. package/dist/logs.d.ts +57 -1
  26. package/dist/logs.js +148 -11
  27. package/dist/memory.d.ts +2 -2
  28. package/dist/migrations.d.ts +13 -0
  29. package/dist/migrations.js +75 -2
  30. package/dist/normalize-browser.d.ts +67 -22
  31. package/dist/normalize-browser.js +46 -19
  32. package/dist/normalize-otlp.js +18 -2
  33. package/dist/redact.d.ts +33 -0
  34. package/dist/redact.js +96 -0
  35. package/dist/sdk-versions.d.ts +58 -0
  36. package/dist/sdk-versions.js +167 -0
  37. package/dist/server.d.ts +3 -0
  38. package/dist/server.js +219 -15
  39. package/dist/sink.d.ts +13 -0
  40. package/dist/sink.js +37 -4
  41. package/dist/source-maps-lookup.d.ts +31 -0
  42. package/dist/source-maps-lookup.js +64 -0
  43. package/dist/source-maps.d.ts +63 -2
  44. package/dist/source-maps.js +121 -13
  45. package/dist/stack-fixtures.d.ts +10 -2
  46. package/dist/stack-fixtures.js +13 -3
  47. package/dist/stack-frames.d.ts +48 -0
  48. package/dist/stack-frames.js +441 -0
  49. package/dist/symbolicate.d.ts +79 -0
  50. package/dist/symbolicate.js +257 -0
  51. package/dist/types.d.ts +86 -0
  52. 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
+ }
@@ -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,
@@ -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
@@ -1,4 +1,5 @@
1
1
  export { createIngesterApp } from "./server.js";
2
2
  export { loadConfig } from "./config.js";
3
+ export * from "./compat.js";
3
4
  export { SinkForwarder, type SinkStats, type SinkTuning } from "./sink.js";
4
5
  export * from "./types.js";
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
- const trimmed = value.length > 500 ? `${value.slice(0, 500)}…` : value;
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[];