2022-11-10 13:00:45 +01:00
import type { Logger } from 'homebridge'
2022-11-14 01:20:53 +01:00
import type { HttpConfig , HttpResponse , HttpServer } from './adapters/http'
2022-11-24 22:12:40 +01:00
import type { Metric } from './metrics'
import { strTrimRight } from './std'
2022-11-07 20:49:11 +01:00
export class MetricsRenderer {
2022-11-24 19:53:03 +01:00
private readonly prefix : string
constructor ( prefix : string ) {
2022-11-24 22:12:40 +01:00
this . prefix = strTrimRight ( prefix , '_' )
2022-11-24 19:53:03 +01:00
}
2022-11-07 20:49:11 +01:00
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 )
2022-11-24 22:12:40 +01:00
. map ( ( [ label , val ] ) = > ` ${ sanitizePrometheusMetricName ( label ) } =" ${ escapeAttributeValue ( val ) } " ` )
2022-11-07 20:49:11 +01:00
. join ( ',' )
return rendered !== '' ? '{' + rendered + '}' : ''
}
private metricName ( name : string ) : string {
name = name . replace ( /^(.*_)?(total)_(.*)$/ , '$1$3_$2' )
2022-11-24 19:53:03 +01:00
return sanitizePrometheusMetricName ( ` ${ this . prefix } _ ${ name } ` )
2022-11-07 20:49:11 +01:00
}
}
2022-11-07 22:31:17 +01:00
2022-11-09 19:08:24 +01:00
const retryAfterWhileDiscovery = 15
const textContentType = 'text/plain; charset=utf-8'
const prometheusSpecVersion = '0.0.4'
2022-11-10 11:10:31 +01:00
const metricsContentType = ` ${ textContentType } ; version= ${ prometheusSpecVersion } `
2022-11-09 19:08:24 +01:00
2022-11-24 19:58:07 +01:00
function withHeaders ( contentType : string , headers : Record < string , string > = { } ) : Record < string , string > {
2022-11-09 19:08:24 +01:00
return { . . . headers , 'Content-Type' : contentType }
}
2022-11-07 22:31:17 +01:00
export class PrometheusServer implements HttpServer {
2022-11-24 19:35:02 +01:00
private metricsDiscovered = false
private metricsResponse = ''
2022-11-07 22:31:17 +01:00
2022-11-24 19:35:02 +01:00
constructor (
public readonly config : HttpConfig ,
2022-11-24 22:12:40 +01:00
public readonly log : Logger | null = null ,
2022-11-24 19:35:02 +01:00
private readonly renderer : MetricsRenderer = new MetricsRenderer ( config . prefix ) ,
) { }
2022-11-07 22:31:17 +01:00
2022-11-24 22:12:40 +01:00
onRequest ( ) : HttpResponse | null {
if ( this . metricsDiscovered ) {
return null
}
return {
statusCode : 503 ,
headers : withHeaders ( textContentType , { 'Retry-After' : String ( retryAfterWhileDiscovery ) } ) ,
body : 'Metrics discovery pending' ,
2022-11-07 22:31:17 +01:00
}
}
onMetrics ( ) : HttpResponse {
return {
statusCode : 200 ,
2022-11-24 19:58:07 +01:00
headers : withHeaders ( metricsContentType ) ,
2022-11-24 19:35:02 +01:00
body : this.metricsResponse ,
2022-11-07 22:31:17 +01:00
}
}
onNotFound ( ) : HttpResponse {
return {
statusCode : 404 ,
2022-11-24 19:58:07 +01:00
headers : withHeaders ( textContentType ) ,
2022-11-07 22:31:17 +01:00
body : 'Not found. Try /metrics' ,
}
}
2022-11-16 22:19:08 +01:00
onError ( error : Error ) : HttpResponse {
2022-11-09 19:08:24 +01:00
this . log ? . error ( 'HTTP request error: %o' , error )
2022-11-07 22:31:17 +01:00
return {
2022-11-24 19:58:07 +01:00
headers : withHeaders ( textContentType ) ,
2022-11-16 22:19:08 +01:00
body : error.message ,
2022-11-07 22:31:17 +01:00
}
}
2022-11-24 19:35:02 +01:00
onMetricsDiscovery ( metrics : Metric [ ] ) : void {
this . metricsResponse = metrics . map ( ( metric ) = > this . renderer . render ( metric ) ) . join ( '\n' )
this . metricsDiscovered = true
2022-11-07 22:31:17 +01:00
}
}
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 .
* /
2022-11-24 22:12:40 +01:00
function escapeAttributeValue ( str : Metric [ 'labels' ] [ keyof Metric [ 'labels' ] ] ) {
2022-11-09 00:41:15 +01:00
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 '_'
}