[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
This commit is contained in:
Constance 2021-01-14 13:15:39 -08:00 committed by GitHub
parent e4293a85fc
commit 53efccbc3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 663 additions and 0 deletions

View file

@ -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();
});
});
});
});

View file

@ -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<MakeLogicType<AnalyticsValues, AnalyticsActions>>({
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();
}
},
}),
});

View file

@ -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';

View file

@ -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[];
}

View file

@ -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);
});
});
});
});

View file

@ -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);
}
);
}

View file

@ -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);
};