diff --git a/x-pack/plugins/uptime/common/runtime_types/network_events.ts b/x-pack/plugins/uptime/common/runtime_types/network_events.ts index d24014aec8ea..0b717f46cedc 100644 --- a/x-pack/plugins/uptime/common/runtime_types/network_events.ts +++ b/x-pack/plugins/uptime/common/runtime_types/network_events.ts @@ -56,6 +56,7 @@ export const SyntheticsNetworkEventsApiResponseType = t.type({ events: t.array(NetworkEventType), total: t.number, isWaterfallSupported: t.boolean, + hasNavigationRequest: t.boolean, }); export type SyntheticsNetworkEventsApiResponse = t.TypeOf< diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.test.tsx new file mode 100644 index 000000000000..8b23d867572f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { + BROWSER_TRACE_NAME, + BROWSER_TRACE_START, + BROWSER_TRACE_TYPE, + useStepWaterfallMetrics, +} from './use_step_waterfall_metrics'; +import * as reduxHooks from 'react-redux'; +import * as searchHooks from '../../../../../../observability/public/hooks/use_es_search'; + +describe('useStepWaterfallMetrics', () => { + jest + .spyOn(reduxHooks, 'useSelector') + .mockReturnValue({ settings: { heartbeatIndices: 'heartbeat-*' } }); + + it('returns result as expected', () => { + // @ts-ignore + const searchHook = jest.spyOn(searchHooks, 'useEsSearch').mockReturnValue({ + loading: false, + data: { + hits: { + total: { value: 2, relation: 'eq' }, + hits: [ + { + fields: { + [BROWSER_TRACE_TYPE]: ['mark'], + [BROWSER_TRACE_NAME]: ['navigationStart'], + [BROWSER_TRACE_START]: [3456789], + }, + }, + { + fields: { + [BROWSER_TRACE_TYPE]: ['mark'], + [BROWSER_TRACE_NAME]: ['domContentLoaded'], + [BROWSER_TRACE_START]: [4456789], + }, + }, + ], + }, + } as any, + }); + + const { result } = renderHook( + (props) => + useStepWaterfallMetrics({ + checkGroup: '44D-444FFF-444-FFF-3333', + hasNavigationRequest: true, + stepIndex: 1, + }), + {} + ); + + expect(searchHook).toHaveBeenCalledWith( + { + body: { + _source: false, + fields: ['browser.*'], + query: { + bool: { + filter: [ + { + term: { + 'synthetics.step.index': 1, + }, + }, + { + term: { + 'monitor.check_group': '44D-444FFF-444-FFF-3333', + }, + }, + { + term: { + 'synthetics.type': 'step/metrics', + }, + }, + ], + }, + }, + size: 1000, + }, + index: 'heartbeat-*', + }, + ['heartbeat-*', '44D-444FFF-444-FFF-3333', true] + ); + expect(result.current).toEqual({ + loading: false, + metrics: [ + { + id: 'domContentLoaded', + offset: 1000, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.ts new file mode 100644 index 000000000000..cf60f6d7d556 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { createEsParams, useEsSearch } from '../../../../../../observability/public'; +import { selectDynamicSettings } from '../../../../state/selectors'; +import { MarkerItems } from '../waterfall/context/waterfall_chart'; + +export interface Props { + checkGroup: string; + stepIndex: number; + hasNavigationRequest?: boolean; +} +export const BROWSER_TRACE_TYPE = 'browser.relative_trace.type'; +export const BROWSER_TRACE_NAME = 'browser.relative_trace.name'; +export const BROWSER_TRACE_START = 'browser.relative_trace.start.us'; +export const NAVIGATION_START = 'navigationStart'; + +export const useStepWaterfallMetrics = ({ checkGroup, hasNavigationRequest, stepIndex }: Props) => { + const { settings } = useSelector(selectDynamicSettings); + + const heartbeatIndices = settings?.heartbeatIndices || ''; + + const { data, loading } = useEsSearch( + hasNavigationRequest + ? createEsParams({ + index: heartbeatIndices!, + body: { + query: { + bool: { + filter: [ + { + term: { + 'synthetics.step.index': stepIndex, + }, + }, + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + { + term: { + 'synthetics.type': 'step/metrics', + }, + }, + ], + }, + }, + fields: ['browser.*'], + size: 1000, + _source: false, + }, + }) + : {}, + [heartbeatIndices, checkGroup, hasNavigationRequest] + ); + + if (!hasNavigationRequest) { + return { metrics: [], loading: false }; + } + + const metrics: MarkerItems = []; + + if (data && hasNavigationRequest) { + const metricDocs = data.hits.hits as unknown as Array<{ fields: any }>; + let navigationStart = 0; + let navigationStartExist = false; + + metricDocs.forEach(({ fields }) => { + if (fields[BROWSER_TRACE_TYPE]?.[0] === 'mark') { + const { [BROWSER_TRACE_NAME]: metricType, [BROWSER_TRACE_START]: metricValue } = fields; + if (metricType?.[0] === NAVIGATION_START) { + navigationStart = metricValue?.[0]; + navigationStartExist = true; + } + } + }); + + if (navigationStartExist) { + metricDocs.forEach(({ fields }) => { + if (fields[BROWSER_TRACE_TYPE]?.[0] === 'mark') { + const { [BROWSER_TRACE_NAME]: metricType, [BROWSER_TRACE_START]: metricValue } = fields; + if (metricType?.[0] !== NAVIGATION_START) { + metrics.push({ + id: metricType?.[0], + offset: (metricValue?.[0] - navigationStart) / 1000, + }); + } + } + }); + } + } + + return { metrics, loading }; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx index 044353125e74..d249c23c44d7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx @@ -14,6 +14,7 @@ import { getNetworkEvents } from '../../../../../state/actions/network_events'; import { networkEventsSelector } from '../../../../../state/selectors'; import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; import { extractItems } from './data_formatting'; +import { useStepWaterfallMetrics } from '../use_step_waterfall_metrics'; export const NO_DATA_TEXT = i18n.translate('xpack.uptime.synthetics.stepDetail.waterfallNoData', { defaultMessage: 'No waterfall data could be found for this step', @@ -44,6 +45,12 @@ export const WaterfallChartContainer: React.FC = ({ checkGroup, stepIndex const isWaterfallSupported = networkEvents?.isWaterfallSupported; const hasEvents = networkEvents?.events?.length > 0; + const { metrics } = useStepWaterfallMetrics({ + checkGroup, + stepIndex, + hasNavigationRequest: networkEvents?.hasNavigationRequest, + }); + return ( <> {!waterfallLoaded && ( @@ -70,6 +77,7 @@ export const WaterfallChartContainer: React.FC = ({ checkGroup, stepIndex {waterfallLoaded && hasEvents && isWaterfallSupported && ( )} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx index fbb6f2c75a54..81ed2d024340 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx @@ -31,7 +31,11 @@ describe('WaterfallChartWrapper', () => { it('renders the correct sidebar items', () => { const { getAllByTestId } = render( - + ); const sideBarItems = getAllByTestId('middleTruncatedTextSROnly'); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx index 26be3cbadee4..8071fd1e3c4d 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx @@ -14,6 +14,7 @@ import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/ import { WaterfallFilter } from './waterfall_filter'; import { WaterfallFlyout } from './waterfall_flyout'; import { WaterfallSidebarItem } from './waterfall_sidebar_item'; +import { MarkerItems } from '../../waterfall/context/waterfall_chart'; export const renderLegendItem: RenderItem = (item) => { return ( @@ -26,9 +27,10 @@ export const renderLegendItem: RenderItem = (item) => { interface Props { total: number; data: NetworkItems; + markerItems?: MarkerItems; } -export const WaterfallChartWrapper: React.FC = ({ data, total }) => { +export const WaterfallChartWrapper: React.FC = ({ data, total, markerItems }) => { const [query, setQuery] = useState(''); const [activeFilters, setActiveFilters] = useState([]); const [onlyHighlighted, setOnlyHighlighted] = useState(false); @@ -107,6 +109,7 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { return ( props.theme.eui.euiZLevel4}; height: 100%; + &&& { + .echAnnotation__icon { + top: 8px; + } + } `; interface WaterfallChartSidebarContainer { diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx index 8723dd744132..d4a7cf6a1f66 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx @@ -24,6 +24,7 @@ import { WaterfallChartChartContainer, WaterfallChartTooltip } from './styles'; import { useWaterfallContext, WaterfallData } from '..'; import { WaterfallTooltipContent } from './waterfall_tooltip_content'; import { formatTooltipHeading } from '../../step_detail/waterfall/data_formatting'; +import { WaterfallChartMarkers } from './waterfall_markers'; const getChartHeight = (data: WaterfallData): number => { // We get the last item x(number of bars) and adds 1 to cater for 0 index @@ -120,6 +121,7 @@ export const WaterfallBarChart = ({ styleAccessor={barStyleAccessor} data={chartData} /> + ); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx index 3a7ab421b627..3824b9ae19d0 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx @@ -20,6 +20,7 @@ import { } from '@elastic/charts'; import { useChartTheme } from '../../../../../hooks/use_chart_theme'; import { WaterfallChartFixedAxisContainer } from './styles'; +import { WaterfallChartMarkers } from './waterfall_markers'; interface Props { tickFormat: TickFormatter; @@ -59,6 +60,7 @@ export const WaterfallChartFixedAxis = ({ tickFormat, domain, barStyleAccessor } styleAccessor={barStyleAccessor} data={[{ x: 0, y0: 0, y1: 1 }]} /> + ); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_markers.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_markers.tsx new file mode 100644 index 000000000000..b341b052e010 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_markers.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AnnotationDomainType, LineAnnotation } from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useWaterfallContext } from '..'; +import { useTheme } from '../../../../../../../observability/public'; +import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; + +export const FCP_LABEL = i18n.translate('xpack.uptime.synthetics.waterfall.fcpLabel', { + defaultMessage: 'First contentful paint', +}); + +export const LCP_LABEL = i18n.translate('xpack.uptime.synthetics.waterfall.lcpLabel', { + defaultMessage: 'Largest contentful paint', +}); + +export const LAYOUT_SHIFT_LABEL = i18n.translate( + 'xpack.uptime.synthetics.waterfall.layoutShiftLabel', + { + defaultMessage: 'Layout shift', + } +); + +export const LOAD_EVENT_LABEL = i18n.translate('xpack.uptime.synthetics.waterfall.loadEventLabel', { + defaultMessage: 'Load event', +}); + +export const DOCUMENT_CONTENT_LOADED_LABEL = i18n.translate( + 'xpack.uptime.synthetics.waterfall.domContentLabel', + { + defaultMessage: 'DOM Content Loaded', + } +); + +export function WaterfallChartMarkers() { + const { markerItems } = useWaterfallContext(); + + const theme = useTheme(); + + if (!markerItems) { + return null; + } + + const markersInfo: Record = { + domContentLoaded: { label: DOCUMENT_CONTENT_LOADED_LABEL, color: theme.eui.euiColorVis0 }, + firstContentfulPaint: { label: FCP_LABEL, color: theme.eui.euiColorVis1 }, + largestContentfulPaint: { label: LCP_LABEL, color: theme.eui.euiColorVis2 }, + layoutShift: { label: LAYOUT_SHIFT_LABEL, color: theme.eui.euiColorVis3 }, + loadEvent: { label: LOAD_EVENT_LABEL, color: theme.eui.euiColorVis9 }, + }; + + return ( + + {markerItems.map(({ id, offset }) => ( + } + style={{ + line: { + strokeWidth: 2, + stroke: markersInfo[id]?.color ?? theme.eui.euiColorMediumShade, + opacity: 1, + }, + }} + /> + ))} + + ); +} + +const Wrapper = euiStyled.span` + &&& { + > .echAnnotation__icon { + top: 8px; + } + } +`; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx index 16b3a24de7d0..cce0533293e0 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx @@ -10,6 +10,17 @@ import { WaterfallData, WaterfallDataEntry, WaterfallMetadata } from '../types'; import { OnSidebarClick, OnElementClick, OnProjectionClick } from '../components/use_flyout'; import { SidebarItems } from '../../step_detail/waterfall/types'; +export type MarkerItems = Array<{ + id: + | 'domContentLoaded' + | 'firstContentfulPaint' + | 'largestContentfulPaint' + | 'layoutShift' + | 'loadEvent' + | 'navigationStart'; + offset: number; +}>; + export interface IWaterfallContext { totalNetworkRequests: number; highlightedNetworkRequests: number; @@ -26,6 +37,7 @@ export interface IWaterfallContext { item: WaterfallDataEntry['config']['tooltipProps'], index?: number ) => JSX.Element; + markerItems?: MarkerItems; } export const WaterfallContext = createContext>({}); @@ -43,11 +55,13 @@ interface ProviderProps { legendItems?: IWaterfallContext['legendItems']; metadata: IWaterfallContext['metadata']; renderTooltipItem: IWaterfallContext['renderTooltipItem']; + markerItems?: MarkerItems; } export const WaterfallProvider: React.FC = ({ children, data, + markerItems, onElementClick, onProjectionClick, onSidebarClick, @@ -64,6 +78,7 @@ export const WaterfallProvider: React.FC = ({ { /> ); }; +import { getDynamicSettings } from '../../state/actions/dynamic_settings'; export const StepDetailPage: React.FC = () => { useInitApp(); const { checkGroupId, stepIndex } = useParams<{ checkGroupId: string; stepIndex: string }>(); useTrackPageview({ app: 'uptime', path: 'stepDetail' }); useTrackPageview({ app: 'uptime', path: 'stepDetail', delay: 15000 }); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(getDynamicSettings()); + }, [dispatch]); return ; }; diff --git a/x-pack/plugins/uptime/public/state/reducers/network_events.ts b/x-pack/plugins/uptime/public/state/reducers/network_events.ts index 56fef5947fb0..e58a8f75c7fa 100644 --- a/x-pack/plugins/uptime/public/state/reducers/network_events.ts +++ b/x-pack/plugins/uptime/public/state/reducers/network_events.ts @@ -23,6 +23,7 @@ export interface NetworkEventsState { loading: boolean; error?: Error; isWaterfallSupported: boolean; + hasNavigationRequest?: boolean; }; }; } @@ -71,7 +72,14 @@ export const networkEventsReducer = handleActions( [String(getNetworkEventsSuccess)]: ( state: NetworkEventsState, { - payload: { events, total, checkGroup, stepIndex, isWaterfallSupported }, + payload: { + events, + total, + checkGroup, + stepIndex, + isWaterfallSupported, + hasNavigationRequest, + }, }: Action ) => { return { @@ -85,12 +93,14 @@ export const networkEventsReducer = handleActions( events, total, isWaterfallSupported, + hasNavigationRequest, } : { loading: false, events, total, isWaterfallSupported, + hasNavigationRequest, }, } : { @@ -99,6 +109,7 @@ export const networkEventsReducer = handleActions( events, total, isWaterfallSupported, + hasNavigationRequest, }, }, }; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts index 5865f821228d..70aafc2f1cc1 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts @@ -298,6 +298,7 @@ describe('getNetworkEvents', () => { "url": "www.test.com", }, ], + "hasNavigationRequest": false, "isWaterfallSupported": true, "total": 1, } diff --git a/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts index b27b1a4c736d..20e5c3a2a118 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts @@ -20,7 +20,12 @@ export const secondsToMillis = (seconds: number) => export const getNetworkEvents: UMElasticsearchQueryFn< GetNetworkEventsParams, - { events: NetworkEvent[]; total: number; isWaterfallSupported: boolean } + { + events: NetworkEvent[]; + total: number; + isWaterfallSupported: boolean; + hasNavigationRequest: boolean; + } > = async ({ uptimeEsClient, checkGroup, stepIndex }) => { const params = { track_total_hits: true, @@ -41,25 +46,34 @@ export const getNetworkEvents: UMElasticsearchQueryFn< const { body: result } = await uptimeEsClient.search({ body: params }); let isWaterfallSupported = false; + let hasNavigationRequest = false; + const events = result.hits.hits.map((event: any) => { - if (event._source.http && event._source.url) { + const docSource = event._source; + + if (docSource.http && docSource.url) { isWaterfallSupported = true; } - const requestSentTime = secondsToMillis(event._source.synthetics.payload.request_sent_time); - const loadEndTime = secondsToMillis(event._source.synthetics.payload.load_end_time); - const securityDetails = event._source.tls?.server?.x509; + const requestSentTime = secondsToMillis(docSource.synthetics.payload.request_sent_time); + const loadEndTime = secondsToMillis(docSource.synthetics.payload.load_end_time); + const securityDetails = docSource.tls?.server?.x509; + + if (docSource.synthetics.payload?.is_navigation_request) { + // if step has navigation request, this means we will display waterfall metrics in ui + hasNavigationRequest = true; + } return { - timestamp: event._source['@timestamp'], - method: event._source.http?.request?.method, - url: event._source.url?.full, - status: event._source.http?.response?.status, - mimeType: event._source.http?.response?.mime_type, + timestamp: docSource['@timestamp'], + method: docSource.http?.request?.method, + url: docSource.url?.full, + status: docSource.http?.response?.status, + mimeType: docSource.http?.response?.mime_type, requestSentTime, loadEndTime, - timings: event._source.synthetics.payload.timings, - transferSize: event._source.synthetics.payload.transfer_size, - resourceSize: event._source.synthetics.payload.resource_size, + timings: docSource.synthetics.payload.timings, + transferSize: docSource.synthetics.payload.transfer_size, + resourceSize: docSource.synthetics.payload.resource_size, certificates: securityDetails ? { issuer: securityDetails.issuer?.common_name, @@ -68,9 +82,9 @@ export const getNetworkEvents: UMElasticsearchQueryFn< validTo: securityDetails.not_after, } : undefined, - requestHeaders: event._source.http?.request?.headers, - responseHeaders: event._source.http?.response?.headers, - ip: event._source.http?.response?.remote_i_p_address, + requestHeaders: docSource.http?.request?.headers, + responseHeaders: docSource.http?.response?.headers, + ip: docSource.http?.response?.remote_i_p_address, }; }); @@ -78,5 +92,6 @@ export const getNetworkEvents: UMElasticsearchQueryFn< total: result.hits.total.value, events, isWaterfallSupported, + hasNavigationRequest, }; };