cchubber 0.3.1 → 0.3.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchubber",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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/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');
@@ -0,0 +1,122 @@
1
+ import https from 'https';
2
+ import { platform, arch } from 'os';
3
+
4
+ // Anonymous usage telemetry — no PII, no tokens, no file contents.
5
+ // Opt out: npx cchubber --no-telemetry
6
+ // Or set env: CC_HUBBER_TELEMETRY=0
7
+
8
+ const TELEMETRY_URL = process.env.CC_HUBBER_TELEMETRY_URL || 'https://cchubber-telemetry.azkhh.workers.dev/collect';
9
+
10
+ export function shouldSendTelemetry(flags) {
11
+ if (flags.noTelemetry) return false;
12
+ if (process.env.CC_HUBBER_TELEMETRY === '0') return false;
13
+ if (process.env.DO_NOT_TRACK === '1') return false;
14
+ return true;
15
+ }
16
+
17
+ export function sendTelemetry(report) {
18
+ const payload = {
19
+ v: '0.3.1',
20
+ ts: new Date().toISOString(),
21
+ os: platform(),
22
+ arch: arch(),
23
+
24
+ // Aggregated stats — no file contents, no project names, no personal data
25
+ // Usage profile
26
+ grade: report.cacheHealth?.grade?.letter || '?',
27
+ cacheRatio: report.cacheHealth?.efficiencyRatio || 0,
28
+ cacheHitRate: report.cacheHealth?.cacheHitRate || 0,
29
+ cacheBreaks: report.cacheHealth?.totalCacheBreaks || 0,
30
+ estimatedBreaks: report.cacheHealth?.estimatedBreaks || 0,
31
+ cacheSaved: report.cacheHealth?.savings?.fromCaching || 0,
32
+ cacheWasted: report.cacheHealth?.savings?.wastedFromBreaks || 0,
33
+
34
+ // Cost & scale
35
+ activeDays: report.costAnalysis?.activeDays || 0,
36
+ totalCostBucket: costBucket(report.costAnalysis?.totalCost || 0),
37
+ avgDailyCost: Math.round(report.costAnalysis?.avgDailyCost || 0),
38
+ peakDayCost: Math.round(report.costAnalysis?.peakDay?.cost || 0),
39
+ totalMessages: report.costAnalysis?.dailyCosts?.reduce((s, d) => s + (d.messageCount || 0), 0) || 0,
40
+
41
+ // Model usage (key for understanding subscription behavior)
42
+ modelSplit: modelSplitSummary(report.costAnalysis?.modelCosts || {}),
43
+ modelCount: Object.keys(report.costAnalysis?.modelCosts || {}).length,
44
+ opusPct: report.modelRouting?.opusPct || 0,
45
+ sonnetPct: report.modelRouting?.sonnetPct || 0,
46
+ haikuPct: report.modelRouting?.haikuPct || 0,
47
+ subagentPct: report.modelRouting?.subagentPct || 0,
48
+
49
+ // CLAUDE.md (how people configure their AI)
50
+ claudeMdTokens: report.claudeMdStack?.totalTokensEstimate || 0,
51
+ claudeMdBytes: report.claudeMdStack?.totalBytes || 0,
52
+ claudeMdSections: report.claudeMdStack?.globalSections?.length || 0,
53
+ claudeMdFiles: report.claudeMdStack?.files?.length || 0,
54
+ claudeMdCostCached: report.claudeMdStack?.costPerMessage?.cached || 0,
55
+ claudeMdCostUncached: report.claudeMdStack?.costPerMessage?.uncached || 0,
56
+
57
+ // Session patterns (how people work)
58
+ sessionCount: report.sessionIntel?.totalSessions || 0,
59
+ avgSessionMin: report.sessionIntel?.avgDuration || 0,
60
+ medianSessionMin: report.sessionIntel?.medianDuration || 0,
61
+ p90SessionMin: report.sessionIntel?.p90Duration || 0,
62
+ maxSessionMin: report.sessionIntel?.maxDuration || 0,
63
+ longSessionPct: report.sessionIntel?.longSessionPct || 0,
64
+ avgToolsPerSession: report.sessionIntel?.avgToolsPerSession || 0,
65
+ linesPerHour: report.sessionIntel?.linesPerHour || 0,
66
+ peakOverlapPct: report.sessionIntel?.peakOverlapPct || 0,
67
+ topTools: (report.sessionIntel?.topTools || []).slice(0, 6).map(t => t.name),
68
+
69
+ // Scale indicators
70
+ projectCount: report.projectBreakdown?.length || 0,
71
+ anomalyCount: report.anomalies?.anomalies?.length || 0,
72
+ trend: report.anomalies?.trend || 'stable',
73
+ inflectionDir: report.inflection?.direction || 'none',
74
+ inflectionMult: report.inflection?.multiplier || 0,
75
+ entryCount: report.costAnalysis?.dailyCosts?.length || 0,
76
+ recCount: report.recommendations?.length || 0,
77
+
78
+ // Rate limits (if available — shows subscription tier indirectly)
79
+ hasOauth: !!report.oauthUsage,
80
+ rateLimit5h: report.oauthUsage?.five_hour?.utilization || null,
81
+ rateLimit7d: report.oauthUsage?.seven_day?.utilization || null,
82
+ };
83
+
84
+ // Fire and forget — never blocks the CLI
85
+ try {
86
+ const data = JSON.stringify(payload);
87
+ const url = new URL(TELEMETRY_URL);
88
+ const req = https.request({
89
+ hostname: url.hostname,
90
+ path: url.pathname,
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
93
+ });
94
+ req.on('error', () => {}); // silent fail
95
+ req.setTimeout(3000, () => req.destroy());
96
+ req.write(data);
97
+ req.end();
98
+ } catch {
99
+ // never crash on telemetry
100
+ }
101
+ }
102
+
103
+ function costBucket(cost) {
104
+ // Bucketed so we can't identify individuals by exact cost
105
+ if (cost < 10) return '<10';
106
+ if (cost < 50) return '10-50';
107
+ if (cost < 200) return '50-200';
108
+ if (cost < 500) return '200-500';
109
+ if (cost < 1000) return '500-1K';
110
+ if (cost < 5000) return '1K-5K';
111
+ return '5K+';
112
+ }
113
+
114
+ function modelSplitSummary(modelCosts) {
115
+ const total = Object.values(modelCosts).reduce((s, c) => s + c, 0);
116
+ if (total === 0) return {};
117
+ const split = {};
118
+ for (const [name, cost] of Object.entries(modelCosts)) {
119
+ split[name] = Math.round((cost / total) * 100);
120
+ }
121
+ return split;
122
+ }