@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/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, 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
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((input, index) => {
127
- 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);
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
- logs = normalizeLogs(req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body);
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 store.insertLogs(ctx, logs);
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
- // Logs are diagnostic evidence. Exceptions and failed outcomes use the trace sink,
286
- // so one operation does not create duplicate issues through two export paths.
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((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.4.0",
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",