@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 +23 -2
- package/dist/clickhouse.d.ts +2 -1
- package/dist/clickhouse.js +62 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -0
- package/dist/fingerprint.d.ts +22 -0
- package/dist/fingerprint.js +46 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -1
- package/dist/llm-pricing.d.ts +20 -0
- package/dist/llm-pricing.js +120 -0
- package/dist/llm.d.ts +20 -0
- package/dist/llm.js +131 -0
- package/dist/migrations.js +37 -0
- package/dist/normalize-browser.js +41 -9
- package/dist/normalize-otlp.d.ts +3 -1
- package/dist/normalize-otlp.js +79 -5
- package/dist/otlp-proto.js +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.js +42 -43
- package/dist/sink.d.ts +103 -0
- package/dist/sink.js +314 -0
- package/dist/types.d.ts +33 -0
- package/package.json +3 -2
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": "
|
|
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",
|