@autter/otlp-ingester 0.1.0 → 1.1.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 +46 -0
- 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/types.d.ts +33 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ one per-repo signal model, fingerprints errors, and writes ClickHouse.
|
|
|
8
8
|
|
|
9
9
|
| Route | Payload | Purpose |
|
|
10
10
|
| --- | --- | --- |
|
|
11
|
-
| `POST /v1/traces` | OTLP/JSON `ExportTraceServiceRequest` | Error spans → occurrences; all spans → `runtime_spans`; server spans → usage rollups |
|
|
11
|
+
| `POST /v1/traces` | OTLP/JSON `ExportTraceServiceRequest` | Error spans → occurrences; all spans → `runtime_spans`; server spans → usage rollups; GenAI spans → `runtime_llm_calls` |
|
|
12
12
|
| `POST /v1/metrics` | OTLP/JSON `ExportMetricsServiceRequest` | HTTP-server duration histograms → usage rollups |
|
|
13
13
|
| `POST /v1/browser` | Browser payload `version: 1` | Errors/rejections → occurrences; session pings → rollups |
|
|
14
14
|
| `GET /healthz` | — | Liveness + ClickHouse reachability |
|
|
@@ -54,12 +54,33 @@ The validator webhook may return the same extra fields:
|
|
|
54
54
|
| `AUTTER_INGEST_KEYS` | — | JSON: `[{"key":"...","orgId":"...","repositoryId":"..."}]` |
|
|
55
55
|
| `AUTTER_KEY_VALIDATOR_URL` | — | Webhook: `POST {key}` → `{orgId, repositoryId}` (60 s cache) |
|
|
56
56
|
| `AUTTER_KEY_VALIDATOR_TOKEN` | — | Bearer token sent to the validator |
|
|
57
|
-
| `AUTTER_SINK_URL` | — |
|
|
57
|
+
| `AUTTER_SINK_URL` | — | Issue-grouping webhook; at-least-once (`docs/ARCHITECTURE.md`) |
|
|
58
58
|
| `AUTTER_SINK_TOKEN` | — | Bearer token sent to the sink |
|
|
59
|
+
| `SINK_MAX_ATTEMPTS` | `12` | Delivery attempts per batch (1–60 s backoff) |
|
|
60
|
+
| `SINK_MAX_BUFFERED_BATCHES` | `1000` | Retry buffer cap; oldest drops are logged |
|
|
61
|
+
| `SINK_MAX_BUFFERED_MB` | `64` | Sink retry buffer cap (memory) |
|
|
59
62
|
| `MAX_BODY_BYTES` | `1048576` | Request body cap |
|
|
60
63
|
| `RATE_LIMIT_PER_MINUTE` | `300` | Per-key fixed window (server keys) |
|
|
61
64
|
| `CLIENT_RATE_LIMIT_PER_MINUTE` | `120` | Per-key fixed window (client keys) |
|
|
62
65
|
| `OCCURRENCE_TTL_DAYS` / `SPAN_TTL_DAYS` / `METRICS_TTL_DAYS` | `14` / `7` / `90` | ClickHouse TTLs (applied at table creation) |
|
|
66
|
+
| `LLM_CALL_TTL_DAYS` | `90` | Retention for `runtime_llm_calls` rows |
|
|
67
|
+
|
|
68
|
+
## LLM / GenAI calls
|
|
69
|
+
|
|
70
|
+
Spans following the [OTel GenAI semconv](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
|
|
71
|
+
(`gen_ai.*` attributes — emitted by `withLlmCall` in `@autter/runtime-node`,
|
|
72
|
+
the Vercel AI SDK with telemetry enabled, and the GenAI instrumentations for
|
|
73
|
+
Python/Go/etc.) are recognised automatically on `/v1/traces` and additionally
|
|
74
|
+
stored per call in `runtime_llm_calls`: provider, model, operation, input/
|
|
75
|
+
output tokens, duration, ok/error status (with the provider exception type
|
|
76
|
+
in `error_type` for failed calls), and a USD cost. The cost is taken
|
|
77
|
+
from the `autter.llm.cost_usd` span attribute when reported; otherwise it's
|
|
78
|
+
estimated from the built-in price table in `src/llm-pricing.ts`
|
|
79
|
+
(`cost_source` records which: `reported` / `estimated` / `unpriced`). An
|
|
80
|
+
opaque calling-user id is read from `autter.user_id` (or the AI SDK's
|
|
81
|
+
`metadata.userId`). The Vercel AI SDK's outer `ai.generateText`/`ai.streamText`
|
|
82
|
+
spans are not counted — their inner `.doGenerate`/`.doEmbed` call spans carry
|
|
83
|
+
the usage, and counting both would double the tokens.
|
|
63
84
|
|
|
64
85
|
## Schema migrations
|
|
65
86
|
|
package/dist/clickhouse.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IngesterConfig } from "./config.js";
|
|
2
2
|
import { type Migration } from "./migrations.js";
|
|
3
|
-
import type { IngestContext, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
|
|
3
|
+
import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
|
|
4
4
|
export declare class ClickHouseStore {
|
|
5
5
|
private readonly config;
|
|
6
6
|
private client;
|
|
@@ -22,6 +22,7 @@ export declare class ClickHouseStore {
|
|
|
22
22
|
ping(): Promise<boolean>;
|
|
23
23
|
insertOccurrences(ctx: IngestContext, occurrences: RuntimeOccurrence[]): Promise<void>;
|
|
24
24
|
insertSpans(ctx: IngestContext, spans: RuntimeSpanRow[]): Promise<void>;
|
|
25
|
+
insertLlmCalls(ctx: IngestContext, calls: RuntimeLlmCall[]): Promise<void>;
|
|
25
26
|
insertMetricPoints(ctx: IngestContext, points: RuntimeMetricPoint[]): Promise<void>;
|
|
26
27
|
close(): Promise<void>;
|
|
27
28
|
}
|
package/dist/clickhouse.js
CHANGED
|
@@ -41,7 +41,7 @@ export class ClickHouseStore {
|
|
|
41
41
|
}
|
|
42
42
|
schemaStatements() {
|
|
43
43
|
const db = this.config.clickhouseDatabase;
|
|
44
|
-
const { occurrenceTtlDays, spanTtlDays, metricsTtlDays } = this.config;
|
|
44
|
+
const { occurrenceTtlDays, spanTtlDays, metricsTtlDays, llmCallTtlDays } = this.config;
|
|
45
45
|
return [
|
|
46
46
|
`CREATE DATABASE IF NOT EXISTS ${db}`,
|
|
47
47
|
`CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
|
|
@@ -113,6 +113,34 @@ export class ClickHouseStore {
|
|
|
113
113
|
PARTITION BY toYYYYMM(bucket_at)
|
|
114
114
|
ORDER BY (org_id, repository_id, service, environment, release, route, bucket_at)
|
|
115
115
|
TTL bucket_at + INTERVAL ${metricsTtlDays} DAY`,
|
|
116
|
+
`CREATE TABLE IF NOT EXISTS ${db}.runtime_llm_calls (
|
|
117
|
+
org_id String,
|
|
118
|
+
repository_id String,
|
|
119
|
+
service LowCardinality(String),
|
|
120
|
+
environment LowCardinality(String),
|
|
121
|
+
release String DEFAULT '',
|
|
122
|
+
trace_id String DEFAULT '',
|
|
123
|
+
span_id String DEFAULT '',
|
|
124
|
+
provider LowCardinality(String) DEFAULT '',
|
|
125
|
+
model LowCardinality(String) DEFAULT '',
|
|
126
|
+
operation LowCardinality(String) DEFAULT '',
|
|
127
|
+
input_tokens UInt64 DEFAULT 0,
|
|
128
|
+
output_tokens UInt64 DEFAULT 0,
|
|
129
|
+
cost_usd Float64 DEFAULT 0,
|
|
130
|
+
cost_source LowCardinality(String) DEFAULT 'none',
|
|
131
|
+
duration_ms Float64 DEFAULT 0,
|
|
132
|
+
status LowCardinality(String) DEFAULT 'ok',
|
|
133
|
+
error_type String DEFAULT '',
|
|
134
|
+
user_id String DEFAULT '',
|
|
135
|
+
session_id String DEFAULT '',
|
|
136
|
+
attributes String DEFAULT '{}' CODEC(ZSTD(1)),
|
|
137
|
+
started_at DateTime64(3, 'UTC'),
|
|
138
|
+
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
139
|
+
)
|
|
140
|
+
ENGINE = MergeTree
|
|
141
|
+
PARTITION BY toDate(started_at)
|
|
142
|
+
ORDER BY (org_id, repository_id, started_at)
|
|
143
|
+
TTL toDateTime(started_at) + INTERVAL ${llmCallTtlDays} DAY`,
|
|
116
144
|
];
|
|
117
145
|
}
|
|
118
146
|
/**
|
|
@@ -228,6 +256,39 @@ export class ClickHouseStore {
|
|
|
228
256
|
})),
|
|
229
257
|
});
|
|
230
258
|
}
|
|
259
|
+
async insertLlmCalls(ctx, calls) {
|
|
260
|
+
if (calls.length === 0 || !this.configured)
|
|
261
|
+
return;
|
|
262
|
+
await this.ensureSchema();
|
|
263
|
+
await this.getClient().insert({
|
|
264
|
+
table: this.table("runtime_llm_calls"),
|
|
265
|
+
format: "JSONEachRow",
|
|
266
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
267
|
+
values: calls.map((c) => ({
|
|
268
|
+
org_id: ctx.orgId,
|
|
269
|
+
repository_id: ctx.repositoryId,
|
|
270
|
+
service: c.service,
|
|
271
|
+
environment: c.environment,
|
|
272
|
+
release: c.release ?? "",
|
|
273
|
+
trace_id: c.traceId,
|
|
274
|
+
span_id: c.spanId,
|
|
275
|
+
provider: c.provider,
|
|
276
|
+
model: c.model,
|
|
277
|
+
operation: c.operation,
|
|
278
|
+
input_tokens: Math.max(0, Math.round(c.inputTokens)),
|
|
279
|
+
output_tokens: Math.max(0, Math.round(c.outputTokens)),
|
|
280
|
+
cost_usd: c.costUsd,
|
|
281
|
+
cost_source: c.costSource,
|
|
282
|
+
duration_ms: c.durationMs,
|
|
283
|
+
status: c.status,
|
|
284
|
+
error_type: c.errorType,
|
|
285
|
+
user_id: c.userId,
|
|
286
|
+
session_id: c.sessionId,
|
|
287
|
+
attributes: JSON.stringify(c.attributes ?? {}),
|
|
288
|
+
started_at: c.startedAt.toISOString(),
|
|
289
|
+
})),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
231
292
|
async insertMetricPoints(ctx, points) {
|
|
232
293
|
if (points.length === 0 || !this.configured)
|
|
233
294
|
return;
|
package/dist/config.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface IngesterConfig {
|
|
|
22
22
|
/** Optional webhook receiving fingerprinted occurrences for issue grouping. */
|
|
23
23
|
sinkUrl: string | null;
|
|
24
24
|
sinkToken: string | null;
|
|
25
|
+
/** Sink delivery attempts per batch before giving up (backoff-capped ~8 min). */
|
|
26
|
+
sinkMaxAttempts: number;
|
|
27
|
+
/** Bounds for the in-memory sink retry buffer; oldest batches drop first. */
|
|
28
|
+
sinkMaxBufferedBatches: number;
|
|
29
|
+
sinkMaxBufferedMb: number;
|
|
25
30
|
maxBodyBytes: number;
|
|
26
31
|
/** Per-key requests per minute (server keys). */
|
|
27
32
|
rateLimitPerMinute: number;
|
|
@@ -31,5 +36,6 @@ export interface IngesterConfig {
|
|
|
31
36
|
occurrenceTtlDays: number;
|
|
32
37
|
spanTtlDays: number;
|
|
33
38
|
metricsTtlDays: number;
|
|
39
|
+
llmCallTtlDays: number;
|
|
34
40
|
}
|
|
35
41
|
export declare function loadConfig(): IngesterConfig;
|
package/dist/config.js
CHANGED
|
@@ -34,12 +34,20 @@ export function loadConfig() {
|
|
|
34
34
|
keyValidatorToken: process.env.AUTTER_KEY_VALIDATOR_TOKEN || null,
|
|
35
35
|
sinkUrl: process.env.AUTTER_SINK_URL || null,
|
|
36
36
|
sinkToken: process.env.AUTTER_SINK_TOKEN || null,
|
|
37
|
+
// 12 attempts with 1s..60s exponential backoff spans ~8 minutes — long
|
|
38
|
+
// enough to ride out a routine consumer deploy without unbounded memory.
|
|
39
|
+
sinkMaxAttempts: intEnv("SINK_MAX_ATTEMPTS", 12),
|
|
40
|
+
sinkMaxBufferedBatches: intEnv("SINK_MAX_BUFFERED_BATCHES", 1000),
|
|
41
|
+
sinkMaxBufferedMb: intEnv("SINK_MAX_BUFFERED_MB", 64),
|
|
37
42
|
maxBodyBytes: intEnv("MAX_BODY_BYTES", 1024 * 1024),
|
|
38
43
|
rateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 300),
|
|
39
44
|
clientRateLimitPerMinute: intEnv("CLIENT_RATE_LIMIT_PER_MINUTE", 120),
|
|
40
45
|
occurrenceTtlDays: intEnv("OCCURRENCE_TTL_DAYS", 14),
|
|
41
46
|
spanTtlDays: intEnv("SPAN_TTL_DAYS", 7),
|
|
42
47
|
metricsTtlDays: intEnv("METRICS_TTL_DAYS", 90),
|
|
48
|
+
// LLM calls keep the metrics horizon, not the span one — cost trends
|
|
49
|
+
// need months, and per-call volume is small next to HTTP spans.
|
|
50
|
+
llmCallTtlDays: intEnv("LLM_CALL_TTL_DAYS", 90),
|
|
43
51
|
};
|
|
44
52
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(config.clickhouseDatabase)) {
|
|
45
53
|
throw new Error(`Invalid CLICKHOUSE_DATABASE name: ${config.clickhouseDatabase}`);
|
package/dist/fingerprint.d.ts
CHANGED
|
@@ -4,6 +4,28 @@ export declare function normalizeMessage(message: string): string;
|
|
|
4
4
|
export declare function normalizeRoute(route: string | null): string;
|
|
5
5
|
export declare function normalizeStackFrames(stack: string | null, topN?: number): string[];
|
|
6
6
|
export declare function fingerprintOccurrence(input: RuntimeOccurrenceInput): string;
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic per-occurrence identity (as opposed to the fingerprint,
|
|
9
|
+
* which is the per-ISSUE identity shared by every occurrence of a defect).
|
|
10
|
+
*
|
|
11
|
+
* The id must be a pure function of the signal, not a fresh UUID per
|
|
12
|
+
* request: OTLP exporters retry whole batches (after a 503 from a partial
|
|
13
|
+
* ClickHouse write, or when only the 2xx was lost), and both the ClickHouse
|
|
14
|
+
* rows and the sink consumer's dedupe ledger key on this id — random ids
|
|
15
|
+
* would turn every transport retry into a duplicate error downstream.
|
|
16
|
+
*
|
|
17
|
+
* Identical signals within one batch stay distinct through their batch
|
|
18
|
+
* position, which is stable across retries because exporters re-send the
|
|
19
|
+
* same serialized batch. Residual coalescing risk: two occurrences from
|
|
20
|
+
* DIFFERENT requests that share the same millisecond, message, and batch
|
|
21
|
+
* position while carrying neither a traceId nor a sessionId — accepted, as
|
|
22
|
+
* server signals virtually always carry a traceId and browser signals a
|
|
23
|
+
* sessionId.
|
|
24
|
+
*/
|
|
25
|
+
export declare function occurrenceIdFor(scope: {
|
|
26
|
+
orgId: string;
|
|
27
|
+
repositoryId: string;
|
|
28
|
+
}, input: RuntimeOccurrenceInput, fingerprint: string, batchIndex: number): string;
|
|
7
29
|
/**
|
|
8
30
|
* Derived, aggregation-ready fields, computed from the SAME normalisers the
|
|
9
31
|
* fingerprint hashes — so a stored fingerprint can always be explained by
|
package/dist/fingerprint.js
CHANGED
|
@@ -13,12 +13,22 @@ const LONG_HEX_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
|
13
13
|
// template too, or per-value messages fragment into separate fingerprints.
|
|
14
14
|
const NUMBER_RE = /\b\d+(\.\d+)?/g;
|
|
15
15
|
const QUOTED_RE = /(["'`])(?:\\.|(?!\1).)*\1/g;
|
|
16
|
+
// `\b` never fires between `_` and a digit (both are word chars), so
|
|
17
|
+
// underscore-glued ids — "prj_1013", "user_42", "order_9f3ac2d144" — escape
|
|
18
|
+
// NUMBER_RE/LONG_HEX_RE entirely and fragment one defect into an issue per
|
|
19
|
+
// id. Template the value after the underscore explicitly. (Letter-glued
|
|
20
|
+
// digits like "sha256"/"utf8" stay literal on purpose — those are usually
|
|
21
|
+
// meaningful tokens, not per-entity ids.)
|
|
22
|
+
const UNDERSCORE_HEX_RE = /_[0-9a-f]{8,}\b/gi;
|
|
23
|
+
const UNDERSCORE_NUMBER_RE = /_\d+(\.\d+)?\b/g;
|
|
16
24
|
export function normalizeMessage(message) {
|
|
17
25
|
return message
|
|
18
26
|
.slice(0, 500)
|
|
19
27
|
.replace(QUOTED_RE, "<str>")
|
|
20
28
|
.replace(UUID_RE, "<uuid>")
|
|
21
29
|
.replace(LONG_HEX_RE, "<hex>")
|
|
30
|
+
.replace(UNDERSCORE_HEX_RE, "_<hex>")
|
|
31
|
+
.replace(UNDERSCORE_NUMBER_RE, "_<n>")
|
|
22
32
|
.replace(NUMBER_RE, "<n>")
|
|
23
33
|
.replace(/\s+/g, " ")
|
|
24
34
|
.trim();
|
|
@@ -69,6 +79,42 @@ export function fingerprintOccurrence(input) {
|
|
|
69
79
|
];
|
|
70
80
|
return createHash("sha256").update(parts.join(" ")).digest("hex").slice(0, 32);
|
|
71
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Deterministic per-occurrence identity (as opposed to the fingerprint,
|
|
84
|
+
* which is the per-ISSUE identity shared by every occurrence of a defect).
|
|
85
|
+
*
|
|
86
|
+
* The id must be a pure function of the signal, not a fresh UUID per
|
|
87
|
+
* request: OTLP exporters retry whole batches (after a 503 from a partial
|
|
88
|
+
* ClickHouse write, or when only the 2xx was lost), and both the ClickHouse
|
|
89
|
+
* rows and the sink consumer's dedupe ledger key on this id — random ids
|
|
90
|
+
* would turn every transport retry into a duplicate error downstream.
|
|
91
|
+
*
|
|
92
|
+
* Identical signals within one batch stay distinct through their batch
|
|
93
|
+
* position, which is stable across retries because exporters re-send the
|
|
94
|
+
* same serialized batch. Residual coalescing risk: two occurrences from
|
|
95
|
+
* DIFFERENT requests that share the same millisecond, message, and batch
|
|
96
|
+
* position while carrying neither a traceId nor a sessionId — accepted, as
|
|
97
|
+
* server signals virtually always carry a traceId and browser signals a
|
|
98
|
+
* sessionId.
|
|
99
|
+
*/
|
|
100
|
+
export function occurrenceIdFor(scope, input, fingerprint, batchIndex) {
|
|
101
|
+
const parts = [
|
|
102
|
+
"v1",
|
|
103
|
+
scope.orgId,
|
|
104
|
+
scope.repositoryId,
|
|
105
|
+
fingerprint,
|
|
106
|
+
String(input.occurredAt.getTime()),
|
|
107
|
+
input.traceId ?? "",
|
|
108
|
+
input.sessionId ?? "",
|
|
109
|
+
input.message.slice(0, 1000),
|
|
110
|
+
String(batchIndex),
|
|
111
|
+
];
|
|
112
|
+
// NUL-joined so a free-text field can never bleed into its neighbour.
|
|
113
|
+
return createHash("sha256")
|
|
114
|
+
.update(parts.join("\u0000"))
|
|
115
|
+
.digest("hex")
|
|
116
|
+
.slice(0, 32);
|
|
117
|
+
}
|
|
72
118
|
export function deriveFields(input) {
|
|
73
119
|
const topFrames = normalizeStackFrames(input.stack);
|
|
74
120
|
return {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadConfig } from "./config.js";
|
|
2
2
|
import { createIngesterApp } from "./server.js";
|
|
3
3
|
const config = loadConfig();
|
|
4
|
-
const { app, store } = createIngesterApp(config);
|
|
4
|
+
const { app, store, sink } = createIngesterApp(config);
|
|
5
5
|
const server = app.listen(config.port, () => {
|
|
6
6
|
console.log(`autter otlp-ingester listening on :${config.port} ` +
|
|
7
7
|
`(clickhouse: ${config.clickhouseUrl ? "configured" : "NOT configured"})`);
|
|
@@ -14,6 +14,15 @@ if (store.configured) {
|
|
|
14
14
|
}
|
|
15
15
|
async function shutdown(signal) {
|
|
16
16
|
console.log(`${signal} received, shutting down`);
|
|
17
|
+
if (sink) {
|
|
18
|
+
const pending = sink.pendingCount();
|
|
19
|
+
sink.stop();
|
|
20
|
+
if (pending > 0) {
|
|
21
|
+
// The retry buffer is memory-only; everything in it is already in
|
|
22
|
+
// ClickHouse, so the consumer's reconciliation replays it.
|
|
23
|
+
console.warn(`${pending} sink batch(es) undelivered at shutdown — recoverable via ClickHouse replay`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
17
26
|
server.close(() => {
|
|
18
27
|
void store.close().finally(() => process.exit(0));
|
|
19
28
|
});
|
|
@@ -23,4 +32,5 @@ process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
|
23
32
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
24
33
|
export { createIngesterApp } from "./server.js";
|
|
25
34
|
export { loadConfig } from "./config.js";
|
|
35
|
+
export { SinkForwarder } from "./sink.js";
|
|
26
36
|
export * from "./types.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in USD price estimates for common LLM models, per 1M tokens.
|
|
3
|
+
*
|
|
4
|
+
* Estimation happens once at ingest so cost queries never re-price history
|
|
5
|
+
* (a price change should not rewrite last month's spend). The table is a
|
|
6
|
+
* best-effort default: SDKs can always report the exact figure via the
|
|
7
|
+
* `autter.llm.cost_usd` span attribute, which takes precedence.
|
|
8
|
+
*
|
|
9
|
+
* Matching is longest-prefix-wins on a normalised model id (lowercased,
|
|
10
|
+
* provider/router prefixes stripped), so `claude-sonnet-4-20250514` matches
|
|
11
|
+
* the `claude-sonnet-4` entry and gateway ids like `openai/gpt-5-mini`
|
|
12
|
+
* match `gpt-5-mini`. Unknown models estimate to 0 with costSource "none" —
|
|
13
|
+
* tokens are still tracked, and dashboards can flag unpriced volume.
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeModelId(model: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Estimate the USD cost of one call, or null when the model is unknown.
|
|
18
|
+
* Rates are per 1M tokens; token counts of 0 legitimately price to 0.
|
|
19
|
+
*/
|
|
20
|
+
export declare function estimateLlmCostUsd(model: string, inputTokens: number, outputTokens: number): number | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in USD price estimates for common LLM models, per 1M tokens.
|
|
3
|
+
*
|
|
4
|
+
* Estimation happens once at ingest so cost queries never re-price history
|
|
5
|
+
* (a price change should not rewrite last month's spend). The table is a
|
|
6
|
+
* best-effort default: SDKs can always report the exact figure via the
|
|
7
|
+
* `autter.llm.cost_usd` span attribute, which takes precedence.
|
|
8
|
+
*
|
|
9
|
+
* Matching is longest-prefix-wins on a normalised model id (lowercased,
|
|
10
|
+
* provider/router prefixes stripped), so `claude-sonnet-4-20250514` matches
|
|
11
|
+
* the `claude-sonnet-4` entry and gateway ids like `openai/gpt-5-mini`
|
|
12
|
+
* match `gpt-5-mini`. Unknown models estimate to 0 with costSource "none" —
|
|
13
|
+
* tokens are still tracked, and dashboards can flag unpriced volume.
|
|
14
|
+
*/
|
|
15
|
+
// Keep more-specific prefixes ABOVE their generic fallback (the list is
|
|
16
|
+
// scanned for the longest match, but equal-length ties resolve by order).
|
|
17
|
+
const MODEL_PRICING = [
|
|
18
|
+
// OpenAI
|
|
19
|
+
{ prefix: "gpt-5-nano", inputPerMTok: 0.05, outputPerMTok: 0.4 },
|
|
20
|
+
{ prefix: "gpt-5-mini", inputPerMTok: 0.25, outputPerMTok: 2 },
|
|
21
|
+
{ prefix: "gpt-5", inputPerMTok: 1.25, outputPerMTok: 10 },
|
|
22
|
+
{ prefix: "gpt-4.1-nano", inputPerMTok: 0.1, outputPerMTok: 0.4 },
|
|
23
|
+
{ prefix: "gpt-4.1-mini", inputPerMTok: 0.4, outputPerMTok: 1.6 },
|
|
24
|
+
{ prefix: "gpt-4.1", inputPerMTok: 2, outputPerMTok: 8 },
|
|
25
|
+
{ prefix: "gpt-4o-mini", inputPerMTok: 0.15, outputPerMTok: 0.6 },
|
|
26
|
+
{ prefix: "gpt-4o", inputPerMTok: 2.5, outputPerMTok: 10 },
|
|
27
|
+
{ prefix: "o3-mini", inputPerMTok: 1.1, outputPerMTok: 4.4 },
|
|
28
|
+
{ prefix: "o3", inputPerMTok: 2, outputPerMTok: 8 },
|
|
29
|
+
{ prefix: "o4-mini", inputPerMTok: 1.1, outputPerMTok: 4.4 },
|
|
30
|
+
{ prefix: "text-embedding-3-small", inputPerMTok: 0.02, outputPerMTok: 0 },
|
|
31
|
+
{ prefix: "text-embedding-3-large", inputPerMTok: 0.13, outputPerMTok: 0 },
|
|
32
|
+
// Anthropic
|
|
33
|
+
{ prefix: "claude-opus-4-5", inputPerMTok: 5, outputPerMTok: 25 },
|
|
34
|
+
{ prefix: "claude-opus-4-8", inputPerMTok: 5, outputPerMTok: 25 },
|
|
35
|
+
{ prefix: "claude-opus-4", inputPerMTok: 15, outputPerMTok: 75 },
|
|
36
|
+
{ prefix: "claude-sonnet-4", inputPerMTok: 3, outputPerMTok: 15 },
|
|
37
|
+
{ prefix: "claude-sonnet", inputPerMTok: 3, outputPerMTok: 15 },
|
|
38
|
+
{ prefix: "claude-haiku-4", inputPerMTok: 1, outputPerMTok: 5 },
|
|
39
|
+
{ prefix: "claude-3-5-haiku", inputPerMTok: 0.8, outputPerMTok: 4 },
|
|
40
|
+
{ prefix: "claude-haiku", inputPerMTok: 1, outputPerMTok: 5 },
|
|
41
|
+
// Google
|
|
42
|
+
{ prefix: "gemini-2.5-pro", inputPerMTok: 1.25, outputPerMTok: 10 },
|
|
43
|
+
{ prefix: "gemini-2.5-flash-lite", inputPerMTok: 0.1, outputPerMTok: 0.4 },
|
|
44
|
+
{ prefix: "gemini-2.5-flash", inputPerMTok: 0.3, outputPerMTok: 2.5 },
|
|
45
|
+
{ prefix: "gemini-2.0-flash", inputPerMTok: 0.1, outputPerMTok: 0.4 },
|
|
46
|
+
// DeepSeek
|
|
47
|
+
{ prefix: "deepseek-chat", inputPerMTok: 0.27, outputPerMTok: 1.1 },
|
|
48
|
+
{ prefix: "deepseek-reasoner", inputPerMTok: 0.55, outputPerMTok: 2.19 },
|
|
49
|
+
// Mistral
|
|
50
|
+
{ prefix: "mistral-large", inputPerMTok: 2, outputPerMTok: 6 },
|
|
51
|
+
{ prefix: "mistral-small", inputPerMTok: 0.1, outputPerMTok: 0.3 },
|
|
52
|
+
// Meta (typical hosted-inference rates)
|
|
53
|
+
{ prefix: "llama-3.1-405b", inputPerMTok: 3, outputPerMTok: 3 },
|
|
54
|
+
{ prefix: "llama-3.1-70b", inputPerMTok: 0.6, outputPerMTok: 0.6 },
|
|
55
|
+
{ prefix: "llama-3.1-8b", inputPerMTok: 0.1, outputPerMTok: 0.1 },
|
|
56
|
+
{ prefix: "llama-3.3-70b", inputPerMTok: 0.6, outputPerMTok: 0.6 },
|
|
57
|
+
// xAI
|
|
58
|
+
{ prefix: "grok-4", inputPerMTok: 3, outputPerMTok: 15 },
|
|
59
|
+
{ prefix: "grok-3-mini", inputPerMTok: 0.3, outputPerMTok: 0.5 },
|
|
60
|
+
{ prefix: "grok-3", inputPerMTok: 3, outputPerMTok: 15 },
|
|
61
|
+
];
|
|
62
|
+
// Router/provider prefixes seen in the wild ahead of the bare model id:
|
|
63
|
+
// gateway ids ("openai/gpt-5"), Azure deployments ("azure/gpt-4o"), Bedrock
|
|
64
|
+
// ("us.anthropic.claude-sonnet-4-...-v1:0"), Vertex ("models/gemini-2.5-pro").
|
|
65
|
+
const STRIP_PREFIXES = [
|
|
66
|
+
"openai/",
|
|
67
|
+
"azure/",
|
|
68
|
+
"anthropic/",
|
|
69
|
+
"google/",
|
|
70
|
+
"vertex_ai/",
|
|
71
|
+
"vertex/",
|
|
72
|
+
"bedrock/",
|
|
73
|
+
"groq/",
|
|
74
|
+
"xai/",
|
|
75
|
+
"mistral/",
|
|
76
|
+
"meta/",
|
|
77
|
+
"meta-llama/",
|
|
78
|
+
"deepseek/",
|
|
79
|
+
"models/",
|
|
80
|
+
"us.",
|
|
81
|
+
"eu.",
|
|
82
|
+
"apac.",
|
|
83
|
+
"anthropic.",
|
|
84
|
+
"amazon.",
|
|
85
|
+
];
|
|
86
|
+
export function normalizeModelId(model) {
|
|
87
|
+
let id = model.trim().toLowerCase();
|
|
88
|
+
let stripped = true;
|
|
89
|
+
while (stripped) {
|
|
90
|
+
stripped = false;
|
|
91
|
+
for (const prefix of STRIP_PREFIXES) {
|
|
92
|
+
if (id.length > prefix.length && id.startsWith(prefix)) {
|
|
93
|
+
id = id.slice(prefix.length);
|
|
94
|
+
stripped = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return id;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Estimate the USD cost of one call, or null when the model is unknown.
|
|
102
|
+
* Rates are per 1M tokens; token counts of 0 legitimately price to 0.
|
|
103
|
+
*/
|
|
104
|
+
export function estimateLlmCostUsd(model, inputTokens, outputTokens) {
|
|
105
|
+
if (!model)
|
|
106
|
+
return null;
|
|
107
|
+
const id = normalizeModelId(model);
|
|
108
|
+
let best = null;
|
|
109
|
+
for (const entry of MODEL_PRICING) {
|
|
110
|
+
if (!id.startsWith(entry.prefix))
|
|
111
|
+
continue;
|
|
112
|
+
if (!best || entry.prefix.length > best.prefix.length)
|
|
113
|
+
best = entry;
|
|
114
|
+
}
|
|
115
|
+
if (!best)
|
|
116
|
+
return null;
|
|
117
|
+
const cost = (inputTokens / 1_000_000) * best.inputPerMTok +
|
|
118
|
+
(outputTokens / 1_000_000) * best.outputPerMTok;
|
|
119
|
+
return Math.round(cost * 1e6) / 1e6;
|
|
120
|
+
}
|
package/dist/llm.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RuntimeLlmCall } from "./types.js";
|
|
2
|
+
export interface LlmSpanFacts {
|
|
3
|
+
name: string;
|
|
4
|
+
traceId: string;
|
|
5
|
+
spanId: string;
|
|
6
|
+
isError: boolean;
|
|
7
|
+
/** exception.type from the span's exception event, if it had one. */
|
|
8
|
+
exceptionType: string | null;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
startedAt: Date;
|
|
11
|
+
service: string;
|
|
12
|
+
environment: string;
|
|
13
|
+
release: string | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Returns the LLM-call row for a span, or null when the span is not an LLM
|
|
17
|
+
* provider call. Never throws — attribute soup from arbitrary SDKs must not
|
|
18
|
+
* take down the ingest path.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractLlmCall(attrs: Map<string, string>, facts: LlmSpanFacts): RuntimeLlmCall | null;
|
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) {
|