@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/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) {
@@ -43,6 +43,27 @@ const TYPE_TO_ERROR_TYPE = {
43
43
  unhandled_rejection: "UnhandledRejection",
44
44
  message: "Message",
45
45
  };
46
+ // Content-level gate for the free-form `context` bag. The schema whitelist
47
+ // above is structural; this masks obvious PII/secrets inside whatever a
48
+ // (possibly outdated) SDK still sends: values under sensitive-looking keys
49
+ // and email-shaped strings — mirroring redactAttributes() in
50
+ // @autter/runtime-node and redactContext() in @autter/runtime-browser.
51
+ const SENSITIVE_KEY_RE = /email|pass|token|secret|^auth([-_.]|$)|authorization|bearer|cookie|credential|api[-_.]?key|ssn|cvv|card([-_. ]?(number|num|no))?$/i;
52
+ const EMAIL_VALUE_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
53
+ const REDACTED = "[redacted]";
54
+ function scrubContext(context) {
55
+ const out = {};
56
+ for (const [key, value] of Object.entries(context)) {
57
+ if (value === undefined || value === null)
58
+ continue;
59
+ out[key] = SENSITIVE_KEY_RE.test(key)
60
+ ? REDACTED
61
+ : typeof value === "string"
62
+ ? value.replace(EMAIL_VALUE_RE, REDACTED)
63
+ : value;
64
+ }
65
+ return out;
66
+ }
46
67
  /** Default severity per event type when the SDK doesn't say. */
47
68
  const TYPE_TO_SEVERITY = {
48
69
  exception: "error",
@@ -52,12 +73,14 @@ const TYPE_TO_SEVERITY = {
52
73
  export function normalizeBrowserPayload(payload) {
53
74
  const occurrences = [];
54
75
  const rollups = new Map();
55
- function bumpRollup(route, occurredAt, field) {
76
+ function bumpRollup(route, occurredAt, counts) {
56
77
  const bucketAt = new Date(Math.floor(occurredAt.getTime() / 60_000) * 60_000);
57
78
  const key = `${route} ${bucketAt.getTime()}`;
58
79
  const existing = rollups.get(key);
59
80
  if (existing) {
60
- existing[field] += 1;
81
+ existing.requestCount += counts.requestCount ?? 0;
82
+ existing.errorCount += counts.errorCount ?? 0;
83
+ existing.sessionCount += counts.sessionCount ?? 0;
61
84
  return;
62
85
  }
63
86
  rollups.set(key, {
@@ -66,16 +89,16 @@ export function normalizeBrowserPayload(payload) {
66
89
  release: payload.release ?? null,
67
90
  route,
68
91
  bucketAt,
69
- requestCount: field === "requestCount" ? 1 : 0,
70
- errorCount: 0,
92
+ requestCount: counts.requestCount ?? 0,
93
+ errorCount: counts.errorCount ?? 0,
71
94
  durationSumMs: 0,
72
- sessionCount: field === "sessionCount" ? 1 : 0,
95
+ sessionCount: counts.sessionCount ?? 0,
73
96
  });
74
97
  }
75
98
  for (const event of payload.events) {
76
99
  const occurredAt = new Date(event.timestamp);
77
100
  if (event.type === "session_start") {
78
- bumpRollup("", occurredAt, "sessionCount");
101
+ bumpRollup("", occurredAt, { sessionCount: 1 });
79
102
  continue;
80
103
  }
81
104
  // Coarse usage counters: track_event("checkout_opened") becomes a
@@ -83,12 +106,21 @@ export function normalizeBrowserPayload(payload) {
83
106
  if (event.type === "track_event") {
84
107
  const name = (event.name ?? event.message ?? "").slice(0, 200);
85
108
  if (name)
86
- bumpRollup(`event:${name}`, occurredAt, "requestCount");
109
+ bumpRollup(`event:${name}`, occurredAt, { requestCount: 1 });
87
110
  continue;
88
111
  }
112
+ const severity = asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error");
113
+ // The browser has no request stream, so error/message events are the
114
+ // only usage signal for many pages — count each as an event (and, for
115
+ // fatal/error severity, an error event). Without this, a page that
116
+ // only reports errors groups issues whose dashboards all read 0.
117
+ bumpRollup(event.route ? (event.route.split("?")[0] ?? "") : "", occurredAt, {
118
+ requestCount: 1,
119
+ errorCount: severity === "fatal" || severity === "error" ? 1 : 0,
120
+ });
89
121
  occurrences.push({
90
122
  source: "browser",
91
- severity: asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error"),
123
+ severity,
92
124
  service: payload.service,
93
125
  environment: payload.environment,
94
126
  release: payload.release ?? null,
@@ -104,7 +136,7 @@ export function normalizeBrowserPayload(payload) {
104
136
  ...(event.filename ? { filename: event.filename.split("?")[0] } : {}),
105
137
  ...(event.line !== undefined ? { line: event.line } : {}),
106
138
  ...(event.column !== undefined ? { column: event.column } : {}),
107
- ...(event.context ? { context: event.context } : {}),
139
+ ...(event.context ? { context: scrubContext(event.context) } : {}),
108
140
  },
109
141
  occurredAt,
110
142
  });
@@ -1,4 +1,4 @@
1
- import { type RuntimeMetricPoint, type RuntimeOccurrenceInput, type RuntimeSpanRow } from "./types.js";
1
+ import { type RuntimeLlmCall, type RuntimeMetricPoint, type RuntimeOccurrenceInput, type RuntimeSpanRow } from "./types.js";
2
2
  /**
3
3
  * OTLP/HTTP JSON → runtime signal. Structural types cover only the fields
4
4
  * we read (the full OTLP schema is large and versioned; unknown fields pass
@@ -65,6 +65,7 @@ export interface OtlpMetricsRequest {
65
65
  unit?: string;
66
66
  histogram?: {
67
67
  dataPoints?: OtlpDataPoint[];
68
+ aggregationTemporality?: string | number;
68
69
  };
69
70
  sum?: {
70
71
  dataPoints?: OtlpDataPoint[];
@@ -77,6 +78,7 @@ export interface NormalizedTraces {
77
78
  occurrences: RuntimeOccurrenceInput[];
78
79
  spans: RuntimeSpanRow[];
79
80
  metricPoints: RuntimeMetricPoint[];
81
+ llmCalls: RuntimeLlmCall[];
80
82
  spanCount: number;
81
83
  }
82
84
  export declare function normalizeTraces(request: OtlpTraceRequest): NormalizedTraces;
@@ -1,3 +1,5 @@
1
+ import { normalizeRoute } from "./fingerprint.js";
2
+ import { extractLlmCall } from "./llm.js";
1
3
  import { asSeverity, } from "./types.js";
2
4
  function attrMap(attributes) {
3
5
  const map = new Map();
@@ -54,6 +56,7 @@ function resourceInfo(resource) {
54
56
  attrs.get("deployment.environment") ??
55
57
  "production",
56
58
  release: attrs.get("service.version") ?? null,
59
+ metricsWired: attrs.get("autter.metrics_wired") === "true",
57
60
  };
58
61
  }
59
62
  function routeOf(attrs) {
@@ -93,6 +96,7 @@ const MAX_SPANS_PER_REQUEST = 5000;
93
96
  export function normalizeTraces(request) {
94
97
  const occurrences = [];
95
98
  const spans = [];
99
+ const llmCalls = [];
96
100
  const rollups = new Map();
97
101
  let spanCount = 0;
98
102
  for (const resourceSpan of request.resourceSpans ?? []) {
@@ -128,6 +132,7 @@ export function normalizeTraces(request) {
128
132
  });
129
133
  // Error occurrences: one per exception event; if the span is
130
134
  // errored without exception events, one from the span status.
135
+ const occurrencesBefore = occurrences.length;
131
136
  const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
132
137
  for (const event of exceptionEvents) {
133
138
  const eventAttrs = attrMap(event.attributes);
@@ -171,14 +176,39 @@ export function normalizeTraces(request) {
171
176
  occurredAt: startedAt,
172
177
  });
173
178
  }
179
+ // LLM provider calls (GenAI semconv, Vercel AI SDK telemetry,
180
+ // or Autter's own helpers) become usage/cost rows. The first
181
+ // exception event's type rides along so failed calls keep the
182
+ // provider error class even without an `error.type` attribute.
183
+ const llmCall = extractLlmCall(attrs, {
184
+ name: span.name ?? "",
185
+ traceId: span.traceId ?? "",
186
+ spanId: span.spanId ?? "",
187
+ isError,
188
+ exceptionType: exceptionEvents[0]
189
+ ? (attrMap(exceptionEvents[0].attributes).get("exception.type") ??
190
+ null)
191
+ : null,
192
+ durationMs,
193
+ startedAt,
194
+ service: resource.service,
195
+ environment: resource.environment,
196
+ release: resource.release,
197
+ });
198
+ if (llmCall)
199
+ llmCalls.push(llmCall);
174
200
  // Server spans fold into 1-minute usage rollups so traffic is
175
- // tracked even when the metrics pipeline isn't wired.
176
- if (kind === "server") {
201
+ // tracked even when the metrics pipeline isn't wired. Spans
202
+ // exported by error-linked tail retention are skipped: the SDK
203
+ // ships those at ~100% alongside a metrics pipeline that already
204
+ // counts every request, so folding them in would double-count
205
+ // erroring routes.
206
+ if (kind === "server" && attrs.get("autter.tail_retained") !== "true") {
177
207
  addToRollup(rollups, {
178
208
  service: resource.service,
179
209
  environment: resource.environment,
180
210
  release: resource.release,
181
- route: route ?? "",
211
+ route: normalizeRoute(route),
182
212
  bucketAt: minuteBucket(startedAt),
183
213
  requestCount: 1,
184
214
  errorCount: isError ? 1 : 0,
@@ -186,10 +216,37 @@ export function normalizeTraces(request) {
186
216
  sessionCount: 0,
187
217
  });
188
218
  }
219
+ else {
220
+ // Occurrences on non-server spans (captureException's internal
221
+ // error spans, workers, consumers) have no request rollup to
222
+ // ride — count each as one event / one error event, or a
223
+ // service whose errors arrive outside HTTP handlers groups
224
+ // issues while every event counter stays 0. Server spans are
225
+ // excluded: their request rollup above already represents them.
226
+ for (const occ of occurrences.slice(occurrencesBefore)) {
227
+ addToRollup(rollups, {
228
+ service: occ.service,
229
+ environment: occ.environment,
230
+ release: occ.release,
231
+ route: occ.route ?? "",
232
+ bucketAt: minuteBucket(occ.occurredAt),
233
+ requestCount: 1,
234
+ errorCount: occ.severity === "fatal" || occ.severity === "error" ? 1 : 0,
235
+ durationSumMs: 0,
236
+ sessionCount: 0,
237
+ });
238
+ }
239
+ }
189
240
  }
190
241
  }
191
242
  }
192
- return { occurrences, spans, metricPoints: [...rollups.values()], spanCount };
243
+ return {
244
+ occurrences,
245
+ spans,
246
+ metricPoints: [...rollups.values()],
247
+ llmCalls,
248
+ spanCount,
249
+ };
193
250
  }
194
251
  function minuteBucket(date) {
195
252
  return new Date(Math.floor(date.getTime() / 60_000) * 60_000);
@@ -222,6 +279,16 @@ const HTTP_DURATION_INSTRUMENTS = {
222
279
  "http.server.duration": 1,
223
280
  "http.server.request.duration": 1000,
224
281
  };
282
+ /**
283
+ * Cumulative histograms report lifetime totals on every export; adding them
284
+ * into a SummingMergeTree would re-count all past requests each interval.
285
+ * Only deltas are summable — cumulative senders are skipped and covered by
286
+ * the span-fed rollup fallback instead. (Enum arrives as a number or an
287
+ * `AGGREGATION_TEMPORALITY_*` string depending on the serialiser.)
288
+ */
289
+ function isCumulativeTemporality(t) {
290
+ return t === 2 || t === "AGGREGATION_TEMPORALITY_CUMULATIVE";
291
+ }
225
292
  export function normalizeMetrics(request) {
226
293
  const rollups = new Map();
227
294
  for (const resourceMetric of request.resourceMetrics ?? []) {
@@ -233,6 +300,9 @@ export function normalizeMetrics(request) {
233
300
  : undefined;
234
301
  if (multiplier === undefined)
235
302
  continue;
303
+ if (isCumulativeTemporality(metric.histogram?.aggregationTemporality)) {
304
+ continue;
305
+ }
236
306
  for (const dataPoint of metric.histogram?.dataPoints ?? []) {
237
307
  const attrs = attrMap(dataPoint.attributes);
238
308
  const statusCode = statusCodeOf(attrs);
@@ -243,7 +313,11 @@ export function normalizeMetrics(request) {
243
313
  service: resource.service,
244
314
  environment: resource.environment,
245
315
  release: resource.release,
246
- route: routeOf(attrs) ?? "",
316
+ // Same normalization as the span-fed rollups: emitters
317
+ // are supposed to put route templates in `http.route`,
318
+ // but some put raw paths there — keep the key space
319
+ // bounded and consistent across both feeds.
320
+ route: normalizeRoute(routeOf(attrs)),
247
321
  bucketAt: minuteBucket(nanosToDate(dataPoint.timeUnixNano)),
248
322
  requestCount: count,
249
323
  errorCount: statusCode !== null && statusCode >= 500 ? count : 0,
@@ -64,7 +64,7 @@ message NumberDataPoint {
64
64
  sfixed64 as_int = 6;
65
65
  repeated KeyValue attributes = 7;
66
66
  }
67
- message Histogram { repeated HistogramDataPoint data_points = 1; }
67
+ message Histogram { repeated HistogramDataPoint data_points = 1; int32 aggregation_temporality = 2; }
68
68
  message HistogramDataPoint {
69
69
  fixed64 time_unix_nano = 3;
70
70
  fixed64 count = 4;
package/dist/server.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { type Express } from "express";
2
2
  import { ClickHouseStore } from "./clickhouse.js";
3
3
  import type { IngesterConfig } from "./config.js";
4
+ import { SinkForwarder } from "./sink.js";
4
5
  export interface IngesterApp {
5
6
  app: Express;
6
7
  store: ClickHouseStore;
8
+ /** Present when AUTTER_SINK_URL is configured. */
9
+ sink: SinkForwarder | null;
7
10
  }
8
11
  export declare function createIngesterApp(config: IngesterConfig): IngesterApp;
package/dist/server.js CHANGED
@@ -1,13 +1,17 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import express from "express";
3
2
  import { KeyResolver, RateLimiter } from "./auth.js";
4
3
  import { ClickHouseStore } from "./clickhouse.js";
5
- import { deriveFields, fingerprintOccurrence } from "./fingerprint.js";
4
+ import { deriveFields, fingerprintOccurrence, occurrenceIdFor, } from "./fingerprint.js";
6
5
  import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
7
6
  import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
8
7
  import { decodeMetricsRequest, decodeTraceRequest } from "./otlp-proto.js";
8
+ import { SinkForwarder } from "./sink.js";
9
9
  export function createIngesterApp(config) {
10
10
  const store = new ClickHouseStore(config);
11
+ // Fingerprinted occurrences feed the consumer's issue grouping, metric
12
+ // points feed the request/error-rate rollups, LLM calls feed spend
13
+ // watching. Delivery is at-least-once with bounded retries — see sink.ts.
14
+ const sink = config.sinkUrl ? new SinkForwarder(config) : null;
11
15
  const keys = new KeyResolver(config);
12
16
  const serverRateLimiter = new RateLimiter(config.rateLimitPerMinute);
13
17
  const clientRateLimiter = new RateLimiter(config.clientRateLimitPerMinute);
@@ -46,16 +50,19 @@ export function createIngesterApp(config) {
46
50
  next();
47
51
  });
48
52
  app.get("/healthz", async (_req, res) => {
53
+ const sinkStats = sink ? { sink: sink.stats() } : {};
49
54
  if (!store.configured) {
50
- res.status(200).json({ ok: true, clickhouse: "unconfigured" });
55
+ res.status(200).json({ ok: true, clickhouse: "unconfigured", ...sinkStats });
51
56
  return;
52
57
  }
53
58
  try {
54
59
  const ok = await store.ping();
55
- res.status(ok ? 200 : 503).json({ ok, clickhouse: ok ? "up" : "down" });
60
+ res
61
+ .status(ok ? 200 : 503)
62
+ .json({ ok, clickhouse: ok ? "up" : "down", ...sinkStats });
56
63
  }
57
64
  catch {
58
- res.status(503).json({ ok: false, clickhouse: "down" });
65
+ res.status(503).json({ ok: false, clickhouse: "down", ...sinkStats });
59
66
  }
60
67
  });
61
68
  /** Auth + scope + rate limit; returns null (response sent) on failure. */
@@ -103,38 +110,20 @@ export function createIngesterApp(config) {
103
110
  }
104
111
  return ctx;
105
112
  }
106
- function fingerprintAll(inputs) {
107
- return inputs.map((input) => ({
108
- ...input,
109
- occurrenceId: randomUUID(),
110
- fingerprint: fingerprintOccurrence(input),
111
- ...deriveFields(input),
112
- }));
113
- }
114
- /** Best-effort forward of fingerprinted occurrences for issue grouping. */
115
- function forwardToSink(ctx, occurrences) {
116
- if (!config.sinkUrl || occurrences.length === 0)
117
- return;
118
- void fetch(config.sinkUrl, {
119
- method: "POST",
120
- headers: {
121
- "content-type": "application/json",
122
- ...(config.sinkToken
123
- ? { authorization: `Bearer ${config.sinkToken}` }
124
- : {}),
125
- },
126
- body: JSON.stringify({
127
- version: 1,
128
- orgId: ctx.orgId,
129
- repositoryId: ctx.repositoryId,
130
- occurrences: occurrences.map((o) => ({
131
- ...o,
132
- occurredAt: o.occurredAt.toISOString(),
133
- })),
134
- }),
135
- signal: AbortSignal.timeout(10_000),
136
- }).catch((err) => {
137
- console.warn("sink forward failed (non-fatal):", err?.message ?? err);
113
+ /** Ids are content-derived (occurrenceIdFor), NOT random: an exporter
114
+ * that retries a batch — after a 503 from a partially-failed ClickHouse
115
+ * write, or when only our 2xx got lost — must produce the same ids, so
116
+ * the sink consumer's per-occurrence dedupe holds across transport
117
+ * retries and duplicated ClickHouse rows stay identifiable. */
118
+ function fingerprintAll(ctx, inputs) {
119
+ return inputs.map((input, index) => {
120
+ const fingerprint = fingerprintOccurrence(input);
121
+ return {
122
+ ...input,
123
+ occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
124
+ fingerprint,
125
+ ...deriveFields(input),
126
+ };
138
127
  });
139
128
  }
140
129
  function storageError(res, err) {
@@ -167,20 +156,29 @@ export function createIngesterApp(config) {
167
156
  else {
168
157
  request = req.body;
169
158
  }
170
- const { occurrences, spans, metricPoints } = normalizeTraces(request);
171
- const fingerprinted = fingerprintAll(occurrences);
159
+ const { occurrences, spans, metricPoints, llmCalls } = normalizeTraces(request);
160
+ const fingerprinted = fingerprintAll(ctx, occurrences);
161
+ // ClickHouse has no cross-table transaction, so these four inserts can
162
+ // partially commit. Recovery boundary: any failure → 503 → the exporter
163
+ // retries the whole batch. Deterministic occurrence ids make the retry
164
+ // idempotent downstream (consumer dedupes per id; duplicate ClickHouse
165
+ // rows share an id, and the consumer's reconciler counts distinct ids),
166
+ // and nothing reaches the sink queue unless every insert succeeded —
167
+ // signals persisted by a partial write are picked up by the consumer's
168
+ // ClickHouse reconciliation instead.
172
169
  try {
173
170
  await Promise.all([
174
171
  store.insertOccurrences(ctx, fingerprinted),
175
172
  store.insertSpans(ctx, spans),
176
173
  store.insertMetricPoints(ctx, metricPoints),
174
+ store.insertLlmCalls(ctx, llmCalls),
177
175
  ]);
178
176
  }
179
177
  catch (err) {
180
178
  storageError(res, err);
181
179
  return;
182
180
  }
183
- forwardToSink(ctx, fingerprinted);
181
+ sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
184
182
  otlpSuccess(req, res);
185
183
  });
186
184
  app.post("/v1/metrics", async (req, res) => {
@@ -208,6 +206,7 @@ export function createIngesterApp(config) {
208
206
  storageError(res, err);
209
207
  return;
210
208
  }
209
+ sink?.enqueue(ctx, [], metricPoints);
211
210
  otlpSuccess(req, res);
212
211
  });
213
212
  app.post("/v1/browser", async (req, res) => {
@@ -234,7 +233,7 @@ export function createIngesterApp(config) {
234
233
  return;
235
234
  }
236
235
  const { occurrences, metricPoints } = normalizeBrowserPayload(parsed.data);
237
- const fingerprinted = fingerprintAll(occurrences);
236
+ const fingerprinted = fingerprintAll(ctx, occurrences);
238
237
  try {
239
238
  await Promise.all([
240
239
  store.insertOccurrences(ctx, fingerprinted),
@@ -245,7 +244,7 @@ export function createIngesterApp(config) {
245
244
  storageError(res, err);
246
245
  return;
247
246
  }
248
- forwardToSink(ctx, fingerprinted);
247
+ sink?.enqueue(ctx, fingerprinted, metricPoints);
249
248
  res.status(202).json({ accepted: fingerprinted.length });
250
249
  });
251
250
  // Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
@@ -263,5 +262,5 @@ export function createIngesterApp(config) {
263
262
  console.error("unhandled error:", err);
264
263
  res.status(500).json({ error: "internal error" });
265
264
  });
266
- return { app, store };
265
+ return { app, store, sink };
267
266
  }