From 3a65925b25dc80d69f09d3dc97a4e8d42f23b303 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 11:42:46 +0100 Subject: [PATCH] [APM] Track usage of Gold+ features (#77630) (#77854) * 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 0000000000000..bc0ffefd79c4d --- /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 aa34515ea460a..45a7fa2a118f2 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 0f6061653f352..1cda70a140c67 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 f25e37927f094..b417f8689b229 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 04807cfac1cea..1996d4d4a262d 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 2cc0cdb1c2b91..f0a22356d074b 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 83c23a75e999d..7882383d78ab0 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 400d0d294bf02..e0e13b7b7fb98 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 a1c647a854bf6..60b4020e73dce 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 bf32c4661afd5..ae62253c62d81 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 0000000000000..bcfe8fce4b948 --- /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'); + }); + }); + }); +}