@autter/otlp-ingester 1.3.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/clickhouse.d.ts +35 -0
- package/dist/clickhouse.js +156 -2
- package/dist/compat-manifest.json +94 -0
- package/dist/compat.d.ts +147 -0
- package/dist/compat.js +219 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +18 -0
- package/dist/context.d.ts +19 -0
- package/dist/context.js +102 -0
- package/dist/error-fields.d.ts +41 -0
- package/dist/error-fields.js +103 -0
- package/dist/fingerprint.d.ts +29 -0
- package/dist/fingerprint.js +45 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -3
- package/dist/llm.js +16 -3
- package/dist/log-promotion.d.ts +55 -0
- package/dist/log-promotion.js +119 -0
- package/dist/logs.d.ts +98 -0
- package/dist/logs.js +261 -0
- package/dist/migrations.d.ts +3 -0
- package/dist/migrations.js +44 -0
- package/dist/normalize-browser.d.ts +16 -4
- package/dist/normalize-browser.js +22 -18
- package/dist/normalize-otlp.d.ts +2 -6
- package/dist/normalize-otlp.js +22 -9
- package/dist/otlp-proto.d.ts +2 -0
- package/dist/otlp-proto.js +21 -0
- package/dist/redact.d.ts +33 -0
- package/dist/redact.js +96 -0
- package/dist/sdk-versions.d.ts +58 -0
- package/dist/sdk-versions.js +167 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +171 -5
- package/dist/sink.d.ts +9 -0
- package/dist/sink.js +26 -4
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
|
@@ -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" | "
|
|
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" | "
|
|
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" | "
|
|
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" | "
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
const
|
|
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
|
-
|
|
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()] };
|
package/dist/normalize-otlp.d.ts
CHANGED
|
@@ -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;
|
package/dist/normalize-otlp.js
CHANGED
|
@@ -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._%+-]
|
|
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:
|
|
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,
|
|
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:
|
|
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;
|
package/dist/otlp-proto.d.ts
CHANGED
|
@@ -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;
|
package/dist/otlp-proto.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -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
|
+
}
|