@autter/otlp-ingester 1.8.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.
@@ -0,0 +1,326 @@
1
+ import { createHash } from "node:crypto";
2
+ const MINUTE_MS = 60_000;
3
+ /**
4
+ * The identity two occurrences must share to fold together (see above),
5
+ * hashed: the raw parts can run to kilobytes (frames, messages).
6
+ */
7
+ export function collapseIdentity(o) {
8
+ const parts = [
9
+ o.fingerprint,
10
+ (o.fingerprints ?? []).map((candidate) => `${candidate.scheme}:${candidate.fingerprint}`).join(","),
11
+ o.service,
12
+ o.environment,
13
+ o.release ?? "",
14
+ o.source,
15
+ o.severity,
16
+ o.expected ? "1" : "0",
17
+ o.errorType,
18
+ o.errorCode ?? "",
19
+ o.messageNormalized,
20
+ o.topFrames.join("\n"),
21
+ o.sessionId ?? "",
22
+ ];
23
+ return createHash("sha256").update(parts.join("\u0000")).digest("base64url").slice(0, 32);
24
+ }
25
+ const tenantKey = (ctx) => `${ctx.orgId}\u0000${ctx.repositoryId}`;
26
+ export class LoopCollapser {
27
+ flushSink;
28
+ minutes = new Map();
29
+ /** Folds committed after their window was flushed, and failed flushes. */
30
+ carry = [];
31
+ heldSamples = 0;
32
+ trackedIds = new Map();
33
+ timer = null;
34
+ flushing = null;
35
+ lastFailureLog = Number.NEGATIVE_INFINITY;
36
+ stats_ = { folded: 0, samplesFlushed: 0, overflow: 0, failedFlushes: 0 };
37
+ after;
38
+ maxWindows;
39
+ maxSamples;
40
+ maxTrackedIds;
41
+ flushIntervalMs;
42
+ forceAfterMs;
43
+ now;
44
+ log;
45
+ constructor(flushSink, options) {
46
+ this.flushSink = flushSink;
47
+ this.after = Math.max(0, Math.floor(options.after));
48
+ this.maxWindows = options.maxWindows ?? 100_000;
49
+ this.maxSamples = options.maxSamples ?? 5_000;
50
+ this.maxTrackedIds = options.maxTrackedIds ?? 500_000;
51
+ this.flushIntervalMs = options.flushIntervalMs ?? 5_000;
52
+ this.forceAfterMs = options.forceAfterMs ?? 4 * MINUTE_MS;
53
+ this.now = options.now ?? Date.now;
54
+ this.log = options.log ?? ((message) => console.warn(message));
55
+ }
56
+ get enabled() {
57
+ return this.after > 0;
58
+ }
59
+ /**
60
+ * Split a request's occurrences into those stored now and those folded
61
+ * into their window's sample. Synchronous; never throws (on an internal
62
+ * error every occurrence is kept).
63
+ */
64
+ plan(ctx, route, occurrences) {
65
+ if (!this.enabled || occurrences.length === 0)
66
+ return passThrough(occurrences);
67
+ try {
68
+ return this.planWindows(ctx, route, occurrences);
69
+ }
70
+ catch (err) {
71
+ this.log(`loop collapse skipped for a batch: ${err?.message ?? err}`);
72
+ return passThrough(occurrences);
73
+ }
74
+ }
75
+ planWindows(ctx, route, occurrences) {
76
+ const minute = Math.floor(this.now() / MINUTE_MS);
77
+ let windows = this.minutes.get(minute);
78
+ if (!windows) {
79
+ windows = new Map();
80
+ this.minutes.set(minute, windows);
81
+ }
82
+ const kept = [];
83
+ const keptReservations = [];
84
+ const folds = [];
85
+ const touched = new Set();
86
+ let repeated = 0;
87
+ const tenant = tenantKey(ctx);
88
+ for (const o of occurrences) {
89
+ const key = `${tenant}\u0000${route}\u0000${collapseIdentity(o)}`;
90
+ let window = windows.get(key);
91
+ if (!window) {
92
+ if (windows.size >= this.maxWindows) {
93
+ this.stats_.overflow += 1;
94
+ kept.push(o);
95
+ continue;
96
+ }
97
+ window = {
98
+ ctx, route, minute, admitted: 0, kept: new Set(), foldedIds: new Set(),
99
+ count: 0, sample: null, reserved: 0, pending: 0, holdsSlot: false, flushed: false, late: null,
100
+ };
101
+ windows.set(key, window);
102
+ }
103
+ if (!touched.has(window)) {
104
+ touched.add(window);
105
+ window.pending += 1;
106
+ }
107
+ const id = o.occurrenceId;
108
+ if (window.kept.has(id)) {
109
+ // A retried batch: stored again with the same id (deduped downstream).
110
+ kept.push(o);
111
+ continue;
112
+ }
113
+ if (window.foldedIds?.has(id)) {
114
+ repeated += 1;
115
+ continue;
116
+ }
117
+ if (window.admitted < this.after) {
118
+ window.admitted += 1;
119
+ window.kept.add(id);
120
+ keptReservations.push([window, id]);
121
+ kept.push(o);
122
+ continue;
123
+ }
124
+ if (!window.holdsSlot) {
125
+ if (this.heldSamples >= this.maxSamples) {
126
+ this.stats_.overflow += 1;
127
+ kept.push(o);
128
+ continue;
129
+ }
130
+ window.holdsSlot = true;
131
+ this.heldSamples += 1;
132
+ }
133
+ window.admitted += 1;
134
+ window.reserved += 1;
135
+ if (window.foldedIds) {
136
+ const tracked = this.trackedIds.get(minute) ?? 0;
137
+ if (tracked < this.maxTrackedIds) {
138
+ window.foldedIds.add(id);
139
+ this.trackedIds.set(minute, tracked + 1);
140
+ }
141
+ else {
142
+ window.foldedIds = null;
143
+ }
144
+ }
145
+ folds.push([window, o]);
146
+ }
147
+ let settled = false;
148
+ const release = () => {
149
+ for (const window of touched)
150
+ window.pending -= 1;
151
+ };
152
+ return {
153
+ kept,
154
+ folded: folds.length,
155
+ repeated,
156
+ commit: () => {
157
+ if (settled)
158
+ return;
159
+ settled = true;
160
+ for (const [window, o] of folds) {
161
+ window.reserved -= 1;
162
+ if (window.flushed) {
163
+ // Its minute was flushed while this request ran: carry the fold
164
+ // (with every other late one of the window) into the next flush.
165
+ if (!window.late) {
166
+ window.late = { ctx: window.ctx, route: window.route, sample: o, count: 0, window: null, lateOf: window };
167
+ this.carry.push(window.late);
168
+ }
169
+ window.late.count += 1;
170
+ if (o.occurredAt.getTime() >= window.late.sample.occurredAt.getTime())
171
+ window.late.sample = o;
172
+ continue;
173
+ }
174
+ window.count += 1;
175
+ if (!window.sample || o.occurredAt.getTime() >= window.sample.occurredAt.getTime())
176
+ window.sample = o;
177
+ }
178
+ this.stats_.folded += folds.length;
179
+ release();
180
+ },
181
+ rollback: () => {
182
+ if (settled)
183
+ return;
184
+ settled = true;
185
+ for (const [window, id] of keptReservations) {
186
+ window.admitted -= 1;
187
+ window.kept.delete(id);
188
+ }
189
+ for (const [window, o] of folds) {
190
+ window.admitted -= 1;
191
+ window.reserved -= 1;
192
+ window.foldedIds?.delete(o.occurrenceId);
193
+ // Nothing folded after all: free the sample slot. (A flushed
194
+ // window's slot was freed by the flush, or is by its sample.)
195
+ if (!window.flushed && window.count === 0 && window.reserved === 0)
196
+ this.releaseSlot(window);
197
+ }
198
+ release();
199
+ },
200
+ };
201
+ }
202
+ /** Flush due windows every flushIntervalMs (unref'd). */
203
+ start() {
204
+ if (!this.enabled || this.timer)
205
+ return;
206
+ this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
207
+ this.timer.unref?.();
208
+ }
209
+ /** Stop the timer and flush every window, the current minute's too (shutdown). */
210
+ async stop() {
211
+ if (this.timer) {
212
+ clearInterval(this.timer);
213
+ this.timer = null;
214
+ }
215
+ await this.flush(true);
216
+ }
217
+ /**
218
+ * Store the samples of every window whose minute is over and that no
219
+ * request still holds, plus carried folds and earlier failed writes.
220
+ * `all`: every window, current minute included. One flush at a time;
221
+ * never rejects.
222
+ */
223
+ flush(all = false) {
224
+ if (this.flushing) {
225
+ if (!all)
226
+ return this.flushing;
227
+ return this.flushing.then(() => this.flush(true));
228
+ }
229
+ this.flushing = this.flushDue(all).finally(() => {
230
+ this.flushing = null;
231
+ });
232
+ return this.flushing;
233
+ }
234
+ collectDue(all) {
235
+ const due = this.carry;
236
+ this.carry = [];
237
+ // Late folds committed from now on start a new carried sample.
238
+ for (const item of due)
239
+ if (item.lateOf?.late === item)
240
+ item.lateOf.late = null;
241
+ const nowMs = this.now();
242
+ const current = Math.floor(nowMs / MINUTE_MS);
243
+ for (const [minute, windows] of this.minutes) {
244
+ if (!all && minute >= current)
245
+ continue;
246
+ const forced = all || nowMs - (minute + 1) * MINUTE_MS >= this.forceAfterMs;
247
+ let open = 0;
248
+ for (const window of windows.values()) {
249
+ if (window.flushed)
250
+ continue;
251
+ if (window.pending > 0 && !forced) {
252
+ open += 1;
253
+ continue;
254
+ }
255
+ window.flushed = true;
256
+ if (window.count > 0 && window.sample) {
257
+ due.push({ ctx: window.ctx, route: window.route, sample: window.sample, count: window.count, window, lateOf: null });
258
+ }
259
+ else {
260
+ // Nothing to store; folds still in flight are carried without a slot.
261
+ this.releaseSlot(window);
262
+ }
263
+ }
264
+ if (open === 0) {
265
+ this.minutes.delete(minute);
266
+ this.trackedIds.delete(minute);
267
+ }
268
+ }
269
+ return due;
270
+ }
271
+ async flushDue(all) {
272
+ let due;
273
+ try {
274
+ due = this.collectDue(all);
275
+ }
276
+ catch (err) {
277
+ this.log(`loop collapse flush failed: ${err?.message ?? err}`);
278
+ return;
279
+ }
280
+ if (due.length === 0)
281
+ return;
282
+ const byTenant = new Map();
283
+ for (const item of due) {
284
+ const key = tenantKey(item.ctx);
285
+ byTenant.set(key, [...(byTenant.get(key) ?? []), item]);
286
+ }
287
+ for (const items of byTenant.values()) {
288
+ const samples = items.map((item) => ({
289
+ route: item.route,
290
+ sample: { ...item.sample, collapsedCount: item.count - 1 },
291
+ folded: item.count,
292
+ }));
293
+ try {
294
+ await this.flushSink(items[0].ctx, samples);
295
+ this.stats_.samplesFlushed += samples.length;
296
+ for (const item of items)
297
+ if (item.window)
298
+ this.releaseSlot(item.window);
299
+ }
300
+ catch (err) {
301
+ this.stats_.failedFlushes += 1;
302
+ // Kept (with their slots) and written again next tick.
303
+ this.carry.push(...items);
304
+ if (this.now() - this.lastFailureLog >= MINUTE_MS) {
305
+ this.lastFailureLog = this.now();
306
+ this.log(`loop collapse: ${samples.length} collapsed sample(s) not stored yet, retrying: ${err?.message ?? err}`);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ releaseSlot(window) {
312
+ if (!window.holdsSlot)
313
+ return;
314
+ window.holdsSlot = false;
315
+ this.heldSamples -= 1;
316
+ }
317
+ stats() {
318
+ let windows = 0;
319
+ for (const minute of this.minutes.values())
320
+ windows += minute.size;
321
+ return { windows, heldSamples: this.heldSamples, ...this.stats_ };
322
+ }
323
+ }
324
+ function passThrough(occurrences) {
325
+ return { kept: occurrences, folded: 0, repeated: 0, commit() { }, rollback() { } };
326
+ }
@@ -53,6 +53,18 @@ export declare const OCCURRENCE_GROUPING_COLUMNS: string[];
53
53
  * before 0020.
54
54
  */
55
55
  export declare const OCCURRENCE_FRAME_RESOLUTION_COLUMNS: string[];
56
+ /**
57
+ * Migration 0021 column — shared with the baseline DDL in clickhouse.ts.
58
+ * Loop collapse (1.9.0, loop-collapse.ts): the identical occurrences a
59
+ * minute's collapsed sample stands for besides itself, whose payloads were
60
+ * not stored; 0 on every other row. An exact occurrence count is, per
61
+ * distinct `occurrence_id` (an exporter retry can store a row twice),
62
+ * 1 + collapsed_count: `SELECT sum(1 + c) FROM (SELECT occurrence_id,
63
+ * max(collapsed_count) AS c … GROUP BY occurrence_id)`. A metadata-only
64
+ * ALTER with a constant default: rows written before it, and by older
65
+ * replicas during a rolling deploy, read 0.
66
+ */
67
+ export declare const OCCURRENCE_COLLAPSE_COLUMNS: string[];
56
68
  export interface Migration {
57
69
  /** Unique, ordered id: "<serial>-<slug>". Never reuse or reorder. */
58
70
  id: string;
@@ -74,6 +74,18 @@ export const OCCURRENCE_FRAME_RESOLUTION_COLUMNS = [
74
74
  "in_app_frames_resolved UInt16 DEFAULT 0",
75
75
  "in_app_frames_unresolved UInt16 DEFAULT 0",
76
76
  ];
77
+ /**
78
+ * Migration 0021 column — shared with the baseline DDL in clickhouse.ts.
79
+ * Loop collapse (1.9.0, loop-collapse.ts): the identical occurrences a
80
+ * minute's collapsed sample stands for besides itself, whose payloads were
81
+ * not stored; 0 on every other row. An exact occurrence count is, per
82
+ * distinct `occurrence_id` (an exporter retry can store a row twice),
83
+ * 1 + collapsed_count: `SELECT sum(1 + c) FROM (SELECT occurrence_id,
84
+ * max(collapsed_count) AS c … GROUP BY occurrence_id)`. A metadata-only
85
+ * ALTER with a constant default: rows written before it, and by older
86
+ * replicas during a rolling deploy, read 0.
87
+ */
88
+ export const OCCURRENCE_COLLAPSE_COLUMNS = ["collapsed_count UInt32 DEFAULT 0"];
77
89
  export const MIGRATIONS = [
78
90
  // 0001 intentionally reserved as the baseline marker: databases created
79
91
  // before the migration runner existed record it as applied without
@@ -230,6 +242,11 @@ export const MIGRATIONS = [
230
242
  id: "0020-occurrence-frame-resolution",
231
243
  statements: OCCURRENCE_FRAME_RESOLUTION_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
232
244
  },
245
+ // Loop collapse (1.9.0): the count a minute's collapsed sample carries.
246
+ {
247
+ id: "0021-occurrence-collapsed-count",
248
+ statements: OCCURRENCE_COLLAPSE_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
249
+ },
233
250
  ];
234
251
  /** The tracking table itself — created by the runner before anything else. */
235
252
  export function migrationsTableDDL(db) {
@@ -53,8 +53,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
53
53
  timestamp: string;
54
54
  breadcrumbs?: unknown[] | undefined;
55
55
  route?: string | undefined;
56
- severity?: "info" | "warning" | "error" | "fatal" | undefined;
57
56
  errorType?: string | undefined;
57
+ id?: string | undefined;
58
+ severity?: "info" | "warning" | "error" | "fatal" | undefined;
58
59
  stack?: string | undefined;
59
60
  traceId?: string | undefined;
60
61
  debugIds?: Record<string, unknown> | undefined;
@@ -63,7 +64,6 @@ export declare const browserPayloadSchema: z.ZodObject<{
63
64
  name?: string | undefined;
64
65
  column?: number | undefined;
65
66
  line?: number | undefined;
66
- id?: string | undefined;
67
67
  context?: Record<string, unknown> | undefined;
68
68
  fingerprint?: string | string[] | undefined;
69
69
  }, {
@@ -72,8 +72,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
72
72
  breadcrumbs?: unknown;
73
73
  route?: string | undefined;
74
74
  message?: string | undefined;
75
- severity?: "info" | "warning" | "error" | "fatal" | undefined;
76
75
  errorType?: string | undefined;
76
+ id?: string | undefined;
77
+ severity?: "info" | "warning" | "error" | "fatal" | undefined;
77
78
  stack?: string | undefined;
78
79
  traceId?: unknown;
79
80
  debugIds?: unknown;
@@ -82,7 +83,6 @@ export declare const browserPayloadSchema: z.ZodObject<{
82
83
  name?: string | undefined;
83
84
  column?: number | undefined;
84
85
  line?: number | undefined;
85
- id?: string | undefined;
86
86
  context?: Record<string, unknown> | undefined;
87
87
  fingerprint?: unknown;
88
88
  }>, "many">;
@@ -103,8 +103,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
103
103
  timestamp: string;
104
104
  breadcrumbs?: unknown[] | undefined;
105
105
  route?: string | undefined;
106
- severity?: "info" | "warning" | "error" | "fatal" | undefined;
107
106
  errorType?: string | undefined;
107
+ id?: string | undefined;
108
+ severity?: "info" | "warning" | "error" | "fatal" | undefined;
108
109
  stack?: string | undefined;
109
110
  traceId?: string | undefined;
110
111
  debugIds?: Record<string, unknown> | undefined;
@@ -113,7 +114,6 @@ export declare const browserPayloadSchema: z.ZodObject<{
113
114
  name?: string | undefined;
114
115
  column?: number | undefined;
115
116
  line?: number | undefined;
116
- id?: string | undefined;
117
117
  context?: Record<string, unknown> | undefined;
118
118
  fingerprint?: string | string[] | undefined;
119
119
  }[];
@@ -130,8 +130,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
130
130
  breadcrumbs?: unknown;
131
131
  route?: string | undefined;
132
132
  message?: string | undefined;
133
- severity?: "info" | "warning" | "error" | "fatal" | undefined;
134
133
  errorType?: string | undefined;
134
+ id?: string | undefined;
135
+ severity?: "info" | "warning" | "error" | "fatal" | undefined;
135
136
  stack?: string | undefined;
136
137
  traceId?: unknown;
137
138
  debugIds?: unknown;
@@ -140,7 +141,6 @@ export declare const browserPayloadSchema: z.ZodObject<{
140
141
  name?: string | undefined;
141
142
  column?: number | undefined;
142
143
  line?: number | undefined;
143
- id?: string | undefined;
144
144
  context?: Record<string, unknown> | undefined;
145
145
  fingerprint?: unknown;
146
146
  }[];
package/dist/server.d.ts CHANGED
@@ -3,6 +3,7 @@ import { IngestDropCounter } from "./ingest-drops.js";
3
3
  import { ClickHouseStore } from "./clickhouse.js";
4
4
  import type { IngesterConfig } from "./config.js";
5
5
  import { SinkForwarder } from "./sink.js";
6
+ import { LoopCollapser } from "./loop-collapse.js";
6
7
  export interface IngesterApp {
7
8
  app: Express;
8
9
  store: ClickHouseStore;
@@ -13,6 +14,8 @@ export interface IngesterApp {
13
14
  reportIngesterInfo: () => Promise<void>;
14
15
  /** Rejection and drop counts per tenant (runtime_ingest_drops); `stop()` writes what is buffered. */
15
16
  drops: IngestDropCounter;
17
+ /** Loop collapse (1.9.0): `stop()` stores every window's sample (call it before `drops.stop()`). */
18
+ collapse: LoopCollapser;
16
19
  }
17
20
  /** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
18
21
  export declare const UPLOAD_KEY_ROUTES: readonly ["/v1/sourcemaps", "/v1/sourcemaps/check"];
package/dist/server.js CHANGED
@@ -17,6 +17,8 @@ import { decodeProfile } from "./profiles.js";
17
17
  import { MAX_MEMORY_SAMPLES_PER_REQUEST, normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema, } from "./memory.js";
18
18
  import { SOURCE_MAP_MAX_BODY_BYTES, validateSourceMap, validateSourceMapCheck } from "./source-maps.js";
19
19
  import { SinkForwarder } from "./sink.js";
20
+ import { LoopCollapser } from "./loop-collapse.js";
21
+ import { applySuppression } from "./suppression.js";
20
22
  import { configureRedaction, scrubOccurrence } from "./redact.js";
21
23
  import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
22
24
  import { MIGRATIONS } from "./migrations.js";
@@ -71,6 +73,46 @@ export function createIngesterApp(config) {
71
73
  records: drop.occurrences,
72
74
  });
73
75
  }
76
+ // Loop collapse (1.9.0, loop-collapse.ts): a looping error past its first
77
+ // LOOP_COLLAPSE_AFTER occurrences of the minute is stored as one sample a
78
+ // minute with an exact count. The minute's samples are written here, then
79
+ // forwarded; a failed write is kept and retried by the collapser.
80
+ const collapse = new LoopCollapser(async (ctx, samples) => {
81
+ const occurrences = samples.map((entry) => entry.sample);
82
+ await store.insertOccurrences(ctx, occurrences);
83
+ sink?.enqueue(ctx, occurrences);
84
+ for (const entry of samples) {
85
+ // Payloads not stored: every folded occurrence but the sample itself.
86
+ if (entry.folded > 1) {
87
+ drops.record({ tenant: ctx, route: entry.route, reason: "collapsed", requests: 0, records: entry.folded - 1 });
88
+ }
89
+ }
90
+ }, { after: config.loopCollapseAfter ?? 10 });
91
+ if (store.configured)
92
+ collapse.start();
93
+ /**
94
+ * Volume control on a request's fingerprinted occurrences (1.9.0): the
95
+ * tenant's suppression rules first, then loop collapse. `kept` is what to
96
+ * store and forward now. `commit()` once the request's writes succeeded
97
+ * (folds and suppressions are counted then), `rollback()` when they
98
+ * failed (the exporter retries, and must find the same windows).
99
+ */
100
+ function admit(ctx, req, occurrences) {
101
+ const route = dropRoute(req.path);
102
+ const { kept, suppressed } = applySuppression(occurrences, ctx.suppression);
103
+ const plan = collapse.plan(ctx, route, kept);
104
+ return {
105
+ kept: plan.kept,
106
+ commit() {
107
+ plan.commit();
108
+ if (suppressed > 0)
109
+ drops.record({ tenant: ctx, route, reason: "suppressed", requests: 0, records: suppressed });
110
+ },
111
+ rollback() {
112
+ plan.rollback();
113
+ },
114
+ };
115
+ }
74
116
  const allMigrationIds = MIGRATIONS.map((migration) => migration.id);
75
117
  const compatReport = () => {
76
118
  const schema = store.schemaStatus();
@@ -231,7 +273,11 @@ export function createIngesterApp(config) {
231
273
  next();
232
274
  });
233
275
  app.get("/healthz", async (_req, res) => {
234
- const sinkStats = sink ? { sink: sink.stats() } : {};
276
+ const sinkStats = {
277
+ ...(sink ? { sink: sink.stats() } : {}),
278
+ // Counters only (no tenant data): /healthz is public.
279
+ ...(collapse.enabled ? { collapse: collapse.stats() } : {}),
280
+ };
235
281
  if (!store.configured) {
236
282
  res.status(200).json({ ok: true, clickhouse: "unconfigured", ...sinkStats });
237
283
  return;
@@ -593,6 +639,7 @@ export function createIngesterApp(config) {
593
639
  }
594
640
  const { occurrences, occurrenceBatchIndexes, spans, metricPoints, llmCalls } = normalized;
595
641
  const fingerprinted = await fingerprintAll(ctx, occurrences, occurrenceBatchIndexes);
642
+ const admitted = admit(ctx, req, fingerprinted);
596
643
  // ClickHouse has no cross-table transaction, so these four inserts can
597
644
  // partially commit. Recovery boundary: any failure → 503 → the exporter
598
645
  // retries the whole batch. Deterministic occurrence ids make the retry
@@ -603,17 +650,19 @@ export function createIngesterApp(config) {
603
650
  // ClickHouse reconciliation instead.
604
651
  try {
605
652
  await Promise.all([
606
- store.insertOccurrences(ctx, fingerprinted),
653
+ store.insertOccurrences(ctx, admitted.kept),
607
654
  store.insertSpans(ctx, spans),
608
655
  store.insertMetricPoints(ctx, metricPoints),
609
656
  store.insertLlmCalls(ctx, llmCalls),
610
657
  ]);
611
658
  }
612
659
  catch (err) {
660
+ admitted.rollback();
613
661
  storageError(req, res, ctx, err, totalSpans);
614
662
  return;
615
663
  }
616
- sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
664
+ admitted.commit();
665
+ sink?.enqueue(ctx, admitted.kept, metricPoints, llmCalls);
617
666
  recordSdks(ctx, request, req);
618
667
  // normalizeTraces stops at SPANS_PER_REQUEST; the rest were not read.
619
668
  const overLimit = Math.max(0, totalSpans - normalized.spanCount);
@@ -650,19 +699,23 @@ export function createIngesterApp(config) {
650
699
  // edge SDKs mark span-less error records `autter.capture.mode = "log"`,
651
700
  // and those are promoted to occurrences here — see log-promotion.ts.
652
701
  const promoted = await promoteLogs(ctx, logs);
702
+ // Rollups count every promoted error, collapsed or suppressed ones too.
653
703
  const metricPoints = promotionRollups(promoted);
704
+ const admitted = admit(ctx, req, promoted);
654
705
  try {
655
706
  await Promise.all([
656
707
  store.insertLogs(ctx, logs),
657
- store.insertOccurrences(ctx, promoted),
708
+ store.insertOccurrences(ctx, admitted.kept),
658
709
  store.insertMetricPoints(ctx, metricPoints),
659
710
  ]);
660
711
  }
661
712
  catch (err) {
713
+ admitted.rollback();
662
714
  storageError(req, res, ctx, err, logs.length + stats.overLimit);
663
715
  return;
664
716
  }
665
- sink?.enqueue(ctx, promoted, metricPoints);
717
+ admitted.commit();
718
+ sink?.enqueue(ctx, admitted.kept, metricPoints);
666
719
  recordSdks(ctx, logsRequest, req);
667
720
  otlpSuccess(req, res, "logs", partialSuccess([
668
721
  { rejected: stats.overLimit, message: PARTIAL_MESSAGES.logRecords(stats.overLimit, MAX_LOG_RECORDS_PER_REQUEST) },
@@ -770,17 +823,20 @@ export function createIngesterApp(config) {
770
823
  }
771
824
  const { occurrences, metricPoints } = normalized;
772
825
  const fingerprinted = await fingerprintAll(ctx, occurrences);
826
+ const admitted = admit(ctx, req, fingerprinted);
773
827
  try {
774
828
  await Promise.all([
775
- store.insertOccurrences(ctx, fingerprinted),
829
+ store.insertOccurrences(ctx, admitted.kept),
776
830
  store.insertMetricPoints(ctx, metricPoints),
777
831
  ]);
778
832
  }
779
833
  catch (err) {
834
+ admitted.rollback();
780
835
  storageError(req, res, ctx, err, payload.events.length + invalidEvents + overLimitEvents);
781
836
  return;
782
837
  }
783
- sink?.enqueue(ctx, fingerprinted, metricPoints);
838
+ admitted.commit();
839
+ sink?.enqueue(ctx, admitted.kept, metricPoints);
784
840
  if (payload.sdk) {
785
841
  void sdkVersions.observe(ctx, [{
786
842
  service: payload.service,
@@ -828,5 +884,5 @@ export function createIngesterApp(config) {
828
884
  res.status(500).json({ error: "internal error" });
829
885
  countUnrouted(req, "internal_error", 500);
830
886
  });
831
- return { app, store, sink, reportIngesterInfo, drops };
887
+ return { app, store, sink, reportIngesterInfo, drops, collapse };
832
888
  }
package/dist/sink.d.ts CHANGED
@@ -124,7 +124,10 @@ export declare class SinkForwarder {
124
124
  * 1.6.0 adds `frames` (innermost first, ≤ 50), `fingerprints` (every
125
125
  * identity candidate, highest precedence first, ending with the legacy
126
126
  * `fingerprint`) and `fingerprintRecord`; `fingerprint`/`fingerprintScheme`
127
- * keep their legacy meaning. Older consumers ignore unknown keys, so the
128
- * version stays 1.
127
+ * keep their legacy meaning. 1.9.0 adds `count`: the occurrences this
128
+ * record stands for, sent only when above 1 (a loop-collapse sample:
129
+ * 1 + collapsedCount, see loop-collapse.ts); consumers add `count ?? 1` to
130
+ * their event counts. Older consumers ignore unknown keys, so the version
131
+ * stays 1.
129
132
  */
130
133
  export declare function sinkOccurrence(o: RuntimeOccurrence): Record<string, unknown>;
package/dist/sink.js CHANGED
@@ -319,13 +319,16 @@ export class SinkForwarder {
319
319
  * 1.6.0 adds `frames` (innermost first, ≤ 50), `fingerprints` (every
320
320
  * identity candidate, highest precedence first, ending with the legacy
321
321
  * `fingerprint`) and `fingerprintRecord`; `fingerprint`/`fingerprintScheme`
322
- * keep their legacy meaning. Older consumers ignore unknown keys, so the
323
- * version stays 1.
322
+ * keep their legacy meaning. 1.9.0 adds `count`: the occurrences this
323
+ * record stands for, sent only when above 1 (a loop-collapse sample:
324
+ * 1 + collapsedCount, see loop-collapse.ts); consumers add `count ?? 1` to
325
+ * their event counts. Older consumers ignore unknown keys, so the version
326
+ * stays 1.
324
327
  */
325
328
  export function sinkOccurrence(o) {
326
329
  const { traceId, route, method, statusCode, errorCode, why, fix, link, expected, requestId, breadcrumbs, frames, fingerprints, fingerprintRecord,
327
330
  // Input-only: locates source maps, never leaves the ingester.
328
- debugIds: _debugIds, ...rest } = o;
331
+ debugIds: _debugIds, collapsedCount, ...rest } = o;
329
332
  return {
330
333
  ...rest,
331
334
  occurredAt: o.occurredAt.toISOString(),
@@ -344,6 +347,8 @@ export function sinkOccurrence(o) {
344
347
  ...(frames?.length ? { frames } : {}),
345
348
  ...(fingerprints?.length ? { fingerprints } : {}),
346
349
  ...(fingerprintRecord ? { fingerprintRecord } : {}),
350
+ // 1.9.0: a collapsed sample stands for 1 + collapsedCount occurrences.
351
+ ...(collapsedCount && collapsedCount > 0 ? { count: 1 + collapsedCount } : {}),
347
352
  };
348
353
  }
349
354
  /** ISO range across every signal in the batch — the replay hint on drops. */