@autter/otlp-ingester 1.6.1 → 1.7.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/dist/server.js CHANGED
@@ -19,6 +19,14 @@ import { configureRedaction, scrubOccurrence } from "./redact.js";
19
19
  import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
20
20
  import { MIGRATIONS } from "./migrations.js";
21
21
  import { BROWSER_SDK_NAME, ingesterVersion, SdkVersionTracker, sdkSightingsFromOtlp, } from "./sdk-versions.js";
22
+ /** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
23
+ export const UPLOAD_KEY_ROUTES = ["/v1/sourcemaps", "/v1/sourcemaps/check"];
24
+ /** The 403 an upload key gets on every other authenticated route. */
25
+ export const UPLOAD_KEY_NOT_ALLOWED = {
26
+ error: "upload_key_not_allowed",
27
+ detail: "autter_rtu_ keys can only upload source maps (POST /v1/sourcemaps and /v1/sourcemaps/check); " +
28
+ "telemetry needs a server key (autter_rt_…), or a client key (autter_rtc_…) for /v1/browser",
29
+ };
22
30
  /** Re-report the ingester version at most this often (keeps the row inside
23
31
  * its TTL for long-running ingesters). */
24
32
  const INGESTER_INFO_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -94,7 +102,7 @@ export function createIngesterApp(config) {
94
102
  const sourceMapBody = express.json({ limit: SOURCE_MAP_MAX_BODY_BYTES });
95
103
  app.post("/v1/sourcemaps", async (req, res, next) => {
96
104
  // On failure the body is never read (Node discards it).
97
- const ctx = await authenticate(req, res, "otlp");
105
+ const ctx = await authenticate(req, res, "sourcemaps");
98
106
  if (!ctx)
99
107
  return;
100
108
  sourceMapBody(req, res, async (err) => {
@@ -197,7 +205,12 @@ export function createIngesterApp(config) {
197
205
  res.setHeader("cache-control", "no-store");
198
206
  res.status(200).json(body);
199
207
  });
200
- /** Auth + scope + rate limit; returns null (response sent) on failure. */
208
+ /**
209
+ * Auth + scope + rate limit; returns null (response sent) on failure.
210
+ * Server keys pass everywhere; client keys only on "browser"; upload
211
+ * keys only on "sourcemaps". A new route that passes "otlp" is closed to
212
+ * client and upload keys without further changes.
213
+ */
201
214
  async function authenticate(req, res, surface) {
202
215
  // A storage-less ingester must refuse, not accept-and-drop: exporters
203
216
  // retry on 503, so telemetry survives a misconfigured deploy.
@@ -215,12 +228,22 @@ export function createIngesterApp(config) {
215
228
  res.status(401).json({ error: "invalid ingest key" });
216
229
  return null;
217
230
  }
218
- if (ctx.scope === "client") {
231
+ if (ctx.scope === "upload") {
232
+ // CI keys: source map upload and check only. Server rate window
233
+ // (each key has its own).
234
+ if (surface !== "sourcemaps") {
235
+ res.status(403).json(UPLOAD_KEY_NOT_ALLOWED);
236
+ return null;
237
+ }
238
+ }
239
+ else if (ctx.scope === "client") {
219
240
  // Publishable keys: browser surface only, origin allow-list, and
220
241
  // the tighter rate window.
221
242
  if (surface !== "browser") {
222
243
  res.status(403).json({
223
- error: "client keys cannot send OTLP — use a server key",
244
+ error: surface === "sourcemaps"
245
+ ? "client keys cannot upload source maps — use an upload key (autter_rtu_…) or a server key"
246
+ : "client keys cannot send OTLP — use a server key",
224
247
  });
225
248
  return null;
226
249
  }
@@ -236,6 +259,12 @@ export function createIngesterApp(config) {
236
259
  }
237
260
  return ctx;
238
261
  }
262
+ else if (ctx.scope !== "server") {
263
+ // KeyResolver never yields another scope; refuse rather than
264
+ // treat an unknown one as a server key.
265
+ res.status(403).json({ error: "key scope not allowed" });
266
+ return null;
267
+ }
239
268
  if (!serverRateLimiter.allow(key)) {
240
269
  res.status(429).json({ error: "rate limit exceeded" });
241
270
  return null;
@@ -275,8 +304,13 @@ export function createIngesterApp(config) {
275
304
  * `fingerprint` / `fingerprintScheme` stay the legacy identity
276
305
  * (message-v1 or code-v1) byte-for-byte; `fingerprints` carries every
277
306
  * candidate (manual-v1, code-v1, frames-v3, message-v1) for the platform
278
- * to resolve the issue from. */
279
- async function fingerprintAll(ctx, inputs) {
307
+ * to resolve the issue from.
308
+ *
309
+ * `batchIndexes` (traces): each occurrence's position among the batch's
310
+ * signals before the per-trace dedupe, so a merged occurrence keeps the
311
+ * id of the recording it kept and a drop never shifts another's id.
312
+ * Defaults to the position in `inputs`. */
313
+ async function fingerprintAll(ctx, inputs, batchIndexes) {
280
314
  return Promise.all(inputs.map(async (raw, index) => {
281
315
  // Second line of defence, before fingerprinting, storage, and the
282
316
  // sink (which feeds LLM fix generation): scrub secrets from the
@@ -291,7 +325,7 @@ export function createIngesterApp(config) {
291
325
  const { debugIds: _debugIds, ...stored } = input;
292
326
  return {
293
327
  ...stored,
294
- occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
328
+ occurrenceId: occurrenceIdFor(ctx, input, fingerprint, batchIndexes?.[index] ?? index),
295
329
  fingerprint,
296
330
  fingerprintScheme: scheme,
297
331
  ...deriveFields(input),
@@ -377,7 +411,7 @@ export function createIngesterApp(config) {
377
411
  /** Which of these maps does this tenant already have? Lets
378
412
  * `autter-runtime sourcemaps upload` skip unchanged maps (1.6.0+). */
379
413
  app.post("/v1/sourcemaps/check", async (req, res) => {
380
- const ctx = await authenticate(req, res, "otlp");
414
+ const ctx = await authenticate(req, res, "sourcemaps");
381
415
  if (!ctx)
382
416
  return;
383
417
  const check = validateSourceMapCheck(req.body);
@@ -432,8 +466,8 @@ export function createIngesterApp(config) {
432
466
  else {
433
467
  request = req.body;
434
468
  }
435
- const { occurrences, spans, metricPoints, llmCalls } = normalizeTraces(request);
436
- const fingerprinted = await fingerprintAll(ctx, occurrences);
469
+ const { occurrences, occurrenceBatchIndexes, spans, metricPoints, llmCalls } = normalizeTraces(request);
470
+ const fingerprinted = await fingerprintAll(ctx, occurrences, occurrenceBatchIndexes);
437
471
  // ClickHouse has no cross-table transaction, so these four inserts can
438
472
  // partially commit. Recovery boundary: any failure → 503 → the exporter
439
473
  // retries the whole batch. Deterministic occurrence ids make the retry
@@ -21,9 +21,12 @@ export declare function lookupSourceMap(client: ClickHouseClient, database: stri
21
21
  */
22
22
  export declare const SOURCE_MAP_FRESH_DAYS = 7;
23
23
  /**
24
- * Which check entries the tenant already has: same release, path, debug id
25
- * and content hash, uploaded in the last SOURCE_MAP_FRESH_DAYS. Returns the
26
- * indexes of `check.entries` that need no upload.
24
+ * Which check entries the tenant already has. An entry with a hash: same
25
+ * release, path, debug id and content hash, uploaded in the last
26
+ * SOURCE_MAP_FRESH_DAYS (the upload skips those). An entry with only a
27
+ * debug id: any stored map with that id, whatever its release and path —
28
+ * exactly what lookupSourceMap would find for a stack from that bundle.
29
+ * Returns the indexes of `check.entries` that are present.
27
30
  */
28
31
  export declare function presentSourceMaps(client: ClickHouseClient, database: string, tenant: {
29
32
  orgId: string;
@@ -34,13 +34,41 @@ export async function lookupSourceMap(client, database, key) {
34
34
  */
35
35
  export const SOURCE_MAP_FRESH_DAYS = 7;
36
36
  /**
37
- * Which check entries the tenant already has: same release, path, debug id
38
- * and content hash, uploaded in the last SOURCE_MAP_FRESH_DAYS. Returns the
39
- * indexes of `check.entries` that need no upload.
37
+ * Which check entries the tenant already has. An entry with a hash: same
38
+ * release, path, debug id and content hash, uploaded in the last
39
+ * SOURCE_MAP_FRESH_DAYS (the upload skips those). An entry with only a
40
+ * debug id: any stored map with that id, whatever its release and path —
41
+ * exactly what lookupSourceMap would find for a stack from that bundle.
42
+ * Returns the indexes of `check.entries` that are present.
40
43
  */
41
44
  export async function presentSourceMaps(client, database, tenant, check) {
42
- if (check.entries.length === 0)
43
- return [];
45
+ const hashed = check.entries.some((entry) => entry.hash);
46
+ const byId = check.entries.filter((entry) => !entry.hash).map((entry) => entry.debugId);
47
+ const [withHash, ids] = await Promise.all([
48
+ hashed ? presentByHash(client, database, tenant, check) : new Set(),
49
+ byId.length ? presentDebugIds(client, database, tenant, byId) : new Set(),
50
+ ]);
51
+ const present = [];
52
+ check.entries.forEach((entry, index) => {
53
+ const found = entry.hash
54
+ ? withHash.has(`${entry.filename}\u0000${entry.debugId}\u0000${entry.hash}`)
55
+ : ids.has(entry.debugId);
56
+ if (found)
57
+ present.push(index);
58
+ });
59
+ return present;
60
+ }
61
+ async function presentDebugIds(client, database, tenant, debugIds) {
62
+ const rows = await client.query({
63
+ query: `SELECT DISTINCT debug_id FROM ${database}.runtime_source_maps
64
+ WHERE org_id = {org:String} AND repository_id = {repo:String}
65
+ AND has({debugIds:Array(String)}, debug_id)`,
66
+ query_params: { org: tenant.orgId, repo: tenant.repositoryId, debugIds: [...new Set(debugIds)] },
67
+ format: "JSONEachRow",
68
+ });
69
+ return new Set((await rows.json()).map((row) => row.debug_id));
70
+ }
71
+ async function presentByHash(client, database, tenant, check) {
44
72
  const rows = await client.query({
45
73
  query: `SELECT DISTINCT filename, debug_id, content_hash FROM ${database}.runtime_source_maps
46
74
  WHERE org_id = {org:String} AND repository_id = {repo:String} AND release = {release:String}
@@ -50,15 +78,9 @@ export async function presentSourceMaps(client, database, tenant, check) {
50
78
  org: tenant.orgId,
51
79
  repo: tenant.repositoryId,
52
80
  release: check.release,
53
- hashes: [...new Set(check.entries.map((entry) => entry.hash))],
81
+ hashes: [...new Set(check.entries.filter((entry) => entry.hash).map((entry) => entry.hash))],
54
82
  },
55
83
  format: "JSONEachRow",
56
84
  });
57
- const stored = new Set((await rows.json()).map((row) => `${row.filename}\u0000${row.debug_id}\u0000${row.content_hash}`));
58
- const present = [];
59
- check.entries.forEach((entry, index) => {
60
- if (stored.has(`${entry.filename}\u0000${entry.debugId}\u0000${entry.hash}`))
61
- present.push(index);
62
- });
63
- return present;
85
+ return new Set((await rows.json()).map((row) => `${row.filename}\u0000${row.debug_id}\u0000${row.content_hash}`));
64
86
  }
@@ -49,6 +49,7 @@ export declare function validateSourceMap(body: unknown): ValidSourceMap | null;
49
49
  export interface SourceMapCheckEntry {
50
50
  filename: string;
51
51
  debugId: string;
52
+ /** sha256 of the stored map, or "" for a check by debug id alone (1.7.0+). */
52
53
  hash: string;
53
54
  }
54
55
  export interface SourceMapCheck {
@@ -59,9 +60,12 @@ export interface SourceMapCheck {
59
60
  export declare const SOURCE_MAP_CHECK_MAX = 500;
60
61
  /**
61
62
  * Validate a `/v1/sourcemaps/check` request:
62
- * `{ release, files: [{ filename, debugId?, hash }] }` (≤ 500 files). The
63
+ * `{ release, files: [{ filename, debugId?, hash? }] }` (≤ 500 files). The
63
64
  * filenames and debug ids are normalised exactly as an upload would store
64
- * them. Null when invalid.
65
+ * them. Each file needs a hash (is this exact map stored? — what `upload`
66
+ * asks to skip maps) or, from 1.7.0, a debug id alone (is any map stored
67
+ * for this bundle? — what `sourcemaps verify` asks after the maps were
68
+ * deleted). Null when invalid.
65
69
  */
66
70
  export declare function validateSourceMapCheck(body: unknown): SourceMapCheck | null;
67
71
  /** Most scripts one browser event may name in `debugIds`. */
@@ -106,9 +106,12 @@ export function validateSourceMap(body) {
106
106
  export const SOURCE_MAP_CHECK_MAX = 500;
107
107
  /**
108
108
  * Validate a `/v1/sourcemaps/check` request:
109
- * `{ release, files: [{ filename, debugId?, hash }] }` (≤ 500 files). The
109
+ * `{ release, files: [{ filename, debugId?, hash? }] }` (≤ 500 files). The
110
110
  * filenames and debug ids are normalised exactly as an upload would store
111
- * them. Null when invalid.
111
+ * them. Each file needs a hash (is this exact map stored? — what `upload`
112
+ * asks to skip maps) or, from 1.7.0, a debug id alone (is any map stored
113
+ * for this bundle? — what `sourcemaps verify` asks after the maps were
114
+ * deleted). Null when invalid.
112
115
  */
113
116
  export function validateSourceMapCheck(body) {
114
117
  if (!body || typeof body !== "object")
@@ -123,7 +126,10 @@ export function validateSourceMapCheck(body) {
123
126
  if (!raw || typeof raw !== "object")
124
127
  return null;
125
128
  const file = raw;
126
- if (typeof file.filename !== "string" || typeof file.hash !== "string" || !/^[0-9a-f]{64}$/.test(file.hash))
129
+ if (typeof file.filename !== "string")
130
+ return null;
131
+ const hash = file.hash === undefined || file.hash === null || file.hash === "" ? "" : file.hash;
132
+ if (typeof hash !== "string" || (hash !== "" && !/^[0-9a-f]{64}$/.test(hash)))
127
133
  return null;
128
134
  const filename = sourceMapPath(file.filename);
129
135
  if (!filename)
@@ -131,7 +137,9 @@ export function validateSourceMapCheck(body) {
131
137
  const debugId = file.debugId === undefined || file.debugId === null || file.debugId === "" ? "" : normalizeDebugId(file.debugId);
132
138
  if (debugId === null)
133
139
  return null;
134
- entries.push({ filename, debugId, hash: file.hash });
140
+ if (!hash && !debugId)
141
+ return null;
142
+ entries.push({ filename, debugId, hash });
135
143
  }
136
144
  return { release: input.release, entries };
137
145
  }
@@ -0,0 +1,98 @@
1
+ import type { RuntimeOccurrenceInput } from "./types.js";
2
+ /**
3
+ * One error can be recorded on several spans of a trace. In a Next.js app a
4
+ * route handler's error is recorded by @autter/runtime-node on the request's
5
+ * HTTP server span AND by Next's own "executing api route" / render span
6
+ * (OpenTelemetry `recordException`); Express, Fastify, NestJS and gRPC
7
+ * instrumentations do the same, and errored ancestor spans with no event
8
+ * would add a stackless `HttpServerError` / `SpanError` on top. Each of
9
+ * those used to become its own occurrence, so one thrown error was counted
10
+ * two or three times. This pass, run on one export batch, keeps one
11
+ * occurrence per thrown error.
12
+ *
13
+ * Rule 1, duplicate recordings. Two exception occurrences are recordings of
14
+ * the same thrown error when ALL of these hold:
15
+ * - same trace, service, environment and release (a thrown error lives in
16
+ * one process);
17
+ * - on DIFFERENT spans, one an ancestor of the other through parent links
18
+ * present in the batch (an error propagating outward through nested
19
+ * spans). Two events on one span are two recordings made on purpose (a
20
+ * retry loop capturing each attempt on the request span) and are never
21
+ * merged; nor are errors on sibling spans (one span per attempt);
22
+ * - the same error type, or the OpenTelemetry JS `recordException`
23
+ * convention: a third-party recording (no `autter.*` on its event) of an
24
+ * error with a `code` reports the code as `exception.type` ("P2002",
25
+ * "ECONNREFUSED"), so its type may instead equal the other's declared
26
+ * `autter.error.code`, or its stack header may name the other's type;
27
+ * - the same message after scrubbing and `normalizeMessage`;
28
+ * - the same stack: the parsed in-app frames (function, file, line) are
29
+ * equal or one list is a prefix (truncated) or suffix (captured at record
30
+ * time, higher up) of the other. A side without a stack is no evidence
31
+ * against; without in-app frames all frames are compared;
32
+ * - recorded within DUPLICATE_WINDOW_MS of the group's latest recording.
33
+ * Recordings are grouped greedily in time order: each joins the eligible
34
+ * group (no member on its span, every member on its ancestor/descendant
35
+ * chain and matching it) whose latest recording is the most recent, so when
36
+ * every attempt of a retry loop is recorded on a child span and again on the
37
+ * parent, each attempt pairs with its own parent recording.
38
+ *
39
+ * The group keeps the occurrence with the most information: the one the
40
+ * Autter SDK recorded (`autter.*` on the event, or `autter.severity` on the
41
+ * span), then the one carrying runtime-next's `onRequestError` context, then
42
+ * the one on the span closest to the root. It keeps that occurrence's type,
43
+ * message, severity, time and batch position (so its deterministic id), and
44
+ * takes from the others what it lacks: attributes (its own win), stack,
45
+ * route, method, status, request id, declared error fields, fingerprint
46
+ * override and breadcrumbs. `autter.merged_recordings` counts the merged
47
+ * recordings.
48
+ *
49
+ * Rule 2, redundant status-only occurrences. A status-only `HttpServerError`
50
+ * / `SpanError` (an errored span without exception or failed-outcome events)
51
+ * is dropped when the same trace and service in this batch has an exception
52
+ * or failed outcome of severity error or fatal, not `autter.handled`, on an
53
+ * ancestor or descendant of its span: the span failed because of that
54
+ * error. It is kept when it is the trace's only signal, when the only
55
+ * errors are warnings or handled ones, or when no ancestor/descendant link
56
+ * is visible in the batch (sibling branches, a parent exported elsewhere).
57
+ *
58
+ * Scope: one export batch. A request's spans normally end, and are
59
+ * exported, together; when the exporter's timer splits them across batches,
60
+ * the recordings in different batches are still counted separately.
61
+ */
62
+ /** Two recordings of one thrown error happen as it propagates outward:
63
+ * milliseconds apart, seconds at most for an error handler that awaits. */
64
+ export declare const DUPLICATE_WINDOW_MS = 5000;
65
+ export interface TraceSignal {
66
+ occurrence: RuntimeOccurrenceInput;
67
+ traceId: string;
68
+ spanId: string;
69
+ /** What produced it: an `exception` event, a failed `autter.outcome`
70
+ * event, or the span's error status alone. */
71
+ signal: "exception" | "outcome" | "status";
72
+ /** The Autter SDK recorded it: `autter.*` on the event, or
73
+ * `autter.severity` (captureException's marker) on the span. */
74
+ autterCapture: boolean;
75
+ /** `autter.*` attributes on the event itself. */
76
+ eventAutter: boolean;
77
+ /** runtime-next's `onRequestError` context on the span or event. */
78
+ nextContext: boolean;
79
+ /** `autter.handled` on the event: handled on purpose, not why a span failed. */
80
+ handled: boolean;
81
+ }
82
+ export interface SpanLink {
83
+ traceId: string;
84
+ spanId: string;
85
+ parentSpanId: string | null;
86
+ }
87
+ export interface DedupedSignals {
88
+ /** The surviving occurrences, in batch order (merged where rule 1 applied). */
89
+ occurrences: RuntimeOccurrenceInput[];
90
+ /** Input index of each survivor: its position in the batch, which its
91
+ * deterministic occurrence id is derived from. */
92
+ sourceIndexes: number[];
93
+ /** Input indexes each survivor stands for (itself first). */
94
+ members: number[][];
95
+ }
96
+ export declare function hasNextContext(...sources: Array<Map<string, string>>): boolean;
97
+ export declare function hasAutterKey(attrs: Map<string, string>): boolean;
98
+ export declare function dedupeTraceSignals(signals: TraceSignal[], spans: SpanLink[]): DedupedSignals;