@autter/otlp-ingester 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import type { IngesterConfig } from "./config.js";
2
+ import { type LatencyHistogram } from "./latency.js";
2
3
  import { type Migration } from "./migrations.js";
3
4
  import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence, RuntimeSpanRow } from "./types.js";
4
5
  export declare class ClickHouseStore {
@@ -24,5 +25,6 @@ export declare class ClickHouseStore {
24
25
  insertSpans(ctx: IngestContext, spans: RuntimeSpanRow[]): Promise<void>;
25
26
  insertLlmCalls(ctx: IngestContext, calls: RuntimeLlmCall[]): Promise<void>;
26
27
  insertMetricPoints(ctx: IngestContext, points: RuntimeMetricPoint[]): Promise<void>;
28
+ insertLatencyHistograms(ctx: IngestContext, points: LatencyHistogram[]): Promise<void>;
27
29
  close(): Promise<void>;
28
30
  }
@@ -1,4 +1,5 @@
1
1
  import { createClient, } from "@clickhouse/client";
2
+ import { latencyTableDDL } from "./latency.js";
2
3
  import { MIGRATIONS, migrationsTableDDL, } from "./migrations.js";
3
4
  /**
4
5
  * ClickHouse persistence. The schema is idempotent and applied lazily once
@@ -44,6 +45,7 @@ export class ClickHouseStore {
44
45
  const { occurrenceTtlDays, spanTtlDays, metricsTtlDays, llmCallTtlDays } = this.config;
45
46
  return [
46
47
  `CREATE DATABASE IF NOT EXISTS ${db}`,
48
+ latencyTableDDL(db, metricsTtlDays),
47
49
  `CREATE TABLE IF NOT EXISTS ${db}.runtime_error_occurrences (
48
50
  org_id String,
49
51
  repository_id String,
@@ -312,6 +314,23 @@ export class ClickHouseStore {
312
314
  })),
313
315
  });
314
316
  }
317
+ async insertLatencyHistograms(ctx, points) {
318
+ if (points.length === 0 || !this.configured)
319
+ return;
320
+ await this.ensureSchema();
321
+ await this.getClient().insert({
322
+ table: this.table("runtime_latency_histograms"),
323
+ format: "JSONEachRow",
324
+ clickhouse_settings: INSERT_SETTINGS,
325
+ values: points.map((point) => ({
326
+ org_id: ctx.orgId, repository_id: ctx.repositoryId, point_id: point.pointId,
327
+ service: point.service, environment: point.environment, release: point.release,
328
+ method: point.method, route: point.route, bucket_at: point.bucketAt.toISOString(),
329
+ request_count: point.requestCount, error_count: point.errorCount,
330
+ duration_sum_ms: point.durationSumMs, bounds_ms: point.boundsMs, counts: point.counts,
331
+ })),
332
+ });
333
+ }
315
334
  async close() {
316
335
  if (!this.client)
317
336
  return;
@@ -0,0 +1,17 @@
1
+ import type { OtlpMetricsRequest } from "./normalize-otlp.js";
2
+ export interface LatencyHistogram {
3
+ pointId: string;
4
+ service: string;
5
+ environment: string;
6
+ release: string;
7
+ method: string;
8
+ route: string;
9
+ bucketAt: Date;
10
+ requestCount: number;
11
+ errorCount: number;
12
+ durationSumMs: number;
13
+ boundsMs: number[];
14
+ counts: number[];
15
+ }
16
+ export declare function latencyTableDDL(database: string, ttlDays?: number): string;
17
+ export declare function normalizeLatencyHistograms(request: OtlpMetricsRequest): LatencyHistogram[];
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import { normalizeRoute } from "./fingerprint.js";
3
+ export function latencyTableDDL(database, ttlDays = 30) {
4
+ return `CREATE TABLE IF NOT EXISTS ${database}.runtime_latency_histograms (
5
+ org_id String, repository_id String, point_id String,
6
+ service LowCardinality(String), environment LowCardinality(String),
7
+ release String, method LowCardinality(String), route String,
8
+ bucket_at DateTime('UTC'), request_count UInt64, error_count UInt64,
9
+ duration_sum_ms Float64, bounds_ms Array(Float64), counts Array(UInt64)
10
+ ) ENGINE = ReplacingMergeTree
11
+ PARTITION BY toDate(bucket_at)
12
+ ORDER BY (org_id, repository_id, service, environment, bucket_at, point_id)
13
+ TTL bucket_at + INTERVAL ${Math.max(1, Math.round(ttlDays))} DAY`;
14
+ }
15
+ export function normalizeLatencyHistograms(request) {
16
+ const points = [];
17
+ for (const resource of request.resourceMetrics ?? []) {
18
+ const hasStableDuration = resource.scopeMetrics?.some((scope) => scope.metrics?.some((metric) => metric.name === "http.server.request.duration"));
19
+ const resourceAttributes = Object.fromEntries((resource.resource?.attributes ?? [])
20
+ .map((attribute) => [attribute.key ?? "", attribute.value?.stringValue ?? ""]));
21
+ for (const scope of resource.scopeMetrics ?? []) {
22
+ for (const metric of scope.metrics ?? []) {
23
+ if (hasStableDuration && metric.name === "http.server.duration")
24
+ continue;
25
+ const multiplier = metric.name === "http.server.request.duration" ? 1000
26
+ : metric.name === "http.server.duration" ? 1 : null;
27
+ if (multiplier === null || ![2, "2", "AGGREGATION_TEMPORALITY_DELTA"].includes(metric.histogram?.aggregationTemporality ?? ""))
28
+ continue;
29
+ for (const point of metric.histogram?.dataPoints ?? []) {
30
+ const bounds = point.explicitBounds ?? [];
31
+ const counts = (point.bucketCounts ?? []).map(Number);
32
+ const count = Number(point.count);
33
+ const endMs = Number(point.timeUnixNano) / 1e6;
34
+ const startMs = Number(point.startTimeUnixNano) / 1e6;
35
+ if (!Number.isSafeInteger(count) || count <= 0 || !Number.isFinite(endMs)
36
+ || !Number.isFinite(startMs) || endMs <= startMs || endMs - startMs > 120_000
37
+ || !Number.isFinite(point.sum) || Number(point.sum) < 0
38
+ || bounds.length === 0 || bounds.length > 256 || counts.length !== bounds.length + 1
39
+ || bounds.some((bound, index) => !Number.isFinite(bound) || bound < 0 || (index > 0 && bound <= bounds[index - 1]))
40
+ || counts.some((value) => !Number.isSafeInteger(value) || value < 0)
41
+ || counts.reduce((total, value) => total + value, 0) !== count)
42
+ continue;
43
+ const attrs = Object.fromEntries((point.attributes ?? []).map((attribute) => [attribute.key ?? "",
44
+ String(attribute.value?.stringValue ?? attribute.value?.intValue ?? "")]));
45
+ const route = normalizeRoute(attrs["http.route"] ?? "");
46
+ const method = attrs["http.request.method"] ?? attrs["http.method"] ?? "";
47
+ if (!route || !method)
48
+ continue;
49
+ const status = Number(attrs["http.response.status_code"] ?? attrs["http.status_code"]);
50
+ points.push({
51
+ pointId: createHash("sha256").update(JSON.stringify([metric.name,
52
+ Object.entries(resourceAttributes).sort(), Object.entries(attrs).sort(),
53
+ String(point.startTimeUnixNano), String(point.timeUnixNano)])).digest("hex"),
54
+ service: resourceAttributes["service.name"] || "unknown",
55
+ environment: resourceAttributes["deployment.environment.name"] || resourceAttributes["deployment.environment"] || "production",
56
+ release: resourceAttributes["service.version"] || "",
57
+ method: method.slice(0, 20), route,
58
+ bucketAt: new Date(Math.floor(endMs / 60_000) * 60_000),
59
+ requestCount: count, errorCount: status >= 500 ? count : 0,
60
+ durationSumMs: Number(point.sum) * multiplier,
61
+ boundsMs: bounds.map((bound) => bound * multiplier), counts,
62
+ });
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return points;
68
+ }
@@ -32,6 +32,7 @@
32
32
  * ],
33
33
  * },
34
34
  */
35
+ import { latencyTableDDL } from "./latency.js";
35
36
  export const MIGRATIONS = [
36
37
  // 0001 intentionally reserved as the baseline marker: databases created
37
38
  // before the migration runner existed record it as applied without
@@ -113,6 +114,7 @@ export const MIGRATIONS = [
113
114
  TTL toDateTime(started_at) + INTERVAL 90 DAY`,
114
115
  ],
115
116
  },
117
+ { id: "0005-latency-histograms", statements: [latencyTableDDL("{db}")] },
116
118
  ];
117
119
  /** The tracking table itself — created by the runner before anything else. */
118
120
  export function migrationsTableDDL(db) {
@@ -24,7 +24,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
24
24
  route: z.ZodOptional<z.ZodString>;
25
25
  context: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
26
26
  }, "strip", z.ZodTypeAny, {
27
- type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
27
+ type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
28
28
  message: string;
29
29
  timestamp: string;
30
30
  id?: string | undefined;
@@ -38,7 +38,7 @@ export declare const browserPayloadSchema: z.ZodObject<{
38
38
  route?: string | undefined;
39
39
  context?: Record<string, unknown> | undefined;
40
40
  }, {
41
- type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
41
+ type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
42
42
  timestamp: string;
43
43
  id?: string | undefined;
44
44
  message?: string | undefined;
@@ -53,11 +53,11 @@ export declare const browserPayloadSchema: z.ZodObject<{
53
53
  context?: Record<string, unknown> | undefined;
54
54
  }>, "many">;
55
55
  }, "strip", z.ZodTypeAny, {
56
- version: 1;
57
56
  service: string;
58
57
  environment: string;
58
+ version: 1;
59
59
  events: {
60
- type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
60
+ type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
61
61
  message: string;
62
62
  timestamp: string;
63
63
  id?: string | undefined;
@@ -71,14 +71,14 @@ export declare const browserPayloadSchema: z.ZodObject<{
71
71
  route?: string | undefined;
72
72
  context?: Record<string, unknown> | undefined;
73
73
  }[];
74
- sessionId?: string | undefined;
75
74
  release?: string | undefined;
75
+ sessionId?: string | undefined;
76
76
  }, {
77
- version: 1;
78
77
  service: string;
79
78
  environment: string;
79
+ version: 1;
80
80
  events: {
81
- type: "message" | "exception" | "unhandled_rejection" | "session_start" | "track_event";
81
+ type: "exception" | "message" | "unhandled_rejection" | "session_start" | "track_event";
82
82
  timestamp: string;
83
83
  id?: string | undefined;
84
84
  message?: string | undefined;
@@ -92,8 +92,8 @@ export declare const browserPayloadSchema: z.ZodObject<{
92
92
  route?: string | undefined;
93
93
  context?: Record<string, unknown> | undefined;
94
94
  }[];
95
- sessionId?: string | undefined;
96
95
  release?: string | undefined;
96
+ sessionId?: string | undefined;
97
97
  }>;
98
98
  export type BrowserPayload = z.infer<typeof browserPayloadSchema>;
99
99
  export interface NormalizedBrowser {
@@ -50,7 +50,10 @@ export interface OtlpTraceRequest {
50
50
  }
51
51
  interface OtlpDataPoint {
52
52
  attributes?: OtlpKeyValue[];
53
+ startTimeUnixNano?: string | number;
53
54
  timeUnixNano?: string | number;
55
+ explicitBounds?: number[];
56
+ bucketCounts?: Array<string | number>;
54
57
  count?: string | number;
55
58
  sum?: number;
56
59
  asInt?: string | number;
@@ -124,10 +124,12 @@ export function normalizeTraces(request) {
124
124
  name: span.name ?? "unnamed",
125
125
  kind,
126
126
  status: isError ? "error" : "ok",
127
- route,
127
+ route: route ? normalizeRoute(route) : null,
128
128
  statusCode,
129
129
  durationMs,
130
- attributes: null,
130
+ attributes: {
131
+ "http.request.method": (attrs.get("http.request.method") ?? attrs.get("http.method") ?? "").slice(0, 20),
132
+ },
131
133
  startedAt,
132
134
  });
133
135
  // Error occurrences: one per exception event; if the span is
@@ -66,9 +66,12 @@ message NumberDataPoint {
66
66
  }
67
67
  message Histogram { repeated HistogramDataPoint data_points = 1; int32 aggregation_temporality = 2; }
68
68
  message HistogramDataPoint {
69
+ fixed64 start_time_unix_nano = 2;
69
70
  fixed64 time_unix_nano = 3;
70
71
  fixed64 count = 4;
71
72
  optional double sum = 5;
73
+ repeated fixed64 bucket_counts = 6;
74
+ repeated double explicit_bounds = 7;
72
75
  repeated KeyValue attributes = 9;
73
76
  }
74
77
  `;
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import express from "express";
2
2
  import { KeyResolver, RateLimiter } from "./auth.js";
3
3
  import { ClickHouseStore } from "./clickhouse.js";
4
+ import { normalizeLatencyHistograms } from "./latency.js";
4
5
  import { deriveFields, fingerprintOccurrence, occurrenceIdFor, } from "./fingerprint.js";
5
6
  import { browserPayloadSchema, normalizeBrowserPayload, } from "./normalize-browser.js";
6
7
  import { normalizeMetrics, normalizeTraces, } from "./normalize-otlp.js";
@@ -200,6 +201,7 @@ export function createIngesterApp(config) {
200
201
  }
201
202
  const metricPoints = normalizeMetrics(request);
202
203
  try {
204
+ await store.insertLatencyHistograms(ctx, normalizeLatencyHistograms(request));
203
205
  await store.insertMetricPoints(ctx, metricPoints);
204
206
  }
205
207
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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",