@autter/otlp-ingester 0.1.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/Dockerfile ADDED
@@ -0,0 +1,18 @@
1
+ FROM node:22-alpine AS build
2
+ WORKDIR /app
3
+ COPY package.json package-lock.json* ./
4
+ COPY packages/otlp-ingester/package.json packages/otlp-ingester/
5
+ RUN npm install --workspace @autter/otlp-ingester
6
+ COPY packages/otlp-ingester packages/otlp-ingester
7
+ RUN npm run build -w @autter/otlp-ingester
8
+
9
+ FROM node:22-alpine
10
+ ENV NODE_ENV=production
11
+ WORKDIR /app
12
+ COPY package.json package-lock.json* ./
13
+ COPY packages/otlp-ingester/package.json packages/otlp-ingester/
14
+ RUN npm install --workspace @autter/otlp-ingester --omit=dev && npm cache clean --force
15
+ COPY --from=build /app/packages/otlp-ingester/dist packages/otlp-ingester/dist
16
+ USER node
17
+ EXPOSE 4318
18
+ CMD ["node", "packages/otlp-ingester/dist/index.js"]
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @autter/otlp-ingester
2
+
3
+ Self-hostable ingest service for Autter Runtime. Receives OTLP/HTTP (JSON)
4
+ traces and metrics plus compact browser error payloads, normalises them into
5
+ one per-repo signal model, fingerprints errors, and writes ClickHouse.
6
+
7
+ ## Endpoints
8
+
9
+ | Route | Payload | Purpose |
10
+ | --- | --- | --- |
11
+ | `POST /v1/traces` | OTLP/JSON `ExportTraceServiceRequest` | Error spans → occurrences; all spans → `runtime_spans`; server spans → usage rollups |
12
+ | `POST /v1/metrics` | OTLP/JSON `ExportMetricsServiceRequest` | HTTP-server duration histograms → usage rollups |
13
+ | `POST /v1/browser` | Browser payload `version: 1` | Errors/rejections → occurrences; session pings → rollups |
14
+ | `GET /healthz` | — | Liveness + ClickHouse reachability |
15
+
16
+ Auth on every ingest route: `Authorization: Bearer <ingest key>`,
17
+ `x-autter-key`, or `?key=` (query param — for sendBeacon, which cannot set
18
+ headers). OTLP endpoints accept **both protobuf and JSON** (`content-type:
19
+ application/x-protobuf` or `application/json`), gzip/deflate bodies
20
+ included — so any OpenTelemetry SDK (Go, Rust, Python, Java, .NET, JS)
21
+ works with its default exporter settings.
22
+
23
+ ### Key scopes
24
+
25
+ | Scope | Prefix convention | Valid on | Extras |
26
+ | --- | --- | --- | --- |
27
+ | `server` (default) | `autter_rt_…` (secret) | all endpoints | 300 req/min |
28
+ | `client` | `autter_rtc_…` (publishable, safe in frontend bundles) | `/v1/browser` only | origin allow-list, 120 req/min |
29
+
30
+ `/v1/browser` answers CORS preflights permissively; real enforcement (key +
31
+ origin allow-list) happens on the POST. Cross-origin browsers send
32
+ `text/plain` bodies (CORS-safelisted, no preflight per beacon) which the
33
+ route parses as JSON.
34
+
35
+ ```json
36
+ AUTTER_INGEST_KEYS='[
37
+ {"key":"autter_rt_…","orgId":"org1","repositoryId":"repo1"},
38
+ {"key":"autter_rtc_…","orgId":"org1","repositoryId":"repo1",
39
+ "scope":"client","allowedOrigins":["https://app.example.com"]}
40
+ ]'
41
+ ```
42
+
43
+ The validator webhook may return the same extra fields:
44
+ `{ orgId, repositoryId, scope?, allowedOrigins? }`.
45
+
46
+ ## Configuration
47
+
48
+ | Env | Default | Description |
49
+ | --- | --- | --- |
50
+ | `PORT` | `4318` | Listen port (OTLP/HTTP convention) |
51
+ | `CLICKHOUSE_URL` | — | e.g. `http://localhost:8123`; unset = ingest returns 503 |
52
+ | `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` | `default` / empty | |
53
+ | `CLICKHOUSE_DATABASE` | `autter_runtime` | Created automatically |
54
+ | `AUTTER_INGEST_KEYS` | — | JSON: `[{"key":"...","orgId":"...","repositoryId":"..."}]` |
55
+ | `AUTTER_KEY_VALIDATOR_URL` | — | Webhook: `POST {key}` → `{orgId, repositoryId}` (60 s cache) |
56
+ | `AUTTER_KEY_VALIDATOR_TOKEN` | — | Bearer token sent to the validator |
57
+ | `AUTTER_SINK_URL` | — | Webhook receiving fingerprinted occurrences for issue grouping |
58
+ | `AUTTER_SINK_TOKEN` | — | Bearer token sent to the sink |
59
+ | `MAX_BODY_BYTES` | `1048576` | Request body cap |
60
+ | `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
61
+ | `CLIENT_RATE_LIMIT_PER_MINUTE` | `120` | Per-key fixed window (client keys) |
62
+ | `OCCURRENCE_TTL_DAYS` / `SPAN_TTL_DAYS` / `METRICS_TTL_DAYS` | `14` / `7` / `90` | ClickHouse TTLs (applied at table creation) |
63
+
64
+ ## Schema migrations
65
+
66
+ The ingester owns the ClickHouse schema and updates it **automatically on
67
+ boot** — deploying a new ingester version is the schema deployment; there
68
+ is no separate migration step to run.
69
+
70
+ How it works: the baseline `CREATE TABLE IF NOT EXISTS` statements
71
+ provision fresh databases; versioned migrations in `src/migrations.ts`
72
+ alter existing ones. Applied migration ids are recorded in
73
+ `<db>.schema_migrations`, so each runs exactly once per database, and every
74
+ statement is written to be idempotent (`ADD COLUMN IF NOT EXISTS`, …) so
75
+ concurrent replicas booting during a rolling deploy race harmlessly.
76
+
77
+ To change the schema (e.g. add a column):
78
+
79
+ 1. Append a migration to `MIGRATIONS` in `src/migrations.ts` — new columns
80
+ need a `DEFAULT` so still-running old replicas can keep inserting.
81
+ 2. Update the baseline in `src/clickhouse.ts` `schemaStatements()` so fresh
82
+ databases come up with the final shape.
83
+ 3. Ship it — the next deploy applies it everywhere; the log line
84
+ `clickhouse migration applied: <id>` confirms.
85
+
86
+ Never edit or reorder a shipped migration; append a corrective one.
87
+
88
+ ## Local development
89
+
90
+ ```bash
91
+ docker compose up clickhouse # from the repo root
92
+ AUTTER_INGEST_KEYS='[{"key":"dev-key","orgId":"org1","repositoryId":"repo1"}]' \
93
+ CLICKHOUSE_URL=http://localhost:8123 CLICKHOUSE_PASSWORD=dev \
94
+ npm run dev
95
+ ```
96
+
97
+ Send a test error span:
98
+
99
+ ```bash
100
+ curl -s http://localhost:4318/v1/traces \
101
+ -H 'authorization: Bearer dev-key' -H 'content-type: application/json' \
102
+ -d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"payments-api"}},{"key":"service.version","value":{"stringValue":"e4a218f"}}]},"scopeSpans":[{"spans":[{"traceId":"0123456789abcdef0123456789abcdef","spanId":"0123456789abcdef","name":"POST /orders/:id","kind":2,"startTimeUnixNano":"1753100000000000000","endTimeUnixNano":"1753100000120000000","status":{"code":2,"message":"boom"},"attributes":[{"key":"http.route","value":{"stringValue":"/orders/:id"}},{"key":"http.response.status_code","value":{"intValue":500}}],"events":[{"name":"exception","timeUnixNano":"1753100000100000000","attributes":[{"key":"exception.type","value":{"stringValue":"TypeError"}},{"key":"exception.message","value":{"stringValue":"cannot read x"}},{"key":"exception.stacktrace","value":{"stringValue":"TypeError: cannot read x\n at handler (/app/dist/orders.js:12:3)"}}]}]}]}]}]}'
103
+ ```
104
+
105
+ ## Pointing OpenTelemetry at it
106
+
107
+ ```ts
108
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; // http/json
109
+
110
+ new OTLPTraceExporter({
111
+ url: "https://otlp.your-domain.dev/v1/traces",
112
+ headers: { authorization: "Bearer <ingest key>" },
113
+ });
114
+ ```
package/dist/auth.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Request } from "express";
2
+ import type { IngesterConfig } from "./config.js";
3
+ import type { IngestContext } from "./types.js";
4
+ export declare class KeyResolver {
5
+ private readonly config;
6
+ private readonly staticKeys;
7
+ private readonly cache;
8
+ constructor(config: IngesterConfig);
9
+ extractKey(req: Request): string | null;
10
+ resolve(key: string): Promise<IngestContext | null>;
11
+ }
12
+ /** Fixed-window per-key rate limiter (single-node; Redis backing is M1). */
13
+ export declare class RateLimiter {
14
+ private readonly limitPerMinute;
15
+ private windows;
16
+ constructor(limitPerMinute: number);
17
+ allow(key: string): boolean;
18
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,103 @@
1
+ const VALIDATOR_CACHE_TTL_MS = 60_000;
2
+ export class KeyResolver {
3
+ config;
4
+ staticKeys = new Map();
5
+ cache = new Map();
6
+ constructor(config) {
7
+ this.config = config;
8
+ for (const entry of config.ingestKeys) {
9
+ this.staticKeys.set(entry.key, {
10
+ orgId: entry.orgId,
11
+ repositoryId: entry.repositoryId,
12
+ scope: entry.scope ?? "server",
13
+ allowedOrigins: entry.allowedOrigins ?? [],
14
+ });
15
+ }
16
+ }
17
+ extractKey(req) {
18
+ const header = req.headers.authorization;
19
+ if (header?.toLowerCase().startsWith("bearer ")) {
20
+ return header.slice(7).trim() || null;
21
+ }
22
+ const alt = req.headers["x-autter-key"];
23
+ if (typeof alt === "string" && alt.trim())
24
+ return alt.trim();
25
+ // Query param — the only channel available to navigator.sendBeacon
26
+ // (it cannot set headers). Intended for publishable client keys.
27
+ const q = req.query.key;
28
+ if (typeof q === "string" && q.trim())
29
+ return q.trim();
30
+ return null;
31
+ }
32
+ async resolve(key) {
33
+ const staticCtx = this.staticKeys.get(key);
34
+ if (staticCtx)
35
+ return staticCtx;
36
+ if (!this.config.keyValidatorUrl)
37
+ return null;
38
+ const cached = this.cache.get(key);
39
+ if (cached && cached.expiresAt > Date.now())
40
+ return cached.ctx;
41
+ let ctx = null;
42
+ try {
43
+ const response = await fetch(this.config.keyValidatorUrl, {
44
+ method: "POST",
45
+ headers: {
46
+ "content-type": "application/json",
47
+ ...(this.config.keyValidatorToken
48
+ ? { authorization: `Bearer ${this.config.keyValidatorToken}` }
49
+ : {}),
50
+ },
51
+ body: JSON.stringify({ key }),
52
+ signal: AbortSignal.timeout(5000),
53
+ });
54
+ if (response.ok) {
55
+ const body = (await response.json());
56
+ if (body.orgId && body.repositoryId) {
57
+ ctx = {
58
+ orgId: body.orgId,
59
+ repositoryId: body.repositoryId,
60
+ scope: body.scope ?? "server",
61
+ allowedOrigins: body.allowedOrigins ?? [],
62
+ };
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ // Validator unreachable: fail closed for unknown keys, but reuse a
68
+ // stale cache entry if we have one so transient validator outages
69
+ // don't drop telemetry from known-good keys.
70
+ if (cached)
71
+ return cached.ctx;
72
+ return null;
73
+ }
74
+ this.cache.set(key, { ctx, expiresAt: Date.now() + VALIDATOR_CACHE_TTL_MS });
75
+ if (this.cache.size > 10_000) {
76
+ const oldest = this.cache.keys().next().value;
77
+ if (oldest !== undefined)
78
+ this.cache.delete(oldest);
79
+ }
80
+ return ctx;
81
+ }
82
+ }
83
+ /** Fixed-window per-key rate limiter (single-node; Redis backing is M1). */
84
+ export class RateLimiter {
85
+ limitPerMinute;
86
+ windows = new Map();
87
+ constructor(limitPerMinute) {
88
+ this.limitPerMinute = limitPerMinute;
89
+ }
90
+ allow(key) {
91
+ const now = Date.now();
92
+ const windowStart = Math.floor(now / 60_000) * 60_000;
93
+ const entry = this.windows.get(key);
94
+ if (!entry || entry.windowStart !== windowStart) {
95
+ this.windows.set(key, { windowStart, count: 1 });
96
+ if (this.windows.size > 50_000)
97
+ this.windows.clear();
98
+ return true;
99
+ }
100
+ entry.count += 1;
101
+ return entry.count <= this.limitPerMinute;
102
+ }
103
+ }
@@ -0,0 +1,27 @@
1
+ import type { IngesterConfig } from "./config.js";
2
+ import { type Migration } from "./migrations.js";
3
+ import type { IngestContext, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
4
+ export declare class ClickHouseStore {
5
+ private readonly config;
6
+ private client;
7
+ private ensurePromise;
8
+ constructor(config: IngesterConfig);
9
+ get configured(): boolean;
10
+ private getClient;
11
+ private table;
12
+ private schemaStatements;
13
+ /**
14
+ * Baseline `CREATE ... IF NOT EXISTS` for fresh databases, then the
15
+ * versioned migrations from migrations.ts for existing ones — the
16
+ * CREATEs no-op on tables that already exist, so schema changes only
17
+ * reach deployed databases through migrations. Runs once per process
18
+ * (boot + lazily before first ingest); a failure clears the memo so the
19
+ * next request retries.
20
+ */
21
+ ensureSchema(migrations?: Migration[]): Promise<void>;
22
+ ping(): Promise<boolean>;
23
+ insertOccurrences(ctx: IngestContext, occurrences: RuntimeOccurrence[]): Promise<void>;
24
+ insertSpans(ctx: IngestContext, spans: RuntimeSpanRow[]): Promise<void>;
25
+ insertMetricPoints(ctx: IngestContext, points: RuntimeMetricPoint[]): Promise<void>;
26
+ close(): Promise<void>;
27
+ }
@@ -0,0 +1,261 @@
1
+ import { createClient, } from "@clickhouse/client";
2
+ import { MIGRATIONS, migrationsTableDDL, } from "./migrations.js";
3
+ /**
4
+ * ClickHouse persistence. The schema is idempotent and applied lazily once
5
+ * per process. Every row is keyed by (org_id, repository_id) — tenant
6
+ * isolation is enforced by always writing the authenticated context, never
7
+ * anything from the payload.
8
+ */
9
+ const INSERT_SETTINGS = {
10
+ date_time_input_format: "best_effort",
11
+ async_insert: 1,
12
+ wait_for_async_insert: 1,
13
+ };
14
+ export class ClickHouseStore {
15
+ config;
16
+ client = null;
17
+ ensurePromise = null;
18
+ constructor(config) {
19
+ this.config = config;
20
+ }
21
+ get configured() {
22
+ return Boolean(this.config.clickhouseUrl);
23
+ }
24
+ getClient() {
25
+ if (!this.config.clickhouseUrl) {
26
+ throw new Error("CLICKHOUSE_URL is not configured");
27
+ }
28
+ if (!this.client) {
29
+ this.client = createClient({
30
+ url: this.config.clickhouseUrl,
31
+ username: this.config.clickhouseUser,
32
+ password: this.config.clickhousePassword,
33
+ application: "autter-otlp-ingester",
34
+ clickhouse_settings: { date_time_input_format: "best_effort" },
35
+ });
36
+ }
37
+ return this.client;
38
+ }
39
+ table(name) {
40
+ return `${this.config.clickhouseDatabase}.${name}`;
41
+ }
42
+ schemaStatements() {
43
+ const db = this.config.clickhouseDatabase;
44
+ const { occurrenceTtlDays, spanTtlDays, metricsTtlDays } = this.config;
45
+ return [
46
+ `CREATE DATABASE IF NOT EXISTS ${db}`,
47
+ `CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
48
+ org_id String,
49
+ repository_id String,
50
+ occurrence_id String,
51
+ fingerprint String,
52
+ source LowCardinality(String),
53
+ severity LowCardinality(String) DEFAULT 'error',
54
+ service LowCardinality(String),
55
+ environment LowCardinality(String),
56
+ release String DEFAULT '',
57
+ error_type String,
58
+ message String CODEC(ZSTD(1)),
59
+ message_normalized String DEFAULT '',
60
+ stack String DEFAULT '' CODEC(ZSTD(3)),
61
+ top_frames Array(String) DEFAULT [],
62
+ first_frame String DEFAULT '',
63
+ route String DEFAULT '',
64
+ route_normalized String DEFAULT '',
65
+ method LowCardinality(String) DEFAULT '',
66
+ status_code UInt16 DEFAULT 0,
67
+ trace_id String DEFAULT '',
68
+ session_id String DEFAULT '',
69
+ attributes String DEFAULT '{}' CODEC(ZSTD(1)),
70
+ occurred_at DateTime64(3, 'UTC'),
71
+ ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
72
+ )
73
+ ENGINE = MergeTree
74
+ PARTITION BY toDate(occurred_at)
75
+ ORDER BY (org_id, repository_id, fingerprint, occurred_at)
76
+ TTL toDateTime(occurred_at) + INTERVAL ${occurrenceTtlDays} DAY`,
77
+ `CREATE TABLE IF NOT EXISTS ${db}.runtime_spans (
78
+ org_id String,
79
+ repository_id String,
80
+ service LowCardinality(String),
81
+ environment LowCardinality(String),
82
+ release String DEFAULT '',
83
+ trace_id String,
84
+ span_id String,
85
+ parent_span_id String DEFAULT '',
86
+ name String,
87
+ kind LowCardinality(String) DEFAULT 'internal',
88
+ status LowCardinality(String) DEFAULT 'ok',
89
+ route String DEFAULT '',
90
+ status_code UInt16 DEFAULT 0,
91
+ duration_ms Float64,
92
+ attributes String DEFAULT '{}' CODEC(ZSTD(1)),
93
+ started_at DateTime64(3, 'UTC')
94
+ )
95
+ ENGINE = MergeTree
96
+ PARTITION BY toDate(started_at)
97
+ ORDER BY (org_id, repository_id, trace_id, started_at)
98
+ TTL toDateTime(started_at) + INTERVAL ${spanTtlDays} DAY`,
99
+ `CREATE TABLE IF NOT EXISTS ${db}.runtime_metrics_1m (
100
+ org_id String,
101
+ repository_id String,
102
+ service LowCardinality(String),
103
+ environment LowCardinality(String),
104
+ release String DEFAULT '',
105
+ route String DEFAULT '',
106
+ bucket_at DateTime('UTC'),
107
+ request_count UInt64,
108
+ error_count UInt64,
109
+ duration_sum_ms Float64,
110
+ session_count UInt64 DEFAULT 0
111
+ )
112
+ ENGINE = SummingMergeTree((request_count, error_count, duration_sum_ms, session_count))
113
+ PARTITION BY toYYYYMM(bucket_at)
114
+ ORDER BY (org_id, repository_id, service, environment, release, route, bucket_at)
115
+ TTL bucket_at + INTERVAL ${metricsTtlDays} DAY`,
116
+ ];
117
+ }
118
+ /**
119
+ * Baseline `CREATE ... IF NOT EXISTS` for fresh databases, then the
120
+ * versioned migrations from migrations.ts for existing ones — the
121
+ * CREATEs no-op on tables that already exist, so schema changes only
122
+ * reach deployed databases through migrations. Runs once per process
123
+ * (boot + lazily before first ingest); a failure clears the memo so the
124
+ * next request retries.
125
+ */
126
+ ensureSchema(migrations = MIGRATIONS) {
127
+ if (this.ensurePromise)
128
+ return this.ensurePromise;
129
+ this.ensurePromise = (async () => {
130
+ const client = this.getClient();
131
+ const db = this.config.clickhouseDatabase;
132
+ for (const statement of this.schemaStatements()) {
133
+ await client.command({ query: statement });
134
+ }
135
+ await client.command({ query: migrationsTableDDL(db) });
136
+ const rows = await client.query({
137
+ query: `SELECT DISTINCT id FROM ${db}.schema_migrations`,
138
+ format: "JSONEachRow",
139
+ });
140
+ const applied = new Set((await rows.json()).map((row) => row.id));
141
+ for (const migration of migrations) {
142
+ if (applied.has(migration.id))
143
+ continue;
144
+ for (const statement of migration.statements) {
145
+ await client.command({
146
+ query: statement.replaceAll("{db}", db),
147
+ });
148
+ }
149
+ await client.insert({
150
+ table: `${db}.schema_migrations`,
151
+ format: "JSONEachRow",
152
+ values: [{ id: migration.id }],
153
+ });
154
+ console.log(`clickhouse migration applied: ${migration.id}`);
155
+ }
156
+ })().catch((err) => {
157
+ this.ensurePromise = null;
158
+ throw err;
159
+ });
160
+ return this.ensurePromise;
161
+ }
162
+ async ping() {
163
+ if (!this.configured)
164
+ return false;
165
+ const result = await this.getClient().ping();
166
+ return result.success;
167
+ }
168
+ async insertOccurrences(ctx, occurrences) {
169
+ if (occurrences.length === 0 || !this.configured)
170
+ return;
171
+ await this.ensureSchema();
172
+ await this.getClient().insert({
173
+ table: this.table("runtime_error_occurrences"),
174
+ format: "JSONEachRow",
175
+ clickhouse_settings: INSERT_SETTINGS,
176
+ values: occurrences.map((o) => ({
177
+ org_id: ctx.orgId,
178
+ repository_id: ctx.repositoryId,
179
+ occurrence_id: o.occurrenceId,
180
+ fingerprint: o.fingerprint,
181
+ source: o.source,
182
+ severity: o.severity,
183
+ service: o.service,
184
+ environment: o.environment,
185
+ release: o.release ?? "",
186
+ error_type: o.errorType,
187
+ message: o.message.slice(0, 4000),
188
+ message_normalized: o.messageNormalized,
189
+ stack: (o.stack ?? "").slice(0, 32000),
190
+ top_frames: o.topFrames,
191
+ first_frame: o.firstFrame,
192
+ route: o.route ?? "",
193
+ route_normalized: o.routeNormalized,
194
+ method: o.method ?? "",
195
+ status_code: o.statusCode ?? 0,
196
+ trace_id: o.traceId ?? "",
197
+ session_id: o.sessionId ?? "",
198
+ attributes: JSON.stringify(o.attributes ?? {}),
199
+ occurred_at: o.occurredAt.toISOString(),
200
+ })),
201
+ });
202
+ }
203
+ async insertSpans(ctx, spans) {
204
+ if (spans.length === 0 || !this.configured)
205
+ return;
206
+ await this.ensureSchema();
207
+ await this.getClient().insert({
208
+ table: this.table("runtime_spans"),
209
+ format: "JSONEachRow",
210
+ clickhouse_settings: INSERT_SETTINGS,
211
+ values: spans.map((s) => ({
212
+ org_id: ctx.orgId,
213
+ repository_id: ctx.repositoryId,
214
+ service: s.service,
215
+ environment: s.environment,
216
+ release: s.release ?? "",
217
+ trace_id: s.traceId,
218
+ span_id: s.spanId,
219
+ parent_span_id: s.parentSpanId ?? "",
220
+ name: s.name.slice(0, 500),
221
+ kind: s.kind,
222
+ status: s.status,
223
+ route: s.route ?? "",
224
+ status_code: s.statusCode ?? 0,
225
+ duration_ms: s.durationMs,
226
+ attributes: JSON.stringify(s.attributes ?? {}),
227
+ started_at: s.startedAt.toISOString(),
228
+ })),
229
+ });
230
+ }
231
+ async insertMetricPoints(ctx, points) {
232
+ if (points.length === 0 || !this.configured)
233
+ return;
234
+ await this.ensureSchema();
235
+ await this.getClient().insert({
236
+ table: this.table("runtime_metrics_1m"),
237
+ format: "JSONEachRow",
238
+ clickhouse_settings: INSERT_SETTINGS,
239
+ values: points.map((p) => ({
240
+ org_id: ctx.orgId,
241
+ repository_id: ctx.repositoryId,
242
+ service: p.service,
243
+ environment: p.environment,
244
+ release: p.release ?? "",
245
+ route: p.route,
246
+ bucket_at: p.bucketAt.toISOString(),
247
+ request_count: Math.max(0, Math.round(p.requestCount)),
248
+ error_count: Math.max(0, Math.round(p.errorCount)),
249
+ duration_sum_ms: p.durationSumMs,
250
+ session_count: Math.max(0, Math.round(p.sessionCount)),
251
+ })),
252
+ });
253
+ }
254
+ async close() {
255
+ if (!this.client)
256
+ return;
257
+ const client = this.client;
258
+ this.client = null;
259
+ await client.close().catch(() => { });
260
+ }
261
+ }
@@ -0,0 +1,35 @@
1
+ export interface StaticIngestKey {
2
+ key: string;
3
+ orgId: string;
4
+ repositoryId: string;
5
+ /** "server" (default) = secret backend key; "client" = publishable browser key. */
6
+ scope?: "client" | "server";
7
+ /** client keys: exact origins allowed to send (e.g. https://app.example.com). */
8
+ allowedOrigins?: string[];
9
+ }
10
+ export interface IngesterConfig {
11
+ port: number;
12
+ /** e.g. http://localhost:8123 or https://xyz.clickhouse.cloud:8443 */
13
+ clickhouseUrl: string | null;
14
+ clickhouseUser: string;
15
+ clickhousePassword: string;
16
+ clickhouseDatabase: string;
17
+ /** Static key → tenant mapping (self-host). JSON array. */
18
+ ingestKeys: StaticIngestKey[];
19
+ /** Webhook that maps a key to a tenant (cloud). POST {key} → {orgId, repositoryId}. */
20
+ keyValidatorUrl: string | null;
21
+ keyValidatorToken: string | null;
22
+ /** Optional webhook receiving fingerprinted occurrences for issue grouping. */
23
+ sinkUrl: string | null;
24
+ sinkToken: string | null;
25
+ maxBodyBytes: number;
26
+ /** Per-key requests per minute (server keys). */
27
+ rateLimitPerMinute: number;
28
+ /** Per-key requests per minute for publishable client keys. */
29
+ clientRateLimitPerMinute: number;
30
+ /** Retention, overridable per deployment. */
31
+ occurrenceTtlDays: number;
32
+ spanTtlDays: number;
33
+ metricsTtlDays: number;
34
+ }
35
+ export declare function loadConfig(): IngesterConfig;
package/dist/config.js ADDED
@@ -0,0 +1,51 @@
1
+ function intEnv(name, fallback) {
2
+ const raw = process.env[name];
3
+ if (!raw)
4
+ return fallback;
5
+ const value = Number.parseInt(raw, 10);
6
+ return Number.isFinite(value) && value > 0 ? value : fallback;
7
+ }
8
+ function parseIngestKeys(raw) {
9
+ if (!raw)
10
+ return [];
11
+ try {
12
+ const parsed = JSON.parse(raw);
13
+ if (!Array.isArray(parsed))
14
+ return [];
15
+ return parsed.filter((entry) => entry &&
16
+ typeof entry.key === "string" &&
17
+ typeof entry.orgId === "string" &&
18
+ typeof entry.repositoryId === "string");
19
+ }
20
+ catch {
21
+ console.error("AUTTER_INGEST_KEYS is not valid JSON — ignoring");
22
+ return [];
23
+ }
24
+ }
25
+ export function loadConfig() {
26
+ const config = {
27
+ port: intEnv("PORT", 4318),
28
+ clickhouseUrl: process.env.CLICKHOUSE_URL || null,
29
+ clickhouseUser: process.env.CLICKHOUSE_USER || "default",
30
+ clickhousePassword: process.env.CLICKHOUSE_PASSWORD || "",
31
+ clickhouseDatabase: process.env.CLICKHOUSE_DATABASE || "autter_runtime",
32
+ ingestKeys: parseIngestKeys(process.env.AUTTER_INGEST_KEYS),
33
+ keyValidatorUrl: process.env.AUTTER_KEY_VALIDATOR_URL || null,
34
+ keyValidatorToken: process.env.AUTTER_KEY_VALIDATOR_TOKEN || null,
35
+ sinkUrl: process.env.AUTTER_SINK_URL || null,
36
+ sinkToken: process.env.AUTTER_SINK_TOKEN || null,
37
+ maxBodyBytes: intEnv("MAX_BODY_BYTES", 1024 * 1024),
38
+ rateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 300),
39
+ clientRateLimitPerMinute: intEnv("CLIENT_RATE_LIMIT_PER_MINUTE", 120),
40
+ occurrenceTtlDays: intEnv("OCCURRENCE_TTL_DAYS", 14),
41
+ spanTtlDays: intEnv("SPAN_TTL_DAYS", 7),
42
+ metricsTtlDays: intEnv("METRICS_TTL_DAYS", 90),
43
+ };
44
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(config.clickhouseDatabase)) {
45
+ throw new Error(`Invalid CLICKHOUSE_DATABASE name: ${config.clickhouseDatabase}`);
46
+ }
47
+ if (config.ingestKeys.length === 0 && !config.keyValidatorUrl) {
48
+ console.warn("No AUTTER_INGEST_KEYS and no AUTTER_KEY_VALIDATOR_URL configured — all ingest requests will be rejected with 401");
49
+ }
50
+ return config;
51
+ }
@@ -0,0 +1,19 @@
1
+ import type { RuntimeOccurrenceInput } from "./types.js";
2
+ export declare function normalizeMessage(message: string): string;
3
+ /** Replace id-like path segments so /orders/812 and /orders/44 group. */
4
+ export declare function normalizeRoute(route: string | null): string;
5
+ export declare function normalizeStackFrames(stack: string | null, topN?: number): string[];
6
+ export declare function fingerprintOccurrence(input: RuntimeOccurrenceInput): string;
7
+ /**
8
+ * Derived, aggregation-ready fields, computed from the SAME normalisers the
9
+ * fingerprint hashes — so a stored fingerprint can always be explained by
10
+ * the stored columns next to it. Severity is deliberately excluded from
11
+ * the fingerprint (a warning that escalates to an error stays one group).
12
+ */
13
+ export interface DerivedFields {
14
+ routeNormalized: string;
15
+ messageNormalized: string;
16
+ topFrames: string[];
17
+ firstFrame: string;
18
+ }
19
+ export declare function deriveFields(input: RuntimeOccurrenceInput): DerivedFields;