Skip to content

Commit ec6d540

Browse files
Do not break CLI process in case analytics fail (#3197)
In case we are unable to start the Analytics Broker process, CLI will fail. But analytics errors should not break user's workflow, so catch the error and ensure the actions will continue. Add tests to verify the behavior.
1 parent da64cf8 commit ec6d540

File tree

3 files changed

+308
-3
lines changed

3 files changed

+308
-3
lines changed

lib/services/analytics/analytics-service.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isInteractive } from '../../common/helpers';
66
import { DeviceTypes, AnalyticsClients } from "../../common/constants";
77

88
export class AnalyticsService extends AnalyticsServiceBase {
9-
private static ANALYTICS_BROKER_START_TIMEOUT = 30 * 1000;
9+
private static ANALYTICS_BROKER_START_TIMEOUT = 10 * 1000;
1010
private brokerProcess: ChildProcess;
1111

1212
constructor(protected $logger: ILogger,
@@ -182,7 +182,14 @@ export class AnalyticsService extends AnalyticsServiceBase {
182182
}
183183

184184
private async sendMessageToBroker(message: ITrackingInformation): Promise<void> {
185-
const broker = await this.getAnalyticsBroker();
185+
let broker: ChildProcess;
186+
try {
187+
broker = await this.getAnalyticsBroker();
188+
} catch (err) {
189+
this.$logger.trace("Unable to get broker instance due to error: ", err);
190+
return;
191+
}
192+
186193
return new Promise<void>((resolve, reject) => {
187194
if (broker && broker.connected) {
188195
try {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { AnalyticsService } from "../../../lib/services/analytics/analytics-service";
2+
import { Yok } from "../../../lib/common/yok";
3+
import * as stubs from "../../stubs";
4+
import { assert } from "chai";
5+
import { EventEmitter } from "events";
6+
import { AnalyticsClients } from "../../../lib/common/constants";
7+
8+
const helpers = require("../../../lib/common/helpers");
9+
const originalIsInteractive = helpers.isInteractive;
10+
11+
const trackFeatureUsage = "TrackFeatureUsage";
12+
const createTestInjector = (): IInjector => {
13+
const testInjector = new Yok();
14+
testInjector.register("options", {});
15+
testInjector.register("logger", stubs.LoggerStub);
16+
17+
testInjector.register("staticConfig", {
18+
disableAnalytics: false,
19+
TRACK_FEATURE_USAGE_SETTING_NAME: trackFeatureUsage,
20+
PATH_TO_BOOTSTRAP: "pathToBootstrap.js"
21+
});
22+
23+
testInjector.register("prompter", {
24+
25+
});
26+
27+
testInjector.register("userSettingsService", {
28+
getSettingValue: async (settingName: string): Promise<any> => {
29+
return "true";
30+
}
31+
});
32+
testInjector.register("analyticsSettingsService", {
33+
canDoRequest: (): Promise<boolean> => Promise.resolve(true)
34+
});
35+
testInjector.register("osInfo", {});
36+
testInjector.register("childProcess", {});
37+
testInjector.register("processService", {
38+
attachToProcessExitSignals: (context: any, callback: () => void): void => undefined
39+
});
40+
testInjector.register("projectDataService", {});
41+
testInjector.register("mobileHelper", {});
42+
43+
return testInjector;
44+
};
45+
46+
describe("analyticsService", () => {
47+
afterEach(() => {
48+
helpers.isInteractive = originalIsInteractive;
49+
});
50+
51+
describe("trackInGoogleAnalytics", () => {
52+
describe("does not track", () => {
53+
const testScenario = async (configuration: {
54+
disableAnalytics: boolean,
55+
assertMessage: string,
56+
userSettingsServiceOpts?: { trackFeatureUsageValue: string, defaultValue: string }
57+
}) => {
58+
const testInjector = createTestInjector();
59+
const staticConfig = testInjector.resolve<Config.IStaticConfig>("staticConfig");
60+
staticConfig.disableAnalytics = configuration.disableAnalytics;
61+
62+
configuration.userSettingsServiceOpts = configuration.userSettingsServiceOpts || { trackFeatureUsageValue: "false", defaultValue: "true" };
63+
const userSettingsService = testInjector.resolve<any>("userSettingsService");
64+
userSettingsService.getSettingValue = async (settingName: string): Promise<string> => {
65+
if (settingName === trackFeatureUsage) {
66+
return configuration.userSettingsServiceOpts.trackFeatureUsageValue;
67+
}
68+
69+
return configuration.userSettingsServiceOpts.defaultValue;
70+
};
71+
72+
let isChildProcessSpawned = false;
73+
const childProcess = testInjector.resolve<IChildProcess>("childProcess");
74+
childProcess.spawn = (command: string, args?: string[], options?: any): any => {
75+
isChildProcessSpawned = true;
76+
};
77+
78+
const analyticsService = testInjector.resolve<IAnalyticsService>(AnalyticsService);
79+
await analyticsService.trackInGoogleAnalytics({
80+
googleAnalyticsDataType: GoogleAnalyticsDataType.Page,
81+
customDimensions: {
82+
customDimension1: "value1"
83+
}
84+
});
85+
86+
assert.isFalse(isChildProcessSpawned, configuration.assertMessage);
87+
};
88+
89+
it("does not track when staticConfig's disableAnalytics is true", () => {
90+
return testScenario({
91+
disableAnalytics: true,
92+
assertMessage: "When staticConfig.disableAnalytics is true, no child process should be started, i.e. we should not track anything."
93+
});
94+
});
95+
96+
it(`does not track when ${trackFeatureUsage} is not true`, async () => {
97+
await testScenario({
98+
disableAnalytics: false,
99+
assertMessage: `When ${trackFeatureUsage} is false, no child process should be started, i.e. we should not track anything.`,
100+
userSettingsServiceOpts: {
101+
trackFeatureUsageValue: "false", defaultValue: "true"
102+
}
103+
});
104+
105+
await testScenario({
106+
disableAnalytics: false,
107+
assertMessage: `When ${trackFeatureUsage} is undefined, no child process should be started, i.e. we should not track anything.`,
108+
userSettingsServiceOpts: {
109+
trackFeatureUsageValue: undefined, defaultValue: "true"
110+
}
111+
});
112+
});
113+
114+
});
115+
116+
const getSpawnedProcess = (): any => {
117+
const spawnedProcess: any = new EventEmitter();
118+
spawnedProcess.stdout = new EventEmitter();
119+
spawnedProcess.stderr = new EventEmitter();
120+
spawnedProcess.unref = (): void => undefined;
121+
return spawnedProcess;
122+
};
123+
124+
describe("does not fail", () => {
125+
const assertExpectedError = async (testInjector: IInjector, opts: { isChildProcessSpawned: boolean, expectedErrorMessage: string }) => {
126+
const analyticsService = testInjector.resolve<IAnalyticsService>(AnalyticsService);
127+
await analyticsService.trackInGoogleAnalytics({
128+
googleAnalyticsDataType: GoogleAnalyticsDataType.Page,
129+
customDimensions: {
130+
customDimension1: "value1"
131+
}
132+
});
133+
134+
assert.isTrue(opts.isChildProcessSpawned);
135+
const logger = testInjector.resolve<stubs.LoggerStub>("logger");
136+
assert.isTrue(logger.traceOutput.indexOf(opts.expectedErrorMessage) !== -1);
137+
};
138+
139+
const setupTest = (expectedErrorMessage: string): any => {
140+
const testInjector = createTestInjector();
141+
const opts = {
142+
isChildProcessSpawned: false,
143+
expectedErrorMessage
144+
};
145+
146+
const childProcess = testInjector.resolve<IChildProcess>("childProcess");
147+
return {
148+
testInjector,
149+
opts,
150+
childProcess
151+
};
152+
};
153+
154+
it("when unable to start broker process", async () => {
155+
const { testInjector, childProcess, opts } = setupTest("Unable to get broker instance due to error: Error: custom error");
156+
childProcess.spawn = (command: string, args?: string[], options?: any): any => {
157+
opts.isChildProcessSpawned = true;
158+
throw new Error("custom error");
159+
};
160+
161+
await assertExpectedError(testInjector, opts);
162+
});
163+
164+
it("when broker cannot start for required timeout", async () => {
165+
const { testInjector, childProcess, opts } = setupTest("Unable to get broker instance due to error: Error: Unable to start Analytics Broker process.");
166+
const originalSetTimeout = setTimeout;
167+
childProcess.spawn = (command: string, args?: string[], options?: any): any => {
168+
opts.isChildProcessSpawned = true;
169+
global.setTimeout = (callback: (...args: any[]) => void, ms: number, ...otherArgs: any[]) => originalSetTimeout(callback, 1);
170+
return getSpawnedProcess();
171+
};
172+
173+
await assertExpectedError(testInjector, opts);
174+
175+
global.setTimeout = originalSetTimeout;
176+
});
177+
178+
it("when broker is not connected", async () => {
179+
const { testInjector, childProcess, opts } = setupTest("Broker not found or not connected.");
180+
181+
childProcess.spawn = (command: string, args?: string[], options?: any): any => {
182+
opts.isChildProcessSpawned = true;
183+
const spawnedProcess: any = getSpawnedProcess();
184+
185+
spawnedProcess.connected = false;
186+
spawnedProcess.send = (): void => undefined;
187+
setTimeout(() => spawnedProcess.emit("message", AnalyticsMessages.BrokerReadyToReceive), 1);
188+
return spawnedProcess;
189+
};
190+
191+
await assertExpectedError(testInjector, opts);
192+
});
193+
194+
it("when sending message fails", async () => {
195+
const { testInjector, childProcess, opts } = setupTest("Error while trying to send message to broker: Error: Failed to sent data.");
196+
197+
childProcess.spawn = (command: string, args?: string[], options?: any): any => {
198+
opts.isChildProcessSpawned = true;
199+
const spawnedProcess: any = getSpawnedProcess();
200+
201+
spawnedProcess.connected = true;
202+
spawnedProcess.send = (): void => {
203+
throw new Error("Failed to sent data.");
204+
};
205+
206+
setTimeout(() => spawnedProcess.emit("message", AnalyticsMessages.BrokerReadyToReceive), 1);
207+
return spawnedProcess;
208+
};
209+
210+
await assertExpectedError(testInjector, opts);
211+
});
212+
});
213+
214+
describe("sends correct message to broker", () => {
215+
const setupTest = (expectedResult: any, dataToSend: any, terminalOpts?: { isInteractive: boolean }): { testInjector: IInjector, opts: any } => {
216+
helpers.isInteractive = () => terminalOpts ? terminalOpts.isInteractive : true;
217+
218+
const testInjector = createTestInjector();
219+
const opts = {
220+
isChildProcessSpawned: false,
221+
expectedResult,
222+
dataToSend,
223+
messageSent: <any>null
224+
};
225+
226+
const childProcess = testInjector.resolve<IChildProcess>("childProcess");
227+
childProcess.spawn = (command: string, args?: string[], options?: any): any => {
228+
opts.isChildProcessSpawned = true;
229+
const spawnedProcess: any = getSpawnedProcess();
230+
231+
spawnedProcess.connected = true;
232+
spawnedProcess.send = (msg: any, action: () => void): void => {
233+
opts.messageSent = msg;
234+
action();
235+
};
236+
237+
setTimeout(() => spawnedProcess.emit("message", AnalyticsMessages.BrokerReadyToReceive), 1);
238+
239+
return spawnedProcess;
240+
};
241+
242+
return {
243+
testInjector,
244+
opts
245+
};
246+
};
247+
248+
const assertExpectedResult = async (testInjector: IInjector, opts: { isChildProcessSpawned: boolean, expectedResult: any, messageSent: any, dataToSend: any }) => {
249+
const analyticsService = testInjector.resolve<IAnalyticsService>(AnalyticsService);
250+
await analyticsService.trackInGoogleAnalytics(opts.dataToSend);
251+
252+
assert.isTrue(opts.isChildProcessSpawned);
253+
assert.deepEqual(opts.messageSent, opts.expectedResult);
254+
};
255+
256+
const getDataToSend = (gaDataType: string): any => ({
257+
googleAnalyticsDataType: gaDataType,
258+
customDimensions: {
259+
customDimension1: "value1"
260+
}
261+
});
262+
263+
const getExpectedResult = (gaDataType: string, analyticsClient?: string): any => ({
264+
type: "googleAnalyticsData",
265+
category: "CLI",
266+
googleAnalyticsDataType: gaDataType,
267+
customDimensions: { customDimension1: "value1", cd5: analyticsClient || "CLI" }
268+
});
269+
270+
_.each([GoogleAnalyticsDataType.Page, GoogleAnalyticsDataType.Event], (googleAnalyticsDataType: string) => {
271+
it(`when data is ${googleAnalyticsDataType}`, async () => {
272+
const { testInjector, opts } = setupTest(getExpectedResult(googleAnalyticsDataType), getDataToSend(googleAnalyticsDataType));
273+
await assertExpectedResult(testInjector, opts);
274+
});
275+
276+
it(`when data is ${googleAnalyticsDataType} and terminal is not interactive`, async () => {
277+
const { testInjector, opts } = setupTest(getExpectedResult(googleAnalyticsDataType, AnalyticsClients.Unknown), getDataToSend(googleAnalyticsDataType), { isInteractive: false });
278+
await assertExpectedResult(testInjector, opts);
279+
});
280+
281+
_.each([true, false], (isInteractive) => {
282+
it(`when data is ${googleAnalyticsDataType} terminal is ${isInteractive ? "" : "not "}interactive and --analyticsClient is passed`, async () => {
283+
const analyticsClient = "AnalyticsClient";
284+
285+
const { testInjector, opts } = setupTest(getExpectedResult(googleAnalyticsDataType, analyticsClient), getDataToSend(googleAnalyticsDataType), { isInteractive });
286+
const options = testInjector.resolve<IOptions>("options");
287+
options.analyticsClient = analyticsClient;
288+
289+
await assertExpectedResult(testInjector, opts);
290+
});
291+
});
292+
});
293+
});
294+
});
295+
});

test/stubs.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ export class LoggerStub implements ILogger {
1313
warnWithLabel(...args: string[]): void { }
1414
info(...args: string[]): void { }
1515
debug(...args: string[]): void { }
16-
trace(...args: string[]): void { }
16+
trace(...args: string[]): void {
17+
this.traceOutput += util.format.apply(null, args) + "\n";
18+
}
1719

1820
public output = "";
21+
public traceOutput = "";
1922

2023
out(...args: string[]): void {
2124
this.output += util.format.apply(null, args) + "\n";

0 commit comments

Comments
 (0)