@autter/otlp-ingester 1.6.1 → 1.8.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 (50) hide show
  1. package/README.md +31 -5
  2. package/dist/auth.d.ts +16 -1
  3. package/dist/auth.js +34 -10
  4. package/dist/clickhouse.d.ts +3 -0
  5. package/dist/clickhouse.js +38 -1
  6. package/dist/compat-manifest.json +37 -0
  7. package/dist/config.d.ts +23 -2
  8. package/dist/config.js +33 -8
  9. package/dist/context.d.ts +5 -0
  10. package/dist/context.js +49 -10
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +8 -2
  13. package/dist/ingest-drops.d.ts +308 -0
  14. package/dist/ingest-drops.js +448 -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/memory.d.ts +9 -1
  20. package/dist/memory.js +28 -7
  21. package/dist/migrations.d.ts +8 -0
  22. package/dist/migrations.js +29 -1
  23. package/dist/normalize-browser.d.ts +53 -25
  24. package/dist/normalize-browser.js +56 -10
  25. package/dist/normalize-otlp.d.ts +6 -0
  26. package/dist/normalize-otlp.js +275 -108
  27. package/dist/otlp-proto.d.ts +14 -0
  28. package/dist/otlp-proto.js +25 -0
  29. package/dist/profiles.d.ts +4 -0
  30. package/dist/profiles.js +6 -2
  31. package/dist/request-spans.d.ts +45 -0
  32. package/dist/request-spans.js +91 -0
  33. package/dist/sdk-versions.d.ts +39 -2
  34. package/dist/sdk-versions.js +49 -8
  35. package/dist/server.d.ts +10 -0
  36. package/dist/server.js +345 -105
  37. package/dist/sink.d.ts +14 -0
  38. package/dist/sink.js +15 -0
  39. package/dist/source-maps-lookup.d.ts +6 -3
  40. package/dist/source-maps-lookup.js +35 -13
  41. package/dist/source-maps.d.ts +6 -2
  42. package/dist/source-maps.js +12 -4
  43. package/dist/stack-frames.d.ts +5 -2
  44. package/dist/stack-frames.js +55 -9
  45. package/dist/symbolicate.d.ts +13 -0
  46. package/dist/symbolicate.js +22 -0
  47. package/dist/trace-dedupe.d.ts +98 -0
  48. package/dist/trace-dedupe.js +385 -0
  49. package/dist/types.d.ts +5 -1
  50. package/package.json +1 -1
@@ -0,0 +1,385 @@
1
+ import { sanitizeRuntimeContext } from "./context.js";
2
+ import { normalizeMessage, normalizeStackFrames } from "./fingerprint.js";
3
+ import { scrubText } from "./redact.js";
4
+ import { parseStackFrames } from "./stack-frames.js";
5
+ /**
6
+ * One error can be recorded on several spans of a trace. In a Next.js app a
7
+ * route handler's error is recorded by @autter/runtime-node on the request's
8
+ * HTTP server span AND by Next's own "executing api route" / render span
9
+ * (OpenTelemetry `recordException`); Express, Fastify, NestJS and gRPC
10
+ * instrumentations do the same, and errored ancestor spans with no event
11
+ * would add a stackless `HttpServerError` / `SpanError` on top. Each of
12
+ * those used to become its own occurrence, so one thrown error was counted
13
+ * two or three times. This pass, run on one export batch, keeps one
14
+ * occurrence per thrown error.
15
+ *
16
+ * Rule 1, duplicate recordings. Two exception occurrences are recordings of
17
+ * the same thrown error when ALL of these hold:
18
+ * - same trace, service, environment and release (a thrown error lives in
19
+ * one process);
20
+ * - on DIFFERENT spans, one an ancestor of the other through parent links
21
+ * present in the batch (an error propagating outward through nested
22
+ * spans). Two events on one span are two recordings made on purpose (a
23
+ * retry loop capturing each attempt on the request span) and are never
24
+ * merged; nor are errors on sibling spans (one span per attempt);
25
+ * - the same error type, or the OpenTelemetry JS `recordException`
26
+ * convention: a third-party recording (no `autter.*` on its event) of an
27
+ * error with a `code` reports the code as `exception.type` ("P2002",
28
+ * "ECONNREFUSED"), so its type may instead equal the other's declared
29
+ * `autter.error.code`, or its stack header may name the other's type;
30
+ * - the same message after scrubbing and `normalizeMessage`;
31
+ * - the same stack: the parsed in-app frames (function, file, line) are
32
+ * equal or one list is a prefix (truncated) or suffix (captured at record
33
+ * time, higher up) of the other. A side without a stack is no evidence
34
+ * against; without in-app frames all frames are compared;
35
+ * - recorded within DUPLICATE_WINDOW_MS of the group's latest recording.
36
+ * Recordings are grouped greedily in time order: each joins the eligible
37
+ * group (no member on its span, every member on its ancestor/descendant
38
+ * chain and matching it) whose latest recording is the most recent, so when
39
+ * every attempt of a retry loop is recorded on a child span and again on the
40
+ * parent, each attempt pairs with its own parent recording.
41
+ *
42
+ * The group keeps the occurrence with the most information: the one the
43
+ * Autter SDK recorded (`autter.*` on the event, or `autter.severity` on the
44
+ * span), then the one carrying runtime-next's `onRequestError` context, then
45
+ * the one on the span closest to the root. It keeps that occurrence's type,
46
+ * message, severity, time and batch position (so its deterministic id), and
47
+ * takes from the others what it lacks: attributes (its own win), stack,
48
+ * route, method, status, request id, declared error fields, fingerprint
49
+ * override and breadcrumbs. `autter.merged_recordings` counts the merged
50
+ * recordings.
51
+ *
52
+ * Rule 2, redundant status-only occurrences. A status-only `HttpServerError`
53
+ * / `SpanError` (an errored span without exception or failed-outcome events)
54
+ * is dropped when the same trace and service in this batch has an exception
55
+ * or failed outcome of severity error or fatal, not `autter.handled`, on an
56
+ * ancestor or descendant of its span: the span failed because of that
57
+ * error. It is kept when it is the trace's only signal, when the only
58
+ * errors are warnings or handled ones, or when no ancestor/descendant link
59
+ * is visible in the batch (sibling branches, a parent exported elsewhere).
60
+ *
61
+ * Scope: one export batch. A request's spans normally end, and are
62
+ * exported, together; when the exporter's timer splits them across batches,
63
+ * the recordings in different batches are still counted separately.
64
+ */
65
+ /** Two recordings of one thrown error happen as it propagates outward:
66
+ * milliseconds apart, seconds at most for an error handler that awaits. */
67
+ export const DUPLICATE_WINDOW_MS = 5_000;
68
+ /** Keys runtime-next's `onRequestError` puts on the request span. Next's own
69
+ * spans carry other `next.*` keys (`next.span_name`, `next.route`, …). */
70
+ const NEXT_CONTEXT_KEYS = [
71
+ "next.route_path",
72
+ "next.router_kind",
73
+ "next.route_type",
74
+ "next.render_source",
75
+ "next.render_type",
76
+ "next.revalidate_reason",
77
+ "next.digest",
78
+ ];
79
+ export function hasNextContext(...sources) {
80
+ return sources.some((attrs) => NEXT_CONTEXT_KEYS.some((key) => attrs.has(key)));
81
+ }
82
+ export function hasAutterKey(attrs) {
83
+ for (const key of attrs.keys())
84
+ if (key.startsWith("autter."))
85
+ return true;
86
+ return false;
87
+ }
88
+ function spanKey(traceId, spanId) {
89
+ return `${traceId}\u0000${spanId}`;
90
+ }
91
+ function resourceKey(o) {
92
+ return [o.service, o.environment, o.release ?? ""].join("\u0000");
93
+ }
94
+ /** Ancestors of each span (nearest first) through the batch's parent links. */
95
+ class SpanTree {
96
+ parents = new Map();
97
+ cache = new Map();
98
+ sets = new Map();
99
+ constructor(spans) {
100
+ for (const span of spans) {
101
+ if (!span.traceId || !span.spanId || !span.parentSpanId)
102
+ continue;
103
+ this.parents.set(spanKey(span.traceId, span.spanId), spanKey(span.traceId, span.parentSpanId));
104
+ }
105
+ }
106
+ ancestors(key) {
107
+ const cached = this.cache.get(key);
108
+ if (cached)
109
+ return cached;
110
+ const out = [];
111
+ const seen = new Set([key]);
112
+ let parent = this.parents.get(key);
113
+ while (parent && !seen.has(parent)) {
114
+ seen.add(parent);
115
+ out.push(parent);
116
+ parent = this.parents.get(parent);
117
+ }
118
+ this.cache.set(key, out);
119
+ return out;
120
+ }
121
+ ancestorSet(key) {
122
+ let set = this.sets.get(key);
123
+ if (!set) {
124
+ set = new Set(this.ancestors(key));
125
+ this.sets.set(key, set);
126
+ }
127
+ return set;
128
+ }
129
+ depth(key) {
130
+ return this.ancestors(key).length;
131
+ }
132
+ /** One of the two spans is an ancestor of the other (not the same span). */
133
+ related(a, b) {
134
+ return a !== b && (this.ancestorSet(a).has(b) || this.ancestorSet(b).has(a));
135
+ }
136
+ }
137
+ function frameKey(frame) {
138
+ const fn = (frame.function ?? "").replace(/^(?:async|new)\s+/, "");
139
+ const file = (frame.filename ?? "").replace(/\\/g, "/").replace(/^file:\/\//, "");
140
+ return [frame.module ?? "", fn, file, frame.lineno ?? ""].join("\u0000");
141
+ }
142
+ const HEADER_RE = /^([A-Za-z_$][\w$.]*)(?::|$)/;
143
+ function signatureOf(o) {
144
+ const message = normalizeMessage(scrubText(o.message));
145
+ const stack = o.stack ? scrubText(o.stack) : "";
146
+ if (!stack.trim())
147
+ return { message, header: null, frames: null, legacy: [] };
148
+ const firstLine = stack.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
149
+ const header = /^at\s/.test(firstLine) ? null : (HEADER_RE.exec(firstLine)?.[1] ?? null);
150
+ let parsed = [];
151
+ try {
152
+ parsed = parseStackFrames(stack);
153
+ }
154
+ catch {
155
+ parsed = [];
156
+ }
157
+ return {
158
+ message,
159
+ header,
160
+ frames: {
161
+ inApp: parsed.filter((frame) => frame.inApp).map(frameKey),
162
+ all: parsed.map(frameKey),
163
+ },
164
+ legacy: parsed.length === 0 ? normalizeStackFrames(stack, 50) : [],
165
+ };
166
+ }
167
+ /** Equal, or the shorter list is the start or the end of the longer one. */
168
+ function prefixOrSuffix(a, b) {
169
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
170
+ if (short.length === 0)
171
+ return true;
172
+ const offset = long.length - short.length;
173
+ return (short.every((value, i) => value === long[i]) ||
174
+ short.every((value, i) => value === long[offset + i]));
175
+ }
176
+ function stacksMatch(a, b) {
177
+ if (!a.frames || !b.frames)
178
+ return true;
179
+ if (a.frames.all.length === 0 || b.frames.all.length === 0) {
180
+ // A stack the parsers do not understand: compare the text-normalised
181
+ // frames the legacy fingerprint uses, when both sides have them.
182
+ if (a.frames.all.length === 0 && b.frames.all.length === 0) {
183
+ return prefixOrSuffix(a.legacy, b.legacy);
184
+ }
185
+ return a.legacy.length === 0 && b.legacy.length === 0;
186
+ }
187
+ if (a.frames.inApp.length > 0 && b.frames.inApp.length > 0) {
188
+ return prefixOrSuffix(a.frames.inApp, b.frames.inApp);
189
+ }
190
+ return prefixOrSuffix(a.frames.all, b.frames.all);
191
+ }
192
+ /** OpenTelemetry JS `recordException` reports `String(error.code)` as
193
+ * `exception.type` when the error has a code; Autter SDKs never do. */
194
+ function typeIsCodeOf(x, xs, y) {
195
+ if (x.eventAutter)
196
+ return false;
197
+ const type = x.occurrence.errorType;
198
+ if (y.occurrence.errorCode && type === y.occurrence.errorCode)
199
+ return true;
200
+ return xs.header !== null && xs.header !== type && xs.header === y.occurrence.errorType;
201
+ }
202
+ function typesMatch(a, as, b, bs) {
203
+ return (a.occurrence.errorType === b.occurrence.errorType ||
204
+ typeIsCodeOf(a, as, b) ||
205
+ typeIsCodeOf(b, bs, a));
206
+ }
207
+ function isExplaining(signal) {
208
+ if (signal.signal === "status" || signal.handled)
209
+ return false;
210
+ const severity = signal.occurrence.severity;
211
+ return severity === "error" || severity === "fatal";
212
+ }
213
+ /** Merge `others` into `kept`: kept's identity wins, the rest fills gaps. */
214
+ function mergeInto(kept, others) {
215
+ const merged = { ...kept };
216
+ const attributes = { ...(kept.attributes ?? {}) };
217
+ for (const other of others) {
218
+ for (const [key, value] of Object.entries(other.attributes ?? {})) {
219
+ if (!(key in attributes))
220
+ attributes[key] = value;
221
+ }
222
+ if (!merged.stack && other.stack)
223
+ merged.stack = other.stack;
224
+ if (!merged.route && other.route)
225
+ merged.route = other.route;
226
+ if (!merged.method && other.method)
227
+ merged.method = other.method;
228
+ if ((merged.statusCode == null || merged.statusCode === 0) && other.statusCode) {
229
+ merged.statusCode = other.statusCode;
230
+ }
231
+ if (merged.errorCode === undefined && other.errorCode !== undefined)
232
+ merged.errorCode = other.errorCode;
233
+ if (merged.why === undefined && other.why !== undefined)
234
+ merged.why = other.why;
235
+ if (merged.fix === undefined && other.fix !== undefined)
236
+ merged.fix = other.fix;
237
+ if (merged.link === undefined && other.link !== undefined)
238
+ merged.link = other.link;
239
+ if (merged.expected === undefined && other.expected !== undefined)
240
+ merged.expected = other.expected;
241
+ if (merged.requestId === undefined && other.requestId !== undefined)
242
+ merged.requestId = other.requestId;
243
+ if (merged.fingerprintOverride === undefined && other.fingerprintOverride !== undefined) {
244
+ merged.fingerprintOverride = other.fingerprintOverride;
245
+ }
246
+ if (!merged.breadcrumbs?.length && other.breadcrumbs?.length)
247
+ merged.breadcrumbs = other.breadcrumbs;
248
+ }
249
+ attributes["autter.merged_recordings"] = others.length + 1;
250
+ // Same bounds as every other occurrence (128 keys, kept's own first).
251
+ merged.attributes = sanitizeRuntimeContext(attributes);
252
+ return merged;
253
+ }
254
+ export function dedupeTraceSignals(signals, spans) {
255
+ const tree = new SpanTree(spans);
256
+ const keyOf = (s) => spanKey(s.traceId, s.spanId);
257
+ const timeOf = (s) => s.occurrence.occurredAt.getTime();
258
+ const signatures = new Map();
259
+ const signature = (i) => {
260
+ let sig = signatures.get(i);
261
+ if (!sig) {
262
+ sig = signatureOf(signals[i].occurrence);
263
+ signatures.set(i, sig);
264
+ }
265
+ return sig;
266
+ };
267
+ // ── Rule 1: duplicate recordings of one thrown error ──────────────────
268
+ const groupOf = new Map();
269
+ const buckets = new Map();
270
+ signals.forEach((s, i) => {
271
+ if (s.signal !== "exception" || !s.traceId || !s.spanId)
272
+ return;
273
+ const key = `${s.traceId}\u0000${resourceKey(s.occurrence)}`;
274
+ const bucket = buckets.get(key);
275
+ if (bucket)
276
+ bucket.push(i);
277
+ else
278
+ buckets.set(key, [i]);
279
+ });
280
+ for (const bucket of buckets.values()) {
281
+ if (bucket.length < 2)
282
+ continue;
283
+ // Time order; at the same instant the inner span first (it records
284
+ // before the error reaches the outer one).
285
+ const ordered = bucket.slice().sort((a, b) => timeOf(signals[a]) - timeOf(signals[b]) ||
286
+ tree.depth(keyOf(signals[b])) - tree.depth(keyOf(signals[a])) ||
287
+ a - b);
288
+ let open = [];
289
+ for (const i of ordered) {
290
+ const s = signals[i];
291
+ const key = keyOf(s);
292
+ const at = timeOf(s);
293
+ open = open.filter((group) => at - group.lastAt <= DUPLICATE_WINDOW_MS);
294
+ let best = null;
295
+ for (const group of open) {
296
+ if (group.spans.has(key))
297
+ continue;
298
+ if (best && group.lastAt <= best.lastAt)
299
+ continue;
300
+ const fits = group.members.every((m) => {
301
+ const other = signals[m];
302
+ return (tree.related(key, keyOf(other)) &&
303
+ signature(i).message === signature(m).message &&
304
+ typesMatch(s, signature(i), other, signature(m)) &&
305
+ stacksMatch(signature(i), signature(m)));
306
+ });
307
+ if (fits)
308
+ best = group;
309
+ }
310
+ if (best) {
311
+ best.members.push(i);
312
+ best.spans.add(key);
313
+ best.lastAt = Math.max(best.lastAt, at);
314
+ groupOf.set(i, best);
315
+ }
316
+ else {
317
+ const group = { members: [i], spans: new Set([key]), lastAt: at };
318
+ open.push(group);
319
+ groupOf.set(i, group);
320
+ }
321
+ }
322
+ }
323
+ // ── Rule 2: status-only occurrences an error on their chain explains ──
324
+ // `explained`: spans with an explaining error on themselves or below.
325
+ // `explaining`: spans that carry one.
326
+ const explained = new Set();
327
+ const explaining = new Set();
328
+ signals.forEach((s) => {
329
+ if (!s.traceId || !s.spanId || !isExplaining(s))
330
+ return;
331
+ const scope = resourceKey(s.occurrence);
332
+ const key = keyOf(s);
333
+ explaining.add(`${scope}\u0000${key}`);
334
+ explained.add(`${scope}\u0000${key}`);
335
+ for (const ancestor of tree.ancestors(key))
336
+ explained.add(`${scope}\u0000${ancestor}`);
337
+ });
338
+ const redundant = (s) => {
339
+ if (s.signal !== "status" || !s.traceId || !s.spanId)
340
+ return false;
341
+ const scope = resourceKey(s.occurrence);
342
+ const key = keyOf(s);
343
+ if (explained.has(`${scope}\u0000${key}`))
344
+ return true;
345
+ return tree.ancestors(key).some((ancestor) => explaining.has(`${scope}\u0000${ancestor}`));
346
+ };
347
+ // ── Survivors, in batch order ─────────────────────────────────────────
348
+ const keptOf = new Map();
349
+ const rank = (i) => {
350
+ const s = signals[i];
351
+ return (s.autterCapture ? 2 : 0) + (s.nextContext ? 1 : 0);
352
+ };
353
+ for (const group of new Set(groupOf.values())) {
354
+ if (group.members.length < 2)
355
+ continue;
356
+ const best = group.members.slice().sort((a, b) => rank(b) - rank(a) ||
357
+ tree.depth(keyOf(signals[a])) - tree.depth(keyOf(signals[b])) ||
358
+ a - b)[0];
359
+ keptOf.set(group, best);
360
+ }
361
+ const out = { occurrences: [], sourceIndexes: [], members: [] };
362
+ signals.forEach((s, i) => {
363
+ if (redundant(s))
364
+ return;
365
+ const group = groupOf.get(i);
366
+ const kept = group ? keptOf.get(group) : undefined;
367
+ if (group && kept !== undefined) {
368
+ if (kept !== i)
369
+ return;
370
+ const others = group.members
371
+ .filter((m) => m !== i)
372
+ .sort((a, b) => rank(b) - rank(a) ||
373
+ tree.depth(keyOf(signals[a])) - tree.depth(keyOf(signals[b])) ||
374
+ a - b);
375
+ out.occurrences.push(mergeInto(s.occurrence, others.map((m) => signals[m].occurrence)));
376
+ out.members.push([i, ...others]);
377
+ }
378
+ else {
379
+ out.occurrences.push(s.occurrence);
380
+ out.members.push([i]);
381
+ }
382
+ out.sourceIndexes.push(i);
383
+ });
384
+ return out;
385
+ }
package/dist/types.d.ts CHANGED
@@ -201,11 +201,15 @@ export interface RuntimeMetricPoint {
201
201
  * - `client` — PUBLISHABLE key shipped in frontend bundles: `/v1/browser`
202
202
  * only, origin allow-list enforced, stricter rate limit. Leaking it can
203
203
  * at worst send fake browser errors for one repo — never read anything.
204
+ * - `upload` — CI key (`autter_rtu_…`, 1.7.0+): `/v1/sourcemaps` and
205
+ * `/v1/sourcemaps/check` only; every telemetry route answers 403. Leaking
206
+ * it can at worst upload source maps for one repo.
204
207
  */
208
+ export type IngestKeyScope = "client" | "server" | "upload";
205
209
  export interface IngestContext {
206
210
  orgId: string;
207
211
  repositoryId: string;
208
- scope: "client" | "server";
212
+ scope: IngestKeyScope;
209
213
  /** client keys only: exact origins allowed to send (empty = any). */
210
214
  allowedOrigins: string[];
211
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Self-hostable OTLP + browser-error ingest service for Autter Runtime: normalises telemetry into a per-repo ClickHouse data model",
5
5
  "license": "MIT",
6
6
  "type": "module",