@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,715 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ExceptionEntry, LogEntry, RequestEntry } from '../../types';
|
|
3
|
+
import {
|
|
4
|
+
KyselyStorage,
|
|
5
|
+
type TelescopeExceptionTable,
|
|
6
|
+
type TelescopeLogTable,
|
|
7
|
+
type TelescopeRequestTable,
|
|
8
|
+
getTelescopeMigration,
|
|
9
|
+
} from '../kysely';
|
|
10
|
+
|
|
11
|
+
// Helper to create mock query builder
|
|
12
|
+
function createMockQueryBuilder() {
|
|
13
|
+
const builder: Record<string, any> = {};
|
|
14
|
+
|
|
15
|
+
// Chain methods return the builder itself
|
|
16
|
+
builder.selectFrom = vi.fn().mockReturnValue(builder);
|
|
17
|
+
builder.insertInto = vi.fn().mockReturnValue(builder);
|
|
18
|
+
builder.deleteFrom = vi.fn().mockReturnValue(builder);
|
|
19
|
+
builder.selectAll = vi.fn().mockReturnValue(builder);
|
|
20
|
+
builder.select = vi.fn().mockReturnValue(builder);
|
|
21
|
+
builder.values = vi.fn().mockReturnValue(builder);
|
|
22
|
+
builder.where = vi.fn().mockReturnValue(builder);
|
|
23
|
+
builder.orderBy = vi.fn().mockReturnValue(builder);
|
|
24
|
+
builder.limit = vi.fn().mockReturnValue(builder);
|
|
25
|
+
builder.offset = vi.fn().mockReturnValue(builder);
|
|
26
|
+
builder.or = vi.fn().mockReturnValue(builder);
|
|
27
|
+
|
|
28
|
+
// Terminal methods
|
|
29
|
+
builder.execute = vi.fn().mockResolvedValue([]);
|
|
30
|
+
builder.executeTakeFirst = vi.fn().mockResolvedValue(undefined);
|
|
31
|
+
|
|
32
|
+
return builder;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('KyselyStorage', () => {
|
|
36
|
+
let mockDb: ReturnType<typeof createMockQueryBuilder>;
|
|
37
|
+
let storage: KyselyStorage<any>;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mockDb = createMockQueryBuilder();
|
|
41
|
+
storage = new KyselyStorage({ db: mockDb as any });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ============================================
|
|
45
|
+
// Requests
|
|
46
|
+
// ============================================
|
|
47
|
+
|
|
48
|
+
describe('saveRequest', () => {
|
|
49
|
+
it('should insert a request into the database', async () => {
|
|
50
|
+
const entry: RequestEntry = {
|
|
51
|
+
id: 'req-123',
|
|
52
|
+
method: 'GET',
|
|
53
|
+
path: '/api/users',
|
|
54
|
+
url: 'http://localhost/api/users',
|
|
55
|
+
headers: { 'content-type': 'application/json' },
|
|
56
|
+
query: { page: '1' },
|
|
57
|
+
status: 200,
|
|
58
|
+
responseHeaders: { 'content-type': 'application/json' },
|
|
59
|
+
responseBody: { users: [] },
|
|
60
|
+
duration: 50,
|
|
61
|
+
timestamp: new Date('2024-01-01T00:00:00Z'),
|
|
62
|
+
ip: '127.0.0.1',
|
|
63
|
+
userId: 'user-1',
|
|
64
|
+
tags: ['api'],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await storage.saveRequest(entry);
|
|
68
|
+
|
|
69
|
+
expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_requests');
|
|
70
|
+
expect(mockDb.values).toHaveBeenCalledWith({
|
|
71
|
+
id: 'req-123',
|
|
72
|
+
method: 'GET',
|
|
73
|
+
path: '/api/users',
|
|
74
|
+
url: 'http://localhost/api/users',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
query: { page: '1' },
|
|
77
|
+
body: null,
|
|
78
|
+
status: 200,
|
|
79
|
+
response_headers: { 'content-type': 'application/json' },
|
|
80
|
+
response_body: { users: [] },
|
|
81
|
+
duration: 50,
|
|
82
|
+
timestamp: entry.timestamp,
|
|
83
|
+
ip: '127.0.0.1',
|
|
84
|
+
user_id: 'user-1',
|
|
85
|
+
tags: ['api'],
|
|
86
|
+
});
|
|
87
|
+
expect(mockDb.execute).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should handle optional fields as null', async () => {
|
|
91
|
+
const entry: RequestEntry = {
|
|
92
|
+
id: 'req-456',
|
|
93
|
+
method: 'POST',
|
|
94
|
+
path: '/api/test',
|
|
95
|
+
url: 'http://localhost/api/test',
|
|
96
|
+
headers: {},
|
|
97
|
+
status: 201,
|
|
98
|
+
responseHeaders: {},
|
|
99
|
+
duration: 10,
|
|
100
|
+
timestamp: new Date(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await storage.saveRequest(entry);
|
|
104
|
+
|
|
105
|
+
expect(mockDb.values).toHaveBeenCalledWith(
|
|
106
|
+
expect.objectContaining({
|
|
107
|
+
body: null,
|
|
108
|
+
query: null,
|
|
109
|
+
response_body: null,
|
|
110
|
+
ip: null,
|
|
111
|
+
user_id: null,
|
|
112
|
+
tags: null,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('saveRequests', () => {
|
|
119
|
+
it('should batch insert multiple requests', async () => {
|
|
120
|
+
const entries: RequestEntry[] = [
|
|
121
|
+
{
|
|
122
|
+
id: 'req-1',
|
|
123
|
+
method: 'GET',
|
|
124
|
+
path: '/1',
|
|
125
|
+
url: 'http://localhost/1',
|
|
126
|
+
headers: {},
|
|
127
|
+
status: 200,
|
|
128
|
+
responseHeaders: {},
|
|
129
|
+
duration: 10,
|
|
130
|
+
timestamp: new Date(),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'req-2',
|
|
134
|
+
method: 'GET',
|
|
135
|
+
path: '/2',
|
|
136
|
+
url: 'http://localhost/2',
|
|
137
|
+
headers: {},
|
|
138
|
+
status: 200,
|
|
139
|
+
responseHeaders: {},
|
|
140
|
+
duration: 20,
|
|
141
|
+
timestamp: new Date(),
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
await storage.saveRequests(entries);
|
|
146
|
+
|
|
147
|
+
expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_requests');
|
|
148
|
+
expect(mockDb.values).toHaveBeenCalledWith(
|
|
149
|
+
expect.arrayContaining([
|
|
150
|
+
expect.objectContaining({ id: 'req-1' }),
|
|
151
|
+
expect.objectContaining({ id: 'req-2' }),
|
|
152
|
+
]),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should not insert when entries array is empty', async () => {
|
|
157
|
+
await storage.saveRequests([]);
|
|
158
|
+
|
|
159
|
+
expect(mockDb.insertInto).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('getRequests', () => {
|
|
164
|
+
it('should query requests with default limit', async () => {
|
|
165
|
+
const mockRows: TelescopeRequestTable[] = [
|
|
166
|
+
{
|
|
167
|
+
id: 'req-1',
|
|
168
|
+
method: 'GET',
|
|
169
|
+
path: '/api/test',
|
|
170
|
+
url: 'http://localhost/api/test',
|
|
171
|
+
headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: null,
|
|
173
|
+
query: null,
|
|
174
|
+
status: 200,
|
|
175
|
+
response_headers: {},
|
|
176
|
+
response_body: null,
|
|
177
|
+
duration: 10,
|
|
178
|
+
timestamp: new Date('2024-01-01'),
|
|
179
|
+
ip: null,
|
|
180
|
+
user_id: null,
|
|
181
|
+
tags: null,
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
mockDb.execute.mockResolvedValueOnce(mockRows);
|
|
186
|
+
|
|
187
|
+
const result = await storage.getRequests();
|
|
188
|
+
|
|
189
|
+
expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_requests');
|
|
190
|
+
expect(mockDb.selectAll).toHaveBeenCalled();
|
|
191
|
+
expect(mockDb.orderBy).toHaveBeenCalledWith('timestamp', 'desc');
|
|
192
|
+
expect(mockDb.limit).toHaveBeenCalledWith(50);
|
|
193
|
+
|
|
194
|
+
expect(result).toHaveLength(1);
|
|
195
|
+
expect(result[0].id).toBe('req-1');
|
|
196
|
+
expect(result[0].headers).toEqual({ 'content-type': 'application/json' });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should apply query options', async () => {
|
|
200
|
+
mockDb.execute.mockResolvedValueOnce([]);
|
|
201
|
+
|
|
202
|
+
await storage.getRequests({
|
|
203
|
+
limit: 10,
|
|
204
|
+
offset: 5,
|
|
205
|
+
after: new Date('2024-01-01'),
|
|
206
|
+
before: new Date('2024-12-31'),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(mockDb.where).toHaveBeenCalledWith(
|
|
210
|
+
'timestamp',
|
|
211
|
+
'>=',
|
|
212
|
+
expect.any(Date),
|
|
213
|
+
);
|
|
214
|
+
expect(mockDb.where).toHaveBeenCalledWith(
|
|
215
|
+
'timestamp',
|
|
216
|
+
'<=',
|
|
217
|
+
expect.any(Date),
|
|
218
|
+
);
|
|
219
|
+
expect(mockDb.limit).toHaveBeenCalledWith(10);
|
|
220
|
+
expect(mockDb.offset).toHaveBeenCalledWith(5);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('getRequest', () => {
|
|
225
|
+
it('should return a single request by ID', async () => {
|
|
226
|
+
const mockRow: TelescopeRequestTable = {
|
|
227
|
+
id: 'req-123',
|
|
228
|
+
method: 'GET',
|
|
229
|
+
path: '/api/test',
|
|
230
|
+
url: 'http://localhost/api/test',
|
|
231
|
+
headers: {},
|
|
232
|
+
body: null,
|
|
233
|
+
query: null,
|
|
234
|
+
status: 200,
|
|
235
|
+
response_headers: {},
|
|
236
|
+
response_body: null,
|
|
237
|
+
duration: 10,
|
|
238
|
+
timestamp: new Date('2024-01-01'),
|
|
239
|
+
ip: '127.0.0.1',
|
|
240
|
+
user_id: 'user-1',
|
|
241
|
+
tags: ['test'],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
|
|
245
|
+
|
|
246
|
+
const result = await storage.getRequest('req-123');
|
|
247
|
+
|
|
248
|
+
expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_requests');
|
|
249
|
+
expect(mockDb.where).toHaveBeenCalledWith('id', '=', 'req-123');
|
|
250
|
+
expect(result).not.toBeNull();
|
|
251
|
+
expect(result?.id).toBe('req-123');
|
|
252
|
+
expect(result?.ip).toBe('127.0.0.1');
|
|
253
|
+
expect(result?.userId).toBe('user-1');
|
|
254
|
+
expect(result?.tags).toEqual(['test']);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should return null when request not found', async () => {
|
|
258
|
+
mockDb.executeTakeFirst.mockResolvedValueOnce(undefined);
|
|
259
|
+
|
|
260
|
+
const result = await storage.getRequest('non-existent');
|
|
261
|
+
|
|
262
|
+
expect(result).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ============================================
|
|
267
|
+
// Exceptions
|
|
268
|
+
// ============================================
|
|
269
|
+
|
|
270
|
+
describe('saveException', () => {
|
|
271
|
+
it('should insert an exception into the database', async () => {
|
|
272
|
+
const entry: ExceptionEntry = {
|
|
273
|
+
id: 'exc-123',
|
|
274
|
+
name: 'Error',
|
|
275
|
+
message: 'Something went wrong',
|
|
276
|
+
stack: [{ file: 'test.ts', line: 10, column: 5, function: 'test' }],
|
|
277
|
+
source: { file: 'test.ts', line: 10, code: 'throw new Error()' },
|
|
278
|
+
requestId: 'req-1',
|
|
279
|
+
timestamp: new Date('2024-01-01'),
|
|
280
|
+
handled: false,
|
|
281
|
+
tags: ['critical'],
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
await storage.saveException(entry);
|
|
285
|
+
|
|
286
|
+
expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_exceptions');
|
|
287
|
+
expect(mockDb.values).toHaveBeenCalledWith({
|
|
288
|
+
id: 'exc-123',
|
|
289
|
+
name: 'Error',
|
|
290
|
+
message: 'Something went wrong',
|
|
291
|
+
stack: entry.stack,
|
|
292
|
+
source: entry.source,
|
|
293
|
+
request_id: 'req-1',
|
|
294
|
+
timestamp: entry.timestamp,
|
|
295
|
+
handled: false,
|
|
296
|
+
tags: ['critical'],
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('saveExceptions', () => {
|
|
302
|
+
it('should batch insert multiple exceptions', async () => {
|
|
303
|
+
const entries: ExceptionEntry[] = [
|
|
304
|
+
{
|
|
305
|
+
id: 'exc-1',
|
|
306
|
+
name: 'Error',
|
|
307
|
+
message: 'First error',
|
|
308
|
+
stack: [],
|
|
309
|
+
timestamp: new Date(),
|
|
310
|
+
handled: true,
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'exc-2',
|
|
314
|
+
name: 'TypeError',
|
|
315
|
+
message: 'Second error',
|
|
316
|
+
stack: [],
|
|
317
|
+
timestamp: new Date(),
|
|
318
|
+
handled: false,
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
await storage.saveExceptions(entries);
|
|
323
|
+
|
|
324
|
+
expect(mockDb.values).toHaveBeenCalledWith(
|
|
325
|
+
expect.arrayContaining([
|
|
326
|
+
expect.objectContaining({ id: 'exc-1', name: 'Error' }),
|
|
327
|
+
expect.objectContaining({ id: 'exc-2', name: 'TypeError' }),
|
|
328
|
+
]),
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should not insert when entries array is empty', async () => {
|
|
333
|
+
await storage.saveExceptions([]);
|
|
334
|
+
|
|
335
|
+
expect(mockDb.insertInto).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('getExceptions', () => {
|
|
340
|
+
it('should query exceptions with default limit', async () => {
|
|
341
|
+
const mockRows: TelescopeExceptionTable[] = [
|
|
342
|
+
{
|
|
343
|
+
id: 'exc-1',
|
|
344
|
+
name: 'Error',
|
|
345
|
+
message: 'Test error',
|
|
346
|
+
stack: [{ file: 'test.ts', line: 1 }],
|
|
347
|
+
source: null,
|
|
348
|
+
request_id: 'req-1',
|
|
349
|
+
timestamp: new Date(),
|
|
350
|
+
handled: false,
|
|
351
|
+
tags: null,
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
mockDb.execute.mockResolvedValueOnce(mockRows);
|
|
356
|
+
|
|
357
|
+
const result = await storage.getExceptions();
|
|
358
|
+
|
|
359
|
+
expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_exceptions');
|
|
360
|
+
expect(result).toHaveLength(1);
|
|
361
|
+
expect(result[0].name).toBe('Error');
|
|
362
|
+
expect(result[0].requestId).toBe('req-1');
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('getException', () => {
|
|
367
|
+
it('should return a single exception by ID', async () => {
|
|
368
|
+
const mockRow: TelescopeExceptionTable = {
|
|
369
|
+
id: 'exc-123',
|
|
370
|
+
name: 'Error',
|
|
371
|
+
message: 'Test',
|
|
372
|
+
stack: [],
|
|
373
|
+
source: null,
|
|
374
|
+
request_id: null,
|
|
375
|
+
timestamp: new Date(),
|
|
376
|
+
handled: true,
|
|
377
|
+
tags: null,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
|
|
381
|
+
|
|
382
|
+
const result = await storage.getException('exc-123');
|
|
383
|
+
|
|
384
|
+
expect(result).not.toBeNull();
|
|
385
|
+
expect(result?.handled).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should return null when exception not found', async () => {
|
|
389
|
+
mockDb.executeTakeFirst.mockResolvedValueOnce(undefined);
|
|
390
|
+
|
|
391
|
+
const result = await storage.getException('non-existent');
|
|
392
|
+
|
|
393
|
+
expect(result).toBeNull();
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ============================================
|
|
398
|
+
// Logs
|
|
399
|
+
// ============================================
|
|
400
|
+
|
|
401
|
+
describe('saveLog', () => {
|
|
402
|
+
it('should insert a log into the database', async () => {
|
|
403
|
+
const entry: LogEntry = {
|
|
404
|
+
id: 'log-123',
|
|
405
|
+
level: 'info',
|
|
406
|
+
message: 'User logged in',
|
|
407
|
+
context: { userId: '123' },
|
|
408
|
+
requestId: 'req-1',
|
|
409
|
+
timestamp: new Date('2024-01-01'),
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
await storage.saveLog(entry);
|
|
413
|
+
|
|
414
|
+
expect(mockDb.insertInto).toHaveBeenCalledWith('telescope_logs');
|
|
415
|
+
expect(mockDb.values).toHaveBeenCalledWith({
|
|
416
|
+
id: 'log-123',
|
|
417
|
+
level: 'info',
|
|
418
|
+
message: 'User logged in',
|
|
419
|
+
context: { userId: '123' },
|
|
420
|
+
request_id: 'req-1',
|
|
421
|
+
timestamp: entry.timestamp,
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('saveLogs', () => {
|
|
427
|
+
it('should batch insert multiple logs', async () => {
|
|
428
|
+
const entries: LogEntry[] = [
|
|
429
|
+
{
|
|
430
|
+
id: 'log-1',
|
|
431
|
+
level: 'debug',
|
|
432
|
+
message: 'Debug message',
|
|
433
|
+
timestamp: new Date(),
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
id: 'log-2',
|
|
437
|
+
level: 'error',
|
|
438
|
+
message: 'Error message',
|
|
439
|
+
timestamp: new Date(),
|
|
440
|
+
},
|
|
441
|
+
];
|
|
442
|
+
|
|
443
|
+
await storage.saveLogs(entries);
|
|
444
|
+
|
|
445
|
+
expect(mockDb.values).toHaveBeenCalledWith(
|
|
446
|
+
expect.arrayContaining([
|
|
447
|
+
expect.objectContaining({ id: 'log-1', level: 'debug' }),
|
|
448
|
+
expect.objectContaining({ id: 'log-2', level: 'error' }),
|
|
449
|
+
]),
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should not insert when entries array is empty', async () => {
|
|
454
|
+
await storage.saveLogs([]);
|
|
455
|
+
|
|
456
|
+
expect(mockDb.insertInto).not.toHaveBeenCalled();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe('getLogs', () => {
|
|
461
|
+
it('should query logs with default limit', async () => {
|
|
462
|
+
const mockRows: TelescopeLogTable[] = [
|
|
463
|
+
{
|
|
464
|
+
id: 'log-1',
|
|
465
|
+
level: 'info',
|
|
466
|
+
message: 'Test message',
|
|
467
|
+
context: { key: 'value' },
|
|
468
|
+
request_id: null,
|
|
469
|
+
timestamp: new Date(),
|
|
470
|
+
},
|
|
471
|
+
];
|
|
472
|
+
|
|
473
|
+
mockDb.execute.mockResolvedValueOnce(mockRows);
|
|
474
|
+
|
|
475
|
+
const result = await storage.getLogs();
|
|
476
|
+
|
|
477
|
+
expect(mockDb.selectFrom).toHaveBeenCalledWith('telescope_logs');
|
|
478
|
+
expect(result).toHaveLength(1);
|
|
479
|
+
expect(result[0].level).toBe('info');
|
|
480
|
+
expect(result[0].context).toEqual({ key: 'value' });
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ============================================
|
|
485
|
+
// Prune
|
|
486
|
+
// ============================================
|
|
487
|
+
|
|
488
|
+
describe('prune', () => {
|
|
489
|
+
it('should delete old entries from all tables', async () => {
|
|
490
|
+
const olderThan = new Date('2024-01-01');
|
|
491
|
+
|
|
492
|
+
mockDb.executeTakeFirst
|
|
493
|
+
.mockResolvedValueOnce({ numDeletedRows: BigInt(5) })
|
|
494
|
+
.mockResolvedValueOnce({ numDeletedRows: BigInt(2) })
|
|
495
|
+
.mockResolvedValueOnce({ numDeletedRows: BigInt(10) });
|
|
496
|
+
|
|
497
|
+
const deleted = await storage.prune(olderThan);
|
|
498
|
+
|
|
499
|
+
expect(mockDb.deleteFrom).toHaveBeenCalledWith('telescope_requests');
|
|
500
|
+
expect(mockDb.deleteFrom).toHaveBeenCalledWith('telescope_exceptions');
|
|
501
|
+
expect(mockDb.deleteFrom).toHaveBeenCalledWith('telescope_logs');
|
|
502
|
+
expect(mockDb.where).toHaveBeenCalledWith('timestamp', '<', olderThan);
|
|
503
|
+
expect(deleted).toBe(17);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should return 0 when no entries deleted', async () => {
|
|
507
|
+
mockDb.executeTakeFirst
|
|
508
|
+
.mockResolvedValueOnce({ numDeletedRows: BigInt(0) })
|
|
509
|
+
.mockResolvedValueOnce({ numDeletedRows: BigInt(0) })
|
|
510
|
+
.mockResolvedValueOnce({ numDeletedRows: BigInt(0) });
|
|
511
|
+
|
|
512
|
+
const deleted = await storage.prune(new Date());
|
|
513
|
+
|
|
514
|
+
expect(deleted).toBe(0);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ============================================
|
|
519
|
+
// Stats
|
|
520
|
+
// ============================================
|
|
521
|
+
|
|
522
|
+
describe('getStats', () => {
|
|
523
|
+
it('should aggregate statistics from all tables', async () => {
|
|
524
|
+
const now = new Date();
|
|
525
|
+
const earlier = new Date(now.getTime() - 3600000);
|
|
526
|
+
|
|
527
|
+
mockDb.executeTakeFirst
|
|
528
|
+
.mockResolvedValueOnce({
|
|
529
|
+
count: BigInt(100),
|
|
530
|
+
oldest: earlier,
|
|
531
|
+
newest: now,
|
|
532
|
+
})
|
|
533
|
+
.mockResolvedValueOnce({
|
|
534
|
+
count: BigInt(5),
|
|
535
|
+
oldest: earlier,
|
|
536
|
+
newest: now,
|
|
537
|
+
})
|
|
538
|
+
.mockResolvedValueOnce({
|
|
539
|
+
count: BigInt(50),
|
|
540
|
+
oldest: earlier,
|
|
541
|
+
newest: now,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const stats = await storage.getStats();
|
|
545
|
+
|
|
546
|
+
expect(stats.requests).toBe(100);
|
|
547
|
+
expect(stats.exceptions).toBe(5);
|
|
548
|
+
expect(stats.logs).toBe(50);
|
|
549
|
+
expect(stats.oldestEntry).toEqual(earlier);
|
|
550
|
+
expect(stats.newestEntry).toEqual(now);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('should handle empty tables', async () => {
|
|
554
|
+
mockDb.executeTakeFirst
|
|
555
|
+
.mockResolvedValueOnce({ count: BigInt(0), oldest: null, newest: null })
|
|
556
|
+
.mockResolvedValueOnce({ count: BigInt(0), oldest: null, newest: null })
|
|
557
|
+
.mockResolvedValueOnce({
|
|
558
|
+
count: BigInt(0),
|
|
559
|
+
oldest: null,
|
|
560
|
+
newest: null,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const stats = await storage.getStats();
|
|
564
|
+
|
|
565
|
+
expect(stats.requests).toBe(0);
|
|
566
|
+
expect(stats.exceptions).toBe(0);
|
|
567
|
+
expect(stats.logs).toBe(0);
|
|
568
|
+
expect(stats.oldestEntry).toBeUndefined();
|
|
569
|
+
expect(stats.newestEntry).toBeUndefined();
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ============================================
|
|
574
|
+
// Table Prefix
|
|
575
|
+
// ============================================
|
|
576
|
+
|
|
577
|
+
describe('table prefix', () => {
|
|
578
|
+
it('should use custom table prefix', async () => {
|
|
579
|
+
const customStorage = new KyselyStorage({
|
|
580
|
+
db: mockDb as any,
|
|
581
|
+
tablePrefix: 'custom',
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
await customStorage.saveRequest({
|
|
585
|
+
id: 'req-1',
|
|
586
|
+
method: 'GET',
|
|
587
|
+
path: '/test',
|
|
588
|
+
url: 'http://localhost/test',
|
|
589
|
+
headers: {},
|
|
590
|
+
status: 200,
|
|
591
|
+
responseHeaders: {},
|
|
592
|
+
duration: 10,
|
|
593
|
+
timestamp: new Date(),
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
expect(mockDb.insertInto).toHaveBeenCalledWith('custom_requests');
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ============================================
|
|
601
|
+
// JSON Parsing
|
|
602
|
+
// ============================================
|
|
603
|
+
|
|
604
|
+
describe('JSON parsing', () => {
|
|
605
|
+
it('should parse JSON strings from database', async () => {
|
|
606
|
+
const mockRow: TelescopeRequestTable = {
|
|
607
|
+
id: 'req-1',
|
|
608
|
+
method: 'GET',
|
|
609
|
+
path: '/test',
|
|
610
|
+
url: 'http://localhost/test',
|
|
611
|
+
headers: '{"content-type":"application/json"}' as unknown as unknown,
|
|
612
|
+
body: null,
|
|
613
|
+
query: '{"page":"1"}' as unknown as unknown,
|
|
614
|
+
status: 200,
|
|
615
|
+
response_headers: '{}' as unknown as unknown,
|
|
616
|
+
response_body: null,
|
|
617
|
+
duration: 10,
|
|
618
|
+
timestamp: new Date(),
|
|
619
|
+
ip: null,
|
|
620
|
+
user_id: null,
|
|
621
|
+
tags: '["api"]' as unknown as unknown,
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
|
|
625
|
+
|
|
626
|
+
const result = await storage.getRequest('req-1');
|
|
627
|
+
|
|
628
|
+
expect(result?.headers).toEqual({ 'content-type': 'application/json' });
|
|
629
|
+
expect(result?.query).toEqual({ page: '1' });
|
|
630
|
+
expect(result?.tags).toEqual(['api']);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('should handle already-parsed JSON objects from JSONB columns', async () => {
|
|
634
|
+
const mockRow: TelescopeRequestTable = {
|
|
635
|
+
id: 'req-1',
|
|
636
|
+
method: 'GET',
|
|
637
|
+
path: '/test',
|
|
638
|
+
url: 'http://localhost/test',
|
|
639
|
+
headers: { 'content-type': 'application/json' },
|
|
640
|
+
body: null,
|
|
641
|
+
query: { page: '1' },
|
|
642
|
+
status: 200,
|
|
643
|
+
response_headers: {},
|
|
644
|
+
response_body: null,
|
|
645
|
+
duration: 10,
|
|
646
|
+
timestamp: new Date(),
|
|
647
|
+
ip: null,
|
|
648
|
+
user_id: null,
|
|
649
|
+
tags: ['api'],
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
mockDb.executeTakeFirst.mockResolvedValueOnce(mockRow);
|
|
653
|
+
|
|
654
|
+
const result = await storage.getRequest('req-1');
|
|
655
|
+
|
|
656
|
+
expect(result?.headers).toEqual({ 'content-type': 'application/json' });
|
|
657
|
+
expect(result?.query).toEqual({ page: '1' });
|
|
658
|
+
expect(result?.tags).toEqual(['api']);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
describe('getTelescopeMigration', () => {
|
|
664
|
+
it('should return up and down migration SQL', () => {
|
|
665
|
+
const migration = getTelescopeMigration();
|
|
666
|
+
|
|
667
|
+
expect(migration.up).toContain(
|
|
668
|
+
'CREATE TABLE IF NOT EXISTS telescope_requests',
|
|
669
|
+
);
|
|
670
|
+
expect(migration.up).toContain(
|
|
671
|
+
'CREATE TABLE IF NOT EXISTS telescope_exceptions',
|
|
672
|
+
);
|
|
673
|
+
expect(migration.up).toContain('CREATE TABLE IF NOT EXISTS telescope_logs');
|
|
674
|
+
expect(migration.up).toContain('CREATE INDEX IF NOT EXISTS');
|
|
675
|
+
expect(migration.down).toContain('DROP TABLE IF EXISTS telescope_logs');
|
|
676
|
+
expect(migration.down).toContain(
|
|
677
|
+
'DROP TABLE IF EXISTS telescope_exceptions',
|
|
678
|
+
);
|
|
679
|
+
expect(migration.down).toContain('DROP TABLE IF EXISTS telescope_requests');
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('should support custom table prefix', () => {
|
|
683
|
+
const migration = getTelescopeMigration('debug');
|
|
684
|
+
|
|
685
|
+
expect(migration.up).toContain('CREATE TABLE IF NOT EXISTS debug_requests');
|
|
686
|
+
expect(migration.up).toContain(
|
|
687
|
+
'CREATE TABLE IF NOT EXISTS debug_exceptions',
|
|
688
|
+
);
|
|
689
|
+
expect(migration.up).toContain('CREATE TABLE IF NOT EXISTS debug_logs');
|
|
690
|
+
expect(migration.up).toContain('idx_debug_requests_timestamp');
|
|
691
|
+
expect(migration.down).toContain('DROP TABLE IF EXISTS debug_logs');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should include proper PostgreSQL column types', () => {
|
|
695
|
+
const migration = getTelescopeMigration();
|
|
696
|
+
|
|
697
|
+
expect(migration.up).toContain('JSONB');
|
|
698
|
+
expect(migration.up).toContain('TIMESTAMPTZ');
|
|
699
|
+
expect(migration.up).toContain('DOUBLE PRECISION');
|
|
700
|
+
expect(migration.up).toContain('BOOLEAN');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('should include indexes for common queries', () => {
|
|
704
|
+
const migration = getTelescopeMigration();
|
|
705
|
+
|
|
706
|
+
expect(migration.up).toContain('idx_telescope_requests_timestamp');
|
|
707
|
+
expect(migration.up).toContain('idx_telescope_requests_path');
|
|
708
|
+
expect(migration.up).toContain('idx_telescope_requests_status');
|
|
709
|
+
expect(migration.up).toContain('idx_telescope_exceptions_timestamp');
|
|
710
|
+
expect(migration.up).toContain('idx_telescope_exceptions_request_id');
|
|
711
|
+
expect(migration.up).toContain('idx_telescope_logs_timestamp');
|
|
712
|
+
expect(migration.up).toContain('idx_telescope_logs_level');
|
|
713
|
+
expect(migration.up).toContain('idx_telescope_logs_request_id');
|
|
714
|
+
});
|
|
715
|
+
});
|