@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 +18 -0
- package/README.md +114 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +103 -0
- package/dist/clickhouse.d.ts +27 -0
- package/dist/clickhouse.js +261 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +51 -0
- package/dist/fingerprint.d.ts +19 -0
- package/dist/fingerprint.js +80 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +26 -0
- package/dist/migrations.d.ts +42 -0
- package/dist/migrations.js +88 -0
- package/dist/normalize-browser.d.ts +103 -0
- package/dist/normalize-browser.js +113 -0
- package/dist/normalize-otlp.d.ts +84 -0
- package/dist/normalize-otlp.js +258 -0
- package/dist/otlp-proto.d.ts +3 -0
- package/dist/otlp-proto.js +107 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +267 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +11 -0
- package/package.json +53 -0
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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|