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}