@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.
- package/README.md +50 -0
- package/dist/clickhouse.d.ts +33 -0
- package/dist/clickhouse.js +144 -5
- package/dist/compat-manifest.json +94 -0
- package/dist/compat.d.ts +147 -0
- package/dist/compat.js +219 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +18 -0
- package/dist/context.js +20 -10
- package/dist/error-fields.d.ts +41 -0
- package/dist/error-fields.js +103 -0
- package/dist/fingerprint.d.ts +29 -0
- package/dist/fingerprint.js +45 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -3
- package/dist/llm.js +16 -3
- package/dist/log-promotion.d.ts +55 -0
- package/dist/log-promotion.js +119 -0
- package/dist/logs.d.ts +57 -1
- package/dist/logs.js +146 -11
- package/dist/migrations.d.ts +3 -0
- package/dist/migrations.js +43 -1
- package/dist/normalize-browser.d.ts +16 -4
- package/dist/normalize-browser.js +22 -18
- package/dist/normalize-otlp.js +14 -2
- package/dist/redact.d.ts +33 -0
- package/dist/redact.js +96 -0
- package/dist/sdk-versions.d.ts +58 -0
- package/dist/sdk-versions.js +167 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +150 -8
- package/dist/sink.d.ts +9 -0
- package/dist/sink.js +26 -4
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
90
|
-
environment
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/migrations.d.ts
CHANGED
|
@@ -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;
|
package/dist/migrations.js
CHANGED
|
@@ -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" | "
|
|
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" | "
|
|
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" | "
|
|
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" | "
|
|
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 {
|