Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1/** 2 * Core observability types and collectors 3 * Framework-agnostic logging, error tracking, and metrics collection 4 */ 5 6import { lokiExporter, metricsExporter } from './exporters' 7 8// ============================================================================ 9// Types 10// ============================================================================ 11 12export interface LogEntry { 13 id: string 14 timestamp: Date 15 level: 'info' | 'warn' | 'error' | 'debug' 16 message: string 17 service: string 18 context?: Record<string, any> 19 traceId?: string 20 eventType?: string 21} 22 23export interface ErrorEntry { 24 id: string 25 timestamp: Date 26 message: string 27 stack?: string 28 service: string 29 context?: Record<string, any> 30 count: number 31 lastSeen: Date 32} 33 34export interface MetricEntry { 35 timestamp: Date 36 path: string 37 method: string 38 statusCode: number 39 duration: number 40 service: string 41} 42 43export interface LogFilter { 44 level?: string 45 service?: string 46 limit?: number 47 search?: string 48 eventType?: string 49} 50 51export interface ErrorFilter { 52 service?: string 53 limit?: number 54} 55 56export interface MetricFilter { 57 service?: string 58 timeWindow?: number 59} 60 61export interface MetricStats { 62 totalRequests: number 63 avgDuration: number 64 p50Duration: number 65 p95Duration: number 66 p99Duration: number 67 errorRate: number 68 requestsPerMinute: number 69} 70 71// ============================================================================ 72// Configuration 73// ============================================================================ 74 75const MAX_LOGS = 5000 76const MAX_ERRORS = 500 77const MAX_METRICS = 10000 78 79// ============================================================================ 80// Storage 81// ============================================================================ 82 83const logs: LogEntry[] = [] 84const errors: Map<string, ErrorEntry> = new Map() 85const metrics: MetricEntry[] = [] 86 87// ============================================================================ 88// Helpers 89// ============================================================================ 90 91let logCounter = 0 92let errorCounter = 0 93 94function generateId(prefix: string, counter: number): string { 95 return `${prefix}-${Date.now()}-${counter}` 96} 97 98function extractEventType(message: string): string | undefined { 99 const match = message.match(/^\[([^\]]+)\]/) 100 return match ? match[1] : undefined 101} 102 103// ============================================================================ 104// Log Collector 105// ============================================================================ 106 107export const logCollector = { 108 log( 109 level: LogEntry['level'], 110 message: string, 111 service: string, 112 context?: Record<string, any>, 113 traceId?: string 114 ) { 115 const entry: LogEntry = { 116 id: generateId('log', logCounter++), 117 timestamp: new Date(), 118 level, 119 message, 120 service, 121 context, 122 traceId, 123 eventType: extractEventType(message) 124 } 125 126 logs.unshift(entry) 127 128 // Rotate if needed 129 if (logs.length > MAX_LOGS) { 130 logs.splice(MAX_LOGS) 131 } 132 133 // Send to Loki exporter 134 lokiExporter.pushLog(entry) 135 136 // Also log to console for compatibility 137 const contextStr = context ? ` ${JSON.stringify(context)}` : '' 138 const traceStr = traceId ? ` [trace:${traceId}]` : '' 139 console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`) 140 }, 141 142 info(message: string, service: string, context?: Record<string, any>, traceId?: string) { 143 this.log('info', message, service, context, traceId) 144 }, 145 146 warn(message: string, service: string, context?: Record<string, any>, traceId?: string) { 147 this.log('warn', message, service, context, traceId) 148 }, 149 150 error( 151 message: string, 152 service: string, 153 error?: any, 154 context?: Record<string, any>, 155 traceId?: string 156 ) { 157 const ctx = { ...context } 158 if (error instanceof Error) { 159 ctx.error = error.message 160 ctx.stack = error.stack 161 } else if (error) { 162 ctx.error = String(error) 163 } 164 this.log('error', message, service, ctx, traceId) 165 166 // Also track in errors 167 errorTracker.track(message, service, error, context) 168 }, 169 170 debug(message: string, service: string, context?: Record<string, any>, traceId?: string) { 171 if (process.env.NODE_ENV !== 'production') { 172 this.log('debug', message, service, context, traceId) 173 } 174 }, 175 176 getLogs(filter?: LogFilter) { 177 let filtered = [...logs] 178 179 if (filter?.level) { 180 filtered = filtered.filter(log => log.level === filter.level) 181 } 182 183 if (filter?.service) { 184 filtered = filtered.filter(log => log.service === filter.service) 185 } 186 187 if (filter?.eventType) { 188 filtered = filtered.filter(log => log.eventType === filter.eventType) 189 } 190 191 if (filter?.search) { 192 const search = filter.search.toLowerCase() 193 filtered = filtered.filter(log => 194 log.message.toLowerCase().includes(search) || 195 (log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false) 196 ) 197 } 198 199 const limit = filter?.limit || 100 200 return filtered.slice(0, limit) 201 }, 202 203 clear() { 204 logs.length = 0 205 } 206} 207 208// ============================================================================ 209// Error Tracker 210// ============================================================================ 211 212export const errorTracker = { 213 track(message: string, service: string, error?: any, context?: Record<string, any>) { 214 const key = `${service}:${message}` 215 216 const existing = errors.get(key) 217 if (existing) { 218 existing.count++ 219 existing.lastSeen = new Date() 220 if (context) { 221 existing.context = { ...existing.context, ...context } 222 } 223 } else { 224 const entry: ErrorEntry = { 225 id: generateId('error', errorCounter++), 226 timestamp: new Date(), 227 message, 228 service, 229 context, 230 count: 1, 231 lastSeen: new Date() 232 } 233 234 if (error instanceof Error) { 235 entry.stack = error.stack 236 } 237 238 errors.set(key, entry) 239 240 // Send to Loki exporter 241 lokiExporter.pushError(entry) 242 243 // Rotate if needed 244 if (errors.size > MAX_ERRORS) { 245 const oldest = Array.from(errors.keys())[0] 246 if (oldest !== undefined) { 247 errors.delete(oldest) 248 } 249 } 250 } 251 }, 252 253 getErrors(filter?: ErrorFilter) { 254 let filtered = Array.from(errors.values()) 255 256 if (filter?.service) { 257 filtered = filtered.filter(err => err.service === filter.service) 258 } 259 260 // Sort by last seen (most recent first) 261 filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()) 262 263 const limit = filter?.limit || 100 264 return filtered.slice(0, limit) 265 }, 266 267 clear() { 268 errors.clear() 269 } 270} 271 272// ============================================================================ 273// Metrics Collector 274// ============================================================================ 275 276export const metricsCollector = { 277 recordRequest( 278 path: string, 279 method: string, 280 statusCode: number, 281 duration: number, 282 service: string 283 ) { 284 const entry: MetricEntry = { 285 timestamp: new Date(), 286 path, 287 method, 288 statusCode, 289 duration, 290 service 291 } 292 293 metrics.unshift(entry) 294 295 // Send to Prometheus/OTLP exporter 296 metricsExporter.recordMetric(entry) 297 298 // Rotate if needed 299 if (metrics.length > MAX_METRICS) { 300 metrics.splice(MAX_METRICS) 301 } 302 }, 303 304 getMetrics(filter?: MetricFilter) { 305 let filtered = [...metrics] 306 307 if (filter?.service) { 308 filtered = filtered.filter(m => m.service === filter.service) 309 } 310 311 if (filter?.timeWindow) { 312 const cutoff = Date.now() - filter.timeWindow 313 filtered = filtered.filter(m => m.timestamp.getTime() > cutoff) 314 } 315 316 return filtered 317 }, 318 319 getStats(service?: string, timeWindow: number = 3600000): MetricStats { 320 const filtered = this.getMetrics({ service, timeWindow }) 321 322 if (filtered.length === 0) { 323 return { 324 totalRequests: 0, 325 avgDuration: 0, 326 p50Duration: 0, 327 p95Duration: 0, 328 p99Duration: 0, 329 errorRate: 0, 330 requestsPerMinute: 0 331 } 332 } 333 334 const durations = filtered.map(m => m.duration).sort((a, b) => a - b) 335 const totalDuration = durations.reduce((sum, d) => sum + d, 0) 336 const errors = filtered.filter(m => m.statusCode >= 400).length 337 338 const p50 = durations[Math.floor(durations.length * 0.5)] 339 const p95 = durations[Math.floor(durations.length * 0.95)] 340 const p99 = durations[Math.floor(durations.length * 0.99)] 341 342 const timeWindowMinutes = timeWindow / 60000 343 344 return { 345 totalRequests: filtered.length, 346 avgDuration: Math.round(totalDuration / filtered.length), 347 p50Duration: Math.round(p50 ?? 0), 348 p95Duration: Math.round(p95 ?? 0), 349 p99Duration: Math.round(p99 ?? 0), 350 errorRate: (errors / filtered.length) * 100, 351 requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) 352 } 353 }, 354 355 clear() { 356 metrics.length = 0 357 } 358} 359 360// ============================================================================ 361// Logger Factory 362// ============================================================================ 363 364/** 365 * Create a service-specific logger instance 366 */ 367export function createLogger(service: string) { 368 return { 369 info: (message: string, context?: Record<string, any>) => 370 logCollector.info(message, service, context), 371 warn: (message: string, context?: Record<string, any>) => 372 logCollector.warn(message, service, context), 373 error: (message: string, error?: any, context?: Record<string, any>) => 374 logCollector.error(message, service, error, context), 375 debug: (message: string, context?: Record<string, any>) => 376 logCollector.debug(message, service, context) 377 } 378}