[Fleet] Package telemetry (#117978) (#118298)

* renamed upgrade event

* added package update events

* fixed tests

* fixed tests

* fixed for async flow

* fixed jest test

* added unit tests

* changed to logger.debug in sender.ts

Co-authored-by: Julia Bardi <90178898+juliaElastic@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-11-11 05:18:12 -05:00 committed by GitHub
parent 7e695cda58
commit 51956998de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 389 additions and 41 deletions

View file

@ -0,0 +1,252 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { savedObjectsClientMock } from 'src/core/server/mocks';
import type { ElasticsearchClient } from 'kibana/server';
import * as Registry from '../registry';
import { sendTelemetryEvents } from '../../upgrade_sender';
import { licenseService } from '../../license';
import { installPackage } from './install';
import * as install from './_install_package';
import * as obj from './index';
jest.mock('../../app_context', () => {
return {
appContextService: {
getLogger: jest.fn(() => {
return { error: jest.fn(), debug: jest.fn(), warn: jest.fn() };
}),
getTelemetryEventsSender: jest.fn(),
},
};
});
jest.mock('./index');
jest.mock('../registry');
jest.mock('../../upgrade_sender');
jest.mock('../../license');
jest.mock('../../upgrade_sender');
jest.mock('./cleanup');
jest.mock('./_install_package', () => {
return {
_installPackage: jest.fn(() => Promise.resolve()),
};
});
jest.mock('../kibana/index_pattern/install', () => {
return {
installIndexPatterns: jest.fn(() => Promise.resolve()),
};
});
jest.mock('../archive', () => {
return {
parseAndVerifyArchiveEntries: jest.fn(() =>
Promise.resolve({ packageInfo: { name: 'apache', version: '1.3.0' } })
),
unpackBufferToCache: jest.fn(),
setPackageInfo: jest.fn(),
};
});
describe('install', () => {
beforeEach(() => {
jest.spyOn(Registry, 'splitPkgKey').mockImplementation((pkgKey: string) => {
const [pkgName, pkgVersion] = pkgKey.split('-');
return { pkgName, pkgVersion };
});
jest
.spyOn(Registry, 'fetchFindLatestPackage')
.mockImplementation(() => Promise.resolve({ version: '1.3.0' } as any));
jest
.spyOn(Registry, 'getRegistryPackage')
.mockImplementation(() => Promise.resolve({ packageInfo: { license: 'basic' } } as any));
});
describe('registry', () => {
it('should send telemetry on install failure, out of date', async () => {
await installPackage({
installSource: 'registry',
pkgkey: 'apache-1.1.0',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: 'not_installed',
dryRun: false,
errorMessage: 'apache-1.1.0 is out-of-date and cannot be installed or updated',
eventType: 'package-install',
installType: 'install',
newVersion: '1.1.0',
packageName: 'apache',
status: 'failure',
});
});
it('should send telemetry on install failure, license error', async () => {
jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(false);
await installPackage({
installSource: 'registry',
pkgkey: 'apache-1.3.0',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: 'not_installed',
dryRun: false,
errorMessage: 'Requires basic license',
eventType: 'package-install',
installType: 'install',
newVersion: '1.3.0',
packageName: 'apache',
status: 'failure',
});
});
it('should send telemetry on install success', async () => {
jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true);
await installPackage({
installSource: 'registry',
pkgkey: 'apache-1.3.0',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: 'not_installed',
dryRun: false,
eventType: 'package-install',
installType: 'install',
newVersion: '1.3.0',
packageName: 'apache',
status: 'success',
});
});
it('should send telemetry on update success', async () => {
jest
.spyOn(obj, 'getInstallationObject')
.mockImplementationOnce(() => Promise.resolve({ attributes: { version: '1.2.0' } } as any));
jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true);
await installPackage({
installSource: 'registry',
pkgkey: 'apache-1.3.0',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: '1.2.0',
dryRun: false,
eventType: 'package-install',
installType: 'update',
newVersion: '1.3.0',
packageName: 'apache',
status: 'success',
});
});
it('should send telemetry on install failure, async error', async () => {
jest
.spyOn(install, '_installPackage')
.mockImplementation(() => Promise.reject(new Error('error')));
jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true);
await installPackage({
installSource: 'registry',
pkgkey: 'apache-1.3.0',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: 'not_installed',
dryRun: false,
errorMessage: 'error',
eventType: 'package-install',
installType: 'install',
newVersion: '1.3.0',
packageName: 'apache',
status: 'failure',
});
});
});
describe('upload', () => {
it('should send telemetry on install failure', async () => {
jest
.spyOn(obj, 'getInstallationObject')
.mockImplementationOnce(() => Promise.resolve({ attributes: { version: '1.2.0' } } as any));
await installPackage({
installSource: 'upload',
archiveBuffer: {} as Buffer,
contentType: '',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: '1.2.0',
dryRun: false,
errorMessage:
'Package upload only supports fresh installations. Package apache is already installed, please uninstall first.',
eventType: 'package-install',
installType: 'update',
newVersion: '1.3.0',
packageName: 'apache',
status: 'failure',
});
});
it('should send telemetry on install success', async () => {
await installPackage({
installSource: 'upload',
archiveBuffer: {} as Buffer,
contentType: '',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: 'not_installed',
dryRun: false,
eventType: 'package-install',
installType: 'install',
newVersion: '1.3.0',
packageName: 'apache',
status: 'success',
});
});
it('should send telemetry on install failure, async error', async () => {
jest
.spyOn(install, '_installPackage')
.mockImplementation(() => Promise.reject(new Error('error')));
await installPackage({
installSource: 'upload',
archiveBuffer: {} as Buffer,
contentType: '',
savedObjectsClient: savedObjectsClientMock.create(),
esClient: {} as ElasticsearchClient,
});
expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, {
currentVersion: 'not_installed',
dryRun: false,
errorMessage: 'error',
eventType: 'package-install',
installType: 'install',
newVersion: '1.3.0',
packageName: 'apache',
status: 'failure',
});
});
});
});

View file

@ -41,6 +41,9 @@ import { toAssetReference } from '../kibana/assets/install';
import type { ArchiveAsset } from '../kibana/assets/install';
import { installIndexPatterns } from '../kibana/index_pattern/install';
import type { PackageUpdateEvent } from '../../upgrade_sender';
import { sendTelemetryEvents, UpdateEventType } from '../../upgrade_sender';
import { isUnremovablePackage, getInstallation, getInstallationObject } from './index';
import { removeInstallation } from './remove';
import { getPackageSavedObjects } from './get';
@ -203,6 +206,26 @@ interface InstallRegistryPackageParams {
force?: boolean;
}
function getTelemetryEvent(pkgName: string, pkgVersion: string): PackageUpdateEvent {
return {
packageName: pkgName,
currentVersion: 'unknown',
newVersion: pkgVersion,
status: 'failure',
dryRun: false,
eventType: UpdateEventType.PACKAGE_INSTALL,
installType: 'unknown',
};
}
function sendEvent(telemetryEvent: PackageUpdateEvent) {
sendTelemetryEvents(
appContextService.getLogger(),
appContextService.getTelemetryEventsSender(),
telemetryEvent
);
}
async function installPackageFromRegistry({
savedObjectsClient,
pkgkey,
@ -216,6 +239,8 @@ async function installPackageFromRegistry({
// if an error happens during getInstallType, report that we don't know
let installType: InstallType = 'unknown';
const telemetryEvent: PackageUpdateEvent = getTelemetryEvent(pkgName, pkgVersion);
try {
// get the currently installed package
const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName });
@ -248,6 +273,9 @@ async function installPackageFromRegistry({
}
}
telemetryEvent.installType = installType;
telemetryEvent.currentVersion = installedPkg?.attributes.version || 'not_installed';
// if the requested version is out-of-date of the latest package version, check if we allow it
// if we don't allow it, return an error
if (semverLt(pkgVersion, latestPackage.version)) {
@ -267,7 +295,12 @@ async function installPackageFromRegistry({
const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion);
if (!licenseService.hasAtLeast(packageInfo.license || 'basic')) {
return { error: new Error(`Requires ${packageInfo.license} license`), installType };
const err = new Error(`Requires ${packageInfo.license} license`);
sendEvent({
...telemetryEvent,
errorMessage: err.message,
});
return { error: err, installType };
}
// try installing the package, if there was an error, call error handler and rethrow
@ -287,6 +320,10 @@ async function installPackageFromRegistry({
pkgName: packageInfo.name,
currentVersion: packageInfo.version,
});
sendEvent({
...telemetryEvent,
status: 'success',
});
return { assets, status: 'installed', installType };
})
.catch(async (err: Error) => {
@ -299,9 +336,17 @@ async function installPackageFromRegistry({
installedPkg,
esClient,
});
sendEvent({
...telemetryEvent,
errorMessage: err.message,
});
return { error: err, installType };
});
} catch (e) {
sendEvent({
...telemetryEvent,
errorMessage: e.message,
});
return {
error: e,
installType,
@ -324,6 +369,7 @@ async function installPackageByUpload({
}: InstallUploadedArchiveParams): Promise<InstallResult> {
// if an error happens during getInstallType, report that we don't know
let installType: InstallType = 'unknown';
const telemetryEvent: PackageUpdateEvent = getTelemetryEvent('', '');
try {
const { packageInfo } = await parseAndVerifyArchiveEntries(archiveBuffer, contentType);
@ -333,6 +379,12 @@ async function installPackageByUpload({
});
installType = getInstallType({ pkgVersion: packageInfo.version, installedPkg });
telemetryEvent.packageName = packageInfo.name;
telemetryEvent.newVersion = packageInfo.version;
telemetryEvent.installType = installType;
telemetryEvent.currentVersion = installedPkg?.attributes.version || 'not_installed';
if (installType !== 'install') {
throw new PackageOperationNotSupportedError(
`Package upload only supports fresh installations. Package ${packageInfo.name} is already installed, please uninstall first.`
@ -364,12 +416,24 @@ async function installPackageByUpload({
installSource,
})
.then((assets) => {
sendEvent({
...telemetryEvent,
status: 'success',
});
return { assets, status: 'installed', installType };
})
.catch(async (err: Error) => {
sendEvent({
...telemetryEvent,
errorMessage: err.message,
});
return { error: err, installType };
});
} catch (e) {
sendEvent({
...telemetryEvent,
errorMessage: e.message,
});
return { error: e, installType };
}
}

View file

@ -134,7 +134,7 @@ jest.mock('./epm/packages/cleanup', () => {
};
});
jest.mock('./upgrade_usage', () => {
jest.mock('./upgrade_sender', () => {
return {
sendTelemetryEvents: jest.fn(),
};

View file

@ -67,8 +67,8 @@ import { compileTemplate } from './epm/agent/agent';
import { normalizeKuery } from './saved_object';
import { appContextService } from '.';
import { removeOldAssets } from './epm/packages/cleanup';
import type { PackagePolicyUpgradeUsage } from './upgrade_usage';
import { sendTelemetryEvents } from './upgrade_usage';
import type { PackageUpdateEvent, UpdateEventType } from './upgrade_sender';
import { sendTelemetryEvents } from './upgrade_sender';
export type InputsOverride = Partial<NewPackagePolicyInput> & {
vars?: Array<NewPackagePolicyInput['vars'] & { name: string }>;
@ -423,12 +423,13 @@ class PackagePolicyService {
});
if (packagePolicy.package.version !== currentVersion) {
const upgradeTelemetry: PackagePolicyUpgradeUsage = {
package_name: packagePolicy.package.name,
current_version: currentVersion || 'unknown',
new_version: packagePolicy.package.version,
const upgradeTelemetry: PackageUpdateEvent = {
packageName: packagePolicy.package.name,
currentVersion: currentVersion || 'unknown',
newVersion: packagePolicy.package.version,
status: 'success',
dryRun: false,
eventType: 'package-policy-upgrade' as UpdateEventType,
};
sendTelemetryEvents(
appContextService.getLogger(),
@ -668,13 +669,14 @@ class PackagePolicyService {
const hasErrors = 'errors' in updatedPackagePolicy;
if (packagePolicy.package.version !== packageInfo.version) {
const upgradeTelemetry: PackagePolicyUpgradeUsage = {
package_name: packageInfo.name,
current_version: packagePolicy.package.version,
new_version: packageInfo.version,
const upgradeTelemetry: PackageUpdateEvent = {
packageName: packageInfo.name,
currentVersion: packagePolicy.package.version,
newVersion: packageInfo.version,
status: hasErrors ? 'failure' : 'success',
error: hasErrors ? updatedPackagePolicy.errors : undefined,
dryRun: true,
eventType: 'package-policy-upgrade' as UpdateEventType,
};
sendTelemetryEvents(
appContextService.getLogger(),

View file

@ -11,8 +11,8 @@ import { loggingSystemMock } from 'src/core/server/mocks';
import type { TelemetryEventsSender } from '../telemetry/sender';
import { createMockTelemetryEventsSender } from '../telemetry/__mocks__';
import { sendTelemetryEvents, capErrorSize } from './upgrade_usage';
import type { PackagePolicyUpgradeUsage } from './upgrade_usage';
import { sendTelemetryEvents, capErrorSize, UpdateEventType } from './upgrade_sender';
import type { PackageUpdateEvent } from './upgrade_sender';
describe('sendTelemetryEvents', () => {
let eventsTelemetryMock: jest.Mocked<TelemetryEventsSender>;
@ -24,23 +24,24 @@ describe('sendTelemetryEvents', () => {
});
it('should queue telemetry events with generic error', () => {
const upgardeMessage: PackagePolicyUpgradeUsage = {
package_name: 'aws',
current_version: '0.6.1',
new_version: '1.3.0',
const upgradeMessage: PackageUpdateEvent = {
packageName: 'aws',
currentVersion: '0.6.1',
newVersion: '1.3.0',
status: 'failure',
error: [
{ key: 'queueUrl', message: ['Queue URL is required'] },
{ message: 'Invalid format' },
],
dryRun: true,
eventType: UpdateEventType.PACKAGE_POLICY_UPGRADE,
};
sendTelemetryEvents(loggerMock, eventsTelemetryMock, upgardeMessage);
sendTelemetryEvents(loggerMock, eventsTelemetryMock, upgradeMessage);
expect(eventsTelemetryMock.queueTelemetryEvents).toHaveBeenCalledWith('fleet-upgrades', [
{
current_version: '0.6.1',
currentVersion: '0.6.1',
error: [
{
key: 'queueUrl',
@ -50,11 +51,12 @@ describe('sendTelemetryEvents', () => {
message: 'Invalid format',
},
],
error_message: ['Field is required', 'Invalid format'],
new_version: '1.3.0',
package_name: 'aws',
errorMessage: ['Field is required', 'Invalid format'],
newVersion: '1.3.0',
packageName: 'aws',
status: 'failure',
dryRun: true,
eventType: 'package-policy-upgrade',
},
]);
});

View file

@ -8,15 +8,23 @@
import type { Logger } from 'src/core/server';
import type { TelemetryEventsSender } from '../telemetry/sender';
import type { InstallType } from '../types';
export interface PackagePolicyUpgradeUsage {
package_name: string;
current_version: string;
new_version: string;
export interface PackageUpdateEvent {
packageName: string;
currentVersion: string;
newVersion: string;
status: 'success' | 'failure';
error?: UpgradeError[];
dryRun?: boolean;
error_message?: string[];
errorMessage?: string[] | string;
error?: UpgradeError[];
eventType: UpdateEventType;
installType?: InstallType;
}
export enum UpdateEventType {
PACKAGE_POLICY_UPGRADE = 'package-policy-upgrade',
PACKAGE_INSTALL = 'package-install',
}
export interface UpgradeError {
@ -30,19 +38,19 @@ export const FLEET_UPGRADES_CHANNEL_NAME = 'fleet-upgrades';
export function sendTelemetryEvents(
logger: Logger,
eventsTelemetry: TelemetryEventsSender | undefined,
upgradeUsage: PackagePolicyUpgradeUsage
upgradeEvent: PackageUpdateEvent
) {
if (eventsTelemetry === undefined) {
return;
}
try {
const cappedErrors = capErrorSize(upgradeUsage.error || [], MAX_ERROR_SIZE);
const cappedErrors = capErrorSize(upgradeEvent.error || [], MAX_ERROR_SIZE);
eventsTelemetry.queueTelemetryEvents(FLEET_UPGRADES_CHANNEL_NAME, [
{
...upgradeUsage,
error: upgradeUsage.error ? cappedErrors : undefined,
error_message: makeErrorGeneric(cappedErrors),
...upgradeEvent,
error: upgradeEvent.error ? cappedErrors : undefined,
errorMessage: upgradeEvent.errorMessage || makeErrorGeneric(cappedErrors),
},
]);
} catch (exc) {

View file

@ -15,6 +15,8 @@ import type { InfoResponse } from '@elastic/elasticsearch/lib/api/types';
import { loggingSystemMock } from 'src/core/server/mocks';
import { UpdateEventType } from '../services/upgrade_sender';
import { TelemetryEventsSender } from './sender';
jest.mock('axios', () => {
@ -38,7 +40,13 @@ describe('TelemetryEventsSender', () => {
describe('queueTelemetryEvents', () => {
it('queues two events', () => {
sender.queueTelemetryEvents('fleet-upgrades', [
{ package_name: 'system', current_version: '0.3', new_version: '1.0', status: 'success' },
{
packageName: 'system',
currentVersion: '0.3',
newVersion: '1.0',
status: 'success',
eventType: UpdateEventType.PACKAGE_POLICY_UPGRADE,
},
]);
expect(sender['queuesPerChannel']['fleet-upgrades']).toBeDefined();
});
@ -54,7 +62,13 @@ describe('TelemetryEventsSender', () => {
};
sender.queueTelemetryEvents('fleet-upgrades', [
{ package_name: 'apache', current_version: '0.3', new_version: '1.0', status: 'success' },
{
packageName: 'apache',
currentVersion: '0.3',
newVersion: '1.0',
status: 'success',
eventType: UpdateEventType.PACKAGE_POLICY_UPGRADE,
},
]);
sender['sendEvents'] = jest.fn();
@ -74,7 +88,13 @@ describe('TelemetryEventsSender', () => {
sender['telemetryStart'] = telemetryStart;
sender.queueTelemetryEvents('fleet-upgrades', [
{ package_name: 'system', current_version: '0.3', new_version: '1.0', status: 'success' },
{
packageName: 'system',
currentVersion: '0.3',
newVersion: '1.0',
status: 'success',
eventType: UpdateEventType.PACKAGE_POLICY_UPGRADE,
},
]);
sender['sendEvents'] = jest.fn();

View file

@ -138,7 +138,7 @@ export class TelemetryEventsSender {
clusterInfo?.version?.number
);
} catch (err) {
this.logger.warn(`Error sending telemetry events data: ${err}`);
this.logger.debug(`Error sending telemetry events data: ${err}`);
queue.clearEvents();
}
}
@ -175,7 +175,7 @@ export class TelemetryEventsSender {
});
this.logger.debug(`Events sent!. Response: ${resp.status} ${JSON.stringify(resp.data)}`);
} catch (err) {
this.logger.warn(
this.logger.debug(
`Error sending events: ${err.response.status} ${JSON.stringify(err.response.data)}`
);
}

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import type { PackagePolicyUpgradeUsage } from '../services/upgrade_usage';
import type { PackageUpdateEvent } from '../services/upgrade_sender';
export interface FleetTelemetryChannelEvents {
// channel name => event type
'fleet-upgrades': PackagePolicyUpgradeUsage;
'fleet-upgrades': PackageUpdateEvent;
}
export type FleetTelemetryChannel = keyof FleetTelemetryChannelEvents;