@autter/otlp-ingester 1.3.1 → 1.3.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @autter/otlp-ingester
2
2
 
3
- Self-hostable ingest service for Autter Runtime. Receives OTLP/HTTP (JSON)
3
+ Self-hostable ingest service for Autter Runtime. Receives OTLP/HTTP (protobuf or JSON)
4
4
  traces and metrics plus compact browser error payloads, normalises them into
5
5
  one per-repo signal model, fingerprints errors, and writes ClickHouse.
6
6
 
@@ -9,7 +9,9 @@ one per-repo signal model, fingerprints errors, and writes ClickHouse.
9
9
  | Route | Payload | Purpose |
10
10
  | --- | --- | --- |
11
11
  | `POST /v1/traces` | OTLP/JSON `ExportTraceServiceRequest` | Error spans → occurrences; all spans → `runtime_spans`; server spans → usage rollups; GenAI spans → `runtime_llm_calls` |
12
- | `POST /v1/metrics` | OTLP/JSON `ExportMetricsServiceRequest` | HTTP-server duration histograms → usage rollups |
12
+ | `POST /v1/metrics` | OTLP `ExportMetricsServiceRequest` | HTTP-server duration histograms → usage rollups; portable process memory/GC metrics → per-instance memory samples |
13
+ | `POST /v1/platform-events` | JSON, server key | ECS/Kubernetes OOM kills and restarts → memory incident correlation |
14
+ | `POST /v1/profiles` | Symbolized pprof, server key | CPU/in-use heap profile samples |
13
15
  | `POST /v1/browser` | Browser payload `version: 1` | Errors/rejections → occurrences; session pings → rollups |
14
16
  | `GET /healthz` | — | Liveness + ClickHouse reachability |
15
17
 
@@ -1,5 +1,7 @@
1
1
  import type { IngesterConfig } from "./config.js";
2
2
  import { type LatencyHistogram } from "./latency.js";
3
+ import { type ProfileSample } from "./profiles.js";
4
+ import { type MemorySample, type PlatformEvent } from "./memory.js";
3
5
  import { type Migration } from "./migrations.js";
4
6
  import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
5
7
  export declare class ClickHouseStore {
@@ -23,6 +25,14 @@ export declare class ClickHouseStore {
23
25
  ping(): Promise<boolean>;
24
26
  insertOccurrences(ctx: IngestContext, occurrences: RuntimeOccurrence[]): Promise<void>;
25
27
  insertSpans(ctx: IngestContext, spans: RuntimeSpanRow[]): Promise<void>;
28
+ insertProfileSamples(ctx: IngestContext, samples: ProfileSample[]): Promise<void>;
29
+ insertMemorySamples(ctx: IngestContext, samples: MemorySample[]): Promise<void>;
30
+ insertPlatformEvent(ctx: IngestContext, event: PlatformEvent): Promise<void>;
31
+ insertSourceMap(ctx: IngestContext, sourceMap: {
32
+ release: string;
33
+ filename: string;
34
+ map: string;
35
+ }): Promise<void>;
26
36
  insertLlmCalls(ctx: IngestContext, calls: RuntimeLlmCall[]): Promise<void>;
27
37
  insertMetricPoints(ctx: IngestContext, points: RuntimeMetricPoint[]): Promise<void>;
28
38
  insertLatencyHistograms(ctx: IngestContext, points: LatencyHistogram[]): Promise<void>;
@@ -1,5 +1,8 @@
1
1
  import { createClient, } from "@clickhouse/client";
2
2
  import { latencyTableDDL } from "./latency.js";
3
+ import { profileTableDDL } from "./profiles.js";
4
+ import { memoryTableDDL, platformEventTableDDL } from "./memory.js";
5
+ import { sourceMapTableDDL } from "./source-maps.js";
3
6
  import { MIGRATIONS, migrationsTableDDL, } from "./migrations.js";
4
7
  /**
5
8
  * ClickHouse persistence. The schema is idempotent and applied lazily once
@@ -46,6 +49,10 @@ export class ClickHouseStore {
46
49
  return [
47
50
  `CREATE DATABASE IF NOT EXISTS ${db}`,
48
51
  latencyTableDDL(db, metricsTtlDays),
52
+ profileTableDDL(db),
53
+ memoryTableDDL(db),
54
+ platformEventTableDDL(db),
55
+ sourceMapTableDDL(db),
49
56
  `CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
50
57
  org_id String,
51
58
  repository_id String,
@@ -258,6 +265,57 @@ export class ClickHouseStore {
258
265
  })),
259
266
  });
260
267
  }
268
+ async insertProfileSamples(ctx, samples) {
269
+ if (!samples.length)
270
+ return;
271
+ if (!this.configured)
272
+ throw new Error("CLICKHOUSE_URL is not configured");
273
+ await this.ensureSchema();
274
+ await this.getClient().insert({
275
+ table: this.table("runtime_profile_samples"), format: "JSONEachRow",
276
+ clickhouse_settings: INSERT_SETTINGS,
277
+ values: samples.map((sample) => ({
278
+ org_id: ctx.orgId, repository_id: ctx.repositoryId,
279
+ profile_id: sample.profileId, sample_index: sample.sampleIndex, service: sample.service,
280
+ environment: sample.environment, release: sample.release,
281
+ trace_id: sample.traceId, instance_id: sample.instanceId, observed_at: sample.observedAt.toISOString(),
282
+ sample_type: sample.sampleType, unit: sample.unit,
283
+ stack: sample.stack, value: sample.value,
284
+ })),
285
+ });
286
+ }
287
+ async insertMemorySamples(ctx, samples) {
288
+ if (!samples.length)
289
+ return;
290
+ if (!this.configured)
291
+ throw new Error("CLICKHOUSE_URL is not configured");
292
+ await this.ensureSchema();
293
+ await this.getClient().insert({ table: this.table("runtime_memory_samples"), format: "JSONEachRow",
294
+ clickhouse_settings: INSERT_SETTINGS,
295
+ values: samples.map((s) => ({ org_id: ctx.orgId, repository_id: ctx.repositoryId,
296
+ service: s.service, environment: s.environment, release: s.release,
297
+ instance_id: s.instanceId, metric: s.metric, value: s.value, temporality: s.temporality,
298
+ observed_at: s.observedAt.toISOString() })) });
299
+ }
300
+ async insertPlatformEvent(ctx, event) {
301
+ if (!this.configured)
302
+ throw new Error("CLICKHOUSE_URL is not configured");
303
+ await this.ensureSchema();
304
+ await this.getClient().insert({ table: this.table("runtime_platform_events"), format: "JSONEachRow",
305
+ clickhouse_settings: INSERT_SETTINGS,
306
+ values: [{ org_id: ctx.orgId, repository_id: ctx.repositoryId, event_id: event.eventId,
307
+ service: event.service, environment: event.environment, release: event.release,
308
+ instance_id: event.instanceId, platform: event.platform, kind: event.kind,
309
+ occurred_at: event.occurredAt }] });
310
+ }
311
+ async insertSourceMap(ctx, sourceMap) {
312
+ await this.ensureSchema();
313
+ await this.getClient().insert({
314
+ table: this.table("runtime_source_maps"), format: "JSONEachRow",
315
+ clickhouse_settings: INSERT_SETTINGS,
316
+ values: [{ org_id: ctx.orgId, repository_id: ctx.repositoryId, ...sourceMap }],
317
+ });
318
+ }
261
319
  async insertLlmCalls(ctx, calls) {
262
320
  if (calls.length === 0 || !this.configured)
263
321
  return;
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import type { OtlpMetricsRequest } from "./normalize-otlp.js";
3
+ export type MemoryMetric = "rss" | "heap_used" | "heap_limit" | "memory_limit" | "heap_after_gc" | "gc_count" | "gc_duration_ms";
4
+ export interface MemorySample {
5
+ service: string;
6
+ environment: string;
7
+ release: string;
8
+ instanceId: string;
9
+ metric: MemoryMetric;
10
+ value: number;
11
+ observedAt: Date;
12
+ temporality: "gauge" | "delta" | "cumulative";
13
+ }
14
+ export declare function normalizeMemoryMetrics(request: OtlpMetricsRequest): MemorySample[];
15
+ export declare const platformEventSchema: z.ZodObject<{
16
+ service: z.ZodString;
17
+ environment: z.ZodDefault<z.ZodString>;
18
+ release: z.ZodDefault<z.ZodString>;
19
+ instanceId: z.ZodString;
20
+ platform: z.ZodEnum<["ecs", "kubernetes", "other"]>;
21
+ kind: z.ZodEnum<["oom_kill", "restart"]>;
22
+ occurredAt: z.ZodString;
23
+ }, "strip", z.ZodTypeAny, {
24
+ service: string;
25
+ environment: string;
26
+ release: string;
27
+ instanceId: string;
28
+ platform: "ecs" | "kubernetes" | "other";
29
+ kind: "oom_kill" | "restart";
30
+ occurredAt: string;
31
+ }, {
32
+ service: string;
33
+ instanceId: string;
34
+ platform: "ecs" | "kubernetes" | "other";
35
+ kind: "oom_kill" | "restart";
36
+ occurredAt: string;
37
+ environment?: string | undefined;
38
+ release?: string | undefined;
39
+ }>;
40
+ export type PlatformEvent = z.infer<typeof platformEventSchema> & {
41
+ eventId: string;
42
+ };
43
+ export declare function normalizePlatformEvent(input: z.infer<typeof platformEventSchema>): PlatformEvent | null;
44
+ export declare function memoryTableDDL(db: string): string;
45
+ export declare function platformEventTableDDL(db: string): string;
package/dist/memory.js ADDED
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ function attrsOf(attributes) {
4
+ return new Map((attributes ?? []).flatMap((a) => {
5
+ const v = a.value?.stringValue ?? a.value?.intValue ?? a.value?.doubleValue;
6
+ return a.key && v !== undefined ? [[a.key, String(v)]] : [];
7
+ }));
8
+ }
9
+ function metricOf(name, attrs) {
10
+ switch (name) {
11
+ case "autter.process.memory.rss":
12
+ case "process.runtime.nodejs.memory.rss": return "rss";
13
+ case "autter.process.memory.heap.used":
14
+ case "process.runtime.nodejs.memory.heap.used": return "heap_used";
15
+ case "autter.process.memory.heap.limit":
16
+ case "process.runtime.nodejs.memory.heap.limit": return "heap_limit";
17
+ case "autter.process.memory.limit":
18
+ case "container.memory.limit": return "memory_limit";
19
+ case "autter.process.memory.heap.after_gc": return "heap_after_gc";
20
+ case "autter.process.gc.count":
21
+ case "process.runtime.nodejs.gc.count": return "gc_count";
22
+ case "autter.process.gc.duration":
23
+ case "process.runtime.nodejs.gc.duration": return "gc_duration_ms";
24
+ case "process.memory.usage": {
25
+ const type = attrs.get("type") ?? attrs.get("state");
26
+ return type === "rss" || type === "physical" ? "rss" : type === "heap_used" ? "heap_used" : null;
27
+ }
28
+ default: return null;
29
+ }
30
+ }
31
+ export function normalizeMemoryMetrics(request) {
32
+ const out = [];
33
+ const now = Date.now();
34
+ for (const rm of request.resourceMetrics ?? []) {
35
+ const resource = attrsOf(rm.resource?.attributes);
36
+ const instanceId = resource.get("service.instance.id")?.slice(0, 128) ?? "";
37
+ const service = resource.get("service.name")?.slice(0, 200) ?? "";
38
+ if (!instanceId || !service)
39
+ continue;
40
+ const environment = (resource.get("deployment.environment.name") ?? resource.get("deployment.environment") ?? "production").slice(0, 100);
41
+ const release = (resource.get("service.version") ?? "").slice(0, 200);
42
+ for (const scope of rm.scopeMetrics ?? [])
43
+ for (const metric of scope.metrics ?? []) {
44
+ const name = metric.name ?? "";
45
+ const points = metric.gauge?.dataPoints ?? metric.sum?.dataPoints ?? [];
46
+ for (const point of points) {
47
+ const kind = metricOf(name, attrsOf(point.attributes));
48
+ if (!kind)
49
+ continue;
50
+ const counter = kind === "gc_count" || kind === "gc_duration_ms";
51
+ const aggregation = metric.sum?.aggregationTemporality;
52
+ const temporality = counter && (aggregation === 2 || aggregation === "AGGREGATION_TEMPORALITY_CUMULATIVE")
53
+ ? "cumulative" : counter ? "delta" : "gauge";
54
+ const value = Number(point.asDouble ?? point.asInt);
55
+ let observedMs = 0;
56
+ try {
57
+ observedMs = Number(BigInt(String(point.timeUnixNano ?? "0")) / 1000000n);
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ if (!Number.isFinite(value) || value < 0 || value > 1e15 || observedMs < now - 30 * 86400_000 || observedMs > now + 300_000)
63
+ continue;
64
+ const isBytes = kind === "rss" || kind === "heap_used" || kind === "heap_limit" || kind === "memory_limit" || kind === "heap_after_gc";
65
+ const unit = metric.unit ?? "By";
66
+ if (isBytes && !["By", "byte", "bytes", "KiBy", "MiBy"].includes(unit))
67
+ continue;
68
+ // Standard OTel runtimes report GC duration in seconds; Autter uses ms.
69
+ out.push({ service, environment, release, instanceId, metric: kind,
70
+ value: isBytes ? value * (unit === "KiBy" ? 1024 : unit === "MiBy" ? 1048576 : 1)
71
+ : kind === "gc_duration_ms" && unit === "s" ? value * 1000 : value,
72
+ observedAt: new Date(observedMs), temporality });
73
+ if (out.length >= 5000)
74
+ return out;
75
+ }
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ export const platformEventSchema = z.object({
81
+ service: z.string().regex(/^[a-zA-Z0-9._-]{1,200}$/),
82
+ environment: z.string().min(1).max(100).default("production"),
83
+ release: z.string().max(200).default(""),
84
+ instanceId: z.string().min(1).max(128),
85
+ platform: z.enum(["ecs", "kubernetes", "other"]),
86
+ kind: z.enum(["oom_kill", "restart"]),
87
+ occurredAt: z.string().datetime(),
88
+ });
89
+ export function normalizePlatformEvent(input) {
90
+ const time = Date.parse(input.occurredAt);
91
+ if (time < Date.now() - 30 * 86400_000 || time > Date.now() + 300_000)
92
+ return null;
93
+ const eventId = createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 32);
94
+ return { ...input, eventId };
95
+ }
96
+ export function memoryTableDDL(db) {
97
+ return `CREATE TABLE IF NOT EXISTS ${db}.runtime_memory_samples (
98
+ org_id String, repository_id String, service LowCardinality(String),
99
+ environment LowCardinality(String), release String DEFAULT '', instance_id String,
100
+ metric LowCardinality(String), value Float64, temporality LowCardinality(String) DEFAULT 'gauge',
101
+ observed_at DateTime64(3, 'UTC')
102
+ ) ENGINE = ReplacingMergeTree PARTITION BY toDate(observed_at)
103
+ ORDER BY (org_id, repository_id, service, environment, instance_id, metric, observed_at)
104
+ TTL toDateTime(observed_at) + INTERVAL 14 DAY`;
105
+ }
106
+ export function platformEventTableDDL(db) {
107
+ return `CREATE TABLE IF NOT EXISTS ${db}.runtime_platform_events (
108
+ org_id String, repository_id String, event_id String, service LowCardinality(String),
109
+ environment LowCardinality(String), release String DEFAULT '', instance_id String,
110
+ platform LowCardinality(String), kind LowCardinality(String), occurred_at DateTime64(3, 'UTC')
111
+ ) ENGINE = ReplacingMergeTree PARTITION BY toDate(occurred_at)
112
+ ORDER BY (org_id, repository_id, event_id)
113
+ TTL toDateTime(occurred_at) + INTERVAL 30 DAY`;
114
+ }
@@ -33,6 +33,9 @@
33
33
  * },
34
34
  */
35
35
  import { latencyTableDDL } from "./latency.js";
36
+ import { profileTableDDL } from "./profiles.js";
37
+ import { sourceMapTableDDL } from "./source-maps.js";
38
+ import { memoryTableDDL, platformEventTableDDL } from "./memory.js";
36
39
  export const MIGRATIONS = [
37
40
  // 0001 intentionally reserved as the baseline marker: databases created
38
41
  // before the migration runner existed record it as applied without
@@ -115,6 +118,15 @@ export const MIGRATIONS = [
115
118
  ],
116
119
  },
117
120
  { id: "0005-latency-histograms", statements: [latencyTableDDL("{db}")] },
121
+ { id: "0006-profile-samples", statements: [profileTableDDL("{db}")] },
122
+ { id: "0007-source-maps", statements: [sourceMapTableDDL("{db}")] },
123
+ { id: "0008-memory-signals", statements: [memoryTableDDL("{db}"), platformEventTableDDL("{db}")] },
124
+ { id: "0009-profile-instance", statements: [
125
+ `ALTER TABLE {db}.runtime_profile_samples ADD COLUMN IF NOT EXISTS instance_id String DEFAULT ''`,
126
+ ] },
127
+ { id: "0010-memory-temporality", statements: [
128
+ `ALTER TABLE {db}.runtime_memory_samples ADD COLUMN IF NOT EXISTS temporality LowCardinality(String) DEFAULT 'gauge'`,
129
+ ] },
118
130
  ];
119
131
  /** The tracking table itself — created by the runner before anything else. */
120
132
  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;
43
- id?: string | undefined;
45
+ durationMs?: number | undefined;
44
46
  message?: string | undefined;
47
+ filename?: string | undefined;
48
+ id?: 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;
83
- id?: string | undefined;
87
+ durationMs?: number | undefined;
84
88
  message?: string | undefined;
89
+ filename?: string | undefined;
90
+ id?: 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,
@@ -66,12 +66,16 @@ export interface OtlpMetricsRequest {
66
66
  metrics?: Array<{
67
67
  name?: string;
68
68
  unit?: string;
69
+ gauge?: {
70
+ dataPoints?: OtlpDataPoint[];
71
+ };
69
72
  histogram?: {
70
73
  dataPoints?: OtlpDataPoint[];
71
74
  aggregationTemporality?: string | number;
72
75
  };
73
76
  sum?: {
74
77
  dataPoints?: OtlpDataPoint[];
78
+ aggregationTemporality?: string | number;
75
79
  };
76
80
  }>;
77
81
  }>;
@@ -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),
@@ -54,10 +54,12 @@ message ScopeMetrics { repeated Metric metrics = 2; }
54
54
  message Metric {
55
55
  string name = 1;
56
56
  string unit = 3;
57
+ Gauge gauge = 5;
57
58
  Sum sum = 7;
58
59
  Histogram histogram = 9;
59
60
  }
60
- message Sum { repeated NumberDataPoint data_points = 1; }
61
+ message Gauge { repeated NumberDataPoint data_points = 1; }
62
+ message Sum { repeated NumberDataPoint data_points = 1; int32 aggregation_temporality = 2; }
61
63
  message NumberDataPoint {
62
64
  fixed64 time_unix_nano = 3;
63
65
  double as_double = 4;
@@ -0,0 +1,22 @@
1
+ export interface ProfileSample {
2
+ profileId: string;
3
+ sampleIndex: number;
4
+ service: string;
5
+ environment: string;
6
+ release: string;
7
+ traceId: string;
8
+ instanceId: string;
9
+ observedAt: Date;
10
+ sampleType: string;
11
+ unit: string;
12
+ stack: string[];
13
+ value: number;
14
+ }
15
+ export declare function profileTableDDL(db: string): string;
16
+ export declare function decodeProfile(body: Buffer, meta: {
17
+ service: string;
18
+ environment: string;
19
+ release: string;
20
+ traceId: string;
21
+ instanceId: string;
22
+ }): ProfileSample[];
@@ -0,0 +1,63 @@
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 '', instance_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 typeIndex = Math.max(0, (profile.sampleType ?? []).findIndex((t) => strings[Number(t.type ?? 0)] === "inuse_space"));
41
+ const type = profile.sampleType?.[typeIndex];
42
+ const observedMs = Number(BigInt(profile.timeNanos ?? "0") / 1000000n);
43
+ const observedAt = observedMs > 0 && observedMs <= Date.now() + 300_000 ? new Date(observedMs) : new Date();
44
+ const profileId = createHash("sha256").update(body).digest("hex").slice(0, 32);
45
+ const samples = [];
46
+ for (const [sampleIndex, sample] of (profile.sample ?? []).slice(0, 1000).entries()) {
47
+ const stack = (sample.locationId ?? []).slice(0, 64).flatMap((id) => {
48
+ const locLine = locations.get(id)?.line?.[0];
49
+ const fn = locLine?.functionId ? functions.get(locLine.functionId) : undefined;
50
+ const name = fn ? strings[Number(fn.name ?? 0)] : undefined;
51
+ const filename = fn ? strings[Number(fn.filename ?? 0)] : undefined;
52
+ return name ? [`${name.slice(0, 150)}${filename ? ` (${filename.slice(0, 300)}:${Number(locLine?.line ?? 0)})` : ""}`] : [];
53
+ });
54
+ const value = Number(sample.value?.[typeIndex] ?? 0);
55
+ if (!stack.length || !Number.isSafeInteger(value) || value <= 0)
56
+ continue;
57
+ samples.push({ profileId, sampleIndex, ...meta, observedAt,
58
+ sampleType: (strings[Number(type?.type ?? 0)] ?? "samples").slice(0, 80),
59
+ unit: (strings[Number(type?.unit ?? 0)] ?? "count").slice(0, 40),
60
+ stack, value });
61
+ }
62
+ return samples;
63
+ }
package/dist/server.js CHANGED
@@ -6,6 +6,9 @@ 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 { normalizeMemoryMetrics, normalizePlatformEvent, platformEventSchema } from "./memory.js";
11
+ import { validateSourceMap } from "./source-maps.js";
9
12
  import { SinkForwarder } from "./sink.js";
10
13
  export function createIngesterApp(config) {
11
14
  const store = new ClickHouseStore(config);
@@ -18,6 +21,8 @@ export function createIngesterApp(config) {
18
21
  const clientRateLimiter = new RateLimiter(config.clientRateLimitPerMinute);
19
22
  const app = express();
20
23
  app.disable("x-powered-by");
24
+ app.use(express.raw({ limit: "1mb", type: ["application/x-pprof"] }));
25
+ app.use("/v1/sourcemaps", express.json({ limit: "5mb" }));
21
26
  app.use(express.json({
22
27
  limit: config.maxBodyBytes,
23
28
  type: ["application/json"],
@@ -140,6 +145,81 @@ export function createIngesterApp(config) {
140
145
  }
141
146
  res.status(200).json({ partialSuccess: {} });
142
147
  }
148
+ app.post("/v1/profiles", async (req, res) => {
149
+ const ctx = await authenticate(req, res, "otlp");
150
+ if (!ctx)
151
+ return;
152
+ if (!req.is("application/x-pprof") || !Buffer.isBuffer(req.body)) {
153
+ res.status(415).json({ error: "expected application/x-pprof" });
154
+ return;
155
+ }
156
+ const service = req.header("x-autter-service")?.trim() ?? "";
157
+ const environment = req.header("x-autter-environment")?.trim() || "production";
158
+ const release = req.header("x-autter-release")?.trim() ?? "";
159
+ const traceId = req.header("x-autter-trace-id")?.trim() ?? "";
160
+ const instanceId = req.header("x-autter-instance-id")?.trim() ?? "";
161
+ if (!/^[a-zA-Z0-9._-]{1,200}$/.test(service) || environment.length > 100 || release.length > 200 || instanceId.length > 128 || (traceId && !/^[a-f0-9]{32}$/.test(traceId))) {
162
+ res.status(400).json({ error: "invalid profile metadata" });
163
+ return;
164
+ }
165
+ let samples;
166
+ try {
167
+ samples = decodeProfile(req.body, { service, environment, release, traceId, instanceId });
168
+ if (!samples.length)
169
+ throw new Error("empty profile");
170
+ }
171
+ catch {
172
+ res.status(400).json({ error: "invalid or unsymbolized profile" });
173
+ return;
174
+ }
175
+ try {
176
+ await store.insertProfileSamples(ctx, samples);
177
+ res.status(202).json({ profileId: samples[0].profileId, samples: samples.length });
178
+ }
179
+ catch (err) {
180
+ storageError(res, err);
181
+ }
182
+ });
183
+ app.post("/v1/sourcemaps", async (req, res) => {
184
+ const ctx = await authenticate(req, res, "otlp");
185
+ if (!ctx)
186
+ return;
187
+ const sourceMap = validateSourceMap(req.body);
188
+ if (!sourceMap) {
189
+ res.status(400).json({ error: "invalid source map" });
190
+ return;
191
+ }
192
+ try {
193
+ await store.insertSourceMap(ctx, sourceMap);
194
+ res.status(202).json({ accepted: true });
195
+ }
196
+ catch (err) {
197
+ storageError(res, err);
198
+ }
199
+ });
200
+ /** ECS/Kubernetes event forwarders use the same server key as OTLP. */
201
+ app.post("/v1/platform-events", async (req, res) => {
202
+ const ctx = await authenticate(req, res, "otlp");
203
+ if (!ctx)
204
+ return;
205
+ const parsed = platformEventSchema.safeParse(req.body);
206
+ if (!parsed.success) {
207
+ res.status(400).json({ error: "invalid platform event" });
208
+ return;
209
+ }
210
+ const event = normalizePlatformEvent(parsed.data);
211
+ if (!event) {
212
+ res.status(400).json({ error: "event timestamp outside retention window" });
213
+ return;
214
+ }
215
+ try {
216
+ await store.insertPlatformEvent(ctx, event);
217
+ res.status(202).json({ accepted: true });
218
+ }
219
+ catch (err) {
220
+ storageError(res, err);
221
+ }
222
+ });
143
223
  app.post("/v1/traces", async (req, res) => {
144
224
  const ctx = await authenticate(req, res, "otlp");
145
225
  if (!ctx)
@@ -200,9 +280,11 @@ export function createIngesterApp(config) {
200
280
  request = req.body;
201
281
  }
202
282
  const metricPoints = normalizeMetrics(request);
283
+ const memorySamples = normalizeMemoryMetrics(request);
203
284
  try {
204
285
  await store.insertLatencyHistograms(ctx, normalizeLatencyHistograms(request));
205
286
  await store.insertMetricPoints(ctx, metricPoints);
287
+ await store.insertMemorySamples(ctx, memorySamples);
206
288
  }
207
289
  catch (err) {
208
290
  storageError(res, err);
@@ -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.3",
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",