Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 12 kB view raw
1/** 2 * Grafana exporters for logs and metrics 3 * Integrates with Grafana Loki for logs and Prometheus/OTLP for metrics 4 */ 5 6import { LogEntry, ErrorEntry, MetricEntry } from './core' 7import { metrics, MeterProvider } from '@opentelemetry/api' 8import { MeterProvider as SdkMeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' 9import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' 10import { Resource } from '@opentelemetry/resources' 11import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' 12 13// ============================================================================ 14// Types 15// ============================================================================ 16 17export interface GrafanaConfig { 18 lokiUrl?: string 19 lokiAuth?: { 20 username?: string 21 password?: string 22 bearerToken?: string 23 } 24 prometheusUrl?: string 25 prometheusAuth?: { 26 username?: string 27 password?: string 28 bearerToken?: string 29 } 30 serviceName?: string 31 serviceVersion?: string 32 batchSize?: number 33 flushIntervalMs?: number 34 enabled?: boolean 35} 36 37interface LokiStream { 38 stream: Record<string, string> 39 values: Array<[string, string]> 40} 41 42interface LokiBatch { 43 streams: LokiStream[] 44} 45 46// ============================================================================ 47// Configuration 48// ============================================================================ 49 50class GrafanaExporterConfig { 51 private config: GrafanaConfig = { 52 enabled: false, 53 batchSize: 100, 54 flushIntervalMs: 5000, 55 serviceName: 'wisp-app', 56 serviceVersion: '1.0.0' 57 } 58 59 initialize(config: GrafanaConfig) { 60 this.config = { ...this.config, ...config } 61 62 // Load from environment variables if not provided 63 if (!this.config.lokiUrl) { 64 this.config.lokiUrl = process.env.GRAFANA_LOKI_URL 65 } 66 67 if (!this.config.prometheusUrl) { 68 this.config.prometheusUrl = process.env.GRAFANA_PROMETHEUS_URL 69 } 70 71 // Load Loki authentication from environment 72 if (!this.config.lokiAuth?.bearerToken && !this.config.lokiAuth?.username) { 73 const token = process.env.GRAFANA_LOKI_TOKEN 74 const username = process.env.GRAFANA_LOKI_USERNAME 75 const password = process.env.GRAFANA_LOKI_PASSWORD 76 77 if (token) { 78 this.config.lokiAuth = { ...this.config.lokiAuth, bearerToken: token } 79 } else if (username && password) { 80 this.config.lokiAuth = { ...this.config.lokiAuth, username, password } 81 } 82 } 83 84 // Load Prometheus authentication from environment 85 if (!this.config.prometheusAuth?.bearerToken && !this.config.prometheusAuth?.username) { 86 const token = process.env.GRAFANA_PROMETHEUS_TOKEN 87 const username = process.env.GRAFANA_PROMETHEUS_USERNAME 88 const password = process.env.GRAFANA_PROMETHEUS_PASSWORD 89 90 if (token) { 91 this.config.prometheusAuth = { ...this.config.prometheusAuth, bearerToken: token } 92 } else if (username && password) { 93 this.config.prometheusAuth = { ...this.config.prometheusAuth, username, password } 94 } 95 } 96 97 // Enable if URLs are configured 98 if (this.config.lokiUrl || this.config.prometheusUrl) { 99 this.config.enabled = true 100 } 101 102 return this 103 } 104 105 getConfig(): GrafanaConfig { 106 return { ...this.config } 107 } 108 109 isEnabled(): boolean { 110 return this.config.enabled === true 111 } 112} 113 114export const grafanaConfig = new GrafanaExporterConfig() 115 116// ============================================================================ 117// Loki Exporter for Logs 118// ============================================================================ 119 120class LokiExporter { 121 private buffer: LogEntry[] = [] 122 private errorBuffer: ErrorEntry[] = [] 123 private flushTimer?: NodeJS.Timeout 124 private config: GrafanaConfig = {} 125 126 initialize(config: GrafanaConfig) { 127 this.config = config 128 129 if (this.config.enabled && this.config.lokiUrl) { 130 this.startBatching() 131 } 132 } 133 134 private startBatching() { 135 const interval = this.config.flushIntervalMs || 5000 136 137 this.flushTimer = setInterval(() => { 138 this.flush() 139 }, interval) 140 } 141 142 stop() { 143 if (this.flushTimer) { 144 clearInterval(this.flushTimer) 145 this.flushTimer = undefined 146 } 147 // Final flush 148 this.flush() 149 } 150 151 pushLog(entry: LogEntry) { 152 if (!this.config.enabled || !this.config.lokiUrl) return 153 154 this.buffer.push(entry) 155 156 const batchSize = this.config.batchSize || 100 157 if (this.buffer.length >= batchSize) { 158 this.flush() 159 } 160 } 161 162 pushError(entry: ErrorEntry) { 163 if (!this.config.enabled || !this.config.lokiUrl) return 164 165 this.errorBuffer.push(entry) 166 167 const batchSize = this.config.batchSize || 100 168 if (this.errorBuffer.length >= batchSize) { 169 this.flush() 170 } 171 } 172 173 private async flush() { 174 if (!this.config.lokiUrl) return 175 176 const logsToSend = [...this.buffer] 177 const errorsToSend = [...this.errorBuffer] 178 179 this.buffer = [] 180 this.errorBuffer = [] 181 182 if (logsToSend.length === 0 && errorsToSend.length === 0) return 183 184 try { 185 const batch = this.createLokiBatch(logsToSend, errorsToSend) 186 await this.sendToLoki(batch) 187 } catch (error) { 188 console.error('[LokiExporter] Failed to send logs to Loki:', error) 189 // Optionally re-queue failed logs 190 } 191 } 192 193 private createLokiBatch(logs: LogEntry[], errors: ErrorEntry[]): LokiBatch { 194 const streams: LokiStream[] = [] 195 196 // Group logs by service and level 197 const logGroups = new Map<string, LogEntry[]>() 198 199 for (const log of logs) { 200 const key = `${log.service}-${log.level}` 201 const group = logGroups.get(key) || [] 202 group.push(log) 203 logGroups.set(key, group) 204 } 205 206 // Create streams for logs 207 for (const [key, entries] of logGroups) { 208 const [service, level] = key.split('-') 209 const values: Array<[string, string]> = entries.map(entry => { 210 const logLine = JSON.stringify({ 211 message: entry.message, 212 context: entry.context, 213 traceId: entry.traceId, 214 eventType: entry.eventType 215 }) 216 217 // Loki expects nanosecond timestamp as string 218 const nanoTimestamp = String(entry.timestamp.getTime() * 1000000) 219 return [nanoTimestamp, logLine] 220 }) 221 222 streams.push({ 223 stream: { 224 service: service || 'unknown', 225 level: level || 'info', 226 job: this.config.serviceName || 'wisp-app' 227 }, 228 values 229 }) 230 } 231 232 // Create streams for errors 233 if (errors.length > 0) { 234 const errorValues: Array<[string, string]> = errors.map(entry => { 235 const logLine = JSON.stringify({ 236 message: entry.message, 237 stack: entry.stack, 238 context: entry.context, 239 count: entry.count 240 }) 241 242 const nanoTimestamp = String(entry.timestamp.getTime() * 1000000) 243 return [nanoTimestamp, logLine] 244 }) 245 246 streams.push({ 247 stream: { 248 service: errors[0]?.service || 'unknown', 249 level: 'error', 250 job: this.config.serviceName || 'wisp-app', 251 type: 'aggregated_error' 252 }, 253 values: errorValues 254 }) 255 } 256 257 return { streams } 258 } 259 260 private async sendToLoki(batch: LokiBatch) { 261 if (!this.config.lokiUrl) return 262 263 const headers: Record<string, string> = { 264 'Content-Type': 'application/json' 265 } 266 267 // Add authentication 268 if (this.config.lokiAuth?.bearerToken) { 269 headers['Authorization'] = `Bearer ${this.config.lokiAuth.bearerToken}` 270 } else if (this.config.lokiAuth?.username && this.config.lokiAuth?.password) { 271 const auth = Buffer.from(`${this.config.lokiAuth.username}:${this.config.lokiAuth.password}`).toString('base64') 272 headers['Authorization'] = `Basic ${auth}` 273 } 274 275 const response = await fetch(`${this.config.lokiUrl}/loki/api/v1/push`, { 276 method: 'POST', 277 headers, 278 body: JSON.stringify(batch) 279 }) 280 281 if (!response.ok) { 282 const text = await response.text() 283 throw new Error(`Loki push failed: ${response.status} - ${text}`) 284 } 285 } 286} 287 288// ============================================================================ 289// OpenTelemetry Metrics Exporter 290// ============================================================================ 291 292class MetricsExporter { 293 private meterProvider?: MeterProvider 294 private requestCounter?: any 295 private requestDuration?: any 296 private errorCounter?: any 297 private config: GrafanaConfig = {} 298 299 initialize(config: GrafanaConfig) { 300 this.config = config 301 302 if (!this.config.enabled || !this.config.prometheusUrl) return 303 304 // Create OTLP exporter with Prometheus endpoint 305 const exporter = new OTLPMetricExporter({ 306 url: `${this.config.prometheusUrl}/v1/metrics`, 307 headers: this.getAuthHeaders(), 308 timeoutMillis: 10000 309 }) 310 311 // Create meter provider with periodic exporting 312 const meterProvider = new SdkMeterProvider({ 313 resource: new Resource({ 314 [ATTR_SERVICE_NAME]: this.config.serviceName || 'wisp-app', 315 [ATTR_SERVICE_VERSION]: this.config.serviceVersion || '1.0.0' 316 }), 317 readers: [ 318 new PeriodicExportingMetricReader({ 319 exporter, 320 exportIntervalMillis: this.config.flushIntervalMs || 5000 321 }) 322 ] 323 }) 324 325 // Set global meter provider 326 metrics.setGlobalMeterProvider(meterProvider) 327 this.meterProvider = meterProvider 328 329 // Create metrics instruments 330 const meter = metrics.getMeter(this.config.serviceName || 'wisp-app') 331 332 this.requestCounter = meter.createCounter('http_requests_total', { 333 description: 'Total number of HTTP requests' 334 }) 335 336 this.requestDuration = meter.createHistogram('http_request_duration_ms', { 337 description: 'HTTP request duration in milliseconds', 338 unit: 'ms' 339 }) 340 341 this.errorCounter = meter.createCounter('errors_total', { 342 description: 'Total number of errors' 343 }) 344 } 345 346 private getAuthHeaders(): Record<string, string> { 347 const headers: Record<string, string> = {} 348 349 if (this.config.prometheusAuth?.bearerToken) { 350 headers['Authorization'] = `Bearer ${this.config.prometheusAuth.bearerToken}` 351 } else if (this.config.prometheusAuth?.username && this.config.prometheusAuth?.password) { 352 const auth = Buffer.from(`${this.config.prometheusAuth.username}:${this.config.prometheusAuth.password}`).toString('base64') 353 headers['Authorization'] = `Basic ${auth}` 354 } 355 356 return headers 357 } 358 359 recordMetric(entry: MetricEntry) { 360 if (!this.config.enabled) return 361 362 const attributes = { 363 method: entry.method, 364 path: entry.path, 365 status: String(entry.statusCode), 366 service: entry.service 367 } 368 369 // Record request count 370 this.requestCounter?.add(1, attributes) 371 372 // Record request duration 373 this.requestDuration?.record(entry.duration, attributes) 374 375 // Record errors 376 if (entry.statusCode >= 400) { 377 this.errorCounter?.add(1, attributes) 378 } 379 } 380 381 async shutdown() { 382 if (this.meterProvider && 'shutdown' in this.meterProvider) { 383 await (this.meterProvider as SdkMeterProvider).shutdown() 384 } 385 } 386} 387 388// ============================================================================ 389// Singleton Instances 390// ============================================================================ 391 392export const lokiExporter = new LokiExporter() 393export const metricsExporter = new MetricsExporter() 394 395// ============================================================================ 396// Initialization 397// ============================================================================ 398 399export function initializeGrafanaExporters(config?: GrafanaConfig) { 400 const finalConfig = grafanaConfig.initialize(config || {}).getConfig() 401 402 if (finalConfig.enabled) { 403 console.log('[Observability] Initializing Grafana exporters', { 404 lokiEnabled: !!finalConfig.lokiUrl, 405 prometheusEnabled: !!finalConfig.prometheusUrl, 406 serviceName: finalConfig.serviceName 407 }) 408 409 lokiExporter.initialize(finalConfig) 410 metricsExporter.initialize(finalConfig) 411 } 412 413 return { 414 lokiExporter, 415 metricsExporter, 416 config: finalConfig 417 } 418} 419 420// ============================================================================ 421// Cleanup 422// ============================================================================ 423 424export async function shutdownGrafanaExporters() { 425 lokiExporter.stop() 426 await metricsExporter.shutdown() 427} 428 429// Graceful shutdown handlers 430if (typeof process !== 'undefined') { 431 process.on('SIGTERM', shutdownGrafanaExporters) 432 process.on('SIGINT', shutdownGrafanaExporters) 433}