diff --git a/.chronus/changes/vscode-telemetry-2025-1-24-22-51-43.md b/.chronus/changes/vscode-telemetry-2025-1-24-22-51-43.md new file mode 100644 index 00000000000..2006dbdd74e --- /dev/null +++ b/.chronus/changes/vscode-telemetry-2025-1-24-22-51-43.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - typespec-vscode +--- + +Support telemetry \ No newline at end of file diff --git a/eng/tsp-core/pipelines/jobs/build-for-publish.yml b/eng/tsp-core/pipelines/jobs/build-for-publish.yml index cbeaac447c5..ac9ac0c259c 100644 --- a/eng/tsp-core/pipelines/jobs/build-for-publish.yml +++ b/eng/tsp-core/pipelines/jobs/build-for-publish.yml @@ -10,15 +10,19 @@ jobs: parameters: useDotNet: true + - script: pnpm run update-telemetry-key $(vscode.telemetryKey) + workingDirectory: $(Build.SourcesDirectory)/packages/typespec-vscode + displayName: Update vscode telemetry key + - template: /eng/tsp-core/pipelines/templates/build.yml - script: pnpm run test:ci displayName: Test - - template: /eng/tsp-core/pipelines/templates/upload-coverage.yml + # - template: /eng/tsp-core/pipelines/templates/upload-coverage.yml - - script: pnpm chronus pack --exclude standalone --pack-destination $(Build.ArtifactStagingDirectory)/npm-packages-stable - displayName: Pack packages + # - script: pnpm chronus pack --exclude standalone --pack-destination $(Build.ArtifactStagingDirectory)/npm-packages-stable + # displayName: Pack packages - task: CopyFiles@2 displayName: "Copy VSCode extension .vsix to artifact directory" @@ -27,45 +31,45 @@ jobs: Contents: "*.vsix" TargetFolder: "$(Build.ArtifactStagingDirectory)/vscode-extension" - - task: CopyFiles@2 - displayName: "Copy VS extension .vsix to artifact directory" - inputs: - SourceFolder: "$(Build.SourcesDirectory)/packages/typespec-vs" - Contents: "*.vsix" - TargetFolder: "$(Build.ArtifactStagingDirectory)/vs-extension" - - - task: AzureCLI@1 - displayName: "Publish bundled packages to package storage" - inputs: - azureSubscription: "Azure SDK Engineering System" - scriptLocation: inlineScript - inlineScript: node ./eng/tsp-core/scripts/upload-bundler-packages.js - - # Update version for next version publish - - script: node ./packages/internal-build-utils/cmd/cli.js bump-version-preview . - displayName: Bump version to prerelease targets - - - script: npm run gen-manifest - displayName: Regen manifest for compiler - workingDirectory: ./packages/compiler - - - script: pnpm chronus pack --exclude standalone --pack-destination $(Build.ArtifactStagingDirectory)/npm-packages-next - displayName: Pack next packages - - # Publish Next playground - - task: AzureCLI@1 - displayName: "Publish playground" - inputs: - azureSubscription: "Azure SDK Engineering System" - scriptLocation: inlineScript - inlineScript: | - az storage blob upload-batch ^ - --auth-mode login ^ - --destination $web ^ - --account-name "cadlplayground" ^ - --destination-path / ^ - --source "./packages/playground-website/dist/web/" ^ - --overwrite + # - task: CopyFiles@2 + # displayName: "Copy VS extension .vsix to artifact directory" + # inputs: + # SourceFolder: "$(Build.SourcesDirectory)/packages/typespec-vs" + # Contents: "*.vsix" + # TargetFolder: "$(Build.ArtifactStagingDirectory)/vs-extension" + + # - task: AzureCLI@1 + # displayName: "Publish bundled packages to package storage" + # inputs: + # azureSubscription: "Azure SDK Engineering System" + # scriptLocation: inlineScript + # inlineScript: node ./eng/tsp-core/scripts/upload-bundler-packages.js + + # # Update version for next version publish + # - script: node ./packages/internal-build-utils/cmd/cli.js bump-version-preview . + # displayName: Bump version to prerelease targets + + # - script: npm run gen-manifest + # displayName: Regen manifest for compiler + # workingDirectory: ./packages/compiler + + # - script: pnpm chronus pack --exclude standalone --pack-destination $(Build.ArtifactStagingDirectory)/npm-packages-next + # displayName: Pack next packages + + # # Publish Next playground + # - task: AzureCLI@1 + # displayName: "Publish playground" + # inputs: + # azureSubscription: "Azure SDK Engineering System" + # scriptLocation: inlineScript + # inlineScript: | + # az storage blob upload-batch ^ + # --auth-mode login ^ + # --destination $web ^ + # --account-name "cadlplayground" ^ + # --destination-path / ^ + # --source "./packages/playground-website/dist/web/" ^ + # --overwrite templateContext: outputs: diff --git a/eng/tsp-core/pipelines/publish.yml b/eng/tsp-core/pipelines/publish.yml index 4a8d65ecc36..b5ea68c53d0 100644 --- a/eng/tsp-core/pipelines/publish.yml +++ b/eng/tsp-core/pipelines/publish.yml @@ -30,90 +30,90 @@ extends: jobs: - template: /eng/tsp-core/pipelines/jobs/build-for-publish.yml@self - - template: /eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml@self - - - stage: publish_npm - displayName: Publish Npm Packages - dependsOn: build - - pool: - name: $(LINUXPOOL) - image: $(LINUXVMIMAGE) - os: linux - - jobs: - - template: /eng/tsp-core/pipelines/jobs/publish-npm.yml - parameters: - artifactName: npm-packages-stable - tag: latest - publishGithubRelease: true - - - template: /eng/tsp-core/pipelines/jobs/publish-npm.yml - parameters: - artifactName: npm-packages-next - tag: next - - - stage: publish_vscode - displayName: Publish VSCode extension - dependsOn: build - jobs: - - template: /eng/tsp-core/pipelines/jobs/publish-vscode.yml - - - stage: publish_vs - displayName: Publish VS extension - dependsOn: build - jobs: - - template: /eng/tsp-core/pipelines/jobs/publish-vs.yml - - - template: /eng/tsp-core/pipelines/stages/sign-publish-tsp-cli.yml@self - - - stage: docker - displayName: Docker - dependsOn: [] - jobs: - - job: docker - displayName: Build and publish - variables: - imageName: "azsdkengsys.azurecr.io/typespec" - pool: - name: $(LINUXPOOL) - image: $(LINUXVMIMAGE) - os: linux - steps: - - task: Docker@1 - displayName: login - inputs: - azureSubscriptionEndpoint: "AzureSDKEngKeyVault Secrets" - azureContainerRegistry: azsdkengsys.azurecr.io - command: login - - - script: | - docker build -f ./docker/Dockerfile \ - -t $(imageName):latest \ - -t $(imageName):alpine \ - . - displayName: "Build" - - script: docker push $(imageName) --all-tags - displayName: "Push" - - - stage: validate_manifest - displayName: Validate Manifest - dependsOn: build - jobs: - - job: validate_manifest - displayName: Validate Manifest - pool: - name: $(WINDOWSPOOL) - image: $(WINDOWSVMIMAGE) - os: windows - variables: - TYPESPEC_SKIP_DOCUSAURUS_BUILD: true # Disable docusaurus build - steps: - - template: /eng/tsp-core/pipelines/templates/install.yml - - template: /eng/tsp-core/pipelines/templates/build.yml - - - script: pnpm run validate-scenarios --debug - displayName: Validate Scenarios - - - script: pnpm run validate-mock-apis --debug - displayName: Validate mock apis + # - template: /eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml@self + + # - stage: publish_npm + # displayName: Publish Npm Packages + # dependsOn: build + + # pool: + # name: $(LINUXPOOL) + # image: $(LINUXVMIMAGE) + # os: linux + + # jobs: + # - template: /eng/tsp-core/pipelines/jobs/publish-npm.yml + # parameters: + # artifactName: npm-packages-stable + # tag: latest + # publishGithubRelease: true + + # - template: /eng/tsp-core/pipelines/jobs/publish-npm.yml + # parameters: + # artifactName: npm-packages-next + # tag: next + + # - stage: publish_vscode + # displayName: Publish VSCode extension + # dependsOn: build + # jobs: + # - template: /eng/tsp-core/pipelines/jobs/publish-vscode.yml + + # - stage: publish_vs + # displayName: Publish VS extension + # dependsOn: build + # jobs: + # - template: /eng/tsp-core/pipelines/jobs/publish-vs.yml + + # - template: /eng/tsp-core/pipelines/stages/sign-publish-tsp-cli.yml@self + + # - stage: docker + # displayName: Docker + # dependsOn: [] + # jobs: + # - job: docker + # displayName: Build and publish + # variables: + # imageName: "azsdkengsys.azurecr.io/typespec" + # pool: + # name: $(LINUXPOOL) + # image: $(LINUXVMIMAGE) + # os: linux + # steps: + # - task: Docker@1 + # displayName: login + # inputs: + # azureSubscriptionEndpoint: "AzureSDKEngKeyVault Secrets" + # azureContainerRegistry: azsdkengsys.azurecr.io + # command: login + + # - script: | + # docker build -f ./docker/Dockerfile \ + # -t $(imageName):latest \ + # -t $(imageName):alpine \ + # . + # displayName: "Build" + # - script: docker push $(imageName) --all-tags + # displayName: "Push" + + # - stage: validate_manifest + # displayName: Validate Manifest + # dependsOn: build + # jobs: + # - job: validate_manifest + # displayName: Validate Manifest + # pool: + # name: $(WINDOWSPOOL) + # image: $(WINDOWSVMIMAGE) + # os: windows + # variables: + # TYPESPEC_SKIP_DOCUSAURUS_BUILD: true # Disable docusaurus build + # steps: + # - template: /eng/tsp-core/pipelines/templates/install.yml + # - template: /eng/tsp-core/pipelines/templates/build.yml + + # - script: pnpm run validate-scenarios --debug + # displayName: Validate Scenarios + + # - script: pnpm run validate-mock-apis --debug + # displayName: Validate mock apis diff --git a/packages/typespec-vscode/ThirdPartyNotices.txt b/packages/typespec-vscode/ThirdPartyNotices.txt index d0d4254891f..1b0bead4696 100644 --- a/packages/typespec-vscode/ThirdPartyNotices.txt +++ b/packages/typespec-vscode/ThirdPartyNotices.txt @@ -8,11 +8,69 @@ original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise. -1. balanced-match version 1.0.2 (https://github.com/juliangruber/balanced-match) -2. brace-expansion version 2.0.1 (https://github.com/juliangruber/brace-expansion) -3. minimatch version 5.1.6 (https://github.com/isaacs/minimatch) -4. semver version 7.6.3 (https://github.com/npm/node-semver) -5. yaml version 2.7.0 (github:eemeli/yaml) +1. @nevware21/ts-async version 0.5.4 (https://github.com/nevware21/ts-async) +2. @nevware21/ts-utils version 0.11.6 (https://github.com/nevware21/ts-utils) +3. balanced-match version 1.0.2 (https://github.com/juliangruber/balanced-match) +4. brace-expansion version 2.0.1 (https://github.com/juliangruber/brace-expansion) +5. minimatch version 5.1.6 (https://github.com/isaacs/minimatch) +6. semver version 7.6.3 (https://github.com/npm/node-semver) +7. yaml version 2.7.0 (github:eemeli/yaml) + + +%% @nevware21/ts-async NOTICES AND INFORMATION BEGIN HERE +===================================================== +MIT License + +Copyright (c) 2022 NevWare21 Solutions LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +====================================================="); +END OF @nevware21/ts-async NOTICES AND INFORMATION + + +%% @nevware21/ts-utils NOTICES AND INFORMATION BEGIN HERE +===================================================== +MIT License + +Copyright (c) 2022 NevWare21 Solutions LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +====================================================="); +END OF @nevware21/ts-utils NOTICES AND INFORMATION %% balanced-match NOTICES AND INFORMATION BEGIN HERE diff --git a/packages/typespec-vscode/package.json b/packages/typespec-vscode/package.json index aec6abec84f..2e0b6ddb1b4 100644 --- a/packages/typespec-vscode/package.json +++ b/packages/typespec-vscode/package.json @@ -36,6 +36,7 @@ "workspaceContains:**/tspconfig.yaml" ], "icon": "./icons/logo.png", + "telemetryKey": "00000000-0000-0000-0000-000000000000", "contributes": { "viewsWelcome": [ { @@ -232,6 +233,7 @@ "copy-tmlanguage": "node scripts/copy-tmlanguage.js", "generate-language-configuration": "node scripts/generate-language-configuration.js", "generate-third-party-notices": "typespec-build-tool generate-third-party-notices", + "update-telemetry-key": "node scripts/update-telemetry-key.js", "package-vsix": "vsce package", "deploy": "vsce publish", "open-in-browser": "vscode-test-web --extensionDevelopmentPath=. .", @@ -240,6 +242,7 @@ }, "devDependencies": { "@rollup/plugin-commonjs": "~28.0.2", + "@rollup/plugin-json": "~6.1.0", "@rollup/plugin-node-resolve": "~16.0.0", "@rollup/plugin-typescript": "~12.1.0", "@types/mocha": "^10.0.9", @@ -250,6 +253,7 @@ "@typespec/internal-build-utils": "workspace:~", "@vitest/coverage-v8": "^3.0.4", "@vitest/ui": "^3.0.3", + "@vscode/extension-telemetry": "^0.9.8", "@vscode/test-web": "^0.0.65", "@vscode/vsce": "~3.2.1", "c8": "^10.1.3", diff --git a/packages/typespec-vscode/rollup.config.ts b/packages/typespec-vscode/rollup.config.ts index 3d2f2727578..2eaf418fc53 100644 --- a/packages/typespec-vscode/rollup.config.ts +++ b/packages/typespec-vscode/rollup.config.ts @@ -3,11 +3,12 @@ import resolve from "@rollup/plugin-node-resolve"; import typescript from "@rollup/plugin-typescript"; import { dirname } from "path"; +import json from "@rollup/plugin-json"; import { defineConfig } from "rollup"; import { fileURLToPath } from "url"; const projDir = dirname(fileURLToPath(import.meta.url)); -const plugins = [(resolve as any)({ preferBuiltins: true }), (commonjs as any)()]; +const plugins = [(resolve as any)({ preferBuiltins: true }), (commonjs as any)(), (json as any)()]; const baseConfig = defineConfig({ input: "src/extension.ts", output: { diff --git a/packages/typespec-vscode/scripts/update-telemetry-key.js b/packages/typespec-vscode/scripts/update-telemetry-key.js new file mode 100644 index 00000000000..2e7b6cd81a5 --- /dev/null +++ b/packages/typespec-vscode/scripts/update-telemetry-key.js @@ -0,0 +1,37 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const newKey = process.argv[2]; +if (!newKey) { + console.log("One argument for telemetry-key to use is expected. Exit without updating anything"); + process.exit(1); +} else { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const targetPackageJsonFile = path.resolve(__dirname, "../package.json"); + console.log(`Updating package.json at ${targetPackageJsonFile}`); + const packageJson = JSON.parse(fs.readFileSync(targetPackageJsonFile, "utf8")); + const oldKey = packageJson.telemetryKey ?? ""; + const keyToString = (key) => + `'${key.substring(0, 8)}...${key.substring(Math.max(0, key.length - 17))}'(length: ${key.length})`; + + console.log(`Updating telemetry key from ${keyToString(oldKey)} to ${keyToString(newKey)}`); + packageJson.telemetryKey = newKey; + + fs.writeFileSync(targetPackageJsonFile, JSON.stringify(packageJson, null, 2)); + + // double verify the updated result + const newPackageJson = JSON.parse(fs.readFileSync(targetPackageJsonFile, "utf8")); + const updatedKey = newPackageJson.telemetryKey ?? ""; + if (updatedKey !== newKey) { + console.error( + `Failed to update telemetry key in package.json. Actual = ${keyToString(updatedKey)}; Expected = ${keyToString(newKey)}`, + ); + process.exit(2); + } else { + console.log( + `telemetryKey in package.json updated to '${keyToString(updatedKey)}' successfully`, + ); + } +} diff --git a/packages/typespec-vscode/src/const.ts b/packages/typespec-vscode/src/const.ts index 8a5d3e4b8f8..5e75dd8acfa 100644 --- a/packages/typespec-vscode/src/const.ts +++ b/packages/typespec-vscode/src/const.ts @@ -1,2 +1,3 @@ export const StartFileName = "main.tsp"; export const TspConfigFileName = "tspconfig.yaml"; +export const EmptyGuid = "00000000-0000-0000-0000-000000000000"; diff --git a/packages/typespec-vscode/src/extension.ts b/packages/typespec-vscode/src/extension.ts index 87e33a09f91..093939d2ba7 100644 --- a/packages/typespec-vscode/src/extension.ts +++ b/packages/typespec-vscode/src/extension.ts @@ -6,11 +6,16 @@ import { ExtensionLogListener, getPopupAction } from "./log/extension-log-listen import logger from "./log/logger.js"; import { TypeSpecLogOutputChannel } from "./log/typespec-log-output-channel.js"; import { createTaskProvider } from "./task-provider.js"; +import telemetryClient from "./telemetry/telemetry-client.js"; +import { OperationTelemetryEvent, TelemetryEventName } from "./telemetry/telemetry-event.js"; import { TspLanguageClient } from "./tsp-language-client.js"; import { CommandName, InstallGlobalCliCommandArgs, RestartServerCommandArgs, + RestartServerCommandResult, + Result, + ResultCode, SettingName, } from "./types.js"; import { isWhitespaceStringOrUndefined } from "./utils.js"; @@ -30,6 +35,8 @@ logger.registerLogListener("extension-log", new ExtensionLogListener(outputChann export async function activate(context: ExtensionContext) { const stateManager = new ExtensionStateManager(context); + context.subscriptions.push(telemetryClient); + context.subscriptions.push(createTaskProvider()); context.subscriptions.push(createCodeActionProvider()); @@ -67,26 +74,35 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push( commands.registerCommand( CommandName.RestartServer, - async (args: RestartServerCommandArgs | undefined): Promise => { + async (args: RestartServerCommandArgs | undefined): Promise => { return vscode.window.withProgress( { title: args?.notificationMessage ?? "Restarting TypeSpec language service...", location: vscode.ProgressLocation.Notification, }, async () => { - if (args?.forceRecreate === true) { - logger.info("Forcing to recreate TypeSpec LSP server..."); - return await recreateLSPClient(context); - } - if (client && client.state === State.Running) { - await client.restart(); - return client; - } else { - logger.info( - "TypeSpec LSP server is not running which is not expected, try to recreate and start...", - ); - return recreateLSPClient(context); - } + return await telemetryClient.doOperationWithTelemetry( + TelemetryEventName.RestartServer, + async (tel) => { + if (args?.forceRecreate === true) { + logger.info("Forcing to recreate TypeSpec LSP server..."); + tel.lastStep = "Recreate LSP client in force"; + return await recreateLSPClient(context, tel.activityId); + } + if (client && client.state === State.Running) { + tel.lastStep = "Restart LSP client"; + await client.restart(); + return { code: ResultCode.Success, value: client }; + } else { + logger.info( + "TypeSpec LSP server is not running which is not expected, try to recreate and start...", + ); + tel.lastStep = "Recreate LSP client"; + return await recreateLSPClient(context, tel.activityId); + } + }, + args?.activityId, + ); }, ); }, @@ -118,7 +134,12 @@ export async function activate(context: ExtensionContext) { vscode.workspace.onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => { if (e.affectsConfiguration(SettingName.TspServerPath)) { logger.info("TypeSpec server path changed, restarting server..."); - await recreateLSPClient(context); + await telemetryClient.doOperationWithTelemetry( + TelemetryEventName.ServerPathSettingChanged, + async (tel) => { + return await recreateLSPClient(context, tel.activityId); + }, + ); } }), ); @@ -160,7 +181,12 @@ export async function activate(context: ExtensionContext) { location: vscode.ProgressLocation.Notification, }, async () => { - await recreateLSPClient(context); + await telemetryClient.doOperationWithTelemetry( + TelemetryEventName.StartExtension, + async (tel: OperationTelemetryEvent) => { + return await recreateLSPClient(context, tel.activityId); + }, + ); }, ); } else { @@ -173,13 +199,23 @@ export async function deactivate() { await client?.stop(); } -async function recreateLSPClient(context: ExtensionContext) { +async function recreateLSPClient( + context: ExtensionContext, + activityId: string, +): Promise> { logger.info("Recreating TypeSpec LSP server..."); const oldClient = client; - client = await TspLanguageClient.create(context, outputChannel); + client = await TspLanguageClient.create(activityId, context, outputChannel); await oldClient?.stop(); - await client.start(); - return client; + await client.start(activityId); + if (client.state === State.Running) { + telemetryClient.logOperationDetailTelemetry(activityId, { + compilerVersion: client.initializeResult?.serverInfo?.version ?? "< 0.64.0", + }); + return { code: ResultCode.Success, value: client }; + } else { + return { code: ResultCode.Fail, details: "TspLanguageClient is not running." }; + } } function showStartUpMessages(stateManager: ExtensionStateManager) { diff --git a/packages/typespec-vscode/src/telemetry/telemetry-client.ts b/packages/typespec-vscode/src/telemetry/telemetry-client.ts new file mode 100644 index 00000000000..6dec66395ef --- /dev/null +++ b/packages/typespec-vscode/src/telemetry/telemetry-client.ts @@ -0,0 +1,200 @@ +import TelemetryReporter from "@vscode/extension-telemetry"; +import pkgJson from "../../package.json" assert { type: "json" }; +import { EmptyGuid } from "../const.js"; +import logger from "../log/logger.js"; +import { ResultCode } from "../types.js"; +import { isWhitespaceStringOrUndefined } from "../utils.js"; +import { + emptyActivityId, + generateActivityId, + OperationDetailPropertyName, + OperationTelemetryEvent, + TelemetryEventName, +} from "./telemetry-event.js"; + +class TelemetryClient { + private _client: TelemetryReporter.default | undefined; + // The maximum number of telemetry error to log to avoid too much noise from it when + // the telemetry doesn't work for some reason + private readonly MAX_LOG_TELEMETRY_ERROR = 5; + private _logTelemetryErrorCount = 0; + + constructor() { + this.initClient(); + } + + private initClient() { + const cs = this.getConnectionString(); + if (!cs) { + this.logErrorWhenLoggingTelemetry( + "Skip initializing telemetry client because no telemetry key is provided", + ); + this._client = undefined; + } else { + this._client = new (TelemetryReporter as any)(cs); + } + } + + private getConnectionString(): string | undefined { + let key: string | undefined = pkgJson.telemetryKey; + if (!key || key === EmptyGuid) { + // try to check environment variable VSCODE_TELEMETRY_KEY if the key is not provided in package.json + key = process.env.VSCODE_TELEMETRY_KEY; + } + if (!key || key === EmptyGuid) { + return undefined; + } + return `InstrumentationKey=${pkgJson.telemetryKey}`; + } + + public async flush() { + // flush function is not exposed by the telemetry client, so we leverage dispose to trigger the flush and recreate the client + await this._client?.dispose(); + this._client = undefined; + this.initClient(); + } + + private sendEvent( + eventName: string, + properties?: { [key: string]: string }, + measurements?: { [key: string]: number }, + ): void { + try { + this._client?.sendTelemetryEvent(eventName, properties, measurements); + } catch (e) { + this.logErrorWhenLoggingTelemetry(e); + } + } + + private sendErrorEvent( + eventName: string, + properties?: { [key: string]: string }, + measurements?: { [key: string]: number }, + ): void { + try { + this._client?.sendTelemetryErrorEvent(eventName, properties, measurements); + } catch (e) { + this.logErrorWhenLoggingTelemetry(e); + } + } + + public async doOperationWithTelemetry( + eventName: TelemetryEventName, + /** + * The result will be set automatically if the return type is ResultCode or Result + * Otherwise, you can set the result manually by setting the opTelemetryEvent.result + */ + operation: ( + opTelemetryEvent: OperationTelemetryEvent, + /** Call this function to send the telemetry event if you don't want to wait until the end of the operation for some reason*/ + sendTelemetryEvent: (result: ResultCode) => void, + ) => Promise, + activityId?: string, + ): Promise { + const opTelemetryEvent = this.createOperationTelemetryEvent(eventName, activityId); + let eventSent = false; + const sendTelemetryEvent = (result?: ResultCode) => { + if (!eventSent) { + eventSent = true; + opTelemetryEvent.endTime ??= new Date(); + if (result) { + opTelemetryEvent.result = result; + } + this.logOperationTelemetryEvent(opTelemetryEvent); + } + }; + try { + const result = await operation(opTelemetryEvent, (result) => sendTelemetryEvent(result)); + if (result) { + const isResultCode = (v: any) => Object.values(ResultCode).includes(v as ResultCode); + if (isResultCode(result)) { + opTelemetryEvent.result ??= result as ResultCode; + } else if (typeof result === "object" && "code" in result && isResultCode(result.code)) { + opTelemetryEvent.result ??= result.code as ResultCode; + } + } + return result; + } finally { + sendTelemetryEvent(); + } + } + + public logOperationTelemetryEvent(event: OperationTelemetryEvent) { + const telFunc = + event.result === "success" || event.result === "cancelled" + ? this.sendEvent + : this.sendErrorEvent; + telFunc.call(this, event.eventName, { + activityId: isWhitespaceStringOrUndefined(event.activityId) + ? emptyActivityId + : event.activityId!, + // ISO format: YYYY-MM-DDTHH:mm:ss.sssZ + startTime: event.startTime.toISOString(), + endTime: event.endTime?.toISOString() ?? "", + lastStep: event.lastStep ?? "undefined", + result: event.result ?? "undefined", + }); + } + + public logOperationDetailTelemetry( + activityId: string, + detail: Partial>, + ) { + const data = { + activityId: activityId, + ...detail, + }; + + if (detail.error !== undefined) { + this.sendErrorEvent(TelemetryEventName.OperationDetail, { + ...data, + }); + } else { + this.sendEvent(TelemetryEventName.OperationDetail, { + ...data, + }); + } + } + + /** + * Create a operation telemetry event with following default values. + * Please make sure the default values are updated properly as needed + * activityId: a new random guid will be generated if not provided + * eventName: the event name provided + * startTime: set to the current time + * endTime: undefined + * result: undefined + * lastStep: undefined + */ + private createOperationTelemetryEvent( + eventName: TelemetryEventName, + activityId?: string, + ): OperationTelemetryEvent { + return { + activityId: isWhitespaceStringOrUndefined(activityId) ? generateActivityId() : activityId!, + eventName: eventName, + startTime: new Date(), + endTime: undefined, + result: undefined, + lastStep: undefined, + }; + } + + private logErrorWhenLoggingTelemetry(error: any) { + if (this._logTelemetryErrorCount++ < this.MAX_LOG_TELEMETRY_ERROR) { + logger.error("Failed to log telemetry event\n", [error]); + } + if (this._logTelemetryErrorCount === this.MAX_LOG_TELEMETRY_ERROR) { + logger.error( + `Failed to log telemetry event more than ${this.MAX_LOG_TELEMETRY_ERROR} times, will stop logging more error`, + ); + } + } + + async dispose() { + await this._client?.dispose(); + } +} + +const telemetryClient = new TelemetryClient(); +export default telemetryClient; diff --git a/packages/typespec-vscode/src/telemetry/telemetry-event.ts b/packages/typespec-vscode/src/telemetry/telemetry-event.ts new file mode 100644 index 00000000000..ae928778295 --- /dev/null +++ b/packages/typespec-vscode/src/telemetry/telemetry-event.ts @@ -0,0 +1,40 @@ +import { EmptyGuid } from "../const.js"; +import { ResultCode } from "../types.js"; + +export enum TelemetryEventName { + StartExtension = "start-extension", + CreateProject = "create-project", + InstallGlobalCompilerCli = "install-global-compiler-cli", + RestartServer = "restart-server", + GenerateCode = "generate-code", + ImportFromOpenApi3 = "import-from-openapi3", + ServerPathSettingChanged = "server-path-changed", + OperationDetail = "operation-detail", +} + +export interface TelemetryEventBase { + activityId: string; + eventName: TelemetryEventName; +} + +export interface OperationTelemetryEvent extends TelemetryEventBase { + startTime: Date; + endTime?: Date; + result?: ResultCode; + lastStep?: string; +} + +export enum OperationDetailPropertyName { + error, + emitterPackage, + emitterVersion, + emitResult, + compilerLocation, + compilerVersion, +} + +export function generateActivityId() { + return crypto.randomUUID(); +} + +export const emptyActivityId = EmptyGuid; diff --git a/packages/typespec-vscode/src/tsp-executable-resolver.ts b/packages/typespec-vscode/src/tsp-executable-resolver.ts index fcba487cfd3..3a5ba884593 100644 --- a/packages/typespec-vscode/src/tsp-executable-resolver.ts +++ b/packages/typespec-vscode/src/tsp-executable-resolver.ts @@ -2,6 +2,7 @@ import { dirname, isAbsolute, join } from "path"; import { ExtensionContext, workspace } from "vscode"; import { Executable, ExecutableOptions } from "vscode-languageclient/node.js"; import logger from "./log/logger.js"; +import telemetryClient from "./telemetry/telemetry-client.js"; import { SettingName } from "./types.js"; import { isFile, loadModule, useShellInExec } from "./utils.js"; import { VSCodeVariableResolver } from "./vscode-variable-resolver.js"; @@ -42,7 +43,10 @@ export async function resolveTypeSpecCli( } } -export async function resolveTypeSpecServer(context: ExtensionContext): Promise { +export async function resolveTypeSpecServer( + activityId: string, + context: ExtensionContext, +): Promise { const nodeOptions = process.env.TYPESPEC_SERVER_NODE_OPTIONS; const args = ["--stdio"]; @@ -75,6 +79,9 @@ export async function resolveTypeSpecServer(context: ExtensionContext): Promise< // @typespec/compiler` in a vanilla setup. if (serverPath) { logger.info(`Server path loaded from TypeSpec extension configuration: ${serverPath}`); + telemetryClient.logOperationDetailTelemetry(activityId, { + compilerLocation: "compiler-configured-by-setting", + }); } else { logger.info( "Server path not configured in TypeSpec extension configuration, trying to resolve locally within current workspace.", @@ -87,8 +94,14 @@ export async function resolveTypeSpecServer(context: ExtensionContext): Promise< logger.warning( `Can't resolve server path from either TypeSpec extension configuration or workspace, try to use default value ${executable}.`, ); + telemetryClient.logOperationDetailTelemetry(activityId, { + compilerLocation: "global-compiler", + }); return useShellInExec({ command: executable, args, options }); } + telemetryClient.logOperationDetailTelemetry(activityId, { + compilerLocation: "local-compiler", + }); const variableResolver = new VSCodeVariableResolver({ workspaceFolder, workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. diff --git a/packages/typespec-vscode/src/tsp-language-client.ts b/packages/typespec-vscode/src/tsp-language-client.ts index c0c23ee7a02..2cb523cbc5b 100644 --- a/packages/typespec-vscode/src/tsp-language-client.ts +++ b/packages/typespec-vscode/src/tsp-language-client.ts @@ -5,10 +5,12 @@ import type { InitProjectTemplate, ServerInitializeResult, } from "@typespec/compiler"; +import { inspect } from "util"; import { ExtensionContext, LogOutputChannel, RelativePattern, workspace } from "vscode"; import { Executable, LanguageClient, LanguageClientOptions } from "vscode-languageclient/node.js"; import { TspConfigFileName } from "./const.js"; import logger from "./log/logger.js"; +import telemetryClient from "./telemetry/telemetry-client.js"; import { resolveTypeSpecServer } from "./tsp-executable-resolver.js"; import { ExecOutput, @@ -143,7 +145,7 @@ export class TspLanguageClient { } } - async start(): Promise { + async start(activityId: string): Promise { try { if (this.client.needsStart()) { // please be aware that this method would popup error notification in vscode directly @@ -173,6 +175,9 @@ export class TspLanguageClient { showPopup: true, }); } + telemetryClient.logOperationDetailTelemetry(activityId, { + error: `Error when starting TypeSpec server: ${inspect(e)}`, + }); } } @@ -183,10 +188,11 @@ export class TspLanguageClient { } static async create( + activityId: string, context: ExtensionContext, outputChannel: LogOutputChannel, ): Promise { - const exe = await resolveTypeSpecServer(context); + const exe = await resolveTypeSpecServer(activityId, context); logger.debug("TypeSpec server resolved as ", [exe]); const watchers = [ workspace.createFileSystemWatcher("**/*.cadl"), diff --git a/packages/typespec-vscode/src/types.ts b/packages/typespec-vscode/src/types.ts index e01a8235d45..5ad5fe40d29 100644 --- a/packages/typespec-vscode/src/types.ts +++ b/packages/typespec-vscode/src/types.ts @@ -1,3 +1,5 @@ +import { TspLanguageClient } from "./tsp-language-client.js"; + export const enum SettingName { TspServerPath = "typespec.tsp-server.path", InitTemplatesUrls = "typespec.initTemplatesUrls", @@ -13,7 +15,13 @@ export const enum CommandName { ImportFromOpenApi3 = "typespec.importFromOpenApi3", } -export interface InstallGlobalCliCommandArgs { +export type RestartServerCommandResult = Result; + +export interface BaseCommandArgs { + activityId: string; +} + +export interface InstallGlobalCliCommandArgs extends BaseCommandArgs { /** * whether to confirm with end user before action * default: false @@ -27,7 +35,7 @@ export interface InstallGlobalCliCommandArgs { silentMode?: boolean; } -export interface RestartServerCommandArgs { +export interface RestartServerCommandArgs extends BaseCommandArgs { /** * whether to recreate TspLanguageClient instead of just restarting it */ @@ -35,7 +43,7 @@ export interface RestartServerCommandArgs { notificationMessage?: string; } -export const enum ResultCode { +export enum ResultCode { Success = "success", Fail = "fail", Cancelled = "cancelled", diff --git a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts index 404cc45fd1a..39ca2317c2b 100644 --- a/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts +++ b/packages/typespec-vscode/src/vscode-cmd/create-tsp-project.ts @@ -11,11 +11,14 @@ import { State } from "vscode-languageclient"; import { ExtensionStateManager } from "../extension-state-manager.js"; import logger from "../log/logger.js"; import { getBaseFileName, getDirectoryPath, joinPaths, normalizePath } from "../path-utils.js"; +import telemetryClient from "../telemetry/telemetry-client.js"; +import { TelemetryEventName } from "../telemetry/telemetry-event.js"; import { TspLanguageClient } from "../tsp-language-client.js"; import { CommandName, InstallGlobalCliCommandArgs, RestartServerCommandArgs, + RestartServerCommandResult, Result, ResultCode, SettingName, @@ -63,202 +66,227 @@ export async function createTypeSpecProject( client: TspLanguageClient | undefined, stateManager: ExtensionStateManager, ) { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Window, - cancellable: false, - title: "Creating TypeSpec Project...", - }, - async () => { - const selectedRootFolder = await selectProjectRootFolder(); - if (!selectedRootFolder) { - logger.info("Creating TypeSpec Project cancelled when selecting project root folder."); - return; - } - if (!(await checkProjectRootFolderEmpty(selectedRootFolder))) { - logger.info( - "Creating TypeSpec Project cancelled when checking whether the project root folder is empty.", - ); - return; - } - const folderName = getBaseFileName(selectedRootFolder); - - if (!client || client.state !== State.Running) { - const r = await CheckCompilerAndStartLSPClient(selectedRootFolder); - if (r.code === ResultCode.Cancelled) { - logger.info("Creating TypeSpec Project cancelled when installing Compiler/CLI"); - return; - } - if ( - r.code !== ResultCode.Success || - r.value === undefined || - r.value.state !== State.Running - ) { - logger.error( - "Unexpected Error when checking Compiler/CLI. Please check the previous log for details.", - [], - { - showOutput: true, - showPopup: true, - }, - ); - return; - } - client = r.value; - } + await telemetryClient.doOperationWithTelemetry( + TelemetryEventName.CreateProject, + async (tel, sendTelEvent) => { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + cancellable: false, + title: "Creating TypeSpec Project...", + }, + async () => { + const selectedRootFolder = await selectProjectRootFolder(); + if (!selectedRootFolder) { + logger.info("Creating TypeSpec Project cancelled when selecting project root folder."); + tel.lastStep = "Select project root folder"; + return ResultCode.Cancelled; + } + if (!(await checkProjectRootFolderEmpty(selectedRootFolder))) { + logger.info( + "Creating TypeSpec Project cancelled when checking whether the project root folder is empty.", + ); + tel.lastStep = "Check project root folder"; + return ResultCode.Cancelled; + } + const folderName = getBaseFileName(selectedRootFolder); + + if (!client || client.state !== State.Running) { + const r = await CheckCompilerAndStartLSPClient(selectedRootFolder, tel.activityId); + if (r.code === ResultCode.Cancelled) { + logger.info("Creating TypeSpec Project cancelled when installing Compiler/CLI"); + tel.lastStep = "Install Compiler/CLI"; + return ResultCode.Cancelled; + } + if ( + r.code !== ResultCode.Success || + r.value === undefined || + r.value.state !== State.Running + ) { + logger.error( + "Unexpected Error when checking Compiler/CLI. Please check the previous log for details.", + [], + { + showOutput: true, + showPopup: true, + }, + ); + tel.lastStep = "Install Compiler/CLI"; + return ResultCode.Fail; + } + client = r.value; + } - const isSupport = await isCompilerSupport(client); - if (!isSupport) { - logger.info("Creating TypeSpec Project cancelled due to unsupported by compiler."); - return; - } + const isSupport = await isCompilerSupport(client); + if (!isSupport) { + logger.info("Creating TypeSpec Project cancelled due to unsupported by compiler."); + tel.lastStep = "Check compiler support"; + return ResultCode.Fail; + } - const templateInfoMap = await loadInitTemplates(client); - if (templateInfoMap.size === 0) { - logger.error( - "Unexpected Error: No templates loaded. Please check the configuration of InitTemplatesUrls or upgrade @typespec/compiler and try again.", - [], - { - showOutput: true, - showPopup: true, - }, - ); - return; - } - const info = await selectTemplate(templateInfoMap); - if (info === undefined) { - logger.info("Creating TypeSpec Project cancelled when selecting template."); - return; - } else { - logger.info(`Selected template: ${info.source}.${info.name}`); - } + const templateInfoMap = await loadInitTemplates(client); + if (templateInfoMap.size === 0) { + logger.error( + "Unexpected Error: No templates loaded. Please check the configuration of InitTemplatesUrls or upgrade @typespec/compiler and try again.", + [], + { + showOutput: true, + showPopup: true, + }, + ); + tel.lastStep = "Load templates"; + return ResultCode.Fail; + } + const info = await selectTemplate(templateInfoMap); + if (info === undefined) { + logger.info("Creating TypeSpec Project cancelled when selecting template."); + tel.lastStep = "Select template"; + return ResultCode.Cancelled; + } else { + logger.info(`Selected template: ${info.source}.${info.name}`); + } - const validateResult = await validateTemplate(info, client); - if (!validateResult) { - logger.info("Creating TypeSpec Project cancelled when validating template."); - return; - } + const validateResult = await validateTemplate(info, client); + if (!validateResult) { + logger.info("Creating TypeSpec Project cancelled when validating template."); + tel.lastStep = "Validate template"; + return ResultCode.Cancelled; + } - const projectName = await vscode.window.showInputBox({ - prompt: "Please input the project name", - value: folderName, - ignoreFocusOut: true, - validateInput: (value) => { - if (isWhitespaceStringOrUndefined(value)) { - return "Project name cannot be empty."; + const projectName = await vscode.window.showInputBox({ + prompt: "Please input the project name", + value: folderName, + ignoreFocusOut: true, + validateInput: (value) => { + if (isWhitespaceStringOrUndefined(value)) { + return "Project name cannot be empty."; + } + // we don't have a full rule for project name. Just have a simple check to avoid some strange name. + const regex = /^(?![./])(?!.*[./]{2})[a-zA-Z0-9-~_@./]*[a-zA-Z0-9-~_@]$/; + if (!regex.test(value)) { + return "Invalid project name. Only [a-zA-Z0-9-~_@./] are allowed and cannot start/end with [./] or consecutive [./]"; + } + return undefined; + }, + }); + if (isWhitespaceStringOrUndefined(projectName)) { + logger.info("Creating TypeSpec Project cancelled when input project name.", [], { + showOutput: false, + showPopup: false, + }); + tel.lastStep = "Input project name"; + return ResultCode.Cancelled; } - // we don't have a full rule for project name. Just have a simple check to avoid some strange name. - const regex = /^(?![./])(?!.*[./]{2})[a-zA-Z0-9-~_@./]*[a-zA-Z0-9-~_@]$/; - if (!regex.test(value)) { - return "Invalid project name. Only [a-zA-Z0-9-~_@./] are allowed and cannot start/end with [./] or consecutive [./]"; + + const includeGitignoreResult = await vscode.window.showQuickPick(["Yes", "No"], { + title: TITLE, + canPickMany: false, + placeHolder: "Do you want to generate a .gitignore file", + ignoreFocusOut: true, + }); + if (includeGitignoreResult === undefined) { + logger.info( + "Creating TypeSpec Project cancelled when selecting whether to include .gitignore.", + ); + tel.lastStep = "Select whether to include .gitignore"; + return ResultCode.Cancelled; } - return undefined; - }, - }); - if (isWhitespaceStringOrUndefined(projectName)) { - logger.info("Creating TypeSpec Project cancelled when input project name.", [], { - showOutput: false, - showPopup: false, - }); - return; - } + const includeGitignore = includeGitignoreResult === "Yes"; - const includeGitignoreResult = await vscode.window.showQuickPick(["Yes", "No"], { - title: TITLE, - canPickMany: false, - placeHolder: "Do you want to generate a .gitignore file", - ignoreFocusOut: true, - }); - if (includeGitignoreResult === undefined) { - logger.info( - "Creating TypeSpec Project cancelled when selecting whether to include .gitignore.", - ); - return; - } - const includeGitignore = includeGitignoreResult === "Yes"; + const librariesToInclude = await selectLibraries(info); + if (librariesToInclude === undefined) { + logger.info("Creating TypeSpec Project cancelled when selecting libraries to include."); + tel.lastStep = "Select libraries"; + return ResultCode.Cancelled; + } - const librariesToInclude = await selectLibraries(info); - if (librariesToInclude === undefined) { - logger.info("Creating TypeSpec Project cancelled when selecting libraries to include."); - return; - } + const selectedEmitters = await selectEmitters(info); + if (selectedEmitters === undefined) { + logger.info("Creating TypeSpec Project cancelled when selecting emitters."); + tel.lastStep = "Select emitters"; + return ResultCode.Cancelled; + } - const selectedEmitters = await selectEmitters(info); - if (selectedEmitters === undefined) { - logger.info("Creating TypeSpec Project cancelled when selecting emitters."); - return; - } + const inputs = await setInputs(info); + if (inputs === undefined) { + logger.info("Creating TypeSpec Project cancelled when setting inputs."); + tel.lastStep = "Set inputs"; + return ResultCode.Cancelled; + } - const inputs = await setInputs(info); - if (inputs === undefined) { - logger.info("Creating TypeSpec Project cancelled when setting inputs."); - return; - } + const initTemplateConfig: InitProjectConfig = { + template: info.template!, + directory: selectedRootFolder, + folderName: folderName, + baseUri: info.baseUrl, + name: projectName!, + parameters: inputs ?? {}, + includeGitignore: includeGitignore, + libraries: librariesToInclude, + emitters: selectedEmitters, + }; + const initResult = await initProject(client, initTemplateConfig); + if (!initResult) { + logger.info("Creating TypeSpec Project cancelled when initializing project.", [], { + showOutput: false, + showPopup: false, + }); + tel.lastStep = "Initialize project"; + return ResultCode.Cancelled; + } - const initTemplateConfig: InitProjectConfig = { - template: info.template!, - directory: selectedRootFolder, - folderName: folderName, - baseUri: info.baseUrl, - name: projectName!, - parameters: inputs ?? {}, - includeGitignore: includeGitignore, - libraries: librariesToInclude, - emitters: selectedEmitters, - }; - const initResult = await initProject(client, initTemplateConfig); - if (!initResult) { - logger.info("Creating TypeSpec Project cancelled when initializing project.", [], { - showOutput: false, - showPopup: false, - }); - return; - } + const packageJsonPath = joinPaths(selectedRootFolder, "package.json"); + if (!(await isFile(packageJsonPath))) { + logger.warning( + "Skip tsp install since no package.json is found in the project folder.", + ); + } else { + // just ignore the result from tsp install. We will open the project folder anyway. + await tspInstall(client, selectedRootFolder); + } - const packageJsonPath = joinPaths(selectedRootFolder, "package.json"); - if (!(await isFile(packageJsonPath))) { - logger.warning("Skip tsp install since no package.json is found in the project folder."); - } else { - // just ignore the result from tsp install. We will open the project folder anyway. - await tspInstall(client, selectedRootFolder); - } + const msg = Object.entries(selectedEmitters) + .filter(([_k, e]) => !isWhitespaceStringOrUndefined(e.message)) + .map(([k, e]) => `\t${k}: \n\t\t${e.message}`) + .join("\n"); + + if (!isWhitespaceStringOrUndefined(msg)) { + const p = normalizePath(selectedRootFolder); + if ( + vscode.workspace.workspaceFolders?.find((x) => normalizePath(x.uri.fsPath) === p) === + undefined + ) { + // if the folder is not opened as workspace, persist the message to extension state because + // openProjectFolder will reinitialize the extension. + stateManager.saveStartUpMessage( + { + popupMessage: + "Please review the message from emitters when creating TypeSpec Project", + detail: msg, + level: "warn", + }, + selectedRootFolder, + ); + } else { + logger.warning("Please review the message from emitters\n", [msg], { + showPopup: true, + notificationButtonText: "Review in Output", + }); + } + } - const msg = Object.entries(selectedEmitters) - .filter(([_k, e]) => !isWhitespaceStringOrUndefined(e.message)) - .map(([k, e]) => `\t${k}: \n\t\t${e.message}`) - .join("\n"); - - if (!isWhitespaceStringOrUndefined(msg)) { - const p = normalizePath(selectedRootFolder); - if ( - vscode.workspace.workspaceFolders?.find((x) => normalizePath(x.uri.fsPath) === p) === - undefined - ) { - // if the folder is not opened as workspace, persist the message to extension state because - // openProjectFolder will reinitialize the extension. - stateManager.saveStartUpMessage( - { - popupMessage: - "Please review the message from emitters when creating TypeSpec Project", - detail: msg, - level: "warn", - }, - selectedRootFolder, - ); - } else { - logger.warning("Please review the message from emitters\n", [msg], { - showPopup: true, - notificationButtonText: "Review in Output", + // vscode.openFolder command will re-initialize the extension, so we need to send telemetry event and flush before it. + sendTelEvent(ResultCode.Success); + await telemetryClient.flush(); + vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(selectedRootFolder), { + forceNewWindow: false, + forceReuseWindow: true, + noRecentEntry: false, }); - } - } - - vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(selectedRootFolder), { - forceNewWindow: false, - forceReuseWindow: true, - noRecentEntry: false, - }); - logger.info(`Creating TypeSpec Project completed successfully in ${selectedRootFolder}.`); + logger.info(`Creating TypeSpec Project completed successfully in ${selectedRootFolder}.`); + return ResultCode.Success; + }, + ); }, ); } @@ -767,7 +795,10 @@ async function checkProjectRootFolderEmpty(selectedFolder: string): Promise> { +async function CheckCompilerAndStartLSPClient( + folder: string, + activityId: string, +): Promise> { // language server may not be started because no workspace is opened or failed to start for some reason // so before trying to start it, let's try to check whether global compiler is available first // to avoid unnecessary error notification when starting LSP which would be confusing (we can't avoid it which @@ -778,6 +809,7 @@ async function CheckCompilerAndStartLSPClient(folder: string): Promise( - CommandName.RestartServer, - rsArgs, - ); - return { code: ResultCode.Success, value: newClient }; + const restartResult: RestartServerCommandResult = + await vscode.commands.executeCommand( + CommandName.RestartServer, + rsArgs, + ); + return restartResult; } async function IsGlobalCompilerAvailable(folder: string): Promise> { diff --git a/packages/typespec-vscode/src/vscode-cmd/import-from-openapi3.ts b/packages/typespec-vscode/src/vscode-cmd/import-from-openapi3.ts index 3fe7bbc7f01..8edbbdaf8db 100644 --- a/packages/typespec-vscode/src/vscode-cmd/import-from-openapi3.ts +++ b/packages/typespec-vscode/src/vscode-cmd/import-from-openapi3.ts @@ -3,6 +3,8 @@ import { major, minor } from "semver"; import vscode from "vscode"; import logger from "../log/logger.js"; import { normalizePath } from "../path-utils.js"; +import telemetryClient from "../telemetry/telemetry-client.js"; +import { TelemetryEventName } from "../telemetry/telemetry-event.js"; import { Result, ResultCode } from "../types.js"; import { checkAndConfirmEmptyFolder, @@ -28,305 +30,335 @@ const TSP_OPENAPI3_PACKAGE_DETAILS = const TSP_OPENAPI3_PACKAGE_LINK = "https://typespec.io/docs/emitters/openapi3/cli/"; export async function importFromOpenApi3(uri: vscode.Uri | undefined) { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Window, - cancellable: false, - title: "Importing from OpenAPI...", - }, - async () => { - const targetFolder = - uri && uri.fsPath && (await isDirectory(uri.fsPath)) - ? normalizePath(uri.fsPath) - : await selectFolder("Select target folder to import OpenAPI", "Select Target Folder"); - if (!targetFolder) { - logger.info("Importing from OpenApi canceled because no target folder selected"); - return; - } - const checkEmpty = await checkAndConfirmEmptyFolder( - targetFolder, - "The selected folder isn't empty. Do you want to continue? Some existing files may be overwritten.", - TITLE, + await telemetryClient.doOperationWithTelemetry( + TelemetryEventName.ImportFromOpenApi3, + async (tel) => { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + cancellable: false, + title: "Importing from OpenAPI...", + }, + async () => { + const targetFolder = + uri && uri.fsPath && (await isDirectory(uri.fsPath)) + ? normalizePath(uri.fsPath) + : await selectFolder( + "Select target folder to import OpenAPI", + "Select Target Folder", + ); + if (!targetFolder) { + logger.info("Importing from OpenApi canceled because no target folder selected"); + tel.lastStep = "No target folder selected"; + return ResultCode.Cancelled; + } + const checkEmpty = await checkAndConfirmEmptyFolder( + targetFolder, + "The selected folder isn't empty. Do you want to continue? Some existing files may be overwritten.", + TITLE, + ); + if (!checkEmpty) { + logger.info("Importing from OpenApi canceled due to non-empty target folder."); + tel.lastStep = "Confirm non-empty target folder"; + return ResultCode.Cancelled; + } + const sourceFile = await selectFile( + "Select OpenAPI file to import", + "Select OpenAPI File", + { + "OpenAPI files": ["json", "yaml", "yml"], + }, + ); + if (!sourceFile) { + logger.info("Importing from OpenApi canceled because no source file selected."); + tel.lastStep = "No source file selected"; + return ResultCode.Cancelled; + } + const result = await checkAndImport(sourceFile, targetFolder); + if (result.code === ResultCode.Success) { + logger.info("Importing from OpenAPI succeeded.", [], { + showPopup: true, + showOutput: false, + }); + } else if (result.code === ResultCode.Cancelled) { + logger.info("Importing from OpenAPI canceled by user.", []); + } else { + logger.error( + "Importing from OpenAPI failed. Please check previous logs for details.", + [], + { + showPopup: true, + showOutput: true, + }, + ); + } + return result.code; + }, ); - if (!checkEmpty) { - logger.info("Importing from OpenApi canceled due to non-empty target folder."); - return; - } - const sourceFile = await selectFile("Select OpenAPI file to import", "Select OpenAPI File", { - "OpenAPI files": ["json", "yaml", "yml"], - }); - if (!sourceFile) { - logger.info("Importing from OpenApi canceled because no source file selected."); - return; - } - const result = await checkAndImport(sourceFile, targetFolder); - if (result.code === ResultCode.Success) { - logger.info("Importing from OpenAPI succeeded.", [], { - showPopup: true, - showOutput: false, - }); - } else if (result.code === ResultCode.Cancelled) { - logger.info("Importing from OpenAPI canceled by user.", []); - } else { - logger.error("Importing from OpenAPI failed. Please check previous logs for details.", [], { - showPopup: true, - showOutput: true, - }); - } - }, - ); -} - -async function checkAndImport( - sourceFile: string, - targetFolder: string, -): Promise> { - const { packageJsonFolder, packageJson } = await searchAndLoadPackageJson(targetFolder); - if (!packageJsonFolder || !packageJson) { - logger.info( - "Cannot find package.json in target folder and its parents, try to import by using global openapi3", - ); - return await importUsingGlobalOpenApi3(sourceFile, targetFolder); - } else { - const result = await tryInstallOpenApi3Locally(packageJson, packageJsonFolder); - if (result.code === ResultCode.Success) { - return tryImport(sourceFile, targetFolder, false /*globalScope*/); - } else { - return result; - } - } -} -async function tryInstallOpenApi3Locally( - packageJson: NodePackage, - packageJsonFolder: string, -): Promise> { - if ( - (packageJson.dependencies && packageJson.dependencies[TSP_OPENAPI3_PACKAGE]) || - (packageJson.devDependencies && packageJson.devDependencies[TSP_OPENAPI3_PACKAGE]) - ) { - const openApi3Package = await loadDependencyPackageJson( - packageJsonFolder, - TSP_OPENAPI3_PACKAGE, - ); - if (openApi3Package) { - // found openapi3 package in package.json which has been installed - logger.info(`Found ${TSP_OPENAPI3_PACKAGE} in package.json which has been installed`); - return { code: ResultCode.Success, value: undefined }; - } else { - // found openapi3 package in package.json but it has not been installed - logger.info( - `Found ${TSP_OPENAPI3_PACKAGE} in package.json but not installed, try to confirm to install it by 'npm install'`, - ); + async function checkAndImport( + sourceFile: string, + targetFolder: string, + ): Promise> { + const { packageJsonFolder, packageJson } = await searchAndLoadPackageJson(targetFolder); + if (!packageJsonFolder || !packageJson) { + logger.info( + "Cannot find package.json in target folder and its parents, try to import by using global openapi3", + ); + return await importUsingGlobalOpenApi3(sourceFile, targetFolder); + } else { + const result = await tryInstallOpenApi3Locally(packageJson, packageJsonFolder); + if (result.code === ResultCode.Success) { + tel.lastStep = "Install openapi3 locally and import"; + return tryImport(sourceFile, targetFolder, false /*globalScope*/); + } else { + tel.lastStep = "Install OpenAPI3 locally"; + return result; + } + } + } - return await tryExecuteWithUi( - { - name: `Confirm and try to install OpenAPI3 package by 'npm install'`, - confirm: { - title: TITLE, - placeholder: `'${TSP_OPENAPI3_PACKAGE}' is required to import OpenApi3. Do you want to install it?`, - yesQuickPickItem: { - label: `Install ${TSP_OPENAPI3_PACKAGE}`, - description: `by 'npm install'`, - detail: TSP_OPENAPI3_PACKAGE_DETAILS, - externalLink: TSP_OPENAPI3_PACKAGE_LINK, + async function tryInstallOpenApi3Locally( + packageJson: NodePackage, + packageJsonFolder: string, + ): Promise> { + if ( + (packageJson.dependencies && packageJson.dependencies[TSP_OPENAPI3_PACKAGE]) || + (packageJson.devDependencies && packageJson.devDependencies[TSP_OPENAPI3_PACKAGE]) + ) { + const openApi3Package = await loadDependencyPackageJson( + packageJsonFolder, + TSP_OPENAPI3_PACKAGE, + ); + if (openApi3Package) { + // found openapi3 package in package.json which has been installed + logger.info(`Found ${TSP_OPENAPI3_PACKAGE} in package.json which has been installed`); + return { code: ResultCode.Success, value: undefined }; + } else { + // found openapi3 package in package.json but it has not been installed + logger.info( + `Found ${TSP_OPENAPI3_PACKAGE} in package.json but not installed, try to confirm to install it by 'npm install'`, + ); + return await tryExecuteWithUi( + { + name: `Confirm and try to install OpenAPI3 package by 'npm install'`, + confirm: { + title: TITLE, + placeholder: `'${TSP_OPENAPI3_PACKAGE}' is required to import OpenApi3. Do you want to install it?`, + yesQuickPickItem: { + label: `Install ${TSP_OPENAPI3_PACKAGE}`, + description: `by 'npm install'`, + detail: TSP_OPENAPI3_PACKAGE_DETAILS, + externalLink: TSP_OPENAPI3_PACKAGE_LINK, + }, + noQuickPickItem: { + label: "Cancel", + }, + }, + progress: { + timeoutInMs: 5 * 60 * 1000, // 5 minutes as timeout + title: `Installing ${TSP_OPENAPI3_PACKAGE}...`, + withCancelAndTimeout: true, + }, + }, + async () => { + try { + return await spawnExecutionAndLogToOutput("npm", ["install"], packageJsonFolder); + } catch (error: any) { + // if we found the error is because confliction from @typespec/compiler, try to output more log to help user troubleshooting + if ( + error?.stderr && + error.stderr.includes("ERESOLVE") && + error.stderr.includes(TSP_COMPILER_PACKAGE) + ) { + logger.error( + `'npm install' failed because of version confliction of ${TSP_COMPILER_PACKAGE} \n` + + ` - Please make sure your typespec's packages are compatible with ${TSP_COMPILER_PACKAGE}. \n` + + ` - Or you can try to upgrade the ${TSP_COMPILER_PACKAGE} and other typespec's packages to the latest version and try again.`, + ); + } + throw error; + } + }, + ); + } + } else { + // cannot find openapi3 package in package.json + logger.info( + `Cannot find ${TSP_OPENAPI3_PACKAGE} in package.json, try to confirm to install it by 'npm install ${TSP_OPENAPI3_PACKAGE}'`, + ); + return tryExecuteWithUi( + { + name: `Confirm and try to install OpenAPI3 package by 'npm install ${TSP_OPENAPI3_PACKAGE}'`, + confirm: { + title: TITLE, + placeholder: `'${TSP_OPENAPI3_PACKAGE}' is required to import OpenApi3. Do you want to install it?`, + yesQuickPickItem: { + label: `Install ${TSP_OPENAPI3_PACKAGE}`, + description: `by 'npm install ${TSP_OPENAPI3_PACKAGE}'`, + detail: TSP_OPENAPI3_PACKAGE_DETAILS, + externalLink: TSP_OPENAPI3_PACKAGE_LINK, + }, + noQuickPickItem: { + label: "Cancel", + }, + }, + progress: { + timeoutInMs: 5 * 60 * 1000, // 5 minutes as timeout + title: ``, + withCancelAndTimeout: true, + }, }, - noQuickPickItem: { - label: "Cancel", + async (progress) => { + // because we have a strict version dependency between compiler and openapi3, we need to check the + // compiler version to determine the openapi3 version to install. + // TODO: remove this part when we loose the version dependency between compiler and openapi3. + progress?.report({ message: "Checking compiler version..." }); + logger.info("Checking compiler version..."); + const compilerJson = await loadDependencyPackageJson( + packageJsonFolder, + TSP_COMPILER_PACKAGE, + ); + let version = "latest"; + if (compilerJson && compilerJson.version) { + // we can't use version directly because openapi3 may not have some patch versions. + const maj = major(compilerJson.version); + const min = minor(compilerJson.version); + version = `${maj}.${min}`; + } + logger.info( + `Compiler version: ${compilerJson?.version ?? "N/A"} found, will try to install ${TSP_OPENAPI3_PACKAGE} with version: ${version}`, + ); + progress?.report({ message: `npm install ${TSP_OPENAPI3_PACKAGE}...` }); + try { + return await installOpenApi3Package(packageJsonFolder, false, version); + } catch (error: any) { + // there is still a small chance installing openapi3 package failed because of the version mismatch. For example, + // user defined another package which requires an old compiler version but haven't actually installed it yet, + // then compiler package is not available for us to check the version, but it would still cause confliction when installing openapi3. + // Here we won't do more check for that case which would take more time/cost and bring worse user experience for other users considering this should be a cornor case, + // instead, we will show more detail log to guide user to handle that case + // TODO: consider to handle that if we find it's not a cornor case. + if ( + error?.stderr && + error.stderr.includes("ERESOLVE") && + error.stderr.includes(TSP_COMPILER_PACKAGE) + ) { + logger.error( + `Error occurs when resolving ${TSP_OPENAPI3_PACKAGE} to install. \n` + + ` - Please make sure your npm packages have been installed properly by 'npm install' or the package manager you are using and try again. \n` + + ` - If the error persists, please try to upgrade the ${TSP_COMPILER_PACKAGE} to the latest version and try again.`, + ); + } + throw error; + } + }, + ); + } + } + + /** + * + * @param sourceFile + * @param targetFolder + * @param isGlobal if true, to use the openapi3 installed globally, otherwise, to use the openapi3 installed locally. + * @returns + */ + async function tryImport( + sourceFile: string, + targetFolder: string, + isGlobal: boolean, + ): Promise> { + const result = await tryExecuteWithUi( + { + name: "Importing OpenAPI to TypeSpec", + progress: { + timeoutInMs: 5 * 60 * 1000, // 5 minutes as timeout + title: "Importing OpenAPI to TypeSpec...", + withCancelAndTimeout: true, }, }, - progress: { - timeoutInMs: 5 * 60 * 1000, // 5 minutes as timeout - title: `Installing ${TSP_OPENAPI3_PACKAGE}...`, - withCancelAndTimeout: true, - }, - }, - async () => { - try { - return await spawnExecutionAndLogToOutput("npm", ["install"], packageJsonFolder); - } catch (error: any) { - // if we found the error is because confliction from @typespec/compiler, try to output more log to help user troubleshooting - if ( - error?.stderr && - error.stderr.includes("ERESOLVE") && - error.stderr.includes(TSP_COMPILER_PACKAGE) - ) { - logger.error( - `'npm install' failed because of version confliction of ${TSP_COMPILER_PACKAGE} \n` + - ` - Please make sure your typespec's packages are compatible with ${TSP_COMPILER_PACKAGE}. \n` + - ` - Or you can try to upgrade the ${TSP_COMPILER_PACKAGE} and other typespec's packages to the latest version and try again.`, + async () => { + if (isGlobal) { + return await spawnExecutionAndLogToOutput( + TSP_OPENAPI3_COMMAND, + [sourceFile, "--output-dir", `${targetFolder}`], + targetFolder, + ); + } else { + return await spawnExecutionAndLogToOutput( + "npx", + [TSP_OPENAPI3_COMMAND, sourceFile, "--output-dir", `${targetFolder}`], + targetFolder, ); } - throw error; - } - }, - ); - } - } else { - // cannot find openapi3 package in package.json - logger.info( - `Cannot find ${TSP_OPENAPI3_PACKAGE} in package.json, try to confirm to install it by 'npm install ${TSP_OPENAPI3_PACKAGE}'`, - ); - return tryExecuteWithUi( - { - name: `Confirm and try to install OpenAPI3 package by 'npm install ${TSP_OPENAPI3_PACKAGE}'`, - confirm: { - title: TITLE, - placeholder: `'${TSP_OPENAPI3_PACKAGE}' is required to import OpenApi3. Do you want to install it?`, - yesQuickPickItem: { - label: `Install ${TSP_OPENAPI3_PACKAGE}`, - description: `by 'npm install ${TSP_OPENAPI3_PACKAGE}'`, - detail: TSP_OPENAPI3_PACKAGE_DETAILS, - externalLink: TSP_OPENAPI3_PACKAGE_LINK, - }, - noQuickPickItem: { - label: "Cancel", }, - }, - progress: { - timeoutInMs: 5 * 60 * 1000, // 5 minutes as timeout - title: ``, - withCancelAndTimeout: true, - }, - }, - async (progress) => { - // because we have a strict version dependency between compiler and openapi3, we need to check the - // compiler version to determine the openapi3 version to install. - // TODO: remove this part when we loose the version dependency between compiler and openapi3. - progress?.report({ message: "Checking compiler version..." }); - logger.info("Checking compiler version..."); - const compilerJson = await loadDependencyPackageJson( - packageJsonFolder, - TSP_COMPILER_PACKAGE, ); - let version = "latest"; - if (compilerJson && compilerJson.version) { - // we can't use version directly because openapi3 may not have some patch versions. - const maj = major(compilerJson.version); - const min = minor(compilerJson.version); - version = `${maj}.${min}`; + if (result.code === ResultCode.Fail) { + telemetryClient.logOperationDetailTelemetry(tel.activityId, { + error: `Error when importing OpenAPI to TypeSpec: ${result.details}`, + }); } - logger.info( - `Compiler version: ${compilerJson?.version ?? "N/A"} found, will try to install ${TSP_OPENAPI3_PACKAGE} with version: ${version}`, - ); - progress?.report({ message: `npm install ${TSP_OPENAPI3_PACKAGE}...` }); - try { - return await installOpenApi3Package(packageJsonFolder, false, version); - } catch (error: any) { - // there is still a small chance installing openapi3 package failed because of the version mismatch. For example, - // user defined another package which requires an old compiler version but haven't actually installed it yet, - // then compiler package is not available for us to check the version, but it would still cause confliction when installing openapi3. - // Here we won't do more check for that case which would take more time/cost and bring worse user experience for other users considering this should be a cornor case, - // instead, we will show more detail log to guide user to handle that case - // TODO: consider to handle that if we find it's not a cornor case. - if ( - error?.stderr && - error.stderr.includes("ERESOLVE") && - error.stderr.includes(TSP_COMPILER_PACKAGE) - ) { - logger.error( - `Error occurs when resolving ${TSP_OPENAPI3_PACKAGE} to install. \n` + - ` - Please make sure your npm packages have been installed properly by 'npm install' or the package manager you are using and try again. \n` + - ` - If the error persists, please try to upgrade the ${TSP_COMPILER_PACKAGE} to the latest version and try again.`, - ); + return result; + } + + async function importUsingGlobalOpenApi3( + sourceFile: string, + targetFolder: string, + ): Promise> { + tel.lastStep = "Import using global openapi3"; + const firstTry = await tryImport(sourceFile, targetFolder, true /* isGlobal */); + if (firstTry.code === ResultCode.Fail && isExecOutputCmdNotFound(firstTry.details)) { + logger.info( + `${TSP_OPENAPI3_COMMAND} failed because the command is not found. Try to prompt user to install it and import again.`, + ); + const installResult = await tryExecuteWithUi( + { + name: `Install ${TSP_OPENAPI3_PACKAGE} globally`, + confirm: { + title: TITLE, + placeholder: `'${TSP_OPENAPI3_PACKAGE}'is required to import OpenAPI. Do you want to install it?`, + yesQuickPickItem: { + label: `Install ${TSP_OPENAPI3_PACKAGE} globally`, + description: `by 'npm install -g ${TSP_OPENAPI3_PACKAGE}'`, + detail: TSP_OPENAPI3_PACKAGE_DETAILS, + externalLink: TSP_OPENAPI3_PACKAGE_LINK, + }, + noQuickPickItem: { + label: "Cancel", + }, + }, + progress: { + timeoutInMs: 300000, + title: `Installing ${TSP_OPENAPI3_PACKAGE} globally...`, + withCancelAndTimeout: true, + }, + }, + async () => { + return await installOpenApi3Package(targetFolder, true /*isGlobal*/); + }, + ); + if (installResult.code === ResultCode.Success) { + tel.lastStep = "Install openapi3 globally and import again"; + return await tryImport(sourceFile, targetFolder, true /* isGlobal */); + } else { + tel.lastStep = "Install openapi3 globally"; + return installResult; } - throw error; + } else { + return firstTry; } - }, - ); - } -} + } -/** - * - * @param sourceFile - * @param targetFolder - * @param isGlobal if true, to use the openapi3 installed globally, otherwise, to use the openapi3 installed locally. - * @returns - */ -async function tryImport( - sourceFile: string, - targetFolder: string, - isGlobal: boolean, -): Promise> { - return await tryExecuteWithUi( - { - name: "Importing OpenAPI to TypeSpec", - progress: { - timeoutInMs: 5 * 60 * 1000, // 5 minutes as timeout - title: "Importing OpenAPI to TypeSpec...", - withCancelAndTimeout: true, - }, - }, - async () => { - if (isGlobal) { - return await spawnExecutionAndLogToOutput( - TSP_OPENAPI3_COMMAND, - [sourceFile, "--output-dir", `${targetFolder}`], - targetFolder, - ); - } else { - return await spawnExecutionAndLogToOutput( - "npx", - [TSP_OPENAPI3_COMMAND, sourceFile, "--output-dir", `${targetFolder}`], - targetFolder, - ); + async function installOpenApi3Package( + folder: string, + isGlobal: boolean, + version?: string, + ): Promise { + const pkgName = `${TSP_OPENAPI3_PACKAGE}@${version ?? "latest"}`; + const args = isGlobal ? ["install", "-g", pkgName] : ["install", "--save-dev", pkgName]; + return await spawnExecutionAndLogToOutput("npm", args, folder); } }, ); } - -async function importUsingGlobalOpenApi3( - sourceFile: string, - targetFolder: string, -): Promise> { - const firstTry = await tryImport(sourceFile, targetFolder, true /* isGlobal */); - if (firstTry.code === ResultCode.Fail && isExecOutputCmdNotFound(firstTry.details)) { - logger.info( - `${TSP_OPENAPI3_COMMAND} failed because the command is not found. Try to prompt user to install it and import again.`, - ); - const installResult = await tryExecuteWithUi( - { - name: `Install ${TSP_OPENAPI3_PACKAGE} globally`, - confirm: { - title: TITLE, - placeholder: `'${TSP_OPENAPI3_PACKAGE}'is required to import OpenAPI. Do you want to install it?`, - yesQuickPickItem: { - label: `Install ${TSP_OPENAPI3_PACKAGE} globally`, - description: `by 'npm install -g ${TSP_OPENAPI3_PACKAGE}'`, - detail: TSP_OPENAPI3_PACKAGE_DETAILS, - externalLink: TSP_OPENAPI3_PACKAGE_LINK, - }, - noQuickPickItem: { - label: "Cancel", - }, - }, - progress: { - timeoutInMs: 300000, - title: `Installing ${TSP_OPENAPI3_PACKAGE} globally...`, - withCancelAndTimeout: true, - }, - }, - async () => { - return await installOpenApi3Package(targetFolder, true /*isGlobal*/); - }, - ); - if (installResult.code === ResultCode.Success) { - return await tryImport(sourceFile, targetFolder, true /* isGlobal */); - } else { - return installResult; - } - } else { - return firstTry; - } -} - -async function installOpenApi3Package( - folder: string, - isGlobal: boolean, - version?: string, -): Promise { - const pkgName = `${TSP_OPENAPI3_PACKAGE}@${version ?? "latest"}`; - const args = isGlobal ? ["install", "-g", pkgName] : ["install", "--save-dev", pkgName]; - return await spawnExecutionAndLogToOutput("npm", args, folder); -} diff --git a/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts b/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts index 125caaa35ad..0787e315fd1 100644 --- a/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts +++ b/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts @@ -1,5 +1,7 @@ import vscode, { QuickPickItem } from "vscode"; import logger from "../log/logger.js"; +import telemetryClient from "../telemetry/telemetry-client.js"; +import { TelemetryEventName } from "../telemetry/telemetry-event.js"; import { InstallGlobalCliCommandArgs, Result, ResultCode } from "../types.js"; import { createPromiseWithCancelAndTimeout, spawnExecutionAndLogToOutput } from "../utils.js"; @@ -9,106 +11,118 @@ const COMPILER_REQUIREMENT = export async function installCompilerGlobally( args: InstallGlobalCliCommandArgs | undefined, ): Promise> { - const showOutput = args?.silentMode !== true; - const showPopup = args?.silentMode !== true; - // confirm with end user by default - if (args?.confirm !== false) { - const detailLink = "https://typespec.io/docs/"; - const yes: QuickPickItem = { - label: "Install TypeSpec Compiler/CLI globally", - detail: COMPILER_REQUIREMENT, - description: " by 'npm install -g @typespec/compiler'", - buttons: [ - { - iconPath: new vscode.ThemeIcon("link-external"), - tooltip: `Open ${detailLink}`, - }, - ], - }; - const no: QuickPickItem = { label: "Cancel" }; - const title = args?.confirmTitle ?? "Please check the requirements and confirm..."; - const confirmPicker = vscode.window.createQuickPick(); - confirmPicker.items = [yes, no]; - confirmPicker.title = title; - confirmPicker.ignoreFocusOut = true; - confirmPicker.placeholder = args?.confirmPlaceholder ?? title; - confirmPicker.onDidTriggerItemButton((event) => { - if (event.item === yes) { - vscode.env.openExternal(vscode.Uri.parse(detailLink)); - } - }); - const p = new Promise((resolve) => { - confirmPicker.onDidAccept(() => { - const selectedItem = [...confirmPicker.selectedItems]; - resolve(selectedItem); - confirmPicker.hide(); - }); - confirmPicker.onDidHide(() => { - resolve(undefined); - confirmPicker.dispose(); - }); - }); - confirmPicker.show(); - const confirm = await p; - - if (!confirm || confirm.length === 0 || confirm[0] !== yes) { - logger.info("User cancelled the installation of TypeSpec Compiler/CLI"); - return { code: ResultCode.Cancelled }; - } else { - logger.info("User confirmed the installation of TypeSpec Compiler/CLI"); - } - } else { - logger.info("Installing TypeSpec Compiler/CLI with confirmation disabled explicitly..."); - } - return await vscode.window.withProgress>( - { - title: "Installing TypeSpec Compiler/CLI...", - location: vscode.ProgressLocation.Notification, - cancellable: true, - }, - async (_progress, token) => { - const TIMEOUT = 300000; // set timeout to 5 minutes which should be enough for installing compiler - try { - await createPromiseWithCancelAndTimeout( - spawnExecutionAndLogToOutput( - "npm", - ["install", "-g", "@typespec/compiler"], - process.cwd(), - ), - token, - TIMEOUT, - ); - - logger.info("TypeSpec Compiler/CLI installed successfully", [], { - showOutput: false, - showPopup, + return telemetryClient.doOperationWithTelemetry( + TelemetryEventName.InstallGlobalCompilerCli, + async (tel) => { + const showOutput = args?.silentMode !== true; + const showPopup = args?.silentMode !== true; + // confirm with end user by default + if (args?.confirm !== false) { + const detailLink = "https://typespec.io/docs/"; + const yes: QuickPickItem = { + label: "Install TypeSpec Compiler/CLI globally", + detail: COMPILER_REQUIREMENT, + description: " by 'npm install -g @typespec/compiler'", + buttons: [ + { + iconPath: new vscode.ThemeIcon("link-external"), + tooltip: `Open ${detailLink}`, + }, + ], + }; + const no: QuickPickItem = { label: "Cancel" }; + const title = args?.confirmTitle ?? "Please check the requirements and confirm..."; + const confirmPicker = vscode.window.createQuickPick(); + confirmPicker.items = [yes, no]; + confirmPicker.title = title; + confirmPicker.ignoreFocusOut = true; + confirmPicker.placeholder = args?.confirmPlaceholder ?? title; + confirmPicker.onDidTriggerItemButton((event) => { + if (event.item === yes) { + vscode.env.openExternal(vscode.Uri.parse(detailLink)); + } }); - return { code: ResultCode.Success, value: undefined }; - } catch (e: any) { - if (e === ResultCode.Cancelled) { - return { code: ResultCode.Cancelled }; - } else if (e === ResultCode.Timeout) { - logger.error(`Installation of TypeSpec Compiler/CLI is timeout after ${TIMEOUT}ms`, [e], { - showOutput, - showPopup, + const p = new Promise((resolve) => { + confirmPicker.onDidAccept(() => { + const selectedItem = [...confirmPicker.selectedItems]; + resolve(selectedItem); + confirmPicker.hide(); }); - return { code: ResultCode.Timeout }; + confirmPicker.onDidHide(() => { + resolve(undefined); + confirmPicker.dispose(); + }); + }); + confirmPicker.show(); + const confirm = await p; + + if (!confirm || confirm.length === 0 || confirm[0] !== yes) { + logger.info("User cancelled the installation of TypeSpec Compiler/CLI"); + tel.lastStep = "Confirm installation of TypeSpec Compiler/CLI"; + return { code: ResultCode.Cancelled }; } else { - logger.error( - `Installing TypeSpec Compiler/CLI failed. Please make sure the pre-requisites below has been installed properly. And you may check the previous log for more detail.\n` + - COMPILER_REQUIREMENT + - "\n" + - `More detail about typespec compiler: https://typespec.io/docs/\n` + - "More detail about nodejs: https://nodejs.org/en/download/package-manager\n", - [e], - { - showOutput, - showPopup, - }, - ); - return { code: ResultCode.Fail, details: e }; + logger.info("User confirmed the installation of TypeSpec Compiler/CLI"); } + } else { + logger.info("Installing TypeSpec Compiler/CLI with confirmation disabled explicitly..."); } + return await vscode.window.withProgress>( + { + title: "Installing TypeSpec Compiler/CLI...", + location: vscode.ProgressLocation.Notification, + cancellable: true, + }, + async (_progress, token) => { + const TIMEOUT = 300000; // set timeout to 5 minutes which should be enough for installing compiler + try { + await createPromiseWithCancelAndTimeout( + spawnExecutionAndLogToOutput( + "npm", + ["install", "-g", "@typespec/compiler"], + process.cwd(), + ), + token, + TIMEOUT, + ); + + logger.info("TypeSpec Compiler/CLI installed successfully", [], { + showOutput: false, + showPopup, + }); + return { code: ResultCode.Success, value: undefined }; + } catch (e: any) { + tel.lastStep = "Installing TypeSpec Compiler/CLI"; + if (e === ResultCode.Cancelled) { + return { code: ResultCode.Cancelled }; + } else if (e === ResultCode.Timeout) { + logger.error( + `Installation of TypeSpec Compiler/CLI is timeout after ${TIMEOUT}ms`, + [e], + { + showOutput, + showPopup, + }, + ); + return { code: ResultCode.Timeout }; + } else { + logger.error( + `Installing TypeSpec Compiler/CLI failed. Please make sure the pre-requisites below has been installed properly. And you may check the previous log for more detail.\n` + + COMPILER_REQUIREMENT + + "\n" + + `More detail about typespec compiler: https://typespec.io/docs/\n` + + "More detail about nodejs: https://nodejs.org/en/download/package-manager\n", + [e], + { + showOutput, + showPopup, + }, + ); + return { code: ResultCode.Fail, details: e }; + } + } + }, + ); }, + args?.activityId, ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a861350df25..f226c963f18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2101,6 +2101,9 @@ importers: '@rollup/plugin-commonjs': specifier: ~28.0.2 version: 28.0.2(rollup@4.31.0) + '@rollup/plugin-json': + specifier: ~6.1.0 + version: 6.1.0(rollup@4.31.0) '@rollup/plugin-node-resolve': specifier: ~16.0.0 version: 16.0.0(rollup@4.31.0) @@ -2131,6 +2134,9 @@ importers: '@vitest/ui': specifier: ^3.0.3 version: 3.0.4(vitest@3.0.5) + '@vscode/extension-telemetry': + specifier: ^0.9.8 + version: 0.9.8(tslib@2.8.1) '@vscode/test-web': specifier: ^0.0.65 version: 0.0.65 @@ -4885,6 +4891,12 @@ packages: '@mermaid-js/parser@0.3.0': resolution: {integrity: sha512-HsvL6zgE5sUPGgkIDlmAWR1HTNHz2Iy11BAWPTa4Jjabkpguy4Ze2gzfLrg6pdRuBvFwgUYyxiaNqZwrEEXepA==} + '@microsoft/1ds-core-js@4.3.5': + resolution: {integrity: sha512-WozEs1DB8FtqFtPcTilKhMZvhyEEw0bzehl+5S8lvncIIXcAbJx9ga6zpx6XqJ1CxmHAQen4MLgMYCLDBIzcNQ==} + + '@microsoft/1ds-post-js@4.3.5': + resolution: {integrity: sha512-AavxRU6qrzZuqDn2W5tiQ0bIqAWQrkyLV7VUn4ZzeB/0ekKCgfBouT69GpmfL1fu2wztlFcQaMpj/V8PXIxW+A==} + '@microsoft/api-extractor-model@7.30.2': resolution: {integrity: sha512-3/t2F+WhkJgBzSNwlkTIL0tBgUoBqDqL66pT+nh2mPbM0NIDGVGtpqbGWPgHIzn/mn7kGS/Ep8D8po58e8UUIw==} @@ -4892,12 +4904,44 @@ packages: resolution: {integrity: sha512-jRTR/XbQF2kb+dYn8hfYSicOGA99+Fo00GrsdMwdfE3eIgLtKdH6Qa2M3wZV9S2XmbgCaGX1OdPtYctbfu5jQg==} hasBin: true + '@microsoft/applicationinsights-channel-js@3.3.5': + resolution: {integrity: sha512-q9iE5alabgddwnxIDxgYLwC/3OMjYNOPk87p3jY+KxO0UmJGhiv7C1uI62zpx4AHBGT2+q6pMbIZdgld9TmMrw==} + peerDependencies: + tslib: '>= 1.0.0' + + '@microsoft/applicationinsights-common@3.3.5': + resolution: {integrity: sha512-zZgMOY3ePBhjTrZ8+MXwAb0Y+Yi4iVDKOqIaz/KoCmj1BxX5JKFgaqYiN8Tvu5O0YPJpEKS4coYXRHbStDm/Hw==} + peerDependencies: + tslib: '>= 1.0.0' + + '@microsoft/applicationinsights-core-js@3.3.5': + resolution: {integrity: sha512-8Gg18W5eOE3usXtkZ5iOqWAMU97hyjb7Oi1CtkWmxEoMUHMlQmqUD62n9BmVq/s5YfbUihGZHxc0keMJy0txAA==} + peerDependencies: + tslib: '>= 1.0.0' + + '@microsoft/applicationinsights-shims@3.0.1': + resolution: {integrity: sha512-DKwboF47H1nb33rSUfjqI6ryX29v+2QWcTrRvcQDA32AZr5Ilkr7whOOSsD1aBzwqX0RJEIP1Z81jfE3NBm/Lg==} + + '@microsoft/applicationinsights-web-basic@3.3.5': + resolution: {integrity: sha512-nYLyxjO3p74SHxq/JctDndD6P/3YBLY1F/F+h2AdJsaMavSGudU7ylMb2IMQc1X+yqFZ4H4cUvkxljj/SiBi2g==} + peerDependencies: + tslib: '>= 1.0.0' + + '@microsoft/dynamicproto-js@2.0.3': + resolution: {integrity: sha512-JTWTU80rMy3mdxOjjpaiDQsTLZ6YSGGqsjURsY6AUQtIj0udlF/jYmhdLZu8693ZIC0T1IwYnFa0+QeiMnziBA==} + '@microsoft/tsdoc-config@0.17.1': resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@nevware21/ts-async@0.5.4': + resolution: {integrity: sha512-IBTyj29GwGlxfzXw2NPnzty+w0Adx61Eze1/lknH/XIVdxtF9UnOpk76tnrHXWa6j84a1RR9hsOcHQPFv9qJjA==} + + '@nevware21/ts-utils@0.11.6': + resolution: {integrity: sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==} + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} @@ -6382,6 +6426,10 @@ packages: '@vscode/emmet-helper@2.11.0': resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + '@vscode/extension-telemetry@0.9.8': + resolution: {integrity: sha512-7YcKoUvmHlIB8QYCE4FNzt3ErHi9gQPhdCM3ZWtpw1bxPT0I+lMdx52KHlzTNoJzQ2NvMX7HyzyDwBEiMgTrWQ==} + engines: {vscode: ^1.75.0} + '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} @@ -16856,6 +16904,26 @@ snapshots: dependencies: langium: 3.0.0 + '@microsoft/1ds-core-js@4.3.5(tslib@2.8.1)': + dependencies: + '@microsoft/applicationinsights-core-js': 3.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-shims': 3.0.1 + '@microsoft/dynamicproto-js': 2.0.3 + '@nevware21/ts-async': 0.5.4 + '@nevware21/ts-utils': 0.11.6 + transitivePeerDependencies: + - tslib + + '@microsoft/1ds-post-js@4.3.5(tslib@2.8.1)': + dependencies: + '@microsoft/1ds-core-js': 4.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-shims': 3.0.1 + '@microsoft/dynamicproto-js': 2.0.3 + '@nevware21/ts-async': 0.5.4 + '@nevware21/ts-utils': 0.11.6 + transitivePeerDependencies: + - tslib + '@microsoft/api-extractor-model@7.30.2(@types/node@22.10.10)': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -16882,6 +16950,51 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@microsoft/applicationinsights-channel-js@3.3.5(tslib@2.8.1)': + dependencies: + '@microsoft/applicationinsights-common': 3.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-core-js': 3.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-shims': 3.0.1 + '@microsoft/dynamicproto-js': 2.0.3 + '@nevware21/ts-async': 0.5.4 + '@nevware21/ts-utils': 0.11.6 + tslib: 2.8.1 + + '@microsoft/applicationinsights-common@3.3.5(tslib@2.8.1)': + dependencies: + '@microsoft/applicationinsights-core-js': 3.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-shims': 3.0.1 + '@microsoft/dynamicproto-js': 2.0.3 + '@nevware21/ts-utils': 0.11.6 + tslib: 2.8.1 + + '@microsoft/applicationinsights-core-js@3.3.5(tslib@2.8.1)': + dependencies: + '@microsoft/applicationinsights-shims': 3.0.1 + '@microsoft/dynamicproto-js': 2.0.3 + '@nevware21/ts-async': 0.5.4 + '@nevware21/ts-utils': 0.11.6 + tslib: 2.8.1 + + '@microsoft/applicationinsights-shims@3.0.1': + dependencies: + '@nevware21/ts-utils': 0.11.6 + + '@microsoft/applicationinsights-web-basic@3.3.5(tslib@2.8.1)': + dependencies: + '@microsoft/applicationinsights-channel-js': 3.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-common': 3.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-core-js': 3.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-shims': 3.0.1 + '@microsoft/dynamicproto-js': 2.0.3 + '@nevware21/ts-async': 0.5.4 + '@nevware21/ts-utils': 0.11.6 + tslib: 2.8.1 + + '@microsoft/dynamicproto-js@2.0.3': + dependencies: + '@nevware21/ts-utils': 0.11.6 + '@microsoft/tsdoc-config@0.17.1': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -16891,6 +17004,12 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} + '@nevware21/ts-async@0.5.4': + dependencies: + '@nevware21/ts-utils': 0.11.6 + + '@nevware21/ts-utils@0.11.6': {} + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': optional: true @@ -18769,6 +18888,14 @@ snapshots: vscode-languageserver-types: 3.17.5 vscode-uri: 3.0.8 + '@vscode/extension-telemetry@0.9.8(tslib@2.8.1)': + dependencies: + '@microsoft/1ds-core-js': 4.3.5(tslib@2.8.1) + '@microsoft/1ds-post-js': 4.3.5(tslib@2.8.1) + '@microsoft/applicationinsights-web-basic': 3.3.5(tslib@2.8.1) + transitivePeerDependencies: + - tslib + '@vscode/l10n@0.0.18': {} '@vscode/test-web@0.0.65':