From c5f289946c623cc54c954a24e722eaee6f506824 Mon Sep 17 00:00:00 2001 From: Lars Strojny Date: Sun, 8 Jan 2023 15:12:01 +0100 Subject: [PATCH] Fix missing metrics grouping (#97) Closes #96 --- package-lock.json | 53 ++++++++++++++--------------- package.json | 1 + src/ambient.d.ts | 9 +++++ src/prometheus.ts | 29 ++++++++++------ tests/adapters/http/fastify.test.ts | 2 ++ tests/prometheus.test.ts | 37 ++++++++++++++------ 6 files changed, 84 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index fbda6f9..8d62069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@fastify/auth": "^4.1.0", "@fastify/basic-auth": "^5.0.0", + "array.prototype.group": "^1.1.2", "bcrypt": "^5.1.0", "fastify": "^4.9.2", "hap-node-client": "^0.2.1", @@ -2351,6 +2352,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array.prototype.group/-/array.prototype.group-1.1.2.tgz", + "integrity": "sha512-XTy3DE/Tz1nsrhPaYXgVTzQrRf1/0/RWySM5o5qC6BSBG7NWQ/AxggKNiLyLCRkLxM+mJoq5GEFosYZ846QlHw==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.1.3" + } + }, "node_modules/array.prototype.map": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.5.tgz", @@ -3718,7 +3731,6 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.4.tgz", "integrity": "sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "es-to-primitive": "^1.2.1", @@ -3780,7 +3792,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, "dependencies": { "has": "^1.0.3" } @@ -3789,7 +3800,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, "dependencies": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -4895,7 +4905,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", @@ -5001,7 +5010,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -5753,7 +5761,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.0", "has": "^1.0.3", @@ -5986,7 +5993,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -6094,7 +6100,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -6199,7 +6204,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -9022,7 +9026,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -9377,7 +9380,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -9391,7 +9393,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -9900,7 +9901,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", @@ -12230,6 +12230,18 @@ "es-shim-unscopables": "^1.0.0" } }, + "array.prototype.group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array.prototype.group/-/array.prototype.group-1.1.2.tgz", + "integrity": "sha512-XTy3DE/Tz1nsrhPaYXgVTzQrRf1/0/RWySM5o5qC6BSBG7NWQ/AxggKNiLyLCRkLxM+mJoq5GEFosYZ846QlHw==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.1.3" + } + }, "array.prototype.map": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.5.tgz", @@ -13238,7 +13250,6 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.4.tgz", "integrity": "sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "es-to-primitive": "^1.2.1", @@ -13291,7 +13302,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, "requires": { "has": "^1.0.3" } @@ -13300,7 +13310,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -14143,7 +14152,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", @@ -14216,7 +14224,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -14756,7 +14763,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, "requires": { "get-intrinsic": "^1.1.0", "has": "^1.0.3", @@ -14907,8 +14913,7 @@ "is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==" }, "is-npm": { "version": "6.0.0", @@ -14971,7 +14976,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, "requires": { "call-bind": "^1.0.2" } @@ -15040,7 +15044,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, "requires": { "call-bind": "^1.0.2" } @@ -17096,7 +17099,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -17380,7 +17382,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -17391,7 +17392,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, "requires": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -17750,7 +17750,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, "requires": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", diff --git a/package.json b/package.json index 8d90a33..8d02742 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "dependencies": { "@fastify/auth": "^4.1.0", "@fastify/basic-auth": "^5.0.0", + "array.prototype.group": "^1.1.2", "bcrypt": "^5.1.0", "fastify": "^4.9.2", "hap-node-client": "^0.2.1", diff --git a/src/ambient.d.ts b/src/ambient.d.ts index fd2df59..5c0d9e7 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -18,3 +18,12 @@ declare module 'hap-node-client' { declare module '@homebridge/dbus-native' { type InvokeError = unknown } + +declare module 'array.prototype.group' { + function shim(): void +} + +interface Array { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + group(fn: (value: T, index: number, array: T[]) => U, thisArg?: any): { U: T[] } +} diff --git a/src/prometheus.ts b/src/prometheus.ts index 68f15df..798036f 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -2,6 +2,8 @@ import type { Logger } from 'homebridge' import type { HttpConfig, HttpResponse, HttpServer } from './adapters/http' import type { Metric } from './metrics' import { strTrimRight } from './std' +import { shim } from 'array.prototype.group' +shim() export class MetricsRenderer { private readonly prefix: string @@ -10,17 +12,24 @@ export class MetricsRenderer { this.prefix = strTrimRight(prefix, '_') } - render(metric: Metric): string { - const name = this.metricName(metric.name) - return [ - `# TYPE ${name} ${name.endsWith('_total') ? 'counter' : 'gauge'}`, - `${name}${this.renderLabels(metric.labels)} ${metric.value}${ - metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : '' - }`, - ].join('\n') + render(metrics: Metric[]): string { + return Object.entries(metrics.sort().group((metric) => this.metricName(metric.name))) + .map(([name, metrics]) => { + return [ + `# TYPE ${name} ${name.endsWith('_total') ? 'counter' : 'gauge'}`, + metrics.map((metric) => this.formatMetric(metric)).join('\n'), + ].join('\n') + }) + .join('\n\n') } - private renderLabels(labels: Metric['labels']): string { + private formatMetric(metric: Metric): string { + return `${this.metricName(metric.name)}${MetricsRenderer.renderLabels(metric.labels)} ${metric.value}${ + metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : '' + }` + } + + private static renderLabels(labels: Metric['labels']): string { const rendered = Object.entries(labels) .map(([label, val]) => `${sanitizePrometheusMetricName(label)}="${escapeAttributeValue(val)}"`) .join(',') @@ -91,7 +100,7 @@ export class PrometheusServer implements HttpServer { } onMetricsDiscovery(metrics: Metric[]): void { - this.metricsResponse = metrics.map((metric) => this.renderer.render(metric)).join('\n') + this.metricsResponse = this.renderer.render(metrics) this.metricsDiscovered = true } } diff --git a/tests/adapters/http/fastify.test.ts b/tests/adapters/http/fastify.test.ts index dddaaa8..df3beaa 100644 --- a/tests/adapters/http/fastify.test.ts +++ b/tests/adapters/http/fastify.test.ts @@ -71,6 +71,7 @@ describe('Fastify HTTP adapter', () => { [ '# TYPE homebridge_metric gauge', 'homebridge_metric{name="metric"} 0.1 1577836800000', + '', '# TYPE homebridge_something_total counter', 'homebridge_something_total{name="counter"} 100 1577836800000', ].join('\n'), @@ -117,6 +118,7 @@ describe('Fastify HTTP adapter', () => { [ '# TYPE homebridge_metric gauge', 'homebridge_metric{name="metric"} 0.1 1577836800000', + '', '# TYPE homebridge_something_total counter', 'homebridge_something_total{name="counter"} 100 1577836800000', ].join('\n'), diff --git a/tests/prometheus.test.ts b/tests/prometheus.test.ts index 88a8d3c..0ff6c9c 100644 --- a/tests/prometheus.test.ts +++ b/tests/prometheus.test.ts @@ -6,14 +6,14 @@ describe('Render prometheus metrics', () => { const renderer = new MetricsRenderer('prefix') test('Renders simple metric', () => { - expect(renderer.render(new Metric('metric', 0.000001))).toEqual( + expect(renderer.render([new Metric('metric', 0.000001)])).toEqual( `# TYPE prefix_metric gauge prefix_metric 0.000001`, ) }) test('Renders simple metric with timestamp', () => { - expect(renderer.render(new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC')))).toEqual( + expect(renderer.render([new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC'))])).toEqual( `# TYPE prefix_metric gauge prefix_metric 0.000001 946684800000`, ) @@ -21,9 +21,9 @@ prefix_metric 0.000001 946684800000`, test('Renders simple metric with labels', () => { expect( - renderer.render( + renderer.render([ new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }), - ), + ]), ).toEqual( `# TYPE prefix_metric gauge prefix_metric{label="Some Label"} 0.000001 946684800000`, @@ -33,9 +33,9 @@ prefix_metric{label="Some Label"} 0.000001 946684800000`, test('Renders total as counter', () => { for (const metricName of ['some_total_metric', 'some_metric_total', 'total_some_metric']) { expect( - renderer.render( + renderer.render([ new Metric(metricName, 42, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }), - ), + ]), ).toEqual( `# TYPE prefix_some_metric_total counter prefix_some_metric_total{label="Some Label"} 42 946684800000`, @@ -43,29 +43,46 @@ prefix_some_metric_total{label="Some Label"} 42 946684800000`, } }) + test('Renders multiple metrics correctly', () => { + expect( + renderer.render([ + new Metric('some_gauge', 10, new Date('2000-01-01 00:00:00 UTC')), + new Metric('another_gauge', 30, new Date('2000-01-01 00:00:00 UTC')), + new Metric('some_gauge', 20, new Date('2000-01-01 00:00:00 UTC')), + ]), + ).toEqual( + `# TYPE prefix_some_gauge gauge +prefix_some_gauge 10 946684800000 +prefix_some_gauge 20 946684800000 + +# TYPE prefix_another_gauge gauge +prefix_another_gauge 30 946684800000`, + ) + }) + test('Sanitizes metric names', () => { - expect(renderer.render(new Metric('mätric name', 0))).toEqual( + expect(renderer.render([new Metric('mätric name', 0)])).toEqual( `# TYPE prefix_m_tric_name gauge prefix_m_tric_name 0`, ) }) test('Sanitizes label names', () => { - expect(renderer.render(new Metric('metric', 0, null, { 'yet another label': 'foo' }))).toEqual( + expect(renderer.render([new Metric('metric', 0, null, { 'yet another label': 'foo' })])).toEqual( `# TYPE prefix_metric gauge prefix_metric{yet_another_label="foo"} 0`, ) }) test('Escapes newlines in attribute value', () => { - expect(renderer.render(new Metric('metric', 0, null, { label: 'foo\nbar' }))).toEqual( + expect(renderer.render([new Metric('metric', 0, null, { label: 'foo\nbar' })])).toEqual( `# TYPE prefix_metric gauge prefix_metric{label="foo\\nbar"} 0`, ) }) test('Escapes quotes in attribute value', () => { - expect(renderer.render(new Metric('metric', 0, null, { label: 'foo"bar' }))).toEqual( + expect(renderer.render([new Metric('metric', 0, null, { label: 'foo"bar' })])).toEqual( `# TYPE prefix_metric gauge prefix_metric{label="foo\\"bar"} 0`, )