@autter/otlp-ingester 1.6.1 → 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 +31 -5
- package/dist/auth.d.ts +16 -1
- package/dist/auth.js +34 -10
- package/dist/clickhouse.d.ts +3 -0
- package/dist/clickhouse.js +38 -1
- package/dist/compat-manifest.json +37 -0
- package/dist/config.d.ts +23 -2
- package/dist/config.js +33 -8
- 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 +53 -25
- package/dist/normalize-browser.js +56 -10
- package/dist/normalize-otlp.d.ts +6 -0
- package/dist/normalize-otlp.js +275 -108
- 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 +10 -0
- package/dist/server.js +345 -105
- package/dist/sink.d.ts +14 -0
- package/dist/sink.js +15 -0
- package/dist/source-maps-lookup.d.ts +6 -3
- package/dist/source-maps-lookup.js +35 -13
- package/dist/source-maps.d.ts +6 -2
- package/dist/source-maps.js +12 -4
- 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/dist/trace-dedupe.d.ts +98 -0
- package/dist/trace-dedupe.js +385 -0
- package/dist/types.d.ts +5 -1
- 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,34 @@ 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";
|
|
24
|
+
/** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
|
|
25
|
+
export const UPLOAD_KEY_ROUTES = ["/v1/sourcemaps", "/v1/sourcemaps/check"];
|
|
26
|
+
/** The 403 an upload key gets on every other authenticated route. */
|
|
27
|
+
export const UPLOAD_KEY_NOT_ALLOWED = {
|
|
28
|
+
error: "upload_key_not_allowed",
|
|
29
|
+
detail: "autter_rtu_ keys can only upload source maps (POST /v1/sourcemaps and /v1/sourcemaps/check); " +
|
|
30
|
+
"telemetry needs a server key (autter_rt_…), or a client key (autter_rtc_…) for /v1/browser",
|
|
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;
|
|
22
40
|
/** Re-report the ingester version at most this often (keeps the row inside
|
|
23
41
|
* its TTL for long-running ingesters). */
|
|
24
42
|
const INGESTER_INFO_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -40,6 +58,19 @@ export function createIngesterApp(config) {
|
|
|
40
58
|
const promotionLookupLimiter = new RateLimiter(config.promotionLookupsPerMinute ?? 30);
|
|
41
59
|
const version = ingesterVersion();
|
|
42
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
|
+
}
|
|
43
74
|
const allMigrationIds = MIGRATIONS.map((migration) => migration.id);
|
|
44
75
|
const compatReport = () => {
|
|
45
76
|
const schema = store.schemaStatus();
|
|
@@ -71,10 +102,54 @@ export function createIngesterApp(config) {
|
|
|
71
102
|
}
|
|
72
103
|
}
|
|
73
104
|
/** Fire-and-forget after a successful ingest: never delays or fails it. */
|
|
74
|
-
function recordSdks(ctx, request) {
|
|
75
|
-
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 }));
|
|
76
107
|
void reportIngesterInfo();
|
|
77
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
|
+
}
|
|
78
153
|
const app = express();
|
|
79
154
|
app.disable("x-powered-by");
|
|
80
155
|
// Every response names the ingester version, so SDKs (and the browser
|
|
@@ -92,32 +167,36 @@ export function createIngesterApp(config) {
|
|
|
92
167
|
// parse one. body-parser inflates gzip/deflate and applies the limit to
|
|
93
168
|
// the inflated bytes, so a small compressed body can't expand past it.
|
|
94
169
|
const sourceMapBody = express.json({ limit: SOURCE_MAP_MAX_BODY_BYTES });
|
|
95
|
-
app.post("/v1/sourcemaps", async (req, res, next) => {
|
|
170
|
+
app.post("/v1/sourcemaps", asyncRoute(async (req, res, next) => {
|
|
96
171
|
// On failure the body is never read (Node discards it).
|
|
97
|
-
const ctx = await authenticate(req, res, "
|
|
172
|
+
const ctx = await authenticate(req, res, "sourcemaps");
|
|
98
173
|
if (!ctx)
|
|
99
174
|
return;
|
|
100
|
-
sourceMapBody(req, res,
|
|
175
|
+
sourceMapBody(req, res, (err) => {
|
|
101
176
|
if (err) {
|
|
177
|
+
// 413 / bad JSON: the error handler answers and counts it.
|
|
102
178
|
next(err);
|
|
103
179
|
return;
|
|
104
180
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
114
196
|
res.status(202).json({ accepted: true });
|
|
115
|
-
}
|
|
116
|
-
catch (storeErr) {
|
|
117
|
-
storageError(res, storeErr);
|
|
118
|
-
}
|
|
197
|
+
})().catch(next);
|
|
119
198
|
});
|
|
120
|
-
});
|
|
199
|
+
}));
|
|
121
200
|
app.use(express.json({
|
|
122
201
|
limit: config.maxBodyBytes,
|
|
123
202
|
type: ["application/json"],
|
|
@@ -197,47 +276,72 @@ export function createIngesterApp(config) {
|
|
|
197
276
|
res.setHeader("cache-control", "no-store");
|
|
198
277
|
res.status(200).json(body);
|
|
199
278
|
});
|
|
200
|
-
/**
|
|
279
|
+
/**
|
|
280
|
+
* Auth + scope + rate limit; returns null (response sent) on failure.
|
|
281
|
+
* Server keys pass everywhere; client keys only on "browser"; upload
|
|
282
|
+
* keys only on "sourcemaps". A new route that passes "otlp" is closed to
|
|
283
|
+
* client and upload keys without further changes.
|
|
284
|
+
*/
|
|
201
285
|
async function authenticate(req, res, surface) {
|
|
202
286
|
// A storage-less ingester must refuse, not accept-and-drop: exporters
|
|
203
|
-
// 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.
|
|
204
289
|
if (!store.configured) {
|
|
205
290
|
res.status(503).json({ error: "storage not configured" });
|
|
206
291
|
return null;
|
|
207
292
|
}
|
|
208
293
|
const key = keys.extractKey(req);
|
|
209
294
|
if (!key) {
|
|
210
|
-
res
|
|
295
|
+
refuse(req, res, null, "missing_key", 401, { error: "missing ingest key" });
|
|
211
296
|
return null;
|
|
212
297
|
}
|
|
213
|
-
const
|
|
298
|
+
const resolution = await keys.resolveDetailed(key);
|
|
299
|
+
const ctx = resolution.ctx;
|
|
214
300
|
if (!ctx) {
|
|
215
|
-
|
|
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" });
|
|
216
304
|
return null;
|
|
217
305
|
}
|
|
218
|
-
if (ctx.scope === "
|
|
306
|
+
if (ctx.scope === "upload") {
|
|
307
|
+
// CI keys: source map upload and check only. Server rate window
|
|
308
|
+
// (each key has its own).
|
|
309
|
+
if (surface !== "sourcemaps") {
|
|
310
|
+
refuse(req, res, ctx, "upload_key_not_allowed", 403, UPLOAD_KEY_NOT_ALLOWED);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (ctx.scope === "client") {
|
|
219
315
|
// Publishable keys: browser surface only, origin allow-list, and
|
|
220
316
|
// the tighter rate window.
|
|
221
317
|
if (surface !== "browser") {
|
|
222
|
-
res
|
|
223
|
-
error:
|
|
318
|
+
refuse(req, res, ctx, "client_key_not_allowed", 403, {
|
|
319
|
+
error: surface === "sourcemaps"
|
|
320
|
+
? "client keys cannot upload source maps — use an upload key (autter_rtu_…) or a server key"
|
|
321
|
+
: "client keys cannot send OTLP — use a server key",
|
|
224
322
|
});
|
|
225
323
|
return null;
|
|
226
324
|
}
|
|
227
325
|
const origin = req.headers.origin;
|
|
228
326
|
if (ctx.allowedOrigins.length > 0 &&
|
|
229
327
|
(!origin || !ctx.allowedOrigins.includes(origin))) {
|
|
230
|
-
res
|
|
328
|
+
refuse(req, res, ctx, "origin_not_allowed", 403, { error: "origin not allowed for this key" });
|
|
231
329
|
return null;
|
|
232
330
|
}
|
|
233
331
|
if (!clientRateLimiter.allow(key)) {
|
|
234
|
-
res
|
|
332
|
+
refuse(req, res, ctx, "rate_limited", 429, { error: "rate limit exceeded" });
|
|
235
333
|
return null;
|
|
236
334
|
}
|
|
237
335
|
return ctx;
|
|
238
336
|
}
|
|
337
|
+
else if (ctx.scope !== "server") {
|
|
338
|
+
// KeyResolver never yields another scope; refuse rather than
|
|
339
|
+
// treat an unknown one as a server key.
|
|
340
|
+
refuse(req, res, ctx, "key_scope_not_allowed", 403, { error: "key scope not allowed" });
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
239
343
|
if (!serverRateLimiter.allow(key)) {
|
|
240
|
-
res
|
|
344
|
+
refuse(req, res, ctx, "rate_limited", 429, { error: "rate limit exceeded" });
|
|
241
345
|
return null;
|
|
242
346
|
}
|
|
243
347
|
return ctx;
|
|
@@ -275,8 +379,13 @@ export function createIngesterApp(config) {
|
|
|
275
379
|
* `fingerprint` / `fingerprintScheme` stay the legacy identity
|
|
276
380
|
* (message-v1 or code-v1) byte-for-byte; `fingerprints` carries every
|
|
277
381
|
* candidate (manual-v1, code-v1, frames-v3, message-v1) for the platform
|
|
278
|
-
* to resolve the issue from.
|
|
279
|
-
|
|
382
|
+
* to resolve the issue from.
|
|
383
|
+
*
|
|
384
|
+
* `batchIndexes` (traces): each occurrence's position among the batch's
|
|
385
|
+
* signals before the per-trace dedupe, so a merged occurrence keeps the
|
|
386
|
+
* id of the recording it kept and a drop never shifts another's id.
|
|
387
|
+
* Defaults to the position in `inputs`. */
|
|
388
|
+
async function fingerprintAll(ctx, inputs, batchIndexes) {
|
|
280
389
|
return Promise.all(inputs.map(async (raw, index) => {
|
|
281
390
|
// Second line of defence, before fingerprinting, storage, and the
|
|
282
391
|
// sink (which feeds LLM fix generation): scrub secrets from the
|
|
@@ -291,7 +400,7 @@ export function createIngesterApp(config) {
|
|
|
291
400
|
const { debugIds: _debugIds, ...stored } = input;
|
|
292
401
|
return {
|
|
293
402
|
...stored,
|
|
294
|
-
occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
|
|
403
|
+
occurrenceId: occurrenceIdFor(ctx, input, fingerprint, batchIndexes?.[index] ?? index),
|
|
295
404
|
fingerprint,
|
|
296
405
|
fingerprintScheme: scheme,
|
|
297
406
|
...deriveFields(input),
|
|
@@ -326,25 +435,51 @@ export function createIngesterApp(config) {
|
|
|
326
435
|
}
|
|
327
436
|
return fingerprinted.filter((o) => !o.traceId || !seen.has(o.traceId));
|
|
328
437
|
}
|
|
329
|
-
function storageError(res, err) {
|
|
438
|
+
function storageError(req, res, ctx, err, records) {
|
|
330
439
|
console.error("clickhouse write failed:", err);
|
|
331
|
-
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";
|
|
332
450
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
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) {
|
|
336
467
|
if (req.is("application/x-protobuf")) {
|
|
337
|
-
res.status(200).type("application/x-protobuf").end();
|
|
468
|
+
res.status(200).type("application/x-protobuf").end(Buffer.from(encodeExportResponse(partial)));
|
|
338
469
|
return;
|
|
339
470
|
}
|
|
340
|
-
|
|
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
|
+
});
|
|
341
476
|
}
|
|
342
|
-
app.post("/v1/profiles", async (req, res) => {
|
|
477
|
+
app.post("/v1/profiles", asyncRoute(async (req, res) => {
|
|
343
478
|
const ctx = await authenticate(req, res, "otlp");
|
|
344
479
|
if (!ctx)
|
|
345
480
|
return;
|
|
346
481
|
if (!req.is("application/x-pprof") || !Buffer.isBuffer(req.body)) {
|
|
347
|
-
res
|
|
482
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, { error: "expected application/x-pprof" });
|
|
348
483
|
return;
|
|
349
484
|
}
|
|
350
485
|
const service = req.header("x-autter-service")?.trim() ?? "";
|
|
@@ -353,87 +488,111 @@ export function createIngesterApp(config) {
|
|
|
353
488
|
const traceId = req.header("x-autter-trace-id")?.trim() ?? "";
|
|
354
489
|
const instanceId = req.header("x-autter-instance-id")?.trim() ?? "";
|
|
355
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))) {
|
|
356
|
-
res
|
|
491
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid profile metadata" });
|
|
357
492
|
return;
|
|
358
493
|
}
|
|
359
494
|
let samples;
|
|
495
|
+
const stats = { overLimit: 0 };
|
|
360
496
|
try {
|
|
361
|
-
samples = decodeProfile(req.body, { service, environment, release, traceId, instanceId });
|
|
497
|
+
samples = decodeProfile(req.body, { service, environment, release, traceId, instanceId }, stats);
|
|
362
498
|
if (!samples.length)
|
|
363
499
|
throw new Error("empty profile");
|
|
364
500
|
}
|
|
365
501
|
catch {
|
|
366
|
-
res
|
|
502
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid or unsymbolized profile" });
|
|
367
503
|
return;
|
|
368
504
|
}
|
|
369
505
|
try {
|
|
370
506
|
await store.insertProfileSamples(ctx, samples);
|
|
371
|
-
res.status(202).json({ profileId: samples[0].profileId, samples: samples.length });
|
|
372
507
|
}
|
|
373
508
|
catch (err) {
|
|
374
|
-
storageError(res, err);
|
|
509
|
+
storageError(req, res, ctx, err, samples.length + stats.overLimit);
|
|
510
|
+
return;
|
|
375
511
|
}
|
|
376
|
-
|
|
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
|
+
}));
|
|
377
515
|
/** Which of these maps does this tenant already have? Lets
|
|
378
516
|
* `autter-runtime sourcemaps upload` skip unchanged maps (1.6.0+). */
|
|
379
|
-
app.post("/v1/sourcemaps/check", async (req, res) => {
|
|
380
|
-
const ctx = await authenticate(req, res, "
|
|
517
|
+
app.post("/v1/sourcemaps/check", asyncRoute(async (req, res) => {
|
|
518
|
+
const ctx = await authenticate(req, res, "sourcemaps");
|
|
381
519
|
if (!ctx)
|
|
382
520
|
return;
|
|
383
521
|
const check = validateSourceMapCheck(req.body);
|
|
384
522
|
if (!check) {
|
|
385
|
-
res
|
|
523
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid source map check" });
|
|
386
524
|
return;
|
|
387
525
|
}
|
|
526
|
+
let present;
|
|
388
527
|
try {
|
|
389
|
-
|
|
528
|
+
present = await store.sourceMapsPresent(ctx, check);
|
|
390
529
|
}
|
|
391
530
|
catch (err) {
|
|
392
|
-
storageError(res, err);
|
|
531
|
+
storageError(req, res, ctx, err);
|
|
532
|
+
return;
|
|
393
533
|
}
|
|
394
|
-
|
|
534
|
+
res.status(200).json({ present });
|
|
535
|
+
}));
|
|
395
536
|
/** ECS/Kubernetes event forwarders use the same server key as OTLP. */
|
|
396
|
-
app.post("/v1/platform-events", async (req, res) => {
|
|
537
|
+
app.post("/v1/platform-events", asyncRoute(async (req, res) => {
|
|
397
538
|
const ctx = await authenticate(req, res, "otlp");
|
|
398
539
|
if (!ctx)
|
|
399
540
|
return;
|
|
400
541
|
const parsed = platformEventSchema.safeParse(req.body);
|
|
401
542
|
if (!parsed.success) {
|
|
402
|
-
res
|
|
543
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid platform event" });
|
|
403
544
|
return;
|
|
404
545
|
}
|
|
405
546
|
const event = normalizePlatformEvent(parsed.data);
|
|
406
547
|
if (!event) {
|
|
407
|
-
res
|
|
548
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "event timestamp outside retention window" });
|
|
408
549
|
return;
|
|
409
550
|
}
|
|
410
551
|
try {
|
|
411
552
|
await store.insertPlatformEvent(ctx, event);
|
|
412
|
-
res.status(202).json({ accepted: true });
|
|
413
553
|
}
|
|
414
554
|
catch (err) {
|
|
415
|
-
storageError(res, err);
|
|
555
|
+
storageError(req, res, ctx, err);
|
|
556
|
+
return;
|
|
416
557
|
}
|
|
417
|
-
|
|
418
|
-
|
|
558
|
+
res.status(202).json({ accepted: true });
|
|
559
|
+
}));
|
|
560
|
+
app.post("/v1/traces", asyncRoute(async (req, res) => {
|
|
419
561
|
const ctx = await authenticate(req, res, "otlp");
|
|
420
562
|
if (!ctx)
|
|
421
563
|
return;
|
|
564
|
+
if (unsupportedOtlpBody(req)) {
|
|
565
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
422
568
|
let request;
|
|
423
569
|
if (req.is("application/x-protobuf")) {
|
|
424
570
|
try {
|
|
425
571
|
request = decodeTraceRequest(req.body);
|
|
426
572
|
}
|
|
427
573
|
catch {
|
|
428
|
-
res
|
|
574
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid protobuf payload" });
|
|
429
575
|
return;
|
|
430
576
|
}
|
|
431
577
|
}
|
|
432
578
|
else {
|
|
433
579
|
request = req.body;
|
|
434
580
|
}
|
|
435
|
-
const
|
|
436
|
-
|
|
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;
|
|
595
|
+
const fingerprinted = await fingerprintAll(ctx, occurrences, occurrenceBatchIndexes);
|
|
437
596
|
// ClickHouse has no cross-table transaction, so these four inserts can
|
|
438
597
|
// partially commit. Recovery boundary: any failure → 503 → the exporter
|
|
439
598
|
// retries the whole batch. Deterministic occurrence ids make the retry
|
|
@@ -451,25 +610,39 @@ export function createIngesterApp(config) {
|
|
|
451
610
|
]);
|
|
452
611
|
}
|
|
453
612
|
catch (err) {
|
|
454
|
-
storageError(res, err);
|
|
613
|
+
storageError(req, res, ctx, err, totalSpans);
|
|
455
614
|
return;
|
|
456
615
|
}
|
|
457
616
|
sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
|
|
458
|
-
recordSdks(ctx, request);
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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) => {
|
|
462
629
|
const ctx = await authenticate(req, res, "otlp");
|
|
463
630
|
if (!ctx)
|
|
464
631
|
return;
|
|
632
|
+
if (unsupportedOtlpBody(req)) {
|
|
633
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
465
636
|
let logs;
|
|
466
637
|
let logsRequest;
|
|
638
|
+
let truncated;
|
|
639
|
+
const stats = { overLimit: 0 };
|
|
467
640
|
try {
|
|
468
641
|
logsRequest = req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body;
|
|
469
|
-
logs = normalizeLogs(logsRequest);
|
|
642
|
+
({ result: logs, truncated } = countTruncations(() => normalizeLogs(logsRequest, stats)));
|
|
470
643
|
}
|
|
471
644
|
catch {
|
|
472
|
-
res
|
|
645
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid OTLP logs payload" });
|
|
473
646
|
return;
|
|
474
647
|
}
|
|
475
648
|
// Logs are diagnostic evidence; exceptions and failed outcomes normally
|
|
@@ -486,46 +659,84 @@ export function createIngesterApp(config) {
|
|
|
486
659
|
]);
|
|
487
660
|
}
|
|
488
661
|
catch (err) {
|
|
489
|
-
storageError(res, err);
|
|
662
|
+
storageError(req, res, ctx, err, logs.length + stats.overLimit);
|
|
490
663
|
return;
|
|
491
664
|
}
|
|
492
665
|
sink?.enqueue(ctx, promoted, metricPoints);
|
|
493
|
-
recordSdks(ctx, logsRequest);
|
|
494
|
-
otlpSuccess(req, res
|
|
495
|
-
|
|
496
|
-
|
|
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) => {
|
|
497
676
|
const ctx = await authenticate(req, res, "otlp");
|
|
498
677
|
if (!ctx)
|
|
499
678
|
return;
|
|
679
|
+
if (unsupportedOtlpBody(req)) {
|
|
680
|
+
refuse(req, res, ctx, "unsupported_media_type", 415, UNSUPPORTED_OTLP);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
500
683
|
let request;
|
|
501
684
|
if (req.is("application/x-protobuf")) {
|
|
502
685
|
try {
|
|
503
686
|
request = decodeMetricsRequest(req.body);
|
|
504
687
|
}
|
|
505
688
|
catch {
|
|
506
|
-
res
|
|
689
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid protobuf payload" });
|
|
507
690
|
return;
|
|
508
691
|
}
|
|
509
692
|
}
|
|
510
693
|
else {
|
|
511
694
|
request = req.body;
|
|
512
695
|
}
|
|
513
|
-
const
|
|
514
|
-
|
|
696
|
+
const memoryStats = { invalid: 0, overLimit: 0 };
|
|
697
|
+
let metricPoints;
|
|
698
|
+
let memorySamples;
|
|
699
|
+
let latency;
|
|
700
|
+
let cumulative;
|
|
701
|
+
try {
|
|
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
|
+
}
|
|
515
711
|
try {
|
|
516
|
-
await store.insertLatencyHistograms(ctx,
|
|
712
|
+
await store.insertLatencyHistograms(ctx, latency);
|
|
517
713
|
await store.insertMetricPoints(ctx, metricPoints);
|
|
518
714
|
await store.insertMemorySamples(ctx, memorySamples);
|
|
519
715
|
}
|
|
520
716
|
catch (err) {
|
|
521
|
-
storageError(res, err);
|
|
717
|
+
storageError(req, res, ctx, err, dataPointsInRequest(request));
|
|
522
718
|
return;
|
|
523
719
|
}
|
|
524
720
|
sink?.enqueue(ctx, [], metricPoints);
|
|
525
|
-
recordSdks(ctx, request);
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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) => {
|
|
529
740
|
const ctx = await authenticate(req, res, "browser");
|
|
530
741
|
if (!ctx)
|
|
531
742
|
return;
|
|
@@ -536,19 +747,28 @@ export function createIngesterApp(config) {
|
|
|
536
747
|
body = JSON.parse(body);
|
|
537
748
|
}
|
|
538
749
|
catch {
|
|
539
|
-
res
|
|
750
|
+
refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid json" }, 0);
|
|
540
751
|
return;
|
|
541
752
|
}
|
|
542
753
|
}
|
|
543
|
-
|
|
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);
|
|
544
757
|
if (!parsed.success) {
|
|
545
|
-
res.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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));
|
|
549
769
|
return;
|
|
550
770
|
}
|
|
551
|
-
const { occurrences, metricPoints } =
|
|
771
|
+
const { occurrences, metricPoints } = normalized;
|
|
552
772
|
const fingerprinted = await fingerprintAll(ctx, occurrences);
|
|
553
773
|
try {
|
|
554
774
|
await Promise.all([
|
|
@@ -557,36 +777,56 @@ export function createIngesterApp(config) {
|
|
|
557
777
|
]);
|
|
558
778
|
}
|
|
559
779
|
catch (err) {
|
|
560
|
-
storageError(res, err);
|
|
780
|
+
storageError(req, res, ctx, err, payload.events.length + invalidEvents + overLimitEvents);
|
|
561
781
|
return;
|
|
562
782
|
}
|
|
563
783
|
sink?.enqueue(ctx, fingerprinted, metricPoints);
|
|
564
|
-
if (
|
|
784
|
+
if (payload.sdk) {
|
|
565
785
|
void sdkVersions.observe(ctx, [{
|
|
566
|
-
service:
|
|
567
|
-
environment:
|
|
786
|
+
service: payload.service,
|
|
787
|
+
environment: payload.environment,
|
|
568
788
|
sdkName: BROWSER_SDK_NAME,
|
|
569
|
-
sdkVersion:
|
|
789
|
+
sdkVersion: payload.sdk,
|
|
570
790
|
sdkLanguage: "webjs",
|
|
791
|
+
via: telemetryVia({ userAgent: req.headers["user-agent"] ?? null, browserKeyScope: ctx.scope }),
|
|
571
792
|
}]);
|
|
572
793
|
}
|
|
573
794
|
void reportIngesterInfo();
|
|
574
|
-
|
|
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();
|
|
575
810
|
});
|
|
576
811
|
// Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
|
|
577
|
-
|
|
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) => {
|
|
578
815
|
if (res.headersSent)
|
|
579
816
|
return next(err);
|
|
580
817
|
if (err.type === "entity.too.large") {
|
|
581
818
|
res.status(413).json({ error: "payload too large" });
|
|
819
|
+
countUnrouted(req, "payload_too_large", 413);
|
|
582
820
|
return;
|
|
583
821
|
}
|
|
584
822
|
if (err.status && err.status < 500) {
|
|
585
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);
|
|
586
825
|
return;
|
|
587
826
|
}
|
|
588
827
|
console.error("unhandled error:", err);
|
|
589
828
|
res.status(500).json({ error: "internal error" });
|
|
829
|
+
countUnrouted(req, "internal_error", 500);
|
|
590
830
|
});
|
|
591
|
-
return { app, store, sink, reportIngesterInfo };
|
|
831
|
+
return { app, store, sink, reportIngesterInfo, drops };
|
|
592
832
|
}
|