@autter/otlp-ingester 1.7.0 → 1.8.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 +17 -3
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +19 -7
- package/dist/clickhouse.d.ts +3 -0
- package/dist/clickhouse.js +38 -1
- package/dist/compat-manifest.json +21 -0
- package/dist/context.d.ts +5 -0
- package/dist/context.js +49 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/ingest-drops.d.ts +308 -0
- package/dist/ingest-drops.js +448 -0
- package/dist/log-promotion.d.ts +4 -3
- package/dist/log-promotion.js +5 -5
- package/dist/logs.d.ts +16 -1
- package/dist/logs.js +17 -3
- package/dist/memory.d.ts +9 -1
- package/dist/memory.js +28 -7
- package/dist/migrations.d.ts +8 -0
- package/dist/migrations.js +29 -1
- package/dist/normalize-browser.d.ts +39 -11
- package/dist/normalize-browser.js +56 -10
- package/dist/normalize-otlp.js +165 -45
- package/dist/otlp-proto.d.ts +14 -0
- package/dist/otlp-proto.js +25 -0
- package/dist/profiles.d.ts +4 -0
- package/dist/profiles.js +6 -2
- package/dist/request-spans.d.ts +45 -0
- package/dist/request-spans.js +91 -0
- package/dist/sdk-versions.d.ts +39 -2
- package/dist/sdk-versions.js +49 -8
- package/dist/server.d.ts +3 -0
- package/dist/server.js +304 -98
- package/dist/sink.d.ts +14 -0
- package/dist/sink.js +15 -0
- package/dist/stack-frames.d.ts +5 -2
- package/dist/stack-frames.js +55 -9
- package/dist/symbolicate.d.ts +13 -0
- package/dist/symbolicate.js +22 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { KeyResolver, RateLimiter } from "./auth.js";
|
|
3
|
+
import { countTruncations } from "./context.js";
|
|
4
|
+
import { autterKeyHash, cumulativeHttpHistogramPoints, dataPointsInRequest, dropRoute, IngestDropCounter, PARTIAL_MESSAGES, partialSuccess, recordsInBody, spansInRequest, } from "./ingest-drops.js";
|
|
3
5
|
import { ClickHouseStore } from "./clickhouse.js";
|
|
4
6
|
import { normalizeLatencyHistograms } from "./latency.js";
|
|
5
7
|
import { deriveFields, occurrenceIdFor } from "./fingerprint.js";
|
|
@@ -7,18 +9,18 @@ import { occurrenceIdentity } from "./fingerprint-v3.js";
|
|
|
7
9
|
import { parseStackFrames } from "./stack-frames.js";
|
|
8
10
|
import { SourceMapCache, Symbolicator } from "./symbolicate.js";
|
|
9
11
|
import { logPromotionCandidates, promotionLookups, promotionRollups, } from "./log-promotion.js";
|
|
10
|
-
import {
|
|
12
|
+
import { normalizeBrowserPayload, parseBrowserPayload, } from "./normalize-browser.js";
|
|
11
13
|
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
12
|
-
import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest } from "./otlp-proto.js";
|
|
13
|
-
import { normalizeLogs } from "./logs.js";
|
|
14
|
+
import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest, encodeExportResponse } from "./otlp-proto.js";
|
|
15
|
+
import { MAX_LOG_RECORDS_PER_REQUEST, normalizeLogs } from "./logs.js";
|
|
14
16
|
import { decodeProfile } from "./profiles.js";
|
|
15
|
-
import { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
|
|
17
|
+
import { MAX_MEMORY_SAMPLES_PER_REQUEST, normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema, } from "./memory.js";
|
|
16
18
|
import { SOURCE_MAP_MAX_BODY_BYTES, validateSourceMap, validateSourceMapCheck } from "./source-maps.js";
|
|
17
19
|
import { SinkForwarder } from "./sink.js";
|
|
18
20
|
import { configureRedaction, scrubOccurrence } from "./redact.js";
|
|
19
21
|
import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
|
|
20
22
|
import { MIGRATIONS } from "./migrations.js";
|
|
21
|
-
import { BROWSER_SDK_NAME, ingesterVersion, SdkVersionTracker, sdkSightingsFromOtlp, } from "./sdk-versions.js";
|
|
23
|
+
import { BROWSER_SDK_NAME, ingesterVersion, SdkVersionTracker, sdkSightingsFromOtlp, telemetryVia, } from "./sdk-versions.js";
|
|
22
24
|
/** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
|
|
23
25
|
export const UPLOAD_KEY_ROUTES = ["/v1/sourcemaps", "/v1/sourcemaps/check"];
|
|
24
26
|
/** The 403 an upload key gets on every other authenticated route. */
|
|
@@ -27,6 +29,14 @@ export const UPLOAD_KEY_NOT_ALLOWED = {
|
|
|
27
29
|
detail: "autter_rtu_ keys can only upload source maps (POST /v1/sourcemaps and /v1/sourcemaps/check); " +
|
|
28
30
|
"telemetry needs a server key (autter_rt_…), or a client key (autter_rtc_…) for /v1/browser",
|
|
29
31
|
};
|
|
32
|
+
/** Express 4 drops a rejected handler promise: hand it to the error handler instead of crashing the process. */
|
|
33
|
+
function asyncRoute(handler) {
|
|
34
|
+
return (req, res, next) => {
|
|
35
|
+
handler(req, res, next).catch(next);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Spans one traces request may carry (normalizeTraces' MAX_SPANS_PER_REQUEST). */
|
|
39
|
+
const SPANS_PER_REQUEST = 5000;
|
|
30
40
|
/** Re-report the ingester version at most this often (keeps the row inside
|
|
31
41
|
* its TTL for long-running ingesters). */
|
|
32
42
|
const INGESTER_INFO_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -48,6 +58,19 @@ export function createIngesterApp(config) {
|
|
|
48
58
|
const promotionLookupLimiter = new RateLimiter(config.promotionLookupsPerMinute ?? 30);
|
|
49
59
|
const version = ingesterVersion();
|
|
50
60
|
const sdkVersions = new SdkVersionTracker((ctx, rows) => store.insertSdkVersions(ctx, rows));
|
|
61
|
+
// What was refused or not stored, per tenant and minute (EI-27). Written
|
|
62
|
+
// in batches every few seconds, bounded, never retried: it can't slow
|
|
63
|
+
// or fail ingest. Without ClickHouse there is nowhere to count.
|
|
64
|
+
const drops = new IngestDropCounter((rows) => store.insertIngestDrops(rows), { enabled: store.configured });
|
|
65
|
+
drops.start();
|
|
66
|
+
if (sink) {
|
|
67
|
+
sink.onDrop = (drop) => drops.record({
|
|
68
|
+
tenant: { orgId: drop.orgId, repositoryId: drop.repositoryId },
|
|
69
|
+
route: "sink",
|
|
70
|
+
reason: drop.reason,
|
|
71
|
+
records: drop.occurrences,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
51
74
|
const allMigrationIds = MIGRATIONS.map((migration) => migration.id);
|
|
52
75
|
const compatReport = () => {
|
|
53
76
|
const schema = store.schemaStatus();
|
|
@@ -79,10 +102,54 @@ export function createIngesterApp(config) {
|
|
|
79
102
|
}
|
|
80
103
|
}
|
|
81
104
|
/** Fire-and-forget after a successful ingest: never delays or fails it. */
|
|
82
|
-
function recordSdks(ctx, request) {
|
|
83
|
-
void sdkVersions.observe(ctx, sdkSightingsFromOtlp(request));
|
|
105
|
+
function recordSdks(ctx, request, req) {
|
|
106
|
+
void sdkVersions.observe(ctx, sdkSightingsFromOtlp(request, { userAgent: req.headers["user-agent"] ?? null }));
|
|
84
107
|
void reportIngesterInfo();
|
|
85
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Answer a refusal and count it. The response is written first; then the
|
|
111
|
+
* records in the body are counted (a walk of the parsed JSON, or a
|
|
112
|
+
* wire-format scan of protobuf: never a decode).
|
|
113
|
+
*/
|
|
114
|
+
function refuse(req, res, who, reason, status, body, records) {
|
|
115
|
+
res.status(status).json(body);
|
|
116
|
+
const route = dropRoute(req.path);
|
|
117
|
+
drops.record({
|
|
118
|
+
tenant: who && "orgId" in who ? who : null,
|
|
119
|
+
...(who && "keyHash" in who ? { keyHash: who.keyHash } : {}),
|
|
120
|
+
route,
|
|
121
|
+
reason,
|
|
122
|
+
status,
|
|
123
|
+
records: records ?? recordsInBody(route, req.body, Boolean(req.is("application/x-protobuf"))),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Count a refusal made before any route ran (body-parser errors, unknown
|
|
128
|
+
* routes): resolve the key in the background to find the tenant. Never
|
|
129
|
+
* delays the response, never throws.
|
|
130
|
+
*/
|
|
131
|
+
function countUnrouted(req, reason, status) {
|
|
132
|
+
if (!store.configured)
|
|
133
|
+
return;
|
|
134
|
+
const route = dropRoute(req.path);
|
|
135
|
+
const records = recordsInBody(route, req.body, Boolean(req.is("application/x-protobuf")));
|
|
136
|
+
const key = keys.extractKey(req);
|
|
137
|
+
if (!key) {
|
|
138
|
+
drops.record({ tenant: null, route, reason, status, records });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
keys
|
|
142
|
+
.resolveDetailed(key)
|
|
143
|
+
.then((resolution) => drops.record({
|
|
144
|
+
tenant: resolution.ctx,
|
|
145
|
+
...(resolution.ctx ? {} : { keyHash: autterKeyHash(key) }),
|
|
146
|
+
route,
|
|
147
|
+
reason,
|
|
148
|
+
status,
|
|
149
|
+
records,
|
|
150
|
+
}))
|
|
151
|
+
.catch(() => { });
|
|
152
|
+
}
|
|
86
153
|
const app = express();
|
|
87
154
|
app.disable("x-powered-by");
|
|
88
155
|
// Every response names the ingester version, so SDKs (and the browser
|
|
@@ -100,32 +167,36 @@ export function createIngesterApp(config) {
|
|
|
100
167
|
// parse one. body-parser inflates gzip/deflate and applies the limit to
|
|
101
168
|
// the inflated bytes, so a small compressed body can't expand past it.
|
|
102
169
|
const sourceMapBody = express.json({ limit: SOURCE_MAP_MAX_BODY_BYTES });
|
|
103
|
-
app.post("/v1/sourcemaps", async (req, res, next) => {
|
|
170
|
+
app.post("/v1/sourcemaps", asyncRoute(async (req, res, next) => {
|
|
104
171
|
// On failure the body is never read (Node discards it).
|
|
105
172
|
const ctx = await authenticate(req, res, "sourcemaps");
|
|
106
173
|
if (!ctx)
|
|
107
174
|
return;
|
|
108
|
-
sourceMapBody(req, res,
|
|
175
|
+
sourceMapBody(req, res, (err) => {
|
|
109
176
|
if (err) {
|
|
177
|
+
// 413 / bad JSON: the error handler answers and counts it.
|
|
110
178
|
next(err);
|
|
111
179
|
return;
|
|
112
180
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
181
|
+
void (async () => {
|
|
182
|
+
const sourceMap = validateSourceMap(req.body);
|
|
183
|
+
if (!sourceMap) {
|
|
184
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid source map" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Idempotent: the same release + path replaces the earlier row
|
|
188
|
+
// (ReplacingMergeTree), so CI retries and re-runs are harmless.
|
|
189
|
+
try {
|
|
190
|
+
await store.insertSourceMap(ctx, sourceMap);
|
|
191
|
+
}
|
|
192
|
+
catch (storeErr) {
|
|
193
|
+
storageError(req, res, ctx, storeErr);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
122
196
|
res.status(202).json({ accepted: true });
|
|
123
|
-
}
|
|
124
|
-
catch (storeErr) {
|
|
125
|
-
storageError(res, storeErr);
|
|
126
|
-
}
|
|
197
|
+
})().catch(next);
|
|
127
198
|
});
|
|
128
|
-
});
|
|
199
|
+
}));
|
|
129
200
|
app.use(express.json({
|
|
130
201
|
limit: config.maxBodyBytes,
|
|
131
202
|
type: ["application/json"],
|
|
@@ -213,26 +284,30 @@ export function createIngesterApp(config) {
|
|
|
213
284
|
*/
|
|
214
285
|
async function authenticate(req, res, surface) {
|
|
215
286
|
// A storage-less ingester must refuse, not accept-and-drop: exporters
|
|
216
|
-
// retry on 503, so telemetry survives a misconfigured deploy.
|
|
287
|
+
// retry on 503, so telemetry survives a misconfigured deploy. Not
|
|
288
|
+
// counted: without ClickHouse there is nowhere to count it.
|
|
217
289
|
if (!store.configured) {
|
|
218
290
|
res.status(503).json({ error: "storage not configured" });
|
|
219
291
|
return null;
|
|
220
292
|
}
|
|
221
293
|
const key = keys.extractKey(req);
|
|
222
294
|
if (!key) {
|
|
223
|
-
res
|
|
295
|
+
refuse(req, res, null, "missing_key", 401, { error: "missing ingest key" });
|
|
224
296
|
return null;
|
|
225
297
|
}
|
|
226
|
-
const
|
|
298
|
+
const resolution = await keys.resolveDetailed(key);
|
|
299
|
+
const ctx = resolution.ctx;
|
|
227
300
|
if (!ctx) {
|
|
228
|
-
|
|
301
|
+
// No tenant: counted against the null tenant, with the key's hash
|
|
302
|
+
// when it is an Autter key (ingest-drops.ts autterKeyHash).
|
|
303
|
+
refuse(req, res, { keyHash: autterKeyHash(key) }, resolution.failure, 401, { error: "invalid ingest key" });
|
|
229
304
|
return null;
|
|
230
305
|
}
|
|
231
306
|
if (ctx.scope === "upload") {
|
|
232
307
|
// CI keys: source map upload and check only. Server rate window
|
|
233
308
|
// (each key has its own).
|
|
234
309
|
if (surface !== "sourcemaps") {
|
|
235
|
-
res
|
|
310
|
+
refuse(req, res, ctx, "upload_key_not_allowed", 403, UPLOAD_KEY_NOT_ALLOWED);
|
|
236
311
|
return null;
|
|
237
312
|
}
|
|
238
313
|
}
|
|
@@ -240,7 +315,7 @@ export function createIngesterApp(config) {
|
|
|
240
315
|
// Publishable keys: browser surface only, origin allow-list, and
|
|
241
316
|
// the tighter rate window.
|
|
242
317
|
if (surface !== "browser") {
|
|
243
|
-
res
|
|
318
|
+
refuse(req, res, ctx, "client_key_not_allowed", 403, {
|
|
244
319
|
error: surface === "sourcemaps"
|
|
245
320
|
? "client keys cannot upload source maps — use an upload key (autter_rtu_…) or a server key"
|
|
246
321
|
: "client keys cannot send OTLP — use a server key",
|
|
@@ -250,11 +325,11 @@ export function createIngesterApp(config) {
|
|
|
250
325
|
const origin = req.headers.origin;
|
|
251
326
|
if (ctx.allowedOrigins.length > 0 &&
|
|
252
327
|
(!origin || !ctx.allowedOrigins.includes(origin))) {
|
|
253
|
-
res
|
|
328
|
+
refuse(req, res, ctx, "origin_not_allowed", 403, { error: "origin not allowed for this key" });
|
|
254
329
|
return null;
|
|
255
330
|
}
|
|
256
331
|
if (!clientRateLimiter.allow(key)) {
|
|
257
|
-
res
|
|
332
|
+
refuse(req, res, ctx, "rate_limited", 429, { error: "rate limit exceeded" });
|
|
258
333
|
return null;
|
|
259
334
|
}
|
|
260
335
|
return ctx;
|
|
@@ -262,11 +337,11 @@ export function createIngesterApp(config) {
|
|
|
262
337
|
else if (ctx.scope !== "server") {
|
|
263
338
|
// KeyResolver never yields another scope; refuse rather than
|
|
264
339
|
// treat an unknown one as a server key.
|
|
265
|
-
res
|
|
340
|
+
refuse(req, res, ctx, "key_scope_not_allowed", 403, { error: "key scope not allowed" });
|
|
266
341
|
return null;
|
|
267
342
|
}
|
|
268
343
|
if (!serverRateLimiter.allow(key)) {
|
|
269
|
-
res
|
|
344
|
+
refuse(req, res, ctx, "rate_limited", 429, { error: "rate limit exceeded" });
|
|
270
345
|
return null;
|
|
271
346
|
}
|
|
272
347
|
return ctx;
|
|
@@ -360,25 +435,51 @@ export function createIngesterApp(config) {
|
|
|
360
435
|
}
|
|
361
436
|
return fingerprinted.filter((o) => !o.traceId || !seen.has(o.traceId));
|
|
362
437
|
}
|
|
363
|
-
function storageError(res, err) {
|
|
438
|
+
function storageError(req, res, ctx, err, records) {
|
|
364
439
|
console.error("clickhouse write failed:", err);
|
|
365
|
-
res
|
|
440
|
+
refuse(req, res, ctx, "storage_unavailable", 503, { error: "storage unavailable, retry later" }, records);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* OTLP/HTTP is JSON or protobuf; anything else gets a 415 (OTLP spec)
|
|
444
|
+
* instead of an empty success that stores nothing. A request with no
|
|
445
|
+
* body at all is an empty export, not a wrong type.
|
|
446
|
+
*/
|
|
447
|
+
function unsupportedOtlpBody(req) {
|
|
448
|
+
const match = req.is(["application/json", "application/x-protobuf"]);
|
|
449
|
+
return match === false && req.headers["content-length"] !== "0";
|
|
366
450
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
function
|
|
451
|
+
const UNSUPPORTED_OTLP = { error: "expected application/json or application/x-protobuf" };
|
|
452
|
+
/** Count what an accepted request did not store, or stored cut. */
|
|
453
|
+
function countAccepted(ctx, req, parts) {
|
|
454
|
+
const route = dropRoute(req.path);
|
|
455
|
+
for (const part of parts) {
|
|
456
|
+
if (part.records > 0)
|
|
457
|
+
drops.record({ tenant: ctx, route, reason: part.reason, records: part.records });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* OTLP success responses mirror the request encoding: protobuf for proto
|
|
462
|
+
* clients (an empty message when everything was accepted), JSON
|
|
463
|
+
* otherwise. When some records were not stored, `partial_success` names
|
|
464
|
+
* how many and why (OTLP spec), so collectors and SDKs log it.
|
|
465
|
+
*/
|
|
466
|
+
function otlpSuccess(req, res, signal, partial) {
|
|
370
467
|
if (req.is("application/x-protobuf")) {
|
|
371
|
-
res.status(200).type("application/x-protobuf").end();
|
|
468
|
+
res.status(200).type("application/x-protobuf").end(Buffer.from(encodeExportResponse(partial)));
|
|
372
469
|
return;
|
|
373
470
|
}
|
|
374
|
-
|
|
471
|
+
const rejectedField = signal === "traces" ? "rejectedSpans" : signal === "logs" ? "rejectedLogRecords" : "rejectedDataPoints";
|
|
472
|
+
// int64 is a decimal string in proto3 JSON; OTLP decoders take both.
|
|
473
|
+
res.status(200).json({
|
|
474
|
+
partialSuccess: partial ? { [rejectedField]: String(partial.rejected), errorMessage: partial.errorMessage } : {},
|
|
475
|
+
});
|
|
375
476
|
}
|
|
376
|
-
app.post("/v1/profiles", async (req, res) => {
|
|
477
|
+
app.post("/v1/profiles", asyncRoute(async (req, res) => {
|
|
377
478
|
const ctx = await authenticate(req, res, "otlp");
|
|
378
479
|
if (!ctx)
|
|
379
480
|
return;
|
|
380
481
|
if (!req.is("application/x-pprof") || !Buffer.isBuffer(req.body)) {
|
|
381
|
-
res
|
|
482
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, { error: "expected application/x-pprof" });
|
|
382
483
|
return;
|
|
383
484
|
}
|
|
384
485
|
const service = req.header("x-autter-service")?.trim() ?? "";
|
|
@@ -387,86 +488,110 @@ export function createIngesterApp(config) {
|
|
|
387
488
|
const traceId = req.header("x-autter-trace-id")?.trim() ?? "";
|
|
388
489
|
const instanceId = req.header("x-autter-instance-id")?.trim() ?? "";
|
|
389
490
|
if (!/^[a-zA-Z0-9._-]{1,200}$/.test(service) || environment.length > 100 || release.length > 200 || instanceId.length > 128 || (traceId && !/^[a-f0-9]{32}$/.test(traceId))) {
|
|
390
|
-
res
|
|
491
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid profile metadata" });
|
|
391
492
|
return;
|
|
392
493
|
}
|
|
393
494
|
let samples;
|
|
495
|
+
const stats = { overLimit: 0 };
|
|
394
496
|
try {
|
|
395
|
-
samples = decodeProfile(req.body, { service, environment, release, traceId, instanceId });
|
|
497
|
+
samples = decodeProfile(req.body, { service, environment, release, traceId, instanceId }, stats);
|
|
396
498
|
if (!samples.length)
|
|
397
499
|
throw new Error("empty profile");
|
|
398
500
|
}
|
|
399
501
|
catch {
|
|
400
|
-
res
|
|
502
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid or unsymbolized profile" });
|
|
401
503
|
return;
|
|
402
504
|
}
|
|
403
505
|
try {
|
|
404
506
|
await store.insertProfileSamples(ctx, samples);
|
|
405
|
-
res.status(202).json({ profileId: samples[0].profileId, samples: samples.length });
|
|
406
507
|
}
|
|
407
508
|
catch (err) {
|
|
408
|
-
storageError(res, err);
|
|
509
|
+
storageError(req, res, ctx, err, samples.length + stats.overLimit);
|
|
510
|
+
return;
|
|
409
511
|
}
|
|
410
|
-
|
|
512
|
+
res.status(202).json({ profileId: samples[0].profileId, samples: samples.length });
|
|
513
|
+
countAccepted(ctx, req, [{ reason: "profile_samples_over_limit", records: stats.overLimit }]);
|
|
514
|
+
}));
|
|
411
515
|
/** Which of these maps does this tenant already have? Lets
|
|
412
516
|
* `autter-runtime sourcemaps upload` skip unchanged maps (1.6.0+). */
|
|
413
|
-
app.post("/v1/sourcemaps/check", async (req, res) => {
|
|
517
|
+
app.post("/v1/sourcemaps/check", asyncRoute(async (req, res) => {
|
|
414
518
|
const ctx = await authenticate(req, res, "sourcemaps");
|
|
415
519
|
if (!ctx)
|
|
416
520
|
return;
|
|
417
521
|
const check = validateSourceMapCheck(req.body);
|
|
418
522
|
if (!check) {
|
|
419
|
-
res
|
|
523
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid source map check" });
|
|
420
524
|
return;
|
|
421
525
|
}
|
|
526
|
+
let present;
|
|
422
527
|
try {
|
|
423
|
-
|
|
528
|
+
present = await store.sourceMapsPresent(ctx, check);
|
|
424
529
|
}
|
|
425
530
|
catch (err) {
|
|
426
|
-
storageError(res, err);
|
|
531
|
+
storageError(req, res, ctx, err);
|
|
532
|
+
return;
|
|
427
533
|
}
|
|
428
|
-
|
|
534
|
+
res.status(200).json({ present });
|
|
535
|
+
}));
|
|
429
536
|
/** ECS/Kubernetes event forwarders use the same server key as OTLP. */
|
|
430
|
-
app.post("/v1/platform-events", async (req, res) => {
|
|
537
|
+
app.post("/v1/platform-events", asyncRoute(async (req, res) => {
|
|
431
538
|
const ctx = await authenticate(req, res, "otlp");
|
|
432
539
|
if (!ctx)
|
|
433
540
|
return;
|
|
434
541
|
const parsed = platformEventSchema.safeParse(req.body);
|
|
435
542
|
if (!parsed.success) {
|
|
436
|
-
res
|
|
543
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid platform event" });
|
|
437
544
|
return;
|
|
438
545
|
}
|
|
439
546
|
const event = normalizePlatformEvent(parsed.data);
|
|
440
547
|
if (!event) {
|
|
441
|
-
res
|
|
548
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "event timestamp outside retention window" });
|
|
442
549
|
return;
|
|
443
550
|
}
|
|
444
551
|
try {
|
|
445
552
|
await store.insertPlatformEvent(ctx, event);
|
|
446
|
-
res.status(202).json({ accepted: true });
|
|
447
553
|
}
|
|
448
554
|
catch (err) {
|
|
449
|
-
storageError(res, err);
|
|
555
|
+
storageError(req, res, ctx, err);
|
|
556
|
+
return;
|
|
450
557
|
}
|
|
451
|
-
|
|
452
|
-
|
|
558
|
+
res.status(202).json({ accepted: true });
|
|
559
|
+
}));
|
|
560
|
+
app.post("/v1/traces", asyncRoute(async (req, res) => {
|
|
453
561
|
const ctx = await authenticate(req, res, "otlp");
|
|
454
562
|
if (!ctx)
|
|
455
563
|
return;
|
|
564
|
+
if (unsupportedOtlpBody(req)) {
|
|
565
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
456
568
|
let request;
|
|
457
569
|
if (req.is("application/x-protobuf")) {
|
|
458
570
|
try {
|
|
459
571
|
request = decodeTraceRequest(req.body);
|
|
460
572
|
}
|
|
461
573
|
catch {
|
|
462
|
-
res
|
|
574
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid protobuf payload" });
|
|
463
575
|
return;
|
|
464
576
|
}
|
|
465
577
|
}
|
|
466
578
|
else {
|
|
467
579
|
request = req.body;
|
|
468
580
|
}
|
|
469
|
-
const
|
|
581
|
+
const totalSpans = spansInRequest(request);
|
|
582
|
+
let normalized;
|
|
583
|
+
let truncated;
|
|
584
|
+
try {
|
|
585
|
+
({ result: normalized, truncated } = countTruncations(() => normalizeTraces(request)));
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// A value the normaliser can't read (a timestamp that is not an
|
|
589
|
+
// integer, a list that is not one): before 1.8.0 this threw out of
|
|
590
|
+
// the handler, an unhandled rejection that stopped the process.
|
|
591
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid OTLP traces payload" }, totalSpans);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const { occurrences, occurrenceBatchIndexes, spans, metricPoints, llmCalls } = normalized;
|
|
470
595
|
const fingerprinted = await fingerprintAll(ctx, occurrences, occurrenceBatchIndexes);
|
|
471
596
|
// ClickHouse has no cross-table transaction, so these four inserts can
|
|
472
597
|
// partially commit. Recovery boundary: any failure → 503 → the exporter
|
|
@@ -485,25 +610,39 @@ export function createIngesterApp(config) {
|
|
|
485
610
|
]);
|
|
486
611
|
}
|
|
487
612
|
catch (err) {
|
|
488
|
-
storageError(res, err);
|
|
613
|
+
storageError(req, res, ctx, err, totalSpans);
|
|
489
614
|
return;
|
|
490
615
|
}
|
|
491
616
|
sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
|
|
492
|
-
recordSdks(ctx, request);
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
617
|
+
recordSdks(ctx, request, req);
|
|
618
|
+
// normalizeTraces stops at SPANS_PER_REQUEST; the rest were not read.
|
|
619
|
+
const overLimit = Math.max(0, totalSpans - normalized.spanCount);
|
|
620
|
+
otlpSuccess(req, res, "traces", partialSuccess([
|
|
621
|
+
{ rejected: overLimit, message: PARTIAL_MESSAGES.spans(overLimit, SPANS_PER_REQUEST) },
|
|
622
|
+
]));
|
|
623
|
+
countAccepted(ctx, req, [
|
|
624
|
+
{ reason: "spans_over_limit", records: overLimit },
|
|
625
|
+
{ reason: "attributes_truncated", records: truncated },
|
|
626
|
+
]);
|
|
627
|
+
}));
|
|
628
|
+
app.post("/v1/logs", asyncRoute(async (req, res) => {
|
|
496
629
|
const ctx = await authenticate(req, res, "otlp");
|
|
497
630
|
if (!ctx)
|
|
498
631
|
return;
|
|
632
|
+
if (unsupportedOtlpBody(req)) {
|
|
633
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
499
636
|
let logs;
|
|
500
637
|
let logsRequest;
|
|
638
|
+
let truncated;
|
|
639
|
+
const stats = { overLimit: 0 };
|
|
501
640
|
try {
|
|
502
641
|
logsRequest = req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body;
|
|
503
|
-
logs = normalizeLogs(logsRequest);
|
|
642
|
+
({ result: logs, truncated } = countTruncations(() => normalizeLogs(logsRequest, stats)));
|
|
504
643
|
}
|
|
505
644
|
catch {
|
|
506
|
-
res
|
|
645
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid OTLP logs payload" });
|
|
507
646
|
return;
|
|
508
647
|
}
|
|
509
648
|
// Logs are diagnostic evidence; exceptions and failed outcomes normally
|
|
@@ -520,46 +659,84 @@ export function createIngesterApp(config) {
|
|
|
520
659
|
]);
|
|
521
660
|
}
|
|
522
661
|
catch (err) {
|
|
523
|
-
storageError(res, err);
|
|
662
|
+
storageError(req, res, ctx, err, logs.length + stats.overLimit);
|
|
524
663
|
return;
|
|
525
664
|
}
|
|
526
665
|
sink?.enqueue(ctx, promoted, metricPoints);
|
|
527
|
-
recordSdks(ctx, logsRequest);
|
|
528
|
-
otlpSuccess(req, res
|
|
529
|
-
|
|
530
|
-
|
|
666
|
+
recordSdks(ctx, logsRequest, req);
|
|
667
|
+
otlpSuccess(req, res, "logs", partialSuccess([
|
|
668
|
+
{ rejected: stats.overLimit, message: PARTIAL_MESSAGES.logRecords(stats.overLimit, MAX_LOG_RECORDS_PER_REQUEST) },
|
|
669
|
+
]));
|
|
670
|
+
countAccepted(ctx, req, [
|
|
671
|
+
{ reason: "log_records_over_limit", records: stats.overLimit },
|
|
672
|
+
{ reason: "attributes_truncated", records: truncated },
|
|
673
|
+
]);
|
|
674
|
+
}));
|
|
675
|
+
app.post("/v1/metrics", asyncRoute(async (req, res) => {
|
|
531
676
|
const ctx = await authenticate(req, res, "otlp");
|
|
532
677
|
if (!ctx)
|
|
533
678
|
return;
|
|
679
|
+
if (unsupportedOtlpBody(req)) {
|
|
680
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
534
683
|
let request;
|
|
535
684
|
if (req.is("application/x-protobuf")) {
|
|
536
685
|
try {
|
|
537
686
|
request = decodeMetricsRequest(req.body);
|
|
538
687
|
}
|
|
539
688
|
catch {
|
|
540
|
-
res
|
|
689
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid protobuf payload" });
|
|
541
690
|
return;
|
|
542
691
|
}
|
|
543
692
|
}
|
|
544
693
|
else {
|
|
545
694
|
request = req.body;
|
|
546
695
|
}
|
|
547
|
-
const
|
|
548
|
-
|
|
696
|
+
const memoryStats = { invalid: 0, overLimit: 0 };
|
|
697
|
+
let metricPoints;
|
|
698
|
+
let memorySamples;
|
|
699
|
+
let latency;
|
|
700
|
+
let cumulative;
|
|
549
701
|
try {
|
|
550
|
-
|
|
702
|
+
metricPoints = normalizeMetrics(request);
|
|
703
|
+
memorySamples = normalizeMemoryMetrics(request, memoryStats);
|
|
704
|
+
latency = normalizeLatencyHistograms(request);
|
|
705
|
+
cumulative = cumulativeHttpHistogramPoints(request);
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid OTLP metrics payload" }, dataPointsInRequest(request));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
await store.insertLatencyHistograms(ctx, latency);
|
|
551
713
|
await store.insertMetricPoints(ctx, metricPoints);
|
|
552
714
|
await store.insertMemorySamples(ctx, memorySamples);
|
|
553
715
|
}
|
|
554
716
|
catch (err) {
|
|
555
|
-
storageError(res, err);
|
|
717
|
+
storageError(req, res, ctx, err, dataPointsInRequest(request));
|
|
556
718
|
return;
|
|
557
719
|
}
|
|
558
720
|
sink?.enqueue(ctx, [], metricPoints);
|
|
559
|
-
recordSdks(ctx, request);
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
721
|
+
recordSdks(ctx, request, req);
|
|
722
|
+
// Metrics the ingester has no use for (anything but HTTP server
|
|
723
|
+
// duration histograms and the memory/GC instruments) are ignored,
|
|
724
|
+
// not rejected: they are neither answered nor counted.
|
|
725
|
+
otlpSuccess(req, res, "metrics", partialSuccess([
|
|
726
|
+
{ rejected: cumulative, message: PARTIAL_MESSAGES.cumulative(cumulative) },
|
|
727
|
+
{ rejected: memoryStats.invalid, message: PARTIAL_MESSAGES.memoryInvalid(memoryStats.invalid) },
|
|
728
|
+
{
|
|
729
|
+
rejected: memoryStats.overLimit,
|
|
730
|
+
message: PARTIAL_MESSAGES.memoryOverLimit(memoryStats.overLimit, MAX_MEMORY_SAMPLES_PER_REQUEST),
|
|
731
|
+
},
|
|
732
|
+
]));
|
|
733
|
+
countAccepted(ctx, req, [
|
|
734
|
+
{ reason: "cumulative_histogram", records: cumulative },
|
|
735
|
+
{ reason: "memory_point_invalid", records: memoryStats.invalid },
|
|
736
|
+
{ reason: "memory_points_over_limit", records: memoryStats.overLimit },
|
|
737
|
+
]);
|
|
738
|
+
}));
|
|
739
|
+
app.post("/v1/browser", asyncRoute(async (req, res) => {
|
|
563
740
|
const ctx = await authenticate(req, res, "browser");
|
|
564
741
|
if (!ctx)
|
|
565
742
|
return;
|
|
@@ -570,19 +747,28 @@ export function createIngesterApp(config) {
|
|
|
570
747
|
body = JSON.parse(body);
|
|
571
748
|
}
|
|
572
749
|
catch {
|
|
573
|
-
res
|
|
750
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid json" }, 0);
|
|
574
751
|
return;
|
|
575
752
|
}
|
|
576
753
|
}
|
|
577
|
-
|
|
754
|
+
// The envelope must be valid; each event is validated on its own and
|
|
755
|
+
// an invalid one is dropped and counted (1.8.0), not the whole beacon.
|
|
756
|
+
const parsed = parseBrowserPayload(body);
|
|
578
757
|
if (!parsed.success) {
|
|
579
|
-
res.
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
758
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid payload", issues: parsed.issues }, recordsInBody("/v1/browser", body, false));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const { payload, invalidEvents, overLimitEvents, issues } = parsed.data;
|
|
762
|
+
let normalized;
|
|
763
|
+
let truncated;
|
|
764
|
+
try {
|
|
765
|
+
({ result: normalized, truncated } = countTruncations(() => normalizeBrowserPayload(payload)));
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid payload" }, recordsInBody("/v1/browser", body, false));
|
|
583
769
|
return;
|
|
584
770
|
}
|
|
585
|
-
const { occurrences, metricPoints } =
|
|
771
|
+
const { occurrences, metricPoints } = normalized;
|
|
586
772
|
const fingerprinted = await fingerprintAll(ctx, occurrences);
|
|
587
773
|
try {
|
|
588
774
|
await Promise.all([
|
|
@@ -591,36 +777,56 @@ export function createIngesterApp(config) {
|
|
|
591
777
|
]);
|
|
592
778
|
}
|
|
593
779
|
catch (err) {
|
|
594
|
-
storageError(res, err);
|
|
780
|
+
storageError(req, res, ctx, err, payload.events.length + invalidEvents + overLimitEvents);
|
|
595
781
|
return;
|
|
596
782
|
}
|
|
597
783
|
sink?.enqueue(ctx, fingerprinted, metricPoints);
|
|
598
|
-
if (
|
|
784
|
+
if (payload.sdk) {
|
|
599
785
|
void sdkVersions.observe(ctx, [{
|
|
600
|
-
service:
|
|
601
|
-
environment:
|
|
786
|
+
service: payload.service,
|
|
787
|
+
environment: payload.environment,
|
|
602
788
|
sdkName: BROWSER_SDK_NAME,
|
|
603
|
-
sdkVersion:
|
|
789
|
+
sdkVersion: payload.sdk,
|
|
604
790
|
sdkLanguage: "webjs",
|
|
791
|
+
via: telemetryVia({ userAgent: req.headers["user-agent"] ?? null, browserKeyScope: ctx.scope }),
|
|
605
792
|
}]);
|
|
606
793
|
}
|
|
607
794
|
void reportIngesterInfo();
|
|
608
|
-
|
|
795
|
+
const rejected = invalidEvents + overLimitEvents;
|
|
796
|
+
res.status(202).json({ accepted: fingerprinted.length, ...(rejected > 0 ? { rejected, issues } : {}) });
|
|
797
|
+
countAccepted(ctx, req, [
|
|
798
|
+
{ reason: "invalid_event", records: invalidEvents },
|
|
799
|
+
{ reason: "events_over_limit", records: overLimitEvents },
|
|
800
|
+
{ reason: "attributes_truncated", records: truncated },
|
|
801
|
+
]);
|
|
802
|
+
}));
|
|
803
|
+
// A POST to a path nobody serves (a collector endpoint with /v1/traces
|
|
804
|
+
// twice, an exporter on the wrong base path): counted, then Express's
|
|
805
|
+
// usual 404.
|
|
806
|
+
app.use((req, _res, next) => {
|
|
807
|
+
if (req.method === "POST")
|
|
808
|
+
countUnrouted(req, "unknown_route", 404);
|
|
809
|
+
next();
|
|
609
810
|
});
|
|
610
811
|
// Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
|
|
611
|
-
|
|
812
|
+
// They happen before any route ran, so the tenant is resolved in the
|
|
813
|
+
// background to count them.
|
|
814
|
+
app.use((err, req, res, next) => {
|
|
612
815
|
if (res.headersSent)
|
|
613
816
|
return next(err);
|
|
614
817
|
if (err.type === "entity.too.large") {
|
|
615
818
|
res.status(413).json({ error: "payload too large" });
|
|
819
|
+
countUnrouted(req, "payload_too_large", 413);
|
|
616
820
|
return;
|
|
617
821
|
}
|
|
618
822
|
if (err.status && err.status < 500) {
|
|
619
823
|
res.status(err.status).json({ error: "bad request" });
|
|
824
|
+
countUnrouted(req, err.status === 415 ? "unsupported_media_type" : err.type === "request.aborted" ? "request_aborted" : "invalid_payload", err.status);
|
|
620
825
|
return;
|
|
621
826
|
}
|
|
622
827
|
console.error("unhandled error:", err);
|
|
623
828
|
res.status(500).json({ error: "internal error" });
|
|
829
|
+
countUnrouted(req, "internal_error", 500);
|
|
624
830
|
});
|
|
625
|
-
return { app, store, sink, reportIngesterInfo };
|
|
831
|
+
return { app, store, sink, reportIngesterInfo, drops };
|
|
626
832
|
}
|