diff --git a/src/discovery/hap_node_js_client.ts b/src/discovery/hap_node_js_client.ts index 36ab875..f211955 100644 --- a/src/discovery/hap_node_js_client.ts +++ b/src/discovery/hap_node_js_client.ts @@ -1,9 +1,9 @@ -import type {HapDiscover} from './api' -import {HAPNodeJSClient} from 'hap-node-client' +import type { HapDiscover } from './api' +import { HAPNodeJSClient } from 'hap-node-client' -import {Device, DeviceBoundary} from '../boundaries' -import {Array, Unknown} from 'runtypes' -import {Logger} from "homebridge"; +import { Device, DeviceBoundary } from '../boundaries' +import { Array, Unknown } from 'runtypes' +import { Logger } from 'homebridge' const MaybeDevices = Array(Unknown) @@ -18,8 +18,8 @@ type HapClient = typeof HAPNodeJSClient type ResolveFunc = (devices: Device[]) => void type RejectFunc = (error: unknown) => void -const clientMap: Record = {} -const promiseMap: Record = {} +const clientMap: Record = {} +const promiseMap: Record = {} function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, reject: RejectFunc) { const key = JSON.stringify(config) @@ -35,11 +35,7 @@ function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, try { devices.push(DeviceBoundary.check(device)) } catch (e) { - logger.error( - 'Boundary check for device data failed %o %s', - e, - JSON.stringify(device, null, 4), - ) + logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4)) } } @@ -55,7 +51,7 @@ function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, promiseMap[key] = [resolve, reject] } -export const discover: HapDiscover = ({pin, refreshInterval, discoveryTimeout, requestTimeout, logger, debug}) => { +export const discover: HapDiscover = ({ pin, refreshInterval, discoveryTimeout, requestTimeout, logger, debug }) => { return new Promise((resolve, reject) => { startDiscovery( logger, @@ -67,7 +63,7 @@ export const discover: HapDiscover = ({pin, refreshInterval, discoveryTimeout, r pin, }, resolve, - reject + reject, ) }) } diff --git a/src/metrics.ts b/src/metrics.ts index 15ccf87..43a5b9e 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -8,8 +8,8 @@ export class Metric { constructor( public readonly name: string, public readonly value: number, - public readonly type: NumberTypes, - public readonly labels: Record, + public readonly timestamp: Date | null = null, + public readonly labels: Record = {}, ) {} } @@ -40,7 +40,7 @@ function debug(devices: Device[]): void { console.log(JSON.stringify(debugInfo, null, 4)) } -export function aggregate(devices: Device[]): Metric[] { +export function aggregate(devices: Device[], timestamp: Date): Metric[] { const metrics: Metric[] = [] for (const device of devices) { @@ -63,7 +63,7 @@ export function aggregate(devices: Device[]): Metric[] { characteristic.unit, ) if (!METRICS_FILTER.includes(name)) { - metrics.push(new Metric(name, characteristic.value, characteristic.format, labels)) + metrics.push(new Metric(name, characteristic.value, timestamp, labels)) } } } diff --git a/src/platform.ts b/src/platform.ts index bcd2e79..4b9c17c 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -4,6 +4,7 @@ import { Metric, aggregate } from './metrics' import { discover } from './discovery/hap_node_js_client' import { serve } from './http/fastify' import { HttpServerController } from './http/api' +import { MetricsRenderer } from './prometheus' export class PrometheusExporterPlatform implements IndependentPlatformPlugin { private metrics: Metric[] = [] @@ -61,16 +62,8 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin { } }, probeController: () => { - const prefix = 'homebridge_' - const metrics = this.metrics - .map((metric) => [ - `# TYPE ${prefix}${metric.name} gauge`, - `${prefix}${metric.name}{${Object.entries(metric.labels) - .map(([key, value]) => `${key}="${value}"`) - .join(',')}} ${metric.value}`, - ]) - .flat() - .join('\n') + const renderer = new MetricsRenderer('homebridge') + const metrics = this.metrics.map(renderer.render).join('\n') return { statusCode: 200, @@ -109,7 +102,7 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin { debug: this.config.debug, }) .then((devices) => { - this.metrics = aggregate(devices) + this.metrics = aggregate(devices, new Date()) this.metricsDiscovered = true this.log.debug('HAP discovery completed, %d metrics discovered', this.metrics.length) this.startHapDiscovery() diff --git a/src/prometheus.ts b/src/prometheus.ts new file mode 100644 index 0000000..5ecf63c --- /dev/null +++ b/src/prometheus.ts @@ -0,0 +1,70 @@ +import { Metric } from './metrics' + +function escapeString(str: string) { + return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n') +} + +/** + * String Attribute values are converted directly to Prometheus attribute values. + * Non-string values are represented as JSON-encoded strings. + * + * `undefined` is converted to an empty string. + */ +function escapeAttributeValue(str: string) { + if (typeof str !== 'string') { + str = JSON.stringify(str) + } + return escapeString(str).replace(/"/g, '\\"') +} + +const invalidCharacterRegex = /[^a-z0-9_]/gi + +/** + * Ensures metric names are valid Prometheus metric names by removing + * characters allowed by OpenTelemetry but disallowed by Prometheus. + * + * https://prometheus.io/docs/concepts/data_model/#metric-names-and-attributes + * + * 1. Names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` + * + * 2. Colons are reserved for user defined recording rules. + * They should not be used by exporters or direct instrumentation. + * + * OpenTelemetry metric names are already validated in the Meter when they are created, + * and they match the format `[a-zA-Z][a-zA-Z0-9_.\-]*` which is very close to a valid + * prometheus metric name, so we only need to strip characters valid in OpenTelemetry + * but not valid in prometheus and replace them with '_'. + * + * @param name name to be sanitized + */ +function sanitizePrometheusMetricName(name: string): string { + return name.replace(invalidCharacterRegex, '_') // replace all invalid characters with '_' +} + +export class MetricsRenderer { + constructor(private readonly prefix: string) {} + + render(metric: Metric): string { + const name = this.metricName(metric.name) + return [ + `# TYPE ${name} ${name.endsWith('_total') ? 'count' : 'gauge'}`, + `${name}${this.renderLabels(metric.labels)} ${metric.value}${ + metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : '' + }`, + ].join('\n') + } + + private renderLabels(labels: Metric['labels']): string { + const rendered = Object.entries(labels) + .map(([key, value]) => `${sanitizePrometheusMetricName(key)}="${escapeAttributeValue(value)}"`) + .join(',') + + return rendered !== '' ? '{' + rendered + '}' : '' + } + + private metricName(name: string): string { + name = name.replace(/^(.*_)?(total)_(.*)$/, '$1$3_$2') + + return sanitizePrometheusMetricName(this.prefix.replace(/_+$/, '') + '_' + name) + } +} diff --git a/tests/aggregator.test.ts b/tests/aggregator.test.ts index 4825b7e..08ea0ec 100644 --- a/tests/aggregator.test.ts +++ b/tests/aggregator.test.ts @@ -7,6 +7,8 @@ import tpLinkData from './fixtures/tp-link.json' import harmonyData from './fixtures/harmony.json' describe('Metrics aggregator', () => { + const timestamp = new Date('2000-01-01 00:00:00 UTC') + test('Aggregates homebridge-dyson fan metrics', () => { const dyson = DeviceBoundary.check(dysonData) @@ -21,24 +23,24 @@ describe('Metrics aggregator', () => { serial_number: 'NN2-EU-KKA0717A', } - expect(aggregate([dyson])).toEqual([ - new Metric('air_purifier_active', 0, 'uint8', expectedLabels), - new Metric('air_purifier_current_air_purifier_state', 0, 'uint8', expectedLabels), - new Metric('air_purifier_target_air_purifier_state', 0, 'uint8', expectedLabels), - new Metric('air_purifier_filter_life_level_percentage', 85, 'float', expectedLabels), - new Metric('air_purifier_rotation_speed_percentage', 100, 'float', expectedLabels), - new Metric('air_purifier_current_temperature_celsius', 22.8, 'float', expectedLabels), - new Metric('air_purifier_swing_mode', 0, 'uint8', expectedLabels), - new Metric('air_purifier_filter_change_indication', 0, 'uint8', expectedLabels), - new Metric('air_purifier_current_relative_humidity_percentage', 52, 'float', expectedLabels), - new Metric('air_purifier_air_quality', 2, 'uint8', expectedLabels), + expect(aggregate([dyson], timestamp)).toEqual([ + new Metric('air_purifier_active', 0, timestamp, expectedLabels), + new Metric('air_purifier_current_air_purifier_state', 0, timestamp, expectedLabels), + new Metric('air_purifier_target_air_purifier_state', 0, timestamp, expectedLabels), + new Metric('air_purifier_filter_life_level_percentage', 85, timestamp, expectedLabels), + new Metric('air_purifier_rotation_speed_percentage', 100, timestamp, expectedLabels), + new Metric('air_purifier_current_temperature_celsius', 22.8, timestamp, expectedLabels), + new Metric('air_purifier_swing_mode', 0, timestamp, expectedLabels), + new Metric('air_purifier_filter_change_indication', 0, timestamp, expectedLabels), + new Metric('air_purifier_current_relative_humidity_percentage', 52, timestamp, expectedLabels), + new Metric('air_purifier_air_quality', 2, timestamp, expectedLabels), ]) }) test('Aggregates empty accessory metrics to empty metrics', () => { const empty = DeviceBoundary.check(emptyData) - expect(aggregate([empty])).toEqual([]) + expect(aggregate([empty], timestamp)).toEqual([]) }) test('Aggregates TP-Link plugs metrics', () => { @@ -66,17 +68,17 @@ describe('Metrics aggregator', () => { serial_number: 'AA:AA:AA:AA:AA:AA 800614AA26608873C919E4592765404019F64D07', } - expect(aggregate([tpLink])).toEqual([ - new Metric('outlet_amperes_a', 0.03, 'float', expectedLabelsAccessory1), - new Metric('outlet_total_consumption_kwh', 0.051, 'float', expectedLabelsAccessory1), - new Metric('outlet_apparent_power_va', 53248.8, 'float', expectedLabelsAccessory1), - new Metric('outlet_volts_v', 230.8, 'float', expectedLabelsAccessory1), - new Metric('outlet_consumption_w', 0, 'float', expectedLabelsAccessory1), - new Metric('outlet_amperes_a', 0.03, 'float', expectedLabelsAccessory2), - new Metric('outlet_total_consumption_kwh', 13.025, 'float', expectedLabelsAccessory2), - new Metric('outlet_apparent_power_va', 53365.6, 'float', expectedLabelsAccessory2), - new Metric('outlet_volts_v', 231, 'float', expectedLabelsAccessory2), - new Metric('outlet_consumption_w', 0, 'float', expectedLabelsAccessory2), + expect(aggregate([tpLink], timestamp)).toEqual([ + new Metric('outlet_amperes_a', 0.03, timestamp, expectedLabelsAccessory1), + new Metric('outlet_total_consumption_kwh', 0.051, timestamp, expectedLabelsAccessory1), + new Metric('outlet_apparent_power_va', 53248.8, timestamp, expectedLabelsAccessory1), + new Metric('outlet_volts_v', 230.8, timestamp, expectedLabelsAccessory1), + new Metric('outlet_consumption_w', 0, timestamp, expectedLabelsAccessory1), + new Metric('outlet_amperes_a', 0.03, timestamp, expectedLabelsAccessory2), + new Metric('outlet_total_consumption_kwh', 13.025, timestamp, expectedLabelsAccessory2), + new Metric('outlet_apparent_power_va', 53365.6, timestamp, expectedLabelsAccessory2), + new Metric('outlet_volts_v', 231, timestamp, expectedLabelsAccessory2), + new Metric('outlet_consumption_w', 0, timestamp, expectedLabelsAccessory2), ]) }) @@ -126,24 +128,24 @@ describe('Metrics aggregator', () => { serial_number: '0e88f449-2720-4000-8c4b-06775986e8ac', } - expect(aggregate([harmony])).toEqual([ - new Metric('television_sleep_discovery_mode', 1, 'uint8', expectedLabels1), - new Metric('television_active', 0, 'uint8', expectedLabels1), - new Metric('television_active_identifier', 0, 'uint32', expectedLabels1), + expect(aggregate([harmony], timestamp)).toEqual([ + new Metric('television_sleep_discovery_mode', 1, timestamp, expectedLabels1), + new Metric('television_active', 0, timestamp, expectedLabels1), + new Metric('television_active_identifier', 0, timestamp, expectedLabels1), - new Metric('input_source_type', 10, 'uint8', expectedLabels2), - new Metric('input_source_is_configured', 1, 'uint8', expectedLabels2), - new Metric('input_source_current_visibility_state', 0, 'uint8', expectedLabels2), - new Metric('input_source_target_visibility_state', 0, 'uint8', expectedLabels2), + new Metric('input_source_type', 10, timestamp, expectedLabels2), + new Metric('input_source_is_configured', 1, timestamp, expectedLabels2), + new Metric('input_source_current_visibility_state', 0, timestamp, expectedLabels2), + new Metric('input_source_target_visibility_state', 0, timestamp, expectedLabels2), - new Metric('input_source_type', 10, 'uint8', expectedLabels3), - new Metric('input_source_is_configured', 1, 'uint8', expectedLabels3), - new Metric('input_source_current_visibility_state', 0, 'uint8', expectedLabels3), - new Metric('input_source_target_visibility_state', 0, 'uint8', expectedLabels3), + new Metric('input_source_type', 10, timestamp, expectedLabels3), + new Metric('input_source_is_configured', 1, timestamp, expectedLabels3), + new Metric('input_source_current_visibility_state', 0, timestamp, expectedLabels3), + new Metric('input_source_target_visibility_state', 0, timestamp, expectedLabels3), - new Metric('speaker_active', 1, 'uint8', expectedLabels4), - new Metric('speaker_volume_control_type', 3, 'uint8', expectedLabels4), - new Metric('speaker_volume_percentage', 50, 'uint8', expectedLabels4), + new Metric('speaker_active', 1, timestamp, expectedLabels4), + new Metric('speaker_volume_control_type', 3, timestamp, expectedLabels4), + new Metric('speaker_volume_percentage', 50, timestamp, expectedLabels4), ]) }) }) diff --git a/tests/prometheus.test.ts b/tests/prometheus.test.ts new file mode 100644 index 0000000..79d8e2f --- /dev/null +++ b/tests/prometheus.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from '@jest/globals' +import { MetricsRenderer } from '../src/prometheus' +import { Metric } from '../src/metrics' + +describe('Render prometheus metrics', () => { + const renderer = new MetricsRenderer('prefix') + + test('Renders simple metric', () => { + 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( + `# TYPE prefix_metric gauge +prefix_metric 0.000001 946684800000`, + ) + }) + + test('Renders simple metric with labels', () => { + expect( + 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`, + ) + }) + + test('Renders total as counter', () => { + for (const metricName of ['some_total_metric', 'some_metric_total', 'total_some_metric']) { + expect( + renderer.render( + new Metric(metricName, 42, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }), + ), + ).toEqual( + `# TYPE prefix_some_metric_total count +prefix_some_metric_total{label="Some Label"} 42 946684800000`, + ) + } + }) + + test('Sanitizes metric names', () => { + 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( + `# 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( + `# 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( + `# TYPE prefix_metric gauge +prefix_metric{label="foo\\"bar"} 0`, + ) + }) +})