@autter/otlp-ingester 1.0.0 → 1.2.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 CHANGED
@@ -8,7 +8,7 @@ one per-repo signal model, fingerprints errors, and writes ClickHouse.
8
8
 
9
9
  | Route | Payload | Purpose |
10
10
  | --- | --- | --- |
11
- | `POST /v1/traces` | OTLP/JSON `ExportTraceServiceRequest` | Error spans → occurrences; all spans → `runtime_spans`; server spans → usage rollups |
11
+ | `POST /v1/traces` | OTLP/JSON `ExportTraceServiceRequest` | Error spans → occurrences; all spans → `runtime_spans`; server spans → usage rollups; GenAI spans → `runtime_llm_calls` |
12
12
  | `POST /v1/metrics` | OTLP/JSON `ExportMetricsServiceRequest` | HTTP-server duration histograms → usage rollups |
13
13
  | `POST /v1/browser` | Browser payload `version: 1` | Errors/rejections → occurrences; session pings → rollups |
14
14
  | `GET /healthz` | — | Liveness + ClickHouse reachability |
@@ -54,12 +54,33 @@ The validator webhook may return the same extra fields:
54
54
  | `AUTTER_INGEST_KEYS` | — | JSON: `[{"key":"...","orgId":"...","repositoryId":"..."}]` |
55
55
  | `AUTTER_KEY_VALIDATOR_URL` | — | Webhook: `POST {key}` → `{orgId, repositoryId}` (60 s cache) |
56
56
  | `AUTTER_KEY_VALIDATOR_TOKEN` | — | Bearer token sent to the validator |
57
- | `AUTTER_SINK_URL` | — | Webhook receiving fingerprinted occurrences for issue grouping |
57
+ | `AUTTER_SINK_URL` | — | Issue-grouping webhook; at-least-once (`docs/ARCHITECTURE.md`) |
58
58
  | `AUTTER_SINK_TOKEN` | — | Bearer token sent to the sink |
59
+ | `SINK_MAX_ATTEMPTS` | `12` | Delivery attempts per batch (1–60 s backoff) |
60
+ | `SINK_MAX_BUFFERED_BATCHES` | `1000` | Retry buffer cap; oldest drops are logged |
61
+ | `SINK_MAX_BUFFERED_MB` | `64` | Sink retry buffer cap (memory) |
59
62
  | `MAX_BODY_BYTES` | `1048576` | Request body cap |
60
63
  | `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
61
64
  | `CLIENT_RATE_LIMIT_PER_MINUTE` | `120` | Per-key fixed window (client keys) |
62
65
  | `OCCURRENCE_TTL_DAYS` / `SPAN_TTL_DAYS` / `METRICS_TTL_DAYS` | `14` / `7` / `90` | ClickHouse TTLs (applied at table creation) |
66
+ | `LLM_CALL_TTL_DAYS` | `90` | Retention for `runtime_llm_calls` rows |
67
+
68
+ ## LLM / GenAI calls
69
+
70
+ Spans following the [OTel GenAI semconv](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
71
+ (`gen_ai.*` attributes — emitted by `withLlmCall` in `@autter/runtime-node`,
72
+ the Vercel AI SDK with telemetry enabled, and the GenAI instrumentations for
73
+ Python/Go/etc.) are recognised automatically on `/v1/traces` and additionally
74
+ stored per call in `runtime_llm_calls`: provider, model, operation, input/
75
+ output tokens, duration, ok/error status (with the provider exception type
76
+ in `error_type` for failed calls), and a USD cost. The cost is taken
77
+ from the `autter.llm.cost_usd` span attribute when reported; otherwise it's
78
+ estimated from the built-in price table in `src/llm-pricing.ts`
79
+ (`cost_source` records which: `reported` / `estimated` / `unpriced`). An
80
+ opaque calling-user id is read from `autter.user_id` (or the AI SDK's
81
+ `metadata.userId`). The Vercel AI SDK's outer `ai.generateText`/`ai.streamText`
82
+ spans are not counted — their inner `.doGenerate`/`.doEmbed` call spans carry
83
+ the usage, and counting both would double the tokens.
63
84
 
64
85
  ## Schema migrations
65
86
 
@@ -1,6 +1,6 @@
1
1
  import type { IngesterConfig } from "./config.js";
2
2
  import { type Migration } from "./migrations.js";
3
- import type { IngestContext, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
3
+ import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
4
4
  export declare class ClickHouseStore {
5
5
  private readonly config;
6
6
  private client;
@@ -22,6 +22,7 @@ export declare class ClickHouseStore {
22
22
  ping(): Promise<boolean>;
23
23
  insertOccurrences(ctx: IngestContext, occurrences: RuntimeOccurrence[]): Promise<void>;
24
24
  insertSpans(ctx: IngestContext, spans: RuntimeSpanRow[]): Promise<void>;
25
+ insertLlmCalls(ctx: IngestContext, calls: RuntimeLlmCall[]): Promise<void>;
25
26
  insertMetricPoints(ctx: IngestContext, points: RuntimeMetricPoint[]): Promise<void>;
26
27
  close(): Promise<void>;
27
28
  }
@@ -41,7 +41,7 @@ export class ClickHouseStore {
41
41
  }
42
42
  schemaStatements() {
43
43
  const db = this.config.clickhouseDatabase;
44
- const { occurrenceTtlDays, spanTtlDays, metricsTtlDays } = this.config;
44
+ const { occurrenceTtlDays, spanTtlDays, metricsTtlDays, llmCallTtlDays } = this.config;
45
45
  return [
46
46
  `CREATE DATABASE IF NOT EXISTS ${db}`,
47
47
  `CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
@@ -113,6 +113,34 @@ export class ClickHouseStore {
113
113
  PARTITION BY toYYYYMM(bucket_at)
114
114
  ORDER BY (org_id, repository_id, service, environment, release, route, bucket_at)
115
115
  TTL bucket_at + INTERVAL ${metricsTtlDays} DAY`,
116
+ `CREATE TABLE IF NOT EXISTS ${db}.runtime_llm_calls (
117
+ org_id String,
118
+ repository_id String,
119
+ service LowCardinality(String),
120
+ environment LowCardinality(String),
121
+ release String DEFAULT '',
122
+ trace_id String DEFAULT '',
123
+ span_id String DEFAULT '',
124
+ provider LowCardinality(String) DEFAULT '',
125
+ model LowCardinality(String) DEFAULT '',
126
+ operation LowCardinality(String) DEFAULT '',
127
+ input_tokens UInt64 DEFAULT 0,
128
+ output_tokens UInt64 DEFAULT 0,
129
+ cost_usd Float64 DEFAULT 0,
130
+ cost_source LowCardinality(String) DEFAULT 'none',
131
+ duration_ms Float64 DEFAULT 0,
132
+ status LowCardinality(String) DEFAULT 'ok',
133
+ error_type String DEFAULT '',
134
+ user_id String DEFAULT '',
135
+ session_id String DEFAULT '',
136
+ attributes String DEFAULT '{}' CODEC(ZSTD(1)),
137
+ started_at DateTime64(3, 'UTC'),
138
+ ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
139
+ )
140
+ ENGINE = MergeTree
141
+ PARTITION BY toDate(started_at)
142
+ ORDER BY (org_id, repository_id, started_at)
143
+ TTL toDateTime(started_at) + INTERVAL ${llmCallTtlDays} DAY`,
116
144
  ];
117
145
  }
118
146
  /**
@@ -228,6 +256,39 @@ export class ClickHouseStore {
228
256
  })),
229
257
  });
230
258
  }
259
+ async insertLlmCalls(ctx, calls) {
260
+ if (calls.length === 0 || !this.configured)
261
+ return;
262
+ await this.ensureSchema();
263
+ await this.getClient().insert({
264
+ table: this.table("runtime_llm_calls"),
265
+ format: "JSONEachRow",
266
+ clickhouse_settings: INSERT_SETTINGS,
267
+ values: calls.map((c) => ({
268
+ org_id: ctx.orgId,
269
+ repository_id: ctx.repositoryId,
270
+ service: c.service,
271
+ environment: c.environment,
272
+ release: c.release ?? "",
273
+ trace_id: c.traceId,
274
+ span_id: c.spanId,
275
+ provider: c.provider,
276
+ model: c.model,
277
+ operation: c.operation,
278
+ input_tokens: Math.max(0, Math.round(c.inputTokens)),
279
+ output_tokens: Math.max(0, Math.round(c.outputTokens)),
280
+ cost_usd: c.costUsd,
281
+ cost_source: c.costSource,
282
+ duration_ms: c.durationMs,
283
+ status: c.status,
284
+ error_type: c.errorType,
285
+ user_id: c.userId,
286
+ session_id: c.sessionId,
287
+ attributes: JSON.stringify(c.attributes ?? {}),
288
+ started_at: c.startedAt.toISOString(),
289
+ })),
290
+ });
291
+ }
231
292
  async insertMetricPoints(ctx, points) {
232
293
  if (points.length === 0 || !this.configured)
233
294
  return;
package/dist/config.d.ts CHANGED
@@ -22,6 +22,11 @@ export interface IngesterConfig {
22
22
  /** Optional webhook receiving fingerprinted occurrences for issue grouping. */
23
23
  sinkUrl: string | null;
24
24
  sinkToken: string | null;
25
+ /** Sink delivery attempts per batch before giving up (backoff-capped ~8 min). */
26
+ sinkMaxAttempts: number;
27
+ /** Bounds for the in-memory sink retry buffer; oldest batches drop first. */
28
+ sinkMaxBufferedBatches: number;
29
+ sinkMaxBufferedMb: number;
25
30
  maxBodyBytes: number;
26
31
  /** Per-key requests per minute (server keys). */
27
32
  rateLimitPerMinute: number;
@@ -31,5 +36,6 @@ export interface IngesterConfig {
31
36
  occurrenceTtlDays: number;
32
37
  spanTtlDays: number;
33
38
  metricsTtlDays: number;
39
+ llmCallTtlDays: number;
34
40
  }
35
41
  export declare function loadConfig(): IngesterConfig;
package/dist/config.js CHANGED
@@ -34,12 +34,20 @@ export function loadConfig() {
34
34
  keyValidatorToken: process.env.AUTTER_KEY_VALIDATOR_TOKEN || null,
35
35
  sinkUrl: process.env.AUTTER_SINK_URL || null,
36
36
  sinkToken: process.env.AUTTER_SINK_TOKEN || null,
37
+ // 12 attempts with 1s..60s exponential backoff spans ~8 minutes — long
38
+ // enough to ride out a routine consumer deploy without unbounded memory.
39
+ sinkMaxAttempts: intEnv("SINK_MAX_ATTEMPTS", 12),
40
+ sinkMaxBufferedBatches: intEnv("SINK_MAX_BUFFERED_BATCHES", 1000),
41
+ sinkMaxBufferedMb: intEnv("SINK_MAX_BUFFERED_MB", 64),
37
42
  maxBodyBytes: intEnv("MAX_BODY_BYTES", 1024 * 1024),
38
43
  rateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 300),
39
44
  clientRateLimitPerMinute: intEnv("CLIENT_RATE_LIMIT_PER_MINUTE", 120),
40
45
  occurrenceTtlDays: intEnv("OCCURRENCE_TTL_DAYS", 14),
41
46
  spanTtlDays: intEnv("SPAN_TTL_DAYS", 7),
42
47
  metricsTtlDays: intEnv("METRICS_TTL_DAYS", 90),
48
+ // LLM calls keep the metrics horizon, not the span one — cost trends
49
+ // need months, and per-call volume is small next to HTTP spans.
50
+ llmCallTtlDays: intEnv("LLM_CALL_TTL_DAYS", 90),
43
51
  };
44
52
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(config.clickhouseDatabase)) {
45
53
  throw new Error(`Invalid CLICKHOUSE_DATABASE name: ${config.clickhouseDatabase}`);
@@ -4,6 +4,28 @@ export declare function normalizeMessage(message: string): string;
4
4
  export declare function normalizeRoute(route: string | null): string;
5
5
  export declare function normalizeStackFrames(stack: string | null, topN?: number): string[];
6
6
  export declare function fingerprintOccurrence(input: RuntimeOccurrenceInput): string;
7
+ /**
8
+ * Deterministic per-occurrence identity (as opposed to the fingerprint,
9
+ * which is the per-ISSUE identity shared by every occurrence of a defect).
10
+ *
11
+ * The id must be a pure function of the signal, not a fresh UUID per
12
+ * request: OTLP exporters retry whole batches (after a 503 from a partial
13
+ * ClickHouse write, or when only the 2xx was lost), and both the ClickHouse
14
+ * rows and the sink consumer's dedupe ledger key on this id — random ids
15
+ * would turn every transport retry into a duplicate error downstream.
16
+ *
17
+ * Identical signals within one batch stay distinct through their batch
18
+ * position, which is stable across retries because exporters re-send the
19
+ * same serialized batch. Residual coalescing risk: two occurrences from
20
+ * DIFFERENT requests that share the same millisecond, message, and batch
21
+ * position while carrying neither a traceId nor a sessionId — accepted, as
22
+ * server signals virtually always carry a traceId and browser signals a
23
+ * sessionId.
24
+ */
25
+ export declare function occurrenceIdFor(scope: {
26
+ orgId: string;
27
+ repositoryId: string;
28
+ }, input: RuntimeOccurrenceInput, fingerprint: string, batchIndex: number): string;
7
29
  /**
8
30
  * Derived, aggregation-ready fields, computed from the SAME normalisers the
9
31
  * fingerprint hashes — so a stored fingerprint can always be explained by
@@ -13,12 +13,22 @@ const LONG_HEX_RE = /\b[0-9a-f]{8,}\b/gi;
13
13
  // template too, or per-value messages fragment into separate fingerprints.
14
14
  const NUMBER_RE = /\b\d+(\.\d+)?/g;
15
15
  const QUOTED_RE = /(["'`])(?:\\.|(?!\1).)*\1/g;
16
+ // `\b` never fires between `_` and a digit (both are word chars), so
17
+ // underscore-glued ids — "prj_1013", "user_42", "order_9f3ac2d144" — escape
18
+ // NUMBER_RE/LONG_HEX_RE entirely and fragment one defect into an issue per
19
+ // id. Template the value after the underscore explicitly. (Letter-glued
20
+ // digits like "sha256"/"utf8" stay literal on purpose — those are usually
21
+ // meaningful tokens, not per-entity ids.)
22
+ const UNDERSCORE_HEX_RE = /_[0-9a-f]{8,}\b/gi;
23
+ const UNDERSCORE_NUMBER_RE = /_\d+(\.\d+)?\b/g;
16
24
  export function normalizeMessage(message) {
17
25
  return message
18
26
  .slice(0, 500)
19
27
  .replace(QUOTED_RE, "<str>")
20
28
  .replace(UUID_RE, "<uuid>")
21
29
  .replace(LONG_HEX_RE, "<hex>")
30
+ .replace(UNDERSCORE_HEX_RE, "_<hex>")
31
+ .replace(UNDERSCORE_NUMBER_RE, "_<n>")
22
32
  .replace(NUMBER_RE, "<n>")
23
33
  .replace(/\s+/g, " ")
24
34
  .trim();
@@ -44,11 +54,125 @@ export function normalizeRoute(route) {
44
54
  .join("/");
45
55
  }
46
56
  const FRAME_LOCATION_RE = /:\d+(:\d+)?\)?$/;
47
- export function normalizeStackFrames(stack, topN = 5) {
48
- if (!stack)
49
- return [];
50
- return stack
51
- .split("\n")
57
+ // A Go location line: "\t<file>.go:<line> +0x<off>" (offset optional).
58
+ const GO_LOCATION_RE = /^\s*(.+\.go):\d+(?:\s+\+0x[0-9a-f]+)?\s*$/;
59
+ const GO_GOROUTINE_RE = /\bgoroutine \d+ \[/;
60
+ // A .NET frame with a source location: "at <method>(...) in <file>:line <n>".
61
+ const DOTNET_FRAME_RE = /^\s*at\s+.+\)\s+in\s+.+:line\s+\d+\s*$/i;
62
+ const DOTNET_CS_RE = /\.cs:line\s+\d+/i;
63
+ // A JVM frame: "at <fqmethod>(<File>.java:<line>)" / "(Native Method)".
64
+ const JVM_FRAME_RE = /^\s*at\s+[\w$.]+(?:\/[\w$.]+)?\(.*\.(?:java|kt|scala|groovy):\d+\)\s*$/;
65
+ const JVM_NATIVE_RE = /\((?:Native Method|Unknown Source)\)\s*$/;
66
+ // A Rust backtrace frame (" 3: my::mod::func") or its "at <file>.rs:<n>" line.
67
+ const RUST_FRAME_RE = /^\s*\d+:\s+(?:0x[0-9a-f]+\s+-\s+)?\S+::\S/;
68
+ const RUST_AT_RS_RE = /^\s*at\s+\S+\.rs:\d+/;
69
+ /**
70
+ * Classify a whole stack by language. Signatures are chosen to be unique to
71
+ * each runtime so a JS or Python stack always falls through to "script".
72
+ */
73
+ function detectStackLanguage(lines) {
74
+ let hasDotnet = false;
75
+ let hasJvm = false;
76
+ let hasRust = false;
77
+ for (const line of lines) {
78
+ if (GO_LOCATION_RE.test(line) || GO_GOROUTINE_RE.test(line))
79
+ return "go";
80
+ if (DOTNET_FRAME_RE.test(line) || DOTNET_CS_RE.test(line))
81
+ hasDotnet = true;
82
+ if (JVM_FRAME_RE.test(line) || JVM_NATIVE_RE.test(line))
83
+ hasJvm = true;
84
+ if (RUST_FRAME_RE.test(line) ||
85
+ RUST_AT_RS_RE.test(line) ||
86
+ line.trim() === "stack backtrace:")
87
+ hasRust = true;
88
+ }
89
+ // .NET and JVM frames both start with "at"; decide by the location marker
90
+ // each detector matched (`.cs`/`:line` vs `.java`/Native Method).
91
+ if (hasDotnet)
92
+ return "dotnet";
93
+ if (hasJvm)
94
+ return "jvm";
95
+ if (hasRust)
96
+ return "rust";
97
+ return "script";
98
+ }
99
+ /** Drop the trailing call-argument group, e.g. "f(0x1, 0x2)" → "f". */
100
+ function stripTrailingArgs(fn) {
101
+ return fn.replace(/\([^()]*\)\s*$/, "").trim();
102
+ }
103
+ function cleanGoFunc(fn) {
104
+ return stripTrailingArgs(fn.replace(/^created by\s+/, "").replace(/\s+in goroutine \d+\s*$/, ""));
105
+ }
106
+ /** Go: a function line followed by a "\t<file>.go:<line> +0x<off>" location. */
107
+ function parseGoFrames(lines, topN) {
108
+ const frames = [];
109
+ let prevFunc = "";
110
+ for (const line of lines) {
111
+ const loc = GO_LOCATION_RE.exec(line);
112
+ if (loc && prevFunc) {
113
+ frames.push(`${cleanGoFunc(prevFunc)} (${loc[1].trim()})`);
114
+ if (frames.length >= topN)
115
+ break;
116
+ prevFunc = "";
117
+ continue;
118
+ }
119
+ const trimmed = line.trim();
120
+ if (trimmed && !GO_GOROUTINE_RE.test(trimmed))
121
+ prevFunc = trimmed;
122
+ }
123
+ return frames;
124
+ }
125
+ /** Rust: " N: module::func" optionally followed by " at <file>:<line>:<col>". */
126
+ function parseRustFrames(lines, topN) {
127
+ const frames = [];
128
+ for (let i = 0; i < lines.length && frames.length < topN; i++) {
129
+ const m = /^\s*\d+:\s+(?:0x[0-9a-f]+\s+-\s+)?(.+?)\s*$/.exec(lines[i]);
130
+ if (!m)
131
+ continue;
132
+ const fn = m[1].replace(/::h[0-9a-f]{6,}$/, "").trim();
133
+ if (!fn)
134
+ continue;
135
+ const at = /^\s*at\s+(\S+?):\d+(?::\d+)?\s*$/.exec(lines[i + 1] ?? "");
136
+ if (at)
137
+ i++;
138
+ frames.push(at ? `${fn} (${at[1]})` : fn);
139
+ }
140
+ return frames;
141
+ }
142
+ /** JVM (Java/Kotlin/Scala): "\tat <fqmethod>(<File>:<line>)". */
143
+ function parseJvmFrames(lines, topN) {
144
+ const frames = [];
145
+ for (const line of lines) {
146
+ const m = /^\s*at\s+(.+?)\((.*)\)\s*$/.exec(line);
147
+ if (!m)
148
+ continue;
149
+ frames.push(`${m[1].trim()}(${m[2].trim().replace(/:\d+$/, "")})`);
150
+ if (frames.length >= topN)
151
+ break;
152
+ }
153
+ return frames;
154
+ }
155
+ /** .NET: " at <method>(<params>) in <file>:line <n>" (location optional). */
156
+ function parseDotnetFrames(lines, topN) {
157
+ const frames = [];
158
+ for (const line of lines) {
159
+ const m = /^\s*at\s+(.+?)(?:\s+in\s+(.+?):line\s+\d+)?\s*$/.exec(line);
160
+ if (!m)
161
+ continue;
162
+ const method = stripTrailingArgs(m[1]);
163
+ const file = m[2]?.trim() ?? "";
164
+ frames.push(file ? `${method} (${file})` : method);
165
+ if (frames.length >= topN)
166
+ break;
167
+ }
168
+ return frames;
169
+ }
170
+ /**
171
+ * Historical JS/TS/Firefox/Python normalisation — output is intentionally
172
+ * unchanged so pre-existing issues in those runtimes keep their fingerprints.
173
+ */
174
+ function parseScriptFrames(lines, topN) {
175
+ return lines
52
176
  .map((line) => line.trim())
53
177
  .filter((line) => /^at\s|@|^\s*File\s/.test(line) || /\.[jt]sx?/.test(line))
54
178
  .slice(0, topN)
@@ -58,6 +182,57 @@ export function normalizeStackFrames(stack, topN = 5) {
58
182
  .replace(/\s+/g, " ")
59
183
  .trim());
60
184
  }
185
+ /**
186
+ * Safe fallback for a non-empty stack we could not parse into frames (an
187
+ * unsupported runtime, or a malformed one). Rather than discard everything —
188
+ * which collapses every same-message error into one issue — derive a stable
189
+ * signature from any structurally frame-like lines, with volatile tokens
190
+ * (addresses, offsets, line/column numbers) templated out so the SAME defect
191
+ * still groups across occurrences. When there is no frame-like structure at
192
+ * all we return nothing, exactly as before, and grouping falls back to the
193
+ * message + service + error type.
194
+ */
195
+ function fallbackFrames(lines, topN) {
196
+ const framey = lines
197
+ .map((line) => line.trim())
198
+ .filter((line) => /(?:[/\\]|\.\w+)\S*[:(]\d+/.test(line) ||
199
+ /\b0x[0-9a-f]+/i.test(line) ||
200
+ /^(?:at|from)\b/.test(line) ||
201
+ /^\d+:\s/.test(line));
202
+ if (framey.length === 0)
203
+ return [];
204
+ return framey
205
+ .map((line) => line
206
+ .replace(/0x[0-9a-f]+/gi, "0x")
207
+ .replace(/:\d+(:\d+)?\b/g, "")
208
+ .replace(/\s+/g, " ")
209
+ .trim())
210
+ .filter(Boolean)
211
+ .slice(0, topN);
212
+ }
213
+ export function normalizeStackFrames(stack, topN = 5) {
214
+ if (!stack)
215
+ return [];
216
+ const lines = stack.split("\n");
217
+ let frames;
218
+ switch (detectStackLanguage(lines)) {
219
+ case "go":
220
+ frames = parseGoFrames(lines, topN);
221
+ break;
222
+ case "rust":
223
+ frames = parseRustFrames(lines, topN);
224
+ break;
225
+ case "jvm":
226
+ frames = parseJvmFrames(lines, topN);
227
+ break;
228
+ case "dotnet":
229
+ frames = parseDotnetFrames(lines, topN);
230
+ break;
231
+ default:
232
+ frames = parseScriptFrames(lines, topN);
233
+ }
234
+ return frames.length > 0 ? frames : fallbackFrames(lines, topN);
235
+ }
61
236
  export function fingerprintOccurrence(input) {
62
237
  const parts = [
63
238
  input.source,
@@ -69,6 +244,42 @@ export function fingerprintOccurrence(input) {
69
244
  ];
70
245
  return createHash("sha256").update(parts.join(" ")).digest("hex").slice(0, 32);
71
246
  }
247
+ /**
248
+ * Deterministic per-occurrence identity (as opposed to the fingerprint,
249
+ * which is the per-ISSUE identity shared by every occurrence of a defect).
250
+ *
251
+ * The id must be a pure function of the signal, not a fresh UUID per
252
+ * request: OTLP exporters retry whole batches (after a 503 from a partial
253
+ * ClickHouse write, or when only the 2xx was lost), and both the ClickHouse
254
+ * rows and the sink consumer's dedupe ledger key on this id — random ids
255
+ * would turn every transport retry into a duplicate error downstream.
256
+ *
257
+ * Identical signals within one batch stay distinct through their batch
258
+ * position, which is stable across retries because exporters re-send the
259
+ * same serialized batch. Residual coalescing risk: two occurrences from
260
+ * DIFFERENT requests that share the same millisecond, message, and batch
261
+ * position while carrying neither a traceId nor a sessionId — accepted, as
262
+ * server signals virtually always carry a traceId and browser signals a
263
+ * sessionId.
264
+ */
265
+ export function occurrenceIdFor(scope, input, fingerprint, batchIndex) {
266
+ const parts = [
267
+ "v1",
268
+ scope.orgId,
269
+ scope.repositoryId,
270
+ fingerprint,
271
+ String(input.occurredAt.getTime()),
272
+ input.traceId ?? "",
273
+ input.sessionId ?? "",
274
+ input.message.slice(0, 1000),
275
+ String(batchIndex),
276
+ ];
277
+ // NUL-joined so a free-text field can never bleed into its neighbour.
278
+ return createHash("sha256")
279
+ .update(parts.join("\u0000"))
280
+ .digest("hex")
281
+ .slice(0, 32);
282
+ }
72
283
  export function deriveFields(input) {
73
284
  const topFrames = normalizeStackFrames(input.stack);
74
285
  return {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { createIngesterApp } from "./server.js";
2
2
  export { loadConfig } from "./config.js";
3
+ export { SinkForwarder, type SinkStats, type SinkTuning } from "./sink.js";
3
4
  export * from "./types.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from "./config.js";
2
2
  import { createIngesterApp } from "./server.js";
3
3
  const config = loadConfig();
4
- const { app, store } = createIngesterApp(config);
4
+ const { app, store, sink } = createIngesterApp(config);
5
5
  const server = app.listen(config.port, () => {
6
6
  console.log(`autter otlp-ingester listening on :${config.port} ` +
7
7
  `(clickhouse: ${config.clickhouseUrl ? "configured" : "NOT configured"})`);
@@ -14,6 +14,15 @@ if (store.configured) {
14
14
  }
15
15
  async function shutdown(signal) {
16
16
  console.log(`${signal} received, shutting down`);
17
+ if (sink) {
18
+ const pending = sink.pendingCount();
19
+ sink.stop();
20
+ if (pending > 0) {
21
+ // The retry buffer is memory-only; everything in it is already in
22
+ // ClickHouse, so the consumer's reconciliation replays it.
23
+ console.warn(`${pending} sink batch(es) undelivered at shutdown — recoverable via ClickHouse replay`);
24
+ }
25
+ }
17
26
  server.close(() => {
18
27
  void store.close().finally(() => process.exit(0));
19
28
  });
@@ -23,4 +32,5 @@ process.on("SIGTERM", () => void shutdown("SIGTERM"));
23
32
  process.on("SIGINT", () => void shutdown("SIGINT"));
24
33
  export { createIngesterApp } from "./server.js";
25
34
  export { loadConfig } from "./config.js";
35
+ export { SinkForwarder } from "./sink.js";
26
36
  export * from "./types.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Built-in USD price estimates for common LLM models, per 1M tokens.
3
+ *
4
+ * Estimation happens once at ingest so cost queries never re-price history
5
+ * (a price change should not rewrite last month's spend). The table is a
6
+ * best-effort default: SDKs can always report the exact figure via the
7
+ * `autter.llm.cost_usd` span attribute, which takes precedence.
8
+ *
9
+ * Matching is longest-prefix-wins on a normalised model id (lowercased,
10
+ * provider/router prefixes stripped), so `claude-sonnet-4-20250514` matches
11
+ * the `claude-sonnet-4` entry and gateway ids like `openai/gpt-5-mini`
12
+ * match `gpt-5-mini`. Unknown models estimate to 0 with costSource "none" —
13
+ * tokens are still tracked, and dashboards can flag unpriced volume.
14
+ */
15
+ export declare function normalizeModelId(model: string): string;
16
+ /**
17
+ * Estimate the USD cost of one call, or null when the model is unknown.
18
+ * Rates are per 1M tokens; token counts of 0 legitimately price to 0.
19
+ */
20
+ export declare function estimateLlmCostUsd(model: string, inputTokens: number, outputTokens: number): number | null;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Built-in USD price estimates for common LLM models, per 1M tokens.
3
+ *
4
+ * Estimation happens once at ingest so cost queries never re-price history
5
+ * (a price change should not rewrite last month's spend). The table is a
6
+ * best-effort default: SDKs can always report the exact figure via the
7
+ * `autter.llm.cost_usd` span attribute, which takes precedence.
8
+ *
9
+ * Matching is longest-prefix-wins on a normalised model id (lowercased,
10
+ * provider/router prefixes stripped), so `claude-sonnet-4-20250514` matches
11
+ * the `claude-sonnet-4` entry and gateway ids like `openai/gpt-5-mini`
12
+ * match `gpt-5-mini`. Unknown models estimate to 0 with costSource "none" —
13
+ * tokens are still tracked, and dashboards can flag unpriced volume.
14
+ */
15
+ // Keep more-specific prefixes ABOVE their generic fallback (the list is
16
+ // scanned for the longest match, but equal-length ties resolve by order).
17
+ const MODEL_PRICING = [
18
+ // OpenAI
19
+ { prefix: "gpt-5-nano", inputPerMTok: 0.05, outputPerMTok: 0.4 },
20
+ { prefix: "gpt-5-mini", inputPerMTok: 0.25, outputPerMTok: 2 },
21
+ { prefix: "gpt-5", inputPerMTok: 1.25, outputPerMTok: 10 },
22
+ { prefix: "gpt-4.1-nano", inputPerMTok: 0.1, outputPerMTok: 0.4 },
23
+ { prefix: "gpt-4.1-mini", inputPerMTok: 0.4, outputPerMTok: 1.6 },
24
+ { prefix: "gpt-4.1", inputPerMTok: 2, outputPerMTok: 8 },
25
+ { prefix: "gpt-4o-mini", inputPerMTok: 0.15, outputPerMTok: 0.6 },
26
+ { prefix: "gpt-4o", inputPerMTok: 2.5, outputPerMTok: 10 },
27
+ { prefix: "o3-mini", inputPerMTok: 1.1, outputPerMTok: 4.4 },
28
+ { prefix: "o3", inputPerMTok: 2, outputPerMTok: 8 },
29
+ { prefix: "o4-mini", inputPerMTok: 1.1, outputPerMTok: 4.4 },
30
+ { prefix: "text-embedding-3-small", inputPerMTok: 0.02, outputPerMTok: 0 },
31
+ { prefix: "text-embedding-3-large", inputPerMTok: 0.13, outputPerMTok: 0 },
32
+ // Anthropic
33
+ { prefix: "claude-opus-4-5", inputPerMTok: 5, outputPerMTok: 25 },
34
+ { prefix: "claude-opus-4-8", inputPerMTok: 5, outputPerMTok: 25 },
35
+ { prefix: "claude-opus-4", inputPerMTok: 15, outputPerMTok: 75 },
36
+ { prefix: "claude-sonnet-4", inputPerMTok: 3, outputPerMTok: 15 },
37
+ { prefix: "claude-sonnet", inputPerMTok: 3, outputPerMTok: 15 },
38
+ { prefix: "claude-haiku-4", inputPerMTok: 1, outputPerMTok: 5 },
39
+ { prefix: "claude-3-5-haiku", inputPerMTok: 0.8, outputPerMTok: 4 },
40
+ { prefix: "claude-haiku", inputPerMTok: 1, outputPerMTok: 5 },
41
+ // Google
42
+ { prefix: "gemini-2.5-pro", inputPerMTok: 1.25, outputPerMTok: 10 },
43
+ { prefix: "gemini-2.5-flash-lite", inputPerMTok: 0.1, outputPerMTok: 0.4 },
44
+ { prefix: "gemini-2.5-flash", inputPerMTok: 0.3, outputPerMTok: 2.5 },
45
+ { prefix: "gemini-2.0-flash", inputPerMTok: 0.1, outputPerMTok: 0.4 },
46
+ // DeepSeek
47
+ { prefix: "deepseek-chat", inputPerMTok: 0.27, outputPerMTok: 1.1 },
48
+ { prefix: "deepseek-reasoner", inputPerMTok: 0.55, outputPerMTok: 2.19 },
49
+ // Mistral
50
+ { prefix: "mistral-large", inputPerMTok: 2, outputPerMTok: 6 },
51
+ { prefix: "mistral-small", inputPerMTok: 0.1, outputPerMTok: 0.3 },
52
+ // Meta (typical hosted-inference rates)
53
+ { prefix: "llama-3.1-405b", inputPerMTok: 3, outputPerMTok: 3 },
54
+ { prefix: "llama-3.1-70b", inputPerMTok: 0.6, outputPerMTok: 0.6 },
55
+ { prefix: "llama-3.1-8b", inputPerMTok: 0.1, outputPerMTok: 0.1 },
56
+ { prefix: "llama-3.3-70b", inputPerMTok: 0.6, outputPerMTok: 0.6 },
57
+ // xAI
58
+ { prefix: "grok-4", inputPerMTok: 3, outputPerMTok: 15 },
59
+ { prefix: "grok-3-mini", inputPerMTok: 0.3, outputPerMTok: 0.5 },
60
+ { prefix: "grok-3", inputPerMTok: 3, outputPerMTok: 15 },
61
+ ];
62
+ // Router/provider prefixes seen in the wild ahead of the bare model id:
63
+ // gateway ids ("openai/gpt-5"), Azure deployments ("azure/gpt-4o"), Bedrock
64
+ // ("us.anthropic.claude-sonnet-4-...-v1:0"), Vertex ("models/gemini-2.5-pro").
65
+ const STRIP_PREFIXES = [
66
+ "openai/",
67
+ "azure/",
68
+ "anthropic/",
69
+ "google/",
70
+ "vertex_ai/",
71
+ "vertex/",
72
+ "bedrock/",
73
+ "groq/",
74
+ "xai/",
75
+ "mistral/",
76
+ "meta/",
77
+ "meta-llama/",
78
+ "deepseek/",
79
+ "models/",
80
+ "us.",
81
+ "eu.",
82
+ "apac.",
83
+ "anthropic.",
84
+ "amazon.",
85
+ ];
86
+ export function normalizeModelId(model) {
87
+ let id = model.trim().toLowerCase();
88
+ let stripped = true;
89
+ while (stripped) {
90
+ stripped = false;
91
+ for (const prefix of STRIP_PREFIXES) {
92
+ if (id.length > prefix.length && id.startsWith(prefix)) {
93
+ id = id.slice(prefix.length);
94
+ stripped = true;
95
+ }
96
+ }
97
+ }
98
+ return id;
99
+ }
100
+ /**
101
+ * Estimate the USD cost of one call, or null when the model is unknown.
102
+ * Rates are per 1M tokens; token counts of 0 legitimately price to 0.
103
+ */
104
+ export function estimateLlmCostUsd(model, inputTokens, outputTokens) {
105
+ if (!model)
106
+ return null;
107
+ const id = normalizeModelId(model);
108
+ let best = null;
109
+ for (const entry of MODEL_PRICING) {
110
+ if (!id.startsWith(entry.prefix))
111
+ continue;
112
+ if (!best || entry.prefix.length > best.prefix.length)
113
+ best = entry;
114
+ }
115
+ if (!best)
116
+ return null;
117
+ const cost = (inputTokens / 1_000_000) * best.inputPerMTok +
118
+ (outputTokens / 1_000_000) * best.outputPerMTok;
119
+ return Math.round(cost * 1e6) / 1e6;
120
+ }
package/dist/llm.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { RuntimeLlmCall } from "./types.js";
2
+ export interface LlmSpanFacts {
3
+ name: string;
4
+ traceId: string;
5
+ spanId: string;
6
+ isError: boolean;
7
+ /** exception.type from the span's exception event, if it had one. */
8
+ exceptionType: string | null;
9
+ durationMs: number;
10
+ startedAt: Date;
11
+ service: string;
12
+ environment: string;
13
+ release: string | null;
14
+ }
15
+ /**
16
+ * Returns the LLM-call row for a span, or null when the span is not an LLM
17
+ * provider call. Never throws — attribute soup from arbitrary SDKs must not
18
+ * take down the ingest path.
19
+ */
20
+ export declare function extractLlmCall(attrs: Map<string, string>, facts: LlmSpanFacts): RuntimeLlmCall | null;