@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.
@@ -6,6 +6,8 @@ export declare const browserPayloadSchema: z.ZodObject<{
6
6
  service: z.ZodString;
7
7
  environment: z.ZodString;
8
8
  release: z.ZodOptional<z.ZodString>;
9
+ /** @autter/runtime-browser version (1.4+); recorded for compatibility checks. */
10
+ sdk: z.ZodCatch<z.ZodOptional<z.ZodString>>;
9
11
  events: z.ZodArray<z.ZodObject<{
10
12
  id: z.ZodOptional<z.ZodString>;
11
13
  type: z.ZodEnum<["exception", "unhandled_rejection", "message", "session_start", "track_event", "outcome", "request_failure", "csp_violation", "timing"]>;
@@ -23,9 +25,17 @@ export declare const browserPayloadSchema: z.ZodObject<{
23
25
  column: z.ZodOptional<z.ZodNumber>;
24
26
  /** Path only; query strings are stripped defensively anyway. */
25
27
  route: z.ZodOptional<z.ZodString>;
28
+ /**
29
+ * Free-form, scrubbed context. runtime-browser ≥1.4.0 also sends the
30
+ * declared-error keys `autter.error.code|why|fix|link|expected` (from
31
+ * coded errors / `autterErrorFromResponse`) and `autter.request.id` (the
32
+ * `x-request-id` of a failed fetch/XHR). They are validated one by one
33
+ * in normalizeBrowserPayload — a malformed value is dropped on its own
34
+ * instead of rejecting the whole beacon.
35
+ */
26
36
  context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
27
37
  }, "strip", z.ZodTypeAny, {
28
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
38
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
29
39
  message: string;
30
40
  timestamp: string;
31
41
  durationMs?: number | undefined;
@@ -40,7 +50,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
40
50
  column?: number | undefined;
41
51
  context?: Record<string, unknown> | undefined;
42
52
  }, {
43
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
53
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
44
54
  timestamp: string;
45
55
  durationMs?: number | undefined;
46
56
  route?: string | undefined;
@@ -60,7 +70,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
60
70
  environment: string;
61
71
  version: 1;
62
72
  events: {
63
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
73
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
64
74
  message: string;
65
75
  timestamp: string;
66
76
  durationMs?: number | undefined;
@@ -77,12 +87,13 @@ export declare const browserPayloadSchema: z.ZodObject<{
77
87
  }[];
78
88
  release?: string | undefined;
79
89
  sessionId?: string | undefined;
90
+ sdk?: string | undefined;
80
91
  }, {
81
92
  service: string;
82
93
  environment: string;
83
94
  version: 1;
84
95
  events: {
85
- type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "csp_violation" | "timing";
96
+ type: "exception" | "message" | "request_failure" | "timing" | "outcome" | "csp_violation" | "unhandled_rejection" | "session_start" | "track_event";
86
97
  timestamp: string;
87
98
  durationMs?: number | undefined;
88
99
  route?: string | undefined;
@@ -99,6 +110,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
99
110
  }[];
100
111
  release?: string | undefined;
101
112
  sessionId?: string | undefined;
113
+ sdk?: unknown;
102
114
  }>;
103
115
  export type BrowserPayload = z.infer<typeof browserPayloadSchema>;
104
116
  export interface NormalizedBrowser {
@@ -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,3 +1,4 @@
1
+ import { type OtlpValue } from "./context.js";
1
2
  import { type RuntimeLlmCall, type RuntimeMetricPoint, type RuntimeOccurrenceInput, type RuntimeSpanRow } from "./types.js";
2
3
  /**
3
4
  * OTLP/HTTP JSON → runtime signal. Structural types cover only the fields
@@ -10,12 +11,7 @@ import { type RuntimeLlmCall, type RuntimeMetricPoint, type RuntimeOccurrenceInp
10
11
  */
11
12
  interface OtlpKeyValue {
12
13
  key?: string;
13
- value?: {
14
- stringValue?: string;
15
- intValue?: string | number;
16
- doubleValue?: number;
17
- boolValue?: boolean;
18
- };
14
+ value?: OtlpValue;
19
15
  }
20
16
  interface OtlpEvent {
21
17
  name?: string;
@@ -1,7 +1,10 @@
1
+ import { decodeOtlpAttributes, sanitizeRuntimeContext } from "./context.js";
2
+ import { liftErrorFields, validRequestId } from "./error-fields.js";
3
+ import { scrubText } from "./redact.js";
1
4
  import { normalizeRoute } from "./fingerprint.js";
2
5
  import { extractLlmCall } from "./llm.js";
3
6
  import { asSeverity, } from "./types.js";
4
- 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;
5
8
  function attrMap(attributes) {
6
9
  const map = new Map();
7
10
  for (const attr of attributes ?? []) {
@@ -126,13 +129,14 @@ export function normalizeTraces(request) {
126
129
  traceId: span.traceId ?? "",
127
130
  spanId: span.spanId ?? "",
128
131
  parentSpanId: span.parentSpanId ?? null,
129
- name: span.name ?? "unnamed",
132
+ name: scrubText(span.name ?? "unnamed").slice(0, 1000),
130
133
  kind,
131
134
  status: isError ? "error" : "ok",
132
135
  route: route ? normalizeRoute(route) : null,
133
136
  statusCode,
134
137
  durationMs,
135
138
  attributes: {
139
+ ...decodeOtlpAttributes(span.attributes),
136
140
  "http.request.method": (attrs.get("http.request.method") ?? attrs.get("http.method") ?? "").slice(0, 20),
137
141
  },
138
142
  startedAt,
@@ -162,11 +166,12 @@ export function normalizeTraces(request) {
162
166
  statusCode,
163
167
  traceId: span.traceId ?? null,
164
168
  sessionId: null,
165
- attributes: handled ? {
166
- "autter.handled": true,
167
- "autter.sampled": eventAttrs.get("autter.sampled") === "true",
168
- } : null,
169
+ attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes),
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) => {
@@ -183,9 +188,11 @@ export function normalizeTraces(request) {
183
188
  environment: resource.environment, release: resource.release,
184
189
  errorType: "OutcomeFailure",
185
190
  message: `${name}: ${(outcome.get("autter.outcome.message") ?? "failed").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 1000)}`,
186
- stack: null, route, method: methodOf(attrs), statusCode,
187
- traceId: span.traceId ?? null, sessionId: null, attributes: null,
191
+ stack: String(decodeOtlpAttributes(event.attributes)["autter.outcome.stack"] ?? "") || null, route, method: methodOf(attrs), statusCode,
192
+ traceId: span.traceId ?? null, sessionId: null,
193
+ attributes: sanitizeRuntimeContext({ ...decodeOtlpAttributes(span.attributes), ...decodeOtlpAttributes(event.attributes) }),
188
194
  occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
195
+ ...liftErrorFields(outcome, attrs),
189
196
  });
190
197
  }
191
198
  if (exceptionEvents.length === 0 && failedOutcomes.length === 0 && isError) {
@@ -206,8 +213,9 @@ export function normalizeTraces(request) {
206
213
  statusCode,
207
214
  traceId: span.traceId ?? null,
208
215
  sessionId: null,
209
- attributes: null,
216
+ attributes: decodeOtlpAttributes(span.attributes),
210
217
  occurredAt: startedAt,
218
+ ...liftErrorFields(attrs),
211
219
  });
212
220
  }
213
221
  // LLM provider calls (GenAI semconv, Vercel AI SDK telemetry,
@@ -304,6 +312,11 @@ function inheritHttpFromParent(occurrences, occurrenceSpanIds, spans) {
304
312
  continue;
305
313
  if (!occurrence.route && http.route)
306
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;
307
320
  const method = methodFromSpan(http);
308
321
  if (!occurrence.method && method)
309
322
  occurrence.method = method;
@@ -1,3 +1,5 @@
1
1
  import type { OtlpMetricsRequest, OtlpTraceRequest } from "./normalize-otlp.js";
2
+ import type { OtlpLogsRequest } from "./logs.js";
2
3
  export declare function decodeTraceRequest(body: Buffer): OtlpTraceRequest;
3
4
  export declare function decodeMetricsRequest(body: Buffer): OtlpMetricsRequest;
5
+ export declare function decodeLogsRequest(body: Buffer): OtlpLogsRequest;
@@ -21,10 +21,27 @@ message AnyValue {
21
21
  bool bool_value = 2;
22
22
  int64 int_value = 3;
23
23
  double double_value = 4;
24
+ ArrayValue array_value = 5;
25
+ KeyValueList kvlist_value = 6;
24
26
  }
25
27
  }
28
+ message ArrayValue { repeated AnyValue values = 1; }
29
+ message KeyValueList { repeated KeyValue values = 1; }
26
30
  message KeyValue { string key = 1; AnyValue value = 2; }
27
31
  message Resource { repeated KeyValue attributes = 1; }
32
+ message ExportLogsServiceRequest { repeated ResourceLogs resource_logs = 1; }
33
+ message ResourceLogs { Resource resource = 1; repeated ScopeLogs scope_logs = 2; }
34
+ message ScopeLogs { repeated LogRecord log_records = 2; }
35
+ message LogRecord {
36
+ fixed64 time_unix_nano = 1;
37
+ int32 severity_number = 2;
38
+ string severity_text = 3;
39
+ AnyValue body = 5;
40
+ repeated KeyValue attributes = 6;
41
+ bytes trace_id = 9;
42
+ bytes span_id = 10;
43
+ fixed64 observed_time_unix_nano = 11;
44
+ }
28
45
 
29
46
  message ExportTraceServiceRequest { repeated ResourceSpans resource_spans = 1; }
30
47
  message ResourceSpans { Resource resource = 1; repeated ScopeSpans scope_spans = 2; }
@@ -80,6 +97,7 @@ message HistogramDataPoint {
80
97
  const root = protobuf.parse(PROTO).root;
81
98
  const TraceRequest = root.lookupType("otlp.ExportTraceServiceRequest");
82
99
  const MetricsRequest = root.lookupType("otlp.ExportMetricsServiceRequest");
100
+ const LogsRequest = root.lookupType("otlp.ExportLogsServiceRequest");
83
101
  const TO_OBJECT_OPTIONS = {
84
102
  longs: String, // 64-bit ints → strings (matches OTLP/JSON)
85
103
  defaults: false,
@@ -110,3 +128,6 @@ export function decodeMetricsRequest(body) {
110
128
  const message = MetricsRequest.decode(body);
111
129
  return hexifyIds(MetricsRequest.toObject(message, TO_OBJECT_OPTIONS));
112
130
  }
131
+ export function decodeLogsRequest(body) {
132
+ return hexifyIds(LogsRequest.toObject(LogsRequest.decode(body), TO_OBJECT_OPTIONS));
133
+ }
@@ -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
+ }