Extract rendering
This commit is contained in:
parent
4b4f8e8d0f
commit
ff7fd44f2c
6 changed files with 201 additions and 67 deletions
|
@ -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<string,HapClient> = {}
|
||||
const promiseMap: Record<string,[ResolveFunc,RejectFunc]> = {}
|
||||
const clientMap: Record<string, HapClient> = {}
|
||||
const promiseMap: Record<string, [ResolveFunc, RejectFunc]> = {}
|
||||
|
||||
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -8,8 +8,8 @@ export class Metric {
|
|||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly value: number,
|
||||
public readonly type: NumberTypes,
|
||||
public readonly labels: Record<string, string>,
|
||||
public readonly timestamp: Date | null = null,
|
||||
public readonly labels: Record<string, string> = {},
|
||||
) {}
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
70
src/prometheus.ts
Normal file
70
src/prometheus.ts
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
73
tests/prometheus.test.ts
Normal file
73
tests/prometheus.test.ts
Normal 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`,
|
||||
)
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue