@autter/otlp-ingester 1.7.0 → 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.
@@ -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,17 @@ 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
+ ];
65
77
  export const MIGRATIONS = [
66
78
  // 0001 intentionally reserved as the baseline marker: databases created
67
79
  // before the migration runner existed record it as applied without
@@ -202,6 +214,22 @@ export const MIGRATIONS = [
202
214
  `ALTER TABLE {db}.runtime_source_maps ADD ${SOURCE_MAP_DEBUG_ID_INDEX.replace("INDEX ", "INDEX IF NOT EXISTS ")}`,
203
215
  ],
204
216
  },
217
+ // Rejections and drops per tenant, minute, route and reason (EI-27):
218
+ // what the ingester refused or did not store, so a repository's missing
219
+ // telemetry has a reason (ingest-drops.ts).
220
+ { id: "0018-runtime-ingest-drops", statements: [ingestDropsTableDDL("{db}")] },
221
+ // How each service's telemetry arrived: an OpenTelemetry Collector, an
222
+ // SDK exporter directly, or the browser relay (sdk-versions.ts).
223
+ {
224
+ id: "0019-sdk-versions-via",
225
+ statements: [`ALTER TABLE {db}.runtime_sdk_versions ADD COLUMN IF NOT EXISTS ${SDK_VERSION_VIA_COLUMN}`],
226
+ },
227
+ // Resolved and unresolved in-app browser frames per occurrence, after
228
+ // symbolication — the share the EI-14 source map rule reads.
229
+ {
230
+ id: "0020-occurrence-frame-resolution",
231
+ statements: OCCURRENCE_FRAME_RESOLUTION_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
232
+ },
205
233
  ];
206
234
  /** The tracking table itself — created by the runner before anything else. */
207
235
  export function migrationsTableDDL(db) {
@@ -1,13 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { type RuntimeMetricPoint, type RuntimeOccurrenceInput } from "./types.js";
3
+ /** Events past this in one payload are dropped and counted (events_over_limit). */
4
+ export declare const MAX_BROWSER_EVENTS = 50;
5
+ /** A whole payload, every event valid: what normalizeBrowserPayload takes. */
3
6
  export declare const browserPayloadSchema: z.ZodObject<{
4
- version: z.ZodLiteral<1>;
5
- sessionId: z.ZodOptional<z.ZodString>;
6
- service: z.ZodString;
7
- environment: z.ZodString;
8
- release: z.ZodOptional<z.ZodString>;
9
- /** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
10
- sdk: z.ZodCatch<z.ZodOptional<z.ZodString>>;
11
7
  events: z.ZodArray<z.ZodObject<{
12
8
  id: z.ZodOptional<z.ZodString>;
13
9
  type: z.ZodEnum<["exception", "unhandled_rejection", "message", "session_start", "track_event", "outcome", "request_failure", "csp_violation", "timing"]>;
@@ -64,9 +60,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
64
60
  debugIds?: Record<string, unknown> | undefined;
65
61
  durationMs?: number | undefined;
66
62
  filename?: string | undefined;
63
+ name?: string | undefined;
67
64
  column?: number | undefined;
68
65
  line?: number | undefined;
69
- name?: string | undefined;
70
66
  id?: string | undefined;
71
67
  context?: Record<string, unknown> | undefined;
72
68
  fingerprint?: string | string[] | undefined;
@@ -83,13 +79,20 @@ export declare const browserPayloadSchema: z.ZodObject<{
83
79
  debugIds?: unknown;
84
80
  durationMs?: number | undefined;
85
81
  filename?: string | undefined;
82
+ name?: string | undefined;
86
83
  column?: number | undefined;
87
84
  line?: number | undefined;
88
- name?: string | undefined;
89
85
  id?: string | undefined;
90
86
  context?: Record<string, unknown> | undefined;
91
87
  fingerprint?: unknown;
92
88
  }>, "many">;
89
+ version: z.ZodLiteral<1>;
90
+ sessionId: z.ZodOptional<z.ZodString>;
91
+ service: z.ZodString;
92
+ environment: z.ZodString;
93
+ release: z.ZodOptional<z.ZodString>;
94
+ /** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
95
+ sdk: z.ZodCatch<z.ZodOptional<z.ZodString>>;
93
96
  }, "strip", z.ZodTypeAny, {
94
97
  version: 1;
95
98
  service: string;
@@ -107,9 +110,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
107
110
  debugIds?: Record<string, unknown> | undefined;
108
111
  durationMs?: number | undefined;
109
112
  filename?: string | undefined;
113
+ name?: string | undefined;
110
114
  column?: number | undefined;
111
115
  line?: number | undefined;
112
- name?: string | undefined;
113
116
  id?: string | undefined;
114
117
  context?: Record<string, unknown> | undefined;
115
118
  fingerprint?: string | string[] | undefined;
@@ -134,9 +137,9 @@ export declare const browserPayloadSchema: z.ZodObject<{
134
137
  debugIds?: unknown;
135
138
  durationMs?: number | undefined;
136
139
  filename?: string | undefined;
140
+ name?: string | undefined;
137
141
  column?: number | undefined;
138
142
  line?: number | undefined;
139
- name?: string | undefined;
140
143
  id?: string | undefined;
141
144
  context?: Record<string, unknown> | undefined;
142
145
  fingerprint?: unknown;
@@ -146,6 +149,31 @@ export declare const browserPayloadSchema: z.ZodObject<{
146
149
  sdk?: unknown;
147
150
  }>;
148
151
  export type BrowserPayload = z.infer<typeof browserPayloadSchema>;
152
+ export interface ParsedBrowserPayload {
153
+ /** The envelope with its valid events (at most MAX_BROWSER_EVENTS). */
154
+ payload: BrowserPayload;
155
+ /** Events dropped because they failed validation (invalid_event). */
156
+ invalidEvents: number;
157
+ /** Events past MAX_BROWSER_EVENTS, not looked at (events_over_limit). */
158
+ overLimitEvents: number;
159
+ /** The first validation issues of the dropped events; `path` starts with ["events", index]. */
160
+ issues: z.ZodIssue[];
161
+ }
162
+ /**
163
+ * Validate a browser payload event by event (1.8.0). The envelope (version,
164
+ * service, environment) must be valid or the payload is refused; an event
165
+ * that fails validation — a type this ingester doesn't know, a bad
166
+ * timestamp, an oversized field — is dropped on its own and counted, so it
167
+ * no longer costs the errors sent in the same beacon. Events past
168
+ * MAX_BROWSER_EVENTS are dropped and counted unread.
169
+ */
170
+ export declare function parseBrowserPayload(body: unknown): {
171
+ success: true;
172
+ data: ParsedBrowserPayload;
173
+ } | {
174
+ success: false;
175
+ issues: z.ZodIssue[];
176
+ };
149
177
  export interface NormalizedBrowser {
150
178
  occurrences: RuntimeOccurrenceInput[];
151
179
  metricPoints: RuntimeMetricPoint[];
@@ -67,7 +67,9 @@ const browserEventSchema = z.object({
67
67
  */
68
68
  traceId: z.string().regex(/^(?!0{32})[0-9a-f]{32}$/).optional().catch(undefined),
69
69
  });
70
- export const browserPayloadSchema = z.object({
70
+ /** Events past this in one payload are dropped and counted (events_over_limit). */
71
+ export const MAX_BROWSER_EVENTS = 50;
72
+ const browserEnvelope = {
71
73
  version: z.literal(1),
72
74
  sessionId: z.string().max(100).optional(),
73
75
  service: z.string().min(1).max(200),
@@ -75,8 +77,53 @@ export const browserPayloadSchema = z.object({
75
77
  release: z.string().max(200).optional(),
76
78
  /** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
77
79
  sdk: z.string().regex(/^\d{1,6}\.\d{1,6}\.\d{1,6}[0-9A-Za-z.+-]{0,20}$/).optional().catch(undefined),
78
- events: z.array(browserEventSchema).max(50),
80
+ };
81
+ /** A whole payload, every event valid: what normalizeBrowserPayload takes. */
82
+ export const browserPayloadSchema = z.object({
83
+ ...browserEnvelope,
84
+ events: z.array(browserEventSchema).max(MAX_BROWSER_EVENTS),
79
85
  });
86
+ const browserEnvelopeSchema = z.object({ ...browserEnvelope, events: z.array(z.unknown()) });
87
+ /**
88
+ * Validate a browser payload event by event (1.8.0). The envelope (version,
89
+ * service, environment) must be valid or the payload is refused; an event
90
+ * that fails validation — a type this ingester doesn't know, a bad
91
+ * timestamp, an oversized field — is dropped on its own and counted, so it
92
+ * no longer costs the errors sent in the same beacon. Events past
93
+ * MAX_BROWSER_EVENTS are dropped and counted unread.
94
+ */
95
+ export function parseBrowserPayload(body) {
96
+ const envelope = browserEnvelopeSchema.safeParse(body);
97
+ if (!envelope.success)
98
+ return { success: false, issues: envelope.error.issues.slice(0, 5) };
99
+ const raw = envelope.data.events;
100
+ const considered = raw.slice(0, MAX_BROWSER_EVENTS);
101
+ const events = [];
102
+ const issues = [];
103
+ let invalidEvents = 0;
104
+ considered.forEach((candidate, index) => {
105
+ const parsed = browserEventSchema.safeParse(candidate);
106
+ if (parsed.success) {
107
+ events.push(parsed.data);
108
+ return;
109
+ }
110
+ invalidEvents += 1;
111
+ for (const issue of parsed.error.issues) {
112
+ if (issues.length >= 5)
113
+ break;
114
+ issues.push({ ...issue, path: ["events", index, ...issue.path] });
115
+ }
116
+ });
117
+ return {
118
+ success: true,
119
+ data: {
120
+ payload: { ...envelope.data, events },
121
+ invalidEvents,
122
+ overLimitEvents: raw.length - considered.length,
123
+ issues,
124
+ },
125
+ };
126
+ }
80
127
  const TYPE_TO_ERROR_TYPE = {
81
128
  exception: "Error",
82
129
  unhandled_rejection: "UnhandledRejection",
@@ -154,14 +201,13 @@ export function normalizeBrowserPayload(payload) {
154
201
  continue;
155
202
  }
156
203
  const severity = asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error");
157
- // The browser has no request stream, so error/message events are the
158
- // only usage signal for many pages — count each as an event (and, for
159
- // fatal/error severity, an error event). Without this, a page that
160
- // only reports errors groups issues whose dashboards all read 0.
161
- bumpRollup(event.route ? (event.route.split("?")[0] ?? "") : "", occurredAt, {
162
- requestCount: 1,
163
- errorCount: severity === "fatal" || severity === "error" ? 1 : 0,
164
- });
204
+ // A browser error belongs to no server request: it counts in
205
+ // `error_count` only (fatal/error severity), never `request_count`,
206
+ // so a page that only reports errors still shows them without
207
+ // inventing requests. Warnings and info messages count nothing.
208
+ if (severity === "fatal" || severity === "error") {
209
+ bumpRollup(event.route ? (event.route.split("?")[0] ?? "") : "", occurredAt, { errorCount: 1 });
210
+ }
165
211
  occurrences.push({
166
212
  source: "browser",
167
213
  severity,
@@ -6,6 +6,7 @@ import { normalizeRoute } from "./fingerprint.js";
6
6
  import { extractLlmCall } from "./llm.js";
7
7
  import { BREADCRUMBS_ATTRIBUTE, breadcrumbsField } from "./breadcrumbs.js";
8
8
  import { dedupeTraceSignals, hasAutterKey, hasNextContext } from "./trace-dedupe.js";
9
+ import { requestMembership } from "./request-spans.js";
9
10
  import { asSeverity, } from "./types.js";
10
11
  const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
11
12
  function attrMap(attributes) {
@@ -57,13 +58,18 @@ function spanKind(kind) {
57
58
  }
58
59
  function resourceInfo(resource) {
59
60
  const attrs = attrMap(resource?.attributes);
61
+ const service = attrs.get("service.name") ?? "unknown";
62
+ const environment = attrs.get("deployment.environment.name") ??
63
+ attrs.get("deployment.environment") ??
64
+ "production";
65
+ const release = attrs.get("service.version") ?? null;
60
66
  return {
61
- service: attrs.get("service.name") ?? "unknown",
62
- environment: attrs.get("deployment.environment.name") ??
63
- attrs.get("deployment.environment") ??
64
- "production",
65
- release: attrs.get("service.version") ?? null,
67
+ service,
68
+ environment,
69
+ release,
70
+ metricsCountRoutedRequests: attrs.get("autter.metrics_wired") === "true" && attrs.get("autter.metrics_routes") === "true",
66
71
  metricsWired: attrs.get("autter.metrics_wired") === "true",
72
+ process: [service, environment, release ?? "", attrs.get("service.instance.id") ?? ""].join("\u0000"),
67
73
  };
68
74
  }
69
75
  function routeOf(attrs) {
@@ -73,6 +79,13 @@ function routeOf(attrs) {
73
79
  null;
74
80
  return route ? (route.split("?")[0] ?? null) : null;
75
81
  }
82
+ /** How good a request name `routeOf` found: 2 for a route template
83
+ * (`http.route`), 1 for a raw path (`url.path` / `http.target`), 0 for none. */
84
+ function routeQualityOf(attrs, route) {
85
+ if (!route)
86
+ return 0;
87
+ return attrs.has("http.route") ? 2 : 1;
88
+ }
76
89
  function statusCodeOf(attrs) {
77
90
  const raw = attrs.get("http.response.status_code") ?? attrs.get("http.status_code");
78
91
  if (!raw)
@@ -102,10 +115,12 @@ function severityOf(eventAttrs, spanAttrs) {
102
115
  const MAX_SPANS_PER_REQUEST = 5000;
103
116
  export function normalizeTraces(request) {
104
117
  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).
118
+ // Per signal: the span it was recorded on (its index in `spans`), and the
119
+ // route its own event rollup keys on (before HTTP inheritance).
107
120
  const signalRollups = [];
108
121
  const spans = [];
122
+ // Per span, what the request rollups need beyond the span row.
123
+ const spanFacts = [];
109
124
  const llmCalls = [];
110
125
  const rollups = new Map();
111
126
  let spanCount = 0;
@@ -146,15 +161,20 @@ export function normalizeTraces(request) {
146
161
  },
147
162
  startedAt,
148
163
  });
164
+ spanFacts.push({
165
+ process: resource.process,
166
+ routeQuality: routeQualityOf(attrs, route),
167
+ // Who counts this span's request, as its outermost server
168
+ // span: see countedBy.
169
+ countedBy: countedBy(resource, attrs),
170
+ templateRoute: attrs.has("http.route") && route ? normalizeRoute(route) : "",
171
+ });
149
172
  // Error signals: one per exception event; if the span is
150
173
  // errored without exception events, one from the span status.
151
174
  // They become occurrences after the batch-wide dedupe below.
152
175
  const traceId = span.traceId ?? "";
153
176
  const spanId = span.spanId ?? "";
154
- const rollup = {
155
- onRequestRollup: kind === "server" && attrs.get("autter.tail_retained") !== "true",
156
- route: route ?? "",
157
- };
177
+ const rollup = { spanIndex: spans.length - 1, route: route ?? "" };
158
178
  const spanAutter = attrs.has("autter.severity");
159
179
  const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
160
180
  for (const event of exceptionEvents) {
@@ -281,25 +301,6 @@ export function normalizeTraces(request) {
281
301
  });
282
302
  if (llmCall)
283
303
  llmCalls.push(llmCall);
284
- // Server spans fold into 1-minute usage rollups so traffic is
285
- // tracked even when the metrics pipeline isn't wired. Spans
286
- // exported by error-linked tail retention are skipped: the SDK
287
- // ships those at ~100% alongside a metrics pipeline that already
288
- // counts every request, so folding them in would double-count
289
- // erroring routes.
290
- if (rollup.onRequestRollup) {
291
- addToRollup(rollups, {
292
- service: resource.service,
293
- environment: resource.environment,
294
- release: resource.release,
295
- route: normalizeRoute(route),
296
- bucketAt: minuteBucket(startedAt),
297
- requestCount: 1,
298
- errorCount: isError ? 1 : 0,
299
- durationSumMs: durationMs,
300
- sessionCount: 0,
301
- });
302
- }
303
304
  }
304
305
  }
305
306
  }
@@ -308,30 +309,94 @@ export function normalizeTraces(request) {
308
309
  // span chain explains are dropped (trace-dedupe.ts).
309
310
  const deduped = dedupeTraceSignals(signals, spans);
310
311
  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.
312
+ // Usage rollups (`runtime_metrics_1m`): `request_count` counts requests
313
+ // and only requests, `error_count` counts failed requests and errors
314
+ // outside any request. A request is its outermost server span
315
+ // (request-spans.ts); its server spans count it unless the sender's metric
316
+ // feed does (countedBySpans), and nothing else adds requests.
317
+ const membership = requestMembership(spans.map((span, i) => ({
318
+ traceId: span.traceId,
319
+ spanId: span.spanId,
320
+ parentSpanId: span.parentSpanId,
321
+ kind: span.kind,
322
+ process: spanFacts[i].process,
323
+ })));
324
+ const requests = spanRequests(spans, spanFacts, membership);
325
+ const requestOfSignal = (m) => {
326
+ const root = membership.requestOf[signalRollups[m].spanIndex];
327
+ return root >= 0 ? requests.get(root) : undefined;
328
+ };
329
+ // Each error or fatal occurrence (after the dedupe: one per thrown error)
330
+ // counts once. One recorded in a request (on its server spans or any span
331
+ // below them in the batch) makes that request a failed one; any other
332
+ // (captureException's always-on spans exported apart from their request,
333
+ // workers, consumers, jobs) is one error outside a request. Warnings and
334
+ // info count nothing; neither does a dropped status-only occurrence.
335
+ const errorsOutsideRequests = [];
319
336
  deduped.members.forEach((members, i) => {
320
- if (members.some((m) => signalRollups[m].onRequestRollup))
321
- return;
322
337
  const occ = occurrences[i];
323
- addToRollup(rollups, {
338
+ if (occ.severity !== "fatal" && occ.severity !== "error")
339
+ return;
340
+ const request = members.map(requestOfSignal).find((r) => r !== undefined);
341
+ if (request) {
342
+ request.hasErrorOccurrence = true;
343
+ return;
344
+ }
345
+ errorsOutsideRequests.push({
324
346
  service: occ.service,
325
347
  environment: occ.environment,
326
348
  release: occ.release,
327
349
  route: signalRollups[deduped.sourceIndexes[i]].route,
328
350
  bucketAt: minuteBucket(occ.occurredAt),
329
- requestCount: 1,
330
- errorCount: occ.severity === "fatal" || occ.severity === "error" ? 1 : 0,
351
+ requestCount: 0,
352
+ errorCount: 1,
331
353
  durationSumMs: 0,
332
354
  sessionCount: 0,
333
355
  });
334
356
  });
357
+ for (const [root, request] of requests) {
358
+ const span = spans[root];
359
+ const failed = request.isError || request.hasErrorOccurrence;
360
+ const point = {
361
+ service: span.service,
362
+ environment: span.environment,
363
+ release: span.release,
364
+ bucketAt: minuteBucket(span.startedAt),
365
+ sessionCount: 0,
366
+ };
367
+ switch (spanFacts[root].countedBy) {
368
+ case "spans":
369
+ addToRollup(rollups, {
370
+ ...point,
371
+ route: request.route,
372
+ requestCount: 1,
373
+ errorCount: failed ? 1 : 0,
374
+ // The request's latency is its outermost span's.
375
+ durationSumMs: span.durationMs,
376
+ });
377
+ break;
378
+ case "metrics": {
379
+ // The metric feed counted it, and already made a 5xx response
380
+ // a failed request. A request it saw succeed that recorded an
381
+ // error adds that one error, on the route its point carried.
382
+ const metricCountedFailure = span.statusCode !== null && span.statusCode >= 500;
383
+ if (request.hasErrorOccurrence && !metricCountedFailure) {
384
+ addToRollup(rollups, { ...point, route: spanFacts[root].templateRoute, requestCount: 0, errorCount: 1, durationSumMs: 0 });
385
+ }
386
+ break;
387
+ }
388
+ case "none":
389
+ // Exported because it failed, with no metric feed counting
390
+ // requests: not a sample of the traffic, so no request; its
391
+ // failure counts once.
392
+ if (failed) {
393
+ addToRollup(rollups, { ...point, route: request.route, requestCount: 0, errorCount: 1, durationSumMs: 0 });
394
+ }
395
+ break;
396
+ }
397
+ }
398
+ for (const error of errorsOutsideRequests)
399
+ addToRollup(rollups, error);
335
400
  inheritHttpFromParent(occurrences, deduped.sourceIndexes.map((index) => signals[index].spanId), spans);
336
401
  return {
337
402
  occurrences,
@@ -342,6 +407,53 @@ export function normalizeTraces(request) {
342
407
  spanCount,
343
408
  };
344
409
  }
410
+ function countedBy(resource, attrs) {
411
+ if (resource.metricsCountRoutedRequests && attrs.get("autter.metric_has_route") === "true")
412
+ return "metrics";
413
+ if (attrs.get("autter.tail_retained") === "true")
414
+ return resource.metricsWired ? "metrics" : "none";
415
+ return "spans";
416
+ }
417
+ /**
418
+ * Every request in the batch, keyed by the batch index of its outermost
419
+ * server span (request-spans.ts). Server spans nested in it add no request:
420
+ * they lend their route when it is better (a template such as Next's
421
+ * `/blog/[slug]` over the HTTP span's raw path; on a tie the shallower
422
+ * span's), and their error. Latency and minute bucket stay the outermost
423
+ * span's.
424
+ */
425
+ function spanRequests(spans, facts, membership) {
426
+ const requests = new Map();
427
+ spans.forEach((span, i) => {
428
+ if (membership.requestOf[i] !== i)
429
+ return;
430
+ requests.set(i, {
431
+ route: span.route ?? "",
432
+ routeQuality: facts[i].routeQuality,
433
+ routeDepth: membership.depth[i],
434
+ isError: span.status === "error",
435
+ hasErrorOccurrence: false,
436
+ });
437
+ });
438
+ spans.forEach((span, i) => {
439
+ const root = membership.requestOf[i];
440
+ if (root === i || span.kind !== "server")
441
+ return;
442
+ const request = requests.get(root);
443
+ if (!request)
444
+ return;
445
+ const quality = facts[i].routeQuality;
446
+ const depth = membership.depth[i];
447
+ if (quality > request.routeQuality || (quality === request.routeQuality && depth < request.routeDepth)) {
448
+ request.route = span.route ?? "";
449
+ request.routeQuality = quality;
450
+ request.routeDepth = depth;
451
+ }
452
+ if (span.status === "error")
453
+ request.isError = true;
454
+ });
455
+ return requests;
456
+ }
345
457
  /**
346
458
  * Exception spans are often children named "Error" with no HTTP attributes.
347
459
  * Copy method, route, status, and duration from the server span in the same
@@ -438,8 +550,10 @@ const HTTP_DURATION_INSTRUMENTS = {
438
550
  * Cumulative histograms report lifetime totals on every export; adding them
439
551
  * into a SummingMergeTree would re-count all past requests each interval.
440
552
  * Only deltas are summable — cumulative senders are skipped and covered by
441
- * the span-fed rollup fallback instead. (Enum arrives as a number or an
442
- * `AGGREGATION_TEMPORALITY_*` string depending on the serialiser.)
553
+ * the span-fed rollup fallback instead (a sender must not mark itself
554
+ * `autter.metrics_wired` with cumulative histograms). (Enum arrives as a
555
+ * number or an `AGGREGATION_TEMPORALITY_*` string depending on the
556
+ * serialiser.)
443
557
  */
444
558
  function isCumulativeTemporality(t) {
445
559
  return t === 2 || t === "AGGREGATION_TEMPORALITY_CUMULATIVE";
@@ -448,6 +562,10 @@ export function normalizeMetrics(request) {
448
562
  const rollups = new Map();
449
563
  for (const resourceMetric of request.resourceMetrics ?? []) {
450
564
  const resource = resourceInfo(resourceMetric.resource);
565
+ // An SDK migrating semantic conventions can emit both instruments for
566
+ // every request (OTel's `OTEL_SEMCONV_STABILITY_OPT_IN=http/dup`).
567
+ // Count the stable one, as the latency histograms do (latency.ts).
568
+ const hasStableDuration = resourceMetric.scopeMetrics?.some((scope) => scope.metrics?.some((metric) => metric.name === "http.server.request.duration"));
451
569
  for (const scopeMetric of resourceMetric.scopeMetrics ?? []) {
452
570
  for (const metric of scopeMetric.metrics ?? []) {
453
571
  const multiplier = metric.name
@@ -455,6 +573,8 @@ export function normalizeMetrics(request) {
455
573
  : undefined;
456
574
  if (multiplier === undefined)
457
575
  continue;
576
+ if (hasStableDuration && metric.name === "http.server.duration")
577
+ continue;
458
578
  if (isCumulativeTemporality(metric.histogram?.aggregationTemporality)) {
459
579
  continue;
460
580
  }
@@ -3,3 +3,17 @@ import type { OtlpLogsRequest } from "./logs.js";
3
3
  export declare function decodeTraceRequest(body: Buffer): OtlpTraceRequest;
4
4
  export declare function decodeMetricsRequest(body: Buffer): OtlpMetricsRequest;
5
5
  export declare function decodeLogsRequest(body: Buffer): OtlpLogsRequest;
6
+ /**
7
+ * The protobuf body of an OTLP export response: empty when everything was
8
+ * accepted, else `partial_success` with the rejected count and why
9
+ * (identical bytes for traces, logs and metrics).
10
+ */
11
+ export declare function encodeExportResponse(partial: {
12
+ rejected: number;
13
+ errorMessage: string;
14
+ } | null): Uint8Array;
15
+ /** Decode an OTLP export response body (tests, and anything reading our answers). */
16
+ export declare function decodeExportResponse(body: Uint8Array): {
17
+ rejected: number;
18
+ errorMessage: string;
19
+ } | null;
@@ -93,11 +93,19 @@ message HistogramDataPoint {
93
93
  repeated double explicit_bounds = 7;
94
94
  repeated KeyValue attributes = 9;
95
95
  }
96
+
97
+ // ExportTraceServiceResponse, ExportLogsServiceResponse and
98
+ // ExportMetricsServiceResponse share this wire shape: partial_success = 1,
99
+ // and in it the rejected count (rejected_spans / rejected_log_records /
100
+ // rejected_data_points) = 1 and error_message = 2.
101
+ message ExportServiceResponse { ExportPartialSuccess partial_success = 1; }
102
+ message ExportPartialSuccess { int64 rejected = 1; string error_message = 2; }
96
103
  `;
97
104
  const root = protobuf.parse(PROTO).root;
98
105
  const TraceRequest = root.lookupType("otlp.ExportTraceServiceRequest");
99
106
  const MetricsRequest = root.lookupType("otlp.ExportMetricsServiceRequest");
100
107
  const LogsRequest = root.lookupType("otlp.ExportLogsServiceRequest");
108
+ const ServiceResponse = root.lookupType("otlp.ExportServiceResponse");
101
109
  const TO_OBJECT_OPTIONS = {
102
110
  longs: String, // 64-bit ints → strings (matches OTLP/JSON)
103
111
  defaults: false,
@@ -131,3 +139,20 @@ export function decodeMetricsRequest(body) {
131
139
  export function decodeLogsRequest(body) {
132
140
  return hexifyIds(LogsRequest.toObject(LogsRequest.decode(body), TO_OBJECT_OPTIONS));
133
141
  }
142
+ /**
143
+ * The protobuf body of an OTLP export response: empty when everything was
144
+ * accepted, else `partial_success` with the rejected count and why
145
+ * (identical bytes for traces, logs and metrics).
146
+ */
147
+ export function encodeExportResponse(partial) {
148
+ if (!partial)
149
+ return new Uint8Array(0);
150
+ return ServiceResponse.encode(ServiceResponse.create({ partialSuccess: { rejected: partial.rejected, errorMessage: partial.errorMessage } })).finish();
151
+ }
152
+ /** Decode an OTLP export response body (tests, and anything reading our answers). */
153
+ export function decodeExportResponse(body) {
154
+ const message = ServiceResponse.toObject(ServiceResponse.decode(body), { longs: Number, defaults: false });
155
+ if (!message.partialSuccess)
156
+ return null;
157
+ return { rejected: message.partialSuccess.rejected ?? 0, errorMessage: message.partialSuccess.errorMessage ?? "" };
158
+ }
@@ -13,10 +13,14 @@ export interface ProfileSample {
13
13
  value: number;
14
14
  }
15
15
  export declare function profileTableDDL(db: string): string;
16
+ /** Samples kept per profile; the rest are dropped and counted (profile_samples_over_limit). */
17
+ export declare const MAX_PROFILE_SAMPLES = 1000;
16
18
  export declare function decodeProfile(body: Buffer, meta: {
17
19
  service: string;
18
20
  environment: string;
19
21
  release: string;
20
22
  traceId: string;
21
23
  instanceId: string;
24
+ }, stats?: {
25
+ overLimit: number;
22
26
  }): ProfileSample[];
package/dist/profiles.js CHANGED
@@ -28,7 +28,9 @@ export function profileTableDDL(db) {
28
28
  ORDER BY (org_id, repository_id, service, environment, release, observed_at, profile_id)
29
29
  TTL toDateTime(observed_at) + INTERVAL 7 DAY`;
30
30
  }
31
- export function decodeProfile(body, meta) {
31
+ /** Samples kept per profile; the rest are dropped and counted (profile_samples_over_limit). */
32
+ export const MAX_PROFILE_SAMPLES = 1000;
33
+ export function decodeProfile(body, meta, stats) {
32
34
  if (body.length === 0 || body.length > 1024 * 1024)
33
35
  throw new Error("invalid profile size");
34
36
  const payload = body[0] === 0x1f && body[1] === 0x8b
@@ -43,7 +45,9 @@ export function decodeProfile(body, meta) {
43
45
  const observedAt = observedMs > 0 && observedMs <= Date.now() + 300_000 ? new Date(observedMs) : new Date();
44
46
  const profileId = createHash("sha256").update(body).digest("hex").slice(0, 32);
45
47
  const samples = [];
46
- for (const [sampleIndex, sample] of (profile.sample ?? []).slice(0, 1000).entries()) {
48
+ if (stats)
49
+ stats.overLimit += Math.max(0, (profile.sample ?? []).length - MAX_PROFILE_SAMPLES);
50
+ for (const [sampleIndex, sample] of (profile.sample ?? []).slice(0, MAX_PROFILE_SAMPLES).entries()) {
47
51
  const stack = (sample.locationId ?? []).slice(0, 64).flatMap((id) => {
48
52
  const locLine = locations.get(id)?.line?.[0];
49
53
  const fn = locLine?.functionId ? functions.get(locLine.functionId) : undefined;