Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Telemetry] Fix optIn telemetry report bug #64214

Merged
merged 1 commit into from
Apr 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/plugins/telemetry/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,20 @@ import { httpServiceMock } from '../../../core/public/http/http_service.mock';
import { notificationServiceMock } from '../../../core/public/notifications/notifications_service.mock';
import { TelemetryService } from './services/telemetry_service';
import { TelemetryNotifications } from './services/telemetry_notifications/telemetry_notifications';
import { TelemetryPluginStart } from './plugin';
import { TelemetryPluginStart, TelemetryPluginConfig } from './plugin';

// The following is to be able to access private methods
/* eslint-disable dot-notation */

export interface TelemetryServiceMockOptions {
reportOptInStatusChange?: boolean;
config?: Partial<TelemetryPluginConfig>;
}

export function mockTelemetryService({
reportOptInStatusChange,
}: { reportOptInStatusChange?: boolean } = {}) {
config: configOverride = {},
}: TelemetryServiceMockOptions = {}) {
const config = {
enabled: true,
url: 'http://localhost',
Expand All @@ -39,14 +48,22 @@ export function mockTelemetryService({
banner: true,
allowChangingOptInStatus: true,
telemetryNotifyUserAboutOptInDefault: true,
...configOverride,
};

return new TelemetryService({
const telemetryService = new TelemetryService({
config,
http: httpServiceMock.createStartContract(),
notifications: notificationServiceMock.createStartContract(),
reportOptInStatusChange,
});

const originalReportOptInStatus = telemetryService['reportOptInStatus'];
telemetryService['reportOptInStatus'] = jest.fn().mockImplementation(optInPayload => {
return originalReportOptInStatus(optInPayload); // Actually calling the original method
});

return telemetryService;
}

export function mockTelemetryNotifications({
Expand Down
103 changes: 86 additions & 17 deletions src/plugins/telemetry/public/services/telemetry_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,31 @@ describe('TelemetryService', () => {
});

describe('setOptIn', () => {
it('does not call the api if canChangeOptInStatus==false', async () => {
const telemetryService = mockTelemetryService({
reportOptInStatusChange: false,
config: { allowChangingOptInStatus: false },
});
expect(await telemetryService.setOptIn(true)).toBe(false);

expect(telemetryService['http'].post).toBeCalledTimes(0);
});

it('calls api if canChangeOptInStatus', async () => {
const telemetryService = mockTelemetryService({ reportOptInStatusChange: false });
telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(true);
const telemetryService = mockTelemetryService({
reportOptInStatusChange: false,
config: { allowChangingOptInStatus: true },
});
await telemetryService.setOptIn(true);

expect(telemetryService['http'].post).toBeCalledTimes(1);
});

it('sends enabled true if optedIn: true', async () => {
const telemetryService = mockTelemetryService({ reportOptInStatusChange: false });
telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(true);
const telemetryService = mockTelemetryService({
reportOptInStatusChange: false,
config: { allowChangingOptInStatus: true },
});
const optedIn = true;
await telemetryService.setOptIn(optedIn);

Expand All @@ -87,8 +101,10 @@ describe('TelemetryService', () => {
});

it('sends enabled false if optedIn: false', async () => {
const telemetryService = mockTelemetryService({ reportOptInStatusChange: false });
telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(true);
const telemetryService = mockTelemetryService({
reportOptInStatusChange: false,
config: { allowChangingOptInStatus: true },
});
const optedIn = false;
await telemetryService.setOptIn(optedIn);

Expand All @@ -98,29 +114,32 @@ describe('TelemetryService', () => {
});

it('does not call reportOptInStatus if reportOptInStatusChange is false', async () => {
const telemetryService = mockTelemetryService({ reportOptInStatusChange: false });
telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(true);
telemetryService['reportOptInStatus'] = jest.fn();
const telemetryService = mockTelemetryService({
reportOptInStatusChange: false,
config: { allowChangingOptInStatus: true },
});
await telemetryService.setOptIn(true);

expect(telemetryService['reportOptInStatus']).toBeCalledTimes(0);
expect(telemetryService['http'].post).toBeCalledTimes(1);
});

it('calls reportOptInStatus if reportOptInStatusChange is true', async () => {
const telemetryService = mockTelemetryService({ reportOptInStatusChange: true });
telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(true);
telemetryService['reportOptInStatus'] = jest.fn();
const telemetryService = mockTelemetryService({
reportOptInStatusChange: true,
config: { allowChangingOptInStatus: true },
});
await telemetryService.setOptIn(true);

expect(telemetryService['reportOptInStatus']).toBeCalledTimes(1);
expect(telemetryService['http'].post).toBeCalledTimes(1);
});

it('adds an error toast on api error', async () => {
const telemetryService = mockTelemetryService({ reportOptInStatusChange: false });
telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(true);
telemetryService['reportOptInStatus'] = jest.fn();
const telemetryService = mockTelemetryService({
reportOptInStatusChange: false,
config: { allowChangingOptInStatus: true },
});
telemetryService['http'].post = jest.fn().mockImplementation((url: string) => {
if (url === '/api/telemetry/v2/optIn') {
throw Error('failed to update opt in.');
Expand All @@ -133,9 +152,13 @@ describe('TelemetryService', () => {
expect(telemetryService['notifications'].toasts.addError).toBeCalledTimes(1);
});

// This one should not happen because the entire method is fully caught but hey! :)
it('adds an error toast on reportOptInStatus error', async () => {
const telemetryService = mockTelemetryService({ reportOptInStatusChange: true });
telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(true);
const telemetryService = mockTelemetryService({
reportOptInStatusChange: true,
config: { allowChangingOptInStatus: true },
});

telemetryService['reportOptInStatus'] = jest.fn().mockImplementation(() => {
throw Error('failed to report OptIn Status.');
});
Expand All @@ -146,4 +169,50 @@ describe('TelemetryService', () => {
expect(telemetryService['notifications'].toasts.addError).toBeCalledTimes(1);
});
});

describe('getTelemetryUrl', () => {
it('should return the config.url parameter', async () => {
const url = 'http://test.com';
const telemetryService = mockTelemetryService({
config: { url },
});

expect(telemetryService.getTelemetryUrl()).toBe(url);
});
});

describe('setUserHasSeenNotice', () => {
it('should hit the API and change the config', async () => {
const telemetryService = mockTelemetryService({
config: { telemetryNotifyUserAboutOptInDefault: undefined },
});

expect(telemetryService.userHasSeenOptedInNotice).toBe(undefined);
expect(telemetryService.getUserHasSeenOptedInNotice()).toBe(false);
await telemetryService.setUserHasSeenNotice();
expect(telemetryService['http'].put).toBeCalledTimes(1);
expect(telemetryService.userHasSeenOptedInNotice).toBe(true);
expect(telemetryService.getUserHasSeenOptedInNotice()).toBe(true);
});

it('should show a toast notification if the request fail', async () => {
const telemetryService = mockTelemetryService({
config: { telemetryNotifyUserAboutOptInDefault: undefined },
});

telemetryService['http'].put = jest.fn().mockImplementation((url: string) => {
if (url === '/api/telemetry/v2/userHasSeenNotice') {
throw Error('failed to update opt in.');
}
});

expect(telemetryService.userHasSeenOptedInNotice).toBe(undefined);
expect(telemetryService.getUserHasSeenOptedInNotice()).toBe(false);
await telemetryService.setUserHasSeenNotice();
expect(telemetryService['http'].put).toBeCalledTimes(1);
expect(telemetryService['notifications'].toasts.addError).toBeCalledTimes(1);
expect(telemetryService.userHasSeenOptedInNotice).toBe(false);
expect(telemetryService.getUserHasSeenOptedInNotice()).toBe(false);
});
});
});
16 changes: 12 additions & 4 deletions src/plugins/telemetry/public/services/telemetry_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,15 @@ export class TelemetryService {
}

try {
await this.http.post('/api/telemetry/v2/optIn', {
// Report the option to the Kibana server to store the settings.
// It returns the encrypted update to send to the telemetry cluster [{cluster_uuid, opt_in_status}]
const optInPayload = await this.http.post<string[]>('/api/telemetry/v2/optIn', {
body: JSON.stringify({ enabled: optedIn }),
});
if (this.reportOptInStatusChange) {
await this.reportOptInStatus(optedIn);
// Use the response to report about the change to the remote telemetry cluster.
// If it's opt-out, this will be the last communication to the remote service.
await this.reportOptInStatus(optInPayload);
}
this.isOptedIn = optedIn;
} catch (err) {
Expand Down Expand Up @@ -162,7 +166,11 @@ export class TelemetryService {
}
};

private reportOptInStatus = async (OptInStatus: boolean): Promise<void> => {
/**
* Pushes the encrypted payload [{cluster_uuid, opt_in_status}] to the remote telemetry service
* @param optInPayload [{cluster_uuid, opt_in_status}] encrypted by the server into an array of strings
*/
private reportOptInStatus = async (optInPayload: string[]): Promise<void> => {
const telemetryOptInStatusUrl = this.getOptInStatusUrl();

try {
Expand All @@ -171,7 +179,7 @@ export class TelemetryService {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled: OptInStatus }),
body: JSON.stringify(optInPayload),
});
} catch (err) {
// Sending the ping is best-effort. Telemetry tries to send the ping once and discards it immediately if sending fails.
Expand Down
28 changes: 19 additions & 9 deletions src/plugins/telemetry/server/routes/telemetry_opt_in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { schema } from '@kbn/config-schema';
import { IRouter } from 'kibana/server';
import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server';
import {
StatsGetterConfig,
TelemetryCollectionManagerPluginSetup,
} from 'src/plugins/telemetry_collection_manager/server';
import { getTelemetryAllowChangingOptInStatus } from '../../common/telemetry_config';
import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats';

Expand Down Expand Up @@ -79,23 +82,30 @@ export function registerTelemetryOptInRoutes({
});
}

const statsGetterConfig: StatsGetterConfig = {
start: moment()
.subtract(20, 'minutes')
.toISOString(),
end: moment().toISOString(),
unencrypted: false,
};

const optInStatus = await telemetryCollectionManager.getOptInStats(
newOptInStatus,
statsGetterConfig
);

if (config.sendUsageFrom === 'server') {
const optInStatusUrl = config.optInStatusUrl;
await sendTelemetryOptInStatus(
telemetryCollectionManager,
{ optInStatusUrl, newOptInStatus },
{
start: moment()
.subtract(20, 'minutes')
.toISOString(),
end: moment().toISOString(),
unencrypted: false,
}
statsGetterConfig
);
}

await updateTelemetrySavedObject(context.core.savedObjects.client, attributes);
return res.ok({});
return res.ok({ body: optInStatus });
}
);
}