@autter/otlp-ingester 1.3.4 → 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/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,15 +2,25 @@ 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, fingerprintOccurrence, occurrenceIdFor, } from "./fingerprint.js";
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
- import { decodeMetricsRequest, decodeTraceRequest } from "./otlp-proto.js";
9
+ import { decodeMetricsRequest, decodeTraceRequest, decodeLogsRequest } from "./otlp-proto.js";
10
+ import { normalizeLogs } from "./logs.js";
9
11
  import { decodeProfile } from "./profiles.js";
10
12
  import { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
11
13
  import { validateSourceMap } from "./source-maps.js";
12
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;
13
22
  export function createIngesterApp(config) {
23
+ configureRedaction(config);
14
24
  const store = new ClickHouseStore(config);
15
25
  // Fingerprinted occurrences feed the consumer's issue grouping, metric
16
26
  // points feed the request/error-rate rollups, LLM calls feed spend
@@ -19,8 +29,54 @@ export function createIngesterApp(config) {
19
29
  const keys = new KeyResolver(config);
20
30
  const serverRateLimiter = new RateLimiter(config.rateLimitPerMinute);
21
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
+ }
22
72
  const app = express();
23
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
+ });
24
80
  app.use(express.raw({ limit: "1mb", type: ["application/x-pprof"] }));
25
81
  app.use("/v1/sourcemaps", express.json({ limit: "5mb" }));
26
82
  app.use(express.json({
@@ -48,6 +104,7 @@ export function createIngesterApp(config) {
48
104
  res.setHeader("access-control-allow-origin", "*");
49
105
  res.setHeader("access-control-allow-methods", "POST, OPTIONS");
50
106
  res.setHeader("access-control-allow-headers", "content-type, authorization, x-autter-key");
107
+ res.setHeader("access-control-expose-headers", INGESTER_VERSION_HEADER);
51
108
  res.setHeader("access-control-max-age", "86400");
52
109
  if (req.method === "OPTIONS") {
53
110
  res.status(204).end();
@@ -71,6 +128,36 @@ export function createIngesterApp(config) {
71
128
  res.status(503).json({ ok: false, clickhouse: "down", ...sinkStats });
72
129
  }
73
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
+ });
74
161
  /** Auth + scope + rate limit; returns null (response sent) on failure. */
75
162
  async function authenticate(req, res, surface) {
76
163
  // A storage-less ingester must refuse, not accept-and-drop: exporters
@@ -122,16 +209,48 @@ export function createIngesterApp(config) {
122
209
  * the sink consumer's per-occurrence dedupe holds across transport
123
210
  * retries and duplicated ClickHouse rows stay identifiable. */
124
211
  function fingerprintAll(ctx, inputs) {
125
- return inputs.map((input, index) => {
126
- const fingerprint = fingerprintOccurrence(input);
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);
127
220
  return {
128
221
  ...input,
129
222
  occurrenceId: occurrenceIdFor(ctx, input, fingerprint, index),
130
223
  fingerprint,
224
+ fingerprintScheme: scheme,
131
225
  ...deriveFields(input),
132
226
  };
133
227
  });
134
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
+ }
135
254
  function storageError(res, err) {
136
255
  console.error("clickhouse write failed:", err);
137
256
  res.status(503).json({ error: "storage unavailable, retry later" });
@@ -260,6 +379,42 @@ export function createIngesterApp(config) {
260
379
  return;
261
380
  }
262
381
  sink?.enqueue(ctx, fingerprinted, metricPoints, llmCalls);
382
+ recordSdks(ctx, request);
383
+ otlpSuccess(req, res);
384
+ });
385
+ app.post("/v1/logs", async (req, res) => {
386
+ const ctx = await authenticate(req, res, "otlp");
387
+ if (!ctx)
388
+ return;
389
+ let logs;
390
+ let logsRequest;
391
+ try {
392
+ logsRequest = req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body;
393
+ logs = normalizeLogs(logsRequest);
394
+ }
395
+ catch {
396
+ res.status(400).json({ error: "invalid OTLP logs payload" });
397
+ return;
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);
405
+ try {
406
+ await Promise.all([
407
+ store.insertLogs(ctx, logs),
408
+ store.insertOccurrences(ctx, promoted),
409
+ store.insertMetricPoints(ctx, metricPoints),
410
+ ]);
411
+ }
412
+ catch (err) {
413
+ storageError(res, err);
414
+ return;
415
+ }
416
+ sink?.enqueue(ctx, promoted, metricPoints);
417
+ recordSdks(ctx, logsRequest);
263
418
  otlpSuccess(req, res);
264
419
  });
265
420
  app.post("/v1/metrics", async (req, res) => {
@@ -291,6 +446,7 @@ export function createIngesterApp(config) {
291
446
  return;
292
447
  }
293
448
  sink?.enqueue(ctx, [], metricPoints);
449
+ recordSdks(ctx, request);
294
450
  otlpSuccess(req, res);
295
451
  });
296
452
  app.post("/v1/browser", async (req, res) => {
@@ -329,6 +485,16 @@ export function createIngesterApp(config) {
329
485
  return;
330
486
  }
331
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();
332
498
  res.status(202).json({ accepted: fingerprinted.length });
333
499
  });
334
500
  // Body-parser errors (oversized/malformed JSON) → clean 4xx, not a stack.
@@ -346,5 +512,5 @@ export function createIngesterApp(config) {
346
512
  console.error("unhandled error:", err);
347
513
  res.status(500).json({ error: "internal error" });
348
514
  });
349
- return { app, store, sink };
515
+ return { app, store, sink, reportIngesterInfo };
350
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((o) => ({
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.4",
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",