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