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
6import { lokiExporter, metricsExporter } from './exporters'
7
8// ============================================================================
9// Types
10// ============================================================================
11
12export interface LogEntry {
13 id: string
14 timestamp: Date
15 level: 'info' | 'warn' | 'error' | 'debug'
16 message: string
17 service: string
18 context?: Record<string, any>
19 traceId?: string
20 eventType?: string
21}
22
23export interface ErrorEntry {
24 id: string
25 timestamp: Date
26 message: string
27 stack?: string
28 service: string
29 context?: Record<string, any>
30 count: number
31 lastSeen: Date
32}
33
34export interface MetricEntry {
35 timestamp: Date
36 path: string
37 method: string
38 statusCode: number
39 duration: number
40 service: string
41}
42
43export interface LogFilter {
44 level?: string
45 service?: string
46 limit?: number
47 search?: string
48 eventType?: string
49}
50
51export interface ErrorFilter {
52 service?: string
53 limit?: number
54}
55
56export interface MetricFilter {
57 service?: string
58 timeWindow?: number
59}
60
61export interface MetricStats {
62 totalRequests: number
63 avgDuration: number
64 p50Duration: number
65 p95Duration: number
66 p99Duration: number
67 errorRate: number
68 requestsPerMinute: number
69}
70
71// ============================================================================
72// Configuration
73// ============================================================================
74
75const MAX_LOGS = 5000
76const MAX_ERRORS = 500
77const MAX_METRICS = 10000
78
79// ============================================================================
80// Storage
81// ============================================================================
82
83const logs: LogEntry[] = []
84const errors: Map<string, ErrorEntry> = new Map()
85const metrics: MetricEntry[] = []
86
87// ============================================================================
88// Helpers
89// ============================================================================
90
91let logCounter = 0
92let errorCounter = 0
93
94function generateId(prefix: string, counter: number): string {
95 return `${prefix}-${Date.now()}-${counter}`
96}
97
98function extractEventType(message: string): string | undefined {
99 const match = message.match(/^\[([^\]]+)\]/)
100 return match ? match[1] : undefined
101}
102
103// ============================================================================
104// Log Collector
105// ============================================================================
106
107export const logCollector = {
108 log(
109 level: LogEntry['level'],
110 message: string,
111 service: string,
112 context?: Record<string, any>,
113 traceId?: string
114 ) {
115 const entry: LogEntry = {
116 id: generateId('log', logCounter++),
117 timestamp: new Date(),
118 level,
119 message,
120 service,
121 context,
122 traceId,
123 eventType: extractEventType(message)
124 }
125
126 logs.unshift(entry)
127
128 // Rotate if needed
129 if (logs.length > MAX_LOGS) {
130 logs.splice(MAX_LOGS)
131 }
132
133 // Send to Loki exporter
134 lokiExporter.pushLog(entry)
135
136 // Also log to console for compatibility
137 const contextStr = context ? ` ${JSON.stringify(context)}` : ''
138 const traceStr = traceId ? ` [trace:${traceId}]` : ''
139 console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`)
140 },
141
142 info(message: string, service: string, context?: Record<string, any>, traceId?: string) {
143 this.log('info', message, service, context, traceId)
144 },
145
146 warn(message: string, service: string, context?: Record<string, any>, traceId?: string) {
147 this.log('warn', message, service, context, traceId)
148 },
149
150 error(
151 message: string,
152 service: string,
153 error?: any,
154 context?: Record<string, any>,
155 traceId?: string
156 ) {
157 const ctx = { ...context }
158 if (error instanceof Error) {
159 ctx.error = error.message
160 ctx.stack = error.stack
161 } else if (error) {
162 ctx.error = String(error)
163 }
164 this.log('error', message, service, ctx, traceId)
165
166 // Also track in errors
167 errorTracker.track(message, service, error, context)
168 },
169
170 debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
171 if (process.env.NODE_ENV !== 'production') {
172 this.log('debug', message, service, context, traceId)
173 }
174 },
175
176 getLogs(filter?: LogFilter) {
177 let filtered = [...logs]
178
179 if (filter?.level) {
180 filtered = filtered.filter(log => log.level === filter.level)
181 }
182
183 if (filter?.service) {
184 filtered = filtered.filter(log => log.service === filter.service)
185 }
186
187 if (filter?.eventType) {
188 filtered = filtered.filter(log => log.eventType === filter.eventType)
189 }
190
191 if (filter?.search) {
192 const search = filter.search.toLowerCase()
193 filtered = filtered.filter(log =>
194 log.message.toLowerCase().includes(search) ||
195 (log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false)
196 )
197 }
198
199 const limit = filter?.limit || 100
200 return filtered.slice(0, limit)
201 },
202
203 clear() {
204 logs.length = 0
205 }
206}
207
208// ============================================================================
209// Error Tracker
210// ============================================================================
211
212export const errorTracker = {
213 track(message: string, service: string, error?: any, context?: Record<string, any>) {
214 const key = `${service}:${message}`
215
216 const existing = errors.get(key)
217 if (existing) {
218 existing.count++
219 existing.lastSeen = new Date()
220 if (context) {
221 existing.context = { ...existing.context, ...context }
222 }
223 } else {
224 const entry: ErrorEntry = {
225 id: generateId('error', errorCounter++),
226 timestamp: new Date(),
227 message,
228 service,
229 context,
230 count: 1,
231 lastSeen: new Date()
232 }
233
234 if (error instanceof Error) {
235 entry.stack = error.stack
236 }
237
238 errors.set(key, entry)
239
240 // Send to Loki exporter
241 lokiExporter.pushError(entry)
242
243 // Rotate if needed
244 if (errors.size > MAX_ERRORS) {
245 const oldest = Array.from(errors.keys())[0]
246 if (oldest !== undefined) {
247 errors.delete(oldest)
248 }
249 }
250 }
251 },
252
253 getErrors(filter?: ErrorFilter) {
254 let filtered = Array.from(errors.values())
255
256 if (filter?.service) {
257 filtered = filtered.filter(err => err.service === filter.service)
258 }
259
260 // Sort by last seen (most recent first)
261 filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
262
263 const limit = filter?.limit || 100
264 return filtered.slice(0, limit)
265 },
266
267 clear() {
268 errors.clear()
269 }
270}
271
272// ============================================================================
273// Metrics Collector
274// ============================================================================
275
276export const metricsCollector = {
277 recordRequest(
278 path: string,
279 method: string,
280 statusCode: number,
281 duration: number,
282 service: string
283 ) {
284 const entry: MetricEntry = {
285 timestamp: new Date(),
286 path,
287 method,
288 statusCode,
289 duration,
290 service
291 }
292
293 metrics.unshift(entry)
294
295 // Send to Prometheus/OTLP exporter
296 metricsExporter.recordMetric(entry)
297
298 // Rotate if needed
299 if (metrics.length > MAX_METRICS) {
300 metrics.splice(MAX_METRICS)
301 }
302 },
303
304 getMetrics(filter?: MetricFilter) {
305 let filtered = [...metrics]
306
307 if (filter?.service) {
308 filtered = filtered.filter(m => m.service === filter.service)
309 }
310
311 if (filter?.timeWindow) {
312 const cutoff = Date.now() - filter.timeWindow
313 filtered = filtered.filter(m => m.timestamp.getTime() > cutoff)
314 }
315
316 return filtered
317 },
318
319 getStats(service?: string, timeWindow: number = 3600000): MetricStats {
320 const filtered = this.getMetrics({ service, timeWindow })
321
322 if (filtered.length === 0) {
323 return {
324 totalRequests: 0,
325 avgDuration: 0,
326 p50Duration: 0,
327 p95Duration: 0,
328 p99Duration: 0,
329 errorRate: 0,
330 requestsPerMinute: 0
331 }
332 }
333
334 const durations = filtered.map(m => m.duration).sort((a, b) => a - b)
335 const totalDuration = durations.reduce((sum, d) => sum + d, 0)
336 const errors = filtered.filter(m => m.statusCode >= 400).length
337
338 const p50 = durations[Math.floor(durations.length * 0.5)]
339 const p95 = durations[Math.floor(durations.length * 0.95)]
340 const p99 = durations[Math.floor(durations.length * 0.99)]
341
342 const timeWindowMinutes = timeWindow / 60000
343
344 return {
345 totalRequests: filtered.length,
346 avgDuration: Math.round(totalDuration / filtered.length),
347 p50Duration: Math.round(p50 ?? 0),
348 p95Duration: Math.round(p95 ?? 0),
349 p99Duration: Math.round(p99 ?? 0),
350 errorRate: (errors / filtered.length) * 100,
351 requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
352 }
353 },
354
355 clear() {
356 metrics.length = 0
357 }
358}
359
360// ============================================================================
361// Logger Factory
362// ============================================================================
363
364/**
365 * Create a service-specific logger instance
366 */
367export function createLogger(service: string) {
368 return {
369 info: (message: string, context?: Record<string, any>) =>
370 logCollector.info(message, service, context),
371 warn: (message: string, context?: Record<string, any>) =>
372 logCollector.warn(message, service, context),
373 error: (message: string, error?: any, context?: Record<string, any>) =>
374 logCollector.error(message, service, error, context),
375 debug: (message: string, context?: Record<string, any>) =>
376 logCollector.debug(message, service, context)
377 }
378}