@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
|
@@ -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,
|
|
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
|
|
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:
|
|
70
|
-
errorCount: 0,
|
|
92
|
+
requestCount: counts.requestCount ?? 0,
|
|
93
|
+
errorCount: counts.errorCount ?? 0,
|
|
71
94
|
durationSumMs: 0,
|
|
72
|
-
sessionCount:
|
|
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,
|
|
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,
|
|
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
|
|
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
|
});
|
package/dist/normalize-otlp.d.ts
CHANGED
|
@@ -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;
|
package/dist/normalize-otlp.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
package/dist/otlp-proto.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|