@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.
- package/README.md +25 -4
- package/dist/auth.js +8 -0
- package/dist/clickhouse.js +4 -1
- package/dist/compat-manifest.json +14 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -4
- package/dist/ingest-drops.d.ts +22 -1
- package/dist/ingest-drops.js +6 -0
- package/dist/loop-collapse.d.ts +144 -0
- package/dist/loop-collapse.js +326 -0
- package/dist/migrations.d.ts +12 -0
- package/dist/migrations.js +17 -0
- package/dist/normalize-browser.d.ts +8 -8
- package/dist/server.d.ts +3 -0
- package/dist/server.js +64 -8
- package/dist/sink.d.ts +5 -2
- package/dist/sink.js +8 -3
- package/dist/suppression.d.ts +79 -0
- package/dist/suppression.js +203 -0
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/migrations.d.ts
CHANGED
|
@@ -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;
|
package/dist/migrations.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
128
|
-
*
|
|
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.
|
|
323
|
-
*
|
|
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. */
|