@autter/otlp-ingester 0.1.0 → 1.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/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();
@@ -69,6 +79,42 @@ export function fingerprintOccurrence(input) {
69
79
  ];
70
80
  return createHash("sha256").update(parts.join(" ")).digest("hex").slice(0, 32);
71
81
  }
82
+ /**
83
+ * Deterministic per-occurrence identity (as opposed to the fingerprint,
84
+ * which is the per-ISSUE identity shared by every occurrence of a defect).
85
+ *
86
+ * The id must be a pure function of the signal, not a fresh UUID per
87
+ * request: OTLP exporters retry whole batches (after a 503 from a partial
88
+ * ClickHouse write, or when only the 2xx was lost), and both the ClickHouse
89
+ * rows and the sink consumer's dedupe ledger key on this id — random ids
90
+ * would turn every transport retry into a duplicate error downstream.
91
+ *
92
+ * Identical signals within one batch stay distinct through their batch
93
+ * position, which is stable across retries because exporters re-send the
94
+ * same serialized batch. Residual coalescing risk: two occurrences from
95
+ * DIFFERENT requests that share the same millisecond, message, and batch
96
+ * position while carrying neither a traceId nor a sessionId — accepted, as
97
+ * server signals virtually always carry a traceId and browser signals a
98
+ * sessionId.
99
+ */
100
+ export function occurrenceIdFor(scope, input, fingerprint, batchIndex) {
101
+ const parts = [
102
+ "v1",
103
+ scope.orgId,
104
+ scope.repositoryId,
105
+ fingerprint,
106
+ String(input.occurredAt.getTime()),
107
+ input.traceId ?? "",
108
+ input.sessionId ?? "",
109
+ input.message.slice(0, 1000),
110
+ String(batchIndex),
111
+ ];
112
+ // NUL-joined so a free-text field can never bleed into its neighbour.
113
+ return createHash("sha256")
114
+ .update(parts.join("\u0000"))
115
+ .digest("hex")
116
+ .slice(0, 32);
117
+ }
72
118
  export function deriveFields(input) {
73
119
  const topFrames = normalizeStackFrames(input.stack);
74
120
  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;
package/dist/llm.js ADDED
@@ -0,0 +1,131 @@
1
+ import { estimateLlmCostUsd } from "./llm-pricing.js";
2
+ /**
3
+ * LLM-call extraction from OTLP spans. Three attribute families are
4
+ * recognised, so most stacks work with zero Autter-specific code:
5
+ *
6
+ * 1. OpenTelemetry GenAI semantic conventions (`gen_ai.*`) — emitted by
7
+ * OpenLLMetry, OpenLIT, the official OTel instrumentations, and
8
+ * @autter/runtime-node's own `withLlmCall`/`trackLlmCall` helpers.
9
+ * 2. Vercel AI SDK telemetry (`ai.*` spans from `experimental_telemetry`).
10
+ * Only the provider-level `.doGenerate`/`.doStream`/`.doEmbed` spans
11
+ * become calls — the umbrella `ai.generateText` span aggregates its
12
+ * children's usage and would double-count.
13
+ * 3. Autter extension attributes (`autter.llm.cost_usd`, `autter.user_id`,
14
+ * `autter.session_id`) for exact costs and user attribution.
15
+ *
16
+ * Cost precedence: reported (`autter.llm.cost_usd`/`gen_ai.usage.cost`) →
17
+ * estimated from the built-in pricing table → 0 with costSource "none".
18
+ */
19
+ const VERCEL_PROVIDER_CALL = /\.do(Generate|Stream|Embed)$/;
20
+ function numAttr(attrs, ...keys) {
21
+ for (const key of keys) {
22
+ const raw = attrs.get(key);
23
+ if (raw === undefined)
24
+ continue;
25
+ const value = Number(raw);
26
+ if (Number.isFinite(value))
27
+ return value;
28
+ }
29
+ return null;
30
+ }
31
+ function strAttr(attrs, ...keys) {
32
+ for (const key of keys) {
33
+ const raw = attrs.get(key);
34
+ if (raw !== undefined && raw !== "")
35
+ return raw;
36
+ }
37
+ return null;
38
+ }
39
+ /** "ai.generateText.doGenerate" → "generateText"; "ai.embed.doEmbed" → "embed". */
40
+ function vercelOperation(name) {
41
+ const parts = name.split(".");
42
+ return parts.length >= 2 ? (parts[1] ?? "") : "";
43
+ }
44
+ /**
45
+ * Returns the LLM-call row for a span, or null when the span is not an LLM
46
+ * provider call. Never throws — attribute soup from arbitrary SDKs must not
47
+ * take down the ingest path.
48
+ */
49
+ export function extractLlmCall(attrs, facts) {
50
+ const isVercelSpan = facts.name.startsWith("ai.");
51
+ // Umbrella Vercel spans (ai.generateText, ai.streamText, ai.toolCall, …)
52
+ // are skipped: usage lives on (and aggregates) their .do* children.
53
+ if (isVercelSpan && !VERCEL_PROVIDER_CALL.test(facts.name))
54
+ return null;
55
+ const model = strAttr(attrs, "gen_ai.response.model", "gen_ai.request.model", "ai.response.model", "ai.model.id");
56
+ // gen_ai.provider.name is the current semconv key, gen_ai.system the
57
+ // pre-1.37 one. Vercel's ai.model.provider looks like "openai.chat" —
58
+ // keep the provider segment, the mode is already in `operation`.
59
+ const provider = strAttr(attrs, "gen_ai.provider.name", "gen_ai.system") ??
60
+ strAttr(attrs, "ai.model.provider")?.toLowerCase().split(".")[0] ??
61
+ null;
62
+ // A call must identify at least a model or a provider system — this is
63
+ // what keeps ordinary spans (HTTP, DB, tool calls) out of the table.
64
+ if (!model && !provider)
65
+ return null;
66
+ const inputTokens = numAttr(attrs, "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "ai.usage.promptTokens", "ai.usage.inputTokens",
67
+ // Vercel .doEmbed spans report a single total under ai.usage.tokens.
68
+ "ai.usage.tokens") ?? 0;
69
+ const outputTokens = numAttr(attrs, "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens", "ai.usage.completionTokens", "ai.usage.outputTokens") ?? 0;
70
+ const reportedCost = numAttr(attrs, "autter.llm.cost_usd", "gen_ai.usage.cost", "ai.usage.cost");
71
+ let costUsd = 0;
72
+ let costSource = "none";
73
+ if (reportedCost !== null && reportedCost >= 0) {
74
+ costUsd = reportedCost;
75
+ costSource = "reported";
76
+ }
77
+ else {
78
+ const estimated = estimateLlmCostUsd(model ?? "", inputTokens, outputTokens);
79
+ if (estimated !== null) {
80
+ costUsd = estimated;
81
+ costSource = "estimated";
82
+ }
83
+ }
84
+ const operation = strAttr(attrs, "gen_ai.operation.name") ??
85
+ (isVercelSpan ? vercelOperation(facts.name) : "");
86
+ return {
87
+ service: facts.service,
88
+ environment: facts.environment,
89
+ release: facts.release,
90
+ traceId: facts.traceId,
91
+ spanId: facts.spanId,
92
+ provider: (provider ?? "").slice(0, 100),
93
+ model: (model ?? "").slice(0, 200),
94
+ operation: operation.slice(0, 100),
95
+ inputTokens: Math.max(0, Math.round(inputTokens)),
96
+ outputTokens: Math.max(0, Math.round(outputTokens)),
97
+ costUsd,
98
+ costSource,
99
+ durationMs: facts.durationMs,
100
+ status: facts.isError ? "error" : "ok",
101
+ errorType: facts.isError
102
+ ? (strAttr(attrs, "error.type") ?? facts.exceptionType ?? "Error").slice(0, 200)
103
+ : "",
104
+ userId: (strAttr(attrs, "autter.user_id", "ai.telemetry.metadata.userId", "ai.telemetry.metadata.user_id", "enduser.id", "user.id") ?? "").slice(0, 200),
105
+ sessionId: (strAttr(attrs, "autter.session_id", "ai.telemetry.metadata.sessionId", "session.id") ?? "").slice(0, 200),
106
+ attributes: llmAttributeSubset(attrs),
107
+ startedAt: facts.startedAt,
108
+ };
109
+ }
110
+ /**
111
+ * Drill-down context stored as JSON alongside the typed columns: only the
112
+ * LLM-relevant attribute families, size-capped so a prompt accidentally
113
+ * stuffed into an attribute can't bloat the row.
114
+ */
115
+ function llmAttributeSubset(attrs) {
116
+ const subset = {};
117
+ let size = 0;
118
+ for (const [key, value] of attrs) {
119
+ if (!key.startsWith("gen_ai.") &&
120
+ !key.startsWith("ai.") &&
121
+ !key.startsWith("autter.llm.")) {
122
+ continue;
123
+ }
124
+ const trimmed = value.length > 500 ? `${value.slice(0, 500)}…` : value;
125
+ size += key.length + trimmed.length;
126
+ if (size > 4000)
127
+ break;
128
+ subset[key] = trimmed;
129
+ }
130
+ return Object.keys(subset).length > 0 ? subset : null;
131
+ }
@@ -76,6 +76,43 @@ export const MIGRATIONS = [
76
76
  MODIFY COLUMN attributes String DEFAULT '{}' CODEC(ZSTD(1))`,
77
77
  ],
78
78
  },
79
+ // LLM observability: one row per provider call (model, tokens, cost,
80
+ // latency, user) extracted from GenAI/Vercel-AI/Autter span attributes.
81
+ // TTL is fixed at the 90-day default here; deployments that override
82
+ // LLM_CALL_TTL_DAYS get it applied on fresh databases via the baseline.
83
+ {
84
+ id: "0004-llm-calls",
85
+ statements: [
86
+ `CREATE TABLE IF NOT EXISTS {db}.runtime_llm_calls (
87
+ org_id String,
88
+ repository_id String,
89
+ service LowCardinality(String),
90
+ environment LowCardinality(String),
91
+ release String DEFAULT '',
92
+ trace_id String DEFAULT '',
93
+ span_id String DEFAULT '',
94
+ provider LowCardinality(String) DEFAULT '',
95
+ model LowCardinality(String) DEFAULT '',
96
+ operation LowCardinality(String) DEFAULT '',
97
+ input_tokens UInt64 DEFAULT 0,
98
+ output_tokens UInt64 DEFAULT 0,
99
+ cost_usd Float64 DEFAULT 0,
100
+ cost_source LowCardinality(String) DEFAULT 'none',
101
+ duration_ms Float64 DEFAULT 0,
102
+ status LowCardinality(String) DEFAULT 'ok',
103
+ error_type String DEFAULT '',
104
+ user_id String DEFAULT '',
105
+ session_id String DEFAULT '',
106
+ attributes String DEFAULT '{}' CODEC(ZSTD(1)),
107
+ started_at DateTime64(3, 'UTC'),
108
+ ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
109
+ )
110
+ ENGINE = MergeTree
111
+ PARTITION BY toDate(started_at)
112
+ ORDER BY (org_id, repository_id, started_at)
113
+ TTL toDateTime(started_at) + INTERVAL 90 DAY`,
114
+ ],
115
+ },
79
116
  ];
80
117
  /** The tracking table itself — created by the runner before anything else. */
81
118
  export function migrationsTableDDL(db) {