@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.
Files changed (50) hide show
  1. package/README.md +31 -5
  2. package/dist/auth.d.ts +16 -1
  3. package/dist/auth.js +34 -10
  4. package/dist/clickhouse.d.ts +3 -0
  5. package/dist/clickhouse.js +38 -1
  6. package/dist/compat-manifest.json +37 -0
  7. package/dist/config.d.ts +23 -2
  8. package/dist/config.js +33 -8
  9. package/dist/context.d.ts +5 -0
  10. package/dist/context.js +49 -10
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +8 -2
  13. package/dist/ingest-drops.d.ts +308 -0
  14. package/dist/ingest-drops.js +448 -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/memory.d.ts +9 -1
  20. package/dist/memory.js +28 -7
  21. package/dist/migrations.d.ts +8 -0
  22. package/dist/migrations.js +29 -1
  23. package/dist/normalize-browser.d.ts +53 -25
  24. package/dist/normalize-browser.js +56 -10
  25. package/dist/normalize-otlp.d.ts +6 -0
  26. package/dist/normalize-otlp.js +275 -108
  27. package/dist/otlp-proto.d.ts +14 -0
  28. package/dist/otlp-proto.js +25 -0
  29. package/dist/profiles.d.ts +4 -0
  30. package/dist/profiles.js +6 -2
  31. package/dist/request-spans.d.ts +45 -0
  32. package/dist/request-spans.js +91 -0
  33. package/dist/sdk-versions.d.ts +39 -2
  34. package/dist/sdk-versions.js +49 -8
  35. package/dist/server.d.ts +10 -0
  36. package/dist/server.js +345 -105
  37. package/dist/sink.d.ts +14 -0
  38. package/dist/sink.js +15 -0
  39. package/dist/source-maps-lookup.d.ts +6 -3
  40. package/dist/source-maps-lookup.js +35 -13
  41. package/dist/source-maps.d.ts +6 -2
  42. package/dist/source-maps.js +12 -4
  43. package/dist/stack-frames.d.ts +5 -2
  44. package/dist/stack-frames.js +55 -9
  45. package/dist/symbolicate.d.ts +13 -0
  46. package/dist/symbolicate.js +22 -0
  47. package/dist/trace-dedupe.d.ts +98 -0
  48. package/dist/trace-dedupe.js +385 -0
  49. package/dist/types.d.ts +5 -1
  50. 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 { 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";
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, "otlp");
172
+ const ctx = await authenticate(req, res, "sourcemaps");
98
173
  if (!ctx)
99
174
  return;
100
- sourceMapBody(req, res, async (err) => {
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
- const sourceMap = validateSourceMap(req.body);
106
- if (!sourceMap) {
107
- res.status(400).json({ error: "invalid source map" });
108
- return;
109
- }
110
- // Idempotent: the same release + path replaces the earlier row
111
- // (ReplacingMergeTree), so CI retries and re-runs are harmless.
112
- try {
113
- 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
+ }
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
- /** Auth + scope + rate limit; returns null (response sent) on failure. */
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.status(401).json({ error: "missing ingest key" });
295
+ refuse(req, res, null, "missing_key", 401, { error: "missing ingest key" });
211
296
  return null;
212
297
  }
213
- const ctx = await keys.resolve(key);
298
+ const resolution = await keys.resolveDetailed(key);
299
+ const ctx = resolution.ctx;
214
300
  if (!ctx) {
215
- 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" });
216
304
  return null;
217
305
  }
218
- if (ctx.scope === "client") {
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.status(403).json({
223
- error: "client keys cannot send OTLP — use a server key",
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.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" });
231
329
  return null;
232
330
  }
233
331
  if (!clientRateLimiter.allow(key)) {
234
- res.status(429).json({ error: "rate limit exceeded" });
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.status(429).json({ error: "rate limit exceeded" });
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
- async function fingerprintAll(ctx, inputs) {
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.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";
332
450
  }
333
- /** OTLP success responses mirror the request encoding: an empty
334
- * protobuf message body for proto clients, JSON otherwise. */
335
- 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) {
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
- 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
+ });
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.status(415).json({ error: "expected application/x-pprof" });
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.status(400).json({ error: "invalid profile metadata" });
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.status(400).json({ error: "invalid or unsymbolized profile" });
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, "otlp");
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.status(400).json({ error: "invalid source map check" });
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
- res.status(200).json({ present: await store.sourceMapsPresent(ctx, check) });
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.status(400).json({ error: "invalid platform event" });
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.status(400).json({ error: "event timestamp outside retention window" });
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
- app.post("/v1/traces", async (req, res) => {
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.status(400).json({ error: "invalid protobuf payload" });
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 { occurrences, spans, metricPoints, llmCalls } = normalizeTraces(request);
436
- const fingerprinted = await fingerprintAll(ctx, occurrences);
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
- otlpSuccess(req, res);
460
- });
461
- 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) => {
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.status(400).json({ error: "invalid OTLP logs payload" });
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
- 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) => {
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.status(400).json({ error: "invalid protobuf payload" });
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 metricPoints = normalizeMetrics(request);
514
- const memorySamples = normalizeMemoryMetrics(request);
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, normalizeLatencyHistograms(request));
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
- otlpSuccess(req, res);
527
- });
528
- 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) => {
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.status(400).json({ error: "invalid json" });
750
+ refuse(req, res, ctx, "invalid_payload", 400, { error: "invalid json" }, 0);
540
751
  return;
541
752
  }
542
753
  }
543
- 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);
544
757
  if (!parsed.success) {
545
- res.status(400).json({
546
- error: "invalid payload",
547
- issues: parsed.error.issues.slice(0, 5),
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 } = normalizeBrowserPayload(parsed.data);
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 (parsed.data.sdk) {
784
+ if (payload.sdk) {
565
785
  void sdkVersions.observe(ctx, [{
566
- service: parsed.data.service,
567
- environment: parsed.data.environment,
786
+ service: payload.service,
787
+ environment: payload.environment,
568
788
  sdkName: BROWSER_SDK_NAME,
569
- sdkVersion: parsed.data.sdk,
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
- 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();
575
810
  });
576
811
  // Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
577
- 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) => {
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
  }