content-grade 1.0.0

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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * ContentGrade Telemetry
3
+ *
4
+ * Privacy-first, anonymous usage tracking.
5
+ * - No file contents, no PII, no account info
6
+ * - Stored locally at ~/.content-grade/events.jsonl
7
+ * - Opt out: --no-telemetry flag, CONTENT_GRADE_NO_TELEMETRY=1, or run: content-grade telemetry off
8
+ * - Remote endpoint: set CONTENT_GRADE_TELEMETRY_URL to forward events
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'fs';
12
+ import { homedir } from 'os';
13
+ import { join } from 'path';
14
+ import { createHash } from 'crypto';
15
+
16
+ const CONFIG_DIR = join(homedir(), '.content-grade');
17
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
18
+ const EVENTS_FILE = join(CONFIG_DIR, 'events.jsonl');
19
+
20
+ // Telemetry endpoint: set CONTENT_GRADE_TELEMETRY_URL to enable remote reporting
21
+ const REMOTE_URL = process.env.CONTENT_GRADE_TELEMETRY_URL || null;
22
+
23
+ // ── Internal helpers ──────────────────────────────────────────────────────────
24
+
25
+ function ensureDir() {
26
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
27
+ }
28
+
29
+ function loadConfig() {
30
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); }
31
+ catch { return null; }
32
+ }
33
+
34
+ function saveConfig(cfg) {
35
+ ensureDir();
36
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
37
+ }
38
+
39
+ function generateId() {
40
+ return createHash('sha256')
41
+ .update(`${Date.now()}-${Math.random()}-${process.pid}-${homedir()}`)
42
+ .digest('hex')
43
+ .slice(0, 16);
44
+ }
45
+
46
+ // ── Public API ────────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Check if telemetry is disabled.
50
+ * Order of precedence: CLI flag > env var > config file.
51
+ */
52
+ export function isOptedOut() {
53
+ if (process.argv.includes('--no-telemetry')) return true;
54
+ if (process.env.CONTENT_GRADE_NO_TELEMETRY === '1') return true;
55
+ const cfg = loadConfig();
56
+ return cfg?.telemetryEnabled === false;
57
+ }
58
+
59
+ /**
60
+ * Initialise telemetry on CLI startup.
61
+ * Returns { installId, isNew, optedOut }.
62
+ * isNew=true means this is the first time the CLI has run — show the notice.
63
+ */
64
+ export function initTelemetry() {
65
+ if (isOptedOut()) return { installId: null, isNew: false, optedOut: true };
66
+
67
+ const cfg = loadConfig();
68
+
69
+ if (cfg?.installId) {
70
+ return { installId: cfg.installId, isNew: false, optedOut: false };
71
+ }
72
+
73
+ // First run — generate anonymous install ID
74
+ const installId = generateId();
75
+ saveConfig({
76
+ installId,
77
+ installedAt: new Date().toISOString(),
78
+ telemetryEnabled: true,
79
+ });
80
+
81
+ // Record install event
82
+ _write({ event: 'install', installId, platform: process.platform, nodeVersion: process.version });
83
+
84
+ return { installId, isNew: true, optedOut: false };
85
+ }
86
+
87
+ /**
88
+ * Record a usage event.
89
+ * Safe to call from anywhere — never throws, never blocks CLI.
90
+ *
91
+ * Tracked fields (safe, no PII):
92
+ * event — 'command'
93
+ * command — 'analyze' | 'headline' | 'demo' | 'start' | 'init' | 'help'
94
+ * duration_ms — wall time
95
+ * success — boolean
96
+ * exit_code — process exit code (set by trackCommand)
97
+ * score — numeric score if available (analyze/headline result)
98
+ * content_type — content category if available (analyze result)
99
+ * version — CLI version
100
+ */
101
+ export function recordEvent(data) {
102
+ if (isOptedOut()) return;
103
+
104
+ const cfg = loadConfig();
105
+ const installId = cfg?.installId;
106
+ if (!installId) return; // shouldn't happen after initTelemetry, but guard
107
+
108
+ const event = {
109
+ ...data,
110
+ installId,
111
+ timestamp: new Date().toISOString(),
112
+ };
113
+
114
+ _write(event);
115
+
116
+ if (REMOTE_URL) {
117
+ _sendRemote(event); // fire-and-forget
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Wrap a command handler to automatically record timing, success, and result metadata.
123
+ *
124
+ * The wrapped fn can return an optional { score, content_type } for richer tracking.
125
+ */
126
+ export function trackCommand(command, fn) {
127
+ return async (...args) => {
128
+ const start = Date.now();
129
+ let success = false;
130
+ let meta = {};
131
+
132
+ // Register exit handler so we capture exit(1) paths too
133
+ process.once('exit', (code) => {
134
+ if (!success) {
135
+ recordEvent({
136
+ event: 'command',
137
+ command,
138
+ duration_ms: Date.now() - start,
139
+ success: code === 0,
140
+ exit_code: code,
141
+ ...meta,
142
+ });
143
+ }
144
+ });
145
+
146
+ try {
147
+ const result = await fn(...args);
148
+ success = true;
149
+ if (result?.score != null) meta.score = result.score;
150
+ if (result?.content_type) meta.content_type = result.content_type;
151
+
152
+ recordEvent({
153
+ event: 'command',
154
+ command,
155
+ duration_ms: Date.now() - start,
156
+ success: true,
157
+ exit_code: 0,
158
+ ...meta,
159
+ });
160
+
161
+ return result;
162
+ } catch (err) {
163
+ // re-throw — the exit handler will record failure
164
+ throw err;
165
+ }
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Disable telemetry and persist to config.
171
+ * Called by: content-grade telemetry off
172
+ */
173
+ export function disableTelemetry() {
174
+ const cfg = loadConfig() || {};
175
+ cfg.telemetryEnabled = false;
176
+ saveConfig(cfg);
177
+ }
178
+
179
+ /**
180
+ * Enable telemetry (undo opt-out).
181
+ * Called by: content-grade telemetry on
182
+ */
183
+ export function enableTelemetry() {
184
+ const cfg = loadConfig() || {};
185
+ cfg.telemetryEnabled = true;
186
+ if (!cfg.installId) cfg.installId = generateId();
187
+ saveConfig(cfg);
188
+ }
189
+
190
+ /**
191
+ * Show telemetry status.
192
+ */
193
+ export function telemetryStatus() {
194
+ const optedOut = isOptedOut();
195
+ const cfg = loadConfig();
196
+ return {
197
+ enabled: !optedOut,
198
+ installId: cfg?.installId || null,
199
+ eventsFile: EVENTS_FILE,
200
+ remoteUrl: REMOTE_URL || '(none — local only)',
201
+ };
202
+ }
203
+
204
+ // ── Internal ──────────────────────────────────────────────────────────────────
205
+
206
+ function _write(event) {
207
+ try {
208
+ ensureDir();
209
+ appendFileSync(EVENTS_FILE, JSON.stringify(event) + '\n');
210
+ } catch {
211
+ // Never fail silently vs crashing the CLI
212
+ }
213
+ }
214
+
215
+ async function _sendRemote(event) {
216
+ const controller = new AbortController();
217
+ const timer = setTimeout(() => controller.abort(), 3000);
218
+ try {
219
+ await fetch(REMOTE_URL, {
220
+ method: 'POST',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify(event),
223
+ signal: controller.signal,
224
+ });
225
+ } catch {
226
+ // Network failure — silently ignore, never interrupt the CLI
227
+ } finally {
228
+ clearTimeout(timer);
229
+ }
230
+ }