Extract prometheus server
This commit is contained in:
parent
53b9688a58
commit
7fd1c5a03c
15 changed files with 168 additions and 177 deletions
|
@ -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
2
TODOs
|
@ -1 +1 @@
|
||||||
- Extract HttpServer
|
- Prefix via configuration
|
|
@ -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
|
|
@ -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
13
src/adapters/http/api.ts
Normal 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>
|
52
src/adapters/http/fastify.ts
Normal file
52
src/adapters/http/fastify.ts
Normal 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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
14
src/http.ts
Normal 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
|
||||||
|
}
|
|
@ -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>
|
|
|
@ -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()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -14,5 +14,5 @@
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/"],
|
"include": ["src/"],
|
||||||
"exclude": ["**/*.test.ts", "node_modules/hap-nodejs/**/**"]
|
"exclude": []
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue