proteum 2.1.3-1 → 2.1.7

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.
Files changed (95) hide show
  1. package/AGENTS.md +22 -14
  2. package/README.md +109 -17
  3. package/agents/project/AGENTS.md +188 -25
  4. package/agents/project/CODING_STYLE.md +1 -0
  5. package/agents/project/client/AGENTS.md +13 -8
  6. package/agents/project/client/pages/AGENTS.md +17 -9
  7. package/agents/project/diagnostics.md +52 -0
  8. package/agents/project/optimizations.md +48 -0
  9. package/agents/project/server/routes/AGENTS.md +9 -6
  10. package/agents/project/server/services/AGENTS.md +10 -6
  11. package/agents/project/tests/AGENTS.md +11 -5
  12. package/cli/app/config.ts +13 -14
  13. package/cli/app/index.ts +58 -0
  14. package/cli/commands/connect.ts +45 -0
  15. package/cli/commands/dev.ts +37 -13
  16. package/cli/commands/diagnose.ts +286 -0
  17. package/cli/commands/doctor.ts +18 -5
  18. package/cli/commands/explain.ts +25 -0
  19. package/cli/commands/perf.ts +243 -0
  20. package/cli/commands/trace.ts +9 -1
  21. package/cli/commands/verify.ts +281 -0
  22. package/cli/compiler/artifacts/connectedProjects.ts +453 -0
  23. package/cli/compiler/artifacts/controllers.ts +198 -49
  24. package/cli/compiler/artifacts/discovery.ts +0 -34
  25. package/cli/compiler/artifacts/manifest.ts +95 -6
  26. package/cli/compiler/artifacts/routing.ts +2 -2
  27. package/cli/compiler/artifacts/services.ts +277 -130
  28. package/cli/compiler/client/index.ts +3 -0
  29. package/cli/compiler/common/files/style.ts +52 -0
  30. package/cli/compiler/common/generatedRouteModules.ts +34 -5
  31. package/cli/compiler/common/scripts.ts +11 -5
  32. package/cli/compiler/index.ts +2 -1
  33. package/cli/compiler/server/index.ts +3 -0
  34. package/cli/presentation/commands.ts +110 -7
  35. package/cli/presentation/devSession.ts +32 -7
  36. package/cli/runtime/commands.ts +165 -6
  37. package/cli/scaffold/index.ts +18 -27
  38. package/cli/scaffold/templates.ts +48 -28
  39. package/cli/utils/agents.ts +106 -13
  40. package/cli/utils/keyboard.ts +8 -0
  41. package/client/dev/profiler/ApexChart.tsx +66 -0
  42. package/client/dev/profiler/index.tsx +2508 -302
  43. package/client/dev/profiler/runtime.noop.ts +12 -0
  44. package/client/dev/profiler/runtime.ts +195 -4
  45. package/client/services/router/request/api.ts +6 -1
  46. package/common/applicationConfig.ts +173 -0
  47. package/common/applicationConfigLoader.ts +102 -0
  48. package/common/connectedProjects.ts +113 -0
  49. package/common/dev/connect.ts +267 -0
  50. package/common/dev/console.ts +31 -0
  51. package/common/dev/contractsDoctor.ts +128 -0
  52. package/common/dev/diagnostics.ts +59 -15
  53. package/common/dev/inspection.ts +491 -0
  54. package/common/dev/performance.ts +809 -0
  55. package/common/dev/profiler.ts +3 -0
  56. package/common/dev/proteumManifest.ts +31 -6
  57. package/common/dev/requestTrace.ts +52 -1
  58. package/common/env/proteumEnv.ts +176 -50
  59. package/common/router/index.ts +1 -0
  60. package/common/router/request/api.ts +2 -0
  61. package/config.ts +5 -0
  62. package/docs/dev-commands.md +5 -1
  63. package/docs/dev-sessions.md +90 -0
  64. package/docs/diagnostics.md +74 -11
  65. package/docs/request-tracing.md +50 -3
  66. package/package.json +1 -1
  67. package/server/app/container/config.ts +16 -87
  68. package/server/app/container/console/index.ts +42 -8
  69. package/server/app/container/index.ts +10 -2
  70. package/server/app/container/trace/index.ts +105 -0
  71. package/server/app/devDiagnostics.ts +138 -0
  72. package/server/app/index.ts +18 -8
  73. package/server/app/service/container.ts +0 -12
  74. package/server/app/service/index.ts +0 -2
  75. package/server/services/prisma/index.ts +121 -4
  76. package/server/services/router/http/index.ts +305 -11
  77. package/server/services/router/index.ts +116 -57
  78. package/server/services/router/request/api.ts +160 -19
  79. package/server/services/router/request/index.ts +8 -0
  80. package/server/services/router/response/index.ts +23 -1
  81. package/server/services/router/response/page/document.tsx +31 -14
  82. package/server/services/router/response/page/index.tsx +10 -0
  83. package/agents/framework/AGENTS.md +0 -177
  84. package/server/services/auth/router/service.json +0 -6
  85. package/server/services/auth/service.json +0 -6
  86. package/server/services/cron/service.json +0 -6
  87. package/server/services/disks/drivers/local/service.json +0 -6
  88. package/server/services/disks/drivers/s3/service.json +0 -6
  89. package/server/services/disks/service.json +0 -6
  90. package/server/services/fetch/service.json +0 -7
  91. package/server/services/prisma/service.json +0 -6
  92. package/server/services/router/service.json +0 -6
  93. package/server/services/schema/router/service.json +0 -6
  94. package/server/services/schema/service.json +0 -6
  95. package/server/services/security/encrypt/aes/service.json +0 -6
@@ -0,0 +1,286 @@
1
+ import fs from 'fs-extra';
2
+ import got, { type Method } from 'got';
3
+ import path from 'path';
4
+ import { UsageError } from 'clipanion';
5
+
6
+ import cli from '..';
7
+ import { renderDoctorResponseHuman } from '../../common/dev/diagnostics';
8
+ import type { TDevConsoleLogsResponse } from '../../common/dev/console';
9
+ import type { TDiagnoseResponse, TExplainOwnerMatch } from '../../common/dev/inspection';
10
+ import type { TProteumManifest } from '../../common/dev/proteumManifest';
11
+ import type { TRequestTraceErrorResponse, TRequestTraceArmResponse } from '../../common/dev/requestTrace';
12
+ import type { TDevSessionErrorResponse, TDevSessionStartResponse } from '../../common/dev/session';
13
+ import { summarizeTraceForDiagnose } from '@common/dev/inspection';
14
+ import { readProteumManifest } from '../compiler/common/proteumManifest';
15
+
16
+ const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '');
17
+ const truncate = (value: string, max = 160) => (value.length <= max ? value : `${value.slice(0, max)}...`);
18
+ const dedupe = <TValue>(values: TValue[]) => [...new Set(values)];
19
+
20
+ const buildBaseUrlCandidates = (value: string) => {
21
+ const normalized = normalizeBaseUrl(value);
22
+
23
+ try {
24
+ const parsed = new URL(normalized);
25
+ const port = parsed.port;
26
+ const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
27
+ const search = parsed.search;
28
+ const hash = parsed.hash;
29
+ const buildUrl = (hostname: string) => `${parsed.protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
30
+
31
+ if (parsed.hostname === '127.0.0.1') return dedupe([normalized, buildUrl('localhost'), buildUrl('[::1]')]);
32
+ if (parsed.hostname === 'localhost') return dedupe([normalized, buildUrl('127.0.0.1'), buildUrl('[::1]')]);
33
+ if (parsed.hostname === '[::1]' || parsed.hostname === '::1') return dedupe([normalized, buildUrl('localhost'), buildUrl('127.0.0.1')]);
34
+ } catch (_error) {}
35
+
36
+ return [normalized];
37
+ };
38
+
39
+ const getRouterPortFromManifest = () => {
40
+ const manifestFilepath = path.join(cli.args.workdir as string, '.proteum', 'manifest.json');
41
+ if (!fs.existsSync(manifestFilepath)) return undefined;
42
+
43
+ const manifest = fs.readJsonSync(manifestFilepath, { throws: false }) as
44
+ | { env?: { resolved?: { routerPort?: number } } }
45
+ | undefined;
46
+ const port = manifest?.env?.resolved?.routerPort;
47
+
48
+ if (typeof port !== 'number' || port <= 0) return undefined;
49
+
50
+ return String(port);
51
+ };
52
+
53
+ const getRouterPort = () => {
54
+ const overridePort = typeof cli.args.port === 'string' && cli.args.port ? cli.args.port : '';
55
+ if (overridePort) return overridePort;
56
+
57
+ const envPort = process.env.PORT?.trim();
58
+ if (envPort) return envPort;
59
+
60
+ const manifestPort = getRouterPortFromManifest();
61
+ if (manifestPort) return manifestPort;
62
+
63
+ throw new UsageError(
64
+ `Could not determine the router port from PORT or .proteum/manifest.json in ${cli.args.workdir as string}. Pass --port or --url explicitly.`,
65
+ );
66
+ };
67
+
68
+ const getRouterBaseUrls = () => {
69
+ const explicitUrl = typeof cli.args.url === 'string' && cli.args.url ? cli.args.url.trim() : '';
70
+ if (explicitUrl) return buildBaseUrlCandidates(explicitUrl);
71
+
72
+ const port = getRouterPort();
73
+ return dedupe([`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://[::1]:${port}`]);
74
+ };
75
+
76
+ const getJsonErrorMessage = (body: TRequestTraceErrorResponse | TDevSessionErrorResponse | object | string | undefined, statusCode: number) => {
77
+ if (typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string') {
78
+ return body.error;
79
+ }
80
+
81
+ return `Request failed with status ${statusCode}.`;
82
+ };
83
+
84
+ const requestJson = async <TResponse>(pathname: string, options?: { json?: object; method?: 'GET' | 'POST' }) => {
85
+ const attempts: string[] = [];
86
+
87
+ for (const baseUrl of getRouterBaseUrls()) {
88
+ try {
89
+ const response = await got(`${baseUrl}${pathname}`, {
90
+ json: options?.json,
91
+ method: options?.method || 'GET',
92
+ responseType: 'json',
93
+ retry: { limit: 0 },
94
+ throwHttpErrors: false,
95
+ });
96
+
97
+ if (response.statusCode >= 400) {
98
+ throw new UsageError(getJsonErrorMessage(response.body as TRequestTraceErrorResponse | object | string | undefined, response.statusCode));
99
+ }
100
+
101
+ return { baseUrl, body: response.body as TResponse };
102
+ } catch (error) {
103
+ if (error instanceof UsageError) throw error;
104
+ attempts.push(`${baseUrl}${pathname}: ${error instanceof Error ? error.message : String(error)}`);
105
+ }
106
+ }
107
+
108
+ throw new UsageError(
109
+ [
110
+ 'Could not reach the Proteum dev diagnostics server.',
111
+ ...attempts.map((attempt) => `- ${attempt}`),
112
+ 'Make sure the app is running with `proteum dev`, or pass `--url http://host:port` if it is bound elsewhere.',
113
+ ].join('\n'),
114
+ );
115
+ };
116
+
117
+ const requestSession = async (email: string, role: string) =>
118
+ requestJson<TDevSessionStartResponse>('/__proteum/session/start', {
119
+ json: role ? { email, role } : { email },
120
+ method: 'POST',
121
+ });
122
+
123
+ const hitRequest = async ({
124
+ baseUrl,
125
+ cookieHeader,
126
+ dataJson,
127
+ method,
128
+ requestPath,
129
+ }: {
130
+ baseUrl: string;
131
+ cookieHeader?: string;
132
+ dataJson?: unknown;
133
+ method: Method;
134
+ requestPath: string;
135
+ }) => {
136
+ const targetUrl = requestPath.startsWith('http://') || requestPath.startsWith('https://') ? requestPath : `${baseUrl}${requestPath}`;
137
+ const headers = {
138
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
139
+ ...(dataJson !== undefined ? { 'Content-Type': 'application/json' } : {}),
140
+ };
141
+ const response = await got(targetUrl, {
142
+ body: dataJson !== undefined ? JSON.stringify(dataJson) : undefined,
143
+ followRedirect: false,
144
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
145
+ method,
146
+ retry: { limit: 0 },
147
+ throwHttpErrors: false,
148
+ });
149
+
150
+ return { statusCode: response.statusCode, url: targetUrl };
151
+ };
152
+
153
+ const formatSource = (match: TExplainOwnerMatch) =>
154
+ `${match.source.filepath}${match.source.line ? `:${match.source.line}` : ''}${match.source.column ? `:${match.source.column}` : ''}`;
155
+
156
+ const renderLogs = (logs: TDevConsoleLogsResponse) =>
157
+ logs.logs.length === 0
158
+ ? ['Server logs', '- none'].join('\n')
159
+ : ['Server logs', ...logs.logs.map((entry) => `- [${entry.level}] ${entry.time} ${truncate(entry.text)}`)].join('\n');
160
+
161
+ const renderOwners = (matches: TExplainOwnerMatch[]) =>
162
+ matches.length === 0
163
+ ? ['Owner matches', '- none'].join('\n')
164
+ : [
165
+ 'Owner matches',
166
+ ...matches.map((match) => `- [${match.kind}] ${match.label} score=${match.score} source=${formatSource(match)}`),
167
+ ].join('\n');
168
+
169
+ const renderSuspects = (response: TDiagnoseResponse) =>
170
+ response.suspects.length === 0
171
+ ? ['Suspects', '- none'].join('\n')
172
+ : [
173
+ 'Suspects',
174
+ ...response.suspects.map(
175
+ (suspect) =>
176
+ `- score=${suspect.score} ${suspect.filepath}${suspect.line ? `:${suspect.line}` : ''} ${suspect.label} reasons=${suspect.reasons.join(', ')}`,
177
+ ),
178
+ ].join('\n');
179
+
180
+ const renderHuman = (manifest: ReturnType<typeof readProteumManifest>, response: TDiagnoseResponse) =>
181
+ [
182
+ 'Proteum diagnose',
183
+ `- query=${response.query}`,
184
+ `- trace=${summarizeTraceForDiagnose(response.request)}`,
185
+ `- manifest=${manifest.app.identity.identifier}`,
186
+ '',
187
+ renderSuspects(response),
188
+ '',
189
+ renderOwners(response.owner.matches.slice(0, 6)),
190
+ '',
191
+ renderDoctorResponseHuman({
192
+ emptyMessage: 'No manifest diagnostics were found.',
193
+ manifest,
194
+ response: response.doctor,
195
+ title: 'Doctor',
196
+ }),
197
+ '',
198
+ renderDoctorResponseHuman({
199
+ emptyMessage: 'No contract diagnostics were found.',
200
+ manifest,
201
+ response: response.contracts,
202
+ title: 'Contracts',
203
+ }),
204
+ '',
205
+ renderLogs(response.serverLogs),
206
+ ].join('\n');
207
+
208
+ const resolveManifest = async () => {
209
+ try {
210
+ return readProteumManifest(cli.paths.appRoot);
211
+ } catch (error) {
212
+ const explicitUrl = typeof cli.args.url === 'string' && cli.args.url.trim();
213
+ if (!explicitUrl) throw error;
214
+
215
+ const explain = await requestJson<TProteumManifest>('/__proteum/explain');
216
+ return explain.body;
217
+ }
218
+ };
219
+
220
+ export const run = async () => {
221
+ const target = typeof cli.args.target === 'string' ? cli.args.target.trim() : '';
222
+ const hit = typeof cli.args.hit === 'string' ? cli.args.hit.trim() : '';
223
+ const sessionEmail = typeof cli.args.sessionEmail === 'string' ? cli.args.sessionEmail.trim() : '';
224
+ const sessionRole = typeof cli.args.sessionRole === 'string' ? cli.args.sessionRole.trim() : '';
225
+ const capture = typeof cli.args.capture === 'string' && cli.args.capture ? cli.args.capture.trim() : 'deep';
226
+ const method = typeof cli.args.method === 'string' && cli.args.method ? cli.args.method.trim().toUpperCase() : 'GET';
227
+ const logsLevel = typeof cli.args.logsLevel === 'string' && cli.args.logsLevel ? cli.args.logsLevel.trim() : 'warn';
228
+ const logsLimit = typeof cli.args.logsLimit === 'string' && cli.args.logsLimit ? cli.args.logsLimit.trim() : '40';
229
+ const shouldPrintJson = cli.args.json === true;
230
+ const hitPath = hit || (target.startsWith('/') ? target : '');
231
+ const query = target || hitPath;
232
+ let parsedDataJson: unknown;
233
+ if (typeof cli.args.dataJson === 'string' && cli.args.dataJson.trim()) {
234
+ try {
235
+ parsedDataJson = JSON.parse(cli.args.dataJson);
236
+ } catch (error) {
237
+ throw new UsageError(`Invalid --data-json payload: ${error instanceof Error ? error.message : String(error)}`);
238
+ }
239
+ }
240
+
241
+ const diagnoseRequest: Record<string, string> = {};
242
+ if (query) diagnoseRequest.query = query;
243
+ if (hitPath) diagnoseRequest.path = hitPath;
244
+ if (logsLevel) diagnoseRequest.logsLevel = logsLevel;
245
+ if (logsLimit) diagnoseRequest.logsLimit = logsLimit;
246
+
247
+ let baseUrl: string | undefined;
248
+
249
+ if (hitPath) {
250
+ const armed = await requestJson<TRequestTraceArmResponse>('/__proteum/trace/arm', {
251
+ json: { capture },
252
+ method: 'POST',
253
+ });
254
+ baseUrl = armed.baseUrl;
255
+
256
+ let cookieHeader: string | undefined;
257
+ if (sessionEmail) {
258
+ const session = await requestSession(sessionEmail, sessionRole);
259
+ baseUrl = session.baseUrl;
260
+ cookieHeader = `${session.body.session.cookieName}=${session.body.session.token}`;
261
+ }
262
+
263
+ const hitResponse = await hitRequest({
264
+ baseUrl,
265
+ cookieHeader,
266
+ dataJson: parsedDataJson,
267
+ method: method as Method,
268
+ requestPath: hitPath,
269
+ });
270
+
271
+ diagnoseRequest.path = hitPath;
272
+ if (!diagnoseRequest.query) diagnoseRequest.query = hitPath;
273
+ if (hitResponse.statusCode >= 300 && hitResponse.statusCode < 400 && !target) diagnoseRequest.query = hitPath;
274
+ }
275
+
276
+ const diagnose = await requestJson<TDiagnoseResponse>(
277
+ `/__proteum/diagnose?${new URLSearchParams(diagnoseRequest).toString()}`,
278
+ );
279
+ if (shouldPrintJson) {
280
+ console.log(JSON.stringify(diagnose.body, null, 2));
281
+ return;
282
+ }
283
+
284
+ const manifest = await resolveManifest();
285
+ console.log(renderHuman(manifest, diagnose.body));
286
+ };
@@ -1,9 +1,10 @@
1
1
  import cli from '..';
2
2
  import Compiler from '../compiler';
3
3
  import { readProteumManifest } from '../compiler/common/proteumManifest';
4
- import { buildDoctorResponse, renderDoctorHuman } from '@common/dev/diagnostics';
4
+ import { buildContractsDoctorResponse } from '@common/dev/contractsDoctor';
5
+ import { buildDoctorResponse, renderDoctorHuman, renderDoctorResponseHuman } from '@common/dev/diagnostics';
5
6
 
6
- const allowedDoctorArgs = new Set(['json', 'strict']);
7
+ const allowedDoctorArgs = new Set(['contracts', 'json', 'strict']);
7
8
 
8
9
  const validateDoctorArgs = () => {
9
10
  const enabledArgs = Object.entries(cli.args)
@@ -26,15 +27,27 @@ export const run = async (): Promise<void> => {
26
27
  await compiler.refreshGeneratedTypings();
27
28
 
28
29
  const manifest = readProteumManifest(cli.paths.appRoot);
29
- const response = buildDoctorResponse(manifest, cli.args.strict === true);
30
+ const response =
31
+ cli.args.contracts === true
32
+ ? buildContractsDoctorResponse(manifest, cli.args.strict === true)
33
+ : buildDoctorResponse(manifest, cli.args.strict === true);
30
34
 
31
35
  if (cli.args.json === true) {
32
36
  console.log(JSON.stringify(response, null, 2));
33
37
  } else {
34
- console.log(renderDoctorHuman(manifest, cli.args.strict === true));
38
+ console.log(
39
+ cli.args.contracts === true
40
+ ? renderDoctorResponseHuman({
41
+ emptyMessage: 'No contract diagnostics were found.',
42
+ manifest,
43
+ response,
44
+ title: 'Proteum doctor contracts',
45
+ })
46
+ : renderDoctorHuman(manifest, cli.args.strict === true),
47
+ );
35
48
  }
36
49
 
37
- if (cli.args.strict === true && manifest.diagnostics.length > 0) {
50
+ if (cli.args.strict === true && response.diagnostics.length > 0) {
38
51
  throw new Error(
39
52
  `Proteum doctor failed in strict mode with ${response.summary.errors} errors and ${response.summary.warnings} warnings.`,
40
53
  );
@@ -7,6 +7,7 @@ import {
7
7
  renderExplainHuman,
8
8
  type TExplainSectionName,
9
9
  } from '@common/dev/diagnostics';
10
+ import { explainOwner } from '@common/dev/inspection';
10
11
 
11
12
  const allowedExplainArgs = new Set(['json', 'all', ...explainSectionNames]);
12
13
 
@@ -37,6 +38,30 @@ export const run = async (): Promise<void> => {
37
38
  await compiler.refreshGeneratedTypings();
38
39
 
39
40
  const manifest = readProteumManifest(cli.paths.appRoot);
41
+ const ownerQuery = typeof cli.args.ownerQuery === 'string' ? cli.args.ownerQuery.trim() : '';
42
+
43
+ if (ownerQuery) {
44
+ const response = explainOwner(manifest, ownerQuery);
45
+ if (cli.args.json === true) {
46
+ console.log(JSON.stringify(response, null, 2));
47
+ return;
48
+ }
49
+
50
+ console.log(
51
+ [
52
+ 'Proteum explain owner',
53
+ `- query=${ownerQuery}`,
54
+ ...(response.matches.length === 0
55
+ ? ['- No matching manifest owners were found.']
56
+ : response.matches.map(
57
+ (match) =>
58
+ `- [${match.kind}] ${match.label} score=${match.score} source=${match.source.filepath}${match.source.line ? `:${match.source.line}` : ''}${match.source.column ? `:${match.source.column}` : ''}`,
59
+ )),
60
+ ].join('\n'),
61
+ );
62
+ return;
63
+ }
64
+
40
65
  const selectedSections = getSelectedSections();
41
66
 
42
67
  if (cli.args.json === true) {
@@ -0,0 +1,243 @@
1
+ import fs from 'fs-extra';
2
+ import got from 'got';
3
+ import path from 'path';
4
+ import { UsageError } from 'clipanion';
5
+
6
+ import cli from '..';
7
+ import type {
8
+ TPerfCompareResponse,
9
+ TPerfMemoryResponse,
10
+ TPerfRequestResponse,
11
+ TPerfTopResponse,
12
+ } from '../../common/dev/performance';
13
+
14
+ type TPerfAction = 'compare' | 'memory' | 'request' | 'top';
15
+
16
+ const allowedActions = new Set<TPerfAction>(['compare', 'memory', 'request', 'top']);
17
+ const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '');
18
+ const truncate = (value: string, max = 160) => (value.length <= max ? value : `${value.slice(0, max)}...`);
19
+ const formatDuration = (value?: number) => (value === undefined ? 'n/a' : `${Math.round(value)} ms`);
20
+ const formatBytes = (value?: number) => (value === undefined ? 'n/a' : `${(value / 1024).toFixed(value >= 1024 ? 1 : 2)} KB`);
21
+ const formatSignedBytes = (value?: number) =>
22
+ value === undefined ? 'n/a' : `${value >= 0 ? '+' : '-'}${formatBytes(Math.abs(value))}`;
23
+ const formatPercent = (value?: number) => (value === undefined ? 'n/a' : `${value >= 0 ? '+' : ''}${value.toFixed(0)}%`);
24
+
25
+ const getAction = () => {
26
+ const action = typeof cli.args.action === 'string' && cli.args.action ? cli.args.action : 'top';
27
+ if (!allowedActions.has(action as TPerfAction)) {
28
+ throw new UsageError(`Unsupported perf action "${action}". Expected one of: ${[...allowedActions].join(', ')}.`);
29
+ }
30
+
31
+ return action as TPerfAction;
32
+ };
33
+
34
+ const getRouterPortFromManifest = () => {
35
+ const manifestFilepath = path.join(cli.args.workdir as string, '.proteum', 'manifest.json');
36
+ if (!fs.existsSync(manifestFilepath)) return undefined;
37
+
38
+ const manifest = fs.readJsonSync(manifestFilepath, { throws: false }) as
39
+ | { env?: { resolved?: { routerPort?: number } } }
40
+ | undefined;
41
+ const port = manifest?.env?.resolved?.routerPort;
42
+
43
+ if (typeof port !== 'number' || port <= 0) return undefined;
44
+
45
+ return String(port);
46
+ };
47
+
48
+ const getRouterPort = () => {
49
+ const overridePort = typeof cli.args.port === 'string' && cli.args.port ? cli.args.port : '';
50
+ if (overridePort) return overridePort;
51
+
52
+ const envPort = process.env.PORT?.trim();
53
+ if (envPort) return envPort;
54
+
55
+ const manifestPort = getRouterPortFromManifest();
56
+ if (manifestPort) return manifestPort;
57
+
58
+ throw new UsageError(
59
+ `Could not determine the router port from PORT or .proteum/manifest.json in ${cli.args.workdir as string}. Pass --port or --url explicitly.`,
60
+ );
61
+ };
62
+
63
+ const getRouterBaseUrls = () => {
64
+ const explicitUrl = typeof cli.args.url === 'string' && cli.args.url ? cli.args.url.trim() : '';
65
+ if (explicitUrl) return [normalizeBaseUrl(explicitUrl)];
66
+
67
+ const port = getRouterPort();
68
+ return [...new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`])];
69
+ };
70
+
71
+ const requestJson = async <TResponse>(pathname: string) => {
72
+ const attempts: string[] = [];
73
+
74
+ for (const baseUrl of getRouterBaseUrls()) {
75
+ try {
76
+ const response = await got(`${baseUrl}${pathname}`, {
77
+ responseType: 'json',
78
+ retry: { limit: 0 },
79
+ throwHttpErrors: false,
80
+ });
81
+
82
+ if (response.statusCode >= 400) {
83
+ const body = response.body as { error?: string } | undefined;
84
+ throw new UsageError(body?.error || `Perf request failed with status ${response.statusCode}.`);
85
+ }
86
+
87
+ return response.body as TResponse;
88
+ } catch (error) {
89
+ if (error instanceof UsageError) throw error;
90
+
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ attempts.push(`${baseUrl}${pathname}: ${message}`);
93
+ }
94
+ }
95
+
96
+ throw new UsageError(
97
+ [
98
+ 'Could not reach the Proteum perf server.',
99
+ ...attempts.map((attempt) => `- ${attempt}`),
100
+ 'Make sure the app is running with `proteum dev`, or pass `--url http://host:port` if it is bound elsewhere.',
101
+ ].join('\n'),
102
+ );
103
+ };
104
+
105
+ const printJson = (value: object) => {
106
+ console.log(JSON.stringify(value, null, 2));
107
+ };
108
+
109
+ const renderWindow = (label: string, value: { startedAt: string; finishedAt: string; requestCount: number; availableRequestCount: number }) =>
110
+ `${label}=${value.requestCount}/${value.availableRequestCount} traces ${value.startedAt}..${value.finishedAt}`;
111
+
112
+ const renderTop = (response: TPerfTopResponse) =>
113
+ [
114
+ `Proteum perf top groupBy=${response.groupBy} ${renderWindow('window', response.window)}`,
115
+ `- requests=${response.summary.requestCount} errors=${response.summary.errorCount} avg=${formatDuration(response.summary.avgDurationMs)} p95=${formatDuration(response.summary.p95DurationMs)} cpu=${formatDuration(response.summary.avgCpuMs)} sql=${formatDuration(response.summary.avgSqlDurationMs)}`,
116
+ ...(response.rows.length === 0
117
+ ? ['- no traced requests matched this window']
118
+ : response.rows.map(
119
+ (row) =>
120
+ `- ${row.label} | requests=${row.requestCount} avg=${formatDuration(row.avgDurationMs)} p95=${formatDuration(row.p95DurationMs)} max=${formatDuration(row.maxDurationMs)} cpu=${formatDuration(row.avgCpuMs)} sql=${formatDuration(row.avgSqlDurationMs)} render=${formatDuration(row.avgRenderDurationMs)} heap=${formatSignedBytes(row.avgHeapDeltaBytes)} slowest=${row.slowestRequestId || 'n/a'}`,
121
+ )),
122
+ ].join('\n');
123
+
124
+ const renderCompare = (response: TPerfCompareResponse) =>
125
+ [
126
+ `Proteum perf compare groupBy=${response.groupBy}`,
127
+ `- baseline ${renderWindow('window', response.baseline)}`,
128
+ `- target ${renderWindow('window', response.target)}`,
129
+ ...(response.rows.length === 0
130
+ ? ['- no traced requests matched either window']
131
+ : response.rows.map(
132
+ (row) =>
133
+ `- [${row.change}] ${row.label} | p95 ${formatPercent(row.p95DurationMs.deltaPercent)} (${formatDuration(row.p95DurationMs.baseline)} -> ${formatDuration(row.p95DurationMs.target)}) | avg ${formatPercent(row.avgDurationMs.deltaPercent)} | cpu ${formatPercent(row.avgCpuMs.deltaPercent)} | heap ${formatSignedBytes(row.avgHeapDeltaBytes.delta)} | sql ${formatPercent(row.avgSqlDurationMs.deltaPercent)}`,
134
+ )),
135
+ ].join('\n');
136
+
137
+ const renderMemory = (response: TPerfMemoryResponse) =>
138
+ [
139
+ `Proteum perf memory groupBy=${response.groupBy} ${renderWindow('window', response.window)}`,
140
+ ...(response.rows.length === 0
141
+ ? ['- no traced requests matched this window']
142
+ : response.rows.map(
143
+ (row) =>
144
+ `- [${row.trend}] ${row.label} | requests=${row.requestCount} heap avg=${formatSignedBytes(row.avgHeapDeltaBytes)} max=${formatSignedBytes(row.maxHeapDeltaBytes)} rss avg=${formatSignedBytes(row.avgRssDeltaBytes)} drift=${formatPercent(row.positiveHeapDriftRatio * 100)}`,
145
+ )),
146
+ ].join('\n');
147
+
148
+ const renderRequest = (response: TPerfRequestResponse) =>
149
+ [
150
+ `Proteum perf request ${response.request.requestId}`,
151
+ `- ${response.request.method} ${response.request.path} status=${response.request.statusCode ?? 'pending'} route=${response.request.routeLabel} controller=${response.request.controllerLabel}`,
152
+ `- total=${formatDuration(response.request.totalDurationMs)} cpu=${formatDuration(response.request.cpuTotalMs)} sql=${formatDuration(response.request.sqlDurationMs)} calls=${formatDuration(response.request.callDurationMs)} render=${formatDuration(response.request.renderDurationMs)} self=${formatDuration(response.request.selfDurationMs)}`,
153
+ `- heap=${formatSignedBytes(response.request.heapDeltaBytes)} rss=${formatSignedBytes(response.request.rssDeltaBytes)} ssr=${formatBytes(response.request.ssrPayloadBytes)} html=${formatBytes(response.request.htmlBytes)} document=${formatBytes(response.request.documentBytes)}`,
154
+ 'Stages',
155
+ ...(response.request.stages.length === 0
156
+ ? ['- none']
157
+ : response.request.stages.map(
158
+ (stage) => `- ${stage.label} | start=+${Math.round(stage.startOffsetMs)}ms end=+${Math.round(stage.endOffsetMs)}ms duration=${formatDuration(stage.durationMs)}`,
159
+ )),
160
+ 'Hot Calls',
161
+ ...(response.request.hottestCalls.length === 0
162
+ ? ['- none']
163
+ : response.request.hottestCalls.map(
164
+ (call) =>
165
+ `- ${call.label} | duration=${formatDuration(call.durationMs)} status=${call.statusCode ?? 'pending'} origin=${call.origin}${call.errorMessage ? ` error=${truncate(call.errorMessage, 96)}` : ''}`,
166
+ )),
167
+ 'Hot SQL',
168
+ ...(response.request.hottestSqlQueries.length === 0
169
+ ? ['- none']
170
+ : response.request.hottestSqlQueries.map(
171
+ (query) =>
172
+ `- ${query.callerLabel} | ${query.operation}${query.model ? ` ${query.model}` : ''} | duration=${formatDuration(query.durationMs)} | ${truncate(query.query, 104)}`,
173
+ )),
174
+ ].join('\n');
175
+
176
+ export const run = async () => {
177
+ const action = getAction();
178
+ const shouldPrintJson = cli.args.json === true;
179
+ const groupBy = typeof cli.args.groupBy === 'string' && cli.args.groupBy ? cli.args.groupBy : 'path';
180
+ const limit =
181
+ typeof cli.args.limit === 'string' && cli.args.limit ? Math.max(1, Number.parseInt(cli.args.limit, 10) || 12) : 12;
182
+
183
+ if (action === 'top') {
184
+ const since = typeof cli.args.since === 'string' && cli.args.since ? cli.args.since : 'today';
185
+ const response = await requestJson<TPerfTopResponse>(
186
+ `/__proteum/perf/top?${new URLSearchParams({ groupBy, limit: String(limit), since }).toString()}`,
187
+ );
188
+ if (shouldPrintJson) {
189
+ printJson(response);
190
+ return;
191
+ }
192
+
193
+ console.log(renderTop(response));
194
+ return;
195
+ }
196
+
197
+ if (action === 'compare') {
198
+ const baseline = typeof cli.args.baseline === 'string' && cli.args.baseline ? cli.args.baseline : 'yesterday';
199
+ const targetWindow = typeof cli.args.targetWindow === 'string' && cli.args.targetWindow ? cli.args.targetWindow : 'today';
200
+ const response = await requestJson<TPerfCompareResponse>(
201
+ `/__proteum/perf/compare?${new URLSearchParams({
202
+ baseline,
203
+ groupBy,
204
+ limit: String(limit),
205
+ target: targetWindow,
206
+ }).toString()}`,
207
+ );
208
+ if (shouldPrintJson) {
209
+ printJson(response);
210
+ return;
211
+ }
212
+
213
+ console.log(renderCompare(response));
214
+ return;
215
+ }
216
+
217
+ if (action === 'memory') {
218
+ const since = typeof cli.args.since === 'string' && cli.args.since ? cli.args.since : 'today';
219
+ const response = await requestJson<TPerfMemoryResponse>(
220
+ `/__proteum/perf/memory?${new URLSearchParams({ groupBy, limit: String(limit), since }).toString()}`,
221
+ );
222
+ if (shouldPrintJson) {
223
+ printJson(response);
224
+ return;
225
+ }
226
+
227
+ console.log(renderMemory(response));
228
+ return;
229
+ }
230
+
231
+ const requestTarget = typeof cli.args.target === 'string' ? cli.args.target.trim() : '';
232
+ if (!requestTarget) throw new UsageError('`proteum perf request` requires a traced request id or path.');
233
+
234
+ const response = await requestJson<TPerfRequestResponse>(
235
+ `/__proteum/perf/request?${new URLSearchParams({ query: requestTarget }).toString()}`,
236
+ );
237
+ if (shouldPrintJson) {
238
+ printJson(response);
239
+ return;
240
+ }
241
+
242
+ console.log(renderRequest(response));
243
+ };
@@ -127,6 +127,7 @@ const renderTraceSummary = (request: TRequestTraceListItem) =>
127
127
  `capture=${request.capture}`,
128
128
  `events=${request.eventCount}`,
129
129
  `calls=${request.callCount}`,
130
+ `sql=${request.sqlQueryCount}`,
130
131
  request.user ? `user=${request.user}` : '',
131
132
  request.errorMessage ? `error=${request.errorMessage}` : '',
132
133
  ]
@@ -137,7 +138,7 @@ const renderTrace = (request: TRequestTrace) =>
137
138
  [
138
139
  `Request ${request.id}`,
139
140
  `- ${request.method} ${request.path} status=${request.statusCode ?? 'pending'} capture=${request.capture}`,
140
- `- started=${request.startedAt} durationMs=${request.durationMs ?? 'pending'} events=${request.events.length} dropped=${request.droppedEvents}`,
141
+ `- started=${request.startedAt} durationMs=${request.durationMs ?? 'pending'} events=${request.events.length} calls=${request.calls.length} sql=${request.sqlQueries.length} dropped=${request.droppedEvents}`,
141
142
  ...(request.user ? [`- user=${request.user}`] : []),
142
143
  ...(request.persistedFilepath ? [`- persisted=${request.persistedFilepath}`] : []),
143
144
  'Calls',
@@ -147,6 +148,13 @@ const renderTrace = (request: TRequestTrace) =>
147
148
  (call) =>
148
149
  `- ${call.origin} ${call.label} ${call.method} ${call.path} status=${call.statusCode ?? 'pending'} durationMs=${call.durationMs ?? 'pending'} req=${call.requestDataKeys.join(',')} res=${call.resultKeys.join(',')}`,
149
150
  )),
151
+ 'SQL',
152
+ ...(request.sqlQueries.length === 0
153
+ ? ['- none']
154
+ : request.sqlQueries.map(
155
+ (query) =>
156
+ `- [${query.durationMs}ms] ${query.kind} ${query.operation} ${query.callerMethod} ${query.callerPath} ${query.query}${query.paramsText ? ` params=${query.paramsText}` : ''}`,
157
+ )),
150
158
  'Events',
151
159
  ...request.events.map(
152
160
  (event) =>