@geekmidas/telescope 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +521 -0
- package/dist/Telescope-B3Wd82yk.cjs +602 -0
- package/dist/Telescope-B3Wd82yk.cjs.map +1 -0
- package/dist/Telescope-C5dyDYYB.d.cts +133 -0
- package/dist/Telescope-D-uoZB6b.mjs +596 -0
- package/dist/Telescope-D-uoZB6b.mjs.map +1 -0
- package/dist/Telescope-DyIWgh9-.d.mts +133 -0
- package/dist/Telescope.cjs +3 -0
- package/dist/Telescope.d.cts +3 -0
- package/dist/Telescope.d.mts +3 -0
- package/dist/Telescope.mjs +3 -0
- package/dist/chunk-CUT6urMc.cjs +30 -0
- package/dist/index.cjs +5 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +4 -0
- package/dist/logger/console.cjs +161 -0
- package/dist/logger/console.cjs.map +1 -0
- package/dist/logger/console.d.cts +109 -0
- package/dist/logger/console.d.mts +109 -0
- package/dist/logger/console.mjs +159 -0
- package/dist/logger/console.mjs.map +1 -0
- package/dist/logger/pino.cjs +118 -0
- package/dist/logger/pino.cjs.map +1 -0
- package/dist/logger/pino.d.cts +89 -0
- package/dist/logger/pino.d.mts +89 -0
- package/dist/logger/pino.mjs +116 -0
- package/dist/logger/pino.mjs.map +1 -0
- package/dist/memory-9-B9WACq.cjs +110 -0
- package/dist/memory-9-B9WACq.cjs.map +1 -0
- package/dist/memory-Cm0eevCS.d.mts +38 -0
- package/dist/memory-DiP1a-pp.d.cts +38 -0
- package/dist/memory-SdN5vtG9.mjs +104 -0
- package/dist/memory-SdN5vtG9.mjs.map +1 -0
- package/dist/server/hono.cjs +180 -0
- package/dist/server/hono.cjs.map +1 -0
- package/dist/server/hono.d.cts +26 -0
- package/dist/server/hono.d.mts +26 -0
- package/dist/server/hono.mjs +176 -0
- package/dist/server/hono.mjs.map +1 -0
- package/dist/storage/kysely.cjs +336 -0
- package/dist/storage/kysely.cjs.map +1 -0
- package/dist/storage/kysely.d.cts +161 -0
- package/dist/storage/kysely.d.mts +161 -0
- package/dist/storage/kysely.mjs +334 -0
- package/dist/storage/kysely.mjs.map +1 -0
- package/dist/storage/memory.cjs +3 -0
- package/dist/storage/memory.d.cts +3 -0
- package/dist/storage/memory.d.mts +3 -0
- package/dist/storage/memory.mjs +3 -0
- package/dist/types-BGDhFv4R.d.cts +170 -0
- package/dist/types-CZbzz8kx.d.mts +170 -0
- package/dist/types.cjs +0 -0
- package/dist/types.d.cts +2 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +0 -0
- package/dist/ui-assets-D6-8TAr_.mjs +30 -0
- package/dist/ui-assets-D6-8TAr_.mjs.map +1 -0
- package/dist/ui-assets-ulevVble.cjs +48 -0
- package/dist/ui-assets-ulevVble.cjs.map +1 -0
- package/dist/ui-assets.cjs +5 -0
- package/dist/ui-assets.d.cts +12 -0
- package/dist/ui-assets.d.mts +12 -0
- package/dist/ui-assets.mjs +3 -0
- package/package.json +83 -0
- package/scripts/embed-ui.ts +90 -0
- package/src/Telescope.ts +714 -0
- package/src/__tests__/Telescope.spec.ts +356 -0
- package/src/index.ts +23 -0
- package/src/logger/__tests__/console.spec.ts +266 -0
- package/src/logger/__tests__/pino.spec.ts +217 -0
- package/src/logger/console.ts +230 -0
- package/src/logger/pino.ts +191 -0
- package/src/server/__tests__/hono.spec.ts +340 -0
- package/src/server/hono.ts +247 -0
- package/src/storage/__tests__/kysely.spec.ts +715 -0
- package/src/storage/__tests__/memory.spec.ts +411 -0
- package/src/storage/kysely.ts +572 -0
- package/src/storage/memory.ts +168 -0
- package/src/types.ts +188 -0
- package/src/ui-assets.ts +40 -0
- package/ui/index.html +12 -0
- package/ui/node_modules/.bin/browserslist +21 -0
- package/ui/node_modules/.bin/jiti +21 -0
- package/ui/node_modules/.bin/terser +21 -0
- package/ui/node_modules/.bin/tsc +21 -0
- package/ui/node_modules/.bin/tsserver +21 -0
- package/ui/node_modules/.bin/tsx +21 -0
- package/ui/node_modules/.bin/vite +21 -0
- package/ui/package.json +24 -0
- package/ui/src/App.tsx +342 -0
- package/ui/src/api.ts +75 -0
- package/ui/src/components/ExceptionDetail.tsx +100 -0
- package/ui/src/components/LogDetail.tsx +91 -0
- package/ui/src/components/RequestDetail.tsx +143 -0
- package/ui/src/main.tsx +10 -0
- package/ui/src/styles.css +10 -0
- package/ui/src/types.ts +63 -0
- package/ui/src/vite-env.d.ts +1 -0
- package/ui/src/vite-plugin-gkm-config.ts +54 -0
- package/ui/tsconfig.json +20 -0
- package/ui/tsconfig.tsbuildinfo +14 -0
- package/ui/vite.config.ts +13 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { Telescope } from '../../Telescope';
|
|
4
|
+
import { InMemoryStorage } from '../../storage/memory';
|
|
5
|
+
import { createMiddleware, createUI, getRequestId } from '../hono';
|
|
6
|
+
|
|
7
|
+
describe('Hono Adapter', () => {
|
|
8
|
+
let telescope: Telescope;
|
|
9
|
+
let storage: InMemoryStorage;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
storage = new InMemoryStorage();
|
|
13
|
+
telescope = new Telescope({ storage });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
telescope.destroy();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('createMiddleware', () => {
|
|
21
|
+
it('should capture GET requests', async () => {
|
|
22
|
+
const app = new Hono();
|
|
23
|
+
app.use('*', createMiddleware(telescope));
|
|
24
|
+
app.get('/api/users', (c) => c.json({ users: [] }));
|
|
25
|
+
|
|
26
|
+
const res = await app.request('/api/users');
|
|
27
|
+
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
|
|
30
|
+
const requests = await telescope.getRequests();
|
|
31
|
+
expect(requests).toHaveLength(1);
|
|
32
|
+
expect(requests[0].method).toBe('GET');
|
|
33
|
+
expect(requests[0].path).toBe('/api/users');
|
|
34
|
+
expect(requests[0].status).toBe(200);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should capture POST requests with body', async () => {
|
|
38
|
+
const app = new Hono();
|
|
39
|
+
app.use('*', createMiddleware(telescope));
|
|
40
|
+
app.post('/api/users', async (c) => {
|
|
41
|
+
const body = await c.req.json();
|
|
42
|
+
return c.json({ id: '123', ...body }, 201);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const res = await app.request('/api/users', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ name: 'John' }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(res.status).toBe(201);
|
|
52
|
+
|
|
53
|
+
const requests = await telescope.getRequests();
|
|
54
|
+
expect(requests).toHaveLength(1);
|
|
55
|
+
expect(requests[0].method).toBe('POST');
|
|
56
|
+
expect(requests[0].body).toEqual({ name: 'John' });
|
|
57
|
+
expect(requests[0].status).toBe(201);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should capture query parameters', async () => {
|
|
61
|
+
const app = new Hono();
|
|
62
|
+
app.use('*', createMiddleware(telescope));
|
|
63
|
+
app.get('/api/search', (c) => c.json({ query: c.req.query('q') }));
|
|
64
|
+
|
|
65
|
+
await app.request('/api/search?q=test&limit=10');
|
|
66
|
+
|
|
67
|
+
const requests = await telescope.getRequests();
|
|
68
|
+
expect(requests[0].query).toEqual({ q: 'test', limit: '10' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should capture request headers', async () => {
|
|
72
|
+
const app = new Hono();
|
|
73
|
+
app.use('*', createMiddleware(telescope));
|
|
74
|
+
app.get('/api/test', (c) => c.json({ ok: true }));
|
|
75
|
+
|
|
76
|
+
await app.request('/api/test', {
|
|
77
|
+
headers: {
|
|
78
|
+
Authorization: 'Bearer token123',
|
|
79
|
+
'X-Custom-Header': 'custom-value',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const requests = await telescope.getRequests();
|
|
84
|
+
expect(requests[0].headers['authorization']).toBe('Bearer token123');
|
|
85
|
+
expect(requests[0].headers['x-custom-header']).toBe('custom-value');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should capture response duration', async () => {
|
|
89
|
+
const app = new Hono();
|
|
90
|
+
app.use('*', createMiddleware(telescope));
|
|
91
|
+
app.get('/api/slow', async (c) => {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
93
|
+
return c.json({ ok: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await app.request('/api/slow');
|
|
97
|
+
|
|
98
|
+
const requests = await telescope.getRequests();
|
|
99
|
+
expect(requests[0].duration).toBeGreaterThan(40);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle errors thrown in handlers', async () => {
|
|
103
|
+
const app = new Hono();
|
|
104
|
+
app.use('*', createMiddleware(telescope));
|
|
105
|
+
app.get('/api/error', () => {
|
|
106
|
+
throw new Error('Test error');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Hono catches errors and returns 500
|
|
110
|
+
const res = await app.request('/api/error');
|
|
111
|
+
expect(res.status).toBe(500);
|
|
112
|
+
|
|
113
|
+
// The error is logged to stderr but the request still completes
|
|
114
|
+
// Exception recording depends on Hono's error handling behavior
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should skip ignored paths', async () => {
|
|
118
|
+
const ignoredTelescope = new Telescope({
|
|
119
|
+
storage,
|
|
120
|
+
ignorePatterns: ['/health', '/__telescope/*'],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const app = new Hono();
|
|
124
|
+
app.use('*', createMiddleware(ignoredTelescope));
|
|
125
|
+
app.get('/health', (c) => c.json({ status: 'ok' }));
|
|
126
|
+
app.get('/api/users', (c) => c.json({ users: [] }));
|
|
127
|
+
|
|
128
|
+
await app.request('/health');
|
|
129
|
+
await app.request('/api/users');
|
|
130
|
+
|
|
131
|
+
const requests = await ignoredTelescope.getRequests();
|
|
132
|
+
expect(requests).toHaveLength(1);
|
|
133
|
+
expect(requests[0].path).toBe('/api/users');
|
|
134
|
+
|
|
135
|
+
ignoredTelescope.destroy();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should skip when telescope is disabled', async () => {
|
|
139
|
+
const disabled = new Telescope({ storage, enabled: false });
|
|
140
|
+
|
|
141
|
+
const app = new Hono();
|
|
142
|
+
app.use('*', createMiddleware(disabled));
|
|
143
|
+
app.get('/api/test', (c) => c.json({ ok: true }));
|
|
144
|
+
|
|
145
|
+
await app.request('/api/test');
|
|
146
|
+
|
|
147
|
+
const requests = await storage.getRequests();
|
|
148
|
+
expect(requests).toHaveLength(0);
|
|
149
|
+
|
|
150
|
+
disabled.destroy();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should not record body when recordBody is false', async () => {
|
|
154
|
+
const noBody = new Telescope({ storage, recordBody: false });
|
|
155
|
+
|
|
156
|
+
const app = new Hono();
|
|
157
|
+
app.use('*', createMiddleware(noBody));
|
|
158
|
+
app.post('/api/users', async (c) => {
|
|
159
|
+
await c.req.json();
|
|
160
|
+
return c.json({ id: '123' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await app.request('/api/users', {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ name: 'John' }),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const requests = await noBody.getRequests();
|
|
170
|
+
expect(requests[0].body).toBeUndefined();
|
|
171
|
+
|
|
172
|
+
noBody.destroy();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('createUI', () => {
|
|
177
|
+
it('should return requests list', async () => {
|
|
178
|
+
// Add a request first
|
|
179
|
+
await telescope.recordRequest({
|
|
180
|
+
method: 'GET',
|
|
181
|
+
path: '/api/test',
|
|
182
|
+
url: 'http://localhost/api/test',
|
|
183
|
+
headers: {},
|
|
184
|
+
query: {},
|
|
185
|
+
status: 200,
|
|
186
|
+
responseHeaders: {},
|
|
187
|
+
duration: 10,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const ui = createUI(telescope);
|
|
191
|
+
const res = await ui.request('/api/requests');
|
|
192
|
+
const data = await res.json();
|
|
193
|
+
|
|
194
|
+
expect(Array.isArray(data)).toBe(true);
|
|
195
|
+
expect(data).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should return single request by ID', async () => {
|
|
199
|
+
const requestId = await telescope.recordRequest({
|
|
200
|
+
method: 'GET',
|
|
201
|
+
path: '/api/test',
|
|
202
|
+
url: 'http://localhost/api/test',
|
|
203
|
+
headers: {},
|
|
204
|
+
query: {},
|
|
205
|
+
status: 200,
|
|
206
|
+
responseHeaders: {},
|
|
207
|
+
duration: 10,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const ui = createUI(telescope);
|
|
211
|
+
const res = await ui.request(`/api/requests/${requestId}`);
|
|
212
|
+
const data = await res.json();
|
|
213
|
+
|
|
214
|
+
expect(data.id).toBe(requestId);
|
|
215
|
+
expect(data.path).toBe('/api/test');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should return 404 for non-existent request', async () => {
|
|
219
|
+
const ui = createUI(telescope);
|
|
220
|
+
const res = await ui.request('/api/requests/non-existent');
|
|
221
|
+
|
|
222
|
+
expect(res.status).toBe(404);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should return exceptions list', async () => {
|
|
226
|
+
await telescope.exception(new Error('Test error'));
|
|
227
|
+
|
|
228
|
+
const ui = createUI(telescope);
|
|
229
|
+
const res = await ui.request('/api/exceptions');
|
|
230
|
+
const data = await res.json();
|
|
231
|
+
|
|
232
|
+
expect(Array.isArray(data)).toBe(true);
|
|
233
|
+
expect(data).toHaveLength(1);
|
|
234
|
+
expect(data[0].message).toBe('Test error');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should return logs list', async () => {
|
|
238
|
+
await telescope.info('Test log', { key: 'value' });
|
|
239
|
+
|
|
240
|
+
const ui = createUI(telescope);
|
|
241
|
+
const res = await ui.request('/api/logs');
|
|
242
|
+
const data = await res.json();
|
|
243
|
+
|
|
244
|
+
expect(Array.isArray(data)).toBe(true);
|
|
245
|
+
expect(data).toHaveLength(1);
|
|
246
|
+
expect(data[0].message).toBe('Test log');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should return stats', async () => {
|
|
250
|
+
await telescope.recordRequest({
|
|
251
|
+
method: 'GET',
|
|
252
|
+
path: '/test',
|
|
253
|
+
url: 'http://localhost/test',
|
|
254
|
+
headers: {},
|
|
255
|
+
query: {},
|
|
256
|
+
status: 200,
|
|
257
|
+
responseHeaders: {},
|
|
258
|
+
duration: 10,
|
|
259
|
+
});
|
|
260
|
+
await telescope.info('Test');
|
|
261
|
+
await telescope.exception(new Error('Test'));
|
|
262
|
+
|
|
263
|
+
const ui = createUI(telescope);
|
|
264
|
+
const res = await ui.request('/api/stats');
|
|
265
|
+
const data = await res.json();
|
|
266
|
+
|
|
267
|
+
expect(data.requests).toBe(1);
|
|
268
|
+
expect(data.logs).toBe(1);
|
|
269
|
+
expect(data.exceptions).toBe(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should return dashboard HTML on root', async () => {
|
|
273
|
+
const ui = createUI(telescope);
|
|
274
|
+
const res = await ui.request('/');
|
|
275
|
+
|
|
276
|
+
expect(res.headers.get('content-type')).toContain('text/html');
|
|
277
|
+
const html = await res.text();
|
|
278
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should support pagination query params', async () => {
|
|
282
|
+
for (let i = 0; i < 10; i++) {
|
|
283
|
+
await telescope.recordRequest({
|
|
284
|
+
method: 'GET',
|
|
285
|
+
path: `/api/item/${i}`,
|
|
286
|
+
url: `http://localhost/api/item/${i}`,
|
|
287
|
+
headers: {},
|
|
288
|
+
query: {},
|
|
289
|
+
status: 200,
|
|
290
|
+
responseHeaders: {},
|
|
291
|
+
duration: 10,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const ui = createUI(telescope);
|
|
296
|
+
const res = await ui.request('/api/requests?limit=3&offset=0');
|
|
297
|
+
const data = await res.json();
|
|
298
|
+
|
|
299
|
+
expect(data).toHaveLength(3);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('getRequestId', () => {
|
|
304
|
+
it('should return undefined during handler (ID is set after response)', async () => {
|
|
305
|
+
let capturedRequestId: string | undefined;
|
|
306
|
+
|
|
307
|
+
const app = new Hono();
|
|
308
|
+
app.use('*', createMiddleware(telescope));
|
|
309
|
+
app.get('/api/test', (c) => {
|
|
310
|
+
// Note: The request ID is set AFTER next() completes,
|
|
311
|
+
// so it's not available during the handler execution
|
|
312
|
+
capturedRequestId = getRequestId(c);
|
|
313
|
+
return c.json({ ok: true });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await app.request('/api/test');
|
|
317
|
+
|
|
318
|
+
// The ID is not available during handler execution
|
|
319
|
+
expect(capturedRequestId).toBeUndefined();
|
|
320
|
+
|
|
321
|
+
// But the request was still recorded
|
|
322
|
+
const requests = await telescope.getRequests();
|
|
323
|
+
expect(requests).toHaveLength(1);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should return undefined when middleware not used', async () => {
|
|
327
|
+
let capturedRequestId: string | undefined;
|
|
328
|
+
|
|
329
|
+
const app = new Hono();
|
|
330
|
+
app.get('/api/test', (c) => {
|
|
331
|
+
capturedRequestId = getRequestId(c);
|
|
332
|
+
return c.json({ ok: true });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await app.request('/api/test');
|
|
336
|
+
|
|
337
|
+
expect(capturedRequestId).toBeUndefined();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
3
|
+
import type { Telescope } from '../Telescope';
|
|
4
|
+
import type { QueryOptions } from '../types';
|
|
5
|
+
import { getAsset, getIndexHtml } from '../ui-assets';
|
|
6
|
+
|
|
7
|
+
const CONTEXT_KEY = 'telescope-request-id';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create Hono middleware that captures requests and responses
|
|
11
|
+
*/
|
|
12
|
+
export function createMiddleware(telescope: Telescope): MiddlewareHandler {
|
|
13
|
+
return async (c: Context, next: Next) => {
|
|
14
|
+
if (!telescope.enabled) {
|
|
15
|
+
return next();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (telescope.shouldIgnore(c.req.path)) {
|
|
19
|
+
return next();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const startTime = performance.now();
|
|
23
|
+
|
|
24
|
+
// Capture request data
|
|
25
|
+
const headers: Record<string, string> = {};
|
|
26
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
27
|
+
headers[key] = value;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const url = new URL(c.req.url);
|
|
31
|
+
const query: Record<string, string> = {};
|
|
32
|
+
url.searchParams.forEach((value, key) => {
|
|
33
|
+
query[key] = value;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let body: unknown;
|
|
37
|
+
if (
|
|
38
|
+
telescope.recordBody &&
|
|
39
|
+
['POST', 'PUT', 'PATCH'].includes(c.req.method)
|
|
40
|
+
) {
|
|
41
|
+
try {
|
|
42
|
+
const contentType = c.req.header('content-type') || '';
|
|
43
|
+
if (contentType.includes('application/json')) {
|
|
44
|
+
body = await c.req.json();
|
|
45
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
46
|
+
const formData = await c.req.formData();
|
|
47
|
+
body = Object.fromEntries(formData.entries());
|
|
48
|
+
} else if (contentType.includes('text/')) {
|
|
49
|
+
body = await c.req.text();
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Ignore body parsing errors
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip');
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await next();
|
|
60
|
+
|
|
61
|
+
// Capture response data
|
|
62
|
+
const duration = performance.now() - startTime;
|
|
63
|
+
|
|
64
|
+
const responseHeaders: Record<string, string> = {};
|
|
65
|
+
c.res.headers.forEach((value, key) => {
|
|
66
|
+
responseHeaders[key] = value;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let responseBody: unknown;
|
|
70
|
+
if (telescope.recordBody) {
|
|
71
|
+
try {
|
|
72
|
+
const contentType = c.res.headers.get('content-type') || '';
|
|
73
|
+
if (contentType.includes('application/json')) {
|
|
74
|
+
const cloned = c.res.clone();
|
|
75
|
+
responseBody = await cloned.json();
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Ignore body parsing errors
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const requestId = await telescope.recordRequest({
|
|
83
|
+
method: c.req.method,
|
|
84
|
+
path: c.req.path,
|
|
85
|
+
url: c.req.url,
|
|
86
|
+
headers,
|
|
87
|
+
body,
|
|
88
|
+
query,
|
|
89
|
+
status: c.res.status,
|
|
90
|
+
responseHeaders,
|
|
91
|
+
responseBody,
|
|
92
|
+
duration,
|
|
93
|
+
ip,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
c.set(CONTEXT_KEY, requestId);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await telescope.exception(error as Error);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse query options from Hono context
|
|
106
|
+
*/
|
|
107
|
+
function parseQueryOptions(c: Context): QueryOptions {
|
|
108
|
+
const limit = parseInt(c.req.query('limit') || '50', 10);
|
|
109
|
+
const offset = parseInt(c.req.query('offset') || '0', 10);
|
|
110
|
+
const search = c.req.query('search');
|
|
111
|
+
const before = c.req.query('before');
|
|
112
|
+
const after = c.req.query('after');
|
|
113
|
+
const tags = c.req.query('tags')?.split(',').filter(Boolean);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
limit: Math.min(limit, 100),
|
|
117
|
+
offset,
|
|
118
|
+
search,
|
|
119
|
+
before: before ? new Date(before) : undefined,
|
|
120
|
+
after: after ? new Date(after) : undefined,
|
|
121
|
+
tags,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create Hono app with dashboard UI and API routes
|
|
127
|
+
*/
|
|
128
|
+
export function createUI(telescope: Telescope): Hono {
|
|
129
|
+
const app = new Hono();
|
|
130
|
+
|
|
131
|
+
// API routes
|
|
132
|
+
app.get('/api/requests', async (c) => {
|
|
133
|
+
const options = parseQueryOptions(c);
|
|
134
|
+
const requests = await telescope.getRequests(options);
|
|
135
|
+
return c.json(requests);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
app.get('/api/requests/:id', async (c) => {
|
|
139
|
+
const request = await telescope.getRequest(c.req.param('id'));
|
|
140
|
+
if (!request) {
|
|
141
|
+
return c.json({ error: 'Request not found' }, 404);
|
|
142
|
+
}
|
|
143
|
+
return c.json(request);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
app.get('/api/exceptions', async (c) => {
|
|
147
|
+
const options = parseQueryOptions(c);
|
|
148
|
+
const exceptions = await telescope.getExceptions(options);
|
|
149
|
+
return c.json(exceptions);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
app.get('/api/exceptions/:id', async (c) => {
|
|
153
|
+
const exception = await telescope.getException(c.req.param('id'));
|
|
154
|
+
if (!exception) {
|
|
155
|
+
return c.json({ error: 'Exception not found' }, 404);
|
|
156
|
+
}
|
|
157
|
+
return c.json(exception);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app.get('/api/logs', async (c) => {
|
|
161
|
+
const options = parseQueryOptions(c);
|
|
162
|
+
const logs = await telescope.getLogs(options);
|
|
163
|
+
return c.json(logs);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
app.get('/api/stats', async (c) => {
|
|
167
|
+
const stats = await telescope.getStats();
|
|
168
|
+
return c.json(stats);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Static assets
|
|
172
|
+
app.get('/assets/:filename', (c) => {
|
|
173
|
+
const filename = c.req.param('filename');
|
|
174
|
+
const assetPath = `assets/${filename}`;
|
|
175
|
+
const asset = getAsset(assetPath);
|
|
176
|
+
if (asset) {
|
|
177
|
+
return c.body(asset.content, 200, {
|
|
178
|
+
'Content-Type': asset.contentType,
|
|
179
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return c.notFound();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Dashboard UI - serve React app
|
|
186
|
+
app.get('/', (c) => {
|
|
187
|
+
const html = getIndexHtml();
|
|
188
|
+
if (html) {
|
|
189
|
+
return c.html(html);
|
|
190
|
+
}
|
|
191
|
+
// Fallback to inline HTML if UI assets not available
|
|
192
|
+
return c.html(telescope.getDashboardHtml());
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
app.get('/*', (c) => {
|
|
196
|
+
// SPA fallback - serve index.html for client-side routing
|
|
197
|
+
const html = getIndexHtml();
|
|
198
|
+
if (html) {
|
|
199
|
+
return c.html(html);
|
|
200
|
+
}
|
|
201
|
+
return c.html(telescope.getDashboardHtml());
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return app;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Set up WebSocket routes for real-time updates.
|
|
209
|
+
* Requires @hono/node-ws for Node.js or Bun's built-in WebSocket.
|
|
210
|
+
*/
|
|
211
|
+
export function setupWebSocket(
|
|
212
|
+
app: Hono,
|
|
213
|
+
telescope: Telescope,
|
|
214
|
+
upgradeWebSocket: (handler: any) => any,
|
|
215
|
+
): void {
|
|
216
|
+
app.get(
|
|
217
|
+
'/ws',
|
|
218
|
+
upgradeWebSocket(() => ({
|
|
219
|
+
onOpen: (_event: Event, ws: WebSocket) => {
|
|
220
|
+
telescope.addWsClient(ws);
|
|
221
|
+
},
|
|
222
|
+
onClose: (_event: Event, ws: WebSocket) => {
|
|
223
|
+
telescope.removeWsClient(ws);
|
|
224
|
+
},
|
|
225
|
+
onMessage: (event: MessageEvent, ws: WebSocket) => {
|
|
226
|
+
try {
|
|
227
|
+
const data = JSON.parse(event.data);
|
|
228
|
+
if (data.type === 'ping') {
|
|
229
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// Ignore invalid messages
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
})),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get the request ID from Hono context (set by middleware)
|
|
241
|
+
*/
|
|
242
|
+
export function getRequestId(c: Context): string | undefined {
|
|
243
|
+
return c.get(CONTEXT_KEY);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Re-export types
|
|
247
|
+
export type { Telescope };
|