@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.
package/dist/sink.d.ts CHANGED
@@ -26,6 +26,17 @@ export interface SinkTuning {
26
26
  /** Deliveries run concurrently while healthy, serially while failing. */
27
27
  healthyConcurrency: number;
28
28
  }
29
+ /** A batch the sink gave up on (counted in runtime_ingest_drops, route "sink"). */
30
+ export interface SinkDrop {
31
+ orgId: string;
32
+ repositoryId: string;
33
+ /** sink_overflow: buffer overflow, or a batch bigger than the buffer;
34
+ * sink_rejected: the consumer answered a permanent 4xx;
35
+ * sink_gave_up: SINK_MAX_ATTEMPTS deliveries failed. */
36
+ reason: "sink_overflow" | "sink_rejected" | "sink_gave_up";
37
+ /** Occurrences in the batch. */
38
+ occurrences: number;
39
+ }
29
40
  /**
30
41
  * Operational counters for /healthz. Failure detail is reduced to a fixed
31
42
  * category (`timeout`, `connection_error`, `http_<status>`, `error`) — the
@@ -72,10 +83,13 @@ export declare class SinkForwarder {
72
83
  private lastFailureAt;
73
84
  private lastFailureReason;
74
85
  private readonly tuning;
86
+ /** Called for every batch given up on; must not throw (errors are swallowed). */
87
+ onDrop: ((drop: SinkDrop) => void) | null;
75
88
  constructor(config: IngesterConfig, fetchImpl?: typeof fetch, tuning?: Partial<SinkTuning>);
76
89
  /** Queue a batch for delivery. No-op when there is nothing to send. */
77
90
  enqueue(ctx: IngestContext, occurrences: RuntimeOccurrence[], metricPoints?: RuntimeMetricPoint[], llmCalls?: RuntimeLlmCall[]): void;
78
91
  stats(): SinkStats;
92
+ private noteDrop;
79
93
  pendingCount(): number;
80
94
  /** Stop scheduling new deliveries (shutdown). Queued batches are logged
81
95
  * by the caller — they are recoverable from ClickHouse, not from here. */
package/dist/sink.js CHANGED
@@ -44,6 +44,8 @@ export class SinkForwarder {
44
44
  lastFailureAt = null;
45
45
  lastFailureReason = null;
46
46
  tuning;
47
+ /** Called for every batch given up on; must not throw (errors are swallowed). */
48
+ onDrop = null;
47
49
  constructor(config, fetchImpl = fetch, tuning = {}) {
48
50
  this.config = config;
49
51
  this.fetchImpl = fetchImpl;
@@ -65,6 +67,7 @@ export class SinkForwarder {
65
67
  if (batch.bytes > this.maxBufferedBytes()) {
66
68
  this.droppedOverflow += 1;
67
69
  console.warn(`sink batch ${batch.batchId} (org ${batch.orgId}) exceeds the buffer cap (${batch.bytes} bytes) — dropped; replay signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"} from ClickHouse`);
70
+ this.noteDrop(batch, "sink_overflow");
68
71
  return;
69
72
  }
70
73
  this.queue.push(batch);
@@ -88,6 +91,14 @@ export class SinkForwarder {
88
91
  oldestQueuedSince: oldest ? new Date(oldest).toISOString() : null,
89
92
  };
90
93
  }
94
+ noteDrop(batch, reason) {
95
+ try {
96
+ this.onDrop?.({ orgId: batch.orgId, repositoryId: batch.repositoryId, reason, occurrences: batch.occurrences });
97
+ }
98
+ catch {
99
+ // Drop accounting must never affect delivery.
100
+ }
101
+ }
91
102
  pendingCount() {
92
103
  return this.queue.length + this.inFlight;
93
104
  }
@@ -122,6 +133,8 @@ export class SinkForwarder {
122
133
  return {
123
134
  batchId,
124
135
  orgId: ctx.orgId,
136
+ repositoryId: ctx.repositoryId,
137
+ occurrences: occurrences.length,
125
138
  body,
126
139
  bytes: Buffer.byteLength(body),
127
140
  seq: ++this.seqCounter,
@@ -170,6 +183,7 @@ export class SinkForwarder {
170
183
  break;
171
184
  this.droppedOverflow += 1;
172
185
  console.warn(`sink buffer overflow: dropped batch ${dropped.batchId} (org ${dropped.orgId}, signals ${dropped.signalsFrom ?? "?"} .. ${dropped.signalsTo ?? "?"}) — replay this range from ClickHouse`);
186
+ this.noteDrop(dropped, "sink_overflow");
173
187
  }
174
188
  }
175
189
  /** The oldest batch of the org holding the most buffered bytes. */
@@ -280,6 +294,7 @@ export class SinkForwarder {
280
294
  if (permanent || batch.attempts >= this.config.sinkMaxAttempts) {
281
295
  this.droppedPermanent += 1;
282
296
  console.error(`sink delivery gave up after ${batch.attempts} attempt(s) (${detail}): batch ${batch.batchId} (signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"}) — replay this range from ClickHouse`);
297
+ this.noteDrop(batch, permanent ? "sink_rejected" : "sink_gave_up");
283
298
  return;
284
299
  }
285
300
  this.retried += 1;
@@ -11,8 +11,10 @@ import type { StackFrame } from "./types.js";
11
11
  * - V8 (Node, Chrome, Edge): `at fn (file:line:col)` / `at file:line:col`
12
12
  * - Firefox / Safari: `fn@url:line:col` / `@url:line:col`
13
13
  * - Python: `File "…", line N, in fn` — tracebacks list the OUTERMOST frame
14
- * first, so the frames are reversed; only the last (reported) traceback
15
- * of a chained exception is used
14
+ * first, so the frames are reversed; only the reported exception's own
15
+ * frames are used: the last top-level block of a chained exception, and
16
+ * for an exception group (3.11+) its own `| `-margined block, never its
17
+ * sub-exceptions
16
18
  * - Go: function line + `\t<file>.go:<line> +0x…`, first goroutine only
17
19
  * - JVM: `at pkg.Class.method(File.java:N)`, first exception block only
18
20
  * (before `Caused by:`)
@@ -34,6 +36,7 @@ export type FrameLanguage = "javascript" | "python" | "go" | "jvm" | "dotnet" |
34
36
  * `C:\app\node_modules\x.js` counts too. A frame with no path is in-app.
35
37
  */
36
38
  export declare function isInAppPath(path: string | null): boolean;
39
+ export declare function isPythonStdlibPath(path: string | null): boolean;
37
40
  /**
38
41
  * Contract §3. Source-map ignore lists are applied by symbolicate.ts; the
39
42
  * language rules apply only to frames of that language, so a JS object
@@ -11,8 +11,10 @@ import { detectStackLanguage } from "./fingerprint.js";
11
11
  * - V8 (Node, Chrome, Edge): `at fn (file:line:col)` / `at file:line:col`
12
12
  * - Firefox / Safari: `fn@url:line:col` / `@url:line:col`
13
13
  * - Python: `File "…", line N, in fn` — tracebacks list the OUTERMOST frame
14
- * first, so the frames are reversed; only the last (reported) traceback
15
- * of a chained exception is used
14
+ * first, so the frames are reversed; only the reported exception's own
15
+ * frames are used: the last top-level block of a chained exception, and
16
+ * for an exception group (3.11+) its own `| `-margined block, never its
17
+ * sub-exceptions
16
18
  * - Go: function line + `\t<file>.go:<line> +0x…`, first goroutine only
17
19
  * - JVM: `at pkg.Class.method(File.java:N)`, first exception block only
18
20
  * (before `Caused by:`)
@@ -63,6 +65,22 @@ export function isInAppPath(path) {
63
65
  return false;
64
66
  return true;
65
67
  }
68
+ /**
69
+ * Python's standard library: frozen modules (`<frozen runpy>`) and the
70
+ * version directory of an installation (`/usr/local/lib/python3.12/`,
71
+ * `…/Versions/3.14/lib/python3.14/`, `C:\Python312\Lib\`); a
72
+ * virtualenv's packages are `site-packages`, covered by the path rule. Like
73
+ * Go's `/usr/local/go/`, they are the runtime, not the app: an asyncio or
74
+ * socket error's first five frames would otherwise all be stdlib frames.
75
+ */
76
+ const PY_STDLIB_RE = /\/lib\/(?:python|pypy)\d\.\d+t?\/|\/python\d{2,3}t?\/lib\//i;
77
+ export function isPythonStdlibPath(path) {
78
+ if (!path)
79
+ return false;
80
+ if (path.startsWith("<frozen "))
81
+ return true;
82
+ return PY_STDLIB_RE.test(path.replace(/\\/g, "/"));
83
+ }
66
84
  /** Fully qualified name for the language rules (`module.function`). */
67
85
  function qualifiedName(frame) {
68
86
  if (frame.module && frame.function)
@@ -84,6 +102,8 @@ export function isInAppFrame(frame, language) {
84
102
  if ((frame.filename ?? "").replace(/\\/g, "/").includes("/usr/local/go/"))
85
103
  return false;
86
104
  }
105
+ if (language === "python" && isPythonStdlibPath(frame.filename))
106
+ return false;
87
107
  if (language === "jvm" && /^(?:java|javax|kotlin|jdk)\./.test(qualified))
88
108
  return false;
89
109
  if (language === "dotnet" && /^(?:System|Microsoft)\./.test(qualified))
@@ -225,15 +245,41 @@ function parseGecko(lines) {
225
245
  // ── Python ────────────────────────────────────────────────────────────────
226
246
  const PY_FILE_RE = /^\s*File "(.+)", line (\d+)(?:, in (.+?))?\s*$/;
227
247
  const PY_TRACEBACK_RE = /^\s*Traceback \(most recent call last\):\s*$/;
248
+ // 3.11+: an exception group prints its own frames behind a "| " margin,
249
+ // under this header (nested groups' headers carry the margin instead of "+"),
250
+ // then its sub-exceptions after a "+-+---- 1 ----" separator.
251
+ const PY_GROUP_TRACEBACK_RE = /^\s*\+ Exception Group Traceback \(most recent call last\):\s*$/;
252
+ const PY_GROUP_MARGIN_RE = /^\s*\|/;
253
+ const PY_GROUP_SUBEXCEPTIONS_RE = /^\s*\+-/;
254
+ function isPythonFrameLine(line) {
255
+ return PY_FILE_RE.test(line) || PY_FILE_RE.test(line.replace(PY_GROUP_MARGIN_RE, ""));
256
+ }
228
257
  function parsePython(lines) {
229
- // A chained exception prints one traceback per exception; the reported
230
- // (final) exception's frames are the last block.
231
- let start = 0;
232
- for (let i = 0; i < lines.length; i++)
233
- if (PY_TRACEBACK_RE.test(lines[i]))
258
+ // A chained exception prints one block per exception, the reported
259
+ // (final) one last, whether the chain says "During handling of the above
260
+ // exception" or "The above exception was the direct cause". Blocks of
261
+ // sub-exceptions and of groups in the chain carry a margin, so the last
262
+ // unmargined header starts the reported exception's own frames.
263
+ let start = -1;
264
+ let group = false;
265
+ for (let i = 0; i < lines.length; i++) {
266
+ if (PY_TRACEBACK_RE.test(lines[i])) {
267
+ start = i;
268
+ group = false;
269
+ }
270
+ else if (PY_GROUP_TRACEBACK_RE.test(lines[i])) {
234
271
  start = i;
272
+ group = true;
273
+ }
274
+ }
235
275
  const outermostFirst = [];
236
- for (const line of lines.slice(start)) {
276
+ for (const raw of lines.slice(start + 1)) {
277
+ let line = raw;
278
+ if (group) {
279
+ if (PY_GROUP_SUBEXCEPTIONS_RE.test(line))
280
+ break;
281
+ line = line.replace(PY_GROUP_MARGIN_RE, "");
282
+ }
237
283
  const m = PY_FILE_RE.exec(line);
238
284
  if (!m)
239
285
  continue;
@@ -391,7 +437,7 @@ export function detectFrameLanguage(lines) {
391
437
  return "dotnet";
392
438
  default:
393
439
  }
394
- if (lines.some((line) => PY_FILE_RE.test(line)))
440
+ if (lines.some(isPythonFrameLine))
395
441
  return "python";
396
442
  if (lines.some((line) => DOTNET_BARE_RE.test(line)) &&
397
443
  !lines.some((line) => /:\d+(?::\d+)?\)?\s*$/.test(line))) {
@@ -40,6 +40,19 @@ export interface SymbolicationScope {
40
40
  /** Script URL → debug id (EI-13), when the browser sent them. */
41
41
  debugIds?: Record<string, string> | null;
42
42
  }
43
+ /**
44
+ * In-app browser frames a source map could resolve, counted after
45
+ * symbolication (EI-27): `resolved` ones, and `unresolved` .js/.mjs/.cjs
46
+ * frames left as parsed (no map uploaded, no release or debug id, no
47
+ * mapping at that position). Frames a map moved out of the app
48
+ * (node_modules, ignoreList) and non-JS frames count in neither. Stored per
49
+ * occurrence (`in_app_frames_resolved` / `in_app_frames_unresolved`) for the
50
+ * EI-14 rule: over 30% unresolved means the release needs source maps.
51
+ */
52
+ export declare function inAppFrameResolution(frames: readonly StackFrame[] | undefined): {
53
+ resolved: number;
54
+ unresolved: number;
55
+ };
43
56
  /** Path-only form of a script URL — the key uploads are stored under. */
44
57
  export declare function scriptPath(filename: string): string | null;
45
58
  export interface SourceMapCacheOptions {
@@ -5,6 +5,28 @@ import { isInAppPath, stripQueryAndHash } from "./stack-frames.js";
5
5
  const JS_FILE_RE = /\.[cm]?js$/i;
6
6
  const MAX_CONTEXT_CHARS = 300;
7
7
  const CONTEXT_LINES = 3;
8
+ /**
9
+ * In-app browser frames a source map could resolve, counted after
10
+ * symbolication (EI-27): `resolved` ones, and `unresolved` .js/.mjs/.cjs
11
+ * frames left as parsed (no map uploaded, no release or debug id, no
12
+ * mapping at that position). Frames a map moved out of the app
13
+ * (node_modules, ignoreList) and non-JS frames count in neither. Stored per
14
+ * occurrence (`in_app_frames_resolved` / `in_app_frames_unresolved`) for the
15
+ * EI-14 rule: over 30% unresolved means the release needs source maps.
16
+ */
17
+ export function inAppFrameResolution(frames) {
18
+ let resolved = 0;
19
+ let unresolved = 0;
20
+ for (const frame of frames ?? []) {
21
+ if (!frame.inApp)
22
+ continue;
23
+ if (frame.resolved)
24
+ resolved += 1;
25
+ else if (frame.filename && JS_FILE_RE.test(frame.filename))
26
+ unresolved += 1;
27
+ }
28
+ return { resolved, unresolved };
29
+ }
8
30
  /** Path-only form of a script URL — the key uploads are stored under. */
9
31
  export function scriptPath(filename) {
10
32
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Self-hostable OTLP + browser-error ingest service for Autter Runtime: normalises telemetry into a per-repo ClickHouse data model",
5
5
  "license": "MIT",
6
6
  "type": "module",