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