@autter/otlp-ingester 1.0.0 → 1.2.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 +23 -2
- package/dist/clickhouse.d.ts +2 -1
- package/dist/clickhouse.js +62 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -0
- package/dist/fingerprint.d.ts +22 -0
- package/dist/fingerprint.js +216 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -1
- package/dist/llm-pricing.d.ts +20 -0
- package/dist/llm-pricing.js +120 -0
- package/dist/llm.d.ts +20 -0
- package/dist/llm.js +131 -0
- package/dist/migrations.js +37 -0
- package/dist/normalize-browser.js +41 -9
- package/dist/normalize-otlp.d.ts +3 -1
- package/dist/normalize-otlp.js +79 -5
- package/dist/otlp-proto.js +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.js +42 -43
- package/dist/sink.d.ts +103 -0
- package/dist/sink.js +314 -0
- package/dist/stack-fixtures.d.ts +25 -0
- package/dist/stack-fixtures.js +231 -0
- package/dist/types.d.ts +33 -0
- package/package.json +3 -2
package/dist/llm.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { estimateLlmCostUsd } from "./llm-pricing.js";
|
|
2
|
+
/**
|
|
3
|
+
* LLM-call extraction from OTLP spans. Three attribute families are
|
|
4
|
+
* recognised, so most stacks work with zero Autter-specific code:
|
|
5
|
+
*
|
|
6
|
+
* 1. OpenTelemetry GenAI semantic conventions (`gen_ai.*`) — emitted by
|
|
7
|
+
* OpenLLMetry, OpenLIT, the official OTel instrumentations, and
|
|
8
|
+
* @autter/runtime-node's own `withLlmCall`/`trackLlmCall` helpers.
|
|
9
|
+
* 2. Vercel AI SDK telemetry (`ai.*` spans from `experimental_telemetry`).
|
|
10
|
+
* Only the provider-level `.doGenerate`/`.doStream`/`.doEmbed` spans
|
|
11
|
+
* become calls — the umbrella `ai.generateText` span aggregates its
|
|
12
|
+
* children's usage and would double-count.
|
|
13
|
+
* 3. Autter extension attributes (`autter.llm.cost_usd`, `autter.user_id`,
|
|
14
|
+
* `autter.session_id`) for exact costs and user attribution.
|
|
15
|
+
*
|
|
16
|
+
* Cost precedence: reported (`autter.llm.cost_usd`/`gen_ai.usage.cost`) →
|
|
17
|
+
* estimated from the built-in pricing table → 0 with costSource "none".
|
|
18
|
+
*/
|
|
19
|
+
const VERCEL_PROVIDER_CALL = /\.do(Generate|Stream|Embed)$/;
|
|
20
|
+
function numAttr(attrs, ...keys) {
|
|
21
|
+
for (const key of keys) {
|
|
22
|
+
const raw = attrs.get(key);
|
|
23
|
+
if (raw === undefined)
|
|
24
|
+
continue;
|
|
25
|
+
const value = Number(raw);
|
|
26
|
+
if (Number.isFinite(value))
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function strAttr(attrs, ...keys) {
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
const raw = attrs.get(key);
|
|
34
|
+
if (raw !== undefined && raw !== "")
|
|
35
|
+
return raw;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** "ai.generateText.doGenerate" → "generateText"; "ai.embed.doEmbed" → "embed". */
|
|
40
|
+
function vercelOperation(name) {
|
|
41
|
+
const parts = name.split(".");
|
|
42
|
+
return parts.length >= 2 ? (parts[1] ?? "") : "";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Returns the LLM-call row for a span, or null when the span is not an LLM
|
|
46
|
+
* provider call. Never throws — attribute soup from arbitrary SDKs must not
|
|
47
|
+
* take down the ingest path.
|
|
48
|
+
*/
|
|
49
|
+
export function extractLlmCall(attrs, facts) {
|
|
50
|
+
const isVercelSpan = facts.name.startsWith("ai.");
|
|
51
|
+
// Umbrella Vercel spans (ai.generateText, ai.streamText, ai.toolCall, …)
|
|
52
|
+
// are skipped: usage lives on (and aggregates) their .do* children.
|
|
53
|
+
if (isVercelSpan && !VERCEL_PROVIDER_CALL.test(facts.name))
|
|
54
|
+
return null;
|
|
55
|
+
const model = strAttr(attrs, "gen_ai.response.model", "gen_ai.request.model", "ai.response.model", "ai.model.id");
|
|
56
|
+
// gen_ai.provider.name is the current semconv key, gen_ai.system the
|
|
57
|
+
// pre-1.37 one. Vercel's ai.model.provider looks like "openai.chat" —
|
|
58
|
+
// keep the provider segment, the mode is already in `operation`.
|
|
59
|
+
const provider = strAttr(attrs, "gen_ai.provider.name", "gen_ai.system") ??
|
|
60
|
+
strAttr(attrs, "ai.model.provider")?.toLowerCase().split(".")[0] ??
|
|
61
|
+
null;
|
|
62
|
+
// A call must identify at least a model or a provider system — this is
|
|
63
|
+
// what keeps ordinary spans (HTTP, DB, tool calls) out of the table.
|
|
64
|
+
if (!model && !provider)
|
|
65
|
+
return null;
|
|
66
|
+
const inputTokens = numAttr(attrs, "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "ai.usage.promptTokens", "ai.usage.inputTokens",
|
|
67
|
+
// Vercel .doEmbed spans report a single total under ai.usage.tokens.
|
|
68
|
+
"ai.usage.tokens") ?? 0;
|
|
69
|
+
const outputTokens = numAttr(attrs, "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens", "ai.usage.completionTokens", "ai.usage.outputTokens") ?? 0;
|
|
70
|
+
const reportedCost = numAttr(attrs, "autter.llm.cost_usd", "gen_ai.usage.cost", "ai.usage.cost");
|
|
71
|
+
let costUsd = 0;
|
|
72
|
+
let costSource = "none";
|
|
73
|
+
if (reportedCost !== null && reportedCost >= 0) {
|
|
74
|
+
costUsd = reportedCost;
|
|
75
|
+
costSource = "reported";
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const estimated = estimateLlmCostUsd(model ?? "", inputTokens, outputTokens);
|
|
79
|
+
if (estimated !== null) {
|
|
80
|
+
costUsd = estimated;
|
|
81
|
+
costSource = "estimated";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const operation = strAttr(attrs, "gen_ai.operation.name") ??
|
|
85
|
+
(isVercelSpan ? vercelOperation(facts.name) : "");
|
|
86
|
+
return {
|
|
87
|
+
service: facts.service,
|
|
88
|
+
environment: facts.environment,
|
|
89
|
+
release: facts.release,
|
|
90
|
+
traceId: facts.traceId,
|
|
91
|
+
spanId: facts.spanId,
|
|
92
|
+
provider: (provider ?? "").slice(0, 100),
|
|
93
|
+
model: (model ?? "").slice(0, 200),
|
|
94
|
+
operation: operation.slice(0, 100),
|
|
95
|
+
inputTokens: Math.max(0, Math.round(inputTokens)),
|
|
96
|
+
outputTokens: Math.max(0, Math.round(outputTokens)),
|
|
97
|
+
costUsd,
|
|
98
|
+
costSource,
|
|
99
|
+
durationMs: facts.durationMs,
|
|
100
|
+
status: facts.isError ? "error" : "ok",
|
|
101
|
+
errorType: facts.isError
|
|
102
|
+
? (strAttr(attrs, "error.type") ?? facts.exceptionType ?? "Error").slice(0, 200)
|
|
103
|
+
: "",
|
|
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),
|
|
106
|
+
attributes: llmAttributeSubset(attrs),
|
|
107
|
+
startedAt: facts.startedAt,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Drill-down context stored as JSON alongside the typed columns: only the
|
|
112
|
+
* LLM-relevant attribute families, size-capped so a prompt accidentally
|
|
113
|
+
* stuffed into an attribute can't bloat the row.
|
|
114
|
+
*/
|
|
115
|
+
function llmAttributeSubset(attrs) {
|
|
116
|
+
const subset = {};
|
|
117
|
+
let size = 0;
|
|
118
|
+
for (const [key, value] of attrs) {
|
|
119
|
+
if (!key.startsWith("gen_ai.") &&
|
|
120
|
+
!key.startsWith("ai.") &&
|
|
121
|
+
!key.startsWith("autter.llm.")) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const trimmed = value.length > 500 ? `${value.slice(0, 500)}…` : value;
|
|
125
|
+
size += key.length + trimmed.length;
|
|
126
|
+
if (size > 4000)
|
|
127
|
+
break;
|
|
128
|
+
subset[key] = trimmed;
|
|
129
|
+
}
|
|
130
|
+
return Object.keys(subset).length > 0 ? subset : null;
|
|
131
|
+
}
|
package/dist/migrations.js
CHANGED
|
@@ -76,6 +76,43 @@ export const MIGRATIONS = [
|
|
|
76
76
|
MODIFY COLUMN attributes String DEFAULT '{}' CODEC(ZSTD(1))`,
|
|
77
77
|
],
|
|
78
78
|
},
|
|
79
|
+
// LLM observability: one row per provider call (model, tokens, cost,
|
|
80
|
+
// latency, user) extracted from GenAI/Vercel-AI/Autter span attributes.
|
|
81
|
+
// TTL is fixed at the 90-day default here; deployments that override
|
|
82
|
+
// LLM_CALL_TTL_DAYS get it applied on fresh databases via the baseline.
|
|
83
|
+
{
|
|
84
|
+
id: "0004-llm-calls",
|
|
85
|
+
statements: [
|
|
86
|
+
`CREATE TABLE IF NOT EXISTS {db}.runtime_llm_calls (
|
|
87
|
+
org_id String,
|
|
88
|
+
repository_id String,
|
|
89
|
+
service LowCardinality(String),
|
|
90
|
+
environment LowCardinality(String),
|
|
91
|
+
release String DEFAULT '',
|
|
92
|
+
trace_id String DEFAULT '',
|
|
93
|
+
span_id String DEFAULT '',
|
|
94
|
+
provider LowCardinality(String) DEFAULT '',
|
|
95
|
+
model LowCardinality(String) DEFAULT '',
|
|
96
|
+
operation LowCardinality(String) DEFAULT '',
|
|
97
|
+
input_tokens UInt64 DEFAULT 0,
|
|
98
|
+
output_tokens UInt64 DEFAULT 0,
|
|
99
|
+
cost_usd Float64 DEFAULT 0,
|
|
100
|
+
cost_source LowCardinality(String) DEFAULT 'none',
|
|
101
|
+
duration_ms Float64 DEFAULT 0,
|
|
102
|
+
status LowCardinality(String) DEFAULT 'ok',
|
|
103
|
+
error_type String DEFAULT '',
|
|
104
|
+
user_id String DEFAULT '',
|
|
105
|
+
session_id String DEFAULT '',
|
|
106
|
+
attributes String DEFAULT '{}' CODEC(ZSTD(1)),
|
|
107
|
+
started_at DateTime64(3, 'UTC'),
|
|
108
|
+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
109
|
+
)
|
|
110
|
+
ENGINE = MergeTree
|
|
111
|
+
PARTITION BY toDate(started_at)
|
|
112
|
+
ORDER BY (org_id, repository_id, started_at)
|
|
113
|
+
TTL toDateTime(started_at) + INTERVAL 90 DAY`,
|
|
114
|
+
],
|
|
115
|
+
},
|
|
79
116
|
];
|
|
80
117
|
/** The tracking table itself — created by the runner before anything else. */
|
|
81
118
|
export function migrationsTableDDL(db) {
|
|
@@ -43,6 +43,27 @@ const TYPE_TO_ERROR_TYPE = {
|
|
|
43
43
|
unhandled_rejection: "UnhandledRejection",
|
|
44
44
|
message: "Message",
|
|
45
45
|
};
|
|
46
|
+
// Content-level gate for the free-form `context` bag. The schema whitelist
|
|
47
|
+
// above is structural; this masks obvious PII/secrets inside whatever a
|
|
48
|
+
// (possibly outdated) SDK still sends: values under sensitive-looking keys
|
|
49
|
+
// and email-shaped strings — mirroring redactAttributes() in
|
|
50
|
+
// @autter/runtime-node and redactContext() in @autter/runtime-browser.
|
|
51
|
+
const SENSITIVE_KEY_RE = /email|pass|token|secret|^auth([-_.]|$)|authorization|bearer|cookie|credential|api[-_.]?key|ssn|cvv|card([-_. ]?(number|num|no))?$/i;
|
|
52
|
+
const EMAIL_VALUE_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
53
|
+
const REDACTED = "[redacted]";
|
|
54
|
+
function scrubContext(context) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [key, value] of Object.entries(context)) {
|
|
57
|
+
if (value === undefined || value === null)
|
|
58
|
+
continue;
|
|
59
|
+
out[key] = SENSITIVE_KEY_RE.test(key)
|
|
60
|
+
? REDACTED
|
|
61
|
+
: typeof value === "string"
|
|
62
|
+
? value.replace(EMAIL_VALUE_RE, REDACTED)
|
|
63
|
+
: value;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
46
67
|
/** Default severity per event type when the SDK doesn't say. */
|
|
47
68
|
const TYPE_TO_SEVERITY = {
|
|
48
69
|
exception: "error",
|
|
@@ -52,12 +73,14 @@ const TYPE_TO_SEVERITY = {
|
|
|
52
73
|
export function normalizeBrowserPayload(payload) {
|
|
53
74
|
const occurrences = [];
|
|
54
75
|
const rollups = new Map();
|
|
55
|
-
function bumpRollup(route, occurredAt,
|
|
76
|
+
function bumpRollup(route, occurredAt, counts) {
|
|
56
77
|
const bucketAt = new Date(Math.floor(occurredAt.getTime() / 60_000) * 60_000);
|
|
57
78
|
const key = `${route} ${bucketAt.getTime()}`;
|
|
58
79
|
const existing = rollups.get(key);
|
|
59
80
|
if (existing) {
|
|
60
|
-
existing
|
|
81
|
+
existing.requestCount += counts.requestCount ?? 0;
|
|
82
|
+
existing.errorCount += counts.errorCount ?? 0;
|
|
83
|
+
existing.sessionCount += counts.sessionCount ?? 0;
|
|
61
84
|
return;
|
|
62
85
|
}
|
|
63
86
|
rollups.set(key, {
|
|
@@ -66,16 +89,16 @@ export function normalizeBrowserPayload(payload) {
|
|
|
66
89
|
release: payload.release ?? null,
|
|
67
90
|
route,
|
|
68
91
|
bucketAt,
|
|
69
|
-
requestCount:
|
|
70
|
-
errorCount: 0,
|
|
92
|
+
requestCount: counts.requestCount ?? 0,
|
|
93
|
+
errorCount: counts.errorCount ?? 0,
|
|
71
94
|
durationSumMs: 0,
|
|
72
|
-
sessionCount:
|
|
95
|
+
sessionCount: counts.sessionCount ?? 0,
|
|
73
96
|
});
|
|
74
97
|
}
|
|
75
98
|
for (const event of payload.events) {
|
|
76
99
|
const occurredAt = new Date(event.timestamp);
|
|
77
100
|
if (event.type === "session_start") {
|
|
78
|
-
bumpRollup("", occurredAt,
|
|
101
|
+
bumpRollup("", occurredAt, { sessionCount: 1 });
|
|
79
102
|
continue;
|
|
80
103
|
}
|
|
81
104
|
// Coarse usage counters: track_event("checkout_opened") becomes a
|
|
@@ -83,12 +106,21 @@ export function normalizeBrowserPayload(payload) {
|
|
|
83
106
|
if (event.type === "track_event") {
|
|
84
107
|
const name = (event.name ?? event.message ?? "").slice(0, 200);
|
|
85
108
|
if (name)
|
|
86
|
-
bumpRollup(`event:${name}`, occurredAt,
|
|
109
|
+
bumpRollup(`event:${name}`, occurredAt, { requestCount: 1 });
|
|
87
110
|
continue;
|
|
88
111
|
}
|
|
112
|
+
const severity = asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error");
|
|
113
|
+
// The browser has no request stream, so error/message events are the
|
|
114
|
+
// only usage signal for many pages — count each as an event (and, for
|
|
115
|
+
// fatal/error severity, an error event). Without this, a page that
|
|
116
|
+
// only reports errors groups issues whose dashboards all read 0.
|
|
117
|
+
bumpRollup(event.route ? (event.route.split("?")[0] ?? "") : "", occurredAt, {
|
|
118
|
+
requestCount: 1,
|
|
119
|
+
errorCount: severity === "fatal" || severity === "error" ? 1 : 0,
|
|
120
|
+
});
|
|
89
121
|
occurrences.push({
|
|
90
122
|
source: "browser",
|
|
91
|
-
severity
|
|
123
|
+
severity,
|
|
92
124
|
service: payload.service,
|
|
93
125
|
environment: payload.environment,
|
|
94
126
|
release: payload.release ?? null,
|
|
@@ -104,7 +136,7 @@ export function normalizeBrowserPayload(payload) {
|
|
|
104
136
|
...(event.filename ? { filename: event.filename.split("?")[0] } : {}),
|
|
105
137
|
...(event.line !== undefined ? { line: event.line } : {}),
|
|
106
138
|
...(event.column !== undefined ? { column: event.column } : {}),
|
|
107
|
-
...(event.context ? { context: event.context } : {}),
|
|
139
|
+
...(event.context ? { context: scrubContext(event.context) } : {}),
|
|
108
140
|
},
|
|
109
141
|
occurredAt,
|
|
110
142
|
});
|
package/dist/normalize-otlp.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RuntimeMetricPoint, type RuntimeOccurrenceInput, type RuntimeSpanRow } from "./types.js";
|
|
1
|
+
import { type RuntimeLlmCall, type RuntimeMetricPoint, type RuntimeOccurrenceInput, type RuntimeSpanRow } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* OTLP/HTTP JSON → runtime signal. Structural types cover only the fields
|
|
4
4
|
* we read (the full OTLP schema is large and versioned; unknown fields pass
|
|
@@ -65,6 +65,7 @@ export interface OtlpMetricsRequest {
|
|
|
65
65
|
unit?: string;
|
|
66
66
|
histogram?: {
|
|
67
67
|
dataPoints?: OtlpDataPoint[];
|
|
68
|
+
aggregationTemporality?: string | number;
|
|
68
69
|
};
|
|
69
70
|
sum?: {
|
|
70
71
|
dataPoints?: OtlpDataPoint[];
|
|
@@ -77,6 +78,7 @@ export interface NormalizedTraces {
|
|
|
77
78
|
occurrences: RuntimeOccurrenceInput[];
|
|
78
79
|
spans: RuntimeSpanRow[];
|
|
79
80
|
metricPoints: RuntimeMetricPoint[];
|
|
81
|
+
llmCalls: RuntimeLlmCall[];
|
|
80
82
|
spanCount: number;
|
|
81
83
|
}
|
|
82
84
|
export declare function normalizeTraces(request: OtlpTraceRequest): NormalizedTraces;
|
package/dist/normalize-otlp.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeRoute } from "./fingerprint.js";
|
|
2
|
+
import { extractLlmCall } from "./llm.js";
|
|
1
3
|
import { asSeverity, } from "./types.js";
|
|
2
4
|
function attrMap(attributes) {
|
|
3
5
|
const map = new Map();
|
|
@@ -54,6 +56,7 @@ function resourceInfo(resource) {
|
|
|
54
56
|
attrs.get("deployment.environment") ??
|
|
55
57
|
"production",
|
|
56
58
|
release: attrs.get("service.version") ?? null,
|
|
59
|
+
metricsWired: attrs.get("autter.metrics_wired") === "true",
|
|
57
60
|
};
|
|
58
61
|
}
|
|
59
62
|
function routeOf(attrs) {
|
|
@@ -93,6 +96,7 @@ const MAX_SPANS_PER_REQUEST = 5000;
|
|
|
93
96
|
export function normalizeTraces(request) {
|
|
94
97
|
const occurrences = [];
|
|
95
98
|
const spans = [];
|
|
99
|
+
const llmCalls = [];
|
|
96
100
|
const rollups = new Map();
|
|
97
101
|
let spanCount = 0;
|
|
98
102
|
for (const resourceSpan of request.resourceSpans ?? []) {
|
|
@@ -128,6 +132,7 @@ export function normalizeTraces(request) {
|
|
|
128
132
|
});
|
|
129
133
|
// Error occurrences: one per exception event; if the span is
|
|
130
134
|
// errored without exception events, one from the span status.
|
|
135
|
+
const occurrencesBefore = occurrences.length;
|
|
131
136
|
const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
|
|
132
137
|
for (const event of exceptionEvents) {
|
|
133
138
|
const eventAttrs = attrMap(event.attributes);
|
|
@@ -171,14 +176,39 @@ export function normalizeTraces(request) {
|
|
|
171
176
|
occurredAt: startedAt,
|
|
172
177
|
});
|
|
173
178
|
}
|
|
179
|
+
// LLM provider calls (GenAI semconv, Vercel AI SDK telemetry,
|
|
180
|
+
// or Autter's own helpers) become usage/cost rows. The first
|
|
181
|
+
// exception event's type rides along so failed calls keep the
|
|
182
|
+
// provider error class even without an `error.type` attribute.
|
|
183
|
+
const llmCall = extractLlmCall(attrs, {
|
|
184
|
+
name: span.name ?? "",
|
|
185
|
+
traceId: span.traceId ?? "",
|
|
186
|
+
spanId: span.spanId ?? "",
|
|
187
|
+
isError,
|
|
188
|
+
exceptionType: exceptionEvents[0]
|
|
189
|
+
? (attrMap(exceptionEvents[0].attributes).get("exception.type") ??
|
|
190
|
+
null)
|
|
191
|
+
: null,
|
|
192
|
+
durationMs,
|
|
193
|
+
startedAt,
|
|
194
|
+
service: resource.service,
|
|
195
|
+
environment: resource.environment,
|
|
196
|
+
release: resource.release,
|
|
197
|
+
});
|
|
198
|
+
if (llmCall)
|
|
199
|
+
llmCalls.push(llmCall);
|
|
174
200
|
// Server spans fold into 1-minute usage rollups so traffic is
|
|
175
|
-
// tracked even when the metrics pipeline isn't wired.
|
|
176
|
-
|
|
201
|
+
// tracked even when the metrics pipeline isn't wired. Spans
|
|
202
|
+
// exported by error-linked tail retention are skipped: the SDK
|
|
203
|
+
// ships those at ~100% alongside a metrics pipeline that already
|
|
204
|
+
// counts every request, so folding them in would double-count
|
|
205
|
+
// erroring routes.
|
|
206
|
+
if (kind === "server" && attrs.get("autter.tail_retained") !== "true") {
|
|
177
207
|
addToRollup(rollups, {
|
|
178
208
|
service: resource.service,
|
|
179
209
|
environment: resource.environment,
|
|
180
210
|
release: resource.release,
|
|
181
|
-
route: route
|
|
211
|
+
route: normalizeRoute(route),
|
|
182
212
|
bucketAt: minuteBucket(startedAt),
|
|
183
213
|
requestCount: 1,
|
|
184
214
|
errorCount: isError ? 1 : 0,
|
|
@@ -186,10 +216,37 @@ export function normalizeTraces(request) {
|
|
|
186
216
|
sessionCount: 0,
|
|
187
217
|
});
|
|
188
218
|
}
|
|
219
|
+
else {
|
|
220
|
+
// Occurrences on non-server spans (captureException's internal
|
|
221
|
+
// error spans, workers, consumers) have no request rollup to
|
|
222
|
+
// ride — count each as one event / one error event, or a
|
|
223
|
+
// service whose errors arrive outside HTTP handlers groups
|
|
224
|
+
// issues while every event counter stays 0. Server spans are
|
|
225
|
+
// excluded: their request rollup above already represents them.
|
|
226
|
+
for (const occ of occurrences.slice(occurrencesBefore)) {
|
|
227
|
+
addToRollup(rollups, {
|
|
228
|
+
service: occ.service,
|
|
229
|
+
environment: occ.environment,
|
|
230
|
+
release: occ.release,
|
|
231
|
+
route: occ.route ?? "",
|
|
232
|
+
bucketAt: minuteBucket(occ.occurredAt),
|
|
233
|
+
requestCount: 1,
|
|
234
|
+
errorCount: occ.severity === "fatal" || occ.severity === "error" ? 1 : 0,
|
|
235
|
+
durationSumMs: 0,
|
|
236
|
+
sessionCount: 0,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
189
240
|
}
|
|
190
241
|
}
|
|
191
242
|
}
|
|
192
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
occurrences,
|
|
245
|
+
spans,
|
|
246
|
+
metricPoints: [...rollups.values()],
|
|
247
|
+
llmCalls,
|
|
248
|
+
spanCount,
|
|
249
|
+
};
|
|
193
250
|
}
|
|
194
251
|
function minuteBucket(date) {
|
|
195
252
|
return new Date(Math.floor(date.getTime() / 60_000) * 60_000);
|
|
@@ -222,6 +279,16 @@ const HTTP_DURATION_INSTRUMENTS = {
|
|
|
222
279
|
"http.server.duration": 1,
|
|
223
280
|
"http.server.request.duration": 1000,
|
|
224
281
|
};
|
|
282
|
+
/**
|
|
283
|
+
* Cumulative histograms report lifetime totals on every export; adding them
|
|
284
|
+
* into a SummingMergeTree would re-count all past requests each interval.
|
|
285
|
+
* Only deltas are summable — cumulative senders are skipped and covered by
|
|
286
|
+
* the span-fed rollup fallback instead. (Enum arrives as a number or an
|
|
287
|
+
* `AGGREGATION_TEMPORALITY_*` string depending on the serialiser.)
|
|
288
|
+
*/
|
|
289
|
+
function isCumulativeTemporality(t) {
|
|
290
|
+
return t === 2 || t === "AGGREGATION_TEMPORALITY_CUMULATIVE";
|
|
291
|
+
}
|
|
225
292
|
export function normalizeMetrics(request) {
|
|
226
293
|
const rollups = new Map();
|
|
227
294
|
for (const resourceMetric of request.resourceMetrics ?? []) {
|
|
@@ -233,6 +300,9 @@ export function normalizeMetrics(request) {
|
|
|
233
300
|
: undefined;
|
|
234
301
|
if (multiplier === undefined)
|
|
235
302
|
continue;
|
|
303
|
+
if (isCumulativeTemporality(metric.histogram?.aggregationTemporality)) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
236
306
|
for (const dataPoint of metric.histogram?.dataPoints ?? []) {
|
|
237
307
|
const attrs = attrMap(dataPoint.attributes);
|
|
238
308
|
const statusCode = statusCodeOf(attrs);
|
|
@@ -243,7 +313,11 @@ export function normalizeMetrics(request) {
|
|
|
243
313
|
service: resource.service,
|
|
244
314
|
environment: resource.environment,
|
|
245
315
|
release: resource.release,
|
|
246
|
-
|
|
316
|
+
// Same normalization as the span-fed rollups: emitters
|
|
317
|
+
// are supposed to put route templates in `http.route`,
|
|
318
|
+
// but some put raw paths there — keep the key space
|
|
319
|
+
// bounded and consistent across both feeds.
|
|
320
|
+
route: normalizeRoute(routeOf(attrs)),
|
|
247
321
|
bucketAt: minuteBucket(nanosToDate(dataPoint.timeUnixNano)),
|
|
248
322
|
requestCount: count,
|
|
249
323
|
errorCount: statusCode !== null && statusCode >= 500 ? count : 0,
|
package/dist/otlp-proto.js
CHANGED
|
@@ -64,7 +64,7 @@ message NumberDataPoint {
|
|
|
64
64
|
sfixed64 as_int = 6;
|
|
65
65
|
repeated KeyValue attributes = 7;
|
|
66
66
|
}
|
|
67
|
-
message Histogram { repeated HistogramDataPoint data_points = 1; }
|
|
67
|
+
message Histogram { repeated HistogramDataPoint data_points = 1; int32 aggregation_temporality = 2; }
|
|
68
68
|
message HistogramDataPoint {
|
|
69
69
|
fixed64 time_unix_nano = 3;
|
|
70
70
|
fixed64 count = 4;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { type Express } from "express";
|
|
2
2
|
import { ClickHouseStore } from "./clickhouse.js";
|
|
3
3
|
import type { IngesterConfig } from "./config.js";
|
|
4
|
+
import { SinkForwarder } from "./sink.js";
|
|
4
5
|
export interface IngesterApp {
|
|
5
6
|
app: Express;
|
|
6
7
|
store: ClickHouseStore;
|
|
8
|
+
/** Present when AUTTER_SINK_URL is configured. */
|
|
9
|
+
sink: SinkForwarder | null;
|
|
7
10
|
}
|
|
8
11
|
export declare function createIngesterApp(config: IngesterConfig): IngesterApp;
|
package/dist/server.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import express from "express";
|
|
3
2
|
import { KeyResolver, RateLimiter } from "./auth.js";
|
|
4
3
|
import { ClickHouseStore } from "./clickhouse.js";
|
|
5
|
-
import { deriveFields, fingerprintOccurrence } from "./fingerprint.js";
|
|
4
|
+
import { deriveFields, fingerprintOccurrence, occurrenceIdFor, } from "./fingerprint.js";
|
|
6
5
|
import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
|
|
7
6
|
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
8
7
|
import { decodeMetricsRequest, decodeTraceRequest } from "./otlp-proto.js";
|
|
8
|
+
import { SinkForwarder } from "./sink.js";
|
|
9
9
|
export function createIngesterApp(config) {
|
|
10
10
|
const store = new ClickHouseStore(config);
|
|
11
|
+
// Fingerprinted occurrences feed the consumer's issue grouping, metric
|
|
12
|
+
// points feed the request/error-rate rollups, LLM calls feed spend
|
|
13
|
+
// watching. Delivery is at-least-once with bounded retries — see sink.ts.
|
|
14
|
+
const sink = config.sinkUrl ? new SinkForwarder(config) : null;
|
|
11
15
|
const keys = new KeyResolver(config);
|
|
12
16
|
const serverRateLimiter = new RateLimiter(config.rateLimitPerMinute);
|
|
13
17
|
const clientRateLimiter = new RateLimiter(config.clientRateLimitPerMinute);
|
|
@@ -46,16 +50,19 @@ export function createIngesterApp(config) {
|
|
|
46
50
|
next();
|
|
47
51
|
});
|
|
48
52
|
app.get("/healthz", async (_req, res) => {
|
|
53
|
+
const sinkStats = sink ? { sink: sink.stats() } : {};
|
|
49
54
|
if (!store.configured) {
|
|
50
|
-
res.status(200).json({ ok: true, clickhouse: "unconfigured" });
|
|
55
|
+
res.status(200).json({ ok: true, clickhouse: "unconfigured", ...sinkStats });
|
|
51
56
|
return;
|
|
52
57
|
}
|
|
53
58
|
try {
|
|
54
59
|
const ok = await store.ping();
|
|
55
|
-
res
|
|
60
|
+
res
|
|
61
|
+
.status(ok ? 200 : 503)
|
|
62
|
+
.json({ ok, clickhouse: ok ? "up" : "down", ...sinkStats });
|
|
56
63
|
}
|
|
57
64
|
catch {
|
|
58
|
-
res.status(503).json({ ok: false, clickhouse: "down" });
|
|
65
|
+
res.status(503).json({ ok: false, clickhouse: "down", ...sinkStats });
|
|
59
66
|
}
|
|
60
67
|
});
|
|
61
68
|
/** Auth + scope + rate limit; returns null (response sent) on failure. */
|
|
@@ -103,38 +110,20 @@ export function createIngesterApp(config) {
|
|
|
103
110
|
}
|
|
104
111
|
return ctx;
|
|
105
112
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
headers: {
|
|
121
|
-
"content-type": "application/json",
|
|
122
|
-
...(config.sinkToken
|
|
123
|
-
? { authorization: `Bearer ${config.sinkToken}` }
|
|
124
|
-
: {}),
|
|
125
|
-
},
|
|
126
|
-
body: JSON.stringify({
|
|
127
|
-
version: 1,
|
|
128
|
-
orgId: ctx.orgId,
|
|
129
|
-
repositoryId: ctx.repositoryId,
|
|
130
|
-
occurrences: occurrences.map((o) => ({
|
|
131
|
-
...o,
|
|
132
|
-
occurredAt: o.occurredAt.toISOString(),
|
|
133
|
-
})),
|
|
134
|
-
}),
|
|
135
|
-
signal: AbortSignal.timeout(10_000),
|
|
136
|
-
}).catch((err) => {
|
|
137
|
-
console.warn("sink forward failed (non-fatal):", err?.message ?? err);
|
|
113
|
+
/** Ids are content-derived (occurrenceIdFor), NOT random: an exporter
|
|
114
|
+
* that retries a batch — after a 503 from a partially-failed ClickHouse
|
|
115
|
+
* write, or when only our 2xx got lost — must produce the same ids, so
|
|
116
|
+
* the sink consumer's per-occurrence dedupe holds across transport
|
|
117
|
+
* retries and duplicated ClickHouse rows stay identifiable. */
|
|
118
|
+
function fingerprintAll(ctx, inputs) {
|
|
119
|
+
return inputs.map((input, index) => {
|
|
120
|
+
const fingerprint = fingerprintOccurrence(input);
|
|
121
|
+
return {
|
|
122
|
+
...input,
|
|
123
|
+
occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
|
|
124
|
+
fingerprint,
|
|
125
|
+
...deriveFields(input),
|
|
126
|
+
};
|
|
138
127
|
});
|
|
139
128
|
}
|
|
140
129
|
function storageError(res, err) {
|
|
@@ -167,20 +156,29 @@ export function createIngesterApp(config) {
|
|
|
167
156
|
else {
|
|
168
157
|
request = req.body;
|
|
169
158
|
}
|
|
170
|
-
const { occurrences, spans, metricPoints } = normalizeTraces(request);
|
|
171
|
-
const fingerprinted = fingerprintAll(occurrences);
|
|
159
|
+
const { occurrences, spans, metricPoints, llmCalls } = normalizeTraces(request);
|
|
160
|
+
const fingerprinted = fingerprintAll(ctx, occurrences);
|
|
161
|
+
// ClickHouse has no cross-table transaction, so these four inserts can
|
|
162
|
+
// partially commit. Recovery boundary: any failure → 503 → the exporter
|
|
163
|
+
// retries the whole batch. Deterministic occurrence ids make the retry
|
|
164
|
+
// idempotent downstream (consumer dedupes per id; duplicate ClickHouse
|
|
165
|
+
// rows share an id, and the consumer's reconciler counts distinct ids),
|
|
166
|
+
// and nothing reaches the sink queue unless every insert succeeded —
|
|
167
|
+
// signals persisted by a partial write are picked up by the consumer's
|
|
168
|
+
// ClickHouse reconciliation instead.
|
|
172
169
|
try {
|
|
173
170
|
await Promise.all([
|
|
174
171
|
store.insertOccurrences(ctx, fingerprinted),
|
|
175
172
|
store.insertSpans(ctx, spans),
|
|
176
173
|
store.insertMetricPoints(ctx, metricPoints),
|
|
174
|
+
store.insertLlmCalls(ctx, llmCalls),
|
|
177
175
|
]);
|
|
178
176
|
}
|
|
179
177
|
catch (err) {
|
|
180
178
|
storageError(res, err);
|
|
181
179
|
return;
|
|
182
180
|
}
|
|
183
|
-
|
|
181
|
+
sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
|
|
184
182
|
otlpSuccess(req, res);
|
|
185
183
|
});
|
|
186
184
|
app.post("/v1/metrics", async (req, res) => {
|
|
@@ -208,6 +206,7 @@ export function createIngesterApp(config) {
|
|
|
208
206
|
storageError(res, err);
|
|
209
207
|
return;
|
|
210
208
|
}
|
|
209
|
+
sink?.enqueue(ctx, [], metricPoints);
|
|
211
210
|
otlpSuccess(req, res);
|
|
212
211
|
});
|
|
213
212
|
app.post("/v1/browser", async (req, res) => {
|
|
@@ -234,7 +233,7 @@ export function createIngesterApp(config) {
|
|
|
234
233
|
return;
|
|
235
234
|
}
|
|
236
235
|
const { occurrences, metricPoints } = normalizeBrowserPayload(parsed.data);
|
|
237
|
-
const fingerprinted = fingerprintAll(occurrences);
|
|
236
|
+
const fingerprinted = fingerprintAll(ctx, occurrences);
|
|
238
237
|
try {
|
|
239
238
|
await Promise.all([
|
|
240
239
|
store.insertOccurrences(ctx, fingerprinted),
|
|
@@ -245,7 +244,7 @@ export function createIngesterApp(config) {
|
|
|
245
244
|
storageError(res, err);
|
|
246
245
|
return;
|
|
247
246
|
}
|
|
248
|
-
|
|
247
|
+
sink?.enqueue(ctx, fingerprinted, metricPoints);
|
|
249
248
|
res.status(202).json({ accepted: fingerprinted.length });
|
|
250
249
|
});
|
|
251
250
|
// Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
|
|
@@ -263,5 +262,5 @@ export function createIngesterApp(config) {
|
|
|
263
262
|
console.error("unhandled error:", err);
|
|
264
263
|
res.status(500).json({ error: "internal error" });
|
|
265
264
|
});
|
|
266
|
-
return { app, store };
|
|
265
|
+
return { app, store, sink };
|
|
267
266
|
}
|