From 9f6df0809391a664661a19468d7449bcae7d647a Mon Sep 17 00:00:00 2001 From: growth-eng Date: Tue, 12 May 2026 13:23:22 -0700 Subject: [PATCH] feat: add opt-in plugin telemetry metrics pipeline --- README.md | 6 ++- hooks/lib/telemetry.js | 115 ++++++++++++++++++++++++++++++++++++++++ hooks/post-edit.js | 2 + hooks/session-start.js | 2 + hooks/session-stop.js | 2 + tests/telemetry.test.js | 39 ++++++++++++++ 6 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 hooks/lib/telemetry.js create mode 100644 tests/telemetry.test.js diff --git a/README.md b/README.md index 8dc557d..154f5dd 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,15 @@ node ~/.gradata/plugin/setup/doctor.js ## Privacy -- Gradata does not collect telemetry. No data leaves your machine. Local files only. +- Telemetry is **opt-in only** via `GRADATA_TELEMETRY=1` (default is off). +- Opt-in telemetry sends only aggregate counters (`wau_ping`, `corrections_captured`, `rules_graduated`), plugin version, UTC timestamp, and an anonymous `user_id` (sha256 of local install ID). +- No prompt text, file paths, emails, API keys, lesson content, or correction payloads are sent. - All data stays local under `~/.gradata/`. - The daemon binds to `127.0.0.1` only — no network exposure. - Cloud sync is optional and only runs when you configure an API key. +Telemetry endpoint defaults to `https://api.gradata.ai/telemetry/plugin` and can be overridden for testing with `GRADATA_TELEMETRY_ENDPOINT`. + ## Supported agent CLIs - **Claude Code** — installer also creates `~/.claude/plugins/gradata` diff --git a/hooks/lib/telemetry.js b/hooks/lib/telemetry.js new file mode 100644 index 0000000..4bacef0 --- /dev/null +++ b/hooks/lib/telemetry.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); + +const GRADATA_HOME = process.env.GRADATA_HOME || path.join(os.homedir(), '.gradata'); +const INSTALL_ID_PATH = path.join(GRADATA_HOME, 'install_id'); +const TELEMETRY_ENDPOINT = process.env.GRADATA_TELEMETRY_ENDPOINT || 'https://api.gradata.ai/telemetry/plugin'; +const TELEMETRY_TIMEOUT_MS = 1500; + +let cachedAnonUserId = null; +let cachedPluginVersion = null; + +function telemetryEnabled() { + return process.env.GRADATA_TELEMETRY === '1'; +} + +function ensureInstallId() { + try { + if (fs.existsSync(INSTALL_ID_PATH)) { + return fs.readFileSync(INSTALL_ID_PATH, 'utf8').trim(); + } + fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true }); + const installId = crypto.randomUUID(); + fs.writeFileSync(INSTALL_ID_PATH, `${installId}\n`, { mode: 0o600 }); + return installId; + } catch { + return ''; + } +} + +function getAnonymousUserId() { + if (cachedAnonUserId) return cachedAnonUserId; + const installId = ensureInstallId(); + if (!installId) return ''; + cachedAnonUserId = crypto.createHash('sha256').update(installId).digest('hex'); + return cachedAnonUserId; +} + +function getPluginVersion() { + if (cachedPluginVersion) return cachedPluginVersion; + try { + const pluginJsonPath = path.resolve(__dirname, '../../.claude-plugin/plugin.json'); + const raw = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); + cachedPluginVersion = typeof raw.version === 'string' ? raw.version : 'unknown'; + } catch { + cachedPluginVersion = 'unknown'; + } + return cachedPluginVersion; +} + +function postJson(url, payload) { + return new Promise((resolve) => { + let requestUrl; + try { + requestUrl = new URL(url); + } catch { + resolve(false); + return; + } + + const body = JSON.stringify(payload); + const req = https.request( + { + protocol: requestUrl.protocol, + hostname: requestUrl.hostname, + port: requestUrl.port || 443, + path: `${requestUrl.pathname}${requestUrl.search}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: TELEMETRY_TIMEOUT_MS, + }, + (res) => { + res.resume(); + resolve(res.statusCode >= 200 && res.statusCode < 300); + } + ); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.write(body); + req.end(); + }); +} + +async function sendTelemetryMetric(metric, count = 1) { + if (!telemetryEnabled()) return false; + const userId = getAnonymousUserId(); + if (!userId) return false; + if (typeof metric !== 'string' || !metric.trim()) return false; + if (!Number.isFinite(count) || count <= 0) return false; + + return postJson(TELEMETRY_ENDPOINT, { + event: 'plugin_metric', + metric, + count: Math.floor(count), + user_id: userId, + ts: new Date().toISOString(), + plugin_version: getPluginVersion(), + }); +} + +module.exports = { + telemetryEnabled, + getAnonymousUserId, + sendTelemetryMetric, +}; diff --git a/hooks/post-edit.js b/hooks/post-edit.js index d46ff3f..e4f1267 100644 --- a/hooks/post-edit.js +++ b/hooks/post-edit.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const { callDaemon } = require('./lib/daemon-client.js'); const { readHookInput, WRITE_TOOLS } = require('./lib/hook-input.js'); +const { sendTelemetryMetric } = require('./lib/telemetry.js'); (async () => { try { const eventData = readHookInput(); @@ -17,5 +18,6 @@ const { readHookInput, WRITE_TOOLS } = require('./lib/hook-input.js'); old_string: oldStr, new_string: newStr, file_path: filePath, session_id: sessionId, }, 1000); + await sendTelemetryMetric('corrections_captured', 1); } catch (e) { /* Best-effort — never block editing */ } })(); diff --git a/hooks/session-start.js b/hooks/session-start.js index 6f3912c..c752e78 100644 --- a/hooks/session-start.js +++ b/hooks/session-start.js @@ -1,10 +1,12 @@ #!/usr/bin/env node const { callDaemon } = require('./lib/daemon-client.js'); const { readHookInput } = require('./lib/hook-input.js'); +const { sendTelemetryMetric } = require('./lib/telemetry.js'); (async () => { try { const eventData = readHookInput(); const sessionId = eventData.session_id || `s_${Date.now()}`; + await sendTelemetryMetric('wau_ping', 1); const result = await callDaemon('/apply-rules', { prompt: '', session_id: sessionId }, 3000); if (!result) { process.stderr.write('[gradata] Daemon not available — corrections will not be captured this session\n'); diff --git a/hooks/session-stop.js b/hooks/session-stop.js index 4b852d3..9b1aa9b 100644 --- a/hooks/session-stop.js +++ b/hooks/session-stop.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const { callDaemon } = require('./lib/daemon-client.js'); const { readHookInput } = require('./lib/hook-input.js'); +const { sendTelemetryMetric } = require('./lib/telemetry.js'); (async () => { try { const eventData = readHookInput(); @@ -14,6 +15,7 @@ const { readHookInput } = require('./lib/hook-input.js'); if (c > 0 || g > 0) { process.stderr.write(`[gradata] Session end: ${c} corrections, ${i} instructions, ${g} graduated\n`); } + if (g > 0) await sendTelemetryMetric('rules_graduated', g); } const maintainResult = await callDaemon('/maintain', { tasks: ['manifest', 'patterns'] }, 10000); diff --git a/tests/telemetry.test.js b/tests/telemetry.test.js new file mode 100644 index 0000000..e2a5ce2 --- /dev/null +++ b/tests/telemetry.test.js @@ -0,0 +1,39 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +test('telemetry disabled by default', () => { + const prev = process.env.GRADATA_TELEMETRY; + delete process.env.GRADATA_TELEMETRY; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { telemetryEnabled } = require('../hooks/lib/telemetry.js'); + assert.strictEqual(telemetryEnabled(), false); + if (prev === undefined) delete process.env.GRADATA_TELEMETRY; else process.env.GRADATA_TELEMETRY = prev; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +}); + +test('telemetry enabled with GRADATA_TELEMETRY=1', () => { + const prev = process.env.GRADATA_TELEMETRY; + process.env.GRADATA_TELEMETRY = '1'; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { telemetryEnabled } = require('../hooks/lib/telemetry.js'); + assert.strictEqual(telemetryEnabled(), true); + if (prev === undefined) delete process.env.GRADATA_TELEMETRY; else process.env.GRADATA_TELEMETRY = prev; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +}); + +test('anonymous user id is stable 64-char lowercase hex hash', () => { + const prevHome = process.env.GRADATA_HOME; + const os = require('node:os'); + const path = require('node:path'); + const fs = require('node:fs'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-telemetry-')); + process.env.GRADATA_HOME = dir; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; + const { getAnonymousUserId } = require('../hooks/lib/telemetry.js'); + const a = getAnonymousUserId(); + const b = getAnonymousUserId(); + assert.strictEqual(a, b); + assert.match(a, /^[0-9a-f]{64}$/); + if (prevHome === undefined) delete process.env.GRADATA_HOME; else process.env.GRADATA_HOME = prevHome; + delete require.cache[require.resolve('../hooks/lib/telemetry.js')]; +});