From e7b0e2823742d4f5eba773a6be12ac01d4a27036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Fri, 18 Sep 2020 09:14:13 +0100 Subject: [PATCH] [APM] Track usage of Gold+ features (#77630) * adding license check * fixing api test * refactoring --- .../plugins/apm/common/custom_link/index.ts | 14 ++ .../Settings/CustomizeUI/CustomLink/index.tsx | 12 +- x-pack/plugins/apm/server/feature.ts | 47 +++++- x-pack/plugins/apm/server/plugin.ts | 12 +- .../plugins/apm/server/routes/service_map.ts | 8 +- .../routes/settings/anomaly_detection.ts | 5 + .../apm/server/routes/settings/custom_link.ts | 26 +++ .../basic/tests/feature_controls.ts | 7 - .../basic/tests/settings/custom_link.ts | 143 ++-------------- .../apm_api_integration/trial/tests/index.ts | 1 + .../trial/tests/settings/custom_link.ts | 159 ++++++++++++++++++ 11 files changed, 271 insertions(+), 163 deletions(-) create mode 100644 x-pack/plugins/apm/common/custom_link/index.ts create mode 100644 x-pack/test/apm_api_integration/trial/tests/settings/custom_link.ts diff --git a/x-pack/plugins/apm/common/custom_link/index.ts b/x-pack/plugins/apm/common/custom_link/index.ts new file mode 100644 index 00000000000000..bc0ffefd79c4da --- /dev/null +++ b/x-pack/plugins/apm/common/custom_link/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const INVALID_LICENSE = i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.license.text', + { + defaultMessage: + "To create custom links, you must be subscribed to an Elastic Gold license or above. With it, you'll have the ability to create custom links to improve your workflow when analyzing your services.", + } +); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index aa34515ea460af..45a7fa2a118f2c 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -7,7 +7,7 @@ import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import React, { useEffect, useState } from 'react'; -import { i18n } from '@kbn/i18n'; +import { INVALID_LICENSE } from '../../../../../../common/custom_link'; import { CustomLink } from '../../../../../../common/custom_link/custom_link_types'; import { useLicense } from '../../../../../hooks/useLicense'; import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; @@ -94,15 +94,7 @@ export function CustomLinkOverview() { /> ) ) : ( - + )} diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index 0f6061653f3523..1cda70a140c673 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -5,7 +5,12 @@ */ import { i18n } from '@kbn/i18n'; +import { LicenseType } from '../../licensing/common/types'; import { AlertType } from '../common/alert_types'; +import { + LicensingPluginSetup, + LicensingRequestHandlerContext, +} from '../../licensing/server'; export const APM_FEATURE = { id: 'apm', @@ -58,5 +63,43 @@ export const APM_FEATURE = { }, }; -export const APM_SERVICE_MAPS_FEATURE_NAME = 'APM service maps'; -export const APM_SERVICE_MAPS_LICENSE_TYPE = 'platinum'; +interface Feature { + name: string; + license: LicenseType; +} +type FeatureName = 'serviceMaps' | 'ml' | 'customLinks'; +export const features: Record = { + serviceMaps: { + name: 'APM service maps', + license: 'platinum', + }, + ml: { + name: 'APM machine learning', + license: 'platinum', + }, + customLinks: { + name: 'APM custom links', + license: 'gold', + }, +}; + +export function registerFeaturesUsage({ + licensingPlugin, +}: { + licensingPlugin: LicensingPluginSetup; +}) { + Object.values(features).forEach(({ name, license }) => { + licensingPlugin.featureUsage.register(name, license); + }); +} + +export function notifyFeatureUsage({ + licensingPlugin, + featureName, +}: { + licensingPlugin: LicensingRequestHandlerContext; + featureName: FeatureName; +}) { + const feature = features[featureName]; + licensingPlugin.featureUsage.notifyUsage(feature.name); +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index f25e37927f0941..b417f8689b2290 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -26,11 +26,7 @@ import { MlPluginSetup } from '../../ml/server'; import { ObservabilityPluginSetup } from '../../observability/server'; import { SecurityPluginSetup } from '../../security/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; -import { - APM_FEATURE, - APM_SERVICE_MAPS_FEATURE_NAME, - APM_SERVICE_MAPS_LICENSE_TYPE, -} from './feature'; +import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; @@ -128,10 +124,8 @@ export class APMPlugin implements Plugin { }); plugins.features.registerKibanaFeature(APM_FEATURE); - plugins.licensing.featureUsage.register( - APM_SERVICE_MAPS_FEATURE_NAME, - APM_SERVICE_MAPS_LICENSE_TYPE - ); + + registerFeaturesUsage({ licensingPlugin: plugins.licensing }); createApmApi().init(core, { config$: mergedConfig$, diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 04807cfac1cea9..1996d4d4a262dc 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -15,7 +15,7 @@ import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; import { rangeRt, uiFiltersRt } from './default_api_types'; -import { APM_SERVICE_MAPS_FEATURE_NAME } from '../feature'; +import { notifyFeatureUsage } from '../feature'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getParsedUiFilters } from '../lib/helpers/convert_ui_filters/get_parsed_ui_filters'; @@ -37,7 +37,11 @@ export const serviceMapRoute = createRoute(() => ({ if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } - context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME); + + notifyFeatureUsage({ + licensingPlugin: context.licensing, + featureName: 'serviceMaps', + }); const logger = context.logger; const setup = await setupRequest(context, request); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index 2cc0cdb1c2b91b..f0a22356d074bb 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -15,6 +15,7 @@ import { setupRequest } from '../../lib/helpers/setup_request'; import { getAllEnvironments } from '../../lib/environments/get_all_environments'; import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; +import { notifyFeatureUsage } from '../../feature'; // get ML anomaly detection jobs for each environment export const anomalyDetectionJobsRoute = createRoute(() => ({ @@ -62,6 +63,10 @@ export const createAnomalyDetectionJobsRoute = createRoute(() => ({ } await createAnomalyDetectionJobs(setup, environments, context.logger); + notifyFeatureUsage({ + licensingPlugin: context.licensing, + featureName: 'ml', + }); }, })); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index 83c23a75e999d5..7882383d78ab04 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -3,9 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +import Boom from 'boom'; import * as t from 'io-ts'; import { pick } from 'lodash'; +import { INVALID_LICENSE } from '../../../common/custom_link'; +import { ILicense } from '../../../../licensing/common/types'; import { FILTER_OPTIONS } from '../../../common/custom_link/custom_link_filter_options'; +import { notifyFeatureUsage } from '../../feature'; import { setupRequest } from '../../lib/helpers/setup_request'; import { createOrUpdateCustomLink } from '../../lib/settings/custom_link/create_or_update_custom_link'; import { @@ -17,6 +22,10 @@ import { getTransaction } from '../../lib/settings/custom_link/get_transaction'; import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; import { createRoute } from '../create_route'; +function isActiveGoldLicense(license: ILicense) { + return license.isActive && license.hasAtLeast('gold'); +} + export const customLinkTransactionRoute = createRoute(() => ({ path: '/api/apm/settings/custom_links/transaction', params: { @@ -37,6 +46,9 @@ export const listCustomLinksRoute = createRoute(() => ({ query: filterOptionsRt, }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const { query } = context.params; // picks only the items listed in FILTER_OPTIONS @@ -55,9 +67,17 @@ export const createCustomLinkRoute = createRoute(() => ({ tags: ['access:apm', 'access:apm_write'], }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const customLink = context.params.body; const res = await createOrUpdateCustomLink({ customLink, setup }); + + notifyFeatureUsage({ + licensingPlugin: context.licensing, + featureName: 'customLinks', + }); return res; }, })); @@ -75,6 +95,9 @@ export const updateCustomLinkRoute = createRoute(() => ({ tags: ['access:apm', 'access:apm_write'], }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const { id } = context.params.path; const customLink = context.params.body; @@ -99,6 +122,9 @@ export const deleteCustomLinkRoute = createRoute(() => ({ tags: ['access:apm', 'access:apm_write'], }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const { id } = context.params.path; const res = await deleteCustomLink({ diff --git a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts index 400d0d294bf02e..e0e13b7b7fb98d 100644 --- a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts +++ b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts @@ -149,13 +149,6 @@ export default function featureControlsTests({ getService }: FtrProviderContext) log.error(JSON.stringify(res, null, 2)); }, }, - { - req: { - url: `/api/apm/settings/custom_links`, - }, - expectForbidden: expect404, - expectResponse: expect200, - }, { req: { url: `/api/apm/settings/custom_links/transaction`, diff --git a/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts b/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts index a1c647a854bf60..60b4020e73dceb 100644 --- a/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts +++ b/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts @@ -3,75 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import URL from 'url'; import expect from '@kbn/expect'; +import { expectSnapshot } from '../../../common/match_snapshot'; import { CustomLink } from '../../../../../plugins/apm/common/custom_link/custom_link_types'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; export default function customLinksTests({ getService }: FtrProviderContext) { - const supertestRead = getService('supertestAsApmReadUser'); const supertestWrite = getService('supertestAsApmWriteUser'); - const log = getService('log'); - const esArchiver = getService('esArchiver'); - - const archiveName = 'apm_8.0.0'; - - function searchCustomLinks(filters?: any) { - const path = URL.format({ - pathname: `/api/apm/settings/custom_links`, - query: filters, - }); - return supertestRead.get(path).set('kbn-xsrf', 'foo'); - } - - async function createCustomLink(customLink: CustomLink) { - log.debug('creating configuration', customLink); - const res = await supertestWrite - .post(`/api/apm/settings/custom_links`) - .send(customLink) - .set('kbn-xsrf', 'foo'); - - throwOnError(res); - - return res; - } - - async function updateCustomLink(id: string, customLink: CustomLink) { - log.debug('updating configuration', id, customLink); - const res = await supertestWrite - .put(`/api/apm/settings/custom_links/${id}`) - .send(customLink) - .set('kbn-xsrf', 'foo'); - - throwOnError(res); - - return res; - } - - async function deleteCustomLink(id: string) { - log.debug('deleting configuration', id); - const res = await supertestWrite - .delete(`/api/apm/settings/custom_links/${id}`) - .set('kbn-xsrf', 'foo'); - - throwOnError(res); - - return res; - } - - function throwOnError(res: any) { - const { statusCode, req, body } = res; - if (statusCode !== 200) { - throw new Error(` - Endpoint: ${req.method} ${req.path} - Service: ${JSON.stringify(res.request._data.service)} - Status code: ${statusCode} - Response: ${body.message}`); - } - } describe('custom links', () => { - before(async () => { + it('is only be available to users with Gold license (or higher)', async () => { const customLink = { url: 'https://elastic.co', label: 'with filters', @@ -80,80 +21,16 @@ export default function customLinksTests({ getService }: FtrProviderContext) { { key: 'transaction.type', value: 'qux' }, ], } as CustomLink; - await createCustomLink(customLink); - }); - it('fetches a custom link', async () => { - const { status, body } = await searchCustomLinks({ - 'service.name': 'baz', - 'transaction.type': 'qux', - }); - const { label, url, filters } = body[0]; - - expect(status).to.equal(200); - expect({ label, url, filters }).to.eql({ - label: 'with filters', - url: 'https://elastic.co', - filters: [ - { key: 'service.name', value: 'baz' }, - { key: 'transaction.type', value: 'qux' }, - ], - }); - }); - it('updates a custom link', async () => { - let { status, body } = await searchCustomLinks({ - 'service.name': 'baz', - 'transaction.type': 'qux', - }); - expect(status).to.equal(200); - await updateCustomLink(body[0].id, { - label: 'foo', - url: 'https://elastic.co?service.name={{service.name}}', - filters: [ - { key: 'service.name', value: 'quz' }, - { key: 'transaction.name', value: 'bar' }, - ], - }); - ({ status, body } = await searchCustomLinks({ - 'service.name': 'quz', - 'transaction.name': 'bar', - })); - const { label, url, filters } = body[0]; - expect(status).to.equal(200); - expect({ label, url, filters }).to.eql({ - label: 'foo', - url: 'https://elastic.co?service.name={{service.name}}', - filters: [ - { key: 'service.name', value: 'quz' }, - { key: 'transaction.name', value: 'bar' }, - ], - }); - }); - it('deletes a custom link', async () => { - let { status, body } = await searchCustomLinks({ - 'service.name': 'quz', - 'transaction.name': 'bar', - }); - expect(status).to.equal(200); - await deleteCustomLink(body[0].id); - ({ status, body } = await searchCustomLinks({ - 'service.name': 'quz', - 'transaction.name': 'bar', - })); - expect(status).to.equal(200); - expect(body).to.eql([]); - }); + const response = await supertestWrite + .post(`/api/apm/settings/custom_links`) + .send(customLink) + .set('kbn-xsrf', 'foo'); - describe('transaction', () => { - before(() => esArchiver.load(archiveName)); - after(() => esArchiver.unload(archiveName)); + expect(response.status).to.be(403); - it('fetches a transaction sample', async () => { - const response = await supertestRead.get( - '/api/apm/settings/custom_links/transaction?service.name=opbeans-java' - ); - expect(response.status).to.be(200); - expect(response.body.service.name).to.eql('opbeans-java'); - }); + expectSnapshot(response.body.message).toMatchInline( + `"To create custom links, you must be subscribed to an Elastic Gold license or above. With it, you'll have the ability to create custom links to improve your workflow when analyzing your services."` + ); }); }); } diff --git a/x-pack/test/apm_api_integration/trial/tests/index.ts b/x-pack/test/apm_api_integration/trial/tests/index.ts index bf32c4661afd5f..ae62253c62d816 100644 --- a/x-pack/test/apm_api_integration/trial/tests/index.ts +++ b/x-pack/test/apm_api_integration/trial/tests/index.ts @@ -19,6 +19,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr }); describe('Settings', function () { + loadTestFile(require.resolve('./settings/custom_link.ts')); describe('Anomaly detection', function () { loadTestFile(require.resolve('./settings/anomaly_detection/no_access_user')); loadTestFile(require.resolve('./settings/anomaly_detection/read_user')); diff --git a/x-pack/test/apm_api_integration/trial/tests/settings/custom_link.ts b/x-pack/test/apm_api_integration/trial/tests/settings/custom_link.ts new file mode 100644 index 00000000000000..bcfe8fce4b948c --- /dev/null +++ b/x-pack/test/apm_api_integration/trial/tests/settings/custom_link.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import URL from 'url'; +import expect from '@kbn/expect'; +import { CustomLink } from '../../../../../plugins/apm/common/custom_link/custom_link_types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function customLinksTests({ getService }: FtrProviderContext) { + const supertestRead = getService('supertest'); + const supertestWrite = getService('supertestAsApmWriteUser'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + + const archiveName = 'apm_8.0.0'; + + function searchCustomLinks(filters?: any) { + const path = URL.format({ + pathname: `/api/apm/settings/custom_links`, + query: filters, + }); + return supertestRead.get(path).set('kbn-xsrf', 'foo'); + } + + async function createCustomLink(customLink: CustomLink) { + log.debug('creating configuration', customLink); + const res = await supertestWrite + .post(`/api/apm/settings/custom_links`) + .send(customLink) + .set('kbn-xsrf', 'foo'); + + throwOnError(res); + + return res; + } + + async function updateCustomLink(id: string, customLink: CustomLink) { + log.debug('updating configuration', id, customLink); + const res = await supertestWrite + .put(`/api/apm/settings/custom_links/${id}`) + .send(customLink) + .set('kbn-xsrf', 'foo'); + + throwOnError(res); + + return res; + } + + async function deleteCustomLink(id: string) { + log.debug('deleting configuration', id); + const res = await supertestWrite + .delete(`/api/apm/settings/custom_links/${id}`) + .set('kbn-xsrf', 'foo'); + + throwOnError(res); + + return res; + } + + function throwOnError(res: any) { + const { statusCode, req, body } = res; + if (statusCode !== 200) { + throw new Error(` + Endpoint: ${req.method} ${req.path} + Service: ${JSON.stringify(res.request._data.service)} + Status code: ${statusCode} + Response: ${body.message}`); + } + } + + describe('custom links', () => { + before(async () => { + const customLink = { + url: 'https://elastic.co', + label: 'with filters', + filters: [ + { key: 'service.name', value: 'baz' }, + { key: 'transaction.type', value: 'qux' }, + ], + } as CustomLink; + await createCustomLink(customLink); + }); + it('fetches a custom link', async () => { + const { status, body } = await searchCustomLinks({ + 'service.name': 'baz', + 'transaction.type': 'qux', + }); + const { label, url, filters } = body[0]; + + expect(status).to.equal(200); + expect({ label, url, filters }).to.eql({ + label: 'with filters', + url: 'https://elastic.co', + filters: [ + { key: 'service.name', value: 'baz' }, + { key: 'transaction.type', value: 'qux' }, + ], + }); + }); + it('updates a custom link', async () => { + let { status, body } = await searchCustomLinks({ + 'service.name': 'baz', + 'transaction.type': 'qux', + }); + expect(status).to.equal(200); + await updateCustomLink(body[0].id, { + label: 'foo', + url: 'https://elastic.co?service.name={{service.name}}', + filters: [ + { key: 'service.name', value: 'quz' }, + { key: 'transaction.name', value: 'bar' }, + ], + }); + ({ status, body } = await searchCustomLinks({ + 'service.name': 'quz', + 'transaction.name': 'bar', + })); + const { label, url, filters } = body[0]; + expect(status).to.equal(200); + expect({ label, url, filters }).to.eql({ + label: 'foo', + url: 'https://elastic.co?service.name={{service.name}}', + filters: [ + { key: 'service.name', value: 'quz' }, + { key: 'transaction.name', value: 'bar' }, + ], + }); + }); + it('deletes a custom link', async () => { + let { status, body } = await searchCustomLinks({ + 'service.name': 'quz', + 'transaction.name': 'bar', + }); + expect(status).to.equal(200); + await deleteCustomLink(body[0].id); + ({ status, body } = await searchCustomLinks({ + 'service.name': 'quz', + 'transaction.name': 'bar', + })); + expect(status).to.equal(200); + expect(body).to.eql([]); + }); + + describe('transaction', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('fetches a transaction sample', async () => { + const response = await supertestRead.get( + '/api/apm/settings/custom_links/transaction?service.name=opbeans-java' + ); + expect(response.status).to.be(200); + expect(response.body.service.name).to.eql('opbeans-java'); + }); + }); + }); +}