@autter/otlp-ingester 1.9.0 → 1.11.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/latency.js CHANGED
@@ -1,5 +1,85 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { normalizeRoute } from "./fingerprint.js";
3
+ /** Points one histogram bucket is spread over (uniform within the bucket). */
4
+ const BUCKET_SPREAD = 8;
5
+ /**
6
+ * Samples for a delta histogram: each bucket's count spread evenly over
7
+ * [previous bound, bound] (at most 8 points), the overflow bucket at the
8
+ * highest bound. Undefined for a histogram without usable buckets.
9
+ */
10
+ export function histogramLatency(boundsMs, counts) {
11
+ if (boundsMs.length === 0 || boundsMs.length > 256 || counts.length !== boundsMs.length + 1)
12
+ return undefined;
13
+ if (boundsMs.some((bound, i) => !Number.isFinite(bound) || bound < 0 || (i > 0 && bound <= boundsMs[i - 1])))
14
+ return undefined;
15
+ if (counts.some((count) => !Number.isSafeInteger(count) || count < 0))
16
+ return undefined;
17
+ const valuesMs = [];
18
+ const weights = [];
19
+ counts.forEach((count, i) => {
20
+ if (count === 0)
21
+ return;
22
+ const lower = i === 0 ? 0 : boundsMs[i - 1];
23
+ if (i === boundsMs.length) {
24
+ valuesMs.push(lower);
25
+ weights.push(count);
26
+ return;
27
+ }
28
+ const upper = boundsMs[i];
29
+ const points = Math.min(count, BUCKET_SPREAD);
30
+ const base = Math.floor(count / points);
31
+ for (let j = 0; j < points; j++) {
32
+ valuesMs.push(lower + ((upper - lower) * (j + 0.5)) / points);
33
+ weights.push(base + (j < count - base * points ? 1 : 0));
34
+ }
35
+ });
36
+ return valuesMs.length ? { valuesMs, weights } : undefined;
37
+ }
38
+ /** One request's duration as samples. */
39
+ export function requestLatency(durationMs) {
40
+ return Number.isFinite(durationMs) && durationMs >= 0 ? { valuesMs: [durationMs], weights: [1] } : undefined;
41
+ }
42
+ /** Concatenate samples of points folded into one rollup row. */
43
+ export function mergeLatency(a, b) {
44
+ if (!a)
45
+ return b && { valuesMs: [...b.valuesMs], weights: [...b.weights] };
46
+ if (b) {
47
+ a.valuesMs.push(...b.valuesMs);
48
+ a.weights.push(...b.weights);
49
+ }
50
+ return a;
51
+ }
52
+ /** Null input table: rows pass to the view, nothing is stored. */
53
+ export function routeLatencyInputDDL(database) {
54
+ return `CREATE TABLE IF NOT EXISTS ${database}.runtime_route_latency_input (
55
+ org_id String, repository_id String, service String, environment String,
56
+ release String, route String, bucket_at DateTime('UTC'),
57
+ values_ms Array(Float64), weights Array(UInt64)
58
+ ) ENGINE = Null`;
59
+ }
60
+ export function routeLatencyTableDDL(database, ttlDays = 90) {
61
+ return `CREATE TABLE IF NOT EXISTS ${database}.runtime_route_latency_1m (
62
+ org_id String, repository_id String,
63
+ service LowCardinality(String), environment LowCardinality(String),
64
+ release String DEFAULT '', route String DEFAULT '', bucket_at DateTime('UTC'),
65
+ request_count SimpleAggregateFunction(sum, UInt64),
66
+ duration_quantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95, 0.99), Float64, UInt64)
67
+ ) ENGINE = AggregatingMergeTree PARTITION BY toYYYYMM(bucket_at)
68
+ ORDER BY (org_id, repository_id, service, environment, release, route, bucket_at)
69
+ TTL bucket_at + INTERVAL ${Math.max(1, Math.round(ttlDays))} DAY`;
70
+ }
71
+ /** Needs both tables above; reads nothing older than itself. */
72
+ export function routeLatencyViewDDL(database) {
73
+ return `CREATE MATERIALIZED VIEW IF NOT EXISTS ${database}.runtime_route_latency_1m_mv
74
+ TO ${database}.runtime_route_latency_1m AS
75
+ SELECT
76
+ org_id, repository_id, service, environment, release, route, bucket_at,
77
+ sum(weight) AS request_count,
78
+ quantilesTDigestWeightedState(0.5, 0.95, 0.99)(value_ms, weight) AS duration_quantiles
79
+ FROM ${database}.runtime_route_latency_input
80
+ ARRAY JOIN values_ms AS value_ms, weights AS weight
81
+ GROUP BY org_id, repository_id, service, environment, release, route, bucket_at`;
82
+ }
3
83
  export function latencyTableDDL(database, ttlDays = 30) {
4
84
  return `CREATE TABLE IF NOT EXISTS ${database}.runtime_latency_histograms (
5
85
  org_id String, repository_id String, point_id String,
@@ -48,9 +48,40 @@ export interface PromotionLookup {
48
48
  */
49
49
  export declare function promotionLookups(occurrences: RuntimeOccurrenceInput[], now?: number): PromotionLookup[];
50
50
  /**
51
- * One error per promoted occurrence, in `error_count` only — the same
52
- * counting the trace path applies to errors outside a request (a log record
53
- * names no request the rollups count), so error counters for logger-only and
54
- * edge services don't read 0 and no request is invented.
51
+ * `autter.request.counted_by = "summary"`: the request this record belongs to
52
+ * is counted in the usage rollups by its request summary (the record of
53
+ * `autter.operation.kind = "request"` carrying the same mark), because no
54
+ * other feed sees it. `@autter/runtime-edge` (1.3.0+) puts it on every
55
+ * record of a request: an edge function has no trace pipeline and no
56
+ * `http.server.duration` histogram, so without it edge services had error
57
+ * counts and no requests. Nothing else sets it: Node and Next.js summaries
58
+ * never carry it (their requests are counted by the histogram or the spans),
59
+ * so a request is counted by its summary only when its sender says the
60
+ * summary is the one count it gets.
55
61
  */
56
- export declare function promotionRollups(occurrences: RuntimeOccurrence[]): RuntimeMetricPoint[];
62
+ export declare const COUNTED_BY_ATTRIBUTE = "autter.request.counted_by";
63
+ /** On a counted summary: an error of the request was reported (an
64
+ * `autter.capture.mode = "log"` record), which makes it a failed request. */
65
+ export declare const ERROR_REPORTED_ATTRIBUTE = "autter.request.error_reported";
66
+ export declare function countedBySummary(attributes: Record<string, unknown> | null | undefined): boolean;
67
+ /**
68
+ * The usage rollups (`runtime_metrics_1m`) a /v1/logs batch adds, counted the
69
+ * way the trace path counts (normalize-otlp.ts): `request_count` counts
70
+ * requests only, `error_count` counts failed requests once and errors outside
71
+ * a counted request one each.
72
+ *
73
+ * - A request summary marked counted (`countedBySummary`) is one request:
74
+ * `request_count` 1, its duration, on its route and minute; `error_count` 1
75
+ * when it failed — outcome `failed`, a status ≥ 500, or an error reported
76
+ * in it (`autter.request.error_reported`), as an error occurrence recorded
77
+ * in a request fails it on the trace path.
78
+ * - A promoted occurrence (logger-only or edge error record) adds
79
+ * `error_count` 1 — unless its record is marked counted: its request's
80
+ * summary already counts that failure once, however many errors it had.
81
+ *
82
+ * The decision is per record, never by matching records across a batch, so
83
+ * a request whose records an exporter splits over two batches is still
84
+ * counted once. Unmarked summaries add nothing (they are counted elsewhere,
85
+ * or by no one, as before).
86
+ */
87
+ export declare function logRollups(rows: RuntimeLogRecord[], promoted: RuntimeOccurrence[]): RuntimeMetricPoint[];
@@ -1,4 +1,5 @@
1
1
  import { normalizeRoute } from "./fingerprint.js";
2
+ import { mergeLatency, requestLatency } from "./latency.js";
2
3
  /**
3
4
  * Log promotion — errors from SDKs running WITHOUT a trace pipeline.
4
5
  *
@@ -81,38 +82,111 @@ export function promotionLookups(occurrences, now = Date.now()) {
81
82
  return lookups;
82
83
  }
83
84
  /**
84
- * One error per promoted occurrence, in `error_count` only — the same
85
- * counting the trace path applies to errors outside a request (a log record
86
- * names no request the rollups count), so error counters for logger-only and
87
- * edge services don't read 0 and no request is invented.
85
+ * `autter.request.counted_by = "summary"`: the request this record belongs to
86
+ * is counted in the usage rollups by its request summary (the record of
87
+ * `autter.operation.kind = "request"` carrying the same mark), because no
88
+ * other feed sees it. `@autter/runtime-edge` (1.3.0+) puts it on every
89
+ * record of a request: an edge function has no trace pipeline and no
90
+ * `http.server.duration` histogram, so without it edge services had error
91
+ * counts and no requests. Nothing else sets it: Node and Next.js summaries
92
+ * never carry it (their requests are counted by the histogram or the spans),
93
+ * so a request is counted by its summary only when its sender says the
94
+ * summary is the one count it gets.
88
95
  */
89
- export function promotionRollups(occurrences) {
96
+ export const COUNTED_BY_ATTRIBUTE = "autter.request.counted_by";
97
+ /** On a counted summary: an error of the request was reported (an
98
+ * `autter.capture.mode = "log"` record), which makes it a failed request. */
99
+ export const ERROR_REPORTED_ATTRIBUTE = "autter.request.error_reported";
100
+ function withLatency(durationMs) {
101
+ const latency = requestLatency(durationMs);
102
+ return latency ? { latency } : {};
103
+ }
104
+ export function countedBySummary(attributes) {
105
+ return attributes?.[COUNTED_BY_ATTRIBUTE] === "summary";
106
+ }
107
+ /**
108
+ * The usage rollups (`runtime_metrics_1m`) a /v1/logs batch adds, counted the
109
+ * way the trace path counts (normalize-otlp.ts): `request_count` counts
110
+ * requests only, `error_count` counts failed requests once and errors outside
111
+ * a counted request one each.
112
+ *
113
+ * - A request summary marked counted (`countedBySummary`) is one request:
114
+ * `request_count` 1, its duration, on its route and minute; `error_count` 1
115
+ * when it failed — outcome `failed`, a status ≥ 500, or an error reported
116
+ * in it (`autter.request.error_reported`), as an error occurrence recorded
117
+ * in a request fails it on the trace path.
118
+ * - A promoted occurrence (logger-only or edge error record) adds
119
+ * `error_count` 1 — unless its record is marked counted: its request's
120
+ * summary already counts that failure once, however many errors it had.
121
+ *
122
+ * The decision is per record, never by matching records across a batch, so
123
+ * a request whose records an exporter splits over two batches is still
124
+ * counted once. Unmarked summaries add nothing (they are counted elsewhere,
125
+ * or by no one, as before).
126
+ */
127
+ export function logRollups(rows, promoted) {
90
128
  const rollups = new Map();
91
- for (const occ of occurrences) {
92
- const bucketAt = new Date(Math.floor(occ.occurredAt.getTime() / 60_000) * 60_000);
93
- const route = normalizeRoute(occ.route);
94
- const key = [
95
- occ.service,
96
- occ.environment,
97
- occ.release ?? "",
98
- route,
99
- bucketAt.getTime(),
100
- ].join(" ");
129
+ const add = (point) => {
130
+ const { at, ...rest } = point;
131
+ const bucketAt = new Date(Math.floor(at.getTime() / 60_000) * 60_000);
132
+ const key = [rest.service, rest.environment, rest.release ?? "", rest.route, bucketAt.getTime()].join(" ");
101
133
  const existing = rollups.get(key);
102
134
  if (existing) {
103
- existing.errorCount += 1;
104
- continue;
135
+ existing.requestCount += rest.requestCount;
136
+ existing.errorCount += rest.errorCount;
137
+ existing.durationSumMs += rest.durationSumMs;
138
+ const latency = mergeLatency(existing.latency, rest.latency);
139
+ if (latency)
140
+ existing.latency = latency;
141
+ return;
105
142
  }
106
- rollups.set(key, {
143
+ const { latency, ...counts } = rest;
144
+ const copy = mergeLatency(undefined, latency);
145
+ rollups.set(key, { ...counts, bucketAt, sessionCount: 0, ...(copy ? { latency: copy } : {}) });
146
+ };
147
+ for (const row of rows) {
148
+ if (row.kind !== "request" || !countedBySummary(row.attributes))
149
+ continue;
150
+ const failed = row.outcome === "failed" ||
151
+ row.statusCode >= 500 ||
152
+ row.attributes[ERROR_REPORTED_ATTRIBUTE] === true;
153
+ add({
154
+ service: row.service,
155
+ environment: row.environment,
156
+ release: row.release || null,
157
+ // Already normalized (logs.ts), the key promoted errors use too.
158
+ route: row.route,
159
+ at: row.occurredAt,
160
+ requestCount: 1,
161
+ errorCount: failed ? 1 : 0,
162
+ durationSumMs: row.durationMs,
163
+ ...withLatency(row.durationMs),
164
+ });
165
+ }
166
+ for (const occ of promoted) {
167
+ // A caught error or deliberate fallback is an occurrence, never a
168
+ // counted failure. (A deliberate fallback is a warning and is already
169
+ // excluded above; a caught error is kept out here.)
170
+ if (occ.severity !== "error" && occ.severity !== "fatal")
171
+ continue;
172
+ const handling = occ.attributes?.["autter.handling"];
173
+ if (handling === "caught" || handling === "deliberate")
174
+ continue;
175
+ // Its request's summary counts this failure.
176
+ if (countedBySummary(occ.attributes))
177
+ continue;
178
+ // One error of its own, in `error_count` only — what the trace path
179
+ // does for an error outside a request: the record names no request
180
+ // the rollups count, so no request is invented.
181
+ add({
107
182
  service: occ.service,
108
183
  environment: occ.environment,
109
184
  release: occ.release,
110
- route,
111
- bucketAt,
185
+ route: normalizeRoute(occ.route),
186
+ at: occ.occurredAt,
112
187
  requestCount: 0,
113
188
  errorCount: 1,
114
189
  durationSumMs: 0,
115
- sessionCount: 0,
116
190
  });
117
191
  }
118
192
  return [...rollups.values()];
package/dist/logs.d.ts CHANGED
@@ -33,9 +33,9 @@ export interface RuntimeLogRecord {
33
33
  aiCalls: number;
34
34
  /**
35
35
  * Not persisted on runtime_logs. Set for logger-only/edge error records
36
- * (`autter.capture.mode = "log"`, severity ≥ error, `exception.*`
37
- * present): the occurrence /v1/logs promotes into issue grouping —
38
- * see log-promotion.ts.
36
+ * (`autter.capture.mode = "log"`, severity ≥ error, or a handled warning
37
+ * such as `runtimeContext.degraded`, with `exception.*` present): the
38
+ * occurrence /v1/logs promotes into issue grouping — see log-promotion.ts.
39
39
  */
40
40
  occurrence?: RuntimeOccurrenceInput;
41
41
  }
package/dist/logs.js CHANGED
@@ -3,6 +3,7 @@ import { decodeOtlpAttributes, sanitizeRuntimeContext, } from "./context.js";
3
3
  import { declaredLink, liftErrorFields } from "./error-fields.js";
4
4
  import { fingerprintOverrideOf } from "./fingerprint-override.js";
5
5
  import { normalizeRoute } from "./fingerprint.js";
6
+ import { DEBUG_IDS_ATTRIBUTE, serverDebugIds, withoutDebugIdsAttribute } from "./source-maps.js";
6
7
  /**
7
8
  * Log records one request may carry. From 1.8.0 the ones past it are
8
9
  * rejected on their own (counted in `stats.overLimit`, answered in the OTLP
@@ -52,11 +53,15 @@ export function normalizeLogs(request, stats) {
52
53
  !Array.isArray(decodedBody))
53
54
  event = sanitizeRuntimeContext(decodedBody);
54
55
  const metadata = Object.fromEntries(Object.entries(attrs).filter(([key]) => /^autter\.(?:operation|event)\./.test(key)));
55
- const attributes = sanitizeRuntimeContext({
56
+ // Server SDKs (runtime-core errorAttributes): the debug ids of the bundles in the
57
+ // exception's stack (RF-05) locate their source maps; read raw
58
+ // (the sanitiser cuts long keys) and not kept.
59
+ const { [DEBUG_IDS_ATTRIBUTE]: rawDebugIds, ...plainAttrs } = attrs;
60
+ const attributes = withoutDebugIdsAttribute(sanitizeRuntimeContext({
56
61
  ...metadata,
57
62
  ...event,
58
- ...attrs,
59
- });
63
+ ...plainAttrs,
64
+ }));
60
65
  const nanos = record.timeUnixNano ?? record.observedTimeUnixNano;
61
66
  const occurredAt = nanos === undefined
62
67
  ? new Date()
@@ -113,8 +118,11 @@ export function normalizeLogs(request, stats) {
113
118
  const aiRollup = ai && typeof ai === "object" && !Array.isArray(ai)
114
119
  ? ai
115
120
  : {};
121
+ // Warnings too when handled: `runtimeContext.degraded` (a
122
+ // deliberate fallback) is recorded at warning severity.
116
123
  const occurrence = attributes["autter.capture.mode"] === "log" &&
117
- (severity === "error" || severity === "fatal") &&
124
+ (severity === "error" || severity === "fatal" ||
125
+ (severity === "warning" && attributes["autter.handled"] === true)) &&
118
126
  hasException(attributes)
119
127
  ? {
120
128
  source: "server",
@@ -138,6 +146,7 @@ export function normalizeLogs(request, stats) {
138
146
  occurredAt,
139
147
  ...declared,
140
148
  ...fingerprintOverrideOf(attributes["autter.fingerprint"]),
149
+ ...serverDebugIds(rawDebugIds),
141
150
  }
142
151
  : undefined;
143
152
  rows.push({
@@ -39,8 +39,9 @@ export declare const OCCURRENCE_REQUEST_ID_INDEX = "INDEX idx_occ_request_id req
39
39
  * Migration 0016 columns — shared with the baseline DDL in clickhouse.ts.
40
40
  * `frames`: the structured (symbolicated) frames as JSON, '' when none.
41
41
  * `fingerprints`: every identity candidate (manual-v1, code-v1, frames-v3,
42
- * message-v1 — highest precedence first, ending with the legacy one), so
43
- * readers find an issue keyed by ANY scheme with
42
+ * message-v1 — highest precedence first, ending with the legacy one — then
43
+ * cause-v1 when computed, RF-06), so readers find an issue keyed by ANY
44
+ * scheme with
44
45
  * `(fingerprint = X OR has(fingerprints, X))`; rows written before 0016
45
46
  * read `[]` and still match on the legacy `fingerprint`.
46
47
  */
@@ -32,7 +32,7 @@
32
32
  * ],
33
33
  * },
34
34
  */
35
- import { latencyTableDDL } from "./latency.js";
35
+ import { latencyTableDDL, routeLatencyInputDDL, routeLatencyTableDDL, routeLatencyViewDDL, } from "./latency.js";
36
36
  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";
@@ -54,8 +54,9 @@ export const OCCURRENCE_REQUEST_ID_INDEX = "INDEX idx_occ_request_id request_id
54
54
  * Migration 0016 columns — shared with the baseline DDL in clickhouse.ts.
55
55
  * `frames`: the structured (symbolicated) frames as JSON, '' when none.
56
56
  * `fingerprints`: every identity candidate (manual-v1, code-v1, frames-v3,
57
- * message-v1 — highest precedence first, ending with the legacy one), so
58
- * readers find an issue keyed by ANY scheme with
57
+ * message-v1 — highest precedence first, ending with the legacy one — then
58
+ * cause-v1 when computed, RF-06), so readers find an issue keyed by ANY
59
+ * scheme with
59
60
  * `(fingerprint = X OR has(fingerprints, X))`; rows written before 0016
60
61
  * read `[]` and still match on the legacy `fingerprint`.
61
62
  */
@@ -247,6 +248,14 @@ export const MIGRATIONS = [
247
248
  id: "0021-occurrence-collapsed-count",
248
249
  statements: OCCURRENCE_COLLAPSE_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
249
250
  },
251
+ // Per-route p50/p95/p99 in the 1-minute rollups (IP-02, latency.ts): a
252
+ // Null input table the ingester writes latency samples to, the
253
+ // AggregatingMergeTree keyed like runtime_metrics_1m, and the view between
254
+ // them. Additive: runtime_metrics_1m and its readers are unchanged.
255
+ {
256
+ id: "0022-route-latency-1m",
257
+ statements: [routeLatencyInputDDL("{db}"), routeLatencyTableDDL("{db}"), routeLatencyViewDDL("{db}")],
258
+ },
250
259
  ];
251
260
  /** The tracking table itself — created by the runner before anything else. */
252
261
  export function migrationsTableDDL(db) {
@@ -53,38 +53,38 @@ export declare const browserPayloadSchema: z.ZodObject<{
53
53
  timestamp: string;
54
54
  breadcrumbs?: unknown[] | undefined;
55
55
  route?: string | undefined;
56
- errorType?: string | undefined;
57
- id?: string | undefined;
58
56
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
57
+ errorType?: string | undefined;
59
58
  stack?: string | undefined;
60
59
  traceId?: string | undefined;
61
60
  debugIds?: Record<string, unknown> | undefined;
62
61
  durationMs?: number | undefined;
63
62
  filename?: string | undefined;
63
+ id?: string | undefined;
64
64
  name?: string | undefined;
65
65
  column?: number | undefined;
66
66
  line?: number | undefined;
67
- context?: Record<string, unknown> | undefined;
68
67
  fingerprint?: string | string[] | undefined;
68
+ context?: Record<string, unknown> | undefined;
69
69
  }, {
70
70
  type: "request_failure" | "timing" | "outcome" | "csp_violation" | "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
71
71
  timestamp: string;
72
72
  breadcrumbs?: unknown;
73
73
  route?: string | undefined;
74
74
  message?: string | undefined;
75
- errorType?: string | undefined;
76
- id?: string | undefined;
77
75
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
76
+ errorType?: string | undefined;
78
77
  stack?: string | undefined;
79
78
  traceId?: unknown;
80
79
  debugIds?: unknown;
81
80
  durationMs?: number | undefined;
82
81
  filename?: string | undefined;
82
+ id?: string | undefined;
83
83
  name?: string | undefined;
84
84
  column?: number | undefined;
85
85
  line?: number | undefined;
86
- context?: Record<string, unknown> | undefined;
87
86
  fingerprint?: unknown;
87
+ context?: Record<string, unknown> | undefined;
88
88
  }>, "many">;
89
89
  version: z.ZodLiteral<1>;
90
90
  sessionId: z.ZodOptional<z.ZodString>;
@@ -103,19 +103,19 @@ export declare const browserPayloadSchema: z.ZodObject<{
103
103
  timestamp: string;
104
104
  breadcrumbs?: unknown[] | undefined;
105
105
  route?: string | undefined;
106
- errorType?: string | undefined;
107
- id?: string | undefined;
108
106
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
107
+ errorType?: string | undefined;
109
108
  stack?: string | undefined;
110
109
  traceId?: string | undefined;
111
110
  debugIds?: Record<string, unknown> | undefined;
112
111
  durationMs?: number | undefined;
113
112
  filename?: string | undefined;
113
+ id?: string | undefined;
114
114
  name?: string | undefined;
115
115
  column?: number | undefined;
116
116
  line?: number | undefined;
117
- context?: Record<string, unknown> | undefined;
118
117
  fingerprint?: string | string[] | undefined;
118
+ context?: Record<string, unknown> | undefined;
119
119
  }[];
120
120
  release?: string | undefined;
121
121
  sessionId?: string | undefined;
@@ -130,19 +130,19 @@ export declare const browserPayloadSchema: z.ZodObject<{
130
130
  breadcrumbs?: unknown;
131
131
  route?: string | undefined;
132
132
  message?: string | undefined;
133
- errorType?: string | undefined;
134
- id?: string | undefined;
135
133
  severity?: "info" | "warning" | "error" | "fatal" | undefined;
134
+ errorType?: string | undefined;
136
135
  stack?: string | undefined;
137
136
  traceId?: unknown;
138
137
  debugIds?: unknown;
139
138
  durationMs?: number | undefined;
140
139
  filename?: string | undefined;
140
+ id?: string | undefined;
141
141
  name?: string | undefined;
142
142
  column?: number | undefined;
143
143
  line?: number | undefined;
144
- context?: Record<string, unknown> | undefined;
145
144
  fingerprint?: unknown;
145
+ context?: Record<string, unknown> | undefined;
146
146
  }[];
147
147
  release?: string | undefined;
148
148
  sessionId?: string | undefined;
@@ -201,11 +201,17 @@ export function normalizeBrowserPayload(payload) {
201
201
  continue;
202
202
  }
203
203
  const severity = asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error");
204
+ const context = event.context ? scrubContext(event.context) : undefined;
205
+ // runtime-browser 1.8.0 sends an Error passed to `console.error` with
206
+ // the log line as `autter.log.message` only (its bundle is size-capped):
207
+ // mark it the way the server SDKs mark a caught error.
208
+ const logged = event.type === "exception" && typeof context?.["autter.log.message"] === "string";
204
209
  // A browser error belongs to no server request: it counts in
205
210
  // `error_count` only (fatal/error severity), never `request_count`,
206
211
  // 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") {
212
+ // inventing requests. A handled (caught) error is an occurrence but
213
+ // no counted failure; warnings and info count nothing.
214
+ if ((severity === "fatal" || severity === "error") && !logged) {
209
215
  bumpRollup(event.route ? (event.route.split("?")[0] ?? "") : "", occurredAt, { errorCount: 1 });
210
216
  }
211
217
  occurrences.push({
@@ -228,7 +234,15 @@ export function normalizeBrowserPayload(payload) {
228
234
  ...(event.filename ? { filename: event.filename.split("?")[0] } : {}),
229
235
  ...(event.line !== undefined ? { line: event.line } : {}),
230
236
  ...(event.column !== undefined ? { column: event.column } : {}),
231
- ...(event.context ? { context: scrubContext(event.context) } : {}),
237
+ ...(context ? { context } : {}),
238
+ ...(logged
239
+ ? {
240
+ "autter.handled": true,
241
+ "autter.handling": "caught",
242
+ "autter.log.source": typeof context?.["autter.log.source"] === "string" ? context["autter.log.source"] : "console",
243
+ "autter.log.message": context?.["autter.log.message"],
244
+ }
245
+ : {}),
232
246
  },
233
247
  occurredAt,
234
248
  ...liftErrorFields(event.context),
@@ -7,6 +7,8 @@ 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
9
  import { requestMembership } from "./request-spans.js";
10
+ import { DEBUG_IDS_ATTRIBUTE, serverDebugIds, withoutDebugIdsAttribute } from "./source-maps.js";
11
+ import { histogramLatency, mergeLatency, requestLatency } from "./latency.js";
10
12
  import { asSeverity, } from "./types.js";
11
13
  const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
12
14
  function attrMap(attributes) {
@@ -40,6 +42,17 @@ function spanDurationMs(span) {
40
42
  function isErrorStatus(code) {
41
43
  return code === 2 || code === "STATUS_CODE_ERROR";
42
44
  }
45
+ /**
46
+ * An error the app handled on purpose and recorded through Autter: a caught
47
+ * error (`autter.handling = "caught"`, RF-01) or a deliberate fallback
48
+ * (`"deliberate"`, RF-02). These are occurrences and issues, but they never
49
+ * fail their span or request and never count in `error_count`. The bare
50
+ * `autter.handled` marker (the opt-in V8 sampler) keeps its old meaning and
51
+ * still counts.
52
+ */
53
+ function softlyHandled(handling) {
54
+ return handling === "caught" || handling === "deliberate";
55
+ }
43
56
  const SPAN_KINDS = {
44
57
  "0": "unspecified",
45
58
  "1": "internal",
@@ -137,8 +150,11 @@ export function normalizeTraces(request) {
137
150
  const startedAt = nanosToDate(span.startTimeUnixNano);
138
151
  const durationMs = spanDurationMs(span);
139
152
  const kind = spanKind(span.kind);
140
- const hasFailureEvent = (span.events ?? []).some((event) => event.name === "exception" || (event.name === "autter.outcome"
141
- && attrMap(event.attributes).get("autter.outcome.status") === "error"));
153
+ // An exception the app handled (`autter.handled`: caught and
154
+ // logged, or a deliberate fallback) never fails its span.
155
+ const hasFailureEvent = (span.events ?? []).some((event) => (event.name === "exception" && !softlyHandled(attrMap(event.attributes).get("autter.handling")))
156
+ || (event.name === "autter.outcome"
157
+ && attrMap(event.attributes).get("autter.outcome.status") === "error"));
142
158
  const isError = statusCode !== null && statusCode >= 400 && statusCode < 500
143
159
  ? hasFailureEvent
144
160
  : hasFailureEvent || isErrorStatus(span.status?.code) || (statusCode !== null && statusCode >= 500);
@@ -207,8 +223,11 @@ export function normalizeTraces(request) {
207
223
  statusCode,
208
224
  traceId: span.traceId ?? null,
209
225
  sessionId: null,
210
- attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
211
- ...(handled ? { "autter.handled": true, "autter.sampled": eventAttrs.get("autter.sampled") === "true" } : {}) }),
226
+ attributes: withoutDebugIdsAttribute(sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
227
+ ...(handled ? { "autter.handled": true, ...(eventAttrs.has("autter.sampled") ? { "autter.sampled": eventAttrs.get("autter.sampled") === "true" } : {}) } : {}) })),
228
+ // Server SDKs (runtime-core errorAttributes): the debug ids of the bundles in the
229
+ // stack (RF-05), which locate their source maps.
230
+ ...serverDebugIds(eventAttrs.get(DEBUG_IDS_ATTRIBUTE)),
212
231
  occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
213
232
  // Event attributes first: one span can carry several
214
233
  // exceptions, each with its own code.
@@ -337,6 +356,11 @@ export function normalizeTraces(request) {
337
356
  const occ = occurrences[i];
338
357
  if (occ.severity !== "fatal" && occ.severity !== "error")
339
358
  return;
359
+ // A caught error or deliberate fallback is an occurrence and an issue,
360
+ // but no failure: it never fails its request and never counts in
361
+ // `error_count`.
362
+ if (softlyHandled(occ.attributes?.["autter.handling"]))
363
+ return;
340
364
  const request = members.map(requestOfSignal).find((r) => r !== undefined);
341
365
  if (request) {
342
366
  request.hasErrorOccurrence = true;
@@ -373,6 +397,7 @@ export function normalizeTraces(request) {
373
397
  errorCount: failed ? 1 : 0,
374
398
  // The request's latency is its outermost span's.
375
399
  durationSumMs: span.durationMs,
400
+ ...withLatency(requestLatency(span.durationMs)),
376
401
  });
377
402
  break;
378
403
  case "metrics": {
@@ -515,6 +540,9 @@ function methodFromSpan(span) {
515
540
  const raw = span.attributes?.["http.request.method"];
516
541
  return typeof raw === "string" && raw.trim() ? raw.trim().toUpperCase().slice(0, 16) : null;
517
542
  }
543
+ function withLatency(latency) {
544
+ return latency ? { latency } : {};
545
+ }
518
546
  function minuteBucket(date) {
519
547
  return new Date(Math.floor(date.getTime() / 60_000) * 60_000);
520
548
  }
@@ -531,13 +559,17 @@ function addToRollup(rollups, point) {
531
559
  const key = rollupKey(point);
532
560
  const existing = rollups.get(key);
533
561
  if (!existing) {
534
- rollups.set(key, { ...point });
562
+ const latency = mergeLatency(undefined, point.latency);
563
+ rollups.set(key, { ...point, ...withLatency(latency) });
535
564
  return;
536
565
  }
537
566
  existing.requestCount += point.requestCount;
538
567
  existing.errorCount += point.errorCount;
539
568
  existing.durationSumMs += point.durationSumMs;
540
569
  existing.sessionCount += point.sessionCount;
570
+ const latency = mergeLatency(existing.latency, point.latency);
571
+ if (latency)
572
+ existing.latency = latency;
541
573
  }
542
574
  // HTTP-server duration instruments we know how to fold into rollups.
543
575
  // `http.server.duration` (old semconv) is milliseconds;
@@ -598,6 +630,8 @@ export function normalizeMetrics(request) {
598
630
  errorCount: statusCode !== null && statusCode >= 500 ? count : 0,
599
631
  durationSumMs: (dataPoint.sum ?? 0) * multiplier,
600
632
  sessionCount: 0,
633
+ // p50/p95/p99 from the same buckets (IP-02, latency.ts).
634
+ ...withLatency(histogramLatency((dataPoint.explicitBounds ?? []).map((bound) => Number(bound) * multiplier), (dataPoint.bucketCounts ?? []).map(Number))),
601
635
  });
602
636
  }
603
637
  }