@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
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
+ }
@@ -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
- release: string;
35
- filename: string;
36
- map: string;
37
- }): Promise<void>;
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>;
@@ -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 { logTableDDL } from "./logs.js";
7
- import { MIGRATIONS, migrationsTableDDL, } from "./migrations.js";
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, ...sourceMap }],
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;