@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 +4 -2
- package/dist/clickhouse.d.ts +10 -0
- package/dist/clickhouse.js +58 -0
- package/dist/memory.d.ts +45 -0
- package/dist/memory.js +114 -0
- package/dist/migrations.js +12 -0
- package/dist/normalize-browser.d.ts +16 -11
- package/dist/normalize-browser.js +21 -1
- package/dist/normalize-otlp.d.ts +4 -0
- package/dist/normalize-otlp.js +34 -6
- package/dist/otlp-proto.js +3 -1
- package/dist/profiles.d.ts +22 -0
- package/dist/profiles.js +63 -0
- package/dist/server.js +82 -0
- package/dist/source-maps.d.ts +6 -0
- package/dist/source-maps.js +41 -0
- package/package.json +1 -1
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
|
|
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
|
|
package/dist/clickhouse.d.ts
CHANGED
|
@@ -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>;
|
package/dist/clickhouse.js
CHANGED
|
@@ -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;
|
package/dist/memory.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/migrations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.d.ts
CHANGED
|
@@ -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
|
}>;
|
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),
|
package/dist/otlp-proto.js
CHANGED
|
@@ -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
|
|
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[];
|
package/dist/profiles.js
ADDED
|
@@ -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,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.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",
|