diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bdabaa..d8adb2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,8 @@ And then using _Install from VSIX_ option in VSC. Releasing requires [`@vscode/vsce`](https://www.npmjs.com/package/@vscode/vsce) package installed. +**IMPORTANT**: To keep telemetry working, before running any `vsce` command, please update `SEGMENT_API_KEY` in `src/config.ts` to correct value for a time of building the extension (DO NOT COMMIT THOUGH!). + After that, run: ```bash diff --git a/README.md b/README.md index be85ece..3fbc1d5 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ This extension contributes the following settings: * `monokle.configurationPath` - Set path to validation configuration file. * `monokle.verbose` - Log runtime info to VSC Developer Console. * `monokle.overwriteRemotePolicyUrl` - Overwrite default Monokle Cloud URL to fetch policies from. +* `monokle.telemetryEnabled` - Enable anonymous telemetry. ## Dependencies diff --git a/package-lock.json b/package-lock.json index f123c3d..10e6335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@monokle/synchronizer": "^0.2.0", "@monokle/validation": "^0.25.2", + "@segment/analytics-node": "^1.1.0", "uuid": "^9.0.0", "yaml": "^2.3.1" }, @@ -795,6 +796,25 @@ "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@monokle/synchronizer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@monokle/synchronizer/-/synchronizer-0.2.0.tgz", @@ -970,6 +990,41 @@ } } }, + "node_modules/@segment/analytics-core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-core/-/analytics-core-1.3.0.tgz", + "integrity": "sha512-ujScWZH49NK1hYlp2/EMw45nOPEh+pmTydAnR6gSkRNucZD4fuinvpPL03rmFCw8ibaMuKLAdgPJfQ0gkLKZ5A==", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "dset": "^3.1.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-core/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/@segment/analytics-node": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-node/-/analytics-node-1.1.0.tgz", + "integrity": "sha512-q8MPpvQJgMUSIRmQXficA33ZmLQ6s5YqUMr623xJhhfp/TGkkgfpcdMk+qSniZVIm8JuQRXm8Mbh922LG3goBQ==", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-core": "1.3.0", + "buffer": "^6.0.3", + "node-fetch": "^2.6.7", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@segment/analytics-node/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -1591,6 +1646,25 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1667,6 +1741,29 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2252,7 +2349,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.2.tgz", "integrity": "sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q==", - "dev": true, "engines": { "node": ">=4" } @@ -3229,6 +3325,25 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", diff --git a/package.json b/package.json index 452cd90..1b7bb0f 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,11 @@ "default": null, "description": "Overwrite Monokle Cloud URL which is used to authenticate and fetch policies from. Useful when running on-premise Monokle Cloud." }, + "monokle.telemetryEnabled": { + "type": "boolean", + "default": true, + "description": "Whenever anonymous telemetry is enabled. It will be also disabled automatically when VSC telemetry is disabled globally." + }, "monokle.enabled": { "type": "boolean", "default": true, @@ -123,7 +128,7 @@ "test": "concurrently -s command-1 -k \"npm run test:server\" \"node ./out/test/run-test.js\"", "test:cc": "concurrently -s command-1 -k \"npm run test:server\" \"c8 node ./out/test/run-test.js\"", "test:server": "node ./out/test/run-server.js", - "test:ensure-coverage": "npx c8 check-coverage --lines 70 --functions 80 --branches 80 --statements 70" + "test:ensure-coverage": "npx c8 check-coverage --lines 70 --functions 80 --branches 75 --statements 70" }, "devDependencies": { "@graphql-tools/mock": "^9.0.0", @@ -152,6 +157,7 @@ "dependencies": { "@monokle/synchronizer": "^0.2.0", "@monokle/validation": "^0.25.2", + "@segment/analytics-node": "^1.1.0", "uuid": "^9.0.0", "yaml": "^2.3.1" }, diff --git a/src/commands/bootstrap-configuration.ts b/src/commands/bootstrap-configuration.ts index 234a65e..af852a7 100644 --- a/src/commands/bootstrap-configuration.ts +++ b/src/commands/bootstrap-configuration.ts @@ -3,6 +3,7 @@ import { canRun } from '../utils/commands'; import { getWorkspaceConfig, getWorkspaceFolders } from '../utils/workspace'; import { createDefaultConfigFile } from '../utils/validation'; import { raiseInfo, raiseWarning } from '../utils/errors'; +import { trackEvent } from '../utils/telemetry'; import logger from '../utils/logger'; import type { Folder } from '../utils/workspace'; @@ -17,6 +18,10 @@ export function getBootstrapConfigurationCommand() { return null; } + trackEvent('command/bootstrap_configuration', { + status: 'started', + }); + const folders = getWorkspaceFolders(); if (!folders.length) { @@ -29,27 +34,46 @@ export function getBootstrapConfigurationCommand() { const currentConfig = await getWorkspaceConfig(folder); if (currentConfig.type === 'file') { raiseInfo(`Local '${currentConfig.fileName}' configuration file already exists, opening it.`); - return currentConfig.path; + return { + path: currentConfig.path, + type: currentConfig.type, + }; } if (currentConfig.type === 'config') { raiseWarning(`Shared '${currentConfig.path}' configuration file already exists, opening it.`); - return currentConfig.path; + return { + path: currentConfig.path, + type: currentConfig.type, + }; } if (currentConfig.type === 'remote') { raiseWarning(`Remote '${currentConfig.fileName}' configuration file already exists, opening it.`); - return currentConfig.path; + return { + path: currentConfig.path, + type: currentConfig.type, + }; } const configPath = (await createDefaultConfigFile(folder.uri.fsPath)).fsPath; - return configPath; + return { + path: configPath, + type: 'default', + }; }; if (folders.length === 1) { - const configPath = await generateConfig(folders[0]); - return await commands.executeCommand('vscode.open', Uri.file(configPath)); + const configData = await generateConfig(folders[0]); + await commands.executeCommand('vscode.open', Uri.file(configData.path)); + + trackEvent('command/bootstrap_configuration', { + status: 'success', + configurationType: configData.type, + }); + + return null; } const quickPick = window.createQuickPick(); @@ -63,13 +87,19 @@ export function getBootstrapConfigurationCommand() { // error } - const configPath = await generateConfig(selectedFolder); + const configData = await generateConfig(selectedFolder); quickPick.hide(); - await commands.executeCommand('vscode.open', Uri.file(configPath)); + await commands.executeCommand('vscode.open', Uri.file(configData.path)); + + trackEvent('command/bootstrap_configuration', { + status: 'success', + configurationType: configData.type, + }); } }); + quickPick.onDidHide(() => quickPick.dispose()); quickPick.show(); diff --git a/src/commands/login.ts b/src/commands/login.ts index 755b7dc..6a6bc42 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -2,6 +2,7 @@ import { window, env, Uri } from 'vscode'; import { canRun } from '../utils/commands'; import { raiseError, raiseInfo } from '../utils/errors'; import { COMMAND_NAMES } from '../constants'; +import { trackEvent } from '../utils/telemetry'; import logger from '../utils/logger'; import type { RuntimeContext } from '../utils/runtime-context'; @@ -11,23 +12,37 @@ const AUTH_METHOD_LABELS = { }; export function getLoginCommand(context: RuntimeContext) { - return async () => { if (!canRun()) { return; } + trackEvent('command/login', { + status: 'started', + }); + const authenticator = context.authenticator; if (authenticator.user.isAuthenticated) { raiseInfo(`You are already logged in. Please logout first with '${COMMAND_NAMES.LOGIN}'.`); + + trackEvent('command/login', { + status: 'cancelled', + error: 'User already logged in.' + }); + return; } const method = await pickLoginMethod(authenticator.methods); if (!method) { - return; + trackEvent('command/login', { + status: 'cancelled', + error: 'No login method selected.' + }); + + return; } try { @@ -57,19 +72,45 @@ export function getLoginCommand(context: RuntimeContext) { prompt: 'You can create account on https://app.monokle.com.', }); + if (!accessToken) { + trackEvent('command/login', { + status: 'cancelled', + method, + error: 'No access token provided.' + }); + + return; + } + loginRequest = await authenticator.login(method, accessToken); } if (!loginRequest) { - return; + trackEvent('command/login', { + status: 'cancelled', + method, + }); + + return; } const user = await loginRequest.onDone; raiseInfo(`You are now logged in as ${user.email}.`); + + trackEvent('command/login', { + status: 'success', + method, + }); } catch (err) { logger.error(err); raiseError(`Failed to login to Monokle Cloud. Please try again. Error: ${err.message}`); + + trackEvent('command/login', { + status: 'failure', + method, + error: err.message, + }); } }; } diff --git a/src/commands/logout.ts b/src/commands/logout.ts index d709b2a..26bcff5 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,6 +1,7 @@ import { canRun } from '../utils/commands'; import { raiseError, raiseInfo } from '../utils/errors'; import { COMMAND_NAMES } from '../constants'; +import { trackEvent } from '../utils/telemetry'; import logger from '../utils/logger'; import type { RuntimeContext } from '../utils/runtime-context'; @@ -10,19 +11,38 @@ export function getLogoutCommand(context: RuntimeContext) { return; } + trackEvent('command/logout', { + status: 'started', + }); + const authenticator = context.authenticator; if (!authenticator.user.isAuthenticated) { raiseInfo(`You are already logged out. You can login with '${COMMAND_NAMES.LOGOUT}' command.`); + + trackEvent('command/logout', { + status: 'cancelled', + error: 'User already logged out.' + }); + return; } try { await authenticator.logout(); raiseInfo('You have been successfully logged out.'); + + trackEvent('command/logout', { + status: 'success', + }); } catch (err) { logger.error(err); raiseError(`Failed to logout from Monokle Cloud. Please try again. Error: ${err.message}`); + + trackEvent('command/logout', { + status: 'failure', + error: err.message, + }); } }; } diff --git a/src/commands/show-configuration.ts b/src/commands/show-configuration.ts index 0bf63db..4db08fd 100644 --- a/src/commands/show-configuration.ts +++ b/src/commands/show-configuration.ts @@ -2,6 +2,7 @@ import { Uri, commands, window } from 'vscode'; import { canRun } from '../utils/commands'; import { getWorkspaceConfig, getWorkspaceFolders } from '../utils/workspace'; import { createTemporaryConfigFile } from '../utils/validation'; +import { trackEvent } from '../utils/telemetry'; import type { Folder } from '../utils/workspace'; type FolderItem = { @@ -15,6 +16,10 @@ export function getShowConfigurationCommand() { return null; } + trackEvent('command/show_configuration', { + status: 'started', + }); + const folders = getWorkspaceFolders(); if (!folders.length) { @@ -27,11 +32,20 @@ export function getShowConfigurationCommand() { Uri.file(config.path) : await createTemporaryConfigFile(config.config, config.owner); - return commands.executeCommand('vscode.open', configUri); + await commands.executeCommand('vscode.open', configUri); + + return config.type; }; if (folders.length === 1) { - return await showConfig(folders[0]); + const configType = await showConfig(folders[0]); + + trackEvent('command/show_configuration', { + status: 'success', + configurationType: configType, + }); + + return; } const quickPick = window.createQuickPick(); @@ -47,7 +61,12 @@ export function getShowConfigurationCommand() { quickPick.hide(); - await showConfig(selectedFolder); + const configType = await showConfig(selectedFolder); + + trackEvent('command/show_configuration', { + status: 'success', + configurationType: configType, + }); } }); quickPick.onDidHide(() => quickPick.dispose()); diff --git a/src/commands/show-panel.ts b/src/commands/show-panel.ts index f8bbb98..a871ca3 100644 --- a/src/commands/show-panel.ts +++ b/src/commands/show-panel.ts @@ -1,5 +1,6 @@ import { commands, extensions } from 'vscode'; import { canRun } from '../utils/commands'; +import { trackEvent } from '../utils/telemetry'; export function getShowPanelCommand() { return async () => { @@ -7,12 +8,20 @@ export function getShowPanelCommand() { return null; } + trackEvent('command/show_panel', { + status: 'started' + }); + const sarifExtension = extensions.getExtension('MS-SarifVSCode.sarif-viewer'); if (!sarifExtension.isActive) { await sarifExtension.activate(); } - return commands.executeCommand('sarif.showPanel'); + await commands.executeCommand('sarif.showPanel'); + + trackEvent('command/show_panel', { + status: 'success' + }); }; -} \ No newline at end of file +} diff --git a/src/commands/synchronize.ts b/src/commands/synchronize.ts index 1d33e30..e9b5d83 100644 --- a/src/commands/synchronize.ts +++ b/src/commands/synchronize.ts @@ -2,6 +2,7 @@ import { commands } from 'vscode'; import { canRun } from '../utils/commands'; import { COMMANDS, COMMAND_NAMES } from '../constants'; import { raiseWarning } from '../utils/errors'; +import { trackEvent } from '../utils/telemetry'; import globals from '../utils/globals'; import type { RuntimeContext } from '../utils/runtime-context'; @@ -11,13 +12,27 @@ export function getSynchronizeCommand(context: RuntimeContext) { return null; } + trackEvent('command/synchronize', { + status: 'started' + }); + if (!globals.user.isAuthenticated) { raiseWarning(`You are not authenticated, cannot synchronize policies. Run ${COMMAND_NAMES.LOGIN} to authenticate first.}`); + + trackEvent('command/synchronize', { + status: 'cancelled', + error: 'User not authenticated.' + }); + return null; } await context.policyPuller.refresh(); + trackEvent('command/synchronize', { + status: 'success' + }); + return commands.executeCommand(COMMANDS.VALIDATE); }; } \ No newline at end of file diff --git a/src/commands/validate.ts b/src/commands/validate.ts index ae6acdc..ce58682 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,6 +1,7 @@ import { validateFolder } from '../utils/validation'; import { getWorkspaceFolders } from '../utils/workspace'; import { canRun } from '../utils/commands'; +import { trackEvent } from '../utils/telemetry'; import type { RuntimeContext } from '../utils/runtime-context'; export function getValidateCommand(context: RuntimeContext) { @@ -9,6 +10,10 @@ export function getValidateCommand(context: RuntimeContext) { return; } + trackEvent('command/validate', { + status: 'started', + }); + context.isValidating = true; const roots = getWorkspaceFolders(); @@ -19,6 +24,11 @@ export function getValidateCommand(context: RuntimeContext) { context.isValidating = false; + trackEvent('command/validate', { + status: 'success', + rootCount: roots.length, + }); + return context.sarifWatcher.replace(resultFiles); }; } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0eea38b --- /dev/null +++ b/src/config.ts @@ -0,0 +1 @@ +export const SEGMENT_API_KEY = ''; diff --git a/src/constants.ts b/src/constants.ts index 17cf186..6179f9a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,10 +13,12 @@ export const SETTINGS = { ENABLED: 'enabled', CONFIGURATION_PATH: 'configurationPath', VERBOSE: 'verbose', + TELEMETRY_ENABLED: 'telemetryEnabled', OVERWRITE_REMOTE_POLICY_URL: 'overwriteRemotePolicyUrl', ENABLED_PATH: 'monokle.enabled', CONFIGURATION_PATH_PATH: 'monokle.configurationPath', VERBOSE_PATH: 'monokle.verbose', + TELEMETRY_ENABLED_PATH: 'monokle.telemetryEnabled', OVERWRITE_REMOTE_POLICY_URL_PATH: 'monokle.overwriteRemotePolicyUrl', }; diff --git a/src/extension.ts b/src/extension.ts index 7fbb1e9..7c38f8a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,7 @@ import { getTooltipContentDefault } from './utils/tooltip'; import { getLogoutCommand } from './commands/logout'; import { getAuthenticator } from './utils/authentication'; import { getSynchronizer } from './utils/synchronization'; +import { trackEvent, initTelemetry, closeTelemetry } from './utils/telemetry'; import logger from './utils/logger'; import globals from './utils/globals'; import type { ExtensionContext } from 'vscode'; @@ -62,30 +63,76 @@ export async function activate(context: ExtensionContext): Promise { const configurationWatcher = workspace.onDidChangeConfiguration(async (event) => { if (event.affectsConfiguration(SETTINGS.ENABLED_PATH)) { const enabled = globals.enabled; + + trackEvent('config/change', { + status: 'success', + name: SETTINGS.ENABLED, + value: String(enabled), + }); + if (enabled) { + await initTelemetry(); await runtimeContext.policyPuller.refresh(); await commands.executeCommand(COMMANDS.VALIDATE); await commands.executeCommand(COMMANDS.WATCH); } else { + await closeTelemetry(); await runtimeContext.dispose(); } } if (event.affectsConfiguration(SETTINGS.CONFIGURATION_PATH_PATH)) { - commands.executeCommand(COMMANDS.VALIDATE); + trackEvent('config/change', { + status: 'success', + name: SETTINGS.CONFIGURATION_PATH, + value: 'redacted', // Can include sensitive data. + }); + + await commands.executeCommand(COMMANDS.VALIDATE); } if (event.affectsConfiguration(SETTINGS.VERBOSE_PATH)) { + trackEvent('config/change', { + status: 'success', + name: SETTINGS.VERBOSE, + value: String(globals.verbose), + }); + logger.debug = globals.verbose; } if (event.affectsConfiguration(SETTINGS.OVERWRITE_REMOTE_POLICY_URL_PATH)) { + trackEvent('config/change', { + status: 'success', + name: SETTINGS.OVERWRITE_REMOTE_POLICY_URL, + value: 'redacted', // Can include sensitive data. + }); + await runtimeContext.policyPuller.refresh(); await commands.executeCommand(COMMANDS.VALIDATE); } + + if (event.affectsConfiguration(SETTINGS.TELEMETRY_ENABLED_PATH)) { + trackEvent('config/change', { + status: 'success', + name: SETTINGS.TELEMETRY_ENABLED, + value: String(globals.telemetryEnabled), + }); + + if (globals.telemetryEnabled) { + await initTelemetry(); + } else { + await closeTelemetry(); + } + } }); const workspaceWatcher = workspace.onDidChangeWorkspaceFolders(async () => { + trackEvent('workspace/change', { + status: 'success', + rootCount: workspace.workspaceFolders?.length ?? 0, + }); + await runtimeContext.policyPuller.refresh(); await commands.executeCommand(COMMANDS.VALIDATE); await commands.executeCommand(COMMANDS.WATCH); @@ -141,6 +188,7 @@ export async function activate(context: ExtensionContext): Promise { return; } + await initTelemetry(); await runtimeContext.policyPuller.refresh(); await commands.executeCommand(COMMANDS.VALIDATE); await commands.executeCommand(COMMANDS.WATCH); @@ -153,6 +201,8 @@ export async function activate(context: ExtensionContext): Promise { export async function deactivate() { logger.log('Deactivating extension...'); + await closeTelemetry(); + if (runtimeContext) { runtimeContext.dispose(); runtimeContext.authenticator.removeAllListeners(); diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 00cb691..4ba1181 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -43,6 +43,10 @@ class Globals { return workspace.getConfiguration(SETTINGS.NAMESPACE).get(SETTINGS.VERBOSE); } + get telemetryEnabled() { + return workspace.getConfiguration(SETTINGS.NAMESPACE).get(SETTINGS.VERBOSE); + } + get user(): Awaited>['user'] { if (!this._authenticator) { throw new Error('Authenticator not initialized for globals.'); diff --git a/src/utils/policy-puller.ts b/src/utils/policy-puller.ts index a297909..a68b506 100644 --- a/src/utils/policy-puller.ts +++ b/src/utils/policy-puller.ts @@ -1,6 +1,7 @@ import { rm } from 'fs/promises'; import { getWorkspaceFolders } from './workspace'; import { getSynchronizer } from './synchronization'; +import { trackEvent } from './telemetry'; import logger from './logger'; import globals from './globals'; import type { Folder } from './workspace'; @@ -65,10 +66,19 @@ export class PolicyPuller { private async fetchPolicyFiles(roots: Folder[]) { for (const folder of roots) { + + trackEvent('policy/synchronize', { + status: 'started', + }); + try { const policy = await this._synchronizer.synchronize(folder.uri.fsPath, globals.user.token); logger.log('fetchPolicyFiles', policy); globals.setFolderStatus(folder); + + trackEvent('policy/synchronize', { + status: 'success', + }); } catch (error) { const errorDetails = this.getErrorDetails(error); @@ -83,6 +93,12 @@ export class PolicyPuller { } globals.setFolderStatus(folder, errorDetails.message); + + trackEvent('policy/synchronize', { + status: 'failure', + errorCode: errorDetails.type, + error: error.msg, + }); } } diff --git a/src/utils/telemetry-client.ts b/src/utils/telemetry-client.ts new file mode 100644 index 0000000..ce56ff0 --- /dev/null +++ b/src/utils/telemetry-client.ts @@ -0,0 +1,30 @@ +import {Analytics} from '@segment/analytics-node'; +import {env} from 'vscode'; +import {SEGMENT_API_KEY} from '../config'; +import logger from './logger'; +import globals from './globals'; + +let client: Analytics | undefined; + +export function getSegmentClient() { + if (!env.isTelemetryEnabled || !globals.telemetryEnabled ) { + return undefined; + } + + if (!client) { + if (SEGMENT_API_KEY) { + logger.log('Enabled Segment'); + client = new Analytics({writeKey: SEGMENT_API_KEY}); + } + } + + return client; +} + +export async function closeSegmentClient() { + if (client) { + logger.log('Close Segment client'); + await client.closeAndFlush(); + client = undefined; + } +} diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts new file mode 100644 index 0000000..c72b193 --- /dev/null +++ b/src/utils/telemetry.ts @@ -0,0 +1,126 @@ +import { platform } from 'node:os'; +import { join, normalize } from 'path'; +import { mkdir, writeFile, readFile } from 'fs/promises'; +import { env } from 'vscode'; +import { closeSegmentClient, getSegmentClient } from './telemetry-client'; +import logger from './logger'; +import globals from './globals'; +import { extensions } from 'vscode'; + +let sessionTimeStart : number; + +// Should be called on extension start or when extension or telemetry gets enabled. +export async function initTelemetry() { + sessionTimeStart = Date.now(); + + const storedMachineId = await readMachineId(); + if (!storedMachineId) { + await saveMachineId(env.machineId); + + const segmentClient = getSegmentClient(); + + logger.log('Identify user', env.machineId); + + segmentClient?.identify({ + userId: env.machineId, + }); + + trackEvent('ext/installed', { + status: 'success', + appVersion: getAppVersion(), + deviceOS: platform(), + }); + } + + trackEvent('ext/session', { + status: 'success', + appVersion: getAppVersion(), + startTimeMs: sessionTimeStart, + }); +} + +// Should be called on extension stop, when extension gets disabled or when telemetry gets disabled. +export async function closeTelemetry() { + const sessionTimeEnd = Date.now(); + trackEvent('ext/session_end', { + status: 'success', + endTimeMs: sessionTimeEnd, + timeSpentSec: Math.ceil((sessionTimeEnd - sessionTimeStart) / 1000), + }); + + sessionTimeStart = 0; + + return closeSegmentClient(); +} + +export function trackEvent(eventName: TEvent, payload?: EventMap[TEvent]) { + const segmentClient = getSegmentClient(); + const eventPayload = { ...payload, sessionId: env.sessionId }; + + if (!segmentClient) { + return; + } + + logger.log('Track event', env.machineId, eventName, eventPayload); + + segmentClient.track({ + event: eventName, + properties: eventPayload, + userId: env.machineId, + }); +} + +export type EventStatus = 'started' | 'success' | 'failure' | 'cancelled'; +export type BaseEvent = {status: EventStatus, error?: string}; + +export type Event = keyof EventMap; +export type EventMap = { + 'ext/installed': BaseEvent & {appVersion: string; deviceOS: NodeJS.Platform}; + 'ext/session': BaseEvent & {appVersion: string, startTimeMs: number}; + 'ext/session_end': BaseEvent & {endTimeMs: number, timeSpentSec: number}; + 'command/login': BaseEvent & {method?: string}; + 'command/logout': BaseEvent; + 'command/validate': BaseEvent & {rootCount?: number}; + 'command/show_panel': BaseEvent; + 'command/show_configuration': BaseEvent & {configurationType?: string}; + 'command/bootstrap_configuration': BaseEvent & {configurationType?: string}; + 'command/synchronize': BaseEvent; + 'config/change': BaseEvent & {name: string; value: string}; + // When new folder is added/removed to/from VSC workspace. + 'workspace/change': BaseEvent & {rootCount: number}; + // When validation is completed for a workspace, usually triggered by file modification. + 'workspace/validate': BaseEvent & { + resourceCount?: number, + configurationType?: string, + isValidConfiguration?: boolean, + validationWarnings?: number, + validationErrors?: number, + }; + // When policy is synced from Monokle Cloud. + 'policy/synchronize': BaseEvent & {errorCode?: string}; +}; + +function getAppVersion(): string { + return extensions.getExtension('kubeshop.monokle')?.packageJSON?.version ?? 'unknown'; +} + +async function readMachineId(): Promise { + try { + const filePath = normalize(join(globals.storagePath, `machine.id`)); + const machineId = await readFile(filePath); + return machineId.toString().trim(); + } catch (e) { + logger.error('Failed to read machine id', e); + return null; + } +} + +async function saveMachineId(machineId: string) { + try { + await mkdir(globals.storagePath, { recursive: true }); + const filePath = normalize(join(globals.storagePath, `machine.id`)); + await writeFile(filePath, machineId); + } catch (e) { + logger.error('Failed to save machine id', e); + } +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index ce9c945..8a7b23b 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -3,9 +3,10 @@ import { join, normalize } from 'path'; import { platform } from 'os'; import { Uri } from 'vscode'; import { Document } from 'yaml'; -import { getWorkspaceConfig, getWorkspaceResources } from './workspace'; +import { getWorkspaceConfig, getWorkspaceResources, WorkspaceFolderConfig } from './workspace'; import { VALIDATION_FILE_SUFFIX, DEFAULT_CONFIG_FILE_NAME, TMP_POLICY_FILE_SUFFIX } from '../constants'; import { getInvalidConfigError } from './errors'; +import { trackEvent } from './telemetry'; import logger from '../utils/logger'; import globals from './globals'; import type { Folder } from './workspace'; @@ -58,9 +59,18 @@ export async function getValidator(validatorId: string, config?: any) { } export async function validateFolder(root: Folder): Promise { + trackEvent('workspace/validate', { + status: 'started', + }); + const resources = await getWorkspaceResources(root); if(!resources.length) { + trackEvent('workspace/validate', { + status: 'cancelled', + resourceCount: 0, + }); + return null; } @@ -71,6 +81,15 @@ export async function validateFolder(root: Folder): Promise { // For remote config, error is already send from policy puller. globals.setFolderStatus(root, getInvalidConfigError(workspaceConfig)); } + + trackEvent('workspace/validate', { + status: 'failure', + resourceCount: resources.length, + configurationType: workspaceConfig.type, + isValidConfiguration: false, + error: 'Invalid configuration', + }); + return null; } @@ -102,7 +121,11 @@ export async function validateFolder(root: Folder): Promise { globals.setFolderStatus(root); - return Uri.file(resultsFilePath); + const resultFilePath = Uri.file(resultsFilePath); + + sendSuccessValidationTelemetry(resources.length, workspaceConfig, result); + + return resultFilePath; } export async function getValidationResult(fileName: string) { @@ -223,6 +246,29 @@ async function getConfigurableValidator() { }; } +function sendSuccessValidationTelemetry(resourceCount: number, workspaceConfig: WorkspaceFolderConfig, validationResult: any) { + const results = validationResult?.runs?.length ? validationResult.runs[0].results : []; + + let errors = 0; + let warnings = 0; + results.forEach(result => { + if (result.level === 'warning') { + warnings++; + } else if (result.level === 'error') { + errors++; + } + }); + + trackEvent('workspace/validate', { + status: 'success', + resourceCount, + configurationType: workspaceConfig.type, + isValidConfiguration: workspaceConfig.isValid, + validationWarnings: warnings, + validationErrors: errors, + }); +} + // For some reason (according to specs? to be checked) SARIF extension doesn't like // valid Windows paths, which are "C:\path\to\file.yaml". It expects them to have // unix like separators, so "C:/path/to/file.yaml".