Skip to content

Commit 3df2fb8

Browse files
Add tracking to Google Analytics
Add new methods to track information in Google Analytics(GA). As the data that we want to track is different than the one we track in Eqatec Analytics, call the new methods whenever we want to send some information to GA. Change the broker process to send information to Eqatec Analytics when we want to track feature or exception and to GA only when the new method for tracking there is called. In Google Analytics (GA) we track two types of data - pages (called pageviews) and events. Each command is tracked as a page - we only track the name of the command as a page: * `tns build android` will be tracked as visited page - `build android` * `tns run` will be tracked as visited page - `run` When we want to track some additional actions that happen during execution of each command, we send events to GA. Each event has the following data: * category - currently we do not have a specific category for each action, so it is hardcoded to "CLI". * action - this is the real action that will be tracked, for example `Build`, `LiveSync`, `Deploy`, etc. * label - contains specific inforamation for the current action, for example what is the platform, device type, etc. In many cases we send labels with a lot of information, but as the value must be string, we use `_` as a separator. For example: * Action `Build` may have the following label: `Android_Debug_Incremental`. This is `<platform>_<build configuration>_<build type>`. * Action `LiveSync` may have the following label: `Android_Emulator_5.1`. This is `<platform>_<device type>_<device os version>`. Custom dimensions For each additional data may be send to GA via Custom Dimensions. More about them can be found [here](https://support.google.com/analytics/answer/2709828). We are using several custom dimensions, most of them have hit scope, i.e. their values are saved for the current hit (event or page), but may be changed for the next time. The only exclusion is `session id` - it has session scope, so whenever it is sent, the new value overwrites the values of the "session id" property in all hits of the current session. One interesting custom dimension is `Client` - it will be set to the value of `--analyticsClient` in case it is passed. In case not, we'll set it to "CLI" when terminal is interactive and "Unknown" when terminal is not interactive. Sessions - what is session for GA In Eqatec Analytics we call `stopMonitor` at the end of each command. So the session there is one command. In GA we've decided to keep the default way of measuring a session - a session is stopped when there are 30 inactive minutes, i.e. no data is sent from 30 minutes. This allows us to investigate the behavior and the flow of commands a user is executing in a single session (during the day for example). Client ID and User ID: Currently we do not send User ID to GA, we use Client ID instead as sending User IDs requires different logic and agreements. For us all users are anonymous. User-Agent When sending requests to GA, we need to set correct User-Agent, so GA will be able to understand the Operating System. For each OS the string is painfully specific... Current implementation works fine for Windows, Linux and macOS. Implementation In order to send data to GA, we are using a `universal-analytics` npm package. We cannot use Google's default libraries (`analytics.js`, `gtag.js`) as they are written for browser. So the only way to track data is to use the Measurement Protocol (directly send http requests). However, `universal-analytics` package provides a very good abstraction over the Measurement Protocol, so we are using it instead of implementing each call. More information about Measurement protocol: https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters
1 parent 4b80f81 commit 3df2fb8

21 files changed

+394
-85
lines changed

lib/bootstrap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ $injector.require("androidDebugService", "./services/android-debug-service");
3030

3131
$injector.require("userSettingsService", "./services/user-settings-service");
3232
$injector.require("analyticsSettingsService", "./services/analytics-settings-service");
33-
$injector.requirePublic("analyticsService", "./services/analytics/analytics-service");
33+
$injector.require("analyticsService", "./services/analytics/analytics-service");
3434
$injector.require("eqatecAnalyticsProvider", "./services/analytics/eqatec-analytics-provider");
35+
$injector.require("googleAnalyticsProvider", "./services/analytics/google-analytics-provider");
3536

3637
$injector.require("emulatorSettingsService", "./services/emulator-settings-service");
3738

lib/constants.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,21 @@ export const enum NativePlatformStatus {
103103
requiresPrepare = "2",
104104
alreadyPrepared = "3"
105105
}
106+
107+
export const enum DebugTools {
108+
Chrome = "Chrome",
109+
Inspector = "Inspector"
110+
}
111+
112+
export const enum TrackActionNames {
113+
Build = "Build",
114+
CreateProject = "Create project",
115+
Debug = "Debug",
116+
Deploy = "Deploy",
117+
LiveSync = "LiveSync"
118+
}
119+
120+
export const enum BuildStates {
121+
Clean = "Clean",
122+
Incremental = "Incremental"
123+
}

lib/nativescript-cli-lib-bootstrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ $injector.requirePublicClass("localBuildService", "./services/local-build-servic
1313
// We need this because some services check if (!$options.justlaunch) to start the device log after some operation.
1414
// We don't want this behaviour when the CLI is required as library.
1515
$injector.resolve("options").justlaunch = true;
16+
$injector.resolve<IStaticConfig>("staticConfig").disableAnalytics = true;

lib/services/analytics-settings-service.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,12 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService {
1111
return true;
1212
}
1313

14-
public async getUserId(): Promise<string> {
15-
let currentUserId = await this.$userSettingsService.getSettingValue<string>("USER_ID");
16-
if (!currentUserId) {
17-
currentUserId = createGUID(false);
18-
19-
this.$logger.trace(`Setting new USER_ID: ${currentUserId}.`);
20-
await this.$userSettingsService.saveSetting<string>("USER_ID", currentUserId);
21-
}
14+
public getUserId(): Promise<string> {
15+
return this.getSettingValueOrDefault("USER_ID");
16+
}
2217

23-
return currentUserId;
18+
public getClientId(): Promise<string> {
19+
return this.getSettingValueOrDefault(this.$staticConfig.ANALYTICS_INSTALLATION_ID_SETTING_NAME);
2420
}
2521

2622
public getClientName(): string {
@@ -43,5 +39,17 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService {
4339
private getSessionsProjectKey(projectName: string): string {
4440
return `${AnalyticsSettingsService.SESSIONS_STARTED_KEY_PREFIX}${projectName}`;
4541
}
42+
43+
private async getSettingValueOrDefault(settingName: string): Promise<string> {
44+
let guid = await this.$userSettingsService.getSettingValue<string>(settingName);
45+
if (!guid) {
46+
guid = createGUID(false);
47+
48+
this.$logger.trace(`Setting new ${settingName}: ${guid}.`);
49+
await this.$userSettingsService.saveSetting<string>(settingName, guid);
50+
}
51+
52+
return guid;
53+
}
4654
}
4755
$injector.register("analyticsSettingsService", AnalyticsSettingsService);

lib/services/analytics/analytics-broker.ts

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,41 @@ import { cache } from "../../common/decorators";
33
export class AnalyticsBroker implements IAnalyticsBroker {
44

55
@cache()
6-
private get $eqatecAnalyticsProvider(): IAnalyticsProvider {
7-
return this.$injector.resolve("eqatecAnalyticsProvider", { pathToBootstrap: this.pathToBootstrap });
6+
private async getEqatecAnalyticsProvider(): Promise<IAnalyticsProvider> {
7+
return this.$injector.resolve("eqatecAnalyticsProvider");
88
}
99

10-
constructor(private pathToBootstrap: string,
11-
private $injector: IInjector) { }
12-
13-
private get analyticsProviders(): IAnalyticsProvider[] {
14-
return [
15-
this.$eqatecAnalyticsProvider
16-
];
10+
@cache()
11+
private async getGoogleAnalyticsProvider(): Promise<IGoogleAnalyticsProvider> {
12+
const clientId = await this.$analyticsSettingsService.getClientId();
13+
return this.$injector.resolve("googleAnalyticsProvider", { clientId });
1714
}
1815

19-
public async sendDataForTracking(trackInfo: ITrackingInformation): Promise<void> {
20-
for (const provider of this.analyticsProviders) {
21-
switch (trackInfo.type) {
22-
case TrackingTypes.Exception:
23-
await provider.trackError(<IExceptionsTrackingInformation>trackInfo);
24-
break;
25-
case TrackingTypes.Feature:
26-
await provider.trackInformation(<IFeatureTrackingInformation>trackInfo);
27-
break;
28-
case TrackingTypes.AcceptTrackFeatureUsage:
29-
await provider.acceptFeatureUsageTracking(<IAcceptUsageReportingInformation>trackInfo);
30-
break;
31-
case TrackingTypes.Finish:
32-
await provider.finishTracking();
33-
break;
34-
default:
35-
throw new Error(`Invalid tracking type: ${trackInfo.type}`);
36-
}
16+
constructor(private $analyticsSettingsService: IAnalyticsSettingsService,
17+
private $injector: IInjector) { }
3718

19+
public async sendDataForTracking(trackInfo: ITrackingInformation): Promise<void> {
20+
const eqatecProvider = await this.getEqatecAnalyticsProvider();
21+
const googleProvider = await this.getGoogleAnalyticsProvider();
22+
23+
switch (trackInfo.type) {
24+
case TrackingTypes.Exception:
25+
await eqatecProvider.trackError(<IExceptionsTrackingInformation>trackInfo);
26+
break;
27+
case TrackingTypes.Feature:
28+
await eqatecProvider.trackInformation(<IFeatureTrackingInformation>trackInfo);
29+
break;
30+
case TrackingTypes.AcceptTrackFeatureUsage:
31+
await eqatecProvider.acceptFeatureUsageTracking(<IAcceptUsageReportingInformation>trackInfo);
32+
break;
33+
case TrackingTypes.GoogleAnalyticsData:
34+
await googleProvider.trackHit(<IGoogleAnalyticsTrackingInformation>trackInfo);
35+
break;
36+
case TrackingTypes.Finish:
37+
await eqatecProvider.finishTracking();
38+
break;
39+
default:
40+
throw new Error(`Invalid tracking type: ${trackInfo.type}`);
3841
}
3942

4043
}

lib/services/analytics/analytics-service.ts

Lines changed: 91 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { AnalyticsServiceBase } from "../../common/services/analytics-service-ba
22
import { ChildProcess } from "child_process";
33
import * as path from "path";
44
import { cache } from "../../common/decorators";
5+
import { isInteractive } from '../../common/helpers';
6+
import { DeviceTypes, AnalyticsClients } from "../../common/constants";
57

68
export class AnalyticsService extends AnalyticsServiceBase implements IAnalyticsService {
79
private static ANALYTICS_BROKER_START_TIMEOUT = 30 * 1000;
@@ -14,26 +16,100 @@ export class AnalyticsService extends AnalyticsServiceBase implements IAnalytics
1416
$analyticsSettingsService: IAnalyticsSettingsService,
1517
$osInfo: IOsInfo,
1618
private $childProcess: IChildProcess,
17-
private $processService: IProcessService) {
19+
private $processService: IProcessService,
20+
private $projectDataService: IProjectDataService,
21+
private $mobileHelper: Mobile.IMobileHelper) {
1822
super($logger, $options, $staticConfig, $prompter, $userSettingsService, $analyticsSettingsService, $osInfo);
1923
}
2024

2125
public track(featureName: string, featureValue: string): Promise<void> {
22-
return this.sendDataForTracking(featureName, featureValue);
26+
const data: IFeatureTrackingInformation = {
27+
type: TrackingTypes.Feature,
28+
featureName: featureName,
29+
featureValue: featureValue
30+
};
31+
32+
return this.sendInfoForTracking(data, this.$staticConfig.TRACK_FEATURE_USAGE_SETTING_NAME);
2333
}
2434

2535
public trackException(exception: any, message: string): Promise<void> {
26-
return this.sendExceptionForTracking(exception, message);
36+
const data: IExceptionsTrackingInformation = {
37+
type: TrackingTypes.Exception,
38+
exception,
39+
message
40+
};
41+
42+
return this.sendInfoForTracking(data, this.$staticConfig.ERROR_REPORT_SETTING_NAME);
2743
}
2844

2945
public async trackAcceptFeatureUsage(settings: { acceptTrackFeatureUsage: boolean }): Promise<void> {
30-
31-
this.sendMessageToBroker(<IAcceptUsageReportingInformation> {
46+
this.sendMessageToBroker(<IAcceptUsageReportingInformation>{
3247
type: TrackingTypes.AcceptTrackFeatureUsage,
3348
acceptTrackFeatureUsage: settings.acceptTrackFeatureUsage
3449
});
3550
}
3651

52+
public async trackInGoogleAnalytics(gaSettings: IGoogleAnalyticsData): Promise<void> {
53+
await this.initAnalyticsStatuses();
54+
55+
if (!this.$staticConfig.disableAnalytics && this.analyticsStatuses[this.$staticConfig.TRACK_FEATURE_USAGE_SETTING_NAME] === AnalyticsStatus.enabled) {
56+
gaSettings.customDimensions = gaSettings.customDimensions || {};
57+
gaSettings.customDimensions[GoogleAnalyticsCustomDimensions.client] = this.$options.analyticsClient || (isInteractive() ? AnalyticsClients.Cli : AnalyticsClients.Unknown);
58+
59+
const googleAnalyticsData: IGoogleAnalyticsTrackingInformation = _.merge({ type: TrackingTypes.GoogleAnalyticsData }, gaSettings, { category: AnalyticsClients.Cli });
60+
return this.sendMessageToBroker(googleAnalyticsData);
61+
}
62+
}
63+
64+
public async trackEventActionInGoogleAnalytics(data: IEventActionData): Promise<void> {
65+
const device = data.device;
66+
const platform = device ? device.deviceInfo.platform : data.platform;
67+
const isForDevice = device ? !device.isEmulator : data.isForDevice;
68+
69+
let label: string = "";
70+
label = this.addDataToLabel(label, platform);
71+
72+
if (isForDevice !== null) {
73+
// In case action is Build and platform is Android, we do not know if the deviceType is emulator or device.
74+
// Just exclude the device_type in this case.
75+
const deviceType = isForDevice ? DeviceTypes.Device : (this.$mobileHelper.isAndroidPlatform(platform) ? DeviceTypes.Emulator : DeviceTypes.Simulator);
76+
label = this.addDataToLabel(label, deviceType);
77+
}
78+
79+
if (device) {
80+
label = this.addDataToLabel(label, device.deviceInfo.version);
81+
}
82+
83+
if (data.additionalData) {
84+
label = this.addDataToLabel(label, data.additionalData);
85+
}
86+
87+
const customDimensions: IStringDictionary = {};
88+
if (data.projectDir) {
89+
const projectData = this.$projectDataService.getProjectData(data.projectDir);
90+
customDimensions[GoogleAnalyticsCustomDimensions.projectType] = projectData.projectType;
91+
}
92+
93+
const googleAnalyticsEventData: IGoogleAnalyticsEventData = {
94+
googleAnalyticsDataType: GoogleAnalyticsDataType.Event,
95+
action: data.action,
96+
label,
97+
customDimensions
98+
};
99+
100+
this.$logger.trace("Will send the following information to Google Analytics:", googleAnalyticsEventData);
101+
102+
await this.trackInGoogleAnalytics(googleAnalyticsEventData);
103+
}
104+
105+
private addDataToLabel(label: string, newData: string): string {
106+
if (newData && label) {
107+
return `${label}_${newData}`;
108+
}
109+
110+
return label || newData || "";
111+
}
112+
37113
@cache()
38114
private getAnalyticsBroker(): Promise<ChildProcess> {
39115
return new Promise<ChildProcess>((resolve, reject) => {
@@ -73,44 +149,25 @@ export class AnalyticsService extends AnalyticsServiceBase implements IAnalytics
73149

74150
if (!isSettled) {
75151
isSettled = true;
152+
153+
this.$processService.attachToProcessExitSignals(this, () => {
154+
broker.send({
155+
type: TrackingTypes.Finish
156+
});
157+
});
158+
76159
resolve(broker);
77160
}
78161
}
79162
});
80-
81-
this.$processService.attachToProcessExitSignals(this, () => {
82-
broker.send({
83-
type: TrackingTypes.Finish
84-
});
85-
});
86163
});
87164
}
88165

89-
private async sendDataForTracking(featureName: string, featureValue: string): Promise<void> {
90-
await this.initAnalyticsStatuses();
91-
92-
if (this.analyticsStatuses[this.$staticConfig.TRACK_FEATURE_USAGE_SETTING_NAME] === AnalyticsStatus.enabled) {
93-
return this.sendMessageToBroker(
94-
<IFeatureTrackingInformation> {
95-
type: TrackingTypes.Feature,
96-
featureName: featureName,
97-
featureValue: featureValue
98-
}
99-
);
100-
}
101-
}
102-
103-
private async sendExceptionForTracking(exception: Error, message: string): Promise<void> {
166+
private async sendInfoForTracking(trackingInfo: ITrackingInformation, settingName: string): Promise<void> {
104167
await this.initAnalyticsStatuses();
105168

106-
if (this.analyticsStatuses[this.$staticConfig.ERROR_REPORT_SETTING_NAME] === AnalyticsStatus.enabled) {
107-
return this.sendMessageToBroker(
108-
<IExceptionsTrackingInformation> {
109-
type: TrackingTypes.Exception,
110-
exception,
111-
message
112-
}
113-
);
169+
if (!this.$staticConfig.disableAnalytics && this.analyticsStatuses[settingName] === AnalyticsStatus.enabled) {
170+
return this.sendMessageToBroker(trackingInfo);
114171
}
115172
}
116173

lib/services/analytics/analytics.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,17 @@ interface IAnalyticsProvider {
8181
*/
8282
finishTracking(): Promise<void>;
8383
}
84+
85+
interface IGoogleAnalyticsTrackingInformation extends IGoogleAnalyticsData, ITrackingInformation { }
86+
87+
/**
88+
* Describes methods required to track in Google Analytics.
89+
*/
90+
interface IGoogleAnalyticsProvider {
91+
/**
92+
* Tracks hit types.
93+
* @param {IGoogleAnalyticsData} data Data that has to be tracked.
94+
* @returns {Promise<void>}
95+
*/
96+
trackHit(data: IGoogleAnalyticsData): Promise<void>;
97+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const enum GoogleAnalyticsCustomDimensions {
2+
cliVersion = "cd1",
3+
projectType = "cd2",
4+
clientID = "cd3",
5+
sessionID = "cd4",
6+
client = "cd5",
7+
nodeVersion = "cd6"
8+
}

0 commit comments

Comments
 (0)