544 lines
13 KiB
TypeScript
544 lines
13 KiB
TypeScript
/**
|
|
* Label map for metrics
|
|
*/
|
|
export type Labels = Record<string, string | number>;
|
|
|
|
/**
|
|
* Metric types
|
|
*/
|
|
export enum MetricType {
|
|
COUNTER = 'counter',
|
|
GAUGE = 'gauge',
|
|
HISTOGRAM = 'histogram',
|
|
}
|
|
|
|
/**
|
|
* Histogram bucket configuration
|
|
*/
|
|
export interface HistogramBuckets {
|
|
buckets: number[];
|
|
counts: Map<number, number>;
|
|
sum: number;
|
|
count: number;
|
|
}
|
|
|
|
/**
|
|
* Base metric class
|
|
*/
|
|
abstract class Metric {
|
|
constructor(
|
|
public readonly name: string,
|
|
public readonly type: MetricType,
|
|
public readonly help: string,
|
|
public readonly labels: string[] = []
|
|
) {}
|
|
|
|
abstract getValue(labels?: Labels): number | HistogramBuckets;
|
|
abstract reset(): void;
|
|
}
|
|
|
|
/**
|
|
* Counter metric - monotonically increasing value
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const requestCounter = new Counter('http_requests_total', 'Total HTTP requests', ['method', 'status']);
|
|
* requestCounter.inc({ method: 'GET', status: '200' });
|
|
* requestCounter.inc({ method: 'POST', status: '201' }, 5);
|
|
* ```
|
|
*/
|
|
export class Counter extends Metric {
|
|
private values: Map<string, number> = new Map();
|
|
|
|
constructor(name: string, help: string, labels: string[] = []) {
|
|
super(name, MetricType.COUNTER, help, labels);
|
|
}
|
|
|
|
/**
|
|
* Increment counter
|
|
*/
|
|
inc(labels: Labels = {}, value: number = 1): void {
|
|
if (value < 0) {
|
|
throw new Error('Counter can only be incremented with positive values');
|
|
}
|
|
|
|
const key = this.getKey(labels);
|
|
const current = this.values.get(key) || 0;
|
|
this.values.set(key, current + value);
|
|
}
|
|
|
|
/**
|
|
* Get current value
|
|
*/
|
|
getValue(labels: Labels = {}): number {
|
|
const key = this.getKey(labels);
|
|
return this.values.get(key) || 0;
|
|
}
|
|
|
|
/**
|
|
* Reset counter
|
|
*/
|
|
reset(): void {
|
|
this.values.clear();
|
|
}
|
|
|
|
/**
|
|
* Get all values with labels
|
|
*/
|
|
getAll(): Array<{ labels: Labels; value: number }> {
|
|
const results: Array<{ labels: Labels; value: number }> = [];
|
|
|
|
for (const [key, value] of this.values.entries()) {
|
|
const labels = this.parseKey(key);
|
|
results.push({ labels, value });
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private getKey(labels: Labels): string {
|
|
const labelStr = this.labels
|
|
.map((label) => `${label}=${labels[label] || ''}`)
|
|
.join(',');
|
|
return labelStr || 'default';
|
|
}
|
|
|
|
private parseKey(key: string): Labels {
|
|
if (key === 'default') return {};
|
|
|
|
const labels: Labels = {};
|
|
const pairs = key.split(',');
|
|
|
|
for (const pair of pairs) {
|
|
const [name, value] = pair.split('=');
|
|
if (name && value !== undefined) {
|
|
labels[name] = value;
|
|
}
|
|
}
|
|
|
|
return labels;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gauge metric - value that can go up and down
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const activeConnections = new Gauge('active_connections', 'Number of active connections');
|
|
* activeConnections.set(42);
|
|
* activeConnections.inc();
|
|
* activeConnections.dec(5);
|
|
* ```
|
|
*/
|
|
export class Gauge extends Metric {
|
|
private values: Map<string, number> = new Map();
|
|
|
|
constructor(name: string, help: string, labels: string[] = []) {
|
|
super(name, MetricType.GAUGE, help, labels);
|
|
}
|
|
|
|
/**
|
|
* Set gauge to a specific value
|
|
*/
|
|
set(value: number, labels: Labels = {}): void {
|
|
const key = this.getKey(labels);
|
|
this.values.set(key, value);
|
|
}
|
|
|
|
/**
|
|
* Increment gauge
|
|
*/
|
|
inc(labels: Labels = {}, value: number = 1): void {
|
|
const key = this.getKey(labels);
|
|
const current = this.values.get(key) || 0;
|
|
this.values.set(key, current + value);
|
|
}
|
|
|
|
/**
|
|
* Decrement gauge
|
|
*/
|
|
dec(labels: Labels = {}, value: number = 1): void {
|
|
this.inc(labels, -value);
|
|
}
|
|
|
|
/**
|
|
* Get current value
|
|
*/
|
|
getValue(labels: Labels = {}): number {
|
|
const key = this.getKey(labels);
|
|
return this.values.get(key) || 0;
|
|
}
|
|
|
|
/**
|
|
* Reset gauge
|
|
*/
|
|
reset(): void {
|
|
this.values.clear();
|
|
}
|
|
|
|
/**
|
|
* Get all values with labels
|
|
*/
|
|
getAll(): Array<{ labels: Labels; value: number }> {
|
|
const results: Array<{ labels: Labels; value: number }> = [];
|
|
|
|
for (const [key, value] of this.values.entries()) {
|
|
const labels = this.parseKey(key);
|
|
results.push({ labels, value });
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private getKey(labels: Labels): string {
|
|
const labelStr = this.labels
|
|
.map((label) => `${label}=${labels[label] || ''}`)
|
|
.join(',');
|
|
return labelStr || 'default';
|
|
}
|
|
|
|
private parseKey(key: string): Labels {
|
|
if (key === 'default') return {};
|
|
|
|
const labels: Labels = {};
|
|
const pairs = key.split(',');
|
|
|
|
for (const pair of pairs) {
|
|
const [name, value] = pair.split('=');
|
|
if (name && value !== undefined) {
|
|
labels[name] = value;
|
|
}
|
|
}
|
|
|
|
return labels;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Histogram metric - tracks distribution of values
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const latency = new Histogram('request_duration_seconds', 'Request latency', ['endpoint'], [0.1, 0.5, 1, 2, 5]);
|
|
* latency.observe(0.234, { endpoint: '/api/users' });
|
|
* latency.observe(1.567, { endpoint: '/api/users' });
|
|
* ```
|
|
*/
|
|
export class Histogram extends Metric {
|
|
private buckets: number[];
|
|
private values: Map<string, HistogramBuckets> = new Map();
|
|
|
|
constructor(
|
|
name: string,
|
|
help: string,
|
|
labels: string[] = [],
|
|
buckets: number[] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
|
|
) {
|
|
super(name, MetricType.HISTOGRAM, help, labels);
|
|
this.buckets = [...buckets].sort((a, b) => a - b);
|
|
}
|
|
|
|
/**
|
|
* Observe a value
|
|
*/
|
|
observe(value: number, labels: Labels = {}): void {
|
|
const key = this.getKey(labels);
|
|
let bucketData = this.values.get(key);
|
|
|
|
if (!bucketData) {
|
|
bucketData = {
|
|
buckets: this.buckets,
|
|
counts: new Map(this.buckets.map((b) => [b, 0])),
|
|
sum: 0,
|
|
count: 0,
|
|
};
|
|
this.values.set(key, bucketData);
|
|
}
|
|
|
|
// Update bucket counts
|
|
for (const bucket of this.buckets) {
|
|
if (value <= bucket) {
|
|
const current = bucketData.counts.get(bucket) || 0;
|
|
bucketData.counts.set(bucket, current + 1);
|
|
}
|
|
}
|
|
|
|
bucketData.sum += value;
|
|
bucketData.count++;
|
|
}
|
|
|
|
/**
|
|
* Get histogram data
|
|
*/
|
|
getValue(labels: Labels = {}): HistogramBuckets {
|
|
const key = this.getKey(labels);
|
|
return (
|
|
this.values.get(key) || {
|
|
buckets: this.buckets,
|
|
counts: new Map(this.buckets.map((b) => [b, 0])),
|
|
sum: 0,
|
|
count: 0,
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reset histogram
|
|
*/
|
|
reset(): void {
|
|
this.values.clear();
|
|
}
|
|
|
|
/**
|
|
* Get all histogram data with labels
|
|
*/
|
|
getAll(): Array<{ labels: Labels; value: HistogramBuckets }> {
|
|
const results: Array<{ labels: Labels; value: HistogramBuckets }> = [];
|
|
|
|
for (const [key, value] of this.values.entries()) {
|
|
const labels = this.parseKey(key);
|
|
results.push({ labels, value });
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private getKey(labels: Labels): string {
|
|
const labelStr = this.labels
|
|
.map((label) => `${label}=${labels[label] || ''}`)
|
|
.join(',');
|
|
return labelStr || 'default';
|
|
}
|
|
|
|
private parseKey(key: string): Labels {
|
|
if (key === 'default') return {};
|
|
|
|
const labels: Labels = {};
|
|
const pairs = key.split(',');
|
|
|
|
for (const pair of pairs) {
|
|
const [name, value] = pair.split('=');
|
|
if (name && value !== undefined) {
|
|
labels[name] = value;
|
|
}
|
|
}
|
|
|
|
return labels;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Metrics registry
|
|
*/
|
|
export class MetricsRegistry {
|
|
private metrics: Map<string, Metric> = new Map();
|
|
|
|
/**
|
|
* Register a metric
|
|
*/
|
|
register(metric: Metric): void {
|
|
if (this.metrics.has(metric.name)) {
|
|
throw new Error(`Metric ${metric.name} already registered`);
|
|
}
|
|
this.metrics.set(metric.name, metric);
|
|
}
|
|
|
|
/**
|
|
* Get a metric by name
|
|
*/
|
|
get(name: string): Metric | undefined {
|
|
return this.metrics.get(name);
|
|
}
|
|
|
|
/**
|
|
* Get all metrics
|
|
*/
|
|
getAll(): Metric[] {
|
|
return Array.from(this.metrics.values());
|
|
}
|
|
|
|
/**
|
|
* Clear all metrics
|
|
*/
|
|
clear(): void {
|
|
this.metrics.clear();
|
|
}
|
|
|
|
/**
|
|
* Reset all metric values
|
|
*/
|
|
reset(): void {
|
|
for (const metric of this.metrics.values()) {
|
|
metric.reset();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export metrics in Prometheus text format
|
|
*/
|
|
export(): string {
|
|
const lines: string[] = [];
|
|
|
|
for (const metric of this.metrics.values()) {
|
|
// Add help text
|
|
lines.push(`# HELP ${metric.name} ${metric.help}`);
|
|
lines.push(`# TYPE ${metric.name} ${metric.type}`);
|
|
|
|
if (metric instanceof Counter || metric instanceof Gauge) {
|
|
const all = metric.getAll();
|
|
for (const { labels, value } of all) {
|
|
const labelStr = Object.entries(labels)
|
|
.map(([k, v]) => `${k}="${v}"`)
|
|
.join(',');
|
|
const metricLine = labelStr
|
|
? `${metric.name}{${labelStr}} ${value}`
|
|
: `${metric.name} ${value}`;
|
|
lines.push(metricLine);
|
|
}
|
|
} else if (metric instanceof Histogram) {
|
|
const all = metric.getAll();
|
|
for (const { labels, value } of all) {
|
|
const labelStr = Object.entries(labels)
|
|
.map(([k, v]) => `${k}="${v}"`)
|
|
.join(',');
|
|
const labelPrefix = labelStr ? `{${labelStr}}` : '';
|
|
|
|
// Export bucket counts
|
|
for (const [bucket, count] of value.counts.entries()) {
|
|
const bucketLabel = labelStr
|
|
? `{${labelStr},le="${bucket}"}`
|
|
: `{le="${bucket}"}`;
|
|
lines.push(`${metric.name}_bucket${bucketLabel} ${count}`);
|
|
}
|
|
|
|
// Export +Inf bucket
|
|
const infLabel = labelStr ? `{${labelStr},le="+Inf"}` : `{le="+Inf"}`;
|
|
lines.push(`${metric.name}_bucket${infLabel} ${value.count}`);
|
|
|
|
// Export sum and count
|
|
lines.push(`${metric.name}_sum${labelPrefix} ${value.sum}`);
|
|
lines.push(`${metric.name}_count${labelPrefix} ${value.count}`);
|
|
}
|
|
}
|
|
|
|
lines.push(''); // Empty line between metrics
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default metrics registry
|
|
*/
|
|
export const defaultRegistry = new MetricsRegistry();
|
|
|
|
/**
|
|
* Metrics collector for Elasticsearch client
|
|
*/
|
|
export class MetricsCollector {
|
|
public readonly registry: MetricsRegistry;
|
|
|
|
// Standard metrics
|
|
public readonly requestsTotal: Counter;
|
|
public readonly requestDuration: Histogram;
|
|
public readonly requestErrors: Counter;
|
|
public readonly activeConnections: Gauge;
|
|
public readonly bulkOperations: Counter;
|
|
public readonly bulkDocuments: Counter;
|
|
public readonly retries: Counter;
|
|
|
|
constructor(registry: MetricsRegistry = defaultRegistry) {
|
|
this.registry = registry;
|
|
|
|
// Initialize standard metrics
|
|
this.requestsTotal = new Counter(
|
|
'elasticsearch_requests_total',
|
|
'Total number of Elasticsearch requests',
|
|
['operation', 'index']
|
|
);
|
|
this.registry.register(this.requestsTotal);
|
|
|
|
this.requestDuration = new Histogram(
|
|
'elasticsearch_request_duration_seconds',
|
|
'Elasticsearch request duration in seconds',
|
|
['operation', 'index']
|
|
);
|
|
this.registry.register(this.requestDuration);
|
|
|
|
this.requestErrors = new Counter(
|
|
'elasticsearch_request_errors_total',
|
|
'Total number of Elasticsearch request errors',
|
|
['operation', 'index', 'error_code']
|
|
);
|
|
this.registry.register(this.requestErrors);
|
|
|
|
this.activeConnections = new Gauge(
|
|
'elasticsearch_active_connections',
|
|
'Number of active Elasticsearch connections'
|
|
);
|
|
this.registry.register(this.activeConnections);
|
|
|
|
this.bulkOperations = new Counter(
|
|
'elasticsearch_bulk_operations_total',
|
|
'Total number of bulk operations',
|
|
['index']
|
|
);
|
|
this.registry.register(this.bulkOperations);
|
|
|
|
this.bulkDocuments = new Counter(
|
|
'elasticsearch_bulk_documents_total',
|
|
'Total number of documents in bulk operations',
|
|
['index', 'status']
|
|
);
|
|
this.registry.register(this.bulkDocuments);
|
|
|
|
this.retries = new Counter(
|
|
'elasticsearch_retries_total',
|
|
'Total number of retry attempts',
|
|
['operation']
|
|
);
|
|
this.registry.register(this.retries);
|
|
}
|
|
|
|
/**
|
|
* Create a custom counter
|
|
*/
|
|
counter(name: string, help: string, labels?: string[]): Counter {
|
|
const counter = new Counter(name, help, labels);
|
|
this.registry.register(counter);
|
|
return counter;
|
|
}
|
|
|
|
/**
|
|
* Create a custom gauge
|
|
*/
|
|
gauge(name: string, help: string, labels?: string[]): Gauge {
|
|
const gauge = new Gauge(name, help, labels);
|
|
this.registry.register(gauge);
|
|
return gauge;
|
|
}
|
|
|
|
/**
|
|
* Create a custom histogram
|
|
*/
|
|
histogram(name: string, help: string, labels?: string[], buckets?: number[]): Histogram {
|
|
const histogram = new Histogram(name, help, labels, buckets);
|
|
this.registry.register(histogram);
|
|
return histogram;
|
|
}
|
|
|
|
/**
|
|
* Export all metrics in Prometheus format
|
|
*/
|
|
export(): string {
|
|
return this.registry.export();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default metrics collector
|
|
*/
|
|
export const defaultMetricsCollector = new MetricsCollector();
|