@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.
@@ -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
  }
package/dist/sink.d.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { IngesterConfig } from "./config.js";
2
+ import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence } from "./types.js";
3
+ /**
4
+ * At-least-once delivery to the sink webhook.
5
+ *
6
+ * The sink feeds the consumer's issue grouping and incident detection, so a
7
+ * lost batch means silently missing error occurrences — a single
8
+ * fire-and-forget POST is not enough (a routine consumer deploy is longer
9
+ * than one request timeout). Batches therefore queue in memory and retry
10
+ * with exponential backoff until delivered, permanently rejected, or the
11
+ * bounded buffer overflows.
12
+ *
13
+ * Durability boundary: the queue is in-memory only. Everything forwarded
14
+ * here was already written to ClickHouse (ingest 503s otherwise), so after
15
+ * a process crash or an overflow/permanent drop the consumer recovers by
16
+ * replaying the logged time range from ClickHouse — see
17
+ * docs/ARCHITECTURE.md "Sink webhook". Every batch carries a unique
18
+ * `batchId` so consumers can deduplicate retried deliveries.
19
+ */
20
+ /** Delivery timing/concurrency knobs; overridable for tests. */
21
+ export interface SinkTuning {
22
+ /** Base delay before the second attempt; doubles per attempt to the cap. */
23
+ retryBaseMs: number;
24
+ retryCapMs: number;
25
+ requestTimeoutMs: number;
26
+ /** Deliveries run concurrently while healthy, serially while failing. */
27
+ healthyConcurrency: number;
28
+ }
29
+ /**
30
+ * Operational counters for /healthz. Failure detail is reduced to a fixed
31
+ * category (`timeout`, `connection_error`, `http_<status>`, `error`) — the
32
+ * health endpoint is unauthenticated, so raw transport/exception text stays
33
+ * in server-side logs only.
34
+ */
35
+ export interface SinkStats {
36
+ queued: number;
37
+ queuedBytes: number;
38
+ inFlight: number;
39
+ delivered: number;
40
+ retried: number;
41
+ droppedOverflow: number;
42
+ droppedPermanent: number;
43
+ consecutiveFailures: number;
44
+ lastFailureAt: string | null;
45
+ lastFailureReason: string | null;
46
+ oldestQueuedSince: string | null;
47
+ }
48
+ export declare class SinkForwarder {
49
+ private readonly config;
50
+ private readonly fetchImpl;
51
+ /** Always sorted by `seq`: queue[0] is the oldest batch. */
52
+ private readonly queue;
53
+ private queuedBytes;
54
+ /** Per-org share of queuedBytes — overflow evicts from the heaviest org. */
55
+ private readonly queuedBytesByOrg;
56
+ private inFlight;
57
+ private timer;
58
+ private stopped;
59
+ private seqCounter;
60
+ private delivered;
61
+ private retried;
62
+ private droppedOverflow;
63
+ private droppedPermanent;
64
+ private consecutiveFailures;
65
+ /**
66
+ * Bumped on every failure. A success only clears consecutiveFailures if
67
+ * no failure happened after that request STARTED — a concurrent success
68
+ * that overlapped a failure proves nothing about current sink health and
69
+ * must not reopen full concurrency mid-outage.
70
+ */
71
+ private failureEpoch;
72
+ private lastFailureAt;
73
+ private lastFailureReason;
74
+ private readonly tuning;
75
+ constructor(config: IngesterConfig, fetchImpl?: typeof fetch, tuning?: Partial<SinkTuning>);
76
+ /** Queue a batch for delivery. No-op when there is nothing to send. */
77
+ enqueue(ctx: IngestContext, occurrences: RuntimeOccurrence[], metricPoints?: RuntimeMetricPoint[], llmCalls?: RuntimeLlmCall[]): void;
78
+ stats(): SinkStats;
79
+ pendingCount(): number;
80
+ /** Stop scheduling new deliveries (shutdown). Queued batches are logged
81
+ * by the caller — they are recoverable from ClickHouse, not from here. */
82
+ stop(): void;
83
+ /** Serialize one delivery payload; `seq` pins its age for ordering. */
84
+ private buildBatch;
85
+ private maxBufferedBytes;
86
+ private track;
87
+ private untrack;
88
+ /** Re-insert a retrying batch at its age position (queue is seq-sorted),
89
+ * so overflow eviction still drops the genuinely oldest signals first. */
90
+ private insertBySeq;
91
+ /**
92
+ * Oldest-first eviction, charged to the heaviest tenant: the freshest
93
+ * signals matter most to grouping, and one org flooding the shared
94
+ * buffer must not evict everyone else's batches.
95
+ */
96
+ private enforceBounds;
97
+ /** The oldest batch of the org holding the most buffered bytes. */
98
+ private evictOne;
99
+ private currentLimit;
100
+ private pump;
101
+ private scheduleWake;
102
+ private send;
103
+ }