@autter/otlp-ingester 1.4.0 → 1.5.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.
@@ -0,0 +1,55 @@
1
+ import type { RuntimeLogRecord } from "./logs.js";
2
+ import type { RuntimeMetricPoint, RuntimeOccurrence, RuntimeOccurrenceInput } from "./types.js";
3
+ /**
4
+ * Log promotion — errors from SDKs running WITHOUT a trace pipeline.
5
+ *
6
+ * Logger-only mode (`initAutterLogging`) and `@autter/runtime-edge` have no
7
+ * span to record an exception on, so they emit an error log record with
8
+ * `exception.*` and `autter.capture.mode = "log"`. /v1/logs turns those
9
+ * records into ordinary server occurrences (fingerprinted, stored in
10
+ * runtime_error_occurrences, forwarded to the sink) — otherwise their
11
+ * errors would never reach issue grouping.
12
+ *
13
+ * Dedupe, so one failure never becomes two occurrences through two export
14
+ * paths:
15
+ * - in-batch: at most one promoted occurrence per trace id (first wins);
16
+ * - cross-batch: a trace id that already produced an occurrence for this
17
+ * tenant within ±PROMOTION_DEDUPE_WINDOW_MS (ClickHouse lookup) is skipped.
18
+ * Records without a trace id are always promoted. The lookup is
19
+ * best-effort and bounded (see promotionLookups, plus a per-tenant lookup
20
+ * budget in server.ts): when it fails, is skipped or is over budget the
21
+ * record is promoted anyway — a rare duplicate beats a silently dropped
22
+ * error.
23
+ */
24
+ export declare const PROMOTION_DEDUPE_WINDOW_MS = 60000;
25
+ /** Widest single lookup window. Candidate timestamps come from the client,
26
+ * so one batch could otherwise ask ClickHouse to scan an arbitrary range. */
27
+ export declare const MAX_LOOKUP_SPAN_MS: number;
28
+ /** At most this many lookup windows (queries) per request. */
29
+ export declare const MAX_LOOKUP_WINDOWS = 4;
30
+ /** Candidates outside [now − 24 h, now + 5 min] are never looked up: their
31
+ * trace cannot plausibly have a recent occurrence to dedupe against. */
32
+ export declare const LOOKUP_PAST_MS: number;
33
+ export declare const LOOKUP_FUTURE_MS: number;
34
+ /** Promotion candidates in record order, deduped by trace id in-batch. */
35
+ export declare function logPromotionCandidates(rows: RuntimeLogRecord[]): RuntimeOccurrenceInput[];
36
+ export interface PromotionLookup {
37
+ from: Date;
38
+ to: Date;
39
+ traceIds: string[];
40
+ }
41
+ /**
42
+ * Bounded dedupe lookups for the candidates that carry a trace id: each
43
+ * window is [oldest − 60 s, newest + 60 s] over candidates taken newest first
44
+ * and spans at most MAX_LOOKUP_SPAN_MS; at most MAX_LOOKUP_WINDOWS windows are
45
+ * planned.
46
+ * Candidates outside the plausible time range or beyond the window cap are
47
+ * not looked up — they are still deduped in-batch and promoted.
48
+ */
49
+ export declare function promotionLookups(occurrences: RuntimeOccurrenceInput[], now?: number): PromotionLookup[];
50
+ /**
51
+ * One event / one error event per promoted occurrence — the same counting
52
+ * the trace path applies to occurrences outside a server span, so issue
53
+ * counters for logger-only services don't read 0.
54
+ */
55
+ export declare function promotionRollups(occurrences: RuntimeOccurrence[]): RuntimeMetricPoint[];
@@ -0,0 +1,119 @@
1
+ import { normalizeRoute } from "./fingerprint.js";
2
+ /**
3
+ * Log promotion — errors from SDKs running WITHOUT a trace pipeline.
4
+ *
5
+ * Logger-only mode (`initAutterLogging`) and `@autter/runtime-edge` have no
6
+ * span to record an exception on, so they emit an error log record with
7
+ * `exception.*` and `autter.capture.mode = "log"`. /v1/logs turns those
8
+ * records into ordinary server occurrences (fingerprinted, stored in
9
+ * runtime_error_occurrences, forwarded to the sink) — otherwise their
10
+ * errors would never reach issue grouping.
11
+ *
12
+ * Dedupe, so one failure never becomes two occurrences through two export
13
+ * paths:
14
+ * - in-batch: at most one promoted occurrence per trace id (first wins);
15
+ * - cross-batch: a trace id that already produced an occurrence for this
16
+ * tenant within ±PROMOTION_DEDUPE_WINDOW_MS (ClickHouse lookup) is skipped.
17
+ * Records without a trace id are always promoted. The lookup is
18
+ * best-effort and bounded (see promotionLookups, plus a per-tenant lookup
19
+ * budget in server.ts): when it fails, is skipped or is over budget the
20
+ * record is promoted anyway — a rare duplicate beats a silently dropped
21
+ * error.
22
+ */
23
+ export const PROMOTION_DEDUPE_WINDOW_MS = 60_000;
24
+ /** Widest single lookup window. Candidate timestamps come from the client,
25
+ * so one batch could otherwise ask ClickHouse to scan an arbitrary range. */
26
+ export const MAX_LOOKUP_SPAN_MS = 10 * 60_000;
27
+ /** At most this many lookup windows (queries) per request. */
28
+ export const MAX_LOOKUP_WINDOWS = 4;
29
+ /** Candidates outside [now − 24 h, now + 5 min] are never looked up: their
30
+ * trace cannot plausibly have a recent occurrence to dedupe against. */
31
+ export const LOOKUP_PAST_MS = 24 * 60 * 60_000;
32
+ export const LOOKUP_FUTURE_MS = 5 * 60_000;
33
+ /** Promotion candidates in record order, deduped by trace id in-batch. */
34
+ export function logPromotionCandidates(rows) {
35
+ const traces = new Set();
36
+ const out = [];
37
+ for (const row of rows) {
38
+ const occurrence = row.occurrence;
39
+ if (!occurrence)
40
+ continue;
41
+ if (occurrence.traceId) {
42
+ if (traces.has(occurrence.traceId))
43
+ continue;
44
+ traces.add(occurrence.traceId);
45
+ }
46
+ out.push(occurrence);
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * Bounded dedupe lookups for the candidates that carry a trace id: each
52
+ * window is [oldest − 60 s, newest + 60 s] over candidates taken newest first
53
+ * and spans at most MAX_LOOKUP_SPAN_MS; at most MAX_LOOKUP_WINDOWS windows are
54
+ * planned.
55
+ * Candidates outside the plausible time range or beyond the window cap are
56
+ * not looked up — they are still deduped in-batch and promoted.
57
+ */
58
+ export function promotionLookups(occurrences, now = Date.now()) {
59
+ const eligible = occurrences
60
+ .filter((o) => !!o.traceId)
61
+ .map((o) => ({ traceId: o.traceId, at: o.occurredAt.getTime() }))
62
+ .filter((o) => o.at >= now - LOOKUP_PAST_MS && o.at <= now + LOOKUP_FUTURE_MS)
63
+ // Newest first: recent records are the likeliest duplicates of an
64
+ // occurrence the trace path just wrote, so they get the windows.
65
+ .sort((a, b) => b.at - a.at);
66
+ const lookups = [];
67
+ let start = 0;
68
+ while (start < eligible.length && lookups.length < MAX_LOOKUP_WINDOWS) {
69
+ const newest = eligible[start].at;
70
+ let end = start;
71
+ while (end + 1 < eligible.length &&
72
+ newest - eligible[end + 1].at + 2 * PROMOTION_DEDUPE_WINDOW_MS <= MAX_LOOKUP_SPAN_MS)
73
+ end++;
74
+ lookups.push({
75
+ from: new Date(eligible[end].at - PROMOTION_DEDUPE_WINDOW_MS),
76
+ to: new Date(newest + PROMOTION_DEDUPE_WINDOW_MS),
77
+ traceIds: [...new Set(eligible.slice(start, end + 1).map((o) => o.traceId))],
78
+ });
79
+ start = end + 1;
80
+ }
81
+ return lookups;
82
+ }
83
+ /**
84
+ * One event / one error event per promoted occurrence — the same counting
85
+ * the trace path applies to occurrences outside a server span, so issue
86
+ * counters for logger-only services don't read 0.
87
+ */
88
+ export function promotionRollups(occurrences) {
89
+ const rollups = new Map();
90
+ for (const occ of occurrences) {
91
+ const bucketAt = new Date(Math.floor(occ.occurredAt.getTime() / 60_000) * 60_000);
92
+ const route = normalizeRoute(occ.route);
93
+ const key = [
94
+ occ.service,
95
+ occ.environment,
96
+ occ.release ?? "",
97
+ route,
98
+ bucketAt.getTime(),
99
+ ].join(" ");
100
+ const existing = rollups.get(key);
101
+ if (existing) {
102
+ existing.requestCount += 1;
103
+ existing.errorCount += 1;
104
+ continue;
105
+ }
106
+ rollups.set(key, {
107
+ service: occ.service,
108
+ environment: occ.environment,
109
+ release: occ.release,
110
+ route,
111
+ bucketAt,
112
+ requestCount: 1,
113
+ errorCount: 1,
114
+ durationSumMs: 0,
115
+ sessionCount: 0,
116
+ });
117
+ }
118
+ return [...rollups.values()];
119
+ }
package/dist/logs.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type OtlpAttribute, type OtlpValue } from "./context.js";
2
+ import type { RuntimeOccurrenceInput } from "./types.js";
2
3
  export interface RuntimeLogRecord {
3
4
  id: string;
4
5
  service: string;
@@ -15,6 +16,28 @@ export interface RuntimeLogRecord {
15
16
  durationMs: number;
16
17
  attributes: Record<string, unknown>;
17
18
  occurredAt: Date;
19
+ /**
20
+ * Lifted wide-event columns (migration 0012). `kind` is "request" for
21
+ * request summaries, "operation" for operation summaries (including
22
+ * pre-1.5.0 SDKs that never sent `autter.operation.kind`), "" for plain
23
+ * log records. The rest default to ""/0 when the record doesn't carry them.
24
+ */
25
+ kind: "" | "request" | "operation";
26
+ requestId: string;
27
+ /** `http.route`, query-stripped and id-normalised. */
28
+ route: string;
29
+ statusCode: number;
30
+ /** `autter.error.code`, only when it matches CODE_PATTERN. */
31
+ errorCode: string;
32
+ aiCostUsd: number;
33
+ aiCalls: number;
34
+ /**
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.
39
+ */
40
+ occurrence?: RuntimeOccurrenceInput;
18
41
  }
19
42
  export interface OtlpLogsRequest {
20
43
  resourceLogs?: Array<{
@@ -39,4 +62,37 @@ export interface OtlpLogsRequest {
39
62
  }>;
40
63
  }
41
64
  export declare function normalizeLogs(request: OtlpLogsRequest): RuntimeLogRecord[];
42
- export declare function logTableDDL(db: string): string;
65
+ /**
66
+ * Baseline for fresh databases. The 0012 columns and request-id skip index
67
+ * are included so a fresh DB matches a migrated one. `ttlDays` is
68
+ * LOG_TTL_DAYS; existing tables are brought in line at boot
69
+ * (ClickHouseStore.applyLogTtl), not here.
70
+ */
71
+ export declare function logTableDDL(db: string, ttlDays?: number): string;
72
+ /** Migration 0012 columns — shared by the baseline DDL and the migration. */
73
+ export declare const LOG_REQUEST_COLUMNS: Array<{
74
+ name: string;
75
+ ddl: string;
76
+ }>;
77
+ export declare const LOG_REQUEST_ID_INDEX = "INDEX idx_logs_request_id request_id TYPE bloom_filter GRANULARITY 4";
78
+ /**
79
+ * Per-minute request rollup (migration 0014), fed by runtime_request_1m_mv
80
+ * from `runtime_logs` rows with kind = 'request'. Route stats read this
81
+ * instead of scanning raw summaries:
82
+ * sum(request_count), sum(failed_count), sum(duration_sum_ms),
83
+ * quantilesMerge(0.5, 0.95)(duration_quantiles)
84
+ */
85
+ export declare function requestRollupTableDDL(db: string): string;
86
+ /**
87
+ * The view only sees rows inserted AFTER it exists (no backfill) and is
88
+ * created by migration 0014 only — not the baseline — because it reads the
89
+ * 0012 columns, which an existing runtime_logs table lacks until migrations
90
+ * run. runtime_logs is a ReplacingMergeTree keyed by a content-derived
91
+ * event_id, so a retried batch collapses there on merge — but the view
92
+ * aggregates every INSERT, so retries can count a summary twice. The
93
+ * rollup is therefore approximate under exporter retries (rare: a retry
94
+ * needs a 503 or a lost 2xx); exact per-request answers come from
95
+ * runtime_logs itself. `method` is read from the attributes JSON since
96
+ * runtime_logs has no method column.
97
+ */
98
+ export declare function requestRollupViewDDL(db: string): string;
package/dist/logs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { decodeOtlpAttributes, sanitizeRuntimeContext, } from "./context.js";
3
+ import { declaredLink, liftErrorFields } from "./error-fields.js";
3
4
  import { normalizeRoute } from "./fingerprint.js";
4
5
  export function normalizeLogs(request) {
5
6
  if (!request || !Array.isArray(request.resourceLogs))
@@ -73,6 +74,56 @@ export function normalizeLogs(request) {
73
74
  : "";
74
75
  if (typeof attributes.path === "string")
75
76
  attributes.path = normalizeRoute(attributes.path.split(/[?#]/)[0]);
77
+ const service = String(info["service.name"] ?? "unknown").slice(0, 200);
78
+ const environment = String(info["deployment.environment.name"] ??
79
+ info["deployment.environment"] ??
80
+ "production").slice(0, 100);
81
+ const release = String(info["service.version"] ?? "").slice(0, 200);
82
+ const declared = liftErrorFields(attributes);
83
+ // The sanitiser strips URL fragments; the declared docs link keeps
84
+ // its #anchor from the raw attribute (still validated).
85
+ const rawLink = declaredLink(record.attributes?.find((a) => a?.key === "autter.error.link")
86
+ ?.value?.stringValue);
87
+ if (rawLink)
88
+ declared.link = rawLink;
89
+ const rawRoute = typeof attributes["http.route"] === "string"
90
+ ? attributes["http.route"].split(/[?#]/)[0]
91
+ : "";
92
+ const route = normalizeRoute(rawRoute).slice(0, 500);
93
+ const status = Number(attributes["http.response.status_code"]);
94
+ const statusCode = Number.isInteger(status) && status > 0 && status < 1000 ? status : 0;
95
+ const declaredKind = attributes["autter.operation.kind"];
96
+ const type = attributes["autter.event.type"] === "operation" ? "operation" : "log";
97
+ const ai = attributes["autter.operation.ai"];
98
+ const aiRollup = ai && typeof ai === "object" && !Array.isArray(ai)
99
+ ? ai
100
+ : {};
101
+ const occurrence = attributes["autter.capture.mode"] === "log" &&
102
+ (severity === "error" || severity === "fatal") &&
103
+ hasException(attributes)
104
+ ? {
105
+ source: "server",
106
+ severity: severity,
107
+ service,
108
+ environment,
109
+ release: release || null,
110
+ errorType: String(attributes["exception.type"] || "Error").slice(0, 200),
111
+ message: String(attributes["exception.message"] || message || "Unknown error").slice(0, 4000),
112
+ stack: typeof attributes["exception.stacktrace"] === "string"
113
+ ? attributes["exception.stacktrace"]
114
+ : null,
115
+ route: rawRoute || stringOrNull(attributes["url.path"]),
116
+ method: typeof attributes["http.request.method"] === "string"
117
+ ? attributes["http.request.method"].toUpperCase().slice(0, 16)
118
+ : null,
119
+ statusCode: statusCode || null,
120
+ traceId: traceId || null,
121
+ sessionId: null,
122
+ attributes,
123
+ occurredAt,
124
+ ...declared,
125
+ }
126
+ : undefined;
76
127
  rows.push({
77
128
  id: createHash("sha256")
78
129
  .update(JSON.stringify([
@@ -86,31 +137,55 @@ export function normalizeLogs(request) {
86
137
  ]))
87
138
  .digest("hex")
88
139
  .slice(0, 32),
89
- service: String(info["service.name"] ?? "unknown").slice(0, 200),
90
- environment: String(info["deployment.environment.name"] ??
91
- info["deployment.environment"] ??
92
- "production").slice(0, 100),
93
- release: String(info["service.version"] ?? "").slice(0, 200),
140
+ service,
141
+ environment,
142
+ release,
94
143
  traceId,
95
144
  spanId,
96
145
  operationId,
97
146
  operation: String(attributes["autter.operation.name"] ?? "").slice(0, 200),
98
- type: attributes["autter.event.type"] === "operation"
99
- ? "operation"
100
- : "log",
147
+ type,
101
148
  severity,
102
149
  message,
103
150
  outcome: String(attributes["autter.operation.outcome"] ?? "").slice(0, 30),
104
151
  durationMs: Math.max(0, Number(attributes["autter.operation.duration_ms"]) || 0),
105
152
  attributes,
106
153
  occurredAt,
154
+ kind: declaredKind === "request" || declaredKind === "operation"
155
+ ? declaredKind
156
+ : type === "operation"
157
+ ? "operation"
158
+ : "",
159
+ requestId: declared.requestId ?? "",
160
+ route,
161
+ statusCode,
162
+ errorCode: declared.errorCode ?? "",
163
+ aiCostUsd: nonNegative(attributes["autter.operation.ai.cost_usd"] ?? aiRollup.cost_usd),
164
+ aiCalls: Math.min(4_294_967_295, Math.round(nonNegative(attributes["autter.operation.ai.calls"] ?? aiRollup.calls))),
165
+ ...(occurrence ? { occurrence } : {}),
107
166
  });
108
167
  }
109
168
  }
110
169
  }
111
170
  return rows;
112
171
  }
113
- export function logTableDDL(db) {
172
+ function hasException(attributes) {
173
+ return ["exception.type", "exception.message", "exception.stacktrace"].some((key) => typeof attributes[key] === "string" && attributes[key] !== "");
174
+ }
175
+ function stringOrNull(value) {
176
+ return typeof value === "string" && value ? value.split(/[?#]/)[0] : null;
177
+ }
178
+ function nonNegative(value) {
179
+ const n = Number(value);
180
+ return Number.isFinite(n) && n > 0 ? n : 0;
181
+ }
182
+ /**
183
+ * Baseline for fresh databases. The 0012 columns and request-id skip index
184
+ * are included so a fresh DB matches a migrated one. `ttlDays` is
185
+ * LOG_TTL_DAYS; existing tables are brought in line at boot
186
+ * (ClickHouseStore.applyLogTtl), not here.
187
+ */
188
+ export function logTableDDL(db, ttlDays = 14) {
114
189
  return `CREATE TABLE IF NOT EXISTS ${db}.runtime_logs (
115
190
  org_id String, repository_id String, event_id String,
116
191
  service LowCardinality(String), environment LowCardinality(String), release String DEFAULT '',
@@ -119,8 +194,68 @@ export function logTableDDL(db) {
119
194
  severity LowCardinality(String) DEFAULT 'info', message String CODEC(ZSTD(1)),
120
195
  outcome LowCardinality(String) DEFAULT '', duration_ms Float64 DEFAULT 0,
121
196
  attributes String DEFAULT '{}' CODEC(ZSTD(1)), occurred_at DateTime64(3, 'UTC'),
122
- ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
197
+ ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
198
+ ${LOG_REQUEST_COLUMNS.map((column) => column.ddl).join(", ")},
199
+ ${LOG_REQUEST_ID_INDEX}
123
200
  ) ENGINE = ReplacingMergeTree PARTITION BY toDate(occurred_at)
124
201
  ORDER BY (org_id, repository_id, occurred_at, event_id)
125
- TTL toDateTime(occurred_at) + INTERVAL 14 DAY`;
202
+ TTL toDateTime(occurred_at) + INTERVAL ${ttlDays} DAY`;
203
+ }
204
+ /** Migration 0012 columns — shared by the baseline DDL and the migration. */
205
+ export const LOG_REQUEST_COLUMNS = [
206
+ { name: "kind", ddl: "kind LowCardinality(String) DEFAULT ''" },
207
+ { name: "request_id", ddl: "request_id String DEFAULT ''" },
208
+ { name: "route", ddl: "route String DEFAULT ''" },
209
+ { name: "status_code", ddl: "status_code UInt16 DEFAULT 0" },
210
+ { name: "error_code", ddl: "error_code String DEFAULT ''" },
211
+ { name: "ai_cost_usd", ddl: "ai_cost_usd Float64 DEFAULT 0" },
212
+ { name: "ai_calls", ddl: "ai_calls UInt32 DEFAULT 0" },
213
+ ];
214
+ export const LOG_REQUEST_ID_INDEX = "INDEX idx_logs_request_id request_id TYPE bloom_filter GRANULARITY 4";
215
+ /**
216
+ * Per-minute request rollup (migration 0014), fed by runtime_request_1m_mv
217
+ * from `runtime_logs` rows with kind = 'request'. Route stats read this
218
+ * instead of scanning raw summaries:
219
+ * sum(request_count), sum(failed_count), sum(duration_sum_ms),
220
+ * quantilesMerge(0.5, 0.95)(duration_quantiles)
221
+ */
222
+ export function requestRollupTableDDL(db) {
223
+ return `CREATE TABLE IF NOT EXISTS ${db}.runtime_request_1m (
224
+ org_id String, repository_id String,
225
+ service LowCardinality(String), environment LowCardinality(String),
226
+ route String, method LowCardinality(String), bucket_at DateTime('UTC'),
227
+ request_count SimpleAggregateFunction(sum, UInt64),
228
+ failed_count SimpleAggregateFunction(sum, UInt64),
229
+ duration_sum_ms SimpleAggregateFunction(sum, Float64),
230
+ duration_quantiles AggregateFunction(quantiles(0.5, 0.95), Float64)
231
+ ) ENGINE = AggregatingMergeTree PARTITION BY toYYYYMM(bucket_at)
232
+ ORDER BY (org_id, repository_id, service, environment, route, method, bucket_at)
233
+ TTL bucket_at + INTERVAL 90 DAY`;
234
+ }
235
+ /**
236
+ * The view only sees rows inserted AFTER it exists (no backfill) and is
237
+ * created by migration 0014 only — not the baseline — because it reads the
238
+ * 0012 columns, which an existing runtime_logs table lacks until migrations
239
+ * run. runtime_logs is a ReplacingMergeTree keyed by a content-derived
240
+ * event_id, so a retried batch collapses there on merge — but the view
241
+ * aggregates every INSERT, so retries can count a summary twice. The
242
+ * rollup is therefore approximate under exporter retries (rare: a retry
243
+ * needs a 503 or a lost 2xx); exact per-request answers come from
244
+ * runtime_logs itself. `method` is read from the attributes JSON since
245
+ * runtime_logs has no method column.
246
+ */
247
+ export function requestRollupViewDDL(db) {
248
+ return `CREATE MATERIALIZED VIEW IF NOT EXISTS ${db}.runtime_request_1m_mv
249
+ TO ${db}.runtime_request_1m AS
250
+ SELECT
251
+ org_id, repository_id, service, environment, route,
252
+ upper(JSONExtractString(attributes, 'http.request.method')) AS method,
253
+ toDateTime(toStartOfMinute(occurred_at), 'UTC') AS bucket_at,
254
+ count() AS request_count,
255
+ countIf(outcome = 'failed') AS failed_count,
256
+ sum(duration_ms) AS duration_sum_ms,
257
+ quantilesState(0.5, 0.95)(duration_ms) AS duration_quantiles
258
+ FROM ${db}.runtime_logs
259
+ WHERE kind = 'request'
260
+ GROUP BY org_id, repository_id, service, environment, route, method, bucket_at`;
126
261
  }
@@ -32,6 +32,9 @@
32
32
  * ],
33
33
  * },
34
34
  */
35
+ /** Migration 0013 columns — shared with the baseline DDL in clickhouse.ts. */
36
+ export declare const OCCURRENCE_CODE_COLUMNS: string[];
37
+ export declare const OCCURRENCE_REQUEST_ID_INDEX = "INDEX idx_occ_request_id request_id TYPE bloom_filter GRANULARITY 4";
35
38
  export interface Migration {
36
39
  /** Unique, ordered id: "<serial>-<slug>". Never reuse or reorder. */
37
40
  id: string;
@@ -35,8 +35,20 @@
35
35
  import { latencyTableDDL } from "./latency.js";
36
36
  import { profileTableDDL } from "./profiles.js";
37
37
  import { sourceMapTableDDL } from "./source-maps.js";
38
- import { logTableDDL } from "./logs.js";
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";
41
+ /** Migration 0013 columns — shared with the baseline DDL in clickhouse.ts. */
42
+ export const OCCURRENCE_CODE_COLUMNS = [
43
+ "error_code String DEFAULT ''",
44
+ "error_why String DEFAULT ''",
45
+ "error_fix String DEFAULT ''",
46
+ "error_link String DEFAULT ''",
47
+ "expected UInt8 DEFAULT 0",
48
+ "request_id String DEFAULT ''",
49
+ "fingerprint_scheme LowCardinality(String) DEFAULT 'message-v1'",
50
+ ];
51
+ export const OCCURRENCE_REQUEST_ID_INDEX = "INDEX idx_occ_request_id request_id TYPE bloom_filter GRANULARITY 4";
40
52
  export const MIGRATIONS = [
41
53
  // 0001 intentionally reserved as the baseline marker: databases created
42
54
  // before the migration runner existed record it as applied without
@@ -129,6 +141,36 @@ export const MIGRATIONS = [
129
141
  `ALTER TABLE {db}.runtime_memory_samples ADD COLUMN IF NOT EXISTS temporality LowCardinality(String) DEFAULT 'gauge'`,
130
142
  ] },
131
143
  { id: "0011-runtime-logs", statements: [logTableDDL("{db}")] },
144
+ // Request wide events: request summaries share runtime_logs with
145
+ // operations and plain logs; the lifted columns make "requests on this
146
+ // route", "everything for request X" and "coded failures" plain column
147
+ // filters. The skip index only covers parts written after it exists
148
+ // (no MATERIALIZE INDEX mutation) — older rows predate request ids.
149
+ {
150
+ id: "0012-runtime-logs-requests",
151
+ statements: [
152
+ ...LOG_REQUEST_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_logs ADD COLUMN IF NOT EXISTS ${column.ddl}`),
153
+ `ALTER TABLE {db}.runtime_logs ADD ${LOG_REQUEST_ID_INDEX.replace("INDEX ", "INDEX IF NOT EXISTS ")}`,
154
+ ],
155
+ },
156
+ // Declared error metadata and code-based grouping (fingerprint_scheme
157
+ // "code-v1" vs the historical "message-v1").
158
+ {
159
+ id: "0013-occurrence-codes",
160
+ statements: [
161
+ ...OCCURRENCE_CODE_COLUMNS.map((column) => `ALTER TABLE {db}.runtime_error_occurrences ADD COLUMN IF NOT EXISTS ${column}`),
162
+ `ALTER TABLE {db}.runtime_error_occurrences ADD ${OCCURRENCE_REQUEST_ID_INDEX.replace("INDEX ", "INDEX IF NOT EXISTS ")}`,
163
+ ],
164
+ },
165
+ // Per-minute route rollup of request summaries. Table first, then the
166
+ // view (which needs both the table and the 0012 columns).
167
+ {
168
+ id: "0014-runtime-request-1m",
169
+ statements: [requestRollupTableDDL("{db}"), requestRollupViewDDL("{db}")],
170
+ },
171
+ // Version compatibility: SDK name/version seen per service, and this
172
+ // ingester's own version + schema level for the Autter dashboard.
173
+ { id: "0015-runtime-compat", statements: [sdkVersionTableDDL("{db}"), ingesterInfoTableDDL("{db}")] },
132
174
  ];
133
175
  /** The tracking table itself — created by the runner before anything else. */
134
176
  export function migrationsTableDDL(db) {
@@ -6,6 +6,8 @@ export declare const browserPayloadSchema: z.ZodObject<{
6
6
  service: z.ZodString;
7
7
  environment: z.ZodString;
8
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>>;
9
11
  events: z.ZodArray<z.ZodObject<{
10
12
  id: z.ZodOptional<z.ZodString>;
11
13
  type: z.ZodEnum<["exception", "unhandled_rejection", "message", "session_start", "track_event", "outcome", "request_failure", "csp_violation", "timing"]>;
@@ -23,9 +25,17 @@ export declare const browserPayloadSchema: z.ZodObject<{
23
25
  column: z.ZodOptional<z.ZodNumber>;
24
26
  /** Path only; query strings are stripped defensively anyway. */
25
27
  route: z.ZodOptional<z.ZodString>;
28
+ /**
29
+ * Free-form, scrubbed context. runtime-browser ≥1.4.0 also sends the
30
+ * declared-error keys `autter.error.code|why|fix|link|expected` (from
31
+ * coded errors / `autterErrorFromResponse`) and `autter.request.id` (the
32
+ * `x-request-id` of a failed fetch/XHR). They are validated one by one
33
+ * in normalizeBrowserPayload — a malformed value is dropped on its own
34
+ * instead of rejecting the whole beacon.
35
+ */
26
36
  context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
27
37
  }, "strip", z.ZodTypeAny, {
28
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
38
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
29
39
  message: string;
30
40
  timestamp: string;
31
41
  durationMs?: number | undefined;
@@ -40,7 +50,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
40
50
  column?: number | undefined;
41
51
  context?: Record<string, unknown> | undefined;
42
52
  }, {
43
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
53
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
44
54
  timestamp: string;
45
55
  durationMs?: number | undefined;
46
56
  route?: string | undefined;
@@ -60,7 +70,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
60
70
  environment: string;
61
71
  version: 1;
62
72
  events: {
63
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
73
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
64
74
  message: string;
65
75
  timestamp: string;
66
76
  durationMs?: number | undefined;
@@ -77,12 +87,13 @@ export declare const browserPayloadSchema: z.ZodObject<{
77
87
  }[];
78
88
  release?: string | undefined;
79
89
  sessionId?: string | undefined;
90
+ sdk?: string | undefined;
80
91
  }, {
81
92
  service: string;
82
93
  environment: string;
83
94
  version: 1;
84
95
  events: {
85
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
96
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
86
97
  timestamp: string;
87
98
  durationMs?: number | undefined;
88
99
  route?: string | undefined;
@@ -99,6 +110,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
99
110
  }[];
100
111
  release?: string | undefined;
101
112
  sessionId?: string | undefined;
113
+ sdk?: unknown;
102
114
  }>;
103
115
  export type BrowserPayload = z.infer<typeof browserPayloadSchema>;
104
116
  export interface NormalizedBrowser {