Skip to content
This repository was archived by the owner on May 25, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
115 changes: 115 additions & 0 deletions hooks/lib/telemetry.js
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 2 additions & 0 deletions hooks/post-edit.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 */ }
})();
2 changes: 2 additions & 0 deletions hooks/session-start.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
2 changes: 2 additions & 0 deletions hooks/session-stop.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions tests/telemetry.test.js
Original file line number Diff line number Diff line change
@@ -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')];
});