@autter/otlp-ingester 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clickhouse.d.ts +7 -0
- package/dist/clickhouse.js +31 -0
- package/dist/migrations.js +4 -0
- package/dist/normalize-browser.d.ts +14 -9
- package/dist/normalize-browser.js +21 -1
- package/dist/normalize-otlp.js +34 -6
- package/dist/profiles.d.ts +20 -0
- package/dist/profiles.js +60 -0
- package/dist/server.js +55 -0
- package/dist/source-maps.d.ts +6 -0
- package/dist/source-maps.js +41 -0
- package/package.json +1 -1
package/dist/clickhouse.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IngesterConfig } from "./config.js";
|
|
2
2
|
import { type LatencyHistogram } from "./latency.js";
|
|
3
|
+
import { type ProfileSample } from "./profiles.js";
|
|
3
4
|
import { type Migration } from "./migrations.js";
|
|
4
5
|
import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
|
|
5
6
|
export declare class ClickHouseStore {
|
|
@@ -23,6 +24,12 @@ export declare class ClickHouseStore {
|
|
|
23
24
|
ping(): Promise<boolean>;
|
|
24
25
|
insertOccurrences(ctx: IngestContext, occurrences: RuntimeOccurrence[]): Promise<void>;
|
|
25
26
|
insertSpans(ctx: IngestContext, spans: RuntimeSpanRow[]): Promise<void>;
|
|
27
|
+
insertProfileSamples(ctx: IngestContext, samples: ProfileSample[]): Promise<void>;
|
|
28
|
+
insertSourceMap(ctx: IngestContext, sourceMap: {
|
|
29
|
+
release: string;
|
|
30
|
+
filename: string;
|
|
31
|
+
map: string;
|
|
32
|
+
}): Promise<void>;
|
|
26
33
|
insertLlmCalls(ctx: IngestContext, calls: RuntimeLlmCall[]): Promise<void>;
|
|
27
34
|
insertMetricPoints(ctx: IngestContext, points: RuntimeMetricPoint[]): Promise<void>;
|
|
28
35
|
insertLatencyHistograms(ctx: IngestContext, points: LatencyHistogram[]): Promise<void>;
|
package/dist/clickhouse.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createClient, } from "@clickhouse/client";
|
|
2
2
|
import { latencyTableDDL } from "./latency.js";
|
|
3
|
+
import { profileTableDDL } from "./profiles.js";
|
|
4
|
+
import { sourceMapTableDDL } from "./source-maps.js";
|
|
3
5
|
import { MIGRATIONS, migrationsTableDDL, } from "./migrations.js";
|
|
4
6
|
/**
|
|
5
7
|
* ClickHouse persistence. The schema is idempotent and applied lazily once
|
|
@@ -46,6 +48,8 @@ export class ClickHouseStore {
|
|
|
46
48
|
return [
|
|
47
49
|
`CREATE DATABASE IF NOT EXISTS ${db}`,
|
|
48
50
|
latencyTableDDL(db, metricsTtlDays),
|
|
51
|
+
profileTableDDL(db),
|
|
52
|
+
sourceMapTableDDL(db),
|
|
49
53
|
`CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
|
|
50
54
|
org_id String,
|
|
51
55
|
repository_id String,
|
|
@@ -258,6 +262,33 @@ export class ClickHouseStore {
|
|
|
258
262
|
})),
|
|
259
263
|
});
|
|
260
264
|
}
|
|
265
|
+
async insertProfileSamples(ctx, samples) {
|
|
266
|
+
if (!samples.length)
|
|
267
|
+
return;
|
|
268
|
+
if (!this.configured)
|
|
269
|
+
throw new Error("CLICKHOUSE_URL is not configured");
|
|
270
|
+
await this.ensureSchema();
|
|
271
|
+
await this.getClient().insert({
|
|
272
|
+
table: this.table("runtime_profile_samples"), format: "JSONEachRow",
|
|
273
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
274
|
+
values: samples.map((sample) => ({
|
|
275
|
+
org_id: ctx.orgId, repository_id: ctx.repositoryId,
|
|
276
|
+
profile_id: sample.profileId, sample_index: sample.sampleIndex, service: sample.service,
|
|
277
|
+
environment: sample.environment, release: sample.release,
|
|
278
|
+
trace_id: sample.traceId, observed_at: sample.observedAt.toISOString(),
|
|
279
|
+
sample_type: sample.sampleType, unit: sample.unit,
|
|
280
|
+
stack: sample.stack, value: sample.value,
|
|
281
|
+
})),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async insertSourceMap(ctx, sourceMap) {
|
|
285
|
+
await this.ensureSchema();
|
|
286
|
+
await this.getClient().insert({
|
|
287
|
+
table: this.table("runtime_source_maps"), format: "JSONEachRow",
|
|
288
|
+
clickhouse_settings: INSERT_SETTINGS,
|
|
289
|
+
values: [{ org_id: ctx.orgId, repository_id: ctx.repositoryId, ...sourceMap }],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
261
292
|
async insertLlmCalls(ctx, calls) {
|
|
262
293
|
if (calls.length === 0 || !this.configured)
|
|
263
294
|
return;
|
package/dist/migrations.js
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
* },
|
|
34
34
|
*/
|
|
35
35
|
import { latencyTableDDL } from "./latency.js";
|
|
36
|
+
import { profileTableDDL } from "./profiles.js";
|
|
37
|
+
import { sourceMapTableDDL } from "./source-maps.js";
|
|
36
38
|
export const MIGRATIONS = [
|
|
37
39
|
// 0001 intentionally reserved as the baseline marker: databases created
|
|
38
40
|
// before the migration runner existed record it as applied without
|
|
@@ -115,6 +117,8 @@ export const MIGRATIONS = [
|
|
|
115
117
|
],
|
|
116
118
|
},
|
|
117
119
|
{ id: "0005-latency-histograms", statements: [latencyTableDDL("{db}")] },
|
|
120
|
+
{ id: "0006-profile-samples", statements: [profileTableDDL("{db}")] },
|
|
121
|
+
{ id: "0007-source-maps", statements: [sourceMapTableDDL("{db}")] },
|
|
118
122
|
];
|
|
119
123
|
/** The tracking table itself — created by the runner before anything else. */
|
|
120
124
|
export function migrationsTableDDL(db) {
|
|
@@ -8,13 +8,14 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
8
8
|
release: z.ZodOptional<z.ZodString>;
|
|
9
9
|
events: z.ZodArray<z.ZodObject<{
|
|
10
10
|
id: z.ZodOptional<z.ZodString>;
|
|
11
|
-
type: z.ZodEnum<["exception", "unhandled_rejection", "message", "session_start", "track_event"]>;
|
|
11
|
+
type: z.ZodEnum<["exception", "unhandled_rejection", "message", "session_start", "track_event", "outcome", "request_failure", "timing"]>;
|
|
12
12
|
timestamp: z.ZodString;
|
|
13
13
|
/** exception/message events: signal level. Defaults per type. */
|
|
14
14
|
severity: z.ZodOptional<z.ZodEnum<["fatal", "error", "warning", "info"]>>;
|
|
15
15
|
message: z.ZodDefault<z.ZodString>;
|
|
16
16
|
/** track_event only: the event name (counted, never free-form PII). */
|
|
17
17
|
name: z.ZodOptional<z.ZodString>;
|
|
18
|
+
durationMs: z.ZodOptional<z.ZodNumber>;
|
|
18
19
|
stack: z.ZodOptional<z.ZodString>;
|
|
19
20
|
errorType: z.ZodOptional<z.ZodString>;
|
|
20
21
|
filename: z.ZodOptional<z.ZodString>;
|
|
@@ -24,29 +25,31 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
24
25
|
route: z.ZodOptional<z.ZodString>;
|
|
25
26
|
context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
26
27
|
}, "strip", z.ZodTypeAny, {
|
|
27
|
-
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
|
|
28
|
+
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "timing";
|
|
28
29
|
message: string;
|
|
29
30
|
timestamp: string;
|
|
31
|
+
durationMs?: number | undefined;
|
|
32
|
+
filename?: string | undefined;
|
|
30
33
|
id?: string | undefined;
|
|
31
34
|
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
32
35
|
name?: string | undefined;
|
|
33
36
|
stack?: string | undefined;
|
|
34
37
|
errorType?: string | undefined;
|
|
35
|
-
filename?: string | undefined;
|
|
36
38
|
line?: number | undefined;
|
|
37
39
|
column?: number | undefined;
|
|
38
40
|
route?: string | undefined;
|
|
39
41
|
context?: Record<string, unknown> | undefined;
|
|
40
42
|
}, {
|
|
41
|
-
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
|
|
43
|
+
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "timing";
|
|
42
44
|
timestamp: string;
|
|
45
|
+
durationMs?: number | undefined;
|
|
46
|
+
filename?: string | undefined;
|
|
43
47
|
id?: string | undefined;
|
|
44
48
|
message?: string | undefined;
|
|
45
49
|
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
46
50
|
name?: string | undefined;
|
|
47
51
|
stack?: string | undefined;
|
|
48
52
|
errorType?: string | undefined;
|
|
49
|
-
filename?: string | undefined;
|
|
50
53
|
line?: number | undefined;
|
|
51
54
|
column?: number | undefined;
|
|
52
55
|
route?: string | undefined;
|
|
@@ -57,15 +60,16 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
57
60
|
environment: string;
|
|
58
61
|
version: 1;
|
|
59
62
|
events: {
|
|
60
|
-
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
|
|
63
|
+
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "timing";
|
|
61
64
|
message: string;
|
|
62
65
|
timestamp: string;
|
|
66
|
+
durationMs?: number | undefined;
|
|
67
|
+
filename?: string | undefined;
|
|
63
68
|
id?: string | undefined;
|
|
64
69
|
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
65
70
|
name?: string | undefined;
|
|
66
71
|
stack?: string | undefined;
|
|
67
72
|
errorType?: string | undefined;
|
|
68
|
-
filename?: string | undefined;
|
|
69
73
|
line?: number | undefined;
|
|
70
74
|
column?: number | undefined;
|
|
71
75
|
route?: string | undefined;
|
|
@@ -78,15 +82,16 @@ export declare const browserPayloadSchema: z.ZodObject<{
|
|
|
78
82
|
environment: string;
|
|
79
83
|
version: 1;
|
|
80
84
|
events: {
|
|
81
|
-
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
|
|
85
|
+
type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event" | "outcome" | "request_failure" | "timing";
|
|
82
86
|
timestamp: string;
|
|
87
|
+
durationMs?: number | undefined;
|
|
88
|
+
filename?: string | undefined;
|
|
83
89
|
id?: string | undefined;
|
|
84
90
|
message?: string | undefined;
|
|
85
91
|
severity?: "fatal" | "error" | "warning" | "info" | undefined;
|
|
86
92
|
name?: string | undefined;
|
|
87
93
|
stack?: string | undefined;
|
|
88
94
|
errorType?: string | undefined;
|
|
89
|
-
filename?: string | undefined;
|
|
90
95
|
line?: number | undefined;
|
|
91
96
|
column?: number | undefined;
|
|
92
97
|
route?: string | undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { normalizeRoute } from "./fingerprint.js";
|
|
2
3
|
import { asSeverity, } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* The compact browser payload (`version: 1`) emitted by
|
|
@@ -14,6 +15,9 @@ const browserEventSchema = z.object({
|
|
|
14
15
|
"message",
|
|
15
16
|
"session_start",
|
|
16
17
|
"track_event",
|
|
18
|
+
"outcome",
|
|
19
|
+
"request_failure",
|
|
20
|
+
"timing",
|
|
17
21
|
]),
|
|
18
22
|
timestamp: z.string().datetime(),
|
|
19
23
|
/** exception/message events: signal level. Defaults per type. */
|
|
@@ -21,6 +25,7 @@ const browserEventSchema = z.object({
|
|
|
21
25
|
message: z.string().max(4000).default(""),
|
|
22
26
|
/** track_event only: the event name (counted, never free-form PII). */
|
|
23
27
|
name: z.string().max(200).optional(),
|
|
28
|
+
durationMs: z.number().nonnegative().max(120000).optional(),
|
|
24
29
|
stack: z.string().max(32000).optional(),
|
|
25
30
|
errorType: z.string().max(200).optional(),
|
|
26
31
|
filename: z.string().max(1000).optional(),
|
|
@@ -42,6 +47,8 @@ const TYPE_TO_ERROR_TYPE = {
|
|
|
42
47
|
exception: "Error",
|
|
43
48
|
unhandled_rejection: "UnhandledRejection",
|
|
44
49
|
message: "Message",
|
|
50
|
+
outcome: "OutcomeFailure",
|
|
51
|
+
request_failure: "HttpRequestError",
|
|
45
52
|
};
|
|
46
53
|
// Content-level gate for the free-form `context` bag. The schema whitelist
|
|
47
54
|
// above is structural; this masks obvious PII/secrets inside whatever a
|
|
@@ -69,6 +76,8 @@ const TYPE_TO_SEVERITY = {
|
|
|
69
76
|
exception: "error",
|
|
70
77
|
unhandled_rejection: "error",
|
|
71
78
|
message: "warning",
|
|
79
|
+
outcome: "error",
|
|
80
|
+
request_failure: "error",
|
|
72
81
|
};
|
|
73
82
|
export function normalizeBrowserPayload(payload) {
|
|
74
83
|
const occurrences = [];
|
|
@@ -109,6 +118,15 @@ export function normalizeBrowserPayload(payload) {
|
|
|
109
118
|
bumpRollup(`event:${name}`, occurredAt, { requestCount: 1 });
|
|
110
119
|
continue;
|
|
111
120
|
}
|
|
121
|
+
if (event.type === "timing") {
|
|
122
|
+
const name = event.name === "browser.longtask" ? "browser.longtask"
|
|
123
|
+
: `browser.resource:${normalizeRoute((event.name ?? "").replace(/^browser\.resource:/, "").split("?")[0].replace(EMAIL_VALUE_RE, REDACTED))}`;
|
|
124
|
+
bumpRollup(name, occurredAt, { requestCount: 1 });
|
|
125
|
+
const point = rollups.get(`${name} ${Math.floor(occurredAt.getTime() / 60_000) * 60_000}`);
|
|
126
|
+
if (point)
|
|
127
|
+
point.durationSumMs += event.durationMs ?? 0;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
112
130
|
const severity = asSeverity(event.severity, TYPE_TO_SEVERITY[event.type] ?? "error");
|
|
113
131
|
// The browser has no request stream, so error/message events are the
|
|
114
132
|
// only usage signal for many pages — count each as an event (and, for
|
|
@@ -125,7 +143,9 @@ export function normalizeBrowserPayload(payload) {
|
|
|
125
143
|
environment: payload.environment,
|
|
126
144
|
release: payload.release ?? null,
|
|
127
145
|
errorType: event.errorType ?? TYPE_TO_ERROR_TYPE[event.type] ?? "Error",
|
|
128
|
-
message: event.
|
|
146
|
+
message: event.type === "outcome" ? `${(event.name ?? "outcome").replace(EMAIL_VALUE_RE, REDACTED)}: ${event.message.replace(EMAIL_VALUE_RE, REDACTED)}`
|
|
147
|
+
: event.type === "request_failure" ? `${normalizeRoute((event.name ?? "request").split("?")[0].replace(EMAIL_VALUE_RE, REDACTED))}: ${event.message}`
|
|
148
|
+
: event.message || "Unknown error",
|
|
129
149
|
stack: event.stack ?? null,
|
|
130
150
|
route: event.route ? (event.route.split("?")[0] ?? null) : null,
|
|
131
151
|
method: null,
|
package/dist/normalize-otlp.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeRoute } from "./fingerprint.js";
|
|
2
2
|
import { extractLlmCall } from "./llm.js";
|
|
3
3
|
import { asSeverity, } from "./types.js";
|
|
4
|
+
const EMAIL_VALUE_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
4
5
|
function attrMap(attributes) {
|
|
5
6
|
const map = new Map();
|
|
6
7
|
for (const attr of attributes ?? []) {
|
|
@@ -112,8 +113,11 @@ export function normalizeTraces(request) {
|
|
|
112
113
|
const startedAt = nanosToDate(span.startTimeUnixNano);
|
|
113
114
|
const durationMs = spanDurationMs(span);
|
|
114
115
|
const kind = spanKind(span.kind);
|
|
115
|
-
const
|
|
116
|
-
|
|
116
|
+
const hasFailureEvent = (span.events ?? []).some((event) => event.name === "exception" || (event.name === "autter.outcome"
|
|
117
|
+
&& attrMap(event.attributes).get("autter.outcome.status") === "error"));
|
|
118
|
+
const isError = statusCode !== null && statusCode >= 400 && statusCode < 500
|
|
119
|
+
? hasFailureEvent
|
|
120
|
+
: hasFailureEvent || isErrorStatus(span.status?.code) || (statusCode !== null && statusCode >= 500);
|
|
117
121
|
spans.push({
|
|
118
122
|
service: resource.service,
|
|
119
123
|
environment: resource.environment,
|
|
@@ -138,6 +142,7 @@ export function normalizeTraces(request) {
|
|
|
138
142
|
const exceptionEvents = (span.events ?? []).filter((event) => event.name === "exception");
|
|
139
143
|
for (const event of exceptionEvents) {
|
|
140
144
|
const eventAttrs = attrMap(event.attributes);
|
|
145
|
+
const handled = eventAttrs.get("autter.handled") === "true";
|
|
141
146
|
occurrences.push({
|
|
142
147
|
source: "server",
|
|
143
148
|
severity: severityOf(eventAttrs, attrs),
|
|
@@ -155,19 +160,42 @@ export function normalizeTraces(request) {
|
|
|
155
160
|
statusCode,
|
|
156
161
|
traceId: span.traceId ?? null,
|
|
157
162
|
sessionId: null,
|
|
158
|
-
attributes:
|
|
163
|
+
attributes: handled ? {
|
|
164
|
+
"autter.handled": true,
|
|
165
|
+
"autter.sampled": eventAttrs.get("autter.sampled") === "true",
|
|
166
|
+
} : null,
|
|
167
|
+
occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const failedOutcomes = (span.events ?? []).filter((event) => {
|
|
171
|
+
if (event.name !== "autter.outcome")
|
|
172
|
+
return false;
|
|
173
|
+
return attrMap(event.attributes).get("autter.outcome.status") === "error";
|
|
174
|
+
});
|
|
175
|
+
for (const event of failedOutcomes) {
|
|
176
|
+
const outcome = attrMap(event.attributes);
|
|
177
|
+
const name = (outcome.get("autter.outcome.name") ?? "operation").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 200);
|
|
178
|
+
occurrences.push({
|
|
179
|
+
source: "server", severity: "error", service: resource.service,
|
|
180
|
+
environment: resource.environment, release: resource.release,
|
|
181
|
+
errorType: "OutcomeFailure",
|
|
182
|
+
message: `${name}: ${(outcome.get("autter.outcome.message") ?? "failed").replace(EMAIL_VALUE_RE, "[redacted]").slice(0, 1000)}`,
|
|
183
|
+
stack: null, route, method: methodOf(attrs), statusCode,
|
|
184
|
+
traceId: span.traceId ?? null, sessionId: null, attributes: null,
|
|
159
185
|
occurredAt: nanosToDate(event.timeUnixNano ?? span.startTimeUnixNano),
|
|
160
186
|
});
|
|
161
187
|
}
|
|
162
|
-
if (exceptionEvents.length === 0 &&
|
|
188
|
+
if (exceptionEvents.length === 0 && failedOutcomes.length === 0 && isError) {
|
|
163
189
|
occurrences.push({
|
|
164
190
|
source: "server",
|
|
165
191
|
severity: severityOf(new Map(), attrs),
|
|
166
192
|
service: resource.service,
|
|
167
193
|
environment: resource.environment,
|
|
168
194
|
release: resource.release,
|
|
169
|
-
errorType: "SpanError",
|
|
170
|
-
message: span.status?.message ||
|
|
195
|
+
errorType: statusCode !== null && statusCode >= 500 ? "HttpServerError" : "SpanError",
|
|
196
|
+
message: span.status?.message || (statusCode !== null && statusCode >= 500
|
|
197
|
+
? `${methodOf(attrs) ?? "HTTP"} ${route ?? span.name ?? "request"} returned ${statusCode}`
|
|
198
|
+
: `${span.name ?? "span"} failed`),
|
|
171
199
|
stack: null,
|
|
172
200
|
route,
|
|
173
201
|
method: methodOf(attrs),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ProfileSample {
|
|
2
|
+
profileId: string;
|
|
3
|
+
sampleIndex: number;
|
|
4
|
+
service: string;
|
|
5
|
+
environment: string;
|
|
6
|
+
release: string;
|
|
7
|
+
traceId: string;
|
|
8
|
+
observedAt: Date;
|
|
9
|
+
sampleType: string;
|
|
10
|
+
unit: string;
|
|
11
|
+
stack: string[];
|
|
12
|
+
value: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function profileTableDDL(db: string): string;
|
|
15
|
+
export declare function decodeProfile(body: Buffer, meta: {
|
|
16
|
+
service: string;
|
|
17
|
+
environment: string;
|
|
18
|
+
release: string;
|
|
19
|
+
traceId: string;
|
|
20
|
+
}): ProfileSample[];
|
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { gunzipSync } from "node:zlib";
|
|
3
|
+
import protobuf from "protobufjs";
|
|
4
|
+
// Only the pprof fields needed for a bounded, symbolized CPU sample index.
|
|
5
|
+
const schema = protobuf.parse(`syntax = "proto3";
|
|
6
|
+
message Profile {
|
|
7
|
+
repeated ValueType sample_type = 1;
|
|
8
|
+
repeated Sample sample = 2;
|
|
9
|
+
repeated Location location = 4;
|
|
10
|
+
repeated Function function = 5;
|
|
11
|
+
repeated string string_table = 6;
|
|
12
|
+
int64 time_nanos = 9;
|
|
13
|
+
}
|
|
14
|
+
message ValueType { int64 type = 1; int64 unit = 2; }
|
|
15
|
+
message Sample { repeated uint64 location_id = 1 [packed=true]; repeated int64 value = 2 [packed=true]; }
|
|
16
|
+
message Location { uint64 id = 1; repeated Line line = 4; }
|
|
17
|
+
message Line { uint64 function_id = 1; int64 line = 2; }
|
|
18
|
+
message Function { uint64 id = 1; int64 name = 2; int64 system_name = 3; int64 filename = 4; }
|
|
19
|
+
`).root.lookupType("Profile");
|
|
20
|
+
export function profileTableDDL(db) {
|
|
21
|
+
return `CREATE TABLE IF NOT EXISTS ${db}.runtime_profile_samples (
|
|
22
|
+
org_id String, repository_id String, profile_id String, sample_index UInt16,
|
|
23
|
+
service LowCardinality(String), environment LowCardinality(String), release String,
|
|
24
|
+
trace_id String DEFAULT '', observed_at DateTime64(3, 'UTC'),
|
|
25
|
+
sample_type LowCardinality(String), unit LowCardinality(String),
|
|
26
|
+
stack Array(String), value UInt64
|
|
27
|
+
) ENGINE = MergeTree PARTITION BY toDate(observed_at)
|
|
28
|
+
ORDER BY (org_id, repository_id, service, environment, release, observed_at, profile_id)
|
|
29
|
+
TTL toDateTime(observed_at) + INTERVAL 7 DAY`;
|
|
30
|
+
}
|
|
31
|
+
export function decodeProfile(body, meta) {
|
|
32
|
+
if (body.length === 0 || body.length > 1024 * 1024)
|
|
33
|
+
throw new Error("invalid profile size");
|
|
34
|
+
const payload = body[0] === 0x1f && body[1] === 0x8b
|
|
35
|
+
? gunzipSync(body, { maxOutputLength: 1024 * 1024 }) : body;
|
|
36
|
+
const profile = schema.toObject(schema.decode(payload), { longs: String, arrays: true });
|
|
37
|
+
const strings = profile.stringTable ?? [];
|
|
38
|
+
const locations = new Map((profile.location ?? []).map((location) => [location.id, location]));
|
|
39
|
+
const functions = new Map((profile.function ?? []).map((fn) => [fn.id, fn]));
|
|
40
|
+
const type = profile.sampleType?.[0];
|
|
41
|
+
const observedMs = Number(BigInt(profile.timeNanos ?? "0") / 1000000n);
|
|
42
|
+
const observedAt = observedMs > 0 && observedMs <= Date.now() + 300_000 ? new Date(observedMs) : new Date();
|
|
43
|
+
const profileId = createHash("sha256").update(body).digest("hex").slice(0, 32);
|
|
44
|
+
const samples = [];
|
|
45
|
+
for (const [sampleIndex, sample] of (profile.sample ?? []).slice(0, 1000).entries()) {
|
|
46
|
+
const stack = (sample.locationId ?? []).slice(0, 64).flatMap((id) => {
|
|
47
|
+
const fnId = locations.get(id)?.line?.[0]?.functionId;
|
|
48
|
+
const name = fnId ? strings[Number(functions.get(fnId)?.name ?? 0)] : undefined;
|
|
49
|
+
return name ? [name.slice(0, 200)] : [];
|
|
50
|
+
});
|
|
51
|
+
const value = Number(sample.value?.[0] ?? 0);
|
|
52
|
+
if (!stack.length || !Number.isSafeInteger(value) || value <= 0)
|
|
53
|
+
continue;
|
|
54
|
+
samples.push({ profileId, sampleIndex, ...meta, observedAt,
|
|
55
|
+
sampleType: (strings[Number(type?.type ?? 0)] ?? "samples").slice(0, 80),
|
|
56
|
+
unit: (strings[Number(type?.unit ?? 0)] ?? "count").slice(0, 40),
|
|
57
|
+
stack, value });
|
|
58
|
+
}
|
|
59
|
+
return samples;
|
|
60
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -6,6 +6,8 @@ import { deriveFields, fingerprintOccurrence, occurrenceIdFor, } from "./fingerp
|
|
|
6
6
|
import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
|
|
7
7
|
import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
|
|
8
8
|
import { decodeMetricsRequest, decodeTraceRequest } from "./otlp-proto.js";
|
|
9
|
+
import { decodeProfile } from "./profiles.js";
|
|
10
|
+
import { validateSourceMap } from "./source-maps.js";
|
|
9
11
|
import { SinkForwarder } from "./sink.js";
|
|
10
12
|
export function createIngesterApp(config) {
|
|
11
13
|
const store = new ClickHouseStore(config);
|
|
@@ -18,6 +20,8 @@ export function createIngesterApp(config) {
|
|
|
18
20
|
const clientRateLimiter = new RateLimiter(config.clientRateLimitPerMinute);
|
|
19
21
|
const app = express();
|
|
20
22
|
app.disable("x-powered-by");
|
|
23
|
+
app.use(express.raw({ limit: "1mb", type: ["application/x-pprof"] }));
|
|
24
|
+
app.use("/v1/sourcemaps", express.json({ limit: "5mb" }));
|
|
21
25
|
app.use(express.json({
|
|
22
26
|
limit: config.maxBodyBytes,
|
|
23
27
|
type: ["application/json"],
|
|
@@ -140,6 +144,57 @@ export function createIngesterApp(config) {
|
|
|
140
144
|
}
|
|
141
145
|
res.status(200).json({ partialSuccess: {} });
|
|
142
146
|
}
|
|
147
|
+
app.post("/v1/profiles", async (req, res) => {
|
|
148
|
+
const ctx = await authenticate(req, res, "otlp");
|
|
149
|
+
if (!ctx)
|
|
150
|
+
return;
|
|
151
|
+
if (!req.is("application/x-pprof") || !Buffer.isBuffer(req.body)) {
|
|
152
|
+
res.status(415).json({ error: "expected application/x-pprof" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const service = req.header("x-autter-service")?.trim() ?? "";
|
|
156
|
+
const environment = req.header("x-autter-environment")?.trim() || "production";
|
|
157
|
+
const release = req.header("x-autter-release")?.trim() ?? "";
|
|
158
|
+
const traceId = req.header("x-autter-trace-id")?.trim() ?? "";
|
|
159
|
+
if (!/^[a-zA-Z0-9._-]{1,200}$/.test(service) || environment.length > 100 || release.length > 200 || (traceId && !/^[a-f0-9]{32}$/.test(traceId))) {
|
|
160
|
+
res.status(400).json({ error: "invalid profile metadata" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let samples;
|
|
164
|
+
try {
|
|
165
|
+
samples = decodeProfile(req.body, { service, environment, release, traceId });
|
|
166
|
+
if (!samples.length)
|
|
167
|
+
throw new Error("empty profile");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
res.status(400).json({ error: "invalid or unsymbolized profile" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await store.insertProfileSamples(ctx, samples);
|
|
175
|
+
res.status(202).json({ profileId: samples[0].profileId, samples: samples.length });
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
storageError(res, err);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
app.post("/v1/sourcemaps", async (req, res) => {
|
|
182
|
+
const ctx = await authenticate(req, res, "otlp");
|
|
183
|
+
if (!ctx)
|
|
184
|
+
return;
|
|
185
|
+
const sourceMap = validateSourceMap(req.body);
|
|
186
|
+
if (!sourceMap) {
|
|
187
|
+
res.status(400).json({ error: "invalid source map" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await store.insertSourceMap(ctx, sourceMap);
|
|
192
|
+
res.status(202).json({ accepted: true });
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
storageError(res, err);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
143
198
|
app.post("/v1/traces", async (req, res) => {
|
|
144
199
|
const ctx = await authenticate(req, res, "otlp");
|
|
145
200
|
if (!ctx)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function sourceMapTableDDL(db) {
|
|
2
|
+
return `CREATE TABLE IF NOT EXISTS ${db}.runtime_source_maps (
|
|
3
|
+
org_id String, repository_id String, release String, filename String,
|
|
4
|
+
map String CODEC(ZSTD(3)), uploaded_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
5
|
+
) ENGINE = ReplacingMergeTree(uploaded_at)
|
|
6
|
+
ORDER BY (org_id, repository_id, release, filename)
|
|
7
|
+
TTL toDateTime(uploaded_at) + INTERVAL 30 DAY`;
|
|
8
|
+
}
|
|
9
|
+
export function validateSourceMap(body) {
|
|
10
|
+
if (!body || typeof body !== "object")
|
|
11
|
+
return null;
|
|
12
|
+
const input = body;
|
|
13
|
+
if (typeof input.release !== "string" || !/^[a-zA-Z0-9._-]{1,200}$/.test(input.release))
|
|
14
|
+
return null;
|
|
15
|
+
if (typeof input.filename !== "string" || input.filename.length > 1000)
|
|
16
|
+
return null;
|
|
17
|
+
let filename;
|
|
18
|
+
try {
|
|
19
|
+
filename = new URL(input.filename, "https://autter.invalid").pathname;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (!filename.endsWith(".js"))
|
|
25
|
+
return null;
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = typeof input.map === "string" ? JSON.parse(input.map) : input.map;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!parsed || parsed.version !== 3 || typeof parsed.mappings !== "string" || !Array.isArray(parsed.sources))
|
|
34
|
+
return null;
|
|
35
|
+
// Source contents are unnecessary for position lookup and may contain secrets.
|
|
36
|
+
const { sourcesContent: _omitted, ...safe } = parsed;
|
|
37
|
+
const map = JSON.stringify(safe);
|
|
38
|
+
if (map.length > 5 * 1024 * 1024)
|
|
39
|
+
return null;
|
|
40
|
+
return { release: input.release, filename, map };
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autter/otlp-ingester",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "Self-hostable OTLP + browser-error ingest service for Autter Runtime: normalises telemetry into a per-repo ClickHouse data model",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|