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:
14
ts/core/observability/index.ts
Normal file
14
ts/core/observability/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Observability layer for Elasticsearch client
|
||||
*
|
||||
* This module provides:
|
||||
* - Structured logging with context and correlation
|
||||
* - Prometheus-compatible metrics collection
|
||||
* - Distributed tracing with OpenTelemetry-compatible API
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './logger.js';
|
||||
export * from './metrics.js';
|
||||
export * from './tracing.js';
|
||||
281
ts/core/observability/logger.ts
Normal file
281
ts/core/observability/logger.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Log levels in order of severity
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level priorities for filtering
|
||||
*/
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
[LogLevel.DEBUG]: 0,
|
||||
[LogLevel.INFO]: 1,
|
||||
[LogLevel.WARN]: 2,
|
||||
[LogLevel.ERROR]: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Structured log entry
|
||||
*/
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
context?: Record<string, unknown>;
|
||||
correlationId?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log transport interface for custom log handlers
|
||||
*/
|
||||
export interface LogTransport {
|
||||
log(entry: LogEntry): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Console transport with colored output
|
||||
*/
|
||||
export class ConsoleTransport implements LogTransport {
|
||||
private readonly colors = {
|
||||
debug: '\x1b[36m', // Cyan
|
||||
info: '\x1b[32m', // Green
|
||||
warn: '\x1b[33m', // Yellow
|
||||
error: '\x1b[31m', // Red
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
|
||||
log(entry: LogEntry): void {
|
||||
const color = this.colors[entry.level];
|
||||
const reset = this.colors.reset;
|
||||
const timestamp = entry.timestamp.toISOString();
|
||||
const level = entry.level.toUpperCase().padEnd(5);
|
||||
|
||||
let message = `${color}[${timestamp}] ${level}${reset} ${entry.message}`;
|
||||
|
||||
if (entry.correlationId) {
|
||||
message += ` ${color}[correlation: ${entry.correlationId}]${reset}`;
|
||||
}
|
||||
|
||||
if (entry.context && Object.keys(entry.context).length > 0) {
|
||||
message += `\n Context: ${JSON.stringify(entry.context, null, 2)}`;
|
||||
}
|
||||
|
||||
if (entry.error) {
|
||||
message += `\n Error: ${entry.error.message}`;
|
||||
if (entry.error.stack) {
|
||||
message += `\n${entry.error.stack}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON transport for structured logging
|
||||
*/
|
||||
export class JsonTransport implements LogTransport {
|
||||
log(entry: LogEntry): void {
|
||||
const jsonEntry = {
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
...(entry.correlationId && { correlationId: entry.correlationId }),
|
||||
...(entry.context && { context: entry.context }),
|
||||
...(entry.error && {
|
||||
error: {
|
||||
message: entry.error.message,
|
||||
name: entry.error.name,
|
||||
stack: entry.error.stack,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(jsonEntry));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger configuration
|
||||
*/
|
||||
export interface LoggerConfig {
|
||||
/** Minimum log level to output */
|
||||
level: LogLevel;
|
||||
|
||||
/** Log transports */
|
||||
transports: LogTransport[];
|
||||
|
||||
/** Default context to include in all logs */
|
||||
defaultContext?: Record<string, unknown>;
|
||||
|
||||
/** Whether to include timestamp */
|
||||
includeTimestamp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured logger with context and correlation support
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const logger = new Logger({
|
||||
* level: LogLevel.INFO,
|
||||
* transports: [new ConsoleTransport()],
|
||||
* defaultContext: { service: 'elasticsearch-client' }
|
||||
* });
|
||||
*
|
||||
* logger.info('Connected to Elasticsearch', { node: 'localhost:9200' });
|
||||
*
|
||||
* const childLogger = logger.withContext({ operation: 'bulk-index' });
|
||||
* childLogger.debug('Processing batch', { size: 1000 });
|
||||
* ```
|
||||
*/
|
||||
export class Logger {
|
||||
private config: LoggerConfig;
|
||||
private context: Record<string, unknown>;
|
||||
private correlationId?: string;
|
||||
|
||||
constructor(config: Partial<LoggerConfig> = {}) {
|
||||
this.config = {
|
||||
level: config.level || LogLevel.INFO,
|
||||
transports: config.transports || [new ConsoleTransport()],
|
||||
defaultContext: config.defaultContext || {},
|
||||
includeTimestamp: config.includeTimestamp !== false,
|
||||
};
|
||||
this.context = { ...this.config.defaultContext };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with additional context
|
||||
*/
|
||||
withContext(context: Record<string, unknown>): Logger {
|
||||
const child = new Logger(this.config);
|
||||
child.context = { ...this.context, ...context };
|
||||
child.correlationId = this.correlationId;
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with correlation ID
|
||||
*/
|
||||
withCorrelation(correlationId: string): Logger {
|
||||
const child = new Logger(this.config);
|
||||
child.context = { ...this.context };
|
||||
child.correlationId = correlationId;
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger for a specific namespace
|
||||
*/
|
||||
child(namespace: string): Logger {
|
||||
return this.withContext({ namespace });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at DEBUG level
|
||||
*/
|
||||
debug(message: string, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.DEBUG, message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at INFO level
|
||||
*/
|
||||
info(message: string, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.INFO, message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at WARN level
|
||||
*/
|
||||
warn(message: string, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.WARN, message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at ERROR level
|
||||
*/
|
||||
error(message: string, error?: Error, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.ERROR, message, meta, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal log method
|
||||
*/
|
||||
private log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
meta?: Record<string, unknown>,
|
||||
error?: Error
|
||||
): void {
|
||||
// Check if we should log this level
|
||||
if (!this.shouldLog(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
context: { ...this.context, ...meta },
|
||||
...(this.correlationId && { correlationId: this.correlationId }),
|
||||
...(error && { error }),
|
||||
};
|
||||
|
||||
// Send to all transports
|
||||
for (const transport of this.config.transports) {
|
||||
try {
|
||||
const result = transport.log(entry);
|
||||
// Handle async transports
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch((err) => {
|
||||
console.error('Transport error:', err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transport error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log level should be output
|
||||
*/
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.config.level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update logger configuration
|
||||
*/
|
||||
setLevel(level: LogLevel): void {
|
||||
this.config.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a transport
|
||||
*/
|
||||
addTransport(transport: LogTransport): void {
|
||||
this.config.transports.push(transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current log level
|
||||
*/
|
||||
getLevel(): LogLevel {
|
||||
return this.config.level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default logger instance
|
||||
*/
|
||||
export const defaultLogger = new Logger({
|
||||
level: LogLevel.INFO,
|
||||
transports: [new ConsoleTransport()],
|
||||
});
|
||||
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();
|
||||
438
ts/core/observability/tracing.ts
Normal file
438
ts/core/observability/tracing.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Span attributes
|
||||
*/
|
||||
export type SpanAttributes = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
/**
|
||||
* Span status
|
||||
*/
|
||||
export enum SpanStatus {
|
||||
OK = 'OK',
|
||||
ERROR = 'ERROR',
|
||||
UNSET = 'UNSET',
|
||||
}
|
||||
|
||||
/**
|
||||
* Span context for distributed tracing
|
||||
*/
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags: number;
|
||||
traceState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Span interface
|
||||
*/
|
||||
export interface Span {
|
||||
/** Span name */
|
||||
name: string;
|
||||
|
||||
/** Span start time */
|
||||
startTime: Date;
|
||||
|
||||
/** Span end time (if ended) */
|
||||
endTime?: Date;
|
||||
|
||||
/** Span status */
|
||||
status: SpanStatus;
|
||||
|
||||
/** Span attributes */
|
||||
attributes: SpanAttributes;
|
||||
|
||||
/** Span context */
|
||||
context: SpanContext;
|
||||
|
||||
/** Parent span ID */
|
||||
parentSpanId?: string;
|
||||
|
||||
/** Set an attribute */
|
||||
setAttribute(key: string, value: string | number | boolean): void;
|
||||
|
||||
/** Set multiple attributes */
|
||||
setAttributes(attributes: SpanAttributes): void;
|
||||
|
||||
/** Set span status */
|
||||
setStatus(status: SpanStatus, message?: string): void;
|
||||
|
||||
/** Add an event to the span */
|
||||
addEvent(name: string, attributes?: SpanAttributes): void;
|
||||
|
||||
/** End the span */
|
||||
end(): void;
|
||||
|
||||
/** Record an exception */
|
||||
recordException(exception: Error): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracer interface
|
||||
*/
|
||||
export interface Tracer {
|
||||
/** Start a new span */
|
||||
startSpan(name: string, attributes?: SpanAttributes): Span;
|
||||
|
||||
/** Get the active span */
|
||||
getActiveSpan(): Span | null;
|
||||
|
||||
/** Execute function within span context */
|
||||
withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random trace ID
|
||||
*/
|
||||
function generateTraceId(): string {
|
||||
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random span ID
|
||||
*/
|
||||
function generateSpanId(): string {
|
||||
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic in-memory span implementation
|
||||
*/
|
||||
export class InMemorySpan implements Span {
|
||||
public name: string;
|
||||
public startTime: Date;
|
||||
public endTime?: Date;
|
||||
public status: SpanStatus = SpanStatus.UNSET;
|
||||
public attributes: SpanAttributes;
|
||||
public context: SpanContext;
|
||||
public parentSpanId?: string;
|
||||
public events: Array<{ name: string; timestamp: Date; attributes?: SpanAttributes }> = [];
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
attributes: SpanAttributes = {},
|
||||
parentContext?: SpanContext
|
||||
) {
|
||||
this.name = name;
|
||||
this.startTime = new Date();
|
||||
this.attributes = { ...attributes };
|
||||
|
||||
if (parentContext) {
|
||||
this.context = {
|
||||
traceId: parentContext.traceId,
|
||||
spanId: generateSpanId(),
|
||||
traceFlags: parentContext.traceFlags,
|
||||
traceState: parentContext.traceState,
|
||||
};
|
||||
this.parentSpanId = parentContext.spanId;
|
||||
} else {
|
||||
this.context = {
|
||||
traceId: generateTraceId(),
|
||||
spanId: generateSpanId(),
|
||||
traceFlags: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setAttribute(key: string, value: string | number | boolean): void {
|
||||
this.attributes[key] = value;
|
||||
}
|
||||
|
||||
setAttributes(attributes: SpanAttributes): void {
|
||||
Object.assign(this.attributes, attributes);
|
||||
}
|
||||
|
||||
setStatus(status: SpanStatus, message?: string): void {
|
||||
this.status = status;
|
||||
if (message) {
|
||||
this.setAttribute('status.message', message);
|
||||
}
|
||||
}
|
||||
|
||||
addEvent(name: string, attributes?: SpanAttributes): void {
|
||||
this.events.push({
|
||||
name,
|
||||
timestamp: new Date(),
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
|
||||
recordException(exception: Error): void {
|
||||
this.setStatus(SpanStatus.ERROR);
|
||||
this.setAttribute('exception.type', exception.name);
|
||||
this.setAttribute('exception.message', exception.message);
|
||||
if (exception.stack) {
|
||||
this.setAttribute('exception.stacktrace', exception.stack);
|
||||
}
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (!this.endTime) {
|
||||
this.endTime = new Date();
|
||||
|
||||
if (this.status === SpanStatus.UNSET) {
|
||||
this.status = SpanStatus.OK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration in milliseconds
|
||||
*/
|
||||
getDuration(): number | null {
|
||||
if (!this.endTime) return null;
|
||||
return this.endTime.getTime() - this.startTime.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert span to JSON
|
||||
*/
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
traceId: this.context.traceId,
|
||||
spanId: this.context.spanId,
|
||||
parentSpanId: this.parentSpanId,
|
||||
startTime: this.startTime.toISOString(),
|
||||
endTime: this.endTime?.toISOString(),
|
||||
duration: this.getDuration(),
|
||||
status: this.status,
|
||||
attributes: this.attributes,
|
||||
events: this.events,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Span exporter interface
|
||||
*/
|
||||
export interface SpanExporter {
|
||||
export(spans: Span[]): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Console span exporter for debugging
|
||||
*/
|
||||
export class ConsoleSpanExporter implements SpanExporter {
|
||||
export(spans: Span[]): void {
|
||||
for (const span of spans) {
|
||||
if (span instanceof InMemorySpan) {
|
||||
console.log('[TRACE]', JSON.stringify(span.toJSON(), null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory tracer implementation
|
||||
*/
|
||||
export class InMemoryTracer implements Tracer {
|
||||
private activeSpan: Span | null = null;
|
||||
private spans: Span[] = [];
|
||||
private exporter: SpanExporter;
|
||||
|
||||
constructor(exporter: SpanExporter = new ConsoleSpanExporter()) {
|
||||
this.exporter = exporter;
|
||||
}
|
||||
|
||||
startSpan(name: string, attributes?: SpanAttributes): Span {
|
||||
const parentContext = this.activeSpan?.context;
|
||||
const span = new InMemorySpan(name, attributes, parentContext);
|
||||
this.spans.push(span);
|
||||
return span;
|
||||
}
|
||||
|
||||
getActiveSpan(): Span | null {
|
||||
return this.activeSpan;
|
||||
}
|
||||
|
||||
async withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T> {
|
||||
const span = this.startSpan(name);
|
||||
const previousActiveSpan = this.activeSpan;
|
||||
this.activeSpan = span;
|
||||
|
||||
try {
|
||||
const result = await fn(span);
|
||||
span.setStatus(SpanStatus.OK);
|
||||
return result;
|
||||
} catch (error) {
|
||||
span.recordException(error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
this.activeSpan = previousActiveSpan;
|
||||
this.exportSpan(span);
|
||||
}
|
||||
}
|
||||
|
||||
private exportSpan(span: Span): void {
|
||||
try {
|
||||
const result = this.exporter.export([span]);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch((err) => {
|
||||
console.error('Span export error:', err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Span export error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded spans
|
||||
*/
|
||||
getSpans(): Span[] {
|
||||
return [...this.spans];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all spans
|
||||
*/
|
||||
clear(): void {
|
||||
this.spans = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context carrier for propagation
|
||||
*/
|
||||
inject(carrier: Record<string, string>): void {
|
||||
if (this.activeSpan) {
|
||||
const { traceId, spanId, traceFlags } = this.activeSpan.context;
|
||||
carrier['traceparent'] = `00-${traceId}-${spanId}-${traceFlags.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract context from carrier
|
||||
*/
|
||||
extract(carrier: Record<string, string>): SpanContext | null {
|
||||
const traceparent = carrier['traceparent'];
|
||||
if (!traceparent) return null;
|
||||
|
||||
const parts = traceparent.split('-');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
return {
|
||||
traceId: parts[1],
|
||||
spanId: parts[2],
|
||||
traceFlags: parseInt(parts[3], 16),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op tracer for when tracing is disabled
|
||||
*/
|
||||
class NoOpSpan implements Span {
|
||||
name = '';
|
||||
startTime = new Date();
|
||||
status = SpanStatus.UNSET;
|
||||
attributes = {};
|
||||
context: SpanContext = {
|
||||
traceId: '00000000000000000000000000000000',
|
||||
spanId: '0000000000000000',
|
||||
traceFlags: 0,
|
||||
};
|
||||
|
||||
setAttribute(): void {}
|
||||
setAttributes(): void {}
|
||||
setStatus(): void {}
|
||||
addEvent(): void {}
|
||||
end(): void {}
|
||||
recordException(): void {}
|
||||
}
|
||||
|
||||
class NoOpTracer implements Tracer {
|
||||
private noOpSpan = new NoOpSpan();
|
||||
|
||||
startSpan(): Span {
|
||||
return this.noOpSpan;
|
||||
}
|
||||
|
||||
getActiveSpan(): Span | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async withSpan<T>(_name: string, fn: (span: Span) => Promise<T>): Promise<T> {
|
||||
return fn(this.noOpSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tracer instance
|
||||
*/
|
||||
export const defaultTracer: Tracer = new InMemoryTracer();
|
||||
|
||||
/**
|
||||
* No-op tracer instance (for performance-sensitive scenarios)
|
||||
*/
|
||||
export const noOpTracer: Tracer = new NoOpTracer();
|
||||
|
||||
/**
|
||||
* Tracing provider configuration
|
||||
*/
|
||||
export interface TracingConfig {
|
||||
enabled: boolean;
|
||||
exporter?: SpanExporter;
|
||||
serviceName?: string;
|
||||
serviceVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracing provider
|
||||
*/
|
||||
export class TracingProvider {
|
||||
private tracer: Tracer;
|
||||
private config: TracingConfig;
|
||||
|
||||
constructor(config: Partial<TracingConfig> = {}) {
|
||||
this.config = {
|
||||
enabled: config.enabled !== false,
|
||||
exporter: config.exporter,
|
||||
serviceName: config.serviceName || 'elasticsearch-client',
|
||||
serviceVersion: config.serviceVersion,
|
||||
};
|
||||
|
||||
this.tracer = this.config.enabled
|
||||
? new InMemoryTracer(this.config.exporter)
|
||||
: noOpTracer;
|
||||
}
|
||||
|
||||
getTracer(): Tracer {
|
||||
return this.tracer;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
createSpan(name: string, attributes?: SpanAttributes): Span {
|
||||
const span = this.tracer.startSpan(name, {
|
||||
...attributes,
|
||||
'service.name': this.config.serviceName,
|
||||
...(this.config.serviceVersion && { 'service.version': this.config.serviceVersion }),
|
||||
});
|
||||
return span;
|
||||
}
|
||||
|
||||
async withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T> {
|
||||
return this.tracer.withSpan(name, fn);
|
||||
}
|
||||
|
||||
propagateContext(carrier: Record<string, string>): void {
|
||||
if (this.tracer instanceof InMemoryTracer) {
|
||||
this.tracer.inject(carrier);
|
||||
}
|
||||
}
|
||||
|
||||
extractContext(carrier: Record<string, string>): SpanContext | null {
|
||||
if (this.tracer instanceof InMemoryTracer) {
|
||||
return this.tracer.extract(carrier);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tracing provider
|
||||
*/
|
||||
export const defaultTracingProvider = new TracingProvider();
|
||||
Reference in New Issue
Block a user