cchubber 0.3.2 → 0.3.4
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/README.md +3 -0
- package/package.json +1 -1
- package/src/telemetry.js +239 -3
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# CC Hubber
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/cchubber)
|
|
4
|
+
[](https://www.npmjs.com/package/cchubber)
|
|
5
|
+
|
|
3
6
|
Your Claude Code usage, diagnosed. One command.
|
|
4
7
|
|
|
5
8
|
```bash
|
package/package.json
CHANGED
package/src/telemetry.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import https from 'https';
|
|
2
|
-
import { platform, arch } from 'os';
|
|
2
|
+
import { platform, arch, homedir } from 'os';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
3
5
|
|
|
4
6
|
// Anonymous usage telemetry — no PII, no tokens, no file contents.
|
|
5
7
|
// Opt out: npx cchubber --no-telemetry
|
|
@@ -16,7 +18,8 @@ export function shouldSendTelemetry(flags) {
|
|
|
16
18
|
|
|
17
19
|
export function sendTelemetry(report) {
|
|
18
20
|
const payload = {
|
|
19
|
-
v: '0.3.
|
|
21
|
+
v: '0.3.3',
|
|
22
|
+
uid: getOrCreateUID(),
|
|
20
23
|
ts: new Date().toISOString(),
|
|
21
24
|
os: platform(),
|
|
22
25
|
arch: arch(),
|
|
@@ -75,10 +78,42 @@ export function sendTelemetry(report) {
|
|
|
75
78
|
entryCount: report.costAnalysis?.dailyCosts?.length || 0,
|
|
76
79
|
recCount: report.recommendations?.length || 0,
|
|
77
80
|
|
|
78
|
-
// Rate limits (
|
|
81
|
+
// Rate limits (shows subscription tier indirectly)
|
|
79
82
|
hasOauth: !!report.oauthUsage,
|
|
80
83
|
rateLimit5h: report.oauthUsage?.five_hour?.utilization || null,
|
|
81
84
|
rateLimit7d: report.oauthUsage?.seven_day?.utilization || null,
|
|
85
|
+
|
|
86
|
+
// Token volumes (bucketed for anonymity)
|
|
87
|
+
totalInputBucket: tokenBucket(report.cacheHealth?.totals?.input || 0),
|
|
88
|
+
totalOutputBucket: tokenBucket(report.cacheHealth?.totals?.output || 0),
|
|
89
|
+
totalCacheReadBucket: tokenBucket(report.cacheHealth?.totals?.cacheRead || 0),
|
|
90
|
+
totalCacheWriteBucket: tokenBucket(report.cacheHealth?.totals?.cacheWrite || 0),
|
|
91
|
+
|
|
92
|
+
// Hour distribution (when people work — 24 values)
|
|
93
|
+
hourDistribution: report.sessionIntel?.hourDistribution || [],
|
|
94
|
+
|
|
95
|
+
// Which recommendations fired (shows common problems)
|
|
96
|
+
recsTriggered: (report.recommendations || []).map(r => r.title.slice(0, 50)),
|
|
97
|
+
|
|
98
|
+
// CLAUDE.md top sections by tokens (what people put in their rules)
|
|
99
|
+
claudeMdTopSections: (report.claudeMdStack?.globalSections || []).slice(0, 5).map(s => ({
|
|
100
|
+
name: s.name.slice(0, 40),
|
|
101
|
+
tokens: s.tokens,
|
|
102
|
+
lines: s.lines,
|
|
103
|
+
})),
|
|
104
|
+
|
|
105
|
+
// Per-message cost impact
|
|
106
|
+
msgCostCached: report.claudeMdStack?.costPerMessage?.cached || 0,
|
|
107
|
+
msgCostUncached: report.claudeMdStack?.costPerMessage?.uncached || 0,
|
|
108
|
+
dailyCost200: report.claudeMdStack?.costPerMessage?.dailyCached200 || 0,
|
|
109
|
+
|
|
110
|
+
// Daily cost trend (last 30 days — shows impact curve)
|
|
111
|
+
dailyCostTrend: (report.costAnalysis?.dailyCosts || []).slice(-30).map(d => ({
|
|
112
|
+
d: d.date, c: Math.round(d.cost * 100) / 100, r: d.cacheOutputRatio || 0
|
|
113
|
+
})),
|
|
114
|
+
|
|
115
|
+
// Environment deep dive
|
|
116
|
+
...gatherEnvironmentData(),
|
|
82
117
|
};
|
|
83
118
|
|
|
84
119
|
// Fire and forget — never blocks the CLI
|
|
@@ -111,6 +146,207 @@ function costBucket(cost) {
|
|
|
111
146
|
return '5K+';
|
|
112
147
|
}
|
|
113
148
|
|
|
149
|
+
function getOrCreateUID() {
|
|
150
|
+
// Anonymous install ID — random, no PII. Same approach as Next.js/Turborepo telemetry.
|
|
151
|
+
const idFile = join(homedir(), '.cchubber-uid');
|
|
152
|
+
try {
|
|
153
|
+
if (existsSync(idFile)) return readFileSync(idFile, 'utf-8').trim();
|
|
154
|
+
const uid = 'u_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
155
|
+
writeFileSync(idFile, uid);
|
|
156
|
+
return uid;
|
|
157
|
+
} catch {
|
|
158
|
+
return 'anon_' + Math.random().toString(36).slice(2, 10);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function gatherEnvironmentData() {
|
|
163
|
+
const home = homedir();
|
|
164
|
+
const claudeDir = join(home, '.claude');
|
|
165
|
+
const data = {};
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Claude Code version (from package or binary)
|
|
169
|
+
const statsCache = join(claudeDir, 'stats-cache.json');
|
|
170
|
+
if (existsSync(statsCache)) {
|
|
171
|
+
const raw = JSON.parse(readFileSync(statsCache, 'utf-8'));
|
|
172
|
+
data.ccVersion = raw.version || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// MCP servers (from settings - which tools people connect)
|
|
176
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
177
|
+
if (existsSync(settingsPath)) {
|
|
178
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
179
|
+
data.hasSettings = true;
|
|
180
|
+
data.settingsSize = statSync(settingsPath).size;
|
|
181
|
+
|
|
182
|
+
// MCP server names (popular tool ecosystem data)
|
|
183
|
+
if (settings.mcpServers) {
|
|
184
|
+
const mcpNames = Object.keys(settings.mcpServers);
|
|
185
|
+
data.mcpServerCount = mcpNames.length;
|
|
186
|
+
data.mcpServers = mcpNames; // which MCP tools are popular
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Hooks configured?
|
|
190
|
+
data.hasHooks = !!(settings.hooks && Object.keys(settings.hooks).length > 0);
|
|
191
|
+
data.hookCount = settings.hooks ? Object.values(settings.hooks).flat().length : 0;
|
|
192
|
+
|
|
193
|
+
// Custom model preferences
|
|
194
|
+
data.defaultModel = settings.model || null;
|
|
195
|
+
data.hasCustomPermissions = !!settings.permissions;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Project-level settings (from .claude.json in cwd)
|
|
199
|
+
const localSettings = join(process.cwd(), '.claude.json');
|
|
200
|
+
if (existsSync(localSettings)) {
|
|
201
|
+
try {
|
|
202
|
+
const local = JSON.parse(readFileSync(localSettings, 'utf-8'));
|
|
203
|
+
if (local.mcpServers) {
|
|
204
|
+
data.localMcpCount = Object.keys(local.mcpServers).length;
|
|
205
|
+
data.localMcpServers = Object.keys(local.mcpServers);
|
|
206
|
+
}
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Skills installed (from ~/.claude/skills/)
|
|
211
|
+
const skillsDir = join(claudeDir, 'skills');
|
|
212
|
+
if (existsSync(skillsDir)) {
|
|
213
|
+
try {
|
|
214
|
+
data.skillCount = readdirSync(skillsDir).filter(f => {
|
|
215
|
+
try { return statSync(join(skillsDir, f)).isDirectory(); } catch { return false; }
|
|
216
|
+
}).length;
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// .claudeignore exists?
|
|
221
|
+
data.hasClaudeignore = existsSync(join(process.cwd(), '.claudeignore'));
|
|
222
|
+
|
|
223
|
+
// Project count (how many projects they work on)
|
|
224
|
+
const projectsDir = join(claudeDir, 'projects');
|
|
225
|
+
if (existsSync(projectsDir)) {
|
|
226
|
+
try {
|
|
227
|
+
data.totalProjectDirs = readdirSync(projectsDir).filter(f => {
|
|
228
|
+
try { return statSync(join(projectsDir, f)).isDirectory(); } catch { return false; }
|
|
229
|
+
}).length;
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Credentials (do they have OAuth set up — subscription signal)
|
|
234
|
+
data.hasOauthCreds = existsSync(join(claudeDir, '.credentials.json'));
|
|
235
|
+
|
|
236
|
+
// CLAUDE.md files found (global + how many project-level)
|
|
237
|
+
data.hasGlobalClaudeMd = existsSync(join(claudeDir, 'CLAUDE.md'));
|
|
238
|
+
|
|
239
|
+
// Usage data size (proxy for how long they've been using CC)
|
|
240
|
+
const usageDir = join(claudeDir, 'usage-data');
|
|
241
|
+
if (existsSync(usageDir)) {
|
|
242
|
+
try {
|
|
243
|
+
const sessionMetaDir = join(usageDir, 'session-meta');
|
|
244
|
+
if (existsSync(sessionMetaDir)) {
|
|
245
|
+
data.totalSessionFiles = readdirSync(sessionMetaDir).filter(f => f.endsWith('.json')).length;
|
|
246
|
+
}
|
|
247
|
+
} catch {}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Node.js version (compatibility data)
|
|
251
|
+
data.nodeVersion = process.version;
|
|
252
|
+
|
|
253
|
+
// Terminal width (UI/UX data)
|
|
254
|
+
data.terminalCols = process.stdout.columns || 0;
|
|
255
|
+
|
|
256
|
+
// How they ran it (npx vs global install vs local)
|
|
257
|
+
data.invokedAs = process.argv[1]?.includes('npx') ? 'npx' : process.argv[1]?.includes('node_modules') ? 'local' : 'global';
|
|
258
|
+
|
|
259
|
+
// Git info from cwd (what kind of projects people work on)
|
|
260
|
+
try {
|
|
261
|
+
const gitDir = join(process.cwd(), '.git');
|
|
262
|
+
data.isGitRepo = existsSync(gitDir);
|
|
263
|
+
if (data.isGitRepo) {
|
|
264
|
+
const gitConfig = join(gitDir, 'config');
|
|
265
|
+
if (existsSync(gitConfig)) {
|
|
266
|
+
const gc = readFileSync(gitConfig, 'utf-8');
|
|
267
|
+
data.hasGitRemote = gc.includes('[remote');
|
|
268
|
+
data.isGitHub = gc.includes('github.com');
|
|
269
|
+
data.isGitLab = gc.includes('gitlab');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {}
|
|
273
|
+
|
|
274
|
+
// Package.json in cwd (what tech stack)
|
|
275
|
+
const pkgPath = join(process.cwd(), 'package.json');
|
|
276
|
+
if (existsSync(pkgPath)) {
|
|
277
|
+
try {
|
|
278
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
279
|
+
data.hasPackageJson = true;
|
|
280
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
281
|
+
const depNames = Object.keys(allDeps);
|
|
282
|
+
data.depCount = depNames.length;
|
|
283
|
+
// Detect major frameworks (anonymized — just booleans)
|
|
284
|
+
data.usesReact = depNames.some(d => d === 'react' || d === 'next');
|
|
285
|
+
data.usesVue = depNames.some(d => d === 'vue' || d === 'nuxt');
|
|
286
|
+
data.usesSvelte = depNames.some(d => d.includes('svelte'));
|
|
287
|
+
data.usesTypescript = depNames.some(d => d === 'typescript');
|
|
288
|
+
data.usesTailwind = depNames.some(d => d.includes('tailwind'));
|
|
289
|
+
data.usesExpress = depNames.some(d => d === 'express' || d === 'fastify' || d === 'hono');
|
|
290
|
+
data.usesPrisma = depNames.some(d => d === 'prisma' || d === '@prisma/client');
|
|
291
|
+
data.projectType = pkg.type || 'commonjs';
|
|
292
|
+
} catch {}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Python project?
|
|
296
|
+
data.isPython = existsSync(join(process.cwd(), 'requirements.txt')) || existsSync(join(process.cwd(), 'pyproject.toml'));
|
|
297
|
+
|
|
298
|
+
// Rust project?
|
|
299
|
+
data.isRust = existsSync(join(process.cwd(), 'Cargo.toml'));
|
|
300
|
+
|
|
301
|
+
// Go project?
|
|
302
|
+
data.isGo = existsSync(join(process.cwd(), 'go.mod'));
|
|
303
|
+
|
|
304
|
+
// File count in cwd (project size proxy)
|
|
305
|
+
try {
|
|
306
|
+
const cwdFiles = readdirSync(process.cwd());
|
|
307
|
+
data.cwdFileCount = cwdFiles.length;
|
|
308
|
+
data.hasSrcDir = cwdFiles.includes('src');
|
|
309
|
+
data.hasTestDir = cwdFiles.includes('test') || cwdFiles.includes('tests') || cwdFiles.includes('__tests__');
|
|
310
|
+
} catch {}
|
|
311
|
+
|
|
312
|
+
// First and last usage date (from JSONL file timestamps)
|
|
313
|
+
if (existsSync(projectsDir)) {
|
|
314
|
+
try {
|
|
315
|
+
let earliest = null, latest = null;
|
|
316
|
+
const dirs = readdirSync(projectsDir).slice(0, 5); // sample first 5 projects
|
|
317
|
+
for (const d of dirs) {
|
|
318
|
+
const pDir = join(projectsDir, d);
|
|
319
|
+
try {
|
|
320
|
+
const files = readdirSync(pDir).filter(f => f.endsWith('.jsonl'));
|
|
321
|
+
for (const f of files) {
|
|
322
|
+
const s = statSync(join(pDir, f));
|
|
323
|
+
if (!earliest || s.mtimeMs < earliest) earliest = s.mtimeMs;
|
|
324
|
+
if (!latest || s.mtimeMs > latest) latest = s.mtimeMs;
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
if (earliest) data.firstUsage = new Date(earliest).toISOString().slice(0, 10);
|
|
329
|
+
if (latest) data.lastUsage = new Date(latest).toISOString().slice(0, 10);
|
|
330
|
+
if (earliest && latest) data.usageDaysSpan = Math.round((latest - earliest) / 86400000);
|
|
331
|
+
} catch {}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
} catch {
|
|
335
|
+
// never crash on telemetry data gathering
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return data;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function tokenBucket(tokens) {
|
|
342
|
+
if (tokens < 1e6) return '<1M';
|
|
343
|
+
if (tokens < 10e6) return '1-10M';
|
|
344
|
+
if (tokens < 100e6) return '10-100M';
|
|
345
|
+
if (tokens < 1e9) return '100M-1B';
|
|
346
|
+
if (tokens < 10e9) return '1-10B';
|
|
347
|
+
return '10B+';
|
|
348
|
+
}
|
|
349
|
+
|
|
114
350
|
function modelSplitSummary(modelCosts) {
|
|
115
351
|
const total = Object.values(modelCosts).reduce((s, c) => s + c, 0);
|
|
116
352
|
if (total === 0) return {};
|