@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.
@@ -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>;
@@ -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;
@@ -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.message || "Unknown error",
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,
@@ -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 isError = isErrorStatus(span.status?.code) ||
116
- (statusCode !== null && statusCode >= 500);
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: null,
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 && isErrorStatus(span.status?.code)) {
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 || `${span.name ?? "span"} failed`,
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[];
@@ -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,6 @@
1
+ export declare function sourceMapTableDDL(db: string): string;
2
+ export declare function validateSourceMap(body: unknown): {
3
+ release: string;
4
+ filename: string;
5
+ map: string;
6
+ } | null;
@@ -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.1",
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",