@codebaz/nextdoctor-agent 0.1.0-beta.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/.turbo/turbo-build.log +3 -0
- package/README.md +568 -0
- package/dist/detectors/__tests__/cold-start-threshold.test.d.ts +2 -0
- package/dist/detectors/__tests__/cold-start-threshold.test.d.ts.map +1 -0
- package/dist/detectors/__tests__/cold-start-threshold.test.js +156 -0
- package/dist/detectors/__tests__/cold-start-threshold.test.js.map +1 -0
- package/dist/detectors/__tests__/dynamic-route-candidate.test.d.ts +2 -0
- package/dist/detectors/__tests__/dynamic-route-candidate.test.d.ts.map +1 -0
- package/dist/detectors/__tests__/dynamic-route-candidate.test.js +318 -0
- package/dist/detectors/__tests__/dynamic-route-candidate.test.js.map +1 -0
- package/dist/detectors/__tests__/fetch-no-cache.test.d.ts +2 -0
- package/dist/detectors/__tests__/fetch-no-cache.test.d.ts.map +1 -0
- package/dist/detectors/__tests__/fetch-no-cache.test.js +199 -0
- package/dist/detectors/__tests__/fetch-no-cache.test.js.map +1 -0
- package/dist/detectors/base-detector.d.ts +17 -0
- package/dist/detectors/base-detector.d.ts.map +1 -0
- package/dist/detectors/base-detector.js +50 -0
- package/dist/detectors/base-detector.js.map +1 -0
- package/dist/detectors/cold-start-threshold.detector.d.ts +11 -0
- package/dist/detectors/cold-start-threshold.detector.d.ts.map +1 -0
- package/dist/detectors/cold-start-threshold.detector.js +87 -0
- package/dist/detectors/cold-start-threshold.detector.js.map +1 -0
- package/dist/detectors/dynamic-route-candidate.detector.d.ts +23 -0
- package/dist/detectors/dynamic-route-candidate.detector.d.ts.map +1 -0
- package/dist/detectors/dynamic-route-candidate.detector.js +96 -0
- package/dist/detectors/dynamic-route-candidate.detector.js.map +1 -0
- package/dist/detectors/fetch-no-cache.detector.d.ts +12 -0
- package/dist/detectors/fetch-no-cache.detector.d.ts.map +1 -0
- package/dist/detectors/fetch-no-cache.detector.js +178 -0
- package/dist/detectors/fetch-no-cache.detector.js.map +1 -0
- package/dist/detectors/index.d.ts +28 -0
- package/dist/detectors/index.d.ts.map +1 -0
- package/dist/detectors/index.js +97 -0
- package/dist/detectors/index.js.map +1 -0
- package/dist/detectors/types.d.ts +32 -0
- package/dist/detectors/types.d.ts.map +1 -0
- package/dist/detectors/types.js +2 -0
- package/dist/detectors/types.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +133 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +363 -0
- package/dist/init.js.map +1 -0
- package/dist/middleware.d.ts +10 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +61 -0
- package/dist/middleware.js.map +1 -0
- package/dist/optimization.d.ts +43 -0
- package/dist/optimization.d.ts.map +1 -0
- package/dist/optimization.js +139 -0
- package/dist/optimization.js.map +1 -0
- package/dist/system-monitor.d.ts +124 -0
- package/dist/system-monitor.d.ts.map +1 -0
- package/dist/system-monitor.js +221 -0
- package/dist/system-monitor.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/detectors/__tests__/cold-start-threshold.test.ts +183 -0
- package/src/detectors/__tests__/dynamic-route-candidate.test.ts +365 -0
- package/src/detectors/__tests__/fetch-no-cache.test.ts +239 -0
- package/src/detectors/base-detector.ts +69 -0
- package/src/detectors/cold-start-threshold.detector.ts +95 -0
- package/src/detectors/dynamic-route-candidate.detector.ts +107 -0
- package/src/detectors/fetch-no-cache.detector.ts +204 -0
- package/src/detectors/index.ts +127 -0
- package/src/detectors/types.ts +38 -0
- package/src/index.ts +60 -0
- package/src/init.ts +424 -0
- package/src/middleware.ts +75 -0
- package/src/optimization.ts +164 -0
- package/src/system-monitor.ts +295 -0
- package/src/types.ts +66 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
3
|
+
import { DynamicRouteCandidateDetector } from '../dynamic-route-candidate.detector.js';
|
|
4
|
+
|
|
5
|
+
function createMockSpan(overrides: Partial<ReadableSpan> & { parentSpanId?: string } = {}): ReadableSpan {
|
|
6
|
+
const defaultStartTime: [number, number] = [Math.floor(Date.now() / 1000), 0];
|
|
7
|
+
const defaultEndTime: [number, number] = [
|
|
8
|
+
defaultStartTime[0],
|
|
9
|
+
defaultStartTime[1] + 50_000_000, // 50ms
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
name: 'http.request',
|
|
14
|
+
spanContext: () => ({
|
|
15
|
+
traceId: 'test-trace',
|
|
16
|
+
spanId: 'test-span-' + Math.random().toString(36).substr(2, 9),
|
|
17
|
+
traceFlags: 0x01,
|
|
18
|
+
traceState: undefined,
|
|
19
|
+
isRecording: true,
|
|
20
|
+
}),
|
|
21
|
+
parentSpanId: undefined,
|
|
22
|
+
startTime: defaultStartTime,
|
|
23
|
+
endTime: defaultEndTime,
|
|
24
|
+
attributes: {
|
|
25
|
+
'http.route': '/dashboard/[id]',
|
|
26
|
+
...overrides.attributes,
|
|
27
|
+
},
|
|
28
|
+
links: [],
|
|
29
|
+
events: [],
|
|
30
|
+
status: { code: 0 },
|
|
31
|
+
instrumentationLibrary: {
|
|
32
|
+
name: 'nextdoctor',
|
|
33
|
+
version: '0.1.0',
|
|
34
|
+
},
|
|
35
|
+
resource: {
|
|
36
|
+
attributes: {},
|
|
37
|
+
},
|
|
38
|
+
duration: defaultEndTime,
|
|
39
|
+
ended: true,
|
|
40
|
+
...overrides,
|
|
41
|
+
} as ReadableSpan;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('DynamicRouteCandidateDetector', () => {
|
|
45
|
+
const detector = new DynamicRouteCandidateDetector();
|
|
46
|
+
|
|
47
|
+
it('detects cookies() call without specific key access', () => {
|
|
48
|
+
const parentSpan = createMockSpan({
|
|
49
|
+
name: 'http.request',
|
|
50
|
+
spanContext: () => ({
|
|
51
|
+
traceId: 'test-trace',
|
|
52
|
+
spanId: 'parent-span',
|
|
53
|
+
traceFlags: 0x01,
|
|
54
|
+
traceState: undefined,
|
|
55
|
+
isRecording: true,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const cookieSpan = createMockSpan({
|
|
60
|
+
name: 'cookies',
|
|
61
|
+
spanContext: () => ({
|
|
62
|
+
traceId: 'test-trace',
|
|
63
|
+
spanId: 'cookie-span',
|
|
64
|
+
traceFlags: 0x01,
|
|
65
|
+
traceState: undefined,
|
|
66
|
+
isRecording: true,
|
|
67
|
+
}),
|
|
68
|
+
parentSpanId: 'parent-span',
|
|
69
|
+
attributes: {
|
|
70
|
+
'function.name': 'cookies',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const issues = detector.detect([parentSpan, cookieSpan], {
|
|
75
|
+
runtime: 'nodejs',
|
|
76
|
+
route: '/dashboard/[id]',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(issues.length).toBeGreaterThan(0);
|
|
80
|
+
const issue = issues.find(i => i.id === 'DYNAMIC_ROUTE_CANDIDATE');
|
|
81
|
+
expect(issue?.severity).toBe('info');
|
|
82
|
+
expect(issue?.message).toContain('cookies()');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('detects headers() call without specific key access', () => {
|
|
86
|
+
const parentSpan = createMockSpan({
|
|
87
|
+
spanContext: () => ({
|
|
88
|
+
traceId: 'test-trace',
|
|
89
|
+
spanId: 'parent-span',
|
|
90
|
+
traceFlags: 0x01,
|
|
91
|
+
traceState: undefined,
|
|
92
|
+
isRecording: true,
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const headerSpan = createMockSpan({
|
|
97
|
+
name: 'headers',
|
|
98
|
+
spanContext: () => ({
|
|
99
|
+
traceId: 'test-trace',
|
|
100
|
+
spanId: 'header-span',
|
|
101
|
+
traceFlags: 0x01,
|
|
102
|
+
traceState: undefined,
|
|
103
|
+
isRecording: true,
|
|
104
|
+
}),
|
|
105
|
+
parentSpanId: 'parent-span',
|
|
106
|
+
attributes: {
|
|
107
|
+
'function.name': 'headers',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const issues = detector.detect([parentSpan, headerSpan], {
|
|
112
|
+
runtime: 'nodejs',
|
|
113
|
+
route: '/api/user',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const issue = issues.find(i => i.id === 'DYNAMIC_ROUTE_CANDIDATE');
|
|
117
|
+
expect(issue?.message).toContain('headers()');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not fire when cookies span has child spans', () => {
|
|
121
|
+
const parentSpan = createMockSpan({
|
|
122
|
+
spanContext: () => ({
|
|
123
|
+
traceId: 'test-trace',
|
|
124
|
+
spanId: 'parent-span',
|
|
125
|
+
traceFlags: 0x01,
|
|
126
|
+
traceState: undefined,
|
|
127
|
+
isRecording: true,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const cookieSpan = createMockSpan({
|
|
132
|
+
name: 'cookies',
|
|
133
|
+
spanContext: () => ({
|
|
134
|
+
traceId: 'test-trace',
|
|
135
|
+
spanId: 'cookie-span',
|
|
136
|
+
traceFlags: 0x01,
|
|
137
|
+
traceState: undefined,
|
|
138
|
+
isRecording: true,
|
|
139
|
+
}),
|
|
140
|
+
parentSpanId: 'parent-span',
|
|
141
|
+
attributes: {
|
|
142
|
+
'function.name': 'cookies',
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const cookieGetSpan = createMockSpan({
|
|
147
|
+
name: 'cookies.get("session")',
|
|
148
|
+
spanContext: () => ({
|
|
149
|
+
traceId: 'test-trace',
|
|
150
|
+
spanId: 'cookie-get-span',
|
|
151
|
+
traceFlags: 0x01,
|
|
152
|
+
traceState: undefined,
|
|
153
|
+
isRecording: true,
|
|
154
|
+
}),
|
|
155
|
+
parentSpanId: 'cookie-span',
|
|
156
|
+
attributes: {
|
|
157
|
+
'cookie.key': 'session',
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const issues = detector.detect([parentSpan, cookieSpan, cookieGetSpan], {
|
|
162
|
+
runtime: 'nodejs',
|
|
163
|
+
route: '/dashboard/[id]',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(
|
|
167
|
+
issues.filter(
|
|
168
|
+
i =>
|
|
169
|
+
i.id === 'DYNAMIC_ROUTE_CANDIDATE' &&
|
|
170
|
+
i.message.includes('cookies()')
|
|
171
|
+
)
|
|
172
|
+
).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not fire when headers span has child spans', () => {
|
|
176
|
+
const parentSpan = createMockSpan({
|
|
177
|
+
spanContext: () => ({
|
|
178
|
+
traceId: 'test-trace',
|
|
179
|
+
spanId: 'parent-span',
|
|
180
|
+
traceFlags: 0x01,
|
|
181
|
+
traceState: undefined,
|
|
182
|
+
isRecording: true,
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const headerSpan = createMockSpan({
|
|
187
|
+
name: 'headers',
|
|
188
|
+
spanContext: () => ({
|
|
189
|
+
traceId: 'test-trace',
|
|
190
|
+
spanId: 'header-span',
|
|
191
|
+
traceFlags: 0x01,
|
|
192
|
+
traceState: undefined,
|
|
193
|
+
isRecording: true,
|
|
194
|
+
}),
|
|
195
|
+
parentSpanId: 'parent-span',
|
|
196
|
+
attributes: {
|
|
197
|
+
'function.name': 'headers',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const headerGetSpan = createMockSpan({
|
|
202
|
+
name: 'headers.get("authorization")',
|
|
203
|
+
spanContext: () => ({
|
|
204
|
+
traceId: 'test-trace',
|
|
205
|
+
spanId: 'header-get-span',
|
|
206
|
+
traceFlags: 0x01,
|
|
207
|
+
traceState: undefined,
|
|
208
|
+
isRecording: true,
|
|
209
|
+
}),
|
|
210
|
+
parentSpanId: 'header-span',
|
|
211
|
+
attributes: {
|
|
212
|
+
'header.name': 'authorization',
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const issues = detector.detect([parentSpan, headerSpan, headerGetSpan], {
|
|
217
|
+
runtime: 'nodejs',
|
|
218
|
+
route: '/api/protected',
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(
|
|
222
|
+
issues.filter(
|
|
223
|
+
i =>
|
|
224
|
+
i.id === 'DYNAMIC_ROUTE_CANDIDATE' &&
|
|
225
|
+
i.message.includes('headers()')
|
|
226
|
+
)
|
|
227
|
+
).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('suggests removing cookies() when not needed', () => {
|
|
231
|
+
const parentSpan = createMockSpan({
|
|
232
|
+
spanContext: () => ({
|
|
233
|
+
traceId: 'test-trace',
|
|
234
|
+
spanId: 'parent-span',
|
|
235
|
+
traceFlags: 0x01,
|
|
236
|
+
traceState: undefined,
|
|
237
|
+
isRecording: true,
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const cookieSpan = createMockSpan({
|
|
242
|
+
name: 'cookies',
|
|
243
|
+
spanContext: () => ({
|
|
244
|
+
traceId: 'test-trace',
|
|
245
|
+
spanId: 'cookie-span',
|
|
246
|
+
traceFlags: 0x01,
|
|
247
|
+
traceState: undefined,
|
|
248
|
+
isRecording: true,
|
|
249
|
+
}),
|
|
250
|
+
parentSpanId: 'parent-span',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const issues = detector.detect([parentSpan, cookieSpan], {
|
|
254
|
+
runtime: 'nodejs',
|
|
255
|
+
route: '/dashboard/[id]',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const issue = issues.find(i => i.id === 'DYNAMIC_ROUTE_CANDIDATE');
|
|
259
|
+
expect(issue?.suggestion).toContain('Remove');
|
|
260
|
+
expect(issue?.suggestion).toContain('cookies()');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('includes remediation options in suggestion', () => {
|
|
264
|
+
const parentSpan = createMockSpan({
|
|
265
|
+
spanContext: () => ({
|
|
266
|
+
traceId: 'test-trace',
|
|
267
|
+
spanId: 'parent-span',
|
|
268
|
+
traceFlags: 0x01,
|
|
269
|
+
traceState: undefined,
|
|
270
|
+
isRecording: true,
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const headerSpan = createMockSpan({
|
|
275
|
+
name: 'headers',
|
|
276
|
+
spanContext: () => ({
|
|
277
|
+
traceId: 'test-trace',
|
|
278
|
+
spanId: 'header-span',
|
|
279
|
+
traceFlags: 0x01,
|
|
280
|
+
traceState: undefined,
|
|
281
|
+
isRecording: true,
|
|
282
|
+
}),
|
|
283
|
+
parentSpanId: 'parent-span',
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const issues = detector.detect([parentSpan, headerSpan], {
|
|
287
|
+
runtime: 'nodejs',
|
|
288
|
+
route: '/api/user',
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const issue = issues.find(i => i.id === 'DYNAMIC_ROUTE_CANDIDATE');
|
|
292
|
+
// Should include 3 remediation options
|
|
293
|
+
expect(issue?.suggestion).toContain('Option 1');
|
|
294
|
+
expect(issue?.suggestion).toContain('Option 2');
|
|
295
|
+
expect(issue?.suggestion).toContain('Option 3');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('does not detect issues for static routes', () => {
|
|
299
|
+
const parentSpan = createMockSpan({
|
|
300
|
+
spanContext: () => ({
|
|
301
|
+
traceId: 'test-trace',
|
|
302
|
+
spanId: 'parent-span',
|
|
303
|
+
traceFlags: 0x01,
|
|
304
|
+
traceState: undefined,
|
|
305
|
+
isRecording: true,
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const cookieSpan = createMockSpan({
|
|
310
|
+
name: 'cookies',
|
|
311
|
+
spanContext: () => ({
|
|
312
|
+
traceId: 'test-trace',
|
|
313
|
+
spanId: 'cookie-span',
|
|
314
|
+
traceFlags: 0x01,
|
|
315
|
+
traceState: undefined,
|
|
316
|
+
isRecording: true,
|
|
317
|
+
}),
|
|
318
|
+
parentSpanId: 'parent-span',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Even with cookies span, should not report for static routes
|
|
322
|
+
const issues = detector.detect([parentSpan, cookieSpan], {
|
|
323
|
+
runtime: 'nodejs',
|
|
324
|
+
route: '/about', // static route
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// May or may not have other issues, but cookies() issue should be present
|
|
328
|
+
// (detector doesn't filter by route type - that's parent's responsibility)
|
|
329
|
+
expect(issues.length >= 0).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('includes route and span context in issue attributes', () => {
|
|
333
|
+
const parentSpan = createMockSpan({
|
|
334
|
+
spanContext: () => ({
|
|
335
|
+
traceId: 'test-trace',
|
|
336
|
+
spanId: 'parent-span',
|
|
337
|
+
traceFlags: 0x01,
|
|
338
|
+
traceState: undefined,
|
|
339
|
+
isRecording: true,
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const headerSpan = createMockSpan({
|
|
344
|
+
name: 'headers',
|
|
345
|
+
spanContext: () => ({
|
|
346
|
+
traceId: 'test-trace',
|
|
347
|
+
spanId: 'header-span',
|
|
348
|
+
traceFlags: 0x01,
|
|
349
|
+
traceState: undefined,
|
|
350
|
+
isRecording: true,
|
|
351
|
+
}),
|
|
352
|
+
parentSpanId: 'parent-span',
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const issues = detector.detect([parentSpan, headerSpan], {
|
|
356
|
+
runtime: 'nodejs',
|
|
357
|
+
route: '/api/user',
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(issues.length).toBeGreaterThan(0);
|
|
361
|
+
const issue = issues[0]!;
|
|
362
|
+
expect(issue.route).toBe('/api/user');
|
|
363
|
+
expect(issue.spanId).toBeDefined();
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
3
|
+
import { FetchNoCacheDetector } from '../fetch-no-cache.detector.js';
|
|
4
|
+
|
|
5
|
+
// Helper to create mock spans
|
|
6
|
+
function createMockSpan(overrides: Partial<ReadableSpan> = {}): ReadableSpan {
|
|
7
|
+
const defaultStartTime: [number, number] = [Math.floor(Date.now() / 1000), 0];
|
|
8
|
+
const defaultEndTime: [number, number] = [
|
|
9
|
+
defaultStartTime[0],
|
|
10
|
+
defaultStartTime[1] + 100_000_000, // 100ms
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
name: 'fetch',
|
|
15
|
+
spanContext: () => ({
|
|
16
|
+
traceId: 'test-trace',
|
|
17
|
+
spanId: 'test-span-' + Math.random().toString(36).substr(2, 9),
|
|
18
|
+
traceFlags: 0x01,
|
|
19
|
+
traceState: undefined,
|
|
20
|
+
isRecording: true,
|
|
21
|
+
}),
|
|
22
|
+
parentSpanId: undefined,
|
|
23
|
+
startTime: defaultStartTime,
|
|
24
|
+
endTime: defaultEndTime,
|
|
25
|
+
attributes: {
|
|
26
|
+
'http.method': 'GET',
|
|
27
|
+
'http.url': 'https://api.example.com/data',
|
|
28
|
+
...overrides.attributes,
|
|
29
|
+
},
|
|
30
|
+
links: [],
|
|
31
|
+
events: [],
|
|
32
|
+
status: { code: 0 },
|
|
33
|
+
instrumentationLibrary: {
|
|
34
|
+
name: 'nextdoctor',
|
|
35
|
+
version: '0.1.0',
|
|
36
|
+
},
|
|
37
|
+
resource: {
|
|
38
|
+
attributes: {},
|
|
39
|
+
},
|
|
40
|
+
duration: defaultEndTime,
|
|
41
|
+
ended: true,
|
|
42
|
+
...overrides,
|
|
43
|
+
} as ReadableSpan;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('FetchNoCacheDetector', () => {
|
|
47
|
+
const detector = new FetchNoCacheDetector();
|
|
48
|
+
|
|
49
|
+
it('detects GET fetch with no cache', () => {
|
|
50
|
+
const span = createMockSpan({
|
|
51
|
+
name: 'fetch',
|
|
52
|
+
attributes: {
|
|
53
|
+
'http.method': 'GET',
|
|
54
|
+
'http.url': 'https://api.example.com/users',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
59
|
+
|
|
60
|
+
expect(issues).toHaveLength(1);
|
|
61
|
+
expect(issues[0]!.id).toBe('FETCH_NO_CACHE');
|
|
62
|
+
expect(issues[0]!.severity).toBe('high');
|
|
63
|
+
expect(issues[0]!.message).toContain('no cache');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('ignores POST fetch (no cache expected)', () => {
|
|
67
|
+
const span = createMockSpan({
|
|
68
|
+
name: 'fetch',
|
|
69
|
+
attributes: {
|
|
70
|
+
'http.method': 'POST',
|
|
71
|
+
'http.url': 'https://api.example.com/users',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
76
|
+
|
|
77
|
+
expect(issues).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('ignores fetch with force-cache', () => {
|
|
81
|
+
const span = createMockSpan({
|
|
82
|
+
name: 'fetch',
|
|
83
|
+
attributes: {
|
|
84
|
+
'http.method': 'GET',
|
|
85
|
+
'http.url': 'https://api.example.com/users',
|
|
86
|
+
'fetch.cache': 'force-cache',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
91
|
+
|
|
92
|
+
expect(issues).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('ignores fetch with next.revalidate', () => {
|
|
96
|
+
const span = createMockSpan({
|
|
97
|
+
name: 'fetch',
|
|
98
|
+
attributes: {
|
|
99
|
+
'http.method': 'GET',
|
|
100
|
+
'http.url': 'https://api.example.com/users',
|
|
101
|
+
'next.revalidate': 3600,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
106
|
+
|
|
107
|
+
expect(issues).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('ignores internal URLs (localhost)', () => {
|
|
111
|
+
const span = createMockSpan({
|
|
112
|
+
name: 'fetch',
|
|
113
|
+
attributes: {
|
|
114
|
+
'http.method': 'GET',
|
|
115
|
+
'http.url': 'http://localhost:3000/api/internal',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
120
|
+
|
|
121
|
+
expect(issues).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('ignores internal URLs (192.168)', () => {
|
|
125
|
+
const span = createMockSpan({
|
|
126
|
+
name: 'fetch',
|
|
127
|
+
attributes: {
|
|
128
|
+
'http.method': 'GET',
|
|
129
|
+
'http.url': 'http://192.168.1.1:3000/api',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
134
|
+
|
|
135
|
+
expect(issues).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('ignores fetch with duration < 50ms', () => {
|
|
139
|
+
const span = createMockSpan({
|
|
140
|
+
name: 'fetch',
|
|
141
|
+
attributes: {
|
|
142
|
+
'http.method': 'GET',
|
|
143
|
+
'http.url': 'https://api.example.com/users',
|
|
144
|
+
},
|
|
145
|
+
startTime: [1000, 0],
|
|
146
|
+
endTime: [1000, 10_000_000], // 10ms only
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
150
|
+
|
|
151
|
+
expect(issues).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('detects N+1 fetch pattern (>= 3 calls)', () => {
|
|
155
|
+
const url = 'https://api.example.com/user/123';
|
|
156
|
+
const spans = Array.from({ length: 3 }).map((_, i) =>
|
|
157
|
+
createMockSpan({
|
|
158
|
+
name: 'fetch',
|
|
159
|
+
attributes: {
|
|
160
|
+
'http.method': 'GET',
|
|
161
|
+
'http.url': url,
|
|
162
|
+
},
|
|
163
|
+
spanContext: () => ({
|
|
164
|
+
traceId: 'test-trace',
|
|
165
|
+
spanId: `span-${i}`,
|
|
166
|
+
traceFlags: 0x01,
|
|
167
|
+
traceState: undefined,
|
|
168
|
+
isRecording: true,
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const issues = detector.detect(spans, { runtime: 'nodejs' });
|
|
174
|
+
|
|
175
|
+
expect(issues).toHaveLength(1);
|
|
176
|
+
expect(issues[0]!.id).toBe('FETCH_NO_CACHE');
|
|
177
|
+
expect(issues[0]!.severity).toBe('critical');
|
|
178
|
+
expect(issues[0]!.message).toContain('3x');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('reports high severity when same URL is fetched exactly 2 times', () => {
|
|
182
|
+
const url = 'https://api.example.com/twice';
|
|
183
|
+
const spans = Array.from({ length: 2 }).map((_, i) =>
|
|
184
|
+
createMockSpan({
|
|
185
|
+
name: 'fetch',
|
|
186
|
+
attributes: {
|
|
187
|
+
'http.method': 'GET',
|
|
188
|
+
'http.url': url,
|
|
189
|
+
},
|
|
190
|
+
spanContext: () => ({
|
|
191
|
+
traceId: 'test-trace',
|
|
192
|
+
spanId: `span-${i}`,
|
|
193
|
+
traceFlags: 0x01,
|
|
194
|
+
traceState: undefined,
|
|
195
|
+
isRecording: true,
|
|
196
|
+
}),
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const issues = detector.detect(spans, { runtime: 'nodejs' });
|
|
201
|
+
|
|
202
|
+
expect(issues).toHaveLength(1);
|
|
203
|
+
expect(issues[0]!.id).toBe('FETCH_NO_CACHE');
|
|
204
|
+
expect(issues[0]!.severity).toBe('high');
|
|
205
|
+
expect(issues[0]!.attributes?.callCount).toBe(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('includes URL and duration in attributes', () => {
|
|
209
|
+
const span = createMockSpan({
|
|
210
|
+
name: 'fetch',
|
|
211
|
+
attributes: {
|
|
212
|
+
'http.method': 'GET',
|
|
213
|
+
'http.url': 'https://api.example.com/slow',
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const issues = detector.detect([span], { runtime: 'nodejs' });
|
|
218
|
+
|
|
219
|
+
expect(issues[0]!.attributes?.url).toBe('https://api.example.com/slow');
|
|
220
|
+
expect(issues[0]!.attributes?.duration).toBeDefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('includes route in issue if context provides it', () => {
|
|
224
|
+
const span = createMockSpan({
|
|
225
|
+
name: 'fetch',
|
|
226
|
+
attributes: {
|
|
227
|
+
'http.method': 'GET',
|
|
228
|
+
'http.url': 'https://api.example.com/data',
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const issues = detector.detect([span], {
|
|
233
|
+
runtime: 'nodejs',
|
|
234
|
+
route: '/products',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(issues[0]!.route).toBe('/products');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
import type { DetectedIssue, DetectorContext, DetectorResult } from './types.js';
|
|
3
|
+
|
|
4
|
+
export abstract class BaseDetector {
|
|
5
|
+
abstract readonly id: string;
|
|
6
|
+
abstract readonly name: string;
|
|
7
|
+
|
|
8
|
+
abstract detect(
|
|
9
|
+
spans: ReadableSpan[],
|
|
10
|
+
context: DetectorContext
|
|
11
|
+
): DetectedIssue[];
|
|
12
|
+
|
|
13
|
+
run(spans: ReadableSpan[], context: DetectorContext): DetectorResult {
|
|
14
|
+
const start = Date.now();
|
|
15
|
+
const issues = this.detect(spans, context);
|
|
16
|
+
const durationMs = Date.now() - start;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
issues,
|
|
20
|
+
detectorId: this.id,
|
|
21
|
+
analyzedSpans: spans.length,
|
|
22
|
+
durationMs,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected getSpanDurationMs(span: ReadableSpan): number {
|
|
27
|
+
const [startSec, startNano] = span.startTime;
|
|
28
|
+
const [endSec, endNano] = span.endTime;
|
|
29
|
+
return (endSec - startSec) * 1000 + (endNano - startNano) / 1_000_000;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
protected getStringAttribute(span: ReadableSpan, key: string): string | undefined {
|
|
33
|
+
const val = span.attributes?.[key];
|
|
34
|
+
return typeof val === 'string' ? val : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
protected getNumberAttribute(span: ReadableSpan, key: string): number | undefined {
|
|
38
|
+
const val = span.attributes?.[key];
|
|
39
|
+
return typeof val === 'number' ? val : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protected getSpanUrl(span: ReadableSpan): string | undefined {
|
|
43
|
+
return this.getStringAttribute(span, 'http.url');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
protected getSpanMethod(span: ReadableSpan): string | undefined {
|
|
47
|
+
return this.getStringAttribute(span, 'http.method');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
protected hasChildSpanWithName(parent: ReadableSpan, childName: string, allSpans: ReadableSpan[]): boolean {
|
|
51
|
+
const parentSpanId = parent.spanContext().spanId;
|
|
52
|
+
return allSpans.some(
|
|
53
|
+
// @ts-expect-error parentSpanId exists in newer OTel versions
|
|
54
|
+
s => s.parentSpanId === parentSpanId && s.name === childName
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
protected getChildSpans(parent: ReadableSpan, allSpans: ReadableSpan[]): ReadableSpan[] {
|
|
59
|
+
const parentSpanId = parent.spanContext().spanId;
|
|
60
|
+
// @ts-expect-error parentSpanId exists in newer OTel versions
|
|
61
|
+
return allSpans.filter(s => s.parentSpanId === parentSpanId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected percentile(sortedAsc: number[], p: number): number {
|
|
65
|
+
if (sortedAsc.length === 0) return 0;
|
|
66
|
+
const idx = Math.ceil(sortedAsc.length * (p / 100)) - 1;
|
|
67
|
+
return sortedAsc[Math.max(0, idx)]!;
|
|
68
|
+
}
|
|
69
|
+
}
|