2022-11-07 20:49:11 +01:00
import { Metric } from './metrics'
2022-11-07 22:31:17 +01:00
import { Logger } from 'homebridge'
import { HttpResponse } from './adapters/http/api'
import { HttpServer } from './http'
2022-11-07 20:49:11 +01:00
export class MetricsRenderer {
constructor ( private readonly prefix : string ) { }
render ( metric : Metric ) : string {
const name = this . metricName ( metric . name )
return [
2022-11-07 22:41:52 +01:00
` # TYPE ${ name } ${ name . endsWith ( '_total' ) ? 'counter' : 'gauge' } ` ,
2022-11-07 20:49:11 +01:00
` ${ 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 )
}
}
2022-11-07 22:31:17 +01:00
const contentTypeHeader = { 'Content-Type' : 'text/plain; charset=UTF-8' }
export class PrometheusServer implements HttpServer {
private metricsInitialized = false
private metrics : Metric [ ] = [ ]
2022-11-08 14:28:13 +01:00
constructor (
public readonly port : number ,
public readonly log : Logger ,
public readonly debug : boolean ,
private readonly prefix : string ,
) { }
2022-11-07 22:31:17 +01:00
onRequest ( ) : HttpResponse | undefined {
if ( ! this . metricsInitialized ) {
return {
statusCode : 503 ,
headers : { . . . contentTypeHeader , 'Retry-After' : '10' } ,
body : 'Metrics discovery pending' ,
}
}
}
onMetrics ( ) : HttpResponse {
2022-11-08 14:28:13 +01:00
const renderer = new MetricsRenderer ( this . prefix )
2022-11-07 22:31:17 +01:00
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
}
}
2022-11-09 00:41:15 +01:00
// From https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts
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 '_'
}