diff --git a/.vscode/settings.json b/.vscode/settings.json index de42fdbe8eb1..0c37edaa8f23 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ { "files.exclude": { "out": true, // set this to true to hide the "out" folder with the compiled JS files + "dist": true, "**/*.pyc": true, ".nyc_output": true, "obj": true, @@ -15,6 +16,7 @@ }, "search.exclude": { "out": true, // set this to false to include "out" folder in search results + "dist": true, "**/node_modules": true, "coverage": true, "languageServer*/**": true, diff --git a/build/webpack/webpack.extension.config.js b/build/webpack/webpack.extension.config.js index 1a936313f113..fe56c70bf15f 100644 --- a/build/webpack/webpack.extension.config.js +++ b/build/webpack/webpack.extension.config.js @@ -53,7 +53,15 @@ const config = { }, ], }, - externals: ['vscode', 'commonjs', ...existingModulesInOutDir], + externals: [ + 'vscode', + 'commonjs', + ...existingModulesInOutDir, + // These dependencies are ignored because we don't use them, and App Insights has try-catch protecting their loading if they don't exist + // See: https://github.com/microsoft/vscode-extension-telemetry/issues/41#issuecomment-598852991 + 'applicationinsights-native-metrics', + '@opentelemetry/tracing', + ], plugins: [...common.getDefaultPlugins('extension')], resolve: { alias: { diff --git a/gulpfile.js b/gulpfile.js index 90d36577cc11..e5b91a15b546 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -193,7 +193,11 @@ function getAllowedWarningsForWebPack(buildConfig) { 'WARNING in ./node_modules/applicationinsights/out/AutoCollection/NativePerformance.js', ]; case 'browser': - return []; + return [ + 'WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).', + 'WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.', + 'WARNING in webpack performance recommendations:', + ]; default: throw new Error('Unknown WebPack Configuration'); } diff --git a/news/3 Code Health/16871.md b/news/3 Code Health/16871.md new file mode 100644 index 000000000000..6e6eef269d55 --- /dev/null +++ b/news/3 Code Health/16871.md @@ -0,0 +1 @@ +Update telemetry client to support browser, plumb to Pylance. diff --git a/news/3 Code Health/16872.md b/news/3 Code Health/16872.md new file mode 100644 index 000000000000..7ba6cba6c200 --- /dev/null +++ b/news/3 Code Health/16872.md @@ -0,0 +1 @@ +Refactor language server middleware to work in the browser. diff --git a/package-lock.json b/package-lock.json index 9b05d062ae97..542f861a1067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1455,17 +1455,6 @@ "default-require-extensions": "^3.0.0" } }, - "applicationinsights": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-1.7.4.tgz", - "integrity": "sha512-XFLsNlcanpjFhHNvVWEfcm6hr7lu9znnb6Le1Lk5RE03YUV9X2B2n2MfM4kJZRrUdV+C0hdHxvWyv+vWoLfY7A==", - "requires": { - "cls-hooked": "^4.2.2", - "continuation-local-storage": "^3.2.1", - "diagnostic-channel": "0.2.0", - "diagnostic-channel-publishers": "^0.3.3" - } - }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -2144,29 +2133,12 @@ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", "dev": true }, - "async-hook-jl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", - "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", - "requires": { - "stack-chain": "^1.3.7" - } - }, "async-limiter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==", "dev": true }, - "async-listener": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/async-listener/-/async-listener-0.6.10.tgz", - "integrity": "sha512-gpuo6xOyF4D5DE5WvyqZdPA3NGhiT6Qf07l7DCB0wwDEsLvDIbCr6j9S5aj5Ch96dLace5tXVzWBZkxU/c5ohw==", - "requires": { - "semver": "^5.3.0", - "shimmer": "^1.1.0" - } - }, "async-settle": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", @@ -3758,16 +3730,6 @@ } } }, - "cls-hooked": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", - "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", - "requires": { - "async-hook-jl": "^1.7.6", - "emitter-listener": "^1.0.1", - "semver": "^5.4.1" - } - }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4069,15 +4031,6 @@ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", "dev": true }, - "continuation-local-storage": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz", - "integrity": "sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA==", - "requires": { - "async-listener": "^0.6.0", - "emitter-listener": "^1.1.1" - } - }, "convert-source-map": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", @@ -5100,19 +5053,6 @@ "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", "dev": true }, - "diagnostic-channel": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-0.2.0.tgz", - "integrity": "sha1-zJmvlhLCP7H/8TYSxy8sv6qNWhc=", - "requires": { - "semver": "^5.3.0" - } - }, - "diagnostic-channel-publishers": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.3.4.tgz", - "integrity": "sha512-SZ1zMfFiEabf4Qx0Og9V1gMsRoqz3O+5ENkVcNOfI+SMJ3QhQsdEoKX99r0zvreagXot2parPxmrwwUM/ja8ug==" - }, "diagnostics": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz", @@ -5381,14 +5321,6 @@ } } }, - "emitter-listener": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", - "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", - "requires": { - "shimmer": "^1.2.0" - } - }, "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -15023,11 +14955,6 @@ } } }, - "shimmer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", - "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" - }, "shortid": { "version": "2.2.14", "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.14.tgz", @@ -15548,11 +15475,6 @@ "figgy-pudding": "^3.5.1" } }, - "stack-chain": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", - "integrity": "sha1-0ZLJ/06moiyUxN1FkXHj8AzqEoU=" - }, "stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -17792,12 +17714,9 @@ "integrity": "sha512-+OMm11R1bGYbpIJ5eQIkwoDGFF4GvBz3Ztl6/VM+/RNNb2Gjk2c0Ku+oMmfhlTmTlPCpgHBsH4JqVCbUYhu5bA==" }, "vscode-extension-telemetry": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/vscode-extension-telemetry/-/vscode-extension-telemetry-0.1.4.tgz", - "integrity": "sha512-9U2pUZ/YwZBfA8CkBrHwMxjnq9Ab+ng8daJWJzEQ6CAxlZyRhmck23bx2lqqpEwGWJCiuceQy4k0Me6llEB4zw==", - "requires": { - "applicationinsights": "1.7.4" - } + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/vscode-extension-telemetry/-/vscode-extension-telemetry-0.2.8.tgz", + "integrity": "sha512-Vf52im5qzORRD2K5Ryp8PXo31YXVcJAYRSDDZGegWlt0OATOd83DYabS1U/WIq9nR5g80UQKH3+BsenhpQHUaA==" }, "vscode-jsonrpc": { "version": "6.0.0", diff --git a/package.json b/package.json index 483816e5fe29..24591f4b77a9 100644 --- a/package.json +++ b/package.json @@ -2017,7 +2017,7 @@ "untildify": "^3.0.2", "vscode-debugadapter": "^1.28.0", "vscode-debugprotocol": "^1.28.0", - "vscode-extension-telemetry": "0.1.4", + "vscode-extension-telemetry": "0.2.8", "vscode-jsonrpc": "6.0.0", "vscode-jupyter-lsp-middleware": "^0.1.3", "vscode-languageclient": "7.0.0", diff --git a/src/client/activation/languageClientMiddleware.ts b/src/client/activation/languageClientMiddleware.ts index 7d9e0bcc89c7..5345183c315a 100644 --- a/src/client/activation/languageClientMiddleware.ts +++ b/src/client/activation/languageClientMiddleware.ts @@ -1,138 +1,39 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as path from 'path'; -import { - CancellationToken, - CodeAction, - CodeLens, - Command, - CompletionItem, - CompletionList, - Declaration as VDeclaration, - Definition, - DefinitionLink, - Diagnostic, - DocumentHighlight, - DocumentLink, - DocumentSymbol, - Location, - ProviderResult, - Range, - SymbolInformation, - TextEdit, - Uri, - WorkspaceEdit, -} from 'vscode'; -import { - ConfigurationParams, - ConfigurationRequest, - HandleDiagnosticsSignature, - LanguageClient, - Middleware, - ResponseError, -} from 'vscode-languageclient/node'; + import NotebookMiddlewareAddon from 'vscode-jupyter-lsp-middleware'; +import { LanguageClient } from 'vscode-languageclient/node'; import { IJupyterExtensionDependencyManager, IVSCodeNotebook } from '../common/application/types'; - -import { HiddenFilePrefix, PYTHON_LANGUAGE } from '../common/constants'; +import { PYTHON_LANGUAGE } from '../common/constants'; +import { traceInfo } from '../common/logger'; import { IFileSystem } from '../common/platform/types'; -import { IConfigurationService, IDisposableRegistry, IExtensions } from '../common/types'; -import { isThenable } from '../common/utils/async'; -import { StopWatch } from '../common/utils/stopWatch'; -import { IEnvironmentVariablesProvider } from '../common/variables/types'; +import { IDisposableRegistry, IExtensions } from '../common/types'; import { IServiceContainer } from '../ioc/types'; import { sendTelemetryEvent } from '../telemetry'; -import { EventName } from '../telemetry/constants'; -import { LanguageServerType } from './types'; -import { traceInfo } from '../common/logger'; - -// Only send 100 events per hour. -const globalDebounce = 1000 * 60 * 60; -const globalLimit = 100; - -// For calls that are more likely to happen during a session (hover, completion, document symbols). -const debounceFrequentCall = 1000 * 60 * 5; - -// For calls that are less likely to happen during a session (go-to-def, workspace symbols). -const debounceRareCall = 1000 * 60; - -export class LanguageClientMiddleware implements Middleware { - // These are public so that the captureTelemetryForLSPMethod decorator can access them. - public readonly eventName: EventName | undefined; - public readonly lastCaptured = new Map(); - public nextWindow: number = 0; - public eventCount: number = 0; - - public workspace = { - configuration: async ( - params: ConfigurationParams, - token: CancellationToken, - next: ConfigurationRequest.HandlerSignature, - ) => { - const configService = this.serviceContainer.get(IConfigurationService); - const envService = this.serviceContainer.get(IEnvironmentVariablesProvider); - - let settings = next(params, token); - if (isThenable(settings)) { - settings = await settings; - } - if (settings instanceof ResponseError) { - return settings; - } - - for (const [i, item] of params.items.entries()) { - if (item.section === 'python') { - const uri = item.scopeUri ? Uri.parse(item.scopeUri) : undefined; - // For backwards compatibility, set python.pythonPath to the configured - // value as though it were in the user's settings.json file. - settings[i].pythonPath = configService.getSettings(uri).pythonPath; - - const env = await envService.getEnvironmentVariables(uri); - const envPYTHONPATH = env.PYTHONPATH; - if (envPYTHONPATH) { - settings[i]._envPYTHONPATH = envPYTHONPATH; - } - } - } - - return settings; - }, - }; - private notebookAddon: NotebookMiddlewareAddon | undefined; - private connected = false; // Default to not forwarding to VS code. +import { LanguageClientMiddlewareBase } from './languageClientMiddlewareBase'; +import { LanguageServerType } from './types'; +export class LanguageClientMiddleware extends LanguageClientMiddlewareBase { public constructor( - readonly serviceContainer: IServiceContainer, + serviceContainer: IServiceContainer, serverType: LanguageServerType, getClient: () => LanguageClient | undefined, - public readonly serverVersion?: string, + serverVersion?: string, ) { - this.handleDiagnostics = this.handleDiagnostics.bind(this); // VS Code calls function without context. - this.didOpen = this.didOpen.bind(this); - this.didSave = this.didSave.bind(this); - this.didChange = this.didChange.bind(this); - this.didClose = this.didClose.bind(this); - this.willSave = this.willSave.bind(this); - this.willSaveWaitUntil = this.willSaveWaitUntil.bind(this); + super(serviceContainer, serverType, sendTelemetryEvent, serverVersion); - if (serverType === LanguageServerType.Microsoft) { - this.eventName = EventName.PYTHON_LANGUAGE_SERVER_REQUEST; - } else if (serverType === LanguageServerType.Node) { - this.eventName = EventName.LANGUAGE_SERVER_REQUEST; - } else if (serverType === LanguageServerType.JediLSP) { - this.eventName = EventName.JEDI_LANGUAGE_SERVER_REQUEST; - } else { + if (serverType === LanguageServerType.None || serverType === LanguageServerType.Jedi) { return; } - const jupyterDependencyManager = this.serviceContainer.get( + const jupyterDependencyManager = serviceContainer.get( IJupyterExtensionDependencyManager, ); - const notebookApi = this.serviceContainer.get(IVSCodeNotebook); - const disposables = this.serviceContainer.get(IDisposableRegistry) || []; - const extensions = this.serviceContainer.get(IExtensions); - const fileSystem = this.serviceContainer.get(IFileSystem); + const notebookApi = serviceContainer.get(IVSCodeNotebook); + const disposables = serviceContainer.get(IDisposableRegistry) || []; + const extensions = serviceContainer.get(IExtensions); + const fileSystem = serviceContainer.get(IFileSystem); // Enable notebook support if jupyter support is installed if (jupyterDependencyManager && jupyterDependencyManager.isJupyterExtensionInstalled) { @@ -164,290 +65,4 @@ export class LanguageClientMiddleware implements Middleware { }), ); } - - public connect() { - this.connected = true; - } - - public disconnect() { - this.connected = false; - } - - public didChange() { - if (this.connected) { - return this.callNext('didChange', arguments); - } - } - - public didOpen() { - // Special case, open and close happen before we connect. - return this.callNext('didOpen', arguments); - } - - public didClose() { - // Special case, open and close happen before we connect. - return this.callNext('didClose', arguments); - } - - public didSave() { - if (this.connected) { - return this.callNext('didSave', arguments); - } - } - - public willSave() { - if (this.connected) { - return this.callNext('willSave', arguments); - } - } - - public willSaveWaitUntil() { - if (this.connected) { - return this.callNext('willSaveWaitUntil', arguments); - } - } - - @captureTelemetryForLSPMethod( - 'textDocument/completion', - debounceFrequentCall, - LanguageClientMiddleware.completionLengthMeasure, - ) - public provideCompletionItem() { - if (this.connected) { - return this.callNext('provideCompletionItem', arguments); - } - } - - private static completionLengthMeasure( - _obj: LanguageClientMiddleware, - result: CompletionItem[] | CompletionList, - ): Record { - const resultLength = Array.isArray(result) ? result.length : result.items.length; - return { resultLength }; - } - - @captureTelemetryForLSPMethod('textDocument/hover', debounceFrequentCall) - public provideHover() { - if (this.connected) { - return this.callNext('provideHover', arguments); - } - } - - public handleDiagnostics(uri: Uri, _diagnostics: Diagnostic[], _next: HandleDiagnosticsSignature) { - if (this.connected) { - // Skip sending if this is a special file. - const filePath = uri.fsPath; - const baseName = filePath ? path.basename(filePath) : undefined; - if (!baseName || !baseName.startsWith(HiddenFilePrefix)) { - return this.callNext('handleDiagnostics', arguments); - } - } - } - - @captureTelemetryForLSPMethod('completionItem/resolve', debounceFrequentCall) - public resolveCompletionItem(): ProviderResult { - if (this.connected) { - return this.callNext('resolveCompletionItem', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/signatureHelp', debounceFrequentCall) - public provideSignatureHelp() { - if (this.connected) { - return this.callNext('provideSignatureHelp', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/definition', debounceRareCall) - public provideDefinition(): ProviderResult { - if (this.connected) { - return this.callNext('provideDefinition', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/references', debounceRareCall) - public provideReferences(): ProviderResult { - if (this.connected) { - return this.callNext('provideReferences', arguments); - } - } - - public provideDocumentHighlights(): ProviderResult { - if (this.connected) { - return this.callNext('provideDocumentHighlights', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/documentSymbol', debounceFrequentCall) - public provideDocumentSymbols(): ProviderResult { - if (this.connected) { - return this.callNext('provideDocumentSymbols', arguments); - } - } - - @captureTelemetryForLSPMethod('workspace/symbol', debounceRareCall) - public provideWorkspaceSymbols(): ProviderResult { - if (this.connected) { - return this.callNext('provideWorkspaceSymbols', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/codeAction', debounceFrequentCall) - public provideCodeActions(): ProviderResult<(Command | CodeAction)[]> { - if (this.connected) { - return this.callNext('provideCodeActions', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/codeLens', debounceFrequentCall) - public provideCodeLenses(): ProviderResult { - if (this.connected) { - return this.callNext('provideCodeLenses', arguments); - } - } - - @captureTelemetryForLSPMethod('codeLens/resolve', debounceFrequentCall) - public resolveCodeLens(): ProviderResult { - if (this.connected) { - return this.callNext('resolveCodeLens', arguments); - } - } - - public provideDocumentFormattingEdits(): ProviderResult { - if (this.connected) { - return this.callNext('provideDocumentFormattingEdits', arguments); - } - } - - public provideDocumentRangeFormattingEdits(): ProviderResult { - if (this.connected) { - return this.callNext('provideDocumentRangeFormattingEdits', arguments); - } - } - - public provideOnTypeFormattingEdits(): ProviderResult { - if (this.connected) { - return this.callNext('provideOnTypeFormattingEdits', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/rename', debounceRareCall) - public provideRenameEdits(): ProviderResult { - if (this.connected) { - return this.callNext('provideRenameEdits', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/prepareRename', debounceRareCall) - public prepareRename(): ProviderResult< - | Range - | { - range: Range; - placeholder: string; - } - > { - if (this.connected) { - return this.callNext('prepareRename', arguments); - } - } - - public provideDocumentLinks(): ProviderResult { - if (this.connected) { - return this.callNext('provideDocumentLinks', arguments); - } - } - - public resolveDocumentLink(): ProviderResult { - if (this.connected) { - return this.callNext('resolveDocumentLink', arguments); - } - } - - @captureTelemetryForLSPMethod('textDocument/declaration', debounceRareCall) - public provideDeclaration(): ProviderResult { - if (this.connected) { - return this.callNext('provideDeclaration', arguments); - } - } - - private callNext(funcName: keyof NotebookMiddlewareAddon, args: IArguments) { - // This function uses the last argument to call the 'next' item. If we're allowing notebook - // middleware, it calls into the notebook middleware first. - if (this.notebookAddon) { - // It would be nice to use args.callee, but not supported in strict mode - - return (this.notebookAddon as any)[funcName](...args); - } else { - return args[args.length - 1](...args); - } - } -} - -function captureTelemetryForLSPMethod( - method: string, - debounceMilliseconds: number, - lazyMeasures?: (this_: any, result: any) => Record, -) { - return function (_target: Object, _propertyKey: string, descriptor: TypedPropertyDescriptor) { - const originalMethod = descriptor.value; - - descriptor.value = function (this: LanguageClientMiddleware, ...args: any[]) { - const eventName = this.eventName; - if (!eventName) { - return originalMethod.apply(this, args); - } - - const now = Date.now(); - - if (now > this.nextWindow) { - // Past the end of the last window, reset. - this.nextWindow = now + globalDebounce; - this.eventCount = 0; - } else if (this.eventCount >= globalLimit) { - // Sent too many events in this window, don't send. - return originalMethod.apply(this, args); - } - - const lastCapture = this.lastCaptured.get(method); - if (lastCapture && now - lastCapture < debounceMilliseconds) { - return originalMethod.apply(this, args); - } - - this.lastCaptured.set(method, now); - this.eventCount += 1; - - // Replace all slashes in the method name so it doesn't get scrubbed by vscode-extension-telemetry. - const formattedMethod = method.replace(/\//g, '.'); - - const properties = { - lsVersion: this.serverVersion || 'unknown', - method: formattedMethod, - }; - - const stopWatch = new StopWatch(); - const sendTelemetry = (result: any) => { - let measures: number | Record = stopWatch.elapsedTime; - if (lazyMeasures) { - measures = { - duration: measures, - ...lazyMeasures(this, result), - }; - } - sendTelemetryEvent(eventName, measures, properties); - return result; - }; - - let result = originalMethod.apply(this, args); - - if (isThenable(result)) { - return result.then(sendTelemetry); - } - - sendTelemetry(result); - - return result; - }; - - return descriptor; - }; } diff --git a/src/client/activation/languageClientMiddlewareBase.ts b/src/client/activation/languageClientMiddlewareBase.ts new file mode 100644 index 000000000000..0f092c12744c --- /dev/null +++ b/src/client/activation/languageClientMiddlewareBase.ts @@ -0,0 +1,425 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import * as path from 'path'; +import { + CancellationToken, + CodeAction, + CodeLens, + Command, + CompletionItem, + CompletionList, + Declaration as VDeclaration, + Definition, + DefinitionLink, + Diagnostic, + DocumentHighlight, + DocumentLink, + DocumentSymbol, + Location, + ProviderResult, + Range, + SymbolInformation, + TextEdit, + Uri, + WorkspaceEdit, +} from 'vscode'; +import { + ConfigurationParams, + ConfigurationRequest, + HandleDiagnosticsSignature, + Middleware, + ResponseError, +} from 'vscode-languageclient'; +import type NotebookMiddlewareAddon from 'vscode-jupyter-lsp-middleware'; + +import { HiddenFilePrefix } from '../common/constants'; +import { IConfigurationService } from '../common/types'; +import { isThenable } from '../common/utils/async'; +import { StopWatch } from '../common/utils/stopWatch'; +import { IEnvironmentVariablesProvider } from '../common/variables/types'; +import { IServiceContainer } from '../ioc/types'; +import { EventName } from '../telemetry/constants'; +import { LanguageServerType } from './types'; + +// Only send 100 events per hour. +const globalDebounce = 1000 * 60 * 60; +const globalLimit = 100; + +// For calls that are more likely to happen during a session (hover, completion, document symbols). +const debounceFrequentCall = 1000 * 60 * 5; + +// For calls that are less likely to happen during a session (go-to-def, workspace symbols). +const debounceRareCall = 1000 * 60; + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable prefer-rest-params */ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface SendTelemetryEventFunc { + (eventName: EventName, measuresOrDurationMs?: Record | number, properties?: any, ex?: Error): void; +} + +export class LanguageClientMiddlewareBase implements Middleware { + // These are public so that the captureTelemetryForLSPMethod decorator can access them. + public readonly eventName: EventName | undefined; + + public readonly lastCaptured = new Map(); + + public nextWindow = 0; + + public eventCount = 0; + + public workspace = { + configuration: async ( + params: ConfigurationParams, + token: CancellationToken, + next: ConfigurationRequest.HandlerSignature, + ) => { + if (!this.serviceContainer) { + return next(params, token); + } + + const configService = this.serviceContainer.get(IConfigurationService); + const envService = this.serviceContainer.get(IEnvironmentVariablesProvider); + + let settings = next(params, token); + if (isThenable(settings)) { + settings = await settings; + } + if (settings instanceof ResponseError) { + return settings; + } + + for (const [i, item] of params.items.entries()) { + if (item.section === 'python') { + const uri = item.scopeUri ? Uri.parse(item.scopeUri) : undefined; + // For backwards compatibility, set python.pythonPath to the configured + // value as though it were in the user's settings.json file. + settings[i].pythonPath = configService.getSettings(uri).pythonPath; + + const env = await envService.getEnvironmentVariables(uri); + const envPYTHONPATH = env.PYTHONPATH; + if (envPYTHONPATH) { + settings[i]._envPYTHONPATH = envPYTHONPATH; + } + } + } + + return settings; + }, + }; + + protected notebookAddon: NotebookMiddlewareAddon | undefined; + + private connected = false; // Default to not forwarding to VS code. + + public constructor( + readonly serviceContainer: IServiceContainer | undefined, + serverType: LanguageServerType, + public readonly sendTelemetryEventFunc: SendTelemetryEventFunc, + public readonly serverVersion?: string, + ) { + this.handleDiagnostics = this.handleDiagnostics.bind(this); // VS Code calls function without context. + this.didOpen = this.didOpen.bind(this); + this.didSave = this.didSave.bind(this); + this.didChange = this.didChange.bind(this); + this.didClose = this.didClose.bind(this); + this.willSave = this.willSave.bind(this); + this.willSaveWaitUntil = this.willSaveWaitUntil.bind(this); + + if (serverType === LanguageServerType.Microsoft) { + this.eventName = EventName.PYTHON_LANGUAGE_SERVER_REQUEST; + } else if (serverType === LanguageServerType.Node) { + this.eventName = EventName.LANGUAGE_SERVER_REQUEST; + } else if (serverType === LanguageServerType.JediLSP) { + this.eventName = EventName.JEDI_LANGUAGE_SERVER_REQUEST; + } + } + + public connect() { + this.connected = true; + } + + public disconnect() { + this.connected = false; + } + + public didChange() { + if (this.connected) { + return this.callNext('didChange', arguments); + } + } + + public didOpen() { + // Special case, open and close happen before we connect. + return this.callNext('didOpen', arguments); + } + + public didClose() { + // Special case, open and close happen before we connect. + return this.callNext('didClose', arguments); + } + + public didSave() { + if (this.connected) { + return this.callNext('didSave', arguments); + } + } + + public willSave() { + if (this.connected) { + return this.callNext('willSave', arguments); + } + } + + public willSaveWaitUntil() { + if (this.connected) { + return this.callNext('willSaveWaitUntil', arguments); + } + } + + @captureTelemetryForLSPMethod( + 'textDocument/completion', + debounceFrequentCall, + LanguageClientMiddlewareBase.completionLengthMeasure, + ) + public provideCompletionItem() { + if (this.connected) { + return this.callNext('provideCompletionItem', arguments); + } + } + + private static completionLengthMeasure( + _obj: LanguageClientMiddlewareBase, + result: CompletionItem[] | CompletionList, + ): Record { + const resultLength = Array.isArray(result) ? result.length : result.items.length; + return { resultLength }; + } + + @captureTelemetryForLSPMethod('textDocument/hover', debounceFrequentCall) + public provideHover() { + if (this.connected) { + return this.callNext('provideHover', arguments); + } + } + + public handleDiagnostics(uri: Uri, _diagnostics: Diagnostic[], _next: HandleDiagnosticsSignature) { + if (this.connected) { + // Skip sending if this is a special file. + const filePath = uri.fsPath; + const baseName = filePath ? path.basename(filePath) : undefined; + if (!baseName || !baseName.startsWith(HiddenFilePrefix)) { + return this.callNext('handleDiagnostics', arguments); + } + } + } + + @captureTelemetryForLSPMethod('completionItem/resolve', debounceFrequentCall) + public resolveCompletionItem(): ProviderResult { + if (this.connected) { + return this.callNext('resolveCompletionItem', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/signatureHelp', debounceFrequentCall) + public provideSignatureHelp() { + if (this.connected) { + return this.callNext('provideSignatureHelp', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/definition', debounceRareCall) + public provideDefinition(): ProviderResult { + if (this.connected) { + return this.callNext('provideDefinition', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/references', debounceRareCall) + public provideReferences(): ProviderResult { + if (this.connected) { + return this.callNext('provideReferences', arguments); + } + } + + public provideDocumentHighlights(): ProviderResult { + if (this.connected) { + return this.callNext('provideDocumentHighlights', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/documentSymbol', debounceFrequentCall) + public provideDocumentSymbols(): ProviderResult { + if (this.connected) { + return this.callNext('provideDocumentSymbols', arguments); + } + } + + @captureTelemetryForLSPMethod('workspace/symbol', debounceRareCall) + public provideWorkspaceSymbols(): ProviderResult { + if (this.connected) { + return this.callNext('provideWorkspaceSymbols', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/codeAction', debounceFrequentCall) + public provideCodeActions(): ProviderResult<(Command | CodeAction)[]> { + if (this.connected) { + return this.callNext('provideCodeActions', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/codeLens', debounceFrequentCall) + public provideCodeLenses(): ProviderResult { + if (this.connected) { + return this.callNext('provideCodeLenses', arguments); + } + } + + @captureTelemetryForLSPMethod('codeLens/resolve', debounceFrequentCall) + public resolveCodeLens(): ProviderResult { + if (this.connected) { + return this.callNext('resolveCodeLens', arguments); + } + } + + public provideDocumentFormattingEdits(): ProviderResult { + if (this.connected) { + return this.callNext('provideDocumentFormattingEdits', arguments); + } + } + + public provideDocumentRangeFormattingEdits(): ProviderResult { + if (this.connected) { + return this.callNext('provideDocumentRangeFormattingEdits', arguments); + } + } + + public provideOnTypeFormattingEdits(): ProviderResult { + if (this.connected) { + return this.callNext('provideOnTypeFormattingEdits', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/rename', debounceRareCall) + public provideRenameEdits(): ProviderResult { + if (this.connected) { + return this.callNext('provideRenameEdits', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/prepareRename', debounceRareCall) + public prepareRename(): ProviderResult< + | Range + | { + range: Range; + placeholder: string; + } + > { + if (this.connected) { + return this.callNext('prepareRename', arguments); + } + } + + public provideDocumentLinks(): ProviderResult { + if (this.connected) { + return this.callNext('provideDocumentLinks', arguments); + } + } + + public resolveDocumentLink(): ProviderResult { + if (this.connected) { + return this.callNext('resolveDocumentLink', arguments); + } + } + + @captureTelemetryForLSPMethod('textDocument/declaration', debounceRareCall) + public provideDeclaration(): ProviderResult { + if (this.connected) { + return this.callNext('provideDeclaration', arguments); + } + } + + private callNext(funcName: keyof NotebookMiddlewareAddon, args: IArguments) { + // This function uses the last argument to call the 'next' item. If we're allowing notebook + // middleware, it calls into the notebook middleware first. + if (this.notebookAddon) { + // It would be nice to use args.callee, but not supported in strict mode + + return (this.notebookAddon as any)[funcName](...args); + } + return args[args.length - 1](...args); + } +} + +function captureTelemetryForLSPMethod( + method: string, + debounceMilliseconds: number, + lazyMeasures?: (this_: any, result: any) => Record, +) { + return function (_target: Object, _propertyKey: string, descriptor: TypedPropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = function (this: LanguageClientMiddlewareBase, ...args: any[]) { + const { eventName } = this; + if (!eventName) { + return originalMethod.apply(this, args); + } + + const now = Date.now(); + + if (now > this.nextWindow) { + // Past the end of the last window, reset. + this.nextWindow = now + globalDebounce; + this.eventCount = 0; + } else if (this.eventCount >= globalLimit) { + // Sent too many events in this window, don't send. + return originalMethod.apply(this, args); + } + + const lastCapture = this.lastCaptured.get(method); + if (lastCapture && now - lastCapture < debounceMilliseconds) { + return originalMethod.apply(this, args); + } + + this.lastCaptured.set(method, now); + this.eventCount += 1; + + // Replace all slashes in the method name so it doesn't get scrubbed by vscode-extension-telemetry. + const formattedMethod = method.replace(/\//g, '.'); + + const properties = { + lsVersion: this.serverVersion || 'unknown', + method: formattedMethod, + }; + + const stopWatch = new StopWatch(); + const sendTelemetry = (result: any) => { + let measures: number | Record = stopWatch.elapsedTime; + if (lazyMeasures) { + measures = { + duration: measures, + ...lazyMeasures(this, result), + }; + } + this.sendTelemetryEventFunc(eventName, measures, properties); + return result; + }; + + const result = originalMethod.apply(this, args); + + if (isThenable(result)) { + return result.then(sendTelemetry); + } + + sendTelemetry(result); + + return result; + }; + + return descriptor; + }; +} diff --git a/src/client/browser/extension.ts b/src/client/browser/extension.ts index 4164bc7f3e7f..4b27ad2a0f7e 100644 --- a/src/client/browser/extension.ts +++ b/src/client/browser/extension.ts @@ -2,10 +2,14 @@ // Licensed under the MIT License. import * as vscode from 'vscode'; -import { LanguageClientOptions } from 'vscode-languageclient'; +import TelemetryReporter from 'vscode-extension-telemetry'; +import { LanguageClientOptions, State } from 'vscode-languageclient'; import { LanguageClient } from 'vscode-languageclient/browser'; +import { LanguageClientMiddlewareBase } from '../activation/languageClientMiddlewareBase'; import { ILSExtensionApi } from '../activation/node/languageServerFolderService'; -import { PYLANCE_EXTENSION_ID } from '../common/constants'; +import { LanguageServerType } from '../activation/types'; +import { AppinsightsKey, PVSC_EXTENSION_ID, PYLANCE_EXTENSION_ID } from '../common/constants'; +import { EventName } from '../telemetry/constants'; interface BrowserConfig { distUrl: string; // URL to Pylance's dist folder. @@ -23,7 +27,7 @@ async function runPylance(context: vscode.ExtensionContext): Promise { throw new Error('Could not find Pylance extension'); } - const { path: distUrl } = await pylanceApi.languageServerFolder(); + const { path: distUrl, version } = await pylanceApi.languageServerFolder(); try { const worker = new Worker(`${distUrl}/browser.server.bundle.js`); @@ -38,6 +42,14 @@ async function runPylance(context: vscode.ExtensionContext): Promise { }; worker.postMessage(config); + const middleware = new LanguageClientMiddlewareBase( + undefined, + LanguageServerType.Node, + sendTelemetryEventBrowser, + version, + ); + middleware.connect(); + const clientOptions: LanguageClientOptions = { // Register the server for python source files. documentSelector: [ @@ -49,9 +61,41 @@ async function runPylance(context: vscode.ExtensionContext): Promise { // Synchronize the setting section to the server. configurationSection: ['python'], }, + middleware, }; const languageClient = new LanguageClient('python', 'Python Language Server', clientOptions, worker); + + languageClient.onDidChangeState((e) => { + // The client's on* methods must be called after the client has started, but if called too + // late the server may have already sent a message (which leads to failures). Register + // these on the state change to running to ensure they are ready soon enough. + if (e.newState !== State.Running) { + return; + } + + context.subscriptions.push( + vscode.commands.registerCommand('python.viewLanguageServerOutput', () => + languageClient.outputChannel.show(), + ), + ); + + languageClient.onTelemetry((telemetryEvent) => { + const eventName = telemetryEvent.EventName || EventName.LANGUAGE_SERVER_TELEMETRY; + const formattedProperties = { + ...telemetryEvent.Properties, + // Replace all slashes in the method name so it doesn't get scrubbed by vscode-extension-telemetry. + method: telemetryEvent.Properties.method?.replace(/\//g, '.'), + }; + sendTelemetryEventBrowser( + eventName, + telemetryEvent.Measurements, + formattedProperties, + telemetryEvent.Exception, + ); + }); + }); + const disposable = languageClient.start(); context.subscriptions.push(disposable); @@ -59,3 +103,97 @@ async function runPylance(context: vscode.ExtensionContext): Promise { console.log(e); } } + +// Duplicate code from telemetry/index.ts to avoid pulling in winston, +// which doesn't support the browser. + +let telemetryReporter: TelemetryReporter | undefined; +function getTelemetryReporter() { + if (telemetryReporter) { + return telemetryReporter; + } + const extensionId = PVSC_EXTENSION_ID; + + // eslint-disable-next-line global-require + const { extensions } = require('vscode') as typeof import('vscode'); + const extension = extensions.getExtension(extensionId)!; + const extensionVersion = extension.packageJSON.version; + + // eslint-disable-next-line global-require + const Reporter = require('vscode-extension-telemetry').default as typeof TelemetryReporter; + telemetryReporter = new Reporter(extensionId, extensionVersion, AppinsightsKey, true); + + return telemetryReporter; +} + +function sendTelemetryEventBrowser( + eventName: EventName, + measuresOrDurationMs?: Record | number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + properties?: any, + ex?: Error, +): void { + const reporter = getTelemetryReporter(); + const measures = + typeof measuresOrDurationMs === 'number' + ? { duration: measuresOrDurationMs } + : measuresOrDurationMs || undefined; + const customProperties: Record = {}; + const eventNameSent = eventName as string; + + if (properties) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = properties as any; + Object.getOwnPropertyNames(data).forEach((prop) => { + if (data[prop] === undefined || data[prop] === null) { + return; + } + try { + // If there are any errors in serializing one property, ignore that and move on. + // Else nothing will be sent. + switch (typeof data[prop]) { + case 'string': + customProperties[prop] = data[prop]; + break; + case 'object': + customProperties[prop] = 'object'; + break; + default: + customProperties[prop] = data[prop].toString(); + break; + } + } catch (exception) { + console.error(`Failed to serialize ${prop} for ${eventName}`, exception); + } + }); + } + + // Add shared properties to telemetry props (we may overwrite existing ones). + // Removed in the browser; there's no setSharedProperty. + // Object.assign(customProperties, sharedProperties); + + if (ex) { + const errorProps = { + errorName: ex.name, + errorMessage: ex.message, + errorStack: ex.stack ?? '', + }; + Object.assign(customProperties, errorProps); + + // To avoid hardcoding the names and forgetting to update later. + const errorPropNames = Object.getOwnPropertyNames(errorProps); + // TODO: remove this "as any" once the upstream lib is fixed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (reporter.sendTelemetryErrorEvent as any)(eventNameSent, customProperties, measures, errorPropNames); + } else { + reporter.sendTelemetryEvent(eventNameSent, customProperties, measures); + } + + if (process.env && process.env.VSC_PYTHON_LOG_TELEMETRY) { + console.error( + `Telemetry Event : ${eventNameSent} Measures: ${JSON.stringify(measures)} Props: ${JSON.stringify( + customProperties, + )} `, + ); + } +} diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 937fc439a5bd..d35ceb7d4b0b 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -155,7 +155,9 @@ export function sendTelemetryEvent