@autter/otlp-ingester 1.3.4 → 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 +35 -0
- package/dist/clickhouse.js +156 -2
- 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.d.ts +19 -0
- package/dist/context.js +102 -0
- 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 +98 -0
- package/dist/logs.js +261 -0
- package/dist/migrations.d.ts +3 -0
- package/dist/migrations.js +44 -0
- package/dist/normalize-browser.d.ts +16 -4
- package/dist/normalize-browser.js +22 -18
- package/dist/normalize-otlp.d.ts +2 -6
- package/dist/normalize-otlp.js +22 -9
- package/dist/otlp-proto.d.ts +2 -0
- package/dist/otlp-proto.js +21 -0
- 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 +171 -5
- 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
package/dist/llm.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { estimateLlmCostUsd } from "./llm-pricing.js";
|
|
2
|
+
import { isSensitiveKey, scrubText } from "./redact.js";
|
|
2
3
|
/**
|
|
3
4
|
* LLM-call extraction from OTLP spans. Three attribute families are
|
|
4
5
|
* recognised, so most stacks work with zero Autter-specific code:
|
|
@@ -101,12 +102,17 @@ export function extractLlmCall(attrs, facts) {
|
|
|
101
102
|
errorType: facts.isError
|
|
102
103
|
? (strAttr(attrs, "error.type") ?? facts.exceptionType ?? "Error").slice(0, 200)
|
|
103
104
|
: "",
|
|
104
|
-
userId: (strAttr(attrs, "autter.user_id", "ai.telemetry.metadata.userId", "ai.telemetry.metadata.user_id", "enduser.id", "user.id") ?? "").slice(0, 200),
|
|
105
|
-
sessionId: (strAttr(attrs, "autter.session_id", "ai.telemetry.metadata.sessionId", "session.id") ?? "").slice(0, 200),
|
|
105
|
+
userId: scrubText(strAttr(attrs, "autter.user_id", "ai.telemetry.metadata.userId", "ai.telemetry.metadata.user_id", "enduser.id", "user.id") ?? "").slice(0, 200),
|
|
106
|
+
sessionId: scrubText(strAttr(attrs, "autter.session_id", "ai.telemetry.metadata.sessionId", "session.id") ?? "").slice(0, 200),
|
|
106
107
|
attributes: llmAttributeSubset(attrs),
|
|
107
108
|
startedAt: facts.startedAt,
|
|
108
109
|
};
|
|
109
110
|
}
|
|
111
|
+
/** Numeric token/usage counters are kept even though their keys contain
|
|
112
|
+
* "token" — cost tracking depends on them. */
|
|
113
|
+
function isUsageCount(key, value) {
|
|
114
|
+
return /tokens?$|token_?count$|usage/i.test(key) && /^\d+(\.\d+)?$/.test(value);
|
|
115
|
+
}
|
|
110
116
|
/**
|
|
111
117
|
* Drill-down context stored as JSON alongside the typed columns: only the
|
|
112
118
|
* LLM-relevant attribute families, size-capped so a prompt accidentally
|
|
@@ -121,7 +127,14 @@ function llmAttributeSubset(attrs) {
|
|
|
121
127
|
!key.startsWith("autter.llm.")) {
|
|
122
128
|
continue;
|
|
123
129
|
}
|
|
124
|
-
|
|
130
|
+
// Prompts/completions (ai.prompt, gen_ai.prompt, …) land here: scrub
|
|
131
|
+
// before the size cap so a secret straddling it can't half-survive.
|
|
132
|
+
const safe = isUsageCount(key, value)
|
|
133
|
+
? value
|
|
134
|
+
: isSensitiveKey(key)
|
|
135
|
+
? "[redacted]"
|
|
136
|
+
: scrubText(value);
|
|
137
|
+
const trimmed = safe.length > 500 ? `${safe.slice(0, 500)}…` : safe;
|
|
125
138
|
size += key.length + trimmed.length;
|
|
126
139
|
if (size > 4000)
|
|
127
140
|
break;
|
|
@@ -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
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type OtlpAttribute, type OtlpValue } from "./context.js";
|
|
2
|
+
import type { RuntimeOccurrenceInput } from "./types.js";
|
|
3
|
+
export interface RuntimeLogRecord {
|
|
4
|
+
id: string;
|
|
5
|
+
service: string;
|
|
6
|
+
environment: string;
|
|
7
|
+
release: string;
|
|
8
|
+
traceId: string;
|
|
9
|
+
spanId: string;
|
|
10
|
+
operationId: string;
|
|
11
|
+
operation: string;
|
|
12
|
+
type: "log" | "operation";
|
|
13
|
+
severity: string;
|
|
14
|
+
message: string;
|
|
15
|
+
outcome: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
attributes: Record<string, unknown>;
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
export interface OtlpLogsRequest {
|
|
43
|
+
resourceLogs?: Array<{
|
|
44
|
+
resource?: {
|
|
45
|
+
attributes?: OtlpAttribute[];
|
|
46
|
+
};
|
|
47
|
+
scopeLogs?: Array<{
|
|
48
|
+
scope?: {
|
|
49
|
+
name?: string;
|
|
50
|
+
};
|
|
51
|
+
logRecords?: Array<{
|
|
52
|
+
timeUnixNano?: string | number;
|
|
53
|
+
observedTimeUnixNano?: string | number;
|
|
54
|
+
severityNumber?: number;
|
|
55
|
+
severityText?: string;
|
|
56
|
+
traceId?: string;
|
|
57
|
+
spanId?: string;
|
|
58
|
+
body?: OtlpValue;
|
|
59
|
+
attributes?: OtlpAttribute[];
|
|
60
|
+
}>;
|
|
61
|
+
}>;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
export declare function normalizeLogs(request: OtlpLogsRequest): RuntimeLogRecord[];
|
|
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
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { decodeOtlpAttributes, sanitizeRuntimeContext, } from "./context.js";
|
|
3
|
+
import { declaredLink, liftErrorFields } from "./error-fields.js";
|
|
4
|
+
import { normalizeRoute } from "./fingerprint.js";
|
|
5
|
+
export function normalizeLogs(request) {
|
|
6
|
+
if (!request || !Array.isArray(request.resourceLogs))
|
|
7
|
+
throw new Error("resourceLogs must be an array");
|
|
8
|
+
const rows = [];
|
|
9
|
+
if (request.resourceLogs.length > 128)
|
|
10
|
+
throw new Error("too many log resources");
|
|
11
|
+
for (const resource of request.resourceLogs) {
|
|
12
|
+
if (!resource ||
|
|
13
|
+
(resource.scopeLogs && !Array.isArray(resource.scopeLogs)) ||
|
|
14
|
+
(resource.scopeLogs?.length ?? 0) > 128)
|
|
15
|
+
throw new Error("invalid log scopes");
|
|
16
|
+
const info = decodeOtlpAttributes(resource.resource?.attributes);
|
|
17
|
+
for (const scope of resource.scopeLogs ?? []) {
|
|
18
|
+
if (!scope || (scope.logRecords && !Array.isArray(scope.logRecords)))
|
|
19
|
+
throw new Error("invalid log records");
|
|
20
|
+
for (const record of scope.logRecords ?? []) {
|
|
21
|
+
if (!record || typeof record !== "object")
|
|
22
|
+
throw new Error("invalid log record");
|
|
23
|
+
if (rows.length >= 2000)
|
|
24
|
+
throw new Error("too many log records");
|
|
25
|
+
const attrs = decodeOtlpAttributes(record.attributes);
|
|
26
|
+
let event = {};
|
|
27
|
+
const decodedBody = decodeOtlpAttributes([
|
|
28
|
+
{ key: "event", value: record.body },
|
|
29
|
+
]).event;
|
|
30
|
+
const body = typeof decodedBody === "string"
|
|
31
|
+
? decodedBody
|
|
32
|
+
: decodedBody == null
|
|
33
|
+
? ""
|
|
34
|
+
: JSON.stringify(decodedBody);
|
|
35
|
+
if (decodedBody &&
|
|
36
|
+
typeof decodedBody === "object" &&
|
|
37
|
+
!Array.isArray(decodedBody))
|
|
38
|
+
event = sanitizeRuntimeContext(decodedBody);
|
|
39
|
+
const metadata = Object.fromEntries(Object.entries(attrs).filter(([key]) => /^autter\.(?:operation|event)\./.test(key)));
|
|
40
|
+
const attributes = sanitizeRuntimeContext({
|
|
41
|
+
...metadata,
|
|
42
|
+
...event,
|
|
43
|
+
...attrs,
|
|
44
|
+
});
|
|
45
|
+
const nanos = record.timeUnixNano ?? record.observedTimeUnixNano;
|
|
46
|
+
const occurredAt = nanos === undefined
|
|
47
|
+
? new Date()
|
|
48
|
+
: new Date(Number(BigInt(String(nanos)) / 1000000n));
|
|
49
|
+
if (!Number.isFinite(occurredAt.getTime()))
|
|
50
|
+
throw new Error("invalid log timestamp");
|
|
51
|
+
const number = Number(record.severityNumber ?? 9);
|
|
52
|
+
const text = String(record.severityText ?? event.level ?? "").toLowerCase();
|
|
53
|
+
const severity = number >= 21 || text === "fatal"
|
|
54
|
+
? "fatal"
|
|
55
|
+
: number >= 17 || text === "error"
|
|
56
|
+
? "error"
|
|
57
|
+
: number >= 13 || /warn/.test(text)
|
|
58
|
+
? "warning"
|
|
59
|
+
: number < 9 || text === "debug"
|
|
60
|
+
? "debug"
|
|
61
|
+
: "info";
|
|
62
|
+
const errorMessage = event.error && typeof event.error === "object"
|
|
63
|
+
? event.error.message
|
|
64
|
+
: event.error;
|
|
65
|
+
const message = String(sanitizeRuntimeContext({
|
|
66
|
+
message: event.message ?? errorMessage ?? body,
|
|
67
|
+
}).message ?? "").slice(0, 4000);
|
|
68
|
+
const operationId = String(attributes["autter.operation.id"] ?? "").slice(0, 128);
|
|
69
|
+
const traceId = /^[a-f0-9]{32}$/i.test(record.traceId ?? "")
|
|
70
|
+
? record.traceId.toLowerCase().replace(/^0+$/, "")
|
|
71
|
+
: "";
|
|
72
|
+
const spanId = /^[a-f0-9]{16}$/i.test(record.spanId ?? "")
|
|
73
|
+
? record.spanId.toLowerCase().replace(/^0+$/, "")
|
|
74
|
+
: "";
|
|
75
|
+
if (typeof attributes.path === "string")
|
|
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;
|
|
127
|
+
rows.push({
|
|
128
|
+
id: createHash("sha256")
|
|
129
|
+
.update(JSON.stringify([
|
|
130
|
+
info,
|
|
131
|
+
nanos,
|
|
132
|
+
traceId,
|
|
133
|
+
spanId,
|
|
134
|
+
severity,
|
|
135
|
+
attributes,
|
|
136
|
+
message,
|
|
137
|
+
]))
|
|
138
|
+
.digest("hex")
|
|
139
|
+
.slice(0, 32),
|
|
140
|
+
service,
|
|
141
|
+
environment,
|
|
142
|
+
release,
|
|
143
|
+
traceId,
|
|
144
|
+
spanId,
|
|
145
|
+
operationId,
|
|
146
|
+
operation: String(attributes["autter.operation.name"] ?? "").slice(0, 200),
|
|
147
|
+
type,
|
|
148
|
+
severity,
|
|
149
|
+
message,
|
|
150
|
+
outcome: String(attributes["autter.operation.outcome"] ?? "").slice(0, 30),
|
|
151
|
+
durationMs: Math.max(0, Number(attributes["autter.operation.duration_ms"]) || 0),
|
|
152
|
+
attributes,
|
|
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 } : {}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return rows;
|
|
171
|
+
}
|
|
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) {
|
|
189
|
+
return `CREATE TABLE IF NOT EXISTS ${db}.runtime_logs (
|
|
190
|
+
org_id String, repository_id String, event_id String,
|
|
191
|
+
service LowCardinality(String), environment LowCardinality(String), release String DEFAULT '',
|
|
192
|
+
trace_id String DEFAULT '', span_id String DEFAULT '', operation_id String DEFAULT '',
|
|
193
|
+
operation String DEFAULT '', event_type LowCardinality(String) DEFAULT 'log',
|
|
194
|
+
severity LowCardinality(String) DEFAULT 'info', message String CODEC(ZSTD(1)),
|
|
195
|
+
outcome LowCardinality(String) DEFAULT '', duration_ms Float64 DEFAULT 0,
|
|
196
|
+
attributes String DEFAULT '{}' CODEC(ZSTD(1)), occurred_at DateTime64(3, 'UTC'),
|
|
197
|
+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
|
|
198
|
+
${LOG_REQUEST_COLUMNS.map((column) => column.ddl).join(", ")},
|
|
199
|
+
${LOG_REQUEST_ID_INDEX}
|
|
200
|
+
) ENGINE = ReplacingMergeTree PARTITION BY toDate(occurred_at)
|
|
201
|
+
ORDER BY (org_id, repository_id, occurred_at, event_id)
|
|
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`;
|
|
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,7 +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 { LOG_REQUEST_COLUMNS, LOG_REQUEST_ID_INDEX, logTableDDL, requestRollupTableDDL, requestRollupViewDDL, } from "./logs.js";
|
|
38
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";
|
|
39
52
|
export const MIGRATIONS = [
|
|
40
53
|
// 0001 intentionally reserved as the baseline marker: databases created
|
|
41
54
|
// before the migration runner existed record it as applied without
|
|
@@ -127,6 +140,37 @@ export const MIGRATIONS = [
|
|
|
127
140
|
{ id: "0010-memory-temporality", statements: [
|
|
128
141
|
`ALTER TABLE {db}.runtime_memory_samples ADD COLUMN IF NOT EXISTS temporality LowCardinality(String) DEFAULT 'gauge'`,
|
|
129
142
|
] },
|
|
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}")] },
|
|
130
174
|
];
|
|
131
175
|
/** The tracking table itself — created by the runner before anything else. */
|
|
132
176
|
export function migrationsTableDDL(db) {
|