@autter/otlp-ingester 0.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/Dockerfile +18 -0
- package/README.md +114 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +103 -0
- package/dist/clickhouse.d.ts +27 -0
- package/dist/clickhouse.js +261 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +51 -0
- package/dist/fingerprint.d.ts +19 -0
- package/dist/fingerprint.js +80 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +26 -0
- package/dist/migrations.d.ts +42 -0
- package/dist/migrations.js +88 -0
- package/dist/normalize-browser.d.ts +103 -0
- package/dist/normalize-browser.js +113 -0
- package/dist/normalize-otlp.d.ts +84 -0
- package/dist/normalize-otlp.js +258 -0
- package/dist/otlp-proto.d.ts +3 -0
- package/dist/otlp-proto.js +107 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +267 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +11 -0
- package/package.json +53 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { asSeverity, } from "./types.js";
|
|
2
|
+
function attrMap(attributes) {
|
|
3
|
+
const map = new Map();
|
|
4
|
+
for (const attr of attributes ?? []) {
|
|
5
|
+
if (!attr.key || !attr.value)
|
|
6
|
+
continue;
|
|
7
|
+
const v = attr.value;
|
|
8
|
+
const value = v.stringValue ??
|
|
9
|
+
(v.intValue !== undefined ? String(v.intValue) : undefined) ??
|
|
10
|
+
(v.doubleValue !== undefined ? String(v.doubleValue) : undefined) ??
|
|
11
|
+
(v.boolValue !== undefined ? String(v.boolValue) : undefined);
|
|
12
|
+
if (value !== undefined)
|
|
13
|
+
map.set(attr.key, value);
|
|
14
|
+
}
|
|
15
|
+
return map;
|
|
16
|
+
}
|
|
17
|
+
function nanosToDate(nanos) {
|
|
18
|
+
if (nanos === undefined)
|
|
19
|
+
return new Date();
|
|
20
|
+
const ms = Number(BigInt(String(nanos)) / 1000000n);
|
|
21
|
+
return new Date(ms);
|
|
22
|
+
}
|
|
23
|
+
function spanDurationMs(span) {
|
|
24
|
+
if (span.startTimeUnixNano === undefined || span.endTimeUnixNano === undefined) {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const nanos = BigInt(String(span.endTimeUnixNano)) - BigInt(String(span.startTimeUnixNano));
|
|
28
|
+
return Math.max(0, Number(nanos) / 1_000_000);
|
|
29
|
+
}
|
|
30
|
+
function isErrorStatus(code) {
|
|
31
|
+
return code === 2 || code === "STATUS_CODE_ERROR";
|
|
32
|
+
}
|
|
33
|
+
const SPAN_KINDS = {
|
|
34
|
+
"0": "unspecified",
|
|
35
|
+
"1": "internal",
|
|
36
|
+
"2": "server",
|
|
37
|
+
"3": "client",
|
|
38
|
+
"4": "producer",
|
|
39
|
+
"5": "consumer",
|
|
40
|
+
};
|
|
41
|
+
function spanKind(kind) {
|
|
42
|
+
if (kind === undefined)
|
|
43
|
+
return "internal";
|
|
44
|
+
if (typeof kind === "string" && kind.startsWith("SPAN_KIND_")) {
|
|
45
|
+
return kind.slice("SPAN_KIND_".length).toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
return SPAN_KINDS[String(kind)] ?? "internal";
|
|
48
|
+
}
|
|
49
|
+
function resourceInfo(resource) {
|
|
50
|
+
const attrs = attrMap(resource?.attributes);
|
|
51
|
+
return {
|
|
52
|
+
service: attrs.get("service.name") ?? "unknown",
|
|
53
|
+
environment: attrs.get("deployment.environment.name") ??
|
|
54
|
+
attrs.get("deployment.environment") ??
|
|
55
|
+
"production",
|
|
56
|
+
release: attrs.get("service.version") ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function routeOf(attrs) {
|
|
60
|
+
const route = attrs.get("http.route") ??
|
|
61
|
+
attrs.get("url.path") ??
|
|
62
|
+
attrs.get("http.target") ??
|
|
63
|
+
null;
|
|
64
|
+
return route ? (route.split("?")[0] ?? null) : null;
|
|
65
|
+
}
|
|
66
|
+
function statusCodeOf(attrs) {
|
|
67
|
+
const raw = attrs.get("http.response.status_code") ?? attrs.get("http.status_code");
|
|
68
|
+
if (!raw)
|
|
69
|
+
return null;
|
|
70
|
+
const code = Number.parseInt(raw, 10);
|
|
71
|
+
return Number.isFinite(code) ? code : null;
|
|
72
|
+
}
|
|
73
|
+
function methodOf(attrs) {
|
|
74
|
+
const raw = attrs.get("http.request.method") ?? attrs.get("http.method");
|
|
75
|
+
return raw ? raw.toUpperCase().slice(0, 16) : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Severity of an exception event. SDKs mark it with `autter.severity`
|
|
79
|
+
* ("fatal" | "error" | "warning" | "info"); `autter.unhandled: true` (the
|
|
80
|
+
* crash marker set by @autter/runtime-node) implies fatal. Default: error.
|
|
81
|
+
*/
|
|
82
|
+
function severityOf(eventAttrs, spanAttrs) {
|
|
83
|
+
const explicit = eventAttrs.get("autter.severity") ?? spanAttrs.get("autter.severity");
|
|
84
|
+
const severity = asSeverity(explicit, "error");
|
|
85
|
+
if (severity === "error" &&
|
|
86
|
+
(eventAttrs.get("autter.unhandled") === "true" ||
|
|
87
|
+
spanAttrs.get("autter.unhandled") === "true")) {
|
|
88
|
+
return "fatal";
|
|
89
|
+
}
|
|
90
|
+
return severity;
|
|
91
|
+
}
|
|
92
|
+
const MAX_SPANS_PER_REQUEST = 5000;
|
|
93
|
+
export function normalizeTraces(request) {
|
|
94
|
+
const occurrences = [];
|
|
95
|
+
const spans = [];
|
|
96
|
+
const rollups = new Map();
|
|
97
|
+
let spanCount = 0;
|
|
98
|
+
for (const resourceSpan of request.resourceSpans ?? []) {
|
|
99
|
+
const resource = resourceInfo(resourceSpan.resource);
|
|
100
|
+
for (const scopeSpan of resourceSpan.scopeSpans ?? []) {
|
|
101
|
+
for (const span of scopeSpan.spans ?? []) {
|
|
102
|
+
if (spanCount >= MAX_SPANS_PER_REQUEST)
|
|
103
|
+
break;
|
|
104
|
+
spanCount += 1;
|
|
105
|
+
const attrs = attrMap(span.attributes);
|
|
106
|
+
const route = routeOf(attrs);
|
|
107
|
+
const statusCode = statusCodeOf(attrs);
|
|
108
|
+
const startedAt = nanosToDate(span.startTimeUnixNano);
|
|
109
|
+
const durationMs = spanDurationMs(span);
|
|
110
|
+
const kind = spanKind(span.kind);
|
|
111
|
+
const isError = isErrorStatus(span.status?.code) ||
|
|
112
|
+
(statusCode !== null && statusCode >= 500);
|
|
113
|
+
spans.push({
|
|
114
|
+
service: resource.service,
|
|
115
|
+
environment: resource.environment,
|
|
116
|
+
release: resource.release,
|
|
117
|
+
traceId: span.traceId ?? "",
|
|
118
|
+
spanId: span.spanId ?? "",
|
|
119
|
+
parentSpanId: span.parentSpanId ?? null,
|
|
120
|
+
name: span.name ?? "unnamed",
|
|
121
|
+
kind,
|
|
122
|
+
status: isError ? "error" : "ok",
|
|
123
|
+
route,
|
|
124
|
+
statusCode,
|
|
125
|
+
durationMs,
|
|
126
|
+
attributes: null,
|
|
127
|
+
startedAt,
|
|
128
|
+
});
|
|
129
|
+
// Error occurrences: one per exception event; if the span is
|
|
130
|
+
// errored without exception events, one from the span status.
|
|
131
|
+
const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
|
|
132
|
+
for (const event of exceptionEvents) {
|
|
133
|
+
const eventAttrs = attrMap(event.attributes);
|
|
134
|
+
occurrences.push({
|
|
135
|
+
source: "server",
|
|
136
|
+
severity: severityOf(eventAttrs, attrs),
|
|
137
|
+
service: resource.service,
|
|
138
|
+
environment: resource.environment,
|
|
139
|
+
release: resource.release,
|
|
140
|
+
errorType: eventAttrs.get("exception.type") ?? "Error",
|
|
141
|
+
message: eventAttrs.get("exception.message") ??
|
|
142
|
+
span.status?.message ??
|
|
143
|
+
span.name ??
|
|
144
|
+
"Unknown error",
|
|
145
|
+
stack: eventAttrs.get("exception.stacktrace") ?? null,
|
|
146
|
+
route,
|
|
147
|
+
method: methodOf(attrs),
|
|
148
|
+
statusCode,
|
|
149
|
+
traceId: span.traceId ?? null,
|
|
150
|
+
sessionId: null,
|
|
151
|
+
attributes: null,
|
|
152
|
+
occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (exceptionEvents.length === 0 && isErrorStatus(span.status?.code)) {
|
|
156
|
+
occurrences.push({
|
|
157
|
+
source: "server",
|
|
158
|
+
severity: severityOf(new Map(), attrs),
|
|
159
|
+
service: resource.service,
|
|
160
|
+
environment: resource.environment,
|
|
161
|
+
release: resource.release,
|
|
162
|
+
errorType: "SpanError",
|
|
163
|
+
message: span.status?.message || `${span.name ?? "span"} failed`,
|
|
164
|
+
stack: null,
|
|
165
|
+
route,
|
|
166
|
+
method: methodOf(attrs),
|
|
167
|
+
statusCode,
|
|
168
|
+
traceId: span.traceId ?? null,
|
|
169
|
+
sessionId: null,
|
|
170
|
+
attributes: null,
|
|
171
|
+
occurredAt: startedAt,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// 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") {
|
|
177
|
+
addToRollup(rollups, {
|
|
178
|
+
service: resource.service,
|
|
179
|
+
environment: resource.environment,
|
|
180
|
+
release: resource.release,
|
|
181
|
+
route: route ?? "",
|
|
182
|
+
bucketAt: minuteBucket(startedAt),
|
|
183
|
+
requestCount: 1,
|
|
184
|
+
errorCount: isError ? 1 : 0,
|
|
185
|
+
durationSumMs: durationMs,
|
|
186
|
+
sessionCount: 0,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { occurrences, spans, metricPoints: [...rollups.values()], spanCount };
|
|
193
|
+
}
|
|
194
|
+
function minuteBucket(date) {
|
|
195
|
+
return new Date(Math.floor(date.getTime() / 60_000) * 60_000);
|
|
196
|
+
}
|
|
197
|
+
function rollupKey(p) {
|
|
198
|
+
return [
|
|
199
|
+
p.service,
|
|
200
|
+
p.environment,
|
|
201
|
+
p.release ?? "",
|
|
202
|
+
p.route,
|
|
203
|
+
p.bucketAt.getTime(),
|
|
204
|
+
].join("");
|
|
205
|
+
}
|
|
206
|
+
function addToRollup(rollups, point) {
|
|
207
|
+
const key = rollupKey(point);
|
|
208
|
+
const existing = rollups.get(key);
|
|
209
|
+
if (!existing) {
|
|
210
|
+
rollups.set(key, { ...point });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
existing.requestCount += point.requestCount;
|
|
214
|
+
existing.errorCount += point.errorCount;
|
|
215
|
+
existing.durationSumMs += point.durationSumMs;
|
|
216
|
+
existing.sessionCount += point.sessionCount;
|
|
217
|
+
}
|
|
218
|
+
// HTTP-server duration instruments we know how to fold into rollups.
|
|
219
|
+
// `http.server.duration` (old semconv) is milliseconds;
|
|
220
|
+
// `http.server.request.duration` (current semconv) is seconds.
|
|
221
|
+
const HTTP_DURATION_INSTRUMENTS = {
|
|
222
|
+
"http.server.duration": 1,
|
|
223
|
+
"http.server.request.duration": 1000,
|
|
224
|
+
};
|
|
225
|
+
export function normalizeMetrics(request) {
|
|
226
|
+
const rollups = new Map();
|
|
227
|
+
for (const resourceMetric of request.resourceMetrics ?? []) {
|
|
228
|
+
const resource = resourceInfo(resourceMetric.resource);
|
|
229
|
+
for (const scopeMetric of resourceMetric.scopeMetrics ?? []) {
|
|
230
|
+
for (const metric of scopeMetric.metrics ?? []) {
|
|
231
|
+
const multiplier = metric.name
|
|
232
|
+
? HTTP_DURATION_INSTRUMENTS[metric.name]
|
|
233
|
+
: undefined;
|
|
234
|
+
if (multiplier === undefined)
|
|
235
|
+
continue;
|
|
236
|
+
for (const dataPoint of metric.histogram?.dataPoints ?? []) {
|
|
237
|
+
const attrs = attrMap(dataPoint.attributes);
|
|
238
|
+
const statusCode = statusCodeOf(attrs);
|
|
239
|
+
const count = Number(dataPoint.count ?? 0);
|
|
240
|
+
if (count <= 0)
|
|
241
|
+
continue;
|
|
242
|
+
addToRollup(rollups, {
|
|
243
|
+
service: resource.service,
|
|
244
|
+
environment: resource.environment,
|
|
245
|
+
release: resource.release,
|
|
246
|
+
route: routeOf(attrs) ?? "",
|
|
247
|
+
bucketAt: minuteBucket(nanosToDate(dataPoint.timeUnixNano)),
|
|
248
|
+
requestCount: count,
|
|
249
|
+
errorCount: statusCode !== null && statusCode >= 500 ? count : 0,
|
|
250
|
+
durationSumMs: (dataPoint.sum ?? 0) * multiplier,
|
|
251
|
+
sessionCount: 0,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return [...rollups.values()];
|
|
258
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import protobuf from "protobufjs";
|
|
2
|
+
/**
|
|
3
|
+
* OTLP/HTTP protobuf decode (`content-type: application/x-protobuf`) —
|
|
4
|
+
* the default wire format for most OpenTelemetry SDKs (Go, Rust, Python,
|
|
5
|
+
* Java, .NET, and the JS proto exporters).
|
|
6
|
+
*
|
|
7
|
+
* The schema below is a TRIMMED mirror of opentelemetry-proto: only the
|
|
8
|
+
* fields the normaliser reads, with their exact field numbers. Protobuf
|
|
9
|
+
* skips unknown fields during decode, so payloads produced against the
|
|
10
|
+
* full schema parse correctly. Decoded messages are converted to the same
|
|
11
|
+
* structural shape as OTLP/JSON (hex ids, camelCase, stringified 64-bit
|
|
12
|
+
* ints) and fed through the existing normaliser.
|
|
13
|
+
*/
|
|
14
|
+
const PROTO = `
|
|
15
|
+
syntax = "proto3";
|
|
16
|
+
package otlp;
|
|
17
|
+
|
|
18
|
+
message AnyValue {
|
|
19
|
+
oneof value {
|
|
20
|
+
string string_value = 1;
|
|
21
|
+
bool bool_value = 2;
|
|
22
|
+
int64 int_value = 3;
|
|
23
|
+
double double_value = 4;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
message KeyValue { string key = 1; AnyValue value = 2; }
|
|
27
|
+
message Resource { repeated KeyValue attributes = 1; }
|
|
28
|
+
|
|
29
|
+
message ExportTraceServiceRequest { repeated ResourceSpans resource_spans = 1; }
|
|
30
|
+
message ResourceSpans { Resource resource = 1; repeated ScopeSpans scope_spans = 2; }
|
|
31
|
+
message ScopeSpans { repeated Span spans = 2; }
|
|
32
|
+
message Span {
|
|
33
|
+
bytes trace_id = 1;
|
|
34
|
+
bytes span_id = 2;
|
|
35
|
+
bytes parent_span_id = 4;
|
|
36
|
+
string name = 5;
|
|
37
|
+
int32 kind = 6;
|
|
38
|
+
fixed64 start_time_unix_nano = 7;
|
|
39
|
+
fixed64 end_time_unix_nano = 8;
|
|
40
|
+
repeated KeyValue attributes = 9;
|
|
41
|
+
repeated Event events = 11;
|
|
42
|
+
Status status = 15;
|
|
43
|
+
message Event {
|
|
44
|
+
fixed64 time_unix_nano = 1;
|
|
45
|
+
string name = 2;
|
|
46
|
+
repeated KeyValue attributes = 3;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
message Status { string message = 2; int32 code = 3; }
|
|
50
|
+
|
|
51
|
+
message ExportMetricsServiceRequest { repeated ResourceMetrics resource_metrics = 1; }
|
|
52
|
+
message ResourceMetrics { Resource resource = 1; repeated ScopeMetrics scope_metrics = 2; }
|
|
53
|
+
message ScopeMetrics { repeated Metric metrics = 2; }
|
|
54
|
+
message Metric {
|
|
55
|
+
string name = 1;
|
|
56
|
+
string unit = 3;
|
|
57
|
+
Sum sum = 7;
|
|
58
|
+
Histogram histogram = 9;
|
|
59
|
+
}
|
|
60
|
+
message Sum { repeated NumberDataPoint data_points = 1; }
|
|
61
|
+
message NumberDataPoint {
|
|
62
|
+
fixed64 time_unix_nano = 3;
|
|
63
|
+
double as_double = 4;
|
|
64
|
+
sfixed64 as_int = 6;
|
|
65
|
+
repeated KeyValue attributes = 7;
|
|
66
|
+
}
|
|
67
|
+
message Histogram { repeated HistogramDataPoint data_points = 1; }
|
|
68
|
+
message HistogramDataPoint {
|
|
69
|
+
fixed64 time_unix_nano = 3;
|
|
70
|
+
fixed64 count = 4;
|
|
71
|
+
optional double sum = 5;
|
|
72
|
+
repeated KeyValue attributes = 9;
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
const root = protobuf.parse(PROTO).root;
|
|
76
|
+
const TraceRequest = root.lookupType("otlp.ExportTraceServiceRequest");
|
|
77
|
+
const MetricsRequest = root.lookupType("otlp.ExportMetricsServiceRequest");
|
|
78
|
+
const TO_OBJECT_OPTIONS = {
|
|
79
|
+
longs: String, // 64-bit ints → strings (matches OTLP/JSON)
|
|
80
|
+
defaults: false,
|
|
81
|
+
};
|
|
82
|
+
function hexifyIds(value) {
|
|
83
|
+
if (Array.isArray(value))
|
|
84
|
+
return value.map(hexifyIds);
|
|
85
|
+
if (value && typeof value === "object" && !(value instanceof Uint8Array)) {
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const [k, v] of Object.entries(value)) {
|
|
88
|
+
if ((k === "traceId" || k === "spanId" || k === "parentSpanId") &&
|
|
89
|
+
v instanceof Uint8Array) {
|
|
90
|
+
out[k] = Buffer.from(v).toString("hex");
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
out[k] = hexifyIds(v);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
export function decodeTraceRequest(body) {
|
|
101
|
+
const message = TraceRequest.decode(body);
|
|
102
|
+
return hexifyIds(TraceRequest.toObject(message, TO_OBJECT_OPTIONS));
|
|
103
|
+
}
|
|
104
|
+
export function decodeMetricsRequest(body) {
|
|
105
|
+
const message = MetricsRequest.decode(body);
|
|
106
|
+
return hexifyIds(MetricsRequest.toObject(message, TO_OBJECT_OPTIONS));
|
|
107
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Express } from "express";
|
|
2
|
+
import { ClickHouseStore } from "./clickhouse.js";
|
|
3
|
+
import type { IngesterConfig } from "./config.js";
|
|
4
|
+
export interface IngesterApp {
|
|
5
|
+
app: Express;
|
|
6
|
+
store: ClickHouseStore;
|
|
7
|
+
}
|
|
8
|
+
export declare function createIngesterApp(config: IngesterConfig): IngesterApp;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { KeyResolver, RateLimiter } from "./auth.js";
|
|
4
|
+
import { ClickHouseStore } from "./clickhouse.js";
|
|
5
|
+
import { deriveFields, fingerprintOccurrence } from "./fingerprint.js";
|
|
6
|
+
import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
|
|
7
|
+
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
8
|
+
import { decodeMetricsRequest, decodeTraceRequest } from "./otlp-proto.js";
|
|
9
|
+
export function createIngesterApp(config) {
|
|
10
|
+
const store = new ClickHouseStore(config);
|
|
11
|
+
const keys = new KeyResolver(config);
|
|
12
|
+
const serverRateLimiter = new RateLimiter(config.rateLimitPerMinute);
|
|
13
|
+
const clientRateLimiter = new RateLimiter(config.clientRateLimitPerMinute);
|
|
14
|
+
const app = express();
|
|
15
|
+
app.disable("x-powered-by");
|
|
16
|
+
app.use(express.json({
|
|
17
|
+
limit: config.maxBodyBytes,
|
|
18
|
+
type: ["application/json"],
|
|
19
|
+
}));
|
|
20
|
+
// Cross-origin sendBeacon can only send CORS-safelisted content types
|
|
21
|
+
// without a preflight, so direct-from-browser payloads arrive as
|
|
22
|
+
// text/plain and are parsed in the /v1/browser handler.
|
|
23
|
+
app.use(express.text({
|
|
24
|
+
limit: config.maxBodyBytes,
|
|
25
|
+
type: ["text/plain"],
|
|
26
|
+
}));
|
|
27
|
+
// OTLP protobuf — the default wire format of most OTel SDKs (Go, Rust,
|
|
28
|
+
// Python, Java, .NET, JS proto exporters). body-parser inflates
|
|
29
|
+
// gzip/deflate request bodies automatically for all three parsers.
|
|
30
|
+
app.use(express.raw({
|
|
31
|
+
limit: config.maxBodyBytes,
|
|
32
|
+
type: ["application/x-protobuf"],
|
|
33
|
+
}));
|
|
34
|
+
// CORS for direct browser ingest (publishable client keys). Auth and the
|
|
35
|
+
// per-key origin allow-list are enforced at POST time; the CORS response
|
|
36
|
+
// itself is permissive so preflights never need key knowledge.
|
|
37
|
+
app.use("/v1/browser", (req, res, next) => {
|
|
38
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
39
|
+
res.setHeader("access-control-allow-methods", "POST, OPTIONS");
|
|
40
|
+
res.setHeader("access-control-allow-headers", "content-type, authorization, x-autter-key");
|
|
41
|
+
res.setHeader("access-control-max-age", "86400");
|
|
42
|
+
if (req.method === "OPTIONS") {
|
|
43
|
+
res.status(204).end();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
next();
|
|
47
|
+
});
|
|
48
|
+
app.get("/healthz", async (_req, res) => {
|
|
49
|
+
if (!store.configured) {
|
|
50
|
+
res.status(200).json({ ok: true, clickhouse: "unconfigured" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const ok = await store.ping();
|
|
55
|
+
res.status(ok ? 200 : 503).json({ ok, clickhouse: ok ? "up" : "down" });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
res.status(503).json({ ok: false, clickhouse: "down" });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
/** Auth + scope + rate limit; returns null (response sent) on failure. */
|
|
62
|
+
async function authenticate(req, res, surface) {
|
|
63
|
+
// A storage-less ingester must refuse, not accept-and-drop: exporters
|
|
64
|
+
// retry on 503, so telemetry survives a misconfigured deploy.
|
|
65
|
+
if (!store.configured) {
|
|
66
|
+
res.status(503).json({ error: "storage not configured" });
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const key = keys.extractKey(req);
|
|
70
|
+
if (!key) {
|
|
71
|
+
res.status(401).json({ error: "missing ingest key" });
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const ctx = await keys.resolve(key);
|
|
75
|
+
if (!ctx) {
|
|
76
|
+
res.status(401).json({ error: "invalid ingest key" });
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (ctx.scope === "client") {
|
|
80
|
+
// Publishable keys: browser surface only, origin allow-list, and
|
|
81
|
+
// the tighter rate window.
|
|
82
|
+
if (surface !== "browser") {
|
|
83
|
+
res.status(403).json({
|
|
84
|
+
error: "client keys cannot send OTLP — use a server key",
|
|
85
|
+
});
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const origin = req.headers.origin;
|
|
89
|
+
if (ctx.allowedOrigins.length > 0 &&
|
|
90
|
+
(!origin || !ctx.allowedOrigins.includes(origin))) {
|
|
91
|
+
res.status(403).json({ error: "origin not allowed for this key" });
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!clientRateLimiter.allow(key)) {
|
|
95
|
+
res.status(429).json({ error: "rate limit exceeded" });
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return ctx;
|
|
99
|
+
}
|
|
100
|
+
if (!serverRateLimiter.allow(key)) {
|
|
101
|
+
res.status(429).json({ error: "rate limit exceeded" });
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return ctx;
|
|
105
|
+
}
|
|
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);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function storageError(res, err) {
|
|
141
|
+
console.error("clickhouse write failed:", err);
|
|
142
|
+
res.status(503).json({ error: "storage unavailable, retry later" });
|
|
143
|
+
}
|
|
144
|
+
/** OTLP success responses mirror the request encoding: an empty
|
|
145
|
+
* protobuf message body for proto clients, JSON otherwise. */
|
|
146
|
+
function otlpSuccess(req, res) {
|
|
147
|
+
if (req.is("application/x-protobuf")) {
|
|
148
|
+
res.status(200).type("application/x-protobuf").end();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
res.status(200).json({ partialSuccess: {} });
|
|
152
|
+
}
|
|
153
|
+
app.post("/v1/traces", async (req, res) => {
|
|
154
|
+
const ctx = await authenticate(req, res, "otlp");
|
|
155
|
+
if (!ctx)
|
|
156
|
+
return;
|
|
157
|
+
let request;
|
|
158
|
+
if (req.is("application/x-protobuf")) {
|
|
159
|
+
try {
|
|
160
|
+
request = decodeTraceRequest(req.body);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
res.status(400).json({ error: "invalid protobuf payload" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
request = req.body;
|
|
169
|
+
}
|
|
170
|
+
const { occurrences, spans, metricPoints } = normalizeTraces(request);
|
|
171
|
+
const fingerprinted = fingerprintAll(occurrences);
|
|
172
|
+
try {
|
|
173
|
+
await Promise.all([
|
|
174
|
+
store.insertOccurrences(ctx, fingerprinted),
|
|
175
|
+
store.insertSpans(ctx, spans),
|
|
176
|
+
store.insertMetricPoints(ctx, metricPoints),
|
|
177
|
+
]);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
storageError(res, err);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
forwardToSink(ctx, fingerprinted);
|
|
184
|
+
otlpSuccess(req, res);
|
|
185
|
+
});
|
|
186
|
+
app.post("/v1/metrics", async (req, res) => {
|
|
187
|
+
const ctx = await authenticate(req, res, "otlp");
|
|
188
|
+
if (!ctx)
|
|
189
|
+
return;
|
|
190
|
+
let request;
|
|
191
|
+
if (req.is("application/x-protobuf")) {
|
|
192
|
+
try {
|
|
193
|
+
request = decodeMetricsRequest(req.body);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
res.status(400).json({ error: "invalid protobuf payload" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
request = req.body;
|
|
202
|
+
}
|
|
203
|
+
const metricPoints = normalizeMetrics(request);
|
|
204
|
+
try {
|
|
205
|
+
await store.insertMetricPoints(ctx, metricPoints);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
storageError(res, err);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
otlpSuccess(req, res);
|
|
212
|
+
});
|
|
213
|
+
app.post("/v1/browser", async (req, res) => {
|
|
214
|
+
const ctx = await authenticate(req, res, "browser");
|
|
215
|
+
if (!ctx)
|
|
216
|
+
return;
|
|
217
|
+
let body = req.body;
|
|
218
|
+
if (typeof body === "string") {
|
|
219
|
+
// text/plain from a cross-origin sendBeacon — see CORS note above.
|
|
220
|
+
try {
|
|
221
|
+
body = JSON.parse(body);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
res.status(400).json({ error: "invalid json" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const parsed = browserPayloadSchema.safeParse(body);
|
|
229
|
+
if (!parsed.success) {
|
|
230
|
+
res.status(400).json({
|
|
231
|
+
error: "invalid payload",
|
|
232
|
+
issues: parsed.error.issues.slice(0, 5),
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const { occurrences, metricPoints } = normalizeBrowserPayload(parsed.data);
|
|
237
|
+
const fingerprinted = fingerprintAll(occurrences);
|
|
238
|
+
try {
|
|
239
|
+
await Promise.all([
|
|
240
|
+
store.insertOccurrences(ctx, fingerprinted),
|
|
241
|
+
store.insertMetricPoints(ctx, metricPoints),
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
storageError(res, err);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
forwardToSink(ctx, fingerprinted);
|
|
249
|
+
res.status(202).json({ accepted: fingerprinted.length });
|
|
250
|
+
});
|
|
251
|
+
// Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
|
|
252
|
+
app.use((err, _req, res, next) => {
|
|
253
|
+
if (res.headersSent)
|
|
254
|
+
return next(err);
|
|
255
|
+
if (err.type === "entity.too.large") {
|
|
256
|
+
res.status(413).json({ error: "payload too large" });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (err.status && err.status < 500) {
|
|
260
|
+
res.status(err.status).json({ error: "bad request" });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
console.error("unhandled error:", err);
|
|
264
|
+
res.status(500).json({ error: "internal error" });
|
|
265
|
+
});
|
|
266
|
+
return { app, store };
|
|
267
|
+
}
|