fraim 2.0.103 → 2.0.105
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/dist/src/cli/commands/add-ide.js +5 -0
- package/dist/src/cli/commands/add-provider.js +23 -0
- package/dist/src/cli/commands/init-project.js +6 -0
- package/dist/src/cli/setup/claude-code-telemetry.js +59 -0
- package/dist/src/local-mcp-server/otlp-metrics-receiver.js +262 -0
- package/dist/src/local-mcp-server/prometheus-scraper.js +152 -0
- package/dist/src/local-mcp-server/stdio-server.js +22 -2
- package/dist/src/local-mcp-server/usage-collector.js +10 -1
- package/package.json +3 -2
|
@@ -45,6 +45,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
45
45
|
const ide_detector_1 = require("../setup/ide-detector");
|
|
46
46
|
const mcp_config_generator_1 = require("../setup/mcp-config-generator");
|
|
47
47
|
const codex_local_config_1 = require("../setup/codex-local-config");
|
|
48
|
+
const claude_code_telemetry_1 = require("../setup/claude-code-telemetry");
|
|
48
49
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
49
50
|
const mcp_server_registry_1 = require("../mcp/mcp-server-registry");
|
|
50
51
|
const get_provider_client_1 = require("../api/get-provider-client");
|
|
@@ -249,6 +250,10 @@ const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
|
|
|
249
250
|
const status = localResult.created ? 'Created' : localResult.updated ? 'Updated' : 'Verified';
|
|
250
251
|
console.log(chalk_1.default.green(` ✅ ${status} local ${ide.name} config: ${localResult.path}`));
|
|
251
252
|
}
|
|
253
|
+
// Enable token telemetry for Claude Code via project-level settings
|
|
254
|
+
if (ide.configType === 'claude-code') {
|
|
255
|
+
(0, claude_code_telemetry_1.ensureClaudeCodeTelemetryEnv)();
|
|
256
|
+
}
|
|
252
257
|
};
|
|
253
258
|
const listSupportedIDEs = () => {
|
|
254
259
|
const allIDEs = (0, ide_detector_1.getAllSupportedIDEs)();
|
|
@@ -9,6 +9,7 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
9
9
|
const prompts_1 = __importDefault(require("prompts"));
|
|
10
10
|
const fs_1 = __importDefault(require("fs"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const child_process_1 = require("child_process");
|
|
12
13
|
const mcp_config_generator_1 = require("../setup/mcp-config-generator");
|
|
13
14
|
const ide_detector_1 = require("../setup/ide-detector");
|
|
14
15
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
@@ -163,6 +164,28 @@ const runAddProvider = async (provider, options) => {
|
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
}
|
|
167
|
+
// Check prerequisites for the provider's MCP server command
|
|
168
|
+
const providerDefCheck = await (0, provider_registry_1.getProvider)(provider);
|
|
169
|
+
if (providerDefCheck?.mcpServer?.type === 'stdio' && providerDefCheck.mcpServer.command && !process.env.FRAIM_SKIP_PREREQ_CHECK) {
|
|
170
|
+
const cmd = providerDefCheck.mcpServer.command;
|
|
171
|
+
// Only check non-standard commands (npx/node are assumed to be available)
|
|
172
|
+
if (!['npx', 'node', 'npm'].includes(cmd)) {
|
|
173
|
+
try {
|
|
174
|
+
(0, child_process_1.execSync)(`${cmd} --version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
const installInstructions = {
|
|
178
|
+
uvx: 'Install uv first: https://docs.astral.sh/uv/getting-started/installation/\n' +
|
|
179
|
+
' macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n' +
|
|
180
|
+
' Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"',
|
|
181
|
+
};
|
|
182
|
+
console.log(chalk_1.default.red(`\n❌ "${cmd}" is required for ${providerName} but was not found on your system.\n`));
|
|
183
|
+
console.log(chalk_1.default.yellow(installInstructions[cmd] || `Please install "${cmd}" and try again.`));
|
|
184
|
+
console.log(chalk_1.default.gray('\nAfter installing, restart your terminal and run this command again.'));
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
166
189
|
// Get credentials using generic prompt system
|
|
167
190
|
try {
|
|
168
191
|
// Build provided tokens/configs from CLI options
|
|
@@ -16,6 +16,7 @@ const platform_detection_1 = require("../utils/platform-detection");
|
|
|
16
16
|
const version_utils_1 = require("../utils/version-utils");
|
|
17
17
|
const ide_detector_1 = require("../setup/ide-detector");
|
|
18
18
|
const codex_local_config_1 = require("../setup/codex-local-config");
|
|
19
|
+
const claude_code_telemetry_1 = require("../setup/claude-code-telemetry");
|
|
19
20
|
const provider_registry_1 = require("../providers/provider-registry");
|
|
20
21
|
const fraim_gitignore_1 = require("../utils/fraim-gitignore");
|
|
21
22
|
const config_writer_1 = require("../../core/config-writer");
|
|
@@ -323,6 +324,11 @@ const runInitProject = async () => {
|
|
|
323
324
|
const status = codexLocalResult.created ? 'Created' : codexLocalResult.updated ? 'Updated' : 'Verified';
|
|
324
325
|
console.log(chalk_1.default.green(`${status} project Codex config at ${codexLocalResult.path}`));
|
|
325
326
|
}
|
|
327
|
+
// Enable token telemetry for Claude Code (user-level, applies to all projects)
|
|
328
|
+
const claudeCodeAvailable = (0, ide_detector_1.detectInstalledIDEs)().some((ide) => ide.configType === 'claude-code');
|
|
329
|
+
if (claudeCodeAvailable) {
|
|
330
|
+
(0, claude_code_telemetry_1.ensureClaudeCodeTelemetryEnv)();
|
|
331
|
+
}
|
|
326
332
|
const adapterUpdates = (0, agent_adapters_1.ensureAgentAdapterFiles)(projectRoot);
|
|
327
333
|
if (adapterUpdates.length > 0) {
|
|
328
334
|
console.log(chalk_1.default.green(`Updated FRAIM agent adapter files: ${adapterUpdates.join(', ')}`));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Configures Claude Code user-level settings for FRAIM token telemetry.
|
|
4
|
+
*
|
|
5
|
+
* Writes OTel env vars to ~/.claude/settings.json so ALL Claude Code
|
|
6
|
+
* sessions push metrics to the FRAIM local proxy's OTLP receiver.
|
|
7
|
+
* User-level so it works across all projects without per-project setup.
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.ensureClaudeCodeTelemetryEnv = ensureClaudeCodeTelemetryEnv;
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const os_1 = __importDefault(require("os"));
|
|
17
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
18
|
+
const TELEMETRY_ENV = {
|
|
19
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: '1',
|
|
20
|
+
OTEL_METRICS_EXPORTER: 'otlp',
|
|
21
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json',
|
|
22
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Ensure Claude Code user-level settings include OTel env vars for token telemetry.
|
|
26
|
+
* Writes to ~/.claude/settings.json. Merges without overwriting user-set values.
|
|
27
|
+
*/
|
|
28
|
+
function ensureClaudeCodeTelemetryEnv() {
|
|
29
|
+
const settingsDir = path_1.default.join(os_1.default.homedir(), '.claude');
|
|
30
|
+
const settingsPath = path_1.default.join(settingsDir, 'settings.json');
|
|
31
|
+
let settings = {};
|
|
32
|
+
if (fs_1.default.existsSync(settingsPath)) {
|
|
33
|
+
try {
|
|
34
|
+
settings = JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Corrupt file — will overwrite
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (!fs_1.default.existsSync(settingsDir)) {
|
|
41
|
+
fs_1.default.mkdirSync(settingsDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
const existingEnv = settings.env || {};
|
|
44
|
+
let added = 0;
|
|
45
|
+
for (const [key, value] of Object.entries(TELEMETRY_ENV)) {
|
|
46
|
+
if (!(key in existingEnv)) {
|
|
47
|
+
existingEnv[key] = value;
|
|
48
|
+
added++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (added > 0) {
|
|
52
|
+
settings.env = existingEnv;
|
|
53
|
+
fs_1.default.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
54
|
+
console.log(chalk_1.default.green(` ✅ Enabled token telemetry in ~/.claude/settings.json (${added} env vars)`));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log(chalk_1.default.gray(` ⏭️ Token telemetry already configured in ~/.claude/settings.json`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight OTLP HTTP Metrics Receiver
|
|
4
|
+
*
|
|
5
|
+
* Accepts OTLP metric pushes from Claude Code when configured with:
|
|
6
|
+
* OTEL_METRICS_EXPORTER=otlp
|
|
7
|
+
* OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
|
8
|
+
* OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
9
|
+
*
|
|
10
|
+
* Multiple Claude Code sessions share one receiver on port 4318.
|
|
11
|
+
* Snapshots are stored per session ID and queryable via HTTP so any
|
|
12
|
+
* FRAIM proxy (not just the one that started the receiver) can fetch
|
|
13
|
+
* its own session's token data.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.parseOtlpMetrics = parseOtlpMetrics;
|
|
17
|
+
exports.fetchSnapshot = fetchSnapshot;
|
|
18
|
+
exports.startOtlpReceiver = startOtlpReceiver;
|
|
19
|
+
exports.stopOtlpReceiver = stopOtlpReceiver;
|
|
20
|
+
const http_1 = require("http");
|
|
21
|
+
const DEFAULT_PORT = 4318;
|
|
22
|
+
/** Stored token snapshots keyed by Claude Code session ID */
|
|
23
|
+
const snapshots = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* Parse OTLP HTTP JSON metrics payload and extract token/cost data.
|
|
26
|
+
*
|
|
27
|
+
* OTLP JSON structure:
|
|
28
|
+
* { resourceMetrics: [{ scopeMetrics: [{ metrics: [{ name, sum: { dataPoints } }] }] }] }
|
|
29
|
+
*/
|
|
30
|
+
function parseOtlpMetrics(body) {
|
|
31
|
+
if (!body?.resourceMetrics)
|
|
32
|
+
return null;
|
|
33
|
+
let inputTokens = 0;
|
|
34
|
+
let outputTokens = 0;
|
|
35
|
+
let cacheReadTokens = 0;
|
|
36
|
+
let cacheCreationTokens = 0;
|
|
37
|
+
let costUsd = 0;
|
|
38
|
+
let claudeSessionId = null;
|
|
39
|
+
let model = null;
|
|
40
|
+
let foundTokenMetric = false;
|
|
41
|
+
for (const rm of body.resourceMetrics) {
|
|
42
|
+
for (const sm of rm.scopeMetrics || []) {
|
|
43
|
+
for (const metric of sm.metrics || []) {
|
|
44
|
+
const dataPoints = metric.sum?.dataPoints || metric.gauge?.dataPoints || [];
|
|
45
|
+
if (metric.name === 'claude_code.token.usage') {
|
|
46
|
+
foundTokenMetric = true;
|
|
47
|
+
for (const dp of dataPoints) {
|
|
48
|
+
const attrs = parseAttributes(dp.attributes);
|
|
49
|
+
const value = Number(dp.asInt ?? dp.asDouble ?? dp.value ?? 0);
|
|
50
|
+
switch (attrs['type']) {
|
|
51
|
+
case 'input':
|
|
52
|
+
inputTokens += value;
|
|
53
|
+
break;
|
|
54
|
+
case 'output':
|
|
55
|
+
outputTokens += value;
|
|
56
|
+
break;
|
|
57
|
+
case 'cacheRead':
|
|
58
|
+
cacheReadTokens += value;
|
|
59
|
+
break;
|
|
60
|
+
case 'cacheCreation':
|
|
61
|
+
cacheCreationTokens += value;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
claudeSessionId = attrs['session.id'] || claudeSessionId;
|
|
65
|
+
model = attrs['model'] || model;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (metric.name === 'claude_code.cost.usage') {
|
|
69
|
+
for (const dp of dataPoints) {
|
|
70
|
+
const value = Number(dp.asDouble ?? dp.asInt ?? dp.value ?? 0);
|
|
71
|
+
costUsd += value;
|
|
72
|
+
const attrs = parseAttributes(dp.attributes);
|
|
73
|
+
claudeSessionId = attrs['session.id'] || claudeSessionId;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!foundTokenMetric)
|
|
80
|
+
return null;
|
|
81
|
+
return {
|
|
82
|
+
inputTokens,
|
|
83
|
+
outputTokens,
|
|
84
|
+
cacheReadTokens,
|
|
85
|
+
cacheCreationTokens,
|
|
86
|
+
costUsd,
|
|
87
|
+
claudeSessionId,
|
|
88
|
+
model,
|
|
89
|
+
capturedAt: new Date(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse OTLP attribute array into a key-value map.
|
|
94
|
+
* Attributes come as: [{ key: "name", value: { stringValue: "x" } }, ...]
|
|
95
|
+
*/
|
|
96
|
+
function parseAttributes(attrs) {
|
|
97
|
+
const result = {};
|
|
98
|
+
if (!Array.isArray(attrs))
|
|
99
|
+
return result;
|
|
100
|
+
for (const attr of attrs) {
|
|
101
|
+
if (attr.key && attr.value) {
|
|
102
|
+
result[attr.key] = attr.value.stringValue
|
|
103
|
+
?? attr.value.intValue
|
|
104
|
+
?? attr.value.doubleValue
|
|
105
|
+
?? String(attr.value.value ?? '');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Parse URL query string into key-value pairs.
|
|
112
|
+
*/
|
|
113
|
+
function parseQuery(url) {
|
|
114
|
+
const idx = url.indexOf('?');
|
|
115
|
+
if (idx < 0)
|
|
116
|
+
return {};
|
|
117
|
+
const params = {};
|
|
118
|
+
for (const part of url.slice(idx + 1).split('&')) {
|
|
119
|
+
const [key, val] = part.split('=');
|
|
120
|
+
if (key)
|
|
121
|
+
params[decodeURIComponent(key)] = decodeURIComponent(val || '');
|
|
122
|
+
}
|
|
123
|
+
return params;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Handle incoming HTTP requests.
|
|
127
|
+
*/
|
|
128
|
+
function handleRequest(req, res) {
|
|
129
|
+
const url = req.url || '';
|
|
130
|
+
const pathname = url.split('?')[0];
|
|
131
|
+
// OTLP metrics push endpoint
|
|
132
|
+
if (req.method === 'POST' && pathname === '/v1/metrics') {
|
|
133
|
+
let body = '';
|
|
134
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
135
|
+
req.on('end', () => {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(body);
|
|
138
|
+
const snapshot = parseOtlpMetrics(parsed);
|
|
139
|
+
if (snapshot && snapshot.claudeSessionId) {
|
|
140
|
+
snapshots.set(snapshot.claudeSessionId, snapshot);
|
|
141
|
+
}
|
|
142
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
143
|
+
res.end('{}');
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
res.writeHead(400);
|
|
147
|
+
res.end('{"error":"invalid json"}');
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Query snapshot — used by any FRAIM proxy to fetch token data
|
|
153
|
+
// ?sessionId=X returns that session's snapshot
|
|
154
|
+
// ?latest=true returns the most recently updated snapshot
|
|
155
|
+
if (req.method === 'GET' && pathname === '/v1/snapshot') {
|
|
156
|
+
const query = parseQuery(url);
|
|
157
|
+
let snapshot;
|
|
158
|
+
if (query['sessionId']) {
|
|
159
|
+
snapshot = snapshots.get(query['sessionId']);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// Return most recently captured snapshot across all sessions
|
|
163
|
+
let latest;
|
|
164
|
+
for (const s of snapshots.values()) {
|
|
165
|
+
if (!latest || s.capturedAt > latest.capturedAt) {
|
|
166
|
+
latest = s;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
snapshot = latest;
|
|
170
|
+
}
|
|
171
|
+
if (snapshot) {
|
|
172
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
173
|
+
res.end(JSON.stringify(snapshot));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end('{"error":"no snapshot available"}');
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Health check
|
|
182
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
183
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
184
|
+
res.end(JSON.stringify({
|
|
185
|
+
status: 'ok',
|
|
186
|
+
sessionCount: snapshots.size,
|
|
187
|
+
sessions: [...snapshots.keys()],
|
|
188
|
+
}));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(404);
|
|
192
|
+
res.end('');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Fetch a token snapshot from the OTLP receiver via HTTP.
|
|
196
|
+
* Works whether this process owns the receiver or another proxy does.
|
|
197
|
+
*
|
|
198
|
+
* @param sessionId If provided, fetches that specific session's snapshot.
|
|
199
|
+
* If omitted, fetches the most recently updated snapshot.
|
|
200
|
+
* @param log Optional logging function
|
|
201
|
+
*/
|
|
202
|
+
async function fetchSnapshot(sessionId, log) {
|
|
203
|
+
const port = parseInt(process.env.FRAIM_OTLP_PORT || String(DEFAULT_PORT), 10);
|
|
204
|
+
const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
|
|
205
|
+
try {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
208
|
+
const resp = await fetch(`http://127.0.0.1:${port}/v1/snapshot${query}`, { signal: controller.signal });
|
|
209
|
+
clearTimeout(timeout);
|
|
210
|
+
if (!resp.ok)
|
|
211
|
+
return null;
|
|
212
|
+
const data = await resp.json();
|
|
213
|
+
if (data.inputTokens !== undefined) {
|
|
214
|
+
return {
|
|
215
|
+
...data,
|
|
216
|
+
capturedAt: new Date(data.capturedAt),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
log?.('OTLP receiver not reachable — no token snapshot available');
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Start the OTLP metrics receiver HTTP server.
|
|
228
|
+
* Only one receiver runs per machine — if port is in use, that's fine;
|
|
229
|
+
* this proxy will query the existing receiver via HTTP.
|
|
230
|
+
*
|
|
231
|
+
* @param log Optional logging function
|
|
232
|
+
* @returns Server info if started, null if port was in use (not an error)
|
|
233
|
+
*/
|
|
234
|
+
function startOtlpReceiver(log) {
|
|
235
|
+
const port = parseInt(process.env.FRAIM_OTLP_PORT || String(DEFAULT_PORT), 10);
|
|
236
|
+
try {
|
|
237
|
+
const server = (0, http_1.createServer)(handleRequest);
|
|
238
|
+
server.on('error', (err) => {
|
|
239
|
+
if (err.code === 'EADDRINUSE') {
|
|
240
|
+
log?.(`OTLP receiver: port ${port} in use — will query existing receiver via HTTP`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
log?.(`OTLP receiver error: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
server.listen(port, '127.0.0.1', () => {
|
|
247
|
+
log?.(`OTLP metrics receiver listening on http://127.0.0.1:${port}/v1/metrics`);
|
|
248
|
+
});
|
|
249
|
+
return { server, port };
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
log?.(`Failed to start OTLP receiver: ${err.message}`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Stop the OTLP receiver and clear stored snapshots.
|
|
258
|
+
*/
|
|
259
|
+
function stopOtlpReceiver(server) {
|
|
260
|
+
server.close();
|
|
261
|
+
snapshots.clear();
|
|
262
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Prometheus Scraper for Claude Code OTel Token Usage Metrics
|
|
4
|
+
*
|
|
5
|
+
* Scrapes Claude Code's local Prometheus endpoint to capture cumulative
|
|
6
|
+
* token usage counters. Returns raw snapshot values — delta computation
|
|
7
|
+
* happens server-side per architecture constraint 15.2.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.parsePrometheusText = parsePrometheusText;
|
|
11
|
+
exports.extractTokenSnapshot = extractTokenSnapshot;
|
|
12
|
+
exports.scrapeTokenSnapshot = scrapeTokenSnapshot;
|
|
13
|
+
/** Candidate Prometheus endpoint ports (OTel SDK defaults) */
|
|
14
|
+
const CANDIDATE_PORTS = [9464, 8888];
|
|
15
|
+
/** Candidate metric names for token usage (OTel dots→underscores, optional suffixes) */
|
|
16
|
+
const TOKEN_METRIC_CANDIDATES = [
|
|
17
|
+
'claude_code_token_usage_total',
|
|
18
|
+
'claude_code_token_usage',
|
|
19
|
+
'claude_code_token_usage_tokens_total',
|
|
20
|
+
'claude_code_token_usage_tokens',
|
|
21
|
+
];
|
|
22
|
+
/** Candidate metric names for cost usage */
|
|
23
|
+
const COST_METRIC_CANDIDATES = [
|
|
24
|
+
'claude_code_cost_usage_total',
|
|
25
|
+
'claude_code_cost_usage',
|
|
26
|
+
'claude_code_cost_usage_usd_total',
|
|
27
|
+
'claude_code_cost_usage_usd',
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Parse Prometheus text exposition format into structured metrics.
|
|
31
|
+
*
|
|
32
|
+
* Format per line: metric_name{label1="val1",label2="val2"} value [timestamp]
|
|
33
|
+
* Lines starting with # are comments (HELP, TYPE). Empty lines are skipped.
|
|
34
|
+
*/
|
|
35
|
+
function parsePrometheusText(text) {
|
|
36
|
+
const results = [];
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
if (line.startsWith('#') || line.trim() === '')
|
|
39
|
+
continue;
|
|
40
|
+
// Match: metric_name{labels} value
|
|
41
|
+
const labeledMatch = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}\s+([\d.eE+-]+)/);
|
|
42
|
+
if (labeledMatch) {
|
|
43
|
+
const [, name, labelsStr, valueStr] = labeledMatch;
|
|
44
|
+
const labels = {};
|
|
45
|
+
for (const pair of labelsStr.matchAll(/(\w+)="([^"]*)"/g)) {
|
|
46
|
+
labels[pair[1]] = pair[2];
|
|
47
|
+
}
|
|
48
|
+
results.push({ name, labels, value: parseFloat(valueStr) });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Match: metric_name value (no labels)
|
|
52
|
+
const noLabelMatch = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([\d.eE+-]+)/);
|
|
53
|
+
if (noLabelMatch) {
|
|
54
|
+
const [, name, valueStr] = noLabelMatch;
|
|
55
|
+
results.push({ name, labels: {}, value: parseFloat(valueStr) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Extract a TokenSnapshot from parsed Prometheus metrics.
|
|
62
|
+
* Tries multiple candidate metric names for resilience to SDK version changes.
|
|
63
|
+
*/
|
|
64
|
+
function extractTokenSnapshot(metrics) {
|
|
65
|
+
const snapshot = {
|
|
66
|
+
inputTokens: 0,
|
|
67
|
+
outputTokens: 0,
|
|
68
|
+
cacheReadTokens: 0,
|
|
69
|
+
cacheCreationTokens: 0,
|
|
70
|
+
costUsd: 0,
|
|
71
|
+
claudeSessionId: null,
|
|
72
|
+
model: null,
|
|
73
|
+
capturedAt: new Date(),
|
|
74
|
+
};
|
|
75
|
+
for (const m of metrics) {
|
|
76
|
+
if (TOKEN_METRIC_CANDIDATES.includes(m.name)) {
|
|
77
|
+
const type = m.labels['type'];
|
|
78
|
+
switch (type) {
|
|
79
|
+
case 'input':
|
|
80
|
+
snapshot.inputTokens = m.value;
|
|
81
|
+
break;
|
|
82
|
+
case 'output':
|
|
83
|
+
snapshot.outputTokens = m.value;
|
|
84
|
+
break;
|
|
85
|
+
case 'cacheRead':
|
|
86
|
+
snapshot.cacheReadTokens = m.value;
|
|
87
|
+
break;
|
|
88
|
+
case 'cacheCreation':
|
|
89
|
+
snapshot.cacheCreationTokens = m.value;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
snapshot.claudeSessionId = m.labels['session_id'] || snapshot.claudeSessionId;
|
|
93
|
+
snapshot.model = m.labels['model'] || snapshot.model;
|
|
94
|
+
}
|
|
95
|
+
if (COST_METRIC_CANDIDATES.includes(m.name)) {
|
|
96
|
+
snapshot.costUsd += m.value;
|
|
97
|
+
snapshot.claudeSessionId = m.labels['session_id'] || snapshot.claudeSessionId;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return snapshot;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Attempt to fetch Prometheus metrics from a candidate endpoint.
|
|
104
|
+
* Returns response text or null on failure.
|
|
105
|
+
*/
|
|
106
|
+
async function tryFetch(port, timeoutMs) {
|
|
107
|
+
try {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
110
|
+
const resp = await fetch(`http://localhost:${port}/metrics`, {
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
});
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
if (resp.ok) {
|
|
115
|
+
return await resp.text();
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Scrape Claude Code's Prometheus endpoint and return a TokenSnapshot.
|
|
125
|
+
*
|
|
126
|
+
* Returns null if:
|
|
127
|
+
* - Prometheus endpoint is not available (OTel not enabled)
|
|
128
|
+
* - Scrape times out (>2s by default)
|
|
129
|
+
* - No matching token metrics found
|
|
130
|
+
*
|
|
131
|
+
* @param log Optional logging function for debug output
|
|
132
|
+
*/
|
|
133
|
+
async function scrapeTokenSnapshot(log) {
|
|
134
|
+
const configuredPort = process.env.FRAIM_PROMETHEUS_PORT;
|
|
135
|
+
const ports = configuredPort ? [parseInt(configuredPort, 10)] : CANDIDATE_PORTS;
|
|
136
|
+
const timeoutMs = 2000;
|
|
137
|
+
for (const port of ports) {
|
|
138
|
+
const text = await tryFetch(port, timeoutMs);
|
|
139
|
+
if (text && text.includes('#')) {
|
|
140
|
+
const metrics = parsePrometheusText(text);
|
|
141
|
+
const snapshot = extractTokenSnapshot(metrics);
|
|
142
|
+
// Only return if we actually found token metrics
|
|
143
|
+
if (snapshot.inputTokens > 0 || snapshot.outputTokens > 0) {
|
|
144
|
+
log?.(`Captured token snapshot from localhost:${port} (input=${snapshot.inputTokens}, output=${snapshot.outputTokens})`);
|
|
145
|
+
return snapshot;
|
|
146
|
+
}
|
|
147
|
+
log?.(`Prometheus endpoint found at localhost:${port} but no token metrics matched`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
log?.('No Prometheus endpoint available — token snapshot skipped');
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
@@ -31,6 +31,7 @@ const local_registry_resolver_1 = require("../core/utils/local-registry-resolver
|
|
|
31
31
|
const ai_mentor_1 = require("../core/ai-mentor");
|
|
32
32
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
33
33
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
34
|
+
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
34
35
|
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
35
36
|
/**
|
|
36
37
|
* Handle template substitution logic separately for better testability
|
|
@@ -398,6 +399,7 @@ class FraimLocalMCPServer {
|
|
|
398
399
|
this.machineInfo = null;
|
|
399
400
|
this.repoInfo = null;
|
|
400
401
|
this.engine = null;
|
|
402
|
+
this.otlpServer = null;
|
|
401
403
|
this.writer = writer || process.stdout.write.bind(process.stdout);
|
|
402
404
|
this.remoteUrl = process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
403
405
|
this.apiKey = this.loadApiKey();
|
|
@@ -415,6 +417,8 @@ class FraimLocalMCPServer {
|
|
|
415
417
|
// Initialize usage collector
|
|
416
418
|
this.usageCollector = new usage_collector_js_1.UsageCollector();
|
|
417
419
|
this.log('📊 Usage analytics collector initialized');
|
|
420
|
+
// Start OTLP metrics receiver for Claude Code token telemetry
|
|
421
|
+
this.otlpServer = (0, otlp_metrics_receiver_js_1.startOtlpReceiver)((msg) => this.log(`📊 ${msg}`));
|
|
418
422
|
}
|
|
419
423
|
/**
|
|
420
424
|
* Load API key from environment variable or user config file
|
|
@@ -1824,6 +1828,9 @@ class FraimLocalMCPServer {
|
|
|
1824
1828
|
const cleanup = () => {
|
|
1825
1829
|
clearInterval(uploadInterval);
|
|
1826
1830
|
this.usageCollector.shutdown();
|
|
1831
|
+
if (this.otlpServer?.server) {
|
|
1832
|
+
(0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
|
|
1833
|
+
}
|
|
1827
1834
|
};
|
|
1828
1835
|
process.stdin.on('data', async (chunk) => {
|
|
1829
1836
|
buffer += chunk;
|
|
@@ -1843,7 +1850,7 @@ class FraimLocalMCPServer {
|
|
|
1843
1850
|
// Only send response if we got one (null means we handled it internally)
|
|
1844
1851
|
if (response) {
|
|
1845
1852
|
// Collect usage for all tools/call requests before sending response
|
|
1846
|
-
this.collectUsageForResponse(message, response);
|
|
1853
|
+
await this.collectUsageForResponse(message, response);
|
|
1847
1854
|
process.stdout.write(JSON.stringify(response) + '\n');
|
|
1848
1855
|
}
|
|
1849
1856
|
}
|
|
@@ -1900,7 +1907,7 @@ class FraimLocalMCPServer {
|
|
|
1900
1907
|
/**
|
|
1901
1908
|
* Collect usage analytics for tools/call requests
|
|
1902
1909
|
*/
|
|
1903
|
-
collectUsageForResponse(request, response) {
|
|
1910
|
+
async collectUsageForResponse(request, response) {
|
|
1904
1911
|
// Only collect usage for tools/call requests
|
|
1905
1912
|
if (request.method !== 'tools/call') {
|
|
1906
1913
|
return;
|
|
@@ -1919,6 +1926,19 @@ class FraimLocalMCPServer {
|
|
|
1919
1926
|
const afterCount = this.usageCollector.getEventCount();
|
|
1920
1927
|
if (afterCount > beforeCount) {
|
|
1921
1928
|
this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
|
|
1929
|
+
// Fetch token snapshot from OTLP receiver for seekMentoring calls
|
|
1930
|
+
if (toolName === 'seekMentoring') {
|
|
1931
|
+
try {
|
|
1932
|
+
const snapshot = await (0, otlp_metrics_receiver_js_1.fetchSnapshot)(undefined, (msg) => this.log(`📊 ${msg}`));
|
|
1933
|
+
if (snapshot) {
|
|
1934
|
+
this.usageCollector.attachTokenSnapshot(snapshot);
|
|
1935
|
+
this.log(`📊 🔢 Token snapshot attached (input=${snapshot.inputTokens}, output=${snapshot.outputTokens}, session=${snapshot.claudeSessionId})`);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
catch (err) {
|
|
1939
|
+
this.log(`📊 ⚠️ Token snapshot fetch failed (non-blocking): ${err.message}`);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1922
1942
|
}
|
|
1923
1943
|
else {
|
|
1924
1944
|
this.log(`📊 ⚠️ Event not queued - tool may not be tracked: ${toolName}`);
|
|
@@ -148,7 +148,7 @@ class UsageCollector {
|
|
|
148
148
|
case 'seekMentoring':
|
|
149
149
|
return { type: 'mentoring', name: UsageCollector.resolveMentoringJobName(args), category: 'mentoring' };
|
|
150
150
|
case 'list_fraim_jobs':
|
|
151
|
-
return
|
|
151
|
+
return null; // Not a job execution — skip tracking
|
|
152
152
|
case 'fraim_connect':
|
|
153
153
|
return { type: 'session', name: 'connect' };
|
|
154
154
|
default:
|
|
@@ -163,6 +163,15 @@ class UsageCollector {
|
|
|
163
163
|
this.events = [];
|
|
164
164
|
return eventsToUpload;
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Attach a token snapshot to the most recently queued event.
|
|
168
|
+
* Used to associate Prometheus-scraped token data with seekMentoring events.
|
|
169
|
+
*/
|
|
170
|
+
attachTokenSnapshot(snapshot) {
|
|
171
|
+
if (this.events.length > 0) {
|
|
172
|
+
this.events[this.events.length - 1].tokenSnapshot = snapshot;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
166
175
|
/**
|
|
167
176
|
* Get current event count
|
|
168
177
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.105",
|
|
4
4
|
"description": "FRAIM CLI - Framework for Rigor-based AI Management (alias for fraim-framework)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -53,7 +53,8 @@
|
|
|
53
53
|
"validate:provider-action-mappings": "tsx scripts/validate-provider-action-mappings.ts",
|
|
54
54
|
"validate:fidelity": "tsx scripts/validate-fidelity.ts",
|
|
55
55
|
"validate:config-tokens": "tsx scripts/validate-config-tokens.ts",
|
|
56
|
-
"validate:template-syntax": "tsx scripts/validate-template-syntax.ts"
|
|
56
|
+
"validate:template-syntax": "tsx scripts/validate-template-syntax.ts",
|
|
57
|
+
"validate:backup": "bash scripts/backup/validate-pitr-restore.sh"
|
|
57
58
|
},
|
|
58
59
|
"repository": {
|
|
59
60
|
"type": "git",
|