@autter/otlp-ingester 1.4.0 → 1.6.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 +51 -0
- package/dist/breadcrumbs.d.ts +41 -0
- package/dist/breadcrumbs.js +151 -0
- package/dist/clickhouse.d.ts +42 -5
- package/dist/clickhouse.js +168 -7
- package/dist/compat-manifest.json +123 -0
- package/dist/compat.d.ts +149 -0
- package/dist/compat.js +221 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +19 -0
- package/dist/context.js +24 -11
- package/dist/error-fields.d.ts +41 -0
- package/dist/error-fields.js +103 -0
- package/dist/fingerprint-override.d.ts +42 -0
- package/dist/fingerprint-override.js +76 -0
- package/dist/fingerprint-v3.d.ts +54 -0
- package/dist/fingerprint-v3.js +96 -0
- package/dist/fingerprint.d.ts +54 -0
- package/dist/fingerprint.js +46 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -3
- package/dist/llm.js +16 -3
- package/dist/log-promotion.d.ts +55 -0
- package/dist/log-promotion.js +119 -0
- package/dist/logs.d.ts +57 -1
- package/dist/logs.js +148 -11
- package/dist/memory.d.ts +2 -2
- package/dist/migrations.d.ts +13 -0
- package/dist/migrations.js +75 -2
- package/dist/normalize-browser.d.ts +67 -22
- package/dist/normalize-browser.js +46 -19
- package/dist/normalize-otlp.js +18 -2
- package/dist/redact.d.ts +33 -0
- package/dist/redact.js +96 -0
- package/dist/sdk-versions.d.ts +58 -0
- package/dist/sdk-versions.js +167 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +219 -15
- package/dist/sink.d.ts +13 -0
- package/dist/sink.js +37 -4
- package/dist/source-maps-lookup.d.ts +31 -0
- package/dist/source-maps-lookup.js +64 -0
- package/dist/source-maps.d.ts +63 -2
- package/dist/source-maps.js +121 -13
- package/dist/stack-fixtures.d.ts +10 -2
- package/dist/stack-fixtures.js +13 -3
- package/dist/stack-frames.d.ts +48 -0
- package/dist/stack-frames.js +441 -0
- package/dist/symbolicate.d.ts +79 -0
- package/dist/symbolicate.js +257 -0
- package/dist/types.d.ts +86 -0
- package/package.json +2 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
/**
|
|
3
|
+
* Which SDK (name + version) each service sends with, recorded cheaply so the
|
|
4
|
+
* Autter dashboard and `/v1/compat` consumers can spot version mismatches.
|
|
5
|
+
*
|
|
6
|
+
* Sources, in priority order:
|
|
7
|
+
* - OTLP resource `telemetry.distro.name` / `telemetry.distro.version` — set
|
|
8
|
+
* by `@autter/runtime-node` and `@autter/runtime-next` (OTel semconv for
|
|
9
|
+
* SDK distributions);
|
|
10
|
+
* - otherwise `telemetry.sdk.language` / `telemetry.sdk.version` — any plain
|
|
11
|
+
* OpenTelemetry SDK (Python, Go, Java, …), recorded as
|
|
12
|
+
* `opentelemetry-<language>`;
|
|
13
|
+
* - browser payloads: the `sdk` field sent by `@autter/runtime-browser`.
|
|
14
|
+
*
|
|
15
|
+
* Writes are deduplicated in memory: one row per (tenant, service,
|
|
16
|
+
* environment, sdk, version) per `refreshMs`, bounded map, fire-and-forget —
|
|
17
|
+
* a failed write is logged and never fails ingest.
|
|
18
|
+
*/
|
|
19
|
+
let cachedVersion = null;
|
|
20
|
+
/** This ingester's own version (package.json — present in the npm package
|
|
21
|
+
* and the Docker image). */
|
|
22
|
+
export function ingesterVersion() {
|
|
23
|
+
if (cachedVersion)
|
|
24
|
+
return cachedVersion;
|
|
25
|
+
try {
|
|
26
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
27
|
+
cachedVersion = typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
cachedVersion = "0.0.0";
|
|
31
|
+
}
|
|
32
|
+
return cachedVersion;
|
|
33
|
+
}
|
|
34
|
+
const CLEAN = /^[\w.@/+:-]{1,100}$/;
|
|
35
|
+
function clean(value, max = 100) {
|
|
36
|
+
if (typeof value !== "string")
|
|
37
|
+
return "";
|
|
38
|
+
const trimmed = value.trim().slice(0, max);
|
|
39
|
+
return CLEAN.test(trimmed) ? trimmed : "";
|
|
40
|
+
}
|
|
41
|
+
/** SDK identity per OTLP resource. Reads only a handful of resource keys. */
|
|
42
|
+
export function sdkSightingsFromOtlp(request) {
|
|
43
|
+
if (!request || typeof request !== "object")
|
|
44
|
+
return [];
|
|
45
|
+
const holders = [
|
|
46
|
+
...(Array.isArray(request.resourceSpans) ? request.resourceSpans : []),
|
|
47
|
+
...(Array.isArray(request.resourceMetrics) ? request.resourceMetrics : []),
|
|
48
|
+
...(Array.isArray(request.resourceLogs) ? request.resourceLogs : []),
|
|
49
|
+
].slice(0, 64);
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const holder of holders) {
|
|
52
|
+
const attrs = holder?.resource?.attributes;
|
|
53
|
+
if (!Array.isArray(attrs))
|
|
54
|
+
continue;
|
|
55
|
+
const get = {};
|
|
56
|
+
for (const attr of attrs.slice(0, 256)) {
|
|
57
|
+
const key = attr?.key;
|
|
58
|
+
const value = attr?.value?.stringValue;
|
|
59
|
+
if (typeof key === "string" && typeof value === "string" && key.length < 64)
|
|
60
|
+
get[key] = value;
|
|
61
|
+
}
|
|
62
|
+
const service = clean(get["service.name"], 200);
|
|
63
|
+
if (!service)
|
|
64
|
+
continue;
|
|
65
|
+
const language = clean(get["telemetry.sdk.language"]);
|
|
66
|
+
let sdkName = clean(get["telemetry.distro.name"]);
|
|
67
|
+
let sdkVersion = clean(get["telemetry.distro.version"]);
|
|
68
|
+
if (!sdkName || !sdkVersion) {
|
|
69
|
+
sdkVersion = clean(get["telemetry.sdk.version"]);
|
|
70
|
+
sdkName = sdkVersion ? `opentelemetry-${language || "unknown"}` : "";
|
|
71
|
+
}
|
|
72
|
+
if (!sdkName || !sdkVersion)
|
|
73
|
+
continue;
|
|
74
|
+
out.push({
|
|
75
|
+
service,
|
|
76
|
+
environment: clean(get["deployment.environment.name"]) ||
|
|
77
|
+
clean(get["deployment.environment"]) ||
|
|
78
|
+
"production",
|
|
79
|
+
sdkName,
|
|
80
|
+
sdkVersion,
|
|
81
|
+
sdkLanguage: language,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
export const BROWSER_SDK_NAME = "@autter/runtime-browser";
|
|
87
|
+
export class SdkVersionTracker {
|
|
88
|
+
write;
|
|
89
|
+
lastWritten = new Map();
|
|
90
|
+
refreshMs;
|
|
91
|
+
maxKeys;
|
|
92
|
+
now;
|
|
93
|
+
onError;
|
|
94
|
+
lastErrorLog = 0;
|
|
95
|
+
constructor(write, options = {}) {
|
|
96
|
+
this.write = write;
|
|
97
|
+
this.refreshMs = options.refreshMs ?? 60 * 60 * 1000;
|
|
98
|
+
this.maxKeys = options.maxKeys ?? 10_000;
|
|
99
|
+
this.now = options.now ?? Date.now;
|
|
100
|
+
this.onError =
|
|
101
|
+
options.onError ??
|
|
102
|
+
((err) => {
|
|
103
|
+
// One line per minute at most: a missing table on a broken schema
|
|
104
|
+
// must not flood the logs on every ingest request.
|
|
105
|
+
if (this.now() - this.lastErrorLog < 60_000)
|
|
106
|
+
return;
|
|
107
|
+
this.lastErrorLog = this.now();
|
|
108
|
+
console.warn("sdk version tracking write failed:", err?.message ?? err);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** Fire-and-forget. Returns the write promise (resolved) for tests. */
|
|
112
|
+
observe(ctx, sightings) {
|
|
113
|
+
if (!sightings.length)
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
const now = this.now();
|
|
116
|
+
const rows = [];
|
|
117
|
+
const keys = [];
|
|
118
|
+
for (const sighting of sightings) {
|
|
119
|
+
const key = [
|
|
120
|
+
ctx.orgId,
|
|
121
|
+
ctx.repositoryId,
|
|
122
|
+
sighting.service,
|
|
123
|
+
sighting.environment,
|
|
124
|
+
sighting.sdkName,
|
|
125
|
+
sighting.sdkVersion,
|
|
126
|
+
].join("\u0000");
|
|
127
|
+
const last = this.lastWritten.get(key);
|
|
128
|
+
if (last !== undefined && now - last < this.refreshMs)
|
|
129
|
+
continue;
|
|
130
|
+
if (keys.includes(key))
|
|
131
|
+
continue;
|
|
132
|
+
keys.push(key);
|
|
133
|
+
rows.push({ ...sighting, ingesterVersion: ingesterVersion(), seenAt: new Date(now) });
|
|
134
|
+
}
|
|
135
|
+
if (!rows.length)
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
if (this.lastWritten.size + keys.length > this.maxKeys)
|
|
138
|
+
this.lastWritten.clear();
|
|
139
|
+
for (const key of keys)
|
|
140
|
+
this.lastWritten.set(key, now);
|
|
141
|
+
return this.write(ctx, rows).catch((err) => {
|
|
142
|
+
// Let the next request retry instead of waiting out refreshMs.
|
|
143
|
+
for (const key of keys)
|
|
144
|
+
this.lastWritten.delete(key);
|
|
145
|
+
this.onError(err);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function sdkVersionTableDDL(db, ttlDays = 90) {
|
|
150
|
+
return `CREATE TABLE IF NOT EXISTS ${db}.runtime_sdk_versions (
|
|
151
|
+
org_id String, repository_id String, service LowCardinality(String),
|
|
152
|
+
environment LowCardinality(String), sdk_name LowCardinality(String),
|
|
153
|
+
sdk_version String, sdk_language LowCardinality(String) DEFAULT '',
|
|
154
|
+
ingester_version String DEFAULT '', last_seen DateTime('UTC')
|
|
155
|
+
) ENGINE = ReplacingMergeTree(last_seen)
|
|
156
|
+
ORDER BY (org_id, repository_id, service, environment, sdk_name, sdk_version)
|
|
157
|
+
TTL last_seen + INTERVAL ${ttlDays} DAY`;
|
|
158
|
+
}
|
|
159
|
+
/** One row per ingester version (latest report wins); not tenant data. */
|
|
160
|
+
export function ingesterInfoTableDDL(db) {
|
|
161
|
+
return `CREATE TABLE IF NOT EXISTS ${db}.runtime_ingester_info (
|
|
162
|
+
ingester_version String, schema_level String DEFAULT '',
|
|
163
|
+
report String DEFAULT '{}' CODEC(ZSTD(1)), reported_at DateTime('UTC')
|
|
164
|
+
) ENGINE = ReplacingMergeTree(reported_at)
|
|
165
|
+
ORDER BY ingester_version
|
|
166
|
+
TTL reported_at + INTERVAL 90 DAY`;
|
|
167
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -7,5 +7,8 @@ export interface IngesterApp {
|
|
|
7
7
|
store: ClickHouseStore;
|
|
8
8
|
/** Present when AUTTER_SINK_URL is configured. */
|
|
9
9
|
sink: SinkForwarder | null;
|
|
10
|
+
/** Record this ingester's version + schema level in ClickHouse (for the
|
|
11
|
+
* Autter dashboard). Called after the boot schema warm-up; throttled. */
|
|
12
|
+
reportIngesterInfo: () => Promise<void>;
|
|
10
13
|
}
|
|
11
14
|
export declare function createIngesterApp(config: IngesterConfig): IngesterApp;
|
package/dist/server.js
CHANGED
|
@@ -2,17 +2,32 @@ import express from "express";
|
|
|
2
2
|
import { KeyResolver, RateLimiter } from "./auth.js";
|
|
3
3
|
import { ClickHouseStore } from "./clickhouse.js";
|
|
4
4
|
import { normalizeLatencyHistograms } from "./latency.js";
|
|
5
|
-
import { deriveFields,
|
|
5
|
+
import { deriveFields, occurrenceIdFor } from "./fingerprint.js";
|
|
6
|
+
import { occurrenceIdentity } from "./fingerprint-v3.js";
|
|
7
|
+
import { parseStackFrames } from "./stack-frames.js";
|
|
8
|
+
import { SourceMapCache, Symbolicator } from "./symbolicate.js";
|
|
9
|
+
import { logPromotionCandidates, promotionLookups, promotionRollups, } from "./log-promotion.js";
|
|
6
10
|
import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
|
|
7
11
|
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
8
12
|
import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest } from "./otlp-proto.js";
|
|
9
13
|
import { normalizeLogs } from "./logs.js";
|
|
10
14
|
import { decodeProfile } from "./profiles.js";
|
|
11
15
|
import { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
|
|
12
|
-
import { validateSourceMap } from "./source-maps.js";
|
|
16
|
+
import { validateSourceMap, validateSourceMapCheck } from "./source-maps.js";
|
|
13
17
|
import { SinkForwarder } from "./sink.js";
|
|
18
|
+
import { configureRedaction, scrubOccurrence } from "./redact.js";
|
|
19
|
+
import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
|
|
20
|
+
import { MIGRATIONS } from "./migrations.js";
|
|
21
|
+
import { BROWSER_SDK_NAME, ingesterVersion, SdkVersionTracker, sdkSightingsFromOtlp, } from "./sdk-versions.js";
|
|
22
|
+
/** Re-report the ingester version at most this often (keeps the row inside
|
|
23
|
+
* its TTL for long-running ingesters). */
|
|
24
|
+
const INGESTER_INFO_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
14
25
|
export function createIngesterApp(config) {
|
|
26
|
+
configureRedaction(config);
|
|
15
27
|
const store = new ClickHouseStore(config);
|
|
28
|
+
// Browser JS frames are resolved through uploaded source maps before they
|
|
29
|
+
// are fingerprinted (frames-v3), with parsed maps cached per release+file.
|
|
30
|
+
const symbolicator = new Symbolicator((key) => store.lookupSourceMap(key), new SourceMapCache({ maxBytes: (config.sourceMapCacheMb ?? 64) * 1024 * 1024 }));
|
|
16
31
|
// Fingerprinted occurrences feed the consumer's issue grouping, metric
|
|
17
32
|
// points feed the request/error-rate rollups, LLM calls feed spend
|
|
18
33
|
// watching. Delivery is at-least-once with bounded retries — see sink.ts.
|
|
@@ -20,8 +35,54 @@ export function createIngesterApp(config) {
|
|
|
20
35
|
const keys = new KeyResolver(config);
|
|
21
36
|
const serverRateLimiter = new RateLimiter(config.rateLimitPerMinute);
|
|
22
37
|
const clientRateLimiter = new RateLimiter(config.clientRateLimitPerMinute);
|
|
38
|
+
// ClickHouse dedupe lookups made by /v1/logs promotion, per tenant. Far
|
|
39
|
+
// tighter than the request limit: each lookup is a query, not an insert.
|
|
40
|
+
const promotionLookupLimiter = new RateLimiter(config.promotionLookupsPerMinute ?? 30);
|
|
41
|
+
const version = ingesterVersion();
|
|
42
|
+
const sdkVersions = new SdkVersionTracker((ctx, rows) => store.insertSdkVersions(ctx, rows));
|
|
43
|
+
const allMigrationIds = MIGRATIONS.map((migration) => migration.id);
|
|
44
|
+
const compatReport = () => {
|
|
45
|
+
const schema = store.schemaStatus();
|
|
46
|
+
return buildCompatReport({
|
|
47
|
+
version,
|
|
48
|
+
schemaStatus: schema.status,
|
|
49
|
+
applied: schema.applied,
|
|
50
|
+
allMigrations: allMigrationIds,
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
let lastInfoReport = 0;
|
|
54
|
+
async function reportIngesterInfo() {
|
|
55
|
+
if (!store.configured)
|
|
56
|
+
return;
|
|
57
|
+
if (Date.now() - lastInfoReport < INGESTER_INFO_INTERVAL_MS)
|
|
58
|
+
return;
|
|
59
|
+
lastInfoReport = Date.now();
|
|
60
|
+
try {
|
|
61
|
+
// Only reached from boot and authenticated ingest, which run the
|
|
62
|
+
// (memoized) schema bootstrap anyway.
|
|
63
|
+
await store.ensureSchema();
|
|
64
|
+
const report = compatReport();
|
|
65
|
+
await store.insertIngesterInfo({ version, schemaLevel: report.schema.level, report });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// Retry in a minute, not on every request.
|
|
69
|
+
lastInfoReport = Date.now() - INGESTER_INFO_INTERVAL_MS + 60_000;
|
|
70
|
+
console.warn("ingester info report failed:", err?.message ?? err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Fire-and-forget after a successful ingest: never delays or fails it. */
|
|
74
|
+
function recordSdks(ctx, request) {
|
|
75
|
+
void sdkVersions.observe(ctx, sdkSightingsFromOtlp(request));
|
|
76
|
+
void reportIngesterInfo();
|
|
77
|
+
}
|
|
23
78
|
const app = express();
|
|
24
79
|
app.disable("x-powered-by");
|
|
80
|
+
// Every response names the ingester version, so SDKs (and the browser
|
|
81
|
+
// relay) learn it for free from responses they already receive.
|
|
82
|
+
app.use((_req, res, next) => {
|
|
83
|
+
res.setHeader(INGESTER_VERSION_HEADER, version);
|
|
84
|
+
next();
|
|
85
|
+
});
|
|
25
86
|
app.use(express.raw({ limit: "1mb", type: ["application/x-pprof"] }));
|
|
26
87
|
app.use("/v1/sourcemaps", express.json({ limit: "5mb" }));
|
|
27
88
|
app.use(express.json({
|
|
@@ -49,6 +110,7 @@ export function createIngesterApp(config) {
|
|
|
49
110
|
res.setHeader("access-control-allow-origin", "*");
|
|
50
111
|
res.setHeader("access-control-allow-methods", "POST, OPTIONS");
|
|
51
112
|
res.setHeader("access-control-allow-headers", "content-type, authorization, x-autter-key");
|
|
113
|
+
res.setHeader("access-control-expose-headers", INGESTER_VERSION_HEADER);
|
|
52
114
|
res.setHeader("access-control-max-age", "86400");
|
|
53
115
|
if (req.method === "OPTIONS") {
|
|
54
116
|
res.status(204).end();
|
|
@@ -72,6 +134,36 @@ export function createIngesterApp(config) {
|
|
|
72
134
|
res.status(503).json({ ok: false, clickhouse: "down", ...sinkStats });
|
|
73
135
|
}
|
|
74
136
|
});
|
|
137
|
+
/**
|
|
138
|
+
* Version compatibility (public, like /healthz; no tenant data): this
|
|
139
|
+
* ingester's version, its ClickHouse schema level, and every feature in
|
|
140
|
+
* the compat manifest with whether it is available here. Optional
|
|
141
|
+
* `?features=a,b&sdk=<name>@<version>` adds the evaluated `issues`.
|
|
142
|
+
* Never triggers schema DDL.
|
|
143
|
+
*/
|
|
144
|
+
app.get("/v1/compat", (req, res) => {
|
|
145
|
+
const report = compatReport();
|
|
146
|
+
const features = typeof req.query.features === "string"
|
|
147
|
+
? req.query.features.split(",").map((f) => f.trim()).filter(Boolean).slice(0, 50)
|
|
148
|
+
: [];
|
|
149
|
+
const sdkParam = typeof req.query.sdk === "string" ? req.query.sdk.slice(0, 120) : "";
|
|
150
|
+
const at = sdkParam.lastIndexOf("@");
|
|
151
|
+
const sdk = at > 0 ? { name: sdkParam.slice(0, at), version: sdkParam.slice(at + 1) } : null;
|
|
152
|
+
const body = features.length || sdk
|
|
153
|
+
? {
|
|
154
|
+
...report,
|
|
155
|
+
issues: evaluateCompat({
|
|
156
|
+
features: features.length
|
|
157
|
+
? features
|
|
158
|
+
: report.features.filter((f) => sdk && f.sdks[sdk.name]).map((f) => f.id),
|
|
159
|
+
ingester: { version, schema: { status: report.schema.status, applied: report.schema.applied } },
|
|
160
|
+
sdk,
|
|
161
|
+
}),
|
|
162
|
+
}
|
|
163
|
+
: report;
|
|
164
|
+
res.setHeader("cache-control", "no-store");
|
|
165
|
+
res.status(200).json(body);
|
|
166
|
+
});
|
|
75
167
|
/** Auth + scope + rate limit; returns null (response sent) on failure. */
|
|
76
168
|
async function authenticate(req, res, surface) {
|
|
77
169
|
// A storage-less ingester must refuse, not accept-and-drop: exporters
|
|
@@ -117,21 +209,89 @@ export function createIngesterApp(config) {
|
|
|
117
209
|
}
|
|
118
210
|
return ctx;
|
|
119
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Structured frames for the frames-v3 identity and the platform's stack
|
|
214
|
+
* view: parsed innermost-first, then — browser only, where bundles are
|
|
215
|
+
* minified — resolved through the release's source maps. Symbolication
|
|
216
|
+
* never fails ingest (it falls back to the parsed frames).
|
|
217
|
+
*/
|
|
218
|
+
async function framesFor(ctx, input) {
|
|
219
|
+
let frames;
|
|
220
|
+
try {
|
|
221
|
+
frames = parseStackFrames(input.stack);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
console.warn("stack parse failed — no frames:", err instanceof Error ? err.message : err);
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
if (input.source !== "browser" || frames.length === 0)
|
|
228
|
+
return frames;
|
|
229
|
+
return symbolicator.symbolicate(frames, {
|
|
230
|
+
orgId: ctx.orgId,
|
|
231
|
+
repositoryId: ctx.repositoryId,
|
|
232
|
+
release: input.release,
|
|
233
|
+
debugIds: input.debugIds ?? null,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
120
236
|
/** Ids are content-derived (occurrenceIdFor), NOT random: an exporter
|
|
121
237
|
* that retries a batch — after a 503 from a partially-failed ClickHouse
|
|
122
238
|
* write, or when only our 2xx got lost — must produce the same ids, so
|
|
123
239
|
* the sink consumer's per-occurrence dedupe holds across transport
|
|
124
|
-
* retries and duplicated ClickHouse rows stay identifiable.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
240
|
+
* retries and duplicated ClickHouse rows stay identifiable.
|
|
241
|
+
*
|
|
242
|
+
* `fingerprint` / `fingerprintScheme` stay the legacy identity
|
|
243
|
+
* (message-v1 or code-v1) byte-for-byte; `fingerprints` carries every
|
|
244
|
+
* candidate (manual-v1, code-v1, frames-v3, message-v1) for the platform
|
|
245
|
+
* to resolve the issue from. */
|
|
246
|
+
async function fingerprintAll(ctx, inputs) {
|
|
247
|
+
return Promise.all(inputs.map(async (raw, index) => {
|
|
248
|
+
// Second line of defence, before fingerprinting, storage, and the
|
|
249
|
+
// sink (which feeds LLM fix generation): scrub secrets from the
|
|
250
|
+
// free-text fields old SDKs and third-party OTLP senders pass
|
|
251
|
+
// through verbatim. Attributes were already sanitised by the
|
|
252
|
+
// normalisers (sanitizeRuntimeContext).
|
|
253
|
+
const input = scrubOccurrence(raw);
|
|
254
|
+
const frames = await framesFor(ctx, input);
|
|
255
|
+
const identity = occurrenceIdentity(input, frames);
|
|
256
|
+
const { fingerprint, scheme } = identity.legacy;
|
|
257
|
+
// Debug ids only locate source maps; they are not stored or forwarded.
|
|
258
|
+
const { debugIds: _debugIds, ...stored } = input;
|
|
128
259
|
return {
|
|
129
|
-
...
|
|
260
|
+
...stored,
|
|
130
261
|
occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
|
|
131
262
|
fingerprint,
|
|
263
|
+
fingerprintScheme: scheme,
|
|
132
264
|
...deriveFields(input),
|
|
265
|
+
...(frames.length > 0 ? { frames } : {}),
|
|
266
|
+
fingerprints: identity.candidates,
|
|
267
|
+
...(identity.record ? { fingerprintRecord: identity.record } : {}),
|
|
133
268
|
};
|
|
134
|
-
});
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
/** Fingerprint log-promotion candidates and drop the ones whose trace
|
|
272
|
+
* already produced an occurrence (in this batch, or within ±60 s). Ids
|
|
273
|
+
* are assigned BEFORE the lookup so they stay stable across retries
|
|
274
|
+
* whatever the lookup returns. */
|
|
275
|
+
async function promoteLogs(ctx, logs) {
|
|
276
|
+
const candidates = logPromotionCandidates(logs);
|
|
277
|
+
if (candidates.length === 0)
|
|
278
|
+
return [];
|
|
279
|
+
const fingerprinted = await fingerprintAll(ctx, candidates);
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
const ownIds = fingerprinted.map((o) => o.occurrenceId);
|
|
282
|
+
for (const lookup of promotionLookups(candidates)) {
|
|
283
|
+
if (!promotionLookupLimiter.allow(`${ctx.orgId}:${ctx.repositoryId}`))
|
|
284
|
+
break;
|
|
285
|
+
try {
|
|
286
|
+
const found = await store.recentOccurrenceTraceIds(ctx, lookup.traceIds, lookup.from, lookup.to, ownIds);
|
|
287
|
+
for (const traceId of found)
|
|
288
|
+
seen.add(traceId);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
console.warn("log promotion dedupe lookup failed — promoting without it:", err instanceof Error ? err.message : err);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return fingerprinted.filter((o) => !o.traceId || !seen.has(o.traceId));
|
|
135
295
|
}
|
|
136
296
|
function storageError(res, err) {
|
|
137
297
|
console.error("clickhouse write failed:", err);
|
|
@@ -190,6 +350,8 @@ export function createIngesterApp(config) {
|
|
|
190
350
|
res.status(400).json({ error: "invalid source map" });
|
|
191
351
|
return;
|
|
192
352
|
}
|
|
353
|
+
// Idempotent: the same release + path replaces the earlier row
|
|
354
|
+
// (ReplacingMergeTree), so CI retries and re-runs are harmless.
|
|
193
355
|
try {
|
|
194
356
|
await store.insertSourceMap(ctx, sourceMap);
|
|
195
357
|
res.status(202).json({ accepted: true });
|
|
@@ -198,6 +360,24 @@ export function createIngesterApp(config) {
|
|
|
198
360
|
storageError(res, err);
|
|
199
361
|
}
|
|
200
362
|
});
|
|
363
|
+
/** Which of these maps does this tenant already have? Lets
|
|
364
|
+
* `autter-runtime sourcemaps upload` skip unchanged maps (1.6.0+). */
|
|
365
|
+
app.post("/v1/sourcemaps/check", async (req, res) => {
|
|
366
|
+
const ctx = await authenticate(req, res, "otlp");
|
|
367
|
+
if (!ctx)
|
|
368
|
+
return;
|
|
369
|
+
const check = validateSourceMapCheck(req.body);
|
|
370
|
+
if (!check) {
|
|
371
|
+
res.status(400).json({ error: "invalid source map check" });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
res.status(200).json({ present: await store.sourceMapsPresent(ctx, check) });
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
storageError(res, err);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
201
381
|
/** ECS/Kubernetes event forwarders use the same server key as OTLP. */
|
|
202
382
|
app.post("/v1/platform-events", async (req, res) => {
|
|
203
383
|
const ctx = await authenticate(req, res, "otlp");
|
|
@@ -239,7 +419,7 @@ export function createIngesterApp(config) {
|
|
|
239
419
|
request = req.body;
|
|
240
420
|
}
|
|
241
421
|
const { occurrences, spans, metricPoints, llmCalls } = normalizeTraces(request);
|
|
242
|
-
const fingerprinted = fingerprintAll(ctx, occurrences);
|
|
422
|
+
const fingerprinted = await fingerprintAll(ctx, occurrences);
|
|
243
423
|
// ClickHouse has no cross-table transaction, so these four inserts can
|
|
244
424
|
// partially commit. Recovery boundary: any failure → 503 → the exporter
|
|
245
425
|
// retries the whole batch. Deterministic occurrence ids make the retry
|
|
@@ -261,6 +441,7 @@ export function createIngesterApp(config) {
|
|
|
261
441
|
return;
|
|
262
442
|
}
|
|
263
443
|
sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
|
|
444
|
+
recordSdks(ctx, request);
|
|
264
445
|
otlpSuccess(req, res);
|
|
265
446
|
});
|
|
266
447
|
app.post("/v1/logs", async (req, res) => {
|
|
@@ -268,22 +449,34 @@ export function createIngesterApp(config) {
|
|
|
268
449
|
if (!ctx)
|
|
269
450
|
return;
|
|
270
451
|
let logs;
|
|
452
|
+
let logsRequest;
|
|
271
453
|
try {
|
|
272
|
-
|
|
454
|
+
logsRequest = req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body;
|
|
455
|
+
logs = normalizeLogs(logsRequest);
|
|
273
456
|
}
|
|
274
457
|
catch {
|
|
275
458
|
res.status(400).json({ error: "invalid OTLP logs payload" });
|
|
276
459
|
return;
|
|
277
460
|
}
|
|
461
|
+
// Logs are diagnostic evidence; exceptions and failed outcomes normally
|
|
462
|
+
// reach grouping through the trace path. The exception: logger-only /
|
|
463
|
+
// edge SDKs mark span-less error records `autter.capture.mode = "log"`,
|
|
464
|
+
// and those are promoted to occurrences here — see log-promotion.ts.
|
|
465
|
+
const promoted = await promoteLogs(ctx, logs);
|
|
466
|
+
const metricPoints = promotionRollups(promoted);
|
|
278
467
|
try {
|
|
279
|
-
await
|
|
468
|
+
await Promise.all([
|
|
469
|
+
store.insertLogs(ctx, logs),
|
|
470
|
+
store.insertOccurrences(ctx, promoted),
|
|
471
|
+
store.insertMetricPoints(ctx, metricPoints),
|
|
472
|
+
]);
|
|
280
473
|
}
|
|
281
474
|
catch (err) {
|
|
282
475
|
storageError(res, err);
|
|
283
476
|
return;
|
|
284
477
|
}
|
|
285
|
-
|
|
286
|
-
|
|
478
|
+
sink?.enqueue(ctx, promoted, metricPoints);
|
|
479
|
+
recordSdks(ctx, logsRequest);
|
|
287
480
|
otlpSuccess(req, res);
|
|
288
481
|
});
|
|
289
482
|
app.post("/v1/metrics", async (req, res) => {
|
|
@@ -315,6 +508,7 @@ export function createIngesterApp(config) {
|
|
|
315
508
|
return;
|
|
316
509
|
}
|
|
317
510
|
sink?.enqueue(ctx, [], metricPoints);
|
|
511
|
+
recordSdks(ctx, request);
|
|
318
512
|
otlpSuccess(req, res);
|
|
319
513
|
});
|
|
320
514
|
app.post("/v1/browser", async (req, res) => {
|
|
@@ -341,7 +535,7 @@ export function createIngesterApp(config) {
|
|
|
341
535
|
return;
|
|
342
536
|
}
|
|
343
537
|
const { occurrences, metricPoints } = normalizeBrowserPayload(parsed.data);
|
|
344
|
-
const fingerprinted = fingerprintAll(ctx, occurrences);
|
|
538
|
+
const fingerprinted = await fingerprintAll(ctx, occurrences);
|
|
345
539
|
try {
|
|
346
540
|
await Promise.all([
|
|
347
541
|
store.insertOccurrences(ctx, fingerprinted),
|
|
@@ -353,6 +547,16 @@ export function createIngesterApp(config) {
|
|
|
353
547
|
return;
|
|
354
548
|
}
|
|
355
549
|
sink?.enqueue(ctx, fingerprinted, metricPoints);
|
|
550
|
+
if (parsed.data.sdk) {
|
|
551
|
+
void sdkVersions.observe(ctx, [{
|
|
552
|
+
service: parsed.data.service,
|
|
553
|
+
environment: parsed.data.environment,
|
|
554
|
+
sdkName: BROWSER_SDK_NAME,
|
|
555
|
+
sdkVersion: parsed.data.sdk,
|
|
556
|
+
sdkLanguage: "webjs",
|
|
557
|
+
}]);
|
|
558
|
+
}
|
|
559
|
+
void reportIngesterInfo();
|
|
356
560
|
res.status(202).json({ accepted: fingerprinted.length });
|
|
357
561
|
});
|
|
358
562
|
// Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
|
|
@@ -370,5 +574,5 @@ export function createIngesterApp(config) {
|
|
|
370
574
|
console.error("unhandled error:", err);
|
|
371
575
|
res.status(500).json({ error: "internal error" });
|
|
372
576
|
});
|
|
373
|
-
return { app, store, sink };
|
|
577
|
+
return { app, store, sink, reportIngesterInfo };
|
|
374
578
|
}
|
package/dist/sink.d.ts
CHANGED
|
@@ -101,3 +101,16 @@ export declare class SinkForwarder {
|
|
|
101
101
|
private scheduleWake;
|
|
102
102
|
private send;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Wire shape of one occurrence (payload `version: 1`). 1.5.0 adds, all
|
|
106
|
+
* optional and omitted when absent: errorCode, why, fix, link, expected,
|
|
107
|
+
* requestId, traceId, route, method, statusCode, fingerprintScheme. The
|
|
108
|
+
* request-context four were previously spread as `null` when unknown and
|
|
109
|
+
* are now simply left out, matching the documented optional-string shape.
|
|
110
|
+
* 1.6.0 adds `frames` (innermost first, ≤ 50), `fingerprints` (every
|
|
111
|
+
* identity candidate, highest precedence first, ending with the legacy
|
|
112
|
+
* `fingerprint`) and `fingerprintRecord`; `fingerprint`/`fingerprintScheme`
|
|
113
|
+
* keep their legacy meaning. Older consumers ignore unknown keys, so the
|
|
114
|
+
* version stays 1.
|
|
115
|
+
*/
|
|
116
|
+
export declare function sinkOccurrence(o: RuntimeOccurrence): Record<string, unknown>;
|
package/dist/sink.js
CHANGED
|
@@ -108,10 +108,7 @@ export class SinkForwarder {
|
|
|
108
108
|
batchId,
|
|
109
109
|
orgId: ctx.orgId,
|
|
110
110
|
repositoryId: ctx.repositoryId,
|
|
111
|
-
occurrences: occurrences.map(
|
|
112
|
-
...o,
|
|
113
|
-
occurredAt: o.occurredAt.toISOString(),
|
|
114
|
-
})),
|
|
111
|
+
occurrences: occurrences.map(sinkOccurrence),
|
|
115
112
|
metrics: metricPoints.map((p) => ({
|
|
116
113
|
...p,
|
|
117
114
|
bucketAt: p.bucketAt.toISOString(),
|
|
@@ -298,6 +295,42 @@ export class SinkForwarder {
|
|
|
298
295
|
this.scheduleWake();
|
|
299
296
|
}
|
|
300
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Wire shape of one occurrence (payload `version: 1`). 1.5.0 adds, all
|
|
300
|
+
* optional and omitted when absent: errorCode, why, fix, link, expected,
|
|
301
|
+
* requestId, traceId, route, method, statusCode, fingerprintScheme. The
|
|
302
|
+
* request-context four were previously spread as `null` when unknown and
|
|
303
|
+
* are now simply left out, matching the documented optional-string shape.
|
|
304
|
+
* 1.6.0 adds `frames` (innermost first, ≤ 50), `fingerprints` (every
|
|
305
|
+
* identity candidate, highest precedence first, ending with the legacy
|
|
306
|
+
* `fingerprint`) and `fingerprintRecord`; `fingerprint`/`fingerprintScheme`
|
|
307
|
+
* keep their legacy meaning. Older consumers ignore unknown keys, so the
|
|
308
|
+
* version stays 1.
|
|
309
|
+
*/
|
|
310
|
+
export function sinkOccurrence(o) {
|
|
311
|
+
const { traceId, route, method, statusCode, errorCode, why, fix, link, expected, requestId, breadcrumbs, frames, fingerprints, fingerprintRecord,
|
|
312
|
+
// Input-only: locates source maps, never leaves the ingester.
|
|
313
|
+
debugIds: _debugIds, ...rest } = o;
|
|
314
|
+
return {
|
|
315
|
+
...rest,
|
|
316
|
+
occurredAt: o.occurredAt.toISOString(),
|
|
317
|
+
...(traceId ? { traceId } : {}),
|
|
318
|
+
...(route ? { route } : {}),
|
|
319
|
+
...(method ? { method } : {}),
|
|
320
|
+
...(statusCode != null ? { statusCode } : {}),
|
|
321
|
+
...(errorCode ? { errorCode } : {}),
|
|
322
|
+
...(why ? { why } : {}),
|
|
323
|
+
...(fix ? { fix } : {}),
|
|
324
|
+
...(link ? { link } : {}),
|
|
325
|
+
...(expected ? { expected: true } : {}),
|
|
326
|
+
...(requestId ? { requestId } : {}),
|
|
327
|
+
// 1.6.0: steps before the error, oldest first, ≤ 30.
|
|
328
|
+
...(breadcrumbs?.length ? { breadcrumbs } : {}),
|
|
329
|
+
...(frames?.length ? { frames } : {}),
|
|
330
|
+
...(fingerprints?.length ? { fingerprints } : {}),
|
|
331
|
+
...(fingerprintRecord ? { fingerprintRecord } : {}),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
301
334
|
/** ISO range across every signal in the batch — the replay hint on drops. */
|
|
302
335
|
function signalRange(occurrences, metricPoints, llmCalls) {
|
|
303
336
|
const timestamps = [
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ClickHouseClient } from "@clickhouse/client";
|
|
2
|
+
import { type SourceMapCheck } from "./source-maps.js";
|
|
3
|
+
/**
|
|
4
|
+
* Source map lookups against `runtime_source_maps` (EI-13). Uploads come
|
|
5
|
+
* from `autter-runtime sourcemaps upload` / the bundler plugins, or any
|
|
6
|
+
* client of `POST /v1/sourcemaps`.
|
|
7
|
+
*/
|
|
8
|
+
export interface SourceMapKey {
|
|
9
|
+
orgId: string;
|
|
10
|
+
repositoryId: string;
|
|
11
|
+
release: string | null;
|
|
12
|
+
filename: string;
|
|
13
|
+
debugId?: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** The stored map JSON for a minified file: by debug id first when given, else by release + filename (path only, no origin/query). Null when none. */
|
|
16
|
+
export declare function lookupSourceMap(client: ClickHouseClient, database: string, key: SourceMapKey): Promise<string | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Re-uploading a map refreshes its 30-day TTL, so a map only counts as
|
|
19
|
+
* already uploaded when it was stored this recently; older ones are sent
|
|
20
|
+
* again and stay alive while the release is still being built.
|
|
21
|
+
*/
|
|
22
|
+
export declare const SOURCE_MAP_FRESH_DAYS = 7;
|
|
23
|
+
/**
|
|
24
|
+
* Which check entries the tenant already has: same release, path, debug id
|
|
25
|
+
* and content hash, uploaded in the last SOURCE_MAP_FRESH_DAYS. Returns the
|
|
26
|
+
* indexes of `check.entries` that need no upload.
|
|
27
|
+
*/
|
|
28
|
+
export declare function presentSourceMaps(client: ClickHouseClient, database: string, tenant: {
|
|
29
|
+
orgId: string;
|
|
30
|
+
repositoryId: string;
|
|
31
|
+
}, check: SourceMapCheck): Promise<number[]>;
|