Extract prometheus server

This commit is contained in:
Lars Strojny 2022-11-07 22:31:17 +01:00
parent 53b9688a58
commit 7fd1c5a03c
15 changed files with 168 additions and 177 deletions

View file

@ -7,6 +7,7 @@ module.exports = {
'prettier/prettier': 'warn', 'prettier/prettier': 'warn',
'@typescript-eslint/explicit-module-boundary-types': 'error', '@typescript-eslint/explicit-module-boundary-types': 'error',
'prefer-arrow-callback': 'error', 'prefer-arrow-callback': 'error',
'sort-imports': 'warn',
}, },
overrides: [ overrides: [
{ {

2
TODOs
View file

@ -1 +1 @@
- Extract HttpServer - Prefix via configuration

View file

@ -1,9 +1,9 @@
import type { Device } from '../boundaries' import type { Device } from '../../boundaries'
import { Logger } from 'homebridge' import { Logger } from 'homebridge'
type Pin = string type Pin = string
export type HapConfig = { export interface HapConfig {
pin: Pin pin: Pin
refreshInterval: number refreshInterval: number
discoveryTimeout: number discoveryTimeout: number

View file

@ -1,13 +1,13 @@
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)
type HapConfig = { interface HapConfig {
debug: boolean debug: boolean
refresh: number refresh: number
timeout: number timeout: number

13
src/adapters/http/api.ts Normal file
View file

@ -0,0 +1,13 @@
import { HttpServer } from '../../http'
export interface HttpResponse {
statusCode?: number
body?: string
headers?: Record<string, string>
}
export interface HttpServerController {
shutdown(): void
}
export type HttpAdapter = (config: HttpServer) => Promise<HttpServerController>

View file

@ -0,0 +1,52 @@
import Fastify, { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify'
import { HttpAdapter, HttpResponse } from './api'
import { HttpServer } from '../../http'
function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void {
if (response.statusCode) {
void reply.code(response.statusCode)
}
if (response.body) {
void reply.send(response.body)
}
if (response.headers) {
void reply.headers(response.headers)
}
}
export const serve: HttpAdapter = async (server: HttpServer) => {
const fastify = Fastify({
logger: server.debug,
})
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, next: HookHandlerDoneFunction) => {
const response = server.onRequest()
if (response) {
adaptResponseToReply(response, reply)
}
next()
})
fastify.setErrorHandler(async (error, request: FastifyRequest, reply: FastifyReply) => {
adaptResponseToReply(server.onError(error), reply)
})
fastify.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => {
adaptResponseToReply(server.onNotFound(), reply)
})
fastify.get('/metrics', async (request: FastifyRequest, reply: FastifyReply) => {
adaptResponseToReply(server.onMetrics(), reply)
})
await fastify.listen({ port: server.port, host: '::' })
return {
shutdown() {
void fastify.close()
},
}
}

View file

@ -1,4 +1,4 @@
import { Number, String, Literal, Array, Record, Union, Static, Optional, Intersect } from 'runtypes' import { Array, Intersect, Literal, Number, Optional, Record, Static, String, Union } from 'runtypes'
export const NUMBER_TYPES = ['float', 'int', 'uint8', 'uint16', 'uint32', 'uint64'] as const export const NUMBER_TYPES = ['float', 'int', 'uint8', 'uint16', 'uint32', 'uint64'] as const
const NumberTypesLiterals = NUMBER_TYPES.map(Literal) const NumberTypesLiterals = NUMBER_TYPES.map(Literal)

14
src/http.ts Normal file
View file

@ -0,0 +1,14 @@
import { HttpResponse } from './adapters/http/api'
import { Metric } from './metrics'
import { Logger } from 'homebridge'
export interface HttpServer {
port: number
debug: boolean
log: Logger
onRequest(): HttpResponse | undefined
onMetrics(): HttpResponse
onNotFound(): HttpResponse
onError(error: unknown): HttpResponse
updateMetrics(metrics: Metric[]): void
}

View file

@ -1,22 +0,0 @@
import { Logger } from 'homebridge'
export type HttpResponse = {
statusCode: number
body: string
headers: Record<string, string>
}
export type HttpServerConfig = {
port: number
requestInterceptor: () => HttpResponse | undefined
metricsController: () => HttpResponse
notFoundController: () => HttpResponse
errorController: (error: unknown) => HttpResponse
logger: Logger
}
export type HttpServerController = {
shutdown(): void
}
export type HttpServer = (config: HttpServerConfig) => Promise<HttpServerController>

View file

@ -1,82 +0,0 @@
import Fastify, { FastifyBaseLogger, FastifyReply, FastifyRequest, HookHandlerDoneFunction } from 'fastify'
import { HttpResponse, HttpServer } from './api'
import { Logger } from 'homebridge'
import { Bindings, ChildLoggerOptions } from 'fastify/types/logger'
import { LogFn } from 'pino'
/*class PinoLoggerBridge implements FastifyBaseLogger {
constructor(private readonly logger: Logger) {
}
child(bindings: Bindings, options: ChildLoggerOptions | undefined): FastifyBaseLogger {
return this
}
debug(m: string|unknown, ...args: unknown[]) {
this.logger.debug(typeof m === 'string' ? m : JSON.stringify(m), ...args)
}
error: LogFn;
fatal: pino.LogFn;
info: pino.LogFn;
level: pino.LevelWithSilent | string;
silent: pino.LogFn;
trace: pino.LogFn;
warn: pino.LogFn;
}*/
function adaptResponseToReply(response: HttpResponse, reply: FastifyReply): void {
if (response.statusCode) {
reply.code(response.statusCode)
}
if (response.body) {
reply.send(response.body)
}
if (response.headers) {
reply.headers(response.headers)
}
}
export const serve: HttpServer = async ({
port,
requestInterceptor,
metricsController,
notFoundController,
errorController,
}) => {
const fastify = Fastify({
// logger
})
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, next: HookHandlerDoneFunction) => {
const response = requestInterceptor()
if (response) {
adaptResponseToReply(response, reply)
}
next()
})
fastify.setErrorHandler(async (error, request: FastifyRequest, reply: FastifyReply) => {
adaptResponseToReply(errorController(error), reply)
})
fastify.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => {
adaptResponseToReply(notFoundController(), reply)
})
fastify.get('/metrics', async (request: FastifyRequest, reply: FastifyReply) => {
adaptResponseToReply(metricsController(), reply)
})
await fastify.listen({ port, host: '::' })
return {
shutdown() {
fastify.close()
},
}
}

View file

@ -1,4 +1,4 @@
import type { Device, Accessory, NumberTypes, Service } from './boundaries' import type { Accessory, Device, Service } from './boundaries'
import { isType } from './std' import { isType } from './std'
import { NUMBER_TYPES } from './boundaries' import { NUMBER_TYPES } from './boundaries'

View file

@ -1,15 +1,14 @@
import { API, Logger, PlatformConfig, IndependentPlatformPlugin } from 'homebridge' import { API, IndependentPlatformPlugin, Logger, PlatformConfig } from 'homebridge'
import { Metric, aggregate } from './metrics' import { aggregate } from './metrics'
import { discover } from './discovery/hap_node_js_client' import { discover } from './adapters/discovery/hap_node_js_client'
import { serve } from './http/fastify' import { serve } from './adapters/http/fastify'
import { HttpServerController } from './http/api' import { HttpServerController } from './adapters/http/api'
import { MetricsRenderer } from './prometheus' import { PrometheusServer } from './prometheus'
export class PrometheusExporterPlatform implements IndependentPlatformPlugin { export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
private metrics: Metric[] = [] private readonly httpServer: PrometheusServer
private metricsDiscovered = false private httpServerController: HttpServerController | undefined = undefined
private http: HttpServerController | undefined = undefined
constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) { constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) {
this.log.debug('Initializing platform %s', this.config.platform) this.log.debug('Initializing platform %s', this.config.platform)
@ -18,12 +17,22 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
this.api.on('shutdown', () => { this.api.on('shutdown', () => {
this.log.debug('Shutting down %s', this.config.platform) this.log.debug('Shutting down %s', this.config.platform)
if (this.http) { if (this.httpServerController) {
this.http.shutdown() this.httpServerController.shutdown()
} }
}) })
this.startHttpServer() this.log.debug('Starting probe HTTP server on port %d', this.config.probe_port)
this.httpServer = new PrometheusServer(this.config.probe_port, this.log, this.config.debug)
serve(this.httpServer)
.then((httpServerController) => {
this.log.debug('HTTP server started on port %d', this.config.probe_port)
this.httpServerController = httpServerController
})
.catch((e) => {
this.log.error('Failed to start probe HTTP server on port %d: %o', this.config.probe_port, e)
})
this.api.on('didFinishLaunching', () => { this.api.on('didFinishLaunching', () => {
this.log.debug('Finished launching %s', this.config.platform) this.log.debug('Finished launching %s', this.config.platform)
@ -45,55 +54,6 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
this.log.debug('Configuration materialized: %o', this.config) this.log.debug('Configuration materialized: %o', this.config)
} }
private startHttpServer(): void {
this.log.debug('Starting probe HTTP server on port %d', this.config.probe_port)
const contentTypeHeader = { 'Content-Type': 'text/plain; charset=UTF-8' }
serve({
port: this.config.probe_port,
logger: this.log,
requestInterceptor: () => {
if (!this.metricsDiscovered) {
return {
statusCode: 503,
headers: { ...contentTypeHeader, 'Retry-After': '10' },
body: 'Discovery pending',
}
}
},
metricsController: () => {
const renderer = new MetricsRenderer('homebridge')
const metrics = this.metrics.map((metric) => renderer.render(metric)).join('\n')
return {
statusCode: 200,
headers: contentTypeHeader,
body: metrics,
}
},
notFoundController: () => ({
statusCode: 404,
headers: contentTypeHeader,
body: 'Not found. Try /metrics',
}),
errorController: (e) => {
this.log.error('HTTP request error: %o', e)
return {
statusCode: 500,
headers: contentTypeHeader,
body: 'Server error',
}
},
})
.then((http) => {
this.log.debug('HTTP server started on port %d', this.config.probe_port)
this.http = http
})
.catch((e) => {
this.log.error('Failed to start probe HTTP server on port %d: %o', this.config.probe_port, e)
})
}
private startHapDiscovery(): void { private startHapDiscovery(): void {
this.log.debug('Starting HAP discovery') this.log.debug('Starting HAP discovery')
discover({ discover({
@ -105,9 +65,9 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
debug: this.config.debug, debug: this.config.debug,
}) })
.then((devices) => { .then((devices) => {
this.metrics = aggregate(devices, new Date()) const metrics = aggregate(devices, new Date())
this.metricsDiscovered = true this.httpServer.updateMetrics(metrics)
this.log.debug('HAP discovery completed, %d metrics discovered', this.metrics.length) this.log.debug('HAP discovery completed, %d metrics discovered', metrics.length)
this.startHapDiscovery() this.startHapDiscovery()
}) })
.catch((e) => { .catch((e) => {

View file

@ -1,4 +1,7 @@
import { Metric } from './metrics' import { Metric } from './metrics'
import { Logger } from 'homebridge'
import { HttpResponse } from './adapters/http/api'
import { HttpServer } from './http'
function escapeString(str: string) { function escapeString(str: string) {
return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n') return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n')
@ -68,3 +71,55 @@ export class MetricsRenderer {
return sanitizePrometheusMetricName(this.prefix.replace(/_+$/, '') + '_' + name) return sanitizePrometheusMetricName(this.prefix.replace(/_+$/, '') + '_' + name)
} }
} }
const contentTypeHeader = { 'Content-Type': 'text/plain; charset=UTF-8' }
export class PrometheusServer implements HttpServer {
private metricsInitialized = false
private metrics: Metric[] = []
constructor(public readonly port: number, public readonly log: Logger, public readonly debug: boolean) {}
onRequest(): HttpResponse | undefined {
if (!this.metricsInitialized) {
return {
statusCode: 503,
headers: { ...contentTypeHeader, 'Retry-After': '10' },
body: 'Metrics discovery pending',
}
}
}
onMetrics(): HttpResponse {
const renderer = new MetricsRenderer('homebridge')
const metrics = this.metrics.map((metric) => renderer.render(metric)).join('\n')
return {
statusCode: 200,
headers: contentTypeHeader,
body: metrics,
}
}
onNotFound(): HttpResponse {
return {
statusCode: 404,
headers: contentTypeHeader,
body: 'Not found. Try /metrics',
}
}
onError(error: unknown): HttpResponse {
this.log.error('HTTP request error: %o', error)
return {
statusCode: 500,
headers: contentTypeHeader,
body: 'Server error',
}
}
updateMetrics(metrics: Metric[]): void {
this.metrics = metrics
this.metricsInitialized = true
}
}

View file

@ -1,5 +1,5 @@
type Types = 'string' | 'number' | 'boolean' | 'object' type Types = 'string' | 'number' | 'boolean' | 'object'
type TypeMap = { interface TypeMap {
string: string string: string
number: number number: number
boolean: boolean boolean: boolean

View file

@ -14,5 +14,5 @@
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/"], "include": ["src/"],
"exclude": ["**/*.test.ts", "node_modules/hap-nodejs/**/**"] "exclude": []
} }