forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
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}