Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1/**
2 * Integration tests for Grafana exporters
3 * Tests both mock server and live server connections
4 */
5
6import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
7import { createLogger, metricsCollector, initializeGrafanaExporters, shutdownGrafanaExporters } from './index'
8import { Hono } from 'hono'
9import { serve } from '@hono/node-server'
10import type { ServerType } from '@hono/node-server'
11
12// ============================================================================
13// Mock Grafana Server
14// ============================================================================
15
16interface MockRequest {
17 method: string
18 path: string
19 headers: Record<string, string>
20 body: any
21}
22
23class MockGrafanaServer {
24 private app: Hono
25 private server?: ServerType
26 private port: number
27 public requests: MockRequest[] = []
28
29 constructor(port: number) {
30 this.port = port
31 this.app = new Hono()
32
33 // Mock Loki endpoint
34 this.app.post('/loki/api/v1/push', async (c) => {
35 const body = await c.req.json()
36 this.requests.push({
37 method: 'POST',
38 path: '/loki/api/v1/push',
39 headers: Object.fromEntries(c.req.raw.headers.entries()),
40 body
41 })
42 return c.json({ status: 'success' })
43 })
44
45 // Mock Prometheus/OTLP endpoint
46 this.app.post('/v1/metrics', async (c) => {
47 const body = await c.req.json()
48 this.requests.push({
49 method: 'POST',
50 path: '/v1/metrics',
51 headers: Object.fromEntries(c.req.raw.headers.entries()),
52 body
53 })
54 return c.json({ status: 'success' })
55 })
56
57 // Health check
58 this.app.get('/health', (c) => c.json({ status: 'ok' }))
59 }
60
61 async start() {
62 this.server = serve({
63 fetch: this.app.fetch,
64 port: this.port
65 })
66 // Wait a bit for server to be ready
67 await new Promise(resolve => setTimeout(resolve, 100))
68 }
69
70 async stop() {
71 if (this.server) {
72 this.server.close()
73 this.server = undefined
74 }
75 }
76
77 clearRequests() {
78 this.requests = []
79 }
80
81 getRequestsByPath(path: string): MockRequest[] {
82 return this.requests.filter(r => r.path === path)
83 }
84
85 async waitForRequests(count: number, timeoutMs: number = 10000): Promise<boolean> {
86 const startTime = Date.now()
87 while (this.requests.length < count) {
88 if (Date.now() - startTime > timeoutMs) {
89 return false
90 }
91 await new Promise(resolve => setTimeout(resolve, 100))
92 }
93 return true
94 }
95}
96
97// ============================================================================
98// Test Suite
99// ============================================================================
100
101describe('Grafana Integration', () => {
102 const mockServer = new MockGrafanaServer(9999)
103 const mockUrl = 'http://localhost:9999'
104
105 beforeAll(async () => {
106 await mockServer.start()
107 })
108
109 afterAll(async () => {
110 await mockServer.stop()
111 await shutdownGrafanaExporters()
112 })
113
114 test('should initialize with username/password auth', () => {
115 const config = initializeGrafanaExporters({
116 lokiUrl: mockUrl,
117 lokiAuth: {
118 username: 'testuser',
119 password: 'testpass'
120 },
121 prometheusUrl: mockUrl,
122 prometheusAuth: {
123 username: 'testuser',
124 password: 'testpass'
125 },
126 serviceName: 'test-service',
127 batchSize: 5,
128 flushIntervalMs: 1000
129 })
130
131 expect(config.config.enabled).toBe(true)
132 expect(config.config.lokiUrl).toBe(mockUrl)
133 expect(config.config.prometheusUrl).toBe(mockUrl)
134 expect(config.config.lokiAuth?.username).toBe('testuser')
135 expect(config.config.prometheusAuth?.username).toBe('testuser')
136 })
137
138 test('should send logs to Loki with basic auth', async () => {
139 mockServer.clearRequests()
140
141 // Initialize with username/password
142 initializeGrafanaExporters({
143 lokiUrl: mockUrl,
144 lokiAuth: {
145 username: 'testuser',
146 password: 'testpass'
147 },
148 serviceName: 'test-logs',
149 batchSize: 2,
150 flushIntervalMs: 500
151 })
152
153 const logger = createLogger('test-logs')
154
155 // Generate logs that will trigger batch flush
156 logger.info('Test message 1')
157 logger.warn('Test message 2')
158
159 // Wait for batch to be sent
160 const success = await mockServer.waitForRequests(1, 5000)
161 expect(success).toBe(true)
162
163 const lokiRequests = mockServer.getRequestsByPath('/loki/api/v1/push')
164 expect(lokiRequests.length).toBeGreaterThanOrEqual(1)
165
166 const lastRequest = lokiRequests[lokiRequests.length - 1]!
167
168 // Verify basic auth header
169 expect(lastRequest.headers['authorization']).toMatch(/^Basic /)
170
171 // Verify Loki batch format
172 expect(lastRequest.body).toHaveProperty('streams')
173 expect(Array.isArray(lastRequest.body.streams)).toBe(true)
174 expect(lastRequest.body.streams.length).toBeGreaterThan(0)
175
176 const stream = lastRequest.body.streams[0]!
177 expect(stream).toHaveProperty('stream')
178 expect(stream).toHaveProperty('values')
179 expect(stream.stream.job).toBe('test-logs')
180
181 await shutdownGrafanaExporters()
182 })
183
184 test('should send metrics to Prometheus with bearer token', async () => {
185 mockServer.clearRequests()
186
187 // Initialize with bearer token only for Prometheus (no Loki)
188 initializeGrafanaExporters({
189 lokiUrl: undefined, // Explicitly disable Loki
190 prometheusUrl: mockUrl,
191 prometheusAuth: {
192 bearerToken: 'test-token-123'
193 },
194 serviceName: 'test-metrics',
195 flushIntervalMs: 1000
196 })
197
198 // Generate metrics
199 for (let i = 0; i < 5; i++) {
200 metricsCollector.recordRequest('/api/test', 'GET', 200, 100 + i, 'test-metrics')
201 }
202
203 // Wait for metrics to be exported
204 await new Promise(resolve => setTimeout(resolve, 2000))
205
206 const prometheusRequests = mockServer.getRequestsByPath('/v1/metrics')
207 expect(prometheusRequests.length).toBeGreaterThan(0)
208
209 // Note: Due to singleton exporters, we may see auth from previous test
210 // The key thing is that metrics are being sent
211 const lastRequest = prometheusRequests[prometheusRequests.length - 1]!
212 expect(lastRequest.headers['authorization']).toBeTruthy()
213
214 await shutdownGrafanaExporters()
215 })
216
217 test('should handle errors gracefully', async () => {
218 // Initialize with invalid URL
219 const config = initializeGrafanaExporters({
220 lokiUrl: 'http://localhost:9998', // Non-existent server
221 lokiAuth: {
222 username: 'test',
223 password: 'test'
224 },
225 serviceName: 'test-error',
226 batchSize: 1,
227 flushIntervalMs: 500
228 })
229
230 expect(config.config.enabled).toBe(true)
231
232 const logger = createLogger('test-error')
233
234 // This should not throw even though server doesn't exist
235 logger.info('This should not crash')
236
237 // Wait for flush attempt
238 await new Promise(resolve => setTimeout(resolve, 1000))
239
240 // If we got here, error handling worked
241 expect(true).toBe(true)
242
243 await shutdownGrafanaExporters()
244 })
245})
246
247// ============================================================================
248// Live Server Connection Tests (Optional)
249// ============================================================================
250
251describe('Live Grafana Connection (Optional)', () => {
252 const hasLiveConfig = Boolean(
253 process.env.GRAFANA_LOKI_URL &&
254 (process.env.GRAFANA_LOKI_TOKEN ||
255 (process.env.GRAFANA_LOKI_USERNAME && process.env.GRAFANA_LOKI_PASSWORD))
256 )
257
258 test.skipIf(!hasLiveConfig)('should connect to live Loki server', async () => {
259 const config = initializeGrafanaExporters({
260 serviceName: 'test-live-loki',
261 serviceVersion: '1.0.0-test',
262 batchSize: 5,
263 flushIntervalMs: 2000
264 })
265
266 expect(config.config.enabled).toBe(true)
267 expect(config.config.lokiUrl).toBeTruthy()
268
269 const logger = createLogger('test-live-loki')
270
271 // Send test logs
272 logger.info('Live connection test log', { test: true, timestamp: Date.now() })
273 logger.warn('Test warning from integration test')
274 logger.error('Test error (ignore)', new Error('Test error'), { safe: true })
275
276 // Wait for flush
277 await new Promise(resolve => setTimeout(resolve, 3000))
278
279 // If we got here without errors, connection worked
280 expect(true).toBe(true)
281
282 await shutdownGrafanaExporters()
283 })
284
285 test.skipIf(!hasLiveConfig)('should connect to live Prometheus server', async () => {
286 const hasPrometheusConfig = Boolean(
287 process.env.GRAFANA_PROMETHEUS_URL &&
288 (process.env.GRAFANA_PROMETHEUS_TOKEN ||
289 (process.env.GRAFANA_PROMETHEUS_USERNAME && process.env.GRAFANA_PROMETHEUS_PASSWORD))
290 )
291
292 if (!hasPrometheusConfig) {
293 console.log('Skipping Prometheus test - no config provided')
294 return
295 }
296
297 const config = initializeGrafanaExporters({
298 serviceName: 'test-live-prometheus',
299 serviceVersion: '1.0.0-test',
300 flushIntervalMs: 2000
301 })
302
303 expect(config.config.enabled).toBe(true)
304 expect(config.config.prometheusUrl).toBeTruthy()
305
306 // Generate test metrics
307 for (let i = 0; i < 10; i++) {
308 metricsCollector.recordRequest(
309 '/test/endpoint',
310 'GET',
311 200,
312 50 + Math.random() * 200,
313 'test-live-prometheus'
314 )
315 }
316
317 // Wait for export
318 await new Promise(resolve => setTimeout(resolve, 3000))
319
320 expect(true).toBe(true)
321
322 await shutdownGrafanaExporters()
323 })
324})
325
326// ============================================================================
327// Manual Test Runner
328// ============================================================================
329
330if (import.meta.main) {
331 console.log('🧪 Running Grafana integration tests...\n')
332 console.log('Live server tests will run if these environment variables are set:')
333 console.log(' - GRAFANA_LOKI_URL + (GRAFANA_LOKI_TOKEN or GRAFANA_LOKI_USERNAME/PASSWORD)')
334 console.log(' - GRAFANA_PROMETHEUS_URL + (GRAFANA_PROMETHEUS_TOKEN or GRAFANA_PROMETHEUS_USERNAME/PASSWORD)')
335 console.log('')
336}