From 12eb3f00f135dff033922e4b7976503feec0c89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serkan=20=C3=96ZAL?= Date: Wed, 22 Jul 2026 14:50:09 +0300 Subject: [PATCH] feat(telemetry): send cursor_ext_* events to PostHog with signed-in email --- README.md | 3 +- docs/ironbee-vscode-design.md | 47 +++- src/accounts/accountManager.ts | 25 ++- src/auth/authManager.ts | 3 + src/config/ironbeeConfig.ts | 44 ++++ src/extension.ts | 319 +++++++++++++++++++++++---- src/lifecycle/telemetry.ts | 106 +++++++-- src/lifecycle/uninstallCleanup.ts | 28 +++ src/runtime/cliRunner.ts | 8 +- test/accounts/accountManager.test.ts | 16 +- test/config/ironbeeConfig.test.ts | 46 ++++ test/lifecycle/telemetry.test.ts | 41 ++-- test/runtime/cliRunner.test.ts | 22 ++ 13 files changed, 616 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index cb9c09b..494ceb8 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Search "IronBee" in Settings: ## Privacy Sign-in tokens are stored in the editor's encrypted secret storage. Telemetry, when enabled, is -anonymous and never includes your email or account id. +keyed to an anonymous id; while you're signed in, your account email is attached so usage can be +tied to your account. Turn it off anytime with the **Telemetry** setting. ## License diff --git a/docs/ironbee-vscode-design.md b/docs/ironbee-vscode-design.md index 7c564a8..121a5a9 100644 --- a/docs/ironbee-vscode-design.md +++ b/docs/ironbee-vscode-design.md @@ -246,9 +246,14 @@ CLI-1..CLI-5 are all **verifications that passed** (no required work). CLI-OPT-1 - **[CLI-1 — ✔ VERIFIED] Devtools override already exists.** `config.ironbeeDevTools.mcp` (full command/args/env replacement) + `config.ironbeeDevTools.env` (`config.ts:2148-2151, - 2181-2208`). The extension writes this block into **global** `~/.ironbee/config.json` before - install (global-only — see EXT-5 / CLI-2b) — **no CLI change**. Do NOT invent - `devtools.mcpCommand`/`mcpArgs` (fictional). + 2181-2208`). **PER-PROJECT (not global):** rather than writing the bundled `mcp` block into the + shared `~/.ironbee/config.json` (which affects every project + goes stale on upgrade), the + extension passes it as the **`IRONBEE_DEVTOOLS_MCP`** env (a full JSON `{command,args,env}`) to + each `ironbee install` spawn. The CLI's `resolveDevToolsEntryFromEnv` (`config.ts:2748`) gives + that env top precedence and bakes it into THIS project's own `.cursor/mcp.json` — zero global + writes. Empirically verified. The extension still `clearDevtoolsMcp()`s any stale global block a + prior version left behind (migration). npx/universal mode keeps the generic (non-stale) + browser-suppress `ironbeeDevTools.env`. Do NOT invent `devtools.mcpCommand`/`mcpArgs` (fictional). - **[CLI-2 — ✔ VERIFIED] The override round-trips into the written project MCP entries** — the load-bearing check for the whole bundling approach, and it holds. All clients compute the entry at write time via `getComposeDevToolsMcpEntry(projectDir)` and serialize it: Cursor → @@ -578,17 +583,39 @@ could disrupt projects the user never intended to touch, and verification settin optionally server-side collector tokens whose name carries the `ironbee-vscode:` prefix (to avoid 10-cap pollution). **Do NOT** delete `~/.ironbee/config.json`'s collector token by default (shared with the CLI), and **do NOT** touch the shared - `~/.ironbee-devtools/config.json` — its anonymous id is shared with `ironbee-devtools-vscode` - (EXT-9), so there is no ironbee-vscode-specific state to remove there. + `~/.ironbee/telemetry.json` — its anonymous id is shared with all IronBee tools (CLI, devtools, + the editor extensions) (EXT-9), so there is no ironbee-vscode-specific state to remove there. - **Self-update:** best-effort, non-blocking poll of the OpenVSX API with backoff; offer to update on a newer version (mirror devtools-vscode). **[EXT-9] Telemetry** -- Show the notice on first run **before** any event is emitted; respect - `ironbee.telemetry.enable` (and the shared anonymous-id file). Events contain only an - anonymous id + event name — **no email/account id** (which would de-anonymize). Storage - path: reuse `~/.ironbee-devtools/config.json` for a shared anonymous id (single decision; - do not split into a second file). Emit `sign_in`, `install`, `switch_account` events. +- On by default (opt-out via `ironbee.telemetry.enable`); no consent notice. `distinct_id` is the + shared anonymous id from `~/.ironbee/telemetry.json` (single decision — all IronBee tools, incl. + the CLI, read/write it; do not split into a second file). When the user is signed in, their email + rides along as the PostHog **person property** via `properties.$set.email` (the reserved key + PostHog recognizes — not a custom prop), read from an in-memory cache populated by `refreshStatus` + (never a per-event API call) and cleared on sign-out. Plus coarse env props (source, extension/ + node version, os platform/arch, timezone). Transport: raw HTTPS `POST /i/v0/e/` to + `us.i.posthog.com` (no posthog-node client). +- **All** event names are prefixed `cursor_ext_`: lifecycle `cursor_ext_installed` / + `_activated` / `_deactivated` / `_uninstalled` / `_error`, product `cursor_ext_sign_in` / + `cursor_ext_switch_account`, project `cursor_ext_project_setup` / `cursor_ext_project_uninstall` + (+ their `_failed` variants). **Every** caught failure is reported as `cursor_ext_error` + (fire-and-forget, non-blocking) with a `context` label + `error_type`/`error_message` and a + `surfaced` flag: `reportError` (surfaced=true) shows the message + an "Open issue on GitHub" + action deep-linking to the repo's prefilled new-issue form; `logError` (surfaced=false) only + writes to the output channel — for best-effort/background failures we don't interrupt the user + over. Expected control-flow catches (universal build has no bundled devtools, fs existence + checks) are NOT errors and stay silent. A safety-net wraps every command handler (VS Code + swallows a handler's rejected promise) and `activate`, so no uncaught error escapes unreported. +- **Product/diagnostic signals** (not errors): `cursor_ext_devtools_mode` (bundled vs npx), + `cursor_ext_devtools_prewarm`, `cursor_ext_browser_install` / + `cursor_ext_browser_system_fallback_accepted`, `cursor_ext_setup_cancelled` (with `at` stage), + `cursor_ext_account_switch_noop`, `cursor_ext_collector_token_rotated`, + `cursor_ext_token_cap_recovered` / `cursor_ext_token_cap_blocked`, + `cursor_ext_signin_provider_link_retry`. `AccountManager`/`AuthManager` take an optional injected + telemetry sink (`event`/`error`) so their internal rotations, cap-handling, and otherwise-swallowed + errors surface without importing VS Code. **[EXT-10] Packaging & publishing** - `.vscodeignore`: keep `@ironbee-ai/cli`, `@ironbee-ai/devtools (≥0.17.0)`, diff --git a/src/accounts/accountManager.ts b/src/accounts/accountManager.ts index 5fd325c..bd65bf4 100644 --- a/src/accounts/accountManager.ts +++ b/src/accounts/accountManager.ts @@ -10,6 +10,12 @@ const TOKEN_ROTATE_SKEW_MS: number = 7 * 24 * 60 * 60 * 1000; /** Reuse the cached token, rotate it (near/at expiry), or mint fresh (gone/revoked). */ type TokenStatus = 'usable' | 'rotate' | 'gone'; +/** Fire-and-forget telemetry sink: product signals + otherwise-swallowed error reporting. */ +export interface AccountTelemetry { + event(name: string, props?: Record): void; + error(context: string, err: unknown): void; +} + export interface AccountManagerDeps { console: ConsoleClient; store: TokenStore; @@ -21,6 +27,8 @@ export interface AccountManagerDeps { refreshSession: () => Promise; /** Injectable clock (for expiry checks/tests). */ now?: () => number; + /** Optional telemetry (never throws); records rotations/cap-handling + swallowed errors. */ + telemetry?: AccountTelemetry; } /** @@ -76,6 +84,7 @@ export class AccountManager { if (previous === targetAccountId) { // Already active server-side, but the local session/token may be stale — refresh the // claim before minting/writing so we never act against a stale custom:account_id. + this.deps.telemetry?.event('account_switch_noop'); await this.deps.refreshSession(); await this.doEnsureCollectorToken(targetAccountId); return; @@ -98,10 +107,11 @@ export class AccountManager { try { await this.deps.console.switchAccount(previousAccountId); await this.deps.refreshSession(); - } catch { + } catch (err) { // Rollback failed (e.g. dead session). Local view diverges from server — // mark dirty so the next operation re-fetches (and re-refreshes) before trusting state. this.dirty = true; + this.deps.telemetry?.error('account-switch-rollback', err); } } @@ -126,8 +136,11 @@ export class AccountManager { return; } if (status === 'rotate') { + this.deps.telemetry?.event('collector_token_rotated', { reason: 'near_expiry' }); // Delete the near-expiry token first so rotations don't pile up toward the 10-cap. - await this.deps.console.deleteAccessToken(cached.id).catch((): void => {}); + await this.deps.console + .deleteAccessToken(cached.id) + .catch((e: unknown): void => this.deps.telemetry?.error('token-rotate-delete-old', e)); } } const minted: MintedToken = await this.mintWithCapHandling(); @@ -139,8 +152,9 @@ export class AccountManager { let list: AccessTokenRecord[]; try { list = await this.deps.console.listAccessTokens(); - } catch { + } catch (err) { // Transient list failure — don't force a needless mint; reuse the cached token. + this.deps.telemetry?.error('token-status-check (reused cached token)', err); return 'usable'; } const found: AccessTokenRecord | undefined = list.find((t: AccessTokenRecord): boolean => t.id === id); @@ -167,13 +181,16 @@ export class AccountManager { const list: AccessTokenRecord[] = await this.deps.console.listAccessTokens(); const owned: AccessTokenRecord | undefined = list.find((t: AccessTokenRecord): boolean => t.name.startsWith(TOKEN_LABEL_PREFIX)); if (!owned) { + this.deps.telemetry?.event('token_cap_blocked'); // at cap, nothing of ours to reclaim throw new Error( 'This account has reached its 10-token limit and none belong to IronBee for VS Code. ' + 'Remove an access token from the IronBee console, then try again.', ); } await this.deps.console.deleteAccessToken(owned.id); - return await this.deps.console.mintAccessToken(label); + const reminted: MintedToken = await this.deps.console.mintAccessToken(label); + this.deps.telemetry?.event('token_cap_recovered'); // reclaimed an owned token + re-minted + return reminted; } throw err; } diff --git a/src/auth/authManager.ts b/src/auth/authManager.ts index bb6ec6d..737ec05 100644 --- a/src/auth/authManager.ts +++ b/src/auth/authManager.ts @@ -25,6 +25,8 @@ export interface AuthManagerDeps { openUrl: (url: string) => Promise; fetchFn?: typeof fetch; now?: () => number; + /** Optional fire-and-forget product-signal sink (never throws). */ + onEvent?: (name: string, props?: Record) => void; } /** Owns the Cognito session: PKCE loopback sign-in, refresh, sign-out. */ @@ -56,6 +58,7 @@ export class AuthManager { // First social sign-in for an existing user links the identity and cancels that attempt; // the second attempt succeeds. Retry once, automatically (per the backend hand-off). if (err instanceof CognitoCallbackError && err.isProviderLinkRetry() && !signal?.aborted) { + this.deps.onEvent?.('signin_provider_link_retry'); await this.attemptSignIn(timeoutMs, signal); return; } diff --git a/src/config/ironbeeConfig.ts b/src/config/ironbeeConfig.ts index 613f76d..98ea26b 100644 --- a/src/config/ironbeeConfig.ts +++ b/src/config/ironbeeConfig.ts @@ -133,6 +133,50 @@ export async function writeDevtoolsMcp( await atomicWriteFile(configPath, JSON.stringify(cfg, null, 2) + '\n'); } +export interface DevtoolsMcpEntry { + command: string; + args: string[]; + env?: Record; +} + +/** + * True when a global `ironbeeDevTools.mcp` block was (almost certainly) written by a PRIOR version of + * THIS extension — i.e. its args point inside an editor extensions dir owned by us + * (`…/extensions/ironbee-ai.ironbee-vscode-…`). Used to migrate away from the old global-write + * behavior WITHOUT clobbering an override a user set by hand or via the CLI for standalone use. + */ +export function isExtensionOwnedDevtoolsMcp(mcp: DevtoolsMcpEntry | undefined): boolean { + if (mcp === undefined || !Array.isArray(mcp.args)) { + return false; + } + return mcp.args.some( + (a: unknown): boolean => typeof a === 'string' && /[/\\]extensions[/\\]ironbee-ai\.ironbee-vscode/i.test(a), + ); +} + +/** + * Remove a stale `ironbeeDevTools.mcp` override so the CLI falls back to its default. The extension + * no longer writes this block to the SHARED global config (it passes the bundled entry per-project + * via `IRONBEE_DEVTOOLS_MCP` at install time instead), so on activation it migrates away any block a + * prior version left in global. Pass `shouldClear` to remove ONLY entries we own — never a user's + * own hand-set/CLI override (which would break their standalone CLI usage). No-op when nothing matches. + */ +export async function clearDevtoolsMcp( + configPath: string = homeIronbeeConfigPath(), + shouldClear?: (mcp: DevtoolsMcpEntry) => boolean, +): Promise { + const cfg: IronbeeGlobalConfig = await readGlobalConfig(configPath); + const devtools: NonNullable | undefined = cfg.ironbeeDevTools; + if (devtools?.mcp === undefined) { + return; + } + if (shouldClear !== undefined && !shouldClear(devtools.mcp)) { + return; // present but not ours — leave the user's override untouched + } + delete devtools.mcp; + await atomicWriteFile(configPath, JSON.stringify(cfg, null, 2) + '\n'); +} + /** True when a usable collector credential is already present (skip-if-authed, EXT-1). */ export function hasCollectorToken(cfg: IronbeeGlobalConfig): boolean { const t: string | undefined = cfg.collector?.oauthToken; diff --git a/src/extension.ts b/src/extension.ts index 281d4f6..20f1d05 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { createRequire } from 'node:module'; import { EXTENSION_ID_PREFIX, clearCollectorTokenFromGlobalConfig, + clearOwnedDevtoolsMcpFromGlobalConfig, isRealUninstall, readObsoleteMap, runCliUninstallAll, @@ -19,7 +20,8 @@ import { AccountManager } from './accounts/accountManager'; import { writeCollectorToken, writeDevtoolsEnv, - writeDevtoolsMcp, + clearDevtoolsMcp, + isExtensionOwnedDevtoolsMcp, writeEnvironmentEndpoints, writePrivacyMode, clearCollectorToken, @@ -37,22 +39,36 @@ import { MODE_DESCRIPTIONS, PLATFORM_DESCRIPTIONS } from './ui/descriptions'; import { runUninstall, type RunnerContext, type VerificationMode } from './runtime/cliRunner'; import { StatusBar } from './ui/statusBar'; import { ensureAnonymousId, emitEvent } from './lifecycle/telemetry'; +import { redact } from './util/redact'; import browserVersions from './generated/browser-versions.json'; const require_: NodeJS.Require = createRequire(__filename); let statusBar: StatusBar | undefined; -const output: () => vscode.OutputChannel = (): vscode.OutputChannel => outputChannel; let outputChannel: vscode.OutputChannel; let extensionContext: vscode.ExtensionContext | undefined; let authManager: AuthManager | undefined; // kept for deactivate (full sign-out on real uninstall) +let currentUserEmail: string | undefined; // last-known signed-in email, attached to telemetry ($set.email) +// Bundled devtools entry as an IRONBEE_DEVTOOLS_MCP JSON, passed to `ironbee install` so it bakes a +// PER-PROJECT .cursor/mcp.json (no global config write). undefined in npx/universal mode. +let devtoolsMcpJson: string | undefined; export async function activate(context: vscode.ExtensionContext): Promise { + try { + await activateInner(context); + } catch (err) { + // A fatal activation error would otherwise only show VS Code's generic banner — record it. + emitErrorEvent('activate', err, false); + throw err; // still let VS Code mark activation as failed + } +} + +async function activateInner(context: vscode.ExtensionContext): Promise { extensionContext = context; outputChannel = vscode.window.createOutputChannel('IronBee'); // Prod by default; a developer's ~/.ironbee/vscode/config.json overrides it (dev/staging). const envConfig: EnvConfig = await loadEnvConfig().catch((e: unknown): EnvConfig => { - log(`~/.ironbee/vscode/config.json ignored (${(e as Error).message}); using prod defaults`); + logError('env-config-load (ignored; using prod defaults)', e); return DEFAULT_ENV_CONFIG; }); log(`environment: ${envConfig.env}`); @@ -62,6 +78,7 @@ export async function activate(context: vscode.ExtensionContext): Promise env: envConfig, store, openUrl: async (url: string): Promise => vscode.env.openExternal(vscode.Uri.parse(url)), + onEvent: (name: string, props?: Record): void => track(name, props), }); authManager = auth; const console: ConsoleClient = new ConsoleClient(envConfig.consoleApiBase, (force?: boolean): Promise => auth.getIdToken(force)); @@ -74,6 +91,10 @@ export async function activate(context: vscode.ExtensionContext): Promise refreshSession: async (): Promise => { await auth.getIdToken(true); }, + telemetry: { + event: (name: string, props?: Record): void => track(name, props), + error: (context: string, err: unknown): void => logError(context, err), + }, }); statusBar = new StatusBar(); @@ -82,20 +103,24 @@ export async function activate(context: vscode.ExtensionContext): Promise // Point devtools at the bundled copy (platform-specific VSIX) or the npx default (universal // VSIX). In both cases tell it NOT to download browsers — the extension pre-installs Chromium. const devtoolsMode: 'bundled' | 'npx' = await wireDevtools().catch((e: unknown): 'npx' => { - log(`could not wire devtools: ${(e as Error).message}`); + logError('devtools-wiring (fell back to npx)', e); return 'npx' as const; }); const svc: Services = { auth, console, accounts, envConfig }; registerCommands(context, svc); - await refreshStatus(auth, console); + await refreshStatus(auth, console); // populates currentUserEmail before any event fires + + // Diagnostic: which devtools delivery ran — bundled (platform-specific VSIX) vs npx (universal). + // After refreshStatus so a signed-in user's email rides along. + track('devtools_mode', { mode: devtoolsMode }); // Mirror the privacy-mode setting into ~/.ironbee/config.json (at activation + on change). - void syncPrivacyMode(); + void syncPrivacyMode().catch((e: unknown): void => logError('privacy-sync', e)); context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((e: vscode.ConfigurationChangeEvent): void => { if (e.affectsConfiguration('ironbee.privacy.enable')) { - void syncPrivacyMode(); + void syncPrivacyMode().catch((err: unknown): void => logError('privacy-sync', err)); } }), ); @@ -103,16 +128,18 @@ export async function activate(context: vscode.ExtensionContext): Promise // Non-blocking + guarded so nothing here fails activation (commands are already registered). // Chromium is always pre-installed (node-independent); the npx devtools pre-warm only applies // to the universal build (the platform-specific build bundles devtools, so nothing to fetch). - void ensureBrowsersOnUpgrade(context).catch((e: unknown): void => log(`browser pre-install skipped: ${(e as Error).message}`)); + void ensureBrowsersOnUpgrade(context).catch((e: unknown): void => logError('browser-preinstall', e)); if (devtoolsMode === 'npx') { - void ensureDevtoolsPrewarmed(context).catch((e: unknown): void => log(`devtools pre-warm skipped: ${(e as Error).message}`)); + void ensureDevtoolsPrewarmed(context).catch((e: unknown): void => logError('devtools-prewarm', e)); } // Onboarding nudge — runs INDEPENDENTLY (never chained to the network rotation below, so a slow/ // hung rotation can't stop it from firing). It gates on the config token, so at worst a valid- // session user whose token gets refilled a moment later sees one dismissible prompt. - void firstRunAndSuggest(context, auth).catch((e: unknown): void => log(`first-run/suggest skipped: ${(e as Error).message}`)); + void firstRunAndSuggest(context, auth).catch((e: unknown): void => logError('first-run/suggest', e)); // Proactively rotate/refill the collector token before its ~90-day expiry (quietly, if signed in). - void rotateCollectorTokenOnStartup(svc).catch((e: unknown): void => log(`startup token check skipped: ${(e as Error).message}`)); + void rotateCollectorTokenOnStartup(svc).catch((e: unknown): void => logError('startup-token-check', e)); + // Extension-lifecycle telemetry (install/upgrade + activated). Fire-and-forget. + void trackActivationLifecycle(context).catch((): void => {}); } /** @@ -129,7 +156,7 @@ async function rotateCollectorTokenOnStartup(svc: Services): Promise { const current: Account = await svc.console.currentAccount(); await svc.accounts.ensureCollectorToken(current.id); } catch (err) { - log(`startup collector-token check failed: ${(err as Error).message}`); + logError('startup-collector-token', err); } } @@ -148,25 +175,71 @@ function resolveBundledDevtoolsEntry(): string | undefined { * Otherwise the CLI keeps its `npx @ironbee-ai/devtools` default and we just suppress its browser * download (Chromium is pre-installed by the extension). * - * NOTE (bundled variant): the persisted MCP path is version-scoped to the extension dir, so on an - * extension upgrade already-set-up projects should be reconfigured to refresh it. TODO for the - * platform-specific track: re-run `ironbee install` for registered projects when the path changes. + * The bundled entry is NOT written to the shared global config; it is passed per-project via + * `IRONBEE_DEVTOOLS_MCP` at `ironbee install` time, which bakes it into each project's own + * `.cursor/mcp.json`. NOTE: that baked path is still version-scoped to the extension dir, so on an + * extension upgrade already-set-up projects should be re-configured (re-run setup) to refresh it. */ async function wireDevtools(): Promise<'bundled' | 'npx'> { const wiring: ReturnType = decideDevtoolsWiring(resolveBundledDevtoolsEntry(), process.execPath); + // We NEVER write the devtools `mcp` entry into the SHARED global ~/.ironbee/config.json anymore: + // that version-scoped absolute path affects every project and goes stale on upgrade/switch. Migrate + // away any block a PRIOR version of this extension left in global — but ONLY ours (path inside our + // editor-extensions dir), never a user's own hand-set/CLI override. + await clearDevtoolsMcp(undefined, isExtensionOwnedDevtoolsMcp); if (wiring.mode === 'bundled') { - await writeDevtoolsMcp(wiring.mcp); - log('devtools: bundled (platform-specific) — runs via the editor Node, no npx'); + // Carry the bundled entry as IRONBEE_DEVTOOLS_MCP for `ironbee install`, which bakes it into + // THIS project's own .cursor/mcp.json (per-project override; no global write). + devtoolsMcpJson = JSON.stringify(wiring.mcp); + log('devtools: bundled (platform-specific) — per-project mcp via IRONBEE_DEVTOOLS_MCP, no global write'); return 'bundled'; } + devtoolsMcpJson = undefined; + // npx (universal): the CLI bakes its own `npx @ironbee-ai/devtools` default entry; we only suppress + // the browser download. This env is generic + non-version-scoped, so it never causes stale paths. await writeDevtoolsEnv(wiring.env); + log('devtools: npx (universal) — CLI default entry, browser download suppressed'); return 'npx'; } +const EXTENSION_ID: string = 'ironbee-ai.ironbee-vscode'; +const GITHUB_ISSUES_BASE: string = 'https://github.com/ironbee-ai/ironbee-vscode/issues/new'; +const TELEMETRY_VERSION_KEY: string = 'ironbee.telemetry.lastVersion'; +const EVENT_PREFIX: string = 'cursor_ext_'; + function telemetryEnabled(): boolean { return vscode.workspace.getConfiguration('ironbee').get('telemetry.enable', true); } +function extensionVersion(): string { + return (vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON as { version?: string } | undefined)?.version ?? ''; +} + +/** + * Coarse context attached to every event. When a user is signed in, the last-known email (cached + * from refreshStatus — never a per-event API call) rides along BOTH as an event property (`email`, + * for immediate per-event filtering) AND as the PostHog person property via `$set.email` (the + * standard reserved key PostHog's UI recognizes, for People search). The distinct id stays the + * shared anonymous id. + */ +function baseTelemetryProperties(): Record { + const props: Record = { + source: 'ironbee-vscode', + extension_id: EXTENSION_ID, + extension_version: extensionVersion(), + node_version: process.version, + os_platform: process.platform, + os_arch: process.arch, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timestamp: new Date().toISOString(), + }; + if (currentUserEmail) { + props.email = currentUserEmail; // event property — immediate per-event filtering + props.$set = { email: currentUserEmail }; // person property — People search + persists + } + return props; +} + /** * Sync `ironbee.privacy.enable` → global config's `privacy.enable`, but ONLY when the user set it * explicitly in the editor — otherwise our default would clobber a value set via the CLI's own TUI. @@ -181,11 +254,117 @@ async function syncPrivacyMode(): Promise { if (explicit === undefined) { return; // not set in the editor — leave whatever the CLI/config has } - await writePrivacyMode(explicit).catch((e: unknown): void => log(`could not sync privacy mode: ${(e as Error).message}`)); + await writePrivacyMode(explicit).catch((e: unknown): void => logError('privacy-sync', e)); +} + +/** + * Fire-and-forget event. Callers pass the SHORT name; every event is namespaced with the + * `cursor_ext_` prefix here (single source of truth). Never throws or blocks. + */ +function track(event: string, properties: Record = {}): void { + void emitEvent(EVENT_PREFIX + event, { + enabled: telemetryEnabled(), + properties: { ...baseTelemetryProperties(), ...properties }, + }).catch((): void => {}); +} + +/** + * Extension-lifecycle telemetry at activation: a `cursor_ext_installed` on first run after an + * install/upgrade (detected by comparing the stored version), then always `cursor_ext_activated`. + * NOTE: globalState survives uninstall, so a reinstall of the SAME version won't re-fire `installed`. + */ +async function trackActivationLifecycle(context: vscode.ExtensionContext): Promise { + const version: string = extensionVersion(); + const previous: string | undefined = context.globalState.get(TELEMETRY_VERSION_KEY); + if (previous !== version) { + track('installed', { previous_version: previous ?? null, upgrade: previous !== undefined }); + await context.globalState.update(TELEMETRY_VERSION_KEY, version).then(undefined, (): void => {}); + } + track('activated'); } -function track(event: string): void { - void emitEvent(event, { enabled: telemetryEnabled() }).catch((): void => {}); +/** Build a prefilled GitHub new-issue URL for our repo (query params encoded). */ +function buildGitHubIssueUrl(title: string, body?: string): string { + const params: URLSearchParams = new URLSearchParams(); + params.set('title', title); + if (body) { + params.set('body', body); + } + return `${GITHUB_ISSUES_BASE}?${params.toString()}`; +} + +/** + * Format an error for the issue body: extension version, type, message, stack. Uses `**` headings so + * `##` is not URL-encoded to `%23%23` in the issue URL. + */ +function formatErrorForIssueBody(error: unknown, version: string): string { + const lines: string[] = []; + if (version) { + lines.push(`**Extension version:** ${version}`, ''); + } + if (error instanceof Error) { + lines.push( + '**Error details**', + '', + `**Type:** \`${error.constructor?.name ?? 'Error'}\``, + '', + `**Message:** ${error.message}`, + '', + '**Stack:**', + '```', + error.stack ?? '(no stack)', + '```', + ); + // redact() the whole body — a message/stack can carry ibt_ tokens, JWTs, OAuth codes, and + // this text is prefilled into a (potentially public) GitHub issue URL. + return redact(lines.join('\n')); + } + lines.push(`**Message:** ${String(error)}`); + return redact(lines.join('\n')); +} + +/** + * Fire a `cursor_ext_error` event (fire-and-forget). `surfaced` records whether the failure was also + * shown to the user (reportError) or only logged (logError), so both can be filtered in PostHog. + */ +function emitErrorEvent(context: string, error: unknown, surfaced: boolean): void { + // redact() before anything leaves the machine — error strings can carry ibt_ tokens, JWTs, + // OAuth codes, bearer headers, etc. + const rawMessage: string | undefined = error instanceof Error ? error.message : error !== undefined ? String(error) : undefined; + track('error', { + context: redact(context).slice(0, 200), + surfaced, + error_type: error instanceof Error ? (error.constructor?.name ?? 'Error') : undefined, + error_message: rawMessage !== undefined ? redact(rawMessage) : undefined, + }); +} + +/** + * Silent error report: write to the output channel AND fire `cursor_ext_error` — no UI. For + * best-effort/background failures we don't want to interrupt the user over. Never throws or blocks. + */ +function logError(context: string, error: unknown): void { + log(redact(`${context}: ${error instanceof Error ? error.message : String(error)}`)); + emitErrorEvent(context, error, false); +} + +/** + * Surface a failure: fire a `cursor_ext_error` event and show the message with an "Open issue on + * GitHub" action that deep-links to our repo's new-issue form (prefilled with the error when given). + */ +function reportError(message: string, error?: unknown, opts: { warning?: boolean } = {}): void { + emitErrorEvent(message, error, true); + const show: typeof vscode.window.showErrorMessage = opts.warning + ? vscode.window.showWarningMessage + : vscode.window.showErrorMessage; + void show(message, 'Open issue on GitHub').then((choice: string | undefined): void => { + if (choice !== 'Open issue on GitHub') { + return; + } + const title: string = redact(message.slice(0, 100).replace(/\s+/g, ' ').trim()); + const body: string | undefined = error !== undefined ? formatErrorForIssueBody(error, extensionVersion()) : undefined; + void vscode.env.openExternal(vscode.Uri.parse(buildGitHubIssueUrl(title, body))); + }); } export async function deactivate(): Promise { @@ -210,17 +389,27 @@ export async function deactivate(): Promise { }, extensionIdPrefix: EXTENSION_ID_PREFIX, }); + const enabled: boolean = telemetryEnabled(); if (real) { + // AWAIT the uninstall event so the HTTPS request completes before the host tears us down + // (a fire-and-forget track() would be cut off). Never throws. + await emitEvent(EVENT_PREFIX + 'uninstalled', { enabled, properties: baseTelemetryProperties() }).catch((): void => {}); // Critical clears FIRST (fast, so they finish inside the shutdown budget): exactly what // sign-out does — revoke + clear SecretStorage (Cognito session + cached collector tokens), // which survives uninstall and would otherwise leave a reinstall "signed in" and refill the // token without asking. Then drop the config token. The slow project uninstall runs LAST. await authManager?.signOut().catch((): void => undefined); clearCollectorTokenFromGlobalConfig(); // drop the extension-managed collector.oauthToken + clearOwnedDevtoolsMcpFromGlobalConfig(); // drop an owned devtools mcp a prior version wrote to global runCliUninstallAll(extPath, process.execPath); + } else { + // Reload/shutdown/window-close — best-effort (may be cut short if the host exits fast). + await emitEvent(EVENT_PREFIX + 'deactivated', { enabled, properties: baseTelemetryProperties() }).catch((): void => {}); } - } catch { - /* non-fatal — never block the host from shutting down */ + } catch (err) { + // Non-fatal — never block the host from shutting down. Best-effort telemetry only (no UI/log: + // the output channel may already be disposed); the request may be cut short on a fast exit. + emitErrorEvent('deactivate-cleanup', err, false); } } @@ -234,8 +423,21 @@ interface Services { } function registerCommands(context: vscode.ExtensionContext, svc: Services): void { + // Safety net: VS Code does not surface a command handler's rejected promise (it only logs to the + // dev console), so wrap every handler — any error that a handler didn't already report itself + // lands here as `cursor_ext_error` (via reportError). Handlers that catch + report internally + // resolve normally, so there's no double-report. const reg: (id: string, cb: (...a: unknown[]) => unknown) => number = (id: string, cb: (...a: unknown[]) => unknown): number => - context.subscriptions.push(vscode.commands.registerCommand(id, cb)); + context.subscriptions.push( + vscode.commands.registerCommand(id, async (...args: unknown[]): Promise => { + try { + return await cb(...args); + } catch (err) { + reportError(`IronBee command '${id}' failed: ${(err as Error).message}`, err); + return undefined; + } + }), + ); reg('ironbee.signIn', (): Promise => signIn(svc)); reg('ironbee.signOut', (): Promise => signOut(svc)); @@ -273,7 +475,7 @@ async function signIn(svc: Services): Promise { if (err instanceof SignInAbortedError) { return; // user cancelled — the progress is already gone; no error toast } - void vscode.window.showErrorMessage(`IronBee sign-in failed: ${(err as Error).message}`); + reportError(`IronBee sign-in failed: ${(err as Error).message}`, err); } } @@ -287,8 +489,9 @@ async function maybePromptPendingInvitations(svc: Services): Promise { let pending: Awaited>; try { pending = await svc.console.pendingInvitations(); - } catch { - return; // endpoint unavailable / not entitled — nothing to surface + } catch (err) { + logError('pending-invitations', err); // endpoint unavailable / not entitled — no UI, just record + return; } if (!pending || pending.length === 0) { return; @@ -341,8 +544,8 @@ async function signOut(svc: Services): Promise { 'Also remove local CLI token', ); if (choice === 'Also remove local CLI token') { - await clearCollectorToken().catch((e: unknown): Thenable => - vscode.window.showErrorMessage(`Could not remove local token: ${(e as Error).message}`), + await clearCollectorToken().catch((e: unknown): void => + reportError(`Could not remove local token: ${(e as Error).message}`, e), ); } await refreshStatus(svc.auth, svc.console); @@ -400,7 +603,7 @@ async function switchAccount(svc: Services): Promise { void vscode.window.showInformationMessage(`Switched to ${pick.name}.`); } catch (err) { if (!notifyAccessIssue(err, svc.envConfig)) { - void vscode.window.showErrorMessage(`Could not switch account: ${(err as Error).message}`); + reportError(`Could not switch account: ${(err as Error).message}`, err); } } } @@ -428,22 +631,25 @@ async function requireSignIn(svc: Services): Promise { async function installIntoProject(svc: Services): Promise { const cliEntry: string | undefined = resolveCliEntry(); if (!cliEntry) { - void vscode.window.showErrorMessage('IronBee CLI is not bundled in this build.'); + reportError('IronBee CLI is not bundled in this build.'); return; } // Sign-in is required to set up IronBee — verification is tied to the user's account. if (!(await requireSignIn(svc))) { + track('setup_cancelled', { at: 'sign_in' }); return; } // 1) Which projects — checkbox list of open folders + a folder browser for custom paths. const folders: string[] | undefined = await pickProjects(); if (!folders || folders.length === 0) { + track('setup_cancelled', { at: 'project_selection' }); return; } const cfg: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration('ironbee'); // 2) Mode — asked ONCE, applied to all selected projects. const mode: VerificationMode | undefined = await pickMode(cfg.get('install.defaultMode', 'assist')); if (!mode) { + track('setup_cancelled', { at: 'mode_selection' }); return; } @@ -456,7 +662,14 @@ async function installIntoProject(svc: Services): Promise { (): Promise => setUpFolder(folder, mode, { pickPlatforms: (folderDir: string): Promise => pickPlatformsFor(folderDir), - runner: { nodePath: process.execPath, cliEntry, log: (l: string): void => log(l) }, + runner: { + nodePath: process.execPath, + cliEntry, + log: (l: string): void => log(l), + // Bundled build: bake the bundled devtools entry into THIS project's mcp.json + // (per-project), instead of the shared global config. + env: devtoolsMcpJson ? { IRONBEE_DEVTOOLS_MCP: devtoolsMcpJson } : undefined, + }, }), ); outcomes.push(outcome); @@ -519,7 +732,7 @@ async function ensureCollectorTokenForActiveAccount(svc: Services): Promise - log(`could not write console/collector URLs: ${(e as Error).message}`), + logError('write-env-endpoints', e), ); if (!(await svc.auth.isSignedIn())) { @@ -548,7 +761,7 @@ async function ensureCollectorTokenForActiveAccount(svc: Services): Promise !o.cancelled && o.failed.length === 0 && o.installed.length > 0); const failed: FolderOutcome[] = outcomes.filter((o: FolderOutcome): boolean => o.failed.length > 0); if (ok.length > 0) { - track('install'); + track('project_setup', { project_count: ok.length }); } if (failed.length > 0) { - void vscode.window.showErrorMessage( + track('project_setup_failed', { project_count: failed.length }); + reportError( `IronBee setup failed for ${failed.length} project(s): ${failed.map((o: FolderOutcome): string => path.basename(o.folder)).join(', ')}. See the IronBee output.`, ); outputChannel.show(true); @@ -581,7 +795,7 @@ function reportSetupOutcomes(outcomes: FolderOutcome[]): void { async function uninstallFromProject(): Promise { const cliEntry: string | undefined = resolveCliEntry(); if (!cliEntry) { - void vscode.window.showErrorMessage('IronBee CLI is not bundled in this build.'); + reportError('IronBee CLI is not bundled in this build.'); return; } // Only offer folders that are actually set up. @@ -627,13 +841,14 @@ async function uninstallFromProject(): Promise { (ok ? removed : failed).push(p.dir); } if (failed.length > 0) { - void vscode.window.showErrorMessage( + track('project_uninstall_failed', { project_count: failed.length }); + reportError( `Could not remove IronBee from ${failed.length} project(s): ${failed.map((d: string): string => path.basename(d)).join(', ')}. See the IronBee output.`, ); outputChannel.show(true); } if (removed.length > 0) { - track('uninstall'); + track('project_uninstall', { project_count: removed.length }); void vscode.window.showInformationMessage( `IronBee removed from ${removed.length} project(s): ${removed.map((d: string): string => path.basename(d)).join(', ')}.`, ); @@ -663,6 +878,7 @@ async function installBrowsers(context: vscode.ExtensionContext, extensionPath: onChromiumFailure: (detail: string): Promise => promptSystemChromeFallback(detail), }), ); + track('browser_install', { ok, revision: browserVersions.chromiumRevision }); if (ok) { log(`browsers ready (chromium rev ${browserVersions.chromiumRevision})`); await context.globalState.update(BROWSERS_MARK, browserVersions.chromiumRevision); @@ -691,6 +907,7 @@ async function promptSystemChromeFallback(detail: string): Promise { 'Not now', ); if (choice === 'Use Google Chrome') { + track('browser_system_fallback_accepted'); await vscode.workspace .getConfiguration('ironbee') .update('browser.useSystemBrowser', true, vscode.ConfigurationTarget.Global); @@ -717,6 +934,7 @@ async function ensureDevtoolsPrewarmed(context: vscode.ExtensionContext): Promis { location: vscode.ProgressLocation.Window, title: 'IronBee: preparing verification tools…' }, (): Promise => prewarmDevtools({ spec, log: (l: string): void => log(l) }), ); + track('devtools_prewarm', { ok: res.ok, reason: res.ok ? undefined : res.reason }); if (res.ok) { log(`devtools pre-warmed (${spec})`); await context.globalState.update(PREWARM_MARK, spec); @@ -780,7 +998,8 @@ async function showStatus(svc: Services): Promise { `Account: ${current.name ?? current.id}`, `Role: ${current.role}`, ].join('\n'); - } catch { + } catch (err) { + logError('show-status', err); detail = 'Signed in (account details unavailable — offline?)'; } const choice: string | undefined = await vscode.window.showInformationMessage('IronBee', { modal: true, detail }, 'Switch Account'); @@ -902,13 +1121,13 @@ async function firstRunAndSuggest(context: vscode.ExtensionContext, auth: AuthMa statusBar?.needsProjectSetup(); const choice: string | undefined = await vscode.window.showInformationMessage( 'IronBee can verify this project’s changes — set it up in one click.', - 'Set up IronBee', + 'Set up', 'Later', - "Don't ask for this project", + "Don't ask again", ); - if (choice === 'Set up IronBee') { + if (choice === 'Set up') { await vscode.commands.executeCommand('ironbee.installIntoProject'); - } else if (choice === "Don't ask for this project") { + } else if (choice === "Don't ask again") { await context.workspaceState.update(key, true); } } @@ -928,7 +1147,8 @@ async function isSetUp(folderDir: string): Promise { async function refreshStatus(auth: AuthManager, console: ConsoleClient): Promise { if (!(await auth.isSignedIn())) { - // State (b): a CLI collector token exists but no Cognito session → distinct label. + currentUserEmail = undefined; // signed out → drop the cached email from telemetry + // State (b): a CLI collector token exists but no Cognito session → distinct label. if (await hasLocalCollectorToken().catch((): boolean => false)) { statusBar?.collectorOnly(); } else { @@ -938,12 +1158,15 @@ async function refreshStatus(auth: AuthManager, console: ConsoleClient): Promise } try { const [me, current]: [{ id: string; email: string }, Account] = await Promise.all([console.usersMe(), console.currentAccount()]); + currentUserEmail = me.email; // cache for telemetry — no per-event API call statusBar?.signedIn(me.email, current.name ?? current.id); } catch (err) { if (err instanceof NotSignedInError) { + currentUserEmail = undefined; statusBar?.signedOut(); } else { // Signed in but API unreachable (e.g. offline / BE-1 pending): keep a neutral label. + logError('refresh-status', err); statusBar?.signedIn(undefined, null); } } @@ -958,5 +1181,11 @@ function resolveCliEntry(): string | undefined { } function log(line: string): void { - output().appendLine(line); + // Guarded: the output channel may not exist yet (very early) or be disposed (during shutdown); + // a logging call must never throw into its caller (e.g. a manager's telemetry error sink). + try { + outputChannel.appendLine(line); + } catch { + /* channel not yet created or already disposed — drop the line */ + } } diff --git a/src/lifecycle/telemetry.ts b/src/lifecycle/telemetry.ts index 6ee96e1..f9b71ff 100644 --- a/src/lifecycle/telemetry.ts +++ b/src/lifecycle/telemetry.ts @@ -1,23 +1,34 @@ import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as https from 'node:https'; +import type * as http from 'node:http'; import * as crypto from 'node:crypto'; import { atomicWriteFile } from '../util/atomicWrite'; /** - * Anonymous telemetry identity, stored in the SHARED `~/.ironbee-devtools/config.json` - * (jointly owned with ironbee-devtools-vscode — the extension must NOT delete this file - * on uninstall). Events carry only { anonymousId, event } — never email/account id. + * Anonymous telemetry identity + event transport, stored in the SHARED `~/.ironbee/telemetry.json` + * (jointly owned by all IronBee tools — ironbee-cli, @ironbee-ai/devtools, and the editor + * extensions — which all read/write the same anonymousId there). It must NOT be deleted on + * uninstall. The distinct id stays the anonymous id; when the caller is signed in it may attach the + * user's email as the PostHog person property via `properties.$set.email` (opt-in by being signed + * in). Opt-out is honoured by the caller via the `enabled` flag (the `ironbee.telemetry.enable` + * setting). */ export interface TelemetryConfig { anonymousId: string; telemetryEnabled?: boolean; - telemetryNoticeShown?: boolean; [k: string]: unknown; } +// Shared IronBee PostHog project. Raw HTTPS ingestion — no posthog-node client needed for +// fire-and-forget capture. Overridable via env for a non-prod project. +const POSTHOG_API_KEY: string = process.env.IRONBEE_POSTHOG_API_KEY || 'phc_ekFEnQ9ipk0F1BbO0KCkaD8OaYPa4bIqqUoxsCfeFsy'; +const POSTHOG_HOST: string = 'us.i.posthog.com'; +const POSTHOG_PATH: string = '/i/v0/e/'; + export function sharedTelemetryPath(): string { - return path.join(os.homedir(), '.ironbee-devtools', 'config.json'); + return path.join(os.homedir(), '.ironbee', 'telemetry.json'); } export async function readTelemetryConfig(configPath: string = sharedTelemetryPath()): Promise { @@ -41,24 +52,93 @@ export async function ensureAnonymousId(configPath: string = sharedTelemetryPath } export interface TelemetryEventPayload { - anonymousId: string; event: string; + distinctId: string; + properties: Record; +} + +export type TelemetryTransport = (payload: TelemetryEventPayload) => Promise; + +/** Give up on a hung request after this long so an awaited send (deactivate/uninstall) can't block. */ +const POSTHOG_TIMEOUT_MS: number = 3000; + +/** + * Raw HTTPS POST to PostHog's capture endpoint (`/i/v0/e/`). Fire-and-forget: any network/parse + * error resolves (never rejects) so telemetry can neither break nor block the extension. + */ +export function postHogTransport(apiKey: string = POSTHOG_API_KEY): TelemetryTransport { + return (payload: TelemetryEventPayload): Promise => + new Promise((resolve: () => void): void => { + let done: boolean = false; + let timer: ReturnType | undefined; + const finish: () => void = (): void => { + if (done) { + return; + } + done = true; + if (timer) { + clearTimeout(timer); + } + resolve(); + }; + try { + const body: string = JSON.stringify({ + api_key: apiKey, + event: payload.event, + distinct_id: payload.distinctId, + properties: payload.properties, + }); + const req: http.ClientRequest = https.request( + { + hostname: POSTHOG_HOST, + path: POSTHOG_PATH, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + timeout: POSTHOG_TIMEOUT_MS, + }, + (res: http.IncomingMessage): void => { + res.on('data', (): void => {}); + res.on('end', finish); + res.on('close', finish); + }, + ); + // A connected-but-silent server won't emit 'error'/'end'. Belt-and-suspenders: the + // socket 'timeout' handler destroys the request, AND a hard timer covers connect/DNS + // black-holes (where socket-timeout semantics are unreliable) so the awaited path + // (deactivate on uninstall) can never hang the extension-host shutdown. + req.on('timeout', (): void => { + req.destroy(); + }); + req.on('error', finish); + timer = setTimeout((): void => { + req.destroy(); + finish(); + }, POSTHOG_TIMEOUT_MS + 500); + timer.unref?.(); // don't keep the event loop alive for a background send + req.write(body); + req.end(); + } catch { + finish(); + } + }); } /** - * Emit a telemetry event (design EXT-9). Respects the opt-out and the notice-before-events - * rule, and carries ONLY { anonymousId, event } — never email/account id. The network - * transport/endpoint is deferred (not specified for this extension yet); callers inject one - * when available, else it is a no-op. + * Emit a telemetry event. Respects opt-out (`enabled`), attaches the shared anonymous id, and sends + * via PostHog by default (tests/callers may inject a transport). Carries ONLY the anonymous id + + * the caller's coarse properties — never email/account id. Never throws. */ export async function emitEvent( name: string, - opts: { enabled: boolean; transport?: (p: TelemetryEventPayload) => void; configPath?: string }, + opts: { enabled: boolean; properties?: Record; transport?: TelemetryTransport; configPath?: string }, ): Promise { - // Telemetry is on by default (opt-out via `enabled`) — no notice/consent gate. if (!opts.enabled) { return; } const cfg: TelemetryConfig = await ensureAnonymousId(opts.configPath); - (opts.transport ?? ((): void => {}))({ anonymousId: cfg.anonymousId, event: name }); + if (!cfg.anonymousId) { + return; + } + const transport: TelemetryTransport = opts.transport ?? postHogTransport(); + await transport({ event: name, distinctId: cfg.anonymousId, properties: opts.properties ?? {} }); } diff --git a/src/lifecycle/uninstallCleanup.ts b/src/lifecycle/uninstallCleanup.ts index 55779f2..e362d44 100644 --- a/src/lifecycle/uninstallCleanup.ts +++ b/src/lifecycle/uninstallCleanup.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { homeIronbeeConfigPath } from '../util/atomicWrite'; +import { isExtensionOwnedDevtoolsMcp, type DevtoolsMcpEntry } from '../config/ironbeeConfig'; /** Folder-name prefix identifying any installed version of this extension. */ export const EXTENSION_ID_PREFIX: string = 'ironbee-ai.ironbee-vscode-'; @@ -81,6 +82,33 @@ export function clearCollectorTokenFromGlobalConfig(configPath: string = homeIro } } +/** + * On a full extension uninstall, drop a devtools `mcp` override from the GLOBAL ~/.ironbee/config.json + * ONLY if a prior version of THIS extension wrote it (path inside our editor-extensions dir) — so it + * doesn't linger and break standalone CLI users after the extension is gone. Never touches a user's + * own hand-set/CLI override. Synchronous + best-effort. (Newer versions pass the entry per-project via + * IRONBEE_DEVTOOLS_MCP and never write global, but older ones did — this migrates that away on removal.) + */ +export function clearOwnedDevtoolsMcpFromGlobalConfig(configPath: string = homeIronbeeConfigPath()): void { + try { + if (!fs.existsSync(configPath)) { + return; + } + const cfg: Record = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record; + const devtools: Record | undefined = + cfg.ironbeeDevTools !== null && typeof cfg.ironbeeDevTools === 'object' + ? (cfg.ironbeeDevTools as Record) + : undefined; + if (devtools === undefined || !isExtensionOwnedDevtoolsMcp(devtools.mcp as DevtoolsMcpEntry | undefined)) { + return; // absent or not ours — leave it + } + delete devtools.mcp; + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n'); + } catch { + /* best-effort */ + } +} + export function runCliUninstallAll(extensionPath: string, execPath: string): void { try { const cliEntry: string = path.join(extensionPath, 'node_modules', '@ironbee-ai', 'cli', 'dist', 'index.js'); diff --git a/src/runtime/cliRunner.ts b/src/runtime/cliRunner.ts index 58c34c0..45aa73e 100644 --- a/src/runtime/cliRunner.ts +++ b/src/runtime/cliRunner.ts @@ -22,6 +22,12 @@ export interface RunnerContext { log?: (line: string) => void; /** Injectable spawn for tests. */ spawn?: typeof nodeSpawn; + /** + * Extra env for the spawned CLI. Used to pass `IRONBEE_DEVTOOLS_MCP` so `ironbee install` bakes the + * bundled devtools entry into THIS project's `.cursor/mcp.json` — a per-project override that never + * touches the shared global `~/.ironbee/config.json`. + */ + env?: Record; } /** @@ -100,7 +106,7 @@ function spawnCli(ctx: RunnerContext, args: string[], cwd: string, startErrLabel const options: SpawnOptions = { cwd, shell: false, - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ...ctx.env }, }; const stdoutSink: LineSink = lineSink(ctx.log); const stderrSink: LineSink = lineSink(ctx.log); diff --git a/test/accounts/accountManager.test.ts b/test/accounts/accountManager.test.ts index bbb7feb..50aec60 100644 --- a/test/accounts/accountManager.test.ts +++ b/test/accounts/accountManager.test.ts @@ -28,6 +28,7 @@ function setup(overrides: Partial = {}, now: () => number = () => 1 ...overrides, }; const store = new TokenStore(memSecrets(), 'prod'); + const telemetry = { event: vi.fn(), error: vi.fn() }; const deps: AccountManagerDeps = { console: console as never, store, @@ -36,8 +37,9 @@ function setup(overrides: Partial = {}, now: () => number = () => 1 writeCollector: async (url, token) => void writes.push([url, token]), refreshSession, now, + telemetry, }; - return { mgr: new AccountManager(deps), console, store, writes, refreshSession }; + return { mgr: new AccountManager(deps), console, store, writes, refreshSession, telemetry }; } const NOW = 1_700_000_000_000; @@ -74,7 +76,7 @@ describe('ensureCollectorToken', () => { }); it('rotates a near-expiry cached token: deletes the old one and mints a fresh one', async () => { - const { mgr, console, store, writes } = setup({ + const { mgr, console, store, writes, telemetry } = setup({ listAccessTokens: vi.fn(async () => [{ id: 'tokA', name: `${TOKEN_LABEL_PREFIX}host9`, expiresAt: iso(NOW + 2 * DAY) }]), }); await store.setCollectorToken('acc1', { token: 'ibt_old', id: 'tokA' }); @@ -83,6 +85,7 @@ describe('ensureCollectorToken', () => { expect(console.mintAccessToken).toHaveBeenCalled(); expect(writes).toEqual([['https://collector.x', 'ibt_new']]); // fresh token written expect((await store.getCollectorToken('acc1'))?.id).toBe('tok_new'); // cache updated + expect(telemetry.event).toHaveBeenCalledWith('collector_token_rotated', { reason: 'near_expiry' }); }); it('reuses the cached token on a transient list failure (no needless mint)', async () => { @@ -121,7 +124,7 @@ describe('ensureCollectorToken', () => { .fn() .mockRejectedValueOnce(new ConsoleError(409, '/access-tokens', 'TOKEN_LIMIT_EXCEEDED')) .mockResolvedValueOnce({ token: 'ibt_after', id: 'tok_after' }); - const { mgr, console } = setup({ + const { mgr, console, telemetry } = setup({ mintAccessToken: mint, listAccessTokens: vi.fn(async () => [ { id: 'foreign', name: 'someone-else' }, @@ -131,16 +134,18 @@ describe('ensureCollectorToken', () => { await mgr.ensureCollectorToken('acc1'); expect(console.deleteAccessToken).toHaveBeenCalledWith('mine'); expect(mint).toHaveBeenCalledTimes(2); + expect(telemetry.event).toHaveBeenCalledWith('token_cap_recovered'); }); it('at the cap with only foreign tokens, throws an actionable error (never deletes foreign)', async () => { const mint = vi.fn().mockRejectedValue(new ConsoleError(409, '/access-tokens', 'TOKEN_LIMIT_EXCEEDED')); - const { mgr, console } = setup({ + const { mgr, console, telemetry } = setup({ mintAccessToken: mint, listAccessTokens: vi.fn(async () => [{ id: 'foreign', name: 'someone-else' }]), }); await expect(mgr.ensureCollectorToken('acc1')).rejects.toThrow(/10-token limit/); expect(console.deleteAccessToken).not.toHaveBeenCalled(); + expect(telemetry.event).toHaveBeenCalledWith('token_cap_blocked'); }); }); @@ -183,10 +188,11 @@ describe('switchTo', () => { .mockResolvedValueOnce(undefined) // switch to acc2 ok .mockRejectedValueOnce(new Error('rollback switch failed')); // rollback fails const refreshSession = vi.fn().mockRejectedValue(new Error('refresh dead')); - const { mgr } = setup({ switchAccount }); + const { mgr, telemetry } = setup({ switchAccount }); (mgr as unknown as { deps: AccountManagerDeps }).deps.refreshSession = refreshSession; await expect(mgr.switchTo('acc2')).rejects.toThrow(); expect(mgr.isDirty()).toBe(true); + expect(telemetry.error).toHaveBeenCalledWith('account-switch-rollback', expect.anything()); }); it('surfaces a currentAccount() failure with no server switch or write', async () => { diff --git a/test/config/ironbeeConfig.test.ts b/test/config/ironbeeConfig.test.ts index 4e8abaa..b97c7a3 100644 --- a/test/config/ironbeeConfig.test.ts +++ b/test/config/ironbeeConfig.test.ts @@ -7,6 +7,8 @@ import { writeCollectorToken, writeDevtoolsEnv, writeDevtoolsMcp, + clearDevtoolsMcp, + isExtensionOwnedDevtoolsMcp, writeEnvironmentEndpoints, writePrivacyMode, hasCollectorToken, @@ -155,6 +157,50 @@ describe('writeDevtoolsMcp', () => { }); }); +describe('clearDevtoolsMcp (bundled → npx transition)', () => { + it('removes a stale bundled mcp override but keeps env + collector', async () => { + await writeCollectorToken('https://c', 'ibt_x', cfgPath); + await writeDevtoolsMcp( + { command: '/old/node', args: ['/deleted/ext-dir/devtools/dist/index.js'], env: { ELECTRON_RUN_AS_NODE: '1' } }, + cfgPath, + ); + await writeDevtoolsEnv({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' }, cfgPath); + await clearDevtoolsMcp(cfgPath); + const cfg = await readGlobalConfig(cfgPath); + expect(cfg.ironbeeDevTools?.mcp).toBeUndefined(); // stale bundled path dropped + expect(cfg.ironbeeDevTools?.env).toEqual({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' }); // env kept + expect(cfg.collector?.oauthToken).toBe('ibt_x'); // unrelated block kept + }); + + it('is a no-op (no throw, no file needed) when there is no mcp override', async () => { + await clearDevtoolsMcp(cfgPath); // missing file + await writeDevtoolsEnv({ A: 'b' }, cfgPath); + await clearDevtoolsMcp(cfgPath); // present config, no mcp + expect((await readGlobalConfig(cfgPath)).ironbeeDevTools?.env).toEqual({ A: 'b' }); + }); + + it('isExtensionOwnedDevtoolsMcp: true only for a path inside our editor-extensions dir', () => { + const owned = { command: '/ed/node', args: ['/Users/x/.cursor/extensions/ironbee-ai.ironbee-vscode-0.1.4-darwin-arm64/node_modules/@ironbee-ai/devtools/dist/index.js'] }; + const ownedVscode = { command: '/ed/node', args: ['C:\\Users\\x\\.vscode\\extensions\\ironbee-ai.ironbee-vscode-0.1.4-win32-x64\\node_modules\\@ironbee-ai\\devtools\\dist\\index.js'] }; + const userCustom = { command: 'node', args: ['/opt/my/devtools/index.js'] }; // hand-set / CLI override + expect(isExtensionOwnedDevtoolsMcp(owned)).toBe(true); + expect(isExtensionOwnedDevtoolsMcp(ownedVscode)).toBe(true); + expect(isExtensionOwnedDevtoolsMcp(userCustom)).toBe(false); + expect(isExtensionOwnedDevtoolsMcp(undefined)).toBe(false); + }); + + it('clearDevtoolsMcp with the owned predicate removes OUR block but leaves a user override', async () => { + // Ours → removed. + await writeDevtoolsMcp({ command: '/ed/node', args: ['/home/u/.cursor/extensions/ironbee-ai.ironbee-vscode-0.1.4-linux-x64/node_modules/@ironbee-ai/devtools/dist/index.js'] }, cfgPath); + await clearDevtoolsMcp(cfgPath, isExtensionOwnedDevtoolsMcp); + expect((await readGlobalConfig(cfgPath)).ironbeeDevTools?.mcp).toBeUndefined(); + // User's own → preserved. + await writeDevtoolsMcp({ command: 'node', args: ['/opt/custom/devtools.js'] }, cfgPath); + await clearDevtoolsMcp(cfgPath, isExtensionOwnedDevtoolsMcp); + expect((await readGlobalConfig(cfgPath)).ironbeeDevTools?.mcp).toEqual({ command: 'node', args: ['/opt/custom/devtools.js'] }); + }); +}); + describe('hasCollectorToken', () => { it('true only for an ibt_ token', () => { expect(hasCollectorToken({ collector: { oauthToken: 'ibt_abc' } })).toBe(true); diff --git a/test/lifecycle/telemetry.test.ts b/test/lifecycle/telemetry.test.ts index 2933315..93e2f76 100644 --- a/test/lifecycle/telemetry.test.ts +++ b/test/lifecycle/telemetry.test.ts @@ -2,26 +2,30 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { ensureAnonymousId, readTelemetryConfig, emitEvent } from '../../src/lifecycle/telemetry'; +import { ensureAnonymousId, readTelemetryConfig, emitEvent, sharedTelemetryPath } from '../../src/lifecycle/telemetry'; let dir: string; let cfgPath: string; beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ib-tel-')); - cfgPath = path.join(dir, '.ironbee-devtools', 'config.json'); + cfgPath = path.join(dir, '.ironbee', 'telemetry.json'); }); afterEach(async () => { await fs.rm(dir, { recursive: true, force: true }); }); describe('telemetry config', () => { + it('lives in the shared ~/.ironbee/telemetry.json (jointly owned by all IronBee tools)', () => { + expect(sharedTelemetryPath()).toBe(path.join(os.homedir(), '.ironbee', 'telemetry.json')); + }); + it('creates a UUID anonymous id when none exists', async () => { const cfg = await ensureAnonymousId(cfgPath); expect(cfg.anonymousId).toMatch(/^[0-9a-f-]{36}$/); expect((await readTelemetryConfig(cfgPath))?.anonymousId).toBe(cfg.anonymousId); }); - it('preserves an existing shared id (jointly owned with devtools-vscode)', async () => { + it('preserves an existing shared id (jointly owned with the CLI / devtools)', async () => { await fs.mkdir(path.dirname(cfgPath), { recursive: true }); await fs.writeFile(cfgPath, JSON.stringify({ anonymousId: 'preexisting', telemetryEnabled: true })); const cfg = await ensureAnonymousId(cfgPath); @@ -36,26 +40,37 @@ describe('telemetry config', () => { describe('emitEvent', () => { it('does nothing when telemetry is disabled (opt-out)', async () => { - const transport = vi.fn(); + const transport = vi.fn(async () => {}); await emitEvent('sign_in', { enabled: false, transport, configPath: cfgPath }); expect(transport).not.toHaveBeenCalled(); }); it('emits by default with NO notice/consent gate (just enabled), creating the id if missing', async () => { - const transport = vi.fn(); - await emitEvent('install', { enabled: true, transport, configPath: cfgPath }); + const transport = vi.fn(async () => {}); + await emitEvent('cursor_ext_activated', { enabled: true, transport, configPath: cfgPath }); expect(transport).toHaveBeenCalledTimes(1); const payload = transport.mock.calls[0][0]; - expect(payload.event).toBe('install'); - expect(payload.anonymousId).toMatch(/^[0-9a-f-]{36}$/); - // no PII - expect(Object.keys(payload).sort()).toEqual(['anonymousId', 'event']); + expect(payload.event).toBe('cursor_ext_activated'); + expect(payload.distinctId).toMatch(/^[0-9a-f-]{36}$/); + // Only { event, distinctId, properties } — no email/account id ever. + expect(Object.keys(payload).sort()).toEqual(['distinctId', 'event', 'properties']); + }); + + it('forwards the caller-supplied properties (coarse env only) to the transport', async () => { + const transport = vi.fn(async () => {}); + await emitEvent('cursor_ext_error', { + enabled: true, + transport, + configPath: cfgPath, + properties: { source: 'ironbee-vscode', error_message: 'boom' }, + }); + expect(transport.mock.calls[0][0].properties).toEqual({ source: 'ironbee-vscode', error_message: 'boom' }); }); - it('reuses the existing anonymous id', async () => { + it('reuses the existing anonymous id as the distinctId', async () => { const cfg = await ensureAnonymousId(cfgPath); - const transport = vi.fn(); + const transport = vi.fn(async () => {}); await emitEvent('switch_account', { enabled: true, transport, configPath: cfgPath }); - expect(transport).toHaveBeenCalledWith({ anonymousId: cfg.anonymousId, event: 'switch_account' }); + expect(transport).toHaveBeenCalledWith({ event: 'switch_account', distinctId: cfg.anonymousId, properties: {} }); }); }); diff --git a/test/runtime/cliRunner.test.ts b/test/runtime/cliRunner.test.ts index 818f736..c59ccdb 100644 --- a/test/runtime/cliRunner.test.ts +++ b/test/runtime/cliRunner.test.ts @@ -109,6 +109,28 @@ describe('runInstall', () => { expect(logs.join('\n')).toMatch(/failed to start.*ENOENT/); }); + it('passes ctx.env (e.g. IRONBEE_DEVTOOLS_MCP) into the spawn env alongside ELECTRON_RUN_AS_NODE', async () => { + await fs.mkdir(path.join(dir, '.ironbee')); + await fs.writeFile(path.join(dir, '.ironbee', 'config.json'), '{}'); + let capturedEnv: NodeJS.ProcessEnv | undefined; + const capturingSpawn = ((_cmd: string, _args: string[], options: { env?: NodeJS.ProcessEnv }) => { + capturedEnv = options.env; + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => child.emit('close', 0)); + return child; + }) as unknown as RunnerContext['spawn']; + const mcpJson = '{"command":"/ed/node","args":["/ext/devtools/dist/index.js"]}'; + const ctx: RunnerContext = { + nodePath: 'node', cliEntry: '/cli.js', spawn: capturingSpawn, + env: { IRONBEE_DEVTOOLS_MCP: mcpJson }, + }; + await runInstall(ctx, req()); + expect(capturedEnv?.IRONBEE_DEVTOOLS_MCP).toBe(mcpJson); + expect(capturedEnv?.ELECTRON_RUN_AS_NODE).toBe('1'); // ours is merged, not clobbering the base + }); + it('redacts secrets streamed to the log sink', async () => { await fs.mkdir(path.join(dir, '.ironbee')); await fs.writeFile(path.join(dir, '.ironbee', 'config.json'), '{}');