localgreenchain/lib/monitoring/metrics.ts
Claude 5ea8bab5c3
Add production deployment infrastructure (Agent 4)
- Docker: Multi-stage Dockerfile with security hardening, docker-compose
  for production and development environments
- Environment: Comprehensive .env.example with all config options,
  lib/config/env.ts for typed environment validation
- Logging: Structured JSON logging with request/response middleware
- Monitoring: Prometheus metrics endpoint, Grafana dashboard, health
  checks (liveness/readiness probes)
- Security: Security headers, rate limiting, CORS middleware
- CI/CD: GitHub Actions workflows for CI, production deploy, and
  preview deployments
- Error tracking: Sentry integration foundation

Files created:
- Docker: Dockerfile, docker-compose.yml, docker-compose.dev.yml, .dockerignore
- Config: lib/config/env.ts, lib/config/index.ts
- Logging: lib/logging/logger.ts, lib/logging/middleware.ts
- Monitoring: lib/monitoring/sentry.ts, lib/monitoring/metrics.ts,
  lib/monitoring/health.ts
- Security: lib/security/headers.ts, lib/security/rateLimit.ts,
  lib/security/cors.ts
- API: pages/api/health/*, pages/api/metrics.ts
- Infra: infra/prometheus/prometheus.yml, infra/grafana/*
2025-11-23 03:54:03 +00:00

272 lines
7.6 KiB
TypeScript

/**
* Application Metrics
* Agent 4: Production Deployment
*
* Provides application metrics for Prometheus monitoring.
* Tracks request counts, response times, and application health.
*/
import { env } from '../config';
interface MetricValue {
value: number;
labels: Record<string, string>;
timestamp: number;
}
interface HistogramBucket {
le: number;
count: number;
}
interface Histogram {
buckets: HistogramBucket[];
sum: number;
count: number;
labels: Record<string, string>;
}
/**
* Simple in-memory metrics store
* In production, replace with prom-client for full Prometheus compatibility
*/
class MetricsRegistry {
private counters: Map<string, MetricValue[]> = new Map();
private gauges: Map<string, MetricValue> = new Map();
private histograms: Map<string, Histogram[]> = new Map();
private readonly defaultBuckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
/**
* Increment a counter metric
*/
incrementCounter(name: string, labels: Record<string, string> = {}, value = 1): void {
const key = this.getKey(name, labels);
const existing = this.counters.get(key) || [];
// Find existing entry with same labels or create new
const labelKey = JSON.stringify(labels);
const existingEntry = existing.find(e => JSON.stringify(e.labels) === labelKey);
if (existingEntry) {
existingEntry.value += value;
existingEntry.timestamp = Date.now();
} else {
existing.push({ value, labels, timestamp: Date.now() });
this.counters.set(key, existing);
}
}
/**
* Set a gauge metric value
*/
setGauge(name: string, value: number, labels: Record<string, string> = {}): void {
const key = this.getKey(name, labels);
this.gauges.set(key, { value, labels, timestamp: Date.now() });
}
/**
* Increment a gauge metric
*/
incrementGauge(name: string, labels: Record<string, string> = {}, value = 1): void {
const key = this.getKey(name, labels);
const existing = this.gauges.get(key);
const newValue = (existing?.value || 0) + value;
this.gauges.set(key, { value: newValue, labels, timestamp: Date.now() });
}
/**
* Decrement a gauge metric
*/
decrementGauge(name: string, labels: Record<string, string> = {}, value = 1): void {
this.incrementGauge(name, labels, -value);
}
/**
* Observe a value in a histogram
*/
observeHistogram(name: string, value: number, labels: Record<string, string> = {}): void {
const key = this.getKey(name, labels);
let histograms = this.histograms.get(key);
if (!histograms) {
histograms = [];
this.histograms.set(key, histograms);
}
const labelKey = JSON.stringify(labels);
let histogram = histograms.find(h => JSON.stringify(h.labels) === labelKey);
if (!histogram) {
histogram = {
buckets: this.defaultBuckets.map(le => ({ le, count: 0 })),
sum: 0,
count: 0,
labels,
};
histograms.push(histogram);
}
// Update histogram
histogram.sum += value;
histogram.count += 1;
for (const bucket of histogram.buckets) {
if (value <= bucket.le) {
bucket.count += 1;
}
}
}
/**
* Get all metrics in Prometheus format
*/
toPrometheusFormat(): string {
const lines: string[] = [];
const prefix = 'lgc';
// Counters
for (const [name, values] of this.counters.entries()) {
const metricName = `${prefix}_${name.replace(/[^a-zA-Z0-9_]/g, '_')}_total`;
lines.push(`# HELP ${metricName} Counter metric`);
lines.push(`# TYPE ${metricName} counter`);
for (const v of values) {
const labelStr = this.formatLabels(v.labels);
lines.push(`${metricName}${labelStr} ${v.value}`);
}
}
// Gauges
const gaugeGroups = new Map<string, MetricValue[]>();
for (const [key, value] of this.gauges.entries()) {
const name = key.split('|')[0];
if (!gaugeGroups.has(name)) {
gaugeGroups.set(name, []);
}
gaugeGroups.get(name)!.push(value);
}
for (const [name, values] of gaugeGroups.entries()) {
const metricName = `${prefix}_${name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
lines.push(`# HELP ${metricName} Gauge metric`);
lines.push(`# TYPE ${metricName} gauge`);
for (const v of values) {
const labelStr = this.formatLabels(v.labels);
lines.push(`${metricName}${labelStr} ${v.value}`);
}
}
// Histograms
for (const [name, histograms] of this.histograms.entries()) {
const metricName = `${prefix}_${name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
lines.push(`# HELP ${metricName} Histogram metric`);
lines.push(`# TYPE ${metricName} histogram`);
for (const h of histograms) {
const baseLabels = this.formatLabels(h.labels);
for (const bucket of h.buckets) {
const bucketLabel = h.labels ? `,le="${bucket.le}"` : `le="${bucket.le}"`;
const labelStr = baseLabels ? baseLabels.slice(0, -1) + bucketLabel + '}' : `{${bucketLabel.slice(1)}}`;
lines.push(`${metricName}_bucket${labelStr} ${bucket.count}`);
}
const infLabel = h.labels ? baseLabels.slice(0, -1) + `,le="+Inf"}` : `{le="+Inf"}`;
lines.push(`${metricName}_bucket${infLabel} ${h.count}`);
lines.push(`${metricName}_sum${baseLabels} ${h.sum}`);
lines.push(`${metricName}_count${baseLabels} ${h.count}`);
}
}
return lines.join('\n');
}
/**
* Get metrics as JSON
*/
toJSON(): Record<string, unknown> {
return {
counters: Object.fromEntries(this.counters),
gauges: Object.fromEntries(this.gauges),
histograms: Object.fromEntries(this.histograms),
timestamp: new Date().toISOString(),
};
}
/**
* Reset all metrics
*/
reset(): void {
this.counters.clear();
this.gauges.clear();
this.histograms.clear();
}
private getKey(name: string, labels: Record<string, string>): string {
return name;
}
private formatLabels(labels: Record<string, string>): string {
const entries = Object.entries(labels);
if (entries.length === 0) return '';
return '{' + entries.map(([k, v]) => `${k}="${v}"`).join(',') + '}';
}
}
// Create singleton instance
export const metrics = new MetricsRegistry();
// Pre-defined metric helpers
export const httpMetrics = {
requestTotal(method: string, path: string, statusCode: number): void {
metrics.incrementCounter('http_requests', {
method,
path,
status: String(statusCode),
});
},
requestDuration(method: string, path: string, durationMs: number): void {
metrics.observeHistogram('http_request_duration_seconds', durationMs / 1000, {
method,
path,
});
},
activeConnections(delta: number): void {
metrics.incrementGauge('http_active_connections', {}, delta);
},
};
export const appMetrics = {
plantsRegistered(count = 1): void {
metrics.incrementCounter('plants_registered', {}, count);
},
transportEvents(eventType: string, count = 1): void {
metrics.incrementCounter('transport_events', { type: eventType }, count);
},
agentCycleCompleted(agentName: string, durationMs: number): void {
metrics.observeHistogram('agent_cycle_duration_seconds', durationMs / 1000, {
agent: agentName,
});
metrics.incrementCounter('agent_cycles', { agent: agentName });
},
activeAgents(count: number): void {
metrics.setGauge('active_agents', count);
},
blockchainBlocks(count: number): void {
metrics.setGauge('blockchain_blocks', count);
},
databaseConnections(count: number): void {
metrics.setGauge('database_connections', count);
},
};
// Export types
export type { MetricValue, Histogram, HistogramBucket };