Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
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}