cchubber 0.3.2 → 0.3.3

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