@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/README.md CHANGED
@@ -31,6 +31,16 @@ works with its default exporter settings.
31
31
  | --- | --- | --- | --- |
32
32
  | `server` (default) | `autter_rt_…` (secret) | all endpoints | 300 req/min |
33
33
  | `client` | `autter_rtc_…` (publishable, safe in frontend bundles) | `/v1/browser` only | origin allow-list, 120 req/min |
34
+ | `upload` (1.7.0+) | `autter_rtu_…` (secret, for CI) | `/v1/sourcemaps` and `/v1/sourcemaps/check` only | 300 req/min |
35
+
36
+ An upload key gets `403 {"error":"upload_key_not_allowed","detail":"autter_rtu_ keys can only upload source maps …"}`
37
+ on every other route, so a CI secret that leaks can upload source maps for
38
+ one repository and nothing else. A static entry without `scope` is an upload
39
+ key when its key starts with `autter_rtu_`, else a server key; an entry (or a
40
+ validator answer) with any other `scope` is ignored rather than treated as a
41
+ server key. Ingesters before 1.7.0 treat every scope but `client` as
42
+ `server`, so a validator must not answer `"scope": "upload"` to them: from
43
+ 1.7.0 every validator request carries `x-autter-ingester-version`.
34
44
 
35
45
  `/v1/browser` answers CORS preflights permissively; real enforcement (key +
36
46
  origin allow-list) happens on the POST. Cross-origin browsers send
@@ -41,12 +51,14 @@ route parses as JSON.
41
51
  AUTTER_INGEST_KEYS='[
42
52
  {"key":"autter_rt_…","orgId":"org1","repositoryId":"repo1"},
43
53
  {"key":"autter_rtc_…","orgId":"org1","repositoryId":"repo1",
44
- "scope":"client","allowedOrigins":["https://app.example.com"]}
54
+ "scope":"client","allowedOrigins":["https://app.example.com"]},
55
+ {"key":"autter_rtu_…","orgId":"org1","repositoryId":"repo1","scope":"upload"}
45
56
  ]'
46
57
  ```
47
58
 
48
59
  The validator webhook may return the same extra fields:
49
- `{ orgId, repositoryId, scope?, allowedOrigins? }`.
60
+ `{ orgId, repositoryId, scope?, allowedOrigins? }`, with `scope` one of
61
+ `server`, `client`, `upload`.
50
62
 
51
63
  ### Version compatibility
52
64
 
package/dist/auth.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Request } from "express";
2
- import type { IngesterConfig } from "./config.js";
2
+ import { type IngesterConfig } from "./config.js";
3
3
  import type { IngestContext } from "./types.js";
4
4
  export declare class KeyResolver {
5
5
  private readonly config;
package/dist/auth.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { INGESTER_VERSION_HEADER } from "./compat.js";
2
+ import { defaultScopeFor, isIngestKeyScope } from "./config.js";
3
+ import { ingesterVersion } from "./sdk-versions.js";
1
4
  const VALIDATOR_CACHE_TTL_MS = 60_000;
2
5
  export class KeyResolver {
3
6
  config;
@@ -6,10 +9,13 @@ export class KeyResolver {
6
9
  constructor(config) {
7
10
  this.config = config;
8
11
  for (const entry of config.ingestKeys) {
12
+ const scope = entry.scope ?? defaultScopeFor(entry.key);
13
+ if (!isIngestKeyScope(scope))
14
+ continue;
9
15
  this.staticKeys.set(entry.key, {
10
16
  orgId: entry.orgId,
11
17
  repositoryId: entry.repositoryId,
12
- scope: entry.scope ?? "server",
18
+ scope,
13
19
  allowedOrigins: entry.allowedOrigins ?? [],
14
20
  });
15
21
  }
@@ -44,6 +50,11 @@ export class KeyResolver {
44
50
  method: "POST",
45
51
  headers: {
46
52
  "content-type": "application/json",
53
+ // Lets the validator tell which scopes this ingester enforces:
54
+ // before 1.7.0 any scope but "client" meant a server key, so an
55
+ // upload key must not be answered as "upload" to an ingester
56
+ // that doesn't send this header (or sends an older version).
57
+ [INGESTER_VERSION_HEADER]: ingesterVersion(),
47
58
  ...(this.config.keyValidatorToken
48
59
  ? { authorization: `Bearer ${this.config.keyValidatorToken}` }
49
60
  : {}),
@@ -53,11 +64,12 @@ export class KeyResolver {
53
64
  });
54
65
  if (response.ok) {
55
66
  const body = (await response.json());
56
- if (body.orgId && body.repositoryId) {
67
+ const scope = body.scope === undefined || body.scope === null ? defaultScopeFor(key) : body.scope;
68
+ if (body.orgId && body.repositoryId && isIngestKeyScope(scope)) {
57
69
  ctx = {
58
70
  orgId: body.orgId,
59
71
  repositoryId: body.repositoryId,
60
- scope: body.scope ?? "server",
72
+ scope,
61
73
  allowedOrigins: body.allowedOrigins ?? [],
62
74
  };
63
75
  }
@@ -118,6 +118,22 @@
118
118
  "ingester": "1.6.0",
119
119
  "migrations": [],
120
120
  "sdks": { "@autter/runtime-browser": "1.6.0", "@autter/runtime-next": "1.6.0", "@autter/runtime-node": "1.6.0" }
121
+ },
122
+ {
123
+ "id": "sourcemap_upload_keys",
124
+ "title": "Upload-only keys for source maps (autter_rtu_)",
125
+ "ingester": "1.7.0",
126
+ "migrations": [],
127
+ "route": "/v1/sourcemaps",
128
+ "sdks": {}
129
+ },
130
+ {
131
+ "id": "source_map_verify",
132
+ "title": "Source map check by debug id (autter-runtime sourcemaps verify)",
133
+ "ingester": "1.7.0",
134
+ "migrations": ["0017-source-maps-debug-id"],
135
+ "route": "/v1/sourcemaps/check",
136
+ "sdks": {}
121
137
  }
122
138
  ]
123
139
  }
package/dist/config.d.ts CHANGED
@@ -1,9 +1,26 @@
1
+ import type { IngestKeyScope } from "./types.js";
2
+ export declare const INGEST_KEY_SCOPES: readonly IngestKeyScope[];
3
+ /** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
4
+ export declare const UPLOAD_KEY_PREFIX = "autter_rtu_";
5
+ export declare function isIngestKeyScope(value: unknown): value is IngestKeyScope;
6
+ /**
7
+ * The scope of a key whose static entry or validator answer names none:
8
+ * "upload" for an `autter_rtu_…` key (its prefix promises no more than
9
+ * that), else "server" as before 1.7.0.
10
+ */
11
+ export declare function defaultScopeFor(key: string): IngestKeyScope;
1
12
  export interface StaticIngestKey {
2
13
  key: string;
3
14
  orgId: string;
4
15
  repositoryId: string;
5
- /** "server" (default) = secret backend key; "client" = publishable browser key. */
6
- scope?: "client" | "server";
16
+ /**
17
+ * "server" = secret backend key, every route; "client" = publishable
18
+ * browser key, `/v1/browser` only; "upload" = CI key, `/v1/sourcemaps`
19
+ * and `/v1/sourcemaps/check` only. Default: "upload" for an
20
+ * `autter_rtu_…` key, else "server". An entry with any other value is
21
+ * ignored (never treated as a server key).
22
+ */
23
+ scope?: IngestKeyScope;
7
24
  /** client keys: exact origins allowed to send (e.g. https://app.example.com). */
8
25
  allowedOrigins?: string[];
9
26
  }
@@ -61,4 +78,8 @@ export interface IngesterConfig {
61
78
  */
62
79
  sourceMapCacheMb?: number;
63
80
  }
81
+ /** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
82
+ * repositoryId, or with an unknown scope, are dropped (a typo in `scope`
83
+ * must not turn a restricted key into a server key). */
84
+ export declare function parseIngestKeys(raw: string | undefined): StaticIngestKey[];
64
85
  export declare function loadConfig(): IngesterConfig;
package/dist/config.js CHANGED
@@ -1,3 +1,17 @@
1
+ export const INGEST_KEY_SCOPES = ["server", "client", "upload"];
2
+ /** Prefix of upload-only keys (`autter_rtu_…`): source map uploads from CI. */
3
+ export const UPLOAD_KEY_PREFIX = "autter_rtu_";
4
+ export function isIngestKeyScope(value) {
5
+ return typeof value === "string" && INGEST_KEY_SCOPES.includes(value);
6
+ }
7
+ /**
8
+ * The scope of a key whose static entry or validator answer names none:
9
+ * "upload" for an `autter_rtu_…` key (its prefix promises no more than
10
+ * that), else "server" as before 1.7.0.
11
+ */
12
+ export function defaultScopeFor(key) {
13
+ return key.startsWith(UPLOAD_KEY_PREFIX) ? "upload" : "server";
14
+ }
1
15
  function intEnv(name, fallback) {
2
16
  const raw = process.env[name];
3
17
  if (!raw)
@@ -19,22 +33,33 @@ function patternListEnv(name) {
19
33
  new RegExp(source);
20
34
  return parsed;
21
35
  }
22
- function parseIngestKeys(raw) {
36
+ /** Parse AUTTER_INGEST_KEYS. Entries without a string key, orgId and
37
+ * repositoryId, or with an unknown scope, are dropped (a typo in `scope`
38
+ * must not turn a restricted key into a server key). */
39
+ export function parseIngestKeys(raw) {
23
40
  if (!raw)
24
41
  return [];
42
+ let parsed;
25
43
  try {
26
- const parsed = JSON.parse(raw);
27
- if (!Array.isArray(parsed))
28
- return [];
29
- return parsed.filter((entry) => entry &&
30
- typeof entry.key === "string" &&
31
- typeof entry.orgId === "string" &&
32
- typeof entry.repositoryId === "string");
44
+ parsed = JSON.parse(raw);
33
45
  }
34
46
  catch {
35
47
  console.error("AUTTER_INGEST_KEYS is not valid JSON — ignoring");
36
48
  return [];
37
49
  }
50
+ if (!Array.isArray(parsed))
51
+ return [];
52
+ const keys = [];
53
+ parsed.forEach((entry, index) => {
54
+ if (!entry || typeof entry.key !== "string" || typeof entry.orgId !== "string" || typeof entry.repositoryId !== "string")
55
+ return;
56
+ if (entry.scope !== undefined && !isIngestKeyScope(entry.scope)) {
57
+ console.error(`AUTTER_INGEST_KEYS[${index}] has scope ${JSON.stringify(entry.scope)}; expected one of ${INGEST_KEY_SCOPES.join(", ")} — ignoring this key`);
58
+ return;
59
+ }
60
+ keys.push({ ...entry, scope: entry.scope ?? defaultScopeFor(entry.key) });
61
+ });
62
+ return keys;
38
63
  }
39
64
  export function loadConfig() {
40
65
  const config = {
@@ -52,15 +52,15 @@ export declare const browserPayloadSchema: z.ZodObject<{
52
52
  */
53
53
  traceId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
54
54
  }, "strip", z.ZodTypeAny, {
55
+ type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
55
56
  message: string;
56
- type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
57
57
  timestamp: string;
58
+ breadcrumbs?: unknown[] | undefined;
59
+ route?: string | undefined;
58
60
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
59
61
  errorType?: string | undefined;
60
62
  stack?: string | undefined;
61
- route?: string | undefined;
62
63
  traceId?: string | undefined;
63
- breadcrumbs?: unknown[] | undefined;
64
64
  debugIds?: Record<string, unknown> | undefined;
65
65
  durationMs?: number | undefined;
66
66
  filename?: string | undefined;
@@ -71,15 +71,15 @@ export declare const browserPayloadSchema: z.ZodObject<{
71
71
  context?: Record<string, unknown> | undefined;
72
72
  fingerprint?: string | string[] | undefined;
73
73
  }, {
74
- type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
74
+ type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
75
75
  timestamp: string;
76
+ breadcrumbs?: unknown;
77
+ route?: string | undefined;
76
78
  message?: string | undefined;
77
79
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
78
80
  errorType?: string | undefined;
79
81
  stack?: string | undefined;
80
- route?: string | undefined;
81
82
  traceId?: unknown;
82
- breadcrumbs?: unknown;
83
83
  debugIds?: unknown;
84
84
  durationMs?: number | undefined;
85
85
  filename?: string | undefined;
@@ -91,19 +91,19 @@ export declare const browserPayloadSchema: z.ZodObject<{
91
91
  fingerprint?: unknown;
92
92
  }>, "many">;
93
93
  }, "strip", z.ZodTypeAny, {
94
+ version: 1;
94
95
  service: string;
95
96
  environment: string;
96
- version: 1;
97
97
  events: {
98
+ type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
98
99
  message: string;
99
- type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
100
100
  timestamp: string;
101
+ breadcrumbs?: unknown[] | undefined;
102
+ route?: string | undefined;
101
103
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
102
104
  errorType?: string | undefined;
103
105
  stack?: string | undefined;
104
- route?: string | undefined;
105
106
  traceId?: string | undefined;
106
- breadcrumbs?: unknown[] | undefined;
107
107
  debugIds?: Record<string, unknown> | undefined;
108
108
  durationMs?: number | undefined;
109
109
  filename?: string | undefined;
@@ -118,19 +118,19 @@ export declare const browserPayloadSchema: z.ZodObject<{
118
118
  sessionId?: string | undefined;
119
119
  sdk?: string | undefined;
120
120
  }, {
121
+ version: 1;
121
122
  service: string;
122
123
  environment: string;
123
- version: 1;
124
124
  events: {
125
- type: "message" | "exception" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
125
+ type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
126
126
  timestamp: string;
127
+ breadcrumbs?: unknown;
128
+ route?: string | undefined;
127
129
  message?: string | undefined;
128
130
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
129
131
  errorType?: string | undefined;
130
132
  stack?: string | undefined;
131
- route?: string | undefined;
132
133
  traceId?: unknown;
133
- breadcrumbs?: unknown;
134
134
  debugIds?: unknown;
135
135
  durationMs?: number | undefined;
136
136
  filename?: string | undefined;
@@ -78,7 +78,13 @@ export interface OtlpMetricsRequest {
78
78
  }>;
79
79
  }
80
80
  export interface NormalizedTraces {
81
+ /** One per thrown error: duplicate recordings across a trace's spans are
82
+ * merged and redundant status-only occurrences dropped (trace-dedupe.ts). */
81
83
  occurrences: RuntimeOccurrenceInput[];
84
+ /** Each occurrence's position among every signal the batch recorded,
85
+ * before deduplication: the batch index its deterministic id is derived
86
+ * from, so a merge or a drop never shifts another occurrence's id. */
87
+ occurrenceBatchIndexes: number[];
82
88
  spans: RuntimeSpanRow[];
83
89
  metricPoints: RuntimeMetricPoint[];
84
90
  llmCalls: RuntimeLlmCall[];
@@ -5,6 +5,7 @@ import { scrubText } from "./redact.js";
5
5
  import { normalizeRoute } from "./fingerprint.js";
6
6
  import { extractLlmCall } from "./llm.js";
7
7
  import { BREADCRUMBS_ATTRIBUTE, breadcrumbsField } from "./breadcrumbs.js";
8
+ import { dedupeTraceSignals, hasAutterKey, hasNextContext } from "./trace-dedupe.js";
8
9
  import { asSeverity, } from "./types.js";
9
10
  const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
10
11
  function attrMap(attributes) {
@@ -100,8 +101,10 @@ function severityOf(eventAttrs, spanAttrs) {
100
101
  }
101
102
  const MAX_SPANS_PER_REQUEST = 5000;
102
103
  export function normalizeTraces(request) {
103
- const occurrences = [];
104
- const occurrenceSpanIds = [];
104
+ const signals = [];
105
+ // Per signal: whether a server span's request rollup already counts it,
106
+ // and the route its own rollup keys on (before HTTP inheritance).
107
+ const signalRollups = [];
105
108
  const spans = [];
106
109
  const llmCalls = [];
107
110
  const rollups = new Map();
@@ -143,39 +146,56 @@ export function normalizeTraces(request) {
143
146
  },
144
147
  startedAt,
145
148
  });
146
- // Error occurrences: one per exception event; if the span is
149
+ // Error signals: one per exception event; if the span is
147
150
  // errored without exception events, one from the span status.
148
- const occurrencesBefore = occurrences.length;
151
+ // They become occurrences after the batch-wide dedupe below.
152
+ const traceId = span.traceId ?? "";
153
+ const spanId = span.spanId ?? "";
154
+ const rollup = {
155
+ onRequestRollup: kind === "server" && attrs.get("autter.tail_retained") !== "true",
156
+ route: route ?? "",
157
+ };
158
+ const spanAutter = attrs.has("autter.severity");
149
159
  const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
150
160
  for (const event of exceptionEvents) {
151
161
  const eventAttrs = attrMap(event.attributes);
152
162
  const handled = eventAttrs.get("autter.handled") === "true";
153
- occurrenceSpanIds.push(span.spanId ?? "");
154
- occurrences.push({
155
- source: "server",
156
- severity: severityOf(eventAttrs, attrs),
157
- service: resource.service,
158
- environment: resource.environment,
159
- release: resource.release,
160
- errorType: eventAttrs.get("exception.type") ?? "Error",
161
- message: eventAttrs.get("exception.message") ??
162
- span.status?.message ??
163
- span.name ??
164
- "Unknown error",
165
- stack: eventAttrs.get("exception.stacktrace") ?? null,
166
- route,
167
- method: methodOf(attrs),
168
- statusCode,
169
- traceId: span.traceId ?? null,
170
- sessionId: null,
171
- attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
172
- ...(handled ? { "autter.handled": true, "autter.sampled": eventAttrs.get("autter.sampled") === "true" } : {}) }),
173
- occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
174
- // Event attributes first: one span can carry several
175
- // exceptions, each with its own code.
176
- ...liftErrorFields(eventAttrs, attrs),
177
- ...breadcrumbsField(eventAttrs.get(BREADCRUMBS_ATTRIBUTE), attrs.get(BREADCRUMBS_ATTRIBUTE)),
178
- ...fingerprintOverrideOf(eventAttrs.get("autter.fingerprint") ?? attrs.get("autter.fingerprint")),
163
+ const eventAutter = hasAutterKey(eventAttrs);
164
+ signalRollups.push(rollup);
165
+ signals.push({
166
+ traceId,
167
+ spanId,
168
+ signal: "exception",
169
+ autterCapture: eventAutter || spanAutter,
170
+ eventAutter,
171
+ nextContext: hasNextContext(eventAttrs, attrs),
172
+ handled,
173
+ occurrence: {
174
+ source: "server",
175
+ severity: severityOf(eventAttrs, attrs),
176
+ service: resource.service,
177
+ environment: resource.environment,
178
+ release: resource.release,
179
+ errorType: eventAttrs.get("exception.type") ?? "Error",
180
+ message: eventAttrs.get("exception.message") ??
181
+ span.status?.message ??
182
+ span.name ??
183
+ "Unknown error",
184
+ stack: eventAttrs.get("exception.stacktrace") ?? null,
185
+ route,
186
+ method: methodOf(attrs),
187
+ statusCode,
188
+ traceId: span.traceId ?? null,
189
+ sessionId: null,
190
+ attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
191
+ ...(handled ? { "autter.handled": true, "autter.sampled": eventAttrs.get("autter.sampled") === "true" } : {}) }),
192
+ occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
193
+ // Event attributes first: one span can carry several
194
+ // exceptions, each with its own code.
195
+ ...liftErrorFields(eventAttrs, attrs),
196
+ ...breadcrumbsField(eventAttrs.get(BREADCRUMBS_ATTRIBUTE), attrs.get(BREADCRUMBS_ATTRIBUTE)),
197
+ ...fingerprintOverrideOf(eventAttrs.get("autter.fingerprint") ?? attrs.get("autter.fingerprint")),
198
+ },
179
199
  });
180
200
  }
181
201
  const failedOutcomes = (span.events ?? []).filter((event) => {
@@ -186,40 +206,58 @@ export function normalizeTraces(request) {
186
206
  for (const event of failedOutcomes) {
187
207
  const outcome = attrMap(event.attributes);
188
208
  const name = (outcome.get("autter.outcome.name") ?? "operation").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 200);
189
- occurrenceSpanIds.push(span.spanId ?? "");
190
- occurrences.push({
191
- source: "server", severity: "error", service: resource.service,
192
- environment: resource.environment, release: resource.release,
193
- errorType: "OutcomeFailure",
194
- message: `${name}: ${(outcome.get("autter.outcome.message") ?? "failed").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 1000)}`,
195
- stack: String(decodeOtlpAttributes(event.attributes)["autter.outcome.stack"] ?? "") || null, route, method: methodOf(attrs), statusCode,
196
- traceId: span.traceId ?? null, sessionId: null,
197
- attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes) }),
198
- occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
199
- ...liftErrorFields(outcome, attrs),
209
+ signalRollups.push(rollup);
210
+ signals.push({
211
+ traceId,
212
+ spanId,
213
+ signal: "outcome",
214
+ autterCapture: true,
215
+ eventAutter: true,
216
+ nextContext: hasNextContext(outcome, attrs),
217
+ handled: false,
218
+ occurrence: {
219
+ source: "server", severity: "error", service: resource.service,
220
+ environment: resource.environment, release: resource.release,
221
+ errorType: "OutcomeFailure",
222
+ message: `${name}: ${(outcome.get("autter.outcome.message") ?? "failed").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 1000)}`,
223
+ stack: String(decodeOtlpAttributes(event.attributes)["autter.outcome.stack"] ?? "") || null, route, method: methodOf(attrs), statusCode,
224
+ traceId: span.traceId ?? null, sessionId: null,
225
+ attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes) }),
226
+ occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
227
+ ...liftErrorFields(outcome, attrs),
228
+ },
200
229
  });
201
230
  }
202
231
  if (exceptionEvents.length === 0 && failedOutcomes.length === 0 && isError) {
203
- occurrenceSpanIds.push(span.spanId ?? "");
204
- occurrences.push({
205
- source: "server",
206
- severity: severityOf(new Map(), attrs),
207
- service: resource.service,
208
- environment: resource.environment,
209
- release: resource.release,
210
- errorType: statusCode !== null && statusCode >= 500 ? "HttpServerError" : "SpanError",
211
- message: span.status?.message || (statusCode !== null && statusCode >= 500
212
- ? `${methodOf(attrs) ?? "HTTP"} ${route ?? span.name ?? "request"} returned ${statusCode}`
213
- : `${span.name ?? "span"} failed`),
214
- stack: null,
215
- route,
216
- method: methodOf(attrs),
217
- statusCode,
218
- traceId: span.traceId ?? null,
219
- sessionId: null,
220
- attributes: decodeOtlpAttributes(span.attributes),
221
- occurredAt: startedAt,
222
- ...liftErrorFields(attrs),
232
+ signalRollups.push(rollup);
233
+ signals.push({
234
+ traceId,
235
+ spanId,
236
+ signal: "status",
237
+ autterCapture: spanAutter,
238
+ eventAutter: false,
239
+ nextContext: hasNextContext(attrs),
240
+ handled: false,
241
+ occurrence: {
242
+ source: "server",
243
+ severity: severityOf(new Map(), attrs),
244
+ service: resource.service,
245
+ environment: resource.environment,
246
+ release: resource.release,
247
+ errorType: statusCode !== null && statusCode >= 500 ? "HttpServerError" : "SpanError",
248
+ message: span.status?.message || (statusCode !== null && statusCode >= 500
249
+ ? `${methodOf(attrs) ?? "HTTP"} ${route ?? span.name ?? "request"} returned ${statusCode}`
250
+ : `${span.name ?? "span"} failed`),
251
+ stack: null,
252
+ route,
253
+ method: methodOf(attrs),
254
+ statusCode,
255
+ traceId: span.traceId ?? null,
256
+ sessionId: null,
257
+ attributes: decodeOtlpAttributes(span.attributes),
258
+ occurredAt: startedAt,
259
+ ...liftErrorFields(attrs),
260
+ },
223
261
  });
224
262
  }
225
263
  // LLM provider calls (GenAI semconv, Vercel AI SDK telemetry,
@@ -249,7 +287,7 @@ export function normalizeTraces(request) {
249
287
  // ships those at ~100% alongside a metrics pipeline that already
250
288
  // counts every request, so folding them in would double-count
251
289
  // erroring routes.
252
- if (kind === "server" && attrs.get("autter.tail_retained") !== "true") {
290
+ if (rollup.onRequestRollup) {
253
291
  addToRollup(rollups, {
254
292
  service: resource.service,
255
293
  environment: resource.environment,
@@ -262,33 +300,42 @@ export function normalizeTraces(request) {
262
300
  sessionCount: 0,
263
301
  });
264
302
  }
265
- else {
266
- // Occurrences on non-server spans (captureException's internal
267
- // error spans, workers, consumers) have no request rollup to
268
- // ride — count each as one event / one error event, or a
269
- // service whose errors arrive outside HTTP handlers groups
270
- // issues while every event counter stays 0. Server spans are
271
- // excluded: their request rollup above already represents them.
272
- for (const occ of occurrences.slice(occurrencesBefore)) {
273
- addToRollup(rollups, {
274
- service: occ.service,
275
- environment: occ.environment,
276
- release: occ.release,
277
- route: occ.route ?? "",
278
- bucketAt: minuteBucket(occ.occurredAt),
279
- requestCount: 1,
280
- errorCount: occ.severity === "fatal" || occ.severity === "error" ? 1 : 0,
281
- durationSumMs: 0,
282
- sessionCount: 0,
283
- });
284
- }
285
- }
286
303
  }
287
304
  }
288
305
  }
289
- inheritHttpFromParent(occurrences, occurrenceSpanIds, spans);
306
+ // One occurrence per thrown error: recordings of one error on several
307
+ // spans of a trace merge, and status-only occurrences an error on their
308
+ // span chain explains are dropped (trace-dedupe.ts).
309
+ const deduped = dedupeTraceSignals(signals, spans);
310
+ const occurrences = deduped.occurrences;
311
+ // Occurrences on non-server spans (captureException's internal error
312
+ // spans, workers, consumers) have no request rollup to ride — count each
313
+ // as one event / one error event, or a service whose errors arrive
314
+ // outside HTTP handlers groups issues while every event counter stays 0.
315
+ // Server spans are excluded: their request rollup above already
316
+ // represents them, and so does it for a merged error any of whose
317
+ // recordings sits on one. Counted after the dedupe, so each thrown error
318
+ // is one event; a dropped status-only occurrence is none.
319
+ deduped.members.forEach((members, i) => {
320
+ if (members.some((m) => signalRollups[m].onRequestRollup))
321
+ return;
322
+ const occ = occurrences[i];
323
+ addToRollup(rollups, {
324
+ service: occ.service,
325
+ environment: occ.environment,
326
+ release: occ.release,
327
+ route: signalRollups[deduped.sourceIndexes[i]].route,
328
+ bucketAt: minuteBucket(occ.occurredAt),
329
+ requestCount: 1,
330
+ errorCount: occ.severity === "fatal" || occ.severity === "error" ? 1 : 0,
331
+ durationSumMs: 0,
332
+ sessionCount: 0,
333
+ });
334
+ });
335
+ inheritHttpFromParent(occurrences, deduped.sourceIndexes.map((index) => signals[index].spanId), spans);
290
336
  return {
291
337
  occurrences,
338
+ occurrenceBatchIndexes: deduped.sourceIndexes,
292
339
  spans,
293
340
  metricPoints: [...rollups.values()],
294
341
  llmCalls,
package/dist/server.d.ts CHANGED
@@ -11,4 +11,11 @@ export interface IngesterApp {
11
11
  * Autter dashboard). Called after the boot schema warm-up; throttled. */
12
12
  reportIngesterInfo: () => Promise<void>;
13
13
  }
14
+ /** The only routes an upload key (`autter_rtu_…`, scope "upload") may call. */
15
+ export declare const UPLOAD_KEY_ROUTES: readonly ["/v1/sourcemaps", "/v1/sourcemaps/check"];
16
+ /** The 403 an upload key gets on every other authenticated route. */
17
+ export declare const UPLOAD_KEY_NOT_ALLOWED: {
18
+ readonly error: "upload_key_not_allowed";
19
+ readonly detail: string;
20
+ };
14
21
  export declare function createIngesterApp(config: IngesterConfig): IngesterApp;