From 53efccbc3b2d79c77cca156dd244541f6659f948 Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 14 Jan 2021 13:15:39 -0800 Subject: [PATCH] [App Search] Analytics - initial logic file & server API routes (#88250) * Set up server API routes * Set up very basic AnalyticsLogic file - mostly just contains API call & basic loading/error state for now - actual usable vars will be defined in a future PR * [PR feedback] Unnecessary exports * [PR feedback] Clean up analyticsAvailable reducer * [PR feedback] Types order * [PR feedback] Unnecessary API validation --- .../analytics/analytics_logic.test.ts | 308 ++++++++++++++++++ .../components/analytics/analytics_logic.ts | 104 ++++++ .../app_search/components/analytics/index.ts | 1 + .../app_search/components/analytics/types.ts | 61 ++++ .../routes/app_search/analytics.test.ts | 124 +++++++ .../server/routes/app_search/analytics.ts | 63 ++++ .../server/routes/app_search/index.ts | 2 + 7 files changed, 663 insertions(+) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/analytics.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts new file mode 100644 index 000000000000..62e241df0136 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts @@ -0,0 +1,308 @@ +/* + * 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 { LogicMounter } from '../../../__mocks__/kea.mock'; + +jest.mock('../../../shared/kibana', () => ({ + KibanaLogic: { values: { history: { location: { search: '' } } } }, +})); +import { KibanaLogic } from '../../../shared/kibana'; + +jest.mock('../../../shared/http', () => ({ + HttpLogic: { values: { http: { get: jest.fn() } } }, +})); +import { HttpLogic } from '../../../shared/http'; + +jest.mock('../../../shared/flash_messages', () => ({ + flashAPIErrors: jest.fn(), +})); +import { flashAPIErrors } from '../../../shared/flash_messages'; + +jest.mock('../engine', () => ({ + EngineLogic: { values: { engineName: 'test-engine' } }, +})); + +import { AnalyticsLogic } from './'; + +describe('AnalyticsLogic', () => { + const DEFAULT_VALUES = { + dataLoading: true, + analyticsUnavailable: false, + }; + + const MOCK_TOP_QUERIES = [ + { + doc_count: 5, + key: 'some-key', + }, + { + doc_count: 0, + key: 'another-key', + }, + ]; + const MOCK_RECENT_QUERIES = [ + { + document_ids: ['1', '2'], + query_string: 'some-query', + tags: ['some-tag'], + timestamp: 'some-timestamp', + }, + ]; + const MOCK_TOP_CLICKS = [ + { + key: 'highly-clicked-query', + doc_count: 1, + document: { + id: 'some-id', + engine: 'some-engine', + tags: [], + }, + clicks: { + doc_count: 100, + }, + }, + ]; + const MOCK_ANALYTICS_RESPONSE = { + analyticsUnavailable: false, + allTags: ['some-tag'], + recentQueries: MOCK_RECENT_QUERIES, + topQueries: MOCK_TOP_QUERIES, + topQueriesNoResults: MOCK_TOP_QUERIES, + topQueriesNoClicks: MOCK_TOP_QUERIES, + topQueriesWithClicks: MOCK_TOP_QUERIES, + totalClicks: 1000, + totalQueries: 5000, + totalQueriesNoResults: 500, + clicksPerDay: [0, 10, 50], + queriesPerDay: [10, 50, 100], + queriesNoResultsPerDay: [1, 2, 3], + }; + const MOCK_QUERY_RESPONSE = { + analyticsUnavailable: false, + allTags: ['some-tag'], + totalQueriesForQuery: 50, + queriesPerDayForQuery: [25, 0, 25], + topClicksForQuery: MOCK_TOP_CLICKS, + }; + + const { mount } = new LogicMounter(AnalyticsLogic); + + beforeEach(() => { + jest.clearAllMocks(); + KibanaLogic.values.history.location.search = ''; + }); + + it('has expected default values', () => { + mount(); + expect(AnalyticsLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onAnalyticsUnavailable', () => { + it('should set state', () => { + mount(); + AnalyticsLogic.actions.onAnalyticsUnavailable(); + + expect(AnalyticsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: false, + analyticsUnavailable: true, + }); + }); + }); + + describe('onAnalyticsDataLoad', () => { + it('should set state', () => { + mount(); + AnalyticsLogic.actions.onAnalyticsDataLoad(MOCK_ANALYTICS_RESPONSE); + + expect(AnalyticsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: false, + analyticsUnavailable: false, + // TODO: more state will get set here in future PRs + }); + }); + }); + + describe('onQueryDataLoad', () => { + it('should set state', () => { + mount(); + AnalyticsLogic.actions.onQueryDataLoad(MOCK_QUERY_RESPONSE); + + expect(AnalyticsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: false, + analyticsUnavailable: false, + // TODO: more state will get set here in future PRs + }); + }); + }); + }); + + describe('listeners', () => { + describe('loadAnalyticsData', () => { + it('should set state', () => { + mount({ dataLoading: false }); + + AnalyticsLogic.actions.loadAnalyticsData(); + + expect(AnalyticsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + + it('should make an API call and set state based on the response', async () => { + const promise = Promise.resolve(MOCK_ANALYTICS_RESPONSE); + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); + mount(); + jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsDataLoad'); + + AnalyticsLogic.actions.loadAnalyticsData(); + await promise; + + expect(HttpLogic.values.http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/analytics/queries', + { + query: { size: 20 }, + } + ); + expect(AnalyticsLogic.actions.onAnalyticsDataLoad).toHaveBeenCalledWith( + MOCK_ANALYTICS_RESPONSE + ); + }); + + it('parses and passes the current search query string', async () => { + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce({}); + KibanaLogic.values.history.location.search = + '?start=1970-01-01&end=1970-01-02&&tag=some_tag'; + mount(); + + AnalyticsLogic.actions.loadAnalyticsData(); + + expect(HttpLogic.values.http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/analytics/queries', + { + query: { + start: '1970-01-01', + end: '1970-01-02', + tag: 'some_tag', + size: 20, + }, + } + ); + }); + + it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { + const promise = Promise.resolve({ analyticsUnavailable: true }); + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); + mount(); + jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); + + AnalyticsLogic.actions.loadAnalyticsData(); + await promise; + + expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); + }); + + it('handles errors', async () => { + const promise = Promise.reject('error'); + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); + mount(); + jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); + + try { + AnalyticsLogic.actions.loadAnalyticsData(); + await promise; + } catch { + // Do nothing + } + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); + }); + }); + + describe('loadQueryData', () => { + it('should set state', () => { + mount({ dataLoading: false }); + + AnalyticsLogic.actions.loadQueryData('some-query'); + + expect(AnalyticsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + + it('should make an API call and set state based on the response', async () => { + const promise = Promise.resolve(MOCK_QUERY_RESPONSE); + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); + mount(); + jest.spyOn(AnalyticsLogic.actions, 'onQueryDataLoad'); + + AnalyticsLogic.actions.loadQueryData('some-query'); + await promise; + + expect(HttpLogic.values.http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/analytics/queries/some-query', + expect.any(Object) // empty query obj + ); + expect(AnalyticsLogic.actions.onQueryDataLoad).toHaveBeenCalledWith(MOCK_QUERY_RESPONSE); + }); + + it('parses and passes the current search query string', async () => { + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce({}); + KibanaLogic.values.history.location.search = + '?start=1970-12-30&end=1970-12-31&&tag=another_tag'; + mount(); + + AnalyticsLogic.actions.loadQueryData('some-query'); + + expect(HttpLogic.values.http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/analytics/queries/some-query', + { + query: { + start: '1970-12-30', + end: '1970-12-31', + tag: 'another_tag', + }, + } + ); + }); + + it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { + const promise = Promise.resolve({ analyticsUnavailable: true }); + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); + mount(); + jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); + + AnalyticsLogic.actions.loadQueryData('some-query'); + await promise; + + expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); + }); + + it('handles errors', async () => { + const promise = Promise.reject('error'); + (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); + mount(); + jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); + + try { + AnalyticsLogic.actions.loadQueryData('some-query'); + await promise; + } catch { + // Do nothing + } + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts new file mode 100644 index 000000000000..1e26acfc3967 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts @@ -0,0 +1,104 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; +import queryString from 'query-string'; + +import { KibanaLogic } from '../../../shared/kibana'; +import { HttpLogic } from '../../../shared/http'; +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { EngineLogic } from '../engine'; + +import { AnalyticsData, QueryDetails } from './types'; + +interface AnalyticsValues extends AnalyticsData, QueryDetails { + dataLoading: boolean; +} + +interface AnalyticsActions { + onAnalyticsUnavailable(): void; + onAnalyticsDataLoad(data: AnalyticsData): AnalyticsData; + onQueryDataLoad(data: QueryDetails): QueryDetails; + loadAnalyticsData(): void; + loadQueryData(query: string): string; +} + +export const AnalyticsLogic = kea>({ + path: ['enterprise_search', 'app_search', 'analytics_logic'], + actions: () => ({ + onAnalyticsUnavailable: true, + onAnalyticsDataLoad: (data) => data, + onQueryDataLoad: (data) => data, + loadAnalyticsData: true, + loadQueryData: (query) => query, + }), + reducers: () => ({ + dataLoading: [ + true, + { + loadAnalyticsData: () => true, + loadQueryData: () => true, + onAnalyticsUnavailable: () => false, + onAnalyticsDataLoad: () => false, + onQueryDataLoad: () => false, + }, + ], + analyticsUnavailable: [ + false, + { + onAnalyticsUnavailable: () => true, + onAnalyticsDataLoad: () => false, + onQueryDataLoad: () => false, + }, + ], + }), + listeners: ({ actions }) => ({ + loadAnalyticsData: async () => { + const { history } = KibanaLogic.values; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const { start, end, tag } = queryString.parse(history.location.search); + const query = { start, end, tag, size: 20 }; + const url = `/api/app_search/engines/${engineName}/analytics/queries`; + + const response = await http.get(url, { query }); + + if (response.analyticsUnavailable) { + actions.onAnalyticsUnavailable(); + } else { + actions.onAnalyticsDataLoad(response); + } + } catch (e) { + flashAPIErrors(e); + actions.onAnalyticsUnavailable(); + } + }, + loadQueryData: async (query) => { + const { history } = KibanaLogic.values; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const { start, end, tag } = queryString.parse(history.location.search); + const queryParams = { start, end, tag }; + const url = `/api/app_search/engines/${engineName}/analytics/queries/${query}`; + + const response = await http.get(url, { query: queryParams }); + + if (response.analyticsUnavailable) { + actions.onAnalyticsUnavailable(); + } else { + actions.onQueryDataLoad(response); + } + } catch (e) { + flashAPIErrors(e); + actions.onAnalyticsUnavailable(); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/index.ts index 0ab5ab80e835..4dbc2d930ddf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/index.ts @@ -5,6 +5,7 @@ */ export { ANALYTICS_TITLE } from './constants'; +export { AnalyticsLogic } from './analytics_logic'; export { AnalyticsRouter } from './analytics_router'; export { AnalyticsChart } from './components'; export { convertToChartData } from './utils'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts new file mode 100644 index 000000000000..5cc14038507b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts @@ -0,0 +1,61 @@ +/* + * 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. + */ + +interface Query { + doc_count: number; + key: string; + clicks?: { doc_count: number }; + searches?: { doc_count: number }; + tags?: string[]; +} + +interface QueryClick extends Query { + document?: { + id: string; + engine: string; + tags?: string[]; + }; +} + +interface RecentQuery { + document_ids: string[]; + query_string: string; + tags: string[]; + timestamp: string; +} + +/** + * API response data + */ + +interface BaseData { + analyticsUnavailable: boolean; + allTags: string[]; + // NOTE: The API sends us back even more data than this (e.g., + // startDate, endDate, currentTag, logRetentionSettings, query), + // but we currently don't need that data in our front-end code, + // so I'm opting not to list them in our types +} + +export interface AnalyticsData extends BaseData { + recentQueries: RecentQuery[]; + topQueries: Query[]; + topQueriesWithClicks: Query[]; + topQueriesNoClicks: Query[]; + topQueriesNoResults: Query[]; + totalClicks: number; + totalQueries: number; + totalQueriesNoResults: number; + clicksPerDay: number[]; + queriesPerDay: number[]; + queriesNoResultsPerDay: number[]; +} + +export interface QueryDetails extends BaseData { + totalQueriesForQuery: number; + queriesPerDayForQuery: number[]; + topClicksForQuery: QueryClick[]; +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts new file mode 100644 index 000000000000..9ede6989052b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.test.ts @@ -0,0 +1,124 @@ +/* + * 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, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerAnalyticsRoutes } from './analytics'; + +describe('analytics routes', () => { + describe('GET /api/app_search/engines/{engineName}/analytics/queries', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/analytics/queries', + payload: 'query', + }); + + registerAnalyticsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/some-engine/analytics/queries', + }); + }); + + describe('validates', () => { + it('correctly without optional query params', () => { + const request = { query: {} }; + mockRouter.shouldValidate(request); + }); + + it('correctly with all optional query params', () => { + const request = { + query: { + size: 20, + start: '1970-01-01', + end: '1970-01-02', + tag: 'some-tag', + }, + }; + mockRouter.shouldValidate(request); + }); + + it('incorrect types', () => { + const request = { + query: { + start: 100, + size: '100', + }, + }; + mockRouter.shouldThrow(request); + }); + }); + }); + + describe('GET /api/app_search/engines/{engineName}/analytics/queries/{query}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/analytics/queries/{query}', + payload: 'query', + }); + + registerAnalyticsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine', query: 'some-query' }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/some-engine/analytics/query/some-query', + }); + }); + + describe('validates', () => { + it('correctly without optional query params', () => { + const request = { query: {} }; + mockRouter.shouldValidate(request); + }); + + it('correctly with all optional query params', () => { + const request = { + query: { + start: '1970-01-01', + end: '1970-01-02', + tag: 'some-tag', + }, + }; + mockRouter.shouldValidate(request); + }); + + it('incorrect types', () => { + const request = { + query: { + start: 100, + tag: false, + }, + }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.ts new file mode 100644 index 000000000000..f7d0786b27fd --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/analytics.ts @@ -0,0 +1,63 @@ +/* + * 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 { RouteDependencies } from '../../plugin'; + +const querySchema = { + start: schema.maybe(schema.string()), // Date string, expected format 'YYYY-MM-DD' + end: schema.maybe(schema.string()), // Date string, expected format 'YYYY-MM-DD' + tag: schema.maybe(schema.string()), +}; +const queriesSchema = { + ...querySchema, + size: schema.maybe(schema.number()), +}; + +export function registerAnalyticsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/analytics/queries', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object(queriesSchema), + }, + }, + async (context, request, response) => { + const { engineName } = request.params; + + return enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/${engineName}/analytics/queries`, + })(context, request, response); + } + ); + + router.get( + { + path: '/api/app_search/engines/{engineName}/analytics/queries/{query}', + validate: { + params: schema.object({ + engineName: schema.string(), + query: schema.string(), + }), + query: schema.object(querySchema), + }, + }, + async (context, request, response) => { + const { engineName, query } = request.params; + + return enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/${engineName}/analytics/query/${query}`, + })(context, request, response); + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 67dcbfdc4f4d..a20e7854db17 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,12 +9,14 @@ import { RouteDependencies } from '../../plugin'; import { registerEnginesRoutes } from './engines'; import { registerCredentialsRoutes } from './credentials'; import { registerSettingsRoutes } from './settings'; +import { registerAnalyticsRoutes } from './analytics'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerEnginesRoutes(dependencies); registerCredentialsRoutes(dependencies); registerSettingsRoutes(dependencies); + registerAnalyticsRoutes(dependencies); registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); };