@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.
- package/README.md +42 -7
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +27 -7
- package/dist/clickhouse.d.ts +3 -0
- package/dist/clickhouse.js +41 -1
- package/dist/compat-manifest.json +35 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +8 -0
- package/dist/context.d.ts +5 -0
- package/dist/context.js +49 -10
- package/dist/index.d.ts +4 -1
- package/dist/index.js +14 -2
- package/dist/ingest-drops.d.ts +329 -0
- package/dist/ingest-drops.js +454 -0
- package/dist/log-promotion.d.ts +4 -3
- package/dist/log-promotion.js +5 -5
- package/dist/logs.d.ts +16 -1
- package/dist/logs.js +17 -3
- package/dist/loop-collapse.d.ts +144 -0
- package/dist/loop-collapse.js +326 -0
- package/dist/memory.d.ts +9 -1
- package/dist/memory.js +28 -7
- package/dist/migrations.d.ts +20 -0
- package/dist/migrations.js +46 -1
- package/dist/normalize-browser.d.ts +47 -19
- package/dist/normalize-browser.js +56 -10
- package/dist/normalize-otlp.js +165 -45
- package/dist/otlp-proto.d.ts +14 -0
- package/dist/otlp-proto.js +25 -0
- package/dist/profiles.d.ts +4 -0
- package/dist/profiles.js +6 -2
- package/dist/request-spans.d.ts +45 -0
- package/dist/request-spans.js +91 -0
- package/dist/sdk-versions.d.ts +39 -2
- package/dist/sdk-versions.js +49 -8
- package/dist/server.d.ts +6 -0
- package/dist/server.js +367 -105
- package/dist/sink.d.ts +19 -2
- package/dist/sink.js +23 -3
- package/dist/stack-frames.d.ts +5 -2
- package/dist/stack-frames.js +55 -9
- package/dist/suppression.d.ts +79 -0
- package/dist/suppression.js +203 -0
- package/dist/symbolicate.d.ts +13 -0
- package/dist/symbolicate.js +22 -0
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { DropRoute } from "./ingest-drops.js";
|
|
2
|
+
import type { IngestContext, RuntimeOccurrence } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Loop collapse (1.9.0, EI-38): an error that repeats in a loop is stored
|
|
5
|
+
* as its first N occurrences of the minute plus ONE sample that carries the
|
|
6
|
+
* exact count of the rest, so a loop of 10,000 events a minute costs N + 1
|
|
7
|
+
* rows a minute, not 10,000, and only duplicate payloads are lost.
|
|
8
|
+
*
|
|
9
|
+
* Identical: same tenant, ingest route and arrival minute, and the same
|
|
10
|
+
* - identity: legacy `fingerprint` and every `fingerprints` candidate;
|
|
11
|
+
* - service, environment, release, source, severity, `expected`;
|
|
12
|
+
* - error type and declared error code;
|
|
13
|
+
* - normalised message (`messageNormalized`: ids, numbers and quoted
|
|
14
|
+
* strings templated out) and normalised top frames (`topFrames`);
|
|
15
|
+
* - session id: an error hitting many sessions is volume, not a loop, and
|
|
16
|
+
* each session must still count as a user.
|
|
17
|
+
* Trace, request and occurrence ids, route, timestamps, attributes and
|
|
18
|
+
* breadcrumbs may differ: they are what a loop varies.
|
|
19
|
+
*
|
|
20
|
+
* Per window (that identity in one arrival minute): the first `after`
|
|
21
|
+
* occurrences (LOOP_COLLAPSE_AFTER, default 10) are stored and forwarded as
|
|
22
|
+
* they arrive. Every later one is folded: not stored on its own, counted.
|
|
23
|
+
* When the minute is over, the window's newest folded occurrence is stored
|
|
24
|
+
* and forwarded as the sample with `collapsedCount` = folded − 1, so the
|
|
25
|
+
* sample stands for every folded occurrence: stored rows + Σ collapsed_count
|
|
26
|
+
* is exactly the number of occurrences accepted. Its time is the newest
|
|
27
|
+
* folded one, so an issue's last-seen stays exact; its weight lands in the
|
|
28
|
+
* minute it arrived in.
|
|
29
|
+
*
|
|
30
|
+
* Exactness under retries: a request's folds are reserved when it is planned
|
|
31
|
+
* and only counted once its ClickHouse writes succeed (`commit`); a request
|
|
32
|
+
* that fails (503, the exporter retries) gives them back (`rollback`). Ids
|
|
33
|
+
* already seen in the window are recognised, so a batch retried after a lost
|
|
34
|
+
* 2xx is not counted twice. A window is flushed only once no request still
|
|
35
|
+
* holds it (or, as a safety net, five minutes after its minute); a fold that
|
|
36
|
+
* commits after that is carried into the next flush. A failed flush write is
|
|
37
|
+
* kept and retried every tick. Shutdown (SIGTERM) flushes every window, so a
|
|
38
|
+
* deploy loses nothing; only a crash loses the folded counts of the minute in
|
|
39
|
+
* progress.
|
|
40
|
+
*
|
|
41
|
+
* Memory is bounded (safety, not a work cap): windows live for their minute
|
|
42
|
+
* and are cleared on the next flush; past `maxWindows` windows a minute, or
|
|
43
|
+
* `maxSamples` held samples, occurrences of new identities pass through
|
|
44
|
+
* uncollapsed (stored as usual, never lost), and past `maxTrackedIds` folded
|
|
45
|
+
* ids a minute, retry recognition stops for new folds.
|
|
46
|
+
*/
|
|
47
|
+
export interface LoopCollapseOptions {
|
|
48
|
+
/** Occurrences stored per window before folding starts; 0 turns collapse off. */
|
|
49
|
+
after: number;
|
|
50
|
+
/** Windows (identities) per minute. Default 100,000. */
|
|
51
|
+
maxWindows?: number;
|
|
52
|
+
/** Samples held across all windows until they are flushed. Default 5,000. */
|
|
53
|
+
maxSamples?: number;
|
|
54
|
+
/** Folded occurrence ids remembered per minute, for retry recognition. Default 500,000. */
|
|
55
|
+
maxTrackedIds?: number;
|
|
56
|
+
/** How often due windows are flushed. Default 5 s. */
|
|
57
|
+
flushIntervalMs?: number;
|
|
58
|
+
/** A window still held by a request is flushed anyway this long after its minute. Default 4 min. */
|
|
59
|
+
forceAfterMs?: number;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
log?: (message: string) => void;
|
|
62
|
+
}
|
|
63
|
+
/** One window's sample, ready to store: `sample.collapsedCount` = folded − 1. */
|
|
64
|
+
export interface CollapsedSample {
|
|
65
|
+
route: DropRoute;
|
|
66
|
+
sample: RuntimeOccurrence;
|
|
67
|
+
/** Occurrences the sample stands for (itself included). */
|
|
68
|
+
folded: number;
|
|
69
|
+
}
|
|
70
|
+
/** Store and forward one tenant's samples. A rejection keeps them for the next tick. */
|
|
71
|
+
export type CollapseFlush = (ctx: IngestContext, samples: CollapsedSample[]) => Promise<void>;
|
|
72
|
+
export interface CollapsePlan {
|
|
73
|
+
/** Occurrences to store and forward now. */
|
|
74
|
+
kept: RuntimeOccurrence[];
|
|
75
|
+
/** Occurrences folded into a window's sample by this request. */
|
|
76
|
+
folded: number;
|
|
77
|
+
/** Occurrences recognised as already counted (a retried batch); not stored again. */
|
|
78
|
+
repeated: number;
|
|
79
|
+
/** The request's writes succeeded: count its folds. */
|
|
80
|
+
commit(): void;
|
|
81
|
+
/** The request failed: give its reservations back. */
|
|
82
|
+
rollback(): void;
|
|
83
|
+
}
|
|
84
|
+
export interface LoopCollapseStats {
|
|
85
|
+
windows: number;
|
|
86
|
+
heldSamples: number;
|
|
87
|
+
/** Occurrences folded (committed) since start. */
|
|
88
|
+
folded: number;
|
|
89
|
+
/** Samples stored since start. */
|
|
90
|
+
samplesFlushed: number;
|
|
91
|
+
/** Occurrences that passed through uncollapsed because a bound was reached. */
|
|
92
|
+
overflow: number;
|
|
93
|
+
/** Flush writes that failed (their samples were kept and retried). */
|
|
94
|
+
failedFlushes: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The identity two occurrences must share to fold together (see above),
|
|
98
|
+
* hashed: the raw parts can run to kilobytes (frames, messages).
|
|
99
|
+
*/
|
|
100
|
+
export declare function collapseIdentity(o: RuntimeOccurrence): string;
|
|
101
|
+
export declare class LoopCollapser {
|
|
102
|
+
private readonly flushSink;
|
|
103
|
+
private readonly minutes;
|
|
104
|
+
/** Folds committed after their window was flushed, and failed flushes. */
|
|
105
|
+
private carry;
|
|
106
|
+
private heldSamples;
|
|
107
|
+
private trackedIds;
|
|
108
|
+
private timer;
|
|
109
|
+
private flushing;
|
|
110
|
+
private lastFailureLog;
|
|
111
|
+
private stats_;
|
|
112
|
+
private readonly after;
|
|
113
|
+
private readonly maxWindows;
|
|
114
|
+
private readonly maxSamples;
|
|
115
|
+
private readonly maxTrackedIds;
|
|
116
|
+
private readonly flushIntervalMs;
|
|
117
|
+
private readonly forceAfterMs;
|
|
118
|
+
private readonly now;
|
|
119
|
+
private readonly log;
|
|
120
|
+
constructor(flushSink: CollapseFlush, options: LoopCollapseOptions);
|
|
121
|
+
get enabled(): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Split a request's occurrences into those stored now and those folded
|
|
124
|
+
* into their window's sample. Synchronous; never throws (on an internal
|
|
125
|
+
* error every occurrence is kept).
|
|
126
|
+
*/
|
|
127
|
+
plan(ctx: IngestContext, route: DropRoute, occurrences: RuntimeOccurrence[]): CollapsePlan;
|
|
128
|
+
private planWindows;
|
|
129
|
+
/** Flush due windows every flushIntervalMs (unref'd). */
|
|
130
|
+
start(): void;
|
|
131
|
+
/** Stop the timer and flush every window, the current minute's too (shutdown). */
|
|
132
|
+
stop(): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Store the samples of every window whose minute is over and that no
|
|
135
|
+
* request still holds, plus carried folds and earlier failed writes.
|
|
136
|
+
* `all`: every window, current minute included. One flush at a time;
|
|
137
|
+
* never rejects.
|
|
138
|
+
*/
|
|
139
|
+
flush(all?: boolean): Promise<void>;
|
|
140
|
+
private collectDue;
|
|
141
|
+
private flushDue;
|
|
142
|
+
private releaseSlot;
|
|
143
|
+
stats(): LoopCollapseStats;
|
|
144
|
+
}
|
|
@@ -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/memory.d.ts
CHANGED
|
@@ -11,7 +11,15 @@ export interface MemorySample {
|
|
|
11
11
|
observedAt: Date;
|
|
12
12
|
temporality: "gauge" | "delta" | "cumulative";
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
/** Memory/GC samples one metrics request may produce; past it, points are dropped and counted. */
|
|
15
|
+
export declare const MAX_MEMORY_SAMPLES_PER_REQUEST = 5000;
|
|
16
|
+
export interface MemoryNormalizeStats {
|
|
17
|
+
/** Memory/GC points with a value, timestamp or byte unit out of range, or on a resource without service.name and service.instance.id. */
|
|
18
|
+
invalid: number;
|
|
19
|
+
/** Memory/GC points past MAX_MEMORY_SAMPLES_PER_REQUEST. */
|
|
20
|
+
overLimit: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function normalizeMemoryMetrics(request: OtlpMetricsRequest, stats?: MemoryNormalizeStats): MemorySample[];
|
|
15
23
|
export declare const platformEventSchema: z.ZodObject<{
|
|
16
24
|
service: z.ZodString;
|
|
17
25
|
environment: z.ZodDefault<z.ZodString>;
|
package/dist/memory.js
CHANGED
|
@@ -28,15 +28,29 @@ function metricOf(name, attrs) {
|
|
|
28
28
|
default: return null;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
/** Memory/GC samples one metrics request may produce; past it, points are dropped and counted. */
|
|
32
|
+
export const MAX_MEMORY_SAMPLES_PER_REQUEST = 5000;
|
|
33
|
+
export function normalizeMemoryMetrics(request, stats) {
|
|
32
34
|
const out = [];
|
|
33
35
|
const now = Date.now();
|
|
34
36
|
for (const rm of request.resourceMetrics ?? []) {
|
|
35
37
|
const resource = attrsOf(rm.resource?.attributes);
|
|
36
38
|
const instanceId = resource.get("service.instance.id")?.slice(0, 128) ?? "";
|
|
37
39
|
const service = resource.get("service.name")?.slice(0, 200) ?? "";
|
|
38
|
-
if (!instanceId || !service)
|
|
40
|
+
if (!instanceId || !service) {
|
|
41
|
+
// Samples are per instance: memory points of a resource without
|
|
42
|
+
// service.name and service.instance.id can't be stored.
|
|
43
|
+
if (stats) {
|
|
44
|
+
for (const scope of rm.scopeMetrics ?? [])
|
|
45
|
+
for (const metric of scope.metrics ?? []) {
|
|
46
|
+
for (const point of metric.gauge?.dataPoints ?? metric.sum?.dataPoints ?? []) {
|
|
47
|
+
if (metricOf(metric.name ?? "", attrsOf(point.attributes)))
|
|
48
|
+
stats.invalid += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
39
52
|
continue;
|
|
53
|
+
}
|
|
40
54
|
const environment = (resource.get("deployment.environment.name") ?? resource.get("deployment.environment") ?? "production").slice(0, 100);
|
|
41
55
|
const release = (resource.get("service.version") ?? "").slice(0, 200);
|
|
42
56
|
for (const scope of rm.scopeMetrics ?? [])
|
|
@@ -47,6 +61,11 @@ export function normalizeMemoryMetrics(request) {
|
|
|
47
61
|
const kind = metricOf(name, attrsOf(point.attributes));
|
|
48
62
|
if (!kind)
|
|
49
63
|
continue;
|
|
64
|
+
if (out.length >= MAX_MEMORY_SAMPLES_PER_REQUEST) {
|
|
65
|
+
if (stats)
|
|
66
|
+
stats.overLimit += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
50
69
|
const counter = kind === "gc_count" || kind === "gc_duration_ms";
|
|
51
70
|
const aggregation = metric.sum?.aggregationTemporality;
|
|
52
71
|
const temporality = counter && (aggregation === 2 || aggregation === "AGGREGATION_TEMPORALITY_CUMULATIVE")
|
|
@@ -57,21 +76,23 @@ export function normalizeMemoryMetrics(request) {
|
|
|
57
76
|
observedMs = Number(BigInt(String(point.timeUnixNano ?? "0")) / 1000000n);
|
|
58
77
|
}
|
|
59
78
|
catch {
|
|
79
|
+
if (stats)
|
|
80
|
+
stats.invalid += 1;
|
|
60
81
|
continue;
|
|
61
82
|
}
|
|
62
|
-
if (!Number.isFinite(value) || value < 0 || value > 1e15 || observedMs < now - 30 * 86400_000 || observedMs > now + 300_000)
|
|
63
|
-
continue;
|
|
64
83
|
const isBytes = kind === "rss" || kind === "heap_used" || kind === "heap_limit" || kind === "memory_limit" || kind === "heap_after_gc";
|
|
65
84
|
const unit = metric.unit ?? "By";
|
|
66
|
-
if (
|
|
85
|
+
if (!Number.isFinite(value) || value < 0 || value > 1e15 || observedMs < now - 30 * 86400_000 || observedMs > now + 300_000
|
|
86
|
+
|| (isBytes && !["By", "byte", "bytes", "KiBy", "MiBy"].includes(unit))) {
|
|
87
|
+
if (stats)
|
|
88
|
+
stats.invalid += 1;
|
|
67
89
|
continue;
|
|
90
|
+
}
|
|
68
91
|
// Standard OTel runtimes report GC duration in seconds; Autter uses ms.
|
|
69
92
|
out.push({ service, environment, release, instanceId, metric: kind,
|
|
70
93
|
value: isBytes ? value * (unit === "KiBy" ? 1024 : unit === "MiBy" ? 1048576 : 1)
|
|
71
94
|
: kind === "gc_duration_ms" && unit === "s" ? value * 1000 : value,
|
|
72
95
|
observedAt: new Date(observedMs), temporality });
|
|
73
|
-
if (out.length >= 5000)
|
|
74
|
-
return out;
|
|
75
96
|
}
|
|
76
97
|
}
|
|
77
98
|
}
|
package/dist/migrations.d.ts
CHANGED
|
@@ -45,6 +45,26 @@ export declare const OCCURRENCE_REQUEST_ID_INDEX = "INDEX idx_occ_request_id req
|
|
|
45
45
|
* read `[]` and still match on the legacy `fingerprint`.
|
|
46
46
|
*/
|
|
47
47
|
export declare const OCCURRENCE_GROUPING_COLUMNS: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Migration 0020 columns — shared with the baseline DDL in clickhouse.ts.
|
|
50
|
+
* In-app browser JS frames a source map resolved / left unresolved at
|
|
51
|
+
* ingest (symbolicate.ts inAppFrameResolution); 0 and 0 for server
|
|
52
|
+
* occurrences, browser ones with no in-app JS frame, and rows written
|
|
53
|
+
* before 0020.
|
|
54
|
+
*/
|
|
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[];
|
|
48
68
|
export interface Migration {
|
|
49
69
|
/** Unique, ordered id: "<serial>-<slug>". Never reuse or reorder. */
|
|
50
70
|
id: string;
|
package/dist/migrations.js
CHANGED
|
@@ -37,7 +37,8 @@ import { profileTableDDL } from "./profiles.js";
|
|
|
37
37
|
import { SOURCE_MAP_DEBUG_COLUMNS, SOURCE_MAP_DEBUG_ID_INDEX, sourceMapTableDDL, } from "./source-maps.js";
|
|
38
38
|
import { LOG_REQUEST_COLUMNS, LOG_REQUEST_ID_INDEX, logTableDDL, requestRollupTableDDL, requestRollupViewDDL, } from "./logs.js";
|
|
39
39
|
import { memoryTableDDL, platformEventTableDDL } from "./memory.js";
|
|
40
|
-
import { ingesterInfoTableDDL, sdkVersionTableDDL } from "./sdk-versions.js";
|
|
40
|
+
import { ingesterInfoTableDDL, SDK_VERSION_VIA_COLUMN, sdkVersionTableDDL } from "./sdk-versions.js";
|
|
41
|
+
import { ingestDropsTableDDL } from "./ingest-drops.js";
|
|
41
42
|
/** Migration 0013 columns — shared with the baseline DDL in clickhouse.ts. */
|
|
42
43
|
export const OCCURRENCE_CODE_COLUMNS = [
|
|
43
44
|
"error_code String DEFAULT ''",
|
|
@@ -62,6 +63,29 @@ export const OCCURRENCE_GROUPING_COLUMNS = [
|
|
|
62
63
|
"frames String DEFAULT '' CODEC(ZSTD(3))",
|
|
63
64
|
"fingerprints Array(String) DEFAULT []",
|
|
64
65
|
];
|
|
66
|
+
/**
|
|
67
|
+
* Migration 0020 columns — shared with the baseline DDL in clickhouse.ts.
|
|
68
|
+
* In-app browser JS frames a source map resolved / left unresolved at
|
|
69
|
+
* ingest (symbolicate.ts inAppFrameResolution); 0 and 0 for server
|
|
70
|
+
* occurrences, browser ones with no in-app JS frame, and rows written
|
|
71
|
+
* before 0020.
|
|
72
|
+
*/
|
|
73
|
+
export const OCCURRENCE_FRAME_RESOLUTION_COLUMNS = [
|
|
74
|
+
"in_app_frames_resolved UInt16 DEFAULT 0",
|
|
75
|
+
"in_app_frames_unresolved UInt16 DEFAULT 0",
|
|
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"];
|
|
65
89
|
export const MIGRATIONS = [
|
|
66
90
|
// 0001 intentionally reserved as the baseline marker: databases created
|
|
67
91
|
// before the migration runner existed record it as applied without
|
|
@@ -202,6 +226,27 @@ export const MIGRATIONS = [
|
|
|
202
226
|
`ALTER TABLE {db}.runtime_source_maps ADD ${SOURCE_MAP_DEBUG_ID_INDEX.replace("INDEX ", "INDEX IF NOT EXISTS ")}`,
|
|
203
227
|
],
|
|
204
228
|
},
|
|
229
|
+
// Rejections and drops per tenant, minute, route and reason (EI-27):
|
|
230
|
+
// what the ingester refused or did not store, so a repository's missing
|
|
231
|
+
// telemetry has a reason (ingest-drops.ts).
|
|
232
|
+
{ id: "0018-runtime-ingest-drops", statements: [ingestDropsTableDDL("{db}")] },
|
|
233
|
+
// How each service's telemetry arrived: an OpenTelemetry Collector, an
|
|
234
|
+
// SDK exporter directly, or the browser relay (sdk-versions.ts).
|
|
235
|
+
{
|
|
236
|
+
id: "0019-sdk-versions-via",
|
|
237
|
+
statements: [`ALTER TABLE {db}.runtime_sdk_versions ADD COLUMN IF NOT EXISTS ${SDK_VERSION_VIA_COLUMN}`],
|
|
238
|
+
},
|
|
239
|
+
// Resolved and unresolved in-app browser frames per occurrence, after
|
|
240
|
+
// symbolication — the share the EI-14 source map rule reads.
|
|
241
|
+
{
|
|
242
|
+
id: "0020-occurrence-frame-resolution",
|
|
243
|
+
statements: OCCURRENCE_FRAME_RESOLUTION_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
|
|
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
|
+
},
|
|
205
250
|
];
|
|
206
251
|
/** The tracking table itself — created by the runner before anything else. */
|
|
207
252
|
export function migrationsTableDDL(db) {
|