diff --git a/.gitignore b/.gitignore index b59b03e..769275f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ _.log /.agents/ /.claude/ -CLAUDE.local.md \ No newline at end of file +CLAUDE.local.md + +.env diff --git a/README.md b/README.md index df00b4a..b56c47c 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,15 @@ CodeLens links appear above HTTP client calls like `client.get('/items')`, letti | Setting | Description | Default | |---------|-------------|---------| | `fastapi.entryPoint` | Path to the main FastAPI application file (e.g., `src/main.py`). If not set, the extension searches common locations: `main.py`, `app/main.py`, `api/main.py`, `src/main.py`, `backend/app/main.py`. | `""` (auto-detect) | -| `fastapi.showTestCodeLenses` | Show CodeLens links above test client calls (e.g., `client.get('/items')`) to navigate to the corresponding route definition. | `true` | +| `fastapi.codeLens.enabled` | Show CodeLens links above test client calls (e.g., `client.get('/items')`) to navigate to the corresponding route definition. | `true` | +| `fastapi.telemetry.enabled` | Send anonymous usage data to help improve the extension. See [TELEMETRY.md](TELEMETRY.md) for details on what is collected. | `true` | **Note:** Currently the extension discovers one FastAPI app per workspace folder. If you have multiple apps, use separate workspace folders or configure `fastapi.entryPoint` to point to your primary app. +## Data and telemetry + +The FastAPI extension collects anonymous usage data and sends it to FastAPI to help improve the extension. You can disable telemetry by setting `fastapi.telemetry.enabled` to `false`. Read our [TELEMETRY.md](TELEMETRY.md) for details on what we collect and what we don't. + ## License +MIT -MIT \ No newline at end of file diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 0000000..293034d --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,61 @@ +# Telemetry + +The FastAPI VS Code extension collects anonymous usage data to help us understand how the extension is used and how we can improve it. This document describes exactly what data is collected. No personally identifiable information is collected. No information is shared with third parties. + +## How to disable telemetry + +You can disable telemetry in two ways: + +### Option 1: Disable all VS Code telemetry + +1. Open VS Code Settings (File > Preferences > Settings, or `Cmd+,` on macOS) +2. Search for `telemetry.telemetryLevel` +3. Set it to `off` + +This disables telemetry for VS Code and all extensions that respect this setting, including FastAPI. + +### Option 2: Disable only FastAPI telemetry + +1. Open VS Code Settings +2. Search for `fastapi.telemetry.enabled` +3. Uncheck the box (set to `false`) + +This disables only the FastAPI extension's telemetry while leaving other telemetry unchanged. + +**Note:** Telemetry is only sent when *both* VS Code's global telemetry (`telemetry.telemetryLevel`) is enabled *and* the extension setting (`fastapi.telemetry.enabled`) is `true`. Disabling either one will stop all telemetry collection. + +## What we collect + +We collect anonymous usage metrics to improve the extension. We do **not** collect: +- File paths or file contents +- Route paths or endpoint names +- Any code from your project +- IP addresses (geo-IP is disabled) + +**Note:** All events include contextual information: client type (VS Code, Cursor, etc.), OS platform, CPU architecture, extension version, and if available, the installed Python version and versions of related packages (FastAPI, Pydantic, Starlette, Typer, FastAPI CLI, FastAPI Cloud CLI) from your active interpreter. + +### Events + +| Event | Data | Why | +|-------|------|-----| +| Extension activated | Activation duration, success/failure, number of routes/routers/apps discovered, workspace folder count | Helps us understand startup performance, project sizes, and environment compatibility | +| Extension deactivated | Session duration (time from activation to deactivation) | Helps us understand how long users keep VS Code open with the extension active | +| Activation failed | Error category (e.g., "parse_error", "wasm_load_error"), failure stage | Helps us debug issues users encounter | +| Entrypoint detected | Detection duration, method used (config/pyproject/heuristic), success/failure, routes and routers count | Helps us understand which detection methods work best | +| Tree view visible | _(none)_ | Know if users see the endpoint explorer | +| Search executed | Number of results, whether user selected a result | Helps us understand search usage | +| CodeLens provided | Number of test calls found, number matched to routes | Helps us understand CodeLens effectiveness | +| Routes navigated | Count of navigations (cumulative) | Helps us understand feature usage depth | +| Routes copied | Count of copies (cumulative) | Helps us understand feature usage depth | +| CodeLens clicked | Count of clicks (cumulative) | Helps us understand feature usage depth | + +### Identifiers + +- A random UUID is generated and stored locally in VS Code's extension storage +- This ID is used solely to count unique users and is not linked to any personal information + +## Source code + +The telemetry implementation is fully open source. See: +- [src/utils/telemetry/](src/utils/telemetry/) - All telemetry code +- [src/utils/telemetry/events.ts](src/utils/telemetry/events.ts) - Event definitions diff --git a/bun.lock b/bun.lock index ca27b8c..4ac0f07 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "fastapi-vscode", "dependencies": { + "posthog-node": "^5.24.1", "toml": "^3.0.0", "web-tree-sitter": "^0.26.3", }, @@ -149,6 +150,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@posthog/core": ["@posthog/core@1.13.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-knjncrk7qRmssFRbGzBl1Tunt21GRpe0Wv+uVelyL0Rh7PdQUsgguulzXFTps8hA6wPwTU4kq85qnbAJ3eH6Wg=="], + "@secretlint/config-creator": ["@secretlint/config-creator@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ=="], "@secretlint/config-loader": ["@secretlint/config-loader@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/resolver": "^10.2.2", "@secretlint/types": "^10.2.2", "ajv": "^8.17.1", "debug": "^4.4.1", "rc-config-loader": "^4.1.3" } }, "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ=="], @@ -675,6 +678,8 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "posthog-node": ["posthog-node@5.24.1", "", { "dependencies": { "@posthog/core": "1.13.0" } }, "sha512-1+wsosb5fjuor9zpp3h2uq0xKYY7rDz8gpw/10Scz8Ob/uVNrsHSwGy76D9rgt4cfyaEgpJwyYv+hPi2+YjWtw=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], diff --git a/esbuild.js b/esbuild.js index a1f0ddb..275a0ec 100644 --- a/esbuild.js +++ b/esbuild.js @@ -5,6 +5,8 @@ import esbuild from "esbuild" const production = process.argv.includes("--production") const watch = process.argv.includes("--watch") +const POSTHOG_API_KEY = "phc_s0Qx8NxueJvnqe4YE7NEKYNosJr8aZ81tIByuzm464X" + function copyWasmFiles() { const wasmDestDir = path.join(import.meta.dirname, "dist", "wasm") mkdirSync(wasmDestDir, { recursive: true }) @@ -44,6 +46,9 @@ async function main() { logLevel: "info", define: { "process.env.NODE_ENV": production ? '"production"' : '"development"', + "process.env.POSTHOG_API_KEY": production + ? JSON.stringify(POSTHOG_API_KEY) + : '""', __DIST_ROOT__: JSON.stringify(path.join(import.meta.dirname, "dist")), }, } @@ -74,7 +79,16 @@ async function main() { }, // vscode is provided by the runtime; web-tree-sitter is bundled but // internally references these Node.js modules for environment detection - external: ["vscode", "fs/promises", "module"], + // posthog-node uses Node.js APIs, so telemetry is disabled in browser + // util and child_process are used for version detection but not in browser + external: [ + "vscode", + "fs/promises", + "module", + "posthog-node", + "util", + "child_process", + ], }) if (watch) { diff --git a/package.json b/package.json index 38562dc..a38cbfc 100644 --- a/package.json +++ b/package.json @@ -203,11 +203,17 @@ "scope": "resource", "description": "Path to the main FastAPI application file (e.g., 'src/main.py'). If not set, the extension will search common locations." }, - "fastapi.showTestCodeLenses": { + "fastapi.codeLens.enabled": { "type": "boolean", "default": true, "scope": "resource", "description": "Show CodeLens links above test client calls (e.g., client.get('/items')) to navigate to the corresponding route definition." + }, + "fastapi.telemetry.enabled": { + "type": "boolean", + "default": true, + "scope": "machine", + "description": "Enable telemetry to help improve the extension. No personal data is collected." } } } @@ -238,6 +244,7 @@ "typescript": "^5.0.0" }, "dependencies": { + "posthog-node": "^5.24.1", "toml": "^3.0.0", "web-tree-sitter": "^0.26.3" }, diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index d7b42cf..1439195 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -13,6 +13,12 @@ import { routerNodeToAppDefinition } from "./core/transformer" import type { AppDefinition } from "./core/types" import { vscodeFileSystem } from "./providers/vscodeFileSystem" import { log } from "./utils/logger" +import { + countRouters, + countRoutes, + createTimer, + trackEntrypointDetected, +} from "./utils/telemetry" export type { EntryPoint } @@ -105,6 +111,9 @@ export async function discoverFastAPIApps( const apps: AppDefinition[] = [] for (const folder of workspaceFolders) { + const folderTimer = createTimer() + let detectionMethod: "config" | "pyproject" | "heuristic" = "heuristic" + const folderApps: AppDefinition[] = [] const config = vscode.workspace.getConfiguration("fastapi", folder.uri) const customEntryPoint = config.get("entryPoint") @@ -126,14 +135,17 @@ export async function discoverFastAPIApps( log(`Using custom entry point: ${customEntryPoint}`) candidates = [{ filePath: entryUri.toString() }] + detectionMethod = "config" } else { // Otherwise, check pyproject.toml or auto-detect const pyprojectEntry = await parsePyprojectForEntryPoint(folder.uri) if (pyprojectEntry) { candidates = [pyprojectEntry] + detectionMethod = "pyproject" } else { const detected = await automaticDetectEntryPoints(folder) candidates = detected.map((filePath) => ({ filePath })) + detectionMethod = "heuristic" log( `Found ${candidates.length} candidate entry file(s) in ${folder.name}`, ) @@ -174,10 +186,20 @@ export async function discoverFastAPIApps( log( `Found FastAPI app "${app.name}" with ${totalRoutes} route(s) in ${app.routers.length} router(s)`, ) + folderApps.push(app) apps.push(app) - break + break // TODO: Only use first successful app per workspace folder, for now } } + + // Track entrypoint detection per workspace folder + trackEntrypointDetected({ + duration_ms: folderTimer(), + method: detectionMethod, + success: folderApps.length > 0, + routes_count: countRoutes(folderApps), + routers_count: countRouters(folderApps), + }) } if (apps.length === 0) { diff --git a/src/extension.ts b/src/extension.ts index cd8cb30..ffbf00a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,24 @@ import { } from "./providers/endpointTreeProvider" import { TestCodeLensProvider } from "./providers/testCodeLensProvider" import { disposeLogger, log } from "./utils/logger" +import { + countRouters, + countRoutes, + createTimer, + flushSessionSummary, + getInstalledVersions, + incrementCodeLensClicked, + incrementRouteCopied, + incrementRouteNavigated, + initVSCodeTelemetry, + TRACKED_PACKAGES, + client as telemetryClient, + trackActivation, + trackActivationFailed, + trackDeactivation, + trackSearchExecuted, + trackTreeViewVisible, +} from "./utils/telemetry" let parserService: Parser | null = null @@ -27,6 +45,7 @@ function navigateToLocation(location: SourceLocation): void { } export async function activate(context: vscode.ExtensionContext) { + const elapsed = createTimer() const extensionVersion = vscode.extensions.getExtension("FastAPILabs.fastapi-vscode")?.packageJSON ?.version ?? "unknown" @@ -34,35 +53,68 @@ export async function activate(context: vscode.ExtensionContext) { `FastAPI extension ${extensionVersion} activated (VS Code ${vscode.version})`, ) - parserService = new Parser() + // Initialize telemetry + await initVSCodeTelemetry(context) + + let apps: Awaited> = [] + let success = true + + try { + parserService = new Parser() - // Read Wasm files via VS Code's virtual filesystem API - const [coreWasm, pythonWasm] = await Promise.all([ - vscode.workspace.fs.readFile( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "wasm", - "web-tree-sitter.wasm", + // Read Wasm files via VS Code's virtual filesystem API + const [coreWasm, pythonWasm] = await Promise.all([ + vscode.workspace.fs.readFile( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "wasm", + "web-tree-sitter.wasm", + ), ), - ), - vscode.workspace.fs.readFile( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "wasm", - "tree-sitter-python.wasm", + vscode.workspace.fs.readFile( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "wasm", + "tree-sitter-python.wasm", + ), ), - ), - ]) + ]) - await parserService.init({ - core: coreWasm, - python: pythonWasm, - }) + await parserService.init({ + core: coreWasm, + python: pythonWasm, + }) + } catch (error) { + success = false + trackActivationFailed(error, "parser_init") + throw error + } - // Discover apps and create providers - const apps = await discoverFastAPIApps(parserService) + try { + // Discover apps and create providers + apps = await discoverFastAPIApps(parserService) + } catch (error) { + success = false + trackActivationFailed(error, "discovery") + throw error + } + + // Get actual installed Python and package versions from the active interpreter + const installedVersions = await getInstalledVersions(TRACKED_PACKAGES) + + // Set versions on telemetry client so they're included in all events + telemetryClient.setVersions(installedVersions) + + trackActivation({ + duration_ms: elapsed(), + success, + routes_count: countRoutes(apps), + routers_count: countRouters(apps), + apps_count: apps.length, + workspace_folder_count: vscode.workspace.workspaceFolders?.length ?? 0, + }) // Create grouping function that groups by workspace folder if there are multiple folders const groupApps = (apps: AppDefinition[]) => { @@ -117,9 +169,15 @@ export async function activate(context: vscode.ExtensionContext) { treeDataProvider: endpointProvider, }) + treeView.onDidChangeVisibility((e) => { + if (e.visible) { + trackTreeViewVisible() + } + }) + // CodeLens provider (optional) const config = vscode.workspace.getConfiguration("fastapi") - if (config.get("showTestCodeLenses", true)) { + if (config.get("codeLens.enabled", true)) { context.subscriptions.push( vscode.languages.registerCodeLensProvider( { language: "python", pattern: "**/*test*.py" }, @@ -128,17 +186,27 @@ export async function activate(context: vscode.ExtensionContext) { ) } + // Periodic telemetry flush (every 5 minutes) + const telemetryFlushInterval = setInterval(flushSessionSummary, 5 * 60 * 1000) + // Register disposables and commands context.subscriptions.push( watcher, treeView, - registerCommands(endpointProvider, codeLensProvider), + registerCommands(endpointProvider, codeLensProvider, groupApps), + { dispose: () => clearInterval(telemetryFlushInterval) }, ) } function registerCommands( endpointProvider: EndpointTreeProvider, codeLensProvider: TestCodeLensProvider, + groupApps: ( + apps: AppDefinition[], + ) => Array< + | { type: "app"; app: AppDefinition } + | { type: "workspace"; label: string; apps: AppDefinition[] } + >, ): vscode.Disposable { return vscode.Disposable.from( vscode.commands.registerCommand( @@ -147,7 +215,7 @@ function registerCommands( if (!parserService) return clearImportCache() const newApps = await discoverFastAPIApps(parserService) - endpointProvider.setApps(newApps) + endpointProvider.setApps(newApps, groupApps) codeLensProvider.setApps(newApps) }, ), @@ -156,6 +224,7 @@ function registerCommands( "fastapi-vscode.goToEndpoint", (item: EndpointTreeItem) => { if (item.type === "route") { + incrementRouteNavigated() navigateToLocation(item.route.location) } }, @@ -183,6 +252,7 @@ function registerCommands( .sort((a, b) => a.sortKey.localeCompare(b.sortKey)) if (items.length === 0) { + trackSearchExecuted(0, false) vscode.window.showInformationMessage( "No FastAPI endpoints found in the workspace.", ) @@ -192,6 +262,7 @@ function registerCommands( const selected = await vscode.window.showQuickPick(items, { placeHolder: "Search FastAPI endpoints...", }) + trackSearchExecuted(items.length, selected !== undefined) if (selected) { navigateToLocation(selected.route.location) } @@ -202,6 +273,7 @@ function registerCommands( "fastapi-vscode.copyEndpointPath", (item: EndpointTreeItem) => { if (item.type === "route") { + incrementRouteCopied() vscode.env.clipboard.writeText( stripLeadingDynamicSegments(item.route.path), ) @@ -237,6 +309,7 @@ function registerCommands( fromUri: vscode.Uri, fromPosition: vscode.Position, ) => { + incrementCodeLensClicked() vscode.commands.executeCommand( "editor.action.goToLocations", fromUri, @@ -250,8 +323,11 @@ function registerCommands( ) } -export function deactivate() { +export async function deactivate() { log("Extension deactivated") + flushSessionSummary() + trackDeactivation() + await telemetryClient.shutdown() parserService?.dispose() parserService = null clearImportCache() diff --git a/src/providers/testCodeLensProvider.ts b/src/providers/testCodeLensProvider.ts index a45a570..b93fbd5 100644 --- a/src/providers/testCodeLensProvider.ts +++ b/src/providers/testCodeLensProvider.ts @@ -27,6 +27,7 @@ import type { RouterDefinition, SourceLocation, } from "../core/types" +import { trackCodeLensProvided } from "../utils/telemetry" interface TestClientCall { method: string @@ -40,6 +41,7 @@ export class TestCodeLensProvider implements CodeLensProvider { private parser: Parser private _onDidChangeCodeLenses = new EventEmitter() readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event + private trackedFiles = new Set() constructor(parser: Parser, apps: AppDefinition[]) { this.parser = parser @@ -48,6 +50,7 @@ export class TestCodeLensProvider implements CodeLensProvider { setApps(apps: AppDefinition[]): void { this.apps = apps + this.trackedFiles.clear() this._onDidChangeCodeLenses.fire() } @@ -94,6 +97,13 @@ export class TestCodeLensProvider implements CodeLensProvider { } } + // Track once per file per session (first open only, edits won't update the count) + const fileKey = document.uri.toString() + if (testClientCalls.length > 0 && !this.trackedFiles.has(fileKey)) { + this.trackedFiles.add(fileKey) + trackCodeLensProvided(testClientCalls.length, codeLenses.length) + } + return codeLenses } diff --git a/src/test/utils/telemetry.test.ts b/src/test/utils/telemetry.test.ts new file mode 100644 index 0000000..9b91da6 --- /dev/null +++ b/src/test/utils/telemetry.test.ts @@ -0,0 +1,64 @@ +import * as assert from "node:assert" +import { sanitizeError } from "../../utils/telemetry" + +suite("telemetry", () => { + suite("sanitizeError", () => { + test("returns enoent for ENOENT errors", () => { + const error = Object.assign( + new Error("ENOENT: no such file or directory"), + { code: "ENOENT" }, + ) + assert.strictEqual(sanitizeError(error), "enoent") + }) + + test("returns etimedout for timeout errors", () => { + const error = Object.assign(new Error("Request timeout exceeded"), { + code: "ETIMEDOUT", + }) + assert.strictEqual(sanitizeError(error), "etimedout") + }) + + test("returns eacces for permission errors", () => { + const error = Object.assign(new Error("Permission denied"), { + code: "EACCES", + }) + assert.strictEqual(sanitizeError(error), "eacces") + }) + + test("returns eperm for operation not permitted errors", () => { + const error = Object.assign(new Error("Operation not permitted"), { + code: "EPERM", + }) + assert.strictEqual(sanitizeError(error), "eperm") + }) + + test("returns econnrefused for connection errors", () => { + const error = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }) + assert.strictEqual(sanitizeError(error), "econnrefused") + }) + + test("returns syntax for SyntaxError", () => { + const error = new SyntaxError("Unexpected token") + assert.strictEqual(sanitizeError(error), "syntax") + }) + + test("returns type for TypeError", () => { + const error = new TypeError("Cannot read property") + assert.strictEqual(sanitizeError(error), "type") + }) + + test("returns unknown_error for other errors", () => { + const error = new Error("Something unexpected happened") + assert.strictEqual(sanitizeError(error), "unknown_error") + }) + + test("returns unknown_error for non-Error values", () => { + assert.strictEqual(sanitizeError("string error"), "unknown_error") + assert.strictEqual(sanitizeError(null), "unknown_error") + assert.strictEqual(sanitizeError(undefined), "unknown_error") + assert.strictEqual(sanitizeError(42), "unknown_error") + }) + }) +}) diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts new file mode 100644 index 0000000..52184ca --- /dev/null +++ b/src/utils/telemetry/client.ts @@ -0,0 +1,114 @@ +import { PostHog } from "posthog-node" +import type { TelemetryConfig } from "./types" + +const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || "" +const POSTHOG_HOST = "https://eu.i.posthog.com" + +/** Flush after this many events are queued */ +const FLUSH_AT = 10 +/** Flush after this many milliseconds (30 seconds) */ +const FLUSH_INTERVAL_MS = 30000 + +/** Python packages to track versions for */ +export const TRACKED_PACKAGES = [ + "fastapi", + "fastapi-cli", + "fastapi-cloud-cli", + "typer", + "starlette", + "pydantic", +] as const + +export class TelemetryClient { + private posthog: PostHog | null = null + private userId: string | null = null + private config: TelemetryConfig | null = null + private initialized = false + private packageVersions: Record = {} + private sessionId: string | null = null + private sessionStartTime: number | null = null + + init(config: TelemetryConfig): void { + if (this.initialized || !config.isEnabled() || !POSTHOG_API_KEY) return + + this.config = config + this.userId = config.userId + this.sessionId = crypto.randomUUID() + this.sessionStartTime = Date.now() + + this.posthog = new PostHog(POSTHOG_API_KEY, { + host: POSTHOG_HOST, + disableGeoip: true, + flushAt: FLUSH_AT, + flushInterval: FLUSH_INTERVAL_MS, + }) + + // Identify user with static properties available at init time. + // Python/FastAPI versions are added later via setVersions() and included in events, + // since they require async detection and can change during the session. + this.posthog.identify({ + distinctId: this.userId, + properties: { + client: config.clientInfo.client, + app_name: config.clientInfo.app_name, + app_host: config.clientInfo.app_host, + is_remote: config.clientInfo.is_remote, + remote_name: config.clientInfo.remote_name, + extension_version: config.extensionVersion, + }, + }) + + this.initialized = true + } + + /** + * Set package versions to include in all events. + * Call this after detecting versions from the Python extension. + */ + setVersions(versions: Record): void { + this.packageVersions = versions + } + + getSessionDuration(): number | null { + if (!this.sessionStartTime) return null + return Date.now() - this.sessionStartTime + } + + async shutdown(): Promise { + if (this.posthog) { + await this.posthog.shutdown() + this.posthog = null + } + this.initialized = false + this.userId = null + this.config = null + this.packageVersions = {} + this.sessionId = null + this.sessionStartTime = null + } + + capture(event: string, properties?: Record): void { + if (!this.posthog || !this.userId || !this.config?.isEnabled()) return + + try { + this.posthog.capture({ + distinctId: this.userId, + event, + properties: { + ...properties, + client: this.config.clientInfo.client, + platform: this.config.clientInfo.platform, + arch: this.config.clientInfo.arch, + extension_version: this.config.extensionVersion, + ...this.packageVersions, + $session_id: this.sessionId, + }, + }) + } catch (error) { + // TODO: Log to Logfire when available + // Telemetry should never break the extension, so we silently catch errors + } + } +} + +export const client = new TelemetryClient() diff --git a/src/utils/telemetry/events.ts b/src/utils/telemetry/events.ts new file mode 100644 index 0000000..4f015a5 --- /dev/null +++ b/src/utils/telemetry/events.ts @@ -0,0 +1,183 @@ +import type { AppDefinition, RouterDefinition } from "../../core/types" +import { client } from "./client" +import type { + ActivationEventProps, + EntrypointDetectedEventProps, +} from "./types" + +export function createTimer(): () => number { + const start = performance.now() + return () => Math.round(performance.now() - start) +} + +export const Events = { + ACTIVATED: "extension_activated", + ACTIVATION_FAILED: "extension_activation_failed", + DEACTIVATED: "extension_deactivated", + ENTRYPOINT_DETECTED: "extension_entrypoint_detected", + CODELENS_PROVIDED: "extension_codelens_provided", + CODELENS_CLICKED: "extension_codelens_clicked", + TREE_VIEW_VISIBLE: "extension_tree_view_visible", + SEARCH_EXECUTED: "extension_search_executed", + ROUTE_NAVIGATED: "extension_route_navigated", + ROUTE_COPIED: "extension_route_copied", +} as const + +// Session counters for aggregated tracking +// Track both session total and last flushed count to send deltas +const sessionCounters = { + routes_navigated: 0, + routes_copied: 0, + codelens_clicks: 0, +} + +const lastFlushedCounters = { + routes_navigated: 0, + routes_copied: 0, + codelens_clicks: 0, +} + +export function incrementRouteNavigated(): void { + sessionCounters.routes_navigated++ +} + +export function incrementRouteCopied(): void { + sessionCounters.routes_copied++ +} + +export function incrementCodeLensClicked(): void { + sessionCounters.codelens_clicks++ +} + +export function flushSessionSummary(): void { + // Send incremental changes since last flush. + // Events are batched with a count property - sum counts in PostHog to get totals. + const routesNavigatedDelta = + sessionCounters.routes_navigated - lastFlushedCounters.routes_navigated + const routesCopiedDelta = + sessionCounters.routes_copied - lastFlushedCounters.routes_copied + const codelensClicksDelta = + sessionCounters.codelens_clicks - lastFlushedCounters.codelens_clicks + + if (routesNavigatedDelta > 0) { + client.capture(Events.ROUTE_NAVIGATED, { + count: routesNavigatedDelta, + }) + lastFlushedCounters.routes_navigated = sessionCounters.routes_navigated + } + if (routesCopiedDelta > 0) { + client.capture(Events.ROUTE_COPIED, { + count: routesCopiedDelta, + }) + lastFlushedCounters.routes_copied = sessionCounters.routes_copied + } + if (codelensClicksDelta > 0) { + client.capture(Events.CODELENS_CLICKED, { + count: codelensClicksDelta, + }) + lastFlushedCounters.codelens_clicks = sessionCounters.codelens_clicks + } +} + +export function countRoutes(apps: AppDefinition[]): number { + const countInRouter = (router: RouterDefinition): number => + router.routes.length + + router.children.reduce((sum, child) => sum + countInRouter(child), 0) + + return apps.reduce( + (sum, app) => + sum + + app.routes.length + + app.routers.reduce((sum, router) => sum + countInRouter(router), 0), + 0, + ) +} + +export function countRouters(apps: AppDefinition[]): number { + const countInRouter = (router: RouterDefinition): number => + 1 + router.children.reduce((sum, child) => sum + countInRouter(child), 0) + + return apps.reduce( + (sum, app) => + sum + app.routers.reduce((s, router) => s + countInRouter(router), 0), + 0, + ) +} + +export function sanitizeError(error: unknown): string { + if (!(error instanceof Error)) { + return "unknown_error" + } + + // Check Node.js error code if available (e.g., ENOENT, EACCES) + const code = (error as NodeJS.ErrnoException).code + if (code) { + return code.toLowerCase() + } + + // Check error constructor name for built-in error types + const errorType = error.constructor.name + if (errorType !== "Error") { + return errorType + .replace(/Error$/, "") + .replace(/([A-Z])/g, "_$1") + .toLowerCase() + .slice(1) + } + + return "unknown_error" +} + +export function trackActivation(props: ActivationEventProps): void { + client.capture(Events.ACTIVATED, { ...props }) +} + +export function trackActivationFailed( + error: unknown, + stage: "parser_init" | "discovery", +): void { + client.capture(Events.ACTIVATION_FAILED, { + error_message: sanitizeError(error), + stage, + }) +} + +export function trackEntrypointDetected( + props: EntrypointDetectedEventProps, +): void { + client.capture(Events.ENTRYPOINT_DETECTED, { ...props }) +} + +export function trackTreeViewVisible(): void { + client.capture(Events.TREE_VIEW_VISIBLE) +} + +export function trackSearchExecuted( + resultsCount: number, + selected: boolean, +): void { + client.capture(Events.SEARCH_EXECUTED, { + results_count: resultsCount, + selected, + }) +} + +export function trackCodeLensProvided( + testCallsCount: number, + matchedCount: number, +): void { + client.capture(Events.CODELENS_PROVIDED, { + test_calls_count: testCallsCount, + matched_count: matchedCount, + match_rate: testCallsCount > 0 ? matchedCount / testCallsCount : 0, + }) +} + +export function trackDeactivation(): void { + const duration = client.getSessionDuration() + if (duration !== null) { + client.capture(Events.DEACTIVATED, { + session_duration_ms: duration, + }) + } +} diff --git a/src/utils/telemetry/index.ts b/src/utils/telemetry/index.ts new file mode 100644 index 0000000..1f90cb0 --- /dev/null +++ b/src/utils/telemetry/index.ts @@ -0,0 +1,33 @@ +// Re-export everything for clean imports +export { client, TRACKED_PACKAGES } from "./client" +export { + countRouters, + countRoutes, + createTimer, + Events, + flushSessionSummary, + incrementCodeLensClicked, + incrementRouteCopied, + incrementRouteNavigated, + sanitizeError, + trackActivation, + trackActivationFailed, + trackCodeLensProvided, + trackDeactivation, + trackEntrypointDetected, + trackSearchExecuted, + trackTreeViewVisible, +} from "./events" +export type { + ActivationEventProps, + ClientInfo, + EntrypointDetectedEventProps, + TelemetryConfig, +} from "./types" +export { + getClientInfo, + getInstalledVersions, + getOrCreateUserId, + initVSCodeTelemetry, + isTelemetryEnabled, +} from "./vscode" diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts new file mode 100644 index 0000000..e86ebb9 --- /dev/null +++ b/src/utils/telemetry/types.ts @@ -0,0 +1,34 @@ +export interface TelemetryConfig { + userId: string + clientInfo: ClientInfo + extensionVersion: string + isEnabled: () => boolean +} + +export interface ClientInfo { + client: string // 'vscode-desktop' | 'cursor' | 'vscodium' | etc. + app_name: string // Human-readable editor name (e.g., "Visual Studio Code", "Cursor") + app_host: string // Host environment (e.g., "desktop", "codespaces", "web") + is_remote: boolean // Whether workspace is opened via remote connection + remote_name: string | undefined // Remote type if applicable (e.g., "ssh-remote", "dev-container", "wsl") + platform: string // OS platform: 'win32' | 'darwin' | 'linux' + arch: string // CPU architecture: 'x64' | 'arm64' +} + +// Event property types +export interface ActivationEventProps { + duration_ms: number + success: boolean + routes_count: number + routers_count: number + apps_count: number + workspace_folder_count: number +} + +export interface EntrypointDetectedEventProps { + duration_ms: number + method: "config" | "pyproject" | "heuristic" + success: boolean + routes_count: number + routers_count: number +} diff --git a/src/utils/telemetry/vscode.ts b/src/utils/telemetry/vscode.ts new file mode 100644 index 0000000..54de704 --- /dev/null +++ b/src/utils/telemetry/vscode.ts @@ -0,0 +1,195 @@ +/** + * VS Code-specific telemetry helpers. + * This file contains all vscode imports to keep the rest of telemetry editor-agnostic. + */ + +import * as vscode from "vscode" +import { client } from "./client" +import type { ClientInfo } from "./types" + +const USER_ID_KEY = "fastapi.telemetry.userId" + +export function getClientInfo(): ClientInfo { + const appName = vscode.env.appName + const appHost = vscode.env.appHost + const uiKind = vscode.env.uiKind + const remoteName = vscode.env.remoteName + + let clientType = "unknown" + const appNameLower = appName.toLowerCase() + + if (appNameLower.includes("cursor")) { + clientType = "cursor" + } else if (appNameLower.includes("windsurf")) { + clientType = "windsurf" + } else if (appNameLower.includes("vscodium")) { + clientType = "vscodium" + } else if (appNameLower.includes("gitpod")) { + clientType = "gitpod" + } else if (appHost === "codespaces" || remoteName === "codespaces") { + clientType = "codespaces" + } else if (uiKind === vscode.UIKind.Web) { + clientType = "vscode-web" + } else if (appNameLower.includes("visual studio code")) { + clientType = "vscode-desktop" + } + + return { + client: clientType, + app_name: appName, + app_host: appHost, + is_remote: remoteName !== undefined, + remote_name: remoteName, + platform: process.platform, + arch: process.arch, + } +} + +/** Check if telemetry is enabled based on both VS Code and extension settings. */ +export function isTelemetryEnabled(): boolean { + const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled + const config = vscode.workspace.getConfiguration("fastapi") + const extensionTelemetryEnabled = config.get( + "telemetry.enabled", + true, + ) + // Telemetry is enabled only if both VS Code and extension settings allow it + return vscodeTelemetryEnabled && extensionTelemetryEnabled +} + +export async function getOrCreateUserId( + context: vscode.ExtensionContext, +): Promise { + let userId = context.globalState.get(USER_ID_KEY) + if (!userId) { + userId = crypto.randomUUID() + await context.globalState.update(USER_ID_KEY, userId) + } + return userId +} + +/** + * Initialize telemetry for VS Code environment. + * Call this once during extension activation. + */ +export async function initVSCodeTelemetry( + context: vscode.ExtensionContext, +): Promise { + // Skip telemetry entirely in development mode + if (context.extensionMode === vscode.ExtensionMode.Development) { + return + } + + // Skip telemetry in browser environments (vscode.dev, github.dev) + // PostHog Node.js client requires Node APIs that aren't available in browsers + if (vscode.env.uiKind === vscode.UIKind.Web) { + return + } + + const userId = await getOrCreateUserId(context) + const extensionVersion = + vscode.extensions.getExtension("FastAPILabs.fastapi-vscode")?.packageJSON + ?.version ?? "unknown" + + client.init({ + userId, + clientInfo: getClientInfo(), + extensionVersion, + isEnabled: isTelemetryEnabled, + }) +} + +/** + * Fetch package versions using the Python interpreter. + * Runs all version checks in parallel for better performance. + */ +async function fetchPackageVersions( + pythonPath: string, + packages: readonly string[], +): Promise<{ [key: string]: string | undefined }> { + const { promisify } = await import("util") + const { execFile } = await import("child_process") + const execFileAsync = promisify(execFile) + + // Fetch all package versions in parallel + const results = await Promise.allSettled( + packages.map(async (pkg) => { + const importName = pkg.replace(/-/g, "_") + const { stdout } = await execFileAsync( + pythonPath, + ["-c", `import ${importName}; print(${importName}.__version__)`], + { timeout: 5000 }, + ) + return { key: `${importName}_version`, version: stdout.trim() } + }), + ) + + // Collect results into a single object + const versions: { [key: string]: string | undefined } = {} + results.forEach((result, index) => { + const importName = packages[index].replace(/-/g, "_") + const key = `${importName}_version` + versions[key] = + result.status === "fulfilled" ? result.value.version : undefined + }) + + return versions +} + +/** + * Get actual Python and package versions installed in the current workspace. + * This requires the Python extension to be installed and activated. + * @returns An object with python_version and package versions (e.g., fastapi_version, pydantic_version). + */ +export async function getInstalledVersions( + packages: readonly string[] = [], +): Promise<{ python_version?: string; [key: string]: string | undefined }> { + try { + // Get Python extension API + const pythonExtension = vscode.extensions.getExtension("ms-python.python") + + // Don't activate the extension just for telemetry - only use it if already active + if (!pythonExtension || !pythonExtension.isActive) { + return {} + } + + const pythonApi = pythonExtension.exports + + // Get active interpreter details + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (!workspaceFolder) { + return {} + } + + const environment = await pythonApi.environments.resolveEnvironment( + await pythonApi.environments.getActiveEnvironmentPath( + workspaceFolder.uri, + ), + ) + + if (!environment?.version?.major) { + return {} + } + + // Extract Python version including patch version (e.g., "3.11.5") + const python_version = environment.version.micro + ? `${environment.version.major}.${environment.version.minor}.${environment.version.micro}` + : `${environment.version.major}.${environment.version.minor}` + + // Try to fetch package versions if we have an executable path + if (!environment.executable?.uri) { + return { python_version } + } + + const pythonPath = environment.executable.uri.fsPath + const packageVersions = await fetchPackageVersions( + pythonPath, + packages, + ).catch(() => ({})) + + return { python_version, ...packageVersions } + } catch { + // If Python extension is not available or any error occurs, return empty + return {} + } +}