cchubber 0.3.1 → 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.
- package/package.json +1 -1
- package/src/cli/index.js +8 -0
- package/src/telemetry.js +344 -0
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -20,11 +20,13 @@ import { analyzeSessionIntelligence } from '../analyzers/session-intelligence.js
|
|
|
20
20
|
import { analyzeModelRouting } from '../analyzers/model-routing.js';
|
|
21
21
|
import { renderHTML } from '../renderers/html-report.js';
|
|
22
22
|
import { renderTerminal } from '../renderers/terminal-summary.js';
|
|
23
|
+
import { shouldSendTelemetry, sendTelemetry } from '../telemetry.js';
|
|
23
24
|
|
|
24
25
|
const args = process.argv.slice(2);
|
|
25
26
|
const flags = {
|
|
26
27
|
help: args.includes('--help') || args.includes('-h'),
|
|
27
28
|
json: args.includes('--json'),
|
|
29
|
+
noTelemetry: args.includes('--no-telemetry'),
|
|
28
30
|
noOpen: args.includes('--no-open'),
|
|
29
31
|
output: (() => {
|
|
30
32
|
const idx = args.indexOf('--output') !== -1 ? args.indexOf('--output') : args.indexOf('-o');
|
|
@@ -149,6 +151,12 @@ async function main() {
|
|
|
149
151
|
|
|
150
152
|
renderTerminal(report);
|
|
151
153
|
|
|
154
|
+
// Anonymous telemetry (opt out: --no-telemetry or CC_HUBBER_TELEMETRY=0)
|
|
155
|
+
if (shouldSendTelemetry(flags)) {
|
|
156
|
+
sendTelemetry(report);
|
|
157
|
+
console.log(' ○ Anonymous stats shared (opt out: --no-telemetry)');
|
|
158
|
+
}
|
|
159
|
+
|
|
152
160
|
const outputPath = flags.output || join(process.cwd(), 'cchubber-report.html');
|
|
153
161
|
const html = renderHTML(report);
|
|
154
162
|
writeFileSync(outputPath, html, 'utf-8');
|
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import https from 'https';
|
|
2
|
+
import { platform, arch, homedir } from 'os';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
// Anonymous usage telemetry — no PII, no tokens, no file contents.
|
|
7
|
+
// Opt out: npx cchubber --no-telemetry
|
|
8
|
+
// Or set env: CC_HUBBER_TELEMETRY=0
|
|
9
|
+
|
|
10
|
+
const TELEMETRY_URL = process.env.CC_HUBBER_TELEMETRY_URL || 'https://cchubber-telemetry.azkhh.workers.dev/collect';
|
|
11
|
+
|
|
12
|
+
export function shouldSendTelemetry(flags) {
|
|
13
|
+
if (flags.noTelemetry) return false;
|
|
14
|
+
if (process.env.CC_HUBBER_TELEMETRY === '0') return false;
|
|
15
|
+
if (process.env.DO_NOT_TRACK === '1') return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sendTelemetry(report) {
|
|
20
|
+
const payload = {
|
|
21
|
+
v: '0.3.1',
|
|
22
|
+
ts: new Date().toISOString(),
|
|
23
|
+
os: platform(),
|
|
24
|
+
arch: arch(),
|
|
25
|
+
|
|
26
|
+
// Aggregated stats — no file contents, no project names, no personal data
|
|
27
|
+
// Usage profile
|
|
28
|
+
grade: report.cacheHealth?.grade?.letter || '?',
|
|
29
|
+
cacheRatio: report.cacheHealth?.efficiencyRatio || 0,
|
|
30
|
+
cacheHitRate: report.cacheHealth?.cacheHitRate || 0,
|
|
31
|
+
cacheBreaks: report.cacheHealth?.totalCacheBreaks || 0,
|
|
32
|
+
estimatedBreaks: report.cacheHealth?.estimatedBreaks || 0,
|
|
33
|
+
cacheSaved: report.cacheHealth?.savings?.fromCaching || 0,
|
|
34
|
+
cacheWasted: report.cacheHealth?.savings?.wastedFromBreaks || 0,
|
|
35
|
+
|
|
36
|
+
// Cost & scale
|
|
37
|
+
activeDays: report.costAnalysis?.activeDays || 0,
|
|
38
|
+
totalCostBucket: costBucket(report.costAnalysis?.totalCost || 0),
|
|
39
|
+
avgDailyCost: Math.round(report.costAnalysis?.avgDailyCost || 0),
|
|
40
|
+
peakDayCost: Math.round(report.costAnalysis?.peakDay?.cost || 0),
|
|
41
|
+
totalMessages: report.costAnalysis?.dailyCosts?.reduce((s, d) => s + (d.messageCount || 0), 0) || 0,
|
|
42
|
+
|
|
43
|
+
// Model usage (key for understanding subscription behavior)
|
|
44
|
+
modelSplit: modelSplitSummary(report.costAnalysis?.modelCosts || {}),
|
|
45
|
+
modelCount: Object.keys(report.costAnalysis?.modelCosts || {}).length,
|
|
46
|
+
opusPct: report.modelRouting?.opusPct || 0,
|
|
47
|
+
sonnetPct: report.modelRouting?.sonnetPct || 0,
|
|
48
|
+
haikuPct: report.modelRouting?.haikuPct || 0,
|
|
49
|
+
subagentPct: report.modelRouting?.subagentPct || 0,
|
|
50
|
+
|
|
51
|
+
// CLAUDE.md (how people configure their AI)
|
|
52
|
+
claudeMdTokens: report.claudeMdStack?.totalTokensEstimate || 0,
|
|
53
|
+
claudeMdBytes: report.claudeMdStack?.totalBytes || 0,
|
|
54
|
+
claudeMdSections: report.claudeMdStack?.globalSections?.length || 0,
|
|
55
|
+
claudeMdFiles: report.claudeMdStack?.files?.length || 0,
|
|
56
|
+
claudeMdCostCached: report.claudeMdStack?.costPerMessage?.cached || 0,
|
|
57
|
+
claudeMdCostUncached: report.claudeMdStack?.costPerMessage?.uncached || 0,
|
|
58
|
+
|
|
59
|
+
// Session patterns (how people work)
|
|
60
|
+
sessionCount: report.sessionIntel?.totalSessions || 0,
|
|
61
|
+
avgSessionMin: report.sessionIntel?.avgDuration || 0,
|
|
62
|
+
medianSessionMin: report.sessionIntel?.medianDuration || 0,
|
|
63
|
+
p90SessionMin: report.sessionIntel?.p90Duration || 0,
|
|
64
|
+
maxSessionMin: report.sessionIntel?.maxDuration || 0,
|
|
65
|
+
longSessionPct: report.sessionIntel?.longSessionPct || 0,
|
|
66
|
+
avgToolsPerSession: report.sessionIntel?.avgToolsPerSession || 0,
|
|
67
|
+
linesPerHour: report.sessionIntel?.linesPerHour || 0,
|
|
68
|
+
peakOverlapPct: report.sessionIntel?.peakOverlapPct || 0,
|
|
69
|
+
topTools: (report.sessionIntel?.topTools || []).slice(0, 6).map(t => t.name),
|
|
70
|
+
|
|
71
|
+
// Scale indicators
|
|
72
|
+
projectCount: report.projectBreakdown?.length || 0,
|
|
73
|
+
anomalyCount: report.anomalies?.anomalies?.length || 0,
|
|
74
|
+
trend: report.anomalies?.trend || 'stable',
|
|
75
|
+
inflectionDir: report.inflection?.direction || 'none',
|
|
76
|
+
inflectionMult: report.inflection?.multiplier || 0,
|
|
77
|
+
entryCount: report.costAnalysis?.dailyCosts?.length || 0,
|
|
78
|
+
recCount: report.recommendations?.length || 0,
|
|
79
|
+
|
|
80
|
+
// Rate limits (shows subscription tier indirectly)
|
|
81
|
+
hasOauth: !!report.oauthUsage,
|
|
82
|
+
rateLimit5h: report.oauthUsage?.five_hour?.utilization || null,
|
|
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(),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Fire and forget — never blocks the CLI
|
|
119
|
+
try {
|
|
120
|
+
const data = JSON.stringify(payload);
|
|
121
|
+
const url = new URL(TELEMETRY_URL);
|
|
122
|
+
const req = https.request({
|
|
123
|
+
hostname: url.hostname,
|
|
124
|
+
path: url.pathname,
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
|
|
127
|
+
});
|
|
128
|
+
req.on('error', () => {}); // silent fail
|
|
129
|
+
req.setTimeout(3000, () => req.destroy());
|
|
130
|
+
req.write(data);
|
|
131
|
+
req.end();
|
|
132
|
+
} catch {
|
|
133
|
+
// never crash on telemetry
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function costBucket(cost) {
|
|
138
|
+
// Bucketed so we can't identify individuals by exact cost
|
|
139
|
+
if (cost < 10) return '<10';
|
|
140
|
+
if (cost < 50) return '10-50';
|
|
141
|
+
if (cost < 200) return '50-200';
|
|
142
|
+
if (cost < 500) return '200-500';
|
|
143
|
+
if (cost < 1000) return '500-1K';
|
|
144
|
+
if (cost < 5000) return '1K-5K';
|
|
145
|
+
return '5K+';
|
|
146
|
+
}
|
|
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
|
+
|
|
336
|
+
function modelSplitSummary(modelCosts) {
|
|
337
|
+
const total = Object.values(modelCosts).reduce((s, c) => s + c, 0);
|
|
338
|
+
if (total === 0) return {};
|
|
339
|
+
const split = {};
|
|
340
|
+
for (const [name, cost] of Object.entries(modelCosts)) {
|
|
341
|
+
split[name] = Math.round((cost / total) * 100);
|
|
342
|
+
}
|
|
343
|
+
return split;
|
|
344
|
+
}
|