From 9e65dc1727659104a7084ba387db9bfaddda9066 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 21 Jan 2026 15:30:09 -0800 Subject: [PATCH 01/17] Add telemetry infra and event instrumentation, along with docs --- .gitignore | 4 +- README.md | 6 +- TELEMETRY.md | 57 ++++++++++ bun.lock | 5 + esbuild.js | 30 +++++- package.json | 9 +- src/appDiscovery.ts | 19 ++++ src/extension.ts | 109 ++++++++++++++----- src/providers/testCodeLensProvider.ts | 10 ++ src/test/utils/telemetry.test.ts | 49 +++++++++ src/utils/telemetry/client.ts | 74 +++++++++++++ src/utils/telemetry/events.ts | 149 ++++++++++++++++++++++++++ src/utils/telemetry/index.ts | 31 ++++++ src/utils/telemetry/types.ts | 31 ++++++ src/utils/telemetry/vscode.ts | 90 ++++++++++++++++ 15 files changed, 643 insertions(+), 30 deletions(-) create mode 100644 TELEMETRY.md create mode 100644 src/test/utils/telemetry.test.ts create mode 100644 src/utils/telemetry/client.ts create mode 100644 src/utils/telemetry/events.ts create mode 100644 src/utils/telemetry/index.ts create mode 100644 src/utils/telemetry/types.ts create mode 100644 src/utils/telemetry/vscode.ts diff --git a/.gitignore b/.gitignore index b59b03e..4ab34ba 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 \ No newline at end of file diff --git a/README.md b/README.md index ecf6525..4991b92 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A VS Code extension for FastAPI development that discovers and displays your API | 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` | **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. @@ -59,3 +59,7 @@ A VS Code extension for FastAPI development that discovers and displays your API - esbuild - Bun (package manager) - VS Code Extension API + +## 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. diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 0000000..a63c232 --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,57 @@ +# 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. 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) + +### Events + +| Event | Data | Why | +|-------|------|-----| +| Extension activated | Activation duration, success/failure, number of routes/apps discovered, workspace folder count | Helps us understand startup performance and project sizes | +| 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..a11bf29 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,7 +1,29 @@ -import { copyFileSync, globSync, mkdirSync } from "node:fs" +import { + copyFileSync, + existsSync, + globSync, + mkdirSync, + readFileSync, +} from "node:fs" import path from "node:path" import esbuild from "esbuild" +// Load .env file if it exists +const envPath = path.join(import.meta.dirname, ".env") +if (existsSync(envPath)) { + const envContent = readFileSync(envPath, "utf-8") + for (const line of envContent.split("\n")) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith("#")) { + const [key, ...valueParts] = trimmed.split("=") + const value = valueParts.join("=") + if (key && value !== undefined && !process.env[key]) { + process.env[key] = value + } + } + } +} + const production = process.argv.includes("--production") const watch = process.argv.includes("--watch") @@ -44,6 +66,9 @@ async function main() { logLevel: "info", define: { "process.env.NODE_ENV": production ? '"production"' : '"development"', + "process.env.POSTHOG_API_KEY": JSON.stringify( + process.env.POSTHOG_API_KEY || "", + ), __DIST_ROOT__: JSON.stringify(path.join(import.meta.dirname, "dist")), }, } @@ -74,7 +99,8 @@ 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 + external: ["vscode", "fs/promises", "module", "posthog-node"], }) if (watch) { diff --git a/package.json b/package.json index b8c840f..5f78eba 100644 --- a/package.json +++ b/package.json @@ -135,11 +135,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." } } } @@ -170,6 +176,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 f225adc..011ba71 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 } @@ -89,6 +95,7 @@ async function parsePyprojectForEntryPoint( export async function discoverFastAPIApps( parser: Parser, ): Promise { + const elapsed = createTimer() const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders) { log("No workspace folders found") @@ -100,6 +107,7 @@ export async function discoverFastAPIApps( ) const apps: AppDefinition[] = [] + let detectionMethod: "config" | "pyproject" | "heuristic" = "heuristic" for (const folder of workspaceFolders) { const config = vscode.workspace.getConfiguration("fastapi", folder.uri) @@ -123,14 +131,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}`, ) @@ -181,5 +192,13 @@ export async function discoverFastAPIApps( log("No FastAPI apps found in workspace") } + trackEntrypointDetected({ + duration_ms: elapsed(), + method: detectionMethod, + success: apps.length > 0, + routes_count: countRoutes(apps), + routers_count: countRouters(apps), + }) + return apps } diff --git a/src/extension.ts b/src/extension.ts index dd4ca8d..da00608 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,20 @@ import { } from "./providers/endpointTreeProvider" import { TestCodeLensProvider } from "./providers/testCodeLensProvider" import { disposeLogger, log } from "./utils/logger" +import { + countRoutes, + createTimer, + flushSessionSummary, + incrementCodeLensClicked, + incrementRouteCopied, + incrementRouteNavigated, + initVSCodeTelemetry, + client as telemetryClient, + trackActivation, + trackActivationFailed, + trackSearchExecuted, + trackTreeViewVisible, +} from "./utils/telemetry" let parserService: Parser | null = null @@ -27,6 +41,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 +49,62 @@ 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 - // 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", + 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", + ), ), - ), - 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 + } + + try { + // Discover apps and create providers + apps = await discoverFastAPIApps(parserService) + } catch (error) { + success = false + trackActivationFailed(error, "discovery") + throw error + } + + trackActivation({ + duration_ms: elapsed(), + success, + routes_count: countRoutes(apps), + apps_count: apps.length, + workspace_folder_count: vscode.workspace.workspaceFolders?.length ?? 0, }) - // Discover apps and create providers - const apps = await discoverFastAPIApps(parserService) const endpointProvider = new EndpointTreeProvider(apps) const codeLensProvider = new TestCodeLensProvider(parserService, apps) @@ -93,9 +135,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" }, @@ -104,11 +152,15 @@ 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), + { dispose: () => clearInterval(telemetryFlushInterval) }, ) } @@ -132,6 +184,7 @@ function registerCommands( "fastapi-vscode.goToEndpoint", (item: EndpointTreeItem) => { if (item.type === "route") { + incrementRouteNavigated() navigateToLocation(item.route.location) } }, @@ -159,6 +212,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.", ) @@ -168,6 +222,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) } @@ -178,6 +233,7 @@ function registerCommands( "fastapi-vscode.copyEndpointPath", (item: EndpointTreeItem) => { if (item.type === "route") { + incrementRouteCopied() vscode.env.clipboard.writeText( stripLeadingDynamicSegments(item.route.path), ) @@ -213,6 +269,7 @@ function registerCommands( fromUri: vscode.Uri, fromPosition: vscode.Position, ) => { + incrementCodeLensClicked() vscode.commands.executeCommand( "editor.action.goToLocations", fromUri, @@ -226,8 +283,10 @@ function registerCommands( ) } -export function deactivate() { +export async function deactivate() { log("Extension deactivated") + flushSessionSummary() + await telemetryClient.shutdown() parserService?.dispose() parserService = null clearImportCache() diff --git a/src/providers/testCodeLensProvider.ts b/src/providers/testCodeLensProvider.ts index a45a570..52c1995 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 + 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..85032a6 --- /dev/null +++ b/src/test/utils/telemetry.test.ts @@ -0,0 +1,49 @@ +import * as assert from "node:assert" +import { sanitizeError } from "../../utils/telemetry" + +suite("telemetry", () => { + suite("sanitizeError", () => { + test("returns file_not_found for ENOENT errors", () => { + const error = new Error("ENOENT: no such file or directory") + assert.strictEqual(sanitizeError(error), "file_not_found") + }) + + test("returns wasm_load_error for wasm errors", () => { + const error = new Error("Failed to load WASM module") + assert.strictEqual(sanitizeError(error), "wasm_load_error") + }) + + test("returns parse_error for parse errors", () => { + const error = new Error("Failed to parse Python file") + assert.strictEqual(sanitizeError(error), "parse_error") + }) + + test("returns timeout_error for timeout errors", () => { + const error = new Error("Request timeout exceeded") + assert.strictEqual(sanitizeError(error), "timeout_error") + }) + + test("returns permission_error for permission errors", () => { + const error = new Error("Permission denied") + assert.strictEqual(sanitizeError(error), "permission_error") + }) + + 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") + }) + + test("is case insensitive", () => { + assert.strictEqual(sanitizeError(new Error("WASM")), "wasm_load_error") + assert.strictEqual(sanitizeError(new Error("Wasm")), "wasm_load_error") + assert.strictEqual(sanitizeError(new Error("wasm")), "wasm_load_error") + }) + }) +}) diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts new file mode 100644 index 0000000..e1fcefd --- /dev/null +++ b/src/utils/telemetry/client.ts @@ -0,0 +1,74 @@ +import { PostHog } from "posthog-node" +import { log } from "../logger" +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 + +export class TelemetryClient { + private posthog: PostHog | null = null + private userId: string | null = null + private config: TelemetryConfig | null = null + private initialized = false + + init(config: TelemetryConfig): void { + if (this.initialized || !config.isEnabled() || !POSTHOG_API_KEY) return + + this.config = config + this.userId = config.userId + + this.posthog = new PostHog(POSTHOG_API_KEY, { + host: POSTHOG_HOST, + disableGeoip: true, + flushAt: FLUSH_AT, + flushInterval: FLUSH_INTERVAL_MS, + }) + + 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 + } + + async shutdown(): Promise { + if (this.posthog) { + await this.posthog.shutdown() + this.posthog = null + } + this.initialized = false + this.userId = null + this.config = null + } + + capture(event: string, properties?: Record): void { + log(`Telemetry: ${event} ${JSON.stringify(properties ?? {})}`) + + if (!this.posthog || !this.userId || !this.config?.isEnabled()) return + + this.posthog.capture({ + distinctId: this.userId, + event, + properties: { + ...properties, + client: this.config.clientInfo.client, + extension_version: this.config.extensionVersion, + }, + }) + } +} + +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..809804b --- /dev/null +++ b/src/utils/telemetry/events.ts @@ -0,0 +1,149 @@ +import type { AppDefinition, RouterDefinition } from "../../core/types" +import { client } from "./client" +import type { + ActivationEventProps, + EntrypointDetectedEventProps, +} from "./types" + +/** Creates a timer that returns elapsed milliseconds when called. */ +export function createTimer(): () => number { + const start = performance.now() + return () => Math.round(performance.now() - start) +} + +// Event name constants +export const Events = { + ACTIVATED: "extension_activated", + ACTIVATION_FAILED: "extension_activation_failed", + 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 +const sessionCounters = { + 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 { + // Cumulative counts - don't reset, so each flush shows running total + // In PostHog, take max(count) per session to get final total + if (sessionCounters.routes_navigated > 0) { + client.capture(Events.ROUTE_NAVIGATED, { + count: sessionCounters.routes_navigated, + }) + } + if (sessionCounters.routes_copied > 0) { + client.capture(Events.ROUTE_COPIED, { + count: sessionCounters.routes_copied, + }) + } + if (sessionCounters.codelens_clicks > 0) { + client.capture(Events.CODELENS_CLICKED, { + count: 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) { + const msg = error.message.toLowerCase() + if (msg.includes("enoent")) return "file_not_found" + if (msg.includes("wasm")) return "wasm_load_error" + if (msg.includes("parse")) return "parse_error" + if (msg.includes("timeout")) return "timeout_error" + if (msg.includes("permission")) return "permission_error" + return "unknown_error" + } + return "unknown_error" +} + +// Typed event tracking functions + +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, + }) +} diff --git a/src/utils/telemetry/index.ts b/src/utils/telemetry/index.ts new file mode 100644 index 0000000..a12336c --- /dev/null +++ b/src/utils/telemetry/index.ts @@ -0,0 +1,31 @@ +// Re-export everything for clean imports +export { client } from "./client" +export { + countRouters, + countRoutes, + createTimer, + Events, + flushSessionSummary, + incrementCodeLensClicked, + incrementRouteCopied, + incrementRouteNavigated, + sanitizeError, + trackActivation, + trackActivationFailed, + trackCodeLensProvided, + trackEntrypointDetected, + trackSearchExecuted, + trackTreeViewVisible, +} from "./events" +export type { + ActivationEventProps, + ClientInfo, + EntrypointDetectedEventProps, + TelemetryConfig, +} from "./types" +export { + getClientInfo, + 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..8f06ad5 --- /dev/null +++ b/src/utils/telemetry/types.ts @@ -0,0 +1,31 @@ +export interface TelemetryConfig { + userId: string + clientInfo: ClientInfo + extensionVersion: string + isEnabled: () => boolean +} + +export interface ClientInfo { + client: string // 'vscode-desktop' | 'cursor' | 'vscodium' | etc. + app_name: string + app_host: string + is_remote: boolean + remote_name: string | undefined +} + +// Event property types +export interface ActivationEventProps { + duration_ms: number + success: boolean + routes_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..311c085 --- /dev/null +++ b/src/utils/telemetry/vscode.ts @@ -0,0 +1,90 @@ +/** + * 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, TelemetryConfig } 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, + } +} + +export function isTelemetryEnabled(): boolean { + const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled + const config = vscode.workspace.getConfiguration("fastapi") + const extensionTelemetryEnabled = config.get( + "telemetry.enabled", + true, + ) + 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 + } + + const userId = await getOrCreateUserId(context) + const extensionVersion = + vscode.extensions.getExtension("FastAPILabs.fastapi-vscode")?.packageJSON + ?.version ?? "unknown" + + client.init({ + userId, + clientInfo: getClientInfo(), + extensionVersion, + isEnabled: isTelemetryEnabled, + }) +} From 6f4ee56a55a87d0cd0f79daaceb314546683080d Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 21 Jan 2026 15:32:48 -0800 Subject: [PATCH 02/17] Add newline --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4ab34ba..769275f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,4 @@ _.log /.claude/ CLAUDE.local.md -.env \ No newline at end of file +.env From 83bf940fa1f6ab4558f30741359dbe05ee3ecce6 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 21 Jan 2026 15:43:17 -0800 Subject: [PATCH 03/17] Add comment --- TELEMETRY.md | 1 + src/providers/testCodeLensProvider.ts | 2 +- src/test/utils/telemetry.test.ts | 8 +------- src/utils/telemetry/events.ts | 4 ---- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index a63c232..84d0a1a 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -45,6 +45,7 @@ We collect anonymous usage metrics. We do **not** collect: | 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 diff --git a/src/providers/testCodeLensProvider.ts b/src/providers/testCodeLensProvider.ts index 52c1995..b93fbd5 100644 --- a/src/providers/testCodeLensProvider.ts +++ b/src/providers/testCodeLensProvider.ts @@ -97,7 +97,7 @@ export class TestCodeLensProvider implements CodeLensProvider { } } - // Track once per file per session + // 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) diff --git a/src/test/utils/telemetry.test.ts b/src/test/utils/telemetry.test.ts index 85032a6..e25bf22 100644 --- a/src/test/utils/telemetry.test.ts +++ b/src/test/utils/telemetry.test.ts @@ -9,7 +9,7 @@ suite("telemetry", () => { }) test("returns wasm_load_error for wasm errors", () => { - const error = new Error("Failed to load WASM module") + const error = new Error("Failed to load Wasm module") assert.strictEqual(sanitizeError(error), "wasm_load_error") }) @@ -39,11 +39,5 @@ suite("telemetry", () => { assert.strictEqual(sanitizeError(undefined), "unknown_error") assert.strictEqual(sanitizeError(42), "unknown_error") }) - - test("is case insensitive", () => { - assert.strictEqual(sanitizeError(new Error("WASM")), "wasm_load_error") - assert.strictEqual(sanitizeError(new Error("Wasm")), "wasm_load_error") - assert.strictEqual(sanitizeError(new Error("wasm")), "wasm_load_error") - }) }) }) diff --git a/src/utils/telemetry/events.ts b/src/utils/telemetry/events.ts index 809804b..7af08d2 100644 --- a/src/utils/telemetry/events.ts +++ b/src/utils/telemetry/events.ts @@ -5,13 +5,11 @@ import type { EntrypointDetectedEventProps, } from "./types" -/** Creates a timer that returns elapsed milliseconds when called. */ export function createTimer(): () => number { const start = performance.now() return () => Math.round(performance.now() - start) } -// Event name constants export const Events = { ACTIVATED: "extension_activated", ACTIVATION_FAILED: "extension_activation_failed", @@ -101,8 +99,6 @@ export function sanitizeError(error: unknown): string { return "unknown_error" } -// Typed event tracking functions - export function trackActivation(props: ActivationEventProps): void { client.capture(Events.ACTIVATED, { ...props }) } From 5e8fa4bc87d9875e4b3372f8f1104212e5d8b4dc Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 08:48:47 -0800 Subject: [PATCH 04/17] Remove log --- src/utils/telemetry/client.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts index e1fcefd..661b8ab 100644 --- a/src/utils/telemetry/client.ts +++ b/src/utils/telemetry/client.ts @@ -1,5 +1,4 @@ import { PostHog } from "posthog-node" -import { log } from "../logger" import type { TelemetryConfig } from "./types" const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || "" @@ -55,8 +54,6 @@ export class TelemetryClient { } capture(event: string, properties?: Record): void { - log(`Telemetry: ${event} ${JSON.stringify(properties ?? {})}`) - if (!this.posthog || !this.userId || !this.config?.isEnabled()) return this.posthog.capture({ From 873b87712192cc2115fa7b37231a208f40259f2b Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 09:44:28 -0800 Subject: [PATCH 05/17] Fix for multiroot --- src/appDiscovery.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index 23c85a1..c4ade9e 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -98,7 +98,6 @@ async function parsePyprojectForEntryPoint( export async function discoverFastAPIApps( parser: Parser, ): Promise { - const elapsed = createTimer() const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders) { log("No workspace folders found") @@ -110,9 +109,11 @@ export async function discoverFastAPIApps( ) const apps: AppDefinition[] = [] - let detectionMethod: "config" | "pyproject" | "heuristic" = "heuristic" 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") @@ -185,23 +186,25 @@ 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 // Only use first successful app per workspace folder } } + + // 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) { log("No FastAPI apps found in workspace") } - trackEntrypointDetected({ - duration_ms: elapsed(), - method: detectionMethod, - success: apps.length > 0, - routes_count: countRoutes(apps), - routers_count: countRouters(apps), - }) - return apps } From 0cfc30888bb2df0f1315830a9518221ca53bdc5f Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 09:47:21 -0800 Subject: [PATCH 06/17] Cleanup --- src/extension.ts | 12 ++++++++++-- src/utils/telemetry/types.ts | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index c258552..907faf8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ import { import { TestCodeLensProvider } from "./providers/testCodeLensProvider" import { disposeLogger, log } from "./utils/logger" import { + countRouters, countRoutes, createTimer, flushSessionSummary, @@ -101,6 +102,7 @@ export async function activate(context: vscode.ExtensionContext) { duration_ms: elapsed(), success, routes_count: countRoutes(apps), + routers_count: countRouters(apps), apps_count: apps.length, workspace_folder_count: vscode.workspace.workspaceFolders?.length ?? 0, }) @@ -182,7 +184,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( watcher, treeView, - registerCommands(endpointProvider, codeLensProvider), + registerCommands(endpointProvider, codeLensProvider, groupApps), { dispose: () => clearInterval(telemetryFlushInterval) }, ) } @@ -190,6 +192,12 @@ export async function activate(context: vscode.ExtensionContext) { 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( @@ -198,7 +206,7 @@ function registerCommands( if (!parserService) return clearImportCache() const newApps = await discoverFastAPIApps(parserService) - endpointProvider.setApps(newApps) + endpointProvider.setApps(newApps, groupApps) codeLensProvider.setApps(newApps) }, ), diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts index 8f06ad5..9f57db5 100644 --- a/src/utils/telemetry/types.ts +++ b/src/utils/telemetry/types.ts @@ -18,6 +18,7 @@ export interface ActivationEventProps { duration_ms: number success: boolean routes_count: number + routers_count: number apps_count: number workspace_folder_count: number } From 1a59c7d488e6453f77479a031bafa3191f494b50 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 11:22:16 -0800 Subject: [PATCH 07/17] Clean up --- TELEMETRY.md | 7 +++- src/extension.ts | 12 ++++++ src/utils/telemetry/client.ts | 56 ++++++++++++++++++++++----- src/utils/telemetry/events.ts | 67 +++++++++++++++++++++++++------- src/utils/telemetry/index.ts | 2 + src/utils/telemetry/types.ts | 2 + src/utils/telemetry/vscode.ts | 73 ++++++++++++++++++++++++++++++++++- 7 files changed, 192 insertions(+), 27 deletions(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index 84d0a1a..219f331 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -26,17 +26,20 @@ This disables only the FastAPI extension's telemetry while leaving other telemet ## What we collect -We collect anonymous usage metrics. We do **not** 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 and FastAPI versions from your active interpreter. + ### Events | Event | Data | Why | |-------|------|-----| -| Extension activated | Activation duration, success/failure, number of routes/apps discovered, workspace folder count | Helps us understand startup performance and project sizes | +| 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 | diff --git a/src/extension.ts b/src/extension.ts index 907faf8..7f94c14 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { countRoutes, createTimer, flushSessionSummary, + getInstalledVersions, incrementCodeLensClicked, incrementRouteCopied, incrementRouteNavigated, @@ -27,6 +28,7 @@ import { client as telemetryClient, trackActivation, trackActivationFailed, + trackDeactivation, trackSearchExecuted, trackTreeViewVisible, } from "./utils/telemetry" @@ -98,6 +100,15 @@ export async function activate(context: vscode.ExtensionContext) { throw error } + // Get actual installed Python and FastAPI versions from the active interpreter + const installedVersions = await getInstalledVersions() + + // Set versions on telemetry client so they're included in all events + telemetryClient.setVersions( + installedVersions.pythonVersion, + installedVersions.fastapiVersion, + ) + trackActivation({ duration_ms: elapsed(), success, @@ -317,6 +328,7 @@ function registerCommands( export async function deactivate() { log("Extension deactivated") flushSessionSummary() + trackDeactivation() await telemetryClient.shutdown() parserService?.dispose() parserService = null diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts index 661b8ab..0b98615 100644 --- a/src/utils/telemetry/client.ts +++ b/src/utils/telemetry/client.ts @@ -14,12 +14,18 @@ export class TelemetryClient { private userId: string | null = null private config: TelemetryConfig | null = null private initialized = false + private pythonVersion: string | undefined = undefined + private fastapiVersion: string | undefined = undefined + 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, @@ -43,6 +49,20 @@ export class TelemetryClient { this.initialized = true } + /** + * Set Python and FastAPI versions to include in all events. + * Call this after detecting versions from the Python extension. + */ + setVersions(pythonVersion?: string, fastapiVersion?: string): void { + this.pythonVersion = pythonVersion + this.fastapiVersion = fastapiVersion + } + + getSessionDuration(): number | null { + if (!this.sessionStartTime) return null + return Date.now() - this.sessionStartTime + } + async shutdown(): Promise { if (this.posthog) { await this.posthog.shutdown() @@ -51,20 +71,38 @@ export class TelemetryClient { this.initialized = false this.userId = null this.config = null + this.pythonVersion = undefined + this.fastapiVersion = undefined + this.sessionId = null + this.sessionStartTime = null } capture(event: string, properties?: Record): void { if (!this.posthog || !this.userId || !this.config?.isEnabled()) return - this.posthog.capture({ - distinctId: this.userId, - event, - properties: { - ...properties, - client: this.config.clientInfo.client, - extension_version: this.config.extensionVersion, - }, - }) + 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, + python_version: this.pythonVersion, + fastapi_version: this.fastapiVersion, + $session_id: this.sessionId, + }, + }) + } catch (error) { + // Log telemetry errors but don't throw - telemetry should never break the extension + console.error( + "[FastAPI Telemetry] Failed to capture event:", + event, + error, + ) + } } } diff --git a/src/utils/telemetry/events.ts b/src/utils/telemetry/events.ts index 7af08d2..f1e553f 100644 --- a/src/utils/telemetry/events.ts +++ b/src/utils/telemetry/events.ts @@ -13,6 +13,7 @@ export function createTimer(): () => number { 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", @@ -23,12 +24,19 @@ export const Events = { } 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++ } @@ -42,22 +50,31 @@ export function incrementCodeLensClicked(): void { } export function flushSessionSummary(): void { - // Cumulative counts - don't reset, so each flush shows running total - // In PostHog, take max(count) per session to get final total - if (sessionCounters.routes_navigated > 0) { + // Send incremental changes since last flush + 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: sessionCounters.routes_navigated, + count: routesNavigatedDelta, }) + lastFlushedCounters.routes_navigated = sessionCounters.routes_navigated } - if (sessionCounters.routes_copied > 0) { + if (routesCopiedDelta > 0) { client.capture(Events.ROUTE_COPIED, { - count: sessionCounters.routes_copied, + count: routesCopiedDelta, }) + lastFlushedCounters.routes_copied = sessionCounters.routes_copied } - if (sessionCounters.codelens_clicks > 0) { + if (codelensClicksDelta > 0) { client.capture(Events.CODELENS_CLICKED, { - count: sessionCounters.codelens_clicks, + count: codelensClicksDelta, }) + lastFlushedCounters.codelens_clicks = sessionCounters.codelens_clicks } } @@ -87,15 +104,26 @@ export function countRouters(apps: AppDefinition[]): number { } export function sanitizeError(error: unknown): string { - if (error instanceof Error) { - const msg = error.message.toLowerCase() - if (msg.includes("enoent")) return "file_not_found" - if (msg.includes("wasm")) return "wasm_load_error" - if (msg.includes("parse")) return "parse_error" - if (msg.includes("timeout")) return "timeout_error" - if (msg.includes("permission")) return "permission_error" + 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" } @@ -143,3 +171,12 @@ export function trackCodeLensProvided( 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 index a12336c..71736b8 100644 --- a/src/utils/telemetry/index.ts +++ b/src/utils/telemetry/index.ts @@ -13,6 +13,7 @@ export { trackActivation, trackActivationFailed, trackCodeLensProvided, + trackDeactivation, trackEntrypointDetected, trackSearchExecuted, trackTreeViewVisible, @@ -25,6 +26,7 @@ export type { } from "./types" export { getClientInfo, + getInstalledVersions, getOrCreateUserId, initVSCodeTelemetry, isTelemetryEnabled, diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts index 9f57db5..41d8f09 100644 --- a/src/utils/telemetry/types.ts +++ b/src/utils/telemetry/types.ts @@ -11,6 +11,8 @@ export interface ClientInfo { app_host: string is_remote: boolean remote_name: string | undefined + platform: string // 'win32' | 'darwin' | 'linux' + arch: string // 'x64' | 'arm64' } // Event property types diff --git a/src/utils/telemetry/vscode.ts b/src/utils/telemetry/vscode.ts index 311c085..f164cc5 100644 --- a/src/utils/telemetry/vscode.ts +++ b/src/utils/telemetry/vscode.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode" import { client } from "./client" -import type { ClientInfo, TelemetryConfig } from "./types" +import type { ClientInfo } from "./types" const USER_ID_KEY = "fastapi.telemetry.userId" @@ -40,6 +40,8 @@ export function getClientInfo(): ClientInfo { app_host: appHost, is_remote: remoteName !== undefined, remote_name: remoteName, + platform: process.platform, + arch: process.arch, } } @@ -88,3 +90,72 @@ export async function initVSCodeTelemetry( isEnabled: isTelemetryEnabled, }) } + +/** + * Get actual Python and FastAPI versions from the active interpreter. + * Uses the Python extension API if available and already active. + * If not active, events will not have version info. + */ +export async function getInstalledVersions(): Promise<{ + pythonVersion?: string + fastapiVersion?: string +}> { + 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 pythonVersion = environment.version.micro + ? `${environment.version.major}.${environment.version.minor}.${environment.version.micro}` + : `${environment.version.major}.${environment.version.minor}` + + // Get FastAPI version by running python command + let fastapiVersion: string | undefined + if (environment.executable?.uri) { + try { + const pythonPath = environment.executable.uri.fsPath + const { promisify } = await import("util") + const { execFile } = await import("child_process") + const execFileAsync = promisify(execFile) + + const { stdout } = await execFileAsync( + pythonPath, + ["-c", "import fastapi; print(fastapi.__version__)"], + { timeout: 5000 }, + ) + + fastapiVersion = stdout.trim() || undefined + } catch { + // FastAPI not installed or execution failed + } + } + + return { pythonVersion, fastapiVersion } + } catch { + // Python extension not available or error + return {} + } +} From fc432573f471427ddffdac8673c1609299b8ab93 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 11:31:35 -0800 Subject: [PATCH 08/17] Update tests --- src/test/utils/telemetry.test.ts | 51 ++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/src/test/utils/telemetry.test.ts b/src/test/utils/telemetry.test.ts index e25bf22..9b91da6 100644 --- a/src/test/utils/telemetry.test.ts +++ b/src/test/utils/telemetry.test.ts @@ -3,29 +3,50 @@ import { sanitizeError } from "../../utils/telemetry" suite("telemetry", () => { suite("sanitizeError", () => { - test("returns file_not_found for ENOENT errors", () => { - const error = new Error("ENOENT: no such file or directory") - assert.strictEqual(sanitizeError(error), "file_not_found") + 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 wasm_load_error for wasm errors", () => { - const error = new Error("Failed to load Wasm module") - assert.strictEqual(sanitizeError(error), "wasm_load_error") + test("returns etimedout for timeout errors", () => { + const error = Object.assign(new Error("Request timeout exceeded"), { + code: "ETIMEDOUT", + }) + assert.strictEqual(sanitizeError(error), "etimedout") }) - test("returns parse_error for parse errors", () => { - const error = new Error("Failed to parse Python file") - assert.strictEqual(sanitizeError(error), "parse_error") + test("returns eacces for permission errors", () => { + const error = Object.assign(new Error("Permission denied"), { + code: "EACCES", + }) + assert.strictEqual(sanitizeError(error), "eacces") }) - test("returns timeout_error for timeout errors", () => { - const error = new Error("Request timeout exceeded") - assert.strictEqual(sanitizeError(error), "timeout_error") + 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 permission_error for permission errors", () => { - const error = new Error("Permission denied") - assert.strictEqual(sanitizeError(error), "permission_error") + 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", () => { From 9884ff7961d2f3df4739f578ed3020da63bbe0e1 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 11:36:51 -0800 Subject: [PATCH 09/17] Add comment --- package.json | 2 +- src/utils/telemetry/client.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f78eba..bd8a4b1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "fastapi-vscode", "displayName": "FastAPI Extension", "description": "VS Code extension for FastAPI development", - "version": "0.0.2", + "version": "0.0.3", "publisher": "FastAPILabs", "license": "MIT", "repository": { diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts index 0b98615..19a3c75 100644 --- a/src/utils/telemetry/client.ts +++ b/src/utils/telemetry/client.ts @@ -34,6 +34,9 @@ export class TelemetryClient { 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: { From edb49c7ffe36e38ad96e6009401f91ac85a34312 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 11:47:49 -0800 Subject: [PATCH 10/17] Improve comments --- src/appDiscovery.ts | 2 +- src/utils/telemetry/client.ts | 8 ++------ src/utils/telemetry/events.ts | 3 ++- src/utils/telemetry/types.ts | 12 ++++++------ src/utils/telemetry/vscode.ts | 4 +++- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index c4ade9e..1439195 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -188,7 +188,7 @@ export async function discoverFastAPIApps( ) folderApps.push(app) apps.push(app) - break // Only use first successful app per workspace folder + break // TODO: Only use first successful app per workspace folder, for now } } diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts index 19a3c75..5e6187c 100644 --- a/src/utils/telemetry/client.ts +++ b/src/utils/telemetry/client.ts @@ -99,12 +99,8 @@ export class TelemetryClient { }, }) } catch (error) { - // Log telemetry errors but don't throw - telemetry should never break the extension - console.error( - "[FastAPI Telemetry] Failed to capture event:", - event, - error, - ) + // TODO: Log to Logfire when available + // Telemetry should never break the extension, so we silently catch errors } } } diff --git a/src/utils/telemetry/events.ts b/src/utils/telemetry/events.ts index f1e553f..4f015a5 100644 --- a/src/utils/telemetry/events.ts +++ b/src/utils/telemetry/events.ts @@ -50,7 +50,8 @@ export function incrementCodeLensClicked(): void { } export function flushSessionSummary(): void { - // Send incremental changes since last flush + // 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 = diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts index 41d8f09..e86ebb9 100644 --- a/src/utils/telemetry/types.ts +++ b/src/utils/telemetry/types.ts @@ -7,12 +7,12 @@ export interface TelemetryConfig { export interface ClientInfo { client: string // 'vscode-desktop' | 'cursor' | 'vscodium' | etc. - app_name: string - app_host: string - is_remote: boolean - remote_name: string | undefined - platform: string // 'win32' | 'darwin' | 'linux' - arch: string // 'x64' | 'arm64' + 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 diff --git a/src/utils/telemetry/vscode.ts b/src/utils/telemetry/vscode.ts index f164cc5..22ed47e 100644 --- a/src/utils/telemetry/vscode.ts +++ b/src/utils/telemetry/vscode.ts @@ -45,6 +45,7 @@ export function getClientInfo(): ClientInfo { } } +/** 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") @@ -52,6 +53,7 @@ export function isTelemetryEnabled(): boolean { "telemetry.enabled", true, ) + // Telemetry is enabled only if both VS Code and extension settings allow it return vscodeTelemetryEnabled && extensionTelemetryEnabled } @@ -132,7 +134,7 @@ export async function getInstalledVersions(): Promise<{ ? `${environment.version.major}.${environment.version.minor}.${environment.version.micro}` : `${environment.version.major}.${environment.version.minor}` - // Get FastAPI version by running python command + // Get FastAPI version let fastapiVersion: string | undefined if (environment.executable?.uri) { try { From 17164097756d336781a0164fef9ec417e64306e5 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 11:48:51 -0800 Subject: [PATCH 11/17] Update esbuild comments --- esbuild.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esbuild.js b/esbuild.js index a11bf29..9540637 100644 --- a/esbuild.js +++ b/esbuild.js @@ -8,7 +8,7 @@ import { import path from "node:path" import esbuild from "esbuild" -// Load .env file if it exists +// Load .env file if it exists (needed for POSTHOG_API_KEY) const envPath = path.join(import.meta.dirname, ".env") if (existsSync(envPath)) { const envContent = readFileSync(envPath, "utf-8") From 15b237e5ba55c3fc65e7c846b9789276fc5672cd Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 11:53:02 -0800 Subject: [PATCH 12/17] Add explicit browser check --- src/utils/telemetry/vscode.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/utils/telemetry/vscode.ts b/src/utils/telemetry/vscode.ts index 22ed47e..67f7890 100644 --- a/src/utils/telemetry/vscode.ts +++ b/src/utils/telemetry/vscode.ts @@ -80,6 +80,12 @@ export async function initVSCodeTelemetry( 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 From fc6f21e02a5e7a87f4c2a9d0458fd115fc4d1a38 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 22 Jan 2026 11:54:37 -0800 Subject: [PATCH 13/17] add setting to table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4991b92..c285846 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A VS Code extension for FastAPI development that discovers and displays your API |---------|-------------|---------| | `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.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. From 0f18e3700727652b5a2ce2aa4d8e870ab18308f0 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 26 Jan 2026 09:11:25 -0800 Subject: [PATCH 14/17] Inline posthog key --- esbuild.js | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/esbuild.js b/esbuild.js index 9540637..988d6d0 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,32 +1,12 @@ -import { - copyFileSync, - existsSync, - globSync, - mkdirSync, - readFileSync, -} from "node:fs" +import { copyFileSync, globSync, mkdirSync } from "node:fs" import path from "node:path" import esbuild from "esbuild" -// Load .env file if it exists (needed for POSTHOG_API_KEY) -const envPath = path.join(import.meta.dirname, ".env") -if (existsSync(envPath)) { - const envContent = readFileSync(envPath, "utf-8") - for (const line of envContent.split("\n")) { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith("#")) { - const [key, ...valueParts] = trimmed.split("=") - const value = valueParts.join("=") - if (key && value !== undefined && !process.env[key]) { - process.env[key] = value - } - } - } -} - 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 }) @@ -66,9 +46,9 @@ async function main() { logLevel: "info", define: { "process.env.NODE_ENV": production ? '"production"' : '"development"', - "process.env.POSTHOG_API_KEY": JSON.stringify( - process.env.POSTHOG_API_KEY || "", - ), + "process.env.POSTHOG_API_KEY": production + ? JSON.stringify(POSTHOG_API_KEY) + : '""', __DIST_ROOT__: JSON.stringify(path.join(import.meta.dirname, "dist")), }, } From 010fd63c321e78dc52871ffa70c9b2f0dfc0a0ae Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 26 Jan 2026 09:42:16 -0800 Subject: [PATCH 15/17] Add support to find other packages --- esbuild.js | 10 ++++- src/extension.ts | 3 +- src/utils/telemetry/client.ts | 10 +++++ src/utils/telemetry/index.ts | 2 +- src/utils/telemetry/vscode.ts | 78 ++++++++++++++++++++++------------- 5 files changed, 72 insertions(+), 31 deletions(-) diff --git a/esbuild.js b/esbuild.js index 988d6d0..275a0ec 100644 --- a/esbuild.js +++ b/esbuild.js @@ -80,7 +80,15 @@ async function main() { // vscode is provided by the runtime; web-tree-sitter is bundled but // internally references these Node.js modules for environment detection // posthog-node uses Node.js APIs, so telemetry is disabled in browser - external: ["vscode", "fs/promises", "module", "posthog-node"], + // 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/src/extension.ts b/src/extension.ts index 7f94c14..b5c81b0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,7 @@ import { incrementRouteCopied, incrementRouteNavigated, initVSCodeTelemetry, + TRACKED_PACKAGES, client as telemetryClient, trackActivation, trackActivationFailed, @@ -101,7 +102,7 @@ export async function activate(context: vscode.ExtensionContext) { } // Get actual installed Python and FastAPI versions from the active interpreter - const installedVersions = await getInstalledVersions() + const installedVersions = await getInstalledVersions(TRACKED_PACKAGES) // Set versions on telemetry client so they're included in all events telemetryClient.setVersions( diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts index 5e6187c..f16845b 100644 --- a/src/utils/telemetry/client.ts +++ b/src/utils/telemetry/client.ts @@ -9,6 +9,16 @@ 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 diff --git a/src/utils/telemetry/index.ts b/src/utils/telemetry/index.ts index 71736b8..1f90cb0 100644 --- a/src/utils/telemetry/index.ts +++ b/src/utils/telemetry/index.ts @@ -1,5 +1,5 @@ // Re-export everything for clean imports -export { client } from "./client" +export { client, TRACKED_PACKAGES } from "./client" export { countRouters, countRoutes, diff --git a/src/utils/telemetry/vscode.ts b/src/utils/telemetry/vscode.ts index 67f7890..0e05069 100644 --- a/src/utils/telemetry/vscode.ts +++ b/src/utils/telemetry/vscode.ts @@ -100,14 +100,46 @@ export async function initVSCodeTelemetry( } /** - * Get actual Python and FastAPI versions from the active interpreter. - * Uses the Python extension API if available and already active. - * If not active, events will not have version info. + * Fetch package versions using the Python interpreter. */ -export async function getInstalledVersions(): Promise<{ - pythonVersion?: string - fastapiVersion?: string -}> { +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) + const versions: { [key: string]: string | undefined } = {} + + for (const pkg of packages) { + // Convert package name: remove hyphens for key, replace with underscores for import + const pkgNiceName = pkg.replace(/-/g, "") + const importName = pkg.replace(/-/g, "_") + const key = `${pkgNiceName}Version` + + try { + const { stdout } = await execFileAsync( + pythonPath, + ["-c", `import ${importName}; print(${importName}.__version__)`], + { timeout: 5000 }, + ) + versions[key] = stdout.trim() || undefined + } catch { + versions[key] = 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 a map of package versions. + */ +export async function getInstalledVersions( + packages: readonly string[] = [], +): Promise<{ pythonVersion?: string; [key: string]: string | undefined }> { try { // Get Python extension API const pythonExtension = vscode.extensions.getExtension("ms-python.python") @@ -140,30 +172,20 @@ export async function getInstalledVersions(): Promise<{ ? `${environment.version.major}.${environment.version.minor}.${environment.version.micro}` : `${environment.version.major}.${environment.version.minor}` - // Get FastAPI version - let fastapiVersion: string | undefined - if (environment.executable?.uri) { - try { - const pythonPath = environment.executable.uri.fsPath - const { promisify } = await import("util") - const { execFile } = await import("child_process") - const execFileAsync = promisify(execFile) - - const { stdout } = await execFileAsync( - pythonPath, - ["-c", "import fastapi; print(fastapi.__version__)"], - { timeout: 5000 }, - ) - - fastapiVersion = stdout.trim() || undefined - } catch { - // FastAPI not installed or execution failed - } + // Try to fetch package versions if we have an executable path + if (!environment.executable?.uri) { + return { pythonVersion } } - return { pythonVersion, fastapiVersion } + const pythonPath = environment.executable.uri.fsPath + const packageVersions = await fetchPackageVersions( + pythonPath, + packages, + ).catch(() => ({})) + + return { pythonVersion, ...packageVersions } } catch { - // Python extension not available or error + // If Python extension is not available or any error occurs, return empty return {} } } From 34865a6a24258ebac015ac500fc938d9bc641c6a Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 26 Jan 2026 10:03:57 -0800 Subject: [PATCH 16/17] Refactor for more package version collection --- src/extension.ts | 7 ++---- src/utils/telemetry/client.ts | 16 ++++++-------- src/utils/telemetry/vscode.ts | 40 +++++++++++++++++++---------------- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index b5c81b0..ffbf00a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -101,14 +101,11 @@ export async function activate(context: vscode.ExtensionContext) { throw error } - // Get actual installed Python and FastAPI versions from the active interpreter + // 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.pythonVersion, - installedVersions.fastapiVersion, - ) + telemetryClient.setVersions(installedVersions) trackActivation({ duration_ms: elapsed(), diff --git a/src/utils/telemetry/client.ts b/src/utils/telemetry/client.ts index f16845b..52184ca 100644 --- a/src/utils/telemetry/client.ts +++ b/src/utils/telemetry/client.ts @@ -24,8 +24,7 @@ export class TelemetryClient { private userId: string | null = null private config: TelemetryConfig | null = null private initialized = false - private pythonVersion: string | undefined = undefined - private fastapiVersion: string | undefined = undefined + private packageVersions: Record = {} private sessionId: string | null = null private sessionStartTime: number | null = null @@ -63,12 +62,11 @@ export class TelemetryClient { } /** - * Set Python and FastAPI versions to include in all events. + * Set package versions to include in all events. * Call this after detecting versions from the Python extension. */ - setVersions(pythonVersion?: string, fastapiVersion?: string): void { - this.pythonVersion = pythonVersion - this.fastapiVersion = fastapiVersion + setVersions(versions: Record): void { + this.packageVersions = versions } getSessionDuration(): number | null { @@ -84,8 +82,7 @@ export class TelemetryClient { this.initialized = false this.userId = null this.config = null - this.pythonVersion = undefined - this.fastapiVersion = undefined + this.packageVersions = {} this.sessionId = null this.sessionStartTime = null } @@ -103,8 +100,7 @@ export class TelemetryClient { platform: this.config.clientInfo.platform, arch: this.config.clientInfo.arch, extension_version: this.config.extensionVersion, - python_version: this.pythonVersion, - fastapi_version: this.fastapiVersion, + ...this.packageVersions, $session_id: this.sessionId, }, }) diff --git a/src/utils/telemetry/vscode.ts b/src/utils/telemetry/vscode.ts index 0e05069..54de704 100644 --- a/src/utils/telemetry/vscode.ts +++ b/src/utils/telemetry/vscode.ts @@ -101,6 +101,7 @@ export async function initVSCodeTelemetry( /** * Fetch package versions using the Python interpreter. + * Runs all version checks in parallel for better performance. */ async function fetchPackageVersions( pythonPath: string, @@ -109,25 +110,28 @@ async function fetchPackageVersions( const { promisify } = await import("util") const { execFile } = await import("child_process") const execFileAsync = promisify(execFile) - const versions: { [key: string]: string | undefined } = {} - - for (const pkg of packages) { - // Convert package name: remove hyphens for key, replace with underscores for import - const pkgNiceName = pkg.replace(/-/g, "") - const importName = pkg.replace(/-/g, "_") - const key = `${pkgNiceName}Version` - try { + // 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 }, ) - versions[key] = stdout.trim() || undefined - } catch { - versions[key] = undefined - } - } + 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 } @@ -135,11 +139,11 @@ async function fetchPackageVersions( /** * 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 a map of package versions. + * @returns An object with python_version and package versions (e.g., fastapi_version, pydantic_version). */ export async function getInstalledVersions( packages: readonly string[] = [], -): Promise<{ pythonVersion?: string; [key: string]: string | undefined }> { +): Promise<{ python_version?: string; [key: string]: string | undefined }> { try { // Get Python extension API const pythonExtension = vscode.extensions.getExtension("ms-python.python") @@ -168,13 +172,13 @@ export async function getInstalledVersions( } // Extract Python version including patch version (e.g., "3.11.5") - const pythonVersion = environment.version.micro + 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 { pythonVersion } + return { python_version } } const pythonPath = environment.executable.uri.fsPath @@ -183,7 +187,7 @@ export async function getInstalledVersions( packages, ).catch(() => ({})) - return { pythonVersion, ...packageVersions } + return { python_version, ...packageVersions } } catch { // If Python extension is not available or any error occurs, return empty return {} From 2b2ee8c60d8ffff5c0d7ee95b6967607125982f5 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 26 Jan 2026 10:05:18 -0800 Subject: [PATCH 17/17] Update docs --- TELEMETRY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TELEMETRY.md b/TELEMETRY.md index 219f331..293034d 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -32,7 +32,7 @@ We collect anonymous usage metrics to improve the extension. We do **not** colle - 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 and FastAPI versions from your active interpreter. +**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