analytics.ts
190 lines · scripts/analytics.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/**
3 * analytics — CLI for viewing gstack skill usage statistics.
4 *
5 * Reads ~/.gstack/analytics/skill-usage.jsonl and displays:
6 * - Top skills by invocation count
7 * - Per-repo skill breakdown
8 * - Safety hook fire events
9 *
10 * Usage:
11 * bun run scripts/analytics.ts [--period 7d|30d|all]
12 */
14import * as fs from 'fs';
15import * as path from 'path';
16import { resolveStateRoot } from '../lib/state-root';
18export interface AnalyticsEvent {
19 skill: string;
20 ts: string;
21 repo: string;
22 event?: string;
23 pattern?: string;
24}
26const ANALYTICS_FILE = path.join(resolveStateRoot(), 'analytics', 'skill-usage.jsonl');
28/**
29 * Parse JSONL content into AnalyticsEvent[], skipping malformed lines.
30 */
31export function parseJSONL(content: string): AnalyticsEvent[] {
32 const events: AnalyticsEvent[] = [];
33 for (const line of content.split('\n')) {
34 const trimmed = line.trim();
35 if (!trimmed) continue;
36 try {
37 const obj = JSON.parse(trimmed);
38 if (typeof obj === 'object' && obj !== null && typeof obj.ts === 'string') {
39 events.push(obj as AnalyticsEvent);
40 }
41 } catch {
42 // skip malformed lines
43 }
44 }
45 return events;
46}
48/**
49 * Filter events by period. Supports "7d", "30d", and "all".
50 */
51export function filterByPeriod(events: AnalyticsEvent[], period: string): AnalyticsEvent[] {
52 if (period === 'all') return events;
54 const match = period.match(/^(\d+)d$/);
55 if (!match) return events;
57 const days = parseInt(match[1], 10);
58 const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
60 return events.filter(e => {
61 const d = new Date(e.ts);
62 return !isNaN(d.getTime()) && d >= cutoff;
63 });
64}
66/**
67 * Format a report string from a list of events.
68 */
69export function formatReport(events: AnalyticsEvent[], period: string = 'all'): string {
70 const skillEvents = events.filter(e => e.event !== 'hook_fire');
71 const hookEvents = events.filter(e => e.event === 'hook_fire');
73 const lines: string[] = [];
74 lines.push('gstack skill usage analytics');
75 lines.push('\u2550'.repeat(39));
76 lines.push('');
78 const periodLabel = period === 'all' ? 'all time' : `last ${period.replace('d', ' days')}`;
79 lines.push(`Period: ${periodLabel}`);
81 // Top Skills
82 const skillCounts = new Map<string, number>();
83 for (const e of skillEvents) {
84 skillCounts.set(e.skill, (skillCounts.get(e.skill) || 0) + 1);
85 }
87 if (skillCounts.size > 0) {
88 lines.push('');
89 lines.push('Top Skills');
91 const sorted = [...skillCounts.entries()].sort((a, b) => b[1] - a[1]);
92 const maxName = Math.max(...sorted.map(([name]) => name.length + 1)); // +1 for /
93 const maxCount = Math.max(...sorted.map(([, count]) => String(count).length));
95 for (const [name, count] of sorted) {
96 const label = `/${name}`;
97 const suffix = `${count} invocation${count === 1 ? '' : 's'}`;
98 const dotLen = Math.max(2, 25 - label.length - suffix.length);
99 const dots = ' ' + '.'.repeat(dotLen) + ' ';
100 lines.push(` ${label}${dots}${suffix}`);
101 }
102 }
104 // By Repo
105 const repoSkills = new Map<string, Map<string, number>>();
106 for (const e of skillEvents) {
107 if (!repoSkills.has(e.repo)) repoSkills.set(e.repo, new Map());
108 const m = repoSkills.get(e.repo)!;
109 m.set(e.skill, (m.get(e.skill) || 0) + 1);
110 }
112 if (repoSkills.size > 0) {
113 lines.push('');
114 lines.push('By Repo');
116 const sortedRepos = [...repoSkills.entries()].sort((a, b) => a[0].localeCompare(b[0]));
117 for (const [repo, skills] of sortedRepos) {
118 const parts = [...skills.entries()]
119 .sort((a, b) => b[1] - a[1])
120 .map(([s, c]) => `${s}(${c})`);
121 lines.push(` ${repo}: ${parts.join(' ')}`);
122 }
123 }
125 // Safety Hook Events
126 const hookCounts = new Map<string, number>();
127 for (const e of hookEvents) {
128 if (e.pattern) {
129 hookCounts.set(e.pattern, (hookCounts.get(e.pattern) || 0) + 1);
130 }
131 }
133 if (hookCounts.size > 0) {
134 lines.push('');
135 lines.push('Safety Hook Events');
137 const sortedHooks = [...hookCounts.entries()].sort((a, b) => b[1] - a[1]);
138 for (const [pattern, count] of sortedHooks) {
139 const suffix = `${count} fire${count === 1 ? '' : 's'}`;
140 const dotLen = Math.max(2, 25 - pattern.length - suffix.length);
141 const dots = ' ' + '.'.repeat(dotLen) + ' ';
142 lines.push(` ${pattern}${dots}${suffix}`);
143 }
144 }
146 // Total
147 const totalSkills = skillEvents.length;
148 const totalHooks = hookEvents.length;
149 lines.push('');
150 lines.push(`Total: ${totalSkills} skill invocation${totalSkills === 1 ? '' : 's'}, ${totalHooks} hook fire${totalHooks === 1 ? '' : 's'}`);
152 return lines.join('\n');
153}
155function main() {
156 // Parse --period flag
157 let period = 'all';
158 const args = process.argv.slice(2);
159 for (let i = 0; i < args.length; i++) {
160 if (args[i] === '--period' && i + 1 < args.length) {
161 period = args[i + 1];
162 i++;
163 }
164 }
166 // Read file
167 if (!fs.existsSync(ANALYTICS_FILE)) {
168 console.log('No analytics data found.');
169 process.exit(0);
170 }
172 const content = fs.readFileSync(ANALYTICS_FILE, 'utf-8').trim();
173 if (!content) {
174 console.log('No analytics data found.');
175 process.exit(0);
176 }
178 const events = parseJSONL(content);
179 if (events.length === 0) {
180 console.log('No analytics data found.');
181 process.exit(0);
182 }
184 const filtered = filterByPeriod(events, period);
185 console.log(formatReport(filtered, period));
186}
188if (import.meta.main) {
189 main();
190}