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