@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/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();
|
|
@@ -44,11 +54,125 @@ export function normalizeRoute(route) {
|
|
|
44
54
|
.join("/");
|
|
45
55
|
}
|
|
46
56
|
const FRAME_LOCATION_RE = /:\d+(:\d+)?\)?$/;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
// A Go location line: "\t<file>.go:<line> +0x<off>" (offset optional).
|
|
58
|
+
const GO_LOCATION_RE = /^\s*(.+\.go):\d+(?:\s+\+0x[0-9a-f]+)?\s*$/;
|
|
59
|
+
const GO_GOROUTINE_RE = /\bgoroutine \d+ \[/;
|
|
60
|
+
// A .NET frame with a source location: "at <method>(...) in <file>:line <n>".
|
|
61
|
+
const DOTNET_FRAME_RE = /^\s*at\s+.+\)\s+in\s+.+:line\s+\d+\s*$/i;
|
|
62
|
+
const DOTNET_CS_RE = /\.cs:line\s+\d+/i;
|
|
63
|
+
// A JVM frame: "at <fqmethod>(<File>.java:<line>)" / "(Native Method)".
|
|
64
|
+
const JVM_FRAME_RE = /^\s*at\s+[\w$.]+(?:\/[\w$.]+)?\(.*\.(?:java|kt|scala|groovy):\d+\)\s*$/;
|
|
65
|
+
const JVM_NATIVE_RE = /\((?:Native Method|Unknown Source)\)\s*$/;
|
|
66
|
+
// A Rust backtrace frame (" 3: my::mod::func") or its "at <file>.rs:<n>" line.
|
|
67
|
+
const RUST_FRAME_RE = /^\s*\d+:\s+(?:0x[0-9a-f]+\s+-\s+)?\S+::\S/;
|
|
68
|
+
const RUST_AT_RS_RE = /^\s*at\s+\S+\.rs:\d+/;
|
|
69
|
+
/**
|
|
70
|
+
* Classify a whole stack by language. Signatures are chosen to be unique to
|
|
71
|
+
* each runtime so a JS or Python stack always falls through to "script".
|
|
72
|
+
*/
|
|
73
|
+
function detectStackLanguage(lines) {
|
|
74
|
+
let hasDotnet = false;
|
|
75
|
+
let hasJvm = false;
|
|
76
|
+
let hasRust = false;
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (GO_LOCATION_RE.test(line) || GO_GOROUTINE_RE.test(line))
|
|
79
|
+
return "go";
|
|
80
|
+
if (DOTNET_FRAME_RE.test(line) || DOTNET_CS_RE.test(line))
|
|
81
|
+
hasDotnet = true;
|
|
82
|
+
if (JVM_FRAME_RE.test(line) || JVM_NATIVE_RE.test(line))
|
|
83
|
+
hasJvm = true;
|
|
84
|
+
if (RUST_FRAME_RE.test(line) ||
|
|
85
|
+
RUST_AT_RS_RE.test(line) ||
|
|
86
|
+
line.trim() === "stack backtrace:")
|
|
87
|
+
hasRust = true;
|
|
88
|
+
}
|
|
89
|
+
// .NET and JVM frames both start with "at"; decide by the location marker
|
|
90
|
+
// each detector matched (`.cs`/`:line` vs `.java`/Native Method).
|
|
91
|
+
if (hasDotnet)
|
|
92
|
+
return "dotnet";
|
|
93
|
+
if (hasJvm)
|
|
94
|
+
return "jvm";
|
|
95
|
+
if (hasRust)
|
|
96
|
+
return "rust";
|
|
97
|
+
return "script";
|
|
98
|
+
}
|
|
99
|
+
/** Drop the trailing call-argument group, e.g. "f(0x1, 0x2)" → "f". */
|
|
100
|
+
function stripTrailingArgs(fn) {
|
|
101
|
+
return fn.replace(/\([^()]*\)\s*$/, "").trim();
|
|
102
|
+
}
|
|
103
|
+
function cleanGoFunc(fn) {
|
|
104
|
+
return stripTrailingArgs(fn.replace(/^created by\s+/, "").replace(/\s+in goroutine \d+\s*$/, ""));
|
|
105
|
+
}
|
|
106
|
+
/** Go: a function line followed by a "\t<file>.go:<line> +0x<off>" location. */
|
|
107
|
+
function parseGoFrames(lines, topN) {
|
|
108
|
+
const frames = [];
|
|
109
|
+
let prevFunc = "";
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const loc = GO_LOCATION_RE.exec(line);
|
|
112
|
+
if (loc && prevFunc) {
|
|
113
|
+
frames.push(`${cleanGoFunc(prevFunc)} (${loc[1].trim()})`);
|
|
114
|
+
if (frames.length >= topN)
|
|
115
|
+
break;
|
|
116
|
+
prevFunc = "";
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const trimmed = line.trim();
|
|
120
|
+
if (trimmed && !GO_GOROUTINE_RE.test(trimmed))
|
|
121
|
+
prevFunc = trimmed;
|
|
122
|
+
}
|
|
123
|
+
return frames;
|
|
124
|
+
}
|
|
125
|
+
/** Rust: " N: module::func" optionally followed by " at <file>:<line>:<col>". */
|
|
126
|
+
function parseRustFrames(lines, topN) {
|
|
127
|
+
const frames = [];
|
|
128
|
+
for (let i = 0; i < lines.length && frames.length < topN; i++) {
|
|
129
|
+
const m = /^\s*\d+:\s+(?:0x[0-9a-f]+\s+-\s+)?(.+?)\s*$/.exec(lines[i]);
|
|
130
|
+
if (!m)
|
|
131
|
+
continue;
|
|
132
|
+
const fn = m[1].replace(/::h[0-9a-f]{6,}$/, "").trim();
|
|
133
|
+
if (!fn)
|
|
134
|
+
continue;
|
|
135
|
+
const at = /^\s*at\s+(\S+?):\d+(?::\d+)?\s*$/.exec(lines[i + 1] ?? "");
|
|
136
|
+
if (at)
|
|
137
|
+
i++;
|
|
138
|
+
frames.push(at ? `${fn} (${at[1]})` : fn);
|
|
139
|
+
}
|
|
140
|
+
return frames;
|
|
141
|
+
}
|
|
142
|
+
/** JVM (Java/Kotlin/Scala): "\tat <fqmethod>(<File>:<line>)". */
|
|
143
|
+
function parseJvmFrames(lines, topN) {
|
|
144
|
+
const frames = [];
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
const m = /^\s*at\s+(.+?)\((.*)\)\s*$/.exec(line);
|
|
147
|
+
if (!m)
|
|
148
|
+
continue;
|
|
149
|
+
frames.push(`${m[1].trim()}(${m[2].trim().replace(/:\d+$/, "")})`);
|
|
150
|
+
if (frames.length >= topN)
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
return frames;
|
|
154
|
+
}
|
|
155
|
+
/** .NET: " at <method>(<params>) in <file>:line <n>" (location optional). */
|
|
156
|
+
function parseDotnetFrames(lines, topN) {
|
|
157
|
+
const frames = [];
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
const m = /^\s*at\s+(.+?)(?:\s+in\s+(.+?):line\s+\d+)?\s*$/.exec(line);
|
|
160
|
+
if (!m)
|
|
161
|
+
continue;
|
|
162
|
+
const method = stripTrailingArgs(m[1]);
|
|
163
|
+
const file = m[2]?.trim() ?? "";
|
|
164
|
+
frames.push(file ? `${method} (${file})` : method);
|
|
165
|
+
if (frames.length >= topN)
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
return frames;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Historical JS/TS/Firefox/Python normalisation — output is intentionally
|
|
172
|
+
* unchanged so pre-existing issues in those runtimes keep their fingerprints.
|
|
173
|
+
*/
|
|
174
|
+
function parseScriptFrames(lines, topN) {
|
|
175
|
+
return lines
|
|
52
176
|
.map((line) => line.trim())
|
|
53
177
|
.filter((line) => /^at\s|@|^\s*File\s/.test(line) || /\.[jt]sx?/.test(line))
|
|
54
178
|
.slice(0, topN)
|
|
@@ -58,6 +182,57 @@ export function normalizeStackFrames(stack, topN = 5) {
|
|
|
58
182
|
.replace(/\s+/g, " ")
|
|
59
183
|
.trim());
|
|
60
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Safe fallback for a non-empty stack we could not parse into frames (an
|
|
187
|
+
* unsupported runtime, or a malformed one). Rather than discard everything —
|
|
188
|
+
* which collapses every same-message error into one issue — derive a stable
|
|
189
|
+
* signature from any structurally frame-like lines, with volatile tokens
|
|
190
|
+
* (addresses, offsets, line/column numbers) templated out so the SAME defect
|
|
191
|
+
* still groups across occurrences. When there is no frame-like structure at
|
|
192
|
+
* all we return nothing, exactly as before, and grouping falls back to the
|
|
193
|
+
* message + service + error type.
|
|
194
|
+
*/
|
|
195
|
+
function fallbackFrames(lines, topN) {
|
|
196
|
+
const framey = lines
|
|
197
|
+
.map((line) => line.trim())
|
|
198
|
+
.filter((line) => /(?:[/\\]|\.\w+)\S*[:(]\d+/.test(line) ||
|
|
199
|
+
/\b0x[0-9a-f]+/i.test(line) ||
|
|
200
|
+
/^(?:at|from)\b/.test(line) ||
|
|
201
|
+
/^\d+:\s/.test(line));
|
|
202
|
+
if (framey.length === 0)
|
|
203
|
+
return [];
|
|
204
|
+
return framey
|
|
205
|
+
.map((line) => line
|
|
206
|
+
.replace(/0x[0-9a-f]+/gi, "0x")
|
|
207
|
+
.replace(/:\d+(:\d+)?\b/g, "")
|
|
208
|
+
.replace(/\s+/g, " ")
|
|
209
|
+
.trim())
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.slice(0, topN);
|
|
212
|
+
}
|
|
213
|
+
export function normalizeStackFrames(stack, topN = 5) {
|
|
214
|
+
if (!stack)
|
|
215
|
+
return [];
|
|
216
|
+
const lines = stack.split("\n");
|
|
217
|
+
let frames;
|
|
218
|
+
switch (detectStackLanguage(lines)) {
|
|
219
|
+
case "go":
|
|
220
|
+
frames = parseGoFrames(lines, topN);
|
|
221
|
+
break;
|
|
222
|
+
case "rust":
|
|
223
|
+
frames = parseRustFrames(lines, topN);
|
|
224
|
+
break;
|
|
225
|
+
case "jvm":
|
|
226
|
+
frames = parseJvmFrames(lines, topN);
|
|
227
|
+
break;
|
|
228
|
+
case "dotnet":
|
|
229
|
+
frames = parseDotnetFrames(lines, topN);
|
|
230
|
+
break;
|
|
231
|
+
default:
|
|
232
|
+
frames = parseScriptFrames(lines, topN);
|
|
233
|
+
}
|
|
234
|
+
return frames.length > 0 ? frames : fallbackFrames(lines, topN);
|
|
235
|
+
}
|
|
61
236
|
export function fingerprintOccurrence(input) {
|
|
62
237
|
const parts = [
|
|
63
238
|
input.source,
|
|
@@ -69,6 +244,42 @@ export function fingerprintOccurrence(input) {
|
|
|
69
244
|
];
|
|
70
245
|
return createHash("sha256").update(parts.join(" ")).digest("hex").slice(0, 32);
|
|
71
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Deterministic per-occurrence identity (as opposed to the fingerprint,
|
|
249
|
+
* which is the per-ISSUE identity shared by every occurrence of a defect).
|
|
250
|
+
*
|
|
251
|
+
* The id must be a pure function of the signal, not a fresh UUID per
|
|
252
|
+
* request: OTLP exporters retry whole batches (after a 503 from a partial
|
|
253
|
+
* ClickHouse write, or when only the 2xx was lost), and both the ClickHouse
|
|
254
|
+
* rows and the sink consumer's dedupe ledger key on this id — random ids
|
|
255
|
+
* would turn every transport retry into a duplicate error downstream.
|
|
256
|
+
*
|
|
257
|
+
* Identical signals within one batch stay distinct through their batch
|
|
258
|
+
* position, which is stable across retries because exporters re-send the
|
|
259
|
+
* same serialized batch. Residual coalescing risk: two occurrences from
|
|
260
|
+
* DIFFERENT requests that share the same millisecond, message, and batch
|
|
261
|
+
* position while carrying neither a traceId nor a sessionId — accepted, as
|
|
262
|
+
* server signals virtually always carry a traceId and browser signals a
|
|
263
|
+
* sessionId.
|
|
264
|
+
*/
|
|
265
|
+
export function occurrenceIdFor(scope, input, fingerprint, batchIndex) {
|
|
266
|
+
const parts = [
|
|
267
|
+
"v1",
|
|
268
|
+
scope.orgId,
|
|
269
|
+
scope.repositoryId,
|
|
270
|
+
fingerprint,
|
|
271
|
+
String(input.occurredAt.getTime()),
|
|
272
|
+
input.traceId ?? "",
|
|
273
|
+
input.sessionId ?? "",
|
|
274
|
+
input.message.slice(0, 1000),
|
|
275
|
+
String(batchIndex),
|
|
276
|
+
];
|
|
277
|
+
// NUL-joined so a free-text field can never bleed into its neighbour.
|
|
278
|
+
return createHash("sha256")
|
|
279
|
+
.update(parts.join("\u0000"))
|
|
280
|
+
.digest("hex")
|
|
281
|
+
.slice(0, 32);
|
|
282
|
+
}
|
|
72
283
|
export function deriveFields(input) {
|
|
73
284
|
const topFrames = normalizeStackFrames(input.stack);
|
|
74
285
|
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;
|