Skip to content
8 changes: 1 addition & 7 deletions src/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,14 @@ describe("metrics", () => {

describe("histogram", () => {
it("should publish histogram metric with options and tags", () => {
const options = {
aggregates: ["max", "min", "avg"] as HistogramOptions["aggregates"],
percentiles: [0.5, 0.95, 0.99],
};

metrics.histogram("test.histogram", 150, options, { endpoint: "/api/users" });
metrics.histogram("test.histogram", 150, { endpoint: "/api/users" });

expect(receivedMessages).toHaveLength(1);
expect(receivedMessages[0]).toEqual({
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: 150,
tags: { endpoint: "/api/users" },
options,
});
});
});
Expand Down
3 changes: 0 additions & 3 deletions src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
type CountMetricPayload,
type GaugeMetricPayload,
type HistogramMetricPayload,
type HistogramOptions,
METRICS_CHANNEL_NAME,
MetricType,
type Tags,
Expand Down Expand Up @@ -55,15 +54,13 @@ export function gauge(name: string, value: number, tags: Tags = {}): void {
export function histogram(
name: string,
value: number,
options: HistogramOptions = {},
tags: Tags = {},
): void {
const payload: HistogramMetricPayload = {
type: MetricType.HISTOGRAM,
name,
value,
tags,
options,
};

metricsChannel.publish(payload);
Expand Down
147 changes: 16 additions & 131 deletions src/metricsDb.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { describe, it, expect, beforeEach } from "vitest";
import { MetricsDb } from "./metricsDb";
import {
type EmittedMetricPayload,
MetricType,
type ExportedMetricPayload,
type HistogramAggregates,
} from "./types";

describe("MetricsDb", () => {
Expand Down Expand Up @@ -65,16 +64,12 @@ describe("MetricsDb", () => {
});

it("should store a histogram metric", () => {
const metric: ExportedMetricPayload = {
const metric: EmittedMetricPayload = {
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: 150,
tags: { endpoint: "/api/users" },
timestamp: Date.now(),
options: {
percentiles: [0.5, 0.95],
aggregates: ["max", "min"] as HistogramAggregates[],
},
};

metricsDb.storeMetric(metric);
Expand All @@ -84,10 +79,8 @@ describe("MetricsDb", () => {
expect(metrics[0]).toEqual({
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: [150],
value: [{ value: 150, time: metric.timestamp }],
tags: { endpoint: "/api/users" },
percentiles: [0.5, 0.95],
aggregates: ["max", "min"],
lastUpdated: metric.timestamp,
});
});
Expand All @@ -97,10 +90,6 @@ describe("MetricsDb", () => {
type: MetricType.HISTOGRAM,
name: "test.histogram",
tags: { endpoint: "/api/users" },
options: {
percentiles: [0.5],
aggregates: ["max"] as HistogramAggregates[],
},
};

metricsDb.storeMetric({
Expand All @@ -117,8 +106,7 @@ describe("MetricsDb", () => {

expect(metricsDb.getMetricCount()).toBe(1);
const metrics = metricsDb.getAllMetrics();
expect(metrics[0].value).toEqual([100, 200]);
expect(metrics[0].lastUpdated).toBe(2000);
expect(metrics[0].value).toEqual([{ value: 100, time: 1000 }, { value: 200, time: 2000 }]);
});

it("should group metric keys based on name, type, and tags", () => {
Expand Down Expand Up @@ -237,133 +225,31 @@ describe("MetricsDb", () => {
name: "test.histogram",
value: 100,
tags: { endpoint: "/api" },
timestamp: 1000,
options: {
percentiles: [0.5, 0.95],
},
});

metricsDb.storeMetric({
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: 200,
tags: { endpoint: "/api" },
timestamp: 2000,
options: {
percentiles: [0.5, 0.95],
},
});

const payloads = metricsDb.toMetricPayloads();

expect(payloads).toHaveLength(2);
expect(payloads).toContainEqual({
type: MetricType.GAUGE,
name: "test.histogram.p50",
value: 100, // 50th percentile of [100, 200]
tags: { endpoint: "/api" },
timestamp: 2000,
});
expect(payloads).toContainEqual({
type: MetricType.GAUGE,
name: "test.histogram.p95",
value: 200, // 95th percentile of [100, 200]
tags: { endpoint: "/api" },
timestamp: 2000,
});
});

it("should export histogram aggregates as appropriate metric types", () => {
metricsDb.storeMetric({
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: 100,
tags: { endpoint: "/api" },
timestamp: 1000,
options: {
aggregates: ["count", "max", "min", "avg"] as HistogramAggregates[],
},
timestamp: 1000
});

metricsDb.storeMetric({
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: 200,
tags: { endpoint: "/api" },
timestamp: 2000,
options: {
aggregates: ["count", "max", "min", "avg"] as HistogramAggregates[],
},
timestamp: 2000
});

const payloads = metricsDb.toMetricPayloads();

expect(payloads).toHaveLength(4);

// Count should be COUNT type
expect(payloads).toContainEqual({
type: MetricType.COUNT,
name: "test.histogram.count",
value: 2,
tags: { endpoint: "/api" },
timestamp: 2000,
});

// Other aggregates should be GAUGE type
expect(payloads).toContainEqual({
type: MetricType.GAUGE,
name: "test.histogram.max",
value: 200,
tags: { endpoint: "/api" },
timestamp: 2000,
});

expect(payloads).toContainEqual({
type: MetricType.GAUGE,
name: "test.histogram.min",
value: 100,
tags: { endpoint: "/api" },
timestamp: 2000,
});

expect(payloads).toContainEqual({
type: MetricType.GAUGE,
name: "test.histogram.avg",
value: 150,
tags: { endpoint: "/api" },
timestamp: 2000,
});
});

it("should handle histogram with both percentiles and aggregates", () => {
metricsDb.storeMetric({
expect(payloads).toHaveLength(1);
expect(payloads[0]).toEqual({
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: 100,
tags: { endpoint: "/api" },
timestamp: 1000,
options: {
percentiles: [0.5],
aggregates: ["count"] as HistogramAggregates[],
},
});

const payloads = metricsDb.toMetricPayloads();

expect(payloads).toHaveLength(2);
expect(payloads).toContainEqual({
type: MetricType.GAUGE,
name: "test.histogram.p50",
value: 100,
tags: { endpoint: "/api" },
timestamp: 1000,
});
expect(payloads).toContainEqual({
type: MetricType.COUNT,
name: "test.histogram.count",
value: 1,
value: [{
value: 100,
time: 1000,
}, {
value: 200,
time: 2000,
}],
tags: { endpoint: "/api" },
timestamp: 1000,
});
});

Expand All @@ -372,14 +258,13 @@ describe("MetricsDb", () => {
type: MetricType.HISTOGRAM,
name: "test.histogram",
value: 100,
options: {},
tags: { endpoint: "/api" },
timestamp: 1000,
});

const payloads = metricsDb.toMetricPayloads();

expect(payloads).toHaveLength(0);
expect(payloads).toHaveLength(1);
});
});

Expand Down
58 changes: 23 additions & 35 deletions src/metricsDb.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { calculateHistogramValue, calculatePercentile } from "./utils/maths";
import {
type HistogramAggregates,
type MetricPayload,
type ExportedMetricPayload,
MetricType,
type Tags,
type EmittedMetricPayload,
} from "./types";

interface BaseStoredMetric {
Expand All @@ -25,9 +24,10 @@ interface StoredGaugeMetric extends BaseStoredMetric {

interface StoredHistogramMetric extends BaseStoredMetric {
type: MetricType.HISTOGRAM;
value: number[];
percentiles?: number[];
aggregates?: HistogramAggregates[];
value: {
time: number;
value: number;
}[];
}

type StoredMetric =
Expand All @@ -49,7 +49,7 @@ export class MetricsDb {
return `${metric.name}:${metric.type}:${tagKey}`;
}

public storeMetric(metric: ExportedMetricPayload): void {
public storeMetric(metric: EmittedMetricPayload): void {
const key = this.getMetricKey(metric);
const existingMetric = this.metrics.get(key);

Expand Down Expand Up @@ -82,23 +82,28 @@ export class MetricsDb {

case MetricType.HISTOGRAM: {
const existingValue = existingMetric
? (existingMetric.value as number[])
? (existingMetric.value as { value: number; time: number }[])
: [];
this.metrics.set(key, {
type: metric.type,
name: metric.name,
tags: metric.tags,
percentiles: metric.options?.percentiles,
aggregates: metric.options?.aggregates,
value: [...existingValue, metric.value],

value: [
...existingValue,
{
value: Number(metric.value),
time: metric.timestamp,
},
],
lastUpdated: metric.timestamp,
});
}
}
}

public storeMetrics(
metrics: (MetricPayload & { timestamp: number })[],
metrics: (MetricPayload & { timestamp: number })[]
): void {
for (const metric of metrics) {
this.storeMetric(metric);
Expand Down Expand Up @@ -140,30 +145,13 @@ export class MetricsDb {
});
break;
case MetricType.HISTOGRAM: {
const sortedArray = [...metric.value].sort();

for (const percentile of metric.percentiles || []) {
const value = calculatePercentile(sortedArray, percentile);
payloads.push({
type: MetricType.GAUGE,
name: `${metric.name}.p${Math.round(percentile * 100)}`,
value: value,
tags: metric.tags,
timestamp: metric.lastUpdated,
});
}

for (const aggregate of metric.aggregates || []) {
const value = calculateHistogramValue(aggregate, metric.value);

payloads.push({
type: aggregate === "count" ? MetricType.COUNT : MetricType.GAUGE,
name: `${metric.name}.${aggregate}`,
value: value,
tags: metric.tags,
timestamp: metric.lastUpdated,
});
}
payloads.push({
type: MetricType.HISTOGRAM,
name: metric.name,
value: metric.value,
tags: metric.tags,
});
break;
}
}
}
Expand Down
Loading