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 +1 -1
- package/src/cli/index.js +8 -0
- package/src/telemetry.js +122 -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,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
|
+
}
|