diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b65d6376c5..95605c404d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,9 @@ After cloning the project run: `yarn`. After that, run `nps prepare.electron`. Every time you add or remove dependencies in electron/package.json, you will need to rerun `nps prepare.electron`. -After this, run `nps dev.up` to start the dev environment. The application will start the process listening on port 4200. The development is done in the browser, but the server uses electron. +After this, run `nps dev.up` to start the dev environment. The application will start the process listening on port 4200. The development is done in the browser, but the server uses electron. This is the most straight forward way of building new functionality. + +To test the electron app, if you go to `dist/apps/electron` and run `electron .` you can have the app running in the electron environment. ### Running Unit Tests @@ -38,6 +40,24 @@ Cypress, which we use to run e2e tests, records the videos of the tests ran on C ## Building Electron App +Before starting, be sure that you have run `nps prepare.electron` at least once (as previously mentioned). + +Building the electron app requires a code signing certificate to be set. You can read more [here](https://www.electron.build/code-signing). + +On macOS, you can export your root certificate by following these steps: + +- Open "Keychain Access" using spotlight (or whatever) +- In the "Keychains" side panel, choose "logins" +- In the "Category" side panel, choose "My Certificates" +- There should at least be one certificate in the main area. expand it by clicking the triangle to the right +- Right click the key, and choose "Export" +- Choose a location and password to save it in .p12 format + +Once you have a certificate saved, you can use it with: + +- Setting `CSC_LINK` environment variable to the path to the file +- Setting `CSC_KEY_PASSWORD` environment variable to the password set (if any) + You can build the electron app by running `nps package.electronMac` or `nps package.electronWin`. Usually, you only need to do it locally if you change something related to electron-builder. ## Building VSCode Plugin diff --git a/angular.json b/angular.json index d759501188..888b782dba 100644 --- a/angular.json +++ b/angular.json @@ -497,7 +497,17 @@ "server": { "root": "libs/server", "sourceRoot": "libs/server/src", - "projectType": "library" + "projectType": "library", + "architect": { + "test": { + "builder": "@nrwl/jest:jest", + "options": { + "jestConfig": "libs/server/jest.config.js", + "tsConfig": "libs/server/tsconfig.spec.json", + "setupFile": "libs/server/src/test-setup.ts" + } + } + } }, "environment": { "root": "libs/environment", diff --git a/apps/angular-console/src/app/app.module.ts b/apps/angular-console/src/app/app.module.ts index 58a1947056..768ad51c76 100644 --- a/apps/angular-console/src/app/app.module.ts +++ b/apps/angular-console/src/app/app.module.ts @@ -19,7 +19,7 @@ import { Telemetry } from '@angular-console/utils'; import { HttpClientModule } from '@angular/common/http'; -import { Inject, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; import { MatIconModule, MatListModule, @@ -45,23 +45,21 @@ export function initApollo( messenger: Messenger, httpLink: HttpLink ) { - telemetry.setUpRouterLogging(); - const errorLink = onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) { graphQLErrors.forEach(error => { messenger.error(error.message); - telemetry.reportException(error.message); + telemetry.exceptionOccured(error.message); }); } else if (networkError) { const n: any = networkError; messenger.error('Angular Console Server was shutdown'); if (n.error && n.error.errors && n.error.errors.length > 0) { const message = n.error.errors[0].message; - telemetry.reportException(message); + telemetry.exceptionOccured(message); console.error(message); } else { - telemetry.reportException(n.message); + telemetry.exceptionOccured(n.message); console.error(n.message); } } @@ -115,14 +113,10 @@ export function initApollo( ], providers: [ IsNodeJsInstalledGuard, - { - provide: 'telemetry', - useClass: Telemetry - }, { provide: APOLLO_OPTIONS, useFactory: initApollo, - deps: [[new Inject('telemetry')], Messenger, HttpLink] + deps: [Telemetry, Messenger, HttpLink] }, { provide: ENVIRONMENT, useValue: environment as Environment }, { provide: IS_VSCODE, useValue: environment.application === 'vscode' }, diff --git a/apps/electron/src/app/start-server.ts b/apps/electron/src/app/start-server.ts index 7a85bb8ce2..97601cdcc1 100644 --- a/apps/electron/src/app/start-server.ts +++ b/apps/electron/src/app/start-server.ts @@ -43,6 +43,7 @@ export async function startServer( const assetsPath = path.join(__dirname, 'assets/public'); const providers = [ { provide: 'serverAddress', useValue: `http://localhost:${port}` }, + { provide: 'telemetry', useValue: telemetry }, { provide: 'store', useValue: store }, { provide: 'selectDirectory', useValue: selectDirectory }, { @@ -70,7 +71,7 @@ export async function startServer( console.log(`Listening on port ${port}`); }); } catch (e) { - telemetry.reportException(`Start Server: ${e.message}`); + telemetry.exceptionOccured(`Start Server: ${e.message}`); throw e; } } diff --git a/apps/electron/src/environments/environment.prod.ts b/apps/electron/src/environments/environment.prod.ts index 3612073bc3..2fe856af62 100644 --- a/apps/electron/src/environments/environment.prod.ts +++ b/apps/electron/src/environments/environment.prod.ts @@ -1,3 +1,4 @@ export const environment = { - production: true + production: true, + disableTelemetry: false }; diff --git a/apps/electron/src/environments/environment.ts b/apps/electron/src/environments/environment.ts index ee37b495d5..76255c46d5 100644 --- a/apps/electron/src/environments/environment.ts +++ b/apps/electron/src/environments/environment.ts @@ -3,5 +3,6 @@ // The list of file replacements can be found in `angular.json`. export const environment = { - production: false + production: false, + disableTelemetry: true }; diff --git a/apps/electron/src/main.ts b/apps/electron/src/main.ts index 839dd6c025..4e5196684f 100644 --- a/apps/electron/src/main.ts +++ b/apps/electron/src/main.ts @@ -4,8 +4,9 @@ import { storeSettings, Telemetry } from '@angular-console/server'; -import { app, BrowserWindow, dialog, ipcMain, Menu } from 'electron'; +import { app, BrowserWindow, dialog, Menu } from 'electron'; import * as ElectronStore from 'electron-store'; +import { environment } from './environments/environment'; import { autoUpdater } from 'electron-updater'; import { statSync } from 'fs'; import { platform } from 'os'; @@ -13,33 +14,21 @@ import * as path from 'path'; import { startServer } from './app/start-server'; +const start = process.hrtime(); const fixPath = require('fix-path'); const getPort = require('get-port'); const store = new ElectronStore(); -const telemetry = new Telemetry(store); + +const telemetry = environment.disableTelemetry + ? Telemetry.withLogger(store) + : Telemetry.withGoogleAnalytics(store, 'electron'); export let mainWindow: BrowserWindow; fixPath(); const currentDirectory = process.cwd(); -function setupEvents() { - process.env.trackingID = 'UA-88380372-8'; - ipcMain.on('event', (_: any, arg: any) => - telemetry.reportEvent(arg.category, arg.action, arg.label, arg.value) - ); - ipcMain.on('dataCollectionEvent', (_: any, arg: any) => - telemetry.dataCollectionEvent(arg.value) - ); - ipcMain.on('reportPageView', (_: any, arg: any) => - telemetry.reportPageView(arg.path) - ); - ipcMain.on('reportException', (_: any, arg: any) => - telemetry.reportException(arg.description) - ); -} - function createMenu() { let menu = []; const name = app.getName(); @@ -149,7 +138,7 @@ function createWindow() { }); } catch (e) { showCloseDialog(`Error when starting Angular Console: ${e.message}`); - telemetry.reportException(`Start failed: ${e.message}`); + telemetry.exceptionOccured(`Start failed: ${e.message}`); } }); @@ -190,7 +179,6 @@ function showRestartDialog() { dialog.showMessageBox(dialogOptions, i => { if (i === 0) { // Restart - telemetry.reportLifecycleEvent('QuitAndInstall'); autoUpdater.quitAndInstall(); } }); @@ -200,7 +188,7 @@ function checkForUpdates() { setTimeout(async () => { autoUpdater.channel = getUpdateChannel(); autoUpdater.allowDowngrade = false; - if (process.env.NODE_ENV !== 'development') { + if (!environment.production) { try { const r = await autoUpdater.checkForUpdates(); if (r.downloadPromise) { @@ -216,7 +204,7 @@ function checkForUpdates() { console.log('checkForUpdates is called. downloadPromise is null.'); } } catch (e) { - telemetry.reportException(e); + telemetry.exceptionOccured(e.message); } } }, 0); @@ -245,7 +233,7 @@ function saveWindowInfo() { try { store.set('windowBounds', JSON.stringify(mainWindow.getBounds())); } catch (e) { - telemetry.reportException(`Saving window bounds failed: ${e.message}`); + telemetry.exceptionOccured(`Saving window bounds failed: ${e.message}`); } } } @@ -260,10 +248,10 @@ app.on('ready', async () => { } startServer(port, telemetry, store, mainWindow); } else { - setupEvents(); - telemetry.reportLifecycleEvent('StartSession'); createMenu(); createWindow(); checkForUpdates(); + const time = process.hrtime(start); + telemetry.appLoaded(time[0]); } }); diff --git a/apps/electron/src/package.json b/apps/electron/src/package.json index 3dafa820e3..b1a93d7de9 100644 --- a/apps/electron/src/package.json +++ b/apps/electron/src/package.json @@ -1,6 +1,7 @@ { "name": "angular-console", "description": "Angular Console", + "homepage": "https://angularconsole.com", "version": "8.0.0", "author": { "name": "Narwhal Technologies Inc", diff --git a/apps/vscode/src/app/get-store-for-context.ts b/apps/vscode/src/app/get-store-for-context.ts deleted file mode 100644 index 934bbb7655..0000000000 --- a/apps/vscode/src/app/get-store-for-context.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ExtensionContext } from 'vscode'; - -export function getStoreForContext(context: ExtensionContext) { - return { - get: (key: string, defaultValue: any) => - context.globalState.get(key) || defaultValue, - set: (key: string, value: any) => context.globalState.update(key, value), - delete: (key: string) => context.globalState.update(key, undefined) - }; -} diff --git a/apps/vscode/src/app/start-server.ts b/apps/vscode/src/app/start-server.ts index 1a504594dc..e6af93e41c 100644 --- a/apps/vscode/src/app/start-server.ts +++ b/apps/vscode/src/app/start-server.ts @@ -2,15 +2,16 @@ import { createServerModule, QueryResolver, SelectDirectory, + Commands, + Telemetry, PseudoTerminalFactory } from '@angular-console/server'; import { NestFactory } from '@nestjs/core'; import * as path from 'path'; import { commands, ExtensionContext, window } from 'vscode'; - -import { getStoreForContext } from './get-store-for-context'; import { environment } from '../environments/environment'; import { executeTask } from './pseudo-terminal.factory'; +import { VSCodeStorage } from './vscode-storage'; function getPseudoTerminalFactory(): PseudoTerminalFactory { return config => executeTask(config); @@ -23,7 +24,10 @@ export async function startServer( workspacePath?: string ) { const port = await getPort({ port: environment.production ? 8888 : 8889 }); - const store = getStoreForContext(context); + const store = VSCodeStorage.fromContext(context); + const telemetry = environment.disableTelemetry + ? Telemetry.withLogger(store) + : Telemetry.withGoogleAnalytics(store, 'vscode'); const selectDirectory: SelectDirectory = async ({ buttonLabel }) => { return await window @@ -79,15 +83,19 @@ export async function startServer( const assetsPath = path.join(context.extensionPath, 'assets', 'public'); - const queryResolver = new QueryResolver(store); - - // Pre-warm cache for workspace. - if (workspacePath) { - queryResolver.workspace(workspacePath, {}); - } - const providers = [ - { provide: QueryResolver, useValue: queryResolver }, + { + provide: QueryResolver, + useFactory: (commandsController: Commands) => { + const resolver = new QueryResolver(store, commandsController); + if (workspacePath) { + resolver.workspace(workspacePath, {}); + } + return resolver; + }, + inject: ['commands'] + }, + { provide: 'telemetry', useValue: telemetry }, { provide: 'serverAddress', useValue: `http://localhost:${port}` }, { provide: 'store', useValue: store }, { provide: 'selectDirectory', useValue: selectDirectory }, diff --git a/apps/vscode/src/app/vscode-storage.ts b/apps/vscode/src/app/vscode-storage.ts new file mode 100644 index 0000000000..7ef4530608 --- /dev/null +++ b/apps/vscode/src/app/vscode-storage.ts @@ -0,0 +1,42 @@ +import { ExtensionContext } from 'vscode'; +import { Store } from '@nrwl/angular-console-enterprise-electron'; + +export class VSCodeStorage implements Store { + static fromContext(context: ExtensionContext): VSCodeStorage { + const store = new VSCodeStorage(context.globalState); + return store; + } + + constructor(private readonly state: VSCGlobalState) {} + + get(key: string, defaultValue?: T): T | null { + const value = this.state.get(key, defaultValue); + return value || defaultValue || null; + } + + set(key: string, value: T): void { + this.state.update(key, value); + } + + delete(key: string): void { + this.state.update(key, undefined); + } +} + +export interface VSCGlobalState { + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + update(key: string, value: any): void; +} + +export class SubstituteGlobalState implements VSCGlobalState { + state: { [key: string]: any } = {}; + + get(key: string, defaultValue?: T): T { + return this.state[key] || defaultValue; + } + + update(key: string, value: T): void { + this.state[key] = value; + } +} diff --git a/apps/vscode/src/environments/environment.prod.ts b/apps/vscode/src/environments/environment.prod.ts index 3612073bc3..2fe856af62 100644 --- a/apps/vscode/src/environments/environment.prod.ts +++ b/apps/vscode/src/environments/environment.prod.ts @@ -1,3 +1,4 @@ export const environment = { - production: true + production: true, + disableTelemetry: false }; diff --git a/apps/vscode/src/environments/environment.ts b/apps/vscode/src/environments/environment.ts index ee37b495d5..76255c46d5 100644 --- a/apps/vscode/src/environments/environment.ts +++ b/apps/vscode/src/environments/environment.ts @@ -3,5 +3,6 @@ // The list of file replacements can be found in `angular.json`. export const environment = { - production: false + production: false, + disableTelemetry: true }; diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 43a35df81b..2e12762b8f 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -13,8 +13,6 @@ import { window, workspace } from 'vscode'; - -import { getStoreForContext } from './app/get-store-for-context'; import { NgTaskProvider } from './app/ng-task-provider/ng-task-provider'; import { Workspace } from './app/tree-item/workspace'; import { @@ -33,6 +31,7 @@ import { } from './app/tree-view/projects-tree-provider'; import { createWebViewPanel } from './app/webview.factory'; import { registerNgCliCommands } from './app/ng-cli-commands'; +import { VSCodeStorage } from './app/vscode-storage'; let server: Promise; @@ -48,8 +47,9 @@ export function activate(context: ExtensionContext) { currentWorkspaceTreeProvider = CurrentWorkspaceTreeProvider.create({ extensionPath: context.extensionPath }); + const store = VSCodeStorage.fromContext(context); - taskProvider = new NgTaskProvider(new FileUtils(getStoreForContext(context))); + taskProvider = new NgTaskProvider(new FileUtils(store)); tasks.registerTaskProvider('ng', taskProvider); registerNgCliCommands(context, taskProvider); diff --git a/libs/environment/src/index.ts b/libs/environment/src/index.ts index 96c668d72e..d588bbfd16 100644 --- a/libs/environment/src/index.ts +++ b/libs/environment/src/index.ts @@ -1,5 +1,7 @@ import { InjectionToken, Provider } from '@angular/core'; +export type ApplicationPlatform = 'electron' | 'vscode' | 'intellij'; + export const ENVIRONMENT = new InjectionToken('ENVIRONMENT'); export const IS_VSCODE = new InjectionToken('IS_VSCODE'); export const IS_INTELLIJ = new InjectionToken('IS_INTELLIJ'); @@ -8,5 +10,5 @@ export interface Environment { production: boolean; disableAnimations?: boolean; providers: Provider[]; - application: 'electron' | 'vscode' | 'intellij'; + application: ApplicationPlatform; } diff --git a/libs/feature-extensions/src/lib/extensions/extensions.component.ts b/libs/feature-extensions/src/lib/extensions/extensions.component.ts index 584e277587..5ecabfe82c 100644 --- a/libs/feature-extensions/src/lib/extensions/extensions.component.ts +++ b/libs/feature-extensions/src/lib/extensions/extensions.component.ts @@ -1,7 +1,7 @@ import { Extension } from '@angular-console/schema'; import { Task, TaskCollection, TaskCollections } from '@angular-console/ui'; -import { RouterNavigation } from '@angular-console/utils'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterNavigation, Telemetry } from '@angular-console/utils'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { combineLatest, Observable } from 'rxjs'; import { @@ -29,7 +29,7 @@ export interface ExtensionGroup { selector: 'angular-console-extensions', templateUrl: './extensions.component.html' }) -export class ExtensionsComponent { +export class ExtensionsComponent implements OnInit { private readonly extensions$: Observable< Array > = this.route.params.pipe( @@ -99,12 +99,17 @@ export class ExtensionsComponent { ); constructor( + private readonly telemetry: Telemetry, private readonly route: ActivatedRoute, private readonly router: Router, private readonly workspaceAndExtensionsGQL: WorkspaceAndExtensionsGQL, private readonly locationExt: RouterNavigation ) {} + ngOnInit() { + this.telemetry.screenViewed('Extensions'); + } + navigateToSelectedExtension(s: Extension | null) { if (s) { this.router.navigate([encodeURIComponent(s.name)], { diff --git a/libs/feature-generate/src/lib/schematics/schematics.component.ts b/libs/feature-generate/src/lib/schematics/schematics.component.ts index 497dfbc7b2..3c935d1381 100644 --- a/libs/feature-generate/src/lib/schematics/schematics.component.ts +++ b/libs/feature-generate/src/lib/schematics/schematics.component.ts @@ -1,7 +1,7 @@ import { Schematic, SchematicCollection } from '@angular-console/schema'; import { Task, TaskCollection, TaskCollections } from '@angular-console/ui'; -import { RouterNavigation } from '@angular-console/utils'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterNavigation, Telemetry } from '@angular-console/utils'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { combineLatest, Observable } from 'rxjs'; import { @@ -25,7 +25,7 @@ interface SchematicId { selector: 'angular-console-generate', templateUrl: './schematics.component.html' }) -export class SchematicsComponent { +export class SchematicsComponent implements OnInit { private readonly schematicCollections$: Observable< Array > = this.route.params.pipe( @@ -98,12 +98,17 @@ export class SchematicsComponent { ); constructor( + private readonly telemetry: Telemetry, private readonly route: ActivatedRoute, private readonly router: Router, private readonly schematicCollectionsGQL: SchematicCollectionsGQL, private readonly locationExt: RouterNavigation ) {} + ngOnInit() { + this.telemetry.screenViewed('Generate'); + } + navigateToSelectedSchematic(s: Schematic | null) { if (s) { this.router.navigate( diff --git a/libs/feature-install-node-js/src/lib/install-node-js.component.ts b/libs/feature-install-node-js/src/lib/install-node-js.component.ts index e93d6ce5b3..52fd24db74 100644 --- a/libs/feature-install-node-js/src/lib/install-node-js.component.ts +++ b/libs/feature-install-node-js/src/lib/install-node-js.component.ts @@ -1,5 +1,5 @@ -import { Settings } from '@angular-console/utils'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Settings, Telemetry } from '@angular-console/utils'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { filter, first } from 'rxjs/operators'; @@ -11,13 +11,18 @@ import { IsNodejsInstalledGQL } from './generated/graphql'; templateUrl: './install-node-js.component.html', styleUrls: ['./install-node-js.component.scss'] }) -export class InstallNodeJsComponent { +export class InstallNodeJsComponent implements OnInit { constructor( + private readonly telemetry: Telemetry, private readonly router: Router, private readonly settings: Settings, private readonly isNodejsInstalledGQL: IsNodejsInstalledGQL ) {} + ngOnInit() { + this.telemetry.screenViewed('Install Node'); + } + installManually() { this.settings.setInstallManually(true); diff --git a/libs/feature-run/src/lib/targets/targets.component.ts b/libs/feature-run/src/lib/targets/targets.component.ts index d7dfa94cd8..f51be18db4 100644 --- a/libs/feature-run/src/lib/targets/targets.component.ts +++ b/libs/feature-run/src/lib/targets/targets.component.ts @@ -1,7 +1,7 @@ import { NpmScripts, Project } from '@angular-console/schema'; import { Task, TaskCollection, TaskCollections } from '@angular-console/ui'; -import { RouterNavigation } from '@angular-console/utils'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterNavigation, Telemetry } from '@angular-console/utils'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { combineLatest, Observable } from 'rxjs'; import { @@ -24,7 +24,7 @@ interface Target { selector: 'angular-console-targets', templateUrl: './targets.component.html' }) -export class TargetsComponent { +export class TargetsComponent implements OnInit { private readonly projectsAndNpmScripts$: Observable< Array > = this.route.params.pipe( @@ -127,12 +127,17 @@ export class TargetsComponent { ); constructor( + private readonly telemetry: Telemetry, private readonly route: ActivatedRoute, private readonly router: Router, private readonly workspaceAndProjectsGQL: WorkspaceAndProjectsGQL, private readonly locationExt: RouterNavigation ) {} + ngOnInit() { + this.telemetry.screenViewed('Run Targets'); + } + navigateToSelectedTarget(target: Target | null) { if (target && isNpmScript(target)) { this.router.navigate(['script', encodeURIComponent(target.targetName)], { diff --git a/libs/feature-settings/src/lib/settings/settings.component.ts b/libs/feature-settings/src/lib/settings/settings.component.ts index 7ce32027d3..de2987a90d 100644 --- a/libs/feature-settings/src/lib/settings/settings.component.ts +++ b/libs/feature-settings/src/lib/settings/settings.component.ts @@ -1,5 +1,5 @@ import { Settings, Telemetry } from '@angular-console/utils'; -import { Component, Inject, OnDestroy } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { ContextualActionBarService } from '@nrwl/angular-console-enterprise-frontend'; import { Subject } from 'rxjs'; @@ -10,12 +10,12 @@ import { filter, takeUntil } from 'rxjs/operators'; templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'] }) -export class SettingsComponent implements OnDestroy { +export class SettingsComponent implements OnDestroy, OnInit { destroyed$ = new Subject(); constructor( + private readonly telemetry: Telemetry, readonly settings: Settings, - @Inject('telemetry') private readonly telemetry: Telemetry, private readonly contextualActionBarService: ContextualActionBarService, router: Router ) { @@ -34,6 +34,10 @@ export class SettingsComponent implements OnDestroy { }); } + ngOnInit() { + this.telemetry.screenViewed('Settings'); + } + ngOnDestroy() { this.destroyed$.next(); this.destroyed$.complete(); @@ -41,6 +45,5 @@ export class SettingsComponent implements OnDestroy { toggleDataCollection(x: boolean) { this.settings.setCanCollectData(x); - this.telemetry.reportDataCollectionEvent(x); } } diff --git a/libs/feature-workspaces/src/lib/new-workspace/new-workspace.component.ts b/libs/feature-workspaces/src/lib/new-workspace/new-workspace.component.ts index 033d979cca..a44c97092d 100644 --- a/libs/feature-workspaces/src/lib/new-workspace/new-workspace.component.ts +++ b/libs/feature-workspaces/src/lib/new-workspace/new-workspace.component.ts @@ -4,14 +4,16 @@ import { CommandRunner, CommandStatus, IncrementalCommandOutput, - Serializer + Serializer, + Telemetry } from '@angular-console/utils'; import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, - ViewEncapsulation + ViewEncapsulation, + OnInit } from '@angular/core'; import { AbstractControl, @@ -57,7 +59,7 @@ interface SchematicCollectionForNgNew { templateUrl: './new-workspace.component.html', styleUrls: ['./new-workspace.component.scss'] }) -export class NewWorkspaceComponent { +export class NewWorkspaceComponent implements OnInit { @ViewChild(MatVerticalStepper, { static: false }) verticalStepper: MatVerticalStepper; commandOutput$?: Observable; @@ -99,6 +101,7 @@ export class NewWorkspaceComponent { } constructor( + private readonly telemetry: Telemetry, private readonly elementRef: ElementRef, private readonly router: Router, private readonly dialogRef: MatDialogRef, @@ -110,6 +113,10 @@ export class NewWorkspaceComponent { private readonly commandRunner: CommandRunner ) {} + ngOnInit() { + this.telemetry.screenViewed('New Workspace'); + } + handleSelection(event: MatSelectionListChange) { // Workaround for https://github.com/angular/material2/issues/7157 if (event.option.selected) { diff --git a/libs/feature-workspaces/src/lib/projects/projects.component.ts b/libs/feature-workspaces/src/lib/projects/projects.component.ts index 7c88c0406d..339b492f5b 100644 --- a/libs/feature-workspaces/src/lib/projects/projects.component.ts +++ b/libs/feature-workspaces/src/lib/projects/projects.component.ts @@ -3,7 +3,8 @@ import { FADE_IN } from '@angular-console/ui'; import { CommandRunner, Settings, - toggleItemInArray + toggleItemInArray, + Telemetry } from '@angular-console/utils'; import { animate, @@ -200,6 +201,7 @@ export class ProjectsComponent implements OnInit, OnDestroy { private readonly router: Router, private readonly contextActionService: ContextualActionBarService, readonly settings: Settings, + private readonly telemetry: Telemetry, private readonly route: ActivatedRoute, private readonly workspaceGQL: WorkspaceGQL, private readonly workspaceDocsGQL: WorkspaceDocsGQL, @@ -210,6 +212,7 @@ export class ProjectsComponent implements OnInit, OnDestroy { ngOnInit() { // Make collection hot to remove jank on initial render. + this.telemetry.screenViewed('Projects'); this.filteredCollections$.subscribe().unsubscribe(); this.router.events .pipe( diff --git a/libs/feature-workspaces/src/lib/workspace/workspace.component.ts b/libs/feature-workspaces/src/lib/workspace/workspace.component.ts index db0678a0a1..b644b79e6c 100644 --- a/libs/feature-workspaces/src/lib/workspace/workspace.component.ts +++ b/libs/feature-workspaces/src/lib/workspace/workspace.component.ts @@ -1,6 +1,6 @@ import { IS_ELECTRON, IS_INTELLIJ } from '@angular-console/environment'; import { FADE_IN } from '@angular-console/ui'; -import { EditorSupport, Settings } from '@angular-console/utils'; +import { EditorSupport, Settings, Telemetry } from '@angular-console/utils'; import { animate, state, @@ -13,7 +13,8 @@ import { Component, Inject, OnDestroy, - ViewEncapsulation + ViewEncapsulation, + OnInit } from '@angular/core'; import { MediaObserver } from '@angular/flex-layout'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; @@ -70,7 +71,7 @@ const TASK_RUNNER_GHOST_STYLE = style({ ]) ] }) -export class WorkspaceComponent implements OnDestroy { +export class WorkspaceComponent implements OnDestroy, OnInit { readonly activeRouteTitle$: Observable = this.router.events.pipe( filter(event => event instanceof NavigationEnd), map(() => { @@ -198,6 +199,7 @@ export class WorkspaceComponent implements OnDestroy { private readonly route: ActivatedRoute, private readonly router: Router, private readonly settings: Settings, + private readonly telemetry: Telemetry, private readonly mediaObserver: MediaObserver, private readonly contextualActionBarService: ContextualActionBarService, private readonly editorSupport: EditorSupport, @@ -210,4 +212,8 @@ export class WorkspaceComponent implements OnDestroy { this.subscription.unsubscribe(); this.editorSubscription.unsubscribe(); } + + ngOnInit(): void { + this.telemetry.screenViewed('Workspace'); + } } diff --git a/libs/feature-workspaces/src/lib/workspaces/workspaces.component.ts b/libs/feature-workspaces/src/lib/workspaces/workspaces.component.ts index 6d5afec14e..04514acc7b 100644 --- a/libs/feature-workspaces/src/lib/workspaces/workspaces.component.ts +++ b/libs/feature-workspaces/src/lib/workspaces/workspaces.component.ts @@ -1,7 +1,8 @@ import { Settings, SettingsModels, - CommandRunner + CommandRunner, + Telemetry } from '@angular-console/utils'; import { animate, @@ -35,6 +36,7 @@ export class WorkspacesComponent implements OnInit { readonly commands$ = this.commandRunner.listAllCommands().pipe(shareReplay()); constructor( + private readonly telemetry: Telemetry, readonly settings: Settings, readonly workspacesService: WorkspacesService, private readonly contextualActionBarService: ContextualActionBarService, @@ -47,6 +49,7 @@ export class WorkspacesComponent implements OnInit { } ngOnInit() { + this.telemetry.screenViewed('Workspaces'); if (this.settings.getRecentWorkspaces().length === 0) { this.contextualActionBarService.breadcrumbs$.next([ { title: 'Welcome to Angular Console!' } diff --git a/libs/schema/src/lib/generated/graphql-types.ts b/libs/schema/src/lib/generated/graphql-types.ts index 1956bac6d6..bea0428da5 100644 --- a/libs/schema/src/lib/generated/graphql-types.ts +++ b/libs/schema/src/lib/generated/graphql-types.ts @@ -277,6 +277,10 @@ export interface Mutation { restartCommand?: Maybe; + screenViewed?: Maybe; + + exceptionOccured?: Maybe; + openInEditor?: Maybe; updateSettings: Settings; @@ -426,6 +430,12 @@ export interface RemoveCommandMutationArgs { export interface RestartCommandMutationArgs { id: string; } +export interface ScreenViewedMutationArgs { + screen: string; +} +export interface ExceptionOccuredMutationArgs { + error: string; +} export interface OpenInEditorMutationArgs { editor: string; @@ -1482,6 +1492,14 @@ export namespace MutationResolvers { restartCommand?: RestartCommandResolver, TypeParent, TContext>; + screenViewed?: ScreenViewedResolver, TypeParent, TContext>; + + exceptionOccured?: ExceptionOccuredResolver< + Maybe, + TypeParent, + TContext + >; + openInEditor?: OpenInEditorResolver, TypeParent, TContext>; updateSettings?: UpdateSettingsResolver; @@ -1611,6 +1629,24 @@ export namespace MutationResolvers { id: string; } + export type ScreenViewedResolver< + R = Maybe, + Parent = {}, + TContext = any + > = Resolver; + export interface ScreenViewedArgs { + screen: string; + } + + export type ExceptionOccuredResolver< + R = Maybe, + Parent = {}, + TContext = any + > = Resolver; + export interface ExceptionOccuredArgs { + error: string; + } + export type OpenInEditorResolver< R = Maybe, Parent = {}, diff --git a/libs/server/jest.config.js b/libs/server/jest.config.js new file mode 100644 index 0000000000..42d58274fb --- /dev/null +++ b/libs/server/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + name: 'server', + preset: '../../jest.config.js', + coverageDirectory: '../../coverage/libs/server' +}; diff --git a/libs/server/src/assets/angular-console-server-schema.graphql b/libs/server/src/assets/angular-console-server-schema.graphql index 891a7dff29..6edd5214d1 100644 --- a/libs/server/src/assets/angular-console-server-schema.graphql +++ b/libs/server/src/assets/angular-console-server-schema.graphql @@ -115,6 +115,8 @@ type Mutation { removeCommand(id: String!): RemoveResult removeAllCommands: RemoveResult restartCommand(id: String!): RemoveResult + screenViewed(screen: String!): Boolean + exceptionOccured(error: String!): Boolean openInEditor(editor: String!, path: String!): OpenInEditor updateSettings(data: String!): Settings! saveRecentAction( diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index d26fa86307..49a20b89d1 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -10,8 +10,9 @@ export { readSettings, storeSettings } from './lib/api/read-settings'; export { createServerModule } from './lib/server.module'; -export { Telemetry } from './lib/utils/telemetry'; +export { Telemetry } from './lib/telemetry'; +export { Commands } from './lib/api/commands'; -export * from './lib/api/run-command'; +export * from './lib/api/executable'; export { SelectDirectory } from './lib/types'; diff --git a/libs/server/src/lib/api/commands.ts b/libs/server/src/lib/api/commands.ts index 16d5b85a44..fc8cabf690 100644 --- a/libs/server/src/lib/api/commands.ts +++ b/libs/server/src/lib/api/commands.ts @@ -1,10 +1,11 @@ import { DetailedStatusCalculator } from './detailed-status-calculator'; -import { PseudoTerminal } from './run-command'; +import { PseudoTerminal } from './executable'; +import { Injectable } from '@nestjs/common'; export interface CommandInformation { id: string; type: string; - workspace: string; + workspace: string | null; command: string; status: string; // TOOD: vsavkin should convert status into an enum outChunk: string; @@ -14,6 +15,7 @@ export interface CommandInformation { detailedStatusCalculator: DetailedStatusCalculator; } +@Injectable() export class Commands { recent = [] as CommandInformation[]; history = [] as CommandInformation[]; @@ -26,7 +28,7 @@ export class Commands { addCommand( type: string, id: string, - workspace: string, + workspace: string | null, command: string, factory: any, detailedStatusCalculator: DetailedStatusCalculator, diff --git a/libs/server/src/lib/api/executable.ts b/libs/server/src/lib/api/executable.ts new file mode 100644 index 0000000000..ee046042b7 --- /dev/null +++ b/libs/server/src/lib/api/executable.ts @@ -0,0 +1,163 @@ +import { FileUtils } from '../utils/file-utils'; +import { readJsonFile } from '../utils/utils'; +import { Commands } from './commands'; +import { createDetailedStatusCalculator } from './detailed-status-calculator'; +import { Telemetry } from '../telemetry'; + +// todo(matt) should be handled by Commands +let commandRunIndex = 0; + +export interface RunResult { + id: string; +} + +export enum ExecutableType { + add = 'add', + new = 'new', + generate = 'generate', + npm = 'npm', + ng = 'ng' +} + +// TODO(matt): next step of this refactoring is 1 level of inheritance to +// handle differences in name / path behaviour for different executables +export class Executable { + private readonly isNvm: boolean; + + private _path = ''; + private _name: string; + + get name() { + return this.isNvm ? 'nvm' : this._name; + } + + set name(name: string) { + this._name = name; + } + + get path() { + return this.isNvm ? `nvm exec ${this._path}` : this._path; + } + + set path(path: string) { + this._path = path; + } + + hasPath(): boolean { + return this._path !== ''; + } + + constructor( + name: string, + private readonly telemetry: Telemetry, + private readonly buildTerminal: PseudoTerminalFactory, + private readonly fileUtils: FileUtils, + private readonly commands: Commands + ) { + this._name = name; + this.isNvm = fileUtils.supportsNvm() && fileUtils.useNvm(); + } + + run( + type: ExecutableType, + cwd: string, + cmds: string[], + addToRecent: boolean = true + ): RunResult { + if (!this.hasPath()) { + throw new Error('setPath of this executable before running'); + } + + const workspace = workspaceName(type, cwd); + const statusCalculator = createDetailedStatusCalculator(cwd, cmds); + + const id = `${this.name} ${cmds.join(' ')} ${commandRunIndex++}`; + let command = `${this.path} ${cmds.join(' ')}`; + + // We currently don't support the windows implementation of NVM. + if (this.isNvm) { + command = `nvm exec ${command}`; + cmds = ['exec', this.path, ...cmds]; + } + + const factory = () => { + const start = process.hrtime(); + + const commandRunning = this.buildTerminal({ + displayCommand: command, + name: id, + program: this.path, + args: cmds, + cwd, + isDryRun: false, + isWsl: this.fileUtils.isWsl() + }); + + if (commandRunning.onDidWriteData) { + commandRunning.onDidWriteData(data => { + this.commands.addOut(id, data); + }); + } + + commandRunning.onExit(code => { + const seconds = process.hrtime(start)[0]; + // don't record dry runs + if (addToRecent) { + this.telemetry.commandRun(type, seconds); + } + this.commands.setFinalStatus(id, code === 0 ? 'successful' : 'failed'); + }); + + return commandRunning; + }; + + this.commands.addCommand( + type, + id, + workspace, + command, + factory, + statusCalculator, + addToRecent + ); + this.commands.startCommand(id); + + return { id }; + } +} + +function workspaceName(type: ExecutableType, cwd: string): string | null { + let name = null; + + if (type !== ExecutableType.new) { + const json = readJsonFile('./package.json', cwd).json; + name = json.name; + } + + return name; +} + +export interface PseudoTerminal { + onDidWriteData?(callback: (data: string) => void): void; + + onExit(callback: (code: number) => void): void; + + setCols?(cols: number): void; + + kill(): void; +} + +export interface PseudoTerminalConfig { + /** Human-readable string which will be used to represent the terminal in the UI. */ + name: string; + program: string; + args: string[]; + isDryRun: boolean; + cwd: string; + displayCommand: string; + isWsl: boolean; +} + +export type PseudoTerminalFactory = ( + config: PseudoTerminalConfig +) => PseudoTerminal; diff --git a/libs/server/src/lib/resolvers/mutation.resolver.ts b/libs/server/src/lib/resolvers/mutation.resolver.ts index c088285807..855226ce52 100644 --- a/libs/server/src/lib/resolvers/mutation.resolver.ts +++ b/libs/server/src/lib/resolvers/mutation.resolver.ts @@ -1,8 +1,5 @@ import { Inject } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; -import { platform } from 'os'; -import * as path from 'path'; - import { docs } from '../api/docs'; import { Editor, openInEditor } from '../api/read-editors'; import { @@ -10,36 +7,66 @@ import { storeTriggeredAction } from '../api/read-recent-actions'; import { readSettings, storeSettings } from '../api/read-settings'; -import { commands, runCommand } from '../api/run-command'; +import { Executable, ExecutableType as Type } from '../api/executable'; import { SelectDirectory } from '../types'; import { FileUtils } from '../utils/file-utils'; +import { Telemetry } from '../telemetry'; +import { Commands } from '../api/commands'; @Resolver() export class MutationResolver { + private readonly ng: Executable; + private readonly npm: Executable; + private readonly newWorkspace: Executable; + constructor( @Inject('store') private readonly store: any, @Inject('pseudoTerminalFactory') - private readonly pseudoTerminalFactory: any, + pseudoTerminalFactory: any, @Inject('selectDirectory') private readonly selectDirectoryImpl: SelectDirectory, - private readonly fileUtils: FileUtils - ) {} + @Inject('commands') + private readonly commands: Commands, + private readonly fileUtils: FileUtils, + @Inject('telemetry') + private readonly telemetry: Telemetry + ) { + // TODO(matt) this should all be moved into DI + this.ng = new Executable( + 'ng', + telemetry, + pseudoTerminalFactory, + fileUtils, + commands + ); + + this.npm = new Executable( + 'npm', + telemetry, + pseudoTerminalFactory, + fileUtils, + commands + ); + + this.newWorkspace = new Executable( + 'new-workspace', + telemetry, + pseudoTerminalFactory, + fileUtils, + commands + ); + + this.configureNewWorkspace(); + } @Mutation() async ngAdd(@Args('path') p: string, @Args('name') name: string) { try { - return runCommand( - 'add', - p, - 'ng', - this.fileUtils.findClosestNg(p), - ['add', name, '--no-interactive'], - this.pseudoTerminalFactory, - this.fileUtils - ); + this.telemetry.featureUsed('NG Add'); + this.configureNg(p); + return this.ng.run(Type.add, p, ['add', name, '--no-interactive']); } catch (e) { - console.error(e); - throw new Error(`Error when running 'ng add'. Message: "${e.message}"`); + this.handleError("running 'ng add'", e); } } @@ -51,30 +78,16 @@ export class MutationResolver { @Args('newCommand') newCommand: string[] ) { try { - return runCommand( - 'new', - p, - 'new-workspace', - path.join( - __dirname, - 'assets', - platform() === 'win32' && !this.fileUtils.isWsl() - ? 'new-workspace.cmd' - : 'new-workspace' - ), - [ - name, - `--directory=${name}`, - `--collection=${collection}`, - ...newCommand, - '--no-interactive' - ], - this.pseudoTerminalFactory, - this.fileUtils - ); + this.telemetry.featureUsed('New Workspace'); + return this.newWorkspace.run(Type.new, p, [ + name, + `--directory=${name}`, + `--collection=${collection}`, + ...newCommand, + '--no-interactive' + ]); } catch (e) { - console.error(e); - throw new Error(`Error when running 'ng new'. Message: "${e.message}"`); + this.handleError("running 'ng new'", e); } } @@ -85,22 +98,17 @@ export class MutationResolver { @Args('genCommand') genCommand: string[] ) { try { + this.telemetry.featureUsed('Generate'); const dryRun = dr ? ['--dry-run'] : []; - return runCommand( - 'generate', + this.configureNg(p); + return this.ng.run( + Type.generate, p, - 'ng', - this.fileUtils.findClosestNg(p), ['generate', ...genCommand, ...dryRun, '--no-interactive'], - this.pseudoTerminalFactory, - this.fileUtils, !dr ); } catch (e) { - console.error(e); - throw new Error( - `Error when running 'ng generate'. Message: "${e.message}"` - ); + this.handleError("running 'ng generate'", e); } } @@ -112,38 +120,29 @@ export class MutationResolver { @Args('genCommand') genCommand: string[] ) { try { + this.telemetry.featureUsed('Generate With NPM'); const dryRun = dr ? ['--dry-run'] : []; - return runCommand( - 'npm', + + this.configureNpmClient(npmClient, p); + return this.npm.run( + Type.npm, p, - npmClient, - this.fileUtils.findExecutable(npmClient, p), [...genCommand, ...dryRun, '--no-interactive'], - this.pseudoTerminalFactory, - this.fileUtils, !dr ); } catch (e) { - console.error(e); - throw new Error(`Error when running npm script. Message: "${e.message}"`); + this.handleError('running npm script', e); } } @Mutation() async runNg(@Args('path') p: string, @Args('runCommand') rc: string[]) { try { - return runCommand( - 'ng', - p, - 'ng', - this.fileUtils.findClosestNg(p), - rc, - this.pseudoTerminalFactory, - this.fileUtils - ); + this.telemetry.featureUsed('Run Custom NG Command'); + this.configureNg(p); + this.ng.run(Type.ng, p, rc); } catch (e) { - console.error(e); - throw new Error(`Error when running 'ng ...'. Message: "${e.message}"`); + this.handleError("running 'ng ...'", e); } } @@ -154,39 +153,35 @@ export class MutationResolver { @Args('npmClient') npmClient: string ) { try { - return runCommand( - 'npm', - p, - npmClient, - this.fileUtils.findExecutable(npmClient, p), - rc, - this.pseudoTerminalFactory, - this.fileUtils - ); + this.telemetry.featureUsed('Run Custom NPM Command'); + this.configureNpmClient(npmClient, p); + this.npm.run(Type.npm, p, rc); } catch (e) { - console.error(e); - throw new Error(`Error when running npm script. Message:"${e.message}"`); + this.handleError('running npm script', e); } } @Mutation() async stopCommand(@Args('id') id: string) { try { - const c = commands.findMatchingCommand(id, commands.recent); + this.telemetry.featureUsed('Stop Command'); + const c = this.commands.findMatchingCommand(id, this.commands.recent); + let result = false; + if (c) { - commands.stopCommands([c]); - return { result: true }; - } else { - return { result: false }; + this.commands.stopCommands([c]); + result = true; } + + return { result }; } catch (e) { - console.error(e); - throw new Error(`Error when stopping commands. Message: "${e.message}"`); + this.handleError('stopping commands', e); } } @Mutation() async openInBrowser(@Args('url') url: string) { + this.telemetry.featureUsed('Open In Browser'); if (url) { const opn = require('opn'); opn(url); @@ -198,6 +193,7 @@ export class MutationResolver { @Mutation() async showItemInFolder(@Args('item') item: string) { + this.telemetry.featureUsed('Show Item In Folder'); if (item) { const opn = require('opn'); opn(item).catch((err: any) => console.error(err)); @@ -210,46 +206,54 @@ export class MutationResolver { @Mutation() async removeCommand(@Args('id') id: string) { try { - commands.removeCommand(id); + this.telemetry.featureUsed('Remove Command'); + this.commands.removeCommand(id); return { result: true }; } catch (e) { - console.error(e); - throw new Error(`Error when removing commands. Message: "${e.message}"`); + this.handleError('removing commands', e); } } + @Mutation() + async exceptionOccured(@Args('error') error: string) { + this.telemetry.exceptionOccured(error); + } + + @Mutation() + async screenViewed(@Args('screen') screen: string) { + this.telemetry.screenViewed(screen); + } + @Mutation() async removeAllCommands() { try { - commands.removeAllCommands(); + this.telemetry.featureUsed('Remove All Commands'); + this.commands.removeAllCommands(); return { result: true }; } catch (e) { - console.error(e); - throw new Error(`Error when removing commands. Message: "${e.message}"`); + this.handleError('removing all commands', e); } } @Mutation() async restartCommand(@Args('id') id: string) { try { - commands.restartCommand(id); + this.telemetry.featureUsed('Restart Commands'); + this.commands.restartCommand(id); return { result: true }; } catch (e) { - console.error(e); - throw new Error( - `Error when restarting commands. Message: "${e.message}"` - ); + this.handleError('restarting commands', e); } } @Mutation() openInEditor(@Args('editor') editor: Editor, @Args('path') p: string) { try { + this.telemetry.featureUsed('Open in Editor'); openInEditor(editor, p); return { response: 'successful' }; } catch (e) { - console.error(e); - throw new Error(`Error when opening an editor. Message: "${e.message}"`); + this.handleError('opening an editor', e); } } @@ -277,7 +281,16 @@ export class MutationResolver { @Mutation() updateSettings(@Args('data') data: string) { - storeSettings(this.store, JSON.parse(data)); + this.telemetry.featureUsed('Settings Update'); + const changes = JSON.parse(data); + storeSettings(this.store, changes); + + if (changes.hasOwnProperty('canCollectData')) { + changes.canCollectData + ? this.telemetry.startedTracking() + : this.telemetry.stoppedTracking(); + } + return readSettings(this.store); } @@ -298,4 +311,26 @@ export class MutationResolver { const result = await docs.openDoc(id).toPromise(); return { result }; } + + configureNg(cwd: string): void { + const path = this.fileUtils.findClosestNg(cwd); + this.ng.path = path; + } + + configureNpmClient(name: string, cwd: string): void { + const path = this.fileUtils.findExecutable(name, cwd); + this.npm.name = name; + this.npm.path = path; + } + + configureNewWorkspace() { + this.newWorkspace.path = this.fileUtils.newWorkspacePath(); + } + + handleError(action: string, err: Error): void { + const msg = `Error when ${action}. Message: "${err.message}"`; + console.error(msg); + this.telemetry.exceptionOccured(msg); + throw new Error(msg); + } } diff --git a/libs/server/src/lib/resolvers/query.resolver.ts b/libs/server/src/lib/resolvers/query.resolver.ts index 1964f07cd6..b46c924eef 100644 --- a/libs/server/src/lib/resolvers/query.resolver.ts +++ b/libs/server/src/lib/resolvers/query.resolver.ts @@ -8,17 +8,7 @@ import { Workspace } from '@angular-console/schema'; import { Inject } from '@nestjs/common'; -import { Args, Context, Query, Resolver } from '@nestjs/graphql'; - -import { CommandInformation } from '../api/commands'; -import { readDependencies } from '../api/read-dependencies'; -import { readEditors } from '../api/read-editors'; -import { availableExtensions, readExtensions } from '../api/read-extensions'; import { schematicCollectionsForNgNew } from '../api/read-ngnews'; -import { readNpmScripts } from '../api/read-npm-scripts'; -import { readProjects } from '../api/read-projects'; -import { readSettings } from '../api/read-settings'; -import { commands } from '../api/run-command'; import { cacheFiles, exists, @@ -26,10 +16,21 @@ import { filterByName, readJsonFile } from '../utils/utils'; +import { readDependencies } from '../api/read-dependencies'; +import { availableExtensions, readExtensions } from '../api/read-extensions'; +import { readProjects } from '../api/read-projects'; +import { readNpmScripts } from '../api/read-npm-scripts'; +import { readEditors } from '../api/read-editors'; +import { Args, Context, Query, Resolver } from '@nestjs/graphql'; +import { CommandInformation, Commands } from '../api/commands'; +import { readSettings } from '../api/read-settings'; @Resolver() export class QueryResolver { - constructor(@Inject('store') private readonly store: any) {} + constructor( + @Inject('store') private readonly store: any, + @Inject('commands') private readonly commandsController: Commands + ) {} @Query() settings(): Settings { @@ -111,7 +112,10 @@ export class QueryResolver { const settings = readSettings(this.store); const includeDetailedStatus = settings.enableDetailedStatus || false; if (id) { - const c = commands.findMatchingCommand(id, commands.history); + const c = this.commandsController.findMatchingCommand( + id, + this.commandsController.history + ); if (!c) return []; const r = serializeIndividualCommand( c, @@ -121,7 +125,7 @@ export class QueryResolver { c.outChunk = ''; return [r as any]; } else { - return commands.recent.map(serializeCommandInList); + return this.commandsController.recent.map(serializeCommandInList); } } catch (e) { console.error(e); diff --git a/libs/server/src/lib/server.module.ts b/libs/server/src/lib/server.module.ts index 68e0c1d515..49a4decce7 100644 --- a/libs/server/src/lib/server.module.ts +++ b/libs/server/src/lib/server.module.ts @@ -22,11 +22,13 @@ import { WorkspaceResolver } from './resolvers/workspace.resolver'; import { ArchitectResolver } from './resolvers/architect.resolver'; import { AngularConsoleExtensionsModule } from '@nrwl/angular-console-enterprise-electron'; import { readSettings } from './api/read-settings'; -import { commands } from './api/run-command'; import { Telemetry } from './utils/telemetry'; import { docs } from './api/docs'; import { FileUtils } from './utils/file-utils'; import { APP_FILTER } from '@nestjs/core'; +import { Commands } from './api/commands'; + +const commands = new Commands(5, 15); export function createServerModule( exports: string[], diff --git a/libs/server/src/lib/telemetry/index.ts b/libs/server/src/lib/telemetry/index.ts new file mode 100644 index 0000000000..2e9d35da7f --- /dev/null +++ b/libs/server/src/lib/telemetry/index.ts @@ -0,0 +1 @@ +export * from './telemetry'; diff --git a/libs/server/src/lib/telemetry/message-builder.ts b/libs/server/src/lib/telemetry/message-builder.ts new file mode 100644 index 0000000000..1840e89d8b --- /dev/null +++ b/libs/server/src/lib/telemetry/message-builder.ts @@ -0,0 +1,11 @@ +export interface TelemetryMessageBuilder { + appLoaded(time: number): void; + loggedIn(): void; + loggedOut(): void; + startedTracking(): void; + stoppedTracking(): void; + screenViewed(screen: string): void; + commandRun(commandType: string, time: number): void; + exceptionOccured(error: string): void; + featureUsed(feature: string): void; +} diff --git a/libs/server/src/lib/telemetry/record.ts b/libs/server/src/lib/telemetry/record.ts new file mode 100644 index 0000000000..d3ae20f20d --- /dev/null +++ b/libs/server/src/lib/telemetry/record.ts @@ -0,0 +1,26 @@ +export type TelemetryType = + | 'AppLoaded' + | 'LoggedIn' + | 'LoggedOut' + | 'StoppedTracking' + | 'StartedTracking' + | 'ScreenViewed' + | 'CommandRun' + | 'ExceptionOccurred' + | 'FeatureUsed' + | 'UserStateChanged'; + +export function isTelemetryRecord(evt: string): evt is TelemetryType { + return new Set([ + 'AppLoaded', + 'LoggedIn', + 'LoggedOut', + 'StoppedTracking', + 'StartedTracking', + 'ScreenViewed', + 'CommandRun', + 'ExceptionOccurred', + 'FeatureUsed', + 'UserStateChanged' + ]).has(evt); +} diff --git a/libs/server/src/lib/telemetry/sink.ts b/libs/server/src/lib/telemetry/sink.ts new file mode 100644 index 0000000000..74e6481424 --- /dev/null +++ b/libs/server/src/lib/telemetry/sink.ts @@ -0,0 +1,5 @@ +import { TelemetryType } from './record'; + +export interface Sink { + record(type: TelemetryType, data: any): void; +} diff --git a/libs/server/src/lib/telemetry/sinks/google-analytics-sink.ts b/libs/server/src/lib/telemetry/sinks/google-analytics-sink.ts new file mode 100644 index 0000000000..da0e662883 --- /dev/null +++ b/libs/server/src/lib/telemetry/sinks/google-analytics-sink.ts @@ -0,0 +1,175 @@ +import { Sink } from '../sink'; +import { TelemetryType } from '../record'; +import { UserState } from '../user'; +import { ApplicationPlatform } from '@angular-console/environment'; +import { TelemetryMessageBuilder } from '../message-builder'; + +// increment this if there is substancial changes to the schema, +// and you want to create a new view that only has this data +const ANALYTICS_VERSION = 2; +const TRACKING_ID = 'UA-88380372-8'; + +class TelemetryParams { + constructor(readonly type: string, readonly data: any) {} + + fetch(key: string): any { + this.require(key); + return this.data[key]; + } + + require(key: string) { + const msg = `Telemetry: ${this.type} is missing ${key}`; + if (!this.data.hasOwnProperty(key)) { + throw new Error(msg); + } + } +} + +export class GoogleAnalyticsSink implements Sink, TelemetryMessageBuilder { + visitor = require('universal-analytics')(TRACKING_ID, { + uid: this.userId + }); + + get enabled() { + return this.state !== 'untracked'; + } + + constructor( + readonly userId: string, + readonly platform: ApplicationPlatform, + public state: UserState + ) { + this.setPersistentParams(); + } + + setPersistentParams() { + this.visitor.set('uid', this.userId); + this.visitor.set('ds', 'app'); + this.visitor.set('cd1', this.state); + this.visitor.set('cd2', this.platform); + this.visitor.set('cd3', ANALYTICS_VERSION); + } + + record(type: TelemetryType, data: any): void { + if (!this.enabled) return; + const params = new TelemetryParams(type, data); + + switch (type) { + case 'UserStateChanged': + this.state = params.fetch('state'); + this.setPersistentParams(); + break; + case 'AppLoaded': + this.appLoaded(params.fetch('time')); + break; + case 'LoggedIn': + this.loggedIn(); + break; + case 'LoggedOut': + this.loggedOut(); + break; + case 'StartedTracking': + this.startedTracking(); + break; + case 'StoppedTracking': + this.stoppedTracking(); + break; + case 'ScreenViewed': + this.screenViewed(params.fetch('screen')); + break; + case 'CommandRun': + this.commandRun(params.fetch('commandType'), params.fetch('time')); + break; + case 'ExceptionOccurred': + this.exceptionOccured(params.fetch('error')); + break; + case 'FeatureUsed': + this.featureUsed(params.fetch('feature')); + break; + default: + throw new Error(`Unknown Telemetry type: ${type}`); + } + } + + appLoaded(time: number): void { + this.visitor + .timing({ + utc: 'Application', + utv: 'Load Time', + utt: time + }) + .send(); + } + + loggedIn(): void { + this.visitor + .event({ + ec: 'NRWL Connect', + ea: 'Connected' + }) + .send(); + } + + loggedOut(): void { + this.visitor + .event({ + ec: 'NRWL Connect', + ea: 'Disconnected' + }) + .send(); + } + + startedTracking(): void { + this.visitor + .event({ + ec: 'Data Collection', + ea: 'Opt In' + }) + .send(); + } + + stoppedTracking(): void { + this.visitor + .event({ + ec: 'Data Collection', + ea: 'Opt Out' + }) + .send(); + } + + screenViewed(screen: string): void { + this.visitor + .screenview({ + an: 'Angular Console', + cd: screen + }) + .send(); + } + + commandRun(commandType: string, time: number): void { + this.visitor + .timing({ + utc: 'Command', + utv: commandType, + utt: time + }) + .send(); + } + + exceptionOccured(error: string) { + this.visitor + .exception({ + exd: error + }) + .send(); + } + + featureUsed(feature: string) { + this.visitor + .event({ + ec: 'Feature', + ea: feature + }) + .send(); + } +} diff --git a/libs/server/src/lib/telemetry/sinks/index.ts b/libs/server/src/lib/telemetry/sinks/index.ts new file mode 100644 index 0000000000..c048963e68 --- /dev/null +++ b/libs/server/src/lib/telemetry/sinks/index.ts @@ -0,0 +1,3 @@ +export * from './memory-sink'; +export * from './logger-sink'; +export * from './google-analytics-sink'; diff --git a/libs/server/src/lib/telemetry/sinks/logger-sink.spec.ts b/libs/server/src/lib/telemetry/sinks/logger-sink.spec.ts new file mode 100644 index 0000000000..0e8d2029bb --- /dev/null +++ b/libs/server/src/lib/telemetry/sinks/logger-sink.spec.ts @@ -0,0 +1,23 @@ +import { LoggerSink, LogWriter, header } from './logger-sink'; +import { TelemetryType } from '../record'; + +describe('Telemetry: Logger Sink', () => { + const type: TelemetryType = 'CommandRun'; + const data = 'data'; + + let sink: LoggerSink; + let writer: LogWriter; + + beforeEach(() => { + writer = { log: jest.fn() }; + sink = new LoggerSink(writer); + }); + + it('logs records with formatted header', () => { + const expected = header(type); + + sink.record(type, data); + + expect(writer.log).toHaveBeenCalledWith(expected, data); + }); +}); diff --git a/libs/server/src/lib/telemetry/sinks/logger-sink.ts b/libs/server/src/lib/telemetry/sinks/logger-sink.ts new file mode 100644 index 0000000000..ac437d1310 --- /dev/null +++ b/libs/server/src/lib/telemetry/sinks/logger-sink.ts @@ -0,0 +1,18 @@ +import { Sink } from '../sink'; +import { TelemetryType } from '../record'; + +export interface LogWriter { + log(...messages: any[]): void; +} + +export function header(type: string): string { + return `[Telemetry ${type}]`; +} + +export class LoggerSink implements Sink { + constructor(private readonly writer: LogWriter = console) {} + + record(type: TelemetryType, data: any) { + this.writer.log(header(type), data); + } +} diff --git a/libs/server/src/lib/telemetry/sinks/memory-sink.spec.ts b/libs/server/src/lib/telemetry/sinks/memory-sink.spec.ts new file mode 100644 index 0000000000..03e3e014b4 --- /dev/null +++ b/libs/server/src/lib/telemetry/sinks/memory-sink.spec.ts @@ -0,0 +1,90 @@ +import { MemorySink } from './memory-sink'; +import { TelemetryType } from '../record'; + +describe('Telemetry: MemorySink', () => { + const type: TelemetryType = 'ScreenViewed'; + const data = 'data'; + const record = { type, data }; + let sink: MemorySink; + + beforeEach(() => { + sink = new MemorySink(); + }); + + it('records into memory', () => { + sink.record(type, data); + + expect(sink.records).toEqual([record]); + }); + + it('can tell when records are recorded', () => { + sink.record(type, data); + + const recorded = sink.hasRecord(); + + expect(recorded).toBe(true); + }); + + it('can tell when records have not been recorded', () => { + const recorded = sink.hasRecord(); + + expect(recorded).toBe(false); + }); + + it('can tell when type of record has been recorded', () => { + sink.record(type, data); + + const recorded = sink.hasRecord(type); + + expect(recorded).toBe(true); + }); + + it('retrieves records by type', () => { + sink.record(type, data); + + const records = sink.recordsByType(type); + + expect(records).toEqual([record]); + }); + + it('does not include records by when type does not match', () => { + sink.record('CommandRun', data); + + const records = sink.recordsByType(type); + + expect(records).toHaveLength(0); + }); + + it('retrieves one record', () => { + sink.record(type, data); + + const result = sink.oneRecord(); + + expect(result).toEqual(record); + }); + + it('retrieves one record by type', () => { + sink.record(type, data); + + const result = sink.oneRecord(type); + + expect(result).toEqual(record); + }); + + it('errors retrieving one record, if records do not match type', () => { + sink.record(type, data); + + expect(() => sink.oneRecord('CommandRun')).toThrowError(); + }); + + it('errors retrieving one record, if there is more then one', () => { + sink.record(type, data); + sink.record(type, data); + + expect(() => sink.oneRecord()).toThrowError(); + }); + + it('errors retrieving one record, if records are empty', () => { + expect(() => sink.oneRecord()).toThrowError(); + }); +}); diff --git a/libs/server/src/lib/telemetry/sinks/memory-sink.ts b/libs/server/src/lib/telemetry/sinks/memory-sink.ts new file mode 100644 index 0000000000..c5bf4a0c32 --- /dev/null +++ b/libs/server/src/lib/telemetry/sinks/memory-sink.ts @@ -0,0 +1,34 @@ +import { Sink } from '../sink'; +import { TelemetryType } from '../record'; + +export class MemorySink implements Sink { + records: Record[] = []; + + record(type: TelemetryType, data: any): void { + this.records.push({ type, data }); + } + + recordsByType(type: TelemetryType): Record[] { + return this.records.filter(r => r.type === type); + } + + oneRecord(type?: TelemetryType): Record { + const records = type ? this.recordsByType(type) : this.records; + + if (records.length !== 1) { + throw new Error(`Expected one record, have ${records.length}`); + } + + return records[0]; + } + + hasRecord(type?: TelemetryType): boolean { + const records = type ? this.recordsByType(type) : this.records; + return records.length > 0; + } +} + +interface Record { + type: TelemetryType; + data: any; +} diff --git a/libs/server/src/lib/telemetry/sinks/telemetry-parameters.ts b/libs/server/src/lib/telemetry/sinks/telemetry-parameters.ts new file mode 100644 index 0000000000..aa05797650 --- /dev/null +++ b/libs/server/src/lib/telemetry/sinks/telemetry-parameters.ts @@ -0,0 +1,21 @@ +import { TelemetryType } from '../record'; + +export class TelemetryParameters { + constructor( + private readonly type: TelemetryType, + private readonly params: any + ) {} + + fetch(key: string): any { + this.require(key); + return this.params[key]; + } + + require(key: string): void { + if (!this.params.hasOwnProperty(key)) { + throw new Error( + `Telemetry ${this.type} does not have a parameter of ${key}` + ); + } + } +} diff --git a/libs/server/src/lib/telemetry/telemetry.spec.ts b/libs/server/src/lib/telemetry/telemetry.spec.ts new file mode 100644 index 0000000000..63266ed1ad --- /dev/null +++ b/libs/server/src/lib/telemetry/telemetry.spec.ts @@ -0,0 +1,175 @@ +import { MemorySink } from './sinks'; +import { Telemetry } from './telemetry'; +import { TelemetryType } from './record'; +import { User } from './user'; + +describe('Telemetry', () => { + const type: TelemetryType = 'CommandRun'; + const data = 'data'; + const record = { type, data }; + const userId = 'user'; + let user: User; + + let telemetry: Telemetry; + let sink: MemorySink; + + beforeEach(() => { + user = new User(userId); + telemetry = new Telemetry(user); + sink = new MemorySink(); + telemetry.addSink(sink); + }); + + it('records to sink when recording telemetry', () => { + telemetry.record(type); + + const written = sink.oneRecord(); + + expect(written).toEqual({ type, data: {} }); + }); + + it('records data to sink', () => { + telemetry.record(type, data); + + const written = sink.oneRecord(); + + expect(written).toEqual(record); + }); + + describe('factory methods', () => { + it('records loading times', () => { + const time = 25; + + telemetry.appLoaded(time); + + const written = sink.oneRecord(); + + expect(written).toEqual({ type: 'AppLoaded', data: { time } }); + }); + + it('records logged in events', () => { + telemetry.state = 'anonymous'; + telemetry.loggedIn(); + + const loggedIn = sink.oneRecord('LoggedIn'); + const stateChanged = sink.oneRecord('UserStateChanged'); + + expect(loggedIn).toEqual({ type: 'LoggedIn', data: {} }); + expect(stateChanged).toEqual({ + type: 'UserStateChanged', + data: { state: 'connected' } + }); + }); + + it('records logged out events', () => { + telemetry.state = 'connected'; + telemetry.loggedOut(); + + const loggedOut = sink.oneRecord('LoggedOut'); + const stateChanged = sink.oneRecord('UserStateChanged'); + + expect(loggedOut).toEqual({ type: 'LoggedOut', data: {} }); + expect(stateChanged).toEqual({ + type: 'UserStateChanged', + data: { state: 'anonymous' } + }); + }); + + it('records starting telemetry tracking', () => { + telemetry.state = 'untracked'; + telemetry.startedTracking(); + + const tracked = sink.oneRecord('StartedTracking'); + const stateChanged = sink.oneRecord('UserStateChanged'); + + expect(tracked).toEqual({ type: 'StartedTracking', data: {} }); + expect(stateChanged).toEqual({ + type: 'UserStateChanged', + data: { state: 'anonymous' } + }); + }); + + it('records halting telemetry tracking', () => { + telemetry.state = 'anonymous'; + telemetry.stoppedTracking(); + + const untracked = sink.oneRecord('StoppedTracking'); + const stateChanged = sink.oneRecord('UserStateChanged'); + + expect(untracked).toEqual({ type: 'StoppedTracking', data: {} }); + expect(stateChanged).toEqual({ + type: 'UserStateChanged', + data: { state: 'untracked' } + }); + }); + + it('records screen views', () => { + const screen = 'screen'; + telemetry.screenViewed(screen); + + const written = sink.oneRecord(); + + expect(written).toEqual({ type: 'ScreenViewed', data: { screen } }); + }); + + it('records command timings', () => { + const commandType = 'command'; + const time = 25; + + telemetry.commandRun(commandType, time); + + const written = sink.oneRecord(); + + expect(written).toEqual({ + type: 'CommandRun', + data: { commandType, time } + }); + }); + + it('times and records command', () => { + const commandType = 'command'; + const commandResult = 'result'; + const time = 0; + + const result = telemetry.timedCommandRun( + commandType, + () => commandResult + ); + + const written = sink.oneRecord(); + + expect(written).toEqual({ + type: 'CommandRun', + data: { commandType, time } + }); + + expect(result).toEqual(commandResult); + }); + + it('records exceptions', () => { + const error = 'error'; + + telemetry.exceptionOccured(error); + + const written = sink.oneRecord(); + + expect(written).toEqual({ + type: 'ExceptionOccurred', + data: { error } + }); + }); + + it('records feature use', () => { + const feature = 'feature'; + + telemetry.featureUsed(feature); + + const written = sink.oneRecord(); + + expect(written).toEqual({ + type: 'FeatureUsed', + data: { feature } + }); + }); + }); +}); diff --git a/libs/server/src/lib/telemetry/telemetry.ts b/libs/server/src/lib/telemetry/telemetry.ts new file mode 100644 index 0000000000..fb15bb6da6 --- /dev/null +++ b/libs/server/src/lib/telemetry/telemetry.ts @@ -0,0 +1,100 @@ +import { TelemetryType } from './record'; +import { Sink } from './sink'; +import { LoggerSink, GoogleAnalyticsSink } from './sinks'; +import { User, UserState } from './user'; +import { ApplicationPlatform } from '@angular-console/environment'; +import { TelemetryMessageBuilder } from './message-builder'; +import { seconds } from '../utils/utils'; +import { RunResult } from '../api/executable'; +import { Store } from '@nrwl/angular-console-enterprise-electron'; + +export class Telemetry implements TelemetryMessageBuilder { + readonly sinks: Sink[] = []; + state: UserState = this.user.state; + + static withGoogleAnalytics( + store: Store, + platform: ApplicationPlatform + ): Telemetry { + const user = User.fromStorage(store); + const instance = new Telemetry(user); + const sink = new GoogleAnalyticsSink(user.id, platform, user.state); + instance.addSink(sink); + return instance; + } + + static withLogger(store: Store): Telemetry { + const user = User.fromStorage(store); + const instance = new Telemetry(user); + const sink = new LoggerSink(); + instance.addSink(sink); + return instance; + } + + constructor(private readonly user: User) {} + + addSink(sink: Sink) { + this.sinks.push(sink); + } + + record(type: TelemetryType, data: any = {}): void { + this.sinks.forEach(s => s.record(type, data)); + } + + appLoaded(time: number): void { + this.record('AppLoaded', { time }); + } + + loggedIn(): void { + this.user.loggedIn(); + this.userStateChanged(); + this.record('LoggedIn'); + } + + loggedOut(): void { + this.user.loggedOut(); + this.userStateChanged(); + this.record('LoggedOut'); + } + + startedTracking(): void { + this.user.tracked(); + this.userStateChanged(); + this.record('StartedTracking'); + } + + stoppedTracking(): void { + this.user.untracked(); + this.userStateChanged(); + this.record('StoppedTracking'); + } + + userStateChanged() { + if (this.state !== this.user.state) { + this.state = this.user.state; + this.record('UserStateChanged', { state: this.user.state }); + } + } + + screenViewed(screen: string): void { + this.record('ScreenViewed', { screen }); + } + + commandRun(commandType: string, time: number): void { + this.record('CommandRun', { commandType, time }); + } + + timedCommandRun(commandType: string, run: Function): RunResult { + const [time, result] = seconds(run); + this.commandRun(commandType, time); + return result; + } + + exceptionOccured(error: string): void { + this.record('ExceptionOccurred', { error }); + } + + featureUsed(feature: string): void { + this.record('FeatureUsed', { feature }); + } +} diff --git a/libs/server/src/lib/telemetry/user.spec.ts b/libs/server/src/lib/telemetry/user.spec.ts new file mode 100644 index 0000000000..ce49635f50 --- /dev/null +++ b/libs/server/src/lib/telemetry/user.spec.ts @@ -0,0 +1,88 @@ +import { User } from './user'; + +describe('Telemetry: User', () => { + const id = 'id'; + + it('can be marked as untracked', () => { + const user = new User(id); + user.untracked(); + + expect(user.state).toEqual('untracked'); + }); + + it('when tracking is enabled, checks if the user is connected', () => { + const user = new User(id, 'untracked', () => true); + + user.tracked(); + + expect(user.state).toEqual('connected'); + }); + + it('when tracking is enabled, checks if the user is not connected', () => { + const user = new User(id, 'untracked', () => false); + + user.tracked(); + + expect(user.state).toEqual('anonymous'); + }); + + it('only notifies of changes when the state actually changes', () => { + const user = new User(id); + + user.loggedOut(); + }); + + describe('predicates', () => { + it('checks if it is anonymous', () => { + const user = new User(id, 'anonymous'); + const check = user.isAnonymous(); + expect(check).toBe(true); + }); + + it('checks if it is untracked', () => { + const user = new User(id, 'untracked'); + const check = user.isUntracked(); + expect(check).toBe(true); + }); + + it('checks if it is connected', () => { + const user = new User(id, 'connected'); + const check = user.isConnected(); + expect(check).toBe(true); + }); + }); + + describe('when tracked', () => { + it('can be logged in', () => { + const user = new User(id, 'anonymous'); + + user.loggedIn(); + + expect(user.state).toEqual('connected'); + }); + + it('can be logged out', () => { + const user = new User(id, 'connected'); + + user.loggedOut(); + + expect(user.state).toEqual('anonymous'); + }); + }); + + describe('when untracked', () => { + const user = new User(id, 'untracked'); + + it('cant be logged in', () => { + user.loggedIn(); + + expect(user.state).toEqual('untracked'); + }); + + it('cant be logged out', () => { + user.loggedOut(); + + expect(user.state).toEqual('untracked'); + }); + }); +}); diff --git a/libs/server/src/lib/telemetry/user.ts b/libs/server/src/lib/telemetry/user.ts new file mode 100644 index 0000000000..1d351e07d5 --- /dev/null +++ b/libs/server/src/lib/telemetry/user.ts @@ -0,0 +1,82 @@ +import { authUtils, Store } from '@nrwl/angular-console-enterprise-electron'; + +export type UserState = 'untracked' | 'anonymous' | 'connected'; + +interface Settings { + isConnectUser: boolean; + canCollectData: boolean; +} + +export type CheckConnection = () => boolean; +export type OnChange = (user: User) => void; + +function isConnected(): boolean { + return !!authUtils.getIdTokenFromStore(); +} + +export class User { + static fromStorage(store: Store): User { + authUtils.setStore(store); + const settings: Settings | null = store.get('settings'); + let id: string | null = store.get('uuid'); + let state: UserState = 'anonymous'; + + if (!id) { + id = require('uuid/v4')(); + store.set('uuid', id); + } + + const connected = authUtils.getIdTokenFromStore(); + if (connected) { + state = 'connected'; + } + + if (settings && !settings.canCollectData) { + state = 'untracked'; + } + + // the cast here is to make windows build happy, it shouldn't be necessary + const user = new User(id as string, state, isConnected); + return user; + } + + constructor( + readonly id: string, + public state: UserState = 'anonymous', + private readonly checkConnection: CheckConnection = () => false + ) {} + + loggedIn() { + if (this.isUntracked()) return; + this.state = 'connected'; + } + + loggedOut() { + if (this.isUntracked()) return; + this.state = 'anonymous'; + } + + tracked() { + if (this.checkConnection()) { + this.state = 'connected'; + } else { + this.state = 'anonymous'; + } + } + + untracked() { + this.state = 'untracked'; + } + + isAnonymous() { + return this.state === 'anonymous'; + } + + isUntracked(): boolean { + return this.state === 'untracked'; + } + + isConnected(): boolean { + return this.state === 'connected'; + } +} diff --git a/libs/server/src/lib/utils/file-utils.ts b/libs/server/src/lib/utils/file-utils.ts index 4d873cfa62..7ae7ceeb8c 100644 --- a/libs/server/src/lib/utils/file-utils.ts +++ b/libs/server/src/lib/utils/file-utils.ts @@ -63,6 +63,18 @@ export class FileUtils { return false; } + supportsNvm(): boolean { + let supportsNVM; + + if (this.isWsl()) { + supportsNVM = this.wslSupportsNvm(); + } else { + supportsNVM = Boolean(process.env.NVM_DIR); + } + + return supportsNVM; + } + convertToWslPath(p: string) { if (this.isWsl() && !p.startsWith('/')) { return execSync(`wsl -e wslpath -u ${p}`) @@ -154,6 +166,16 @@ export class FileUtils { } } + newWorkspacePath(): string { + return path.join( + __dirname, + 'assets', + platform() === 'win32' && !this.isWsl() + ? 'new-workspace.cmd' + : 'new-workspace' + ); + } + joinForCommandRun(...p: string[]) { return p .splice(1) diff --git a/libs/server/src/lib/utils/utils.spec.ts b/libs/server/src/lib/utils/utils.spec.ts index 0f9c0234be..274dffb003 100644 --- a/libs/server/src/lib/utils/utils.spec.ts +++ b/libs/server/src/lib/utils/utils.spec.ts @@ -1,4 +1,4 @@ -import { normalizeSchema } from './utils'; +import { normalizeSchema, seconds } from './utils'; describe('utils', () => { describe('normalizeSchema', () => { @@ -18,6 +18,14 @@ describe('utils', () => { expect(r[0].required).toBeTruthy(); }); + it('measures seconds', () => { + const returns = 'result'; + const [elapsed, result] = seconds(() => returns); + + expect(elapsed).toEqual(0); + expect(result).toEqual(returns); + }); + it('should not mark fields as required otherwise', () => { const r = normalizeSchema({ properties: { one: {} }, diff --git a/libs/server/src/lib/utils/utils.ts b/libs/server/src/lib/utils/utils.ts index a2f9c60189..f8d0313aef 100644 --- a/libs/server/src/lib/utils/utils.ts +++ b/libs/server/src/lib/utils/utils.ts @@ -275,6 +275,13 @@ export function normalizePath(value: string): string { .join('\\'); } +export function seconds(fn: Function): [number, T] { + const start = process.hrtime(); + const result = fn(); + const end = process.hrtime(start); + return [end[0], result]; +} + /** * To improve performance angular console pre-processes * diff --git a/libs/server/src/test-setup.ts b/libs/server/src/test-setup.ts new file mode 100644 index 0000000000..42d58274fb --- /dev/null +++ b/libs/server/src/test-setup.ts @@ -0,0 +1,5 @@ +module.exports = { + name: 'server', + preset: '../../jest.config.js', + coverageDirectory: '../../coverage/libs/server' +}; diff --git a/libs/server/tsconfig.spec.json b/libs/server/tsconfig.spec.json new file mode 100644 index 0000000000..cfff29a544 --- /dev/null +++ b/libs/server/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/ui/src/lib/data-collection/data-collection.component.ts b/libs/ui/src/lib/data-collection/data-collection.component.ts index c7ccb0c83d..11ad21d7a4 100644 --- a/libs/ui/src/lib/data-collection/data-collection.component.ts +++ b/libs/ui/src/lib/data-collection/data-collection.component.ts @@ -1,5 +1,5 @@ -import { Component, Inject, ChangeDetectionStrategy } from '@angular/core'; -import { Telemetry, Settings } from '@angular-console/utils'; +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Settings } from '@angular-console/utils'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -8,10 +8,7 @@ import { Telemetry, Settings } from '@angular-console/utils'; styleUrls: ['./data-collection.component.scss'] }) export class DataCollectionComponent { - constructor( - @Inject('telemetry') private readonly telemetry: Telemetry, - private readonly settings: Settings - ) {} + constructor(private readonly settings: Settings) {} get showMessage() { return this.settings.canCollectData() === undefined; @@ -19,6 +16,5 @@ export class DataCollectionComponent { close(value: boolean) { this.settings.setCanCollectData(value); - this.telemetry.reportDataCollectionEvent(value); } } diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index b0884a04e9..d00b2238d7 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -1,4 +1,3 @@ -export * from './lib/telemetry.service'; export * from './lib/completion.service'; export * from './lib/command-runner.service'; export * from './lib/editor-support.service'; @@ -11,4 +10,5 @@ export * from './lib/settings.service'; export * from './lib/router-navigation.service'; export * from './lib/show-item-in-folder.service'; export * from './lib/in-memory-platform-location.service'; +export * from './lib/telemetry.service'; export { OpenDocGQL } from './lib/generated/graphql'; diff --git a/libs/utils/src/lib/generated/graphql.ts b/libs/utils/src/lib/generated/graphql.ts index 980e5dfd25..2358563ec4 100644 --- a/libs/utils/src/lib/generated/graphql.ts +++ b/libs/utils/src/lib/generated/graphql.ts @@ -22,6 +22,18 @@ export namespace Editors { }; } +export namespace ExceptionOccured { + export type Variables = { + error: string; + }; + + export type Mutation = { + __typename?: 'Mutation'; + + exceptionOccured: Maybe; + }; +} + export namespace GetCommandInitial { export type Variables = { id: string; @@ -225,6 +237,18 @@ export namespace RestartCommand { }; } +export namespace ScreenViewed { + export type Variables = { + screen: string; + }; + + export type Mutation = { + __typename?: 'Mutation'; + + screenViewed: Maybe; + }; +} + export namespace Settings { export type Variables = {}; @@ -378,6 +402,19 @@ export class EditorsGQL extends Apollo.Query { @Injectable({ providedIn: 'root' }) +export class ExceptionOccuredGQL extends Apollo.Mutation< + ExceptionOccured.Mutation, + ExceptionOccured.Variables +> { + document: any = gql` + mutation ExceptionOccured($error: String!) { + exceptionOccured(error: $error) + } + `; +} +@Injectable({ + providedIn: 'root' +}) export class GetCommandInitialGQL extends Apollo.Query< GetCommandInitial.Query, GetCommandInitial.Variables @@ -541,6 +578,19 @@ export class RestartCommandGQL extends Apollo.Mutation< @Injectable({ providedIn: 'root' }) +export class ScreenViewedGQL extends Apollo.Mutation< + ScreenViewed.Mutation, + ScreenViewed.Variables +> { + document: any = gql` + mutation ScreenViewed($screen: String!) { + screenViewed(screen: $screen) + } + `; +} +@Injectable({ + providedIn: 'root' +}) export class SettingsGQL extends Apollo.Query< Settings.Query, Settings.Variables diff --git a/libs/utils/src/lib/graphql/exception-occured.graphql b/libs/utils/src/lib/graphql/exception-occured.graphql new file mode 100644 index 0000000000..2d78c4c0b3 --- /dev/null +++ b/libs/utils/src/lib/graphql/exception-occured.graphql @@ -0,0 +1,3 @@ +mutation ExceptionOccured($error: String!) { + exceptionOccured(error: $error) +} diff --git a/libs/utils/src/lib/graphql/screen-viewed.graphql b/libs/utils/src/lib/graphql/screen-viewed.graphql new file mode 100644 index 0000000000..db713a7a63 --- /dev/null +++ b/libs/utils/src/lib/graphql/screen-viewed.graphql @@ -0,0 +1,3 @@ +mutation ScreenViewed($screen: String!) { + screenViewed(screen: $screen) +} diff --git a/libs/utils/src/lib/telemetry.service.spec.ts b/libs/utils/src/lib/telemetry.service.spec.ts deleted file mode 100644 index 2b1a11c36c..0000000000 --- a/libs/utils/src/lib/telemetry.service.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { cleanUpUrl } from './telemetry.service'; - -describe('Telemetry', () => { - it('should remove workspace path from the url', () => { - expect(cleanUpUrl('/workspace/secret/aa/bb')).toEqual( - '/workspace/PATH/aa/bb' - ); - expect(cleanUpUrl('/workspace')).toEqual('/workspace'); - expect(cleanUpUrl('/open-workspace')).toEqual('/open-workspace'); - expect(cleanUpUrl('/workspaces')).toEqual('/workspaces'); - }); -}); diff --git a/libs/utils/src/lib/telemetry.service.ts b/libs/utils/src/lib/telemetry.service.ts index 11784bbcaf..adf2b74048 100644 --- a/libs/utils/src/lib/telemetry.service.ts +++ b/libs/utils/src/lib/telemetry.service.ts @@ -1,65 +1,49 @@ -import { Injectable } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter } from 'rxjs/operators'; - -declare var global: any; - -@Injectable() -// TODO: Make a VSCode version of this class and move this implementation to apps/electron. +import { Injectable, Inject } from '@angular/core'; +import { ExceptionOccuredGQL, ScreenViewedGQL } from './generated/graphql'; +import { Observable } from 'rxjs'; +import { ENVIRONMENT, Environment } from '@angular-console/environment'; +import { timeout, take } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) export class Telemetry { - private readonly ipc: any; - - constructor(private readonly router: Router) { - if (typeof global !== 'undefined' && global.require) { - try { - this.ipc = global.require('electron').ipcRenderer; - } catch (e) { - console.error( - 'Could not get a hold of ipcRenderer to log to analytics' - ); - } - } - } - - setUpRouterLogging() { - this.router.events - .pipe(filter(event => event instanceof NavigationEnd)) - .subscribe(event => - this.reportPageView(cleanUpUrl((event as NavigationEnd).url)) - ); + constructor( + @Inject(ENVIRONMENT) + private readonly environment: Environment, + private readonly exceptionOccuredGQL: ExceptionOccuredGQL, + private readonly screenViewedGQL: ScreenViewedGQL + ) {} + + get isDev() { + return !this.environment.production; } - reportException(description: string) { - if (this.ipc) { - this.ipc.send('reportException', { description }); - } + exceptionOccured(error: string): void { + this.send(this.exceptionOccuredGQL.mutate({ error }), 'ExceptionOccured'); } - reportDataCollectionEvent(value: boolean) { - if (this.ipc) { - this.ipc.send('dataCollectionEvent', { value }); - } + screenViewed(screen: string): void { + this.send(this.screenViewedGQL.mutate({ screen }), 'ScreenViewed'); } - reportEvent(category: string, action: string, label: string, value: string) { - if (this.ipc) { - this.ipc.send('event', { category, action, label, value }); - } - } - - private reportPageView(path: string) { - if (this.ipc) { - this.ipc.send('reportPageView', { path }); - } - } -} - -export function cleanUpUrl(url: string): string { - if (url.startsWith('/workspace/')) { - const parts = url.split('/'); - // tslint:disable-next-line - return [parts[0], parts[1], 'PATH', ...parts.slice(3)].join('/'); - } else { - return url; + send(req: Observable, name: string): void { + req + .pipe( + timeout(2000), + take(1) + ) + .subscribe( + () => { + if (this.isDev) { + console.log(`[Telemetry Written: ${name}]`); + } + }, + () => { + if (this.isDev) { + console.error(`[Telemetry Failed: ${name}]`); + } + } + ); } } diff --git a/package.json b/package.json index 0230bbf854..b10ff42e4f 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@angular/core": "8.1.2", "@nrwl/angular": "8.2.0", "@types/fontfaceobserver": "^0.0.6", + "@types/uuid": "^3.4.5", "core-js": "2.6.9", "fontfaceobserver": "^2.1.0", "ij-rpc-client": "^0.3.2", @@ -157,6 +158,7 @@ "electron-builder": "21.1.1", "electron-installer-dmg": "3.0.0", "electron-packager": "14.0.2", + "electron-ipc-mock": "^0.0.3", "electron-rebuild": "^1.8.5", "electron-store": "3.3.0", "electron-updater": "4.1.2", diff --git a/yarn.lock b/yarn.lock index 0ace4675ea..9183b35cc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,6 +1906,13 @@ resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.2.tgz#d21a122a984bf8261eb206bcb7ccb1c0e8353d04" integrity sha512-ndv0aYA5tNPdl4KYUperlswuiSj49+o7Pwx8hrCiE9x2oeiMrn7A2jXFDdTLFIymiYZImDX02ycq0i6uQ3TL0A== +"@types/uuid@^3.4.5": + version "3.4.5" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.5.tgz#d4dc10785b497a1474eae0ba7f0cb09c0ddfd6eb" + integrity sha512-MNL15wC3EKyw1VLF+RoVO4hJJdk9t/Hlv3rt1OL65Qvuadm4BYo6g9ZJQqoq7X8NBFSsQXgAujWciovh2lpVjA== + dependencies: + "@types/node" "*" + "@types/valid-url@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@types/valid-url/-/valid-url-1.0.2.tgz#60fa435ce24bfd5ba107b8d2a80796aeaf3a8f45" @@ -2375,7 +2382,7 @@ ajv-keywords@^3.4.1: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== -ajv@6.10.0, ajv@^6.1.0, ajv@^6.5.5, ajv@^6.9.1: +ajv@6.10.0, ajv@^6.1.0, ajv@^6.9.1: version "6.10.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg== @@ -2395,7 +2402,7 @@ ajv@^5.0.0: fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.3.0" -ajv@^6.10.0, ajv@^6.10.1: +ajv@^6.10.0, ajv@^6.10.1, ajv@^6.5.5: version "6.10.2" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== @@ -5686,6 +5693,11 @@ electron-installer-dmg@3.0.0: optionalDependencies: appdmg "^0.6.0" +electron-ipc-mock@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/electron-ipc-mock/-/electron-ipc-mock-0.0.3.tgz#eec117c5510eeca7da68291a8a41bfc7868d3e03" + integrity sha1-7sEXxVEO7KfaaCkaikG/x4aNPgM= + electron-notarize@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/electron-notarize/-/electron-notarize-0.1.1.tgz#c3563d70c5e7b3315f44e8495b30050a8c408b91" @@ -10572,11 +10584,16 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= -ms@2.1.1, ms@^2.0.0, ms@^2.1.1: +ms@2.1.1, ms@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== +ms@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + multer@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/multer/-/multer-1.3.0.tgz#092b2670f6846fa4914965efc8cf94c20fec6cd2" @@ -12214,7 +12231,12 @@ pseudomap@^1.0.2: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.24, psl@^1.1.28: +psl@^1.1.24: + version "1.2.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.2.0.tgz#df12b5b1b3a30f51c329eacbdef98f3a6e136dc6" + integrity sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA== + +psl@^1.1.28: version "1.1.31" resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" integrity sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw== @@ -13165,11 +13187,16 @@ safe-buffer@5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" integrity sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg== -safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-buffer@^5.0.1, safe-buffer@^5.1.2: + version "5.2.0" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" + integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== + safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"