- 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/*
272 lines
7.6 KiB
TypeScript
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 };
|