diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index 0a049f80a82fea..d0c4c9733da2a5 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -4,6 +4,7 @@ "kibanaVersion": "kibana", "requiredPlugins": ["home"], "configPath": ["enterpriseSearch"], + "optionalPlugins": ["usageCollection"], "server": true, "ui": true } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx index b33a4ea17f756c..7459800d4a893d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -9,8 +9,8 @@ import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@ import { EuiButton } from '../../../shared/react_router_helpers'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; - import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; @@ -21,6 +21,7 @@ export const ErrorState: ReactFC<> = () => { return ( + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx index f1ebaed71d95bd..41ffe88f57fccc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/no_user_state.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { EngineOverviewHeader } from '../engine_overview_header'; import { getUserName } from '../../utils/get_username'; @@ -19,6 +20,7 @@ export const NoUserState: React.FC<> = () => { return ( + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 67f2a512ca1b37..c55f1f46c0e505 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import EnginesIcon from '../../assets/engine.svg'; @@ -96,6 +97,7 @@ export const EngineOverview: ReactFC<> = () => { return ( + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index c4ad289bfda6f4..ed51c40671b4ae 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -7,6 +7,7 @@ import React, { useContext } from 'react'; import { EuiBasicTable, EuiLink } from '@elastic/eui'; +import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; interface IEngineTableProps { @@ -27,17 +28,24 @@ export const EngineTable: ReactFC = ({ data, pagination: { totalEngines, pageIndex = 0, onPaginate }, }) => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const engineLinkProps = { + href: `${enterpriseSearchUrl}/as/engines/${name}`, + target: '_blank', + onClick: () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }), + }; const columns = [ { field: 'name', name: 'Name', - render: name => ( - - {name} - - ), + render: name => {name}, width: '30%', truncateText: true, mobileOptions: { @@ -78,11 +86,7 @@ export const EngineTable: ReactFC = ({ field: 'name', name: 'Actions', dataType: 'string', - render: name => ( - - Manage - - ), + render: name => Manage, align: 'right', width: '100px', }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index 69bfb9ad124eb5..df3238fde56d88 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -7,10 +7,11 @@ import React, { useContext } from 'react'; import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiButton } from '@elastic/eui'; +import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; export const EngineOverviewHeader: React.FC<> = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { fill: true, @@ -20,6 +21,13 @@ export const EngineOverviewHeader: React.FC<> = () => { if (enterpriseSearchUrl) { buttonProps.href = `${enterpriseSearchUrl}/as`; buttonProps.target = '_blank'; + buttonProps.onClick = () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'header_launch_button', + }); } else { buttonProps.isDisabled = true; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index c1bb71bada7232..3931f1dc0073ec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -24,6 +24,7 @@ import { } from '@elastic/eui'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import GettingStarted from '../../assets/getting_started.png'; import './setup_guide.scss'; @@ -32,6 +33,8 @@ export const SetupGuide: React.FC<> = () => { return ( + + Setup Guide diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts new file mode 100644 index 00000000000000..f871f48b171548 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { sendTelemetry } from './send_telemetry'; +export { SendAppSearchTelemetry } from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx new file mode 100644 index 00000000000000..a46e2124d95c52 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 React from 'react'; +import { mount } from 'enzyme'; + +import { httpServiceMock } from 'src/core/public/mocks'; +import { mountWithKibanaContext } from '../../test_utils/helpers'; +import { sendTelemetry, SendAppSearchTelemetry } from './'; + +describe('Shared Telemetry Helpers', () => { + const httpMock = httpServiceMock.createSetupContract(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('sendTelemetry', () => { + it('successfully calls the server-side telemetry endpoint', () => { + sendTelemetry({ + http: httpMock, + product: 'enterprise_search', + action: 'viewed', + metric: 'setup_guide', + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: '{"action":"viewed","metric":"setup_guide"}', + }); + }); + + it('throws an error if the telemetry endpoint fails', () => { + const httpRejectMock = { put: () => Promise.reject() }; + + expect(sendTelemetry({ http: httpRejectMock })).rejects.toThrow('Unable to send telemetry'); + }); + }); + + describe('React component helpers', () => { + it('SendAppSearchTelemetry component', () => { + const wrapper = mountWithKibanaContext( + , + { http: httpMock } + ); + + expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: '{"action":"clicked","metric":"button"}', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx new file mode 100644 index 00000000000000..00c521303d2690 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -0,0 +1,50 @@ +/* + * 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 React, { useContext, useEffect } from 'react'; + +import { HttpHandler } from 'src/core/public'; +import { KibanaContext, IKibanaContext } from '../../index'; + +interface ISendTelemetryProps { + action: 'viewed' | 'error' | 'clicked'; + metric: string; // e.g., 'setup_guide' +} + +interface ISendTelemetry extends ISendTelemetryProps { + http(): HttpHandler; + product: 'app_search' | 'workplace_search' | 'enterprise_search'; +} + +/** + * Base function - useful for non-component actions, e.g. clicks + */ + +export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { + try { + await http.put(`/api/${product}/telemetry`, { + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ action, metric }), + }); + } catch (error) { + throw new Error('Unable to send telemetry'); + } +}; + +/** + * React component helpers - useful for on-page-load/views + * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry + */ + +export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'app_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts new file mode 100644 index 00000000000000..f6028284f3d00c --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -0,0 +1,118 @@ +/* + * 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 { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; + +/** + * Since these route callbacks are so thin, these serve simply as integration tests + * to ensure they're wired up to the lib functions correctly. Business logic is tested + * more thoroughly in the lib/telemetry tests. + */ +describe('App Search Telemetry Usage Collector', () => { + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.setup_guide': 10, + 'ui_viewed.engines_overview': 20, + 'ui_error.cannot_connect': 3, + 'ui_error.no_as_account': 4, + 'ui_clicked.header_launch_button': 50, + 'ui_clicked.engine_table_link': 60, + }, + }), + incrementCounter: jest.fn(), + }; + const dependencies = { + usageCollection: { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + }, + savedObjects: { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(dependencies); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search_kibana_telemetry'); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(dependencies); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 10, + engines_overview: 20, + }, + ui_error: { + cannot_connect: 3, + no_as_account: 4, + }, + ui_clicked: { + header_launch_button: 50, + engine_table_link: 60, + }, + }); + }); + + it('should not error & should return a default telemetry object if no saved data exists', async () => { + registerTelemetryUsageCollector({ + ...dependencies, + savedObjects: { createInternalRepository: () => ({}) }, + }); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + no_as_account: 0, + }, + ui_clicked: { + header_launch_button: 0, + engine_table_link: 0, + }, + }); + }); + }); + + describe('incrementUICounter', () => { + it('should increment the saved objects internal repository', async () => { + const { savedObjects } = dependencies; + + const response = await incrementUICounter({ + savedObjects, + uiAction: 'ui_clicked', + metric: 'button', + }); + + expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( + 'app_search_kibana_telemetry', + 'app_search_kibana_telemetry', + 'ui_clicked.button' + ); + expect(response).toEqual({ success: true }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts new file mode 100644 index 00000000000000..302e9843488e1d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -0,0 +1,109 @@ +/* + * 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 { set } from 'lodash'; +import { ISavedObjectsRepository, SavedObjectsServiceStart } from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +import { AS_TELEMETRY_NAME, ITelemetrySavedObject } from '../../saved_objects/app_search/telemetry'; + +/** + * Register the telemetry collector + */ + +interface Dependencies { + savedObjects: SavedObjectsServiceStart; + usageCollection: UsageCollectionSetup; +} + +export const registerTelemetryUsageCollector = ({ + usageCollection, + savedObjects, +}: Dependencies) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: AS_TELEMETRY_NAME, + fetch: async () => fetchTelemetryMetrics(savedObjects), + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = await getSavedObjectAttributesFromRepo(savedObjectsRepository); + + const defaultTelemetrySavedObject: ITelemetrySavedObject = { + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + no_as_account: 0, + }, + ui_clicked: { + header_launch_button: 0, + engine_table_link: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + // Iterate through each attribute key and set its saved values + const attributeKeys = Object.keys(savedObjectAttributes); + const telemetryObj = defaultTelemetrySavedObject; + attributeKeys.forEach((key: string) => { + set(telemetryObj, key, savedObjectAttributes[key]); + }); + + return telemetryObj as ITelemetrySavedObject; +}; + +/** + * Helper function - fetches saved objects attributes + */ + +interface ISavedObjectAttributes { + [key: string]: any; +} + +const getSavedObjectAttributesFromRepo = async ( + savedObjectsRepository: ISavedObjectsRepository +) => { + try { + return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes; + } catch (e) { + return null; + } +}; + +/** + * Set saved objection attributes - used by telemetry route + */ + +interface IIncrementUICounter { + savedObjects: SavedObjectsServiceStart; + uiAction: string; + metric: string; +} + +export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) { + const internalRepository = savedObjects.createInternalRepository(); + + await internalRepository.incrementCounter( + AS_TELEMETRY_NAME, + AS_TELEMETRY_NAME, + `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide + ); + + return { success: true }; +} diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 1ebe58ab28c8ef..d9e5ce79537e61 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -6,9 +6,23 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { + Plugin, + PluginInitializerContext, + CoreSetup, + CoreStart, + SavedObjectsServiceStart, +} from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { registerEnginesRoute } from './routes/app_search/engines'; +import { registerTelemetryRoute } from './routes/app_search/telemetry'; +import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; + +export interface PluginsSetup { + usageCollection?: UsageCollectionSetup; +} export interface ServerConfigType { host?: string; @@ -16,20 +30,45 @@ export interface ServerConfigType { export class EnterpriseSearchPlugin implements Plugin { private config: Observable; + private savedObjects?: SavedObjectsServiceStart; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create(); } - public async setup({ http }: CoreSetup) { + public async setup( + { http, savedObjects, getStartServices }: CoreSetup, + { usageCollection }: PluginsSetup + ) { const router = http.createRouter(); const config = await this.config.pipe(first()).toPromise(); const dependencies = { router, config }; registerEnginesRoute(dependencies); + + /** + * Bootstrap the routes, saved objects, and collector for telemetry + */ + registerTelemetryRoute({ + ...dependencies, + getSavedObjectsService: () => { + if (!this.savedObjectsServiceStart) { + throw new Error('Saved Objects Start service not available'); + } + return this.savedObjectsServiceStart; + }, + }); + savedObjects.registerType(appSearchTelemetryType); + if (usageCollection) { + getStartServices().then(([{ savedObjects: savedObjectsStarted }]) => { + registerTelemetryUsageCollector({ usageCollection, savedObjects: savedObjectsStarted }); + }); + } } - public start() {} + public start({ savedObjects }: CoreStart) { + this.savedObjectsServiceStart = savedObjects; + } public stop() {} } diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts new file mode 100644 index 00000000000000..02fc3f63f402a6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { mockRouter, RouterMock } from 'src/core/server/http/router/router.mock'; +import { savedObjectsServiceMock } from 'src/core/server/saved_objects/saved_objects_service.mock'; +import { httpServerMock } from 'src/core/server/http/http_server.mocks'; + +import { registerTelemetryRoute } from './telemetry'; + +jest.mock('../../collectors/app_search/telemetry', () => ({ + incrementUICounter: jest.fn(), +})); +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +describe('App Search Telemetry API', () => { + let router: RouterMock; + const mockResponseFactory = httpServerMock.createResponseFactory(); + + beforeEach(() => { + jest.resetAllMocks(); + router = mockRouter.create(); + registerTelemetryRoute({ + router, + getSavedObjectsService: () => savedObjectsServiceMock.create(), + }); + }); + + describe('PUT /api/app_search/telemetry', () => { + it('increments the saved objects counter', async () => { + const successResponse = { success: true }; + incrementUICounter.mockImplementation(jest.fn(() => successResponse)); + + await callThisRoute('put', { body: { action: 'viewed', metric: 'setup_guide' } }); + + expect(incrementUICounter).toHaveBeenCalledWith({ + savedObjects: expect.any(Object), + uiAction: 'ui_viewed', + metric: 'setup_guide', + }); + expect(mockResponseFactory.ok).toHaveBeenCalledWith({ body: successResponse }); + }); + + it('throws an error when incrementing fails', async () => { + incrementUICounter.mockImplementation(jest.fn(() => Promise.reject())); + + await callThisRoute('put', { body: { action: 'error', metric: 'error' } }); + + expect(incrementUICounter).toHaveBeenCalled(); + expect(mockResponseFactory.internalError).toHaveBeenCalled(); + }); + + describe('validates', () => { + const itShouldValidate = request => { + expect(() => executeRouteValidation(request)).not.toThrow(); + }; + + const itShouldThrow = request => { + expect(() => executeRouteValidation(request)).toThrow(); + }; + + it('correctly', () => { + const request = { body: { action: 'viewed', metric: 'setup_guide' } }; + itShouldValidate(request); + }); + + it('wrong action enum', () => { + const request = { body: { action: 'invalid', metric: 'setup_guide' } }; + itShouldThrow(request); + }); + + describe('wrong metric type', () => { + const request = { body: { action: 'clicked', metric: true } }; + itShouldThrow(request); + }); + + describe('action is missing', () => { + const request = { body: { metric: 'engines_overview' } }; + itShouldThrow(request); + }); + + describe('metric is missing', () => { + const request = { body: { action: 'error' } }; + itShouldThrow(request); + }); + }); + }); + + /** + * Test helpers + */ + + const callThisRoute = async (method, request) => { + const [_, handler] = router[method].mock.calls[0]; + + const context = {}; + await handler(context, httpServerMock.createKibanaRequest(request), mockResponseFactory); + }; + + const executeRouteValidation = request => { + const method = 'put'; + + const [config] = router[method].mock.calls[0]; + const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + + const payload = 'body'; + validate[payload].validate(request[payload]); + }; +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts new file mode 100644 index 00000000000000..3eabe1f19c5ff4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts @@ -0,0 +1,42 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +export function registerTelemetryRoute({ router, getSavedObjectsService }) { + router.put( + { + path: '/api/app_search/telemetry', + validate: { + body: schema.object({ + action: schema.oneOf([ + schema.literal('viewed'), + schema.literal('clicked'), + schema.literal('error'), + ]), + metric: schema.string(), + }), + }, + }, + async (ctx, request, response) => { + const { action, metric } = request.body; + + try { + return response.ok({ + body: await incrementUICounter({ + savedObjects: getSavedObjectsService(), + uiAction: `ui_${action}`, + metric, + }), + }); + } catch (e) { + return response.internalError({ body: 'App Search UI telemetry failed' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts new file mode 100644 index 00000000000000..614492bcf65104 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -0,0 +1,70 @@ +/* + * 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 { SavedObjectsType } from 'src/core/server'; + +export const AS_TELEMETRY_NAME = 'app_search_kibana_telemetry'; + +export interface ITelemetrySavedObject { + ui_viewed: { + setup_guide: number; + engines_overview: number; + }; + ui_error: { + cannot_connect: number; + no_as_account: number; + }; + ui_clicked: { + header_launch_button: number; + engine_table_link: number; + }; +} + +export const appSearchTelemetryType: SavedObjectsType = { + name: AS_TELEMETRY_NAME, + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + ui_viewed: { + properties: { + setup_guide: { + type: 'long', + null_value: 0, + }, + engines_overview: { + type: 'long', + null_value: 0, + }, + }, + }, + ui_error: { + properties: { + cannot_connect: { + type: 'long', + null_value: 0, + }, + no_as_account: { + type: 'long', + null_value: 0, + }, + }, + }, + ui_clicked: { + properties: { + header_launch_button: { + type: 'long', + null_value: 0, + }, + engine_table_link: { + type: 'long', + null_value: 0, + }, + }, + }, + }, + }, +};