@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,95 @@
|
|
|
1
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
import { BaseDetector } from './base-detector.js';
|
|
3
|
+
import type { DetectedIssue, DetectorContext } from './types.js';
|
|
4
|
+
|
|
5
|
+
export class ColdStartThresholdDetector extends BaseDetector {
|
|
6
|
+
readonly id = 'COLD_START_THRESHOLD';
|
|
7
|
+
readonly name = 'Cold Start Threshold Detector';
|
|
8
|
+
private readonly threshold = 800; // ms
|
|
9
|
+
private readonly minSamplesForVariance = 20;
|
|
10
|
+
|
|
11
|
+
detect(spans: ReadableSpan[], context: DetectorContext): DetectedIssue[] {
|
|
12
|
+
const issues: DetectedIssue[] = [];
|
|
13
|
+
|
|
14
|
+
// Check startup time if provided
|
|
15
|
+
if (context.startupTimeMs && context.startupTimeMs > this.threshold) {
|
|
16
|
+
issues.push({
|
|
17
|
+
id: this.id,
|
|
18
|
+
severity: 'critical',
|
|
19
|
+
message: `Edge cold start ${context.startupTimeMs}ms > ${this.threshold}ms → users are paying this cost on every cold invocation`,
|
|
20
|
+
suggestion: `Options in order of impact:
|
|
21
|
+
|
|
22
|
+
1. Move heavy imports outside the handler:
|
|
23
|
+
// ❌ Inside the handler
|
|
24
|
+
export default async function handler(req, res) {
|
|
25
|
+
const { heavy } = await import('./heavy-lib');
|
|
26
|
+
return handleRequest(heavy, req);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ✅ Outside the handler
|
|
30
|
+
const heavyPromise = import('./heavy-lib');
|
|
31
|
+
export default async function handler(req, res) {
|
|
32
|
+
const { heavy } = await heavyPromise;
|
|
33
|
+
return handleRequest(heavy, req);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
2. Consider switching to Node.js runtime if Edge is not required:
|
|
37
|
+
export const runtime = 'nodejs';
|
|
38
|
+
|
|
39
|
+
3. Use route warming via a cron job to keep instances warm.`,
|
|
40
|
+
route: context.route,
|
|
41
|
+
attributes: {
|
|
42
|
+
startupTimeMs: context.startupTimeMs,
|
|
43
|
+
threshold: this.threshold,
|
|
44
|
+
runtime: context.runtime,
|
|
45
|
+
},
|
|
46
|
+
detectedAt: Date.now(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Detect intermittent cold starts via span latency variance
|
|
51
|
+
const routeSpans = context.route
|
|
52
|
+
? spans.filter(s => s.attributes?.['http.route'] === context.route)
|
|
53
|
+
: spans.filter(s => s.attributes?.['http.route']);
|
|
54
|
+
|
|
55
|
+
if (routeSpans.length >= this.minSamplesForVariance) {
|
|
56
|
+
const durations = routeSpans.map(s => this.getSpanDurationMs(s));
|
|
57
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
58
|
+
const p50 = this.percentile(sorted, 50);
|
|
59
|
+
const p99 = this.percentile(sorted, 99);
|
|
60
|
+
const variance = p99 - p50;
|
|
61
|
+
|
|
62
|
+
if (variance > 2000) {
|
|
63
|
+
issues.push({
|
|
64
|
+
id: 'COLD_START_INTERMITTENT',
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
message: `Route "${context.route || 'unknown'}" has high latency variance: P50=${Math.round(p50)}ms vs P99=${Math.round(p99)}ms → likely intermittent cold starts`,
|
|
67
|
+
suggestion: `A difference > 2000ms between P50 and P99 indicates periodic cold starts. Consider these strategies:
|
|
68
|
+
|
|
69
|
+
1. Keep-warm via external cron job (call your endpoint every 5 minutes)
|
|
70
|
+
2. Migrate to Node.js runtime if Edge Runtime is optional:
|
|
71
|
+
export const runtime = 'nodejs';
|
|
72
|
+
|
|
73
|
+
3. Use Next.js Middleware to keep the runtime warm — middleware runs on every
|
|
74
|
+
request and prevents full cold starts on subsequent edge invocations:
|
|
75
|
+
|
|
76
|
+
// middleware.ts
|
|
77
|
+
export const config = { matcher: '/api/:path*' };
|
|
78
|
+
export function middleware() {
|
|
79
|
+
// Intentionally lightweight — presence keeps runtime warm
|
|
80
|
+
}`,
|
|
81
|
+
route: context.route,
|
|
82
|
+
attributes: {
|
|
83
|
+
p50: Math.round(p50),
|
|
84
|
+
p99: Math.round(p99),
|
|
85
|
+
variance: Math.round(variance),
|
|
86
|
+
sampleCount: sorted.length,
|
|
87
|
+
},
|
|
88
|
+
detectedAt: Date.now(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return issues;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
import { BaseDetector } from './base-detector.js';
|
|
3
|
+
import type { DetectedIssue, DetectorContext } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DYNAMIC_ROUTE_CANDIDATE Detector
|
|
7
|
+
*
|
|
8
|
+
* Detects routes that call cookies() or headers() without reading any value,
|
|
9
|
+
* forcing unnecessary dynamic rendering.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ KNOWN LIMITATION: Next.js does not emit granular OTel spans for individual
|
|
12
|
+
* cookie key reads (e.g. cookies().get('key')). This detector uses a conservative
|
|
13
|
+
* heuristic: if the cookies/headers span has no child spans, the call is considered
|
|
14
|
+
* unused. This may produce false negatives (misses cases where a key is read) but
|
|
15
|
+
* avoids false positives (incorrectly flagging correct usage).
|
|
16
|
+
*
|
|
17
|
+
* Severity is 'info' until validated with real production traces.
|
|
18
|
+
*/
|
|
19
|
+
export class DynamicRouteCandidateDetector extends BaseDetector {
|
|
20
|
+
readonly id = 'DYNAMIC_ROUTE_CANDIDATE';
|
|
21
|
+
readonly name = 'Dynamic Route Candidate Detector';
|
|
22
|
+
|
|
23
|
+
detect(spans: ReadableSpan[], context: DetectorContext): DetectedIssue[] {
|
|
24
|
+
const issues: DetectedIssue[] = [];
|
|
25
|
+
|
|
26
|
+
if (!context.route) {
|
|
27
|
+
return issues;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Find spans that indicate reading of cookies/headers
|
|
31
|
+
const cookiesSpans = spans.filter(s => s.name === 'cookies');
|
|
32
|
+
const headersSpans = spans.filter(s => s.name === 'headers');
|
|
33
|
+
|
|
34
|
+
const dynamicTriggerSpans = [...cookiesSpans, ...headersSpans];
|
|
35
|
+
|
|
36
|
+
dynamicTriggerSpans.forEach(span => {
|
|
37
|
+
const children = this.getChildSpans(span, spans);
|
|
38
|
+
|
|
39
|
+
// Conservative heuristic: if there are NO child spans at all,
|
|
40
|
+
// cookies()/headers() was called but nothing was read from it.
|
|
41
|
+
// If there ARE children, assume the value was used (safer default).
|
|
42
|
+
const hasAnyChildActivity = children.length > 0;
|
|
43
|
+
|
|
44
|
+
if (!hasAnyChildActivity) {
|
|
45
|
+
const spanName = span.name === 'cookies' ? 'cookies()' : 'headers()';
|
|
46
|
+
|
|
47
|
+
issues.push({
|
|
48
|
+
id: this.id,
|
|
49
|
+
severity: 'info',
|
|
50
|
+
message: `Route "${context.route}" may be unnecessarily dynamic: ${spanName} called with no subsequent reads detected`,
|
|
51
|
+
suggestion: `This route is paying the dynamic rendering cost without reading any actual values.
|
|
52
|
+
|
|
53
|
+
Option 1 — Remove the ${spanName}() call:
|
|
54
|
+
// ❌ Before
|
|
55
|
+
export default async function Page() {
|
|
56
|
+
const cookies = cookies(); // forces dynamic but never used
|
|
57
|
+
return <div>Static content</div>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ✅ After
|
|
61
|
+
export default function Page() {
|
|
62
|
+
return <div>Static content</div>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Option 2 — Move to a Server Action:
|
|
66
|
+
'use client';
|
|
67
|
+
import { getSessionCookie } from './actions';
|
|
68
|
+
|
|
69
|
+
export default function Page() {
|
|
70
|
+
const handleClick = async () => {
|
|
71
|
+
const session = await getSessionCookie();
|
|
72
|
+
// ...
|
|
73
|
+
};
|
|
74
|
+
return <button onClick={handleClick}>Click me</button>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// app/actions.ts
|
|
78
|
+
'use server';
|
|
79
|
+
import { cookies } from 'next/headers';
|
|
80
|
+
|
|
81
|
+
export async function getSessionCookie() {
|
|
82
|
+
const c = cookies();
|
|
83
|
+
return c.get('session')?.value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Option 3 — If the route is fully static and shouldn't trigger dynamic rendering:
|
|
87
|
+
export const dynamic = 'force-static';
|
|
88
|
+
|
|
89
|
+
export default function Page() {
|
|
90
|
+
// ... static content
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
See: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic`,
|
|
94
|
+
route: context.route,
|
|
95
|
+
spanId: span.spanContext().spanId,
|
|
96
|
+
attributes: {
|
|
97
|
+
trigger: span.name,
|
|
98
|
+
childrenCount: children.length,
|
|
99
|
+
},
|
|
100
|
+
detectedAt: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return issues;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
import { BaseDetector } from './base-detector.js';
|
|
3
|
+
import type { DetectedIssue, DetectorContext } from './types.js';
|
|
4
|
+
|
|
5
|
+
interface FetchInfo {
|
|
6
|
+
url: string;
|
|
7
|
+
duration: number;
|
|
8
|
+
spanId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class FetchNoCacheDetector extends BaseDetector {
|
|
12
|
+
readonly id = 'FETCH_NO_CACHE';
|
|
13
|
+
readonly name = 'Fetch Without Cache Detector';
|
|
14
|
+
private readonly minDurationMs = 50;
|
|
15
|
+
private readonly nPlus1Threshold = 3;
|
|
16
|
+
|
|
17
|
+
detect(spans: ReadableSpan[], context: DetectorContext): DetectedIssue[] {
|
|
18
|
+
const issues: DetectedIssue[] = [];
|
|
19
|
+
|
|
20
|
+
// Find fetch spans without cache directive
|
|
21
|
+
const fetchSpans = spans.filter(span => {
|
|
22
|
+
const name = span.name.toLowerCase();
|
|
23
|
+
return name.includes('fetch') || name.includes('http.client');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const noCacheFetches: FetchInfo[] = [];
|
|
27
|
+
const urlCounts = new Map<string, FetchInfo[]>();
|
|
28
|
+
|
|
29
|
+
fetchSpans.forEach(span => {
|
|
30
|
+
const method = this.getSpanMethod(span);
|
|
31
|
+
const url = this.getSpanUrl(span);
|
|
32
|
+
|
|
33
|
+
// Ignore non-GET and non-POST (POST/PUT/DELETE shouldn't be cached anyway)
|
|
34
|
+
if (!method || !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!url) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Ignore internal URLs
|
|
43
|
+
if (this.isInternalUrl(url)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check cache directive
|
|
48
|
+
const cacheAttr = this.getStringAttribute(span, 'fetch.cache');
|
|
49
|
+
const nextRevalidate = this.getNumberAttribute(span, 'next.revalidate');
|
|
50
|
+
|
|
51
|
+
// Has cache if:
|
|
52
|
+
// - has fetch.cache='force-cache' or 'force-revalidate'
|
|
53
|
+
// - has next.revalidate > 0
|
|
54
|
+
const hasCache =
|
|
55
|
+
cacheAttr === 'force-cache' ||
|
|
56
|
+
cacheAttr === 'force-revalidate' ||
|
|
57
|
+
(nextRevalidate !== undefined && nextRevalidate > 0);
|
|
58
|
+
|
|
59
|
+
if (hasCache) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const duration = this.getSpanDurationMs(span);
|
|
64
|
+
if (duration < this.minDurationMs) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const fetchInfo = {
|
|
69
|
+
url,
|
|
70
|
+
duration,
|
|
71
|
+
spanId: span.spanContext().spanId,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
noCacheFetches.push(fetchInfo);
|
|
75
|
+
|
|
76
|
+
// Track for N+1 detection
|
|
77
|
+
const existing = urlCounts.get(url);
|
|
78
|
+
if (existing) {
|
|
79
|
+
existing.push(fetchInfo);
|
|
80
|
+
} else {
|
|
81
|
+
urlCounts.set(url, [fetchInfo]);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Report individual fetches and group N+1s
|
|
86
|
+
urlCounts.forEach((fetches, url) => {
|
|
87
|
+
if (fetches.length >= this.nPlus1Threshold) {
|
|
88
|
+
// N+1 detected
|
|
89
|
+
issues.push({
|
|
90
|
+
id: this.id,
|
|
91
|
+
severity: 'critical',
|
|
92
|
+
message: `Fetch "${url}" called ${fetches.length}x with no cache → likely fetch N+1 pattern`,
|
|
93
|
+
suggestion: `This endpoint is being called multiple times without caching. Add a cache directive:
|
|
94
|
+
|
|
95
|
+
// Option 1: Force cache (recommended for static data)
|
|
96
|
+
fetch("${url}", { cache: 'force-cache' })
|
|
97
|
+
|
|
98
|
+
// Option 2: Revalidate after time (recommended for semi-dynamic data)
|
|
99
|
+
fetch("${url}", {
|
|
100
|
+
next: { revalidate: 3600 } // revalidate every hour
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Option 3: Use server-side data fetching instead
|
|
104
|
+
// Move to a parent Server Component or Route Handler
|
|
105
|
+
|
|
106
|
+
// Option 4: Use unstable_cache wrapper (experimental)
|
|
107
|
+
import { unstable_cache } from 'next/cache';
|
|
108
|
+
const cachedFetch = unstable_cache(
|
|
109
|
+
async () => fetch("${url}").then(r => r.json()),
|
|
110
|
+
[${JSON.stringify(url)}],
|
|
111
|
+
{ revalidate: 3600 }
|
|
112
|
+
);`,
|
|
113
|
+
route: context.route,
|
|
114
|
+
spanId: fetches[0]?.spanId,
|
|
115
|
+
attributes: {
|
|
116
|
+
url,
|
|
117
|
+
callCount: fetches.length,
|
|
118
|
+
totalDuration: Math.round(fetches.reduce((sum, f) => sum + f.duration, 0)),
|
|
119
|
+
avgDuration: Math.round(
|
|
120
|
+
fetches.reduce((sum, f) => sum + f.duration, 0) / fetches.length
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
detectedAt: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
} else if (fetches.length === 1) {
|
|
126
|
+
// Single slow fetch without cache
|
|
127
|
+
const fetch = fetches[0]!;
|
|
128
|
+
issues.push({
|
|
129
|
+
id: this.id,
|
|
130
|
+
severity: 'high',
|
|
131
|
+
message: `Fetch "${url}" has no cache → +${Math.round(fetch.duration)}ms per request`,
|
|
132
|
+
suggestion: `Add a cache directive to this fetch:
|
|
133
|
+
|
|
134
|
+
// Option 1: Force cache (best for static APIs)
|
|
135
|
+
fetch("${url}", { cache: 'force-cache' })
|
|
136
|
+
|
|
137
|
+
// Option 2: Revalidate after time (better for semi-dynamic data)
|
|
138
|
+
fetch("${url}", {
|
|
139
|
+
next: { revalidate: 3600 } // revalidate every hour
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Option 3: Combine with other optimizations
|
|
143
|
+
const data = await fetch("${url}", {
|
|
144
|
+
cache: 'force-cache',
|
|
145
|
+
headers: {
|
|
146
|
+
'User-Agent': 'NextDoctor-Agent/1.0'
|
|
147
|
+
}
|
|
148
|
+
});`,
|
|
149
|
+
route: context.route,
|
|
150
|
+
spanId: fetch.spanId,
|
|
151
|
+
attributes: {
|
|
152
|
+
url,
|
|
153
|
+
duration: Math.round(fetch.duration),
|
|
154
|
+
},
|
|
155
|
+
detectedAt: Date.now(),
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
// 2 to nPlus1Threshold-1 calls: still suspicious
|
|
159
|
+
const totalDuration = Math.round(fetches.reduce((sum, f) => sum + f.duration, 0));
|
|
160
|
+
issues.push({
|
|
161
|
+
id: this.id,
|
|
162
|
+
severity: 'high',
|
|
163
|
+
message: `Fetch "${url}" called ${fetches.length}x with no cache → ${totalDuration}ms wasted per request`,
|
|
164
|
+
suggestion: `This endpoint is being called multiple times without caching. Consider adding a cache directive or deduplicating the request:
|
|
165
|
+
|
|
166
|
+
// Option 1: Force cache
|
|
167
|
+
fetch("${url}", { cache: 'force-cache' })
|
|
168
|
+
|
|
169
|
+
// Option 2: Revalidate after time
|
|
170
|
+
fetch("${url}", { next: { revalidate: 3600 } })`,
|
|
171
|
+
route: context.route,
|
|
172
|
+
spanId: fetches[0]?.spanId,
|
|
173
|
+
attributes: {
|
|
174
|
+
url,
|
|
175
|
+
callCount: fetches.length,
|
|
176
|
+
totalDuration,
|
|
177
|
+
avgDuration: Math.round(totalDuration / fetches.length),
|
|
178
|
+
},
|
|
179
|
+
detectedAt: Date.now(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return issues;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private isInternalUrl(url: string): boolean {
|
|
188
|
+
try {
|
|
189
|
+
const parsed = new URL(url);
|
|
190
|
+
const hostname = parsed.hostname;
|
|
191
|
+
return (
|
|
192
|
+
hostname === 'localhost' ||
|
|
193
|
+
hostname === '127.0.0.1' ||
|
|
194
|
+
hostname === '::1' ||
|
|
195
|
+
hostname.endsWith('.local') ||
|
|
196
|
+
hostname.endsWith('.test') ||
|
|
197
|
+
hostname.startsWith('192.168.') ||
|
|
198
|
+
hostname.startsWith('10.')
|
|
199
|
+
);
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
import { BaseDetector } from './base-detector.js';
|
|
3
|
+
import { ColdStartThresholdDetector } from './cold-start-threshold.detector.js';
|
|
4
|
+
import { FetchNoCacheDetector } from './fetch-no-cache.detector.js';
|
|
5
|
+
import { DynamicRouteCandidateDetector } from './dynamic-route-candidate.detector.js';
|
|
6
|
+
import type { DetectedIssue, DetectorContext, IssueDeduplicationKey, DedupedIssue } from './types.js';
|
|
7
|
+
|
|
8
|
+
export class DetectionEngine {
|
|
9
|
+
private detectors: BaseDetector[];
|
|
10
|
+
private issueCache = new Map<string, DedupedIssue>();
|
|
11
|
+
private readonly dedupWindowMs = 60_000; // 60 seconds
|
|
12
|
+
|
|
13
|
+
constructor(config?: { disabledDetectors?: string[] }) {
|
|
14
|
+
const disabledSet = new Set(config?.disabledDetectors || []);
|
|
15
|
+
|
|
16
|
+
// Instantiate all detectors
|
|
17
|
+
const allDetectors: BaseDetector[] = [
|
|
18
|
+
new ColdStartThresholdDetector(),
|
|
19
|
+
new FetchNoCacheDetector(),
|
|
20
|
+
new DynamicRouteCandidateDetector(),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
this.detectors = allDetectors.filter(d => !disabledSet.has(d.id));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
analyzeSpans(spans: ReadableSpan[], context: DetectorContext): DetectedIssue[] {
|
|
27
|
+
const allIssues: DetectedIssue[] = [];
|
|
28
|
+
|
|
29
|
+
// Run all detectors
|
|
30
|
+
for (const detector of this.detectors) {
|
|
31
|
+
const result = detector.run(spans, context);
|
|
32
|
+
allIssues.push(...result.issues);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Deduplicate within the window
|
|
36
|
+
const deduped = this.deduplicateIssues(allIssues);
|
|
37
|
+
|
|
38
|
+
// Sort by severity: critical > high > warning > info
|
|
39
|
+
const severityOrder = { critical: 0, high: 1, warning: 2, info: 3 };
|
|
40
|
+
deduped.sort(
|
|
41
|
+
(a, b) =>
|
|
42
|
+
severityOrder[a.severity as keyof typeof severityOrder] -
|
|
43
|
+
severityOrder[b.severity as keyof typeof severityOrder]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return deduped;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private deduplicateIssues(issues: DetectedIssue[]): DetectedIssue[] {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const dedupedMap = new Map<string, DedupedIssue>();
|
|
52
|
+
|
|
53
|
+
// Clean old entries from cache
|
|
54
|
+
for (const [key, cached] of this.issueCache.entries()) {
|
|
55
|
+
if (now - cached.lastDetectedAt > this.dedupWindowMs) {
|
|
56
|
+
this.issueCache.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Process new issues
|
|
61
|
+
for (const issue of issues) {
|
|
62
|
+
const key = this.getDeduplicationKey(issue);
|
|
63
|
+
const existingCached = this.issueCache.get(key);
|
|
64
|
+
|
|
65
|
+
if (existingCached) {
|
|
66
|
+
// Update cache
|
|
67
|
+
existingCached.lastDetectedAt = now;
|
|
68
|
+
existingCached.count++;
|
|
69
|
+
// Don't duplicate in output if already reported recently
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// New issue - add to cache and output
|
|
74
|
+
const dedupedIssue: DedupedIssue = {
|
|
75
|
+
...issue,
|
|
76
|
+
firstDetectedAt: now,
|
|
77
|
+
lastDetectedAt: now,
|
|
78
|
+
count: 1,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
this.issueCache.set(key, dedupedIssue);
|
|
82
|
+
dedupedMap.set(key, dedupedIssue);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Array.from(dedupedMap.values());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private getDeduplicationKey(issue: DetectedIssue): string {
|
|
89
|
+
const key: IssueDeduplicationKey = {
|
|
90
|
+
id: issue.id,
|
|
91
|
+
route: issue.route,
|
|
92
|
+
};
|
|
93
|
+
return JSON.stringify(key);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getDetectorById(id: string): BaseDetector | undefined {
|
|
97
|
+
return this.detectors.find(d => d.id === id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
listDetectors(): Array<{ id: string; name: string }> {
|
|
101
|
+
return this.detectors.map(d => ({
|
|
102
|
+
id: d.id,
|
|
103
|
+
name: d.name,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
clearCache(): void {
|
|
108
|
+
this.issueCache.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getCacheSize(): number {
|
|
112
|
+
return this.issueCache.size;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Singleton instance
|
|
117
|
+
// MVP LIMITATION: In persistent Node.js environments (self-hosted), this cache
|
|
118
|
+
// is shared globally. Deduplication keys include 'id' and 'route', but lack
|
|
119
|
+
// project-level scope, which could cause cache collisions in multi-tenant usages.
|
|
120
|
+
export const detectionEngine = new DetectionEngine();
|
|
121
|
+
|
|
122
|
+
// Re-export all types and detectors for public API
|
|
123
|
+
export type { DetectedIssue, DetectorContext, DetectorResult } from './types.js';
|
|
124
|
+
export { BaseDetector } from './base-detector.js';
|
|
125
|
+
export { ColdStartThresholdDetector } from './cold-start-threshold.detector.js';
|
|
126
|
+
export { FetchNoCacheDetector } from './fetch-no-cache.detector.js';
|
|
127
|
+
export { DynamicRouteCandidateDetector } from './dynamic-route-candidate.detector.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
|
|
3
|
+
export type IssueSeverity = 'info' | 'warning' | 'high' | 'critical';
|
|
4
|
+
|
|
5
|
+
export interface DetectedIssue {
|
|
6
|
+
id: string;
|
|
7
|
+
severity: IssueSeverity;
|
|
8
|
+
message: string;
|
|
9
|
+
suggestion: string;
|
|
10
|
+
route?: string;
|
|
11
|
+
spanId?: string;
|
|
12
|
+
attributes?: Record<string, unknown>;
|
|
13
|
+
detectedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DetectorResult {
|
|
17
|
+
issues: DetectedIssue[];
|
|
18
|
+
detectorId: string;
|
|
19
|
+
analyzedSpans: number;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DetectorContext {
|
|
24
|
+
route?: string;
|
|
25
|
+
runtime: 'nodejs' | 'edge';
|
|
26
|
+
startupTimeMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IssueDeduplicationKey {
|
|
30
|
+
id: string;
|
|
31
|
+
route?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DedupedIssue extends DetectedIssue {
|
|
35
|
+
firstDetectedAt: number;
|
|
36
|
+
lastDetectedAt: number;
|
|
37
|
+
count: number;
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export {
|
|
2
|
+
initNextDoctor,
|
|
3
|
+
shutdownNextDoctor,
|
|
4
|
+
getNextDoctorAgent,
|
|
5
|
+
reportMetric,
|
|
6
|
+
getHealthStatus,
|
|
7
|
+
getDetectedIssues,
|
|
8
|
+
getSystemMetrics,
|
|
9
|
+
getSystemHealth,
|
|
10
|
+
getSystemSummary,
|
|
11
|
+
} from './init.js';
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
NextDoctorConfig,
|
|
15
|
+
DetectedIssue,
|
|
16
|
+
AgentHealth,
|
|
17
|
+
RetryPolicy,
|
|
18
|
+
ExporterConfig,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
LogLevel,
|
|
23
|
+
ExporterType,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
withNextDoctorMonitoring,
|
|
28
|
+
withNextDoctorTiming,
|
|
29
|
+
} from './middleware.js';
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
IntelligentSampler,
|
|
33
|
+
BatchProcessor,
|
|
34
|
+
CircuitBreaker,
|
|
35
|
+
} from './optimization.js';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
SystemMonitor,
|
|
39
|
+
CPUMonitor,
|
|
40
|
+
MemoryMonitor,
|
|
41
|
+
} from './system-monitor.js';
|
|
42
|
+
|
|
43
|
+
export type {
|
|
44
|
+
CPUMetrics,
|
|
45
|
+
MemoryMetrics,
|
|
46
|
+
SystemMetrics,
|
|
47
|
+
} from './system-monitor.js';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
detectionEngine,
|
|
51
|
+
ColdStartThresholdDetector,
|
|
52
|
+
FetchNoCacheDetector,
|
|
53
|
+
DynamicRouteCandidateDetector,
|
|
54
|
+
} from './detectors/index.js';
|
|
55
|
+
|
|
56
|
+
export type {
|
|
57
|
+
DetectorContext,
|
|
58
|
+
DetectorResult,
|
|
59
|
+
IssueSeverity,
|
|
60
|
+
} from './detectors/types.js';
|