@rahul05ranjan/dhruv-cli 1.3.0 → 1.5.0
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/.github/ENTERPRISE.md +275 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +45 -17
- package/.github/ISSUE_TEMPLATE/documentation_issue.md +61 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +61 -9
- package/.github/ISSUE_TEMPLATE/security_vulnerability.md +74 -0
- package/.github/dependabot.yml +42 -2
- package/.github/pull_request_template.md +13 -0
- package/.github/workflows/ci.yml +51 -38
- package/.github/workflows/contribution.yml +41 -34
- package/.github/workflows/dependabot-auto-merge.yml +62 -2
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/release.yml +229 -0
- package/.github/workflows/security.yml +201 -0
- package/.releaserc.json +50 -0
- package/AGENTS.md +13 -0
- package/CHANGELOG.md +13 -0
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +439 -0
- package/__tests__/diagnostics.test.ts +155 -0
- package/__tests__/file-workflows.test.ts +195 -0
- package/__tests__/interactive.test.ts +119 -0
- package/__tests__/setup.ts +62 -0
- package/__tests__/workflows.test.ts +118 -0
- package/dist/commands/explain.js +13 -44
- package/dist/commands/fix.js +13 -38
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +60 -55
- package/dist/commands/health.d.ts +4 -0
- package/dist/commands/health.js +419 -0
- package/dist/commands/init.js +56 -42
- package/dist/commands/menu.js +137 -24
- package/dist/commands/metrics.d.ts +5 -0
- package/dist/commands/metrics.js +80 -0
- package/dist/commands/optimize.js +42 -34
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +87 -43
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +109 -38
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +83 -0
- package/dist/commands/suggest.js +13 -39
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +53 -8
- package/dist/core/ai.d.ts +88 -2
- package/dist/core/ai.js +231 -30
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.d.ts +17 -0
- package/dist/core/command-runner.js +157 -0
- package/dist/core/logger.d.ts +40 -0
- package/dist/core/logger.js +139 -0
- package/dist/core/metrics.d.ts +62 -0
- package/dist/core/metrics.js +284 -0
- package/dist/core/prompts.d.ts +1 -0
- package/dist/core/prompts.js +121 -0
- package/dist/core/security.d.ts +34 -0
- package/dist/core/security.js +197 -0
- package/dist/index.js +84 -22
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- package/dist/utils/ux.d.ts +3 -0
- package/dist/utils/ux.js +15 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/api/.nojekyll +1 -0
- package/docs/api/assets/hierarchy.js +1 -0
- package/docs/api/assets/highlight.css +71 -0
- package/docs/api/assets/icons.js +18 -0
- package/docs/api/assets/icons.svg +1 -0
- package/docs/api/assets/main.js +60 -0
- package/docs/api/assets/navigation.js +1 -0
- package/docs/api/assets/search.js +1 -0
- package/docs/api/assets/style.css +1633 -0
- package/docs/api/hierarchy.html +1 -0
- package/docs/api/index.html +161 -0
- package/docs/api/media/CONTRIBUTING.md +60 -0
- package/docs/api/media/SECURITY.md +8 -0
- package/docs/api/media/dhruv-cli-preview.svg +42 -0
- package/docs/api/media/publishing-fix.md +34 -0
- package/docs/api/modules.html +1 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/eslint.config.js +170 -0
- package/jest.config.json +37 -0
- package/lighthouserc.json +22 -0
- package/package.json +62 -8
- package/src/commands/explain.ts +13 -42
- package/src/commands/fix.ts +13 -31
- package/src/commands/generate.ts +68 -48
- package/src/commands/health.ts +485 -0
- package/src/commands/init.ts +57 -42
- package/src/commands/menu.ts +132 -24
- package/src/commands/metrics.ts +100 -0
- package/src/commands/optimize.ts +40 -28
- package/src/commands/review.ts +96 -37
- package/src/commands/security-check.ts +127 -33
- package/src/commands/status.ts +83 -0
- package/src/commands/suggest.ts +13 -32
- package/src/config/config.ts +60 -8
- package/src/core/ai.ts +265 -26
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +185 -0
- package/src/core/logger.ts +195 -0
- package/src/core/metrics.ts +335 -0
- package/src/core/prompts.ts +128 -0
- package/src/core/security.ts +243 -0
- package/src/index.ts +90 -22
- package/src/utils/projectType.ts +22 -7
- package/src/utils/ux.ts +18 -0
- package/test-suite.sh +147 -0
- package/tsconfig.json +4 -3
- package/typedoc.json +44 -0
- package/types/global.d.ts +13 -0
- package/validate-workflows.sh +270 -0
- package/.eslintignore +0 -1
- package/.eslintrc.cjs +0 -43
- package/.github/workflows/auto-assign.yml +0 -14
- package/src/core/ai.test.js +0 -40
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command runner: everything about executing an AI-backed command lives
|
|
3
|
+
* here. Commands supply their specifics (prompt, file reading, footer text,
|
|
4
|
+
* post-processing) and call the runner through one interface; the pipeline —
|
|
5
|
+
* validation, rate limiting, spinner, header, AI call, streaming, logging,
|
|
6
|
+
* metrics, error mapping — fires identically for every command.
|
|
7
|
+
*/
|
|
8
|
+
import ora from 'ora';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { loadConfig } from '../config/config.js';
|
|
11
|
+
import { printError } from '../utils/ux.js';
|
|
12
|
+
import { ask, AIError, AIRequest } from '../core/ai.js';
|
|
13
|
+
import { logger, logCommand, logPerformance, logError } from '../core/logger.js';
|
|
14
|
+
import { metricsCollector } from '../core/metrics.js';
|
|
15
|
+
import { securityManager } from '../core/security.js';
|
|
16
|
+
|
|
17
|
+
interface CommandResult {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
command: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
response?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
hint?: string;
|
|
24
|
+
durationMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What makes a command distinct. The runner owns everything else. */
|
|
28
|
+
export interface CommandSpec {
|
|
29
|
+
/** Commander command name, for validation, logging, and metrics. */
|
|
30
|
+
name: string;
|
|
31
|
+
/** The user-facing arguments, validated as a unit. */
|
|
32
|
+
input: Record<string, string>;
|
|
33
|
+
/** Builds the AI request from validated input. */
|
|
34
|
+
buildRequest: (input: Record<string, string>, model: string) => AIRequest;
|
|
35
|
+
/** Header line under the banner, e.g. "📚 Explanation:". */
|
|
36
|
+
header: string;
|
|
37
|
+
/** Footer hint shown after success. */
|
|
38
|
+
footer?: string;
|
|
39
|
+
/** Post-processing on the full response (e.g. saving generated test files). */
|
|
40
|
+
onComplete?: (response: string, input: Record<string, string>) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Maps typed AI errors to user-facing hints — once, not per command. */
|
|
44
|
+
function describeAIError(error: unknown, model: string): string {
|
|
45
|
+
if (!error || typeof error !== 'object' || !('kind' in error)) {
|
|
46
|
+
return error instanceof Error ? error.message : String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const typedError = error as AIError;
|
|
50
|
+
switch (typedError.kind) {
|
|
51
|
+
case 'connection':
|
|
52
|
+
return `💡 Make sure Ollama is running: ollama serve`;
|
|
53
|
+
case 'model-not-found':
|
|
54
|
+
return `💡 Install the model: ollama pull ${typedError.model || model}`;
|
|
55
|
+
case 'empty-response':
|
|
56
|
+
return `💡 Model returned nothing. Install it: ollama pull ${typedError.model || model}`;
|
|
57
|
+
case 'timeout':
|
|
58
|
+
return `💡 The request timed out after ${typedError.timeoutMs}ms. Try again, use a smaller prompt, or increase --timeout.`;
|
|
59
|
+
case 'cancelled':
|
|
60
|
+
return '💡 Request cancelled. Run the command again when ready.';
|
|
61
|
+
default:
|
|
62
|
+
return typedError.cause;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runCommand(spec: CommandSpec): Promise<void> {
|
|
67
|
+
const startTime = Date.now();
|
|
68
|
+
const { name, input } = spec;
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
const jsonOutput = config.responseFormat === 'json';
|
|
71
|
+
|
|
72
|
+
const writeJson = (result: CommandResult): void => {
|
|
73
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const fail = (error: string): void => {
|
|
77
|
+
process.exitCode = 2;
|
|
78
|
+
if (jsonOutput) writeJson({ ok: false, command: name, error, model: config.model, durationMs: Date.now() - startTime });
|
|
79
|
+
else printError(error);
|
|
80
|
+
logCommand(name, startTime, false, { error });
|
|
81
|
+
metricsCollector.recordCommand(name, Date.now() - startTime, false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Validation and rate limiting — every command, uniformly.
|
|
85
|
+
const securityCheck = securityManager.validateInput(name, input);
|
|
86
|
+
if (!securityCheck.valid) {
|
|
87
|
+
fail(securityCheck.error!);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rateLimitCheck = securityManager.checkRateLimit('user');
|
|
92
|
+
if (!rateLimitCheck.allowed) {
|
|
93
|
+
fail('Rate limit exceeded. Please try again later.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const spinner = jsonOutput ? undefined : ora('Thinking...').start();
|
|
98
|
+
let requestTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
99
|
+
let sigintHandler: (() => void) | undefined;
|
|
100
|
+
try {
|
|
101
|
+
spinner?.stop();
|
|
102
|
+
|
|
103
|
+
if (!jsonOutput) {
|
|
104
|
+
console.log(chalk.yellowBright('🤖 Dhruv CLI: AI-powered developer assistant'));
|
|
105
|
+
console.log(chalk.green.bold(spec.header));
|
|
106
|
+
console.log();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let streamed = false;
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const request = {
|
|
112
|
+
...spec.buildRequest(input, config.model),
|
|
113
|
+
signal: controller.signal,
|
|
114
|
+
onToken: (token: string) => {
|
|
115
|
+
streamed = true;
|
|
116
|
+
if (!jsonOutput) process.stdout.write(token);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const aiStartTime = Date.now();
|
|
120
|
+
const responsePromise = ask(request);
|
|
121
|
+
const cancellationPromise = new Promise<string>((_, reject) => {
|
|
122
|
+
sigintHandler = () => {
|
|
123
|
+
controller.abort();
|
|
124
|
+
reject({ kind: 'cancelled' } satisfies AIError);
|
|
125
|
+
};
|
|
126
|
+
process.once('SIGINT', sigintHandler);
|
|
127
|
+
});
|
|
128
|
+
const response = config.timeoutMs > 0
|
|
129
|
+
? await Promise.race([
|
|
130
|
+
responsePromise,
|
|
131
|
+
cancellationPromise,
|
|
132
|
+
new Promise<string>((_, reject) => {
|
|
133
|
+
requestTimeout = setTimeout(() => {
|
|
134
|
+
controller.abort();
|
|
135
|
+
reject({ kind: 'timeout', timeoutMs: config.timeoutMs } satisfies AIError);
|
|
136
|
+
}, config.timeoutMs);
|
|
137
|
+
}),
|
|
138
|
+
])
|
|
139
|
+
: await Promise.race([responsePromise, cancellationPromise]);
|
|
140
|
+
if (requestTimeout) clearTimeout(requestTimeout);
|
|
141
|
+
if (sigintHandler) process.removeListener('SIGINT', sigintHandler);
|
|
142
|
+
if (!response.trim()) {
|
|
143
|
+
throw { kind: 'empty-response', model: config.model } satisfies AIError;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const durationMs = Date.now() - startTime;
|
|
147
|
+
if (jsonOutput) {
|
|
148
|
+
writeJson({ ok: true, command: name, model: config.model, response, durationMs });
|
|
149
|
+
} else {
|
|
150
|
+
if (!streamed) process.stdout.write(response);
|
|
151
|
+
process.stdout.write('\n');
|
|
152
|
+
console.log('\n');
|
|
153
|
+
if (spec.footer) console.log(chalk.dim(spec.footer));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (spec.onComplete) spec.onComplete(response, input);
|
|
157
|
+
|
|
158
|
+
metricsCollector.recordAIRequest(config.model, name, Date.now() - aiStartTime, true);
|
|
159
|
+
logCommand(name, startTime, true, { model: config.model });
|
|
160
|
+
logPerformance(name, durationMs);
|
|
161
|
+
metricsCollector.recordCommand(name, durationMs, true);
|
|
162
|
+
logger.info(`${name} command completed successfully`, { duration: durationMs });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
spinner?.stop();
|
|
165
|
+
if (requestTimeout) clearTimeout(requestTimeout);
|
|
166
|
+
if (sigintHandler) process.removeListener('SIGINT', sigintHandler);
|
|
167
|
+
const cancelled = typeof err === 'object' && err !== null && 'kind' in err && (err as { kind?: string }).kind === 'cancelled';
|
|
168
|
+
process.exitCode = cancelled ? 130 : 1;
|
|
169
|
+
const duration = Date.now() - startTime;
|
|
170
|
+
const hint = describeAIError(err as AIError, config.model);
|
|
171
|
+
metricsCollector.recordAIRequest(config.model, name, duration, false);
|
|
172
|
+
|
|
173
|
+
if (jsonOutput) {
|
|
174
|
+
writeJson({ ok: false, command: name, model: config.model, error: 'Command failed.', hint, durationMs: duration });
|
|
175
|
+
} else {
|
|
176
|
+
printError(`Command failed.`);
|
|
177
|
+
console.log(chalk.yellow(hint));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
logError(`${name} command failed`, err as Error, { command: name });
|
|
181
|
+
logCommand(name, startTime, false, { error: (err as Error).message });
|
|
182
|
+
metricsCollector.recordCommand(name, duration, false);
|
|
183
|
+
metricsCollector.recordError('ai_request_failed', name);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
import DailyRotateFile from 'winston-daily-rotate-file';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { loadConfig } from '../config/config.js';
|
|
6
|
+
|
|
7
|
+
export interface LogEntry {
|
|
8
|
+
timestamp: string;
|
|
9
|
+
level: 'error' | 'warn' | 'info' | 'debug';
|
|
10
|
+
message: string;
|
|
11
|
+
command?: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
duration?: number;
|
|
15
|
+
metadata?: Record<string, any>;
|
|
16
|
+
error?: {
|
|
17
|
+
name: string;
|
|
18
|
+
message: string;
|
|
19
|
+
stack?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class Logger {
|
|
24
|
+
private logger: winston.Logger;
|
|
25
|
+
private sessionId: string;
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
this.sessionId = this.generateSessionId();
|
|
29
|
+
this.logger = this.createLogger();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private generateSessionId(): string {
|
|
33
|
+
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private createLogger(): winston.Logger {
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
const logDir = path.join(process.cwd(), 'logs');
|
|
39
|
+
|
|
40
|
+
// Ensure log directory exists
|
|
41
|
+
if (!fs.existsSync(logDir)) {
|
|
42
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const logFormat = winston.format.combine(
|
|
46
|
+
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
47
|
+
winston.format.errors({ stack: true }),
|
|
48
|
+
winston.format.json(),
|
|
49
|
+
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
timestamp,
|
|
52
|
+
level,
|
|
53
|
+
message,
|
|
54
|
+
sessionId: this.sessionId,
|
|
55
|
+
...meta
|
|
56
|
+
});
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const transports: winston.transport[] = [
|
|
61
|
+
// Console transport for development
|
|
62
|
+
new winston.transports.Console({
|
|
63
|
+
level: config.verbose ? 'debug' : 'info',
|
|
64
|
+
stderrLevels: ['error', 'warn', 'info', 'debug'],
|
|
65
|
+
format: winston.format.combine(
|
|
66
|
+
winston.format.colorize(),
|
|
67
|
+
winston.format.simple(),
|
|
68
|
+
winston.format.printf(({ timestamp, level, message }) => {
|
|
69
|
+
return `${timestamp} ${level}: ${message}`;
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
// Daily rotating file for all logs
|
|
75
|
+
new DailyRotateFile({
|
|
76
|
+
filename: path.join(logDir, 'dhruv-%DATE%.log'),
|
|
77
|
+
datePattern: 'YYYY-MM-DD',
|
|
78
|
+
maxSize: '20m',
|
|
79
|
+
maxFiles: '14d',
|
|
80
|
+
level: 'debug'
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
// Separate error log
|
|
84
|
+
new DailyRotateFile({
|
|
85
|
+
filename: path.join(logDir, 'dhruv-error-%DATE%.log'),
|
|
86
|
+
datePattern: 'YYYY-MM-DD',
|
|
87
|
+
maxSize: '20m',
|
|
88
|
+
maxFiles: '30d',
|
|
89
|
+
level: 'error'
|
|
90
|
+
})
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
return winston.createLogger({
|
|
94
|
+
level: 'debug',
|
|
95
|
+
format: logFormat,
|
|
96
|
+
defaultMeta: { sessionId: this.sessionId },
|
|
97
|
+
transports
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public info(message: string, meta?: Record<string, any>): void {
|
|
102
|
+
this.logger.info(message, meta);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public warn(message: string, meta?: Record<string, any>): void {
|
|
106
|
+
this.logger.warn(message, meta);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public error(message: string, error?: Error, meta?: Record<string, any>): void {
|
|
110
|
+
const errorInfo = error ? {
|
|
111
|
+
name: error.name,
|
|
112
|
+
message: error.message,
|
|
113
|
+
stack: error.stack
|
|
114
|
+
} : undefined;
|
|
115
|
+
|
|
116
|
+
this.logger.error(message, { error: errorInfo, ...meta });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public debug(message: string, meta?: Record<string, any>): void {
|
|
120
|
+
this.logger.debug(message, meta);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public command(command: string, startTime: number, success: boolean, meta?: Record<string, any>): void {
|
|
124
|
+
const duration = Date.now() - startTime;
|
|
125
|
+
const level = success ? 'info' : 'error';
|
|
126
|
+
const message = `Command ${command} ${success ? 'completed' : 'failed'} in ${duration}ms`;
|
|
127
|
+
|
|
128
|
+
this.logger.log(level, message, {
|
|
129
|
+
command,
|
|
130
|
+
duration,
|
|
131
|
+
success,
|
|
132
|
+
...meta
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public performance(metric: string, value: number, unit: string = 'ms', meta?: Record<string, any>): void {
|
|
137
|
+
this.logger.info(`Performance: ${metric} = ${value}${unit}`, {
|
|
138
|
+
metric,
|
|
139
|
+
value,
|
|
140
|
+
unit,
|
|
141
|
+
...meta
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public security(event: string, details: Record<string, any>): void {
|
|
146
|
+
this.logger.warn(`Security Event: ${event}`, {
|
|
147
|
+
security: true,
|
|
148
|
+
event,
|
|
149
|
+
...details
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public getSessionId(): string {
|
|
154
|
+
return this.sessionId;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public async flush(): Promise<void> {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
this.logger.on('finish', resolve);
|
|
160
|
+
this.logger.end();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Export singleton instance
|
|
166
|
+
export const logger = new Logger();
|
|
167
|
+
|
|
168
|
+
// Helper functions for common logging patterns
|
|
169
|
+
export const logCommand = (command: string, startTime: number, success: boolean, meta?: Record<string, any>) => {
|
|
170
|
+
logger.command(command, startTime, success, meta);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const logPerformance = (metric: string, value: number, unit: string = 'ms', meta?: Record<string, any>) => {
|
|
174
|
+
logger.performance(metric, value, unit, meta);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const logSecurity = (event: string, details: Record<string, any>) => {
|
|
178
|
+
logger.security(event, details);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const logError = (message: string, error?: Error, meta?: Record<string, any>) => {
|
|
182
|
+
logger.error(message, error, meta);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const logInfo = (message: string, meta?: Record<string, any>) => {
|
|
186
|
+
logger.info(message, meta);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const logWarn = (message: string, meta?: Record<string, any>) => {
|
|
190
|
+
logger.warn(message, meta);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const logDebug = (message: string, meta?: Record<string, any>) => {
|
|
194
|
+
logger.debug(message, meta);
|
|
195
|
+
};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import promClient from 'prom-client';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export interface CommandSummary {
|
|
7
|
+
runs: number;
|
|
8
|
+
successes: number;
|
|
9
|
+
failures: number;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ModelSummary {
|
|
14
|
+
requests: number;
|
|
15
|
+
successes: number;
|
|
16
|
+
failures: number;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CacheSummary {
|
|
21
|
+
hits: number;
|
|
22
|
+
misses: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MetricsSummary {
|
|
26
|
+
sessions: number;
|
|
27
|
+
commands: Record<string, CommandSummary>;
|
|
28
|
+
errors: Record<string, number>;
|
|
29
|
+
models: Record<string, ModelSummary>;
|
|
30
|
+
cache: CacheSummary;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emptySummary(): MetricsSummary {
|
|
34
|
+
return { sessions: 0, commands: {}, errors: {}, models: {}, cache: { hits: 0, misses: 0 } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Create a Registry which registers the metrics
|
|
38
|
+
const register = new promClient.Registry();
|
|
39
|
+
|
|
40
|
+
// Add a default label which is added to all metrics
|
|
41
|
+
register.setDefaultLabels({
|
|
42
|
+
app: 'dhruv-cli'
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Enable the collection of default metrics
|
|
46
|
+
promClient.collectDefaultMetrics({ register });
|
|
47
|
+
|
|
48
|
+
// Custom metrics
|
|
49
|
+
export const metrics = {
|
|
50
|
+
// Command execution metrics
|
|
51
|
+
commandDuration: new promClient.Histogram({
|
|
52
|
+
name: 'dhruv_command_duration_seconds',
|
|
53
|
+
help: 'Duration of command execution in seconds',
|
|
54
|
+
labelNames: ['command', 'success'],
|
|
55
|
+
buckets: [0.1, 0.5, 1, 2, 5, 10, 30]
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
commandCount: new promClient.Counter({
|
|
59
|
+
name: 'dhruv_command_total',
|
|
60
|
+
help: 'Total number of commands executed',
|
|
61
|
+
labelNames: ['command', 'success']
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
// AI service metrics
|
|
65
|
+
aiRequestDuration: new promClient.Histogram({
|
|
66
|
+
name: 'dhruv_ai_request_duration_seconds',
|
|
67
|
+
help: 'Duration of AI requests in seconds',
|
|
68
|
+
labelNames: ['model', 'command_type'],
|
|
69
|
+
buckets: [1, 5, 10, 30, 60, 120]
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
aiRequestCount: new promClient.Counter({
|
|
73
|
+
name: 'dhruv_ai_request_total',
|
|
74
|
+
help: 'Total number of AI requests',
|
|
75
|
+
labelNames: ['model', 'command_type', 'success']
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
aiTokensUsed: new promClient.Counter({
|
|
79
|
+
name: 'dhruv_ai_tokens_total',
|
|
80
|
+
help: 'Total number of tokens used in AI requests',
|
|
81
|
+
labelNames: ['model', 'command_type']
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
// Cache metrics
|
|
85
|
+
cacheHitCount: new promClient.Counter({
|
|
86
|
+
name: 'dhruv_cache_hit_total',
|
|
87
|
+
help: 'Total number of cache hits',
|
|
88
|
+
labelNames: ['cache_type']
|
|
89
|
+
}),
|
|
90
|
+
|
|
91
|
+
cacheMissCount: new promClient.Counter({
|
|
92
|
+
name: 'dhruv_cache_miss_total',
|
|
93
|
+
help: 'Total number of cache misses',
|
|
94
|
+
labelNames: ['cache_type']
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
cacheSize: new promClient.Gauge({
|
|
98
|
+
name: 'dhruv_cache_size_bytes',
|
|
99
|
+
help: 'Current size of cache in bytes',
|
|
100
|
+
labelNames: ['cache_type']
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
// Error metrics
|
|
104
|
+
errorCount: new promClient.Counter({
|
|
105
|
+
name: 'dhruv_error_total',
|
|
106
|
+
help: 'Total number of errors',
|
|
107
|
+
labelNames: ['error_type', 'command']
|
|
108
|
+
}),
|
|
109
|
+
|
|
110
|
+
// Performance metrics
|
|
111
|
+
memoryUsage: new promClient.Gauge({
|
|
112
|
+
name: 'dhruv_memory_usage_bytes',
|
|
113
|
+
help: 'Current memory usage in bytes',
|
|
114
|
+
labelNames: ['type']
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
// User engagement metrics
|
|
118
|
+
sessionCount: new promClient.Counter({
|
|
119
|
+
name: 'dhruv_session_total',
|
|
120
|
+
help: 'Total number of user sessions'
|
|
121
|
+
}),
|
|
122
|
+
|
|
123
|
+
pluginLoadedCount: new promClient.Counter({
|
|
124
|
+
name: 'dhruv_plugin_loaded_total',
|
|
125
|
+
help: 'Total number of plugins loaded'
|
|
126
|
+
})
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Register all metrics
|
|
130
|
+
Object.values(metrics).forEach(metric => {
|
|
131
|
+
register.registerMetric(metric);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export class MetricsCollector {
|
|
135
|
+
private static instance: MetricsCollector;
|
|
136
|
+
private metricsEnabled: boolean;
|
|
137
|
+
|
|
138
|
+
private constructor() {
|
|
139
|
+
this.metricsEnabled = process.env.DHRUV_METRICS_ENABLED !== 'false';
|
|
140
|
+
if (this.metricsEnabled) {
|
|
141
|
+
logger.info('Metrics collection enabled');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public static getInstance(): MetricsCollector {
|
|
146
|
+
if (!MetricsCollector.instance) {
|
|
147
|
+
MetricsCollector.instance = new MetricsCollector();
|
|
148
|
+
}
|
|
149
|
+
return MetricsCollector.instance;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public recordCommand(command: string, duration: number, success: boolean): void {
|
|
153
|
+
if (!this.metricsEnabled) return;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
metrics.commandDuration.observe({ command, success: success.toString() }, duration / 1000);
|
|
157
|
+
metrics.commandCount.inc({ command, success: success.toString() });
|
|
158
|
+
this.updatePersistent((summary) => {
|
|
159
|
+
const current = summary.commands[command] ?? { runs: 0, successes: 0, failures: 0, durationMs: 0 };
|
|
160
|
+
current.runs += 1;
|
|
161
|
+
if (success) current.successes += 1;
|
|
162
|
+
else current.failures += 1;
|
|
163
|
+
current.durationMs += duration;
|
|
164
|
+
summary.commands[command] = current;
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
logger.error('Failed to record command metrics', error as Error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public recordAIRequest(model: string, commandType: string, duration: number, success: boolean, tokensUsed?: number): void {
|
|
172
|
+
if (!this.metricsEnabled) return;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
metrics.aiRequestDuration.observe({ model, command_type: commandType }, duration / 1000);
|
|
176
|
+
metrics.aiRequestCount.inc({ model, command_type: commandType, success: success.toString() });
|
|
177
|
+
|
|
178
|
+
if (tokensUsed) {
|
|
179
|
+
metrics.aiTokensUsed.inc({ model, command_type: commandType }, tokensUsed);
|
|
180
|
+
}
|
|
181
|
+
this.updatePersistent((summary) => {
|
|
182
|
+
const current = summary.models[model] ?? { requests: 0, successes: 0, failures: 0, durationMs: 0 };
|
|
183
|
+
current.requests += 1;
|
|
184
|
+
if (success) current.successes += 1;
|
|
185
|
+
else current.failures += 1;
|
|
186
|
+
current.durationMs += duration;
|
|
187
|
+
summary.models[model] = current;
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
logger.error('Failed to record AI request metrics', error as Error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public recordCacheHit(cacheType: string): void {
|
|
195
|
+
if (!this.metricsEnabled) return;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
metrics.cacheHitCount.inc({ cache_type: cacheType });
|
|
199
|
+
this.updatePersistent((summary) => {
|
|
200
|
+
summary.cache.hits += 1;
|
|
201
|
+
});
|
|
202
|
+
} catch (error) {
|
|
203
|
+
logger.error('Failed to record cache hit metrics', error as Error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public recordCacheMiss(cacheType: string): void {
|
|
208
|
+
if (!this.metricsEnabled) return;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
metrics.cacheMissCount.inc({ cache_type: cacheType });
|
|
212
|
+
this.updatePersistent((summary) => {
|
|
213
|
+
summary.cache.misses += 1;
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
logger.error('Failed to record cache miss metrics', error as Error);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
public updateCacheSize(cacheType: string, size: number): void {
|
|
221
|
+
if (!this.metricsEnabled) return;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
metrics.cacheSize.set({ cache_type: cacheType }, size);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
logger.error('Failed to update cache size metrics', error as Error);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
public recordError(errorType: string, command?: string): void {
|
|
231
|
+
if (!this.metricsEnabled) return;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
metrics.errorCount.inc({ error_type: errorType, command: command || 'unknown' });
|
|
235
|
+
this.updatePersistent((summary) => {
|
|
236
|
+
const key = command ? `${errorType}:${command}` : errorType;
|
|
237
|
+
summary.errors[key] = (summary.errors[key] ?? 0) + 1;
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
logger.error('Failed to record error metrics', error as Error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public updateMemoryUsage(): void {
|
|
245
|
+
if (!this.metricsEnabled) return;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const memUsage = process.memoryUsage();
|
|
249
|
+
metrics.memoryUsage.set({ type: 'rss' }, memUsage.rss);
|
|
250
|
+
metrics.memoryUsage.set({ type: 'heap_used' }, memUsage.heapUsed);
|
|
251
|
+
metrics.memoryUsage.set({ type: 'heap_total' }, memUsage.heapTotal);
|
|
252
|
+
metrics.memoryUsage.set({ type: 'external' }, memUsage.external);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
logger.error('Failed to update memory usage metrics', error as Error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
public recordSession(): void {
|
|
259
|
+
if (!this.metricsEnabled) return;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
metrics.sessionCount.inc();
|
|
263
|
+
this.updatePersistent((summary) => {
|
|
264
|
+
summary.sessions += 1;
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
logger.error('Failed to record session metrics', error as Error);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
public recordPluginLoaded(): void {
|
|
272
|
+
if (!this.metricsEnabled) return;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
metrics.pluginLoadedCount.inc();
|
|
276
|
+
} catch (error) {
|
|
277
|
+
logger.error('Failed to record plugin loaded metrics', error as Error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
public getSummary(): MetricsSummary {
|
|
282
|
+
return this.readPersistent();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
public resetPersistent(): void {
|
|
286
|
+
try {
|
|
287
|
+
fs.unlinkSync(this.persistentPath());
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private persistentPath(): string {
|
|
294
|
+
return path.join(process.cwd(), '.dhruv-metrics.json');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private readPersistent(): MetricsSummary {
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(fs.readFileSync(this.persistentPath(), 'utf8')) as Partial<MetricsSummary>;
|
|
300
|
+
return {
|
|
301
|
+
sessions: typeof parsed.sessions === 'number' ? parsed.sessions : 0,
|
|
302
|
+
commands: parsed.commands ?? {},
|
|
303
|
+
errors: parsed.errors ?? {},
|
|
304
|
+
models: parsed.models ?? {},
|
|
305
|
+
cache: parsed.cache ?? { hits: 0, misses: 0 },
|
|
306
|
+
};
|
|
307
|
+
} catch {
|
|
308
|
+
return emptySummary();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private updatePersistent(update: (summary: MetricsSummary) => void): void {
|
|
313
|
+
try {
|
|
314
|
+
const summary = this.readPersistent();
|
|
315
|
+
update(summary);
|
|
316
|
+
fs.writeFileSync(this.persistentPath(), JSON.stringify(summary, null, 2));
|
|
317
|
+
} catch (error) {
|
|
318
|
+
logger.debug('Failed to persist metrics', { error: (error as Error).message });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
public async getMetrics(): Promise<string> {
|
|
323
|
+
return register.metrics();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
public async getMetricsJSON(): Promise<promClient.MetricObjectWithValues<promClient.MetricValue<string>>[]> {
|
|
327
|
+
return register.getMetricsAsJSON();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
public getRegistry(): promClient.Registry {
|
|
331
|
+
return register;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const metricsCollector = MetricsCollector.getInstance();
|