From d71170ffd914998f14fcff9eb5acbbd593eed234 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Mon, 1 Jun 2026 06:38:52 +0200 Subject: [PATCH 1/9] feat: add telemetry for installer and tool usage --- README.md | 1 + package-lock.json | 103 +++++++ packages/argent-cli/package.json | 1 + packages/argent-cli/src/index.ts | 1 + packages/argent-cli/src/telemetry.ts | 142 ++++++++++ packages/argent-installer/package.json | 1 + packages/argent-installer/src/init.ts | 157 ++++++++++- packages/argent-installer/src/uninstall.ts | 35 +++ packages/argent-installer/src/update.ts | 77 +++++- .../argent-installer/test/uninstall.test.ts | 54 +++- packages/argent-installer/test/update.test.ts | 19 ++ packages/argent/package.json | 2 + packages/argent/scripts/bundle-tools.cjs | 26 +- packages/argent/src/cli.ts | 3 + packages/registry/src/logger.ts | 14 +- packages/registry/src/registry.ts | 14 +- packages/registry/src/types.ts | 11 +- packages/registry/tests/logger.test.ts | 23 +- packages/registry/tests/registry.test.ts | 58 +++- packages/telemetry/package.json | 23 ++ packages/telemetry/src/base-props.ts | 62 +++++ packages/telemetry/src/ci-detect.ts | 54 ++++ packages/telemetry/src/consent.ts | 152 ++++++++++ packages/telemetry/src/debug.ts | 55 ++++ packages/telemetry/src/erasure.ts | 56 ++++ packages/telemetry/src/events.ts | 193 +++++++++++++ packages/telemetry/src/hash.ts | 20 ++ packages/telemetry/src/identity.ts | 84 ++++++ packages/telemetry/src/index.ts | 261 ++++++++++++++++++ packages/telemetry/src/paths.ts | 32 +++ packages/telemetry/src/posthog.ts | 65 +++++ packages/telemetry/src/registry-listener.ts | 104 +++++++ packages/telemetry/src/sanitize.ts | 175 ++++++++++++ packages/telemetry/test/base-props.test.ts | 87 ++++++ packages/telemetry/test/ci-detect.test.ts | 42 +++ packages/telemetry/test/consent.test.ts | 180 ++++++++++++ packages/telemetry/test/debug.test.ts | 99 +++++++ packages/telemetry/test/helpers.ts | 57 ++++ packages/telemetry/test/identity.test.ts | 93 +++++++ packages/telemetry/test/index.test.ts | 204 ++++++++++++++ packages/telemetry/test/paths.test.ts | 35 +++ packages/telemetry/test/posthog-host.test.ts | 86 ++++++ .../telemetry/test/registry-listener.test.ts | 192 +++++++++++++ packages/telemetry/test/sanitize.test.ts | 252 +++++++++++++++++ packages/telemetry/tsconfig.json | 11 + packages/telemetry/tsconfig.test.json | 11 + packages/telemetry/vitest.config.ts | 8 + packages/tool-server/package.json | 1 + packages/tool-server/src/http.ts | 46 ++- packages/tool-server/src/index.ts | 41 ++- .../src/tools/system/update-argent.ts | 1 + .../tool-server/test/http-tools-meta.test.ts | 77 +++++- .../test/update-argent-tool.test.ts | 1 + tsconfig.json | 1 + 54 files changed, 3564 insertions(+), 39 deletions(-) create mode 100644 packages/argent-cli/src/telemetry.ts create mode 100644 packages/telemetry/package.json create mode 100644 packages/telemetry/src/base-props.ts create mode 100644 packages/telemetry/src/ci-detect.ts create mode 100644 packages/telemetry/src/consent.ts create mode 100644 packages/telemetry/src/debug.ts create mode 100644 packages/telemetry/src/erasure.ts create mode 100644 packages/telemetry/src/events.ts create mode 100644 packages/telemetry/src/hash.ts create mode 100644 packages/telemetry/src/identity.ts create mode 100644 packages/telemetry/src/index.ts create mode 100644 packages/telemetry/src/paths.ts create mode 100644 packages/telemetry/src/posthog.ts create mode 100644 packages/telemetry/src/registry-listener.ts create mode 100644 packages/telemetry/src/sanitize.ts create mode 100644 packages/telemetry/test/base-props.test.ts create mode 100644 packages/telemetry/test/ci-detect.test.ts create mode 100644 packages/telemetry/test/consent.test.ts create mode 100644 packages/telemetry/test/debug.test.ts create mode 100644 packages/telemetry/test/helpers.ts create mode 100644 packages/telemetry/test/identity.test.ts create mode 100644 packages/telemetry/test/index.test.ts create mode 100644 packages/telemetry/test/paths.test.ts create mode 100644 packages/telemetry/test/posthog-host.test.ts create mode 100644 packages/telemetry/test/registry-listener.test.ts create mode 100644 packages/telemetry/test/sanitize.test.ts create mode 100644 packages/telemetry/tsconfig.json create mode 100644 packages/telemetry/tsconfig.test.json create mode 100644 packages/telemetry/vitest.config.ts diff --git a/README.md b/README.md index 60462c1a..affd86db 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ argent init | `argent enable` | Enable a predefined feature flag (`--scope project` for project-local) | | `argent disable` | Disable a feature flag (`--scope project` for project-local) | | `argent flags` | List available feature flags and their state | +| `argent telemetry` | Manage anonymous telemetry: `status` / `enable` / `disable` | ## Supported Editors diff --git a/package-lock.json b/package-lock.json index 9aed7e83..c3d45e39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,10 @@ "resolved": "packages/skills", "link": true }, + "node_modules/@argent/telemetry": { + "resolved": "packages/telemetry", + "link": true + }, "node_modules/@argent/tool-server": { "resolved": "packages/tool-server", "link": true @@ -904,6 +908,21 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@posthog/core": { + "version": "1.29.8", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.8.tgz", + "integrity": "sha512-wdX4/WzZ+sV92z4ppC9SjOWdztY/0bN74SbJFy1X8/1N8+aNTSHsGEKHtbHitkIkJc861oYWr4ZzOoV0iVDP4w==", + "license": "MIT", + "dependencies": { + "@posthog/types": "1.375.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.375.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.375.0.tgz", + "integrity": "sha512-ykjHtJv1eUnEUQIuCavMi/+lnBhZPRVnFDrbG6m4fS+vZ3ajn8dGooPpbWjF33Uo4g7W4ew51dBtJGf2evvurA==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", @@ -1650,6 +1669,21 @@ "node": ">=18" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "dev": true, @@ -2787,6 +2821,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-node": { + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.35.0.tgz", + "integrity": "sha512-5Hos1mlwrZtzZbh1Pij1FyU9p4R3bajVtAKjPZ3vxhAScsGeyLsF5KqMaEAw3EYWmsX9SQ5CbYZtSlHf+nkw6g==", + "license": "MIT", + "dependencies": { + "@posthog/core": "1.29.8" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -3680,12 +3734,14 @@ "@argent/cli": "file:../argent-cli", "@argent/installer": "file:../argent-installer", "@argent/mcp": "file:../argent-mcp", + "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@clack/prompts": "^1.1.0", "@types/node": "^25.9.0", "@types/semver": "^7.7.1", "esbuild": "^0.28.0", "picocolors": "^1.1.1", + "posthog-node": "5.35.0", "semver": "^7.7.4", "smol-toml": "^1.6.1", "typescript": "^6.0.3", @@ -3697,6 +3753,7 @@ "name": "@argent/cli", "version": "0.10.0", "dependencies": { + "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client" }, "devDependencies": { @@ -3740,6 +3797,7 @@ "name": "@argent/installer", "version": "0.10.0", "dependencies": { + "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@argent/update-core": "file:../update-core", "@clack/prompts": "^1.4.0", @@ -4041,6 +4099,50 @@ "argent-skills": "scripts/install.js" } }, + "packages/telemetry": { + "name": "@argent/telemetry", + "version": "0.8.1", + "dependencies": { + "ci-info": "^4.4.0", + "posthog-node": "5.35.0" + }, + "devDependencies": { + "@types/node": "^25.9.0", + "typescript": "^6.0.3", + "vitest": "^4.1.6" + } + }, + "packages/telemetry/node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "packages/telemetry/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/telemetry/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "packages/tool-server": { "name": "@argent/tool-server", "version": "0.10.0", @@ -4048,6 +4150,7 @@ "@argent/native-devtools-android": "file:../native-devtools-android", "@argent/native-devtools-ios": "file:../native-devtools-ios", "@argent/registry": "file:../registry", + "@argent/telemetry": "file:../telemetry", "@argent/update-core": "file:../update-core", "@clack/prompts": "^1.1.0", "express": "^4.19.2", diff --git a/packages/argent-cli/package.json b/packages/argent-cli/package.json index c2cede09..32ff29d4 100644 --- a/packages/argent-cli/package.json +++ b/packages/argent-cli/package.json @@ -12,6 +12,7 @@ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { + "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@clack/prompts": "^1.1.0", "picocolors": "^1.1.1" diff --git a/packages/argent-cli/src/index.ts b/packages/argent-cli/src/index.ts index a88db2f9..0e7b6bbb 100644 --- a/packages/argent-cli/src/index.ts +++ b/packages/argent-cli/src/index.ts @@ -12,3 +12,4 @@ export { type FlagScope, type FlagDefinition, } from "./flags.js"; +export { telemetry } from "./telemetry.js"; diff --git a/packages/argent-cli/src/telemetry.ts b/packages/argent-cli/src/telemetry.ts new file mode 100644 index 00000000..949ee61f --- /dev/null +++ b/packages/argent-cli/src/telemetry.ts @@ -0,0 +1,142 @@ +import pc from "picocolors"; +import { + init as telemetryInit, + isEnabled as telemetryIsEnabled, + markDisabled, + markEnabled, + shutdown as telemetryShutdown, + status as telemetryStatus, + trackImmediate, +} from "@argent/telemetry"; + +// Consent-management subcommands for anonymous telemetry. +export async function telemetry(args: string[]): Promise { + const sub = args[0]; + const startedAt = performance.now(); + telemetryInit("cli"); + + const trackCommandComplete = async ( + subcommand: "status" | "enable" | "disable" | "help" | "unknown" + ): Promise => { + await trackImmediate("telemetry:command_complete", { + subcommand, + duration_ms: performance.now() - startedAt, + }); + }; + + switch (sub) { + case undefined: + case "status": + printStatus(); + await trackCommandComplete("status"); + await telemetryShutdown(); + return; + case "enable": + await cmdEnable(trackCommandComplete); + return; + case "disable": + await cmdDisable(trackCommandComplete); + return; + case "--help": + case "-h": + case "help": + printHelp(); + await trackCommandComplete("help"); + await telemetryShutdown(); + return; + default: + console.error(pc.red(`Unknown telemetry subcommand: ${sub}\n`)); + printHelp(); + await trackCommandComplete("unknown"); + await telemetryShutdown(); + process.exit(1); + } +} + +function printHelp(): void { + console.log(` +${pc.bold("argent telemetry")} — manage anonymous opt-out telemetry + +Usage: argent telemetry + +Subcommands: + ${pc.cyan("status")} Show current state, anon id prefix, host, and key status + ${pc.cyan("enable")} Persist consent and resume sending telemetry + ${pc.cyan("disable")} Emit a final telemetry:opt_out event, drop in-flight queue, + and persist consent=false + +Env-var overrides (any one wins, evaluated on every track() call): + DO_NOT_TRACK=1 + ARGENT_TELEMETRY=0 + CI environments are captured with is_ci=true unless explicitly disabled + +Debug audit: ARGENT_TELEMETRY_DEBUG=1 dumps every sanitized payload to +stderr and \`~/.argent/telemetry-debug.log\`. +`); +} + +function printStatus(): void { + const s = telemetryStatus(); + + const lines: string[] = []; + lines.push(`${pc.bold("State:")} ${s.enabled ? pc.green("enabled") : pc.yellow("disabled")}`); + + const sourceLabel = + s.source.source === "env_do_not_track" + ? "env DO_NOT_TRACK" + : s.source.source === "env_argent_telemetry" + ? "env ARGENT_TELEMETRY" + : s.source.source === "config_file" + ? "~/.argent/config.json" + : "default"; + lines.push( + `${pc.bold("Source:")} ${sourceLabel}${s.source.detail ? ` (${s.source.detail})` : ""}` + ); + + const anonLabel = s.anonIdPrefix + ? `${s.anonIdPrefix}…` + : s.hasAnonIdOnDisk + ? pc.dim("present (not shown — telemetry disabled)") + : pc.dim("not created"); + lines.push(`${pc.bold("Anon ID:")} ${anonLabel}`); + lines.push(`${pc.bold("Host:")} ${s.host}`); + lines.push( + `${pc.bold("Key:")} ${s.isKeyConfigured ? pc.green("configured") : pc.dim("sentinel-disabled (this build will never send)")}` + ); + lines.push(""); + lines.push(pc.dim("Disable: argent telemetry disable\n" + "Debug: ARGENT_TELEMETRY_DEBUG=1")); + + console.log(""); + for (const l of lines) console.log(" " + l); + console.log(""); +} + +async function cmdEnable( + trackCommandComplete: (subcommand: "enable") => Promise +): Promise { + const wasEnabled = telemetryIsEnabled(); + markEnabled(); + if (wasEnabled) { + console.log(pc.dim("Telemetry was already enabled.")); + } else { + console.log(pc.green("✓ Telemetry enabled.")); + } + await trackCommandComplete("enable"); + await telemetryShutdown(); +} + +async function cmdDisable( + trackCommandComplete: (subcommand: "disable") => Promise +): Promise { + const wasEnabled = telemetryIsEnabled(); + if (!wasEnabled) { + console.log(pc.dim("Telemetry was already disabled.")); + await trackCommandComplete("disable"); + await telemetryShutdown(); + return; + } + await trackCommandComplete("disable"); + await markDisabled(); + console.log(pc.green("✓ Telemetry disabled. In-flight events dropped, opt_out recorded.")); + await telemetryShutdown(); +} diff --git a/packages/argent-installer/package.json b/packages/argent-installer/package.json index 4938d96d..dfed0ca8 100644 --- a/packages/argent-installer/package.json +++ b/packages/argent-installer/package.json @@ -12,6 +12,7 @@ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { + "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@argent/update-core": "file:../update-core", "@clack/prompts": "^1.4.0", diff --git a/packages/argent-installer/src/init.ts b/packages/argent-installer/src/init.ts index d005a600..be96dccd 100644 --- a/packages/argent-installer/src/init.ts +++ b/packages/argent-installer/src/init.ts @@ -3,6 +3,12 @@ import pc from "picocolors"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { spawn } from "node:child_process"; +import { + init as telemetryInit, + trackImmediate, + isEnabled as telemetryIsEnabled, + shutdown as telemetryShutdown, +} from "@argent/telemetry"; import { detectAdapters, ALL_ADAPTERS, @@ -61,6 +67,45 @@ function extractFlag(args: string[], flag: string): string | null { export async function init(args: string[]): Promise { const nonInteractive = args.includes("--yes") || args.includes("-y"); const fromTar = extractFlag(args, "--from"); + const initStartTime = performance.now(); + + telemetryInit("installer"); + printTelemetryNotice(); + + await trackImmediate("installation:cli_init_start", { + package_manager: detectPackageManager(), + is_non_interactive: nonInteractive, + }); + + let editorsConfiguredCount = 0; + let initSucceeded = false; + const finalizeInitTelemetry = async (): Promise => { + await trackImmediate("installation:cli_init_complete", { + duration_ms: performance.now() - initStartTime, + is_success: initSucceeded, + editors_configured_count: editorsConfiguredCount, + }); + await telemetryShutdown(); + }; + + const trackPackageAction = async ( + action: + | "fresh_install" + | "already_installed" + | "init_triggered_update" + | "no_update" + | "update_skipped" + | "update_failed", + startedAt: number, + isSuccess: boolean + ): Promise => { + await trackImmediate("installation:package_action", { + trigger: "init", + action, + is_success: isSuccess, + duration_ms: performance.now() - startedAt, + }); + }; printBanner(); @@ -91,29 +136,38 @@ export async function init(args: string[]): Promise { }); if (p.isCancel(installChoice) || installChoice === "cancel") { + await trackImmediate("installation:global_install_decision", { decision: "cancel" }); + await trackImmediate("installation:cli_init_cancel", { step: "global_install" }); + await finalizeInitTelemetry(); p.cancel("Installation cancelled."); process.exit(0); } } + await trackImmediate("installation:global_install_decision", { decision: "install" }); + const pm = detectPackageManager(); const installTarget = fromTar ?? PACKAGE_NAME; const cmd = globalInstallCommand(pm, installTarget); const cmdStr = formatShellCommand(cmd); const spinner = p.spinner(); spinner.start(`Installing ${PACKAGE_NAME} globally...`); + const packageActionStartedAt = performance.now(); try { await runShellCommand(cmd); spinner.stop(pc.green("Installed globally.")); version = getInstalledVersion() ?? version; + await trackPackageAction("fresh_install", packageActionStartedAt, true); } catch (err) { spinner.stop(pc.red("Installation failed.")); p.log.error(`${err}`); p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); + await trackPackageAction("fresh_install", packageActionStartedAt, false); + await finalizeInitTelemetry(); process.exit(1); } } else if (fromTar) { - // --from flag: reinstall from the specified tarball/path + // Developer-only reinstall path; it is not a product install decision. const pm = detectPackageManager(); const cmd = globalInstallCommand(pm, fromTar); const cmdStr = formatShellCommand(cmd); @@ -127,21 +181,30 @@ export async function init(args: string[]): Promise { spinner.stop(pc.red("Installation failed.")); p.log.error(`${err}`); p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); + await finalizeInitTelemetry(); process.exit(1); } } else { + const packageActionStartedAt = performance.now(); + await trackImmediate("installation:global_install_decision", { decision: "already_installed" }); + await trackPackageAction("already_installed", packageActionStartedAt, true); let latest: string | null = null; const spinner = p.spinner(); spinner.start("Checking for updates..."); try { latest = getLatestVersion(); } catch { + await trackPackageAction("update_skipped", packageActionStartedAt, false); // Registry unreachable - silently skip } spinner.stop(pc.dim("Version check complete.")); if (latest && isNewerVersion(latest, version)) { - if (!nonInteractive) { + const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; + const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; + if (nonInteractive) { + await trackPackageAction("update_skipped", packageActionStartedAt, true); + } else { const updateChoice = await p.select({ message: `Update available: ${pc.yellow(`v${version}`)} → ${pc.green(`v${latest}`)}`, options: [ @@ -157,16 +220,26 @@ export async function init(args: string[]): Promise { ], }); - if (!p.isCancel(updateChoice) && updateChoice === "update") { + await trackImmediate("installation:update_decision", { + from_major: fromMajor, + to_major: toMajor, + decision: p.isCancel(updateChoice) ? "skip" : (updateChoice as "update" | "skip"), + }); + + if (p.isCancel(updateChoice) || updateChoice === "skip") { + await trackPackageAction("update_skipped", packageActionStartedAt, true); + } else if (updateChoice === "update") { const pm = detectPackageManager(); const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${latest}`); const cmdStr = formatShellCommand(cmd); const updateSpinner = p.spinner(); updateSpinner.start(`Updating to v${latest}...`); + const updateStartedAt = performance.now(); try { await runShellCommand(cmd); updateSpinner.stop(pc.green(`Updated to v${latest}.`)); version = getInstalledVersion() ?? version; + await trackPackageAction("init_triggered_update", updateStartedAt, true); // The user just bumped to a newer argent. Re-sync and prune // argent skills in every scope that already tracks them — this @@ -183,9 +256,19 @@ export async function init(args: string[]): Promise { updateSpinner.stop(pc.red("Update failed.")); p.log.error(`${err}`); p.log.info(`You can update manually later: ${pc.cyan(cmdStr)}`); + await trackPackageAction("update_failed", updateStartedAt, false); } } } + } else if (latest) { + const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; + const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; + await trackImmediate("installation:update_decision", { + from_major: fromMajor, + to_major: toMajor, + decision: "no_update", + }); + await trackPackageAction("no_update", packageActionStartedAt, true); } } @@ -228,6 +311,8 @@ export async function init(args: string[]): Promise { }); if (p.isCancel(selected)) { + await trackImmediate("installation:cli_init_cancel", { step: "editors" }); + await finalizeInitTelemetry(); p.cancel("Initialization cancelled."); process.exit(0); } @@ -235,6 +320,7 @@ export async function init(args: string[]): Promise { selectedAdapters = selected as McpConfigAdapter[]; } + editorsConfiguredCount = selectedAdapters.length; p.log.info(`Editors: ${selectedAdapters.map((a) => pc.cyan(a.name)).join(", ")}`); // Ask scope: global, local, or custom path @@ -268,6 +354,8 @@ export async function init(args: string[]): Promise { }); if (p.isCancel(scopeChoice)) { + await trackImmediate("installation:cli_init_cancel", { step: "scope" }); + await finalizeInitTelemetry(); p.cancel("Initialization cancelled."); process.exit(0); } @@ -287,6 +375,8 @@ export async function init(args: string[]): Promise { }); if (p.isCancel(customPathInput)) { + await trackImmediate("installation:cli_init_cancel", { step: "scope" }); + await finalizeInitTelemetry(); p.cancel("Initialization cancelled."); process.exit(0); } @@ -298,6 +388,13 @@ export async function init(args: string[]): Promise { const projectRoot = resolveProjectRoot(process.cwd()); const effectiveRoot = scope === "custom" ? customRoot! : projectRoot; const normalizedScope: "local" | "global" = scope === "global" ? "global" : "local"; + + await trackImmediate("installation:editors_select", { + editors: selectedAdapters.map((a) => sanitizeEditorName(a.name)), + detected_editor_count: detected.length, + scope, + }); + const mcpEntry = getMcpEntry(); const mcpResults: string[] = []; @@ -369,6 +466,8 @@ export async function init(args: string[]): Promise { }); if (p.isCancel(allowlistChoice)) { + await trackImmediate("installation:cli_init_cancel", { step: "allowlist" }); + await finalizeInitTelemetry(); p.cancel("Initialization cancelled."); process.exit(0); } @@ -377,6 +476,11 @@ export async function init(args: string[]): Promise { } } + await trackImmediate("installation:allowlist_decision", { + is_enabled: allowlistEnabled, + applicable_adapter_count: adaptersWithAllowlist.length, + }); + if (allowlistEnabled) { const allowlistResults: string[] = []; @@ -454,6 +558,8 @@ export async function init(args: string[]): Promise { }); if (p.isCancel(choice)) { + await trackImmediate("installation:cli_init_cancel", { step: "skills" }); + await finalizeInitTelemetry(); p.cancel("Initialization cancelled."); process.exit(0); } @@ -461,6 +567,12 @@ export async function init(args: string[]): Promise { skillsMethod = choice as SkillsMethod; } + await trackImmediate("installation:skill_install", { + method: skillsMethod, + is_online: online, + has_offline_cache: offlineWithCache, + }); + // Prefer the GitHub-pinned source. SKILLS_DIR as a fallback. const useGitHubSource = online && !fromTar && version !== "unknown"; const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; @@ -510,12 +622,16 @@ export async function init(args: string[]): Promise { if (skillsMethod === "default") { spinner.stop("Skills installed."); } + await trackImmediate("installation:skill_install_result", { is_success: true }); } catch (err) { if (skillsMethod === "default") { spinner.stop(pc.red("Skills installation failed.")); } p.log.error(`Failed to run npx skills: ${err}`); p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); + await trackImmediate("installation:skill_install_result", { + is_success: false, + }); } } @@ -537,6 +653,8 @@ export async function init(args: string[]): Promise { p.log.info(pc.dim("No rules or agents to copy for selected editors.")); } + await trackImmediate("installation:rules_agents_copy", { copied_count: copyResults.length }); + // ── Summary ───────────────────────────────────────────────────────────────── const summaryLines = [ @@ -561,6 +679,37 @@ export async function init(args: string[]): Promise { pc.bgGreen(pc.black(" Get Started ")) ); p.outro("Done."); + + initSucceeded = true; + await finalizeInitTelemetry(); +} + +// Print the notice only when this run may send telemetry. +function printTelemetryNotice(): void { + if (!telemetryIsEnabled()) return; + p.log.info( + [ + pc.bold("Telemetry"), + pc.dim("Argent collects anonymous, opt-out usage telemetry (PostHog EU) so we can"), + pc.dim("prioritise the features and bugs that matter most. No raw arguments, paths,"), + pc.dim("error messages, or environment values are ever sent — only event names and"), + pc.dim("a small set of typed, validator-enforced properties."), + pc.dim(""), + pc.dim("A lifetime-stable anonymous UUID is generated on first run. To opt out,"), + pc.dim("run \`argent telemetry disable\` or set \`DO_NOT_TRACK=1\` /"), + pc.dim("\`ARGENT_TELEMETRY=0\`. To audit what is sent,"), + pc.dim("set \`ARGENT_TELEMETRY_DEBUG=1\` and inspect \`~/.argent/telemetry-debug.log\`."), + ].join("\n") + ); +} + +function sanitizeEditorName(raw: string): string { + // Shape display names to the sanitizer's kebab-case adapter format. + return raw + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); } export function printBanner(): void { @@ -585,7 +734,7 @@ export function printBanner(): void { console.log(); } -function runNpxSkills(args: string[], interactive: boolean, cwd?: string): Promise { +export function runNpxSkills(args: string[], interactive: boolean, cwd?: string): Promise { return new Promise((resolve, reject) => { const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"; const child = spawn(npxCmd, args, { diff --git a/packages/argent-installer/src/uninstall.ts b/packages/argent-installer/src/uninstall.ts index 77a9167b..768fdf42 100644 --- a/packages/argent-installer/src/uninstall.ts +++ b/packages/argent-installer/src/uninstall.ts @@ -3,6 +3,12 @@ import pc from "picocolors"; import * as fs from "node:fs"; import * as path from "node:path"; import { execFileSync } from "node:child_process"; +import { + init as telemetryInit, + trackImmediate, + forget as telemetryForget, + shutdown as telemetryShutdown, +} from "@argent/telemetry"; import { ALL_ADAPTERS, getManagedContentTargets, @@ -285,6 +291,20 @@ function cleanupBundledTargets( export async function uninstall(args: string[]): Promise { const nonInteractive = args.includes("--yes") || args.includes("-y"); + telemetryInit("installer"); + await trackImmediate("installation:cli_uninstall_start", {}); + + const finalizeUninstallTelemetry = async ( + hasPrunedContent: boolean, + hasUninstalledPackage: boolean + ): Promise => { + await trackImmediate("installation:cli_uninstall_complete", { + has_pruned_content: hasPrunedContent, + has_uninstalled_package: hasUninstalledPackage, + }); + await telemetryShutdown(); + }; + p.intro(pc.bgRed(pc.white(" argent uninstall "))); if (!nonInteractive) { @@ -296,6 +316,7 @@ export async function uninstall(args: string[]): Promise { }); if (p.isCancel(proceed) || !proceed) { + await finalizeUninstallTelemetry(false, false); p.cancel("Uninstall cancelled."); process.exit(0); } @@ -456,6 +477,7 @@ export async function uninstall(args: string[]): Promise { } } + let hasUninstalledPackage = false; if (shouldUninstallPackage) { const pm = detectPackageManager(); const cmd = globalUninstallCommand(pm, PACKAGE_NAME); @@ -466,10 +488,23 @@ export async function uninstall(args: string[]): Promise { try { execFileSync(cmd.bin, cmd.args, { stdio: "inherit" }); p.log.success("Package uninstalled."); + hasUninstalledPackage = true; } catch (err) { p.log.error(`Uninstall failed: ${err}`); } } + await trackImmediate("installation:cli_uninstall_complete", { + has_pruned_content: shouldPrune, + has_uninstalled_package: hasUninstalledPackage, + }); + + try { + await telemetryForget({ disableConsent: false }); + } catch { + /* swallow — uninstall must succeed even if forget fails */ + } + await telemetryShutdown(); + p.outro(pc.green("argent has been removed.")); } diff --git a/packages/argent-installer/src/update.ts b/packages/argent-installer/src/update.ts index cc577261..a9f7d66c 100644 --- a/packages/argent-installer/src/update.ts +++ b/packages/argent-installer/src/update.ts @@ -9,6 +9,11 @@ import { copyRulesAndAgents, type McpConfigAdapter, } from "./mcp-configs.js"; +import { + init as telemetryInit, + trackImmediate, + shutdown as telemetryShutdown, +} from "@argent/telemetry"; import { getGloballyInstalledVersion, isGloballyInstalled, @@ -38,9 +43,48 @@ function getRequestedVersion(args: string[]): string | null { return null; } +type UpdateTrigger = "update" | "mcp_update"; +type UpdatePackageAction = "standalone_update" | "standalone_install" | "mcp_update"; + +export function getUpdateTriggerFromEnv(env: NodeJS.ProcessEnv = process.env): UpdateTrigger { + return env.ARGENT_UPDATE_TRIGGER === "mcp_update" ? "mcp_update" : "update"; +} + +export function resolveUpdatePackageAction( + trigger: UpdateTrigger, + installed: string | null +): UpdatePackageAction { + if (trigger === "mcp_update") return "mcp_update"; + return installed ? "standalone_update" : "standalone_install"; +} + export async function update(args: string[]): Promise { const nonInteractive = args.includes("--yes") || args.includes("-y"); const requestedVersion = getRequestedVersion(args); + const trigger = getUpdateTriggerFromEnv(); + telemetryInit("installer"); + const updateStartTime = performance.now(); + await trackImmediate("installation:cli_update_start", {}); + + const trackPackageAction = async ( + action: UpdatePackageAction | "no_update" | "update_skipped" | "update_failed", + startedAt: number, + isSuccess: boolean + ): Promise => { + await trackImmediate("installation:package_action", { + trigger, + action, + is_success: isSuccess, + duration_ms: performance.now() - startedAt, + }); + }; + + const failUpdateTelemetry = async (): Promise => { + await trackImmediate("installation:cli_update_fail", { + duration_ms: performance.now() - updateStartTime, + }); + await telemetryShutdown(); + }; p.intro(pc.bgCyan(pc.black(" argent update "))); @@ -55,6 +99,8 @@ export async function update(args: string[]): Promise { const installed = globallyInstalled ? getGloballyInstalledVersion() : null; if (globallyInstalled && !installed) { + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); p.log.error("Could not determine installed version."); process.exit(1); } @@ -70,6 +116,8 @@ export async function update(args: string[]): Promise { if (requestedVersion !== null) { if (!semver.valid(requestedVersion) || semver.prerelease(requestedVersion)) { spinner.stop(pc.red("Invalid update target.")); + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); p.log.error(`Requested version is not a stable semver: ${requestedVersion}`); process.exit(1); } @@ -80,12 +128,16 @@ export async function update(args: string[]): Promise { resolved = await resolveInstallableUpdateTarget(pm, installed); } catch (err) { spinner.stop(pc.red("Could not reach registry.")); + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); p.log.error(`Failed to check registry: ${err}`); process.exit(1); } if (resolved === null) { spinner.stop(pc.red("Could not reach registry.")); + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); p.log.error("Failed to determine the latest Argent release from the registry."); process.exit(1); } @@ -133,6 +185,11 @@ export async function update(args: string[]): Promise { }); if (p.isCancel(proceed) || !proceed) { + await trackPackageAction("update_skipped", updateStartTime, true); + await trackImmediate("installation:cli_update_complete", { + duration_ms: performance.now() - updateStartTime, + }); + await telemetryShutdown(); p.cancel(installed ? "Update cancelled." : "Install cancelled."); process.exit(0); } @@ -140,18 +197,31 @@ export async function update(args: string[]): Promise { p.log.info(`Running: ${pc.dim(cmdStr)}`); - await killToolServer(); + try { + await killToolServer(); + } catch (err) { + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); + p.log.error(`Could not stop the running tool server: ${err}`); + process.exit(1); + } + const packageAction = resolveUpdatePackageAction(trigger, installed); + const packageActionStartedAt = performance.now(); try { execFileSync(cmd.bin, cmd.args, { stdio: "inherit", env: { ...process.env, ARGENT_SKIP_POSTINSTALL: "1" }, }); } catch (err) { + await trackPackageAction(packageAction, packageActionStartedAt, false); + await failUpdateTelemetry(); p.log.error(`${installed ? "Update" : "Install"} failed: ${err}`); process.exit(1); } + await trackPackageAction(packageAction, packageActionStartedAt, true); } else { + await trackPackageAction("no_update", updateStartTime, true); if (latest && target === null && latestIsNewer && minReleaseAgeMs > 0) { p.log.warn( `Latest version ${pc.cyan(`v${latest}`)} is still held by your minimum-release-age policy.` @@ -227,5 +297,10 @@ export async function update(args: string[]): Promise { p.note(skillSummary, "Skills Updated"); } + await trackImmediate("installation:cli_update_complete", { + duration_ms: performance.now() - updateStartTime, + }); + await telemetryShutdown(); + p.outro(pc.green("Update complete.")); } diff --git a/packages/argent-installer/test/uninstall.test.ts b/packages/argent-installer/test/uninstall.test.ts index de0c8772..8729da87 100644 --- a/packages/argent-installer/test/uninstall.test.ts +++ b/packages/argent-installer/test/uninstall.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; @@ -14,9 +14,48 @@ import { getBundledSkillNames, removeBundledContent, removeBundledSkillInstalls, + uninstall, } from "../src/uninstall.js"; +const telemetryMock = vi.hoisted(() => ({ + init: vi.fn(), + trackImmediate: vi.fn().mockResolvedValue(undefined), + forget: vi.fn().mockResolvedValue({ + localIdRemoved: true, + consentDisabled: false, + }), + shutdown: vi.fn().mockResolvedValue(undefined), +})); + +const childProcessMock = vi.hoisted(() => ({ + execFileSync: vi.fn(), +})); + +const toolsClientMock = vi.hoisted(() => ({ + killToolServer: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@argent/telemetry", () => telemetryMock); +vi.mock("node:child_process", () => childProcessMock); +vi.mock("@argent/tools-client", () => toolsClientMock); +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + log: { + error: vi.fn(), + info: vi.fn(), + message: vi.fn(), + step: vi.fn(), + success: vi.fn(), + }, + note: vi.fn(), +})); + let tmpDir: string; +let originalCwd: string; function readConfigFile(filePath: string): Record { if (filePath.endsWith(".toml")) return readToml(filePath); @@ -30,12 +69,25 @@ function writeFile(filePath: string, contents = "test"): void { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-uninstall-test-")); + originalCwd = process.cwd(); + vi.clearAllMocks(); }); afterEach(() => { + process.chdir(originalCwd); fs.rmSync(tmpDir, { recursive: true, force: true }); }); +describe("uninstall — telemetry consent preservation", () => { + it("resets uninstall telemetry identity without persisting a consent opt-out", async () => { + process.chdir(tmpDir); + + await uninstall(["--yes"]); + + expect(telemetryMock.forget).toHaveBeenCalledWith({ disableConsent: false }); + }); +}); + // ── MCP entry removal across all adapters ───────────────────────────────────── describe("uninstall — MCP entry removal", () => { diff --git a/packages/argent-installer/test/update.test.ts b/packages/argent-installer/test/update.test.ts index 2fde1a95..d7f2c00d 100644 --- a/packages/argent-installer/test/update.test.ts +++ b/packages/argent-installer/test/update.test.ts @@ -9,6 +9,7 @@ import { globalInstallCommand, formatShellCommand, } from "../src/utils.js"; +import { getUpdateTriggerFromEnv, resolveUpdatePackageAction } from "../src/update.js"; import { PACKAGE_NAME, NPM_REGISTRY } from "../src/constants.js"; describe("update — version comparison logic", () => { @@ -77,6 +78,24 @@ describe("update — registry safety", () => { }); }); +describe("update — telemetry package action tagging", () => { + it("distinguishes standalone update/install from MCP-triggered update", () => { + expect(resolveUpdatePackageAction("update", "0.8.0")).toBe("standalone_update"); + expect(resolveUpdatePackageAction("update", null)).toBe("standalone_install"); + expect(resolveUpdatePackageAction("mcp_update", "0.8.0")).toBe("mcp_update"); + }); + + it("reads only the supported MCP update trigger env enum", () => { + expect( + getUpdateTriggerFromEnv({ ARGENT_UPDATE_TRIGGER: "mcp_update" } as NodeJS.ProcessEnv) + ).toBe("mcp_update"); + expect( + getUpdateTriggerFromEnv({ ARGENT_UPDATE_TRIGGER: "https://internal" } as NodeJS.ProcessEnv) + ).toBe("update"); + expect(getUpdateTriggerFromEnv({} as NodeJS.ProcessEnv)).toBe("update"); + }); +}); + // These exercise getGloballyInstalledVersion against a real on-disk install // layout (binary symlinked into a node_modules// directory tree) rather // than mocking. That way we actually validate the bug fix end-to-end: diff --git a/packages/argent/package.json b/packages/argent/package.json index fe176594..d3927518 100644 --- a/packages/argent/package.json +++ b/packages/argent/package.json @@ -47,12 +47,14 @@ "@argent/cli": "file:../argent-cli", "@argent/installer": "file:../argent-installer", "@argent/mcp": "file:../argent-mcp", + "@argent/telemetry": "file:../telemetry", "@argent/tools-client": "file:../argent-tools-client", "@clack/prompts": "^1.1.0", "@types/node": "^25.9.0", "@types/semver": "^7.7.1", "esbuild": "^0.28.0", "picocolors": "^1.1.1", + "posthog-node": "5.35.0", "semver": "^7.7.4", "smol-toml": "^1.6.1", "typescript": "^6.0.3", diff --git a/packages/argent/scripts/bundle-tools.cjs b/packages/argent/scripts/bundle-tools.cjs index 8aa7609f..22584731 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -8,6 +8,7 @@ const path = require("path"); const WORKSPACE_ROOT = path.resolve(__dirname, "../../.."); const TOOLS_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/tool-server/src/index.ts"); const REGISTRY_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/registry/src/index.ts"); +const TELEMETRY_ENTRY = path.resolve(WORKSPACE_ROOT, "packages/telemetry/src/index.ts"); const NATIVE_DEVTOOLS_ENTRY = path.resolve( WORKSPACE_ROOT, "packages/native-devtools-ios/src/index.ts" @@ -37,6 +38,25 @@ const ALIASES = { "@argent/installer": INSTALLER_ENTRY, "@argent/mcp": MCP_ENTRY, "@argent/cli": CLI_ENTRY, + "@argent/telemetry": TELEMETRY_ENTRY, +}; + +// Build-time constants for @argent/telemetry. The PostHog project token is a +// checked-in public write-only key; only version metadata needs esbuild defines. +const TELEMETRY_CLI_VERSION = (() => { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); + return String(pkg.version ?? "0.0.0"); + } catch { + return "0.0.0"; + } +})(); +const TELEMETRY_CLI_VERSION_MAJOR_MINOR = TELEMETRY_CLI_VERSION.split(".").slice(0, 2).join("."); +const TELEMETRY_CLI_MAJOR_VERSION = TELEMETRY_CLI_VERSION.split(".")[0] ?? "0"; + +const TELEMETRY_DEFINE = { + ARGENT_CLI_VERSION_MAJOR_MINOR: JSON.stringify(TELEMETRY_CLI_VERSION_MAJOR_MINOR), + ARGENT_CLI_MAJOR_VERSION: JSON.stringify(TELEMETRY_CLI_MAJOR_VERSION), }; // esbuild on platform:"node" defaults mainFields to ["main","module"], which @@ -109,7 +129,7 @@ if (fs.existsSync(ANDROID_APK_DEST_DIR)) { // Ensure dist/ exists fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); -// Bundle the tools server +// Bundle the tools server. esbuild.buildSync({ entryPoints: [TOOLS_ENTRY], bundle: true, @@ -119,6 +139,7 @@ esbuild.buildSync({ outfile: OUT_FILE, alias: ALIASES, mainFields: MAIN_FIELDS, + define: TELEMETRY_DEFINE, }); console.log(`✓ Bundled tools server → ${path.relative(process.cwd(), OUT_FILE)}`); @@ -137,6 +158,7 @@ esbuild.buildSync({ banner: ESM_REQUIRE_BANNER, external: ["node:*"], mainFields: MAIN_FIELDS, + define: TELEMETRY_DEFINE, }); console.log(`✓ Bundled installer → ${path.relative(process.cwd(), INSTALLER_OUT_FILE)}`); @@ -154,6 +176,7 @@ esbuild.buildSync({ banner: ESM_REQUIRE_BANNER, external: ["node:*"], mainFields: MAIN_FIELDS, + define: TELEMETRY_DEFINE, }); console.log(`✓ Bundled MCP server → ${path.relative(process.cwd(), MCP_OUT_FILE)}`); @@ -170,6 +193,7 @@ esbuild.buildSync({ banner: ESM_REQUIRE_BANNER, external: ["node:*"], mainFields: MAIN_FIELDS, + define: TELEMETRY_DEFINE, }); console.log(`✓ Bundled CLI commands → ${path.relative(process.cwd(), CLI_OUT_FILE)}`); diff --git a/packages/argent/src/cli.ts b/packages/argent/src/cli.ts index 091d03e0..1500974f 100644 --- a/packages/argent/src/cli.ts +++ b/packages/argent/src/cli.ts @@ -75,6 +75,7 @@ Commands: enable Enable a feature flag (global by default, --scope project for project) disable Disable a feature flag (global by default, --scope project for project) flags Show current feature-flag state + telemetry Manage anonymous opt-out telemetry (status / enable / disable) Options: --help, -h Show this help message @@ -130,6 +131,8 @@ async function main(): Promise { return (await loadCli()).disable(rest); case "flags": return (await loadCli()).flags(rest); + case "telemetry": + return (await loadCli()).telemetry(rest); case "--version": case "-v": console.log(getInstalledVersion() ?? "unknown"); diff --git a/packages/registry/src/logger.ts b/packages/registry/src/logger.ts index a7a02d1b..db61803f 100644 --- a/packages/registry/src/logger.ts +++ b/packages/registry/src/logger.ts @@ -59,15 +59,17 @@ export function attachRegistryLogger(registry: Registry): void { console.log(`${PREFIX} toolRegistered ${toolId}`); }); - registry.events.on("toolInvoked", (toolId) => { - console.log(`${PREFIX} toolInvoked ${toolId}`); + registry.events.on("toolInvoked", (toolId, toolInvocationId) => { + console.log(`${PREFIX} toolInvoked ${toolId} (${toolInvocationId})`); }); - registry.events.on("toolCompleted", (toolId, durationMs) => { - console.log(`${PREFIX} toolCompleted ${toolId} (${durationMs.toFixed(2)}ms)`); + registry.events.on("toolCompleted", (toolId, toolInvocationId, durationMs) => { + console.log( + `${PREFIX} toolCompleted ${toolId} (${toolInvocationId}, ${durationMs.toFixed(2)}ms)` + ); }); - registry.events.on("toolFailed", (toolId, error) => { - console.error(`${PREFIX} toolFailed ${toolId}:\n${formatError(error)}`); + registry.events.on("toolFailed", (toolId, toolInvocationId, error) => { + console.error(`${PREFIX} toolFailed ${toolId} (${toolInvocationId}):\n${formatError(error)}`); }); } diff --git a/packages/registry/src/registry.ts b/packages/registry/src/registry.ts index 926c3ba0..0dc120c2 100644 --- a/packages/registry/src/registry.ts +++ b/packages/registry/src/registry.ts @@ -18,6 +18,7 @@ import { } from "./errors"; import { parseURN } from "./urn"; import { zodObjectToJsonSchema } from "./zod-to-json-schema"; +import { randomUUID } from "node:crypto"; export class Registry { /** Single map: URN -> ServiceNode (all instances). */ @@ -74,7 +75,8 @@ export class Registry { const { definition } = record; const startTime = performance.now(); - this.events.emit("toolInvoked", id); + const toolInvocationId = randomUUID(); + this.events.emit("toolInvoked", id, toolInvocationId); try { // Validate params against the tool's zod schema for EVERY dispatch path, @@ -104,7 +106,7 @@ export class Registry { const result = await definition.execute(resolvedServices, effectiveParams, options); const duration = performance.now() - startTime; - this.events.emit("toolCompleted", id, duration); + this.events.emit("toolCompleted", id, toolInvocationId, duration); return result as TResult; } catch (error) { const originalMsg = error instanceof Error ? error.message : String(error); @@ -118,7 +120,13 @@ export class Registry { cause: error instanceof Error ? error : new Error(String(error)), }); - this.events.emit("toolFailed", id, wrappedError); + this.events.emit( + "toolFailed", + id, + toolInvocationId, + wrappedError, + performance.now() - startTime + ); throw wrappedError; } } diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts index 87c5c109..34fd13e9 100644 --- a/packages/registry/src/types.ts +++ b/packages/registry/src/types.ts @@ -182,7 +182,12 @@ export type RegistryEvents = { serviceError: (serviceId: string, error: Error) => void; serviceRegistered: (serviceId: string) => void; toolRegistered: (toolId: string) => void; - toolInvoked: (toolId: string) => void; - toolCompleted: (toolId: string, durationMs: number) => void; - toolFailed: (toolId: string, error: Error) => void; + toolInvoked: (toolId: string, toolInvocationId: string) => void; + toolCompleted: (toolId: string, toolInvocationId: string, durationMs: number) => void; + toolFailed: ( + toolId: string, + toolInvocationId: string, + error: Error, + durationMs?: number + ) => void; }; diff --git a/packages/registry/tests/logger.test.ts b/packages/registry/tests/logger.test.ts index 62f5d0de..675adedf 100644 --- a/packages/registry/tests/logger.test.ts +++ b/packages/registry/tests/logger.test.ts @@ -105,10 +105,12 @@ describe("attachRegistryLogger — formatError via toolFailed", () => { const root = new Error("timeout"); const outer = new Error("evaluate failed", { cause: root }); - registry.events.emit("toolFailed", "my-tool", outer); + registry.events.emit("toolFailed", "my-tool", "11111111-1111-4111-8111-111111111111", outer); const output = errorSpy.mock.calls[0]![0] as string; - expect(output).toContain("[registry] toolFailed my-tool:"); + expect(output).toContain( + "[registry] toolFailed my-tool (11111111-1111-4111-8111-111111111111):" + ); expect(output).toContain("evaluate failed"); expect(output).toContain("timeout"); }); @@ -139,20 +141,29 @@ describe("attachRegistryLogger — happy-path events", () => { const registry = new Registry(); attachRegistryLogger(registry); - registry.events.emit("toolInvoked", "my-tool"); + registry.events.emit("toolInvoked", "my-tool", "11111111-1111-4111-8111-111111111111"); expect(logSpy).toHaveBeenCalledOnce(); - expect(logSpy.mock.calls[0]![0]).toContain("toolInvoked my-tool"); + expect(logSpy.mock.calls[0]![0]).toContain( + "toolInvoked my-tool (11111111-1111-4111-8111-111111111111)" + ); }); it("logs toolCompleted with duration", () => { const registry = new Registry(); attachRegistryLogger(registry); - registry.events.emit("toolCompleted", "my-tool", 123.456); + registry.events.emit( + "toolCompleted", + "my-tool", + "11111111-1111-4111-8111-111111111111", + 123.456 + ); expect(logSpy).toHaveBeenCalledOnce(); - expect(logSpy.mock.calls[0]![0]).toContain("toolCompleted my-tool (123.46ms)"); + expect(logSpy.mock.calls[0]![0]).toContain( + "toolCompleted my-tool (11111111-1111-4111-8111-111111111111, 123.46ms)" + ); }); it("logs serviceRegistered", () => { diff --git a/packages/registry/tests/registry.test.ts b/packages/registry/tests/registry.test.ts index b804ef87..04d01826 100644 --- a/packages/registry/tests/registry.test.ts +++ b/packages/registry/tests/registry.test.ts @@ -340,16 +340,62 @@ describe("Registry -- Tool Tests", () => { registry.registerBlueprint(sBlueprint); registry.registerTool(createMockToolDef("T", () => ({ S: staticUrn("S") }))); - const invokedEvents: string[] = []; - const completedEvents: string[] = []; + const invokedEvents: Array<{ id: string; toolInvocationId: string }> = []; + const completedEvents: Array<{ id: string; toolInvocationId: string; durationMs: number }> = []; - registry.events.on("toolInvoked", (id) => invokedEvents.push(id)); - registry.events.on("toolCompleted", (id) => completedEvents.push(id)); + registry.events.on("toolInvoked", (id, toolInvocationId) => + invokedEvents.push({ id, toolInvocationId }) + ); + registry.events.on("toolCompleted", (id, toolInvocationId, durationMs) => + completedEvents.push({ id, toolInvocationId, durationMs }) + ); await registry.invokeTool("T"); - expect(invokedEvents).toEqual(["T"]); - expect(completedEvents).toEqual(["T"]); + expect(invokedEvents).toHaveLength(1); + expect(completedEvents).toHaveLength(1); + expect(invokedEvents[0]).toMatchObject({ id: "T" }); + expect(completedEvents[0]).toMatchObject({ id: "T", durationMs: expect.any(Number) }); + expect(invokedEvents[0]!.toolInvocationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + expect(completedEvents[0]!.toolInvocationId).toBe(invokedEvents[0]!.toolInvocationId); + }); + + it("emits toolFailed with real invocation duration", async () => { + const registry = new Registry(); + registry.registerTool({ + id: "fail-duration", + services: () => ({}), + async execute() { + await new Promise((resolve) => setTimeout(resolve, 5)); + throw new Error("tool failed"); + }, + }); + + const invokedEvents: Array<{ id: string; toolInvocationId: string }> = []; + const failedEvents: Array<{ + id: string; + toolInvocationId: string; + error: Error; + durationMs?: number; + }> = []; + registry.events.on("toolInvoked", (id, toolInvocationId) => { + invokedEvents.push({ id, toolInvocationId }); + }); + registry.events.on("toolFailed", (id, toolInvocationId, error, durationMs) => { + failedEvents.push({ id, toolInvocationId, error, durationMs }); + }); + + await expect(registry.invokeTool("fail-duration")).rejects.toThrow(ToolExecutionError); + + expect(invokedEvents).toHaveLength(1); + expect(failedEvents).toHaveLength(1); + expect(failedEvents[0]).toMatchObject({ id: "fail-duration" }); + expect(failedEvents[0]!.toolInvocationId).toBe(invokedEvents[0]!.toolInvocationId); + expect(failedEvents[0]!.error).toBeInstanceOf(ToolExecutionError); + expect(failedEvents[0]!.durationMs).toEqual(expect.any(Number)); + expect(failedEvents[0]!.durationMs!).toBeGreaterThan(0); }); }); diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json new file mode 100644 index 00000000..ab6eab8b --- /dev/null +++ b/packages/telemetry/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "name": "@argent/telemetry", + "version": "0.8.1", + "description": "Anonymous opt-out telemetry client for Argent CLI / installer / tool-server", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "rm -rf dist tsconfig.tsbuildinfo && tsc", + "test": "vitest run", + "test:watch": "vitest", + "typecheck:tests": "tsc --noEmit -p tsconfig.test.json" + }, + "dependencies": { + "ci-info": "^4.4.0", + "posthog-node": "5.35.0" + }, + "devDependencies": { + "@types/node": "^25.9.0", + "typescript": "^6.0.3", + "vitest": "^4.1.6" + } +} diff --git a/packages/telemetry/src/base-props.ts b/packages/telemetry/src/base-props.ts new file mode 100644 index 00000000..9732a19c --- /dev/null +++ b/packages/telemetry/src/base-props.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto"; +import { isCi } from "./ci-detect.js"; + +// Build-time version metadata injected by esbuild; source tests fall back to "0.0". +declare const ARGENT_CLI_VERSION_MAJOR_MINOR: string | undefined; + +// Process-local session id. Never persisted or reused across Node processes. +let SESSION_ID: string = randomUUID(); + +function readCliVersionMajorMinor(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fromDefine = (globalThis as any).ARGENT_CLI_VERSION_MAJOR_MINOR; + if (typeof fromDefine === "string" && fromDefine !== "") return fromDefine; + if (typeof ARGENT_CLI_VERSION_MAJOR_MINOR === "string" && ARGENT_CLI_VERSION_MAJOR_MINOR !== "") { + return ARGENT_CLI_VERSION_MAJOR_MINOR; + } + return "0.0"; +} + +function readNodeVersionMajor(): string { + // process.version is "vMAJOR.MINOR.PATCH" + const m = /^v?(\d+)/.exec(process.version); + return m ? m[1]! : "unknown"; +} + +export type Runtime = "installer" | "tool_server" | "cli" | "mcp"; + +export interface BaseProps { + cli_version_major_minor: string; + node_version_major: string; + os: NodeJS.Platform; + arch: NodeJS.Architecture; + is_tty: boolean; + is_ci: boolean; + runtime: Runtime; + $session_id: string; + $process_person_profile: false; +} + +// Keep version metadata coarse to avoid high-resolution fingerprints. +export function getBaseProps(runtime: Runtime): BaseProps { + return { + cli_version_major_minor: readCliVersionMajorMinor(), + node_version_major: readNodeVersionMajor(), + os: process.platform, + arch: process.arch, + is_tty: Boolean(process.stdout.isTTY), + is_ci: isCi(), + runtime, + $session_id: SESSION_ID, + $process_person_profile: false, + }; +} + +export function getSessionId(): string { + return SESSION_ID; +} + +/** Test seam: regenerate the process-local session id. */ +export function _resetSessionIdForTest(): void { + SESSION_ID = randomUUID(); +} diff --git a/packages/telemetry/src/ci-detect.ts b/packages/telemetry/src/ci-detect.ts new file mode 100644 index 00000000..930dcb2a --- /dev/null +++ b/packages/telemetry/src/ci-detect.ts @@ -0,0 +1,54 @@ +import vendors from "ci-info/vendors.json"; + +type VendorEnv = + | string + | { env: string; includes: string } + | { any: string[] } + | Record; + +interface VendorDefinition { + env: VendorEnv | VendorEnv[]; +} + +const GENERIC_CI_ENV_VARS = [ + "BUILD_ID", + "BUILD_NUMBER", + "CI", + "CI_APP_ID", + "CI_BUILD_ID", + "CI_BUILD_NUMBER", + "CI_NAME", + "CONTINUOUS_INTEGRATION", + "RUN_ID", +] as const; + +function checkEnv(env: NodeJS.ProcessEnv, def: VendorEnv): boolean { + if (typeof def === "string") return Boolean(env[def]); + + if ("env" in def) { + const value = env[def.env]; + return Boolean(value && value.includes(def.includes)); + } + + if ("any" in def && Array.isArray(def.any)) { + return def.any.some((key: string) => Boolean(env[key])); + } + + return Object.entries(def).every(([key, value]) => env[key] === value); +} + +function isKnownVendorCi(env: NodeJS.ProcessEnv): boolean { + return (vendors as VendorDefinition[]).some((vendor) => { + const defs = Array.isArray(vendor.env) ? vendor.env : [vendor.env]; + return defs.every((def) => checkEnv(env, def)); + }); +} + +export function isCi(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.CI === "false") return false; + if (GENERIC_CI_ENV_VARS.some((name) => Boolean(env[name]))) return true; + return isKnownVendorCi(env); +} + +/** Exposed for vitest coverage assertions; do not import outside tests. */ +export const _CI_VENDOR_COUNT_FOR_TEST: number = (vendors as VendorDefinition[]).length; diff --git a/packages/telemetry/src/consent.ts b/packages/telemetry/src/consent.ts new file mode 100644 index 00000000..b9baf202 --- /dev/null +++ b/packages/telemetry/src/consent.ts @@ -0,0 +1,152 @@ +import * as fs from "node:fs"; +import { argentHomeDir, configFilePath } from "./paths.js"; + +// Consent is evaluated on every track() so a running tool server sees opt-outs. +// The config file is re-parsed only when its mtime or inode changes. +export interface ConsentSource { + source: "env_do_not_track" | "env_argent_telemetry" | "config_file" | "default"; + /** Detailed override identifier for `argent telemetry status` output. */ + detail?: string; +} + +export interface ConsentState { + enabled: boolean; + source: ConsentSource; +} + +interface CachedConfig { + mtimeMs: number | null; + fingerprint: string | null; + enabledOverride: boolean | null; +} + +const cache: { current: CachedConfig | null } = { current: null }; + +function readConfigOverride(): boolean | null { + let stats: fs.Stats | null = null; + try { + stats = fs.lstatSync(configFilePath()); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + cache.current = { mtimeMs: null, fingerprint: null, enabledOverride: null }; + return null; + } + // File errors must not silently flip telemetry on. + cache.current = { mtimeMs: null, fingerprint: null, enabledOverride: null }; + return null; + } + + if (!stats.isFile()) { + // Refuse to read symlinks / sockets / directories at that path. + cache.current = { mtimeMs: null, fingerprint: null, enabledOverride: null }; + return null; + } + + const fingerprint = `${stats.dev}:${stats.ino}`; + const mtimeMs = stats.mtimeMs; + + if ( + cache.current && + cache.current.fingerprint === fingerprint && + cache.current.mtimeMs === mtimeMs + ) { + return cache.current.enabledOverride; + } + + let parsedEnabled: boolean | null = null; + try { + const raw = fs.readFileSync(configFilePath(), "utf8"); + const json = JSON.parse(raw) as unknown; + if (json && typeof json === "object") { + const t = (json as Record).telemetry; + if (t && typeof t === "object") { + const enabled = (t as Record).enabled; + if (typeof enabled === "boolean") parsedEnabled = enabled; + } + } + } catch { + // Malformed config — treat as "no override" rather than crash. + } + + cache.current = { mtimeMs, fingerprint, enabledOverride: parsedEnabled }; + return parsedEnabled; +} + +function parseFalsy(value: string | undefined): boolean { + if (value === undefined) return false; + const v = value.trim().toLowerCase(); + return v === "0" || v === "false" || v === "no" || v === "off"; +} + +function parseTruthy(value: string | undefined): boolean { + if (value === undefined) return false; + const v = value.trim().toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "on"; +} + +/** Computes the effective consent state without mutating anything on disk. */ +export function getConsentState(env: NodeJS.ProcessEnv = process.env): ConsentState { + if (parseTruthy(env.DO_NOT_TRACK)) { + return { enabled: false, source: { source: "env_do_not_track", detail: "DO_NOT_TRACK=1" } }; + } + + const argentEnv = env.ARGENT_TELEMETRY; + if (parseFalsy(argentEnv)) { + return { + enabled: false, + source: { source: "env_argent_telemetry", detail: `ARGENT_TELEMETRY=${argentEnv}` }, + }; + } + + const persisted = readConfigOverride(); + if (persisted === false) { + return { enabled: false, source: { source: "config_file", detail: "config.json" } }; + } + if (persisted === true) { + return { enabled: true, source: { source: "config_file", detail: "config.json" } }; + } + + // Default-on unless one of the explicit opt-out sources above applies. + return { enabled: true, source: { source: "default" } }; +} + +export function isEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return getConsentState(env).enabled; +} + +/** Persist the telemetry flag without discarding other config keys. */ +export function writeConsentFlag(enabled: boolean): void { + fs.mkdirSync(argentHomeDir(), { recursive: true }); + + let existing: Record = {}; + try { + const raw = fs.readFileSync(configFilePath(), "utf8"); + const json = JSON.parse(raw) as unknown; + if (json && typeof json === "object") { + existing = json as Record; + } + } catch { + /* missing or malformed — write a fresh document */ + } + + const telemetryBlock = + typeof existing.telemetry === "object" && existing.telemetry + ? (existing.telemetry as Record) + : {}; + + const next: Record = { + ...existing, + telemetry: { + ...telemetryBlock, + enabled, + }, + }; + + fs.writeFileSync(configFilePath(), JSON.stringify(next, null, 2) + "\n"); + cache.current = null; +} + +/** Test seam: blow away the in-memory mtime cache. */ +export function _resetConsentCacheForTest(): void { + cache.current = null; +} diff --git a/packages/telemetry/src/debug.ts b/packages/telemetry/src/debug.ts new file mode 100644 index 00000000..2eedbd62 --- /dev/null +++ b/packages/telemetry/src/debug.ts @@ -0,0 +1,55 @@ +import * as fs from "node:fs"; +import { argentHomeDir, debugLogPath } from "./paths.js"; + +// ARGENT_TELEMETRY_DEBUG=1 mirrors sanitized payloads and SDK errors locally. +export function isDebugEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const v = env.ARGENT_TELEMETRY_DEBUG; + if (!v) return false; + const norm = v.trim().toLowerCase(); + return norm === "1" || norm === "true" || norm === "yes" || norm === "on"; +} + +export interface DebugPayload { + event: string; + distinctId: string; + properties: Record; + ts: string; +} + +export function emitDebugPayload(payload: DebugPayload): void { + if (!isDebugEnabled()) return; + + let line: string; + try { + line = JSON.stringify(payload); + } catch (err) { + const reason = err instanceof Error ? err.constructor.name : "Error"; + line = JSON.stringify({ + event: payload.event, + distinctId: payload.distinctId, + properties: { debug_payload_serialization_error: reason }, + ts: payload.ts, + }); + } + try { + process.stderr.write(`[argent-telemetry] ${line}\n`); + } catch { + /* stderr can EPIPE in piped contexts */ + } + try { + fs.mkdirSync(argentHomeDir(), { recursive: true }); + fs.appendFileSync(debugLogPath(), line + "\n"); + } catch { + /* best-effort */ + } +} + +export function emitDebugError(prefix: string, err: unknown): void { + if (!isDebugEnabled()) return; + const msg = err instanceof Error ? `${err.constructor.name}: ${err.message}` : String(err); + try { + process.stderr.write(`[argent-telemetry] ${prefix}: ${msg}\n`); + } catch { + /* nothing to do */ + } +} diff --git a/packages/telemetry/src/erasure.ts b/packages/telemetry/src/erasure.ts new file mode 100644 index 00000000..344e795b --- /dev/null +++ b/packages/telemetry/src/erasure.ts @@ -0,0 +1,56 @@ +import * as fs from "node:fs"; +import { deleteAnonId } from "./identity.js"; +import { writeConsentFlag } from "./consent.js"; +import { configFilePath } from "./paths.js"; +import { emitDebugError } from "./debug.js"; + +export interface ForgetOptions { + /** Persist opt-out as part of the local reset. */ + disableConsent?: boolean; +} + +export interface ForgetResult { + /** True if the on-disk identity file was deleted (or already gone). */ + localIdRemoved: boolean; + /** True if persisted consent flag was set to false. */ + consentDisabled: boolean; +} + +// Local-only reset: optionally persist opt-out, then delete the anonymous id. +// Errors are debug-only because forget/uninstall should keep moving. +export async function forget(options: ForgetOptions = {}): Promise { + const disableConsent = options.disableConsent ?? true; + + let consentDisabled = false; + if (disableConsent) { + try { + writeConsentFlag(false); + consentDisabled = true; + } catch (err) { + emitDebugError("forget: writing consent flag failed", err); + } + } + + let localIdRemoved = false; + try { + deleteAnonId(); + localIdRemoved = true; + } catch (err) { + emitDebugError("forget: deleting telemetry-id failed", err); + } + + if (disableConsent) { + // Preserve config unless the write left an empty object behind. + try { + const raw = fs.readFileSync(configFilePath(), "utf8"); + const json = JSON.parse(raw) as Record; + if (json && typeof json === "object" && Object.keys(json).length === 0) { + fs.unlinkSync(configFilePath()); + } + } catch { + /* leave config in whatever state it ended up in */ + } + } + + return { localIdRemoved, consentDisabled }; +} diff --git a/packages/telemetry/src/events.ts b/packages/telemetry/src/events.ts new file mode 100644 index 00000000..d302b552 --- /dev/null +++ b/packages/telemetry/src/events.ts @@ -0,0 +1,193 @@ +// Typed telemetry event names and property shapes. sanitize.ts enforces the +// same surface at runtime. + +// Installation events + +export interface InstallationCliInitStartProps { + package_manager: "npm" | "yarn" | "pnpm" | "bun" | "unknown"; + is_non_interactive: boolean; +} + +export interface InstallationCliInitCompleteProps { + duration_ms: number; + is_success: boolean; + editors_configured_count: number; +} + +export interface InstallationCliInitCancelProps { + step: "global_install" | "editors" | "scope" | "skills" | "allowlist"; +} + +export interface InstallationGlobalInstallDecisionProps { + /** The developer-only `--from ` path is not reported. */ + decision: "install" | "cancel" | "already_installed"; +} + +export interface InstallationUpdateDecisionProps { + from_major: number; + to_major: number; + decision: "update" | "skip" | "no_update"; +} + +export interface InstallationEditorsSelectProps { + /** Bounded list of adapter names — sanitizer caps to 16 elements. */ + editors: string[]; + detected_editor_count: number; + scope: "local" | "global" | "custom"; +} + +export interface InstallationAllowlistDecisionProps { + is_enabled: boolean; + applicable_adapter_count: number; +} + +export interface InstallationSkillInstallProps { + method: "default" | "interactive" | "manual"; + is_online: boolean; + has_offline_cache: boolean; +} + +export interface InstallationSkillInstallResultProps { + is_success: boolean; +} + +export interface InstallationRulesAgentsCopyProps { + copied_count: number; +} + +export type InstallationPackageActionTrigger = "init" | "update" | "mcp_update"; + +export type InstallationPackageAction = + | "fresh_install" + | "already_installed" + | "init_triggered_update" + | "no_update" + | "update_skipped" + | "update_failed" + | "standalone_update" + | "standalone_install" + | "mcp_update"; + +export interface InstallationPackageActionProps { + trigger: InstallationPackageActionTrigger; + action: InstallationPackageAction; + is_success: boolean; + duration_ms: number; +} + +export interface InstallationCliUpdateStartProps {} + +export interface InstallationCliUpdateCompleteProps { + duration_ms: number; +} + +export interface InstallationCliUpdateFailProps { + duration_ms: number; +} + +export interface InstallationCliUninstallStartProps {} + +export interface InstallationCliUninstallCompleteProps { + has_pruned_content: boolean; + has_uninstalled_package: boolean; +} + +// Tool usage events + +export interface ToolInvokeProps { + tool: string; + tool_invocation_id: string; + platform?: "ios" | "android"; + /** sha256(udid).slice(0, 12), salted with cli major version. */ + device_id_hash?: string; +} + +export interface ToolCompleteProps { + tool: string; + tool_invocation_id: string; + platform?: "ios" | "android"; + duration_ms: number; +} + +export interface ToolFailProps { + tool: string; + tool_invocation_id: string; + platform?: "ios" | "android"; + duration_ms: number; +} + +// Lifecycle events + +export interface ToolserverStartProps {} + +export interface ToolserverStopProps { + reason: "idle" | "signal" | "crash"; + uptime_ms: number; + total_tool_calls: number; +} + +// Consent transition events + +export interface TelemetryOptOutProps {} + +export interface TelemetryCommandCompleteProps { + subcommand: "status" | "enable" | "disable" | "help" | "unknown"; + duration_ms: number; +} + +// Discriminated union for typed-track() + +export interface EventPropertyMap { + "installation:cli_init_start": InstallationCliInitStartProps; + "installation:cli_init_complete": InstallationCliInitCompleteProps; + "installation:cli_init_cancel": InstallationCliInitCancelProps; + "installation:global_install_decision": InstallationGlobalInstallDecisionProps; + "installation:update_decision": InstallationUpdateDecisionProps; + "installation:editors_select": InstallationEditorsSelectProps; + "installation:allowlist_decision": InstallationAllowlistDecisionProps; + "installation:skill_install": InstallationSkillInstallProps; + "installation:skill_install_result": InstallationSkillInstallResultProps; + "installation:rules_agents_copy": InstallationRulesAgentsCopyProps; + "installation:package_action": InstallationPackageActionProps; + "installation:cli_update_start": InstallationCliUpdateStartProps; + "installation:cli_update_complete": InstallationCliUpdateCompleteProps; + "installation:cli_update_fail": InstallationCliUpdateFailProps; + "installation:cli_uninstall_start": InstallationCliUninstallStartProps; + "installation:cli_uninstall_complete": InstallationCliUninstallCompleteProps; + "tool:invoke": ToolInvokeProps; + "tool:complete": ToolCompleteProps; + "tool:fail": ToolFailProps; + "toolserver:start": ToolserverStartProps; + "toolserver:stop": ToolserverStopProps; + "telemetry:opt_out": TelemetryOptOutProps; + "telemetry:command_complete": TelemetryCommandCompleteProps; +} + +export type EventName = keyof EventPropertyMap; + +/** Static list consumed by sanitize.ts and coverage tests. */ +export const EVENT_NAMES: readonly EventName[] = [ + "installation:cli_init_start", + "installation:cli_init_complete", + "installation:cli_init_cancel", + "installation:global_install_decision", + "installation:update_decision", + "installation:editors_select", + "installation:allowlist_decision", + "installation:skill_install", + "installation:skill_install_result", + "installation:rules_agents_copy", + "installation:package_action", + "installation:cli_update_start", + "installation:cli_update_complete", + "installation:cli_update_fail", + "installation:cli_uninstall_start", + "installation:cli_uninstall_complete", + "tool:invoke", + "tool:complete", + "tool:fail", + "toolserver:start", + "toolserver:stop", + "telemetry:opt_out", + "telemetry:command_complete", +]; diff --git a/packages/telemetry/src/hash.ts b/packages/telemetry/src/hash.ts new file mode 100644 index 00000000..0f3fd189 --- /dev/null +++ b/packages/telemetry/src/hash.ts @@ -0,0 +1,20 @@ +import * as crypto from "node:crypto"; + +// Build-time salt prefix for device id hashes. +declare const ARGENT_CLI_MAJOR_VERSION: string | undefined; + +function readSalt(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const define = (globalThis as any).ARGENT_CLI_MAJOR_VERSION; + if (typeof define === "string" && define !== "") return define; + if (typeof ARGENT_CLI_MAJOR_VERSION === "string" && ARGENT_CLI_MAJOR_VERSION !== "") { + return ARGENT_CLI_MAJOR_VERSION; + } + return "0"; +} + +/** Truncated, salted SHA-256 of a sensitive device identifier. */ +export function hashId(value: string): string { + const salt = readSalt(); + return crypto.createHash("sha256").update(`${salt}:${value}`).digest("hex").slice(0, 12); +} diff --git a/packages/telemetry/src/identity.ts b/packages/telemetry/src/identity.ts new file mode 100644 index 00000000..676bd585 --- /dev/null +++ b/packages/telemetry/src/identity.ts @@ -0,0 +1,84 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { argentHomeDir, identityFilePath } from "./paths.js"; + +// Read or atomically create the anonymous id. link(2) prevents concurrent +// first-run processes from overwriting each other. +export function readOrCreateAnonId(): string { + const finalPath = identityFilePath(); + const existing = tryReadId(finalPath); + if (existing) return existing; + + fs.mkdirSync(argentHomeDir(), { recursive: true }); + + const uuid = crypto.randomUUID(); + + // The random tmp path should be unique; retry defensively anyway. + for (let attempt = 0; attempt < 3; attempt++) { + const tmpPath = path.join( + argentHomeDir(), + `.telemetry-id.tmp.${process.pid}.${crypto.randomUUID()}` + ); + let fd: number; + try { + fd = fs.openSync(tmpPath, "wx", 0o600); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") continue; + throw err; + } + try { + fs.writeSync(fd, uuid); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + + try { + // POSIX rename() would replace; link() gives us no-overwrite publish. + fs.linkSync(tmpPath, finalPath); + return uuid; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") { + const beatUs = tryReadId(finalPath); + if (beatUs) return beatUs; + // Final file vanished between link and read; retry. + continue; + } + throw err; + } finally { + try { + fs.unlinkSync(tmpPath); + } catch { + /* nothing to clean up */ + } + } + } + throw new Error("telemetry: failed to create anonymous identity after retries"); +} + +/** Delete the identity file. Used by uninstall cleanup. */ +export function deleteAnonId(): void { + try { + fs.unlinkSync(identityFilePath()); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } +} + +function tryReadId(filePath: string): string | null { + let raw: string; + try { + // lstat rejects symlinks at the identity path. + const stats = fs.lstatSync(filePath); + if (!stats.isFile()) return null; + raw = fs.readFileSync(filePath, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + return null; + } + const value = raw.trim(); + // Accept a UUID-like value in case older versions wrote a different shape. + if (/^[0-9a-fA-F-]{32,128}$/.test(value)) return value; + return null; +} diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts new file mode 100644 index 00000000..dc73742f --- /dev/null +++ b/packages/telemetry/src/index.ts @@ -0,0 +1,261 @@ +// Anonymous opt-out telemetry for Argent. Public functions swallow telemetry +// failures and surface diagnostics only when ARGENT_TELEMETRY_DEBUG=1. + +import * as fs from "node:fs"; +import { + getClient, + getConstructedClient, + resetClient, + POSTHOG_HOST, + resolveConfig, +} from "./posthog.js"; +import { sanitize } from "./sanitize.js"; +import { getBaseProps, type Runtime } from "./base-props.js"; +import { readOrCreateAnonId } from "./identity.js"; +import { identityFilePath } from "./paths.js"; +import { isEnabled as consentIsEnabled, writeConsentFlag, getConsentState } from "./consent.js"; +import { emitDebugError, emitDebugPayload, isDebugEnabled } from "./debug.js"; +import { forget as forgetImpl, type ForgetOptions, type ForgetResult } from "./erasure.js"; +import type { EventName, EventPropertyMap } from "./events.js"; + +export type { EventName, EventPropertyMap } from "./events.js"; +export type { Runtime } from "./base-props.js"; +export type { ForgetOptions, ForgetResult } from "./erasure.js"; +export type { ConsentState, ConsentSource } from "./consent.js"; +export { attachRegistryTelemetry } from "./registry-listener.js"; +export { hashId } from "./hash.js"; +export { POSTHOG_HOST, resolveConfig } from "./posthog.js"; +export { _resetConsentCacheForTest } from "./consent.js"; +export { EVENT_NAMES } from "./events.js"; +export { isDebugEnabled } from "./debug.js"; +export { getConsentState } from "./consent.js"; +export { getSessionId } from "./base-props.js"; + +const SHORT_FLUSH_TIMEOUT_MS = 1_500; + +interface RuntimeState { + runtime: Runtime; + initialized: boolean; +} + +let state: RuntimeState | null = null; + +export function init(runtime: Runtime): void { + if (state && state.runtime === runtime) return; + state = { + runtime, + initialized: true, + }; +} + +function activeRuntime(): Runtime { + return state?.runtime ?? "cli"; +} + +function buildPayload( + event: string, + props: Record +): { + distinctId: string; + properties: Record; +} | null { + // Lazy id creation: only on the first event we send. + let distinctId: string; + try { + distinctId = readOrCreateAnonId(); + } catch (err) { + emitDebugError("buildPayload: identity creation failed", err); + return null; + } + + const base = getBaseProps(activeRuntime()); + const sanitized = sanitize(event, props); + const properties = { ...base, ...sanitized }; + return { distinctId, properties }; +} + +export function track(event: E, props: EventPropertyMap[E]): void { + try { + if (!consentIsEnabled()) return; + const built = buildPayload(event, props as Record); + if (!built) return; + + if (isDebugEnabled()) { + emitDebugPayload({ + event, + distinctId: built.distinctId, + properties: built.properties, + ts: new Date().toISOString(), + }); + } + + const client = getClient(); + if (!client) return; + try { + client.capture({ + distinctId: built.distinctId, + event, + properties: built.properties, + }); + } catch (err) { + emitDebugError(`track: capture(${event}) failed`, err); + } + } catch (err) { + emitDebugError(`track: outer wrapper caught ${event}`, err); + } +} + +export async function trackImmediate( + event: E, + props: EventPropertyMap[E] +): Promise { + try { + if (!consentIsEnabled()) return; + const built = buildPayload(event, props as Record); + if (!built) return; + + if (isDebugEnabled()) { + emitDebugPayload({ + event, + distinctId: built.distinctId, + properties: built.properties, + ts: new Date().toISOString(), + }); + } + + const client = getClient(); + if (!client) return; + const send = (async () => { + try { + client.capture({ + distinctId: built.distinctId, + event, + properties: built.properties, + }); + await client.flush(); + } catch (err) { + emitDebugError(`trackImmediate: capture/flush(${event}) failed`, err); + } + })(); + await Promise.race([ + send, + new Promise((resolve) => setTimeout(resolve, SHORT_FLUSH_TIMEOUT_MS).unref()), + ]); + } catch (err) { + emitDebugError(`trackImmediate: outer wrapper caught ${event}`, err); + } +} + +export async function shutdown(timeoutMs = SHORT_FLUSH_TIMEOUT_MS): Promise { + const client = getConstructedClient(); + if (!client) { + state = null; + return; + } + try { + await Promise.race([ + client.shutdown(timeoutMs), + new Promise((resolve) => setTimeout(resolve, timeoutMs + 250).unref()), + ]); + } catch (err) { + emitDebugError("shutdown failed", err); + } finally { + resetClient(); + state = null; + } +} + +export function isEnabled(): boolean { + return consentIsEnabled(); +} + +/** Persist `enabled=true`. */ +export function markEnabled(): void { + writeConsentFlag(true); +} + +// Disable records one final opt-out event, persists the flag, then flushes. +export async function markDisabled(): Promise { + try { + const wasEnabled = consentIsEnabled(); + let client = wasEnabled ? getConstructedClient() : null; + if (wasEnabled) { + const built = buildPayload("telemetry:opt_out", {}); + if (built && isDebugEnabled()) { + emitDebugPayload({ + event: "telemetry:opt_out", + distinctId: built.distinctId, + properties: built.properties, + ts: new Date().toISOString(), + }); + } + client = getClient(); + if (built && client) { + try { + client.capture({ + distinctId: built.distinctId, + event: "telemetry:opt_out", + properties: built.properties, + }); + } catch (err) { + emitDebugError("markDisabled: capture(telemetry:opt_out) failed", err); + } + } + } + writeConsentFlag(false); + if (client) { + try { + await Promise.race([ + client.flush(), + new Promise((resolve) => setTimeout(resolve, SHORT_FLUSH_TIMEOUT_MS).unref()), + ]); + } catch { + /* swallow */ + } + } + // Next track() will short-circuit on the persisted opt-out. + resetClient(); + state = null; + } catch (err) { + emitDebugError("markDisabled failed", err); + } +} + +export async function forget(options?: ForgetOptions): Promise { + return forgetImpl(options); +} + +/** Status payload for `argent telemetry status`; does not create a client. */ +export function status(): { + enabled: boolean; + source: ReturnType["source"]; + anonIdPrefix: string | null; + hasAnonIdOnDisk: boolean; + host: string; + isKeyConfigured: boolean; +} { + const consent = getConsentState(); + + // Read the id without creating one; status must be side-effect free. + let anonIdPrefix: string | null = null; + let hasAnonIdOnDisk = false; + try { + const raw = fs.readFileSync(identityFilePath(), "utf8").trim(); + if (/^[0-9a-fA-F-]{32,128}$/.test(raw)) { + hasAnonIdOnDisk = true; + anonIdPrefix = raw.slice(0, 8); + } + } catch { + /* missing — keep nulls */ + } + + const config = resolveConfig(); + return { + enabled: consent.enabled, + source: consent.source, + anonIdPrefix, + hasAnonIdOnDisk, + host: POSTHOG_HOST, + isKeyConfigured: config.isUsable, + }; +} diff --git a/packages/telemetry/src/paths.ts b/packages/telemetry/src/paths.ts new file mode 100644 index 00000000..7352a908 --- /dev/null +++ b/packages/telemetry/src/paths.ts @@ -0,0 +1,32 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +// Resolve at call time so tests can override HOME/USERPROFILE per case. +function nonEmpty(value: string | undefined): string | null { + if (value == undefined) return null; + const trimmed = value.trim(); + return trimmed === "" ? null : value; +} + +export function argentHomeDir(): string { + const home = + process.platform === "win32" + ? (nonEmpty(process.env.USERPROFILE) ?? os.homedir()) + : (nonEmpty(process.env.HOME) ?? os.homedir()); + return path.join(home, ".argent"); +} + +/** Anonymous identity file (UUID v4, mode 0600, atomic create). */ +export function identityFilePath(): string { + return path.join(argentHomeDir(), "telemetry-id"); +} + +/** Persisted opt-in / opt-out flag (JSON, "{telemetry: {enabled: boolean}}"). */ +export function configFilePath(): string { + return path.join(argentHomeDir(), "config.json"); +} + +/** Local payload audit log emitted when `ARGENT_TELEMETRY_DEBUG=1`. */ +export function debugLogPath(): string { + return path.join(argentHomeDir(), "telemetry-debug.log"); +} diff --git a/packages/telemetry/src/posthog.ts b/packages/telemetry/src/posthog.ts new file mode 100644 index 00000000..a11c43a8 --- /dev/null +++ b/packages/telemetry/src/posthog.ts @@ -0,0 +1,65 @@ +import { PostHog } from "posthog-node"; + +/** Hard-coded host so env-var overrides cannot redirect ingestion. */ +export const POSTHOG_HOST = "https://eu.i.posthog.com"; + +/** Public write-only PostHog project token. */ +export const POSTHOG_PROJECT_TOKEN = "phc_tkPxaBJ8WVr2KQAuu7FoN2nAcJ7MhVNsHSpUSuNC9HGV"; + +interface ResolvedConfig { + key: string; + /** True iff key is a real `phc_*` token (not "" / "phc_disabled"). */ + isUsable: boolean; +} + +function readProjectToken(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = globalThis as any; + const override = g.__ARGENT_POSTHOG_KEY_TEST; + if (typeof override === "string") return override; + return POSTHOG_PROJECT_TOKEN; +} + +export function resolveConfig(): ResolvedConfig { + const key = readProjectToken(); + + // Sentinel guard for tests and emergency local builds. + const isUsable = key !== "" && key !== "phc_disabled" && key.startsWith("phc_"); + return { key, isUsable }; +} + +let client: PostHog | null | undefined; + +export function getClient(): PostHog | null { + if (client !== undefined) return client; + const config = resolveConfig(); + if (!config.isUsable) { + client = null; + return null; + } + + const opts = { + host: POSTHOG_HOST, + disableGeoip: true, + requestTimeout: 3000, + flushAt: 20, + flushInterval: 10_000, + }; + + try { + client = new PostHog(config.key, opts); + } catch { + client = null; + return null; + } + + return client; +} + +export function getConstructedClient(): PostHog | null { + return client ?? null; +} + +export function resetClient(): void { + client = undefined; +} diff --git a/packages/telemetry/src/registry-listener.ts b/packages/telemetry/src/registry-listener.ts new file mode 100644 index 00000000..ef5aeb13 --- /dev/null +++ b/packages/telemetry/src/registry-listener.ts @@ -0,0 +1,104 @@ +import type { Registry } from "@argent/registry"; +import { track } from "./index.js"; +import { hashId } from "./hash.js"; + +// HTTP captures request-only metadata here so registry lifecycle events can +// include platform/device context without carrying raw params. +export interface InvocationMeta { + platform?: "ios" | "android"; + deviceId?: string; +} + +interface AttachHandle { + /** Idempotent unsubscribe. */ + detach: () => void; + /** Register metadata for the next invocation of this tool id. */ + recordInvocation: (toolId: string, meta: InvocationMeta) => () => void; + /** Counter exposed for the `toolserver:stop` payload. */ + getTotalToolCalls: () => number; +} + +export function attachRegistryTelemetry(registry: Registry): AttachHandle { + const pendingMetaByTool = new Map(); + const activeMetaByInvocationId = new Map(); + let totalToolCalls = 0; + + function consumePendingMeta(toolId: string): InvocationMeta { + const queue = pendingMetaByTool.get(toolId); + const meta = queue?.shift(); + if (queue && queue.length === 0) pendingMetaByTool.delete(toolId); + return meta ?? {}; + } + + function consumeActiveMeta(toolInvocationId: string): InvocationMeta { + const meta = activeMetaByInvocationId.get(toolInvocationId); + if (meta) activeMetaByInvocationId.delete(toolInvocationId); + return meta ?? {}; + } + + const onInvoked = (toolId: string, toolInvocationId: string): void => { + totalToolCalls += 1; + const meta = consumePendingMeta(toolId); + activeMetaByInvocationId.set(toolInvocationId, meta); + track("tool:invoke", { + tool: toolId, + tool_invocation_id: toolInvocationId, + ...(meta.platform ? { platform: meta.platform } : {}), + ...(meta.deviceId ? { device_id_hash: hashId(meta.deviceId) } : {}), + }); + }; + + const onCompleted = (toolId: string, toolInvocationId: string, durationMs: number): void => { + const meta = consumeActiveMeta(toolInvocationId); + track("tool:complete", { + tool: toolId, + tool_invocation_id: toolInvocationId, + ...(meta.platform ? { platform: meta.platform } : {}), + duration_ms: durationMs, + }); + }; + + const onFailed = ( + toolId: string, + toolInvocationId: string, + error: Error, + durationMs = 0 + ): void => { + const meta = consumeActiveMeta(toolInvocationId); + track("tool:fail", { + tool: toolId, + tool_invocation_id: toolInvocationId, + ...(meta.platform ? { platform: meta.platform } : {}), + duration_ms: durationMs, + }); + }; + + registry.events.on("toolInvoked", onInvoked); + registry.events.on("toolCompleted", onCompleted); + registry.events.on("toolFailed", onFailed); + + function recordInvocation(toolId: string, meta: InvocationMeta): () => void { + const queue = pendingMetaByTool.get(toolId) ?? []; + queue.push(meta); + pendingMetaByTool.set(toolId, queue); + return () => { + const current = pendingMetaByTool.get(toolId); + if (!current) return; + const index = current.indexOf(meta); + if (index >= 0) current.splice(index, 1); + if (current.length === 0) pendingMetaByTool.delete(toolId); + }; + } + + return { + detach: () => { + registry.events.off("toolInvoked", onInvoked); + registry.events.off("toolCompleted", onCompleted); + registry.events.off("toolFailed", onFailed); + pendingMetaByTool.clear(); + activeMetaByInvocationId.clear(); + }, + recordInvocation, + getTotalToolCalls: () => totalToolCalls, + }; +} diff --git a/packages/telemetry/src/sanitize.ts b/packages/telemetry/src/sanitize.ts new file mode 100644 index 00000000..3eaa9787 --- /dev/null +++ b/packages/telemetry/src/sanitize.ts @@ -0,0 +1,175 @@ +import type { EventName } from "./events.js"; + +// Per-event property allowlist and validators. Unknown keys and invalid values +// are dropped before anything reaches PostHog. + +export type Validator = (v: unknown) => unknown | undefined; + +const oneOf = + (opts: readonly T[]): Validator => + (v) => + typeof v === "string" && (opts as readonly string[]).includes(v) ? v : undefined; + +const matches = + (re: RegExp, max = 80): Validator => + (v) => + typeof v === "string" && v.length <= max && re.test(v) ? v : undefined; + +const finiteNonNeg = + (max = 2 ** 31): Validator => + (v) => + typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= max ? v : undefined; + +const bool: Validator = (v) => (typeof v === "boolean" ? v : undefined); + +const arrayOf = + (elem: Validator, maxLen = 16): Validator => + (v) => { + if (!Array.isArray(v)) return undefined; + if (v.length > maxLen) return undefined; + const out: unknown[] = []; + for (const e of v) { + const cleaned = elem(e); + if (cleaned === undefined) return undefined; + out.push(cleaned); + } + return out; + }; + +// Shared validators + +const TOOL_NAME = matches(/^[a-z][a-z0-9-]{0,63}$/, 64); +const PLATFORM = oneOf(["ios", "android"] as const); +const ID_HASH = matches(/^[0-9a-f]{12}$/, 12); +const UUID = matches( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + 36 +); +const PACKAGE_MANAGER = oneOf(["npm", "yarn", "pnpm", "bun", "unknown"] as const); +const PACKAGE_ACTION_TRIGGER = oneOf(["init", "update", "mcp_update"] as const); +const PACKAGE_ACTION = oneOf([ + "fresh_install", + "already_installed", + "init_triggered_update", + "no_update", + "update_skipped", + "update_failed", + "standalone_update", + "standalone_install", + "mcp_update", +] as const); +const TELEMETRY_SUBCOMMAND = oneOf(["status", "enable", "disable", "help", "unknown"] as const); +const ADAPTER_NAME = matches(/^[a-z][a-z0-9-]{0,63}$/, 64); +const COUNT = finiteNonNeg(); +const DURATION_MS = finiteNonNeg(); +const MAJOR_VERSION = finiteNonNeg(9999); + +// Per-event validators + +export const ALLOWED: Record> = { + "installation:cli_init_start": { + package_manager: PACKAGE_MANAGER, + is_non_interactive: bool, + }, + "installation:cli_init_complete": { + duration_ms: DURATION_MS, + is_success: bool, + editors_configured_count: COUNT, + }, + "installation:cli_init_cancel": { + step: oneOf(["global_install", "editors", "scope", "skills", "allowlist"] as const), + }, + "installation:global_install_decision": { + // `from_tar` is intentionally absent; the installer skips that dev path. + decision: oneOf(["install", "cancel", "already_installed"] as const), + }, + "installation:update_decision": { + from_major: MAJOR_VERSION, + to_major: MAJOR_VERSION, + decision: oneOf(["update", "skip", "no_update"] as const), + }, + "installation:editors_select": { + editors: arrayOf(ADAPTER_NAME), + detected_editor_count: COUNT, + scope: oneOf(["local", "global", "custom"] as const), + }, + "installation:allowlist_decision": { + is_enabled: bool, + applicable_adapter_count: COUNT, + }, + "installation:skill_install": { + method: oneOf(["default", "interactive", "manual"] as const), + is_online: bool, + has_offline_cache: bool, + }, + "installation:skill_install_result": { + is_success: bool, + }, + "installation:rules_agents_copy": { + copied_count: COUNT, + }, + "installation:package_action": { + trigger: PACKAGE_ACTION_TRIGGER, + action: PACKAGE_ACTION, + is_success: bool, + duration_ms: DURATION_MS, + }, + "installation:cli_update_start": {}, + "installation:cli_update_complete": { + duration_ms: DURATION_MS, + }, + "installation:cli_update_fail": { + duration_ms: DURATION_MS, + }, + "installation:cli_uninstall_start": {}, + "installation:cli_uninstall_complete": { + has_pruned_content: bool, + has_uninstalled_package: bool, + }, + "tool:invoke": { + tool: TOOL_NAME, + tool_invocation_id: UUID, + platform: PLATFORM, + device_id_hash: ID_HASH, + }, + "tool:complete": { + tool: TOOL_NAME, + tool_invocation_id: UUID, + platform: PLATFORM, + duration_ms: DURATION_MS, + }, + "tool:fail": { + tool: TOOL_NAME, + tool_invocation_id: UUID, + platform: PLATFORM, + duration_ms: DURATION_MS, + }, + "toolserver:start": {}, + "toolserver:stop": { + reason: oneOf(["idle", "signal", "crash"] as const), + uptime_ms: DURATION_MS, + total_tool_calls: COUNT, + }, + "telemetry:opt_out": {}, + "telemetry:command_complete": { + subcommand: TELEMETRY_SUBCOMMAND, + duration_ms: DURATION_MS, + }, +}; + +/** Strip keys and values that are not allowed for this event. */ +export function sanitize(event: string, raw: Record): Record { + const validators = (ALLOWED as Record>)[event]; + if (!validators) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + const validate = validators[k]; + if (!validate) continue; + const cleaned = validate(v); + if (cleaned !== undefined) out[k] = cleaned; + } + return out; +} + +/** Re-export of the validator combinators for unit tests. */ +export const _testValidators = { oneOf, matches, finiteNonNeg, bool, arrayOf }; diff --git a/packages/telemetry/test/base-props.test.ts b/packages/telemetry/test/base-props.test.ts new file mode 100644 index 00000000..0d2a48ef --- /dev/null +++ b/packages/telemetry/test/base-props.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { getBaseProps, getSessionId, _resetSessionIdForTest } from "../src/base-props.js"; +import { snapshotEnv } from "./helpers.js"; +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe("base-props", () => { + it("returns the full base set with coarse CI telemetry", () => { + const restore = snapshotEnv(["CI", "GITHUB_ACTIONS"]); + try { + process.env.CI = "false"; + delete process.env.GITHUB_ACTIONS; + const props = getBaseProps("cli"); + expect(Object.keys(props).sort()).toEqual( + [ + "$process_person_profile", + "$session_id", + "arch", + "cli_version_major_minor", + "is_ci", + "is_tty", + "node_version_major", + "os", + "runtime", + ].sort() + ); + expect(props.$process_person_profile).toBe(false); + expect(typeof props.is_tty).toBe("boolean"); + expect(props.is_ci).toBe(false); + expect(typeof props.node_version_major).toBe("string"); + expect(typeof props.arch).toBe("string"); + expect(props.runtime).toBe("cli"); + expect(typeof props.$session_id).toBe("string"); + expect(props.$session_id).toMatch(UUID_V4); + expect(props).not.toHaveProperty("ci_provider"); + expect(props).not.toHaveProperty("is_container"); + expect(props).not.toHaveProperty("container_runtime"); + } finally { + restore(); + } + }); + + it("sets is_ci when the process is running in CI", () => { + const restore = snapshotEnv(["CI"]); + try { + process.env.CI = "1"; + expect(getBaseProps("cli").is_ci).toBe(true); + } finally { + restore(); + } + }); + + it("still does NOT carry full cli_version / full node_version", () => { + const props = getBaseProps("tool_server") as unknown as Record; + expect(props).not.toHaveProperty("cli_version"); + expect(props).not.toHaveProperty("node_version"); + }); + + it("arch matches process.arch verbatim (no transformation)", () => { + const props = getBaseProps("cli"); + expect(props.arch).toBe(process.arch); + }); + + describe("$session_id", () => { + it("is stable within a process across calls and across runtimes", () => { + const a = getBaseProps("cli").$session_id; + const b = getBaseProps("tool_server").$session_id; + const c = getBaseProps("installer").$session_id; + expect(a).toBe(b); + expect(b).toBe(c); + expect(a).toBe(getSessionId()); + }); + + it("is a v4-shaped UUID", () => { + expect(getSessionId()).toMatch(UUID_V4); + }); + + it("rotates after the test seam runs (asserts fresh-process behaviour)", () => { + const before = getSessionId(); + _resetSessionIdForTest(); + const after = getSessionId(); + expect(after).not.toBe(before); + expect(after).toMatch(UUID_V4); + // Subsequent getBaseProps calls reflect the new id immediately. + expect(getBaseProps("cli").$session_id).toBe(after); + }); + }); +}); diff --git a/packages/telemetry/test/ci-detect.test.ts b/packages/telemetry/test/ci-detect.test.ts new file mode 100644 index 00000000..511e2603 --- /dev/null +++ b/packages/telemetry/test/ci-detect.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { _CI_VENDOR_COUNT_FOR_TEST, isCi } from "../src/ci-detect.js"; + +describe("ci-detect", () => { + it("returns false when no CI env var is set", () => { + expect(isCi({})).toBe(false); + }); + + it.each(["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "RUN_ID"])( + "detects generic %s", + (name) => { + expect(isCi({ [name]: "1" })).toBe(true); + } + ); + + it.each(["GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "TF_BUILD", "CODEBUILD_BUILD_ARN"])( + "detects provider %s", + (name) => { + expect(isCi({ [name]: "1" })).toBe(true); + } + ); + + it("detects provider includes checks from ci-info's vendor table", () => { + expect(isCi({ NODE: "/app/.heroku/node/bin/node" })).toBe(true); + }); + + it("does not detect incomplete provider includes checks", () => { + expect(isCi({ NODE: "/usr/local/bin/node" })).toBe(false); + }); + + it("ignores an explicitly-empty env var", () => { + expect(isCi({ CI: "" })).toBe(false); + }); + + it("treats CI=false as not CI (ci-info escape hatch)", () => { + expect(isCi({ CI: "false", GITHUB_ACTIONS: "1" })).toBe(false); + }); + + it("uses ci-info's broad provider table", () => { + expect(_CI_VENDOR_COUNT_FOR_TEST).toBeGreaterThanOrEqual(45); + }); +}); diff --git a/packages/telemetry/test/consent.test.ts b/packages/telemetry/test/consent.test.ts new file mode 100644 index 00000000..7434598d --- /dev/null +++ b/packages/telemetry/test/consent.test.ts @@ -0,0 +1,180 @@ +import * as fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { scopeHome, snapshotEnv } from "./helpers.js"; +import { + getConsentState, + isEnabled, + writeConsentFlag, + _resetConsentCacheForTest, +} from "../src/consent.js"; +import { configFilePath } from "../src/paths.js"; + +describe("consent", () => { + const { tmp } = scopeHome(); + const restoreEnv = () => + snapshotEnv([ + "DO_NOT_TRACK", + "ARGENT_TELEMETRY", + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CONTINUOUS_INTEGRATION", + "BUILD_NUMBER", + "RUN_ID", + "CIRCLECI", + "TRAVIS", + "JENKINS_URL", + "JENKINS_HOME", + "TEAMCITY_VERSION", + "BUILDKITE", + "BITBUCKET_BUILD_NUMBER", + "CODEBUILD_BUILD_ID", + "TF_BUILD", + "VERCEL", + "NETLIFY", + "DRONE", + "APPVEYOR", + ]); + + function emptyEnv(): NodeJS.ProcessEnv { + return {}; + } + + it("defaults to enabled in an empty env / no config file", () => { + const restore = restoreEnv(); + try { + expect(getConsentState(emptyEnv()).enabled).toBe(true); + expect(getConsentState(emptyEnv()).source.source).toBe("default"); + } finally { + restore(); + } + }); + + it("DO_NOT_TRACK=1 disables (consortium standard)", () => { + const restore = restoreEnv(); + try { + expect(getConsentState({ DO_NOT_TRACK: "1" }).enabled).toBe(false); + expect(getConsentState({ DO_NOT_TRACK: "true" }).enabled).toBe(false); + } finally { + restore(); + } + }); + + it("ARGENT_TELEMETRY=0 disables", () => { + const restore = restoreEnv(); + try { + expect(getConsentState({ ARGENT_TELEMETRY: "0" }).enabled).toBe(false); + expect(getConsentState({ ARGENT_TELEMETRY: "false" }).enabled).toBe(false); + } finally { + restore(); + } + }); + + it.each(["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_HOME", "TF_BUILD"])( + "%s=1 does not auto-disable", + (envName) => { + const restore = restoreEnv(); + try { + const state = getConsentState({ [envName]: "1" }); + expect(state.enabled).toBe(true); + expect(state.source.source).toBe("default"); + } finally { + restore(); + } + } + ); + + it("respects persisted config.json", () => { + const restore = restoreEnv(); + try { + _resetConsentCacheForTest(); + writeConsentFlag(false); + expect(isEnabled(emptyEnv())).toBe(false); + expect(getConsentState(emptyEnv()).source.source).toBe("config_file"); + _resetConsentCacheForTest(); + writeConsentFlag(true); + expect(isEnabled(emptyEnv())).toBe(true); + } finally { + restore(); + } + }); + + it("re-reads the config file when mtime changes", () => { + const restore = restoreEnv(); + try { + _resetConsentCacheForTest(); + writeConsentFlag(true); + expect(isEnabled(emptyEnv())).toBe(true); + + // Manually rewrite to disabled; bumping mtime to a future second so + // the cache invalidates even on macOS HFS+'s 1 s granularity. + const future = new Date(Date.now() + 5_000); + fs.writeFileSync( + configFilePath(), + JSON.stringify({ telemetry: { enabled: false } }, null, 2) + "\n" + ); + fs.utimesSync(configFilePath(), future, future); + expect(isEnabled(emptyEnv())).toBe(false); + } finally { + restore(); + } + }); + + it("treats a malformed config.json as no override (does NOT enable silently)", () => { + const restore = restoreEnv(); + try { + _resetConsentCacheForTest(); + // Manually write garbage + fs.mkdirSync(tmp() + "/.argent", { recursive: true }); + fs.writeFileSync(configFilePath(), "{ this is not json"); + expect(isEnabled(emptyEnv())).toBe(true); // falls back to default-on, fine + // Important: source must NOT claim config_file as the reason. + expect(getConsentState(emptyEnv()).source.source).toBe("default"); + } finally { + restore(); + } + }); + + it("rejects a symlink at the config path", () => { + const restore = restoreEnv(); + try { + _resetConsentCacheForTest(); + fs.mkdirSync(tmp() + "/.argent", { recursive: true }); + const fake = tmp() + "/elsewhere.json"; + fs.writeFileSync(fake, JSON.stringify({ telemetry: { enabled: false } })); + fs.symlinkSync(fake, configFilePath()); + // Symlink at the target → treated as missing, default-on. + expect(isEnabled(emptyEnv())).toBe(true); + } finally { + restore(); + } + }); + + it("env override beats persisted config", () => { + const restore = restoreEnv(); + try { + _resetConsentCacheForTest(); + writeConsentFlag(true); + expect(getConsentState({ DO_NOT_TRACK: "1" }).enabled).toBe(false); + expect(getConsentState({ ARGENT_TELEMETRY: "0" }).enabled).toBe(false); + } finally { + restore(); + } + }); + + it("explicit env opt-outs still disable in CI", () => { + const restore = restoreEnv(); + try { + expect(getConsentState({ CI: "1", DO_NOT_TRACK: "1" }).enabled).toBe(false); + expect(getConsentState({ CI: "1", DO_NOT_TRACK: "1" }).source.source).toBe( + "env_do_not_track" + ); + expect(getConsentState({ CI: "1", ARGENT_TELEMETRY: "0" }).enabled).toBe(false); + expect(getConsentState({ CI: "1", ARGENT_TELEMETRY: "0" }).source.source).toBe( + "env_argent_telemetry" + ); + } finally { + restore(); + } + }); +}); diff --git a/packages/telemetry/test/debug.test.ts b/packages/telemetry/test/debug.test.ts new file mode 100644 index 00000000..76ac0c38 --- /dev/null +++ b/packages/telemetry/test/debug.test.ts @@ -0,0 +1,99 @@ +import * as fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { scopeHome, snapshotEnv } from "./helpers.js"; +import { emitDebugPayload, isDebugEnabled } from "../src/debug.js"; +import { debugLogPath } from "../src/paths.js"; + +describe("debug", () => { + scopeHome(); + + it("isDebugEnabled honours ARGENT_TELEMETRY_DEBUG=1", () => { + const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]); + try { + delete process.env.ARGENT_TELEMETRY_DEBUG; + expect(isDebugEnabled()).toBe(false); + process.env.ARGENT_TELEMETRY_DEBUG = "1"; + expect(isDebugEnabled()).toBe(true); + process.env.ARGENT_TELEMETRY_DEBUG = "true"; + expect(isDebugEnabled()).toBe(true); + process.env.ARGENT_TELEMETRY_DEBUG = "0"; + expect(isDebugEnabled()).toBe(false); + } finally { + restore(); + } + }); + + it("emitDebugPayload appends to ~/.argent/telemetry-debug.log when debug is on", () => { + const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]); + try { + process.env.ARGENT_TELEMETRY_DEBUG = "1"; + emitDebugPayload({ + event: "test:event", + distinctId: "00000000-0000-0000-0000-000000000000", + properties: { foo: "bar" }, + ts: "2026-05-25T00:00:00.000Z", + }); + const contents = fs.readFileSync(debugLogPath(), "utf8"); + expect(contents).toContain("test:event"); + expect(contents).toContain('"foo":"bar"'); + } finally { + restore(); + } + }); + + it("emitDebugPayload is a no-op when debug is off (file is not created)", () => { + const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]); + try { + delete process.env.ARGENT_TELEMETRY_DEBUG; + emitDebugPayload({ + event: "test:event", + distinctId: "00000000-0000-0000-0000-000000000000", + properties: {}, + ts: "2026-05-25T00:00:00.000Z", + }); + expect(fs.existsSync(debugLogPath())).toBe(false); + } finally { + restore(); + } + }); + + it("emitDebugPayload does not throw when properties contain circular values", () => { + const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]); + try { + process.env.ARGENT_TELEMETRY_DEBUG = "1"; + const circular: Record = {}; + circular.self = circular; + expect(() => + emitDebugPayload({ + event: "test:event", + distinctId: "00000000-0000-0000-0000-000000000000", + properties: circular, + ts: "2026-05-25T00:00:00.000Z", + }) + ).not.toThrow(); + const contents = fs.readFileSync(debugLogPath(), "utf8"); + expect(contents).toContain("debug_payload_serialization_error"); + } finally { + restore(); + } + }); + + it("emitDebugPayload does not throw when properties contain BigInt", () => { + const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]); + try { + process.env.ARGENT_TELEMETRY_DEBUG = "1"; + expect(() => + emitDebugPayload({ + event: "test:event", + distinctId: "00000000-0000-0000-0000-000000000000", + properties: { value: 1n }, + ts: "2026-05-25T00:00:00.000Z", + }) + ).not.toThrow(); + const contents = fs.readFileSync(debugLogPath(), "utf8"); + expect(contents).toContain("debug_payload_serialization_error"); + } finally { + restore(); + } + }); +}); diff --git a/packages/telemetry/test/helpers.ts b/packages/telemetry/test/helpers.ts new file mode 100644 index 00000000..dd44d11c --- /dev/null +++ b/packages/telemetry/test/helpers.ts @@ -0,0 +1,57 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach } from "vitest"; +import { _resetConsentCacheForTest } from "../src/consent.js"; + +let savedHome: string | undefined; +let savedUserProfile: string | undefined; + +// Point telemetry home resolution at a vitest-scoped temp directory. +export function useTempHome(): { tmp: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "argent-telemetry-")); + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + process.env.HOME = tmp; + process.env.USERPROFILE = tmp; + return { tmp }; +} + +export function restoreHome(tmp: string): void { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + try { + fs.rmSync(tmp, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + _resetConsentCacheForTest(); +} + +export function scopeHome(): { tmp: () => string } { + let active: string; + beforeEach(() => { + const { tmp } = useTempHome(); + active = tmp; + }); + afterEach(() => { + restoreHome(active); + }); + return { tmp: () => active }; +} + +export function withEnv(snapshot: Record): void { + for (const [k, v] of Object.entries(snapshot)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +} + +/** Snapshot env vars and return a restorer. */ +export function snapshotEnv(keys: string[]): () => void { + const saved: Record = {}; + for (const k of keys) saved[k] = process.env[k]; + return () => withEnv(saved); +} diff --git a/packages/telemetry/test/identity.test.ts b/packages/telemetry/test/identity.test.ts new file mode 100644 index 00000000..ce518b44 --- /dev/null +++ b/packages/telemetry/test/identity.test.ts @@ -0,0 +1,93 @@ +import * as fs from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { scopeHome } from "./helpers.js"; +import { readOrCreateAnonId, deleteAnonId } from "../src/identity.js"; +import { identityFilePath } from "../src/paths.js"; + +describe("identity", () => { + const { tmp } = scopeHome(); + + it("creates a UUID on first call and reuses it on second", () => { + const first = readOrCreateAnonId(); + expect(first).toMatch(/^[0-9a-f-]{36}$/); + const second = readOrCreateAnonId(); + expect(second).toBe(first); + }); + + it("writes with mode 0600", () => { + readOrCreateAnonId(); + const stats = fs.lstatSync(identityFilePath()); + expect(stats.mode & 0o777).toBe(0o600); + }); + + it("survives a half-written tmpfile", () => { + readOrCreateAnonId(); + // Plant a leftover tmpfile to simulate a crash mid-create. The next + // read still succeeds because the FINAL path was written cleanly. + fs.writeFileSync(tmp() + "/.argent/.telemetry-id.tmp.99999.deadbeef", "half-written-garbage"); + const id = readOrCreateAnonId(); + expect(id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("fails closed when a symlink is planted at the final path", () => { + // First create a real file elsewhere. + fs.mkdirSync(tmp() + "/.argent", { recursive: true }); + const evilTarget = tmp() + "/evil.txt"; + fs.writeFileSync(evilTarget, "00000000-0000-0000-0000-000000000000"); + fs.symlinkSync(evilTarget, identityFilePath()); + + // The symlink should NOT be honoured or replaced. + expect(() => readOrCreateAnonId()).toThrow(); + expect(fs.lstatSync(identityFilePath()).isSymbolicLink()).toBe(true); + }); + + it("deleteAnonId removes the file and is idempotent", () => { + readOrCreateAnonId(); + expect(fs.existsSync(identityFilePath())).toBe(true); + deleteAnonId(); + expect(fs.existsSync(identityFilePath())).toBe(false); + expect(() => deleteAnonId()).not.toThrow(); + }); + + it("two concurrent createOrRead calls converge on one UUID", async () => { + // Race two creates from the same process; the final-path link() gate + // guarantees the loser reads the winner's file. + const [a, b] = await Promise.all([ + Promise.resolve().then(() => readOrCreateAnonId()), + Promise.resolve().then(() => readOrCreateAnonId()), + ]); + expect(a).toBe(b); + }); + + it("does not overwrite an id published between temp write and final publish", async () => { + const winner = "11111111-1111-4111-8111-111111111111"; + let planted = false; + + vi.resetModules(); + vi.doMock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + linkSync: vi.fn((existingPath: fs.PathLike, newPath: fs.PathLike) => { + if (!planted && String(newPath) === identityFilePath()) { + planted = true; + actual.writeFileSync(identityFilePath(), winner, { mode: 0o600 }); + const err = new Error("file exists") as NodeJS.ErrnoException; + err.code = "EEXIST"; + throw err; + } + return actual.linkSync(existingPath, newPath); + }), + }; + }); + + try { + const { readOrCreateAnonId } = await import("../src/identity.js"); + expect(readOrCreateAnonId()).toBe(winner); + expect(fs.readFileSync(identityFilePath(), "utf8")).toBe(winner); + } finally { + vi.doUnmock("node:fs"); + vi.resetModules(); + } + }); +}); diff --git a/packages/telemetry/test/index.test.ts b/packages/telemetry/test/index.test.ts new file mode 100644 index 00000000..02448554 --- /dev/null +++ b/packages/telemetry/test/index.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import { + _resetConsentCacheForTest, + getConsentState, + markDisabled, + markEnabled, + forget, + init, + isEnabled, + status, + shutdown, + track, + trackImmediate, +} from "../src/index.js"; +import { resetClient } from "../src/posthog.js"; +import { scopeHome, snapshotEnv } from "./helpers.js"; +import { configFilePath } from "../src/paths.js"; + +const posthogMock = vi.hoisted(() => ({ + instances: [] as Array<{ + capture: ReturnType; + flush: ReturnType; + shutdown: ReturnType; + opts: unknown; + }>, + flushImpl: () => Promise.resolve(), +})); + +vi.mock("posthog-node", () => { + return { + PostHog: vi.fn().mockImplementation(function (_key: string, opts: unknown) { + const instance = { + capture: vi.fn(), + flush: vi.fn(() => posthogMock.flushImpl()), + shutdown: vi.fn().mockResolvedValue(undefined), + opts, + }; + posthogMock.instances.push(instance); + return instance; + }), + }; +}); + +describe("telemetry public surface", () => { + scopeHome(); + + beforeEach(() => { + posthogMock.instances.length = 0; + posthogMock.flushImpl = () => Promise.resolve(); + resetClient(); + (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = "phc_real"; + init("tool_server"); + markEnabled(); + }); + + afterEach(() => { + delete (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST; + resetClient(); + vi.restoreAllMocks(); + }); + + it("markDisabled sends opt-out, persists disabled state, and flushes prior events", async () => { + track("toolserver:start", {}); + + posthogMock.flushImpl = async () => { + expect(isEnabled()).toBe(false); + }; + + await markDisabled(); + + const client = posthogMock.instances[0]!; + expect(posthogMock.instances).toHaveLength(1); + expect(client.capture).toHaveBeenCalledWith( + expect.objectContaining({ event: "toolserver:start" }) + ); + expect(client.capture).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry:opt_out" }) + ); + expect(client.flush).toHaveBeenCalledTimes(1); + expect(isEnabled()).toBe(false); + }); + + it("uses one client and flushes only for trackImmediate", async () => { + track("toolserver:start", {}); + await trackImmediate("toolserver:stop", { + reason: "signal", + uptime_ms: 1, + total_tool_calls: 0, + }); + + const client = posthogMock.instances[0]!; + + expect(posthogMock.instances).toHaveLength(1); + expect(client.capture).toHaveBeenCalledTimes(2); + expect(client.flush).toHaveBeenCalledTimes(1); + expect(client.opts).toEqual( + expect.objectContaining({ flushAt: 20, flushInterval: 10_000 }) + ); + }); + + it("captures events in CI and annotates payloads with is_ci", () => { + const restore = snapshotEnv(["CI"]); + try { + process.env.CI = "1"; + + track("toolserver:start", {}); + + const client = posthogMock.instances[0]!; + expect(client.capture).toHaveBeenCalledWith( + expect.objectContaining({ + event: "toolserver:start", + properties: expect.objectContaining({ is_ci: true }), + }) + ); + } finally { + restore(); + } + }); + + it("shutdown drains the constructed client", async () => { + track("toolserver:start", {}); + await trackImmediate("toolserver:stop", { + reason: "signal", + uptime_ms: 1, + total_tool_calls: 0, + }); + + const client = posthogMock.instances[0]!; + + await shutdown(); + + expect(posthogMock.instances).toHaveLength(1); + expect(client.shutdown).toHaveBeenCalledTimes(1); + }); + + it("forget does not send delete-person and performs local cleanup by default", async () => { + track("toolserver:start", {}); + expect(status().hasAnonIdOnDisk).toBe(true); + + const result = await forget(); + const client = posthogMock.instances[0]!; + + expect(posthogMock.instances).toHaveLength(1); + expect(client.capture).not.toHaveBeenCalledWith( + expect.objectContaining({ event: "$delete_person" }) + ); + expect(client.flush).not.toHaveBeenCalled(); + expect(client.shutdown).not.toHaveBeenCalled(); + expect(result.localIdRemoved).toBe(true); + expect(result.consentDisabled).toBe(true); + expect(status().hasAnonIdOnDisk).toBe(false); + expect(isEnabled()).toBe(false); + }); + + it("forget can erase telemetry identity without creating consent config", async () => { + fs.unlinkSync(configFilePath()); + _resetConsentCacheForTest(); + track("toolserver:start", {}); + + const result = await forget({ disableConsent: false }); + + const client = posthogMock.instances[0]!; + expect(client.capture).not.toHaveBeenCalledWith( + expect.objectContaining({ event: "$delete_person" }) + ); + expect(result.localIdRemoved).toBe(true); + expect(result.consentDisabled).toBe(false); + expect(status().hasAnonIdOnDisk).toBe(false); + expect(fs.existsSync(configFilePath())).toBe(false); + expect(isEnabled()).toBe(true); + }); + + it("forget without consent changes preserves an explicit opt-out", async () => { + fs.writeFileSync(configFilePath(), JSON.stringify({ telemetry: { enabled: false } }) + "\n"); + _resetConsentCacheForTest(); + + const result = await forget({ disableConsent: false }); + + expect(result.consentDisabled).toBe(false); + expect(getConsentState({}).enabled).toBe(false); + expect(getConsentState({}).source.source).toBe("config_file"); + }); + + it("forget without consent changes preserves an explicit opt-in", async () => { + markEnabled(); + + const result = await forget({ disableConsent: false }); + + expect(result.consentDisabled).toBe(false); + expect(getConsentState({}).enabled).toBe(true); + expect(getConsentState({}).source.source).toBe("config_file"); + }); + + it("forget without consent changes still removes the local telemetry id", async () => { + track("toolserver:start", {}); + expect(status().hasAnonIdOnDisk).toBe(true); + + const result = await forget({ disableConsent: false }); + + expect(result.localIdRemoved).toBe(true); + expect(status().hasAnonIdOnDisk).toBe(false); + }); +}); diff --git a/packages/telemetry/test/paths.test.ts b/packages/telemetry/test/paths.test.ts new file mode 100644 index 00000000..bc798b95 --- /dev/null +++ b/packages/telemetry/test/paths.test.ts @@ -0,0 +1,35 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { argentHomeDir } from "../src/paths.js"; +import { snapshotEnv } from "./helpers.js"; + +describe("paths", () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it.each(["", " "])("treats HOME=%j as missing on POSIX", (home) => { + const restore = snapshotEnv(["HOME"]); + try { + Object.defineProperty(process, "platform", { value: "darwin" }); + process.env.HOME = home; + expect(argentHomeDir()).toBe(path.join(os.homedir(), ".argent")); + } finally { + restore(); + } + }); + + it.each(["", " "])("treats USERPROFILE=%j as missing on Windows", (userProfile) => { + const restore = snapshotEnv(["USERPROFILE"]); + try { + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.USERPROFILE = userProfile; + expect(argentHomeDir()).toBe(path.join(os.homedir(), ".argent")); + } finally { + restore(); + } + }); +}); diff --git a/packages/telemetry/test/posthog-host.test.ts b/packages/telemetry/test/posthog-host.test.ts new file mode 100644 index 00000000..980f4a00 --- /dev/null +++ b/packages/telemetry/test/posthog-host.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + POSTHOG_HOST, + resetClient, + resolveConfig, + getClient, +} from "../src/posthog.js"; + +vi.mock("posthog-node", () => { + return { + PostHog: vi.fn().mockImplementation(function ( + this: { opts: unknown }, + _key: string, + opts: unknown + ) { + this.opts = opts; + }), + }; +}); + +describe("posthog host invariance", () => { + beforeEach(() => { + resetClient(); + (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = "phc_real"; + }); + + afterEach(() => { + delete (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST; + resetClient(); + }); + + it("POSTHOG_HOST is the hard-coded EU URL", () => { + expect(POSTHOG_HOST).toBe("https://eu.i.posthog.com"); + }); + + it.each([ + ["POSTHOG_HOST", "https://attacker.example/collect"], + ["POSTHOG_API_HOST", "https://us.i.posthog.com"], + ["POSTHOG_INGESTION_HOST", "https://attacker.example"], + ["POSTHOG_PERSONAL_API_KEY", "phx_steal_me"], + ])("ignores env var %s=%s", async (envName, value) => { + const old = process.env[envName]; + process.env[envName] = value; + try { + const client = getClient() as unknown as { opts: { host: string } } | null; + expect(client).not.toBeNull(); + expect(client!.opts.host).toBe("https://eu.i.posthog.com"); + } finally { + if (old === undefined) delete process.env[envName]; + else process.env[envName] = old; + } + }); + + it("does not construct a client when key is sentinel-disabled", () => { + (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = ""; + resetClient(); + expect(getClient()).toBeNull(); + + (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = "phc_disabled"; + resetClient(); + expect(getClient()).toBeNull(); + + }); + + it("does construct a client when a real key is configured", () => { + (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = "phc_real"; + resetClient(); + expect(getClient()).not.toBeNull(); + }); + + it("resolveConfig uses the single public project token", () => { + (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = "phc_single"; + expect(resolveConfig().key).toBe("phc_single"); + }); + + it("uses the queued batching config for the singleton client", () => { + const client = getClient() as unknown as { + opts: { flushAt: number; flushInterval: number }; + } | null; + + expect(client).not.toBeNull(); + expect(client!.opts).toEqual( + expect.objectContaining({ flushAt: 20, flushInterval: 10_000 }) + ); + }); +}); diff --git a/packages/telemetry/test/registry-listener.test.ts b/packages/telemetry/test/registry-listener.test.ts new file mode 100644 index 00000000..91b0d9cc --- /dev/null +++ b/packages/telemetry/test/registry-listener.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { Registry } from "@argent/registry"; +import { attachRegistryTelemetry } from "../src/registry-listener.js"; +import { scopeHome } from "./helpers.js"; +import * as telemetry from "../src/index.js"; + +const INVOCATION_ID_1 = "11111111-1111-4111-8111-111111111111"; +const INVOCATION_ID_2 = "22222222-2222-4222-8222-222222222222"; + +describe("attachRegistryTelemetry", () => { + scopeHome(); + + beforeEach(() => { + telemetry.init("tool_server"); + // Reset the in-memory consent cache so each test starts fresh. + telemetry.markEnabled(); + (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = ""; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("emits tool:invoke + tool:complete on a successful invocation", () => { + const trackSpy = vi.spyOn(telemetry, "track"); + const registry = new Registry(); + + const handle = attachRegistryTelemetry(registry); + handle.recordInvocation("gesture-tap", { + platform: "ios", + deviceId: "ABCD1234-EFGH-5678-IJKL-9012MNOP3456", + }); + + registry.events.emit("toolInvoked", "gesture-tap", INVOCATION_ID_1); + registry.events.emit("toolCompleted", "gesture-tap", INVOCATION_ID_1, 42.5); + + expect(trackSpy).toHaveBeenCalledTimes(2); + expect(trackSpy.mock.calls[0]![0]).toBe("tool:invoke"); + expect(trackSpy.mock.calls[0]![1]).toMatchObject({ + tool: "gesture-tap", + tool_invocation_id: INVOCATION_ID_1, + platform: "ios", + }); + expect((trackSpy.mock.calls[0]![1] as Record).device_id_hash).toMatch( + /^[0-9a-f]{12}$/ + ); + + expect(trackSpy.mock.calls[1]![0]).toBe("tool:complete"); + expect(trackSpy.mock.calls[1]![1]).toMatchObject({ + tool: "gesture-tap", + tool_invocation_id: INVOCATION_ID_1, + duration_ms: 42.5, + }); + + handle.detach(); + }); + + it("emits tool:fail with tool metadata and real duration", () => { + const trackSpy = vi.spyOn(telemetry, "track"); + const registry = new Registry(); + const handle = attachRegistryTelemetry(registry); + + handle.recordInvocation("screenshot", { platform: "android" }); + + registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_1); + + class TimeoutError extends Error {} + emitToolFailed( + registry, + "screenshot", + INVOCATION_ID_1, + new TimeoutError("ETIMEDOUT 1.2.3.4"), + 17.25 + ); + + expect(trackSpy).toHaveBeenCalledTimes(2); + expect(trackSpy.mock.calls[1]![0]).toBe("tool:fail"); + expect(trackSpy.mock.calls[1]![1]).toMatchObject({ + tool: "screenshot", + tool_invocation_id: INVOCATION_ID_1, + platform: "android", + duration_ms: 17.25, + }); + // The error MESSAGE must never reach the payload. + expect(JSON.stringify(trackSpy.mock.calls[1]![1])).not.toContain("ETIMEDOUT"); + expect(JSON.stringify(trackSpy.mock.calls[1]![1])).not.toContain("1.2.3.4"); + + handle.detach(); + }); + + it("totalToolCalls counter increments per invocation", () => { + const registry = new Registry(); + const handle = attachRegistryTelemetry(registry); + + expect(handle.getTotalToolCalls()).toBe(0); + registry.events.emit("toolInvoked", "x", INVOCATION_ID_1); + registry.events.emit("toolInvoked", "y", INVOCATION_ID_2); + expect(handle.getTotalToolCalls()).toBe(2); + handle.detach(); + }); + + it("detach unsubscribes — no further events emitted", () => { + const trackSpy = vi.spyOn(telemetry, "track"); + const registry = new Registry(); + const handle = attachRegistryTelemetry(registry); + handle.detach(); + registry.events.emit("toolInvoked", "x", INVOCATION_ID_1); + expect(trackSpy).not.toHaveBeenCalled(); + }); + + it("omits platform when no device metadata was recorded", () => { + const trackSpy = vi.spyOn(telemetry, "track"); + const registry = new Registry(); + const handle = attachRegistryTelemetry(registry); + registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_1); + expect(trackSpy.mock.calls[0]![1]).toEqual({ + tool: "screenshot", + tool_invocation_id: INVOCATION_ID_1, + }); + handle.detach(); + }); + + it("release function drops pending metadata before invocation", () => { + const trackSpy = vi.spyOn(telemetry, "track"); + const registry = new Registry(); + const handle = attachRegistryTelemetry(registry); + const release = handle.recordInvocation("screenshot", { platform: "android" }); + + release(); + registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_1); + + expect(trackSpy.mock.calls[0]![1]).toEqual({ + tool: "screenshot", + tool_invocation_id: INVOCATION_ID_1, + }); + handle.detach(); + }); + + it("keeps same-tool invocation metadata separate by invocation id", () => { + const trackSpy = vi.spyOn(telemetry, "track"); + const registry = new Registry(); + const handle = attachRegistryTelemetry(registry); + + handle.recordInvocation("screenshot", { platform: "ios" }); + handle.recordInvocation("screenshot", { platform: "android" }); + + registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_1); + registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_2); + registry.events.emit("toolCompleted", "screenshot", INVOCATION_ID_2, 20); + registry.events.emit("toolCompleted", "screenshot", INVOCATION_ID_1, 10); + + expect(trackSpy.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ + tool_invocation_id: INVOCATION_ID_1, + platform: "ios", + }), + expect.objectContaining({ + tool_invocation_id: INVOCATION_ID_2, + platform: "android", + }), + expect.objectContaining({ + tool_invocation_id: INVOCATION_ID_2, + platform: "android", + duration_ms: 20, + }), + expect.objectContaining({ + tool_invocation_id: INVOCATION_ID_1, + platform: "ios", + duration_ms: 10, + }), + ]); + + handle.detach(); + }); +}); + +function emitToolFailed( + registry: Registry, + toolId: string, + toolInvocationId: string, + error: Error, + durationMs: number +): void { + const emit = registry.events.emit.bind(registry.events) as ( + event: "toolFailed", + toolId: string, + toolInvocationId: string, + error: Error, + durationMs: number + ) => void; + emit("toolFailed", toolId, toolInvocationId, error, durationMs); +} diff --git a/packages/telemetry/test/sanitize.test.ts b/packages/telemetry/test/sanitize.test.ts new file mode 100644 index 00000000..d5448806 --- /dev/null +++ b/packages/telemetry/test/sanitize.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import { sanitize, ALLOWED } from "../src/sanitize.js"; +import { EVENT_NAMES } from "../src/events.js"; + +describe("sanitize", () => { + describe("event allowlist", () => { + it("returns empty object for an unknown event name", () => { + expect(sanitize("attack:pwned", { foo: 1 })).toEqual({}); + }); + + it("drops every unknown property key", () => { + expect( + sanitize("installation:cli_init_start", { + package_manager: "npm", + is_non_interactive: true, + home_path: "/Users/alice", // not in allowlist + ssn: "123-45-6789", + }) + ).toEqual({ package_manager: "npm", is_non_interactive: true }); + }); + }); + + describe("oneOf validator", () => { + it("accepts canonical values", () => { + expect(sanitize("installation:cli_init_start", { package_manager: "npm" })).toEqual({ + package_manager: "npm", + }); + }); + + it("drops a fork-derived path-leaking value", () => { + expect( + sanitize("installation:cli_init_start", { + package_manager: "npm (/Users/alice/.nvm/versions/node/v20/bin/npm)", + }) + ).toEqual({}); + }); + + it("drops unknown enum values", () => { + expect(sanitize("installation:cli_init_cancel", { step: "scope_typo" })).toEqual({}); + }); + + it("drops the legacy unknown tool platform value", () => { + expect(sanitize("tool:invoke", { tool: "list-devices", platform: "unknown" })).toEqual({ + tool: "list-devices", + }); + }); + + it("drops the legacy `from_tar` decision (developer-only path is off the books)", () => { + expect(sanitize("installation:global_install_decision", { decision: "from_tar" })).toEqual( + {} + ); + }); + + it("accepts the documented global_install_decision enum values", () => { + for (const decision of ["install", "cancel", "already_installed"] as const) { + expect(sanitize("installation:global_install_decision", { decision })).toEqual({ + decision, + }); + } + }); + }); + + describe("matches validator", () => { + it("accepts a tool id under 64 chars", () => { + expect(sanitize("tool:invoke", { tool: "gesture-tap", platform: "ios" })).toEqual({ + tool: "gesture-tap", + platform: "ios", + }); + }); + + it("rejects a tool id with uppercase / weird chars", () => { + expect(sanitize("tool:invoke", { tool: "Gesture-Tap!", platform: "ios" })).toEqual({ + platform: "ios", + }); + }); + + it("rejects an oversize string even when shape matches", () => { + const longButValid = "a".repeat(200); + expect(sanitize("tool:invoke", { tool: longButValid, platform: "ios" })).toEqual({ + platform: "ios", + }); + }); + + it("drops error metadata fields from tool failures", () => { + expect( + sanitize("tool:fail", { + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + error_message: "ENOENT /Users/alice/.ssh/id_rsa", + }) + ).toEqual({ + tool: "gesture-tap", + platform: "ios", + duration_ms: 1, + }); + }); + }); + + describe("number validator", () => { + it.each([ + ["NaN", Number.NaN], + ["+Infinity", Number.POSITIVE_INFINITY], + ["-Infinity", Number.NEGATIVE_INFINITY], + ["negative", -1], + ["over 2^31", 2 ** 31 + 1], + ])("drops poison number %s", (_label, value) => { + expect(sanitize("tool:complete", { tool: "x", platform: "ios", duration_ms: value })).toEqual( + { tool: "x", platform: "ios" } + ); + }); + + it("accepts a normal duration", () => { + expect(sanitize("tool:complete", { tool: "x", platform: "ios", duration_ms: 42.5 })).toEqual({ + tool: "x", + platform: "ios", + duration_ms: 42.5, + }); + }); + + it("accepts valid tool invocation ids and drops invalid ones", () => { + expect( + sanitize("tool:complete", { + tool: "x", + tool_invocation_id: "11111111-1111-4111-8111-111111111111", + platform: "ios", + duration_ms: 42.5, + }) + ).toEqual({ + tool: "x", + tool_invocation_id: "11111111-1111-4111-8111-111111111111", + platform: "ios", + duration_ms: 42.5, + }); + + expect( + sanitize("tool:complete", { + tool: "x", + tool_invocation_id: "/Users/alice/project", + platform: "ios", + duration_ms: 42.5, + }) + ).toEqual({ + tool: "x", + platform: "ios", + duration_ms: 42.5, + }); + }); + }); + + describe("package action telemetry", () => { + it("accepts the requested package-action enum set", () => { + for (const action of [ + "fresh_install", + "already_installed", + "init_triggered_update", + "no_update", + "update_skipped", + "update_failed", + "standalone_update", + "standalone_install", + "mcp_update", + ] as const) { + expect( + sanitize("installation:package_action", { + trigger: action === "mcp_update" ? "mcp_update" : "init", + action, + is_success: true, + duration_ms: 2, + }) + ).toMatchObject({ action, duration_ms: 2 }); + } + }); + }); + + describe("arrayOf validator", () => { + it("accepts a valid editors list", () => { + expect( + sanitize("installation:editors_select", { + editors: ["cursor", "claude-code"], + detected_editor_count: 2, + scope: "local", + }) + ).toEqual({ + editors: ["cursor", "claude-code"], + detected_editor_count: 2, + scope: "local", + }); + }); + + it("drops an array with a bad element (whole array discarded)", () => { + expect( + sanitize("installation:editors_select", { + editors: ["cursor", "Bad Editor"], + detected_editor_count: 2, + scope: "local", + }) + ).toEqual({ detected_editor_count: 2, scope: "local" }); + }); + + it("drops an oversized array (>16 elements)", () => { + const editors = Array.from({ length: 17 }, (_, i) => `editor-${i}`); + expect( + sanitize("installation:editors_select", { + editors, + detected_editor_count: 17, + scope: "local", + }) + ).toEqual({ detected_editor_count: 17, scope: "local" }); + }); + }); + + describe("sensitive-arg drop tests", () => { + it.each([ + ["keyboard.text", { text: "hunter2" }], + ["paste.text", { text: "secret" }], + ["open-url.url", { url: "https://internal.example/admin" }], + ["flow-add-step.args", { args: '{"text":"private"}' }], + ["flow-add-echo.message", { message: "personal data" }], + ["customRoot", { customRoot: "/Users/alice/work" }], + ["fromTar", { from: "/Users/alice/file.tgz" }], + ["bundle_id_hash", { bundle_id_hash: "0123456789ab" }], + ])("drops %s when accidentally passed to a tool event", (_label, payload) => { + const out = sanitize("tool:invoke", { tool: "x", platform: "ios", ...payload }); + expect(out).toEqual({ tool: "x", platform: "ios" }); + }); + + it("drops accidental error metadata", () => { + const out = sanitize("tool:fail", { + tool: "x", + platform: "ios", + duration_ms: 1, + error_message: "password=hunter2", + }); + expect(out).toEqual({ tool: "x", platform: "ios", duration_ms: 1 }); + }); + }); + + describe("ALLOWED ↔ EVENT_NAMES sync", () => { + it("every declared event name has a sanitizer entry", () => { + for (const name of EVENT_NAMES) { + expect(ALLOWED[name]).toBeDefined(); + } + }); + + it("every sanitizer entry is a declared event name", () => { + for (const name of Object.keys(ALLOWED)) { + expect(EVENT_NAMES).toContain(name); + } + }); + }); +}); diff --git a/packages/telemetry/tsconfig.json b/packages/telemetry/tsconfig.json new file mode 100644 index 00000000..03d52d62 --- /dev/null +++ b/packages/telemetry/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/telemetry/tsconfig.test.json b/packages/telemetry/tsconfig.test.json new file mode 100644 index 00000000..ce2080a4 --- /dev/null +++ b/packages/telemetry/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "rootDir": ".", + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*", "test/**/*", "vitest.config.ts"] +} diff --git a/packages/telemetry/vitest.config.ts b/packages/telemetry/vitest.config.ts new file mode 100644 index 00000000..758c2705 --- /dev/null +++ b/packages/telemetry/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + globals: true, + }, +}); diff --git a/packages/tool-server/package.json b/packages/tool-server/package.json index 99f4bad7..bf81c4d0 100644 --- a/packages/tool-server/package.json +++ b/packages/tool-server/package.json @@ -16,6 +16,7 @@ "@argent/native-devtools-ios": "file:../native-devtools-ios", "@argent/native-devtools-android": "file:../native-devtools-android", "@argent/registry": "file:../registry", + "@argent/telemetry": "file:../telemetry", "@argent/update-core": "file:../update-core", "@clack/prompts": "^1.1.0", "express": "^4.19.2", diff --git a/packages/tool-server/src/http.ts b/packages/tool-server/src/http.ts index 8f024664..0bb000eb 100644 --- a/packages/tool-server/src/http.ts +++ b/packages/tool-server/src/http.ts @@ -47,6 +47,29 @@ function findDependencyMissing(err: unknown): DependencyMissingError | null { return null; } +function extractDeviceArg(data: unknown): string | null { + if (!data || typeof data !== "object") return null; + const record = data as Record; + if (typeof record.udid === "string") return record.udid; + if (typeof record.device_id === "string") return record.device_id; + return null; +} + +type InvocationMeta = { platform?: "ios" | "android"; deviceId?: string }; + +function extractInvocationMeta(hasCapability: boolean, data: unknown): InvocationMeta | null { + if (!hasCapability || !data || typeof data !== "object") return null; + const record = data as Record; + const deviceArg = extractDeviceArg(record); + if (deviceArg) { + return { platform: resolveDevice(deviceArg).platform, deviceId: deviceArg }; + } + if (typeof record.avdName === "string") { + return { platform: "android" }; + } + return null; +} + // ── HTTP app ──────────────────────────────────────────────────────── export interface HttpAppOptions { @@ -63,6 +86,11 @@ export interface HttpAppOptions { * opted into network exposure (and is warned at startup). */ bindHost?: string; + /** Optional telemetry hook for per-invocation platform/device metadata. */ + recordInvocation?: ( + toolId: string, + meta: InvocationMeta + ) => () => void; } export interface HttpAppHandle { @@ -269,12 +297,7 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt // tools). Honour both so an Android serial reaching an iOS-only // device_id-tool is rejected at the gate instead of falling through // to the deeper blueprint error (which surfaces as a generic 500). - const deviceArg = - typeof parsedData?.udid === "string" - ? parsedData.udid - : typeof parsedData?.device_id === "string" - ? parsedData.device_id - : null; + const deviceArg = extractDeviceArg(parsedData); if (def.capability && deviceArg) { try { const device = resolveDevice(deviceArg); @@ -311,6 +334,15 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt if (!res.writableFinished) controller.abort(); }); + // Hashing happens in the telemetry listener, not in the HTTP layer. + let releaseInvocationMeta: (() => void) | undefined; + if (options?.recordInvocation) { + const invocationMeta = extractInvocationMeta(Boolean(def.capability), parsedData); + if (invocationMeta) { + releaseInvocationMeta = options.recordInvocation(name, invocationMeta); + } + } + try { const data = await registry.invokeTool(name, parsedData, { signal: controller.signal, @@ -362,6 +394,8 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt return; } res.status(500).json({ error: formatErrorForAgent(err) }); + } finally { + releaseInvocationMeta?.(); } } ); diff --git a/packages/tool-server/src/index.ts b/packages/tool-server/src/index.ts index 177c2660..aa41f65f 100644 --- a/packages/tool-server/src/index.ts +++ b/packages/tool-server/src/index.ts @@ -1,4 +1,11 @@ import { attachRegistryLogger } from "@argent/registry"; +import { + init as telemetryInit, + attachRegistryTelemetry, + track as telemetryTrack, + trackImmediate as telemetryTrackImmediate, + shutdown as telemetryShutdown, +} from "@argent/telemetry"; import { createHttpApp } from "./http"; import { createRegistry } from "./utils/setup-registry"; import { startSimulatorWatcher } from "./utils/simulator-watcher"; @@ -50,6 +57,7 @@ export function start(): void { process.stderr.write(`[tool-server] ${label}: ${detail}\n`); if (shuttingDown) return; // avoid re-entrant shutdown shuttingDown = true; + shutdownReason = "crash"; setTimeout(() => process.exit(1), PROCESS_TIMEOUT_MS); if (shutdown) { shutdown(1).catch(() => process.exit(1)); @@ -80,6 +88,13 @@ export function start(): void { // ── Bootstrap ───────────────────────────────────────────────────── const registry = createRegistry(); attachRegistryLogger(registry); + + // Tool events use the queued client; shutdown gets a bounded final flush. + telemetryInit("tool_server"); + const telemetryHandle = attachRegistryTelemetry(registry); + const serverStartedAt = Date.now(); + let shutdownReason: "idle" | "signal" | "crash" = "signal"; + const updateChecker = startUpdateChecker(); const { stop: stopWatcher, ready: watcherReady } = startSimulatorWatcher(registry); @@ -92,6 +107,20 @@ export function start(): void { updateChecker.dispose(); stopWatcher(); httpHandle.dispose(); + + // Emit toolserver:stop before tearing the registry down. + try { + await telemetryTrackImmediate("toolserver:stop", { + reason: shutdownReason, + uptime_ms: Date.now() - serverStartedAt, + total_tool_calls: telemetryHandle.getTotalToolCalls(), + }); + telemetryHandle.detach(); + await telemetryShutdown(1500); + } catch { + // Telemetry must never block process exit. + } + await registry.dispose(); if (server) { const forceExit = setTimeout(() => process.exit(exitCode), PROCESS_TIMEOUT_MS); @@ -103,9 +132,13 @@ export function start(): void { const httpHandle = createHttpApp(registry, { idleTimeoutMs, - onIdle: shutdown, + onIdle: () => { + shutdownReason = "idle"; + shutdown?.(); + }, onShutdown: shutdown, bindHost: HOST, + recordInvocation: telemetryHandle.recordInvocation, }); // Block advertising readiness until the first watcher poll completes — this @@ -123,6 +156,12 @@ export function start(): void { if (idleTimeoutMs > 0) { process.stderr.write(` Idle timeout: ${idleMinutes}min\n`); } + // Report start only after the HTTP listener actually binds. + try { + telemetryTrack("toolserver:start", {}); + } catch { + /* swallow */ + } }); // Surface bind failures (EADDRINUSE / EACCES on privileged ports) as a // clean exit instead of routing through uncaughtException → crashShutdown. diff --git a/packages/tool-server/src/tools/system/update-argent.ts b/packages/tool-server/src/tools/system/update-argent.ts index 0429501e..98cf04e9 100644 --- a/packages/tool-server/src/tools/system/update-argent.ts +++ b/packages/tool-server/src/tools/system/update-argent.ts @@ -49,6 +49,7 @@ export const updateArgentTool: ToolDefinition = { const child = spawn("argent", ["update", "--yes", "--version", installableVersion], { detached: true, stdio: "ignore", + env: { ...process.env, ARGENT_UPDATE_TRIGGER: "mcp_update" }, }); child.unref(); }, 2000); diff --git a/packages/tool-server/test/http-tools-meta.test.ts b/packages/tool-server/test/http-tools-meta.test.ts index 90aca8ec..1f6f846b 100644 --- a/packages/tool-server/test/http-tools-meta.test.ts +++ b/packages/tool-server/test/http-tools-meta.test.ts @@ -18,7 +18,7 @@ function stubRegistry(): Registry { getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], - tools: ["always-tool", "hinted-tool", "plain-tool"], + tools: ["always-tool", "hinted-tool", "plain-tool", "device-tool", "boot-tool"], })), getTool: vi.fn((name: string) => { if (name === "always-tool") { @@ -51,6 +51,26 @@ function stubRegistry(): Registry { execute: async () => ({}), }; } + if (name === "device-tool") { + return { + id: "device-tool", + description: "Device tool", + inputSchema: { type: "object", properties: {} }, + capability: { apple: { simulator: true }, android: { emulator: true } }, + services: () => ({}), + execute: async () => ({}), + }; + } + if (name === "boot-tool") { + return { + id: "boot-tool", + description: "Boot tool", + inputSchema: { type: "object", properties: {} }, + capability: { apple: { simulator: true }, android: { emulator: true } }, + services: () => ({}), + execute: async () => ({}), + }; + } return undefined; }), invokeTool: vi.fn(), @@ -87,4 +107,59 @@ describe("GET /tools progressive-loading metadata", () => { expect(byName.get("plain-tool")).not.toHaveProperty("alwaysLoad"); expect(byName.get("plain-tool")).not.toHaveProperty("searchHint"); }); + + it("does not pass bundleId into telemetry invocation metadata", async () => { + const release = vi.fn(); + let seenMeta: Record | undefined; + const recordInvocation = vi.fn((_toolId: string, meta: Record) => { + seenMeta = meta; + return release; + }); + handle.dispose(); + handle = createHttpApp(stubRegistry(), { recordInvocation }); + + await request(handle.app) + .post("/tools/device-tool") + .send({ + udid: "11111111-1111-1111-1111-111111111111", + bundleId: "com.example.app", + }) + .expect(200); + + expect(recordInvocation).toHaveBeenCalledWith("device-tool", { + platform: "ios", + deviceId: "11111111-1111-1111-1111-111111111111", + }); + expect(seenMeta).not.toHaveProperty("bundleId"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("does not record platform metadata for non-device tools", async () => { + const recordInvocation = vi.fn(() => vi.fn()); + handle.dispose(); + handle = createHttpApp(stubRegistry(), { recordInvocation }); + + await request(handle.app) + .post("/tools/plain-tool") + .send({ + udid: "11111111-1111-1111-1111-111111111111", + bundleId: "com.example.app", + }) + .expect(200); + + expect(recordInvocation).not.toHaveBeenCalled(); + }); + + it("records Android platform for avdName device-management calls without a device hash", async () => { + const recordInvocation = vi.fn((_toolId: string, meta: Record) => { + expect(meta).toEqual({ platform: "android" }); + return vi.fn(); + }); + handle.dispose(); + handle = createHttpApp(stubRegistry(), { recordInvocation }); + + await request(handle.app).post("/tools/boot-tool").send({ avdName: "Pixel_9" }).expect(200); + + expect(recordInvocation).toHaveBeenCalledWith("boot-tool", { platform: "android" }); + }); }); diff --git a/packages/tool-server/test/update-argent-tool.test.ts b/packages/tool-server/test/update-argent-tool.test.ts index c779ee30..f07e4eca 100644 --- a/packages/tool-server/test/update-argent-tool.test.ts +++ b/packages/tool-server/test/update-argent-tool.test.ts @@ -90,6 +90,7 @@ describe("update-argent tool", () => { expect(mockSpawn).toHaveBeenCalledWith("argent", ["update", "--yes", "--version", "99.0.0"], { detached: true, stdio: "ignore", + env: { ...process.env, ARGENT_UPDATE_TRIGGER: "mcp_update" }, }); }); diff --git a/tsconfig.json b/tsconfig.json index 76ac2f8b..a675382c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "packages/registry" }, + { "path": "packages/telemetry" }, { "path": "packages/update-core" }, { "path": "packages/native-devtools-ios" }, { "path": "packages/native-devtools-android" }, From 7535b14bab1381205c475b37644243139bde0df2 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Mon, 1 Jun 2026 06:40:50 +0200 Subject: [PATCH 2/9] chore: run format --- packages/registry/src/types.ts | 7 +------ packages/telemetry/test/index.test.ts | 4 +--- packages/telemetry/test/posthog-host.test.ts | 12 ++---------- packages/tool-server/src/http.ts | 5 +---- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts index 34fd13e9..6f2fe5fa 100644 --- a/packages/registry/src/types.ts +++ b/packages/registry/src/types.ts @@ -184,10 +184,5 @@ export type RegistryEvents = { toolRegistered: (toolId: string) => void; toolInvoked: (toolId: string, toolInvocationId: string) => void; toolCompleted: (toolId: string, toolInvocationId: string, durationMs: number) => void; - toolFailed: ( - toolId: string, - toolInvocationId: string, - error: Error, - durationMs?: number - ) => void; + toolFailed: (toolId: string, toolInvocationId: string, error: Error, durationMs?: number) => void; }; diff --git a/packages/telemetry/test/index.test.ts b/packages/telemetry/test/index.test.ts index 02448554..c418f59b 100644 --- a/packages/telemetry/test/index.test.ts +++ b/packages/telemetry/test/index.test.ts @@ -94,9 +94,7 @@ describe("telemetry public surface", () => { expect(posthogMock.instances).toHaveLength(1); expect(client.capture).toHaveBeenCalledTimes(2); expect(client.flush).toHaveBeenCalledTimes(1); - expect(client.opts).toEqual( - expect.objectContaining({ flushAt: 20, flushInterval: 10_000 }) - ); + expect(client.opts).toEqual(expect.objectContaining({ flushAt: 20, flushInterval: 10_000 })); }); it("captures events in CI and annotates payloads with is_ci", () => { diff --git a/packages/telemetry/test/posthog-host.test.ts b/packages/telemetry/test/posthog-host.test.ts index 980f4a00..1007c4ee 100644 --- a/packages/telemetry/test/posthog-host.test.ts +++ b/packages/telemetry/test/posthog-host.test.ts @@ -1,10 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - POSTHOG_HOST, - resetClient, - resolveConfig, - getClient, -} from "../src/posthog.js"; +import { POSTHOG_HOST, resetClient, resolveConfig, getClient } from "../src/posthog.js"; vi.mock("posthog-node", () => { return { @@ -59,7 +54,6 @@ describe("posthog host invariance", () => { (globalThis as Record).__ARGENT_POSTHOG_KEY_TEST = "phc_disabled"; resetClient(); expect(getClient()).toBeNull(); - }); it("does construct a client when a real key is configured", () => { @@ -79,8 +73,6 @@ describe("posthog host invariance", () => { } | null; expect(client).not.toBeNull(); - expect(client!.opts).toEqual( - expect.objectContaining({ flushAt: 20, flushInterval: 10_000 }) - ); + expect(client!.opts).toEqual(expect.objectContaining({ flushAt: 20, flushInterval: 10_000 })); }); }); diff --git a/packages/tool-server/src/http.ts b/packages/tool-server/src/http.ts index 0bb000eb..648f7a1a 100644 --- a/packages/tool-server/src/http.ts +++ b/packages/tool-server/src/http.ts @@ -87,10 +87,7 @@ export interface HttpAppOptions { */ bindHost?: string; /** Optional telemetry hook for per-invocation platform/device metadata. */ - recordInvocation?: ( - toolId: string, - meta: InvocationMeta - ) => () => void; + recordInvocation?: (toolId: string, meta: InvocationMeta) => () => void; } export interface HttpAppHandle { From e8d5058ccf42ef5beeda9e9d7f2603bd36bfea24 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Tue, 2 Jun 2026 10:20:36 +0200 Subject: [PATCH 3/9] fix: capture argent full version --- packages/argent/scripts/bundle-tools.cjs | 3 +-- packages/telemetry/src/base-props.ts | 19 +++++++++---------- packages/telemetry/test/base-props.test.ts | 8 +++++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/argent/scripts/bundle-tools.cjs b/packages/argent/scripts/bundle-tools.cjs index 22584731..114df626 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -51,11 +51,10 @@ const TELEMETRY_CLI_VERSION = (() => { return "0.0.0"; } })(); -const TELEMETRY_CLI_VERSION_MAJOR_MINOR = TELEMETRY_CLI_VERSION.split(".").slice(0, 2).join("."); const TELEMETRY_CLI_MAJOR_VERSION = TELEMETRY_CLI_VERSION.split(".")[0] ?? "0"; const TELEMETRY_DEFINE = { - ARGENT_CLI_VERSION_MAJOR_MINOR: JSON.stringify(TELEMETRY_CLI_VERSION_MAJOR_MINOR), + ARGENT_CLI_VERSION: JSON.stringify(TELEMETRY_CLI_VERSION), ARGENT_CLI_MAJOR_VERSION: JSON.stringify(TELEMETRY_CLI_MAJOR_VERSION), }; diff --git a/packages/telemetry/src/base-props.ts b/packages/telemetry/src/base-props.ts index 9732a19c..117d5d9f 100644 --- a/packages/telemetry/src/base-props.ts +++ b/packages/telemetry/src/base-props.ts @@ -1,20 +1,20 @@ import { randomUUID } from "node:crypto"; import { isCi } from "./ci-detect.js"; -// Build-time version metadata injected by esbuild; source tests fall back to "0.0". -declare const ARGENT_CLI_VERSION_MAJOR_MINOR: string | undefined; +// Build-time version metadata injected by esbuild; source tests fall back to "0.0.0". +declare const ARGENT_CLI_VERSION: string | undefined; // Process-local session id. Never persisted or reused across Node processes. let SESSION_ID: string = randomUUID(); -function readCliVersionMajorMinor(): string { +function readCliVersion(): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fromDefine = (globalThis as any).ARGENT_CLI_VERSION_MAJOR_MINOR; + const fromDefine = (globalThis as any).ARGENT_CLI_VERSION; if (typeof fromDefine === "string" && fromDefine !== "") return fromDefine; - if (typeof ARGENT_CLI_VERSION_MAJOR_MINOR === "string" && ARGENT_CLI_VERSION_MAJOR_MINOR !== "") { - return ARGENT_CLI_VERSION_MAJOR_MINOR; + if (typeof ARGENT_CLI_VERSION === "string" && ARGENT_CLI_VERSION !== "") { + return ARGENT_CLI_VERSION; } - return "0.0"; + return "0.0.0"; } function readNodeVersionMajor(): string { @@ -26,7 +26,7 @@ function readNodeVersionMajor(): string { export type Runtime = "installer" | "tool_server" | "cli" | "mcp"; export interface BaseProps { - cli_version_major_minor: string; + cli_version: string; node_version_major: string; os: NodeJS.Platform; arch: NodeJS.Architecture; @@ -37,10 +37,9 @@ export interface BaseProps { $process_person_profile: false; } -// Keep version metadata coarse to avoid high-resolution fingerprints. export function getBaseProps(runtime: Runtime): BaseProps { return { - cli_version_major_minor: readCliVersionMajorMinor(), + cli_version: readCliVersion(), node_version_major: readNodeVersionMajor(), os: process.platform, arch: process.arch, diff --git a/packages/telemetry/test/base-props.test.ts b/packages/telemetry/test/base-props.test.ts index 0d2a48ef..e47a10c3 100644 --- a/packages/telemetry/test/base-props.test.ts +++ b/packages/telemetry/test/base-props.test.ts @@ -15,7 +15,7 @@ describe("base-props", () => { "$process_person_profile", "$session_id", "arch", - "cli_version_major_minor", + "cli_version", "is_ci", "is_tty", "node_version_major", @@ -24,6 +24,7 @@ describe("base-props", () => { ].sort() ); expect(props.$process_person_profile).toBe(false); + expect(props.cli_version).toBe("0.0.0"); expect(typeof props.is_tty).toBe("boolean"); expect(props.is_ci).toBe(false); expect(typeof props.node_version_major).toBe("string"); @@ -49,9 +50,10 @@ describe("base-props", () => { } }); - it("still does NOT carry full cli_version / full node_version", () => { + it("does not carry legacy cli_version_major_minor / full node_version", () => { const props = getBaseProps("tool_server") as unknown as Record; - expect(props).not.toHaveProperty("cli_version"); + expect(props).toHaveProperty("cli_version"); + expect(props).not.toHaveProperty("cli_version_major_minor"); expect(props).not.toHaveProperty("node_version"); }); From 9f27ef791e3d0a1813387901181306e5cef5556c Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Tue, 2 Jun 2026 13:07:03 +0200 Subject: [PATCH 4/9] fix: drain telemetry reliably and stop tracking device ids --- README.md | 12 +- packages/argent-cli/src/telemetry.ts | 100 +- packages/argent-installer/src/init.ts | 981 +++++++++--------- .../src/telemetry-finalize.ts | 15 + packages/argent-installer/src/uninstall.ts | 348 ++++--- packages/argent-installer/src/update.ts | 388 +++---- .../test/telemetry-finalize.test.ts | 39 + .../argent-installer/test/uninstall.test.ts | 76 +- packages/argent/scripts/bundle-tools.cjs | 2 - packages/registry/src/registry.ts | 2 +- packages/registry/src/types.ts | 2 + packages/telemetry/src/base-props.ts | 2 +- packages/telemetry/src/events.ts | 9 - packages/telemetry/src/hash.ts | 20 - packages/telemetry/src/index.ts | 67 +- packages/telemetry/src/registry-listener.ts | 35 +- packages/telemetry/src/sanitize.ts | 7 - packages/telemetry/test/index.test.ts | 24 +- .../telemetry/test/registry-listener.test.ts | 19 +- packages/telemetry/test/sanitize.test.ts | 1 + packages/tool-server/src/http.ts | 14 +- packages/tool-server/src/index.ts | 19 +- .../tool-server/test/http-tools-meta.test.ts | 18 +- .../test/startup-telemetry.test.ts | 96 ++ 24 files changed, 1214 insertions(+), 1082 deletions(-) create mode 100644 packages/argent-installer/src/telemetry-finalize.ts create mode 100644 packages/argent-installer/test/telemetry-finalize.test.ts delete mode 100644 packages/telemetry/src/hash.ts create mode 100644 packages/tool-server/test/startup-telemetry.test.ts diff --git a/README.md b/README.md index affd86db..dd040f72 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,18 @@ argent init ## Privacy -Argent does not collect or transmit any user data. -No telemetry, no analytics, no crash reporting. +Argent includes anonymous, opt-out usage telemetry for installation and tool +health signals. It does not collect raw tool arguments, file paths, app data, +error messages, or device identifiers. - Argent integrates with your agent locally over MCP stdio. - Its internal tools are not reachable from outside your machine. -- The only outbound network call we make is the version check against our public npm package, which sends no user data and fails gracefully if blocked. +- Telemetry can be disabled with `argent telemetry disable`, `DO_NOT_TRACK=1`, + or `ARGENT_TELEMETRY=0`. +- Telemetry events are sent to PostHog EU with a public write-only project key. + Dashboards and exports must treat client-side event properties as untrusted. +- The version check against our public npm package sends no user data and fails + gracefully if blocked. ## License diff --git a/packages/argent-cli/src/telemetry.ts b/packages/argent-cli/src/telemetry.ts index 949ee61f..dda1d491 100644 --- a/packages/argent-cli/src/telemetry.ts +++ b/packages/argent-cli/src/telemetry.ts @@ -6,137 +6,81 @@ import { markEnabled, shutdown as telemetryShutdown, status as telemetryStatus, - trackImmediate, } from "@argent/telemetry"; // Consent-management subcommands for anonymous telemetry. export async function telemetry(args: string[]): Promise { const sub = args[0]; - const startedAt = performance.now(); telemetryInit("cli"); - const trackCommandComplete = async ( - subcommand: "status" | "enable" | "disable" | "help" | "unknown" - ): Promise => { - await trackImmediate("telemetry:command_complete", { - subcommand, - duration_ms: performance.now() - startedAt, - }); - }; - switch (sub) { case undefined: + printUsage(); + await telemetryShutdown(); + return; case "status": printStatus(); - await trackCommandComplete("status"); await telemetryShutdown(); return; case "enable": - await cmdEnable(trackCommandComplete); + await cmdEnable(); return; case "disable": - await cmdDisable(trackCommandComplete); + await cmdDisable(); return; case "--help": case "-h": - case "help": - printHelp(); - await trackCommandComplete("help"); + printUsage(); await telemetryShutdown(); return; default: - console.error(pc.red(`Unknown telemetry subcommand: ${sub}\n`)); - printHelp(); - await trackCommandComplete("unknown"); + console.error(`Unknown subcommand: telemetry ${sub}`); await telemetryShutdown(); process.exit(1); } } -function printHelp(): void { - console.log(` -${pc.bold("argent telemetry")} — manage anonymous opt-out telemetry - -Usage: argent telemetry - -Subcommands: - ${pc.cyan("status")} Show current state, anon id prefix, host, and key status - ${pc.cyan("enable")} Persist consent and resume sending telemetry - ${pc.cyan("disable")} Emit a final telemetry:opt_out event, drop in-flight queue, - and persist consent=false - -Env-var overrides (any one wins, evaluated on every track() call): - DO_NOT_TRACK=1 - ARGENT_TELEMETRY=0 - CI environments are captured with is_ci=true unless explicitly disabled - -Debug audit: ARGENT_TELEMETRY_DEBUG=1 dumps every sanitized payload to -stderr and \`~/.argent/telemetry-debug.log\`. +function printUsage(): void { + console.log(`Usage: + argent telemetry status Show telemetry state and anonymous id + argent telemetry enable Enable telemetry + argent telemetry disable Disable telemetry `); } function printStatus(): void { const s = telemetryStatus(); - const lines: string[] = []; - lines.push(`${pc.bold("State:")} ${s.enabled ? pc.green("enabled") : pc.yellow("disabled")}`); - - const sourceLabel = - s.source.source === "env_do_not_track" - ? "env DO_NOT_TRACK" - : s.source.source === "env_argent_telemetry" - ? "env ARGENT_TELEMETRY" - : s.source.source === "config_file" - ? "~/.argent/config.json" - : "default"; - lines.push( - `${pc.bold("Source:")} ${sourceLabel}${s.source.detail ? ` (${s.source.detail})` : ""}` - ); - const anonLabel = s.anonIdPrefix - ? `${s.anonIdPrefix}…` + ? `${s.anonIdPrefix}...` : s.hasAnonIdOnDisk - ? pc.dim("present (not shown — telemetry disabled)") - : pc.dim("not created"); - lines.push(`${pc.bold("Anon ID:")} ${anonLabel}`); - lines.push(`${pc.bold("Host:")} ${s.host}`); - lines.push( - `${pc.bold("Key:")} ${s.isKeyConfigured ? pc.green("configured") : pc.dim("sentinel-disabled (this build will never send)")}` - ); - lines.push(""); - lines.push(pc.dim("Disable: argent telemetry disable\n" + "Debug: ARGENT_TELEMETRY_DEBUG=1")); + ? "present" + : "not created"; - console.log(""); - for (const l of lines) console.log(" " + l); - console.log(""); + console.log("telemetry:"); + console.log(` state: ${s.enabled ? "enabled" : "disabled"}`); + console.log(` anon id: ${anonLabel}`); } -async function cmdEnable( - trackCommandComplete: (subcommand: "enable") => Promise -): Promise { +async function cmdEnable(): Promise { const wasEnabled = telemetryIsEnabled(); markEnabled(); if (wasEnabled) { console.log(pc.dim("Telemetry was already enabled.")); } else { - console.log(pc.green("✓ Telemetry enabled.")); + console.log(pc.green("Telemetry enabled.")); } - await trackCommandComplete("enable"); await telemetryShutdown(); } -async function cmdDisable( - trackCommandComplete: (subcommand: "disable") => Promise -): Promise { +async function cmdDisable(): Promise { const wasEnabled = telemetryIsEnabled(); if (!wasEnabled) { console.log(pc.dim("Telemetry was already disabled.")); - await trackCommandComplete("disable"); await telemetryShutdown(); return; } - await trackCommandComplete("disable"); await markDisabled(); - console.log(pc.green("✓ Telemetry disabled. In-flight events dropped, opt_out recorded.")); + console.log(pc.red("Telemetry disabled.")); await telemetryShutdown(); } diff --git a/packages/argent-installer/src/init.ts b/packages/argent-installer/src/init.ts index be96dccd..9be20613 100644 --- a/packages/argent-installer/src/init.ts +++ b/packages/argent-installer/src/init.ts @@ -3,12 +3,7 @@ import pc from "picocolors"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { spawn } from "node:child_process"; -import { - init as telemetryInit, - trackImmediate, - isEnabled as telemetryIsEnabled, - shutdown as telemetryShutdown, -} from "@argent/telemetry"; +import { init as telemetryInit, track, isEnabled as telemetryIsEnabled } from "@argent/telemetry"; import { detectAdapters, ALL_ADAPTERS, @@ -35,6 +30,7 @@ import { } from "./utils.js"; import { refreshArgentSkills, formatSkillRefreshSummary } from "./skills.js"; import { PACKAGE_NAME } from "./constants.js"; +import { finalizeTelemetry } from "./telemetry-finalize.js"; function runShellCommand(cmd: ShellCommand): Promise { return new Promise((resolve, reject) => { @@ -72,20 +68,24 @@ export async function init(args: string[]): Promise { telemetryInit("installer"); printTelemetryNotice(); - await trackImmediate("installation:cli_init_start", { + track("installation:cli_init_start", { package_manager: detectPackageManager(), is_non_interactive: nonInteractive, }); let editorsConfiguredCount = 0; let initSucceeded = false; + let telemetryFinalized = false; const finalizeInitTelemetry = async (): Promise => { - await trackImmediate("installation:cli_init_complete", { - duration_ms: performance.now() - initStartTime, - is_success: initSucceeded, - editors_configured_count: editorsConfiguredCount, + if (telemetryFinalized) return; + telemetryFinalized = true; + await finalizeTelemetry(() => { + track("installation:cli_init_complete", { + duration_ms: performance.now() - initStartTime, + is_success: initSucceeded, + editors_configured_count: editorsConfiguredCount, + }); }); - await telemetryShutdown(); }; const trackPackageAction = async ( @@ -99,7 +99,7 @@ export async function init(args: string[]): Promise { startedAt: number, isSuccess: boolean ): Promise => { - await trackImmediate("installation:package_action", { + track("installation:package_action", { trigger: "init", action, is_success: isSuccess, @@ -107,581 +107,586 @@ export async function init(args: string[]): Promise { }); }; - printBanner(); + try { + printBanner(); - p.intro(pc.bgCyan(pc.black(" argent init "))); + p.intro(pc.bgCyan(pc.black(" argent init "))); - let version = getInstalledVersion() ?? "unknown"; - p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); + let version = getInstalledVersion() ?? "unknown"; + p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); - // ── Step 0: Install / Update Check ────────────────────────────────────────── + // ── Step 0: Install / Update Check ────────────────────────────────────────── - const globallyInstalled = isGloballyInstalled(); + const globallyInstalled = isGloballyInstalled(); - if (!globallyInstalled) { - if (!nonInteractive) { - const installChoice = await p.select({ - message: "Argent is not installed globally. Would you like to install it?", - options: [ - { - value: "global" as const, - label: "Install globally", - hint: "Makes the argent command available everywhere", - }, - { - value: "cancel" as const, - label: "Cancel installation", - }, - ], - }); - - if (p.isCancel(installChoice) || installChoice === "cancel") { - await trackImmediate("installation:global_install_decision", { decision: "cancel" }); - await trackImmediate("installation:cli_init_cancel", { step: "global_install" }); - await finalizeInitTelemetry(); - p.cancel("Installation cancelled."); - process.exit(0); - } - } - - await trackImmediate("installation:global_install_decision", { decision: "install" }); - - const pm = detectPackageManager(); - const installTarget = fromTar ?? PACKAGE_NAME; - const cmd = globalInstallCommand(pm, installTarget); - const cmdStr = formatShellCommand(cmd); - const spinner = p.spinner(); - spinner.start(`Installing ${PACKAGE_NAME} globally...`); - const packageActionStartedAt = performance.now(); - try { - await runShellCommand(cmd); - spinner.stop(pc.green("Installed globally.")); - version = getInstalledVersion() ?? version; - await trackPackageAction("fresh_install", packageActionStartedAt, true); - } catch (err) { - spinner.stop(pc.red("Installation failed.")); - p.log.error(`${err}`); - p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); - await trackPackageAction("fresh_install", packageActionStartedAt, false); - await finalizeInitTelemetry(); - process.exit(1); - } - } else if (fromTar) { - // Developer-only reinstall path; it is not a product install decision. - const pm = detectPackageManager(); - const cmd = globalInstallCommand(pm, fromTar); - const cmdStr = formatShellCommand(cmd); - const spinner = p.spinner(); - spinner.start(`Installing from ${fromTar}...`); - try { - await runShellCommand(cmd); - spinner.stop(pc.green("Installed from tarball.")); - version = getInstalledVersion() ?? version; - } catch (err) { - spinner.stop(pc.red("Installation failed.")); - p.log.error(`${err}`); - p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); - await finalizeInitTelemetry(); - process.exit(1); - } - } else { - const packageActionStartedAt = performance.now(); - await trackImmediate("installation:global_install_decision", { decision: "already_installed" }); - await trackPackageAction("already_installed", packageActionStartedAt, true); - let latest: string | null = null; - const spinner = p.spinner(); - spinner.start("Checking for updates..."); - try { - latest = getLatestVersion(); - } catch { - await trackPackageAction("update_skipped", packageActionStartedAt, false); - // Registry unreachable - silently skip - } - spinner.stop(pc.dim("Version check complete.")); - - if (latest && isNewerVersion(latest, version)) { - const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; - const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; - if (nonInteractive) { - await trackPackageAction("update_skipped", packageActionStartedAt, true); - } else { - const updateChoice = await p.select({ - message: `Update available: ${pc.yellow(`v${version}`)} → ${pc.green(`v${latest}`)}`, + if (!globallyInstalled) { + if (!nonInteractive) { + const installChoice = await p.select({ + message: "Argent is not installed globally. Would you like to install it?", options: [ { - value: "update" as const, - label: `Update to v${latest} (recommended)`, + value: "global" as const, + label: "Install globally", + hint: "Makes the argent command available everywhere", }, { - value: "skip" as const, - label: "Skip", - hint: "Continue with current version", + value: "cancel" as const, + label: "Cancel installation", }, ], }); - await trackImmediate("installation:update_decision", { - from_major: fromMajor, - to_major: toMajor, - decision: p.isCancel(updateChoice) ? "skip" : (updateChoice as "update" | "skip"), - }); + if (p.isCancel(installChoice) || installChoice === "cancel") { + track("installation:global_install_decision", { decision: "cancel" }); + track("installation:cli_init_cancel", { step: "global_install" }); + await finalizeInitTelemetry(); + p.cancel("Installation cancelled."); + process.exit(0); + } + } - if (p.isCancel(updateChoice) || updateChoice === "skip") { + track("installation:global_install_decision", { decision: "install" }); + + const pm = detectPackageManager(); + const installTarget = fromTar ?? PACKAGE_NAME; + const cmd = globalInstallCommand(pm, installTarget); + const cmdStr = formatShellCommand(cmd); + const spinner = p.spinner(); + spinner.start(`Installing ${PACKAGE_NAME} globally...`); + const packageActionStartedAt = performance.now(); + try { + await runShellCommand(cmd); + spinner.stop(pc.green("Installed globally.")); + version = getInstalledVersion() ?? version; + await trackPackageAction("fresh_install", packageActionStartedAt, true); + } catch (err) { + spinner.stop(pc.red("Installation failed.")); + p.log.error(`${err}`); + p.log.info(`Install Argent manually with: ${pc.cyan(cmdStr)}`); + await trackPackageAction("fresh_install", packageActionStartedAt, false); + await finalizeInitTelemetry(); + process.exit(1); + } + } else if (fromTar) { + // Developer-only reinstall path; it is not a product install decision. + const pm = detectPackageManager(); + const cmd = globalInstallCommand(pm, fromTar); + const cmdStr = formatShellCommand(cmd); + const spinner = p.spinner(); + spinner.start(`Installing from ${fromTar}...`); + try { + await runShellCommand(cmd); + spinner.stop(pc.green("Installed from tarball.")); + version = getInstalledVersion() ?? version; + } catch (err) { + spinner.stop(pc.red("Installation failed.")); + p.log.error(`${err}`); + p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); + await finalizeInitTelemetry(); + process.exit(1); + } + } else { + const packageActionStartedAt = performance.now(); + track("installation:global_install_decision", { decision: "already_installed" }); + await trackPackageAction("already_installed", packageActionStartedAt, true); + let latest: string | null = null; + const spinner = p.spinner(); + spinner.start("Checking for updates..."); + try { + latest = getLatestVersion(); + } catch { + await trackPackageAction("update_skipped", packageActionStartedAt, false); + // Registry unreachable - silently skip + } + spinner.stop(pc.dim("Version check complete.")); + + if (latest && isNewerVersion(latest, version)) { + const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; + const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; + if (nonInteractive) { await trackPackageAction("update_skipped", packageActionStartedAt, true); - } else if (updateChoice === "update") { - const pm = detectPackageManager(); - const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${latest}`); - const cmdStr = formatShellCommand(cmd); - const updateSpinner = p.spinner(); - updateSpinner.start(`Updating to v${latest}...`); - const updateStartedAt = performance.now(); - try { - await runShellCommand(cmd); - updateSpinner.stop(pc.green(`Updated to v${latest}.`)); - version = getInstalledVersion() ?? version; - await trackPackageAction("init_triggered_update", updateStartedAt, true); - - // The user just bumped to a newer argent. Re-sync and prune - // argent skills in every scope that already tracks them — this - // is the only point in init where we can surface orphans - // (skills removed from a previous argent version) before - // Step 2's single-scope `skills add`. - const skillSummary = formatSkillRefreshSummary( - refreshArgentSkills(resolveProjectRoot(process.cwd())) - ); - if (skillSummary) { - p.note(skillSummary, "Skills Updated"); + } else { + const updateChoice = await p.select({ + message: `Update available: ${pc.yellow(`v${version}`)} → ${pc.green(`v${latest}`)}`, + options: [ + { + value: "update" as const, + label: `Update to v${latest} (recommended)`, + }, + { + value: "skip" as const, + label: "Skip", + hint: "Continue with current version", + }, + ], + }); + + track("installation:update_decision", { + from_major: fromMajor, + to_major: toMajor, + decision: p.isCancel(updateChoice) ? "skip" : (updateChoice as "update" | "skip"), + }); + + if (p.isCancel(updateChoice) || updateChoice === "skip") { + await trackPackageAction("update_skipped", packageActionStartedAt, true); + } else if (updateChoice === "update") { + const pm = detectPackageManager(); + const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${latest}`); + const cmdStr = formatShellCommand(cmd); + const updateSpinner = p.spinner(); + updateSpinner.start(`Updating to v${latest}...`); + const updateStartedAt = performance.now(); + try { + await runShellCommand(cmd); + updateSpinner.stop(pc.green(`Updated to v${latest}.`)); + version = getInstalledVersion() ?? version; + await trackPackageAction("init_triggered_update", updateStartedAt, true); + + // The user just bumped to a newer argent. Re-sync and prune + // argent skills in every scope that already tracks them — this + // is the only point in init where we can surface orphans + // (skills removed from a previous argent version) before + // Step 2's single-scope `skills add`. + const skillSummary = formatSkillRefreshSummary( + refreshArgentSkills(resolveProjectRoot(process.cwd())) + ); + if (skillSummary) { + p.note(skillSummary, "Skills Updated"); + } + } catch (err) { + updateSpinner.stop(pc.red("Update failed.")); + p.log.error(`${err}`); + p.log.info(`You can update manually later: ${pc.cyan(cmdStr)}`); + await trackPackageAction("update_failed", updateStartedAt, false); } - } catch (err) { - updateSpinner.stop(pc.red("Update failed.")); - p.log.error(`${err}`); - p.log.info(`You can update manually later: ${pc.cyan(cmdStr)}`); - await trackPackageAction("update_failed", updateStartedAt, false); } } + } else if (latest) { + const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; + const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; + track("installation:update_decision", { + from_major: fromMajor, + to_major: toMajor, + decision: "no_update", + }); + await trackPackageAction("no_update", packageActionStartedAt, true); } - } else if (latest) { - const fromMajor = Number.parseInt(version.split(".")[0] ?? "0", 10) || 0; - const toMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10) || 0; - await trackImmediate("installation:update_decision", { - from_major: fromMajor, - to_major: toMajor, - decision: "no_update", - }); - await trackPackageAction("no_update", packageActionStartedAt, true); } - } - // ── Step 1: MCP Server Configuration ──────────────────────────────────────── + // ── Step 1: MCP Server Configuration ──────────────────────────────────────── - p.log.step(pc.bold("Step 1: MCP Server Configuration")); + p.log.step(pc.bold("Step 1: MCP Server Configuration")); - const detected = detectAdapters(); - const detectedNames = detected.map((a) => a.name); + const detected = detectAdapters(); + const detectedNames = detected.map((a) => a.name); - let selectedAdapters: McpConfigAdapter[]; + let selectedAdapters: McpConfigAdapter[]; - if (nonInteractive) { - selectedAdapters = detected.length > 0 ? detected : ALL_ADAPTERS; - } else { - const choices = ALL_ADAPTERS.map((a) => { - const parts: string[] = []; - if (detectedNames.includes(a.name)) parts.push("detected"); - const hasProject = a.projectPath(process.cwd()) != null; - const hasGlobal = a.globalPath() != null; - if (!hasProject && hasGlobal) { - parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s global config`))); - } else if (hasProject && !hasGlobal) { - parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s project config`))); - } - return { - value: a, - label: a.name, - hint: parts.length > 0 ? parts.join(", ") : undefined, - }; - }); + if (nonInteractive) { + selectedAdapters = detected.length > 0 ? detected : ALL_ADAPTERS; + } else { + const choices = ALL_ADAPTERS.map((a) => { + const parts: string[] = []; + if (detectedNames.includes(a.name)) parts.push("detected"); + const hasProject = a.projectPath(process.cwd()) != null; + const hasGlobal = a.globalPath() != null; + if (!hasProject && hasGlobal) { + parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s global config`))); + } else if (hasProject && !hasGlobal) { + parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s project config`))); + } + return { + value: a, + label: a.name, + hint: parts.length > 0 ? parts.join(", ") : undefined, + }; + }); - p.log.message(pc.dim(" Use arrow keys to move, space to toggle, enter to confirm.")); + p.log.message(pc.dim(" Use arrow keys to move, space to toggle, enter to confirm.")); - const selected = await p.multiselect({ - message: "Which editors should Argent be configured for?", - options: choices, - initialValues: detected, - required: true, - }); + const selected = await p.multiselect({ + message: "Which editors should Argent be configured for?", + options: choices, + initialValues: detected, + required: true, + }); - if (p.isCancel(selected)) { - await trackImmediate("installation:cli_init_cancel", { step: "editors" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); + if (p.isCancel(selected)) { + track("installation:cli_init_cancel", { step: "editors" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); + } + + selectedAdapters = selected as McpConfigAdapter[]; } - selectedAdapters = selected as McpConfigAdapter[]; - } + editorsConfiguredCount = selectedAdapters.length; + p.log.info(`Editors: ${selectedAdapters.map((a) => pc.cyan(a.name)).join(", ")}`); - editorsConfiguredCount = selectedAdapters.length; - p.log.info(`Editors: ${selectedAdapters.map((a) => pc.cyan(a.name)).join(", ")}`); - - // Ask scope: global, local, or custom path - let scope: "local" | "global" | "custom"; - let customRoot: string | undefined; - - if (nonInteractive) { - scope = "local"; - } else { - p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); - - const scopeChoice = await p.select({ - message: "Install MCP server globally or locally?", - options: [ - { - value: "local" as const, - label: "Local", - hint: "Current project only - .cursor/mcp.json, .mcp.json, ...", - }, - { - value: "global" as const, - label: "Global", - hint: "Available across all projects - ~/.*/mcp.json", - }, - { - value: "custom" as const, - label: "Specify installation directory", - hint: "Specify a directory to use as the project root", - }, - ], - }); + // Ask scope: global, local, or custom path + let scope: "local" | "global" | "custom"; + let customRoot: string | undefined; - if (p.isCancel(scopeChoice)) { - await trackImmediate("installation:cli_init_cancel", { step: "scope" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); - } + if (nonInteractive) { + scope = "local"; + } else { + p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); - scope = scopeChoice as "local" | "global" | "custom"; - - if (scope === "custom") { - const customPathInput = await p.text({ - message: "Enter the path to use as the project root for MCP config:", - placeholder: process.cwd(), - validate(value) { - if (!value?.trim()) return "Path cannot be empty."; - const resolved = resolve(value.trim()); - if (!existsSync(resolved)) - return `Path does not exist: ${resolved}. Please verify and enter a valid path.`; - }, + const scopeChoice = await p.select({ + message: "Install MCP server globally or locally?", + options: [ + { + value: "local" as const, + label: "Local", + hint: "Current project only - .cursor/mcp.json, .mcp.json, ...", + }, + { + value: "global" as const, + label: "Global", + hint: "Available across all projects - ~/.*/mcp.json", + }, + { + value: "custom" as const, + label: "Specify installation directory", + hint: "Specify a directory to use as the project root", + }, + ], }); - if (p.isCancel(customPathInput)) { - await trackImmediate("installation:cli_init_cancel", { step: "scope" }); + if (p.isCancel(scopeChoice)) { + track("installation:cli_init_cancel", { step: "scope" }); await finalizeInitTelemetry(); p.cancel("Initialization cancelled."); process.exit(0); } - customRoot = resolve((customPathInput as string).trim()); + scope = scopeChoice as "local" | "global" | "custom"; + + if (scope === "custom") { + const customPathInput = await p.text({ + message: "Enter the path to use as the project root for MCP config:", + placeholder: process.cwd(), + validate(value) { + if (!value?.trim()) return "Path cannot be empty."; + const resolved = resolve(value.trim()); + if (!existsSync(resolved)) + return `Path does not exist: ${resolved}. Please verify and enter a valid path.`; + }, + }); + + if (p.isCancel(customPathInput)) { + track("installation:cli_init_cancel", { step: "scope" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); + } + + customRoot = resolve((customPathInput as string).trim()); + } } - } - const projectRoot = resolveProjectRoot(process.cwd()); - const effectiveRoot = scope === "custom" ? customRoot! : projectRoot; - const normalizedScope: "local" | "global" = scope === "global" ? "global" : "local"; + const projectRoot = resolveProjectRoot(process.cwd()); + const effectiveRoot = scope === "custom" ? customRoot! : projectRoot; + const normalizedScope: "local" | "global" = scope === "global" ? "global" : "local"; - await trackImmediate("installation:editors_select", { - editors: selectedAdapters.map((a) => sanitizeEditorName(a.name)), - detected_editor_count: detected.length, - scope, - }); + track("installation:editors_select", { + editors: selectedAdapters.map((a) => sanitizeEditorName(a.name)), + detected_editor_count: detected.length, + scope, + }); - const mcpEntry = getMcpEntry(); - const mcpResults: string[] = []; + const mcpEntry = getMcpEntry(); + const mcpResults: string[] = []; - for (const adapter of selectedAdapters) { - const configPath = - scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); + for (const adapter of selectedAdapters) { + const configPath = + scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); - if (!configPath) { - if (scope === "global" && adapter.projectPath(projectRoot)) { - const fallback = adapter.projectPath(projectRoot)!; - try { - adapter.write(fallback, mcpEntry); - mcpResults.push( - `${pc.green("+")} ${adapter.name} ${pc.dim(`(local fallback: ${fallback})`)}` - ); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); - } - } else if (scope !== "global" && adapter.globalPath()) { - const fallback = adapter.globalPath()!; - try { - adapter.write(fallback, mcpEntry); + if (!configPath) { + if (scope === "global" && adapter.projectPath(projectRoot)) { + const fallback = adapter.projectPath(projectRoot)!; + try { + adapter.write(fallback, mcpEntry); + mcpResults.push( + `${pc.green("+")} ${adapter.name} ${pc.dim(`(local fallback: ${fallback})`)}` + ); + } catch (err) { + mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } + } else if (scope !== "global" && adapter.globalPath()) { + const fallback = adapter.globalPath()!; + try { + adapter.write(fallback, mcpEntry); + mcpResults.push( + `${pc.green("+")} ${adapter.name} ${pc.dim(`(global fallback: ${fallback})`)}` + ); + } catch (err) { + mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } + } else { mcpResults.push( - `${pc.green("+")} ${adapter.name} ${pc.dim(`(global fallback: ${fallback})`)}` + `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config path for this scope)")}` ); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); } - } else { - mcpResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config path for this scope)")}` - ); + continue; } - continue; - } - try { - adapter.write(configPath, mcpEntry); - mcpResults.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); - } catch (err) { - mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + try { + adapter.write(configPath, mcpEntry); + mcpResults.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); + } catch (err) { + mcpResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } } - } - p.note(mcpResults.join("\n"), "MCP Configuration"); + p.note(mcpResults.join("\n"), "MCP Configuration"); - // ── Tool Auto-Approval ──────────────────────────────────────────────────── + // ── Tool Auto-Approval ──────────────────────────────────────────────────── - const adaptersWithAllowlist = selectedAdapters.filter((a) => a.addAllowlist); - const adaptersWithoutAllowlist = selectedAdapters.filter((a) => !a.addAllowlist); + const adaptersWithAllowlist = selectedAdapters.filter((a) => a.addAllowlist); + const adaptersWithoutAllowlist = selectedAdapters.filter((a) => !a.addAllowlist); - let allowlistEnabled = false; + let allowlistEnabled = false; - if (adaptersWithAllowlist.length > 0) { - p.log.info( - `By default, editors ask for confirmation before running each MCP tool.\n` + - ` Adding Argent to the auto-approve allowlist lets tools run without\n` + - ` repeated prompts. This is ${pc.cyan("recommended")} for a smooth experience.` - ); + if (adaptersWithAllowlist.length > 0) { + p.log.info( + `By default, editors ask for confirmation before running each MCP tool.\n` + + ` Adding Argent to the auto-approve allowlist lets tools run without\n` + + ` repeated prompts. This is ${pc.cyan("recommended")} for a smooth experience.` + ); - if (nonInteractive) { - allowlistEnabled = true; - } else { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + if (nonInteractive) { + allowlistEnabled = true; + } else { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const allowlistChoice = await p.confirm({ - message: "Add Argent tools to editor auto-approve lists? - recommended", - initialValue: true, - }); + const allowlistChoice = await p.confirm({ + message: "Add Argent tools to editor auto-approve lists? - recommended", + initialValue: true, + }); - if (p.isCancel(allowlistChoice)) { - await trackImmediate("installation:cli_init_cancel", { step: "allowlist" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); - } + if (p.isCancel(allowlistChoice)) { + track("installation:cli_init_cancel", { step: "allowlist" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); + } - allowlistEnabled = allowlistChoice as boolean; + allowlistEnabled = allowlistChoice as boolean; + } } - } - await trackImmediate("installation:allowlist_decision", { - is_enabled: allowlistEnabled, - applicable_adapter_count: adaptersWithAllowlist.length, - }); + track("installation:allowlist_decision", { + is_enabled: allowlistEnabled, + applicable_adapter_count: adaptersWithAllowlist.length, + }); - if (allowlistEnabled) { - const allowlistResults: string[] = []; + if (allowlistEnabled) { + const allowlistResults: string[] = []; - for (const adapter of adaptersWithAllowlist) { - const hasPath = - normalizedScope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); - if (!hasPath) { + for (const adapter of adaptersWithAllowlist) { + const hasPath = + normalizedScope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot); + if (!hasPath) { + allowlistResults.push( + `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config for this scope)")}` + ); + continue; + } + try { + adapter.addAllowlist!(effectiveRoot, normalizedScope); + allowlistResults.push(`${pc.green("+")} ${adapter.name}`); + } catch (err) { + allowlistResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); + } + } + + for (const adapter of adaptersWithoutAllowlist) { allowlistResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config for this scope)")}` + `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no auto-approve API - configure manually)")}` ); - continue; - } - try { - adapter.addAllowlist!(effectiveRoot, normalizedScope); - allowlistResults.push(`${pc.green("+")} ${adapter.name}`); - } catch (err) { - allowlistResults.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`); } - } - for (const adapter of adaptersWithoutAllowlist) { - allowlistResults.push( - `${pc.yellow("-")} ${adapter.name} ${pc.dim("(no auto-approve API - configure manually)")}` - ); + p.note(allowlistResults.join("\n"), "Tool Auto-Approval"); } - p.note(allowlistResults.join("\n"), "Tool Auto-Approval"); - } - - // ── Step 2: Skills Installation ───────────────────────────────────────────── - - p.log.step(pc.bold("Step 2: Skills Installation")); - p.log.warn(pc.yellow("Skills installation is required for Argent to function properly.")); + // ── Step 2: Skills Installation ───────────────────────────────────────────── - type SkillsMethod = "default" | "interactive" | "manual"; - let skillsMethod: SkillsMethod; + p.log.step(pc.bold("Step 2: Skills Installation")); + p.log.warn(pc.yellow("Skills installation is required for Argent to function properly.")); - const online = await isOnline(); - const offlineWithCache = !online && isSkillsCliAvailable(); - const skillsCliReady = online || offlineWithCache; - - if (!skillsCliReady) { - p.log.warn( - pc.yellow("You appear to be offline. ") + - "Automatic skills installation requires a network connection." - ); - } + type SkillsMethod = "default" | "interactive" | "manual"; + let skillsMethod: SkillsMethod; - if (!skillsCliReady) { - skillsMethod = "manual"; - } else if (nonInteractive) { - skillsMethod = "default"; - } else { - p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); - - const choice = await p.select({ - message: "How would you like to install skills?", - options: [ - { - value: "default" as const, - label: "Automatic", - hint: "Installs all skills automatically with npx skills", - }, - { - value: "interactive" as const, - label: "Interactive", - hint: "Full npx skills TUI - choose skills, agents, and method", - }, - { - value: "manual" as const, - label: "Manual", - hint: "Print instructions for manual installation", - }, - ], - }); + const online = await isOnline(); + const offlineWithCache = !online && isSkillsCliAvailable(); + const skillsCliReady = online || offlineWithCache; - if (p.isCancel(choice)) { - await trackImmediate("installation:cli_init_cancel", { step: "skills" }); - await finalizeInitTelemetry(); - p.cancel("Initialization cancelled."); - process.exit(0); + if (!skillsCliReady) { + p.log.warn( + pc.yellow("You appear to be offline. ") + + "Automatic skills installation requires a network connection." + ); } - skillsMethod = choice as SkillsMethod; - } - - await trackImmediate("installation:skill_install", { - method: skillsMethod, - is_online: online, - has_offline_cache: offlineWithCache, - }); - - // Prefer the GitHub-pinned source. SKILLS_DIR as a fallback. - const useGitHubSource = online && !fromTar && version !== "unknown"; - const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; + if (!skillsCliReady) { + skillsMethod = "manual"; + } else if (nonInteractive) { + skillsMethod = "default"; + } else { + p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); - if (skillsMethod === "manual") { - p.note( - [ - `Skills are bundled at:`, - ` ${pc.cyan(SKILLS_DIR)}`, - ``, - `To install manually, copy them to your editor's skills directory:`, - ``, - ` ${pc.dim("# Claude Code")}`, - ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.claude/skills/" : `${scope === "custom" ? customRoot! : "."}/.claude/skills/`}`, - ``, - ` ${pc.dim("# Cursor")}`, - ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.cursor/skills/" : `${scope === "custom" ? customRoot! : "."}/.cursor/skills/`}`, - ``, - ` ${pc.dim("# Or use npx skills directly:")}`, - ` npx skills add ${skillsSource}`, - ].join("\n"), - "Manual Skills Installation" - ); - } else { - const skillsArgs = ["skills", "add", skillsSource]; + const choice = await p.select({ + message: "How would you like to install skills?", + options: [ + { + value: "default" as const, + label: "Automatic", + hint: "Installs all skills automatically with npx skills", + }, + { + value: "interactive" as const, + label: "Interactive", + hint: "Full npx skills TUI - choose skills, agents, and method", + }, + { + value: "manual" as const, + label: "Manual", + hint: "Print instructions for manual installation", + }, + ], + }); - if (scope === "global") { - skillsArgs.push("-g"); - } + if (p.isCancel(choice)) { + track("installation:cli_init_cancel", { step: "skills" }); + await finalizeInitTelemetry(); + p.cancel("Initialization cancelled."); + process.exit(0); + } - if (skillsMethod === "default") { - skillsArgs.push("--skill", "*", "-y"); + skillsMethod = choice as SkillsMethod; } - const npxArgs = offlineWithCache ? ["--no-install", ...skillsArgs] : skillsArgs; + track("installation:skill_install", { + method: skillsMethod, + is_online: online, + has_offline_cache: offlineWithCache, + }); - p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); + // Prefer the GitHub-pinned source. SKILLS_DIR as a fallback. + const useGitHubSource = online && !fromTar && version !== "unknown"; + const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; + + if (skillsMethod === "manual") { + p.note( + [ + `Skills are bundled at:`, + ` ${pc.cyan(SKILLS_DIR)}`, + ``, + `To install manually, copy them to your editor's skills directory:`, + ``, + ` ${pc.dim("# Claude Code")}`, + ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.claude/skills/" : `${scope === "custom" ? customRoot! : "."}/.claude/skills/`}`, + ``, + ` ${pc.dim("# Cursor")}`, + ` cp -r ${SKILLS_DIR}/* ${scope === "global" ? "~/.cursor/skills/" : `${scope === "custom" ? customRoot! : "."}/.cursor/skills/`}`, + ``, + ` ${pc.dim("# Or use npx skills directly:")}`, + ` npx skills add ${skillsSource}`, + ].join("\n"), + "Manual Skills Installation" + ); + } else { + const skillsArgs = ["skills", "add", skillsSource]; - const spinner = p.spinner(); - if (skillsMethod === "default") { - spinner.start("Installing skills..."); - } + if (scope === "global") { + skillsArgs.push("-g"); + } - try { - const skillsCwd = scope === "custom" ? customRoot : undefined; - await runNpxSkills(npxArgs, skillsMethod === "interactive", skillsCwd); if (skillsMethod === "default") { - spinner.stop("Skills installed."); + skillsArgs.push("--skill", "*", "-y"); } - await trackImmediate("installation:skill_install_result", { is_success: true }); - } catch (err) { + + const npxArgs = offlineWithCache ? ["--no-install", ...skillsArgs] : skillsArgs; + + p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); + + const spinner = p.spinner(); if (skillsMethod === "default") { - spinner.stop(pc.red("Skills installation failed.")); + spinner.start("Installing skills..."); + } + + try { + const skillsCwd = scope === "custom" ? customRoot : undefined; + await runNpxSkills(npxArgs, skillsMethod === "interactive", skillsCwd); + if (skillsMethod === "default") { + spinner.stop("Skills installed."); + } + track("installation:skill_install_result", { is_success: true }); + } catch (err) { + if (skillsMethod === "default") { + spinner.stop(pc.red("Skills installation failed.")); + } + p.log.error(`Failed to run npx skills: ${err}`); + p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); + track("installation:skill_install_result", { + is_success: false, + }); } - p.log.error(`Failed to run npx skills: ${err}`); - p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); - await trackImmediate("installation:skill_install_result", { - is_success: false, - }); } - } - // ── Step 3: Rules and Agents ──────────────────────────────────────────────── + // ── Step 3: Rules and Agents ──────────────────────────────────────────────── - p.log.step(pc.bold("Step 3: Rules & Agents")); + p.log.step(pc.bold("Step 3: Rules & Agents")); - const copyResults = copyRulesAndAgents( - selectedAdapters, - effectiveRoot, - normalizedScope, - RULES_DIR, - AGENTS_DIR - ); + const copyResults = copyRulesAndAgents( + selectedAdapters, + effectiveRoot, + normalizedScope, + RULES_DIR, + AGENTS_DIR + ); - if (copyResults.length > 0) { - p.note(copyResults.join("\n"), "Rules & Agents"); - } else { - p.log.info(pc.dim("No rules or agents to copy for selected editors.")); - } + if (copyResults.length > 0) { + p.note(copyResults.join("\n"), "Rules & Agents"); + } else { + p.log.info(pc.dim("No rules or agents to copy for selected editors.")); + } - await trackImmediate("installation:rules_agents_copy", { copied_count: copyResults.length }); + track("installation:rules_agents_copy", { copied_count: copyResults.length }); - // ── Summary ───────────────────────────────────────────────────────────────── + // ── Summary ───────────────────────────────────────────────────────────────── - const summaryLines = [ - `${pc.green("MCP server")} configured for ${selectedAdapters.map((a) => a.name).join(", ")} (${scope})`, - `${pc.green("Auto-approve")} ${allowlistEnabled ? "enabled" : "skipped"}`, - `${pc.green("Skills")} ${skillsMethod === "manual" ? "instructions printed" : "installed"}`, - `${pc.green("Rules & agents")} ${copyResults.length > 0 ? "copied" : "n/a"}`, - ]; + const summaryLines = [ + `${pc.green("MCP server")} configured for ${selectedAdapters.map((a) => a.name).join(", ")} (${scope})`, + `${pc.green("Auto-approve")} ${allowlistEnabled ? "enabled" : "skipped"}`, + `${pc.green("Skills")} ${skillsMethod === "manual" ? "instructions printed" : "installed"}`, + `${pc.green("Rules & agents")} ${copyResults.length > 0 ? "copied" : "n/a"}`, + ]; - p.note(summaryLines.join("\n"), "Summary"); + p.note(summaryLines.join("\n"), "Summary"); - p.note( - [ - pc.bold(pc.green("Argent is ready!")), - "", - `${pc.bold("Get started")} by asking your assistant:`, - "", - ` ${pc.bold(pc.cyan(`"What can Argent do?"`))}`, - "", - pc.dim("It will walk you through all capabilities available."), - ].join("\n"), - pc.bgGreen(pc.black(" Get Started ")) - ); - p.outro("Done."); + p.note( + [ + pc.bold(pc.green("Argent is ready!")), + "", + `${pc.bold("Get started")} by asking your assistant:`, + "", + ` ${pc.bold(pc.cyan(`"What can Argent do?"`))}`, + "", + pc.dim("It will walk you through all capabilities available."), + ].join("\n"), + pc.bgGreen(pc.black(" Get Started ")) + ); + p.outro("Done."); - initSucceeded = true; - await finalizeInitTelemetry(); + initSucceeded = true; + await finalizeInitTelemetry(); + } catch (err) { + await finalizeInitTelemetry(); + throw err; + } } // Print the notice only when this run may send telemetry. diff --git a/packages/argent-installer/src/telemetry-finalize.ts b/packages/argent-installer/src/telemetry-finalize.ts new file mode 100644 index 00000000..c8aaa15b --- /dev/null +++ b/packages/argent-installer/src/telemetry-finalize.ts @@ -0,0 +1,15 @@ +import { shutdown as telemetryShutdown } from "@argent/telemetry"; + +export async function finalizeTelemetry(captureFinalEvent: () => void): Promise { + try { + captureFinalEvent(); + } catch { + /* telemetry must not change command behavior */ + } + + try { + await telemetryShutdown(); + } catch { + /* telemetry must not change command behavior */ + } +} diff --git a/packages/argent-installer/src/uninstall.ts b/packages/argent-installer/src/uninstall.ts index 768fdf42..f787d981 100644 --- a/packages/argent-installer/src/uninstall.ts +++ b/packages/argent-installer/src/uninstall.ts @@ -3,12 +3,7 @@ import pc from "picocolors"; import * as fs from "node:fs"; import * as path from "node:path"; import { execFileSync } from "node:child_process"; -import { - init as telemetryInit, - trackImmediate, - forget as telemetryForget, - shutdown as telemetryShutdown, -} from "@argent/telemetry"; +import { init as telemetryInit, track, forget as telemetryForget } from "@argent/telemetry"; import { ALL_ADAPTERS, getManagedContentTargets, @@ -20,12 +15,14 @@ import { detectPackageManager, formatShellCommand, globalUninstallCommand, + isGloballyInstalled, resolveProjectRoot, RULES_DIR, SKILLS_DIR, } from "./utils.js"; import { PACKAGE_NAME } from "./constants.js"; import { killToolServer } from "@argent/tools-client"; +import { finalizeTelemetry } from "./telemetry-finalize.js"; export interface BundledContentRemoval { removedPaths: string[]; @@ -292,219 +289,232 @@ export async function uninstall(args: string[]): Promise { const nonInteractive = args.includes("--yes") || args.includes("-y"); telemetryInit("installer"); - await trackImmediate("installation:cli_uninstall_start", {}); + track("installation:cli_uninstall_start", {}); + let telemetryFinalized = false; const finalizeUninstallTelemetry = async ( hasPrunedContent: boolean, hasUninstalledPackage: boolean ): Promise => { - await trackImmediate("installation:cli_uninstall_complete", { - has_pruned_content: hasPrunedContent, - has_uninstalled_package: hasUninstalledPackage, + if (telemetryFinalized) return; + telemetryFinalized = true; + await finalizeTelemetry(() => { + track("installation:cli_uninstall_complete", { + has_pruned_content: hasPrunedContent, + has_uninstalled_package: hasUninstalledPackage, + }); }); - await telemetryShutdown(); }; - p.intro(pc.bgRed(pc.white(" argent uninstall "))); + let shouldPrune = nonInteractive; + let hasUninstalledPackage = false; + + try { + p.intro(pc.bgRed(pc.white(" argent uninstall "))); - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + if (!nonInteractive) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const proceed = await p.confirm({ - message: "Remove argent configuration from this workspace?", - initialValue: true, - }); + const proceed = await p.confirm({ + message: "Remove argent configuration from this workspace?", + initialValue: true, + }); - if (p.isCancel(proceed) || !proceed) { - await finalizeUninstallTelemetry(false, false); - p.cancel("Uninstall cancelled."); - process.exit(0); + if (p.isCancel(proceed) || !proceed) { + await finalizeUninstallTelemetry(false, false); + p.cancel("Uninstall cancelled."); + process.exit(0); + } } - } - const projectRoot = resolveProjectRoot(process.cwd()); - const results: string[] = []; + const projectRoot = resolveProjectRoot(process.cwd()); + const results: string[] = []; - // ── Remove MCP entries ────────────────────────────────────────────────────── + // ── Remove MCP entries ────────────────────────────────────────────────────── - p.log.step(pc.bold("Removing MCP server entries...")); + p.log.step(pc.bold("Removing MCP server entries...")); - for (const adapter of ALL_ADAPTERS) { - for (const pathFn of [() => adapter.projectPath(projectRoot), () => adapter.globalPath()]) { - const configPath = pathFn(); - if (!configPath) continue; - try { - const removed = adapter.remove(configPath); - if (removed) { - results.push(`${pc.green("+")} Removed from ${adapter.name} ${pc.dim(configPath)}`); + for (const adapter of ALL_ADAPTERS) { + for (const pathFn of [() => adapter.projectPath(projectRoot), () => adapter.globalPath()]) { + const configPath = pathFn(); + if (!configPath) continue; + try { + const removed = adapter.remove(configPath); + if (removed) { + results.push(`${pc.green("+")} Removed from ${adapter.name} ${pc.dim(configPath)}`); + } + } catch { + // non-fatal } - } catch { - // non-fatal } } - } - // ── Remove allowlists ────────────────────────────────────────────────────── + // ── Remove allowlists ────────────────────────────────────────────────────── - for (const adapter of ALL_ADAPTERS) { - if (!adapter.removeAllowlist) continue; - for (const s of ["local", "global"] as const) { - try { - adapter.removeAllowlist(projectRoot, s); - results.push(`${pc.green("+")} Removed ${adapter.name} allowlist ${pc.dim(`(${s})`)}`); - } catch { - // non-fatal + for (const adapter of ALL_ADAPTERS) { + if (!adapter.removeAllowlist) continue; + for (const s of ["local", "global"] as const) { + try { + adapter.removeAllowlist(projectRoot, s); + results.push(`${pc.green("+")} Removed ${adapter.name} allowlist ${pc.dim(`(${s})`)}`); + } catch { + // non-fatal + } } } - } - - if (results.length > 0) { - p.note(results.join("\n"), "MCP Entries Removed"); - } else { - p.log.info(pc.dim("No MCP entries found to remove.")); - } - // ── Prune skills / rules / agents ─────────────────────────────────────────── + if (results.length > 0) { + p.note(results.join("\n"), "MCP Entries Removed"); + } else { + p.log.info(pc.dim("No MCP entries found to remove.")); + } - let shouldPrune = nonInteractive; + // ── Prune skills / rules / agents ─────────────────────────────────────────── - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + if (!nonInteractive) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const pruneChoice = await p.confirm({ - message: "Also remove Argent-owned skills, rules, and agents?", - initialValue: true, - }); + const pruneChoice = await p.confirm({ + message: "Also remove Argent-owned skills, rules, and agents?", + initialValue: true, + }); - if (!p.isCancel(pruneChoice)) { - shouldPrune = pruneChoice as boolean; + if (!p.isCancel(pruneChoice)) { + shouldPrune = pruneChoice as boolean; + } } - } - if (shouldPrune) { - const pruneResults: string[] = []; - const localTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "local"); - const globalTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "global"); - - const bundledSkillNames = getBundledSkillNames(SKILLS_DIR); - pruneResults.push( - ...cleanupBundledSkills(bundledSkillNames, [ - ...localTargets.skillTargets, - ...globalTargets.skillTargets, - ]) - ); - - for (const { targetPath, label } of [ - ...localTargets.skillsLockTargets, - ...globalTargets.skillsLockTargets, - ]) { - try { - const { removedSkills, removedFile } = cleanupSkillsLockFile(targetPath, bundledSkillNames); - if (removedSkills.length === 0 && !removedFile) continue; - - const itemsLabel = removedSkills.length === 1 ? "skill" : "skills"; - const fileLabel = removedFile ? " and removed the now-empty lockfile" : ""; - pruneResults.push( - `${pc.green("+")} Removed ${removedSkills.length} Argent ${itemsLabel} from ${label}${fileLabel}` - ); - } catch (err) { - pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); + if (shouldPrune) { + const pruneResults: string[] = []; + const localTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "local"); + const globalTargets = getManagedContentTargets(ALL_ADAPTERS, projectRoot, "global"); + + const bundledSkillNames = getBundledSkillNames(SKILLS_DIR); + pruneResults.push( + ...cleanupBundledSkills(bundledSkillNames, [ + ...localTargets.skillTargets, + ...globalTargets.skillTargets, + ]) + ); + + for (const { targetPath, label } of [ + ...localTargets.skillsLockTargets, + ...globalTargets.skillsLockTargets, + ]) { + try { + const { removedSkills, removedFile } = cleanupSkillsLockFile( + targetPath, + bundledSkillNames + ); + if (removedSkills.length === 0 && !removedFile) continue; + + const itemsLabel = removedSkills.length === 1 ? "skill" : "skills"; + const fileLabel = removedFile ? " and removed the now-empty lockfile" : ""; + pruneResults.push( + `${pc.green("+")} Removed ${removedSkills.length} Argent ${itemsLabel} from ${label}${fileLabel}` + ); + } catch (err) { + pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); + } } - } - const bundledTargets: Array<{ - sourceDir: string; - targets: ManagedContentTarget[]; - contentLabel: string; - }> = [ - { - sourceDir: AGENTS_DIR, - targets: [...localTargets.agentTargets, ...globalTargets.agentTargets], - contentLabel: "agent", - }, - { - sourceDir: RULES_DIR, - targets: [...localTargets.ruleTargets, ...globalTargets.ruleTargets], - contentLabel: "rule", - }, - ]; - - for (const { sourceDir, targets, contentLabel } of bundledTargets) { - try { - pruneResults.push(...cleanupBundledTargets(sourceDir, targets, contentLabel)); - } catch { - // non-fatal + const bundledTargets: Array<{ + sourceDir: string; + targets: ManagedContentTarget[]; + contentLabel: string; + }> = [ + { + sourceDir: AGENTS_DIR, + targets: [...localTargets.agentTargets, ...globalTargets.agentTargets], + contentLabel: "agent", + }, + { + sourceDir: RULES_DIR, + targets: [...localTargets.ruleTargets, ...globalTargets.ruleTargets], + contentLabel: "rule", + }, + ]; + + for (const { sourceDir, targets, contentLabel } of bundledTargets) { + try { + pruneResults.push(...cleanupBundledTargets(sourceDir, targets, contentLabel)); + } catch { + // non-fatal + } } - } - // Codex: remove argent rules from developer_instructions in config.toml - for (const { targetPath, label } of [ - ...localTargets.codexConfigTargets, - ...globalTargets.codexConfigTargets, - ]) { - try { - if (removeCodexRules(targetPath)) { - pruneResults.push(`${pc.green("+")} Removed argent rules from ${label}`); + // Codex: remove argent rules from developer_instructions in config.toml + for (const { targetPath, label } of [ + ...localTargets.codexConfigTargets, + ...globalTargets.codexConfigTargets, + ]) { + try { + if (removeCodexRules(targetPath)) { + pruneResults.push(`${pc.green("+")} Removed argent rules from ${label}`); + } + } catch (err) { + pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); } - } catch (err) { - pruneResults.push(`${pc.red("x")} Could not clean ${label}: ${err}`); } - } - if (pruneResults.length > 0) { - p.note(pruneResults.join("\n"), "Pruned Argent Content"); + if (pruneResults.length > 0) { + p.note(pruneResults.join("\n"), "Pruned Argent Content"); + } else { + p.log.info(pc.dim("No Argent-owned skills, rules, or agents found to remove.")); + } } else { - p.log.info(pc.dim("No Argent-owned skills, rules, or agents found to remove.")); + p.log.info(pc.dim("Kept Argent-owned skills, rules, and agents.")); } - } else { - p.log.info(pc.dim("Kept Argent-owned skills, rules, and agents.")); - } - // ── Uninstall the global package ──────────────────────────────────────────── + // ── Uninstall the global package ──────────────────────────────────────────── - let shouldUninstallPackage = nonInteractive; + const globallyInstalled = isGloballyInstalled(); + let shouldUninstallPackage = nonInteractive && globallyInstalled; - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + if (!globallyInstalled) { + p.log.info(pc.dim(`${PACKAGE_NAME} is not installed globally; skipping package uninstall.`)); + } else if (!nonInteractive) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - const uninstallPkg = await p.confirm({ - message: `Uninstall the global ${PACKAGE_NAME} package?`, - initialValue: false, - }); + const uninstallPkg = await p.confirm({ + message: `Uninstall the global ${PACKAGE_NAME} package?`, + initialValue: false, + }); - if (!p.isCancel(uninstallPkg)) { - shouldUninstallPackage = uninstallPkg as boolean; + if (!p.isCancel(uninstallPkg)) { + shouldUninstallPackage = uninstallPkg as boolean; + } } - } - let hasUninstalledPackage = false; - if (shouldUninstallPackage) { - const pm = detectPackageManager(); - const cmd = globalUninstallCommand(pm, PACKAGE_NAME); - p.log.info(`Running: ${pc.dim(formatShellCommand(cmd))}`); + if (shouldUninstallPackage) { + const pm = detectPackageManager(); + const cmd = globalUninstallCommand(pm, PACKAGE_NAME); + p.log.info(`Running: ${pc.dim(formatShellCommand(cmd))}`); - await killToolServer(); + await killToolServer(); - try { - execFileSync(cmd.bin, cmd.args, { stdio: "inherit" }); - p.log.success("Package uninstalled."); - hasUninstalledPackage = true; - } catch (err) { - p.log.error(`Uninstall failed: ${err}`); + try { + execFileSync(cmd.bin, cmd.args, { stdio: "inherit" }); + p.log.success("Package uninstalled."); + hasUninstalledPackage = true; + } catch (err) { + p.log.error(`Uninstall failed: ${err}`); + } } - } - await trackImmediate("installation:cli_uninstall_complete", { - has_pruned_content: shouldPrune, - has_uninstalled_package: hasUninstalledPackage, - }); + await finalizeUninstallTelemetry(shouldPrune, hasUninstalledPackage); - try { - await telemetryForget({ disableConsent: false }); - } catch { - /* swallow — uninstall must succeed even if forget fails */ - } - await telemetryShutdown(); + if (hasUninstalledPackage) { + try { + await telemetryForget({ disableConsent: false }); + } catch { + /* swallow — uninstall must succeed even if forget fails */ + } + } - p.outro(pc.green("argent has been removed.")); + p.outro(pc.green("argent has been removed.")); + } catch (err) { + await finalizeUninstallTelemetry(shouldPrune, hasUninstalledPackage); + throw err; + } } diff --git a/packages/argent-installer/src/update.ts b/packages/argent-installer/src/update.ts index a9f7d66c..dfb6467b 100644 --- a/packages/argent-installer/src/update.ts +++ b/packages/argent-installer/src/update.ts @@ -2,6 +2,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { execFileSync } from "node:child_process"; import semver from "semver"; +import { init as telemetryInit, track } from "@argent/telemetry"; import { ALL_ADAPTERS, findConfiguredAdapterScopes, @@ -9,11 +10,6 @@ import { copyRulesAndAgents, type McpConfigAdapter, } from "./mcp-configs.js"; -import { - init as telemetryInit, - trackImmediate, - shutdown as telemetryShutdown, -} from "@argent/telemetry"; import { getGloballyInstalledVersion, isGloballyInstalled, @@ -29,6 +25,7 @@ import { refreshArgentSkills, formatSkillRefreshSummary } from "./skills.js"; import { PACKAGE_NAME } from "./constants.js"; import { resolveInstallableUpdateTarget } from "./update-target.js"; import { killToolServer } from "@argent/tools-client"; +import { finalizeTelemetry } from "./telemetry-finalize.js"; function getRequestedVersion(args: string[]): string | null { for (let i = 0; i < args.length; i += 1) { @@ -64,14 +61,15 @@ export async function update(args: string[]): Promise { const trigger = getUpdateTriggerFromEnv(); telemetryInit("installer"); const updateStartTime = performance.now(); - await trackImmediate("installation:cli_update_start", {}); + track("installation:cli_update_start", {}); + let telemetryFinalized = false; const trackPackageAction = async ( action: UpdatePackageAction | "no_update" | "update_skipped" | "update_failed", startedAt: number, isSuccess: boolean ): Promise => { - await trackImmediate("installation:package_action", { + track("installation:package_action", { trigger, action, is_success: isSuccess, @@ -80,227 +78,239 @@ export async function update(args: string[]): Promise { }; const failUpdateTelemetry = async (): Promise => { - await trackImmediate("installation:cli_update_fail", { - duration_ms: performance.now() - updateStartTime, + if (telemetryFinalized) return; + telemetryFinalized = true; + await finalizeTelemetry(() => { + track("installation:cli_update_fail", { + duration_ms: performance.now() - updateStartTime, + }); }); - await telemetryShutdown(); }; - p.intro(pc.bgCyan(pc.black(" argent update "))); - - // When invoked via `npx @swmansion/argent update`, the running package is - // the npx cache and will always be at the latest published version. Reading the - // version from PACKAGE_ROOT would falsely report "already on the latest" - // both when no global install exists AND when the global install is - // outdated. getGloballyInstalledVersion() resolves the *real* global - // binary's package.json, so the compare reflects what the user has - // installed rather than what npx just downloaded. - const globallyInstalled = isGloballyInstalled(); - const installed = globallyInstalled ? getGloballyInstalledVersion() : null; - - if (globallyInstalled && !installed) { - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); - p.log.error("Could not determine installed version."); - process.exit(1); - } - - const spinner = p.spinner(); - spinner.start("Checking for updates..."); + const completeUpdateTelemetry = async (): Promise => { + if (telemetryFinalized) return; + telemetryFinalized = true; + await finalizeTelemetry(() => { + track("installation:cli_update_complete", { + duration_ms: performance.now() - updateStartTime, + }); + }); + }; - const pm = detectPackageManager(); - let latest: string | null = null; - let target: string | null = null; - let minReleaseAgeMs = 0; + try { + p.intro(pc.bgCyan(pc.black(" argent update "))); - if (requestedVersion !== null) { - if (!semver.valid(requestedVersion) || semver.prerelease(requestedVersion)) { - spinner.stop(pc.red("Invalid update target.")); - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); - p.log.error(`Requested version is not a stable semver: ${requestedVersion}`); - process.exit(1); - } - target = requestedVersion; - } else { - let resolved; - try { - resolved = await resolveInstallableUpdateTarget(pm, installed); - } catch (err) { - spinner.stop(pc.red("Could not reach registry.")); - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); - p.log.error(`Failed to check registry: ${err}`); - process.exit(1); - } + // When invoked via `npx @swmansion/argent update`, the running package is + // the npx cache and will always be at the latest published version. Reading the + // version from PACKAGE_ROOT would falsely report "already on the latest" + // both when no global install exists AND when the global install is + // outdated. getGloballyInstalledVersion() resolves the *real* global + // binary's package.json, so the compare reflects what the user has + // installed rather than what npx just downloaded. + const globallyInstalled = isGloballyInstalled(); + const installed = globallyInstalled ? getGloballyInstalledVersion() : null; - if (resolved === null) { - spinner.stop(pc.red("Could not reach registry.")); + if (globallyInstalled && !installed) { await trackPackageAction("update_failed", updateStartTime, false); await failUpdateTelemetry(); - p.log.error("Failed to determine the latest Argent release from the registry."); + p.log.error("Could not determine installed version."); process.exit(1); } - latest = resolved.latestVersion; - target = resolved.targetVersion; - minReleaseAgeMs = resolved.minReleaseAgeMs; - } + const spinner = p.spinner(); + spinner.start("Checking for updates..."); + + const pm = detectPackageManager(); + let latest: string | null = null; + let target: string | null = null; + let minReleaseAgeMs = 0; + + if (requestedVersion !== null) { + if (!semver.valid(requestedVersion) || semver.prerelease(requestedVersion)) { + spinner.stop(pc.red("Invalid update target.")); + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); + p.log.error(`Requested version is not a stable semver: ${requestedVersion}`); + process.exit(1); + } + target = requestedVersion; + } else { + let resolved; + try { + resolved = await resolveInstallableUpdateTarget(pm, installed); + } catch (err) { + spinner.stop(pc.red("Could not reach registry.")); + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); + p.log.error(`Failed to check registry: ${err}`); + process.exit(1); + } - spinner.stop("Version check complete."); + if (resolved === null) { + spinner.stop(pc.red("Could not reach registry.")); + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); + p.log.error("Failed to determine the latest Argent release from the registry."); + process.exit(1); + } - if (installed) { - p.log.info(`Installed: ${pc.cyan(`v${installed}`)}`); - } else { - p.log.warn(`${PACKAGE_NAME} is not installed globally.`); - } - if (latest) { - p.log.info(`Latest: ${pc.cyan(`v${latest}`)}`); - } - if (target) { - const label = latest && latest !== target ? "Target: " : "Version: "; - const suffix = latest && latest !== target ? pc.dim(" (newest installable)") : ""; - p.log.info(`${label}${pc.cyan(`v${target}`)}${suffix}`); - } + latest = resolved.latestVersion; + target = resolved.targetVersion; + minReleaseAgeMs = resolved.minReleaseAgeMs; + } - const needsInstall = target !== null && (!installed || isNewerVersion(target, installed)); - const latestIsNewer = latest !== null && (!installed || isNewerVersion(latest, installed)); + spinner.stop("Version check complete."); - if (needsInstall && target !== null) { if (installed) { - p.log.warn(`Update available: ${pc.yellow(`v${installed}`)} -> ${pc.green(`v${target}`)}`); + p.log.info(`Installed: ${pc.cyan(`v${installed}`)}`); + } else { + p.log.warn(`${PACKAGE_NAME} is not installed globally.`); + } + if (latest) { + p.log.info(`Latest: ${pc.cyan(`v${latest}`)}`); } + if (target) { + const label = latest && latest !== target ? "Target: " : "Version: "; + const suffix = latest && latest !== target ? pc.dim(" (newest installable)") : ""; + p.log.info(`${label}${pc.cyan(`v${target}`)}${suffix}`); + } + + const needsInstall = target !== null && (!installed || isNewerVersion(target, installed)); + const latestIsNewer = latest !== null && (!installed || isNewerVersion(latest, installed)); - const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${target}`); - const cmdStr = formatShellCommand(cmd); + if (needsInstall && target !== null) { + if (installed) { + p.log.warn(`Update available: ${pc.yellow(`v${installed}`)} -> ${pc.green(`v${target}`)}`); + } - if (!nonInteractive) { - p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); + const cmd = globalInstallCommand(pm, `${PACKAGE_NAME}@${target}`); + const cmdStr = formatShellCommand(cmd); - const proceed = await p.confirm({ - message: installed - ? `Update to v${target}?` - : `Install ${PACKAGE_NAME}@${target} globally?`, - initialValue: true, - }); + if (!nonInteractive) { + p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm.")); - if (p.isCancel(proceed) || !proceed) { - await trackPackageAction("update_skipped", updateStartTime, true); - await trackImmediate("installation:cli_update_complete", { - duration_ms: performance.now() - updateStartTime, + const proceed = await p.confirm({ + message: installed + ? `Update to v${target}?` + : `Install ${PACKAGE_NAME}@${target} globally?`, + initialValue: true, }); - await telemetryShutdown(); - p.cancel(installed ? "Update cancelled." : "Install cancelled."); - process.exit(0); + + if (p.isCancel(proceed) || !proceed) { + await trackPackageAction("update_skipped", updateStartTime, true); + await completeUpdateTelemetry(); + p.cancel(installed ? "Update cancelled." : "Install cancelled."); + process.exit(0); + } } - } - p.log.info(`Running: ${pc.dim(cmdStr)}`); + p.log.info(`Running: ${pc.dim(cmdStr)}`); - try { - await killToolServer(); - } catch (err) { - await trackPackageAction("update_failed", updateStartTime, false); - await failUpdateTelemetry(); - p.log.error(`Could not stop the running tool server: ${err}`); - process.exit(1); - } + try { + await killToolServer(); + } catch (err) { + await trackPackageAction("update_failed", updateStartTime, false); + await failUpdateTelemetry(); + p.log.error(`Could not stop the running tool server: ${err}`); + process.exit(1); + } - const packageAction = resolveUpdatePackageAction(trigger, installed); - const packageActionStartedAt = performance.now(); - try { - execFileSync(cmd.bin, cmd.args, { - stdio: "inherit", - env: { ...process.env, ARGENT_SKIP_POSTINSTALL: "1" }, - }); - } catch (err) { - await trackPackageAction(packageAction, packageActionStartedAt, false); - await failUpdateTelemetry(); - p.log.error(`${installed ? "Update" : "Install"} failed: ${err}`); - process.exit(1); - } - await trackPackageAction(packageAction, packageActionStartedAt, true); - } else { - await trackPackageAction("no_update", updateStartTime, true); - if (latest && target === null && latestIsNewer && minReleaseAgeMs > 0) { - p.log.warn( - `Latest version ${pc.cyan(`v${latest}`)} is still held by your minimum-release-age policy.` - ); - p.log.info("No installable update is available yet."); - } else if (latest && target && latest !== target) { - p.log.success("Already on the latest installable version."); + const packageAction = resolveUpdatePackageAction(trigger, installed); + const packageActionStartedAt = performance.now(); + try { + execFileSync(cmd.bin, cmd.args, { + stdio: "inherit", + env: { ...process.env, ARGENT_SKIP_POSTINSTALL: "1" }, + }); + } catch (err) { + await trackPackageAction(packageAction, packageActionStartedAt, false); + await failUpdateTelemetry(); + p.log.error(`${installed ? "Update" : "Install"} failed: ${err}`); + process.exit(1); + } + await trackPackageAction(packageAction, packageActionStartedAt, true); } else { - p.log.success("Already on the latest version."); + await trackPackageAction("no_update", updateStartTime, true); + if (latest && target === null && latestIsNewer && minReleaseAgeMs > 0) { + p.log.warn( + `Latest version ${pc.cyan(`v${latest}`)} is still held by your minimum-release-age policy.` + ); + p.log.info("No installable update is available yet."); + } else if (latest && target && latest !== target) { + p.log.success("Already on the latest installable version."); + } else { + p.log.success("Already on the latest version."); + } } - } - // Refresh configuration - spinner.start("Refreshing workspace configuration..."); - - const projectRoot = resolveProjectRoot(process.cwd()); - const mcpEntry = getMcpEntry(); - const results: string[] = []; - - // Only refresh adapter scopes that already contain an argent entry. A - // present editor dir (`.gemini`, `.cursor`, ...) is not consent — issue - // #195 — so we look for the argent MCP server key in the actual config. - const configuredScopes = findConfiguredAdapterScopes(ALL_ADAPTERS, projectRoot); - const adaptersByScope = new Map<"local" | "global", Set>([ - ["local", new Set()], - ["global", new Set()], - ]); - - for (const { adapter, scope, configPath } of configuredScopes) { - try { - adapter.write(configPath, mcpEntry); - results.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); - } catch { - // Skip paths that can't be written. - } - adaptersByScope.get(scope === "project" ? "local" : "global")!.add(adapter); - } + // Refresh configuration + spinner.start("Refreshing workspace configuration..."); + + const projectRoot = resolveProjectRoot(process.cwd()); + const mcpEntry = getMcpEntry(); + const results: string[] = []; + + // Only refresh adapter scopes that already contain an argent entry. A + // present editor dir (`.gemini`, `.cursor`, ...) is not consent — issue + // #195 — so we look for the argent MCP server key in the actual config. + const configuredScopes = findConfiguredAdapterScopes(ALL_ADAPTERS, projectRoot); + const adaptersByScope = new Map<"local" | "global", Set>([ + ["local", new Set()], + ["global", new Set()], + ]); - // Refresh allowlists only for scopes that already had argent configured — - // matches the editor list above. - for (const [scope, adapters] of adaptersByScope) { - for (const adapter of adapters) { - if (!adapter.addAllowlist) continue; + for (const { adapter, scope, configPath } of configuredScopes) { try { - adapter.addAllowlist(projectRoot, scope); + adapter.write(configPath, mcpEntry); + results.push(`${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`); } catch { - // non-fatal + // Skip paths that can't be written. } + adaptersByScope.get(scope === "project" ? "local" : "global")!.add(adapter); } - } - // Refresh rules/agents the same way: per-scope, only for adapters the user - // opted into in that scope. - const localAdapters = [...adaptersByScope.get("local")!]; - const globalAdapters = [...adaptersByScope.get("global")!]; - const ruleResults = [ - ...copyRulesAndAgents(globalAdapters, projectRoot, "global", RULES_DIR, AGENTS_DIR), - ...copyRulesAndAgents(localAdapters, projectRoot, "local", RULES_DIR, AGENTS_DIR), - ]; + // Refresh allowlists only for scopes that already had argent configured — + // matches the editor list above. + for (const [scope, adapters] of adaptersByScope) { + for (const adapter of adapters) { + if (!adapter.addAllowlist) continue; + try { + adapter.addAllowlist(projectRoot, scope); + } catch { + // non-fatal + } + } + } - spinner.stop("Configuration refreshed."); + // Refresh rules/agents the same way: per-scope, only for adapters the user + // opted into in that scope. + const localAdapters = [...adaptersByScope.get("local")!]; + const globalAdapters = [...adaptersByScope.get("global")!]; + const ruleResults = [ + ...copyRulesAndAgents(globalAdapters, projectRoot, "global", RULES_DIR, AGENTS_DIR), + ...copyRulesAndAgents(localAdapters, projectRoot, "local", RULES_DIR, AGENTS_DIR), + ]; - if (results.length > 0) { - p.note(results.join("\n"), "MCP Configs Updated"); - } + spinner.stop("Configuration refreshed."); - if (ruleResults.length > 0) { - p.note(ruleResults.join("\n"), "Rules & Agents Updated"); - } + if (results.length > 0) { + p.note(results.join("\n"), "MCP Configs Updated"); + } - const skillSummary = formatSkillRefreshSummary(refreshArgentSkills(projectRoot)); - if (skillSummary) { - p.note(skillSummary, "Skills Updated"); - } + if (ruleResults.length > 0) { + p.note(ruleResults.join("\n"), "Rules & Agents Updated"); + } - await trackImmediate("installation:cli_update_complete", { - duration_ms: performance.now() - updateStartTime, - }); - await telemetryShutdown(); + const skillSummary = formatSkillRefreshSummary(refreshArgentSkills(projectRoot)); + if (skillSummary) { + p.note(skillSummary, "Skills Updated"); + } + + await completeUpdateTelemetry(); - p.outro(pc.green("Update complete.")); + p.outro(pc.green("Update complete.")); + } catch (err) { + await failUpdateTelemetry(); + throw err; + } } diff --git a/packages/argent-installer/test/telemetry-finalize.test.ts b/packages/argent-installer/test/telemetry-finalize.test.ts new file mode 100644 index 00000000..2892af6f --- /dev/null +++ b/packages/argent-installer/test/telemetry-finalize.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { finalizeTelemetry } from "../src/telemetry-finalize.js"; + +const telemetryMock = vi.hoisted(() => ({ + shutdown: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@argent/telemetry", () => telemetryMock); + +describe("finalizeTelemetry", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("captures the final event and drains telemetry", async () => { + const capture = vi.fn(); + + await finalizeTelemetry(capture); + + expect(capture).toHaveBeenCalledOnce(); + expect(telemetryMock.shutdown).toHaveBeenCalledOnce(); + }); + + it("still drains when final capture throws", async () => { + const capture = vi.fn(() => { + throw new Error("capture failed"); + }); + + await expect(finalizeTelemetry(capture)).resolves.toBeUndefined(); + + expect(telemetryMock.shutdown).toHaveBeenCalledOnce(); + }); + + it("swallows shutdown failures", async () => { + telemetryMock.shutdown.mockRejectedValueOnce(new Error("network timeout")); + + await expect(finalizeTelemetry(vi.fn())).resolves.toBeUndefined(); + }); +}); diff --git a/packages/argent-installer/test/uninstall.test.ts b/packages/argent-installer/test/uninstall.test.ts index 8729da87..a8adc299 100644 --- a/packages/argent-installer/test/uninstall.test.ts +++ b/packages/argent-installer/test/uninstall.test.ts @@ -19,7 +19,7 @@ import { const telemetryMock = vi.hoisted(() => ({ init: vi.fn(), - trackImmediate: vi.fn().mockResolvedValue(undefined), + track: vi.fn(), forget: vi.fn().mockResolvedValue({ localIdRemoved: true, consentDisabled: false, @@ -28,6 +28,7 @@ const telemetryMock = vi.hoisted(() => ({ })); const childProcessMock = vi.hoisted(() => ({ + execSync: vi.fn(() => "/usr/local/bin/argent\n"), execFileSync: vi.fn(), })); @@ -71,6 +72,8 @@ beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-uninstall-test-")); originalCwd = process.cwd(); vi.clearAllMocks(); + childProcessMock.execSync.mockImplementation(() => "/usr/local/bin/argent\n"); + childProcessMock.execFileSync.mockImplementation(() => undefined); }); afterEach(() => { @@ -79,13 +82,82 @@ afterEach(() => { }); describe("uninstall — telemetry consent preservation", () => { - it("resets uninstall telemetry identity without persisting a consent opt-out", async () => { + it("does not reset uninstall telemetry identity when no global package was uninstalled", async () => { + childProcessMock.execSync.mockImplementationOnce(() => { + throw new Error("not found"); + }); + process.chdir(tmpDir); + + await uninstall(["--yes"]); + + expect(childProcessMock.execFileSync).not.toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["uninstall", "-g"]) + ); + expect(telemetryMock.track).toHaveBeenCalledWith("installation:cli_uninstall_complete", { + has_pruned_content: true, + has_uninstalled_package: false, + }); + expect(telemetryMock.forget).not.toHaveBeenCalled(); + }); + + it("resets uninstall telemetry identity without persisting a consent opt-out after global package uninstall", async () => { process.chdir(tmpDir); await uninstall(["--yes"]); + expect(childProcessMock.execFileSync).toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["uninstall", "-g", "@swmansion/argent"]), + expect.any(Object) + ); expect(telemetryMock.forget).toHaveBeenCalledWith({ disableConsent: false }); }); + + it("drains queued uninstall telemetry before deleting the local telemetry id", async () => { + process.chdir(tmpDir); + + await uninstall(["--yes"]); + + expect(telemetryMock.track).toHaveBeenCalledWith("installation:cli_uninstall_complete", { + has_pruned_content: true, + has_uninstalled_package: true, + }); + + const shutdownOrder = telemetryMock.shutdown.mock.invocationCallOrder[0]!; + const forgetOrder = telemetryMock.forget.mock.invocationCallOrder[0]!; + expect(shutdownOrder).toBeLessThan(forgetOrder); + }); + + it("does not delete the local telemetry id when global package uninstall fails", async () => { + process.chdir(tmpDir); + childProcessMock.execFileSync.mockImplementation((bin: string) => { + if (bin === "npm") throw new Error("npm failed"); + return undefined; + }); + + await uninstall(["--yes"]); + + expect(telemetryMock.track).toHaveBeenCalledWith("installation:cli_uninstall_complete", { + has_pruned_content: true, + has_uninstalled_package: false, + }); + expect(telemetryMock.forget).not.toHaveBeenCalled(); + }); + + it("drains uninstall telemetry when package shutdown throws before uninstalling", async () => { + process.chdir(tmpDir); + toolsClientMock.killToolServer.mockRejectedValueOnce(new Error("tool server busy")); + + await expect(uninstall(["--yes"])).rejects.toThrow("tool server busy"); + + expect(telemetryMock.track).toHaveBeenCalledWith("installation:cli_uninstall_complete", { + has_pruned_content: true, + has_uninstalled_package: false, + }); + expect(telemetryMock.shutdown).toHaveBeenCalledOnce(); + expect(telemetryMock.forget).not.toHaveBeenCalled(); + }); }); // ── MCP entry removal across all adapters ───────────────────────────────────── diff --git a/packages/argent/scripts/bundle-tools.cjs b/packages/argent/scripts/bundle-tools.cjs index 114df626..1cd9e2e1 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -51,11 +51,9 @@ const TELEMETRY_CLI_VERSION = (() => { return "0.0.0"; } })(); -const TELEMETRY_CLI_MAJOR_VERSION = TELEMETRY_CLI_VERSION.split(".")[0] ?? "0"; const TELEMETRY_DEFINE = { ARGENT_CLI_VERSION: JSON.stringify(TELEMETRY_CLI_VERSION), - ARGENT_CLI_MAJOR_VERSION: JSON.stringify(TELEMETRY_CLI_MAJOR_VERSION), }; // esbuild on platform:"node" defaults mainFields to ["main","module"], which diff --git a/packages/registry/src/registry.ts b/packages/registry/src/registry.ts index 0dc120c2..fc295011 100644 --- a/packages/registry/src/registry.ts +++ b/packages/registry/src/registry.ts @@ -75,7 +75,7 @@ export class Registry { const { definition } = record; const startTime = performance.now(); - const toolInvocationId = randomUUID(); + const toolInvocationId = options?.toolInvocationId ?? randomUUID(); this.events.emit("toolInvoked", id, toolInvocationId); try { diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts index 6f2fe5fa..2d01d395 100644 --- a/packages/registry/src/types.ts +++ b/packages/registry/src/types.ts @@ -58,6 +58,8 @@ export type ServiceRef = string | { urn: string; options?: Record(event: E, props: EventPropertyMap[E]): void { +/** + * Enqueue a telemetry event on the shared PostHog client. + * + * This does not force a network send. Short-lived commands must call + * shutdown() before process exit; shutdown() waits for PostHog's async capture + * preparation and drains the queue with a bounded timeout. + */ +export function track( + event: E, + props: EventPropertyMap[E] +): void { try { if (!consentIsEnabled()) return; const built = buildPayload(event, props as Record); @@ -105,47 +114,13 @@ export function track(event: E, props: EventPropertyMap[E]) } } -export async function trackImmediate( - event: E, - props: EventPropertyMap[E] -): Promise { - try { - if (!consentIsEnabled()) return; - const built = buildPayload(event, props as Record); - if (!built) return; - - if (isDebugEnabled()) { - emitDebugPayload({ - event, - distinctId: built.distinctId, - properties: built.properties, - ts: new Date().toISOString(), - }); - } - - const client = getClient(); - if (!client) return; - const send = (async () => { - try { - client.capture({ - distinctId: built.distinctId, - event, - properties: built.properties, - }); - await client.flush(); - } catch (err) { - emitDebugError(`trackImmediate: capture/flush(${event}) failed`, err); - } - })(); - await Promise.race([ - send, - new Promise((resolve) => setTimeout(resolve, SHORT_FLUSH_TIMEOUT_MS).unref()), - ]); - } catch (err) { - emitDebugError(`trackImmediate: outer wrapper caught ${event}`, err); - } -} - +/** + * Drain queued telemetry and reset the shared client. + * + * PostHog capture() performs async event preparation before queueing. Use + * shutdown(), not flush(), at command boundaries so pending capture work is + * joined before the queue is flushed. + */ export async function shutdown(timeoutMs = SHORT_FLUSH_TIMEOUT_MS): Promise { const client = getConstructedClient(); if (!client) { @@ -174,11 +149,11 @@ export function markEnabled(): void { writeConsentFlag(true); } -// Disable records one final opt-out event, persists the flag, then flushes. +// Disable records one final opt-out event, persists the flag, then drains. export async function markDisabled(): Promise { try { const wasEnabled = consentIsEnabled(); - let client = wasEnabled ? getConstructedClient() : null; + let client = getConstructedClient(); if (wasEnabled) { const built = buildPayload("telemetry:opt_out", {}); if (built && isDebugEnabled()) { @@ -206,7 +181,7 @@ export async function markDisabled(): Promise { if (client) { try { await Promise.race([ - client.flush(), + client.shutdown(SHORT_FLUSH_TIMEOUT_MS), new Promise((resolve) => setTimeout(resolve, SHORT_FLUSH_TIMEOUT_MS).unref()), ]); } catch { diff --git a/packages/telemetry/src/registry-listener.ts b/packages/telemetry/src/registry-listener.ts index ef5aeb13..57357cf0 100644 --- a/packages/telemetry/src/registry-listener.ts +++ b/packages/telemetry/src/registry-listener.ts @@ -1,35 +1,25 @@ import type { Registry } from "@argent/registry"; import { track } from "./index.js"; -import { hashId } from "./hash.js"; // HTTP captures request-only metadata here so registry lifecycle events can -// include platform/device context without carrying raw params. +// include platform context without carrying raw params. export interface InvocationMeta { platform?: "ios" | "android"; - deviceId?: string; } interface AttachHandle { /** Idempotent unsubscribe. */ detach: () => void; - /** Register metadata for the next invocation of this tool id. */ - recordInvocation: (toolId: string, meta: InvocationMeta) => () => void; + /** Register metadata for a known invocation id. */ + recordInvocation: (toolInvocationId: string, meta: InvocationMeta) => () => void; /** Counter exposed for the `toolserver:stop` payload. */ getTotalToolCalls: () => number; } export function attachRegistryTelemetry(registry: Registry): AttachHandle { - const pendingMetaByTool = new Map(); const activeMetaByInvocationId = new Map(); let totalToolCalls = 0; - function consumePendingMeta(toolId: string): InvocationMeta { - const queue = pendingMetaByTool.get(toolId); - const meta = queue?.shift(); - if (queue && queue.length === 0) pendingMetaByTool.delete(toolId); - return meta ?? {}; - } - function consumeActiveMeta(toolInvocationId: string): InvocationMeta { const meta = activeMetaByInvocationId.get(toolInvocationId); if (meta) activeMetaByInvocationId.delete(toolInvocationId); @@ -38,13 +28,11 @@ export function attachRegistryTelemetry(registry: Registry): AttachHandle { const onInvoked = (toolId: string, toolInvocationId: string): void => { totalToolCalls += 1; - const meta = consumePendingMeta(toolId); - activeMetaByInvocationId.set(toolInvocationId, meta); + const meta = activeMetaByInvocationId.get(toolInvocationId) ?? {}; track("tool:invoke", { tool: toolId, tool_invocation_id: toolInvocationId, ...(meta.platform ? { platform: meta.platform } : {}), - ...(meta.deviceId ? { device_id_hash: hashId(meta.deviceId) } : {}), }); }; @@ -77,16 +65,12 @@ export function attachRegistryTelemetry(registry: Registry): AttachHandle { registry.events.on("toolCompleted", onCompleted); registry.events.on("toolFailed", onFailed); - function recordInvocation(toolId: string, meta: InvocationMeta): () => void { - const queue = pendingMetaByTool.get(toolId) ?? []; - queue.push(meta); - pendingMetaByTool.set(toolId, queue); + function recordInvocation(toolInvocationId: string, meta: InvocationMeta): () => void { + activeMetaByInvocationId.set(toolInvocationId, meta); return () => { - const current = pendingMetaByTool.get(toolId); - if (!current) return; - const index = current.indexOf(meta); - if (index >= 0) current.splice(index, 1); - if (current.length === 0) pendingMetaByTool.delete(toolId); + if (activeMetaByInvocationId.get(toolInvocationId) === meta) { + activeMetaByInvocationId.delete(toolInvocationId); + } }; } @@ -95,7 +79,6 @@ export function attachRegistryTelemetry(registry: Registry): AttachHandle { registry.events.off("toolInvoked", onInvoked); registry.events.off("toolCompleted", onCompleted); registry.events.off("toolFailed", onFailed); - pendingMetaByTool.clear(); activeMetaByInvocationId.clear(); }, recordInvocation, diff --git a/packages/telemetry/src/sanitize.ts b/packages/telemetry/src/sanitize.ts index 3eaa9787..d1fa1f7f 100644 --- a/packages/telemetry/src/sanitize.ts +++ b/packages/telemetry/src/sanitize.ts @@ -40,7 +40,6 @@ const arrayOf = const TOOL_NAME = matches(/^[a-z][a-z0-9-]{0,63}$/, 64); const PLATFORM = oneOf(["ios", "android"] as const); -const ID_HASH = matches(/^[0-9a-f]{12}$/, 12); const UUID = matches( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, 36 @@ -58,7 +57,6 @@ const PACKAGE_ACTION = oneOf([ "standalone_install", "mcp_update", ] as const); -const TELEMETRY_SUBCOMMAND = oneOf(["status", "enable", "disable", "help", "unknown"] as const); const ADAPTER_NAME = matches(/^[a-z][a-z0-9-]{0,63}$/, 64); const COUNT = finiteNonNeg(); const DURATION_MS = finiteNonNeg(); @@ -130,7 +128,6 @@ export const ALLOWED: Record> = { tool: TOOL_NAME, tool_invocation_id: UUID, platform: PLATFORM, - device_id_hash: ID_HASH, }, "tool:complete": { tool: TOOL_NAME, @@ -151,10 +148,6 @@ export const ALLOWED: Record> = { total_tool_calls: COUNT, }, "telemetry:opt_out": {}, - "telemetry:command_complete": { - subcommand: TELEMETRY_SUBCOMMAND, - duration_ms: DURATION_MS, - }, }; /** Strip keys and values that are not allowed for this event. */ diff --git a/packages/telemetry/test/index.test.ts b/packages/telemetry/test/index.test.ts index c418f59b..b9f4d90d 100644 --- a/packages/telemetry/test/index.test.ts +++ b/packages/telemetry/test/index.test.ts @@ -11,7 +11,6 @@ import { status, shutdown, track, - trackImmediate, } from "../src/index.js"; import { resetClient } from "../src/posthog.js"; import { scopeHome, snapshotEnv } from "./helpers.js"; @@ -60,16 +59,16 @@ describe("telemetry public surface", () => { vi.restoreAllMocks(); }); - it("markDisabled sends opt-out, persists disabled state, and flushes prior events", async () => { + it("markDisabled queues opt-out, persists disabled state, and drains prior events", async () => { track("toolserver:start", {}); + const client = posthogMock.instances[0]!; - posthogMock.flushImpl = async () => { + client.shutdown.mockImplementation(async () => { expect(isEnabled()).toBe(false); - }; + }); await markDisabled(); - const client = posthogMock.instances[0]!; expect(posthogMock.instances).toHaveLength(1); expect(client.capture).toHaveBeenCalledWith( expect.objectContaining({ event: "toolserver:start" }) @@ -77,13 +76,14 @@ describe("telemetry public surface", () => { expect(client.capture).toHaveBeenCalledWith( expect.objectContaining({ event: "telemetry:opt_out" }) ); - expect(client.flush).toHaveBeenCalledTimes(1); + expect(client.flush).not.toHaveBeenCalled(); + expect(client.shutdown).toHaveBeenCalledTimes(1); expect(isEnabled()).toBe(false); }); - it("uses one client and flushes only for trackImmediate", async () => { + it("track queues without flushing so command shutdown drains later", async () => { track("toolserver:start", {}); - await trackImmediate("toolserver:stop", { + track("toolserver:stop", { reason: "signal", uptime_ms: 1, total_tool_calls: 0, @@ -93,7 +93,11 @@ describe("telemetry public surface", () => { expect(posthogMock.instances).toHaveLength(1); expect(client.capture).toHaveBeenCalledTimes(2); - expect(client.flush).toHaveBeenCalledTimes(1); + expect(client.capture).toHaveBeenCalledWith( + expect.objectContaining({ event: "toolserver:stop" }) + ); + expect(client.flush).not.toHaveBeenCalled(); + expect(client.shutdown).not.toHaveBeenCalled(); expect(client.opts).toEqual(expect.objectContaining({ flushAt: 20, flushInterval: 10_000 })); }); @@ -118,7 +122,7 @@ describe("telemetry public surface", () => { it("shutdown drains the constructed client", async () => { track("toolserver:start", {}); - await trackImmediate("toolserver:stop", { + track("toolserver:stop", { reason: "signal", uptime_ms: 1, total_tool_calls: 0, diff --git a/packages/telemetry/test/registry-listener.test.ts b/packages/telemetry/test/registry-listener.test.ts index 91b0d9cc..bec1cd77 100644 --- a/packages/telemetry/test/registry-listener.test.ts +++ b/packages/telemetry/test/registry-listener.test.ts @@ -26,10 +26,7 @@ describe("attachRegistryTelemetry", () => { const registry = new Registry(); const handle = attachRegistryTelemetry(registry); - handle.recordInvocation("gesture-tap", { - platform: "ios", - deviceId: "ABCD1234-EFGH-5678-IJKL-9012MNOP3456", - }); + handle.recordInvocation(INVOCATION_ID_1, { platform: "ios" }); registry.events.emit("toolInvoked", "gesture-tap", INVOCATION_ID_1); registry.events.emit("toolCompleted", "gesture-tap", INVOCATION_ID_1, 42.5); @@ -41,9 +38,7 @@ describe("attachRegistryTelemetry", () => { tool_invocation_id: INVOCATION_ID_1, platform: "ios", }); - expect((trackSpy.mock.calls[0]![1] as Record).device_id_hash).toMatch( - /^[0-9a-f]{12}$/ - ); + expect(trackSpy.mock.calls[0]![1]).not.toHaveProperty("device_id_hash"); expect(trackSpy.mock.calls[1]![0]).toBe("tool:complete"); expect(trackSpy.mock.calls[1]![1]).toMatchObject({ @@ -60,7 +55,7 @@ describe("attachRegistryTelemetry", () => { const registry = new Registry(); const handle = attachRegistryTelemetry(registry); - handle.recordInvocation("screenshot", { platform: "android" }); + handle.recordInvocation(INVOCATION_ID_1, { platform: "android" }); registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_1); @@ -124,7 +119,7 @@ describe("attachRegistryTelemetry", () => { const trackSpy = vi.spyOn(telemetry, "track"); const registry = new Registry(); const handle = attachRegistryTelemetry(registry); - const release = handle.recordInvocation("screenshot", { platform: "android" }); + const release = handle.recordInvocation(INVOCATION_ID_1, { platform: "android" }); release(); registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_1); @@ -136,13 +131,13 @@ describe("attachRegistryTelemetry", () => { handle.detach(); }); - it("keeps same-tool invocation metadata separate by invocation id", () => { + it("keeps same-tool invocation metadata separate by caller-provided invocation id", () => { const trackSpy = vi.spyOn(telemetry, "track"); const registry = new Registry(); const handle = attachRegistryTelemetry(registry); - handle.recordInvocation("screenshot", { platform: "ios" }); - handle.recordInvocation("screenshot", { platform: "android" }); + handle.recordInvocation(INVOCATION_ID_2, { platform: "android" }); + handle.recordInvocation(INVOCATION_ID_1, { platform: "ios" }); registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_1); registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_2); diff --git a/packages/telemetry/test/sanitize.test.ts b/packages/telemetry/test/sanitize.test.ts index d5448806..07c25a69 100644 --- a/packages/telemetry/test/sanitize.test.ts +++ b/packages/telemetry/test/sanitize.test.ts @@ -220,6 +220,7 @@ describe("sanitize", () => { ["customRoot", { customRoot: "/Users/alice/work" }], ["fromTar", { from: "/Users/alice/file.tgz" }], ["bundle_id_hash", { bundle_id_hash: "0123456789ab" }], + ["device_id_hash", { device_id_hash: "0123456789ab" }], ])("drops %s when accidentally passed to a tool event", (_label, payload) => { const out = sanitize("tool:invoke", { tool: "x", platform: "ios", ...payload }); expect(out).toEqual({ tool: "x", platform: "ios" }); diff --git a/packages/tool-server/src/http.ts b/packages/tool-server/src/http.ts index 648f7a1a..2d86db56 100644 --- a/packages/tool-server/src/http.ts +++ b/packages/tool-server/src/http.ts @@ -1,4 +1,5 @@ import express, { Request, Response } from "express"; +import { randomUUID } from "node:crypto"; import type { Registry } from "@argent/registry"; import { ToolNotFoundError } from "@argent/registry"; import { createIdleTimer } from "./utils/idle-timer"; @@ -55,14 +56,14 @@ function extractDeviceArg(data: unknown): string | null { return null; } -type InvocationMeta = { platform?: "ios" | "android"; deviceId?: string }; +type InvocationMeta = { platform?: "ios" | "android" }; function extractInvocationMeta(hasCapability: boolean, data: unknown): InvocationMeta | null { if (!hasCapability || !data || typeof data !== "object") return null; const record = data as Record; const deviceArg = extractDeviceArg(record); if (deviceArg) { - return { platform: resolveDevice(deviceArg).platform, deviceId: deviceArg }; + return { platform: resolveDevice(deviceArg).platform }; } if (typeof record.avdName === "string") { return { platform: "android" }; @@ -87,7 +88,7 @@ export interface HttpAppOptions { */ bindHost?: string; /** Optional telemetry hook for per-invocation platform/device metadata. */ - recordInvocation?: (toolId: string, meta: InvocationMeta) => () => void; + recordInvocation?: (toolInvocationId: string, meta: InvocationMeta) => () => void; } export interface HttpAppHandle { @@ -331,18 +332,21 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt if (!res.writableFinished) controller.abort(); }); - // Hashing happens in the telemetry listener, not in the HTTP layer. + // The HTTP layer owns the invocation id so request metadata can be + // correlated without relying on same-tool FIFO ordering. + const toolInvocationId = randomUUID(); let releaseInvocationMeta: (() => void) | undefined; if (options?.recordInvocation) { const invocationMeta = extractInvocationMeta(Boolean(def.capability), parsedData); if (invocationMeta) { - releaseInvocationMeta = options.recordInvocation(name, invocationMeta); + releaseInvocationMeta = options.recordInvocation(toolInvocationId, invocationMeta); } } try { const data = await registry.invokeTool(name, parsedData, { signal: controller.signal, + toolInvocationId, }); // Gate on `updateInstallable` (not `updateAvailable`) and advertise the // version the resolver would install — both account for the release-age policy. diff --git a/packages/tool-server/src/index.ts b/packages/tool-server/src/index.ts index aa41f65f..816a8019 100644 --- a/packages/tool-server/src/index.ts +++ b/packages/tool-server/src/index.ts @@ -3,7 +3,6 @@ import { init as telemetryInit, attachRegistryTelemetry, track as telemetryTrack, - trackImmediate as telemetryTrackImmediate, shutdown as telemetryShutdown, } from "@argent/telemetry"; import { createHttpApp } from "./http"; @@ -55,8 +54,6 @@ export function start(): void { function crashShutdown(label: string, detail: string): void { process.stderr.write(`[tool-server] ${label}: ${detail}\n`); - if (shuttingDown) return; // avoid re-entrant shutdown - shuttingDown = true; shutdownReason = "crash"; setTimeout(() => process.exit(1), PROCESS_TIMEOUT_MS); if (shutdown) { @@ -104,13 +101,16 @@ export function start(): void { // `shutdown` closes over `server` by reference — reads the current value when // called, so it works correctly whether server has started yet or not. shutdown = async (exitCode = 0) => { + if (shuttingDown) return; + shuttingDown = true; + updateChecker.dispose(); stopWatcher(); httpHandle.dispose(); // Emit toolserver:stop before tearing the registry down. try { - await telemetryTrackImmediate("toolserver:stop", { + telemetryTrack("toolserver:stop", { reason: shutdownReason, uptime_ms: Date.now() - serverStartedAt, total_tool_calls: telemetryHandle.getTotalToolCalls(), @@ -174,10 +174,13 @@ export function start(): void { }); }) .catch((err) => { - process.stderr.write( - `[tool-server] Failed to start: ${err instanceof Error ? err.message : err}\n` - ); - process.exit(1); + void (async () => { + process.stderr.write( + `[tool-server] Failed to start: ${err instanceof Error ? err.message : err}\n` + ); + shutdownReason = "crash"; + await shutdown?.(1); + })(); }); // ── Lifecycle ───────────────────────────────────────────────────── diff --git a/packages/tool-server/test/http-tools-meta.test.ts b/packages/tool-server/test/http-tools-meta.test.ts index 1f6f846b..601a8509 100644 --- a/packages/tool-server/test/http-tools-meta.test.ts +++ b/packages/tool-server/test/http-tools-meta.test.ts @@ -111,12 +111,13 @@ describe("GET /tools progressive-loading metadata", () => { it("does not pass bundleId into telemetry invocation metadata", async () => { const release = vi.fn(); let seenMeta: Record | undefined; - const recordInvocation = vi.fn((_toolId: string, meta: Record) => { + const recordInvocation = vi.fn((_toolInvocationId: string, meta: Record) => { seenMeta = meta; return release; }); + const registry = stubRegistry(); handle.dispose(); - handle = createHttpApp(stubRegistry(), { recordInvocation }); + handle = createHttpApp(registry, { recordInvocation }); await request(handle.app) .post("/tools/device-tool") @@ -126,12 +127,17 @@ describe("GET /tools progressive-loading metadata", () => { }) .expect(200); - expect(recordInvocation).toHaveBeenCalledWith("device-tool", { + expect(recordInvocation).toHaveBeenCalledWith(expect.any(String), { platform: "ios", - deviceId: "11111111-1111-1111-1111-111111111111", }); expect(seenMeta).not.toHaveProperty("bundleId"); + expect(seenMeta).not.toHaveProperty("deviceId"); expect(release).toHaveBeenCalledOnce(); + expect(registry.invokeTool).toHaveBeenCalledWith( + "device-tool", + expect.any(Object), + expect.objectContaining({ toolInvocationId: recordInvocation.mock.calls[0]![0] }) + ); }); it("does not record platform metadata for non-device tools", async () => { @@ -151,7 +157,7 @@ describe("GET /tools progressive-loading metadata", () => { }); it("records Android platform for avdName device-management calls without a device hash", async () => { - const recordInvocation = vi.fn((_toolId: string, meta: Record) => { + const recordInvocation = vi.fn((_toolInvocationId: string, meta: Record) => { expect(meta).toEqual({ platform: "android" }); return vi.fn(); }); @@ -160,6 +166,6 @@ describe("GET /tools progressive-loading metadata", () => { await request(handle.app).post("/tools/boot-tool").send({ avdName: "Pixel_9" }).expect(200); - expect(recordInvocation).toHaveBeenCalledWith("boot-tool", { platform: "android" }); + expect(recordInvocation).toHaveBeenCalledWith(expect.any(String), { platform: "android" }); }); }); diff --git a/packages/tool-server/test/startup-telemetry.test.ts b/packages/tool-server/test/startup-telemetry.test.ts new file mode 100644 index 00000000..428a21d5 --- /dev/null +++ b/packages/tool-server/test/startup-telemetry.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +const telemetryMock = vi.hoisted(() => ({ + init: vi.fn(), + attachRegistryTelemetry: vi.fn(() => ({ + detach: vi.fn(), + recordInvocation: vi.fn(), + getTotalToolCalls: vi.fn(() => 0), + })), + track: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), +})); + +const registryMock = vi.hoisted(() => ({ + dispose: vi.fn().mockResolvedValue(undefined), +})); + +const httpHandleMock = vi.hoisted(() => ({ + dispose: vi.fn(), + app: { + listen: vi.fn(), + }, +})); + +const updateCheckerMock = vi.hoisted(() => ({ + dispose: vi.fn(), +})); + +const watcherMock = vi.hoisted(() => ({ + stop: vi.fn(), +})); + +vi.mock("@argent/telemetry", () => telemetryMock); +vi.mock("@argent/registry", () => ({ + attachRegistryLogger: vi.fn(), +})); +vi.mock("../src/utils/setup-registry", () => ({ + createRegistry: vi.fn(() => registryMock), +})); +vi.mock("../src/http", () => ({ + createHttpApp: vi.fn(() => httpHandleMock), +})); +vi.mock("../src/utils/update-checker", () => ({ + startUpdateChecker: vi.fn(() => updateCheckerMock), +})); +vi.mock("../src/utils/simulator-watcher", () => ({ + startSimulatorWatcher: vi.fn(() => ({ + stop: watcherMock.stop, + ready: new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("watcher failed")), 0); + }), + })), +})); + +describe("tool-server startup telemetry", () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it("drains telemetry when startup fails before the listener binds", async () => { + const { start } = await import("../src/index"); + + start(); + + await vi.waitFor(() => { + expect(telemetryMock.shutdown).toHaveBeenCalledWith(1500); + }); + + expect(telemetryMock.track).toHaveBeenCalledWith("toolserver:stop", { + reason: "crash", + uptime_ms: expect.any(Number), + total_tool_calls: 0, + }); + expect(updateCheckerMock.dispose).toHaveBeenCalledOnce(); + expect(watcherMock.stop).toHaveBeenCalledOnce(); + expect(httpHandleMock.dispose).toHaveBeenCalledOnce(); + expect(registryMock.dispose).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + + process.emit("SIGTERM"); + + expect(telemetryMock.track).toHaveBeenCalledTimes(1); + expect(telemetryMock.shutdown).toHaveBeenCalledTimes(1); + expect(updateCheckerMock.dispose).toHaveBeenCalledOnce(); + expect(watcherMock.stop).toHaveBeenCalledOnce(); + expect(httpHandleMock.dispose).toHaveBeenCalledOnce(); + expect(registryMock.dispose).toHaveBeenCalledOnce(); + }); +}); From 1f4bc5c12d1c9e1536b597353db639b0f333ae04 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Tue, 2 Jun 2026 16:21:40 +0200 Subject: [PATCH 5/9] chore: restore README privacy section --- README.md | 12 +++--------- packages/telemetry/src/index.ts | 5 +---- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index dd040f72..affd86db 100644 --- a/README.md +++ b/README.md @@ -127,18 +127,12 @@ argent init ## Privacy -Argent includes anonymous, opt-out usage telemetry for installation and tool -health signals. It does not collect raw tool arguments, file paths, app data, -error messages, or device identifiers. +Argent does not collect or transmit any user data. +No telemetry, no analytics, no crash reporting. - Argent integrates with your agent locally over MCP stdio. - Its internal tools are not reachable from outside your machine. -- Telemetry can be disabled with `argent telemetry disable`, `DO_NOT_TRACK=1`, - or `ARGENT_TELEMETRY=0`. -- Telemetry events are sent to PostHog EU with a public write-only project key. - Dashboards and exports must treat client-side event properties as untrusted. -- The version check against our public npm package sends no user data and fails - gracefully if blocked. +- The only outbound network call we make is the version check against our public npm package, which sends no user data and fails gracefully if blocked. ## License diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index db0ca7fd..1fa195f9 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -80,10 +80,7 @@ function buildPayload( * shutdown() before process exit; shutdown() waits for PostHog's async capture * preparation and drains the queue with a bounded timeout. */ -export function track( - event: E, - props: EventPropertyMap[E] -): void { +export function track(event: E, props: EventPropertyMap[E]): void { try { if (!consentIsEnabled()) return; const built = buildPayload(event, props as Record); From 1727e62f1a35fa35919954244dbf46a113410e9f Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Tue, 2 Jun 2026 16:45:01 +0200 Subject: [PATCH 6/9] chore: bump telemetry version to 0.9.0 --- package-lock.json | 2 +- packages/telemetry/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3d45e39..3df64a6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4101,7 +4101,7 @@ }, "packages/telemetry": { "name": "@argent/telemetry", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "ci-info": "^4.4.0", "posthog-node": "5.35.0" diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index ab6eab8b..8d9941f2 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@argent/telemetry", - "version": "0.8.1", + "version": "0.9.0", "description": "Anonymous opt-out telemetry client for Argent CLI / installer / tool-server", "main": "dist/index.js", "types": "dist/index.d.ts", From adec928d224ff67676981c7fa83ebe3ecbbef4b8 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Tue, 9 Jun 2026 09:43:37 +0200 Subject: [PATCH 7/9] chore: bump telemetry package version --- package-lock.json | 2 +- packages/telemetry/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3df64a6d..b0c3c933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4101,7 +4101,7 @@ }, "packages/telemetry": { "name": "@argent/telemetry", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "ci-info": "^4.4.0", "posthog-node": "5.35.0" diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 8d9941f2..e7aecae6 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@argent/telemetry", - "version": "0.9.0", + "version": "0.10.0", "description": "Anonymous opt-out telemetry client for Argent CLI / installer / tool-server", "main": "dist/index.js", "types": "dist/index.d.ts", From 002fd6bac5b55f85c8d53315add446563b597264 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Wed, 10 Jun 2026 22:36:21 +0200 Subject: [PATCH 8/9] refactor: remove uninformative events --- packages/argent-installer/src/init.ts | 13 ------------- packages/telemetry/src/ci-detect.ts | 7 +++++++ packages/telemetry/src/events.ts | 21 --------------------- packages/telemetry/src/sanitize.ts | 12 ------------ 4 files changed, 7 insertions(+), 46 deletions(-) diff --git a/packages/argent-installer/src/init.ts b/packages/argent-installer/src/init.ts index 9be20613..514901c2 100644 --- a/packages/argent-installer/src/init.ts +++ b/packages/argent-installer/src/init.ts @@ -479,7 +479,6 @@ export async function init(args: string[]): Promise { track("installation:allowlist_decision", { is_enabled: allowlistEnabled, - applicable_adapter_count: adaptersWithAllowlist.length, }); if (allowlistEnabled) { @@ -568,12 +567,6 @@ export async function init(args: string[]): Promise { skillsMethod = choice as SkillsMethod; } - track("installation:skill_install", { - method: skillsMethod, - is_online: online, - has_offline_cache: offlineWithCache, - }); - // Prefer the GitHub-pinned source. SKILLS_DIR as a fallback. const useGitHubSource = online && !fromTar && version !== "unknown"; const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; @@ -623,16 +616,12 @@ export async function init(args: string[]): Promise { if (skillsMethod === "default") { spinner.stop("Skills installed."); } - track("installation:skill_install_result", { is_success: true }); } catch (err) { if (skillsMethod === "default") { spinner.stop(pc.red("Skills installation failed.")); } p.log.error(`Failed to run npx skills: ${err}`); p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); - track("installation:skill_install_result", { - is_success: false, - }); } } @@ -654,8 +643,6 @@ export async function init(args: string[]): Promise { p.log.info(pc.dim("No rules or agents to copy for selected editors.")); } - track("installation:rules_agents_copy", { copied_count: copyResults.length }); - // ── Summary ───────────────────────────────────────────────────────────────── const summaryLines = [ diff --git a/packages/telemetry/src/ci-detect.ts b/packages/telemetry/src/ci-detect.ts index 930dcb2a..6d64decb 100644 --- a/packages/telemetry/src/ci-detect.ts +++ b/packages/telemetry/src/ci-detect.ts @@ -10,6 +10,13 @@ interface VendorDefinition { env: VendorEnv | VendorEnv[]; } +// Copied verbatim from ci-info 4.4.0's `isCI` detector: the same generic env +// vars and the `CI === "false"` bypass in isCi() below. ci-info is the de-facto +// ecosystem standard (npm, Jest, etc.), so we inherit exactly its false-positive +// surface — narrowing this list would diverge from it and risk false negatives +// (missing real Jenkins/TeamCity/TaskCluster CI). We re-implement rather than +// import ci-info's `isCI` because that value is computed once at import time; +// this wrapper takes `env` lazily so tests can inject it. const GENERIC_CI_ENV_VARS = [ "BUILD_ID", "BUILD_NUMBER", diff --git a/packages/telemetry/src/events.ts b/packages/telemetry/src/events.ts index 40a06bbf..5359247e 100644 --- a/packages/telemetry/src/events.ts +++ b/packages/telemetry/src/events.ts @@ -38,21 +38,6 @@ export interface InstallationEditorsSelectProps { export interface InstallationAllowlistDecisionProps { is_enabled: boolean; - applicable_adapter_count: number; -} - -export interface InstallationSkillInstallProps { - method: "default" | "interactive" | "manual"; - is_online: boolean; - has_offline_cache: boolean; -} - -export interface InstallationSkillInstallResultProps { - is_success: boolean; -} - -export interface InstallationRulesAgentsCopyProps { - copied_count: number; } export type InstallationPackageActionTrigger = "init" | "update" | "mcp_update"; @@ -138,9 +123,6 @@ export interface EventPropertyMap { "installation:update_decision": InstallationUpdateDecisionProps; "installation:editors_select": InstallationEditorsSelectProps; "installation:allowlist_decision": InstallationAllowlistDecisionProps; - "installation:skill_install": InstallationSkillInstallProps; - "installation:skill_install_result": InstallationSkillInstallResultProps; - "installation:rules_agents_copy": InstallationRulesAgentsCopyProps; "installation:package_action": InstallationPackageActionProps; "installation:cli_update_start": InstallationCliUpdateStartProps; "installation:cli_update_complete": InstallationCliUpdateCompleteProps; @@ -166,9 +148,6 @@ export const EVENT_NAMES: readonly EventName[] = [ "installation:update_decision", "installation:editors_select", "installation:allowlist_decision", - "installation:skill_install", - "installation:skill_install_result", - "installation:rules_agents_copy", "installation:package_action", "installation:cli_update_start", "installation:cli_update_complete", diff --git a/packages/telemetry/src/sanitize.ts b/packages/telemetry/src/sanitize.ts index d1fa1f7f..7aad211d 100644 --- a/packages/telemetry/src/sanitize.ts +++ b/packages/telemetry/src/sanitize.ts @@ -93,18 +93,6 @@ export const ALLOWED: Record> = { }, "installation:allowlist_decision": { is_enabled: bool, - applicable_adapter_count: COUNT, - }, - "installation:skill_install": { - method: oneOf(["default", "interactive", "manual"] as const), - is_online: bool, - has_offline_cache: bool, - }, - "installation:skill_install_result": { - is_success: bool, - }, - "installation:rules_agents_copy": { - copied_count: COUNT, }, "installation:package_action": { trigger: PACKAGE_ACTION_TRIGGER, From 688a6f958c9fd9849e7dbec9d23fb5c4e9488a1b Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Thu, 11 Jun 2026 09:05:39 +0200 Subject: [PATCH 9/9] fix: bring back skill_install event --- packages/argent-installer/src/init.ts | 12 ++++++++++++ packages/telemetry/src/events.ts | 9 +++++++++ packages/telemetry/src/sanitize.ts | 6 ++++++ 3 files changed, 27 insertions(+) diff --git a/packages/argent-installer/src/init.ts b/packages/argent-installer/src/init.ts index 514901c2..ebaad186 100644 --- a/packages/argent-installer/src/init.ts +++ b/packages/argent-installer/src/init.ts @@ -571,6 +571,8 @@ export async function init(args: string[]): Promise { const useGitHubSource = online && !fromTar && version !== "unknown"; const skillsSource = useGitHubSource ? buildArgentSkillsSource(version) : SKILLS_DIR; + let skillOutcome: "success" | "failure" | "skipped"; + if (skillsMethod === "manual") { p.note( [ @@ -590,6 +592,7 @@ export async function init(args: string[]): Promise { ].join("\n"), "Manual Skills Installation" ); + skillOutcome = "skipped"; } else { const skillsArgs = ["skills", "add", skillsSource]; @@ -616,15 +619,24 @@ export async function init(args: string[]): Promise { if (skillsMethod === "default") { spinner.stop("Skills installed."); } + skillOutcome = "success"; } catch (err) { if (skillsMethod === "default") { spinner.stop(pc.red("Skills installation failed.")); } p.log.error(`Failed to run npx skills: ${err}`); p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); + skillOutcome = "failure"; } } + track("installation:skill_install", { + method: skillsMethod, + is_online: online, + has_offline_cache: offlineWithCache, + outcome: skillOutcome, + }); + // ── Step 3: Rules and Agents ──────────────────────────────────────────────── p.log.step(pc.bold("Step 3: Rules & Agents")); diff --git a/packages/telemetry/src/events.ts b/packages/telemetry/src/events.ts index 5359247e..31887436 100644 --- a/packages/telemetry/src/events.ts +++ b/packages/telemetry/src/events.ts @@ -40,6 +40,13 @@ export interface InstallationAllowlistDecisionProps { is_enabled: boolean; } +export interface InstallationSkillInstallProps { + method: "default" | "interactive" | "manual"; + is_online: boolean; + has_offline_cache: boolean; + outcome: "success" | "failure" | "skipped"; +} + export type InstallationPackageActionTrigger = "init" | "update" | "mcp_update"; export type InstallationPackageAction = @@ -123,6 +130,7 @@ export interface EventPropertyMap { "installation:update_decision": InstallationUpdateDecisionProps; "installation:editors_select": InstallationEditorsSelectProps; "installation:allowlist_decision": InstallationAllowlistDecisionProps; + "installation:skill_install": InstallationSkillInstallProps; "installation:package_action": InstallationPackageActionProps; "installation:cli_update_start": InstallationCliUpdateStartProps; "installation:cli_update_complete": InstallationCliUpdateCompleteProps; @@ -148,6 +156,7 @@ export const EVENT_NAMES: readonly EventName[] = [ "installation:update_decision", "installation:editors_select", "installation:allowlist_decision", + "installation:skill_install", "installation:package_action", "installation:cli_update_start", "installation:cli_update_complete", diff --git a/packages/telemetry/src/sanitize.ts b/packages/telemetry/src/sanitize.ts index 7aad211d..2e6c7696 100644 --- a/packages/telemetry/src/sanitize.ts +++ b/packages/telemetry/src/sanitize.ts @@ -94,6 +94,12 @@ export const ALLOWED: Record> = { "installation:allowlist_decision": { is_enabled: bool, }, + "installation:skill_install": { + method: oneOf(["default", "interactive", "manual"] as const), + is_online: bool, + has_offline_cache: bool, + outcome: oneOf(["success", "failure", "skipped"] as const), + }, "installation:package_action": { trigger: PACKAGE_ACTION_TRIGGER, action: PACKAGE_ACTION,