@autter/otlp-ingester 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +42 -7
  2. package/dist/auth.d.ts +15 -0
  3. package/dist/auth.js +27 -7
  4. package/dist/clickhouse.d.ts +3 -0
  5. package/dist/clickhouse.js +41 -1
  6. package/dist/compat-manifest.json +35 -0
  7. package/dist/config.d.ts +15 -0
  8. package/dist/config.js +8 -0
  9. package/dist/context.d.ts +5 -0
  10. package/dist/context.js +49 -10
  11. package/dist/index.d.ts +4 -1
  12. package/dist/index.js +14 -2
  13. package/dist/ingest-drops.d.ts +329 -0
  14. package/dist/ingest-drops.js +454 -0
  15. package/dist/log-promotion.d.ts +4 -3
  16. package/dist/log-promotion.js +5 -5
  17. package/dist/logs.d.ts +16 -1
  18. package/dist/logs.js +17 -3
  19. package/dist/loop-collapse.d.ts +144 -0
  20. package/dist/loop-collapse.js +326 -0
  21. package/dist/memory.d.ts +9 -1
  22. package/dist/memory.js +28 -7
  23. package/dist/migrations.d.ts +20 -0
  24. package/dist/migrations.js +46 -1
  25. package/dist/normalize-browser.d.ts +47 -19
  26. package/dist/normalize-browser.js +56 -10
  27. package/dist/normalize-otlp.js +165 -45
  28. package/dist/otlp-proto.d.ts +14 -0
  29. package/dist/otlp-proto.js +25 -0
  30. package/dist/profiles.d.ts +4 -0
  31. package/dist/profiles.js +6 -2
  32. package/dist/request-spans.d.ts +45 -0
  33. package/dist/request-spans.js +91 -0
  34. package/dist/sdk-versions.d.ts +39 -2
  35. package/dist/sdk-versions.js +49 -8
  36. package/dist/server.d.ts +6 -0
  37. package/dist/server.js +367 -105
  38. package/dist/sink.d.ts +19 -2
  39. package/dist/sink.js +23 -3
  40. package/dist/stack-frames.d.ts +5 -2
  41. package/dist/stack-frames.js +55 -9
  42. package/dist/suppression.d.ts +79 -0
  43. package/dist/suppression.js +203 -0
  44. package/dist/symbolicate.d.ts +13 -0
  45. package/dist/symbolicate.js +22 -0
  46. package/dist/types.d.ts +14 -0
  47. package/package.json +1 -1
@@ -0,0 +1,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
- export declare function normalizeMemoryMetrics(request: OtlpMetricsRequest): MemorySample[];
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
- export function normalizeMemoryMetrics(request) {
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 (isBytes && !["By", "byte", "bytes", "KiBy", "MiBy"].includes(unit))
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
  }
@@ -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;
@@ -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) {