@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/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 { 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";
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, async (err) => {
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
- 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);
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.status(401).json({ error: "missing ingest key" });
295
+ refuse(req, res, null, "missing_key", 401, { error: "missing ingest key" });
224
296
  return null;
225
297
  }
226
- const ctx = await keys.resolve(key);
298
+ const resolution = await keys.resolveDetailed(key);
299
+ const ctx = resolution.ctx;
227
300
  if (!ctx) {
228
- res.status(401).json({ error: "invalid ingest key" });
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.status(403).json(UPLOAD_KEY_NOT_ALLOWED);
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.status(403).json({
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.status(403).json({ error: "origin not allowed for this key" });
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.status(429).json({ error: "rate limit exceeded" });
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.status(403).json({ error: "key scope not allowed" });
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.status(429).json({ error: "rate limit exceeded" });
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.status(503).json({ error: "storage unavailable, retry later" });
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
- /** OTLP success responses mirror the request encoding: an empty
368
- * protobuf message body for proto clients, JSON otherwise. */
369
- function otlpSuccess(req, res) {
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
- res.status(200).json({ partialSuccess: {} });
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.status(415).json({ error: "expected application/x-pprof" });
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.status(400).json({ error: "invalid profile metadata" });
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.status(400).json({ error: "invalid or unsymbolized profile" });
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.status(400).json({ error: "invalid source map check" });
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
- res.status(200).json({ present: await store.sourceMapsPresent(ctx, check) });
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.status(400).json({ error: "invalid platform event" });
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.status(400).json({ error: "event timestamp outside retention window" });
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
- app.post("/v1/traces", async (req, res) => {
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.status(400).json({ error: "invalid protobuf payload" });
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 { occurrences, occurrenceBatchIndexes, spans, metricPoints, llmCalls } = normalizeTraces(request);
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
- otlpSuccess(req, res);
494
- });
495
- app.post("/v1/logs", async (req, res) => {
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.status(400).json({ error: "invalid OTLP logs payload" });
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
- app.post("/v1/metrics", async (req, res) => {
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.status(400).json({ error: "invalid protobuf payload" });
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 metricPoints = normalizeMetrics(request);
548
- const memorySamples = normalizeMemoryMetrics(request);
696
+ const memoryStats = { invalid: 0, overLimit: 0 };
697
+ let metricPoints;
698
+ let memorySamples;
699
+ let latency;
700
+ let cumulative;
549
701
  try {
550
- await store.insertLatencyHistograms(ctx, normalizeLatencyHistograms(request));
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
- otlpSuccess(req, res);
561
- });
562
- app.post("/v1/browser", async (req, res) => {
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.status(400).json({ error: "invalid json" });
750
+ refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid json" }, 0);
574
751
  return;
575
752
  }
576
753
  }
577
- const parsed = browserPayloadSchema.safeParse(body);
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.status(400).json({
580
- error: "invalid payload",
581
- issues: parsed.error.issues.slice(0, 5),
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 } = normalizeBrowserPayload(parsed.data);
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 (parsed.data.sdk) {
784
+ if (payload.sdk) {
599
785
  void sdkVersions.observe(ctx, [{
600
- service: parsed.data.service,
601
- environment: parsed.data.environment,
786
+ service: payload.service,
787
+ environment: payload.environment,
602
788
  sdkName: BROWSER_SDK_NAME,
603
- sdkVersion: parsed.data.sdk,
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
- res.status(202).json({ accepted: fingerprinted.length });
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
- app.use((err, _req, res, next) => {
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
  }