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 - 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 // Don't log 404 errors
316 const statusCode = set.status || 500
317 if (statusCode !== 404) {
318 logCollector.error(
319 `Request failed: ${request.method} ${url.pathname}`,
320 service,
321 error,
322 { statusCode }
323 )
324 }
325 }
326 }
327}
328
329// Export singleton logger for easy access
330export const logger = {
331 info: (message: string, context?: Record<string, any>) =>
332 logCollector.info(message, 'main-app', context),
333 warn: (message: string, context?: Record<string, any>) =>
334 logCollector.warn(message, 'main-app', context),
335 error: (message: string, error?: any, context?: Record<string, any>) =>
336 logCollector.error(message, 'main-app', error, context),
337 debug: (message: string, context?: Record<string, any>) =>
338 logCollector.debug(message, 'main-app', context)
339}