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