diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx deleted file mode 100644 index fcc7b214943f..000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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 React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations'; -import { CoreVitalItem } from './CoreVitalItem'; -import { UXMetrics } from '../UXMetrics'; -import { formatToSec } from '../UXMetrics/KeyUXMetrics'; - -const CoreVitalsThresholds = { - LCP: { good: '2.5s', bad: '4.0s' }, - FID: { good: '100ms', bad: '300ms' }, - CLS: { good: '0.1', bad: '0.25' }, -}; - -interface Props { - data?: UXMetrics | null; - loading: boolean; -} - -export function CoreVitals({ data, loading }: Props) { - const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {}; - - return ( - - - - - - - - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts deleted file mode 100644 index cae1a4373371..000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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 LCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.lcp', { - defaultMessage: 'Largest contentful paint', -}); - -export const FID_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fip', { - defaultMessage: 'First input delay', -}); - -export const CLS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.cls', { - defaultMessage: 'Cumulative layout shift', -}); - -export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', { - defaultMessage: 'First contentful paint', -}); - -export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', { - defaultMessage: 'Total blocking time', -}); - -export const NO_OF_LONG_TASK = i18n.translate( - 'xpack.apm.rum.uxMetrics.noOfLongTasks', - { - defaultMessage: 'No. of long tasks', - } -); - -export const LONGEST_LONG_TASK = i18n.translate( - 'xpack.apm.rum.uxMetrics.longestLongTasks', - { - defaultMessage: 'Longest long task duration', - } -); - -export const SUM_LONG_TASKS = i18n.translate( - 'xpack.apm.rum.uxMetrics.sumLongTasks', - { - defaultMessage: 'Total long tasks duration', - } -); - -export const CV_POOR_LABEL = i18n.translate('xpack.apm.rum.coreVitals.poor', { - defaultMessage: 'a poor', -}); - -export const CV_GOOD_LABEL = i18n.translate('xpack.apm.rum.coreVitals.good', { - defaultMessage: 'a good', -}); - -export const CV_AVERAGE_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.average', - { - defaultMessage: 'an average', - } -); - -export const LEGEND_POOR_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.poor', - { - defaultMessage: 'Poor', - } -); - -export const LEGEND_GOOD_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.good', - { - defaultMessage: 'Good', - } -); - -export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.needsImprovement', - { - defaultMessage: 'Needs improvement', - } -); - -export const MORE_LABEL = i18n.translate('xpack.apm.rum.coreVitals.more', { - defaultMessage: 'more', -}); - -export const LESS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.less', { - defaultMessage: 'less', -}); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx index c995510e6c0c..f30a3ea5fb2d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx @@ -7,16 +7,16 @@ import React from 'react'; import { EuiFlexItem, EuiStat, EuiFlexGroup } from '@elastic/eui'; import numeral from '@elastic/numeral'; -import { UXMetrics } from './index'; import { FCP_LABEL, LONGEST_LONG_TASK, NO_OF_LONG_TASK, SUM_LONG_TASKS, TBT_LABEL, -} from '../CoreVitals/translations'; +} from './translations'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUxQuery } from '../hooks/useUxQuery'; +import { UXMetrics } from '../../../../../../observability/public'; export function formatToSec( value?: number | string, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx index f43be5beece8..da3e8af6ba04 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx @@ -4,36 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React from 'react'; import { - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiLink, EuiPanel, - EuiPopover, EuiSpacer, EuiTitle, - EuiText, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { I18LABELS } from '../translations'; -import { CoreVitals } from '../CoreVitals'; import { KeyUXMetrics } from './KeyUXMetrics'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUxQuery } from '../hooks/useUxQuery'; - -export interface UXMetrics { - cls: string; - fid: number; - lcp: number; - tbt: number; - fcp: number; - lcpRanks: number[]; - fidRanks: number[]; - clsRanks: number[]; -} +import { CoreVitals } from '../../../../../../observability/public'; export function UXMetrics() { const uxQuery = useUxQuery(); @@ -53,10 +37,6 @@ export function UXMetrics() { [uxQuery] ); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const closePopover = () => setIsPopoverOpen(false); - return ( @@ -72,39 +52,6 @@ export function UXMetrics() { - - - {I18LABELS.coreWebVitals} - setIsPopoverOpen(true)} - color={'text'} - iconType={'questionInCircle'} - /> - } - closePopover={closePopover} - > - - - - - {' '} - {I18LABELS.coreWebVitals} - - - - - - diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts new file mode 100644 index 000000000000..e6d8f881bee5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts @@ -0,0 +1,36 @@ +/* + * 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 FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', { + defaultMessage: 'First contentful paint', +}); + +export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', { + defaultMessage: 'Total blocking time', +}); + +export const NO_OF_LONG_TASK = i18n.translate( + 'xpack.apm.rum.uxMetrics.noOfLongTasks', + { + defaultMessage: 'No. of long tasks', + } +); + +export const LONGEST_LONG_TASK = i18n.translate( + 'xpack.apm.rum.uxMetrics.longestLongTasks', + { + defaultMessage: 'Longest long task duration', + } +); + +export const SUM_LONG_TASKS = i18n.translate( + 'xpack.apm.rum.uxMetrics.sumLongTasks', + { + defaultMessage: 'Total long tasks duration', + } +); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts new file mode 100644 index 000000000000..a9f2486a3c28 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts @@ -0,0 +1,49 @@ +/* + * 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 { + FetchDataParams, + HasDataParams, + UxFetchDataResponse, +} from '../../../../../observability/public/'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; + +export { createCallApmApi } from '../../../services/rest/createCallApmApi'; + +export const fetchUxOverviewDate = async ({ + absoluteTime, + relativeTime, + serviceName, +}: FetchDataParams): Promise => { + const data = await callApmApi({ + pathname: '/api/apm/rum-client/web-core-vitals', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + uiFilters: `{"serviceName":["${serviceName}"]}`, + }, + }, + }); + + return { + coreWebVitals: data, + appLink: `/app/ux?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`, + }; +}; + +export async function hasRumData({ absoluteTime }: HasDataParams) { + return await callApmApi({ + pathname: '/api/apm/observability_overview/has_rum_data', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + uiFilters: '', + }, + }, + }); +} diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap index a5f8c4087654..63093900cb54 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap @@ -238,8 +238,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -2021127760, - "componentId": "sc-fzoLsD", + "baseHash": 211589981, + "componentId": "sc-fznyAO", "isStatic": false, "rules": Array [ " @@ -254,7 +254,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-fzoLsD", + "styledComponentId": "sc-fznyAO", "target": "span", "toString": [Function], "warnTooManyClasses": [Function], @@ -444,8 +444,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -462,7 +462,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -474,8 +474,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -500,7 +500,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -669,8 +669,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -687,7 +687,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -699,8 +699,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -725,7 +725,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -895,8 +895,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -913,7 +913,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -925,8 +925,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -951,7 +951,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -1131,8 +1131,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -1149,7 +1149,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -1161,8 +1161,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -1187,7 +1187,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -1384,8 +1384,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -1402,7 +1402,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -1414,8 +1414,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -1440,7 +1440,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index ea4e2a005412..560a1a077931 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -7,6 +7,7 @@ import { ConfigSchema } from '.'; import { FetchDataParams, + HasDataParams, ObservabilityPluginSetup, } from '../../observability/public'; import { @@ -100,6 +101,30 @@ export class ApmPlugin implements Plugin { return await dataHelper.fetchOverviewPageData(params); }, }); + + const getUxDataHelper = async () => { + const { + fetchUxOverviewDate, + hasRumData, + createCallApmApi, + } = await import('./components/app/RumDashboard/ux_overview_fetchers'); + // have to do this here as well in case app isn't mounted yet + createCallApmApi(core.http); + + return { fetchUxOverviewDate, hasRumData }; + }; + + plugins.observability.dashboard.register({ + appName: 'ux', + hasData: async (params?: HasDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.hasRumData(params!); + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.fetchUxOverviewDate(params); + }, + }); } core.application.register({ diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts new file mode 100644 index 000000000000..14245ce1d6c8 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -0,0 +1,59 @@ +/* + * 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, SetupTimeRange } from '../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { TRANSACTION_PAGE_LOAD } from '../../../common/transaction_types'; + +export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { + try { + const { start, end } = setup; + + const params = { + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }], + }, + }, + aggs: { + services: { + filter: { + range: rangeFilter(start, end), + }, + aggs: { + mostTraffic: { + terms: { + field: SERVICE_NAME, + size: 1, + }, + }, + }, + }, + }, + }, + }; + + const { apmEventClient } = setup; + + const response = await apmEventClient.search(params); + return { + hasData: response.hits.total.value > 0, + serviceName: + response.aggregations?.services?.mostTraffic?.buckets?.[0]?.key, + }; + } 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 0560b977e708..c1f13ee646e4 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -79,6 +79,7 @@ import { anomalyDetectionEnvironmentsRoute, } from './settings/anomaly_detection'; import { + rumHasDataRoute, rumClientMetricsRoute, rumJSErrors, rumLongTaskMetrics, @@ -186,7 +187,8 @@ const createApmApi = () => { .add(rumWebCoreVitals) .add(rumJSErrors) .add(rumUrlSearch) - .add(rumLongTaskMetrics); + .add(rumLongTaskMetrics) + .add(rumHasDataRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index eec2b2660bde..cfa6eb289688 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -18,6 +18,7 @@ import { getWebCoreVitals } from '../lib/rum_client/get_web_core_vitals'; import { getJSErrors } from '../lib/rum_client/get_js_errors'; import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics'; import { getUrlSearch } from '../lib/rum_client/get_url_search'; +import { hasRumData } from '../lib/rum_client/has_rum_data'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -229,3 +230,14 @@ export const rumJSErrors = createRoute(() => ({ }); }, })); + +export const rumHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_overview/has_rum_data', + params: { + query: t.intersection([uiFiltersRt, rangeRt]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + return await hasRumData({ setup }); + }, +})); diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index 1304936860b7..85489525cc30 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -20,6 +20,7 @@ describe('renderApp', () => { chrome: { docTitle: { change: () => {} }, setBreadcrumbs: () => {} }, i18n: { Context: ({ children }: { children: React.ReactNode }) => children }, uiSettings: { get: () => false }, + http: { basePath: { prepend: (path: string) => path } }, } as unknown) as CoreStart; const params = ({ element: window.document.createElement('div'), diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 5c23c7a065b5..879d745ff2b6 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -123,7 +123,7 @@ export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props) defaultMessage: 'Down', })} series={series?.down} - ticktFormatter={formatter} + tickFormatter={formatter} color={downColor} /> @@ -145,13 +145,13 @@ function UptimeBarSeries({ label, series, color, - ticktFormatter, + tickFormatter, }: { id: string; label: string; series?: Series; color: string; - ticktFormatter: TickFormatter; + tickFormatter: TickFormatter; }) { if (!series) { return null; @@ -178,7 +178,7 @@ function UptimeBarSeries({ position={Position.Bottom} showOverlappingTicks={false} showOverlappingLabels={false} - tickFormat={ticktFormatter} + tickFormat={tickFormatter} /> { + it('renders with core web vitals', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: response, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const { getByText, getAllByText } = render( + + ); + + expect(getByText('User Experience')).toBeInTheDocument(); + expect(getByText('View in app')).toBeInTheDocument(); + expect(getByText('elastic-co-frontend')).toBeInTheDocument(); + expect(getByText('Largest contentful paint')).toBeInTheDocument(); + expect(getByText('Largest contentful paint 1.94 s')).toBeInTheDocument(); + expect(getByText('First input delay 14 ms')).toBeInTheDocument(); + expect(getByText('Cumulative layout shift 0.01')).toBeInTheDocument(); + + expect(getByText('Largest contentful paint')).toBeInTheDocument(); + expect(getByText('Largest contentful paint 1.94 s')).toBeInTheDocument(); + expect(getByText('First input delay 14 ms')).toBeInTheDocument(); + expect(getByText('Cumulative layout shift 0.01')).toBeInTheDocument(); + + // LCP Rank Values + expect(getByText('Good (65%)')).toBeInTheDocument(); + expect(getByText('Needs improvement (19%)')).toBeInTheDocument(); + + // LCP and FID both have same poor value + expect(getAllByText('Poor (16%)')).toHaveLength(2); + + // FID Rank Values + expect(getByText('Good (73%)')).toBeInTheDocument(); + expect(getByText('Needs improvement (11%)')).toBeInTheDocument(); + + // CLS Rank Values + expect(getByText('Good (86%)')).toBeInTheDocument(); + expect(getByText('Needs improvement (8%)')).toBeInTheDocument(); + expect(getByText('Poor (6%)')).toBeInTheDocument(); + }); + it('shows loading state', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: undefined, + status: fetcherHook.FETCH_STATUS.LOADING, + refetch: jest.fn(), + }); + const { getByText, queryAllByText, getAllByText } = render( + + ); + + expect(getByText('User Experience')).toBeInTheDocument(); + expect(getAllByText('Statistic is loading')).toHaveLength(3); + expect(queryAllByText('View in app')).toEqual([]); + expect(getByText('elastic-co-frontend')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx new file mode 100644 index 000000000000..0c40ce0bf7a2 --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx @@ -0,0 +1,60 @@ +/* + * 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 React from 'react'; +import { SectionContainer } from '../'; +import { getDataHandler } from '../../../../data_handler'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { CoreVitals } from '../../../shared/core_web_vitals'; + +interface Props { + serviceName: string; + bucketSize: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; +} + +export function UXSection({ serviceName, bucketSize, absoluteTime, relativeTime }: Props) { + const { start, end } = absoluteTime; + + const { data, status } = useFetcher(() => { + if (start && end) { + return getDataHandler('ux')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + serviceName, + bucketSize, + }); + } + }, [start, end, relativeTime, serviceName, bucketSize]); + + const isLoading = status === FETCH_STATUS.LOADING; + + const { appLink, coreWebVitals } = data || {}; + + return ( + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/app/section/ux/mock_data/ux.mock.ts b/x-pack/plugins/observability/public/components/app/section/ux/mock_data/ux.mock.ts new file mode 100644 index 000000000000..e61564f9df75 --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/section/ux/mock_data/ux.mock.ts @@ -0,0 +1,21 @@ +/* + * 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 { UxFetchDataResponse } from '../../../../../typings'; + +export const response: UxFetchDataResponse = { + appLink: '/app/ux', + coreWebVitals: { + cls: '0.01', + fid: 13.5, + lcp: 1942.6666666666667, + tbt: 281.55833333333334, + fcp: 1487, + lcpRanks: [65, 19, 16], + fidRanks: [73, 11, 16], + clsRanks: [86, 8, 6], + }, +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/__stories__/CoreVitals.stories.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx similarity index 91% rename from x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/__stories__/CoreVitals.stories.tsx rename to x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx index 6ab75469e2b1..39be850e5a93 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/__stories__/CoreVitals.stories.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx @@ -8,10 +8,10 @@ import React, { ComponentType } from 'react'; import { IntlProvider } from 'react-intl'; import { Observable } from 'rxjs'; import { CoreStart } from 'src/core/public'; -import { createKibanaReactContext } from '../../../../../../../../../src/plugins/kibana_react/public'; -import { EuiThemeProvider } from '../../../../../../../observability/public'; -import { CoreVitalItem } from '../CoreVitalItem'; +import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public'; +import { CoreVitalItem } from '../core_vital_item'; import { LCP_LABEL } from '../translations'; +import { EuiThemeProvider } from '../../../../typings'; const KibanaReactContext = createKibanaReactContext(({ uiSettings: { get: () => {}, get$: () => new Observable() }, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/color_palette_flex_item.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx rename to x-pack/plugins/observability/public/components/shared/core_web_vitals/color_palette_flex_item.tsx index fc2390acde0b..4b5f3ee80f7b 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/color_palette_flex_item.tsx @@ -14,12 +14,7 @@ const ColoredSpan = styled.div` cursor: pointer; `; -const getSpanStyle = ( - position: number, - inFocus: boolean, - hexCode: string, - percentage: number -) => { +const getSpanStyle = (position: number, inFocus: boolean, hexCode: string, percentage: number) => { let first = position === 0 || percentage === 100; let last = position === 2 || percentage === 100; if (percentage === 100) { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx similarity index 80% rename from x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx rename to x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx index 6107a8e764ad..4c84a163d332 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx @@ -4,16 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - euiPaletteForStatus, - EuiSpacer, - EuiStat, -} from '@elastic/eui'; +import { EuiFlexGroup, euiPaletteForStatus, EuiSpacer, EuiStat } from '@elastic/eui'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { PaletteLegends } from './PaletteLegends'; -import { ColorPaletteFlexItem } from './ColorPaletteFlexItem'; +import { PaletteLegends } from './palette_legends'; +import { ColorPaletteFlexItem } from './color_palette_flex_item'; import { CV_AVERAGE_LABEL, CV_GOOD_LABEL, @@ -45,7 +40,7 @@ export function getCoreVitalTooltipMessage( const bad = position === 2; const average = !good && !bad; - return i18n.translate('xpack.apm.csm.dashboard.webVitals.palette.tooltip', { + return i18n.translate('xpack.observability.ux.dashboard.webVitals.palette.tooltip', { defaultMessage: '{percentage} % of users have {exp} experience because the {title} takes {moreOrLess} than {value}{averageMessage}.', values: { @@ -55,7 +50,7 @@ export function getCoreVitalTooltipMessage( moreOrLess: bad || average ? MORE_LABEL : LESS_LABEL, value: good || average ? thresholds.good : thresholds.bad, averageMessage: average - ? i18n.translate('xpack.apm.rum.coreVitals.averageMessage', { + ? i18n.translate('xpack.observability.ux.coreVitals.averageMessage', { defaultMessage: ' and less than {bad}', values: { bad: thresholds.bad }, }) @@ -64,13 +59,7 @@ export function getCoreVitalTooltipMessage( }); } -export function CoreVitalItem({ - loading, - title, - value, - thresholds, - ranks = [100, 0, 0], -}: Props) { +export function CoreVitalItem({ loading, title, value, thresholds, ranks = [100, 0, 0] }: Props) { const palette = euiPaletteForStatus(3); const [inFocusInd, setInFocusInd] = useState(null); @@ -100,12 +89,7 @@ export function CoreVitalItem({ position={ind} inFocus={inFocusInd !== ind && inFocusInd !== null} percentage={ranks[ind]} - tooltip={getCoreVitalTooltipMessage( - thresholds, - ind, - title, - ranks[ind] - )} + tooltip={getCoreVitalTooltipMessage(thresholds, ind, title, ranks[ind])} /> ))} diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/index.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/index.tsx new file mode 100644 index 000000000000..6d44cd51285b --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/index.tsx @@ -0,0 +1,86 @@ +/* + * 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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations'; +import { CoreVitalItem } from './core_vital_item'; +import { WebCoreVitalsTitle } from './web_core_vitals_title'; +import { ServiceName } from './service_name'; + +export interface UXMetrics { + cls: string; + fid: number; + lcp: number; + tbt: number; + fcp: number; + lcpRanks: number[]; + fidRanks: number[]; + clsRanks: number[]; +} + +export function formatToSec(value?: number | string, fromUnit = 'MicroSec'): string { + const valueInMs = Number(value ?? 0) / (fromUnit === 'MicroSec' ? 1000 : 1); + + if (valueInMs < 1000) { + return valueInMs.toFixed(0) + ' ms'; + } + return (valueInMs / 1000).toFixed(2) + ' s'; +} + +const CoreVitalsThresholds = { + LCP: { good: '2.5s', bad: '4.0s' }, + FID: { good: '100ms', bad: '300ms' }, + CLS: { good: '0.1', bad: '0.25' }, +}; + +interface Props { + loading: boolean; + data?: UXMetrics | null; + displayServiceName?: boolean; + serviceName?: string; +} + +export function CoreVitals({ data, loading, displayServiceName, serviceName }: Props) { + const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {}; + + return ( + <> + + + {displayServiceName && } + + + + + + + + + + + + + > + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/palette_legends.tsx similarity index 75% rename from x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx rename to x-pack/plugins/observability/public/components/shared/core_web_vitals/palette_legends.tsx index d27581c97de2..682cf5aa6538 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/palette_legends.tsx @@ -17,8 +17,8 @@ import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { getCoreVitalTooltipMessage, Thresholds } from './CoreVitalItem'; -import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; +import { getCoreVitalTooltipMessage, Thresholds } from './core_vital_item'; +import { useUiSetting$ } from '../../../../../../../src/plugins/kibana_react/public'; import { LEGEND_NEEDS_IMPROVEMENT_LABEL, LEGEND_GOOD_LABEL, @@ -37,9 +37,7 @@ const StyledSpan = styled.span<{ }>` &:hover { background-color: ${(props) => - props.darkMode - ? euiDarkVars.euiColorLightestShade - : euiLightVars.euiColorLightestShade}; + props.darkMode ? euiDarkVars.euiColorLightestShade : euiLightVars.euiColorLightestShade}; } `; @@ -50,20 +48,11 @@ interface Props { title: string; } -export function PaletteLegends({ - ranks, - title, - onItemHover, - thresholds, -}: Props) { +export function PaletteLegends({ ranks, title, onItemHover, thresholds }: Props) { const [darkMode] = useUiSetting$('theme:darkMode'); const palette = euiPaletteForStatus(3); - const labels = [ - LEGEND_GOOD_LABEL, - LEGEND_NEEDS_IMPROVEMENT_LABEL, - LEGEND_POOR_LABEL, - ]; + const labels = [LEGEND_GOOD_LABEL, LEGEND_NEEDS_IMPROVEMENT_LABEL, LEGEND_POOR_LABEL]; return ( @@ -79,19 +68,14 @@ export function PaletteLegends({ }} > + + {SERVICE_LABEL} + + + + {name} + + > + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/translations.ts b/x-pack/plugins/observability/public/components/shared/core_web_vitals/translations.ts new file mode 100644 index 000000000000..546d828f9dab --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/translations.ts @@ -0,0 +1,54 @@ +/* + * 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 LCP_LABEL = i18n.translate('xpack.observability.ux.coreVitals.lcp', { + defaultMessage: 'Largest contentful paint', +}); + +export const FID_LABEL = i18n.translate('xpack.observability.ux.coreVitals.fip', { + defaultMessage: 'First input delay', +}); + +export const CLS_LABEL = i18n.translate('xpack.observability.ux.coreVitals.cls', { + defaultMessage: 'Cumulative layout shift', +}); + +export const CV_POOR_LABEL = i18n.translate('xpack.observability.ux.coreVitals.poor', { + defaultMessage: 'a poor', +}); + +export const CV_GOOD_LABEL = i18n.translate('xpack.observability.ux.coreVitals.good', { + defaultMessage: 'a good', +}); + +export const CV_AVERAGE_LABEL = i18n.translate('xpack.observability.ux.coreVitals.average', { + defaultMessage: 'an average', +}); + +export const LEGEND_POOR_LABEL = i18n.translate('xpack.observability.ux.coreVitals.legends.poor', { + defaultMessage: 'Poor', +}); + +export const LEGEND_GOOD_LABEL = i18n.translate('xpack.observability.ux.coreVitals.legends.good', { + defaultMessage: 'Good', +}); + +export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate( + 'xpack.observability.ux.coreVitals.legends.needsImprovement', + { + defaultMessage: 'Needs improvement', + } +); + +export const MORE_LABEL = i18n.translate('xpack.observability.ux.coreVitals.more', { + defaultMessage: 'more', +}); + +export const LESS_LABEL = i18n.translate('xpack.observability.ux.coreVitals.less', { + defaultMessage: 'less', +}); diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/web_core_vitals_title.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/web_core_vitals_title.tsx new file mode 100644 index 000000000000..de3453c5c2c1 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/web_core_vitals_title.tsx @@ -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 React, { useState } from 'react'; +import { EuiButtonIcon, EuiLink, EuiPopover, EuiText, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +const CORE_WEB_VITALS = i18n.translate('xpack.observability.ux.coreWebVitals', { + defaultMessage: 'Core web vitals', +}); + +export function WebCoreVitalsTitle() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopover = () => setIsPopoverOpen(false); + + return ( + + + {CORE_WEB_VITALS} + setIsPopoverOpen(true)} + color={'text'} + iconType={'questionInCircle'} + /> + } + closePopover={closePopover} + > + + + + + {' '} + {CORE_WEB_VITALS} + + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 935fc0682c41..dae2f62777d3 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -15,6 +15,7 @@ import { LogsFetchDataResponse, MetricsFetchDataResponse, UptimeFetchDataResponse, + UxFetchDataResponse, } from './typings'; const params = { @@ -273,6 +274,60 @@ describe('registerDataHandler', () => { expect(hasData).toBeTruthy(); }); }); + describe('Ux', () => { + registerDataHandler({ + appName: 'ux', + fetchData: async () => { + return { + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: '0.01', + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }; + }, + hasData: async () => ({ hasData: true, serviceName: 'elastic-co-frontend' }), + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('ux'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('ux'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: '0.01', + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('ux'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Metrics', () => { registerDataHandler({ appName: 'infra_metrics', @@ -396,6 +451,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -425,11 +481,19 @@ describe('registerDataHandler', () => { throw new Error('BOOM'); }, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => { + throw new Error('BOOM'); + }, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); it('returns true when has data and false when an exception happens', async () => { @@ -437,6 +501,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -462,11 +527,19 @@ describe('registerDataHandler', () => { throw new Error('BOOM'); }, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => { + throw new Error('BOOM'); + }, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: true, uptime: false, infra_logs: true, infra_metrics: false, + ux: false, }); }); it('returns true when has data', async () => { @@ -474,6 +547,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -495,11 +569,23 @@ describe('registerDataHandler', () => { fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), hasData: async () => true, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => ({ + hasData: true, + serviceName: 'elastic-co', + }), + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: true, uptime: true, infra_logs: true, infra_metrics: true, + ux: { + hasData: true, + serviceName: 'elastic-co', + }, }); }); it('returns false when has no data', async () => { @@ -507,6 +593,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -528,11 +615,17 @@ describe('registerDataHandler', () => { fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), hasData: async () => false, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => false, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); it('returns false when has data was not registered', async () => { @@ -540,12 +633,14 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); - expect(await fetchHasData()).toEqual({ + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); }); diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index cae21fd9fed5..91043a3da0da 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataHandler, ObservabilityFetchDataPlugins } from './typings/fetch_overview_data'; +import { + DataHandler, + HasDataResponse, + ObservabilityFetchDataPlugins, +} from './typings/fetch_overview_data'; const dataHandlers: Partial> = {}; @@ -31,14 +35,26 @@ export function getDataHandler(appName: } } -export async function fetchHasData(): Promise> { - const apps: ObservabilityFetchDataPlugins[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics']; +export async function fetchHasData(absoluteTime: { + start: number; + end: number; +}): Promise> { + const apps: ObservabilityFetchDataPlugins[] = [ + 'apm', + 'uptime', + 'infra_logs', + 'infra_metrics', + 'ux', + ]; - const promises = apps.map(async (app) => getDataHandler(app)?.hasData() || false); + const promises = apps.map( + async (app) => + getDataHandler(app)?.hasData(app === 'ux' ? { absoluteTime } : undefined) || false + ); const results = await Promise.allSettled(promises); - const [apm, uptime, logs, metrics] = results.map((result) => { + const [apm, uptime, logs, metrics, ux] = results.map((result) => { if (result.status === 'fulfilled') { return result.value; } @@ -50,6 +66,7 @@ export async function fetchHasData(): Promise { + return search ? parse(search[0] === '?' ? search.slice(1) : search, { sort: false }) : {}; +}; + +export function useQueryParams() { + const { from, to } = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + + const { rangeFrom, rangeTo } = getParsedParams(useLocation().search); + + return useMemo(() => { + return { + start: (rangeFrom as string) ?? from, + end: (rangeTo as string) ?? to, + absStart: getAbsoluteTime((rangeFrom as string) ?? from)!, + absEnd: getAbsoluteTime((rangeTo as string) ?? to, { roundUp: true })!, + }; + }, [rangeFrom, rangeTo, from, to]); +} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 0aecea59ad01..9c16e3034400 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -17,6 +17,8 @@ export const plugin: PluginInitializer fetchHasData(), []); + + const { absStart, absEnd } = useQueryParams(); + + const { data = {} } = useFetcher( + () => fetchHasData({ start: absStart, end: absEnd }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const values = Object.values(data); const hasSomeData = values.length ? values.some((hasData) => hasData) : null; @@ -24,5 +33,5 @@ export function HomePage() { } }, [hasSomeData, history]); - return <>>; + return ; } diff --git a/x-pack/plugins/observability/public/pages/overview/data_sections.tsx b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx new file mode 100644 index 000000000000..dfb335902b7b --- /dev/null +++ b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx @@ -0,0 +1,80 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { LogsSection } from '../../components/app/section/logs'; +import { MetricsSection } from '../../components/app/section/metrics'; +import { APMSection } from '../../components/app/section/apm'; +import { UptimeSection } from '../../components/app/section/uptime'; +import { UXSection } from '../../components/app/section/ux'; +import { + HasDataResponse, + ObservabilityFetchDataPlugins, + UXHasDataResponse, +} from '../../typings/fetch_overview_data'; + +interface Props { + bucketSize: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; + hasData: Record; +} + +export function DataSections({ bucketSize, hasData, absoluteTime, relativeTime }: Props) { + return ( + + + {hasData?.infra_logs && ( + + + + )} + {hasData?.infra_metrics && ( + + + + )} + {hasData?.apm && ( + + + + )} + {hasData?.uptime && ( + + + + )} + {hasData?.ux && ( + + + + )} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 0330ba5cc04b..5b13f2bcbbbd 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -69,6 +69,21 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I }), href: core.http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'), }, + { + id: 'ux', + title: i18n.translate('xpack.observability.emptySection.apps.ux.title', { + defaultMessage: 'User Experience', + }), + icon: 'logoAPM', + description: i18n.translate('xpack.observability.emptySection.apps.ux.description', { + defaultMessage: + 'Performance is a distribution. Measure the experiences of all visitors to your web application and understand how to improve the experience for everyone.', + }), + linkTitle: i18n.translate('xpack.observability.emptySection.apps.ux.link', { + defaultMessage: 'Install Rum Agent', + }), + href: core.http.basePath.prepend('/app/home#/tutorial/apm'), + }, { id: 'alert', title: i18n.translate('xpack.observability.emptySection.apps.alert.title', { diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index fd64867da2be..e041f8c50f7f 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -8,32 +8,29 @@ import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; import { EmptySection } from '../../components/app/empty_section'; import { WithHeaderLayout } from '../../components/app/layout/with_header'; -import { NewsFeed } from '../../components/app/news_feed'; import { Resources } from '../../components/app/resources'; import { AlertsSection } from '../../components/app/section/alerts'; -import { APMSection } from '../../components/app/section/apm'; -import { LogsSection } from '../../components/app/section/logs'; -import { MetricsSection } from '../../components/app/section/metrics'; -import { UptimeSection } from '../../components/app/section/uptime'; import { DatePicker, TimePickerTime } from '../../components/shared/data_picker'; +import { NewsFeed } from '../../components/app/news_feed'; import { fetchHasData } from '../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings'; import { usePluginContext } from '../../hooks/use_plugin_context'; -import { useTrackPageview } from '../../hooks/use_track_metric'; import { RouteParams } from '../../routes'; -import { getNewsFeed } from '../../services/get_news_feed'; import { getObservabilityAlerts } from '../../services/get_observability_alerts'; import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; +import { getNewsFeed } from '../../services/get_news_feed'; +import { DataSections } from './data_sections'; +import { useTrackPageview } from '../..'; interface Props { routeParams: RouteParams<'/overview'>; } -function calculatetBucketSize({ start, end }: { start?: number; end?: number }) { +function calculateBucketSize({ start, end }: { start?: number; end?: number }) { if (start && end) { return getBucketSize({ start, end, minInterval: '60s' }); } @@ -42,6 +39,22 @@ function calculatetBucketSize({ start, end }: { start?: number; end?: number }) export function OverviewPage({ routeParams }: Props) { const { core, plugins } = usePluginContext(); + // read time from state and update the url + const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); + + const timePickerDefaults = useKibanaUISettings( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + const relativeTime = { + start: routeParams.query.rangeFrom || timePickerSharedState.from || timePickerDefaults.from, + end: routeParams.query.rangeTo || timePickerSharedState.to || timePickerDefaults.to, + }; + + const absoluteTime = { + start: getAbsoluteTime(relativeTime.start) as number, + end: getAbsoluteTime(relativeTime.end, { roundUp: true }) as number, + }; + useTrackPageview({ app: 'observability', path: 'overview' }); useTrackPageview({ app: 'observability', path: 'overview', delay: 15000 }); @@ -52,13 +65,12 @@ export function OverviewPage({ routeParams }: Props) { const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); const theme = useContext(ThemeContext); - const timePickerDefaults = useKibanaUISettings( - UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS - ); - // read time from state and update the url - const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); - const result = useFetcher(() => fetchHasData(), []); + const result = useFetcher( + () => fetchHasData(absoluteTime), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const hasData = result.data; if (!hasData) { @@ -67,17 +79,7 @@ export function OverviewPage({ routeParams }: Props) { const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - const relativeTime = { - start: routeParams.query.rangeFrom || timePickerSharedState.from || timePickerDefaults.from, - end: routeParams.query.rangeTo || timePickerSharedState.to || timePickerDefaults.to, - }; - - const absoluteTime = { - start: getAbsoluteTime(relativeTime.start), - end: getAbsoluteTime(relativeTime.end, { roundUp: true }), - }; - - const bucketSize = calculatetBucketSize({ + const bucketSize = calculateBucketSize({ start: absoluteTime.start, end: absoluteTime.end, }); @@ -121,46 +123,12 @@ export function OverviewPage({ routeParams }: Props) { {/* Data sections */} {showDataSections && ( - - - {hasData.infra_logs && ( - - - - )} - {hasData.infra_metrics && ( - - - - )} - {hasData.apm && ( - - - - )} - {hasData.uptime && ( - - - - )} - - + )} {/* Empty sections */} diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 41330e878035..a64e6fc55b85 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -5,6 +5,7 @@ */ import { ObservabilityApp } from '../../../typings/common'; +import { UXMetrics } from '../../components/shared/core_web_vitals'; export interface Stat { type: 'number' | 'percent' | 'bytesPerSecond'; @@ -24,17 +25,29 @@ export interface FetchDataParams { absoluteTime: { start: number; end: number }; relativeTime: { start: string; end: string }; bucketSize: string; + serviceName?: string; } +export interface HasDataParams { + absoluteTime: { start: number; end: number }; +} + +export interface UXHasDataResponse { + hasData: boolean; + serviceName: string | number | undefined; +} + +export type HasDataResponse = UXHasDataResponse | boolean; + export type FetchData = ( fetchDataParams: FetchDataParams ) => Promise; -export type HasData = () => Promise; +export type HasData = (params?: HasDataParams) => Promise; export type ObservabilityFetchDataPlugins = Exclude< ObservabilityApp, - 'observability' | 'stack_monitoring' | 'ux' + 'observability' | 'stack_monitoring' >; export interface DataHandler< @@ -89,9 +102,14 @@ export interface ApmFetchDataResponse extends FetchDataResponse { }; } +export interface UxFetchDataResponse extends FetchDataResponse { + coreWebVitals: UXMetrics; +} + export interface ObservabilityFetchDataResponse { apm: ApmFetchDataResponse; infra_metrics: MetricsFetchDataResponse; infra_logs: LogsFetchDataResponse; uptime: UptimeFetchDataResponse; + ux: UxFetchDataResponse; } diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index 37eaef2bcf35..d857165f2952 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { render as testLibRender } from '@testing-library/react'; import { CoreStart } from 'kibana/public'; +import { of } from 'rxjs'; import { PluginContext } from '../context/plugin_context'; import { EuiThemeProvider } from '../typings'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; @@ -17,6 +18,10 @@ export const core = ({ prepend: jest.fn(), }, }, + uiSettings: { + get: (key: string) => true, + get$: (key: string) => of(true), + }, } as unknown) as CoreStart; const plugins = ({ diff --git a/x-pack/test/apm_api_integration/trial/tests/csm/has_rum_data.ts b/x-pack/test/apm_api_integration/trial/tests/csm/has_rum_data.ts new file mode 100644 index 000000000000..12fdb5ba9704 --- /dev/null +++ b/x-pack/test/apm_api_integration/trial/tests/csm/has_rum_data.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. + */ + +import expect from '@kbn/expect'; +import { expectSnapshot } from '../../../common/match_snapshot'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function rumHasDataApiTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('CSM has rum data api', () => { + describe('when there is no data', () => { + it('returns empty list', async () => { + const response = await supertest.get( + '/api/apm/observability_overview/has_rum_data?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=' + ); + + expect(response.status).to.be(200); + expectSnapshot(response.body).toMatchInline(` + Object { + "hasData": false, + } + `); + }); + }); + + describe('when there is data', () => { + before(async () => { + await esArchiver.load('8.0.0'); + await esArchiver.load('rum_8.0.0'); + }); + after(async () => { + await esArchiver.unload('8.0.0'); + await esArchiver.unload('rum_8.0.0'); + }); + + it('returns that it has data and service name with most traffice', async () => { + const response = await supertest.get( + '/api/apm/observability_overview/has_rum_data?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=' + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatchInline(` + Object { + "hasData": true, + "serviceName": "client", + } + `); + }); + }); + }); +} 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 e60927936639..a67dd1bcbd7a 100644 --- a/x-pack/test/apm_api_integration/trial/tests/index.ts +++ b/x-pack/test/apm_api_integration/trial/tests/index.ts @@ -39,6 +39,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr loadTestFile(require.resolve('./csm/url_search.ts')); loadTestFile(require.resolve('./csm/page_views.ts')); loadTestFile(require.resolve('./csm/js_errors.ts')); + loadTestFile(require.resolve('./csm/has_rum_data.ts')); }); }); }