BREAKING CHANGE(core): Refactor to v3: introduce modular core/domain architecture, plugin system, observability and strict TypeScript configuration; remove legacy classes
This commit is contained in:
543
ts/core/observability/metrics.ts
Normal file
543
ts/core/observability/metrics.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user