Skip to content

Commit 4f3a2e1

Browse files
brianlovinclaude
andcommitted
Add telemetry with improved reliability and testing
Implement anonymous usage telemetry with race condition prevention, failure tracking, and comprehensive tests: - Add flushing guard to prevent concurrent flush calls - Track consecutive failures and stop retrying after 3 attempts - Use shorter 1.5s timeout on app exit instead of 5s for faster shutdown - Remove high-frequency j/k navigation tracking (redundant with story_selected) - Add tests for auto-flush threshold, concurrent flush prevention, and failure limits - Include documentation in README and CLAUDE.md with opt-out instructions Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent e19ce09 commit 4f3a2e1

8 files changed

Lines changed: 590 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,15 @@ bun run debug highlighted-comment # Test comment highlighting
5757
- `src/api.ts` - API client for fetching from HNPWA API
5858
- `src/app.ts` - Main app class (testable)
5959
- `src/index.ts` - Entry point
60+
- `src/telemetry.ts` - Anonymous usage telemetry
6061
- `src/test/` - Test suite using OpenTUI testing framework
6162

63+
## Telemetry
64+
65+
Anonymous usage telemetry is enabled by default. Disable with:
66+
- Settings menu (`s`) → toggle Telemetry off
67+
- `hn --disable-telemetry` flag (permanently disables)
68+
6269
## Dependencies
6370

6471
- `@opentui/core` - Terminal UI framework with Yoga layout engine

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,38 @@ I made these choices so that it's easier for me to keep up with the most interes
130130

131131
If you want your version of this tool to work differently, feel free to clone or consider opening a PR with more advanced settings to let people customize the default experience.
132132

133+
## Telemetry
134+
135+
This CLI collects anonymous usage data to help understand how people use it and what features to improve. No personal information or content is ever collected.
136+
137+
### What's collected
138+
139+
- App launches (with version number)
140+
- Feature usage counts (TLDR, chat, refresh)
141+
- Navigation patterns (stories selected, comments viewed)
142+
- Keyboard shortcut usage
143+
144+
### What's NOT collected
145+
146+
- Story content, titles, or URLs
147+
- Chat messages or AI responses
148+
- API keys or credentials
149+
- IP addresses or location data
150+
151+
### Disabling telemetry
152+
153+
**Option 1: Settings menu**
154+
155+
Press `s` to open settings, then toggle "Telemetry" off.
156+
157+
**Option 2: Launch flag**
158+
159+
```bash
160+
hn --disable-telemetry
161+
```
162+
163+
This permanently disables telemetry. Your preference is stored locally at `~/.config/hn-cli/config.json`.
164+
133165
## Credits
134166

135167
Built with [OpenTUI](https://github.com/anthropics/opentui)

src/app.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BoxRenderable, type CliRenderer, type RenderContext } from "@opentui/core";
22
import { log } from "./logger";
3+
import * as telemetry from "./telemetry";
34
import { getRankedPosts, getPostById } from "./api";
45
import type { HackerNewsPost } from "./types";
56
import {
@@ -10,6 +11,8 @@ import {
1011
saveConfig,
1112
loadConfig,
1213
clearAllApiKeys,
14+
isTelemetryEnabled,
15+
setTelemetryEnabled,
1316
} from "./config";
1417
import { type UpdateInfo } from "./version";
1518
import { COLORS, detectTheme } from "./theme";
@@ -442,6 +445,11 @@ export class HackerNewsApp {
442445
}
443446
this.rerenderSettings();
444447
break;
448+
449+
case "toggle_telemetry":
450+
setTelemetryEnabled(!isTelemetryEnabled());
451+
this.rerenderSettings();
452+
break;
445453
}
446454
}
447455

@@ -591,6 +599,7 @@ export class HackerNewsApp {
591599

592600
private handleMainKey(key: any) {
593601
if (key.name === "s") {
602+
telemetry.track("settings_opened");
594603
this.settingsIntent = "settings";
595604
this.showSettings();
596605
return;
@@ -601,15 +610,20 @@ export class HackerNewsApp {
601610
} else if (key.name === "k") {
602611
this.navigateStory(-1);
603612
} else if (key.name === "space" || key.name === " ") {
613+
telemetry.track("comment_nav");
604614
// Space navigates to next root comment only (forward)
605615
this.navigateToNextComment();
606616
} else if (key.name === "o") {
617+
telemetry.track("url_opened", { type: "url" });
607618
this.openStoryUrl();
608619
} else if (key.name === "c") {
620+
telemetry.track("chat_opened");
609621
this.openChat();
610622
} else if (key.name === "r") {
623+
telemetry.track("refresh");
611624
this.refresh();
612625
} else if (key.name === "t") {
626+
telemetry.track("tldr_requested");
613627
this.handleTldrRequest();
614628
}
615629
}
@@ -651,6 +665,8 @@ export class HackerNewsApp {
651665
if (index < 0 || index >= this.posts.length) return;
652666
if (this.renderer.isDestroyed) return;
653667

668+
telemetry.track("story_selected");
669+
654670
// Stop TLDR loading animation in detail view when switching stories
655671
// (TLDR generation continues in background - AI indicator keeps showing)
656672
if (this.tldrLoading) {
@@ -795,6 +811,7 @@ export class HackerNewsApp {
795811
generateTLDR(this.selectedPost, provider, {
796812
onComplete: (tldr) => {
797813
if (this.renderer.isDestroyed) return;
814+
telemetry.track("tldr_completed", { success: true });
798815
this.tldrCache.set(storyId, tldr);
799816
this.tldrLoading = false;
800817
this.tldrLoadingStoryId = null;
@@ -806,6 +823,7 @@ export class HackerNewsApp {
806823
}
807824
},
808825
onError: (error) => {
826+
telemetry.track("tldr_completed", { success: false });
809827
log("[ERROR]", "TLDR generation failed:", error);
810828
this.tldrErrorIds.add(storyId);
811829
this.tldrLoading = false;
@@ -1102,6 +1120,8 @@ export class HackerNewsApp {
11021120
const userMessage = this.chatPanelState.input.plainText.trim();
11031121
if (!userMessage) return;
11041122

1123+
telemetry.track("chat_message");
1124+
11051125
// Clear input and suggestions
11061126
this.chatPanelState.input.clear();
11071127
this.chatPanelState.suggestions.suggestions = [];

src/components/SettingsPanel.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
type Provider,
55
getApiKey,
66
getModel,
7+
isTelemetryEnabled,
78
ANTHROPIC_MODELS,
89
OPENAI_MODELS,
910
} from "../config";
1011

1112
type SettingsItemType =
1213
| { type: "provider"; provider: Provider; hasKey: boolean }
1314
| { type: "model"; modelId: string; modelName: string }
15+
| { type: "telemetry"; enabled: boolean }
1416
| { type: "action"; action: "done" | "clear_keys" };
1517

1618
interface SettingsListItem {
@@ -56,6 +58,12 @@ function getSettingsList(chatProvider: Provider): SettingsListItem[] {
5658
}
5759
}
5860

61+
// Telemetry toggle
62+
items.push({
63+
item: { type: "telemetry", enabled: isTelemetryEnabled() },
64+
enabled: true,
65+
});
66+
5967
// Action buttons
6068
items.push({
6169
item: { type: "action", action: "done" },
@@ -193,6 +201,48 @@ export function renderSettings(
193201
}
194202
}
195203

204+
// Telemetry section
205+
container.add(new BoxRenderable(ctx, { height: 1 }));
206+
207+
const telemetryHeader = new TextRenderable(ctx, {
208+
content: t`${bold("Telemetry")}`,
209+
fg: COLORS.textSecondary,
210+
});
211+
container.add(telemetryHeader);
212+
213+
for (let i = 0; i < items.length; i++) {
214+
const listItem = items[i];
215+
if (!listItem || listItem.item.type !== "telemetry") continue;
216+
217+
const isSelected = i === state.selectedIndex;
218+
const isEnabled = listItem.item.enabled;
219+
220+
const itemBox = new BoxRenderable(ctx, {
221+
flexDirection: "row",
222+
gap: 1,
223+
});
224+
225+
const indicator = new TextRenderable(ctx, {
226+
content: isSelected ? "›" : " ",
227+
fg: COLORS.accent,
228+
});
229+
itemBox.add(indicator);
230+
231+
const toggle = new TextRenderable(ctx, {
232+
content: isEnabled ? "●" : "○",
233+
fg: isEnabled ? COLORS.accent : COLORS.textSecondary,
234+
});
235+
itemBox.add(toggle);
236+
237+
const label = new TextRenderable(ctx, {
238+
content: isEnabled ? "Enabled" : "Disabled",
239+
fg: isSelected ? COLORS.accent : COLORS.textPrimary,
240+
});
241+
itemBox.add(label);
242+
243+
container.add(itemBox);
244+
}
245+
196246
container.add(new BoxRenderable(ctx, { height: 1 }));
197247

198248
// Actions section
@@ -258,6 +308,7 @@ export type SettingsAction =
258308
| { type: "add_anthropic" }
259309
| { type: "add_openai" }
260310
| { type: "clear_keys" }
311+
| { type: "toggle_telemetry" }
261312
| { type: "done" }
262313
| null;
263314

@@ -290,6 +341,9 @@ export function selectSettingsItem(
290341
provider: chatProvider,
291342
};
292343

344+
case "telemetry":
345+
return { type: "toggle_telemetry" };
346+
293347
case "action":
294348
switch (selected.item.action) {
295349
case "done":

src/config.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface Config {
3333
openaiApiKey?: string;
3434
anthropicModel?: AnthropicModel;
3535
openaiModel?: OpenAIModel;
36+
telemetryEnabled?: boolean;
37+
userId?: string;
3638
}
3739

3840
const CONFIG_DIR = join(homedir(), ".config", "hn-cli");
@@ -167,6 +169,45 @@ export function clearApiKey(provider: Provider): void {
167169
}
168170

169171
export function clearAllApiKeys(): void {
170-
// Clear everything - start fresh (currently Config only contains API-related settings)
171-
saveConfig({});
172+
// Clear API-related settings but preserve telemetry preferences
173+
const config = loadConfig();
174+
saveConfig({
175+
telemetryEnabled: config.telemetryEnabled,
176+
userId: config.userId,
177+
});
178+
}
179+
180+
// Telemetry settings
181+
182+
/**
183+
* Check if telemetry is enabled (default: true)
184+
*/
185+
export function isTelemetryEnabled(): boolean {
186+
const config = loadConfig();
187+
return config.telemetryEnabled !== false;
188+
}
189+
190+
/**
191+
* Enable or disable telemetry
192+
*/
193+
export function setTelemetryEnabled(enabled: boolean): void {
194+
const config = loadConfig();
195+
config.telemetryEnabled = enabled;
196+
saveConfig(config);
197+
}
198+
199+
/**
200+
* Get or generate anonymous user ID for telemetry
201+
*/
202+
export function getUserId(): string {
203+
const config = loadConfig();
204+
if (config.userId) {
205+
return config.userId;
206+
}
207+
208+
// Generate a new UUID
209+
const userId = crypto.randomUUID();
210+
config.userId = userId;
211+
saveConfig(config);
212+
return userId;
172213
}

src/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,24 @@
22
import { createCliRenderer } from "@opentui/core";
33
import { exec } from "child_process";
44
import { HackerNewsApp } from "./app";
5-
import { checkForUpdates } from "./version";
5+
import { checkForUpdates, currentVersion } from "./version";
6+
import { setTelemetryEnabled } from "./config";
7+
import * as telemetry from "./telemetry";
68

79
const COLORS = {
810
bg: "#1a1a1a",
911
};
1012

1113
async function main() {
14+
// Handle --disable-telemetry flag (permanently disables telemetry)
15+
if (process.argv.includes("--disable-telemetry")) {
16+
setTelemetryEnabled(false);
17+
}
18+
19+
// Initialize telemetry and track app launch
20+
telemetry.init();
21+
telemetry.track("app_launch", { version: currentVersion });
22+
1223
const renderer = await createCliRenderer({
1324
exitOnCtrlC: true,
1425
backgroundColor: COLORS.bg,
@@ -25,7 +36,8 @@ async function main() {
2536
onOpenUrl: (url) => {
2637
exec(`open "${url}"`);
2738
},
28-
onExit: () => {
39+
onExit: async () => {
40+
await telemetry.flushSync();
2941
renderer.destroy();
3042
process.exit(0);
3143
},

0 commit comments

Comments
 (0)