Skip to content

Commit 47be8d7

Browse files
Improve analytics (#3088)
* Improve analytics We have a requirement to track information in several different providers. However, this may slow down CLI's operations as each track (at least for some of the providers) may last around 100-300ms. In order to track in multiple analytics provider without slowing down CLI commands, move the tracking to a new process. The process is detached from CLI, so when CLI finishes its work it will exit, while the child process will remain alive until it sends all required information. The new process is called `analytics-broker-process`, it is detached from CLI, but it there's IPC channel between them, so CLI can send information to be tracked. Just before CLI dies, it will send "finish" message to the `broker` process and the IPC channel will be closed. In CLI implement a new `analytics-service`, so when the methods `track`, `trackFeature` and `trackException` are called, instead of tracking directly in CLI's process (i.e. the old implementation), to send information to the broker process. The only exception is tracking of the answer if user allows us to send anonymous statistics. As this action requires user interaction, it is executed in CLI's process, a new eqatec analytics monitor is started and the answer is sent to it. After that the analytics is stopped. In case the answer is "yes" and there is more information that we want to track in the current process, it will be send to broker and tracked in all providers. In case the answer is "no", no other data will be tracked. The broker process is started from analytics process once per process, i.e. on each CLI process (which have something to track) there's a corresponding `analytics-broker-process`. Once it is initialized, it sends BrokerReadyToReceive message to CLI and CLI knows that it can start sending tracking information. Initialization phase is creating a new instance of AnalyticsBroker and attaching handlers for `message` and `disconnect` events. The AnalyitcsBroker itself has a single purpose - sends tracking data to all available prociders. Currently the only provider is `eqatecAnalyticsProvider`. Once CLI sends "finish" message (or `analytics-broker-process` receives `disconnect` event), the process sends information to all providers that there's nothing more to be tracked. When all providers finish their work, they should disocnnect themselves from the broker process and it will exit gracefully. `EqatecAnalyticsProvider` is used to track features and exceptions in Progress Telerik Analytics. Our requirement is to track features in two projects (same data should be send to both of them) and exceptions in another project. Each of them should be in a separate child process, so failure in one of them will not reflect the others. So the provider starts all required processes. When broker sends information to the provider, it sends it to all required child processes, for example if a feature should be tracked, the provider will send information for it to the two processes used for feature tracking. Each eqatec child process is a new Node.js process runnign `eqatec-analytics-process`. Again - this is a detached process. Once it is initialized, it sends "EqatecAnalyticsReadyToReceive" message to the `EqatecAnalyitcsProvider` and it knows it can send information to the new child process. The initialization phase is creation of new instance of `EqatectAnalyticsService`, attaching to "message" and "disconnect" events. Once `EqatecAnalyitcsProvider` receives `EqatecAnalyticsReadyToReceive` message, it sends initialization data for the eqatec monitor. It includes the analytics project API key, user id, current sessions count, etc. The information is persisted in a file on user's machine, so this file should be updated from a single process. As we might have 3 eqatec analytics processes, the provider is the only place where the file should be read and updated. When `eqatec-analytics-process` receives initialization data, it creates a new instance of the `EqatectAnalyticsService` class with it. However a monitor is not started yet. It will be started when there's something to be tracked, i.e. once `eqatec-analytics-process` receives feature or exception to be tracked. When `eqatec-analytics-process` receives "finish" message, it awaits all pending requests and after that disconnets itself from the parent. In case this operation fails, the process just exits, which will allow the parent to exit gracefully. Remove the public `startEqatecMonitor` method from API - it has not been used. * Craete all eqatec analytics monitors in the broker process Eqatec Analytics monitors can be created in the broker process directly instead of starting multiple child processes. So delete some unneeded files and move tracking of `AcceptUsageReporting` out of CLI process. * 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 * Fix hanging commands In case the command is not related to LiveSync, once it finishes its work, CLI process should end. However, this works only on Windows, as the broker child process there is really detached from the parent. On macOS and Linux, the 'ipc' channel between the broker process and CLI, keeps both alive. So use the 'dispose' method, which is called at the end of command execution and disconnect the broker process. In case we are in LiveSync case, set shouldDispose to false and this way the broker will be kept alive. Once Ctrl + C is used to end the CLI process, send the finish message to broker and it will disconnect itself from CLI.
1 parent 570ebbb commit 47be8d7

32 files changed

+746
-88
lines changed

.vscode/launch.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,19 @@
6767
{
6868
"type": "node",
6969
"request": "attach",
70-
"name": "Attach to Process",
71-
"port": 5858,
70+
"name": "Attach to Broker Process",
71+
// In case you want to debug Analytics Broker process, add `--debug-brk=9897` (or --inspect-brk=9897) when spawning analytics-broker-process.
72+
"port": 9897,
73+
"sourceMaps": true
74+
},
75+
76+
{
77+
"type": "node",
78+
"request": "attach",
79+
"name": "Attach to Eqatec Process",
80+
// In case you want to debug Eqatec Analytics process, add `--debug-brk=9855` (or --inspect-brk=9855) when spawning eqatec-analytics-process.
81+
// NOTE: Ensure you set it only for one of the analytics processes.
82+
"port": 9855,
7283
"sourceMaps": true
7384
}
7485

PublicAPI.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -394,21 +394,6 @@ tns.npm.view(["nativescript"], {}).then(result => {
394394
});
395395
```
396396
397-
## analyticsService
398-
Provides a way to configure analytics.
399-
400-
### startEqatecMonitor
401-
* Definition:
402-
```TypeScript
403-
/**
404-
* Starts analytics monitor with provided key.
405-
* @param {string} projectApiKey API key with which to start analytics monitor.
406-
* @returns {Promise<void>}.
407-
*/
408-
startEqatecMonitor(projectApiKey: string): Promise<void>;
409-
```
410-
411-
412397
## debugService
413398
Provides methods for debugging applications on devices. The service is also event emitter, that raises the following events:
414399
* `connectionError` event - this event is raised when the debug operation cannot start on iOS device. The causes can be:

lib/bootstrap.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +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-service");
33+
$injector.require("analyticsService", "./services/analytics/analytics-service");
34+
$injector.require("eqatecAnalyticsProvider", "./services/analytics/eqatec-analytics-provider");
35+
$injector.require("googleAnalyticsProvider", "./services/analytics/google-analytics-provider");
3436

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

lib/commands/run.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ export class RunCommandBase implements ICommand {
55

66
public platform: string;
77
constructor(protected $platformService: IPlatformService,
8-
protected $liveSyncService: ILiveSyncService,
98
protected $projectData: IProjectData,
109
protected $options: IOptions,
1110
protected $devicePlatformsConstants: Mobile.IDevicePlatformsConstants,

lib/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class StaticConfig extends StaticConfigBase implements IStaticConfig {
7373
}
7474

7575
public get PATH_TO_BOOTSTRAP(): string {
76-
return path.join(__dirname, "bootstrap");
76+
return path.join(__dirname, "bootstrap.js");
7777
}
7878

7979
public async getAdbFilePath(): Promise<string> {

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-service.ts

Lines changed: 0 additions & 34 deletions
This file was deleted.
Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createGUID } from "../common/helpers";
22

33
class AnalyticsSettingsService implements IAnalyticsSettingsService {
4-
private static SESSIONS_STARTED_OBSOLETE_KEY = "SESSIONS_STARTED";
54
private static SESSIONS_STARTED_KEY_PREFIX = "SESSIONS_STARTED_";
65

76
constructor(private $userSettingsService: UserSettings.IUserSettingsService,
@@ -12,16 +11,12 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService {
1211
return true;
1312
}
1413

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

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

2722
public getClientName(): string {
@@ -33,14 +28,8 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService {
3328
}
3429

3530
public async getUserSessionsCount(projectName: string): Promise<number> {
36-
const oldSessionCount = await this.$userSettingsService.getSettingValue<number>(AnalyticsSettingsService.SESSIONS_STARTED_OBSOLETE_KEY);
37-
38-
if (oldSessionCount) {
39-
// remove the old property for sessions count
40-
await this.$userSettingsService.removeSetting(AnalyticsSettingsService.SESSIONS_STARTED_OBSOLETE_KEY);
41-
}
42-
43-
return await this.$userSettingsService.getSettingValue<number>(this.getSessionsProjectKey(projectName)) || oldSessionCount || 0;
31+
const sessionsCountForProject = await this.$userSettingsService.getSettingValue<number>(this.getSessionsProjectKey(projectName));
32+
return sessionsCountForProject || 0;
4433
}
4534

4635
public async setUserSessionsCount(count: number, projectName: string): Promise<void> {
@@ -50,5 +39,17 @@ class AnalyticsSettingsService implements IAnalyticsSettingsService {
5039
private getSessionsProjectKey(projectName: string): string {
5140
return `${AnalyticsSettingsService.SESSIONS_STARTED_KEY_PREFIX}${projectName}`;
5241
}
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+
}
5354
}
5455
$injector.register("analyticsSettingsService", AnalyticsSettingsService);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as fs from "fs";
2+
import { AnalyticsBroker } from "./analytics-broker";
3+
4+
const pathToBootstrap = process.argv[2];
5+
if (!pathToBootstrap || !fs.existsSync(pathToBootstrap)) {
6+
throw new Error("Invalid path to bootstrap.");
7+
}
8+
9+
// After requiring the bootstrap we can use $injector
10+
require(pathToBootstrap);
11+
12+
const analyticsBroker = $injector.resolve<IAnalyticsBroker>(AnalyticsBroker, { pathToBootstrap });
13+
let trackingQueue: Promise<void> = Promise.resolve();
14+
15+
let sentFinishMsg = false;
16+
let receivedFinishMsg = false;
17+
18+
const sendDataForTracking = async (data: ITrackingInformation) => {
19+
trackingQueue = trackingQueue.then(() => analyticsBroker.sendDataForTracking(data));
20+
await trackingQueue;
21+
};
22+
23+
const finishTracking = async (data?: ITrackingInformation) => {
24+
if (!sentFinishMsg) {
25+
sentFinishMsg = true;
26+
27+
data = data || { type: TrackingTypes.Finish };
28+
const action = async () => {
29+
await sendDataForTracking(data);
30+
process.disconnect();
31+
};
32+
33+
if (receivedFinishMsg) {
34+
await action();
35+
} else {
36+
// In case we've got here without receiving "finish" message from parent (receivedFinishMsg is false)
37+
// there might be various reasons, but most probably the parent is dead.
38+
// However, there's no guarantee that we've received all messages. So wait some time before sending finish message to children.
39+
setTimeout(async () => {
40+
await action();
41+
}, 1000);
42+
}
43+
}
44+
};
45+
46+
process.on("message", async (data: ITrackingInformation) => {
47+
if (data.type === TrackingTypes.Finish) {
48+
receivedFinishMsg = true;
49+
await finishTracking(data);
50+
return;
51+
}
52+
53+
await sendDataForTracking(data);
54+
});
55+
56+
process.on("disconnect", async () => {
57+
await finishTracking();
58+
});
59+
60+
process.send(AnalyticsMessages.BrokerReadyToReceive);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { cache } from "../../common/decorators";
2+
3+
export class AnalyticsBroker implements IAnalyticsBroker {
4+
5+
@cache()
6+
private async getEqatecAnalyticsProvider(): Promise<IAnalyticsProvider> {
7+
return this.$injector.resolve("eqatecAnalyticsProvider");
8+
}
9+
10+
@cache()
11+
private async getGoogleAnalyticsProvider(): Promise<IGoogleAnalyticsProvider> {
12+
const clientId = await this.$analyticsSettingsService.getClientId();
13+
return this.$injector.resolve("googleAnalyticsProvider", { clientId });
14+
}
15+
16+
constructor(private $analyticsSettingsService: IAnalyticsSettingsService,
17+
private $injector: IInjector) { }
18+
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}`);
41+
}
42+
43+
}
44+
45+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Defines messages used in communication between CLI's process and analytics subprocesses.
3+
*/
4+
const enum AnalyticsMessages {
5+
/**
6+
* Analytics Broker is initialized and is ready to receive information for tracking.
7+
*/
8+
BrokerReadyToReceive = "BrokerReadyToReceive",
9+
10+
/**
11+
* Eqatec Analytics process is initialized and is ready to receive information for tracking.
12+
*/
13+
EqatecAnalyticsReadyToReceive = "EqatecAnalyticsReadyToReceive"
14+
}

0 commit comments

Comments
 (0)