diff --git a/.gitignore b/.gitignore index 1c8ad757e..41f855bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules .DS_Store test-results.xml connect-form +constants.json +.env diff --git a/.vscode/launch.json b/.vscode/launch.json index 8881ff707..d32afdc76 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -40,14 +40,19 @@ "protocol": "inspector", "port": 6005, "sourceMaps": true, - "outFiles": ["${workspaceFolder}/out/**/*.js"], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], "preLaunchTask": "${defaultBuildTask}" } ], "compounds": [ { "name": "Extension + Server Inspector", - "configurations": ["Run Extension", "Attach to Language Server"] + "configurations": [ + "Run Extension", + "Attach to Language Server" + ] } ] } diff --git a/.vscodeignore b/.vscodeignore index ed3f9d37c..13f58817c 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -7,4 +7,5 @@ vsc-extension-quickstart.md **/tsconfig.json **/tslint.json **/*.map -**/*.ts \ No newline at end of file +**/*.ts +.env diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e1a181034..2e7331718 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -57,6 +57,8 @@ steps: - bash: npm run test displayName: 'Run Tests' + env: + SEGMENT_KEY: $(segmentKey) - bash: ls -alh displayName: 'Post Tests' @@ -72,6 +74,8 @@ steps: npm i -g vsce; vsce package displayName: 'Build .vsix' + env: + SEGMENT_KEY: $(segmentKey) # https://docs.microsoft.com/en-us/azure/devops/pipelines/process/variables?view=azure-devops&tabs=yaml%2Cbatch#set-variables-in-scripts - bash: | diff --git a/package-lock.json b/package-lock.json index d36978349..01b015246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -703,6 +703,15 @@ "fastq": "^1.6.0" } }, + "@segment/loosely-validate-event": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", + "integrity": "sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==", + "requires": { + "component-type": "^1.2.1", + "join-component": "^1.1.0" + } + }, "@sinonjs/commons": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.7.1.tgz", @@ -1339,6 +1348,33 @@ "through2": "^0.6.3" } }, + "analytics-node": { + "version": "3.4.0-beta.1", + "resolved": "https://registry.npmjs.org/analytics-node/-/analytics-node-3.4.0-beta.1.tgz", + "integrity": "sha512-+0F/y4Asc5S2qhWcYss+iCob6TTXQktwbqlIk02gcZaRxpekCbnTbJu/rcaRooVHxqp9WSzUXiWCesJYPJETZQ==", + "requires": { + "@segment/loosely-validate-event": "^2.0.0", + "axios": "^0.18.1", + "axios-retry": "^3.0.2", + "lodash.isstring": "^4.0.1", + "md5": "^2.2.1", + "ms": "^2.0.0", + "remove-trailing-slash": "^0.1.0", + "uuid": "^3.2.1" + }, + "dependencies": { + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, "ansi": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", @@ -1604,6 +1640,23 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz", "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==" }, + "axios": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "requires": { + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + } + }, + "axios-retry": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.1.2.tgz", + "integrity": "sha512-+X0mtJ3S0mmia1kTVi1eA3DAC+oWnT2A29g3CpkzcBPMT6vJm+hn/WiV9wPt/KXLHVmg5zev9mWqkPx7bHMovg==", + "requires": { + "is-retry-allowed": "^1.1.0" + } + }, "azure-devops-node-api": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-7.2.0.tgz", @@ -2106,8 +2159,7 @@ "charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", - "dev": true + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" }, "check-error": { "version": "1.0.2", @@ -2402,6 +2454,11 @@ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "component-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-type/-/component-type-1.2.1.tgz", + "integrity": "sha1-ikeQFwAjjk/DIml3EjAibyS0Fak=" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2637,8 +2694,7 @@ "crypt": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", - "dev": true + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" }, "crypto-browserify": { "version": "3.12.0", @@ -3342,6 +3398,12 @@ "domelementtype": "1" } }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==", + "dev": true + }, "downcache": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/downcache/-/downcache-0.0.9.tgz", @@ -4849,6 +4911,29 @@ "readable-stream": "^2.3.6" } }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -5752,8 +5837,7 @@ "is-buffer": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", - "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==", - "dev": true + "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==" }, "is-callable": { "version": "1.1.5", @@ -5937,8 +6021,7 @@ "is-retry-allowed": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", - "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", - "dev": true + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==" }, "is-stream": { "version": "1.1.0", @@ -6031,6 +6114,11 @@ "is-object": "^1.0.1" } }, + "join-component": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/join-component/-/join-component-1.1.0.tgz", + "integrity": "sha1-uEF7dQZho5K+4sJTfGiyqdSXfNU=" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7375,7 +7463,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", - "dev": true, "requires": { "charenc": "~0.0.1", "crypt": "~0.0.1", @@ -7385,8 +7472,7 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" } } }, @@ -10774,6 +10860,11 @@ "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", "dev": true }, + "remove-trailing-slash": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-slash/-/remove-trailing-slash-0.1.0.tgz", + "integrity": "sha1-FJjl3wmEwn5Jt26/Boh8otARUNI=" + }, "repeat-element": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", diff --git a/package.json b/package.json index e57f3db9b..ca3b5f137 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,12 @@ "compile": "npm-run-all compile:*", "compile:views": "webpack --mode development", "compile:extension": "npm run update-grammar && npm run update-snippets && tsc -p ./", + "compile:keyfile": "ts-node ./scripts/generate-keyfile.ts", "watch": "npm-run-all -p watch:*", "watch:views": "webpack --watch --mode development", "watch:extension": "npm run compile:extension -- -watch", "pretest": "npm run compile && cross-env MONGODB_VERSION=4.2.3 mongodb-runner start --port=27018", - "test": "xvfb-maybe node ./out/test/runTest.js", + "test": "cross-env NODE_OPTIONS=--no-force-async-hooks-checks xvfb-maybe node ./out/test/runTest.js", "posttest": "mongodb-runner stop --port=27018", "vscode:prepublish": "npm run compile", "check": "mongodb-js-precommit './src/**/*{.ts}'", @@ -420,6 +421,11 @@ "default": true, "description": "Show a confirmation message before running commands in a playground." }, + "mdb.sendTelemetry": { + "type": "boolean", + "default": true, + "description": "Allow the sending of diagnostic and usage telemetry data to help improve user experience." + }, "mdb.connectionSaving.hideOptionToChooseWhereToSaveNewConnections": { "type": "boolean", "default": false, @@ -451,6 +457,7 @@ "dependencies": { "@mongosh/browser-runtime-electron": "0.0.1-alpha.10", "@mongosh/service-provider-server": "0.0.1-alpha.10", + "analytics-node": "^3.4.0-beta.1", "bson": "^4.0.3", "debug": "^4.1.1", "mongodb-connection-model": "^14.6.2", @@ -483,6 +490,7 @@ "chai-json-schema": "^1.5.1", "cross-env": "^7.0.2", "css-loader": "^3.4.2", + "dotenv": "^8.2.0", "eslint": "^6.8.0", "eslint-config-mongodb-js": "^5.0.3", "eslint-plugin-react": "^7.19.0", diff --git a/scripts/generate-keyfile.ts b/scripts/generate-keyfile.ts new file mode 100644 index 000000000..0d64fd9e6 --- /dev/null +++ b/scripts/generate-keyfile.ts @@ -0,0 +1,29 @@ +#! /usr/bin/env ts-node + +import ora = require('ora'); +import fs = require('fs'); +import path = require('path'); +import { resolve } from 'path'; +import { config } from 'dotenv'; + +const { promisify } = require('util'); +const writeFile = promisify(fs.writeFile); +const ROOT_DIR = path.join(__dirname, '..'); +const ui = ora('Generate constants keyfile').start(); + +config({ path: resolve(__dirname, '../.env') }); + +(async () => { + if (process.env.SEGMENT_KEY) { + await writeFile( + `${ROOT_DIR}/constants.json`, + JSON.stringify({ segmentKey: process.env.SEGMENT_KEY }, null, 2) + ); + ui.succeed('Generated .constants.json'); + } else { + await Promise.reject(new Error('The Segment key is missing in environment variables')); + } +})().catch((error) => { + ui.fail('Failed to generate constants keyfile'); + console.log(error); +}) diff --git a/scripts/update-grammar.ts b/scripts/update-grammar.ts index e7f89f513..5f0926237 100755 --- a/scripts/update-grammar.ts +++ b/scripts/update-grammar.ts @@ -1,10 +1,10 @@ #! /usr/bin/env ts-node -const path = require('path'); -const download = require('download'); -const ora = require('ora'); -const meow = require('meow'); -const mkdirp = require('mkdirp'); +import path = require('path'); +import mkdirp = require('mkdirp'); +import ora = require('ora'); +import download = require('download'); +import meow = require('meow'); const DEFAULT_DEST = path.join(__dirname, '..', 'syntaxes'); @@ -45,10 +45,11 @@ const cli = meow( const ui = ora() .info('Downlading latest mongodb.tmLanguage.json') .start(); + try { await download(cli.flags.url, cli.flags.dest); ui.succeed( - `Downloaded to ${path.join(cli.flags.dest, 'mongodb.tmLanguage.json')}` + `Downloaded to ${path.join(cli.flags.dest as string, 'mongodb.tmLanguage.json')}` ); } catch (err) { ui.fail(`Download failed: ${err.message}`); diff --git a/scripts/update-snippets.ts b/scripts/update-snippets.ts index 98d7bade7..d1464076c 100755 --- a/scripts/update-snippets.ts +++ b/scripts/update-snippets.ts @@ -1,15 +1,13 @@ #! /usr/bin/env ts-node -const fs = require('fs'); +import path = require('path'); +import mkdirp = require('mkdirp'); +import ora = require('ora'); +import fs = require('fs'); + const { promisify } = require('util'); const writeFile = promisify(fs.writeFile); - -const path = require('path'); -const mkdirp = require('mkdirp'); -const ora = require('ora'); - const { STAGE_OPERATORS } = require('mongodb-ace-autocompleter'); -const config = require(path.join(__dirname, '..', 'package.json')); const SNIPPETS_DIR = path.join(__dirname, '..', 'snippets'); /** @@ -75,9 +73,9 @@ const snippets = STAGE_OPERATORS.reduce( (async () => { const ui = ora('Update snippets').start(); + ui.info(`Create the ${SNIPPETS_DIR} folder`); await mkdirp(SNIPPETS_DIR); - await writeFile( `${SNIPPETS_DIR}/stage-autocompleter.json`, JSON.stringify(snippets, null, 2) diff --git a/src/editors/playgroundController.ts b/src/editors/playgroundController.ts index d6dc61204..fc8d87ccd 100644 --- a/src/editors/playgroundController.ts +++ b/src/editors/playgroundController.ts @@ -1,29 +1,28 @@ import * as vscode from 'vscode'; -import ConnectionController from '../connectionController'; +import ConnectionController, { DataServiceEventTypes } from '../connectionController'; +import TelemetryController, { TelemetryEventTypes } from '../telemetry/telemetryController'; import { ElectronRuntime } from '@mongosh/browser-runtime-electron'; import { CompassServiceProvider } from '@mongosh/service-provider-server'; import ActiveConnectionCodeLensProvider from './activeConnectionCodeLensProvider'; import formatOutput from '../utils/formatOutput'; -import { createLogger } from '../logging'; import { OutputChannel } from 'vscode'; -import { DataServiceEventTypes } from '../connectionController'; import playgroundTemplate from '../templates/playgroundTemplate'; -const log = createLogger('editors controller'); - /** * This controller manages playground. */ export default class PlaygroundController { _context: vscode.ExtensionContext; + _telemetryController?: TelemetryController; _connectionController: ConnectionController; _activeDB?: any; _activeConnectionCodeLensProvider?: ActiveConnectionCodeLensProvider; _outputChannel: OutputChannel; - constructor(context: vscode.ExtensionContext, connectionController: ConnectionController) { + constructor(context: vscode.ExtensionContext, connectionController: ConnectionController, telemetryController?: TelemetryController) { this._context = context; + this._telemetryController = telemetryController; this._connectionController = connectionController; this._outputChannel = vscode.window.createOutputChannel( 'Playground output' @@ -72,6 +71,31 @@ export default class PlaygroundController { }); } + prepareTelemetry(res: any) { + let type = 'other'; + + if (!res.shellApiType) { + return { type }; + } + + const shellApiType = res.shellApiType.toLocaleLowerCase(); + + // See: https://github.com/mongodb-js/mongosh/blob/master/packages/shell-api/src/shell-api.js + if (shellApiType.includes('insert')) { + type = 'insert'; + } else if (shellApiType.includes('update')) { + type = 'update'; + } else if (shellApiType.includes('delete')) { + type = 'delete'; + } else if (shellApiType.includes('aggregation')) { + type = 'aggregation'; + } else if (shellApiType.includes('cursor')) { + type = 'query'; + } + + return { type }; + } + async evaluate(codeToEvaluate: string): Promise { const activeConnection = this._connectionController.getActiveDataService(); @@ -86,9 +110,15 @@ export default class PlaygroundController { ); const runtime = new ElectronRuntime(serviceProvider); const res = await runtime.evaluate(codeToEvaluate); - const value = formatOutput(res); - return Promise.resolve(value); + if (res) { + this._telemetryController?.track( + TelemetryEventTypes.PLAYGROUND_CODE_EXECUTED, + this.prepareTelemetry(res) + ); + } + + return Promise.resolve(formatOutput(res)); } runAllPlaygroundBlocks(): Promise { diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index 6dbac6b29..55994af26 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import ConnectionController from './connectionController'; import { EditorsController, PlaygroundController } from './editors'; import { ExplorerController, CollectionTreeItem } from './explorer'; +import { TelemetryController } from './telemetry'; import { StatusView } from './views'; import { createLogger } from './logging'; import { StorageController } from './storage'; @@ -28,6 +29,7 @@ export default class MDBExtensionController implements vscode.Disposable { _explorerController: ExplorerController; _statusView: StatusView; _storageController: StorageController; + _telemetryController: TelemetryController; constructor( context: vscode.ExtensionContext, @@ -37,6 +39,7 @@ export default class MDBExtensionController implements vscode.Disposable { this._statusView = new StatusView(context); this._storageController = new StorageController(context); + this._telemetryController = new TelemetryController(this._storageController); if (connectionController) { this._connectionController = connectionController; @@ -56,13 +59,15 @@ export default class MDBExtensionController implements vscode.Disposable { ); this._playgroundController = new PlaygroundController( context, - this._connectionController + this._connectionController, + this._telemetryController ); } activate(): void { this._connectionController.loadSavedConnections(); this._explorerController.createTreeView(); + this._telemetryController.activate(); log.info('Registering commands...'); @@ -399,5 +404,6 @@ export default class MDBExtensionController implements vscode.Disposable { this._connectionController.disconnect(); this._explorerController.deactivate(); this._playgroundController.deactivate(); + this._telemetryController.deactivate(); } } diff --git a/src/storage/storageController.ts b/src/storage/storageController.ts index d73480341..a2c233344 100644 --- a/src/storage/storageController.ts +++ b/src/storage/storageController.ts @@ -1,7 +1,9 @@ import * as vscode from 'vscode'; +import { v4 as uuidv4 } from 'uuid'; export enum StorageVariables { GLOBAL_SAVED_CONNECTIONS = 'GLOBAL_SAVED_CONNECTIONS', // Only exists on globalState. + GLOBAL_USER_ID = 'GLOBAL_USER_ID', // Only exists on globalState. WORKSPACE_SAVED_CONNECTIONS = 'WORKSPACE_SAVED_CONNECTIONS' // Only exists on workspaceState. } @@ -58,6 +60,19 @@ export default class StorageController { return Promise.resolve(); } + public getUserID() { + let globalUserId = this.get(StorageVariables.GLOBAL_USER_ID); + + if (globalUserId) { + return globalUserId; + } + + globalUserId = uuidv4(); + this.update(StorageVariables.GLOBAL_USER_ID, globalUserId); + + return globalUserId; + } + public saveConnectionToGlobalStore( connection: SavedConnection ): Thenable { diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts new file mode 100644 index 000000000..b2b9a77a9 --- /dev/null +++ b/src/telemetry/index.ts @@ -0,0 +1,3 @@ +import TelemetryController from './telemetryController'; + +export { TelemetryController }; diff --git a/src/telemetry/telemetryController.ts b/src/telemetry/telemetryController.ts new file mode 100644 index 000000000..6528d9f7b --- /dev/null +++ b/src/telemetry/telemetryController.ts @@ -0,0 +1,89 @@ +import * as vscode from 'vscode'; +import { createLogger } from '../logging'; +import SegmentAnalytics = require('analytics-node'); +import { resolve } from 'path'; +import { config } from 'dotenv'; +import { StorageController } from '../storage'; + +config({ path: resolve(__dirname, '../../.env') }); + +const log = createLogger('analytics'); + +export enum TelemetryEventTypes { + PLAYGROUND_CODE_EXECUTED = 'playground code executed', +} + +/** + * This controller manages telemetry. + */ +export default class TelemetryController { + private _segmentAnalytics: any; + private _segmentUserID: string | undefined; // The user uuid from the global storage. + private _segmentKey: string | undefined; // The segment API write key. + + constructor(storageController: StorageController) { + this._segmentUserID = storageController.getUserID(); + + try { + this._segmentKey = require('../../constants')?.segmentKey; + } catch (error) { + this._segmentKey = process.env.SEGMENT_KEY; + log.error('TELEMETRY file reading', error); + } + } + + get segmentUserID(): string | undefined { + return this._segmentUserID; + } + + get segmentKey(): string | undefined { + return this._segmentKey; + } + + public activate() { + if (this._segmentKey) { + this._segmentAnalytics = new SegmentAnalytics(this._segmentKey, { + // Segment batches messages and flushes asynchronously to the server. + // The flushAt is a number of messages to enqueue before flushing. + // For the development mode we want to flush every submitted message. + // Otherwise, we use 20 that is the default libraries' value. + flushAt: (process.env.MODE === 'development') ? 1 : 20, + // The number of milliseconds to wait + // before flushing the queue automatically. + flushInterval: 10000 // 10 seconds is the default libraries' value. + }); + this._segmentAnalytics.identify({ userId: this._segmentUserID }); + } + } + + public deactivate() { + // Flush on demand to make sure that nothing is left in the queue. + this._segmentAnalytics?.flush(); + } + + public track(eventType: TelemetryEventTypes, properties: object): void { + const shouldSendTelemetry = vscode.workspace + .getConfiguration('mdb') + .get('sendTelemetry'); + + if (shouldSendTelemetry) { + this._segmentAnalytics?.track({ + userId: this._segmentUserID, + event: eventType, + properties + }, (error) => { + if (error) { + log.error(error); + } + + const analytics = [ + `The "${eventType}" metric was sent.`, + `The user: "${this._segmentUserID}."`, + `The props:` + ]; + + log.info(analytics.join(' '), properties); + }); + } + } +} diff --git a/src/test/suite/editors/playgroundController.test.ts b/src/test/suite/editors/playgroundController.test.ts index e73811c21..13b838e4a 100644 --- a/src/test/suite/editors/playgroundController.test.ts +++ b/src/test/suite/editors/playgroundController.test.ts @@ -9,10 +9,10 @@ import { cleanupTestDB } from '../dbTestHelper'; -const chai = require('chai') -const expect = chai.expect +const chai = require('chai'); +const expect = chai.expect; -chai.use(require('chai-as-promised')) +chai.use(require('chai-as-promised')); import { PlaygroundController } from '../../../editors'; @@ -94,6 +94,104 @@ suite('Playground Controller Test Suite', () => { ).then(done, done); }); + test('convert AggregationCursor shellApiType to aggregation telemetry type', () => { + const res = { shellApiType: 'AggregationCursor' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'aggregation' }); + }); + + test('convert BulkWriteResult shellApiType to other telemetry type', () => { + const res = { shellApiType: 'BulkWriteResult' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'other' }); + }); + + test('convert Collection shellApiType to other telemetry type', () => { + const res = { shellApiType: 'Collection' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'other' }); + }); + + test('convert Cursor shellApiType to other telemetry type', () => { + const res = { shellApiType: 'Cursor' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'query' }); + }); + + test('convert Database shellApiType to other telemetry type', () => { + const res = { shellApiType: 'Database' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'other' }); + }); + + test('convert DeleteResult shellApiType to other telemetry type', () => { + const res = { shellApiType: 'DeleteResult' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'delete' }); + }); + + test('convert InsertManyResult shellApiType to other telemetry type', () => { + const res = { shellApiType: 'InsertManyResult' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'insert' }); + }); + + test('convert InsertOneResult shellApiType to other telemetry type', () => { + const res = { shellApiType: 'InsertOneResult' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'insert' }); + }); + + test('convert ReplicaSet shellApiType to other telemetry type', () => { + const res = { shellApiType: 'ReplicaSet' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'other' }); + }); + + test('convert Shard shellApiType to other telemetry type', () => { + const res = { shellApiType: 'Shard' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'other' }); + }); + + test('convert ShellApi shellApiType to other telemetry type', () => { + const res = { shellApiType: 'ShellApi' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'other' }); + }); + + test('convert UpdateResult shellApiType to other telemetry type', () => { + const res = { shellApiType: 'UpdateResult' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'update' }); + }); + + test('convert UpdateResult shellApiType to other telemetry type', () => { + const res = { shellApiType: 'UpdateResult' }; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'update' }); + }); + + test('return other telemetry type if evaluation returns a string', () => { + const res = '2'; + const type = testPlaygroundController.prepareTelemetry(res); + + expect(type).to.deep.equal({ type: 'other' }); + }); + test('create a new playground instance for each run', () => { const mockDocument = { _id: new ObjectId('5e32b4d67bf47f4525f2f777'), diff --git a/src/test/suite/mdbExtensionController.test.ts b/src/test/suite/mdbExtensionController.test.ts index f14fbf227..bbe9e4327 100644 --- a/src/test/suite/mdbExtensionController.test.ts +++ b/src/test/suite/mdbExtensionController.test.ts @@ -1121,7 +1121,7 @@ suite('MDBExtensionController Test Suite', () => { }).then(done, done); }); - test('mdb.createPlayground should create a MongoDB playground without template', (done) => { + test('mdb.createPlayground command should create a MongoDB playground without template', (done) => { const mockOpenTextDocument = sinon.fake.resolves('untitled'); sinon.replace(vscode.workspace, 'openTextDocument', mockOpenTextDocument); diff --git a/src/test/suite/storage/storageController.test.ts b/src/test/suite/storage/storageController.test.ts index e705d2808..3e98c2689 100644 --- a/src/test/suite/storage/storageController.test.ts +++ b/src/test/suite/storage/storageController.test.ts @@ -129,4 +129,30 @@ suite('Storage Controller Test Suite', () => { 'Expected storage scope to be set.' ); }); + + test('getUserID adds user uuid to the global store if it does not exist there', () => { + const testExtensionContext = new TestExtensionContext(); + testExtensionContext._globalState = {}; + const testStorageController = new StorageController(testExtensionContext); + testStorageController.getUserID(); + const userId = testStorageController.get( + StorageVariables.GLOBAL_USER_ID + ); + assert(userId); + }); + + test('getUserID does not update the user id in the global store if it already exist there', () => { + const testExtensionContext = new TestExtensionContext(); + testExtensionContext._globalState = {}; + const testStorageController = new StorageController(testExtensionContext); + testStorageController.getUserID(); + const userId = testStorageController.get( + StorageVariables.GLOBAL_USER_ID + ); + testStorageController.getUserID(); + const userIdAfterSecondCall = testStorageController.get( + StorageVariables.GLOBAL_USER_ID + ); + assert(userId === userIdAfterSecondCall); + }); }); diff --git a/src/test/suite/telemetry/telemetryController.test.ts b/src/test/suite/telemetry/telemetryController.test.ts new file mode 100644 index 000000000..665702ae6 --- /dev/null +++ b/src/test/suite/telemetry/telemetryController.test.ts @@ -0,0 +1,39 @@ +import * as vscode from 'vscode'; +import { StorageController } from '../../../storage'; +import { TestExtensionContext } from '../stubs'; +import { resolve } from 'path'; +import { config } from 'dotenv'; +import { TelemetryController } from '../../../telemetry'; + +const chai = require('chai'); +const expect = chai.expect; + +chai.use(require('chai-as-promised')); +config({ path: resolve(__dirname, '../../../../.env') }); + +suite('Telemetry Controller Test Suite', () => { + vscode.window.showInformationMessage('Starting tests...'); + + const mockExtensionContext = new TestExtensionContext(); + const mockStorageController = new StorageController(mockExtensionContext); + + test('get segment key from constants keyfile', () => { + const testTelemetryController = new TelemetryController(mockStorageController); + let segmentKey: string | undefined; + + try { + segmentKey = require('../../../../constants')?.segmentKey; + } catch (error) { + expect(error).to.be.undefined; + } + + expect(segmentKey).to.be.equal(process.env.SEGMENT_KEY); + expect(testTelemetryController.segmentKey).to.be.a('string'); + }); + + test('get user id from the global storage', () => { + const testTelemetryController = new TelemetryController(mockStorageController); + + expect(testTelemetryController.segmentUserID).to.be.a('string'); + }); +});