@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
@@ -5,6 +5,8 @@ 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";
9
+ import { requestMembership } from "./request-spans.js";
8
10
  import { asSeverity, } from "./types.js";
9
11
  const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
10
12
  function attrMap(attributes) {
@@ -56,13 +58,18 @@ function spanKind(kind) {
56
58
  }
57
59
  function resourceInfo(resource) {
58
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;
59
66
  return {
60
- service: attrs.get("service.name") ?? "unknown",
61
- environment: attrs.get("deployment.environment.name") ??
62
- attrs.get("deployment.environment") ??
63
- "production",
64
- 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",
65
71
  metricsWired: attrs.get("autter.metrics_wired") === "true",
72
+ process: [service, environment, release ?? "", attrs.get("service.instance.id") ?? ""].join("\u0000"),
66
73
  };
67
74
  }
68
75
  function routeOf(attrs) {
@@ -72,6 +79,13 @@ function routeOf(attrs) {
72
79
  null;
73
80
  return route ? (route.split("?")[0] ?? null) : null;
74
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
+ }
75
89
  function statusCodeOf(attrs) {
76
90
  const raw = attrs.get("http.response.status_code") ?? attrs.get("http.status_code");
77
91
  if (!raw)
@@ -100,9 +114,13 @@ function severityOf(eventAttrs, spanAttrs) {
100
114
  }
101
115
  const MAX_SPANS_PER_REQUEST = 5000;
102
116
  export function normalizeTraces(request) {
103
- const occurrences = [];
104
- const occurrenceSpanIds = [];
117
+ const signals = [];
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).
120
+ const signalRollups = [];
105
121
  const spans = [];
122
+ // Per span, what the request rollups need beyond the span row.
123
+ const spanFacts = [];
106
124
  const llmCalls = [];
107
125
  const rollups = new Map();
108
126
  let spanCount = 0;
@@ -143,39 +161,61 @@ export function normalizeTraces(request) {
143
161
  },
144
162
  startedAt,
145
163
  });
146
- // Error occurrences: one per exception event; if the span is
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
+ });
172
+ // Error signals: one per exception event; if the span is
147
173
  // errored without exception events, one from the span status.
148
- const occurrencesBefore = occurrences.length;
174
+ // They become occurrences after the batch-wide dedupe below.
175
+ const traceId = span.traceId ?? "";
176
+ const spanId = span.spanId ?? "";
177
+ const rollup = { spanIndex: spans.length - 1, route: route ?? "" };
178
+ const spanAutter = attrs.has("autter.severity");
149
179
  const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
150
180
  for (const event of exceptionEvents) {
151
181
  const eventAttrs = attrMap(event.attributes);
152
182
  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")),
183
+ const eventAutter = hasAutterKey(eventAttrs);
184
+ signalRollups.push(rollup);
185
+ signals.push({
186
+ traceId,
187
+ spanId,
188
+ signal: "exception",
189
+ autterCapture: eventAutter || spanAutter,
190
+ eventAutter,
191
+ nextContext: hasNextContext(eventAttrs, attrs),
192
+ handled,
193
+ occurrence: {
194
+ source: "server",
195
+ severity: severityOf(eventAttrs, attrs),
196
+ service: resource.service,
197
+ environment: resource.environment,
198
+ release: resource.release,
199
+ errorType: eventAttrs.get("exception.type") ?? "Error",
200
+ message: eventAttrs.get("exception.message") ??
201
+ span.status?.message ??
202
+ span.name ??
203
+ "Unknown error",
204
+ stack: eventAttrs.get("exception.stacktrace") ?? null,
205
+ route,
206
+ method: methodOf(attrs),
207
+ statusCode,
208
+ traceId: span.traceId ?? null,
209
+ sessionId: null,
210
+ attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
211
+ ...(handled ? { "autter.handled": true, "autter.sampled": eventAttrs.get("autter.sampled") === "true" } : {}) }),
212
+ occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
213
+ // Event attributes first: one span can carry several
214
+ // exceptions, each with its own code.
215
+ ...liftErrorFields(eventAttrs, attrs),
216
+ ...breadcrumbsField(eventAttrs.get(BREADCRUMBS_ATTRIBUTE), attrs.get(BREADCRUMBS_ATTRIBUTE)),
217
+ ...fingerprintOverrideOf(eventAttrs.get("autter.fingerprint") ?? attrs.get("autter.fingerprint")),
218
+ },
179
219
  });
180
220
  }
181
221
  const failedOutcomes = (span.events ?? []).filter((event) => {
@@ -186,40 +226,58 @@ export function normalizeTraces(request) {
186
226
  for (const event of failedOutcomes) {
187
227
  const outcome = attrMap(event.attributes);
188
228
  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),
229
+ signalRollups.push(rollup);
230
+ signals.push({
231
+ traceId,
232
+ spanId,
233
+ signal: "outcome",
234
+ autterCapture: true,
235
+ eventAutter: true,
236
+ nextContext: hasNextContext(outcome, attrs),
237
+ handled: false,
238
+ occurrence: {
239
+ source: "server", severity: "error", service: resource.service,
240
+ environment: resource.environment, release: resource.release,
241
+ errorType: "OutcomeFailure",
242
+ message: `${name}: ${(outcome.get("autter.outcome.message") ?? "failed").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 1000)}`,
243
+ stack: String(decodeOtlpAttributes(event.attributes)["autter.outcome.stack"] ?? "") || null, route, method: methodOf(attrs), statusCode,
244
+ traceId: span.traceId ?? null, sessionId: null,
245
+ attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes) }),
246
+ occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
247
+ ...liftErrorFields(outcome, attrs),
248
+ },
200
249
  });
201
250
  }
202
251
  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),
252
+ signalRollups.push(rollup);
253
+ signals.push({
254
+ traceId,
255
+ spanId,
256
+ signal: "status",
257
+ autterCapture: spanAutter,
258
+ eventAutter: false,
259
+ nextContext: hasNextContext(attrs),
260
+ handled: false,
261
+ occurrence: {
262
+ source: "server",
263
+ severity: severityOf(new Map(), attrs),
264
+ service: resource.service,
265
+ environment: resource.environment,
266
+ release: resource.release,
267
+ errorType: statusCode !== null && statusCode >= 500 ? "HttpServerError" : "SpanError",
268
+ message: span.status?.message || (statusCode !== null && statusCode >= 500
269
+ ? `${methodOf(attrs) ?? "HTTP"} ${route ?? span.name ?? "request"} returned ${statusCode}`
270
+ : `${span.name ?? "span"} failed`),
271
+ stack: null,
272
+ route,
273
+ method: methodOf(attrs),
274
+ statusCode,
275
+ traceId: span.traceId ?? null,
276
+ sessionId: null,
277
+ attributes: decodeOtlpAttributes(span.attributes),
278
+ occurredAt: startedAt,
279
+ ...liftErrorFields(attrs),
280
+ },
223
281
  });
224
282
  }
225
283
  // LLM provider calls (GenAI semconv, Vercel AI SDK telemetry,
@@ -243,58 +301,159 @@ export function normalizeTraces(request) {
243
301
  });
244
302
  if (llmCall)
245
303
  llmCalls.push(llmCall);
246
- // Server spans fold into 1-minute usage rollups so traffic is
247
- // tracked even when the metrics pipeline isn't wired. Spans
248
- // exported by error-linked tail retention are skipped: the SDK
249
- // ships those at ~100% alongside a metrics pipeline that already
250
- // counts every request, so folding them in would double-count
251
- // erroring routes.
252
- if (kind === "server" && attrs.get("autter.tail_retained") !== "true") {
253
- addToRollup(rollups, {
254
- service: resource.service,
255
- environment: resource.environment,
256
- release: resource.release,
257
- route: normalizeRoute(route),
258
- bucketAt: minuteBucket(startedAt),
259
- requestCount: 1,
260
- errorCount: isError ? 1 : 0,
261
- durationSumMs: durationMs,
262
- sessionCount: 0,
263
- });
264
- }
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
- }
304
+ }
305
+ }
306
+ }
307
+ // One occurrence per thrown error: recordings of one error on several
308
+ // spans of a trace merge, and status-only occurrences an error on their
309
+ // span chain explains are dropped (trace-dedupe.ts).
310
+ const deduped = dedupeTraceSignals(signals, spans);
311
+ const occurrences = deduped.occurrences;
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 = [];
336
+ deduped.members.forEach((members, i) => {
337
+ const occ = occurrences[i];
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({
346
+ service: occ.service,
347
+ environment: occ.environment,
348
+ release: occ.release,
349
+ route: signalRollups[deduped.sourceIndexes[i]].route,
350
+ bucketAt: minuteBucket(occ.occurredAt),
351
+ requestCount: 0,
352
+ errorCount: 1,
353
+ durationSumMs: 0,
354
+ sessionCount: 0,
355
+ });
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 });
285
385
  }
386
+ break;
286
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;
287
396
  }
288
397
  }
289
- inheritHttpFromParent(occurrences, occurrenceSpanIds, spans);
398
+ for (const error of errorsOutsideRequests)
399
+ addToRollup(rollups, error);
400
+ inheritHttpFromParent(occurrences, deduped.sourceIndexes.map((index) => signals[index].spanId), spans);
290
401
  return {
291
402
  occurrences,
403
+ occurrenceBatchIndexes: deduped.sourceIndexes,
292
404
  spans,
293
405
  metricPoints: [...rollups.values()],
294
406
  llmCalls,
295
407
  spanCount,
296
408
  };
297
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
+ }
298
457
  /**
299
458
  * Exception spans are often children named "Error" with no HTTP attributes.
300
459
  * Copy method, route, status, and duration from the server span in the same
@@ -391,8 +550,10 @@ const HTTP_DURATION_INSTRUMENTS = {
391
550
  * Cumulative histograms report lifetime totals on every export; adding them
392
551
  * into a SummingMergeTree would re-count all past requests each interval.
393
552
  * Only deltas are summable — cumulative senders are skipped and covered by
394
- * the span-fed rollup fallback instead. (Enum arrives as a number or an
395
- * `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.)
396
557
  */
397
558
  function isCumulativeTemporality(t) {
398
559
  return t === 2 || t === "AGGREGATION_TEMPORALITY_CUMULATIVE";
@@ -401,6 +562,10 @@ export function normalizeMetrics(request) {
401
562
  const rollups = new Map();
402
563
  for (const resourceMetric of request.resourceMetrics ?? []) {
403
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"));
404
569
  for (const scopeMetric of resourceMetric.scopeMetrics ?? []) {
405
570
  for (const metric of scopeMetric.metrics ?? []) {
406
571
  const multiplier = metric.name
@@ -408,6 +573,8 @@ export function normalizeMetrics(request) {
408
573
  : undefined;
409
574
  if (multiplier === undefined)
410
575
  continue;
576
+ if (hasStableDuration && metric.name === "http.server.duration")
577
+ continue;
411
578
  if (isCumulativeTemporality(metric.histogram?.aggregationTemporality)) {
412
579
  continue;
413
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;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Which request each span of an export batch belongs to, so the span-fed
3
+ * usage rollups (`runtime_metrics_1m`) count one request once.
4
+ *
5
+ * A framework that opens its own SERVER span under the HTTP
6
+ * instrumentation's gives one request two server spans: Next.js's
7
+ * `BaseServer.handleRequest` span is a child of the HTTP server span, and
8
+ * some NestJS and Fastify setups and gRPC gateways serving RPCs in-process do
9
+ * the same. Each server span used to add a request, its latency and its error
10
+ * to the rollups, so every traced request of such an app counted twice.
11
+ *
12
+ * The request is the OUTERMOST server span. Walking up from a span through
13
+ * the parent links present in the batch, its request is the highest SERVER
14
+ * span reached. The walk stops:
15
+ * - at a parent that is not in the batch: the span's own chain ends there,
16
+ * so a server span whose parent is missing is a request of its own, as
17
+ * before. When an exporter's timer splits one request's spans across two
18
+ * batches, the nested server span therefore counts in its batch and the
19
+ * outer one in the other; a batch cannot tell a parent exported elsewhere
20
+ * from a remote caller;
21
+ * - at a parent in another process (service, environment, release and
22
+ * `service.instance.id`): one process serves one request;
23
+ * - where a SERVER or CONSUMER span's parent is a CLIENT or PRODUCER span:
24
+ * that edge is a call over the wire, a request of its own even when a
25
+ * service calls itself.
26
+ */
27
+ export interface RequestSpanLink {
28
+ traceId: string;
29
+ spanId: string;
30
+ parentSpanId: string | null;
31
+ /** Lower-case OTel span kind: "server", "client", "internal", … */
32
+ kind: string;
33
+ /** The process the span was recorded in; spans of different processes
34
+ * are never one request. */
35
+ process: string;
36
+ }
37
+ export interface RequestMembership {
38
+ /** Per span: the batch index of its request's (outermost) server span,
39
+ * or -1 when no server span is on its chain. */
40
+ requestOf: number[];
41
+ /** Per span: how many parent links the walk followed above it (0 at the
42
+ * top of its chain). Nested spans are deeper than their request span. */
43
+ depth: number[];
44
+ }
45
+ export declare function requestMembership(spans: RequestSpanLink[]): RequestMembership;