@autter/otlp-ingester 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/clickhouse.d.ts +33 -0
- package/dist/clickhouse.js +144 -5
- package/dist/compat-manifest.json +94 -0
- package/dist/compat.d.ts +147 -0
- package/dist/compat.js +219 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +18 -0
- package/dist/context.js +20 -10
- package/dist/error-fields.d.ts +41 -0
- package/dist/error-fields.js +103 -0
- package/dist/fingerprint.d.ts +29 -0
- package/dist/fingerprint.js +45 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -3
- package/dist/llm.js +16 -3
- package/dist/log-promotion.d.ts +55 -0
- package/dist/log-promotion.js +119 -0
- package/dist/logs.d.ts +57 -1
- package/dist/logs.js +146 -11
- package/dist/migrations.d.ts +3 -0
- package/dist/migrations.js +43 -1
- package/dist/normalize-browser.d.ts +16 -4
- package/dist/normalize-browser.js +22 -18
- package/dist/normalize-otlp.js +14 -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 +150 -8
- package/dist/sink.d.ts +9 -0
- package/dist/sink.js +26 -4
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -2,7 +2,8 @@ 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, occurrenceFingerprint, occurrenceIdFor, } from "./fingerprint.js";
|
|
6
|
+
import { logPromotionCandidates, promotionLookups, promotionRollups, } from "./log-promotion.js";
|
|
6
7
|
import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
|
|
7
8
|
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
8
9
|
import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest } from "./otlp-proto.js";
|
|
@@ -11,7 +12,15 @@ import { decodeProfile } from "./profiles.js";
|
|
|
11
12
|
import { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
|
|
12
13
|
import { validateSourceMap } from "./source-maps.js";
|
|
13
14
|
import { SinkForwarder } from "./sink.js";
|
|
15
|
+
import { configureRedaction, scrubOccurrence } from "./redact.js";
|
|
16
|
+
import { buildCompatReport, evaluateCompat, INGESTER_VERSION_HEADER, } from "./compat.js";
|
|
17
|
+
import { MIGRATIONS } from "./migrations.js";
|
|
18
|
+
import { BROWSER_SDK_NAME, ingesterVersion, SdkVersionTracker, sdkSightingsFromOtlp, } from "./sdk-versions.js";
|
|
19
|
+
/** Re-report the ingester version at most this often (keeps the row inside
|
|
20
|
+
* its TTL for long-running ingesters). */
|
|
21
|
+
const INGESTER_INFO_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
14
22
|
export function createIngesterApp(config) {
|
|
23
|
+
configureRedaction(config);
|
|
15
24
|
const store = new ClickHouseStore(config);
|
|
16
25
|
// Fingerprinted occurrences feed the consumer's issue grouping, metric
|
|
17
26
|
// points feed the request/error-rate rollups, LLM calls feed spend
|
|
@@ -20,8 +29,54 @@ export function createIngesterApp(config) {
|
|
|
20
29
|
const keys = new KeyResolver(config);
|
|
21
30
|
const serverRateLimiter = new RateLimiter(config.rateLimitPerMinute);
|
|
22
31
|
const clientRateLimiter = new RateLimiter(config.clientRateLimitPerMinute);
|
|
32
|
+
// ClickHouse dedupe lookups made by /v1/logs promotion, per tenant. Far
|
|
33
|
+
// tighter than the request limit: each lookup is a query, not an insert.
|
|
34
|
+
const promotionLookupLimiter = new RateLimiter(config.promotionLookupsPerMinute ?? 30);
|
|
35
|
+
const version = ingesterVersion();
|
|
36
|
+
const sdkVersions = new SdkVersionTracker((ctx, rows) => store.insertSdkVersions(ctx, rows));
|
|
37
|
+
const allMigrationIds = MIGRATIONS.map((migration) => migration.id);
|
|
38
|
+
const compatReport = () => {
|
|
39
|
+
const schema = store.schemaStatus();
|
|
40
|
+
return buildCompatReport({
|
|
41
|
+
version,
|
|
42
|
+
schemaStatus: schema.status,
|
|
43
|
+
applied: schema.applied,
|
|
44
|
+
allMigrations: allMigrationIds,
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
let lastInfoReport = 0;
|
|
48
|
+
async function reportIngesterInfo() {
|
|
49
|
+
if (!store.configured)
|
|
50
|
+
return;
|
|
51
|
+
if (Date.now() - lastInfoReport < INGESTER_INFO_INTERVAL_MS)
|
|
52
|
+
return;
|
|
53
|
+
lastInfoReport = Date.now();
|
|
54
|
+
try {
|
|
55
|
+
// Only reached from boot and authenticated ingest, which run the
|
|
56
|
+
// (memoized) schema bootstrap anyway.
|
|
57
|
+
await store.ensureSchema();
|
|
58
|
+
const report = compatReport();
|
|
59
|
+
await store.insertIngesterInfo({ version, schemaLevel: report.schema.level, report });
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// Retry in a minute, not on every request.
|
|
63
|
+
lastInfoReport = Date.now() - INGESTER_INFO_INTERVAL_MS + 60_000;
|
|
64
|
+
console.warn("ingester info report failed:", err?.message ?? err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Fire-and-forget after a successful ingest: never delays or fails it. */
|
|
68
|
+
function recordSdks(ctx, request) {
|
|
69
|
+
void sdkVersions.observe(ctx, sdkSightingsFromOtlp(request));
|
|
70
|
+
void reportIngesterInfo();
|
|
71
|
+
}
|
|
23
72
|
const app = express();
|
|
24
73
|
app.disable("x-powered-by");
|
|
74
|
+
// Every response names the ingester version, so SDKs (and the browser
|
|
75
|
+
// relay) learn it for free from responses they already receive.
|
|
76
|
+
app.use((_req, res, next) => {
|
|
77
|
+
res.setHeader(INGESTER_VERSION_HEADER, version);
|
|
78
|
+
next();
|
|
79
|
+
});
|
|
25
80
|
app.use(express.raw({ limit: "1mb", type: ["application/x-pprof"] }));
|
|
26
81
|
app.use("/v1/sourcemaps", express.json({ limit: "5mb" }));
|
|
27
82
|
app.use(express.json({
|
|
@@ -49,6 +104,7 @@ export function createIngesterApp(config) {
|
|
|
49
104
|
res.setHeader("access-control-allow-origin", "*");
|
|
50
105
|
res.setHeader("access-control-allow-methods", "POST, OPTIONS");
|
|
51
106
|
res.setHeader("access-control-allow-headers", "content-type, authorization, x-autter-key");
|
|
107
|
+
res.setHeader("access-control-expose-headers", INGESTER_VERSION_HEADER);
|
|
52
108
|
res.setHeader("access-control-max-age", "86400");
|
|
53
109
|
if (req.method === "OPTIONS") {
|
|
54
110
|
res.status(204).end();
|
|
@@ -72,6 +128,36 @@ export function createIngesterApp(config) {
|
|
|
72
128
|
res.status(503).json({ ok: false, clickhouse: "down", ...sinkStats });
|
|
73
129
|
}
|
|
74
130
|
});
|
|
131
|
+
/**
|
|
132
|
+
* Version compatibility (public, like /healthz; no tenant data): this
|
|
133
|
+
* ingester's version, its ClickHouse schema level, and every feature in
|
|
134
|
+
* the compat manifest with whether it is available here. Optional
|
|
135
|
+
* `?features=a,b&sdk=<name>@<version>` adds the evaluated `issues`.
|
|
136
|
+
* Never triggers schema DDL.
|
|
137
|
+
*/
|
|
138
|
+
app.get("/v1/compat", (req, res) => {
|
|
139
|
+
const report = compatReport();
|
|
140
|
+
const features = typeof req.query.features === "string"
|
|
141
|
+
? req.query.features.split(",").map((f) => f.trim()).filter(Boolean).slice(0, 50)
|
|
142
|
+
: [];
|
|
143
|
+
const sdkParam = typeof req.query.sdk === "string" ? req.query.sdk.slice(0, 120) : "";
|
|
144
|
+
const at = sdkParam.lastIndexOf("@");
|
|
145
|
+
const sdk = at > 0 ? { name: sdkParam.slice(0, at), version: sdkParam.slice(at + 1) } : null;
|
|
146
|
+
const body = features.length || sdk
|
|
147
|
+
? {
|
|
148
|
+
...report,
|
|
149
|
+
issues: evaluateCompat({
|
|
150
|
+
features: features.length
|
|
151
|
+
? features
|
|
152
|
+
: report.features.filter((f) => sdk && f.sdks[sdk.name]).map((f) => f.id),
|
|
153
|
+
ingester: { version, schema: { status: report.schema.status, applied: report.schema.applied } },
|
|
154
|
+
sdk,
|
|
155
|
+
}),
|
|
156
|
+
}
|
|
157
|
+
: report;
|
|
158
|
+
res.setHeader("cache-control", "no-store");
|
|
159
|
+
res.status(200).json(body);
|
|
160
|
+
});
|
|
75
161
|
/** Auth + scope + rate limit; returns null (response sent) on failure. */
|
|
76
162
|
async function authenticate(req, res, surface) {
|
|
77
163
|
// A storage-less ingester must refuse, not accept-and-drop: exporters
|
|
@@ -123,16 +209,48 @@ export function createIngesterApp(config) {
|
|
|
123
209
|
* the sink consumer's per-occurrence dedupe holds across transport
|
|
124
210
|
* retries and duplicated ClickHouse rows stay identifiable. */
|
|
125
211
|
function fingerprintAll(ctx, inputs) {
|
|
126
|
-
return inputs.map((
|
|
127
|
-
|
|
212
|
+
return inputs.map((raw, index) => {
|
|
213
|
+
// Second line of defence, before fingerprinting, storage, and the
|
|
214
|
+
// sink (which feeds LLM fix generation): scrub secrets from the
|
|
215
|
+
// free-text fields old SDKs and third-party OTLP senders pass
|
|
216
|
+
// through verbatim. Attributes were already sanitised by the
|
|
217
|
+
// normalisers (sanitizeRuntimeContext).
|
|
218
|
+
const input = scrubOccurrence(raw);
|
|
219
|
+
const { fingerprint, scheme } = occurrenceFingerprint(input);
|
|
128
220
|
return {
|
|
129
221
|
...input,
|
|
130
222
|
occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
|
|
131
223
|
fingerprint,
|
|
224
|
+
fingerprintScheme: scheme,
|
|
132
225
|
...deriveFields(input),
|
|
133
226
|
};
|
|
134
227
|
});
|
|
135
228
|
}
|
|
229
|
+
/** Fingerprint log-promotion candidates and drop the ones whose trace
|
|
230
|
+
* already produced an occurrence (in this batch, or within ±60 s). Ids
|
|
231
|
+
* are assigned BEFORE the lookup so they stay stable across retries
|
|
232
|
+
* whatever the lookup returns. */
|
|
233
|
+
async function promoteLogs(ctx, logs) {
|
|
234
|
+
const candidates = logPromotionCandidates(logs);
|
|
235
|
+
if (candidates.length === 0)
|
|
236
|
+
return [];
|
|
237
|
+
const fingerprinted = fingerprintAll(ctx, candidates);
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
const ownIds = fingerprinted.map((o) => o.occurrenceId);
|
|
240
|
+
for (const lookup of promotionLookups(candidates)) {
|
|
241
|
+
if (!promotionLookupLimiter.allow(`${ctx.orgId}:${ctx.repositoryId}`))
|
|
242
|
+
break;
|
|
243
|
+
try {
|
|
244
|
+
const found = await store.recentOccurrenceTraceIds(ctx, lookup.traceIds, lookup.from, lookup.to, ownIds);
|
|
245
|
+
for (const traceId of found)
|
|
246
|
+
seen.add(traceId);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
console.warn("log promotion dedupe lookup failed — promoting without it:", err instanceof Error ? err.message : err);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return fingerprinted.filter((o) => !o.traceId || !seen.has(o.traceId));
|
|
253
|
+
}
|
|
136
254
|
function storageError(res, err) {
|
|
137
255
|
console.error("clickhouse write failed:", err);
|
|
138
256
|
res.status(503).json({ error: "storage unavailable, retry later" });
|
|
@@ -261,6 +379,7 @@ export function createIngesterApp(config) {
|
|
|
261
379
|
return;
|
|
262
380
|
}
|
|
263
381
|
sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
|
|
382
|
+
recordSdks(ctx, request);
|
|
264
383
|
otlpSuccess(req, res);
|
|
265
384
|
});
|
|
266
385
|
app.post("/v1/logs", async (req, res) => {
|
|
@@ -268,22 +387,34 @@ export function createIngesterApp(config) {
|
|
|
268
387
|
if (!ctx)
|
|
269
388
|
return;
|
|
270
389
|
let logs;
|
|
390
|
+
let logsRequest;
|
|
271
391
|
try {
|
|
272
|
-
|
|
392
|
+
logsRequest = req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body;
|
|
393
|
+
logs = normalizeLogs(logsRequest);
|
|
273
394
|
}
|
|
274
395
|
catch {
|
|
275
396
|
res.status(400).json({ error: "invalid OTLP logs payload" });
|
|
276
397
|
return;
|
|
277
398
|
}
|
|
399
|
+
// Logs are diagnostic evidence; exceptions and failed outcomes normally
|
|
400
|
+
// reach grouping through the trace path. The exception: logger-only /
|
|
401
|
+
// edge SDKs mark span-less error records `autter.capture.mode = "log"`,
|
|
402
|
+
// and those are promoted to occurrences here — see log-promotion.ts.
|
|
403
|
+
const promoted = await promoteLogs(ctx, logs);
|
|
404
|
+
const metricPoints = promotionRollups(promoted);
|
|
278
405
|
try {
|
|
279
|
-
await
|
|
406
|
+
await Promise.all([
|
|
407
|
+
store.insertLogs(ctx, logs),
|
|
408
|
+
store.insertOccurrences(ctx, promoted),
|
|
409
|
+
store.insertMetricPoints(ctx, metricPoints),
|
|
410
|
+
]);
|
|
280
411
|
}
|
|
281
412
|
catch (err) {
|
|
282
413
|
storageError(res, err);
|
|
283
414
|
return;
|
|
284
415
|
}
|
|
285
|
-
|
|
286
|
-
|
|
416
|
+
sink?.enqueue(ctx, promoted, metricPoints);
|
|
417
|
+
recordSdks(ctx, logsRequest);
|
|
287
418
|
otlpSuccess(req, res);
|
|
288
419
|
});
|
|
289
420
|
app.post("/v1/metrics", async (req, res) => {
|
|
@@ -315,6 +446,7 @@ export function createIngesterApp(config) {
|
|
|
315
446
|
return;
|
|
316
447
|
}
|
|
317
448
|
sink?.enqueue(ctx, [], metricPoints);
|
|
449
|
+
recordSdks(ctx, request);
|
|
318
450
|
otlpSuccess(req, res);
|
|
319
451
|
});
|
|
320
452
|
app.post("/v1/browser", async (req, res) => {
|
|
@@ -353,6 +485,16 @@ export function createIngesterApp(config) {
|
|
|
353
485
|
return;
|
|
354
486
|
}
|
|
355
487
|
sink?.enqueue(ctx, fingerprinted, metricPoints);
|
|
488
|
+
if (parsed.data.sdk) {
|
|
489
|
+
void sdkVersions.observe(ctx, [{
|
|
490
|
+
service: parsed.data.service,
|
|
491
|
+
environment: parsed.data.environment,
|
|
492
|
+
sdkName: BROWSER_SDK_NAME,
|
|
493
|
+
sdkVersion: parsed.data.sdk,
|
|
494
|
+
sdkLanguage: "webjs",
|
|
495
|
+
}]);
|
|
496
|
+
}
|
|
497
|
+
void reportIngesterInfo();
|
|
356
498
|
res.status(202).json({ accepted: fingerprinted.length });
|
|
357
499
|
});
|
|
358
500
|
// Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
|
|
@@ -370,5 +512,5 @@ export function createIngesterApp(config) {
|
|
|
370
512
|
console.error("unhandled error:", err);
|
|
371
513
|
res.status(500).json({ error: "internal error" });
|
|
372
514
|
});
|
|
373
|
-
return { app, store, sink };
|
|
515
|
+
return { app, store, sink, reportIngesterInfo };
|
|
374
516
|
}
|
package/dist/sink.d.ts
CHANGED
|
@@ -101,3 +101,12 @@ 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
|
+
* Older consumers ignore unknown keys, so the version stays 1.
|
|
111
|
+
*/
|
|
112
|
+
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,31 @@ 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
|
+
* Older consumers ignore unknown keys, so the version stays 1.
|
|
305
|
+
*/
|
|
306
|
+
export function sinkOccurrence(o) {
|
|
307
|
+
const { traceId, route, method, statusCode, errorCode, why, fix, link, expected, requestId, ...rest } = o;
|
|
308
|
+
return {
|
|
309
|
+
...rest,
|
|
310
|
+
occurredAt: o.occurredAt.toISOString(),
|
|
311
|
+
...(traceId ? { traceId } : {}),
|
|
312
|
+
...(route ? { route } : {}),
|
|
313
|
+
...(method ? { method } : {}),
|
|
314
|
+
...(statusCode != null ? { statusCode } : {}),
|
|
315
|
+
...(errorCode ? { errorCode } : {}),
|
|
316
|
+
...(why ? { why } : {}),
|
|
317
|
+
...(fix ? { fix } : {}),
|
|
318
|
+
...(link ? { link } : {}),
|
|
319
|
+
...(expected ? { expected: true } : {}),
|
|
320
|
+
...(requestId ? { requestId } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
301
323
|
/** ISO range across every signal in the batch — the replay hint on drops. */
|
|
302
324
|
function signalRange(occurrences, metricPoints, llmCalls) {
|
|
303
325
|
const timestamps = [
|
package/dist/types.d.ts
CHANGED
|
@@ -28,6 +28,22 @@ export interface RuntimeOccurrenceInput {
|
|
|
28
28
|
sessionId: string | null;
|
|
29
29
|
attributes: Record<string, unknown> | null;
|
|
30
30
|
occurredAt: Date;
|
|
31
|
+
/**
|
|
32
|
+
* Declared error metadata (`autter.error.*`, see error-fields.ts) —
|
|
33
|
+
* optional and omitted when absent. A valid `errorCode` switches the
|
|
34
|
+
* fingerprint to the source-independent "code-v1" scheme.
|
|
35
|
+
*/
|
|
36
|
+
errorCode?: string;
|
|
37
|
+
/** Declared cause (≤1000 chars). */
|
|
38
|
+
why?: string;
|
|
39
|
+
/** Declared remedy (≤1000 chars). */
|
|
40
|
+
fix?: string;
|
|
41
|
+
/** Docs link — http(s) only, ≤500 chars. */
|
|
42
|
+
link?: string;
|
|
43
|
+
/** Expected business failure (declines, validation): recorded, never paged. */
|
|
44
|
+
expected?: boolean;
|
|
45
|
+
/** `autter.request.id` — joins the occurrence to its request summary. */
|
|
46
|
+
requestId?: string;
|
|
31
47
|
}
|
|
32
48
|
/**
|
|
33
49
|
* Derived, aggregation-ready fields — computed once at ingest (from the
|
|
@@ -37,6 +53,8 @@ export interface RuntimeOccurrenceInput {
|
|
|
37
53
|
export interface RuntimeOccurrence extends RuntimeOccurrenceInput {
|
|
38
54
|
occurrenceId: string;
|
|
39
55
|
fingerprint: string;
|
|
56
|
+
/** "code-v1" when grouped by a valid error code, else "message-v1". */
|
|
57
|
+
fingerprintScheme: "message-v1" | "code-v1";
|
|
40
58
|
/** normalizeRoute(route) — "/users/:id/orders/:id"; GROUP BY-safe. */
|
|
41
59
|
routeNormalized: string;
|
|
42
60
|
/** normalizeMessage(message) — ids/numbers/strings templated out. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autter/otlp-ingester",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Self-hostable OTLP + browser-error ingest service for Autter Runtime: normalises telemetry into a per-repo ClickHouse data model",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|