[8.7] [Synthetics] Error timeline date range (#151965) (#152468)

# Backport

This will backport the following commits from `main` to `8.7`:
- [[Synthetics] Error timeline date range
(#151965)](https://github.com/elastic/kibana/pull/151965)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT
[{"author":{"name":"Shahzad","email":"shahzad31comp@gmail.com"},"sourceCommit":{"committedDate":"2023-03-01T15:46:25Z","message":"[Synthetics]
Error timeline date range
(#151965)","sha":"d051183adee6328ef86a8124b835ae69bfbae802","branchLabelMapping":{"^v8.8.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:uptime","release_note:skip","v8.7.0","v8.8.0"],"number":151965,"url":"https://github.com/elastic/kibana/pull/151965","mergeCommit":{"message":"[Synthetics]
Error timeline date range
(#151965)","sha":"d051183adee6328ef86a8124b835ae69bfbae802"}},"sourceBranch":"main","suggestedTargetBranches":["8.7"],"targetPullRequestStates":[{"branch":"8.7","label":"v8.7.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.8.0","labelRegex":"^v8.8.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/151965","number":151965,"mergeCommit":{"message":"[Synthetics]
Error timeline date range
(#151965)","sha":"d051183adee6328ef86a8124b835ae69bfbae802"}}]}]
BACKPORT-->

Co-authored-by: Shahzad <shahzad31comp@gmail.com>
This commit is contained in:
Kibana Machine 2023-03-01 11:56:36 -05:00 committed by GitHub
parent 054c846521
commit 3f3f616303
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 321 additions and 105 deletions

View file

@ -6,9 +6,8 @@
*/
import * as t from 'io-ts';
export const ErrorStateCodec = t.type({
duration_ms: t.string,
export const StateEndsCodec = t.type({
duration_ms: t.union([t.string, t.number]),
checks: t.number,
ends: t.union([t.string, t.null]),
started_at: t.string,
@ -17,3 +16,16 @@ export const ErrorStateCodec = t.type({
down: t.number,
status: t.string,
});
export const ErrorStateCodec = t.type({
duration_ms: t.union([t.string, t.number]),
checks: t.number,
ends: t.union([StateEndsCodec, t.null]),
started_at: t.string,
id: t.string,
up: t.number,
down: t.number,
status: t.string,
});
export type ErrorState = t.TypeOf<typeof ErrorStateCodec>;

View file

@ -65,6 +65,6 @@ journey(`TestRunDetailsPage`, async ({ page, params }) => {
await page.waitForSelector('text=Test run details');
await page.waitForSelector('text=Go to https://www.google.com');
await page.waitForSelector('text=After 2.1 s');
await page.waitForSelector('text=After 2.12 s');
});
});

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import React, { CSSProperties, ReactElement, useState } from 'react';
import React, { CSSProperties, ReactElement, useCallback, useEffect, useState } from 'react';
import {
EuiBasicTable,
EuiBasicTableColumn,
@ -62,25 +62,38 @@ export const BrowserStepsList = ({
Record<string, ReactElement>
>({});
const toggleDetails = (item: JourneyStep) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[item._id]) {
delete itemIdToExpandedRowMapValues[item._id];
} else {
if (testNowMode) {
itemIdToExpandedRowMapValues[item._id] = (
<EuiFlexGroup>
<EuiFlexItem>
<StepTabs step={item} loading={false} stepsList={steps} />
</EuiFlexItem>
</EuiFlexGroup>
);
} else {
itemIdToExpandedRowMapValues[item._id] = <></>;
}
const toggleDetails = useCallback(
(item: JourneyStep) => {
setItemIdToExpandedRowMap((prevState) => {
const itemIdToExpandedRowMapValues = { ...prevState };
if (itemIdToExpandedRowMapValues[item._id]) {
delete itemIdToExpandedRowMapValues[item._id];
} else {
if (testNowMode) {
itemIdToExpandedRowMapValues[item._id] = (
<EuiFlexGroup>
<EuiFlexItem>
<StepTabs step={item} loading={false} stepsList={steps} />
</EuiFlexItem>
</EuiFlexGroup>
);
} else {
itemIdToExpandedRowMapValues[item._id] = <></>;
}
}
return itemIdToExpandedRowMapValues;
});
},
[steps, testNowMode]
);
const failedStep = stepEnds?.find((step) => step.synthetics.step?.status === 'failed');
useEffect(() => {
if (failedStep && showExpand) {
toggleDetails(failedStep);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
};
}, [failedStep, showExpand, toggleDetails]);
const columns: Array<EuiBasicTableColumn<JourneyStep>> = [
...(showExpand

View file

@ -8,7 +8,8 @@ import React from 'react';
import { EuiDescriptionList } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import moment, { Moment } from 'moment';
import { useFindMyKillerState } from '../hooks/use_find_my_killer_state';
import { useErrorFailedTests } from '../hooks/use_last_error_state';
export const ErrorDuration: React.FC = () => {
@ -16,13 +17,41 @@ export const ErrorDuration: React.FC = () => {
const state = failedTests?.[0]?.state;
const duration = state ? moment().diff(moment(state?.started_at), 'minutes') : 0;
const { killerState } = useFindMyKillerState();
return (
<EuiDescriptionList listItems={[{ title: ERROR_DURATION, description: `${duration} min` }]} />
);
const endsAt = killerState?.timestamp ? moment(killerState?.timestamp) : moment();
const startedAt = moment(state?.started_at);
const duration = state ? getErrorDuration(startedAt, endsAt) : 0;
return <EuiDescriptionList listItems={[{ title: ERROR_DURATION, description: duration }]} />;
};
const ERROR_DURATION = i18n.translate('xpack.synthetics.errorDetails.errorDuration', {
defaultMessage: 'Error duration',
});
const getErrorDuration = (startedAt: Moment, endsAt: Moment) => {
// const endsAt = state.ends ? moment(state.ends) : moment();
// const startedAt = moment(state?.started_at);
const diffInDays = endsAt.diff(startedAt, 'days');
if (diffInDays > 1) {
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.days', {
defaultMessage: '{value} days',
values: { value: diffInDays },
});
}
const diffInHours = endsAt.diff(startedAt, 'hours');
if (diffInHours > 1) {
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.hours', {
defaultMessage: '{value} hours',
values: { value: diffInHours },
});
}
const diffInMinutes = endsAt.diff(startedAt, 'minutes');
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.mins', {
defaultMessage: '{value} mins',
values: { value: diffInMinutes },
});
};

View file

@ -5,8 +5,32 @@
* 2.0.
*/
import React from 'react';
import { EuiLoadingContent } from '@elastic/eui';
import moment from 'moment';
import { Ping } from '../../../../../../common/runtime_types';
import { MonitorFailedTests } from '../../monitor_details/monitor_errors/failed_tests';
export const ErrorTimeline = () => {
return <MonitorFailedTests time={{ from: 'now-1h', to: 'now' }} />;
export const ErrorTimeline = ({ lastTestRun }: { lastTestRun?: Ping }) => {
if (!lastTestRun) {
return <EuiLoadingContent lines={3} />;
}
const diff = moment(lastTestRun.monitor.timespan?.lt).diff(
moment(lastTestRun.monitor.timespan?.gte),
'minutes'
);
const startedAt = lastTestRun?.state?.started_at;
return (
<MonitorFailedTests
time={{
from: moment(startedAt)
.subtract(diff / 2, 'minutes')
.toISOString(),
to: moment(lastTestRun.timestamp)
.add(diff / 2, 'minutes')
.toISOString(),
}}
allowBrushing={false}
/>
);
};

View file

@ -8,15 +8,13 @@ import React, { ReactElement } from 'react';
import { EuiDescriptionList } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useErrorFailedTests } from '../hooks/use_last_error_state';
import { useFormatTestRunAt } from '../../../utils/monitor_test_result/test_time_formats';
import { useFindMyKillerState } from '../hooks/use_find_my_killer_state';
export const ResolvedAt: React.FC = () => {
const { failedTests } = useErrorFailedTests();
const { killerState } = useFindMyKillerState();
const state = failedTests?.[0]?.state;
let endsAt: string | ReactElement = useFormatTestRunAt(state?.ends ?? '');
let endsAt: string | ReactElement = useFormatTestRunAt(killerState?.timestamp);
if (!endsAt) {
endsAt = 'N/A';

View file

@ -43,7 +43,7 @@ export function ErrorDetailsPage() {
return (
<div>
<PanelWithTitle title={TIMELINE_LABEL}>
<ErrorTimeline />
<ErrorTimeline lastTestRun={lastTestRun} />
</PanelWithTitle>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="m">
@ -71,13 +71,19 @@ export function ErrorDetailsPage() {
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={1} style={{ height: 'fit-content' }}>
<PanelWithTitle>
{data?.details?.journey && failedStep && (
<StepImage ping={data?.details?.journey} step={failedStep} isFailed={isFailedStep} />
)}
</PanelWithTitle>
{data?.details?.journey && failedStep && (
<>
<PanelWithTitle>
<StepImage
ping={data?.details?.journey}
step={failedStep}
isFailed={isFailedStep}
/>
</PanelWithTitle>
<EuiSpacer size="m" />
</>
)}
<EuiSpacer size="m" />
<StepDurationPanel doBreakdown={false} />
<EuiSpacer size="m" />
<MonitorDetailsPanelContainer hideLocations />

View file

@ -6,6 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useSelectedLocation } from '../../monitor_details/hooks/use_selected_location';
import { useTestRunDetailsBreadcrumbs } from '../../test_run_details/hooks/use_test_run_details_breadcrumbs';
import { useSelectedMonitor } from '../../monitor_details/hooks/use_selected_monitor';
import { ConfigKey } from '../../../../../../common/runtime_types';
@ -19,10 +20,14 @@ export const useErrorDetailsBreadcrumbs = (
const { monitor } = useSelectedMonitor();
const selectedLocation = useSelectedLocation();
const errorsBreadcrumbs = [
{
text: ERRORS_CRUMB,
href: `${appPath}/monitor/${monitor?.[ConfigKey.CONFIG_ID]}/errors`,
href: `${appPath}/monitor/${monitor?.[ConfigKey.CONFIG_ID]}/errors?locationId=${
selectedLocation?.id
}`,
},
...(extraCrumbs ?? []),
];

View file

@ -57,7 +57,8 @@ export function useErrorFailedTests() {
return useMemo(() => {
const failedTests =
data?.hits.hits?.map((doc) => {
return doc._source as Ping;
const source = doc._source as any;
return { ...source, timestamp: source['@timestamp'] } as Ping;
}) ?? [];
return {

View file

@ -0,0 +1,76 @@
/*
* 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 { useParams } from 'react-router-dom';
import { useMemo } from 'react';
import { useReduxEsSearch } from '../../../hooks/use_redux_es_search';
import { Ping } from '../../../../../../common/runtime_types';
import {
EXCLUDE_RUN_ONCE_FILTER,
SUMMARY_FILTER,
} from '../../../../../../common/constants/client_defaults';
import { SYNTHETICS_INDEX_PATTERN } from '../../../../../../common/constants';
import { useSyntheticsRefreshContext } from '../../../contexts';
import { useGetUrlParams } from '../../../hooks';
export function useFindMyKillerState() {
const { lastRefresh } = useSyntheticsRefreshContext();
const { errorStateId, monitorId } = useParams<{ errorStateId: string; monitorId: string }>();
const { dateRangeStart, dateRangeEnd } = useGetUrlParams();
const { data, loading } = useReduxEsSearch(
{
index: SYNTHETICS_INDEX_PATTERN,
body: {
// TODO: remove this once we have a better way to handle this mapping
runtime_mappings: {
'state.ends.id': {
type: 'keyword',
},
},
size: 1,
query: {
bool: {
filter: [
SUMMARY_FILTER,
EXCLUDE_RUN_ONCE_FILTER,
{
term: {
'state.ends.id': errorStateId,
},
},
{
term: {
config_id: monitorId,
},
},
],
},
},
sort: [{ '@timestamp': 'desc' }],
},
},
[lastRefresh, monitorId, dateRangeStart, dateRangeEnd],
{ name: 'getStateWhichEndTheState' }
);
return useMemo(() => {
const killerStates =
data?.hits.hits?.map((doc) => {
const source = doc._source as any;
return { ...source, timestamp: source['@timestamp'] } as Ping;
}) ?? [];
return {
loading,
killerState: killerStates?.[0],
};
}, [data, loading]);
}

View file

@ -39,7 +39,7 @@ export const getErrorDetailsRouteConfig = (
),
rightSideItems: [
<ErrorDuration />,
<MonitorDetailsLocation />,
<MonitorDetailsLocation isDisabled={true} />,
<ResolvedAt />,
<ErrorStartedAt />,
],

View file

@ -87,12 +87,15 @@ export function useMonitorErrors(monitorIdArg?: string) {
},
},
},
[lastRefresh, monitorId, monitorIdArg, dateRangeStart, dateRangeEnd],
{ name: 'getMonitorErrors', isRequestReady: Boolean(selectedLocation?.label) }
[lastRefresh, monitorId, monitorIdArg, dateRangeStart, dateRangeEnd, selectedLocation?.label],
{
name: `getMonitorErrors/${dateRangeStart}/${dateRangeEnd}`,
isRequestReady: Boolean(selectedLocation?.label),
}
);
return useMemo(() => {
const errorStates = (data?.aggregations?.errorStates.buckets ?? []).map((loc) => {
const errorStates = data?.aggregations?.errorStates.buckets?.map((loc) => {
return loc.summary.hits.hits?.[0]._source as PingState;
});

View file

@ -50,6 +50,10 @@ export const ErrorsList = ({
const selectedLocation = useSelectedLocation();
const lastTestRun = errorStates?.sort((a, b) => {
return moment(b.state.started_at).valueOf() - moment(a.state.started_at).valueOf();
})?.[0];
const columns = [
{
field: 'item.state.started_at',
@ -67,49 +71,56 @@ export const ErrorsList = ({
/>
);
const isActive = isActiveState(item);
if (!isActive) {
if (!isActive || lastTestRun.state.id !== item.state.id) {
return link;
}
return (
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>{link}</EuiFlexItem>
<EuiFlexItem grow={false} className="eui-textNoWrap">
{link}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge iconType="clock" iconSide="right">
Active
{ACTIVE_LABEL}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
);
},
},
...(isBrowserType
? [
{
field: 'monitor.check_group',
name: FAILED_STEP_LABEL,
truncateText: true,
sortable: (a: PingState) => {
const failedStep = failedSteps.find(
(step) => step.monitor.check_group === a.monitor.check_group
);
if (!failedStep) {
return a.monitor.check_group;
}
return failedStep.synthetics?.step?.name;
},
render: (value: string, item: PingState) => {
const failedStep = failedSteps.find((step) => step.monitor.check_group === value);
if (!failedStep) {
return <>--</>;
}
return (
<EuiText size="s">
{failedStep.synthetics?.step?.index}. {failedStep.synthetics?.step?.name}
</EuiText>
);
},
},
]
: []),
{
field: 'monitor.check_group',
name: !isBrowserType ? ERROR_MESSAGE_LABEL : FAILED_STEP_LABEL,
truncateText: true,
sortable: (a: PingState) => {
const failedStep = failedSteps.find(
(step) => step.monitor.check_group === a.monitor.check_group
);
if (!failedStep) {
return a.monitor.check_group;
}
return failedStep.synthetics?.step?.name;
},
render: (value: string, item: PingState) => {
if (!isBrowserType) {
return <EuiText size="s">{item.error.message ?? '--'}</EuiText>;
}
const failedStep = failedSteps.find((step) => step.monitor.check_group === value);
if (!failedStep) {
return <>--</>;
}
return (
<EuiText size="s">
{failedStep.synthetics?.step?.index}. {failedStep.synthetics?.step?.name}
</EuiText>
);
},
field: 'error.message',
name: ERROR_MESSAGE_LABEL,
},
{
field: 'state.duration_ms',
@ -157,6 +168,7 @@ export const ErrorsList = ({
<div>
<EuiSpacer />
<EuiInMemoryTable
tableLayout="auto"
tableCaption={ERRORS_LIST_LABEL}
loading={loading}
items={errorStates}
@ -216,3 +228,7 @@ const FAILED_STEP_LABEL = i18n.translate('xpack.synthetics.failedStep.label', {
const TIMESTAMP_LABEL = i18n.translate('xpack.synthetics.timestamp.label', {
defaultMessage: '@timestamp',
});
const ACTIVE_LABEL = i18n.translate('xpack.synthetics.active.label', {
defaultMessage: 'Active',
});

View file

@ -15,7 +15,13 @@ import { useUrlParams } from '../../../hooks';
import { useMonitorQueryId } from '../hooks/use_monitor_query_id';
import { ClientPluginsStart } from '../../../../../plugin';
export const MonitorFailedTests = ({ time }: { time: { to: string; from: string } }) => {
export const MonitorFailedTests = ({
time,
allowBrushing = true,
}: {
time: { to: string; from: string };
allowBrushing?: boolean;
}) => {
const { observability } = useKibana<ClientPluginsStart>().services;
const { ExploratoryViewEmbeddable } = observability;
@ -41,7 +47,8 @@ export const MonitorFailedTests = ({ time }: { time: { to: string; from: string
{
time,
reportDefinitions: {
...(monitorId ? { 'monitor.id': [monitorId] } : { 'state.id': [errorStateId] }),
...(monitorId ? { 'monitor.id': [monitorId] } : {}),
...(errorStateId ? { 'state.id': [errorStateId] } : {}),
},
dataType: 'synthetics',
selectedMetricField: 'failed_tests',
@ -49,21 +56,25 @@ export const MonitorFailedTests = ({ time }: { time: { to: string; from: string
},
]}
onBrushEnd={({ range }) => {
updateUrl({
dateRangeStart: moment(range[0]).toISOString(),
dateRangeEnd: moment(range[1]).toISOString(),
});
if (allowBrushing) {
updateUrl({
dateRangeStart: moment(range[0]).toISOString(),
dateRangeEnd: moment(range[1]).toISOString(),
});
}
}}
/>
<EuiFlexGroup>
<EuiFlexItem grow style={{ marginLeft: 10 }}>
<EuiHealth color="danger">{FAILED_TESTS_LABEL}</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
{BRUSH_LABEL}
</EuiText>
</EuiFlexItem>
{allowBrushing && (
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
{BRUSH_LABEL}
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
);

View file

@ -23,9 +23,9 @@ import { ErrorsTabContent } from './errors_tab_content';
export const MonitorErrors = () => {
const { errorStates, loading, data } = useMonitorErrors();
const initialLoading = loading && !data;
const initialLoading = !data;
const emptyState = !loading && errorStates.length === 0;
const emptyState = !loading && errorStates && errorStates?.length === 0;
const redirect = useMonitorDetailsPage();
if (redirect) {
@ -39,7 +39,7 @@ export const MonitorErrors = () => {
{initialLoading && <LoadingErrors />}
{emptyState && <EmptyErrors />}
<div style={{ visibility: initialLoading || emptyState ? 'collapse' : 'initial' }}>
<ErrorsTabContent errorStates={errorStates} loading={loading} />
<ErrorsTabContent errorStates={errorStates ?? []} loading={loading} />
</div>
</>
);

View file

@ -9,16 +9,16 @@ import { formatTestDuration } from './test_time_formats';
describe('formatTestDuration', () => {
it.each`
duration | expected | isMilli
${undefined} | ${'0 ms'} | ${undefined}
${120_000_000} | ${'2 min'} | ${undefined}
${6_200_000} | ${'6.2 s'} | ${false}
${500_000} | ${'500 ms'} | ${undefined}
${100} | ${'0 ms'} | ${undefined}
${undefined} | ${'0 ms'} | ${true}
${600_000} | ${'10 min'} | ${true}
${6_200} | ${'6.2 s'} | ${true}
${500} | ${'500 ms'} | ${true}
duration | expected | isMilli
${undefined} | ${'0 ms'} | ${undefined}
${120_000_000} | ${'2 mins'} | ${undefined}
${6_200_000} | ${'6.2 sec'} | ${false}
${500_000} | ${'500 ms'} | ${undefined}
${100} | ${'0 ms'} | ${undefined}
${undefined} | ${'0 ms'} | ${true}
${600_000} | ${'10 mins'} | ${true}
${6_200} | ${'6.2 sec'} | ${true}
${500} | ${'500 ms'} | ${true}
`(
'returns $expected when `duration` is $duration and `isMilli` $isMilli',
({

View file

@ -6,6 +6,7 @@
*/
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { useKibanaDateFormat } from '../../../../hooks/use_kibana_date_format';
/**
@ -16,19 +17,40 @@ import { useKibanaDateFormat } from '../../../../hooks/use_kibana_date_format';
export const formatTestDuration = (duration = 0, isMilli = false) => {
const secs = isMilli ? duration / 1e3 : duration / 1e6;
const hours = Math.floor(secs / 3600);
if (hours >= 1) {
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.hours', {
defaultMessage: '{value} hours',
values: { value: hours },
});
}
if (secs >= 60) {
return `${parseFloat((secs / 60).toFixed(1))} min`;
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.minutes', {
defaultMessage: '{value} mins',
values: { value: parseFloat((secs / 60).toFixed(1)) },
});
}
if (secs >= 1) {
return `${parseFloat(secs.toFixed(1))} s`;
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.seconds', {
defaultMessage: '{value} sec',
values: { value: parseFloat(secs.toFixed(1)) },
});
}
if (isMilli) {
return `${duration.toFixed(0)} ms`;
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.milliseconds', {
defaultMessage: '{value} ms',
values: { value: duration.toFixed(0) },
});
}
return `${(duration / 1000).toFixed(0)} ms`;
return i18n.translate('xpack.synthetics.errorDetails.errorDuration.microseconds', {
defaultMessage: '{value} ms',
values: { value: (duration / 1000).toFixed(0) },
});
};
export function formatTestRunAt(timestamp: string, format: string) {