@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.
Files changed (52) hide show
  1. package/README.md +51 -0
  2. package/dist/breadcrumbs.d.ts +41 -0
  3. package/dist/breadcrumbs.js +151 -0
  4. package/dist/clickhouse.d.ts +42 -5
  5. package/dist/clickhouse.js +168 -7
  6. package/dist/compat-manifest.json +123 -0
  7. package/dist/compat.d.ts +149 -0
  8. package/dist/compat.js +221 -0
  9. package/dist/config.d.ts +23 -0
  10. package/dist/config.js +19 -0
  11. package/dist/context.js +24 -11
  12. package/dist/error-fields.d.ts +41 -0
  13. package/dist/error-fields.js +103 -0
  14. package/dist/fingerprint-override.d.ts +42 -0
  15. package/dist/fingerprint-override.js +76 -0
  16. package/dist/fingerprint-v3.d.ts +54 -0
  17. package/dist/fingerprint-v3.js +96 -0
  18. package/dist/fingerprint.d.ts +54 -0
  19. package/dist/fingerprint.js +46 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +5 -3
  22. package/dist/llm.js +16 -3
  23. package/dist/log-promotion.d.ts +55 -0
  24. package/dist/log-promotion.js +119 -0
  25. package/dist/logs.d.ts +57 -1
  26. package/dist/logs.js +148 -11
  27. package/dist/memory.d.ts +2 -2
  28. package/dist/migrations.d.ts +13 -0
  29. package/dist/migrations.js +75 -2
  30. package/dist/normalize-browser.d.ts +67 -22
  31. package/dist/normalize-browser.js +46 -19
  32. package/dist/normalize-otlp.js +18 -2
  33. package/dist/redact.d.ts +33 -0
  34. package/dist/redact.js +96 -0
  35. package/dist/sdk-versions.d.ts +58 -0
  36. package/dist/sdk-versions.js +167 -0
  37. package/dist/server.d.ts +3 -0
  38. package/dist/server.js +219 -15
  39. package/dist/sink.d.ts +13 -0
  40. package/dist/sink.js +37 -4
  41. package/dist/source-maps-lookup.d.ts +31 -0
  42. package/dist/source-maps-lookup.js +64 -0
  43. package/dist/source-maps.d.ts +63 -2
  44. package/dist/source-maps.js +121 -13
  45. package/dist/stack-fixtures.d.ts +10 -2
  46. package/dist/stack-fixtures.js +13 -3
  47. package/dist/stack-frames.d.ts +48 -0
  48. package/dist/stack-frames.js +441 -0
  49. package/dist/symbolicate.d.ts +79 -0
  50. package/dist/symbolicate.js +257 -0
  51. package/dist/types.d.ts +86 -0
  52. 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, fingerprintOccurrence, occurrenceIdFor, } from "./fingerprint.js";
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
- function fingerprintAll(ctx, inputs) {
126
- return inputs.map((input, index) => {
127
- const fingerprint = fingerprintOccurrence(input);
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
- ...input,
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
- logs = normalizeLogs(req.is("application/x-protobuf") ? decodeLogsRequest(req.body) : req.body);
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 store.insertLogs(ctx, logs);
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
- // 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.
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((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,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[]>;