metrics.ts•7.52 kB
import { Metrics } from '../schemas/index';
/**
* OpenTelemetry Metrics Collection
*
* Provides metrics collection for debugging performance and system health
* following the DAP performance targets: TFFB ≤ 1000ms, Step p95 ≤ 200ms
*/
export class MetricsCollector {
private static instance: MetricsCollector;
private metrics: Map<string, Metrics[]> = new Map();
private timers: Map<string, number> = new Map();
private constructor() {
// Initialize default metrics
this.initializeMetrics();
}
static getInstance(): MetricsCollector {
if (!MetricsCollector.instance) {
MetricsCollector.instance = new MetricsCollector();
}
return MetricsCollector.instance;
}
/**
* Initialize default metrics
*/
private initializeMetrics(): void {
const defaultMetrics = [
{ metric: 'dap.start.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.start.time', value: 0, tags: { type: 'duration' } },
{ metric: 'dap.breakpoints.set.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.continue.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.pause.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.step.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.step.time', value: 0, tags: { type: 'duration' } },
{ metric: 'dap.threads.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.stackTrace.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.scopes.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.variables.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.evaluate.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.events.poll.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.terminate.count', value: 0, tags: { type: 'request' } },
{ metric: 'dap.disconnect.count', value: 0, tags: { type: 'request' } },
{ metric: 'sessions.active', value: 0, tags: { type: 'gauge' } },
{ metric: 'sessions.total', value: 0, tags: { type: 'counter' } },
{ metric: 'events.buffer.size', value: 0, tags: { type: 'gauge' } },
{ metric: 'events.buffer.capacity', value: 0, tags: { type: 'gauge' } },
];
defaultMetrics.forEach(metric => {
const metricWithTimestamp = { ...metric, timestamp: Date.now() };
this.metrics.set(metric.metric, [metricWithTimestamp]);
});
}
/**
* Start timing an operation
*/
startTimer(name: string): void {
this.timers.set(name, Date.now());
}
/**
* Stop timing and record duration
*/
stopTimer(name: string, tags?: Record<string, string>): number {
const startTime = this.timers.get(name);
if (!startTime) {
return 0;
}
const duration = Date.now() - startTime;
this.timers.delete(name);
this.record(`${name}.time`, duration, tags);
return duration;
}
/**
* Record a metric value
*/
record(metric: string, value: number, tags?: Record<string, string>): void {
if (!this.hasMetric(metric)) {
this.metrics.set(metric, []);
}
const metricData: Metrics = {
timestamp: Date.now(),
metric,
value,
tags: tags || {},
};
const metricList = this.metrics.get(metric)!;
metricList.push(metricData);
// Keep only the last 1000 data points per metric
if (metricList.length > 1000) {
metricList.splice(0, metricList.length - 1000);
}
}
/**
* Increment a counter metric
*/
increment(metric: string, delta: number = 1, tags?: Record<string, string>): void {
const currentValue = this.get(metric, 0, tags);
this.record(metric, currentValue + delta, tags);
}
/**
* Get metric value(s)
*/
get(metric: string, defaultValue: number = 0, tags?: Record<string, string>): number {
const metricList = this.metrics.get(metric) || [];
if (tags) {
// Filter by tags if provided
const matching = metricList.filter(
m =>
m.tags &&
Object.entries(tags).every(
([key, value]) => m.tags && m.tags[key] !== undefined && m.tags[key] === value
)
);
return matching.length > 0
? matching[matching.length - 1]?.value || defaultValue
: defaultValue;
}
return metricList.length > 0
? metricList[metricList.length - 1]?.value || defaultValue
: defaultValue;
}
/**
* Get multiple metric values
*/
getMultiple(metric: string, limit: number = 100): Metrics[] {
const metricList = this.metrics.get(metric) || [];
return metricList.slice(-limit);
}
/**
* Calculate performance targets
*/
calculatePerformanceTargets(): {
tffb: { current: number; target: number; status: 'pass' | 'fail' | 'unknown' };
stepP95: { current: number; target: number; status: 'pass' | 'fail' | 'unknown' };
} {
// Time to First Break (TFFB) calculation
const tffbValues = this.getMultiple('dap.start.time', 100)
.map(m => m.value)
.filter(v => v > 0);
const tffbCurrent = tffbValues.length > 0 ? this.calculatePercentile(tffbValues, 95) : 0;
const tffbTarget = 1000; // Target: ≤ 1000ms
const tffbStatus = tffbCurrent > 0 ? (tffbCurrent <= tffbTarget ? 'pass' : 'fail') : 'unknown';
// Step operation p95 calculation
const stepValues = this.getMultiple('dap.step.time', 100)
.map(m => m.value)
.filter(v => v > 0);
const stepP95Current = stepValues.length > 0 ? this.calculatePercentile(stepValues, 95) : 0;
const stepP95Target = 200; // Target: ≤ 200ms
const stepP95Status =
stepP95Current > 0 ? (stepP95Current <= stepP95Target ? 'pass' : 'fail') : 'unknown';
return {
tffb: {
current: tffbCurrent,
target: tffbTarget,
status: tffbStatus,
},
stepP95: {
current: stepP95Current,
target: stepP95Target,
status: stepP95Status,
},
};
}
/**
* Calculate percentile from array of values
*/
private calculatePercentile(values: number[], percentile: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(index, sorted.length - 1))] || 0;
}
/**
* Check if metric exists
*/
private hasMetric(metric: string): boolean {
return this.metrics.has(metric);
}
/**
* Export metrics for OpenTelemetry
*/
export(): Record<string, any> {
const exported: Record<string, any> = {};
const performanceTargets = this.calculatePerformanceTargets();
// Export all metrics
for (const [metric, values] of Array.from(this.metrics.entries())) {
if (values.length > 0) {
exported[metric] = {
values: values.map(v => ({
timestamp: v.timestamp,
value: v.value,
tags: v.tags,
})),
current: values[values.length - 1]?.value || 0,
count: values.length,
};
}
}
// Export performance targets
exported['performance.targets'] = performanceTargets;
return exported;
}
/**
* Reset all metrics
*/
reset(): void {
this.metrics.clear();
this.timers.clear();
this.initializeMetrics();
}
}
/**
* Setup OpenTelemetry metrics
*/
export function setupMetrics(): MetricsCollector {
return MetricsCollector.getInstance();
}