Extract rendering

This commit is contained in:
Lars Strojny 2022-11-07 20:49:11 +01:00
parent 4b4f8e8d0f
commit ff7fd44f2c
6 changed files with 201 additions and 67 deletions

View file

@ -1,9 +1,9 @@
import type {HapDiscover} from './api' import type { HapDiscover } from './api'
import {HAPNodeJSClient} from 'hap-node-client' import { HAPNodeJSClient } from 'hap-node-client'
import {Device, DeviceBoundary} from '../boundaries' import { Device, DeviceBoundary } from '../boundaries'
import {Array, Unknown} from 'runtypes' import { Array, Unknown } from 'runtypes'
import {Logger} from "homebridge"; import { Logger } from 'homebridge'
const MaybeDevices = Array(Unknown) const MaybeDevices = Array(Unknown)
@ -18,8 +18,8 @@ type HapClient = typeof HAPNodeJSClient
type ResolveFunc = (devices: Device[]) => void type ResolveFunc = (devices: Device[]) => void
type RejectFunc = (error: unknown) => void type RejectFunc = (error: unknown) => void
const clientMap: Record<string,HapClient> = {} const clientMap: Record<string, HapClient> = {}
const promiseMap: Record<string,[ResolveFunc,RejectFunc]> = {} const promiseMap: Record<string, [ResolveFunc, RejectFunc]> = {}
function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, reject: RejectFunc) { function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc, reject: RejectFunc) {
const key = JSON.stringify(config) const key = JSON.stringify(config)
@ -35,11 +35,7 @@ function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc,
try { try {
devices.push(DeviceBoundary.check(device)) devices.push(DeviceBoundary.check(device))
} catch (e) { } catch (e) {
logger.error( logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4))
'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] 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) => { return new Promise((resolve, reject) => {
startDiscovery( startDiscovery(
logger, logger,
@ -67,7 +63,7 @@ export const discover: HapDiscover = ({pin, refreshInterval, discoveryTimeout, r
pin, pin,
}, },
resolve, resolve,
reject reject,
) )
}) })
} }

View file

@ -8,8 +8,8 @@ export class Metric {
constructor( constructor(
public readonly name: string, public readonly name: string,
public readonly value: number, public readonly value: number,
public readonly type: NumberTypes, public readonly timestamp: Date | null = null,
public readonly labels: Record<string, string>, public readonly labels: Record<string, string> = {},
) {} ) {}
} }
@ -40,7 +40,7 @@ function debug(devices: Device[]): void {
console.log(JSON.stringify(debugInfo, null, 4)) console.log(JSON.stringify(debugInfo, null, 4))
} }
export function aggregate(devices: Device[]): Metric[] { export function aggregate(devices: Device[], timestamp: Date): Metric[] {
const metrics: Metric[] = [] const metrics: Metric[] = []
for (const device of devices) { for (const device of devices) {
@ -63,7 +63,7 @@ export function aggregate(devices: Device[]): Metric[] {
characteristic.unit, characteristic.unit,
) )
if (!METRICS_FILTER.includes(name)) { if (!METRICS_FILTER.includes(name)) {
metrics.push(new Metric(name, characteristic.value, characteristic.format, labels)) metrics.push(new Metric(name, characteristic.value, timestamp, labels))
} }
} }
} }

View file

@ -4,6 +4,7 @@ import { Metric, aggregate } from './metrics'
import { discover } from './discovery/hap_node_js_client' import { discover } from './discovery/hap_node_js_client'
import { serve } from './http/fastify' import { serve } from './http/fastify'
import { HttpServerController } from './http/api' import { HttpServerController } from './http/api'
import { MetricsRenderer } from './prometheus'
export class PrometheusExporterPlatform implements IndependentPlatformPlugin { export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
private metrics: Metric[] = [] private metrics: Metric[] = []
@ -61,16 +62,8 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
} }
}, },
probeController: () => { probeController: () => {
const prefix = 'homebridge_' const renderer = new MetricsRenderer('homebridge')
const metrics = this.metrics const metrics = this.metrics.map(renderer.render).join('\n')
.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')
return { return {
statusCode: 200, statusCode: 200,
@ -109,7 +102,7 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
debug: this.config.debug, debug: this.config.debug,
}) })
.then((devices) => { .then((devices) => {
this.metrics = aggregate(devices) this.metrics = aggregate(devices, new Date())
this.metricsDiscovered = true this.metricsDiscovered = true
this.log.debug('HAP discovery completed, %d metrics discovered', this.metrics.length) this.log.debug('HAP discovery completed, %d metrics discovered', this.metrics.length)
this.startHapDiscovery() this.startHapDiscovery()

70
src/prometheus.ts Normal file
View file

@ -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)
}
}

View file

@ -7,6 +7,8 @@ import tpLinkData from './fixtures/tp-link.json'
import harmonyData from './fixtures/harmony.json' import harmonyData from './fixtures/harmony.json'
describe('Metrics aggregator', () => { describe('Metrics aggregator', () => {
const timestamp = new Date('2000-01-01 00:00:00 UTC')
test('Aggregates homebridge-dyson fan metrics', () => { test('Aggregates homebridge-dyson fan metrics', () => {
const dyson = DeviceBoundary.check(dysonData) const dyson = DeviceBoundary.check(dysonData)
@ -21,24 +23,24 @@ describe('Metrics aggregator', () => {
serial_number: 'NN2-EU-KKA0717A', serial_number: 'NN2-EU-KKA0717A',
} }
expect(aggregate([dyson])).toEqual([ expect(aggregate([dyson], timestamp)).toEqual([
new Metric('air_purifier_active', 0, 'uint8', expectedLabels), new Metric('air_purifier_active', 0, timestamp, expectedLabels),
new Metric('air_purifier_current_air_purifier_state', 0, 'uint8', expectedLabels), new Metric('air_purifier_current_air_purifier_state', 0, timestamp, expectedLabels),
new Metric('air_purifier_target_air_purifier_state', 0, 'uint8', expectedLabels), new Metric('air_purifier_target_air_purifier_state', 0, timestamp, expectedLabels),
new Metric('air_purifier_filter_life_level_percentage', 85, 'float', expectedLabels), new Metric('air_purifier_filter_life_level_percentage', 85, timestamp, expectedLabels),
new Metric('air_purifier_rotation_speed_percentage', 100, 'float', expectedLabels), new Metric('air_purifier_rotation_speed_percentage', 100, timestamp, expectedLabels),
new Metric('air_purifier_current_temperature_celsius', 22.8, 'float', expectedLabels), new Metric('air_purifier_current_temperature_celsius', 22.8, timestamp, expectedLabels),
new Metric('air_purifier_swing_mode', 0, 'uint8', expectedLabels), new Metric('air_purifier_swing_mode', 0, timestamp, expectedLabels),
new Metric('air_purifier_filter_change_indication', 0, 'uint8', expectedLabels), new Metric('air_purifier_filter_change_indication', 0, timestamp, expectedLabels),
new Metric('air_purifier_current_relative_humidity_percentage', 52, 'float', expectedLabels), new Metric('air_purifier_current_relative_humidity_percentage', 52, timestamp, expectedLabels),
new Metric('air_purifier_air_quality', 2, 'uint8', expectedLabels), new Metric('air_purifier_air_quality', 2, timestamp, expectedLabels),
]) ])
}) })
test('Aggregates empty accessory metrics to empty metrics', () => { test('Aggregates empty accessory metrics to empty metrics', () => {
const empty = DeviceBoundary.check(emptyData) const empty = DeviceBoundary.check(emptyData)
expect(aggregate([empty])).toEqual([]) expect(aggregate([empty], timestamp)).toEqual([])
}) })
test('Aggregates TP-Link plugs metrics', () => { test('Aggregates TP-Link plugs metrics', () => {
@ -66,17 +68,17 @@ describe('Metrics aggregator', () => {
serial_number: 'AA:AA:AA:AA:AA:AA 800614AA26608873C919E4592765404019F64D07', serial_number: 'AA:AA:AA:AA:AA:AA 800614AA26608873C919E4592765404019F64D07',
} }
expect(aggregate([tpLink])).toEqual([ expect(aggregate([tpLink], timestamp)).toEqual([
new Metric('outlet_amperes_a', 0.03, 'float', expectedLabelsAccessory1), new Metric('outlet_amperes_a', 0.03, timestamp, expectedLabelsAccessory1),
new Metric('outlet_total_consumption_kwh', 0.051, 'float', expectedLabelsAccessory1), new Metric('outlet_total_consumption_kwh', 0.051, timestamp, expectedLabelsAccessory1),
new Metric('outlet_apparent_power_va', 53248.8, 'float', expectedLabelsAccessory1), new Metric('outlet_apparent_power_va', 53248.8, timestamp, expectedLabelsAccessory1),
new Metric('outlet_volts_v', 230.8, 'float', expectedLabelsAccessory1), new Metric('outlet_volts_v', 230.8, timestamp, expectedLabelsAccessory1),
new Metric('outlet_consumption_w', 0, 'float', expectedLabelsAccessory1), new Metric('outlet_consumption_w', 0, timestamp, expectedLabelsAccessory1),
new Metric('outlet_amperes_a', 0.03, 'float', expectedLabelsAccessory2), new Metric('outlet_amperes_a', 0.03, timestamp, expectedLabelsAccessory2),
new Metric('outlet_total_consumption_kwh', 13.025, 'float', expectedLabelsAccessory2), new Metric('outlet_total_consumption_kwh', 13.025, timestamp, expectedLabelsAccessory2),
new Metric('outlet_apparent_power_va', 53365.6, 'float', expectedLabelsAccessory2), new Metric('outlet_apparent_power_va', 53365.6, timestamp, expectedLabelsAccessory2),
new Metric('outlet_volts_v', 231, 'float', expectedLabelsAccessory2), new Metric('outlet_volts_v', 231, timestamp, expectedLabelsAccessory2),
new Metric('outlet_consumption_w', 0, 'float', expectedLabelsAccessory2), new Metric('outlet_consumption_w', 0, timestamp, expectedLabelsAccessory2),
]) ])
}) })
@ -126,24 +128,24 @@ describe('Metrics aggregator', () => {
serial_number: '0e88f449-2720-4000-8c4b-06775986e8ac', serial_number: '0e88f449-2720-4000-8c4b-06775986e8ac',
} }
expect(aggregate([harmony])).toEqual([ expect(aggregate([harmony], timestamp)).toEqual([
new Metric('television_sleep_discovery_mode', 1, 'uint8', expectedLabels1), new Metric('television_sleep_discovery_mode', 1, timestamp, expectedLabels1),
new Metric('television_active', 0, 'uint8', expectedLabels1), new Metric('television_active', 0, timestamp, expectedLabels1),
new Metric('television_active_identifier', 0, 'uint32', expectedLabels1), new Metric('television_active_identifier', 0, timestamp, expectedLabels1),
new Metric('input_source_type', 10, 'uint8', expectedLabels2), new Metric('input_source_type', 10, timestamp, expectedLabels2),
new Metric('input_source_is_configured', 1, 'uint8', expectedLabels2), new Metric('input_source_is_configured', 1, timestamp, expectedLabels2),
new Metric('input_source_current_visibility_state', 0, 'uint8', expectedLabels2), new Metric('input_source_current_visibility_state', 0, timestamp, expectedLabels2),
new Metric('input_source_target_visibility_state', 0, 'uint8', expectedLabels2), new Metric('input_source_target_visibility_state', 0, timestamp, expectedLabels2),
new Metric('input_source_type', 10, 'uint8', expectedLabels3), new Metric('input_source_type', 10, timestamp, expectedLabels3),
new Metric('input_source_is_configured', 1, 'uint8', expectedLabels3), new Metric('input_source_is_configured', 1, timestamp, expectedLabels3),
new Metric('input_source_current_visibility_state', 0, 'uint8', expectedLabels3), new Metric('input_source_current_visibility_state', 0, timestamp, expectedLabels3),
new Metric('input_source_target_visibility_state', 0, 'uint8', expectedLabels3), new Metric('input_source_target_visibility_state', 0, timestamp, expectedLabels3),
new Metric('speaker_active', 1, 'uint8', expectedLabels4), new Metric('speaker_active', 1, timestamp, expectedLabels4),
new Metric('speaker_volume_control_type', 3, 'uint8', expectedLabels4), new Metric('speaker_volume_control_type', 3, timestamp, expectedLabels4),
new Metric('speaker_volume_percentage', 50, 'uint8', expectedLabels4), new Metric('speaker_volume_percentage', 50, timestamp, expectedLabels4),
]) ])
}) })
}) })

73
tests/prometheus.test.ts Normal file
View file

@ -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`,
)
})
})