[Uptime] Annotate waterfall chart with additional metrics (#103642) (#113898)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Shahzad <shahzad.muhammad@elastic.co>
This commit is contained in:
Kibana Machine 2021-10-07 06:17:09 -04:00 committed by GitHub
parent 9847e5a35d
commit 54d903ad41
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 390 additions and 19 deletions

View file

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

View file

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

View file

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

View file

@ -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<Props> = ({ 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<Props> = ({ checkGroup, stepIndex
{waterfallLoaded && hasEvents && isWaterfallSupported && (
<WaterfallChartWrapper
data={extractItems(networkEvents.events)}
markerItems={metrics}
total={networkEvents.total}
/>
)}

View file

@ -31,7 +31,11 @@ describe('WaterfallChartWrapper', () => {
it('renders the correct sidebar items', () => {
const { getAllByTestId } = render(
<WaterfallChartWrapper data={extractItems(NETWORK_EVENTS.events)} total={1000} />
<WaterfallChartWrapper
data={extractItems(NETWORK_EVENTS.events)}
total={1000}
markerItems={[{ id: 'domContentLoaded', offset: 2352353 }]}
/>
);
const sideBarItems = getAllByTestId('middleTruncatedTextSROnly');

View file

@ -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<LegendItem> = (item) => {
return (
@ -26,9 +27,10 @@ export const renderLegendItem: RenderItem<LegendItem> = (item) => {
interface Props {
total: number;
data: NetworkItems;
markerItems?: MarkerItems;
}
export const WaterfallChartWrapper: React.FC<Props> = ({ data, total }) => {
export const WaterfallChartWrapper: React.FC<Props> = ({ data, total, markerItems }) => {
const [query, setQuery] = useState<string>('');
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [onlyHighlighted, setOnlyHighlighted] = useState(false);
@ -107,6 +109,7 @@ export const WaterfallChartWrapper: React.FC<Props> = ({ data, total }) => {
return (
<WaterfallProvider
markerItems={markerItems}
totalNetworkRequests={total}
fetchedNetworkRequests={networkData.length}
highlightedNetworkRequests={totalHighlightedRequests}

View file

@ -76,6 +76,11 @@ export const WaterfallChartFixedAxisContainer = euiStyled.div`
height: ${FIXED_AXIS_HEIGHT}px;
z-index: ${(props) => props.theme.eui.euiZLevel4};
height: 100%;
&&& {
.echAnnotation__icon {
top: 8px;
}
}
`;
interface WaterfallChartSidebarContainer {

View file

@ -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}
/>
<WaterfallChartMarkers />
</Chart>
</WaterfallChartChartContainer>
);

View file

@ -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 }]}
/>
<WaterfallChartMarkers />
</Chart>
</WaterfallChartFixedAxisContainer>
);

View file

@ -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<string, { label: string; color: string }> = {
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 (
<Wrapper>
{markerItems.map(({ id, offset }) => (
<LineAnnotation
key={id}
id={id}
domainType={AnnotationDomainType.YDomain}
dataValues={[
{
dataValue: offset,
details: markersInfo[id]?.label ?? id,
header: i18n.translate('xpack.uptime.synthetics.waterfall.offsetUnit', {
defaultMessage: '{offset} ms',
values: { offset },
}),
},
]}
marker={<EuiIcon type="dot" size="l" />}
style={{
line: {
strokeWidth: 2,
stroke: markersInfo[id]?.color ?? theme.eui.euiColorMediumShade,
opacity: 1,
},
}}
/>
))}
</Wrapper>
);
}
const Wrapper = euiStyled.span`
&&& {
> .echAnnotation__icon {
top: 8px;
}
}
`;

View file

@ -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<Partial<IWaterfallContext>>({});
@ -43,11 +55,13 @@ interface ProviderProps {
legendItems?: IWaterfallContext['legendItems'];
metadata: IWaterfallContext['metadata'];
renderTooltipItem: IWaterfallContext['renderTooltipItem'];
markerItems?: MarkerItems;
}
export const WaterfallProvider: React.FC<ProviderProps> = ({
children,
data,
markerItems,
onElementClick,
onProjectionClick,
onSidebarClick,
@ -64,6 +78,7 @@ export const WaterfallProvider: React.FC<ProviderProps> = ({
<WaterfallContext.Provider
value={{
data,
markerItems,
showOnlyHighlightedNetworkRequests,
sidebarItems,
legendItems,

View file

@ -135,12 +135,18 @@ export const StepDetailPageChildren = () => {
/>
);
};
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 <StepDetailContainer checkGroup={checkGroupId} stepIndex={Number(stepIndex)} />;
};

View file

@ -23,6 +23,7 @@ export interface NetworkEventsState {
loading: boolean;
error?: Error;
isWaterfallSupported: boolean;
hasNavigationRequest?: boolean;
};
};
}
@ -71,7 +72,14 @@ export const networkEventsReducer = handleActions<NetworkEventsState, Payload>(
[String(getNetworkEventsSuccess)]: (
state: NetworkEventsState,
{
payload: { events, total, checkGroup, stepIndex, isWaterfallSupported },
payload: {
events,
total,
checkGroup,
stepIndex,
isWaterfallSupported,
hasNavigationRequest,
},
}: Action<SyntheticsNetworkEventsApiResponse & FetchNetworkEventsParams>
) => {
return {
@ -85,12 +93,14 @@ export const networkEventsReducer = handleActions<NetworkEventsState, Payload>(
events,
total,
isWaterfallSupported,
hasNavigationRequest,
}
: {
loading: false,
events,
total,
isWaterfallSupported,
hasNavigationRequest,
},
}
: {
@ -99,6 +109,7 @@ export const networkEventsReducer = handleActions<NetworkEventsState, Payload>(
events,
total,
isWaterfallSupported,
hasNavigationRequest,
},
},
};

View file

@ -298,6 +298,7 @@ describe('getNetworkEvents', () => {
"url": "www.test.com",
},
],
"hasNavigationRequest": false,
"isWaterfallSupported": true,
"total": 1,
}

View file

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