homebridge-prometheus-exporter/src/prometheus.ts
2023-01-08 17:21:41 +01:00

151 lines
5 KiB
TypeScript

import type { Logger } from 'homebridge'
import type { HttpConfig, HttpResponse, HttpServer } from './adapters/http'
import type { Metric } from './metrics'
import { strTrimRight } from './std'
import { shim } from 'array.prototype.group'
shim()
export class MetricsRenderer {
private readonly prefix: string
constructor(prefix: string) {
this.prefix = strTrimRight(prefix, '_')
}
render(metrics: Metric[]): string {
return (
Object.entries(metrics.sort().group((metric) => this.metricName(metric.name)))
.map(([name, metrics]) => {
return [
`# TYPE ${name} ${name.endsWith('_total') ? 'counter' : 'gauge'}`,
metrics.map((metric) => this.formatMetric(metric)).join('\n'),
].join('\n')
})
.join('\n\n') + '\n'
)
}
private formatMetric(metric: Metric): string {
return `${this.metricName(metric.name)}${MetricsRenderer.renderLabels(metric.labels)} ${metric.value}${
metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : ''
}`
}
private static renderLabels(labels: Metric['labels']): string {
const rendered = Object.entries(labels)
.map(([label, val]) => `${sanitizePrometheusMetricName(label)}="${escapeAttributeValue(val)}"`)
.join(',')
return rendered !== '' ? '{' + rendered + '}' : ''
}
private metricName(name: string): string {
name = name.replace(/^(.*_)?(total)_(.*)$/, '$1$3_$2')
return sanitizePrometheusMetricName(`${this.prefix}_${name}`)
}
}
const retryAfterWhileDiscovery = 15
const textContentType = 'text/plain; charset=utf-8'
const prometheusSpecVersion = '0.0.4'
const metricsContentType = `${textContentType}; version=${prometheusSpecVersion}`
function withHeaders(contentType: string, headers: Record<string, string> = {}): Record<string, string> {
return { ...headers, 'Content-Type': contentType }
}
export class PrometheusServer implements HttpServer {
private metricsDiscovered = false
private metricsResponse = ''
constructor(
public readonly config: HttpConfig,
public readonly log: Logger | null = null,
private readonly renderer: MetricsRenderer = new MetricsRenderer(config.prefix),
) {}
onRequest(): HttpResponse | null {
if (this.metricsDiscovered) {
return null
}
return {
statusCode: 503,
headers: withHeaders(textContentType, { 'Retry-After': String(retryAfterWhileDiscovery) }),
body: 'Metrics discovery pending',
}
}
onMetrics(): HttpResponse {
return {
statusCode: 200,
headers: withHeaders(metricsContentType),
body: this.metricsResponse,
}
}
onNotFound(): HttpResponse {
return {
statusCode: 404,
headers: withHeaders(textContentType),
body: 'Not found. Try /metrics',
}
}
onError(error: Error): HttpResponse {
this.log?.error('HTTP request error: %o', error)
return {
headers: withHeaders(textContentType),
body: error.message,
}
}
onMetricsDiscovery(metrics: Metric[]): void {
this.metricsResponse = this.renderer.render(metrics)
this.metricsDiscovered = true
}
}
// 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: Metric['labels'][keyof Metric['labels']]) {
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 '_'
}