@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
package/README.md
CHANGED
|
@@ -9,10 +9,13 @@ one per-repo signal model, fingerprints errors, and writes ClickHouse.
|
|
|
9
9
|
| Route | Payload | Purpose |
|
|
10
10
|
| --- | --- | --- |
|
|
11
11
|
| `POST /v1/traces` | OTLP/JSON `ExportTraceServiceRequest` | Error spans → occurrences; all spans → `runtime_spans`; server spans → usage rollups; GenAI spans → `runtime_llm_calls` |
|
|
12
|
+
| `POST /v1/logs` | OTLP `ExportLogsServiceRequest`, server key | Wide events (request/operation summaries, logs) → `runtime_logs` (+ `runtime_request_1m` rollup); logger-only error records (`autter.capture.mode=log`) → occurrences |
|
|
12
13
|
| `POST /v1/metrics` | OTLP `ExportMetricsServiceRequest` | HTTP-server duration histograms → usage rollups; portable process memory/GC metrics → per-instance memory samples |
|
|
13
14
|
| `POST /v1/platform-events` | JSON, server key | ECS/Kubernetes OOM kills and restarts → memory incident correlation |
|
|
14
15
|
| `POST /v1/profiles` | Symbolized pprof, server key | CPU/in-use heap profile samples |
|
|
15
16
|
| `POST /v1/browser` | Browser payload `version: 1` | Errors/rejections → occurrences; session pings → rollups |
|
|
17
|
+
| `POST /v1/logs` | OTLP logs, server key | Structured logs and operation summaries → `runtime_logs` |
|
|
18
|
+
| `GET /v1/compat` | — | Version, schema level and supported features (public) |
|
|
16
19
|
| `GET /healthz` | — | Liveness + ClickHouse reachability |
|
|
17
20
|
|
|
18
21
|
Auth on every ingest route: `Authorization: Bearer <ingest key>`,
|
|
@@ -45,6 +48,17 @@ AUTTER_INGEST_KEYS='[
|
|
|
45
48
|
The validator webhook may return the same extra fields:
|
|
46
49
|
`{ orgId, repositoryId, scope?, allowedOrigins? }`.
|
|
47
50
|
|
|
51
|
+
### Version compatibility
|
|
52
|
+
|
|
53
|
+
`GET /v1/compat` (public, no tenant data) returns this ingester's version,
|
|
54
|
+
its ClickHouse schema state, and which features from
|
|
55
|
+
`src/compat-manifest.json` it supports. Add
|
|
56
|
+
`?features=operation_logging&sdk=@autter/runtime-node@1.3.0` to get the evaluated
|
|
57
|
+
incompatibilities. Every response carries `x-autter-ingester-version`. The
|
|
58
|
+
ingester records the SDK name and version each service sends with
|
|
59
|
+
(`runtime_sdk_versions`) and its own version (`runtime_ingester_info`), and
|
|
60
|
+
the Autter dashboard reads both. See [docs/COMPATIBILITY.md](../../docs/COMPATIBILITY.md).
|
|
61
|
+
|
|
48
62
|
## Configuration
|
|
49
63
|
|
|
50
64
|
| Env | Default | Description |
|
|
@@ -61,11 +75,48 @@ The validator webhook may return the same extra fields:
|
|
|
61
75
|
| `SINK_MAX_ATTEMPTS` | `12` | Delivery attempts per batch (1–60 s backoff) |
|
|
62
76
|
| `SINK_MAX_BUFFERED_BATCHES` | `1000` | Retry buffer cap; oldest drops are logged |
|
|
63
77
|
| `SINK_MAX_BUFFERED_MB` | `64` | Sink retry buffer cap (memory) |
|
|
78
|
+
| `SOURCE_MAP_CACHE_MB` | `64` | Memory budget (estimated decoded size) for parsed source maps used to symbolicate browser frames at ingest; least recently used maps are evicted |
|
|
64
79
|
| `MAX_BODY_BYTES` | `1048576` | Request body cap |
|
|
65
80
|
| `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
|
|
66
81
|
| `CLIENT_RATE_LIMIT_PER_MINUTE` | `120` | Per-key fixed window (client keys) |
|
|
82
|
+
| `PROMOTION_LOOKUPS_PER_MINUTE` | `30` | Per-tenant ClickHouse dedupe lookups for `/v1/logs` error promotion; over budget, records are promoted with in-batch dedupe only |
|
|
67
83
|
| `OCCURRENCE_TTL_DAYS` / `SPAN_TTL_DAYS` / `METRICS_TTL_DAYS` | `14` / `7` / `90` | ClickHouse TTLs (applied at table creation) |
|
|
68
84
|
| `LLM_CALL_TTL_DAYS` | `90` | Retention for `runtime_llm_calls` rows |
|
|
85
|
+
| `LOG_TTL_DAYS` | `14` | Retention for `runtime_logs` (request summaries are always kept, never sampled, so this is the main volume knob). Unlike the other TTLs it is also applied to **existing** tables: at boot the ingester compares it with the table's TTL and runs `ALTER TABLE … MODIFY TTL` only when they differ; a failure is logged and retried next boot |
|
|
86
|
+
| `AUTTER_REDACT_VALUE_PATTERNS` | — | JSON array of extra regex sources masked in stored/forwarded text and attribute values (built-in secret/PII patterns always apply) |
|
|
87
|
+
| `AUTTER_REDACT_KEY_PATTERNS` | — | JSON array of extra regex sources for attribute keys whose values are masked wholesale |
|
|
88
|
+
|
|
89
|
+
## Coded errors and request summaries (1.5.0)
|
|
90
|
+
|
|
91
|
+
- **Code-based grouping.** An occurrence carrying a valid `autter.error.code`
|
|
92
|
+
(`^[a-z][a-z0-9_]*(\.[a-z0-9_]+){0,3}$`, ≤80 chars) is fingerprinted by
|
|
93
|
+
scheme `code-v1`: `sha256("code-v1\0" + service + "\0" + code)[:32]` —
|
|
94
|
+
independent of source, message, stack and route, so one code is one issue
|
|
95
|
+
across traces, log promotion, the browser and external connectors.
|
|
96
|
+
Uncoded errors keep the historical fingerprint (`message-v1`) byte-for-byte.
|
|
97
|
+
`fingerprint_scheme` is stored per occurrence and sent to the sink.
|
|
98
|
+
- **Declared fields.** `autter.error.why|fix|link|expected` and
|
|
99
|
+
`autter.request.id` are lifted from span/exception-event attributes, log
|
|
100
|
+
attributes and browser context into `runtime_error_occurrences`
|
|
101
|
+
(`error_why`, `error_fix`, `error_link`, `expected`, `request_id`) and the
|
|
102
|
+
sink payload. why/fix are scrubbed and capped at 1000 chars; links must be
|
|
103
|
+
http(s) and ≤500 chars. Exception spans inherit the request id of their
|
|
104
|
+
server span.
|
|
105
|
+
- **Request summaries.** `/v1/logs` lifts `autter.operation.kind`,
|
|
106
|
+
`autter.request.id`, `http.route` (id-normalised), `http.response.status_code`,
|
|
107
|
+
`autter.error.code` and the `autter.operation.ai` rollup (`cost_usd`, `calls`)
|
|
108
|
+
into `runtime_logs` columns. `kind = 'request'` rows feed the
|
|
109
|
+
`runtime_request_1m` materialized view (per route/method/minute: count,
|
|
110
|
+
failed, duration sum, p50/p95). The view counts every insert, so a retried
|
|
111
|
+
batch can be counted twice — route stats are approximate under exporter
|
|
112
|
+
retries; `runtime_logs` itself collapses duplicates.
|
|
113
|
+
- **Log promotion.** SDKs without a trace pipeline (logger-only mode,
|
|
114
|
+
`@autter/runtime-edge`) send errors as log records with `exception.*` and
|
|
115
|
+
`autter.capture.mode = "log"`. `/v1/logs` promotes those (severity ≥ error)
|
|
116
|
+
to server occurrences — stored, counted and forwarded to the sink —
|
|
117
|
+
skipping a record when its trace id already produced an occurrence in the
|
|
118
|
+
same batch or (ClickHouse lookup) within ±60 s. If the lookup fails the
|
|
119
|
+
record is promoted anyway.
|
|
69
120
|
|
|
70
121
|
## LLM / GenAI calls
|
|
71
122
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumbs — the steps before an error (sink contract §1).
|
|
3
|
+
*
|
|
4
|
+
* Browser events carry them as `breadcrumbs` (runtime-browser's opt-in
|
|
5
|
+
* `/breadcrumbs` module); server SDKs put a JSON string of the same array on
|
|
6
|
+
* the exception span event as `autter.breadcrumbs`. Either way they are
|
|
7
|
+
* untrusted client input, so this is a whitelist gate: unknown fields are
|
|
8
|
+
* dropped, every string is scrubbed, and the list is bounded before it is
|
|
9
|
+
* stored (under `autter.breadcrumbs` in the occurrence's attributes JSON)
|
|
10
|
+
* and forwarded to the sink.
|
|
11
|
+
*/
|
|
12
|
+
export type BreadcrumbType = "console" | "request" | "navigation" | "click" | "log" | "custom";
|
|
13
|
+
export type BreadcrumbLevel = "debug" | "info" | "warning" | "error";
|
|
14
|
+
export interface Breadcrumb {
|
|
15
|
+
type: BreadcrumbType;
|
|
16
|
+
/** ISO timestamp. */
|
|
17
|
+
at: string;
|
|
18
|
+
level?: BreadcrumbLevel;
|
|
19
|
+
/** ≤ 500 chars, scrubbed. */
|
|
20
|
+
message: string;
|
|
21
|
+
/** ≤ 10 keys of primitives; strings ≤ 200 chars, scrubbed. */
|
|
22
|
+
data?: Record<string, string | number | boolean | null>;
|
|
23
|
+
}
|
|
24
|
+
/** Span event / log attribute carrying a JSON string of `Breadcrumb[]`. */
|
|
25
|
+
export declare const BREADCRUMBS_ATTRIBUTE = "autter.breadcrumbs";
|
|
26
|
+
/** Key under which an occurrence's breadcrumbs are stored in its attributes JSON. */
|
|
27
|
+
export declare const BREADCRUMBS_STORAGE_KEY = "autter.breadcrumbs";
|
|
28
|
+
export declare const MAX_BREADCRUMBS = 30;
|
|
29
|
+
/**
|
|
30
|
+
* Validate, bound and scrub breadcrumbs from any sender: an array, or a JSON
|
|
31
|
+
* string of one (the span attribute). Keeps the newest 30 valid entries,
|
|
32
|
+
* oldest first, within 16 KB of JSON; malformed entries are dropped one by
|
|
33
|
+
* one. Undefined when nothing valid remains.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseBreadcrumbs(raw: unknown): Breadcrumb[] | undefined;
|
|
36
|
+
/** `{ breadcrumbs }` for an occurrence, or `{}` — spread into the input. */
|
|
37
|
+
export declare function breadcrumbsField(...candidates: unknown[]): {
|
|
38
|
+
breadcrumbs?: Breadcrumb[];
|
|
39
|
+
};
|
|
40
|
+
/** Attributes JSON to store: the occurrence's attributes plus its breadcrumbs. */
|
|
41
|
+
export declare function attributesWithBreadcrumbs(attributes: Record<string, unknown> | null, breadcrumbs: Breadcrumb[] | undefined): Record<string, unknown>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isSensitiveKey, scrubText } from "./redact.js";
|
|
2
|
+
/** Span event / log attribute carrying a JSON string of `Breadcrumb[]`. */
|
|
3
|
+
export const BREADCRUMBS_ATTRIBUTE = "autter.breadcrumbs";
|
|
4
|
+
/** Key under which an occurrence's breadcrumbs are stored in its attributes JSON. */
|
|
5
|
+
export const BREADCRUMBS_STORAGE_KEY = "autter.breadcrumbs";
|
|
6
|
+
export const MAX_BREADCRUMBS = 30;
|
|
7
|
+
const MAX_MESSAGE = 500;
|
|
8
|
+
const MAX_DATA_KEYS = 10;
|
|
9
|
+
const MAX_DATA_STRING = 200;
|
|
10
|
+
/** Stored JSON per occurrence; the oldest breadcrumbs go first. */
|
|
11
|
+
const MAX_TOTAL_CHARS = 16 * 1024;
|
|
12
|
+
/** Raw JSON accepted from a span attribute before parsing. */
|
|
13
|
+
const MAX_RAW_CHARS = 256 * 1024;
|
|
14
|
+
/** Entries examined from the end of an oversized list. */
|
|
15
|
+
const MAX_SCANNED = 200;
|
|
16
|
+
const TYPES = new Set(["console", "request", "navigation", "click", "log", "custom"]);
|
|
17
|
+
const LEVELS = {
|
|
18
|
+
debug: "debug",
|
|
19
|
+
trace: "debug",
|
|
20
|
+
info: "info",
|
|
21
|
+
log: "info",
|
|
22
|
+
warning: "warning",
|
|
23
|
+
warn: "warning",
|
|
24
|
+
error: "error",
|
|
25
|
+
fatal: "error",
|
|
26
|
+
critical: "error",
|
|
27
|
+
};
|
|
28
|
+
const UNSAFE_KEY = /^(?:__proto__|constructor|prototype)$/;
|
|
29
|
+
/** Drop query strings and fragments from http(s) URLs (procedural: linear). */
|
|
30
|
+
function stripUrlQueries(value) {
|
|
31
|
+
return value.replace(/https?:\/\/[^\s]*/gi, (url) => {
|
|
32
|
+
const rest = url.slice(url.indexOf("://") + 3);
|
|
33
|
+
const cut = rest.search(/[?#]/);
|
|
34
|
+
return cut <= 0 ? url : url.slice(0, url.length - rest.length + cut);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function cleanText(value, max) {
|
|
38
|
+
// Cut before scrubbing (bounded work), with slack so a secret straddling
|
|
39
|
+
// the cut is masked whole.
|
|
40
|
+
return stripUrlQueries(scrubText(value.slice(0, max + 512))).slice(0, max);
|
|
41
|
+
}
|
|
42
|
+
function cleanData(raw) {
|
|
43
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
44
|
+
return undefined;
|
|
45
|
+
const out = {};
|
|
46
|
+
let count = 0;
|
|
47
|
+
for (const [rawKey, value] of Object.entries(raw).slice(0, 64)) {
|
|
48
|
+
if (count >= MAX_DATA_KEYS)
|
|
49
|
+
break;
|
|
50
|
+
const key = rawKey.slice(0, 100);
|
|
51
|
+
if (!key || UNSAFE_KEY.test(key))
|
|
52
|
+
continue;
|
|
53
|
+
if (isSensitiveKey(key)) {
|
|
54
|
+
out[key] = "[redacted]";
|
|
55
|
+
}
|
|
56
|
+
else if (typeof value === "string") {
|
|
57
|
+
const text = /(?:url|path|route|target)$/i.test(key) ? value.split(/[?#]/)[0] : value;
|
|
58
|
+
out[key] = cleanText(text, MAX_DATA_STRING);
|
|
59
|
+
}
|
|
60
|
+
else if (typeof value === "number") {
|
|
61
|
+
if (!Number.isFinite(value))
|
|
62
|
+
continue;
|
|
63
|
+
out[key] = value;
|
|
64
|
+
}
|
|
65
|
+
else if (typeof value === "boolean" || value === null) {
|
|
66
|
+
out[key] = value;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
count++;
|
|
72
|
+
}
|
|
73
|
+
return count > 0 ? out : undefined;
|
|
74
|
+
}
|
|
75
|
+
function cleanBreadcrumb(raw) {
|
|
76
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
77
|
+
return null;
|
|
78
|
+
const input = raw;
|
|
79
|
+
const at = typeof input.at === "string" || typeof input.at === "number" ? new Date(input.at) : null;
|
|
80
|
+
if (!at || Number.isNaN(at.getTime()))
|
|
81
|
+
return null;
|
|
82
|
+
const message = typeof input.message === "string"
|
|
83
|
+
? input.message
|
|
84
|
+
: typeof input.message === "number" || typeof input.message === "boolean"
|
|
85
|
+
? String(input.message)
|
|
86
|
+
: "";
|
|
87
|
+
const type = typeof input.type === "string" && TYPES.has(input.type) ? input.type : "custom";
|
|
88
|
+
const level = typeof input.level === "string" ? LEVELS[input.level.toLowerCase()] : undefined;
|
|
89
|
+
const data = cleanData(input.data);
|
|
90
|
+
if (!message && !data)
|
|
91
|
+
return null;
|
|
92
|
+
return {
|
|
93
|
+
type: type,
|
|
94
|
+
at: at.toISOString(),
|
|
95
|
+
...(level ? { level } : {}),
|
|
96
|
+
message: cleanText(message, MAX_MESSAGE),
|
|
97
|
+
...(data ? { data } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Validate, bound and scrub breadcrumbs from any sender: an array, or a JSON
|
|
102
|
+
* string of one (the span attribute). Keeps the newest 30 valid entries,
|
|
103
|
+
* oldest first, within 16 KB of JSON; malformed entries are dropped one by
|
|
104
|
+
* one. Undefined when nothing valid remains.
|
|
105
|
+
*/
|
|
106
|
+
export function parseBreadcrumbs(raw) {
|
|
107
|
+
let list = raw;
|
|
108
|
+
if (typeof list === "string") {
|
|
109
|
+
if (list.length > MAX_RAW_CHARS)
|
|
110
|
+
return undefined;
|
|
111
|
+
try {
|
|
112
|
+
list = JSON.parse(list);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
119
|
+
return undefined;
|
|
120
|
+
const out = [];
|
|
121
|
+
let chars = 2;
|
|
122
|
+
const first = Math.max(0, list.length - MAX_SCANNED);
|
|
123
|
+
for (let i = list.length - 1; i >= first && out.length < MAX_BREADCRUMBS; i--) {
|
|
124
|
+
const crumb = cleanBreadcrumb(list[i]);
|
|
125
|
+
if (!crumb)
|
|
126
|
+
continue;
|
|
127
|
+
const size = JSON.stringify(crumb).length + 1;
|
|
128
|
+
if (chars + size > MAX_TOTAL_CHARS)
|
|
129
|
+
break;
|
|
130
|
+
chars += size;
|
|
131
|
+
out.push(crumb);
|
|
132
|
+
}
|
|
133
|
+
return out.length > 0 ? out.reverse() : undefined;
|
|
134
|
+
}
|
|
135
|
+
/** `{ breadcrumbs }` for an occurrence, or `{}` — spread into the input. */
|
|
136
|
+
export function breadcrumbsField(...candidates) {
|
|
137
|
+
for (const candidate of candidates) {
|
|
138
|
+
if (candidate === undefined || candidate === null)
|
|
139
|
+
continue;
|
|
140
|
+
const breadcrumbs = parseBreadcrumbs(candidate);
|
|
141
|
+
if (breadcrumbs)
|
|
142
|
+
return { breadcrumbs };
|
|
143
|
+
}
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
/** Attributes JSON to store: the occurrence's attributes plus its breadcrumbs. */
|
|
147
|
+
export function attributesWithBreadcrumbs(attributes, breadcrumbs) {
|
|
148
|
+
return breadcrumbs?.length
|
|
149
|
+
? { ...(attributes ?? {}), [BREADCRUMBS_STORAGE_KEY]: breadcrumbs }
|
|
150
|
+
: (attributes ?? {});
|
|
151
|
+
}
|
package/dist/clickhouse.d.ts
CHANGED
|
@@ -2,13 +2,19 @@ import type { IngesterConfig } from "./config.js";
|
|
|
2
2
|
import { type LatencyHistogram } from "./latency.js";
|
|
3
3
|
import { type ProfileSample } from "./profiles.js";
|
|
4
4
|
import { type MemorySample, type PlatformEvent } from "./memory.js";
|
|
5
|
+
import { type SourceMapCheck, type ValidSourceMap } from "./source-maps.js";
|
|
6
|
+
import { type SourceMapKey } from "./source-maps-lookup.js";
|
|
5
7
|
import { type RuntimeLogRecord } from "./logs.js";
|
|
8
|
+
import { type SdkVersionRow } from "./sdk-versions.js";
|
|
9
|
+
import type { SchemaStatus } from "./compat.js";
|
|
6
10
|
import { type Migration } from "./migrations.js";
|
|
7
11
|
import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
|
|
8
12
|
export declare class ClickHouseStore {
|
|
9
13
|
private readonly config;
|
|
10
14
|
private client;
|
|
11
15
|
private ensurePromise;
|
|
16
|
+
private schemaState;
|
|
17
|
+
private appliedIds;
|
|
12
18
|
constructor(config: IngesterConfig);
|
|
13
19
|
get configured(): boolean;
|
|
14
20
|
private getClient;
|
|
@@ -23,6 +29,37 @@ export declare class ClickHouseStore {
|
|
|
23
29
|
* next request retries.
|
|
24
30
|
*/
|
|
25
31
|
ensureSchema(migrations?: Migration[]): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Bring runtime_logs' TTL in line with LOG_TTL_DAYS. Idempotent: reads the
|
|
34
|
+
* current TTL from system.tables and only issues `MODIFY TTL` when it
|
|
35
|
+
* differs (MODIFY TTL rewrites TTL info for existing parts, so it must not
|
|
36
|
+
* run on every boot). Best-effort — a failure is logged and never blocks
|
|
37
|
+
* ingest; the next boot retries.
|
|
38
|
+
*/
|
|
39
|
+
applyLogTtl(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Trace ids (from `traceIds`) that already produced an occurrence for
|
|
42
|
+
* this tenant in [from, to] — the cross-batch half of the log-promotion
|
|
43
|
+
* dedupe. `excludeIds` are the candidate occurrences' own deterministic
|
|
44
|
+
* ids, so a retried batch does not dedupe against its own earlier,
|
|
45
|
+
* partially-committed write. Browser occurrences share the server's trace
|
|
46
|
+
* id (runtime-browser traceparent) but are a different signal, so they
|
|
47
|
+
* never suppress a server error. Throws on failure; the caller decides.
|
|
48
|
+
*/
|
|
49
|
+
recentOccurrenceTraceIds(ctx: IngestContext, traceIds: string[], from: Date, to: Date, excludeIds: string[]): Promise<Set<string>>;
|
|
50
|
+
/** Schema state as of the last ensureSchema attempt — never triggers DDL
|
|
51
|
+
* (it backs the unauthenticated /v1/compat endpoint). */
|
|
52
|
+
schemaStatus(): {
|
|
53
|
+
status: SchemaStatus;
|
|
54
|
+
applied: string[];
|
|
55
|
+
};
|
|
56
|
+
insertSdkVersions(ctx: IngestContext, rows: SdkVersionRow[]): Promise<void>;
|
|
57
|
+
/** Record this ingester's version and schema level (global, not tenant data). */
|
|
58
|
+
insertIngesterInfo(info: {
|
|
59
|
+
version: string;
|
|
60
|
+
schemaLevel: string;
|
|
61
|
+
report: unknown;
|
|
62
|
+
}): Promise<void>;
|
|
26
63
|
ping(): Promise<boolean>;
|
|
27
64
|
insertOccurrences(ctx: IngestContext, occurrences: RuntimeOccurrence[]): Promise<void>;
|
|
28
65
|
insertSpans(ctx: IngestContext, spans: RuntimeSpanRow[]): Promise<void>;
|
|
@@ -30,11 +67,11 @@ export declare class ClickHouseStore {
|
|
|
30
67
|
insertProfileSamples(ctx: IngestContext, samples: ProfileSample[]): Promise<void>;
|
|
31
68
|
insertMemorySamples(ctx: IngestContext, samples: MemorySample[]): Promise<void>;
|
|
32
69
|
insertPlatformEvent(ctx: IngestContext, event: PlatformEvent): Promise<void>;
|
|
33
|
-
insertSourceMap(ctx: IngestContext, sourceMap:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
70
|
+
insertSourceMap(ctx: IngestContext, sourceMap: ValidSourceMap): Promise<void>;
|
|
71
|
+
/** The uploaded source map for a minified file, or null (source-maps-lookup.ts). */
|
|
72
|
+
lookupSourceMap(key: SourceMapKey): Promise<string | null>;
|
|
73
|
+
/** Indexes of `check.entries` this tenant uploaded recently (see presentSourceMaps). */
|
|
74
|
+
sourceMapsPresent(ctx: IngestContext, check: SourceMapCheck): Promise<number[]>;
|
|
38
75
|
insertLlmCalls(ctx: IngestContext, calls: RuntimeLlmCall[]): Promise<void>;
|
|
39
76
|
insertMetricPoints(ctx: IngestContext, points: RuntimeMetricPoint[]): Promise<void>;
|
|
40
77
|
insertLatencyHistograms(ctx: IngestContext, points: LatencyHistogram[]): Promise<void>;
|
package/dist/clickhouse.js
CHANGED
|
@@ -3,8 +3,11 @@ import { latencyTableDDL } 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";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { attributesWithBreadcrumbs } from "./breadcrumbs.js";
|
|
7
|
+
import { lookupSourceMap, presentSourceMaps } from "./source-maps-lookup.js";
|
|
8
|
+
import { logTableDDL, requestRollupTableDDL, } from "./logs.js";
|
|
9
|
+
import { ingesterInfoTableDDL, sdkVersionTableDDL, } from "./sdk-versions.js";
|
|
10
|
+
import { MIGRATIONS, OCCURRENCE_CODE_COLUMNS, OCCURRENCE_GROUPING_COLUMNS, OCCURRENCE_REQUEST_ID_INDEX, migrationsTableDDL, } from "./migrations.js";
|
|
8
11
|
/**
|
|
9
12
|
* ClickHouse persistence. The schema is idempotent and applied lazily once
|
|
10
13
|
* per process. Every row is keyed by (org_id, repository_id) — tenant
|
|
@@ -16,10 +19,16 @@ const INSERT_SETTINGS = {
|
|
|
16
19
|
async_insert: 1,
|
|
17
20
|
wait_for_async_insert: 1,
|
|
18
21
|
};
|
|
22
|
+
/** "YYYY-MM-DD hh:mm:ss.sss" (UTC) — the DateTime64 query-parameter form. */
|
|
23
|
+
function chDateTime(date) {
|
|
24
|
+
return date.toISOString().replace("T", " ").replace("Z", "");
|
|
25
|
+
}
|
|
19
26
|
export class ClickHouseStore {
|
|
20
27
|
config;
|
|
21
28
|
client = null;
|
|
22
29
|
ensurePromise = null;
|
|
30
|
+
schemaState = "pending";
|
|
31
|
+
appliedIds = [];
|
|
23
32
|
constructor(config) {
|
|
24
33
|
this.config = config;
|
|
25
34
|
}
|
|
@@ -47,6 +56,7 @@ export class ClickHouseStore {
|
|
|
47
56
|
schemaStatements() {
|
|
48
57
|
const db = this.config.clickhouseDatabase;
|
|
49
58
|
const { occurrenceTtlDays, spanTtlDays, metricsTtlDays, llmCallTtlDays } = this.config;
|
|
59
|
+
const logTtlDays = this.config.logTtlDays ?? 14;
|
|
50
60
|
return [
|
|
51
61
|
`CREATE DATABASE IF NOT EXISTS ${db}`,
|
|
52
62
|
latencyTableDDL(db, metricsTtlDays),
|
|
@@ -54,7 +64,13 @@ export class ClickHouseStore {
|
|
|
54
64
|
memoryTableDDL(db),
|
|
55
65
|
platformEventTableDDL(db),
|
|
56
66
|
sourceMapTableDDL(db),
|
|
57
|
-
logTableDDL(db),
|
|
67
|
+
logTableDDL(db, logTtlDays),
|
|
68
|
+
// The table only: its materialized view reads 0012 columns that an
|
|
69
|
+
// existing runtime_logs lacks until migrations run, so the view is
|
|
70
|
+
// created by migration 0014 alone (migrations also run on fresh DBs).
|
|
71
|
+
requestRollupTableDDL(db),
|
|
72
|
+
sdkVersionTableDDL(db, metricsTtlDays),
|
|
73
|
+
ingesterInfoTableDDL(db),
|
|
58
74
|
`CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
|
|
59
75
|
org_id String,
|
|
60
76
|
repository_id String,
|
|
@@ -79,7 +95,10 @@ export class ClickHouseStore {
|
|
|
79
95
|
session_id String DEFAULT '',
|
|
80
96
|
attributes String DEFAULT '{}' CODEC(ZSTD(1)),
|
|
81
97
|
occurred_at DateTime64(3, 'UTC'),
|
|
82
|
-
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
98
|
+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
|
|
99
|
+
${OCCURRENCE_CODE_COLUMNS.join(",\n\t\t\t\t")},
|
|
100
|
+
${OCCURRENCE_GROUPING_COLUMNS.join(",\n\t\t\t\t")},
|
|
101
|
+
${OCCURRENCE_REQUEST_ID_INDEX}
|
|
83
102
|
)
|
|
84
103
|
ENGINE = MergeTree
|
|
85
104
|
PARTITION BY toDate(occurred_at)
|
|
@@ -192,12 +211,129 @@ export class ClickHouseStore {
|
|
|
192
211
|
});
|
|
193
212
|
console.log(`clickhouse migration applied: ${migration.id}`);
|
|
194
213
|
}
|
|
214
|
+
await this.applyLogTtl();
|
|
215
|
+
this.appliedIds = migrations.map((migration) => migration.id);
|
|
216
|
+
this.schemaState = "ready";
|
|
195
217
|
})().catch((err) => {
|
|
196
218
|
this.ensurePromise = null;
|
|
219
|
+
this.schemaState = "failed";
|
|
197
220
|
throw err;
|
|
198
221
|
});
|
|
199
222
|
return this.ensurePromise;
|
|
200
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Bring runtime_logs' TTL in line with LOG_TTL_DAYS. Idempotent: reads the
|
|
226
|
+
* current TTL from system.tables and only issues `MODIFY TTL` when it
|
|
227
|
+
* differs (MODIFY TTL rewrites TTL info for existing parts, so it must not
|
|
228
|
+
* run on every boot). Best-effort — a failure is logged and never blocks
|
|
229
|
+
* ingest; the next boot retries.
|
|
230
|
+
*/
|
|
231
|
+
async applyLogTtl() {
|
|
232
|
+
const days = this.config.logTtlDays ?? 14;
|
|
233
|
+
const db = this.config.clickhouseDatabase;
|
|
234
|
+
try {
|
|
235
|
+
const client = this.getClient();
|
|
236
|
+
const rows = await client.query({
|
|
237
|
+
query: `SELECT engine_full FROM system.tables WHERE database = {db:String} AND name = 'runtime_logs'`,
|
|
238
|
+
query_params: { db },
|
|
239
|
+
format: "JSONEachRow",
|
|
240
|
+
});
|
|
241
|
+
const [table] = await rows.json();
|
|
242
|
+
if (!table)
|
|
243
|
+
return;
|
|
244
|
+
const current = /\bTTL\b[^]*?toIntervalDay\((\d+)\)/.exec(table.engine_full);
|
|
245
|
+
if (!current) {
|
|
246
|
+
console.warn("runtime_logs TTL not recognised — LOG_TTL_DAYS not applied");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (Number(current[1]) === days)
|
|
250
|
+
return;
|
|
251
|
+
await client.command({
|
|
252
|
+
query: `ALTER TABLE ${db}.runtime_logs MODIFY TTL toDateTime(occurred_at) + INTERVAL ${days} DAY`,
|
|
253
|
+
});
|
|
254
|
+
console.log(`runtime_logs TTL changed from ${current[1]} to ${days} days (LOG_TTL_DAYS)`);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
console.warn("applying LOG_TTL_DAYS to runtime_logs failed (will retry next boot):", err instanceof Error ? err.message : err);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Trace ids (from `traceIds`) that already produced an occurrence for
|
|
262
|
+
* this tenant in [from, to] — the cross-batch half of the log-promotion
|
|
263
|
+
* dedupe. `excludeIds` are the candidate occurrences' own deterministic
|
|
264
|
+
* ids, so a retried batch does not dedupe against its own earlier,
|
|
265
|
+
* partially-committed write. Browser occurrences share the server's trace
|
|
266
|
+
* id (runtime-browser traceparent) but are a different signal, so they
|
|
267
|
+
* never suppress a server error. Throws on failure; the caller decides.
|
|
268
|
+
*/
|
|
269
|
+
async recentOccurrenceTraceIds(ctx, traceIds, from, to, excludeIds) {
|
|
270
|
+
if (traceIds.length === 0 || !this.configured)
|
|
271
|
+
return new Set();
|
|
272
|
+
await this.ensureSchema();
|
|
273
|
+
const rows = await this.getClient().query({
|
|
274
|
+
query: `SELECT DISTINCT trace_id FROM ${this.table("runtime_error_occurrences")}
|
|
275
|
+
WHERE org_id = {org:String} AND repository_id = {repo:String}
|
|
276
|
+
AND occurred_at >= {from:DateTime64(3, 'UTC')} AND occurred_at <= {to:DateTime64(3, 'UTC')}
|
|
277
|
+
AND source != 'browser'
|
|
278
|
+
AND has({traces:Array(String)}, trace_id)
|
|
279
|
+
AND NOT has({exclude:Array(String)}, occurrence_id)`,
|
|
280
|
+
query_params: {
|
|
281
|
+
org: ctx.orgId,
|
|
282
|
+
repo: ctx.repositoryId,
|
|
283
|
+
from: chDateTime(from),
|
|
284
|
+
to: chDateTime(to),
|
|
285
|
+
traces: traceIds,
|
|
286
|
+
exclude: excludeIds,
|
|
287
|
+
},
|
|
288
|
+
format: "JSONEachRow",
|
|
289
|
+
});
|
|
290
|
+
return new Set((await rows.json()).map((row) => row.trace_id));
|
|
291
|
+
}
|
|
292
|
+
/** Schema state as of the last ensureSchema attempt — never triggers DDL
|
|
293
|
+
* (it backs the unauthenticated /v1/compat endpoint). */
|
|
294
|
+
schemaStatus() {
|
|
295
|
+
if (!this.configured)
|
|
296
|
+
return { status: "unconfigured", applied: [] };
|
|
297
|
+
return { status: this.schemaState, applied: [...this.appliedIds] };
|
|
298
|
+
}
|
|
299
|
+
async insertSdkVersions(ctx, rows) {
|
|
300
|
+
if (!rows.length || !this.configured)
|
|
301
|
+
return;
|
|
302
|
+
await this.ensureSchema();
|
|
303
|
+
await this.getClient().insert({
|
|
304
|
+
table: this.table("runtime_sdk_versions"),
|
|
305
|
+
format: "JSONEachRow",
|
|
306
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
307
|
+
values: rows.map((row) => ({
|
|
308
|
+
org_id: ctx.orgId,
|
|
309
|
+
repository_id: ctx.repositoryId,
|
|
310
|
+
service: row.service,
|
|
311
|
+
environment: row.environment,
|
|
312
|
+
sdk_name: row.sdkName,
|
|
313
|
+
sdk_version: row.sdkVersion,
|
|
314
|
+
sdk_language: row.sdkLanguage,
|
|
315
|
+
ingester_version: row.ingesterVersion,
|
|
316
|
+
last_seen: row.seenAt.toISOString(),
|
|
317
|
+
})),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Record this ingester's version and schema level (global, not tenant data). */
|
|
321
|
+
async insertIngesterInfo(info) {
|
|
322
|
+
if (!this.configured)
|
|
323
|
+
return;
|
|
324
|
+
await this.ensureSchema();
|
|
325
|
+
await this.getClient().insert({
|
|
326
|
+
table: this.table("runtime_ingester_info"),
|
|
327
|
+
format: "JSONEachRow",
|
|
328
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
329
|
+
values: [{
|
|
330
|
+
ingester_version: info.version,
|
|
331
|
+
schema_level: info.schemaLevel,
|
|
332
|
+
report: JSON.stringify(info.report),
|
|
333
|
+
reported_at: new Date().toISOString(),
|
|
334
|
+
}],
|
|
335
|
+
});
|
|
336
|
+
}
|
|
201
337
|
async ping() {
|
|
202
338
|
if (!this.configured)
|
|
203
339
|
return false;
|
|
@@ -234,8 +370,17 @@ export class ClickHouseStore {
|
|
|
234
370
|
status_code: o.statusCode ?? 0,
|
|
235
371
|
trace_id: o.traceId ?? "",
|
|
236
372
|
session_id: o.sessionId ?? "",
|
|
237
|
-
attributes: JSON.stringify(o.attributes
|
|
373
|
+
attributes: JSON.stringify(attributesWithBreadcrumbs(o.attributes, o.breadcrumbs)),
|
|
238
374
|
occurred_at: o.occurredAt.toISOString(),
|
|
375
|
+
error_code: o.errorCode ?? "",
|
|
376
|
+
error_why: o.why ?? "",
|
|
377
|
+
error_fix: o.fix ?? "",
|
|
378
|
+
error_link: o.link ?? "",
|
|
379
|
+
expected: o.expected ? 1 : 0,
|
|
380
|
+
request_id: o.requestId ?? "",
|
|
381
|
+
fingerprint_scheme: o.fingerprintScheme,
|
|
382
|
+
frames: o.frames?.length ? JSON.stringify(o.frames) : "",
|
|
383
|
+
fingerprints: (o.fingerprints ?? []).map((candidate) => candidate.fingerprint),
|
|
239
384
|
})),
|
|
240
385
|
});
|
|
241
386
|
}
|
|
@@ -278,7 +423,9 @@ export class ClickHouseStore {
|
|
|
278
423
|
service: row.service, environment: row.environment, release: row.release, trace_id: row.traceId,
|
|
279
424
|
span_id: row.spanId, operation_id: row.operationId, operation: row.operation, event_type: row.type,
|
|
280
425
|
severity: row.severity, message: row.message, outcome: row.outcome, duration_ms: row.durationMs,
|
|
281
|
-
attributes: JSON.stringify(row.attributes), occurred_at: row.occurredAt.toISOString()
|
|
426
|
+
attributes: JSON.stringify(row.attributes), occurred_at: row.occurredAt.toISOString(),
|
|
427
|
+
kind: row.kind, request_id: row.requestId, route: row.route, status_code: row.statusCode,
|
|
428
|
+
error_code: row.errorCode, ai_cost_usd: row.aiCostUsd, ai_calls: row.aiCalls })) });
|
|
282
429
|
}
|
|
283
430
|
async insertProfileSamples(ctx, samples) {
|
|
284
431
|
if (!samples.length)
|
|
@@ -328,9 +475,23 @@ export class ClickHouseStore {
|
|
|
328
475
|
await this.getClient().insert({
|
|
329
476
|
table: this.table("runtime_source_maps"), format: "JSONEachRow",
|
|
330
477
|
clickhouse_settings: INSERT_SETTINGS,
|
|
331
|
-
values: [{ org_id: ctx.orgId, repository_id: ctx.repositoryId,
|
|
478
|
+
values: [{ org_id: ctx.orgId, repository_id: ctx.repositoryId, release: sourceMap.release,
|
|
479
|
+
filename: sourceMap.filename, map: sourceMap.map, debug_id: sourceMap.debugId,
|
|
480
|
+
content_hash: sourceMap.contentHash }],
|
|
332
481
|
});
|
|
333
482
|
}
|
|
483
|
+
/** The uploaded source map for a minified file, or null (source-maps-lookup.ts). */
|
|
484
|
+
async lookupSourceMap(key) {
|
|
485
|
+
if (!this.configured)
|
|
486
|
+
return null;
|
|
487
|
+
await this.ensureSchema();
|
|
488
|
+
return lookupSourceMap(this.getClient(), this.config.clickhouseDatabase, key);
|
|
489
|
+
}
|
|
490
|
+
/** Indexes of `check.entries` this tenant uploaded recently (see presentSourceMaps). */
|
|
491
|
+
async sourceMapsPresent(ctx, check) {
|
|
492
|
+
await this.ensureSchema();
|
|
493
|
+
return presentSourceMaps(this.getClient(), this.config.clickhouseDatabase, ctx, check);
|
|
494
|
+
}
|
|
334
495
|
async insertLlmCalls(ctx, calls) {
|
|
335
496
|
if (calls.length === 0 || !this.configured)
|
|
336
497
|
return;
|