@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
@@ -0,0 +1,454 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Every reason, its kind, and (for rejections) the HTTP status it answers with. */
3
+ export const DROP_REASONS = {
4
+ // ── rejected: the request got this status and nothing was stored ──
5
+ /** No key in `Authorization`, `x-autter-key` or `?key=`. */
6
+ missing_key: { kind: "rejected", status: 401 },
7
+ /** Not a configured key, and the validator did not accept it (revoked, mistyped, another repository's deployment). */
8
+ unknown_key: { kind: "rejected", status: 401 },
9
+ /** The key validator could not answer (unreachable, timeout, 5xx) and nothing was cached. */
10
+ key_unverified: { kind: "rejected", status: 401 },
11
+ /** An upload key (`autter_rtu_…`) on a telemetry route. */
12
+ upload_key_not_allowed: { kind: "rejected", status: 403 },
13
+ /** A client key (`autter_rtc_…`) on an OTLP or source map route. */
14
+ client_key_not_allowed: { kind: "rejected", status: 403 },
15
+ /** A client key sent from an origin missing from its allow-list. */
16
+ origin_not_allowed: { kind: "rejected", status: 403 },
17
+ /** A key whose scope this ingester does not know (never yielded by KeyResolver). */
18
+ key_scope_not_allowed: { kind: "rejected", status: 403 },
19
+ rate_limited: { kind: "rejected", status: 429 },
20
+ /** Malformed JSON or protobuf, a schema failure, or values the normaliser can't read. */
21
+ invalid_payload: { kind: "rejected", status: 400 },
22
+ /** The client went away before its body arrived. */
23
+ request_aborted: { kind: "rejected", status: 400 },
24
+ /** Body over MAX_BODY_BYTES (after gzip is inflated), or a source map over 32 MiB. */
25
+ payload_too_large: { kind: "rejected", status: 413 },
26
+ /** A content type or content encoding (zstd, br) the route does not take. */
27
+ unsupported_media_type: { kind: "rejected", status: 415 },
28
+ /** POST to a path the ingester does not serve (route "other"). */
29
+ unknown_route: { kind: "rejected", status: 404 },
30
+ internal_error: { kind: "rejected", status: 500 },
31
+ /** A ClickHouse write failed; exporters retry. */
32
+ storage_unavailable: { kind: "rejected", status: 503 },
33
+ // ── dropped: accepted, but these records were not stored ──
34
+ /** Spans past the per-request limit (5000). */
35
+ spans_over_limit: { kind: "dropped" },
36
+ /** Log records past the per-request limit (2000). */
37
+ log_records_over_limit: { kind: "dropped" },
38
+ /** Data points of cumulative `http.server.duration` / `http.server.request.duration` histograms (only delta is summable). */
39
+ cumulative_histogram: { kind: "dropped" },
40
+ /** Memory/GC data points with a value, timestamp or unit out of range, or without service.name / service.instance.id. */
41
+ memory_point_invalid: { kind: "dropped" },
42
+ /** Memory/GC data points past 5000 in one request. */
43
+ memory_points_over_limit: { kind: "dropped" },
44
+ /** Browser events that failed validation (unknown type, bad timestamp, oversized field). */
45
+ invalid_event: { kind: "dropped" },
46
+ /** Browser events past 50 in one payload. */
47
+ events_over_limit: { kind: "dropped" },
48
+ /** Profile samples past the first 1000. */
49
+ profile_samples_over_limit: { kind: "dropped" },
50
+ // ── truncated: stored, with attribute values cut ──
51
+ attributes_truncated: { kind: "truncated" },
52
+ // ── unforwarded: stored, but the sink webhook gave up on the batch ──
53
+ /** The sink retry buffer overflowed, or one batch was bigger than all of it. */
54
+ sink_overflow: { kind: "unforwarded" },
55
+ /** The sink consumer answered a permanent 4xx. */
56
+ sink_rejected: { kind: "unforwarded" },
57
+ /** SINK_MAX_ATTEMPTS deliveries failed. */
58
+ sink_gave_up: { kind: "unforwarded" },
59
+ // ── collapsed: counted on the minute's sample, payload not stored (1.9.0) ──
60
+ /** Occurrences of a looping error past the first LOOP_COLLAPSE_AFTER of the minute. */
61
+ collapsed: { kind: "collapsed" },
62
+ // ── suppressed: a suppression rule set by the user left them out (1.9.0) ──
63
+ /** Occurrences a `drop` rule matched, or a `sample` rule sampled out. */
64
+ suppressed: { kind: "suppressed" },
65
+ };
66
+ /** Routes counted by name; anything else is "other". `sink` is the webhook. */
67
+ export const DROP_ROUTES = [
68
+ "/v1/traces",
69
+ "/v1/logs",
70
+ "/v1/metrics",
71
+ "/v1/browser",
72
+ "/v1/profiles",
73
+ "/v1/platform-events",
74
+ "/v1/sourcemaps",
75
+ "/v1/sourcemaps/check",
76
+ ];
77
+ /** The route column for a request path (Express matches case-insensitively and ignores a trailing slash). */
78
+ export function dropRoute(path) {
79
+ const normalised = path.toLowerCase().replace(/\/+$/, "");
80
+ return DROP_ROUTES.includes(normalised) ? normalised : "other";
81
+ }
82
+ const ACCEPTED_202 = new Set(["/v1/browser", "/v1/profiles", "/v1/platform-events", "/v1/sourcemaps"]);
83
+ /** The HTTP status a reason is recorded with. `sink` has none (0). */
84
+ export function dropStatus(reason, route) {
85
+ const info = DROP_REASONS[reason];
86
+ if ("status" in info)
87
+ return info.status;
88
+ if (route === "sink")
89
+ return 0;
90
+ return ACCEPTED_202.has(route) ? 202 : 200;
91
+ }
92
+ const AUTTER_KEY_RE = /^autter_rt[cu]?_[A-Za-z0-9_-]{16,256}$/;
93
+ /**
94
+ * sha256 (hex) of a key that did not resolve, when it has an Autter key
95
+ * prefix; "" otherwise. Autter stores the same hash for every key it issues
96
+ * (revoked ones included), so the platform can tell a repository that a
97
+ * revoked or rotated key is still sending, while the ingester never learns
98
+ * whose key it was. Anything without the prefix (another vendor's token, a
99
+ * password in the wrong field) is never hashed or stored.
100
+ */
101
+ export function autterKeyHash(key) {
102
+ return AUTTER_KEY_RE.test(key) ? createHash("sha256").update(key).digest("hex") : "";
103
+ }
104
+ // ── records in a request body ────────────────────────────────────────────
105
+ const arr = (value) => (Array.isArray(value) ? value : []);
106
+ const field = (value, key) => value && typeof value === "object" ? value[key] : undefined;
107
+ /** Leaf counts along a JSON path of array fields: `["resourceSpans", "scopeSpans", "spans"]`. */
108
+ function countJson(root, path) {
109
+ let level = [root];
110
+ for (const key of path.slice(0, -1))
111
+ level = level.flatMap((node) => arr(field(node, key)));
112
+ const last = path[path.length - 1];
113
+ return level.reduce((sum, node) => sum + arr(field(node, last)).length, 0);
114
+ }
115
+ const METRIC_KINDS = ["gauge", "sum", "histogram", "exponentialHistogram", "summary"];
116
+ function countJsonDataPoints(root) {
117
+ let points = 0;
118
+ for (const resource of arr(field(root, "resourceMetrics")))
119
+ for (const scope of arr(field(resource, "scopeMetrics")))
120
+ for (const metric of arr(field(scope, "metrics")))
121
+ for (const kind of METRIC_KINDS)
122
+ points += arr(field(field(metric, kind), "dataPoints")).length;
123
+ return points;
124
+ }
125
+ function readVarint(buf, start, end) {
126
+ let value = 0;
127
+ let scale = 1;
128
+ for (let pos = start; pos < end && pos < start + 10; pos++) {
129
+ const byte = buf[pos];
130
+ value += (byte & 0x7f) * scale;
131
+ if (byte < 0x80)
132
+ return { value, next: pos + 1 };
133
+ scale *= 128;
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * Count the length-delimited fields at a nested path of field numbers
139
+ * (one set per level) without decoding the message: tags are read and
140
+ * everything else is skipped by length. O(body size); null when the bytes
141
+ * are not a well-formed message.
142
+ */
143
+ export function countProtoPath(buf, path) {
144
+ let count = 0;
145
+ const scan = (start, end, depth) => {
146
+ let pos = start;
147
+ while (pos < end) {
148
+ const tag = readVarint(buf, pos, end);
149
+ if (!tag)
150
+ return false;
151
+ pos = tag.next;
152
+ const wire = tag.value % 8;
153
+ const number = Math.floor(tag.value / 8);
154
+ if (wire === 0) {
155
+ const skipped = readVarint(buf, pos, end);
156
+ if (!skipped)
157
+ return false;
158
+ pos = skipped.next;
159
+ }
160
+ else if (wire === 1) {
161
+ pos += 8;
162
+ }
163
+ else if (wire === 5) {
164
+ pos += 4;
165
+ }
166
+ else if (wire === 2) {
167
+ const length = readVarint(buf, pos, end);
168
+ if (!length)
169
+ return false;
170
+ const stop = length.next + length.value;
171
+ if (stop > end)
172
+ return false;
173
+ if (path[depth].includes(number)) {
174
+ if (depth === path.length - 1)
175
+ count += 1;
176
+ else if (!scan(length.next, stop, depth + 1))
177
+ return false;
178
+ }
179
+ pos = stop;
180
+ }
181
+ else {
182
+ return false;
183
+ }
184
+ if (pos > end)
185
+ return false;
186
+ }
187
+ return true;
188
+ };
189
+ return scan(0, buf.length, 0) ? count : null;
190
+ }
191
+ // ExportTraceServiceRequest.resource_spans(1) → ScopeSpans.scope_spans(2) → spans(2), …
192
+ const PROTO_SPANS = [[1], [2], [2]];
193
+ const PROTO_LOG_RECORDS = [[1], [2], [2]];
194
+ // … → Metric.gauge(5) | sum(7) | histogram(9) | exponential_histogram(10) | summary(11) → data_points(1)
195
+ const PROTO_DATA_POINTS = [[1], [2], [2], [5, 7, 9, 10, 11], [1]];
196
+ /**
197
+ * Records (spans, log records, data points, browser events) in a request
198
+ * body that was refused, where they can be known: 0 for a body never read
199
+ * (413, a bad encoding) or not understood. Profiles count samples, which a
200
+ * refused profile never had decoded, so 0; a source map upload or platform
201
+ * event is one record; a source map check carries none.
202
+ */
203
+ export function recordsInBody(route, body, protobuf) {
204
+ try {
205
+ if (route === "/v1/platform-events" || route === "/v1/sourcemaps")
206
+ return 1;
207
+ if (protobuf && Buffer.isBuffer(body)) {
208
+ const path = route === "/v1/traces" ? PROTO_SPANS
209
+ : route === "/v1/logs" ? PROTO_LOG_RECORDS
210
+ : route === "/v1/metrics" ? PROTO_DATA_POINTS
211
+ : null;
212
+ return path ? (countProtoPath(body, path) ?? 0) : 0;
213
+ }
214
+ switch (route) {
215
+ case "/v1/traces":
216
+ return countJson(body, ["resourceSpans", "scopeSpans", "spans"]);
217
+ case "/v1/logs":
218
+ return countJson(body, ["resourceLogs", "scopeLogs", "logRecords"]);
219
+ case "/v1/metrics":
220
+ return countJsonDataPoints(body);
221
+ case "/v1/browser": {
222
+ // Direct browser beacons arrive as text/plain.
223
+ const parsed = typeof body === "string" ? JSON.parse(body) : body;
224
+ return arr(field(parsed, "events")).length;
225
+ }
226
+ default:
227
+ return 0;
228
+ }
229
+ }
230
+ catch {
231
+ return 0;
232
+ }
233
+ }
234
+ /** Spans in an OTLP traces request (JSON-shaped, as decoded). */
235
+ export function spansInRequest(request) {
236
+ return countJson(request, ["resourceSpans", "scopeSpans", "spans"]);
237
+ }
238
+ /** Data points in an OTLP metrics request (JSON-shaped, as decoded). */
239
+ export function dataPointsInRequest(request) {
240
+ return countJsonDataPoints(request);
241
+ }
242
+ /**
243
+ * Data points of cumulative HTTP server duration histograms: the request
244
+ * rollups (normalizeMetrics) and the latency table (normalizeLatencyHistograms)
245
+ * both skip them, since cumulative points repeat lifetime totals on every
246
+ * export. Mirrors normalizeMetrics' check; keep the two in step.
247
+ */
248
+ export function cumulativeHttpHistogramPoints(request) {
249
+ let points = 0;
250
+ for (const resource of arr(field(request, "resourceMetrics")))
251
+ for (const scope of arr(field(resource, "scopeMetrics")))
252
+ for (const metric of arr(field(scope, "metrics"))) {
253
+ const name = field(metric, "name");
254
+ if (name !== "http.server.duration" && name !== "http.server.request.duration")
255
+ continue;
256
+ const histogram = field(metric, "histogram");
257
+ const temporality = field(histogram, "aggregationTemporality");
258
+ if (temporality !== 2 && temporality !== "AGGREGATION_TEMPORALITY_CUMULATIVE")
259
+ continue;
260
+ points += arr(field(histogram, "dataPoints")).length;
261
+ }
262
+ return points;
263
+ }
264
+ /** Merge per-reason rejections into one partial success; null when nothing was rejected. */
265
+ export function partialSuccess(parts) {
266
+ const kept = parts.filter((part) => part.rejected > 0);
267
+ if (kept.length === 0)
268
+ return null;
269
+ return {
270
+ rejected: kept.reduce((sum, part) => sum + part.rejected, 0),
271
+ errorMessage: kept.map((part) => part.message).join(" "),
272
+ };
273
+ }
274
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
275
+ export const PARTIAL_MESSAGES = {
276
+ spans: (rejected, limit) => `${plural(rejected, "span")} rejected: one request takes at most ${limit} spans; send smaller batches (OTel Collector: batch processor send_batch_max_size).`,
277
+ logRecords: (rejected, limit) => `${plural(rejected, "log record")} rejected: one request takes at most ${limit} log records; send smaller batches (OTel Collector: batch processor send_batch_max_size).`,
278
+ cumulative: (rejected) => `${plural(rejected, "data point")} of cumulative http.server.duration / http.server.request.duration histograms rejected: request counts need delta temporality (OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta); without it they come from server spans.`,
279
+ memoryInvalid: (rejected) => `${plural(rejected, "memory data point")} rejected: the resource needs service.name and service.instance.id, and each point a value, timestamp (at most 30 days old, 5 minutes ahead) and byte unit in range.`,
280
+ memoryOverLimit: (rejected, limit) => `${plural(rejected, "memory data point")} rejected: one request takes at most ${limit}.`,
281
+ };
282
+ export class IngestDropCounter {
283
+ write;
284
+ rows = new Map();
285
+ keyHashes = new Set();
286
+ overflowRequests = 0;
287
+ overflowSinceLog = 0;
288
+ inFlight = null;
289
+ timer = null;
290
+ written = 0;
291
+ failedWrites = 0;
292
+ discardedRows = 0;
293
+ lastErrorLog = Number.NEGATIVE_INFINITY;
294
+ flushIntervalMs;
295
+ maxRows;
296
+ maxKeyHashes;
297
+ writeTimeoutMs;
298
+ enabled;
299
+ now;
300
+ log;
301
+ constructor(write, options = {}) {
302
+ this.write = write;
303
+ this.flushIntervalMs = options.flushIntervalMs ?? 5_000;
304
+ this.maxRows = options.maxRows ?? 10_000;
305
+ this.maxKeyHashes = options.maxKeyHashes ?? 1_000;
306
+ this.writeTimeoutMs = options.writeTimeoutMs ?? 30_000;
307
+ this.enabled = options.enabled ?? true;
308
+ this.now = options.now ?? Date.now;
309
+ this.log = options.log ?? ((message) => console.warn(message));
310
+ }
311
+ /** Count a drop. Synchronous and O(1); never throws. */
312
+ record(drop) {
313
+ if (!this.enabled)
314
+ return;
315
+ try {
316
+ const requests = Math.max(0, Math.round(drop.requests ?? 1));
317
+ const records = Math.max(0, Math.round(drop.records ?? 0));
318
+ if (requests === 0 && records === 0)
319
+ return;
320
+ const tenant = drop.tenant;
321
+ let keyHash = tenant ? "" : (drop.keyHash ?? "");
322
+ if (keyHash && !this.keyHashes.has(keyHash) && this.keyHashes.size >= this.maxKeyHashes)
323
+ keyHash = "";
324
+ const minuteMs = Math.floor(this.now() / 60_000) * 60_000;
325
+ const status = drop.status ?? dropStatus(drop.reason, drop.route);
326
+ const orgId = tenant?.orgId ?? "";
327
+ const repositoryId = tenant?.repositoryId ?? "";
328
+ const key = [orgId, repositoryId, minuteMs, drop.route, drop.reason, status, keyHash].join("\u0000");
329
+ const existing = this.rows.get(key);
330
+ if (existing) {
331
+ existing.requests += requests;
332
+ existing.records += records;
333
+ return;
334
+ }
335
+ if (this.rows.size >= this.maxRows) {
336
+ this.overflowRequests += requests;
337
+ this.overflowSinceLog += requests;
338
+ return;
339
+ }
340
+ if (keyHash)
341
+ this.keyHashes.add(keyHash);
342
+ this.rows.set(key, {
343
+ orgId,
344
+ repositoryId,
345
+ minute: new Date(minuteMs),
346
+ route: drop.route,
347
+ reason: drop.reason,
348
+ kind: DROP_REASONS[drop.reason].kind,
349
+ status,
350
+ keyHash,
351
+ requests,
352
+ records,
353
+ });
354
+ }
355
+ catch {
356
+ // Counting must never fail ingest.
357
+ }
358
+ }
359
+ /** Write buffered rows every flushIntervalMs (unref'd: never keeps the process alive). */
360
+ start() {
361
+ if (!this.enabled || this.timer)
362
+ return;
363
+ this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
364
+ this.timer.unref?.();
365
+ }
366
+ /** Stop the timer and write what is buffered (shutdown). */
367
+ async stop() {
368
+ if (this.timer) {
369
+ clearInterval(this.timer);
370
+ this.timer = null;
371
+ }
372
+ await this.flush();
373
+ }
374
+ /**
375
+ * Write the buffered rows once. One write at a time: while one is in
376
+ * flight, rows keep accumulating (bounded) for the next. A failed or
377
+ * stalled write is logged and its rows dropped, never retried. Never
378
+ * rejects.
379
+ */
380
+ flush() {
381
+ if (this.inFlight)
382
+ return this.inFlight;
383
+ if (this.overflowSinceLog > 0) {
384
+ this.log(`ingest drop counter full: ${this.overflowSinceLog} request(s) not counted since the last write`);
385
+ this.overflowSinceLog = 0;
386
+ }
387
+ if (this.rows.size === 0)
388
+ return Promise.resolve();
389
+ const batch = [...this.rows.values()];
390
+ this.rows = new Map();
391
+ this.keyHashes = new Set();
392
+ let timeout;
393
+ const stalled = new Promise((_, reject) => {
394
+ timeout = setTimeout(() => reject(new Error(`write did not settle in ${this.writeTimeoutMs} ms`)), this.writeTimeoutMs);
395
+ timeout.unref?.();
396
+ });
397
+ let written;
398
+ try {
399
+ written = this.write(batch);
400
+ }
401
+ catch (err) {
402
+ written = Promise.reject(err);
403
+ }
404
+ this.inFlight = Promise.race([written, stalled])
405
+ .then(() => {
406
+ this.written += batch.length;
407
+ })
408
+ .catch((err) => {
409
+ this.failedWrites += 1;
410
+ this.discardedRows += batch.length;
411
+ // One line a minute at most: a missing table or a ClickHouse
412
+ // outage must not flood the logs every few seconds.
413
+ if (this.now() - this.lastErrorLog >= 60_000) {
414
+ this.lastErrorLog = this.now();
415
+ this.log(`ingest drop counts not written (${batch.length} row(s) dropped): ${err?.message ?? err}`);
416
+ }
417
+ })
418
+ .finally(() => {
419
+ clearTimeout(timeout);
420
+ this.inFlight = null;
421
+ });
422
+ return this.inFlight;
423
+ }
424
+ stats() {
425
+ return {
426
+ buffered: this.rows.size,
427
+ written: this.written,
428
+ failedWrites: this.failedWrites,
429
+ discardedRows: this.discardedRows,
430
+ overflowRequests: this.overflowRequests,
431
+ };
432
+ }
433
+ }
434
+ /**
435
+ * Migration 0018 / baseline. A SummingMergeTree: readers `sum(requests)`,
436
+ * `sum(records)` and GROUP BY, since rows of one key collapse at merge time,
437
+ * eventually. `org_id = '' AND repository_id = ''` is the null tenant: keys
438
+ * that did not resolve (401), with `key_hash` set for Autter-format keys.
439
+ * `status` is the HTTP status answered (2xx for drops inside accepted
440
+ * requests, 0 for the sink).
441
+ */
442
+ export function ingestDropsTableDDL(db) {
443
+ return `CREATE TABLE IF NOT EXISTS ${db}.runtime_ingest_drops (
444
+ org_id String, repository_id String,
445
+ minute DateTime('UTC'),
446
+ route LowCardinality(String), reason LowCardinality(String),
447
+ kind LowCardinality(String), status UInt16,
448
+ key_hash String DEFAULT '',
449
+ requests UInt64, records UInt64
450
+ ) ENGINE = SummingMergeTree((requests, records))
451
+ PARTITION BY toYYYYMM(minute)
452
+ ORDER BY (org_id, repository_id, minute, route, reason, kind, status, key_hash)
453
+ TTL minute + INTERVAL 30 DAY`;
454
+ }
@@ -48,8 +48,9 @@ export interface PromotionLookup {
48
48
  */
49
49
  export declare function promotionLookups(occurrences: RuntimeOccurrenceInput[], now?: number): PromotionLookup[];
50
50
  /**
51
- * One event / one error event per promoted occurrence — the same counting
52
- * the trace path applies to occurrences outside a server span, so issue
53
- * counters for logger-only services don't read 0.
51
+ * One error per promoted occurrence, in `error_count` only — the same
52
+ * counting the trace path applies to errors outside a request (a log record
53
+ * names no request the rollups count), so error counters for logger-only and
54
+ * edge services don't read 0 and no request is invented.
54
55
  */
55
56
  export declare function promotionRollups(occurrences: RuntimeOccurrence[]): RuntimeMetricPoint[];
@@ -81,9 +81,10 @@ export function promotionLookups(occurrences, now = Date.now()) {
81
81
  return lookups;
82
82
  }
83
83
  /**
84
- * One event / one error event per promoted occurrence — the same counting
85
- * the trace path applies to occurrences outside a server span, so issue
86
- * counters for logger-only services don't read 0.
84
+ * One error per promoted occurrence, in `error_count` only — the same
85
+ * counting the trace path applies to errors outside a request (a log record
86
+ * names no request the rollups count), so error counters for logger-only and
87
+ * edge services don't read 0 and no request is invented.
87
88
  */
88
89
  export function promotionRollups(occurrences) {
89
90
  const rollups = new Map();
@@ -99,7 +100,6 @@ export function promotionRollups(occurrences) {
99
100
  ].join(" ");
100
101
  const existing = rollups.get(key);
101
102
  if (existing) {
102
- existing.requestCount += 1;
103
103
  existing.errorCount += 1;
104
104
  continue;
105
105
  }
@@ -109,7 +109,7 @@ export function promotionRollups(occurrences) {
109
109
  release: occ.release,
110
110
  route,
111
111
  bucketAt,
112
- requestCount: 1,
112
+ requestCount: 0,
113
113
  errorCount: 1,
114
114
  durationSumMs: 0,
115
115
  sessionCount: 0,
package/dist/logs.d.ts CHANGED
@@ -61,7 +61,22 @@ export interface OtlpLogsRequest {
61
61
  }>;
62
62
  }>;
63
63
  }
64
- export declare function normalizeLogs(request: OtlpLogsRequest): RuntimeLogRecord[];
64
+ /**
65
+ * Log records one request may carry. From 1.8.0 the ones past it are
66
+ * rejected on their own (counted in `stats.overLimit`, answered in the OTLP
67
+ * `partialSuccess`); before, the whole request got a 400.
68
+ */
69
+ export declare const MAX_LOG_RECORDS_PER_REQUEST = 2000;
70
+ export interface NormalizeLogsStats {
71
+ /** Records past MAX_LOG_RECORDS_PER_REQUEST: not read, not stored. */
72
+ overLimit: number;
73
+ }
74
+ /**
75
+ * Throws on a structurally invalid request (the caller answers 400); more
76
+ * than 128 resources or scopes is invalid too. Records past
77
+ * MAX_LOG_RECORDS_PER_REQUEST are skipped and counted, not refused.
78
+ */
79
+ export declare function normalizeLogs(request: OtlpLogsRequest, stats?: NormalizeLogsStats): RuntimeLogRecord[];
65
80
  /**
66
81
  * Baseline for fresh databases. The 0012 columns and request-id skip index
67
82
  * are included so a fresh DB matches a migrated one. `ttlDays` is
package/dist/logs.js CHANGED
@@ -3,7 +3,18 @@ import { decodeOtlpAttributes, sanitizeRuntimeContext, } from "./context.js";
3
3
  import { declaredLink, liftErrorFields } from "./error-fields.js";
4
4
  import { fingerprintOverrideOf } from "./fingerprint-override.js";
5
5
  import { normalizeRoute } from "./fingerprint.js";
6
- export function normalizeLogs(request) {
6
+ /**
7
+ * Log records one request may carry. From 1.8.0 the ones past it are
8
+ * rejected on their own (counted in `stats.overLimit`, answered in the OTLP
9
+ * `partialSuccess`); before, the whole request got a 400.
10
+ */
11
+ export const MAX_LOG_RECORDS_PER_REQUEST = 2000;
12
+ /**
13
+ * Throws on a structurally invalid request (the caller answers 400); more
14
+ * than 128 resources or scopes is invalid too. Records past
15
+ * MAX_LOG_RECORDS_PER_REQUEST are skipped and counted, not refused.
16
+ */
17
+ export function normalizeLogs(request, stats) {
7
18
  if (!request || !Array.isArray(request.resourceLogs))
8
19
  throw new Error("resourceLogs must be an array");
9
20
  const rows = [];
@@ -19,10 +30,13 @@ export function normalizeLogs(request) {
19
30
  if (!scope || (scope.logRecords && !Array.isArray(scope.logRecords)))
20
31
  throw new Error("invalid log records");
21
32
  for (const record of scope.logRecords ?? []) {
33
+ if (rows.length >= MAX_LOG_RECORDS_PER_REQUEST) {
34
+ if (stats)
35
+ stats.overLimit += 1;
36
+ continue;
37
+ }
22
38
  if (!record || typeof record !== "object")
23
39
  throw new Error("invalid log record");
24
- if (rows.length >= 2000)
25
- throw new Error("too many log records");
26
40
  const attrs = decodeOtlpAttributes(record.attributes);
27
41
  let event = {};
28
42
  const decodedBody = decodeOtlpAttributes([