diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index e9de8fcd890d0..0e495391c94f2 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -38,6 +38,11 @@ import { setHelpExtension } from './setHelpExtension'; import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { createStaticIndexPattern } from './services/rest/index_pattern'; +import { + fetchLandingPageData, + hasData, +} from './services/rest/observability_dashboard'; +import { getTheme } from './utils/get_theme'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -73,6 +78,19 @@ export class ApmPlugin implements Plugin { pluginSetupDeps.home.environment.update({ apmUi: true }); pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); + if (plugins.observability) { + const theme = getTheme({ + isDarkMode: core.uiSettings.get('theme:darkMode'), + }); + plugins.observability.dashboard.register({ + appName: 'apm', + fetchData: async (params) => { + return fetchLandingPageData(params, { theme }); + }, + hasData, + }); + } + core.application.register({ id: 'apm', title: 'APM', diff --git a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts b/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts new file mode 100644 index 0000000000000..1ee8d79ee99a5 --- /dev/null +++ b/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { fetchLandingPageData, hasData } from './observability_dashboard'; +import * as createCallApmApi from './createCallApmApi'; +import { getTheme } from '../../utils/get_theme'; + +const theme = getTheme({ isDarkMode: false }); + +describe('Observability dashboard data', () => { + const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + afterEach(() => { + callApmApiMock.mockClear(); + }); + describe('hasData', () => { + it('returns false when no data is available', async () => { + callApmApiMock.mockImplementation(() => Promise.resolve(false)); + const response = await hasData(); + expect(response).toBeFalsy(); + }); + it('returns true when data is available', async () => { + callApmApiMock.mockImplementation(() => Promise.resolve(true)); + const response = await hasData(); + expect(response).toBeTruthy(); + }); + }); + + describe('fetchLandingPageData', () => { + it('returns APM data with series and stats', async () => { + callApmApiMock.mockImplementation(() => + Promise.resolve({ + serviceCount: 10, + transactionCoordinates: [ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: 3 }, + ], + }) + ); + const response = await fetchLandingPageData( + { + startTime: '1', + endTime: '2', + bucketSize: '3', + }, + { theme } + ); + expect(response).toEqual({ + title: 'APM', + appLink: '/app/apm', + stats: { + services: { + type: 'number', + label: 'Services', + value: 10, + }, + transactions: { + type: 'number', + label: 'Transactions', + value: 6, + color: '#6092c0', + }, + }, + series: { + transactions: { + label: 'Transactions', + coordinates: [ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: 3 }, + ], + color: '#6092c0', + }, + }, + }); + }); + it('returns empty transaction coordinates', async () => { + callApmApiMock.mockImplementation(() => + Promise.resolve({ + serviceCount: 0, + transactionCoordinates: [], + }) + ); + const response = await fetchLandingPageData( + { + startTime: '1', + endTime: '2', + bucketSize: '3', + }, + { theme } + ); + expect(response).toEqual({ + title: 'APM', + appLink: '/app/apm', + stats: { + services: { + type: 'number', + label: 'Services', + value: 0, + }, + transactions: { + type: 'number', + label: 'Transactions', + value: 0, + color: '#6092c0', + }, + }, + series: { + transactions: { + label: 'Transactions', + coordinates: [], + color: '#6092c0', + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts b/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts new file mode 100644 index 0000000000000..2221904932b63 --- /dev/null +++ b/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts @@ -0,0 +1,71 @@ +/* + * 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'; +import { sum } from 'lodash'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FetchDataParams } from '../../../../observability/public/data_handler'; +import { ApmFetchDataResponse } from '../../../../observability/public/typings/fetch_data_response'; +import { callApmApi } from './createCallApmApi'; +import { Theme } from '../../utils/get_theme'; + +interface Options { + theme: Theme; +} + +export const fetchLandingPageData = async ( + { startTime, endTime, bucketSize }: FetchDataParams, + { theme }: Options +): Promise => { + const data = await callApmApi({ + pathname: '/api/apm/observability_dashboard', + params: { query: { start: startTime, end: endTime, bucketSize } }, + }); + + const { serviceCount, transactionCoordinates } = data; + + return { + title: i18n.translate('xpack.apm.observabilityDashboard.title', { + defaultMessage: 'APM', + }), + appLink: '/app/apm', + stats: { + services: { + type: 'number', + label: i18n.translate( + 'xpack.apm.observabilityDashboard.stats.services', + { defaultMessage: 'Services' } + ), + value: serviceCount, + }, + transactions: { + type: 'number', + label: i18n.translate( + 'xpack.apm.observabilityDashboard.stats.transactions', + { defaultMessage: 'Transactions' } + ), + value: sum(transactionCoordinates.map((coordinates) => coordinates.y)), + color: theme.euiColorVis1, + }, + }, + series: { + transactions: { + label: i18n.translate( + 'xpack.apm.observabilityDashboard.chart.transactions', + { defaultMessage: 'Transactions' } + ), + color: theme.euiColorVis1, + coordinates: transactionCoordinates, + }, + }, + }; +}; + +export async function hasData() { + return await callApmApi({ + pathname: '/api/apm/observability_dashboard/has_data', + }); +} diff --git a/x-pack/plugins/apm/public/utils/get_theme.ts b/x-pack/plugins/apm/public/utils/get_theme.ts new file mode 100644 index 0000000000000..e5020202b7721 --- /dev/null +++ b/x-pack/plugins/apm/public/utils/get_theme.ts @@ -0,0 +1,13 @@ +/* + * 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 lightTheme from '@elastic/eui/dist/eui_theme_light.json'; +import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; + +export type Theme = ReturnType; + +export function getTheme({ isDarkMode }: { isDarkMode: boolean }) { + return isDarkMode ? darkTheme : lightTheme; +} diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts new file mode 100644 index 0000000000000..4c4d058c7139d --- /dev/null +++ b/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts @@ -0,0 +1,52 @@ +/* + * 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 { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { + SERVICE_NAME, + PROCESSOR_EVENT, +} from '../../../common/elasticsearch_fieldnames'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; + +export async function getServiceCount({ + setup, +}: { + setup: Setup & SetupTimeRange; +}) { + const { client, indices, start, end } = setup; + + const params = { + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { + terms: { + [PROCESSOR_EVENT]: [ + ProcessorEvent.error, + ProcessorEvent.transaction, + ProcessorEvent.metric, + ], + }, + }, + ], + }, + }, + aggs: { serviceCount: { cardinality: { field: SERVICE_NAME } } }, + }, + }; + + const { aggregations } = await client.search(params); + return aggregations?.serviceCount.value || 0; +} diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts new file mode 100644 index 0000000000000..78ed11d839ad2 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts @@ -0,0 +1,57 @@ +/* + * 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. + */ +/* + * 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 { rangeFilter } from '../../../common/utils/range_filter'; +import { Coordinates } from '../../../../observability/public/typings/fetch_data_response'; +import { PROCESSOR_EVENT } from '../../../common/elasticsearch_fieldnames'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { ProcessorEvent } from '../../../common/processor_event'; + +export async function getTransactionCoordinates({ + setup, + bucketSize, +}: { + setup: Setup & SetupTimeRange; + bucketSize: string; +}): Promise { + const { client, indices, start, end } = setup; + + const { aggregations } = await client.search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { range: rangeFilter(start, end) }, + ], + }, + }, + aggs: { + distribution: { + date_histogram: { + field: '@timestamp', + fixed_interval: bucketSize, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + }, + }, + }, + }); + + return ( + aggregations?.distribution.buckets.map((bucket) => ({ + x: bucket.key, + y: bucket.doc_count, + })) || [] + ); +} diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts b/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts new file mode 100644 index 0000000000000..73cc2d273ec69 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts @@ -0,0 +1,26 @@ +/* + * 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 { Setup } from '../helpers/setup_request'; + +export async function hasData({ setup }: { setup: Setup }) { + const { client, indices } = setup; + try { + const params = { + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + ], + terminateAfter: 1, + size: 0, + }; + + const response = await client.search(params); + return response.hits.total.value > 0; + } catch (e) { + return false; + } +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index a34690aff43b4..02be2e7e4dcdf 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -76,6 +76,10 @@ import { rumPageViewsTrendRoute, rumPageLoadDistributionRoute, } from './rum_client'; +import { + observabilityDashboardHasDataRoute, + observabilityDashboardDataRoute, +} from './observability_dashboard'; const createApmApi = () => { const api = createApi() @@ -160,7 +164,11 @@ const createApmApi = () => { .add(rumOverviewLocalFiltersRoute) .add(rumPageViewsTrendRoute) .add(rumPageLoadDistributionRoute) - .add(rumClientMetricsRoute); + .add(rumClientMetricsRoute) + + // Observability dashboard + .add(observabilityDashboardHasDataRoute) + .add(observabilityDashboardDataRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/observability_dashboard.ts b/x-pack/plugins/apm/server/routes/observability_dashboard.ts new file mode 100644 index 0000000000000..10c74295fe3e4 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/observability_dashboard.ts @@ -0,0 +1,41 @@ +/* + * 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 * as t from 'io-ts'; +import { setupRequest } from '../lib/helpers/setup_request'; +import { hasData } from '../lib/observability_dashboard/has_data'; +import { createRoute } from './create_route'; +import { rangeRt } from './default_api_types'; +import { getServiceCount } from '../lib/observability_dashboard/get_service_count'; +import { getTransactionCoordinates } from '../lib/observability_dashboard/get_transaction_coordinates'; + +export const observabilityDashboardHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_dashboard/has_data', + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + return await hasData({ setup }); + }, +})); + +export const observabilityDashboardDataRoute = createRoute(() => ({ + path: '/api/apm/observability_dashboard', + params: { + query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { bucketSize } = context.params.query; + const serviceCountPromise = getServiceCount({ setup }); + const transactionCoordinatesPromise = getTransactionCoordinates({ + setup, + bucketSize, + }); + const [serviceCount, transactionCoordinates] = await Promise.all([ + serviceCountPromise, + transactionCoordinatesPromise, + ]); + return { serviceCount, transactionCoordinates }; + }, +})); diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index 288da3d78bf36..65f2c52a4e320 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -7,7 +7,7 @@ import { ObservabilityFetchDataResponse, FetchDataResponse } from './typings/fetch_data_response'; import { ObservabilityApp } from '../typings/common'; -interface FetchDataParams { +export interface FetchDataParams { // The start timestamp in milliseconds of the queried time interval startTime: string; // The end timestamp in milliseconds of the queried time interval