@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.
@@ -1,5 +1,8 @@
1
1
  import { z } from "zod";
2
+ import { liftErrorFields } from "./error-fields.js";
3
+ import { sanitizeRuntimeContext } from "./context.js";
2
4
  import { normalizeRoute } from "./fingerprint.js";
5
+ import { scrubText } from "./redact.js";
3
6
  import { asSeverity, } from "./types.js";
4
7
  /**
5
8
  * The compact browser payload (`version: 1`) emitted by
@@ -34,6 +37,14 @@ const browserEventSchema = z.object({
34
37
  column: z.number().int().nonnegative().optional(),
35
38
  /** Path only; query strings are stripped defensively anyway. */
36
39
  route: z.string().max(1000).optional(),
40
+ /**
41
+ * Free-form, scrubbed context. runtime-browser ≥1.4.0 also sends the
42
+ * declared-error keys `autter.error.code|why|fix|link|expected` (from
43
+ * coded errors / `autterErrorFromResponse`) and `autter.request.id` (the
44
+ * `x-request-id` of a failed fetch/XHR). They are validated one by one
45
+ * in normalizeBrowserPayload — a malformed value is dropped on its own
46
+ * instead of rejecting the whole beacon.
47
+ */
37
48
  context: z.record(z.unknown()).optional(),
38
49
  });
39
50
  export const browserPayloadSchema = z.object({
@@ -42,6 +53,8 @@ export const browserPayloadSchema = z.object({
42
53
  service: z.string().min(1).max(200),
43
54
  environment: z.string().min(1).max(100),
44
55
  release: z.string().max(200).optional(),
56
+ /** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
57
+ sdk: z.string().regex(/^\d{1,6}\.\d{1,6}\.\d{1,6}[0-9A-Za-z.+-]{0,20}$/).optional().catch(undefined),
45
58
  events: z.array(browserEventSchema).max(50),
46
59
  });
47
60
  const TYPE_TO_ERROR_TYPE = {
@@ -53,25 +66,14 @@ const TYPE_TO_ERROR_TYPE = {
53
66
  csp_violation: "CspViolation",
54
67
  };
55
68
  // Content-level gate for the free-form `context` bag. The schema whitelist
56
- // above is structural; this masks obvious PII/secrets inside whatever a
57
- // (possibly outdated) SDK still sends: values under sensitive-looking keys
58
- // and email-shaped strings — mirroring redactAttributes() in
59
- // @autter/runtime-node and redactContext() in @autter/runtime-browser.
60
- const SENSITIVE_KEY_RE = /email|pass|token|secret|^auth([-_.]|$)|authorization|bearer|cookie|credential|api[-_.]?key|ssn|cvv|card([-_. ]?(number|num|no))?$/i;
61
- const EMAIL_VALUE_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
69
+ // above is structural; this masks secrets/PII inside whatever a (possibly
70
+ // outdated) SDK still sends — sensitive keys and secret-shaped values at any
71
+ // depth, via the same sanitiser as OTLP attributes. Message, stack, and route
72
+ // are scrubbed centrally before fingerprinting (server.ts fingerprintAll).
73
+ const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
62
74
  const REDACTED = "[redacted]";
63
75
  function scrubContext(context) {
64
- const out = {};
65
- for (const [key, value] of Object.entries(context)) {
66
- if (value === undefined || value === null)
67
- continue;
68
- out[key] = SENSITIVE_KEY_RE.test(key)
69
- ? REDACTED
70
- : typeof value === "string"
71
- ? value.replace(EMAIL_VALUE_RE, REDACTED)
72
- : value;
73
- }
74
- return out;
76
+ return sanitizeRuntimeContext(Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined && value !== null)));
75
77
  }
76
78
  /** Default severity per event type when the SDK doesn't say. */
77
79
  const TYPE_TO_SEVERITY = {
@@ -86,6 +88,7 @@ export function normalizeBrowserPayload(payload) {
86
88
  const occurrences = [];
87
89
  const rollups = new Map();
88
90
  function bumpRollup(route, occurredAt, counts) {
91
+ route = scrubText(route);
89
92
  const bucketAt = new Date(Math.floor(occurredAt.getTime() / 60_000) * 60_000);
90
93
  const key = `${route} ${bucketAt.getTime()}`;
91
94
  const existing = rollups.get(key);
@@ -125,7 +128,7 @@ export function normalizeBrowserPayload(payload) {
125
128
  const name = event.name === "browser.longtask" ? "browser.longtask"
126
129
  : `browser.resource:${normalizeRoute((event.name ?? "").replace(/^browser\.resource:/, "").split("?")[0].replace(EMAIL_VALUE_RE, REDACTED))}`;
127
130
  bumpRollup(name, occurredAt, { requestCount: 1 });
128
- const point = rollups.get(`${name} ${Math.floor(occurredAt.getTime() / 60_000) * 60_000}`);
131
+ const point = rollups.get(`${scrubText(name)} ${Math.floor(occurredAt.getTime() / 60_000) * 60_000}`);
129
132
  if (point)
130
133
  point.durationSumMs += event.durationMs ?? 0;
131
134
  continue;
@@ -162,6 +165,7 @@ export function normalizeBrowserPayload(payload) {
162
165
  ...(event.context ? { context: scrubContext(event.context) } : {}),
163
166
  },
164
167
  occurredAt,
168
+ ...liftErrorFields(event.context),
165
169
  });
166
170
  }
167
171
  return { occurrences, metricPoints: [...rollups.values()] };
@@ -1,8 +1,10 @@
1
1
  import { decodeOtlpAttributes, sanitizeRuntimeContext } from "./context.js";
2
+ import { liftErrorFields, validRequestId } from "./error-fields.js";
3
+ import { scrubText } from "./redact.js";
2
4
  import { normalizeRoute } from "./fingerprint.js";
3
5
  import { extractLlmCall } from "./llm.js";
4
6
  import { asSeverity, } from "./types.js";
5
- const EMAIL_VALUE_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
7
+ const EMAIL_VALUE_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
6
8
  function attrMap(attributes) {
7
9
  const map = new Map();
8
10
  for (const attr of attributes ?? []) {
@@ -127,7 +129,7 @@ export function normalizeTraces(request) {
127
129
  traceId: span.traceId ?? "",
128
130
  spanId: span.spanId ?? "",
129
131
  parentSpanId: span.parentSpanId ?? null,
130
- name: span.name ?? "unnamed",
132
+ name: scrubText(span.name ?? "unnamed").slice(0, 1000),
131
133
  kind,
132
134
  status: isError ? "error" : "ok",
133
135
  route: route ? normalizeRoute(route) : null,
@@ -167,6 +169,9 @@ export function normalizeTraces(request) {
167
169
  attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
168
170
  ...(handled ? { "autter.handled": true, "autter.sampled": eventAttrs.get("autter.sampled") === "true" } : {}) }),
169
171
  occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
172
+ // Event attributes first: one span can carry several
173
+ // exceptions, each with its own code.
174
+ ...liftErrorFields(eventAttrs, attrs),
170
175
  });
171
176
  }
172
177
  const failedOutcomes = (span.events ?? []).filter((event) => {
@@ -187,6 +192,7 @@ export function normalizeTraces(request) {
187
192
  traceId: span.traceId ?? null, sessionId: null,
188
193
  attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes) }),
189
194
  occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
195
+ ...liftErrorFields(outcome, attrs),
190
196
  });
191
197
  }
192
198
  if (exceptionEvents.length === 0 && failedOutcomes.length === 0 && isError) {
@@ -209,6 +215,7 @@ export function normalizeTraces(request) {
209
215
  sessionId: null,
210
216
  attributes: decodeOtlpAttributes(span.attributes),
211
217
  occurredAt: startedAt,
218
+ ...liftErrorFields(attrs),
212
219
  });
213
220
  }
214
221
  // LLM provider calls (GenAI semconv, Vercel AI SDK telemetry,
@@ -305,6 +312,11 @@ function inheritHttpFromParent(occurrences, occurrenceSpanIds, spans) {
305
312
  continue;
306
313
  if (!occurrence.route && http.route)
307
314
  occurrence.route = http.route;
315
+ // Request ids live on the request (server) span; child error spans
316
+ // from captureException inherit it so the issue links to the request.
317
+ const requestId = validRequestId(http.attributes?.["autter.request.id"]);
318
+ if (!occurrence.requestId && requestId)
319
+ occurrence.requestId = requestId;
308
320
  const method = methodFromSpan(http);
309
321
  if (!occurrence.method && method)
310
322
  occurrence.method = method;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Server-side secret/PII scrubbing — the ingester's second line of defence.
3
+ *
4
+ * The SDKs scrub before anything leaves the customer's process, but the
5
+ * ingester also accepts older SDK versions and arbitrary third-party OTLP
6
+ * senders (any OTel SDK in any language) that scrub nothing. Everything this
7
+ * module touches is written to ClickHouse and forwarded to the sink webhook,
8
+ * which is where error context is later read for LLM-generated fixes — so
9
+ * scrubbing here, before storage, covers every downstream consumer.
10
+ *
11
+ * Same patterns as redactText() in @autter/runtime-node; parity is enforced
12
+ * by the shared test-vectors/redaction.json.
13
+ */
14
+ import type { RuntimeOccurrenceInput } from "./types.js";
15
+ /** Keys whose values are masked wholesale (tested case-insensitively). */
16
+ export declare const SENSITIVE_KEY_RE: RegExp;
17
+ /**
18
+ * Deployment-specific patterns on top of the built-ins (env
19
+ * AUTTER_REDACT_VALUE_PATTERNS / AUTTER_REDACT_KEY_PATTERNS, JSON arrays of
20
+ * regex sources). Server-side scrubbing has no off switch: it is the
21
+ * backstop for senders that don't scrub.
22
+ */
23
+ export declare function configureRedaction(options: {
24
+ redactValuePatterns?: string[];
25
+ redactKeyPatterns?: string[];
26
+ }): void;
27
+ export declare function isSensitiveKey(key: string): boolean;
28
+ export declare function scrubText(value: string): string;
29
+ /** Nullable convenience for optional columns. */
30
+ export declare function scrubOptional(value: string | null): string | null;
31
+ /** Scrub the free-text fields of an occurrence (message, stack, route,
32
+ * error type). Returns a copy. */
33
+ export declare function scrubOccurrence(occurrence: RuntimeOccurrenceInput): RuntimeOccurrenceInput;
package/dist/redact.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Server-side secret/PII scrubbing — the ingester's second line of defence.
3
+ *
4
+ * The SDKs scrub before anything leaves the customer's process, but the
5
+ * ingester also accepts older SDK versions and arbitrary third-party OTLP
6
+ * senders (any OTel SDK in any language) that scrub nothing. Everything this
7
+ * module touches is written to ClickHouse and forwarded to the sink webhook,
8
+ * which is where error context is later read for LLM-generated fixes — so
9
+ * scrubbing here, before storage, covers every downstream consumer.
10
+ *
11
+ * Same patterns as redactText() in @autter/runtime-node; parity is enforced
12
+ * by the shared test-vectors/redaction.json.
13
+ */
14
+ const MASK = "[redacted]";
15
+ const PRIVATE_KEY_BLOCK_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g;
16
+ const PREFIXED_SECRET_RE = /\b(?:sk-[A-Za-z0-9_-]{20,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}|whsec_[A-Za-z0-9]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,}|(?:AKIA|ASIA)[0-9A-Z]{16}|npm_[A-Za-z0-9]{36}|autter_(?:rt|pat)_[A-Za-z0-9_-]{10,}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})/g;
17
+ // Lookbehind, not \b: a \b start at every "eyJ" after a "-" made this
18
+ // quadratic ("eyJ-" x 16k took 3.4 s per string).
19
+ const JWT_RE = /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g;
20
+ const AUTH_SCHEME_RE = /\b(?:bearer\s+[A-Za-z0-9._~+/=-]{10,}|basic\s+(?=[A-Za-z0-9+/]*[0-9+/=])[A-Za-z0-9+/]{8,}={0,2})/gi;
21
+ const HEADER_VALUE_RE = /((?:^|[^\w-])(?:proxy-)?(?:authorization|(?:set-)?cookie)["']?\s*[:=]\s*["']?)[^"'\r\n]+/gim;
22
+ const SECRET_ASSIGNMENT_RE = /(password|passwd|pwd|passphrase|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credential|signature|session[_-]?id|sessionid|ssn)(["']?\s*[:=]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s"'&,;)}\]\[<>]+)/gi;
23
+ const URL_CREDENTIALS_RE = /\b([a-z][a-z0-9+.-]{0,31}:\/\/)(?:[^\s/:@"'<>]*:[^\s/"'<>]*|[^\s/:@"'<>]{16,})@/gi;
24
+ const CARD_RE = /\b(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d){9,15}\b/g;
25
+ const EMAIL_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi;
26
+ /** Keys whose values are masked wholesale (tested case-insensitively). */
27
+ export const SENSITIVE_KEY_RE = /password|passwd|passphrase|(^|[._-])pwd$|secret|token|credential|authori[sz]ation|^auth(-|_|$)|bearer|cookie|e-?mail|phone|(^|[^a-z])ssn($|[^a-z])|cvv|card[._-]?number|connection[._-]?string|(^|[._-])dsn$|(api|access|private|client|signing|encryption)[._-]?key|apikey|(^|[._-])session$|^(j|php)?sess(ion)?id$|^sid$|connect\.sid|request[._-]?body|response[._-]?body|headers|url[._-]?query/i;
28
+ let extraValuePatterns = [];
29
+ let extraKeyPatterns = [];
30
+ /**
31
+ * Deployment-specific patterns on top of the built-ins (env
32
+ * AUTTER_REDACT_VALUE_PATTERNS / AUTTER_REDACT_KEY_PATTERNS, JSON arrays of
33
+ * regex sources). Server-side scrubbing has no off switch: it is the
34
+ * backstop for senders that don't scrub.
35
+ */
36
+ export function configureRedaction(options) {
37
+ extraValuePatterns = (options.redactValuePatterns ?? []).map((source) => new RegExp(source, "gi"));
38
+ extraKeyPatterns = (options.redactKeyPatterns ?? []).map((source) => new RegExp(source, "i"));
39
+ }
40
+ export function isSensitiveKey(key) {
41
+ return SENSITIVE_KEY_RE.test(key) || extraKeyPatterns.some((re) => re.test(key));
42
+ }
43
+ function luhnValid(candidate) {
44
+ const digits = candidate.replace(/\D/g, "");
45
+ if (digits.length < 13 || digits.length > 19)
46
+ return false;
47
+ let sum = 0;
48
+ for (let i = 0; i < digits.length; i++) {
49
+ let d = digits.charCodeAt(digits.length - 1 - i) - 48;
50
+ if (i % 2 === 1) {
51
+ d *= 2;
52
+ if (d > 9)
53
+ d -= 9;
54
+ }
55
+ sum += d;
56
+ }
57
+ return sum % 10 === 0;
58
+ }
59
+ /** Scrub secrets/PII embedded in one string; the rest of it survives. */
60
+ /** Longest string scanned (same cap as the Node SDK). Callers that store
61
+ * less cut first; anything longer is truncated rather than stored unscanned. */
62
+ const MAX_SCRUB_CHARS = 64 * 1024;
63
+ export function scrubText(value) {
64
+ if (typeof value !== "string" || value === "")
65
+ return value;
66
+ let out = (value.length > MAX_SCRUB_CHARS ? value.slice(0, MAX_SCRUB_CHARS) : value)
67
+ .replace(PRIVATE_KEY_BLOCK_RE, MASK)
68
+ .replace(PREFIXED_SECRET_RE, MASK)
69
+ .replace(JWT_RE, MASK)
70
+ .replace(AUTH_SCHEME_RE, MASK)
71
+ .replace(HEADER_VALUE_RE, (_m, head) => head + MASK)
72
+ .replace(SECRET_ASSIGNMENT_RE, (_m, key, sep, val) => key + sep + (val[0] === '"' || val[0] === "'" ? val[0] + MASK + val[0] : MASK))
73
+ .replace(URL_CREDENTIALS_RE, (_m, scheme) => `${scheme}${MASK}@`)
74
+ .replace(CARD_RE, (m) => (luhnValid(m) ? MASK : m))
75
+ .replace(EMAIL_RE, MASK);
76
+ for (const re of extraValuePatterns) {
77
+ re.lastIndex = 0;
78
+ out = out.replace(re, MASK);
79
+ }
80
+ return out;
81
+ }
82
+ /** Nullable convenience for optional columns. */
83
+ export function scrubOptional(value) {
84
+ return value === null ? null : scrubText(value);
85
+ }
86
+ /** Scrub the free-text fields of an occurrence (message, stack, route,
87
+ * error type). Returns a copy. */
88
+ export function scrubOccurrence(occurrence) {
89
+ return {
90
+ ...occurrence,
91
+ errorType: scrubText(occurrence.errorType),
92
+ message: scrubText(occurrence.message),
93
+ stack: scrubOptional(occurrence.stack),
94
+ route: scrubOptional(occurrence.route),
95
+ };
96
+ }
@@ -0,0 +1,58 @@
1
+ import type { IngestContext } from "./types.js";
2
+ /** This ingester's own version (package.json — present in the npm package
3
+ * and the Docker image). */
4
+ export declare function ingesterVersion(): string;
5
+ export interface SdkSighting {
6
+ service: string;
7
+ environment: string;
8
+ sdkName: string;
9
+ sdkVersion: string;
10
+ sdkLanguage: string;
11
+ }
12
+ interface KeyValue {
13
+ key?: string;
14
+ value?: {
15
+ stringValue?: string;
16
+ };
17
+ }
18
+ interface ResourceHolder {
19
+ resource?: {
20
+ attributes?: KeyValue[];
21
+ };
22
+ }
23
+ export interface OtlpResourceRequest {
24
+ resourceSpans?: ResourceHolder[];
25
+ resourceMetrics?: ResourceHolder[];
26
+ resourceLogs?: ResourceHolder[];
27
+ }
28
+ /** SDK identity per OTLP resource. Reads only a handful of resource keys. */
29
+ export declare function sdkSightingsFromOtlp(request: OtlpResourceRequest | null | undefined): SdkSighting[];
30
+ export declare const BROWSER_SDK_NAME = "@autter/runtime-browser";
31
+ export interface SdkVersionRow extends SdkSighting {
32
+ ingesterVersion: string;
33
+ seenAt: Date;
34
+ }
35
+ export interface SdkVersionTrackerOptions {
36
+ /** Re-write an unchanged sighting at most this often. Default 1 h. */
37
+ refreshMs?: number;
38
+ /** Dedupe map bound; cleared (not grown) past this. Default 10 000. */
39
+ maxKeys?: number;
40
+ now?: () => number;
41
+ onError?: (err: unknown) => void;
42
+ }
43
+ export declare class SdkVersionTracker {
44
+ private readonly write;
45
+ private readonly lastWritten;
46
+ private readonly refreshMs;
47
+ private readonly maxKeys;
48
+ private readonly now;
49
+ private readonly onError;
50
+ private lastErrorLog;
51
+ constructor(write: (ctx: IngestContext, rows: SdkVersionRow[]) => Promise<void>, options?: SdkVersionTrackerOptions);
52
+ /** Fire-and-forget. Returns the write promise (resolved) for tests. */
53
+ observe(ctx: IngestContext, sightings: SdkSighting[]): Promise<void>;
54
+ }
55
+ export declare function sdkVersionTableDDL(db: string, ttlDays?: number): string;
56
+ /** One row per ingester version (latest report wins); not tenant data. */
57
+ export declare function ingesterInfoTableDDL(db: string): string;
58
+ export {};
@@ -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;