Skip to content

Commit 44831be

Browse files
authored
Merge pull request #97 from AxmeAI/feat/telemetry-client-20260410
feat(telemetry): anonymous Phase 1 + Phase 2 client
2 parents f92c1af + 997e2b2 commit 44831be

18 files changed

Lines changed: 2319 additions & 47 deletions

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,34 @@ Additional presets available: `production-ready`, `team-collaboration`.
284284

285285
---
286286

287+
## Telemetry
288+
289+
axme-code sends anonymous usage telemetry to help us improve the product. We collect:
290+
291+
- **Lifecycle events**: install, startup, version update
292+
- **Product health events**: setup completion, audit completion (counts of memories/decisions/safety extracted, duration, cost, error class)
293+
- **Errors**: category and bounded error class for failures in audit, setup, hooks, and auto-update
294+
295+
What we **never** send:
296+
- Hostnames, usernames, file paths, working directories
297+
- Source code, transcripts, decisions, memories, or any project content
298+
- IP addresses (stripped at the server)
299+
- Raw exception messages (we map to a small set of error classes)
300+
301+
Each install gets a random 64-character machine ID stored in `~/.local/share/axme-code/machine-id`. The ID is not derived from hardware and cannot be linked back to you.
302+
303+
**To disable telemetry**, set either of these environment variables:
304+
305+
```bash
306+
export AXME_TELEMETRY_DISABLED=1
307+
# or the industry-standard:
308+
export DO_NOT_TRACK=1
309+
```
310+
311+
When disabled, no network requests are made and no machine ID is generated.
312+
313+
---
314+
287315
## Contributing
288316

289317
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

docs/TELEMETRY_TZ.md

Lines changed: 765 additions & 0 deletions
Large diffs are not rendered by default.

src/agents/session-auditor.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface SessionAuditResult {
4646
chunks?: number;
4747
/** Estimated prompt tokens for observability. */
4848
promptTokens?: number;
49+
/** Number of extraction blocks the parser dropped due to missing required fields. Used for telemetry. */
50+
droppedCount?: number;
4951
}
5052

5153
/**
@@ -537,6 +539,7 @@ export async function runSessionAudit(opts: {
537539
let totalCostUsd = 0;
538540
let totalCostCached: CostInfo | undefined;
539541
let totalPromptChars = 0;
542+
let totalDroppedCount = 0;
540543

541544
for (let i = 0; i < chunks.length; i++) {
542545
const chunkBlock = chunks[i];
@@ -561,6 +564,7 @@ export async function runSessionAudit(opts: {
561564
});
562565

563566
totalPromptChars += chunkResult.promptChars;
567+
totalDroppedCount += chunkResult.droppedCount ?? 0;
564568
if (chunkResult.cost) {
565569
totalCostCached = chunkResult.cost;
566570
totalCostUsd += chunkResult.cost.costUsd ?? 0;
@@ -594,6 +598,7 @@ export async function runSessionAudit(opts: {
594598
durationMs: Date.now() - startTime,
595599
chunks: chunks.length,
596600
promptTokens: Math.round(totalPromptChars / 4),
601+
droppedCount: totalDroppedCount,
597602
};
598603
}
599604

@@ -639,7 +644,7 @@ async function runSingleAuditCall(opts: {
639644
// auto-loading the project's .claude/settings.json, but users or CI may
640645
// register hooks via environment or other means, so the belt-and-braces
641646
// env check in every hook handler is what actually stops the recursion.
642-
env: { ...process.env, AXME_SKIP_HOOKS: "1" },
647+
env: { ...process.env, AXME_SKIP_HOOKS: "1", AXME_TELEMETRY_DISABLED: "1" },
643648
};
644649

645650
const isMultiChunk = opts.totalChunks > 1;
@@ -891,7 +896,7 @@ ${freeTextAnalysis}`;
891896
"Read", "Grep", "Glob", "Write", "Edit", "NotebookEdit", "Agent",
892897
"Skill", "TodoWrite", "WebFetch", "WebSearch", "Bash", "ToolSearch",
893898
],
894-
env: { ...process.env, AXME_SKIP_HOOKS: "1" },
899+
env: { ...process.env, AXME_SKIP_HOOKS: "1", AXME_TELEMETRY_DISABLED: "1" },
895900
};
896901

897902
const q = sdk.query({ prompt: formatPrompt, options: queryOpts });
@@ -925,10 +930,11 @@ ${freeTextAnalysis}`;
925930
*/
926931
export function parseAuditOutput(output: string | object, sessionId: string): Omit<SessionAuditResult, "cost" | "durationMs"> {
927932
const today = new Date().toISOString().slice(0, 10);
933+
let droppedCount = 0;
928934
const json = typeof output === "object" ? output : extractJson(output);
929935
if (!json) {
930936
process.stderr.write(`AXME auditor: failed to extract JSON from output (${typeof output === "string" ? output.length : 0} chars). First 300: ${typeof output === "string" ? output.slice(0, 300) : JSON.stringify(output).slice(0, 300)}\n`);
931-
return { memories: [], decisions: [], safetyRules: [], oracleNeedsRescan: false, questions: [], handoff: null, sessionSummary: null };
937+
return { memories: [], decisions: [], safetyRules: [], oracleNeedsRescan: false, questions: [], handoff: null, sessionSummary: null, droppedCount: 0 };
932938
}
933939

934940
// Parse memories
@@ -945,11 +951,11 @@ export function parseAuditOutput(output: string | object, sessionId: string): Om
945951
const fieldName = m.body ? "body" : m.summary ? "summary" : "description";
946952
process.stderr.write(`AXME auditor: memory title recovered from ${fieldName}: ${title.slice(0, 80)}\n`);
947953
}
948-
if (!title) { process.stderr.write(`AXME auditor: memory dropped (no usable content): ${JSON.stringify(m).slice(0, 200)}\n`); continue; }
954+
if (!title) { droppedCount++; process.stderr.write(`AXME auditor: memory dropped (no usable content): ${JSON.stringify(m).slice(0, 200)}\n`); continue; }
949955
const type = m.type;
950-
if (type !== "feedback" && type !== "pattern") { process.stderr.write(`AXME auditor: memory "${title.slice(0, 60)}" dropped (invalid type: ${type})\n`); continue; }
956+
if (type !== "feedback" && type !== "pattern") { droppedCount++; process.stderr.write(`AXME auditor: memory "${title.slice(0, 60)}" dropped (invalid type: ${type})\n`); continue; }
951957
const slug = toMemorySlug(m.slug || title);
952-
if (!slug) { process.stderr.write(`AXME auditor: memory "${title.slice(0, 60)}" dropped (could not generate slug)\n`); continue; }
958+
if (!slug) { droppedCount++; process.stderr.write(`AXME auditor: memory "${title.slice(0, 60)}" dropped (could not generate slug)\n`); continue; }
953959
const scope = parseScopeField(m.scope);
954960
memories.push({
955961
slug, type, title,
@@ -965,10 +971,11 @@ export function parseAuditOutput(output: string | object, sessionId: string): Om
965971
const decisions: Omit<Decision, "id">[] = [];
966972
for (const d of (Array.isArray(json.decisions) ? json.decisions : [])) {
967973
const title = d.title;
968-
if (!title) { process.stderr.write(`AXME auditor: decision dropped (no title): ${JSON.stringify(d).slice(0, 200)}\n`); continue; }
974+
if (!title) { droppedCount++; process.stderr.write(`AXME auditor: decision dropped (no title): ${JSON.stringify(d).slice(0, 200)}\n`); continue; }
969975
// Fallback: if decision body is missing, try reasoning or use title
970976
let decision = d.decision || d.reasoning || "";
971977
if (!decision) {
978+
droppedCount++;
972979
process.stderr.write(`AXME auditor: decision "${title}" dropped (no decision or reasoning field)\n`);
973980
continue;
974981
}
@@ -996,8 +1003,8 @@ export function parseAuditOutput(output: string | object, sessionId: string): Om
9961003
for (const s of (Array.isArray(json.safety) ? json.safety : [])) {
9971004
const ruleType = s.rule_type;
9981005
const value = s.value;
999-
if (!ruleType) { process.stderr.write(`AXME auditor: safety dropped (no rule_type): ${JSON.stringify(s).slice(0, 200)}\n`); continue; }
1000-
if (!value) { process.stderr.write(`AXME auditor: safety dropped (no value, rule_type=${ruleType})\n`); continue; }
1006+
if (!ruleType) { droppedCount++; process.stderr.write(`AXME auditor: safety dropped (no rule_type): ${JSON.stringify(s).slice(0, 200)}\n`); continue; }
1007+
if (!value) { droppedCount++; process.stderr.write(`AXME auditor: safety dropped (no value, rule_type=${ruleType})\n`); continue; }
10011008
const scope = parseScopeField(s.scope);
10021009
safetyRules.push({ ruleType, value, ...(scope ? { scope } : {}) });
10031010
}
@@ -1046,7 +1053,7 @@ export function parseAuditOutput(output: string | object, sessionId: string): Om
10461053
const sessionSummary = json.session_summary && typeof json.session_summary === "string" && json.session_summary.trim().length > 10
10471054
? json.session_summary.trim() : null;
10481055

1049-
return { memories, decisions, safetyRules, oracleNeedsRescan, questions, handoff, sessionSummary };
1056+
return { memories, decisions, safetyRules, oracleNeedsRescan, questions, handoff, sessionSummary, droppedCount };
10501057
}
10511058

10521059
/**

src/auto-update.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,12 @@ export async function backgroundAutoUpdate(): Promise<void> {
217217
updated: false,
218218
});
219219
}
220-
} catch {
221-
// Never crash the server due to update logic
220+
} catch (err) {
221+
// Never crash the server due to update logic.
222+
// Report to telemetry so we can see auto-update failures in aggregate.
223+
try {
224+
const { reportError, classifyError } = await import("./telemetry.js");
225+
reportError("auto_update", classifyError(err), false);
226+
} catch { /* swallow */ }
222227
}
223228
}

src/cli.ts

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -266,15 +266,59 @@ After setup, run 'claude' as usual. AXME tools are available automatically.`);
266266
}
267267

268268
async function main() {
269+
// Send anonymous startup telemetry only for user-facing CLI commands.
270+
// Hook and audit-session subcommands run as short-lived subprocesses
271+
// (Claude Code hooks fire many times per session, audit-session is a
272+
// detached background worker), so firing telemetry per invocation would
273+
// spam the endpoint and skew startup counts. The `serve` command sends
274+
// its own startup event from server.ts after MCP server is up.
275+
// We AWAIT this so events flush before heavy work begins — under event
276+
// loop pressure (LLM scanners), fire-and-forget setImmediate may stall.
277+
const startupCommands = new Set(["setup", "status", "stats", "audit-kb", "cleanup", "help"]);
278+
if (command && startupCommands.has(command)) {
279+
const { sendStartupEvents } = await import("./telemetry.js");
280+
await sendStartupEvents();
281+
}
282+
269283
switch (command) {
270284
case "setup": {
285+
const setupStartMs = Date.now();
271286
const forceSetup = args.includes("--force");
272287
const pluginMode = args.includes("--plugin") || !!process.env.CLAUDE_PLUGIN_ROOT;
273288
const setupArgs = args.filter(a => a !== "--force" && a !== "--plugin");
274289
const projectPath = resolve(setupArgs[1] || ".");
275290
const hasGitDir = existsSync(join(projectPath, ".git"));
276291
const ws = detectWorkspace(projectPath);
277292
const isWorkspace = hasGitDir ? false : ws.type !== "single";
293+
const childRepos = isWorkspace
294+
? ws.projects.filter(p => existsSync(join(projectPath, p.path, ".git"))).length
295+
: 0;
296+
// Telemetry-relevant fields, populated as setup progresses
297+
let setupOutcome: "success" | "fallback" | "failed" = "failed";
298+
let setupMethod: "llm" | "deterministic" = "deterministic";
299+
let setupPhaseFailed: string | null = null;
300+
let setupPresetsApplied = 0;
301+
let setupScannersRun = 0;
302+
let setupScannersFailed = 0;
303+
// Use the blocking variant so the event lands BEFORE process.exit() runs.
304+
// The fire-and-forget sendTelemetry uses setImmediate, which is killed
305+
// by process.exit() before the network request is even started.
306+
const sendSetupTelemetry = async () => {
307+
try {
308+
const { sendTelemetryBlocking } = await import("./telemetry.js");
309+
await sendTelemetryBlocking("setup_complete", {
310+
outcome: setupOutcome,
311+
duration_ms: Date.now() - setupStartMs,
312+
method: setupMethod,
313+
scanners_run: setupScannersRun,
314+
scanners_failed: setupScannersFailed,
315+
phase_failed: setupPhaseFailed,
316+
presets_applied: setupPresetsApplied,
317+
is_workspace: isWorkspace,
318+
child_repos: childRepos,
319+
});
320+
} catch { /* swallow */ }
321+
};
278322

279323
if (isWorkspace) {
280324
console.log(`Initializing AXME Code workspace in ${projectPath} (${ws.type}, ${ws.projects.length} projects)...`);
@@ -289,36 +333,60 @@ async function main() {
289333
console.error(`To authenticate, run one of:`);
290334
console.error(` claude login (Claude subscription)`);
291335
console.error(` export ANTHROPIC_API_KEY=sk-ant-... (API key)\n`);
336+
setupOutcome = "failed";
337+
setupPhaseFailed = "auth_check";
338+
await sendSetupTelemetry();
292339
process.exit(1);
293340
}
294341

295342
// Init with LLM scanners (parallel)
296-
if (isWorkspace) {
297-
const { workspaceResult, projectResults } = await initWorkspaceWithLLM(projectPath, { onProgress: console.log });
298-
const totalCost = workspaceResult.cost.costUsd + projectResults.reduce((s, r) => s + r.cost.costUsd, 0);
299-
console.log(` Workspace: ${workspaceResult.decisions.count} decisions, ${workspaceResult.memories.count} memories`);
300-
for (const r of projectResults) {
301-
const name = r.projectPath.split("/").pop();
302-
console.log(` ${name}: ${r.decisions.count} decisions (${r.decisions.fromScan} LLM + ${r.decisions.fromPresets} presets)`);
303-
}
304-
if (totalCost > 0) console.log(` Total cost: $${totalCost.toFixed(2)}`);
305-
for (const e of [...workspaceResult.errors, ...projectResults.flatMap(r => r.errors)]) {
306-
console.log(` Warning: ${e}`);
307-
}
308-
generateWorkspaceYaml(projectPath, ws);
309-
} else {
310-
const result = await initProjectWithLLM(projectPath, { onProgress: console.log, force: forceSetup });
311-
if (!result.created && result.durationMs === 0) {
312-
console.log(` Already initialized (skipped LLM scan). Use --force to re-scan.`);
313-
console.log(` Decisions: ${result.decisions.count}, Memories: ${result.memories.count}`);
343+
try {
344+
if (isWorkspace) {
345+
const { workspaceResult, projectResults } = await initWorkspaceWithLLM(projectPath, { onProgress: console.log });
346+
const totalCost = workspaceResult.cost.costUsd + projectResults.reduce((s, r) => s + r.cost.costUsd, 0);
347+
console.log(` Workspace: ${workspaceResult.decisions.count} decisions, ${workspaceResult.memories.count} memories`);
348+
for (const r of projectResults) {
349+
const name = r.projectPath.split("/").pop();
350+
console.log(` ${name}: ${r.decisions.count} decisions (${r.decisions.fromScan} LLM + ${r.decisions.fromPresets} presets)`);
351+
}
352+
if (totalCost > 0) console.log(` Total cost: $${totalCost.toFixed(2)}`);
353+
for (const e of [...workspaceResult.errors, ...projectResults.flatMap(r => r.errors)]) {
354+
console.log(` Warning: ${e}`);
355+
}
356+
generateWorkspaceYaml(projectPath, ws);
357+
// Track telemetry: any LLM scan in any repo means LLM method
358+
const anyLlm = projectResults.some(r => r.oracle.llm) || workspaceResult.decisions.fromScan > 0;
359+
setupMethod = anyLlm ? "llm" : "deterministic";
360+
setupPresetsApplied = projectResults.reduce((s, r) => s + (r.decisions.fromPresets || 0), 0);
361+
// Sum scanner counts across workspace + all projects
362+
setupScannersRun = workspaceResult.scannersRun + projectResults.reduce((s, r) => s + r.scannersRun, 0);
363+
setupScannersFailed = workspaceResult.scannersFailed + projectResults.reduce((s, r) => s + r.scannersFailed, 0);
314364
} else {
315-
console.log(` Oracle: ${result.oracle.files} files (${result.oracle.llm ? "LLM scan" : "deterministic fallback"})`);
316-
console.log(` Decisions: ${result.decisions.count} (${result.decisions.fromScan} LLM + ${result.decisions.fromPresets} presets)`);
317-
console.log(` Memories: ${result.memories.count} (${result.memories.fromPresets} from presets)`);
318-
console.log(` Safety: ${result.safety.llm ? "LLM scan" : "defaults + presets"}`);
319-
if (result.cost.costUsd > 0) console.log(` Cost: $${result.cost.costUsd.toFixed(2)}, ${(result.durationMs / 1000).toFixed(1)}s`);
320-
for (const e of result.errors) console.log(` Warning: ${e}`);
365+
const result = await initProjectWithLLM(projectPath, { onProgress: console.log, force: forceSetup });
366+
if (!result.created && result.durationMs === 0) {
367+
console.log(` Already initialized (skipped LLM scan). Use --force to re-scan.`);
368+
console.log(` Decisions: ${result.decisions.count}, Memories: ${result.memories.count}`);
369+
} else {
370+
console.log(` Oracle: ${result.oracle.files} files (${result.oracle.llm ? "LLM scan" : "deterministic fallback"})`);
371+
console.log(` Decisions: ${result.decisions.count} (${result.decisions.fromScan} LLM + ${result.decisions.fromPresets} presets)`);
372+
console.log(` Memories: ${result.memories.count} (${result.memories.fromPresets} from presets)`);
373+
console.log(` Safety: ${result.safety.llm ? "LLM scan" : "defaults + presets"}`);
374+
if (result.cost.costUsd > 0) console.log(` Cost: $${result.cost.costUsd.toFixed(2)}, ${(result.durationMs / 1000).toFixed(1)}s`);
375+
for (const e of result.errors) console.log(` Warning: ${e}`);
376+
}
377+
// Track telemetry: oracle.llm tells us whether LLM path was used
378+
setupMethod = result.oracle.llm ? "llm" : "deterministic";
379+
setupPresetsApplied = (result.decisions.fromPresets || 0);
380+
setupScannersRun = result.scannersRun;
381+
setupScannersFailed = result.scannersFailed;
321382
}
383+
} catch (err) {
384+
setupOutcome = "failed";
385+
setupPhaseFailed = "init_scan";
386+
const { classifyError, reportError } = await import("./telemetry.js");
387+
try { reportError("setup", classifyError(err), true); } catch { /* swallow */ }
388+
await sendSetupTelemetry();
389+
throw err;
322390
}
323391

324392
// Detect plugin context — skip .mcp.json and hooks if running from plugin
@@ -378,6 +446,11 @@ async function main() {
378446
: 0;
379447
writeBootstrapToAxmeMemory(projectPath, isWorkspace, repoCount);
380448

449+
// Setup completed. setupMethod was set above by the init scan.
450+
// outcome=success when LLM ran end-to-end, fallback when deterministic was used.
451+
setupOutcome = setupMethod === "llm" ? "success" : "fallback";
452+
await sendSetupTelemetry();
453+
381454
console.log("\nDone! Run 'claude' to start using AXME tools.");
382455
break;
383456
}

src/hooks/post-tool-use.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,13 @@ export async function runPostToolUseHook(workspacePath?: string): Promise<void>
6868
for await (const chunk of process.stdin) chunks.push(chunk);
6969
const input = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as HookInput;
7070
handlePostToolUse(workspacePath, input);
71-
} catch {
72-
// Hook failures must be silent
71+
} catch (err) {
72+
// Hook failures must be silent — but reported to telemetry for visibility.
73+
// Use blocking send: hook subprocess exits ms after this catch and would
74+
// kill any setImmediate-queued network call.
75+
try {
76+
const { sendTelemetryBlocking, classifyError } = await import("../telemetry.js");
77+
await sendTelemetryBlocking("error", { category: "hook", error_class: classifyError(err), fatal: false });
78+
} catch { /* swallow */ }
7379
}
7480
}

src/hooks/pre-tool-use.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,13 @@ export async function runPreToolUseHook(workspacePath?: string): Promise<void> {
219219
for await (const chunk of process.stdin) chunks.push(chunk);
220220
const input = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as HookInput;
221221
handlePreToolUse(workspacePath, input);
222-
} catch {
223-
// Hook failures must be silent - fail open for safety
222+
} catch (err) {
223+
// Hook failures must be silent - fail open for safety.
224+
// Reported to telemetry via blocking send so the network call lands
225+
// before this short-lived hook subprocess exits.
226+
try {
227+
const { sendTelemetryBlocking, classifyError } = await import("../telemetry.js");
228+
await sendTelemetryBlocking("error", { category: "hook", error_class: classifyError(err), fatal: false });
229+
} catch { /* swallow */ }
224230
}
225231
}

src/hooks/session-end.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ export async function runSessionEndHook(workspacePath?: string): Promise<void> {
8787
// Empty/invalid stdin is fine — we'll proceed without transcript attachment
8888
}
8989
handleSessionEnd(workspacePath, input);
90-
} catch {
91-
// Hook failures must be silent
90+
} catch (err) {
91+
// Hook failures must be silent — but reported to telemetry for visibility.
92+
// Use blocking send: hook subprocess exits ms after this catch.
93+
try {
94+
const { sendTelemetryBlocking, classifyError } = await import("../telemetry.js");
95+
await sendTelemetryBlocking("error", { category: "hook", error_class: classifyError(err), fatal: false });
96+
} catch { /* swallow */ }
9297
}
9398
}

0 commit comments

Comments
 (0)