Skip to content

Commit 9e4d7c9

Browse files
authored
Properly encode values in Prometheus text formatter (#45)
* Properly encode values in prometheus text formatter * Add npm version and license badges to main README
1 parent 064304e commit 9e4d7c9

File tree

8 files changed

+330
-88
lines changed

8 files changed

+330
-88
lines changed

.changeset/ninety-pigs-watch.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"metriq": minor
3+
"@metriq/express": minor
4+
"@metriq/fastify": minor
5+
"@metriq/nestjs": minor
6+
"@metriq/benchmarks": minor
7+
"@metriq/examples-basic": minor
8+
"@metriq/examples-nestjs": minor
9+
---
10+
11+
Properly encode values in prometheus text formatter

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# metriq
22

3+
[![npm version](https://img.shields.io/npm/v/metriq.svg)](https://www.npmjs.com/package/metriq)
4+
[![License](https://img.shields.io/npm/l/metriq.svg)](https://www.npmjs.com/package/metriq)
5+
36
A high-performance TypeScript metrics collection library designed for heavy workloads. Metriq provides a modern, type-safe API for collecting and exposing metrics with exceptional performance characteristics.
47

58
## ⚡ Performance

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
- [x] collect callback
1212
- [x] typed labels
1313
- [x] clear/reset
14-
- [ ] validation/escaping
14+
- [ ] validation
15+
- [x] escaping
1516
- [x] Support massive amount of metrics
1617
- [x] Performance
1718
- [x] Streaming writer

metriq/src/exporters/prometheus-formatter.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,152 @@ describe("PrometheusFormatter", () => {
903903
});
904904
});
905905

906+
describe("encoding", () => {
907+
let metrics: MetricsImpl;
908+
let formatter: PrometheusFormatterImpl;
909+
910+
beforeEach(() => {
911+
metrics = new MetricsImpl({ enableInternalMetrics: false });
912+
formatter = new PrometheusFormatterImpl(metrics);
913+
});
914+
915+
describe("label values", () => {
916+
it("should escape double quotes", async () => {
917+
// Arrange
918+
const counter = metrics.createCounter("counter", "description");
919+
counter.increment({ key: 'value"with"double"quotes' }, 5);
920+
921+
// Act
922+
const stream = formatter.writeMetrics();
923+
const result = await consumeAsyncStringGenerator(stream);
924+
925+
// Assert
926+
expect(result).toBe(dedent`
927+
# HELP counter description
928+
# TYPE counter counter
929+
counter{key="value\"with\"double\"quotes"} 5\n
930+
`);
931+
});
932+
933+
it("should escape backslashes", async () => {
934+
// Arrange
935+
const counter = metrics.createCounter("counter", "description");
936+
counter.increment({ key: "value\\with\\backslashes" }, 5);
937+
938+
// Act
939+
const stream = formatter.writeMetrics();
940+
const result = await consumeAsyncStringGenerator(stream);
941+
942+
// Assert
943+
expect(result).toBe(dedent`
944+
# HELP counter description
945+
# TYPE counter counter
946+
counter{key="value\\with\\backslashes"} 5\n
947+
`);
948+
});
949+
950+
it("should escape newlines", async () => {
951+
// Arrange
952+
const counter = metrics.createCounter("counter", "description");
953+
counter.increment({ key: "value\nwith\nnewlines" }, 5);
954+
955+
// Act
956+
const stream = formatter.writeMetrics();
957+
const result = await consumeAsyncStringGenerator(stream);
958+
959+
// Assert
960+
expect(result).toBe(
961+
dedent(`
962+
# HELP counter description
963+
# TYPE counter counter
964+
counter{key="value\\nwith\\nnewlines"} 5`) + "\n",
965+
);
966+
});
967+
968+
it("should replace undefined values with empty strings", async () => {
969+
// Arrange
970+
const counter = metrics.createCounter("counter", "description");
971+
counter.increment({ key: undefined }, 5);
972+
973+
// Act
974+
const stream = formatter.writeMetrics();
975+
const result = await consumeAsyncStringGenerator(stream);
976+
977+
// Assert
978+
expect(result).toBe(dedent`
979+
# HELP counter description
980+
# TYPE counter counter
981+
counter{key=""} 5\n`);
982+
});
983+
984+
it("should throw error if label value is not a string", async () => {
985+
// Arrange
986+
const counter = metrics.createCounter("counter", "description");
987+
988+
// @ts-expect-error - label value is not a string
989+
counter.increment({ key: null }, 5);
990+
991+
// Act
992+
const stream = formatter.writeMetrics();
993+
994+
// Assert
995+
await expect(consumeAsyncStringGenerator(stream)).rejects.toThrow();
996+
});
997+
});
998+
999+
describe("metric values", () => {
1000+
it("should encode NaN", async () => {
1001+
// Arrange
1002+
const gauge = metrics.createGauge("gauge", "description");
1003+
gauge.set(NaN);
1004+
// Act
1005+
const stream = formatter.writeMetrics();
1006+
const result = await consumeAsyncStringGenerator(stream);
1007+
1008+
// Assert
1009+
expect(result).toBe(dedent`
1010+
# HELP gauge description
1011+
# TYPE gauge gauge
1012+
gauge NaN\n
1013+
`);
1014+
});
1015+
1016+
it("should encode Infinity", async () => {
1017+
// Arrange
1018+
const gauge = metrics.createGauge("gauge", "description");
1019+
gauge.set(Infinity);
1020+
1021+
// Act
1022+
const stream = formatter.writeMetrics();
1023+
const result = await consumeAsyncStringGenerator(stream);
1024+
1025+
// Assert
1026+
expect(result).toBe(dedent`
1027+
# HELP gauge description
1028+
# TYPE gauge gauge
1029+
gauge +Inf\n
1030+
`);
1031+
});
1032+
1033+
it("should encode -Infinity", async () => {
1034+
// Arrange
1035+
const gauge = metrics.createGauge("gauge", "description");
1036+
gauge.set(-Infinity);
1037+
1038+
// Act
1039+
const stream = formatter.writeMetrics();
1040+
const result = await consumeAsyncStringGenerator(stream);
1041+
1042+
// Assert
1043+
expect(result).toBe(dedent`
1044+
# HELP gauge description
1045+
# TYPE gauge gauge
1046+
gauge -Inf\n
1047+
`);
1048+
});
1049+
});
1050+
});
1051+
9061052
describe("async collect", () => {
9071053
let metrics: MetricsImpl;
9081054
let formatter: PrometheusFormatterImpl;

metriq/src/exporters/prometheus-formatter.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HistogramImpl } from "../instruments/histogram";
44
import { MetricsImpl } from "../metrics";
55
import { Labels } from "../types";
66
import { batchGenerator } from "../utils";
7+
import { encodeMetricValue, escapeLabelValue } from "./prometheus-text-utils";
78

89
const BATCH_SIZE = 40000;
910

@@ -43,7 +44,7 @@ export class PrometheusFormatterImpl {
4344
yield* batchGenerator(
4445
counter.getInstrumentValues(),
4546
BATCH_SIZE,
46-
(item) => `${counter.name}${this.writeLabels(item.labels)} ${item.value}\n`,
47+
(item) => `${counter.name}${this.writeLabels(item.labels)} ${encodeMetricValue(item.value)}\n`,
4748
);
4849
}
4950

@@ -54,7 +55,7 @@ export class PrometheusFormatterImpl {
5455
yield* batchGenerator(
5556
instrument.getInstrumentValues(),
5657
BATCH_SIZE,
57-
(item) => `${instrument.name}${this.writeLabels(item.labels)} ${item.value}\n`,
58+
(item) => `${instrument.name}${this.writeLabels(item.labels)} ${encodeMetricValue(item.value)}\n`,
5859
);
5960
}
6061

@@ -63,9 +64,9 @@ export class PrometheusFormatterImpl {
6364
yield `# TYPE ${histogram.name} histogram\n`;
6465

6566
yield* batchGenerator(histogram.getInstrumentValues(), BATCH_SIZE, ({ labels, value }) => {
66-
const sum = value[value.length - 1];
67-
const count = value[value.length - 2];
68-
const buckets = value.slice(0, value.length - 2);
67+
const sum = encodeMetricValue(value[value.length - 1]);
68+
const count = encodeMetricValue(value[value.length - 2]);
69+
const buckets = value.slice(0, value.length - 2).map(encodeMetricValue);
6970

7071
let output = "";
7172

@@ -93,7 +94,7 @@ export class PrometheusFormatterImpl {
9394

9495
for (let i = 0; i < len; i++) {
9596
const key = keys[i];
96-
const value = labels[key];
97+
const value = escapeLabelValue(labels[key]);
9798
segments[i] = `${key}="${value}"`;
9899
}
99100

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const QUOTE = 34; // '"' -> 0x22
2+
const BACKSL = 92; // '\' -> 0x5c
3+
const NEWLINE = 10; // '\n' -> 0x0a
4+
5+
export function escapeLabelValue(str: string | undefined): string {
6+
if (typeof str !== "string") {
7+
if (typeof str === "undefined") {
8+
return "";
9+
}
10+
11+
throw new Error("Label value is not a string");
12+
}
13+
14+
let out = "";
15+
let start = 0;
16+
17+
for (let i = 0; i < str.length; ++i) {
18+
const code = str.charCodeAt(i);
19+
if (code === QUOTE || code === BACKSL || code === NEWLINE) {
20+
if (start < i) {
21+
out += str.slice(start, i); // flush safe run
22+
}
23+
24+
out += code === NEWLINE ? "\\n" : "\\" + str[i];
25+
start = i + 1;
26+
}
27+
}
28+
29+
if (start < str.length) {
30+
out += str.slice(start); // flush any tail
31+
}
32+
33+
return out;
34+
}
35+
36+
export function encodeMetricValue(value: number): string {
37+
if (typeof value !== "number") {
38+
throw new Error("Value is not a number");
39+
}
40+
41+
if (Number.isNaN(value)) {
42+
return "NaN";
43+
}
44+
45+
if (value === Infinity) {
46+
return "+Inf";
47+
}
48+
49+
if (value === -Infinity) {
50+
return "-Inf";
51+
}
52+
53+
return value.toString();
54+
}

0 commit comments

Comments
 (0)