@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/dist/sink.js ADDED
@@ -0,0 +1,314 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const DEFAULT_TUNING = {
3
+ retryBaseMs: 1000,
4
+ retryCapMs: 60_000,
5
+ requestTimeoutMs: 10_000,
6
+ healthyConcurrency: 4,
7
+ };
8
+ /** Fixed failure category — safe for the unauthenticated health response. */
9
+ function failureReason(err) {
10
+ if (err instanceof Error) {
11
+ if (err.name === "TimeoutError" || err.name === "AbortError") {
12
+ return "timeout";
13
+ }
14
+ // fetch surfaces DNS/TLS/socket failures as TypeError.
15
+ if (err instanceof TypeError)
16
+ return "connection_error";
17
+ }
18
+ return "error";
19
+ }
20
+ export class SinkForwarder {
21
+ config;
22
+ fetchImpl;
23
+ /** Always sorted by `seq`: queue[0] is the oldest batch. */
24
+ queue = [];
25
+ queuedBytes = 0;
26
+ /** Per-org share of queuedBytes — overflow evicts from the heaviest org. */
27
+ queuedBytesByOrg = new Map();
28
+ inFlight = 0;
29
+ timer = null;
30
+ stopped = false;
31
+ seqCounter = 0;
32
+ delivered = 0;
33
+ retried = 0;
34
+ droppedOverflow = 0;
35
+ droppedPermanent = 0;
36
+ consecutiveFailures = 0;
37
+ /**
38
+ * Bumped on every failure. A success only clears consecutiveFailures if
39
+ * no failure happened after that request STARTED — a concurrent success
40
+ * that overlapped a failure proves nothing about current sink health and
41
+ * must not reopen full concurrency mid-outage.
42
+ */
43
+ failureEpoch = 0;
44
+ lastFailureAt = null;
45
+ lastFailureReason = null;
46
+ tuning;
47
+ constructor(config, fetchImpl = fetch, tuning = {}) {
48
+ this.config = config;
49
+ this.fetchImpl = fetchImpl;
50
+ this.tuning = { ...DEFAULT_TUNING, ...tuning };
51
+ }
52
+ /** Queue a batch for delivery. No-op when there is nothing to send. */
53
+ enqueue(ctx, occurrences, metricPoints = [], llmCalls = []) {
54
+ if (!this.config.sinkUrl || this.stopped)
55
+ return;
56
+ if (occurrences.length === 0 &&
57
+ metricPoints.length === 0 &&
58
+ llmCalls.length === 0) {
59
+ return;
60
+ }
61
+ const batch = this.buildBatch(ctx, occurrences, metricPoints, llmCalls);
62
+ // A batch bigger than the whole buffer could never be admitted without
63
+ // evicting everyone else — and the consumer's body cap would reject it
64
+ // anyway. Drop it alone instead of letting it flush the queue.
65
+ if (batch.bytes > this.maxBufferedBytes()) {
66
+ this.droppedOverflow += 1;
67
+ console.warn(`sink batch ${batch.batchId} (org ${batch.orgId}) exceeds the buffer cap (${batch.bytes} bytes) — dropped; replay signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"} from ClickHouse`);
68
+ return;
69
+ }
70
+ this.queue.push(batch);
71
+ this.track(batch);
72
+ this.enforceBounds();
73
+ this.pump();
74
+ }
75
+ stats() {
76
+ const oldest = this.queue[0]?.enqueuedAt ?? null;
77
+ return {
78
+ queued: this.queue.length,
79
+ queuedBytes: this.queuedBytes,
80
+ inFlight: this.inFlight,
81
+ delivered: this.delivered,
82
+ retried: this.retried,
83
+ droppedOverflow: this.droppedOverflow,
84
+ droppedPermanent: this.droppedPermanent,
85
+ consecutiveFailures: this.consecutiveFailures,
86
+ lastFailureAt: this.lastFailureAt,
87
+ lastFailureReason: this.lastFailureReason,
88
+ oldestQueuedSince: oldest ? new Date(oldest).toISOString() : null,
89
+ };
90
+ }
91
+ pendingCount() {
92
+ return this.queue.length + this.inFlight;
93
+ }
94
+ /** Stop scheduling new deliveries (shutdown). Queued batches are logged
95
+ * by the caller — they are recoverable from ClickHouse, not from here. */
96
+ stop() {
97
+ this.stopped = true;
98
+ if (this.timer) {
99
+ clearTimeout(this.timer);
100
+ this.timer = null;
101
+ }
102
+ }
103
+ /** Serialize one delivery payload; `seq` pins its age for ordering. */
104
+ buildBatch(ctx, occurrences, metricPoints, llmCalls) {
105
+ const batchId = randomUUID();
106
+ const body = JSON.stringify({
107
+ version: 1,
108
+ batchId,
109
+ orgId: ctx.orgId,
110
+ repositoryId: ctx.repositoryId,
111
+ occurrences: occurrences.map((o) => ({
112
+ ...o,
113
+ occurredAt: o.occurredAt.toISOString(),
114
+ })),
115
+ metrics: metricPoints.map((p) => ({
116
+ ...p,
117
+ bucketAt: p.bucketAt.toISOString(),
118
+ })),
119
+ llmCalls: llmCalls.map((c) => ({
120
+ ...c,
121
+ startedAt: c.startedAt.toISOString(),
122
+ })),
123
+ });
124
+ const range = signalRange(occurrences, metricPoints, llmCalls);
125
+ return {
126
+ batchId,
127
+ orgId: ctx.orgId,
128
+ body,
129
+ bytes: Buffer.byteLength(body),
130
+ seq: ++this.seqCounter,
131
+ attempts: 0,
132
+ nextAttemptAt: Date.now(),
133
+ signalsFrom: range.from,
134
+ signalsTo: range.to,
135
+ enqueuedAt: Date.now(),
136
+ };
137
+ }
138
+ maxBufferedBytes() {
139
+ return this.config.sinkMaxBufferedMb * 1024 * 1024;
140
+ }
141
+ track(batch) {
142
+ this.queuedBytes += batch.bytes;
143
+ this.queuedBytesByOrg.set(batch.orgId, (this.queuedBytesByOrg.get(batch.orgId) ?? 0) + batch.bytes);
144
+ }
145
+ untrack(batch) {
146
+ this.queuedBytes -= batch.bytes;
147
+ const left = (this.queuedBytesByOrg.get(batch.orgId) ?? 0) - batch.bytes;
148
+ if (left > 0)
149
+ this.queuedBytesByOrg.set(batch.orgId, left);
150
+ else
151
+ this.queuedBytesByOrg.delete(batch.orgId);
152
+ }
153
+ /** Re-insert a retrying batch at its age position (queue is seq-sorted),
154
+ * so overflow eviction still drops the genuinely oldest signals first. */
155
+ insertBySeq(batch) {
156
+ const at = this.queue.findIndex((b) => b.seq > batch.seq);
157
+ if (at === -1)
158
+ this.queue.push(batch);
159
+ else
160
+ this.queue.splice(at, 0, batch);
161
+ }
162
+ /**
163
+ * Oldest-first eviction, charged to the heaviest tenant: the freshest
164
+ * signals matter most to grouping, and one org flooding the shared
165
+ * buffer must not evict everyone else's batches.
166
+ */
167
+ enforceBounds() {
168
+ const maxBytes = this.maxBufferedBytes();
169
+ while (this.queue.length > this.config.sinkMaxBufferedBatches ||
170
+ (this.queuedBytes > maxBytes && this.queue.length > 1)) {
171
+ const dropped = this.evictOne();
172
+ if (!dropped)
173
+ break;
174
+ this.droppedOverflow += 1;
175
+ console.warn(`sink buffer overflow: dropped batch ${dropped.batchId} (org ${dropped.orgId}, signals ${dropped.signalsFrom ?? "?"} .. ${dropped.signalsTo ?? "?"}) — replay this range from ClickHouse`);
176
+ }
177
+ }
178
+ /** The oldest batch of the org holding the most buffered bytes. */
179
+ evictOne() {
180
+ let heaviest = null;
181
+ let heaviestBytes = -1;
182
+ for (const [orgId, bytes] of this.queuedBytesByOrg) {
183
+ if (bytes > heaviestBytes) {
184
+ heaviest = orgId;
185
+ heaviestBytes = bytes;
186
+ }
187
+ }
188
+ const at = heaviest
189
+ ? this.queue.findIndex((b) => b.orgId === heaviest)
190
+ : 0;
191
+ const [dropped] = this.queue.splice(at === -1 ? 0 : at, 1);
192
+ if (!dropped)
193
+ return null;
194
+ this.untrack(dropped);
195
+ return dropped;
196
+ }
197
+ currentLimit() {
198
+ return this.consecutiveFailures > 0 ? 1 : this.tuning.healthyConcurrency;
199
+ }
200
+ pump() {
201
+ if (this.stopped || !this.config.sinkUrl)
202
+ return;
203
+ const limit = this.currentLimit();
204
+ const now = Date.now();
205
+ while (this.inFlight < limit) {
206
+ const index = this.queue.findIndex((b) => b.nextAttemptAt <= now);
207
+ if (index === -1)
208
+ break;
209
+ const [batch] = this.queue.splice(index, 1);
210
+ if (!batch)
211
+ break;
212
+ this.untrack(batch);
213
+ this.inFlight += 1;
214
+ void this.send(batch).finally(() => {
215
+ this.inFlight -= 1;
216
+ this.pump();
217
+ });
218
+ }
219
+ this.scheduleWake();
220
+ }
221
+ scheduleWake() {
222
+ if (this.timer) {
223
+ clearTimeout(this.timer);
224
+ this.timer = null;
225
+ }
226
+ if (this.stopped || this.queue.length === 0)
227
+ return;
228
+ // At capacity there is nothing to wake for: every completion pumps
229
+ // again anyway, and a 0 ms timer here would spin the event loop while
230
+ // a ready batch waits on a slow in-flight request.
231
+ if (this.inFlight >= this.currentLimit())
232
+ return;
233
+ const next = Math.min(...this.queue.map((b) => b.nextAttemptAt));
234
+ this.timer = setTimeout(() => {
235
+ this.timer = null;
236
+ this.pump();
237
+ }, Math.max(0, next - Date.now()));
238
+ this.timer.unref();
239
+ }
240
+ async send(batch) {
241
+ batch.attempts += 1;
242
+ const epochAtStart = this.failureEpoch;
243
+ let reason = null;
244
+ /** Raw failure text — logged server-side, never surfaced in stats. */
245
+ let detail = null;
246
+ let permanent = false;
247
+ try {
248
+ const res = await this.fetchImpl(this.config.sinkUrl, {
249
+ method: "POST",
250
+ headers: {
251
+ "content-type": "application/json",
252
+ ...(this.config.sinkToken
253
+ ? { authorization: `Bearer ${this.config.sinkToken}` }
254
+ : {}),
255
+ },
256
+ body: batch.body,
257
+ signal: AbortSignal.timeout(this.tuning.requestTimeoutMs),
258
+ });
259
+ // Drain the body so keep-alive sockets are reusable; its content is
260
+ // irrelevant to delivery.
261
+ await res.text().catch(() => { });
262
+ if (res.ok) {
263
+ this.delivered += 1;
264
+ if (epochAtStart === this.failureEpoch) {
265
+ this.consecutiveFailures = 0;
266
+ }
267
+ return;
268
+ }
269
+ reason = `http_${res.status}`;
270
+ detail = `sink responded ${res.status}`;
271
+ // 4xx (except timeout/rate-limit) means the consumer rejected the
272
+ // batch — retrying the same body cannot succeed.
273
+ permanent = res.status < 500 && res.status !== 408 && res.status !== 429;
274
+ }
275
+ catch (err) {
276
+ reason = failureReason(err);
277
+ detail = err instanceof Error ? err.message : String(err);
278
+ }
279
+ this.failureEpoch += 1;
280
+ this.consecutiveFailures += 1;
281
+ this.lastFailureAt = new Date().toISOString();
282
+ this.lastFailureReason = reason;
283
+ if (permanent || batch.attempts >= this.config.sinkMaxAttempts) {
284
+ this.droppedPermanent += 1;
285
+ console.error(`sink delivery gave up after ${batch.attempts} attempt(s) (${detail}): batch ${batch.batchId} (signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"}) — replay this range from ClickHouse`);
286
+ return;
287
+ }
288
+ this.retried += 1;
289
+ // Full jitter avoids retry stampedes when the consumer comes back.
290
+ const backoff = Math.min(this.tuning.retryCapMs, this.tuning.retryBaseMs * 2 ** (batch.attempts - 1));
291
+ batch.nextAttemptAt = Date.now() + backoff / 2 + Math.random() * (backoff / 2);
292
+ this.insertBySeq(batch);
293
+ this.track(batch);
294
+ if (batch.attempts === 1 || batch.attempts % 5 === 0) {
295
+ console.warn(`sink delivery failed (attempt ${batch.attempts}/${this.config.sinkMaxAttempts}, will retry): ${detail}`);
296
+ }
297
+ this.enforceBounds();
298
+ this.scheduleWake();
299
+ }
300
+ }
301
+ /** ISO range across every signal in the batch — the replay hint on drops. */
302
+ function signalRange(occurrences, metricPoints, llmCalls) {
303
+ const timestamps = [
304
+ ...occurrences.map((o) => o.occurredAt),
305
+ ...metricPoints.map((p) => p.bucketAt),
306
+ ...llmCalls.map((c) => c.startedAt),
307
+ ].map((d) => d.getTime());
308
+ if (timestamps.length === 0)
309
+ return { from: null, to: null };
310
+ return {
311
+ from: new Date(Math.min(...timestamps)).toISOString(),
312
+ to: new Date(Math.max(...timestamps)).toISOString(),
313
+ };
314
+ }
package/dist/types.d.ts CHANGED
@@ -62,6 +62,39 @@ export interface RuntimeSpanRow {
62
62
  attributes: Record<string, unknown> | null;
63
63
  startedAt: Date;
64
64
  }
65
+ /**
66
+ * One LLM provider call (chat, completion, embedding, …) extracted from a
67
+ * span carrying GenAI/Vercel-AI/Autter attributes — see llm.ts. Stored per
68
+ * call, not rolled up: LLM traffic is orders of magnitude smaller than
69
+ * HTTP, and spend analysis needs every call. Tokens and cost are resolved
70
+ * at ingest so usage/spend queries are plain aggregates.
71
+ */
72
+ export interface RuntimeLlmCall {
73
+ service: string;
74
+ environment: string;
75
+ release: string | null;
76
+ traceId: string;
77
+ spanId: string;
78
+ /** gen_ai.provider.name / gen_ai.system / ai.model.provider — "openai", … */
79
+ provider: string;
80
+ model: string;
81
+ /** gen_ai.operation.name — "chat", "embeddings", "generateText", … */
82
+ operation: string;
83
+ inputTokens: number;
84
+ outputTokens: number;
85
+ costUsd: number;
86
+ /** reported = SDK sent the exact cost; estimated = built-in pricing table. */
87
+ costSource: "reported" | "estimated" | "none";
88
+ durationMs: number;
89
+ status: "ok" | "error";
90
+ /** Provider exception type for failed calls ("RateLimitError", …); "" when ok. */
91
+ errorType: string;
92
+ /** Opaque end-user id for per-user usage/cost attribution. */
93
+ userId: string;
94
+ sessionId: string;
95
+ attributes: Record<string, string> | null;
96
+ startedAt: Date;
97
+ }
65
98
  /** One 60-second usage rollup bucket. */
66
99
  export interface RuntimeMetricPoint {
67
100
  service: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "Self-hostable OTLP + browser-error ingest service for Autter Runtime: normalises telemetry into a per-repo ClickHouse data model",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,8 @@
14
14
  "scripts": {
15
15
  "dev": "tsx watch src/index.ts",
16
16
  "start": "node dist/index.js",
17
- "build": "tsc -p tsconfig.json"
17
+ "build": "tsc -p tsconfig.json",
18
+ "test": "tsx --test src/*.test.ts"
18
19
  },
19
20
  "dependencies": {
20
21
  "@clickhouse/client": "^1.12.0",