diff --git a/.vscode/launch.json b/.vscode/launch.json index acf4559e49..88a6442f47 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -67,8 +67,19 @@ { "type": "node", "request": "attach", - "name": "Attach to Process", - "port": 5858, + "name": "Attach to Broker Process", + // In case you want to debug Analytics Broker process, add `--debug-brk=9897` (or --inspect-brk=9897) when spawning analytics-broker-process. + "port": 9897, + "sourceMaps": true + }, + + { + "type": "node", + "request": "attach", + "name": "Attach to Eqatec Process", + // In case you want to debug Eqatec Analytics process, add `--debug-brk=9855` (or --inspect-brk=9855) when spawning eqatec-analytics-process. + // NOTE: Ensure you set it only for one of the analytics processes. + "port": 9855, "sourceMaps": true } diff --git a/PublicAPI.md b/PublicAPI.md index a84043d074..206287aee4 100644 --- a/PublicAPI.md +++ b/PublicAPI.md @@ -394,21 +394,6 @@ tns.npm.view(["nativescript"], {}).then(result => { }); ``` -## analyticsService -Provides a way to configure analytics. - -### startEqatecMonitor -* Definition: -```TypeScript -/** - * Starts analytics monitor with provided key. - * @param {string} projectApiKey API key with which to start analytics monitor. - * @returns {Promise}. - */ -startEqatecMonitor(projectApiKey: string): Promise; -``` - - ## debugService Provides methods for debugging applications on devices. The service is also event emitter, that raises the following events: * `connectionError` event - this event is raised when the debug operation cannot start on iOS device. The causes can be: diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 96c6f24eda..f4ad743cdd 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -30,7 +30,9 @@ $injector.require("androidDebugService", "./services/android-debug-service"); $injector.require("userSettingsService", "./services/user-settings-service"); $injector.require("analyticsSettingsService", "./services/analytics-settings-service"); -$injector.requirePublic("analyticsService", "./services/analytics-service"); +$injector.require("analyticsService", "./services/analytics/analytics-service"); +$injector.require("eqatecAnalyticsProvider", "./services/analytics/eqatec-analytics-provider"); +$injector.require("googleAnalyticsProvider", "./services/analytics/google-analytics-provider"); $injector.require("emulatorSettingsService", "./services/emulator-settings-service"); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index da4da5148b..505552f685 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -5,7 +5,6 @@ export class RunCommandBase implements ICommand { public platform: string; constructor(protected $platformService: IPlatformService, - protected $liveSyncService: ILiveSyncService, protected $projectData: IProjectData, protected $options: IOptions, protected $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, diff --git a/lib/common b/lib/common index 5b4c6da25c..804e28c137 160000 --- a/lib/common +++ b/lib/common @@ -1 +1 @@ -Subproject commit 5b4c6da25ca015f52756431ddc63b5a7fbd8a995 +Subproject commit 804e28c137442922609caced942dee3d6512523b diff --git a/lib/config.ts b/lib/config.ts index 247c721ede..4b6b21c942 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -73,7 +73,7 @@ export class StaticConfig extends StaticConfigBase implements IStaticConfig { } public get PATH_TO_BOOTSTRAP(): string { - return path.join(__dirname, "bootstrap"); + return path.join(__dirname, "bootstrap.js"); } public async getAdbFilePath(): Promise { diff --git a/lib/constants.ts b/lib/constants.ts index 346d475dab..6405df9471 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -103,3 +103,21 @@ export const enum NativePlatformStatus { requiresPrepare = "2", alreadyPrepared = "3" } + +export const enum DebugTools { + Chrome = "Chrome", + Inspector = "Inspector" +} + +export const enum TrackActionNames { + Build = "Build", + CreateProject = "Create project", + Debug = "Debug", + Deploy = "Deploy", + LiveSync = "LiveSync" +} + +export const enum BuildStates { + Clean = "Clean", + Incremental = "Incremental" +} diff --git a/lib/nativescript-cli-lib-bootstrap.ts b/lib/nativescript-cli-lib-bootstrap.ts index 95dcf39e2f..1e545239e3 100644 --- a/lib/nativescript-cli-lib-bootstrap.ts +++ b/lib/nativescript-cli-lib-bootstrap.ts @@ -13,3 +13,4 @@ $injector.requirePublicClass("localBuildService", "./services/local-build-servic // We need this because some services check if (!$options.justlaunch) to start the device log after some operation. // We don't want this behaviour when the CLI is required as library. $injector.resolve("options").justlaunch = true; +$injector.resolve("staticConfig").disableAnalytics = true; diff --git a/lib/services/analytics-service.ts b/lib/services/analytics-service.ts deleted file mode 100644 index ba98c7f662..0000000000 --- a/lib/services/analytics-service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { AnalyticsServiceBase } from "../common/services/analytics-service-base"; -import { exported } from "../common/decorators"; - -export class AnalyticsService extends AnalyticsServiceBase implements IAnalyticsService { - private static ANALYTICS_FEATURE_USAGE_TRACKING_API_KEY = "9912cff308334c6d9ad9c33f76a983e3"; - - constructor(protected $logger: ILogger, - protected $options: IOptions, - $staticConfig: Config.IStaticConfig, - $prompter: IPrompter, - $userSettingsService: UserSettings.IUserSettingsService, - $analyticsSettingsService: IAnalyticsSettingsService, - $progressIndicator: IProgressIndicator, - $osInfo: IOsInfo) { - super($logger, $options, $staticConfig, $prompter, $userSettingsService, $analyticsSettingsService, $progressIndicator, $osInfo); - } - - @exported("analyticsService") - public async startEqatecMonitor(projectApiKey: string): Promise { - if (await this.isEnabled(this.$staticConfig.TRACK_FEATURE_USAGE_SETTING_NAME) || await this.isEnabled(this.$staticConfig.ERROR_REPORT_SETTING_NAME)) { - await this.restartEqatecMonitor(projectApiKey); - } - } - - protected async checkConsentCore(trackFeatureUsage: boolean): Promise { - await this.restartEqatecMonitor(AnalyticsService.ANALYTICS_FEATURE_USAGE_TRACKING_API_KEY); - await super.checkConsentCore(trackFeatureUsage); - - // Stop the monitor, so correct API_KEY will be used when features are tracked. - this.tryStopEqatecMonitor(); - } -} - -$injector.register("analyticsService", AnalyticsService); diff --git a/lib/services/analytics-settings-service.ts b/lib/services/analytics-settings-service.ts index 2147bbd273..6ca50db27e 100644 --- a/lib/services/analytics-settings-service.ts +++ b/lib/services/analytics-settings-service.ts @@ -1,7 +1,6 @@ import { createGUID } from "../common/helpers"; class AnalyticsSettingsService implements IAnalyticsSettingsService { - private static SESSIONS_STARTED_OBSOLETE_KEY = "SESSIONS_STARTED"; private static SESSIONS_STARTED_KEY_PREFIX = "SESSIONS_STARTED_"; constructor(private $userSettingsService: UserSettings.IUserSettingsService, @@ -12,16 +11,12 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService { return true; } - public async getUserId(): Promise { - let currentUserId = await this.$userSettingsService.getSettingValue("USER_ID"); - if (!currentUserId) { - currentUserId = createGUID(false); - - this.$logger.trace(`Setting new USER_ID: ${currentUserId}.`); - await this.$userSettingsService.saveSetting("USER_ID", currentUserId); - } + public getUserId(): Promise { + return this.getSettingValueOrDefault("USER_ID"); + } - return currentUserId; + public getClientId(): Promise { + return this.getSettingValueOrDefault(this.$staticConfig.ANALYTICS_INSTALLATION_ID_SETTING_NAME); } public getClientName(): string { @@ -33,14 +28,8 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService { } public async getUserSessionsCount(projectName: string): Promise { - const oldSessionCount = await this.$userSettingsService.getSettingValue(AnalyticsSettingsService.SESSIONS_STARTED_OBSOLETE_KEY); - - if (oldSessionCount) { - // remove the old property for sessions count - await this.$userSettingsService.removeSetting(AnalyticsSettingsService.SESSIONS_STARTED_OBSOLETE_KEY); - } - - return await this.$userSettingsService.getSettingValue(this.getSessionsProjectKey(projectName)) || oldSessionCount || 0; + const sessionsCountForProject = await this.$userSettingsService.getSettingValue(this.getSessionsProjectKey(projectName)); + return sessionsCountForProject || 0; } public async setUserSessionsCount(count: number, projectName: string): Promise { @@ -50,5 +39,17 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService { private getSessionsProjectKey(projectName: string): string { return `${AnalyticsSettingsService.SESSIONS_STARTED_KEY_PREFIX}${projectName}`; } + + private async getSettingValueOrDefault(settingName: string): Promise { + let guid = await this.$userSettingsService.getSettingValue(settingName); + if (!guid) { + guid = createGUID(false); + + this.$logger.trace(`Setting new ${settingName}: ${guid}.`); + await this.$userSettingsService.saveSetting(settingName, guid); + } + + return guid; + } } $injector.register("analyticsSettingsService", AnalyticsSettingsService); diff --git a/lib/services/analytics/analytics-broker-process.ts b/lib/services/analytics/analytics-broker-process.ts new file mode 100644 index 0000000000..78769ae8ea --- /dev/null +++ b/lib/services/analytics/analytics-broker-process.ts @@ -0,0 +1,60 @@ +import * as fs from "fs"; +import { AnalyticsBroker } from "./analytics-broker"; + +const pathToBootstrap = process.argv[2]; +if (!pathToBootstrap || !fs.existsSync(pathToBootstrap)) { + throw new Error("Invalid path to bootstrap."); +} + +// After requiring the bootstrap we can use $injector +require(pathToBootstrap); + +const analyticsBroker = $injector.resolve(AnalyticsBroker, { pathToBootstrap }); +let trackingQueue: Promise = Promise.resolve(); + +let sentFinishMsg = false; +let receivedFinishMsg = false; + +const sendDataForTracking = async (data: ITrackingInformation) => { + trackingQueue = trackingQueue.then(() => analyticsBroker.sendDataForTracking(data)); + await trackingQueue; +}; + +const finishTracking = async (data?: ITrackingInformation) => { + if (!sentFinishMsg) { + sentFinishMsg = true; + + data = data || { type: TrackingTypes.Finish }; + const action = async () => { + await sendDataForTracking(data); + process.disconnect(); + }; + + if (receivedFinishMsg) { + await action(); + } else { + // In case we've got here without receiving "finish" message from parent (receivedFinishMsg is false) + // there might be various reasons, but most probably the parent is dead. + // However, there's no guarantee that we've received all messages. So wait some time before sending finish message to children. + setTimeout(async () => { + await action(); + }, 1000); + } + } +}; + +process.on("message", async (data: ITrackingInformation) => { + if (data.type === TrackingTypes.Finish) { + receivedFinishMsg = true; + await finishTracking(data); + return; + } + + await sendDataForTracking(data); +}); + +process.on("disconnect", async () => { + await finishTracking(); +}); + +process.send(AnalyticsMessages.BrokerReadyToReceive); diff --git a/lib/services/analytics/analytics-broker.ts b/lib/services/analytics/analytics-broker.ts new file mode 100644 index 0000000000..471840cd7b --- /dev/null +++ b/lib/services/analytics/analytics-broker.ts @@ -0,0 +1,45 @@ +import { cache } from "../../common/decorators"; + +export class AnalyticsBroker implements IAnalyticsBroker { + + @cache() + private async getEqatecAnalyticsProvider(): Promise { + return this.$injector.resolve("eqatecAnalyticsProvider"); + } + + @cache() + private async getGoogleAnalyticsProvider(): Promise { + const clientId = await this.$analyticsSettingsService.getClientId(); + return this.$injector.resolve("googleAnalyticsProvider", { clientId }); + } + + constructor(private $analyticsSettingsService: IAnalyticsSettingsService, + private $injector: IInjector) { } + + public async sendDataForTracking(trackInfo: ITrackingInformation): Promise { + const eqatecProvider = await this.getEqatecAnalyticsProvider(); + const googleProvider = await this.getGoogleAnalyticsProvider(); + + switch (trackInfo.type) { + case TrackingTypes.Exception: + await eqatecProvider.trackError(trackInfo); + break; + case TrackingTypes.Feature: + await eqatecProvider.trackInformation(trackInfo); + break; + case TrackingTypes.AcceptTrackFeatureUsage: + await eqatecProvider.acceptFeatureUsageTracking(trackInfo); + break; + case TrackingTypes.GoogleAnalyticsData: + await googleProvider.trackHit(trackInfo); + break; + case TrackingTypes.Finish: + await eqatecProvider.finishTracking(); + break; + default: + throw new Error(`Invalid tracking type: ${trackInfo.type}`); + } + + } + +} diff --git a/lib/services/analytics/analytics-constants.ts b/lib/services/analytics/analytics-constants.ts new file mode 100644 index 0000000000..5aff79435c --- /dev/null +++ b/lib/services/analytics/analytics-constants.ts @@ -0,0 +1,14 @@ +/** + * Defines messages used in communication between CLI's process and analytics subprocesses. + */ +const enum AnalyticsMessages { + /** + * Analytics Broker is initialized and is ready to receive information for tracking. + */ + BrokerReadyToReceive = "BrokerReadyToReceive", + + /** + * Eqatec Analytics process is initialized and is ready to receive information for tracking. + */ + EqatecAnalyticsReadyToReceive = "EqatecAnalyticsReadyToReceive" +} diff --git a/lib/services/analytics/analytics-service.ts b/lib/services/analytics/analytics-service.ts new file mode 100644 index 0000000000..a15f7f7583 --- /dev/null +++ b/lib/services/analytics/analytics-service.ts @@ -0,0 +1,189 @@ +import { AnalyticsServiceBase } from "../../common/services/analytics-service-base"; +import { ChildProcess } from "child_process"; +import * as path from "path"; +import { cache } from "../../common/decorators"; +import { isInteractive } from '../../common/helpers'; +import { DeviceTypes, AnalyticsClients } from "../../common/constants"; + +export class AnalyticsService extends AnalyticsServiceBase { + private static ANALYTICS_BROKER_START_TIMEOUT = 30 * 1000; + private brokerProcess: ChildProcess; + + constructor(protected $logger: ILogger, + protected $options: IOptions, + $staticConfig: Config.IStaticConfig, + $prompter: IPrompter, + $userSettingsService: UserSettings.IUserSettingsService, + $analyticsSettingsService: IAnalyticsSettingsService, + $osInfo: IOsInfo, + private $childProcess: IChildProcess, + private $processService: IProcessService, + private $projectDataService: IProjectDataService, + private $mobileHelper: Mobile.IMobileHelper) { + super($logger, $options, $staticConfig, $prompter, $userSettingsService, $analyticsSettingsService, $osInfo); + } + + public track(featureName: string, featureValue: string): Promise { + const data: IFeatureTrackingInformation = { + type: TrackingTypes.Feature, + featureName: featureName, + featureValue: featureValue + }; + + return this.sendInfoForTracking(data, this.$staticConfig.TRACK_FEATURE_USAGE_SETTING_NAME); + } + + public trackException(exception: any, message: string): Promise { + const data: IExceptionsTrackingInformation = { + type: TrackingTypes.Exception, + exception, + message + }; + + return this.sendInfoForTracking(data, this.$staticConfig.ERROR_REPORT_SETTING_NAME); + } + + public async trackAcceptFeatureUsage(settings: { acceptTrackFeatureUsage: boolean }): Promise { + this.sendMessageToBroker({ + type: TrackingTypes.AcceptTrackFeatureUsage, + acceptTrackFeatureUsage: settings.acceptTrackFeatureUsage + }); + } + + public async trackInGoogleAnalytics(gaSettings: IGoogleAnalyticsData): Promise { + await this.initAnalyticsStatuses(); + + if (!this.$staticConfig.disableAnalytics && this.analyticsStatuses[this.$staticConfig.TRACK_FEATURE_USAGE_SETTING_NAME] === AnalyticsStatus.enabled) { + gaSettings.customDimensions = gaSettings.customDimensions || {}; + gaSettings.customDimensions[GoogleAnalyticsCustomDimensions.client] = this.$options.analyticsClient || (isInteractive() ? AnalyticsClients.Cli : AnalyticsClients.Unknown); + + const googleAnalyticsData: IGoogleAnalyticsTrackingInformation = _.merge({ type: TrackingTypes.GoogleAnalyticsData, category: AnalyticsClients.Cli }, gaSettings); + return this.sendMessageToBroker(googleAnalyticsData); + } + } + + public async trackEventActionInGoogleAnalytics(data: IEventActionData): Promise { + const device = data.device; + const platform = device ? device.deviceInfo.platform : data.platform; + const isForDevice = device ? !device.isEmulator : data.isForDevice; + + let label: string = ""; + label = this.addDataToLabel(label, platform); + + // In some cases (like in case action is Build and platform is Android), we do not know if the deviceType is emulator or device. + // Just exclude the device_type in this case. + if (isForDevice !== null) { + const deviceType = isForDevice ? DeviceTypes.Device : (this.$mobileHelper.isAndroidPlatform(platform) ? DeviceTypes.Emulator : DeviceTypes.Simulator); + label = this.addDataToLabel(label, deviceType); + } + + if (device) { + label = this.addDataToLabel(label, device.deviceInfo.version); + } + + if (data.additionalData) { + label = this.addDataToLabel(label, data.additionalData); + } + + const customDimensions: IStringDictionary = {}; + if (data.projectDir) { + const projectData = this.$projectDataService.getProjectData(data.projectDir); + customDimensions[GoogleAnalyticsCustomDimensions.projectType] = projectData.projectType; + } + + const googleAnalyticsEventData: IGoogleAnalyticsEventData = { + googleAnalyticsDataType: GoogleAnalyticsDataType.Event, + action: data.action, + label, + customDimensions + }; + + this.$logger.trace("Will send the following information to Google Analytics:", googleAnalyticsEventData); + + await this.trackInGoogleAnalytics(googleAnalyticsEventData); + } + + public dispose(): void { + if (this.brokerProcess && this.shouldDisposeInstance) { + this.brokerProcess.disconnect(); + } + } + + private addDataToLabel(label: string, newData: string): string { + if (newData && label) { + return `${label}_${newData}`; + } + + return label || newData || ""; + } + + @cache() + private getAnalyticsBroker(): Promise { + return new Promise((resolve, reject) => { + const broker = this.$childProcess.spawn("node", + [ + path.join(__dirname, "analytics-broker-process.js"), + this.$staticConfig.PATH_TO_BOOTSTRAP + ], + { + stdio: ["ignore", "ignore", "ignore", "ipc"], + detached: true + } + ); + + broker.unref(); + + let isSettled = false; + + const timeoutId = setTimeout(() => { + if (!isSettled) { + reject(new Error("Unable to start Analytics Broker process.")); + } + }, AnalyticsService.ANALYTICS_BROKER_START_TIMEOUT); + + broker.on("error", (err: Error) => { + clearTimeout(timeoutId); + + if (!isSettled) { + isSettled = true; + reject(err); + } + }); + + broker.on("message", (data: any) => { + if (data === AnalyticsMessages.BrokerReadyToReceive) { + clearTimeout(timeoutId); + + if (!isSettled) { + isSettled = true; + + this.$processService.attachToProcessExitSignals(this, () => { + broker.send({ + type: TrackingTypes.Finish + }); + }); + + this.brokerProcess = broker; + + resolve(broker); + } + } + }); + }); + } + + private async sendInfoForTracking(trackingInfo: ITrackingInformation, settingName: string): Promise { + await this.initAnalyticsStatuses(); + + if (!this.$staticConfig.disableAnalytics && this.analyticsStatuses[settingName] === AnalyticsStatus.enabled) { + return this.sendMessageToBroker(trackingInfo); + } + } + + private async sendMessageToBroker(message: ITrackingInformation): Promise { + const broker = await this.getAnalyticsBroker(); + return new Promise((resolve, reject) => broker.send(message, resolve)); + } +} + +$injector.register("analyticsService", AnalyticsService); diff --git a/lib/services/analytics/analytics.d.ts b/lib/services/analytics/analytics.d.ts new file mode 100644 index 0000000000..ecb51c8b1f --- /dev/null +++ b/lib/services/analytics/analytics.d.ts @@ -0,0 +1,97 @@ +/** + * Describes if the user allows to be tracked. + */ +interface IAcceptUsageReportingInformation extends ITrackingInformation { + /** + * The answer of the question if user allows us to track them. + */ + acceptTrackFeatureUsage: boolean; +} + +/** + * Describes information used for tracking feature. + */ +interface IFeatureTrackingInformation extends ITrackingInformation { + /** + * The name of the feature that should be tracked. + */ + featureName: string; + + /** + * Value of the feature that should be tracked. + */ + featureValue: string; +} + +/** + * Describes information for exception that should be tracked. + */ +interface IExceptionsTrackingInformation extends ITrackingInformation { + /** + * The exception that should be tracked. + */ + exception: Error; + + /** + * The message of the error that should be tracked. + */ + message: string; +} + +/** + * Describes the broker used to pass information to all analytics providers. + */ +interface IAnalyticsBroker { + /** + * Sends the specified tracking information to all providers. + * @param {ITrackingInformation} trackInfo The information that should be passed to all providers. + * @returns {Promise} + */ + sendDataForTracking(trackInfo: ITrackingInformation): Promise; +} + +/** + * Describes analytics provider used for tracking in a specific Analytics Service. + */ +interface IAnalyticsProvider { + /** + * Sends exception for tracking in the analytics service provider. + * @param {IExceptionsTrackingInformation} trackInfo The information for exception that should be tracked. + * @returns {Promise} + */ + trackError(trackInfo: IExceptionsTrackingInformation): Promise; + + /** + * Sends feature for tracking in the analytics service provider. + * @param {IFeatureTrackingInformation} trackInfo The information for feature that should be tracked. + * @returns {Promise} + */ + trackInformation(trackInfo: IFeatureTrackingInformation): Promise; + + /** + * Sends information if user accepts to be tracked. + * @param {IAcceptUsageReportingInformation} trackInfo The information, containing user's answer if they allow to be tracked. + * @returns {Promise} + */ + acceptFeatureUsageTracking(data: IAcceptUsageReportingInformation): Promise; + + /** + * Waits for execution of all pending requests and finishes tracking operation + * @returns {Promise} + */ + finishTracking(): Promise; +} + +interface IGoogleAnalyticsTrackingInformation extends IGoogleAnalyticsData, ITrackingInformation { } + +/** + * Describes methods required to track in Google Analytics. + */ +interface IGoogleAnalyticsProvider { + /** + * Tracks hit types. + * @param {IGoogleAnalyticsData} data Data that has to be tracked. + * @returns {Promise} + */ + trackHit(data: IGoogleAnalyticsData): Promise; +} diff --git a/lib/services/analytics/eqatec-analytics-provider.ts b/lib/services/analytics/eqatec-analytics-provider.ts new file mode 100644 index 0000000000..e2b2f43163 --- /dev/null +++ b/lib/services/analytics/eqatec-analytics-provider.ts @@ -0,0 +1,60 @@ +import { AnalyticsServiceBase } from "../../common/services/analytics-service-base"; + +export class EqatecAnalyticsProvider extends AnalyticsServiceBase implements IAnalyticsProvider { + private static ANALYTICS_FEATURE_USAGE_TRACKING_API_KEY = "9912cff308334c6d9ad9c33f76a983e3"; + private static NEW_PROJECT_ANALYTICS_API_KEY = "b40f24fcb4f94bccaf64e4dc6337422e"; + + protected featureTrackingAPIKeys: string[] = [ + this.$staticConfig.ANALYTICS_API_KEY, + EqatecAnalyticsProvider.NEW_PROJECT_ANALYTICS_API_KEY + ]; + + protected acceptUsageReportingAPIKeys: string[] = [ + EqatecAnalyticsProvider.ANALYTICS_FEATURE_USAGE_TRACKING_API_KEY + ]; + + constructor(protected $logger: ILogger, + protected $options: IOptions, + $staticConfig: Config.IStaticConfig, + $prompter: IPrompter, + $userSettingsService: UserSettings.IUserSettingsService, + $analyticsSettingsService: IAnalyticsSettingsService, + $osInfo: IOsInfo) { + super($logger, $options, $staticConfig, $prompter, $userSettingsService, $analyticsSettingsService, $osInfo); + } + + public async trackInformation(data: IFeatureTrackingInformation): Promise { + try { + await this.trackFeatureCore(`${data.featureName}.${data.featureValue}`); + } catch (e) { + this.$logger.trace(`Analytics exception: ${e}`); + } + } + + public async trackError(data: IExceptionsTrackingInformation): Promise { + try { + await this.trackException(data.exception, data.message); + } catch (e) { + this.$logger.trace(`Analytics exception: ${e}`); + } + } + + public async acceptFeatureUsageTracking(data: IAcceptUsageReportingInformation): Promise { + try { + await this.trackAcceptFeatureUsage({ acceptTrackFeatureUsage: data.acceptTrackFeatureUsage }); + } catch (e) { + this.$logger.trace(`Analytics exception: ${e}`); + } + } + + public async finishTracking(): Promise { + this.tryStopEqatecMonitors(); + } + + public dispose(): void { + // Intentionally left blank. + } + +} + +$injector.register("eqatecAnalyticsProvider", EqatecAnalyticsProvider); diff --git a/lib/services/analytics/google-analytics-custom-dimensions.ts b/lib/services/analytics/google-analytics-custom-dimensions.ts new file mode 100644 index 0000000000..9e7c1d7007 --- /dev/null +++ b/lib/services/analytics/google-analytics-custom-dimensions.ts @@ -0,0 +1,8 @@ +const enum GoogleAnalyticsCustomDimensions { + cliVersion = "cd1", + projectType = "cd2", + clientID = "cd3", + sessionID = "cd4", + client = "cd5", + nodeVersion = "cd6" +} diff --git a/lib/services/analytics/google-analytics-provider.ts b/lib/services/analytics/google-analytics-provider.ts new file mode 100644 index 0000000000..1bb8d9fe13 --- /dev/null +++ b/lib/services/analytics/google-analytics-provider.ts @@ -0,0 +1,120 @@ +import * as uuid from "uuid"; +import * as ua from "universal-analytics"; +import { AnalyticsClients } from "../../common/constants"; + +export class GoogleAnalyticsProvider implements IGoogleAnalyticsProvider { + private static GA_TRACKING_ID = "UA-111455-44"; + private currentPage: string; + + constructor(private clientId: string, + private $staticConfig: IStaticConfig, + private $hostInfo: IHostInfo, + private $osInfo: IOsInfo) { + } + + public async trackHit(trackInfo: IGoogleAnalyticsData): Promise { + const visitor = ua({ + tid: GoogleAnalyticsProvider.GA_TRACKING_ID, + cid: this.clientId, + headers: { + ["User-Agent"]: this.getUserAgentString() + } + }); + + this.setCustomDimensions(visitor, trackInfo.customDimensions); + + switch (trackInfo.googleAnalyticsDataType) { + case GoogleAnalyticsDataType.Page: + await this.trackPageView(visitor, trackInfo); + break; + case GoogleAnalyticsDataType.Event: + await this.trackEvent(visitor, trackInfo); + break; + } + } + + private setCustomDimensions(visitor: ua.Visitor, customDimensions: IStringDictionary): void { + const defaultValues: IStringDictionary = { + [GoogleAnalyticsCustomDimensions.cliVersion]: this.$staticConfig.version, + [GoogleAnalyticsCustomDimensions.nodeVersion]: process.version, + [GoogleAnalyticsCustomDimensions.clientID]: this.clientId, + [GoogleAnalyticsCustomDimensions.projectType]: null, + [GoogleAnalyticsCustomDimensions.sessionID]: uuid.v4(), + [GoogleAnalyticsCustomDimensions.client]: AnalyticsClients.Unknown + }; + + customDimensions = _.merge(defaultValues, customDimensions); + + _.each(customDimensions, (value, key) => { + visitor.set(key, value); + }); + } + + private trackEvent(visitor: ua.Visitor, trackInfo: IGoogleAnalyticsEventData): Promise { + return new Promise((resolve, reject) => { + visitor.event(trackInfo.category, trackInfo.action, trackInfo.label, trackInfo.value, { p: this.currentPage }, (err: Error) => { + if (err) { + reject(err); + return; + } + + resolve(); + }); + }); + } + + private trackPageView(visitor: ua.Visitor, trackInfo: IGoogleAnalyticsPageviewData): Promise { + return new Promise((resolve, reject) => { + this.currentPage = trackInfo.path; + + const pageViewData: ua.PageviewParams = { + dp: trackInfo.path, + dt: trackInfo.title + }; + + visitor.pageview(pageViewData, (err) => { + if (err) { + reject(err); + return; + } + + resolve(); + }); + }); + } + + private getUserAgentString(): string { + let osString = ""; + const osRelease = this.$osInfo.release(); + + if (this.$hostInfo.isWindows) { + osString = `Windows NT ${osRelease}`; + } else if (this.$hostInfo.isDarwin) { + osString = `Macintosh`; + const macRelease = this.getMacOSReleaseVersion(osRelease); + if (macRelease) { + osString += `; Intel Mac OS X ${macRelease}`; + } + } else { + osString = `Linux x86`; + if (this.$osInfo.arch() === "x64") { + osString += "_64"; + } + } + + const userAgent = `tnsCli/${this.$staticConfig.version} (${osString}; ${this.$osInfo.arch()})`; + + return userAgent; + } + + private getMacOSReleaseVersion(osRelease: string): string { + // https://en.wikipedia.org/wiki/Darwin_(operating_system)#Release_history + // Each macOS version is labeled 10., where it looks like is taken from the major version returned by os.release() (16.x.x for example) and subtracting 4 from it. + // So the version becomes "10.12" in this case. + // Could be improved by spawning `system_profiler SPSoftwareDataType` and getting the System Version line from the result. + const majorVersion = osRelease && _.first(osRelease.split(".")); + return majorVersion && `10.${+majorVersion - 4}`; + } +} + +$injector.register("googleAnalyticsProvider", GoogleAnalyticsProvider); diff --git a/lib/services/debug-service.ts b/lib/services/debug-service.ts index a8c314019b..ab6e730835 100644 --- a/lib/services/debug-service.ts +++ b/lib/services/debug-service.ts @@ -3,6 +3,7 @@ import { parse } from "url"; import { EventEmitter } from "events"; import { CONNECTION_ERROR_EVENT_NAME, DebugCommandErrors } from "../constants"; import { CONNECTED_STATUS } from "../common/constants"; +import { DebugTools, TrackActionNames } from "../constants"; export class DebugService extends EventEmitter implements IDebugService { private _platformDebugServices: IDictionary; @@ -10,7 +11,8 @@ export class DebugService extends EventEmitter implements IDebugService { private $errors: IErrors, private $injector: IInjector, private $hostInfo: IHostInfo, - private $mobileHelper: Mobile.IMobileHelper) { + private $mobileHelper: Mobile.IMobileHelper, + private $analyticsService: IAnalyticsService) { super(); this._platformDebugServices = {}; } @@ -26,6 +28,13 @@ export class DebugService extends EventEmitter implements IDebugService { this.$errors.failWithoutHelp(`The device with identifier ${debugData.deviceIdentifier} is unreachable. Make sure it is Trusted and try again.`); } + await this.$analyticsService.trackEventActionInGoogleAnalytics({ + action: TrackActionNames.Debug, + device, + additionalData: this.$mobileHelper.isiOSPlatform(device.deviceInfo.platform) && (!options || !options.chrome) ? DebugTools.Inspector : DebugTools.Chrome, + projectDir: debugData.projectDir + }); + if (!(await device.applicationManager.isApplicationInstalled(debugData.applicationIdentifier))) { this.$errors.failWithoutHelp(`The application ${debugData.applicationIdentifier} is not installed on device with identifier ${debugData.deviceIdentifier}.`); } diff --git a/lib/services/livesync/livesync-command-helper.ts b/lib/services/livesync/livesync-command-helper.ts index fae89af3c9..54be3c7a19 100644 --- a/lib/services/livesync/livesync-command-helper.ts +++ b/lib/services/livesync/livesync-command-helper.ts @@ -7,7 +7,9 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { private $iosDeviceOperations: IIOSDeviceOperations, private $mobileHelper: Mobile.IMobileHelper, private $platformsData: IPlatformsData, + private $analyticsService: IAnalyticsService, private $errors: IErrors) { + this.$analyticsService.setShouldDispose(this.$options.justlaunch || !this.$options.watch); } public getPlatformsForOperation(platform: string): string[] { diff --git a/lib/services/livesync/livesync-service.ts b/lib/services/livesync/livesync-service.ts index e4bb9936c6..4d18546ea8 100644 --- a/lib/services/livesync/livesync-service.ts +++ b/lib/services/livesync/livesync-service.ts @@ -3,7 +3,7 @@ import * as choki from "chokidar"; import { EOL } from "os"; import { EventEmitter } from "events"; import { hook } from "../../common/helpers"; -import { APP_FOLDER_NAME, PACKAGE_JSON_FILE_NAME, LiveSyncTrackActionNames, USER_INTERACTION_NEEDED_EVENT_NAME, DEBUGGER_ATTACHED_EVENT_NAME, DEBUGGER_DETACHED_EVENT_NAME } from "../../constants"; +import { APP_FOLDER_NAME, PACKAGE_JSON_FILE_NAME, LiveSyncTrackActionNames, USER_INTERACTION_NEEDED_EVENT_NAME, DEBUGGER_ATTACHED_EVENT_NAME, DEBUGGER_DETACHED_EVENT_NAME, TrackActionNames } from "../../constants"; import { FileExtensions, DeviceTypes } from "../../common/constants"; const deviceDescriptorPrimaryKey = "identifier"; @@ -33,6 +33,7 @@ export class LiveSyncService extends EventEmitter implements IDebugLiveSyncServi private $debugService: IDebugService, private $errors: IErrors, private $debugDataService: IDebugDataService, + private $analyticsService: IAnalyticsService, private $injector: IInjector) { super(); } @@ -366,6 +367,12 @@ export class LiveSyncService extends EventEmitter implements IDebugLiveSyncServi action = LiveSyncTrackActionNames.LIVESYNC_OPERATION_BUILD; } + await this.$analyticsService.trackEventActionInGoogleAnalytics({ + action: TrackActionNames.LiveSync, + device: options.device, + projectDir: options.projectData.projectDir + }); + await this.trackAction(action, platform, options); const shouldInstall = await this.$platformService.shouldInstall(options.device, options.projectData, options.deviceBuildInfoDescriptor.outputPath); diff --git a/lib/services/platform-service.ts b/lib/services/platform-service.ts index 206fbcc2ec..268c2c2095 100644 --- a/lib/services/platform-service.ts +++ b/lib/services/platform-service.ts @@ -1,6 +1,7 @@ import * as path from "path"; import * as shell from "shelljs"; import * as constants from "../constants"; +import { Configurations } from "../common/constants"; import * as helpers from "../common/helpers"; import * as semver from "semver"; import { EventEmitter } from "events"; @@ -466,9 +467,18 @@ export class PlatformService extends EventEmitter implements IPlatformService { public async buildPlatform(platform: string, buildConfig: IBuildConfig, projectData: IProjectData): Promise { this.$logger.out("Building project..."); + const action = constants.TrackActionNames.Build; await this.trackProjectType(projectData); const isForDevice = this.$mobileHelper.isAndroidPlatform(platform) ? null : buildConfig && buildConfig.buildForDevice; - await this.trackActionForPlatform({ action: "Build", platform, isForDevice }); + await this.trackActionForPlatform({ action, platform, isForDevice }); + + await this.$analyticsService.trackEventActionInGoogleAnalytics({ + action, + isForDevice, + platform, + projectDir: projectData.projectDir, + additionalData: `${buildConfig.release ? Configurations.Release : Configurations.Debug}_${buildConfig.clean ? constants.BuildStates.Clean : constants.BuildStates.Incremental}` + }); const platformData = this.$platformsData.getPlatformData(platform, projectData); const handler = (data: any) => { @@ -510,6 +520,13 @@ export class PlatformService extends EventEmitter implements IPlatformService { public async installApplication(device: Mobile.IDevice, buildConfig: IBuildConfig, projectData: IProjectData, packageFile?: string, outputFilePath?: string): Promise { this.$logger.out("Installing..."); + + await this.$analyticsService.trackEventActionInGoogleAnalytics({ + action: constants.TrackActionNames.Deploy, + device, + projectDir: projectData.projectDir + }); + const platformData = this.$platformsData.getPlatformData(device.deviceInfo.platform, projectData); if (!packageFile) { if (this.$devicesService.isiOSSimulator(device)) { @@ -567,7 +584,7 @@ export class PlatformService extends EventEmitter implements IPlatformService { this.$logger.out("Skipping install."); } - await this.trackActionForPlatform({ action: "Deploy", platform: device.deviceInfo.platform, isForDevice: !device.isEmulator, deviceOsVersion: device.deviceInfo.version }); + await this.trackActionForPlatform({ action: constants.TrackActionNames.Deploy, platform: device.deviceInfo.platform, isForDevice: !device.isEmulator, deviceOsVersion: device.deviceInfo.version }); }; await this.$devicesService.execute(action, this.getCanExecuteAction(platform, deployOptions)); diff --git a/lib/services/project-templates-service.ts b/lib/services/project-templates-service.ts index d3cb0a0489..32ac27ec57 100644 --- a/lib/services/project-templates-service.ts +++ b/lib/services/project-templates-service.ts @@ -20,6 +20,12 @@ export class ProjectTemplatesService implements IProjectTemplatesService { await this.$analyticsService.track("Template used for project creation", templateName); + await this.$analyticsService.trackEventActionInGoogleAnalytics({ + action: constants.TrackActionNames.CreateProject, + isForDevice: null, + additionalData: templateName + }); + const realTemplatePath = await this.prepareNativeScriptTemplate(templateName, version, projectDir); // this removes dependencies from templates so they are not copied to app folder diff --git a/lib/services/test-execution-service.ts b/lib/services/test-execution-service.ts index b3d4221d26..318032c65b 100644 --- a/lib/services/test-execution-service.ts +++ b/lib/services/test-execution-service.ts @@ -26,7 +26,9 @@ class TestExecutionService implements ITestExecutionService { private $errors: IErrors, private $debugService: IDebugService, private $devicesService: Mobile.IDevicesService, + private $analyticsService: IAnalyticsService, private $childProcess: IChildProcess) { + this.$analyticsService.setShouldDispose(this.$options.justlaunch || !this.$options.watch); } public platform: string; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 78883fb1e0..eb666497e4 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "nativescript", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -74,6 +74,12 @@ "integrity": "sha1-3TS72OMv5OdPLj2KwH+KpbRaR6w=", "dev": true }, + "@types/universal-analytics": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/universal-analytics/-/universal-analytics-0.4.1.tgz", + "integrity": "sha512-AZSPpDUEZ4mAgO9geHc62dp/xCLmBJ1yIpbgTq5W/cWcVQsxmU/FyKwYKHXk2hnT9TAmYVFFdAijMrCdYjuHsA==", + "dev": true + }, "abbrev": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.0.tgz", @@ -1838,8 +1844,7 @@ }, "jsbn": { "version": "0.1.1", - "bundled": true, - "optional": true + "bundled": true }, "json-schema": { "version": "0.2.3", @@ -2207,8 +2212,7 @@ }, "tweetnacl": { "version": "0.14.5", - "bundled": true, - "optional": true + "bundled": true }, "uid-number": { "version": "0.0.6", @@ -5164,6 +5168,24 @@ "integrity": "sha1-gGmSYzZl1eX8tNsfs6hi62jp5to=", "dev": true }, + "universal-analytics": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.4.15.tgz", + "integrity": "sha512-9Dt6WBWsHsmv74G+N/rmEgi6KFZxVvQXkVhr0disegeUryybQAUQwMD1l5EtqaOu+hSOGbhL/hPPQYisZIqPRw==", + "requires": { + "async": "1.2.1", + "request": "2.81.0", + "underscore": "1.5.2", + "uuid": "3.0.1" + }, + "dependencies": { + "async": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/async/-/async-1.2.1.tgz", + "integrity": "sha1-pIFqF81f9RbfosdpikUzabl5DeA=" + } + } + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 114d1bc60d..1d2593406f 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "mocha": "node test-scripts/mocha.js", "tsc": "tsc", "tslint": "tslint -p tsconfig.json --type-check", - "test-watch": "node ./dev/tsc-to-mocha-watch.js", - "tslint-fix": "tslint -p tsconfig.json --type-check --fix" + "test-watch": "node ./dev/tsc-to-mocha-watch.js", + "tslint-fix": "tslint -p tsconfig.json --type-check --fix" }, "repository": { "type": "git", @@ -72,6 +72,7 @@ "source-map": "0.5.6", "tabtab": "https://github.com/Icenium/node-tabtab/tarball/master", "temp": "0.8.3", + "universal-analytics": "0.4.15", "uuid": "3.0.1", "winreg": "0.0.17", "ws": "2.2.0", @@ -91,6 +92,7 @@ "@types/request": "0.0.45", "@types/semver": "^5.3.31", "@types/source-map": "0.5.0", + "@types/universal-analytics": "0.4.1", "chai": "4.0.2", "chai-as-promised": "7.0.0", "grunt": "1.0.1", diff --git a/test/nativescript-cli-lib.ts b/test/nativescript-cli-lib.ts index 2168f38da0..10854e48f0 100644 --- a/test/nativescript-cli-lib.ts +++ b/test/nativescript-cli-lib.ts @@ -21,7 +21,6 @@ describe("nativescript-cli-lib", () => { npm: ["install", "uninstall", "view", "search"], extensibilityService: ["loadExtensions", "loadExtension", "getInstalledExtensions", "installExtension", "uninstallExtension"], liveSyncService: ["liveSync", "stopLiveSync", "enableDebugging", "disableDebugging", "attachDebugger"], - analyticsService: ["startEqatecMonitor"], debugService: ["debug"] }; diff --git a/test/plugins-service.ts b/test/plugins-service.ts index 57533401b3..3fdd2c24f2 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -76,7 +76,10 @@ function createTestInjector() { testInjector.register("analyticsService", { trackException: () => { return Promise.resolve(); }, checkConsent: () => { return Promise.resolve(); }, - trackFeature: () => { return Promise.resolve(); } + trackFeature: () => { return Promise.resolve(); }, + trackEventActionInGoogleAnalytics: (data: IEventActionData) => Promise.resolve(), + trackInGoogleAnalytics: (data: IGoogleAnalyticsData) => Promise.resolve(), + trackAcceptFeatureUsage: (settings: { acceptTrackFeatureUsage: boolean }) => Promise.resolve() }); testInjector.register("projectFilesManager", ProjectFilesManager); testInjector.register("pluginVariablesService", { diff --git a/test/project-service.ts b/test/project-service.ts index 4cb3367dce..d0d3090367 100644 --- a/test/project-service.ts +++ b/test/project-service.ts @@ -137,7 +137,10 @@ class ProjectIntegrationTest { this.testInjector.register("fs", FileSystem); this.testInjector.register("projectDataService", ProjectDataServiceLib.ProjectDataService); this.testInjector.register("staticConfig", StaticConfig); - this.testInjector.register("analyticsService", { track: async (): Promise => undefined }); + this.testInjector.register("analyticsService", { + track: async (): Promise => undefined, + trackEventActionInGoogleAnalytics: (data: IEventActionData) => Promise.resolve() + }); this.testInjector.register("npmInstallationManager", NpmInstallationManager); this.testInjector.register("npm", NpmLib.NodePackageManager); diff --git a/test/project-templates-service.ts b/test/project-templates-service.ts index 46e2d1c918..e227a232e3 100644 --- a/test/project-templates-service.ts +++ b/test/project-templates-service.ts @@ -50,7 +50,10 @@ function createTestInjector(configuration?: { shouldNpmInstallThrow: boolean, np injector.register("projectTemplatesService", ProjectTemplatesService); - injector.register("analyticsService", { track: async (): Promise => undefined }); + injector.register("analyticsService", { + track: async (): Promise => undefined, + trackEventActionInGoogleAnalytics: (data: IEventActionData) => Promise.resolve() + }); return injector; } diff --git a/test/services/debug-service.ts b/test/services/debug-service.ts index 8d4887c61f..e3ab301b44 100644 --- a/test/services/debug-service.ts +++ b/test/services/debug-service.ts @@ -93,6 +93,10 @@ describe("debugService", () => { testInjector.register("logger", stubs.LoggerStub); + testInjector.register("analyticsService", { + trackEventActionInGoogleAnalytics: (data: IEventActionData) => Promise.resolve() + }); + return testInjector; }; diff --git a/test/services/livesync-service.ts b/test/services/livesync-service.ts index 0dde25150b..3279bea316 100644 --- a/test/services/livesync-service.ts +++ b/test/services/livesync-service.ts @@ -25,6 +25,7 @@ const createTestInjector = (): IInjector => { }); testInjector.register("pluginsService", {}); + testInjector.register("analyticsService", {}); testInjector.register("injector", testInjector); return testInjector; @@ -44,6 +45,7 @@ class LiveSyncServiceInheritor extends LiveSyncService { $debugService: IDebugService, $errors: IErrors, $debugDataService: IDebugDataService, + $analyticsService: IAnalyticsService, $injector: IInjector) { super( @@ -60,6 +62,7 @@ class LiveSyncServiceInheritor extends LiveSyncService { $debugService, $errors, $debugDataService, + $analyticsService, $injector ); }