From 874fadf388eed6fc43dde79e74698158b5245398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Fri, 12 Feb 2021 22:53:05 +0100 Subject: [PATCH] [APM] Adding comparison to throughput chart (#90128) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dario Gieselaar --- .../date_as_string_rt/index.test.ts | 29 - .../iso_to_epoch_rt/index.test.ts | 32 + .../index.ts | 19 +- .../runtime_types/to_boolean_rt/index.ts | 24 + .../runtime_types/to_number_rt/index.ts | 2 +- .../service_inventory.test.tsx | 9 +- .../service_overview_throughput_chart.tsx | 54 +- .../transaction_overview.test.tsx | 4 +- .../shared/charts/timeseries_chart.tsx | 5 +- .../get_time_range_comparison.test.ts | 120 +++ .../get_time_range_comparison.ts | 88 ++ .../shared/time_comparison/index.test.tsx | 43 +- .../shared/time_comparison/index.tsx | 75 +- .../url_params_context/resolve_url_params.ts | 4 +- .../context/url_params_context/types.ts | 3 +- .../apm/server/lib/helpers/setup_request.ts | 13 +- .../apm/server/lib/services/get_throughput.ts | 50 +- .../apm/server/routes/default_api_types.ts | 11 +- x-pack/plugins/apm/server/routes/services.ts | 67 +- .../routes/settings/agent_configuration.ts | 4 +- x-pack/plugins/apm/server/routes/typings.ts | 2 +- .../offset_previous_period_coordinate.test.ts | 57 ++ .../offset_previous_period_coordinate.ts | 35 + .../services/__snapshots__/throughput.snap | 795 +++++++++++++++++- .../tests/services/throughput.ts | 91 +- 25 files changed, 1454 insertions(+), 182 deletions(-) delete mode 100644 x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts create mode 100644 x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts rename x-pack/plugins/apm/common/runtime_types/{date_as_string_rt => iso_to_epoch_rt}/index.ts (57%) create mode 100644 x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts create mode 100644 x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts create mode 100644 x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts deleted file mode 100644 index 313b597e5d409..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts +++ /dev/null @@ -1,29 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { dateAsStringRt } from './index'; -import { isLeft, isRight } from 'fp-ts/lib/Either'; - -describe('dateAsStringRt', () => { - it('validates whether a string is a valid date', () => { - expect(isLeft(dateAsStringRt.decode(1566299881499))).toBe(true); - - expect(isRight(dateAsStringRt.decode('2019-08-20T11:18:31.407Z'))).toBe( - true - ); - }); - - it('returns the string it was given', () => { - const either = dateAsStringRt.decode('2019-08-20T11:18:31.407Z'); - - if (isRight(either)) { - expect(either.right).toBe('2019-08-20T11:18:31.407Z'); - } else { - fail(); - } - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts new file mode 100644 index 0000000000000..573bfdc83e429 --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { isoToEpochRt } from './index'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('isoToEpochRt', () => { + it('validates whether its input is a valid ISO timestamp', () => { + expect(isRight(isoToEpochRt.decode(1566299881499))).toBe(false); + + expect(isRight(isoToEpochRt.decode('2019-08-20T11:18:31.407Z'))).toBe(true); + }); + + it('decodes valid ISO timestamps to epoch time', () => { + const iso = '2019-08-20T11:18:31.407Z'; + const result = isoToEpochRt.decode(iso); + + if (isRight(result)) { + expect(result.right).toBe(new Date(iso).getTime()); + } else { + fail(); + } + }); + + it('encodes epoch time to ISO string', () => { + expect(isoToEpochRt.encode(1566299911407)).toBe('2019-08-20T11:18:31.407Z'); + }); +}); diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts similarity index 57% rename from x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts rename to x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts index 182399657f6f3..1a17f82a52141 100644 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts @@ -9,15 +9,20 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; // Checks whether a string is a valid ISO timestamp, -// but doesn't convert it into a Date object when decoding +// and returns an epoch timestamp -export const dateAsStringRt = new t.Type( - 'DateAsString', - t.string.is, +export const isoToEpochRt = new t.Type( + 'isoToEpochRt', + t.number.is, (input, context) => either.chain(t.string.validate(input, context), (str) => { - const date = new Date(str); - return isNaN(date.getTime()) ? t.failure(input, context) : t.success(str); + const epochDate = new Date(str).getTime(); + return isNaN(epochDate) + ? t.failure(input, context) + : t.success(epochDate); }), - t.identity + (a) => { + const d = new Date(a); + return d.toISOString(); + } ); diff --git a/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts new file mode 100644 index 0000000000000..1e6828ed4ead3 --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts @@ -0,0 +1,24 @@ +/* + * 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 * as t from 'io-ts'; + +export const toBooleanRt = new t.Type( + 'ToBoolean', + t.boolean.is, + (input) => { + let value: boolean; + if (typeof input === 'string') { + value = input === 'true'; + } else { + value = !!input; + } + + return t.success(value); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts index 4103cb8837cde..a4632680cb6e1 100644 --- a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; export const toNumberRt = new t.Type( 'ToNumber', - t.any.is, + t.number.is, (input, context) => { const number = Number(input); return !isNaN(number) ? t.success(number) : t.failure(input, context); diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx index 69b4149625824..419b66da5d222 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx @@ -20,10 +20,12 @@ import { MockApmPluginContextWrapper, } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { clearCache } from '../../../services/rest/callApi'; import * as useDynamicIndexPatternHooks from '../../../hooks/use_dynamic_index_pattern'; import { SessionStorageMock } from '../../../services/__mocks__/SessionStorageMock'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import * as hook from './use_anomaly_detection_jobs_fetcher'; +import { TimeRangeComparisonType } from '../../shared/time_comparison/get_time_range_comparison'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -55,10 +57,10 @@ function wrapper({ children }: { children?: ReactNode }) { params={{ rangeFrom: 'now-15m', rangeTo: 'now', - start: 'mystart', - end: 'myend', + start: '2021-02-12T13:20:43.344Z', + end: '2021-02-12T13:20:58.344Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }} > {children} @@ -74,6 +76,7 @@ describe('ServiceInventory', () => { beforeEach(() => { // @ts-expect-error global.sessionStorage = new SessionStorageMock(); + clearCache(); jest.spyOn(hook, 'useAnomalyDetectionJobsFetcher').mockReturnValue({ anomalyDetectionJobsStatus: FETCH_STATUS.SUCCESS, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index d70dae5ae6316..92111c5671c91 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -15,6 +15,15 @@ import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { TimeseriesChart } from '../../shared/charts/timeseries_chart'; +import { + getTimeRangeComparison, + getComparisonChartTheme, +} from '../../shared/time_comparison/get_time_range_comparison'; + +const INITIAL_STATE = { + currentPeriod: [], + previousPeriod: [], +}; export function ServiceOverviewThroughputChart({ height, @@ -25,9 +34,20 @@ export function ServiceOverviewThroughputChart({ const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { transactionType } = useApmServiceContext(); - const { start, end } = urlParams; + const { start, end, comparisonEnabled, comparisonType } = urlParams; + const comparisonChartTheme = getComparisonChartTheme(theme); + const { + comparisonStart = undefined, + comparisonEnd = undefined, + } = comparisonType + ? getTimeRangeComparison({ + start, + end, + comparisonType, + }) + : {}; - const { data, status } = useFetcher( + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (serviceName && transactionType && start && end) { return callApmApi({ @@ -41,12 +61,22 @@ export function ServiceOverviewThroughputChart({ end, transactionType, uiFilters: JSON.stringify(uiFilters), + comparisonStart, + comparisonEnd, }, }, }); } }, - [serviceName, start, end, uiFilters, transactionType] + [ + serviceName, + start, + end, + uiFilters, + transactionType, + comparisonStart, + comparisonEnd, + ] ); return ( @@ -63,9 +93,10 @@ export function ServiceOverviewThroughputChart({ height={height} showAnnotations={false} fetchStatus={status} + customTheme={comparisonChartTheme} timeseries={[ { - data: data?.throughput ?? [], + data: data.currentPeriod, type: 'linemark', color: theme.eui.euiColorVis0, title: i18n.translate( @@ -73,6 +104,21 @@ export function ServiceOverviewThroughputChart({ { defaultMessage: 'Throughput' } ), }, + ...(comparisonEnabled + ? [ + { + data: data.previousPeriod, + type: 'area', + color: theme.eui.euiColorLightestShade, + title: i18n.translate( + 'xpack.apm.serviceOverview.throughtputChart.previousPeriodLabel', + { + defaultMessage: 'Previous period', + } + ), + }, + ] + : []), ]} yLabelFormat={asTransactionRate} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index 7d0ada3e31bff..8fb5166bd8676 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -131,7 +131,7 @@ describe('TransactionOverview', () => { }); expect(history.location.search).toEqual( - '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=day' ); expect(getByText(container, 'firstType')).toBeInTheDocument(); expect(getByText(container, 'secondType')).toBeInTheDocument(); @@ -142,7 +142,7 @@ describe('TransactionOverview', () => { expect(history.push).toHaveBeenCalled(); expect(history.location.search).toEqual( - '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=day' ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index 441dbf4df2827..7bfe17e82bf4a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -59,6 +59,7 @@ interface Props { anomalyTimeseries?: ReturnType< typeof getLatencyChartSelector >['anomalyTimeseries']; + customTheme?: Record; } export function TimeseriesChart({ @@ -72,13 +73,14 @@ export function TimeseriesChart({ showAnnotations = true, yDomain, anomalyTimeseries, + customTheme = {}, }: Props) { const history = useHistory(); const { annotations } = useAnnotationsContext(); - const chartTheme = useChartTheme(); const { setPointerEvent, chartRef } = useChartPointerEventContext(); const { urlParams } = useUrlParams(); const theme = useTheme(); + const chartTheme = useChartTheme(); const { start, end } = urlParams; @@ -103,6 +105,7 @@ export function TimeseriesChart({ areaSeriesStyle: { line: { visible: false }, }, + ...customTheme, }} onPointerUpdate={setPointerEvent} externalPointerEvents={{ diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts new file mode 100644 index 0000000000000..7234e94881ce7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; + +describe('getTimeRangeComparison', () => { + describe('return empty object', () => { + it('when start is not defined', () => { + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + start: undefined, + end, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + + it('when end is not defined', () => { + const start = '2021-01-28T14:45:00.000Z'; + const result = getTimeRangeComparison({ + start, + end: undefined, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + }); + + describe('Time range is between 0 - 24 hours', () => { + describe('when day before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.DayBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-27T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-27T15:00:00.000Z'); + }); + }); + describe('when a week before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-21T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + describe('when previous period is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-02-09T14:40:01.087Z'; + const end = '2021-02-09T14:56:00.000Z'; + const result = getTimeRangeComparison({ + start, + end, + comparisonType: TimeRangeComparisonType.PeriodBefore, + }); + expect(result).toEqual({ + comparisonStart: '2021-02-09T14:24:02.174Z', + comparisonEnd: '2021-02-09T14:40:01.087Z', + }); + }); + }); + }); + + describe('Time range is between 24 hours - 1 week', () => { + describe('when a week before is selected', () => { + it('returns the correct time range - 2 days', () => { + const start = '2021-01-26T15:00:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-19T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + }); + + describe('Time range is greater than 7 days', () => { + it('uses the date difference to calculate the time range - 8 days', () => { + const start = '2021-01-10T15:00:00.000Z'; + const end = '2021-01-18T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-10T15:00:00.000Z'); + }); + + it('uses the date difference to calculate the time range - 30 days', () => { + const start = '2021-01-01T15:00:00.000Z'; + const end = '2021-01-31T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2020-12-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-01T15:00:00.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts new file mode 100644 index 0000000000000..5dd014441a9e4 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -0,0 +1,88 @@ +/* + * 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 moment from 'moment'; +import { EuiTheme } from 'src/plugins/kibana_react/common'; +import { getDateDifference } from '../../../../common/utils/formatters'; + +export enum TimeRangeComparisonType { + WeekBefore = 'week', + DayBefore = 'day', + PeriodBefore = 'period', +} + +export function getComparisonChartTheme(theme: EuiTheme) { + return { + areaSeriesStyle: { + area: { + fill: theme.eui.euiColorLightestShade, + visible: true, + opacity: 1, + }, + line: { + stroke: theme.eui.euiColorMediumShade, + strokeWidth: 1, + visible: true, + }, + point: { + visible: false, + }, + }, + }; +} + +const oneDayInMilliseconds = moment.duration(1, 'day').asMilliseconds(); +const oneWeekInMilliseconds = moment.duration(1, 'week').asMilliseconds(); + +export function getTimeRangeComparison({ + comparisonType, + start, + end, +}: { + comparisonType: TimeRangeComparisonType; + start?: string; + end?: string; +}) { + if (!start || !end) { + return {}; + } + + const startMoment = moment(start); + const endMoment = moment(end); + + const startEpoch = startMoment.valueOf(); + const endEpoch = endMoment.valueOf(); + + let diff: number; + + switch (comparisonType) { + case TimeRangeComparisonType.DayBefore: + diff = oneDayInMilliseconds; + break; + + case TimeRangeComparisonType.WeekBefore: + diff = oneWeekInMilliseconds; + break; + + case TimeRangeComparisonType.PeriodBefore: + diff = getDateDifference({ + start: startMoment, + end: endMoment, + unitOfTime: 'milliseconds', + precise: true, + }); + break; + + default: + throw new Error('Unknown comparisonType'); + } + + return { + comparisonStart: new Date(startEpoch - diff).toISOString(), + comparisonEnd: new Date(endEpoch - diff).toISOString(), + }; +} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx index 4ace78f74ee79..a4f44290fe777 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx @@ -18,6 +18,7 @@ import { import { TimeComparison } from './'; import * as urlHelpers from '../../shared/Links/url_helpers'; import moment from 'moment'; +import { TimeRangeComparisonType } from './get_time_range_comparison'; function getWrapper(params?: IUrlParams) { return ({ children }: { children?: ReactNode }) => { @@ -53,22 +54,22 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }, }); }); - it('selects yesterday and enables comparison', () => { + it('selects day before and enables comparison', () => { const Wrapper = getWrapper({ start: '2021-01-28T14:45:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -80,13 +81,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -98,13 +99,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now-15m', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['28/01 11:00 - 29/01 11:00']); + expectTextsInDocument(component, ['27/01 11:00 - 28/01 11:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -118,14 +119,14 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T11:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); }); it('sets default values', () => { const Wrapper = getWrapper({ @@ -139,7 +140,7 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, }, }); }); @@ -148,14 +149,14 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -167,13 +168,13 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: '2021-01-28T15:00:00.000Z', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['26/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['24/01 16:00 - 26/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -187,14 +188,14 @@ describe('TimeComparison', () => { start: '2021-01-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['12/01 16:00 - 20/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -206,14 +207,14 @@ describe('TimeComparison', () => { start: '2020-12-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/12/20 16:00 - 28/01/21 16:00']); + expectTextsInDocument(component, ['11/11/20 16:00 - 20/12/20 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index e4b03bd57377a..0b6c1a2c52a98 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -16,6 +16,10 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { px, unit } from '../../../style/variables'; import * as urlHelpers from '../../shared/Links/url_helpers'; import { useBreakPoints } from '../../../hooks/use_break_points'; +import { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; const PrependContainer = euiStyled.div` display: flex; @@ -25,15 +29,32 @@ const PrependContainer = euiStyled.div` padding: 0 ${px(unit)}; `; -function formatPreviousPeriodDates({ - momentStart, - momentEnd, +function getDateFormat({ + previousPeriodStart, + currentPeriodEnd, }: { - momentStart: moment.Moment; - momentEnd: moment.Moment; + previousPeriodStart?: string; + currentPeriodEnd?: string; }) { - const isDifferentYears = momentStart.get('year') !== momentEnd.get('year'); - const dateFormat = isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; + const momentPreviousPeriodStart = moment(previousPeriodStart); + const momentCurrentPeriodEnd = moment(currentPeriodEnd); + const isDifferentYears = + momentPreviousPeriodStart.get('year') !== + momentCurrentPeriodEnd.get('year'); + return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; +} + +function formatDate({ + dateFormat, + previousPeriodStart, + previousPeriodEnd, +}: { + dateFormat: string; + previousPeriodStart?: string; + previousPeriodEnd?: string; +}) { + const momentStart = moment(previousPeriodStart); + const momentEnd = moment(previousPeriodEnd); return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; } @@ -49,17 +70,17 @@ function getSelectOptions({ const momentStart = moment(start); const momentEnd = moment(end); - const yesterdayOption = { - value: 'yesterday', - text: i18n.translate('xpack.apm.timeComparison.select.yesterday', { - defaultMessage: 'Yesterday', + const dayBeforeOption = { + value: TimeRangeComparisonType.DayBefore, + text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { + defaultMessage: 'Day before', }), }; - const aWeekAgoOption = { - value: 'week', - text: i18n.translate('xpack.apm.timeComparison.select.weekAgo', { - defaultMessage: 'A week ago', + const weekBeforeOption = { + value: TimeRangeComparisonType.WeekBefore, + text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { + defaultMessage: 'Week before', }), }; @@ -69,23 +90,39 @@ function getSelectOptions({ unitOfTime: 'days', precise: true, }); + const isRangeToNow = rangeTo === 'now'; if (isRangeToNow) { // Less than or equals to one day if (dateDiff <= 1) { - return [yesterdayOption, aWeekAgoOption]; + return [dayBeforeOption, weekBeforeOption]; } // Less than or equals to one week if (dateDiff <= 7) { - return [aWeekAgoOption]; + return [weekBeforeOption]; } } + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + + const dateFormat = getDateFormat({ + previousPeriodStart: comparisonStart, + currentPeriodEnd: end, + }); + const prevPeriodOption = { - value: 'previousPeriod', - text: formatPreviousPeriodDates({ momentStart, momentEnd }), + value: TimeRangeComparisonType.PeriodBefore, + text: formatDate({ + dateFormat, + previousPeriodStart: comparisonStart, + previousPeriodEnd: comparisonEnd, + }), }; // above one week or when rangeTo is not "now" diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index 5b72a50e8dbd8..addef74f5b25b 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -11,6 +11,7 @@ import { pickKeys } from '../../../common/utils/pick_keys'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; import { toQuery } from '../../components/shared/Links/url_helpers'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; import { getDateRange, removeUndefinedProps, @@ -84,8 +85,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { comparisonEnabled: comparisonEnabled ? toBoolean(comparisonEnabled) : undefined, - comparisonType, - + comparisonType: comparisonType as TimeRangeComparisonType | undefined, // ui filters environment, ...localUIFilters, diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index 723fca4487237..4332019d1a1c9 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -7,6 +7,7 @@ import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { LocalUIFilterName } from '../../../common/ui_filter'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; export type IUrlParams = { detailTab?: string; @@ -32,5 +33,5 @@ export type IUrlParams = { percentile?: number; latencyAggregationType?: LatencyAggregationType; comparisonEnabled?: boolean; - comparisonType?: string; + comparisonType?: TimeRangeComparisonType; } & Partial>; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 5de2abc312815..b12a396befe8c 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -6,7 +6,6 @@ */ import { Logger } from 'kibana/server'; -import moment from 'moment'; import { isActivePlatinumLicense } from '../../../common/license_check'; import { APMConfig } from '../..'; import { KibanaRequest } from '../../../../../../src/core/server'; @@ -54,19 +53,19 @@ interface SetupRequestParams { /** * Timestamp in ms since epoch */ - start?: string; + start?: number; /** * Timestamp in ms since epoch */ - end?: string; + end?: number; uiFilters?: string; }; } type InferSetup = Setup & - (TParams extends { query: { start: string } } ? { start: number } : {}) & - (TParams extends { query: { end: string } } ? { end: number } : {}); + (TParams extends { query: { start: number } } ? { start: number } : {}) & + (TParams extends { query: { end: number } } ? { end: number } : {}); export async function setupRequest( context: APMRequestHandlerContext, @@ -115,8 +114,8 @@ export async function setupRequest( }; return { - ...('start' in query ? { start: moment.utc(query.start).valueOf() } : {}), - ...('end' in query ? { end: moment.utc(query.end).valueOf() } : {}), + ...('start' in query ? { start: query.start } : {}), + ...('end' in query ? { end: query.end } : {}), ...coreSetupRequest, } as InferSetup; }); diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index c4e217f95bcd1..33268e9b3332d 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -6,7 +6,6 @@ */ import { ESFilter } from '../../../../../typings/elasticsearch'; -import { PromiseReturnType } from '../../../../observability/typings/common'; import { SERVICE_NAME, TRANSACTION_TYPE, @@ -17,38 +16,27 @@ import { getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; import { getBucketSize } from '../helpers/get_bucket_size'; -import { calculateThroughput } from '../helpers/calculate_throughput'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { Setup } from '../helpers/setup_request'; import { withApmSpan } from '../../utils/with_apm_span'; interface Options { searchAggregatedTransactions: boolean; serviceName: string; - setup: Setup & SetupTimeRange; + setup: Setup; transactionType: string; + start: number; + end: number; } -type ESResponse = PromiseReturnType; - -function transform(options: Options, response: ESResponse) { - if (response.hits.total.value === 0) { - return []; - } - const { start, end } = options.setup; - const buckets = response.aggregations?.throughput.buckets ?? []; - return buckets.map(({ key: x, doc_count: value }) => ({ - x, - y: calculateThroughput({ start, end, value }), - })); -} - -async function fetcher({ +function fetcher({ searchAggregatedTransactions, serviceName, setup, transactionType, + start, + end, }: Options) { - const { start, end, apmEventClient } = setup; + const { apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end }); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, @@ -72,13 +60,20 @@ async function fetcher({ size: 0, query: { bool: { filter } }, aggs: { - throughput: { + timeseries: { date_histogram: { field: '@timestamp', fixed_interval: intervalString, min_doc_count: 0, extended_bounds: { min: start, max: end }, }, + aggs: { + throughput: { + rate: { + unit: 'minute' as const, + }, + }, + }, }, }, }, @@ -89,8 +84,15 @@ async function fetcher({ export function getThroughput(options: Options) { return withApmSpan('get_throughput_for_service', async () => { - return { - throughput: transform(options, await fetcher(options)), - }; + const response = await fetcher(options); + + return ( + response.aggregations?.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + y: bucket.throughput.value, + }; + }) ?? [] + ); }); } diff --git a/x-pack/plugins/apm/server/routes/default_api_types.ts b/x-pack/plugins/apm/server/routes/default_api_types.ts index 0ab4e0331652b..fdc1e8ebe5a55 100644 --- a/x-pack/plugins/apm/server/routes/default_api_types.ts +++ b/x-pack/plugins/apm/server/routes/default_api_types.ts @@ -6,11 +6,16 @@ */ import * as t from 'io-ts'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; export const rangeRt = t.type({ - start: dateAsStringRt, - end: dateAsStringRt, + start: isoToEpochRt, + end: isoToEpochRt, +}); + +export const comparisonRangeRt = t.partial({ + comparisonStart: isoToEpochRt, + comparisonEnd: isoToEpochRt, }); export const uiFiltersRt = t.type({ uiFilters: t.string }); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index a5c4de7552d78..ff064e0571d13 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,26 +5,27 @@ * 2.0. */ -import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import * as t from 'io-ts'; import { uniq } from 'lodash'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; +import { toNumberRt } from '../../common/runtime_types/to_number_rt'; +import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getServiceAgentName } from '../lib/services/get_service_agent_name'; -import { getServices } from '../lib/services/get_services'; -import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; -import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; -import { createRoute } from './create_route'; -import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; -import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; -import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; +import { getServices } from '../lib/services/get_services'; +import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; -import { toNumberRt } from '../../common/runtime_types/to_number_rt'; -import { getThroughput } from '../lib/services/get_throughput'; +import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; import { getServiceInstances } from '../lib/services/get_service_instances'; import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; +import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; +import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; +import { getThroughput } from '../lib/services/get_throughput'; +import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; +import { createRoute } from './create_route'; +import { comparisonRangeRt, rangeRt, uiFiltersRt } from './default_api_types'; import { withApmSpan } from '../utils/with_apm_span'; export const servicesRoute = createRoute({ @@ -216,7 +217,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ }), body: t.intersection([ t.type({ - '@timestamp': dateAsStringRt, + '@timestamp': isoToEpochRt, service: t.intersection([ t.type({ version: t.string, @@ -251,6 +252,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ annotationsClient.create({ message: body.service.version, ...body, + '@timestamp': new Date(body['@timestamp']).toISOString(), annotation: { type: 'deployment', }, @@ -325,23 +327,56 @@ export const serviceThroughputRoute = createRoute({ t.type({ transactionType: t.string }), uiFiltersRt, rangeRt, + comparisonRangeRt, ]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionType } = context.params.query; + const { + transactionType, + comparisonStart, + comparisonEnd, + } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); - return getThroughput({ + const { start, end } = setup; + + const commonProps = { searchAggregatedTransactions, serviceName, setup, transactionType, - }); + }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + getThroughput({ + ...commonProps, + start, + end, + }), + comparisonStart && comparisonEnd + ? getThroughput({ + ...commonProps, + start: comparisonStart, + end: comparisonEnd, + }).then((coordinates) => + offsetPreviousPeriodCoordinates({ + currentPeriodStart: start, + previousPeriodStart: comparisonStart, + previousPeriodTimeseries: coordinates, + }) + ) + : [], + ]); + + return { + currentPeriod, + previousPeriod, + }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 61875db0985e4..ae0d9aeeaade1 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import { toBooleanRt } from '../../../common/runtime_types/to_boolean_rt'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names'; import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration'; @@ -22,7 +23,6 @@ import { serviceRt, agentConfigurationIntakeRt, } from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; // get list of configurations @@ -103,7 +103,7 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ tags: ['access:apm', 'access:apm_write'], }, params: t.intersection([ - t.partial({ query: t.partial({ overwrite: jsonRt.pipe(t.boolean) }) }), + t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), t.type({ body: agentConfigurationIntakeRt }), ]), handler: async ({ context, request }) => { diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index e5901cabc4ef6..4d3e07040f76b 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -143,7 +143,7 @@ export type Client< forceCache?: boolean; endpoint: TEndpoint; } & (TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ params: t.TypeOf }> + ? MaybeOptional<{ params: t.OutputOf }> : {}) & (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) ) => Promise< diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts new file mode 100644 index 0000000000000..6436c7c5193ec --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Coordinate } from '../../typings/timeseries'; +import { offsetPreviousPeriodCoordinates } from './offset_previous_period_coordinate'; + +const previousPeriodStart = new Date('2021-01-27T14:45:00.000Z').valueOf(); +const currentPeriodStart = new Date('2021-01-28T14:45:00.000Z').valueOf(); + +describe('mergePeriodsTimeseries', () => { + describe('returns empty array', () => { + it('when previous timeseries is not defined', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: undefined, + }) + ).toEqual([]); + }); + + it('when previous timeseries is empty', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: [], + }) + ).toEqual([]); + }); + }); + + it('offsets previous period timeseries', () => { + const previousPeriodTimeseries: Coordinate[] = [ + { x: new Date('2021-01-27T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-27T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:30:00.000Z').valueOf(), y: 3 }, + ]; + + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, + }) + ).toEqual([ + { x: new Date('2021-01-28T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-28T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:30:00.000Z').valueOf(), y: 3 }, + ]); + }); +}); diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts new file mode 100644 index 0000000000000..837e3d02056f0 --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts @@ -0,0 +1,35 @@ +/* + * 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 moment from 'moment'; +import { Coordinate } from '../../typings/timeseries'; + +export function offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, +}: { + currentPeriodStart: number; + previousPeriodStart: number; + previousPeriodTimeseries?: Coordinate[]; +}) { + if (!previousPeriodTimeseries) { + return []; + } + + const dateOffset = moment(currentPeriodStart).diff( + moment(previousPeriodStart) + ); + + return previousPeriodTimeseries.map(({ x, y }) => { + const offsetX = moment(x).add(dateOffset).valueOf(); + return { + x: offsetX, + y, + }; + }); +} diff --git a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap index eee0ec7f9ad38..b4fd2219cb733 100644 --- a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap +++ b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap @@ -8,7 +8,7 @@ Array [ }, Object { "x": 1607435880000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607435910000, @@ -16,7 +16,7 @@ Array [ }, Object { "x": 1607435940000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607435970000, @@ -24,11 +24,11 @@ Array [ }, Object { "x": 1607436000000, - "y": 0.1, + "y": 6, }, Object { "x": 1607436030000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436060000, @@ -40,7 +40,7 @@ Array [ }, Object { "x": 1607436120000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607436150000, @@ -56,7 +56,7 @@ Array [ }, Object { "x": 1607436240000, - "y": 0.2, + "y": 12, }, Object { "x": 1607436270000, @@ -68,15 +68,15 @@ Array [ }, Object { "x": 1607436330000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436360000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436390000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436420000, @@ -88,11 +88,11 @@ Array [ }, Object { "x": 1607436480000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436510000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436540000, @@ -104,11 +104,11 @@ Array [ }, Object { "x": 1607436600000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436630000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607436660000, @@ -124,7 +124,7 @@ Array [ }, Object { "x": 1607436750000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436780000, @@ -132,15 +132,15 @@ Array [ }, Object { "x": 1607436810000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436840000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436870000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436900000, @@ -152,11 +152,11 @@ Array [ }, Object { "x": 1607436960000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436990000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607437020000, @@ -168,11 +168,11 @@ Array [ }, Object { "x": 1607437080000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437110000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437140000, @@ -184,15 +184,15 @@ Array [ }, Object { "x": 1607437200000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437230000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607437260000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437290000, @@ -200,11 +200,11 @@ Array [ }, Object { "x": 1607437320000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437350000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437380000, @@ -216,11 +216,11 @@ Array [ }, Object { "x": 1607437440000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437470000, - "y": 0.1, + "y": 6, }, Object { "x": 1607437500000, @@ -232,7 +232,7 @@ Array [ }, Object { "x": 1607437560000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437590000, @@ -248,3 +248,740 @@ Array [ }, ] `; + +exports[`APM API tests basic apm_8.0.0 Throughput when data is loaded with time comparison has the correct throughput 1`] = ` +Object { + "currentPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 0, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 6, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 0, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 6, + }, + Object { + "x": 1607436870000, + "y": 6, + }, + Object { + "x": 1607436880000, + "y": 6, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 0, + }, + Object { + "x": 1607436920000, + "y": 0, + }, + Object { + "x": 1607436930000, + "y": 0, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 12, + }, + Object { + "x": 1607436990000, + "y": 6, + }, + Object { + "x": 1607437000000, + "y": 18, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 0, + }, + Object { + "x": 1607437040000, + "y": 0, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 6, + }, + Object { + "x": 1607437110000, + "y": 6, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 0, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 12, + }, + Object { + "x": 1607437230000, + "y": 30, + }, + Object { + "x": 1607437240000, + "y": 12, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 6, + }, + Object { + "x": 1607437280000, + "y": 0, + }, + Object { + "x": 1607437290000, + "y": 0, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 6, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 12, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 0, + }, + Object { + "x": 1607437410000, + "y": 0, + }, + Object { + "x": 1607437420000, + "y": 0, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 6, + }, + Object { + "x": 1607437470000, + "y": 12, + }, + Object { + "x": 1607437480000, + "y": 6, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 0, + }, + Object { + "x": 1607437530000, + "y": 0, + }, + Object { + "x": 1607437540000, + "y": 0, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 6, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 0, + }, + Object { + "x": 1607437660000, + "y": 0, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], + "previousPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 24, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 0, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 12, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 0, + }, + Object { + "x": 1607436870000, + "y": 0, + }, + Object { + "x": 1607436880000, + "y": 0, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 12, + }, + Object { + "x": 1607436920000, + "y": 6, + }, + Object { + "x": 1607436930000, + "y": 6, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 0, + }, + Object { + "x": 1607436990000, + "y": 0, + }, + Object { + "x": 1607437000000, + "y": 0, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 6, + }, + Object { + "x": 1607437040000, + "y": 18, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 0, + }, + Object { + "x": 1607437110000, + "y": 0, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 36, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 0, + }, + Object { + "x": 1607437230000, + "y": 0, + }, + Object { + "x": 1607437240000, + "y": 6, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 0, + }, + Object { + "x": 1607437280000, + "y": 30, + }, + Object { + "x": 1607437290000, + "y": 6, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 0, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 0, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 12, + }, + Object { + "x": 1607437410000, + "y": 6, + }, + Object { + "x": 1607437420000, + "y": 24, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 0, + }, + Object { + "x": 1607437470000, + "y": 0, + }, + Object { + "x": 1607437480000, + "y": 0, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 12, + }, + Object { + "x": 1607437530000, + "y": 30, + }, + Object { + "x": 1607437540000, + "y": 12, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 0, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 6, + }, + Object { + "x": 1607437660000, + "y": 6, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], +} +`; diff --git a/x-pack/test/apm_api_integration/tests/services/throughput.ts b/x-pack/test/apm_api_integration/tests/services/throughput.ts index 29f5d84d31b07..787436ea37b05 100644 --- a/x-pack/test/apm_api_integration/tests/services/throughput.ts +++ b/x-pack/test/apm_api_integration/tests/services/throughput.ts @@ -8,10 +8,15 @@ import expect from '@kbn/expect'; import qs from 'querystring'; import { first, last } from 'lodash'; +import moment from 'moment'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { registry } from '../../common/registry'; +type ThroughputReturn = APIReturnType<'GET /api/apm/services/{serviceName}/throughput'>; + export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -29,17 +34,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { })}` ); expect(response.status).to.be(200); - expect(response.body.throughput.length).to.be(0); + expect(response.body.currentPeriod.length).to.be(0); + expect(response.body.previousPeriod.length).to.be(0); }); }); + let throughputResponse: ThroughputReturn; registry.when( 'Throughput when data is loaded', { config: 'basic', archives: [archiveName] }, () => { - let throughputResponse: { - throughput: Array<{ x: number; y: number | null }>; - }; before(async () => { const response = await supertest.get( `/api/apm/services/opbeans-java/throughput?${qs.stringify({ @@ -53,31 +57,98 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('returns some data', () => { - expect(throughputResponse.throughput.length).to.be.greaterThan(0); + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).not.to.be.greaterThan(0); - const nonNullDataPoints = throughputResponse.throughput.filter(({ y }) => y !== null); + const nonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); expect(nonNullDataPoints.length).to.be.greaterThan(0); }); it('has the correct start date', () => { expectSnapshot( - new Date(first(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T13:57:30.000Z"`); }); it('has the correct end date', () => { expectSnapshot( - new Date(last(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T14:27:30.000Z"`); }); it('has the correct number of buckets', () => { - expectSnapshot(throughputResponse.throughput.length).toMatchInline(`61`); + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`61`); + }); + + it('has the correct throughput', () => { + expectSnapshot(throughputResponse.currentPeriod).toMatch(); + }); + } + ); + + registry.when( + 'Throughput when data is loaded with time comparison', + { config: 'basic', archives: [archiveName] }, + () => { + before(async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-java/throughput?${qs.stringify({ + uiFilters: encodeURIComponent('{}'), + transactionType: 'request', + start: moment(metadata.end).subtract(15, 'minutes').toISOString(), + end: metadata.end, + comparisonStart: metadata.start, + comparisonEnd: moment(metadata.start).add(15, 'minutes').toISOString(), + })}` + ); + throughputResponse = response.body; + }); + + it('returns some data', () => { + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).to.be.greaterThan(0); + + const currentPeriodNonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + const previousPeriodNonNullDataPoints = throughputResponse.previousPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + + expect(currentPeriodNonNullDataPoints.length).to.be.greaterThan(0); + expect(previousPeriodNonNullDataPoints.length).to.be.greaterThan(0); + }); + + it('has the correct start date', () => { + expectSnapshot( + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + + expectSnapshot( + new Date(first(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + }); + + it('has the correct end date', () => { + expectSnapshot( + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + + expectSnapshot( + new Date(last(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + }); + + it('has the correct number of buckets', () => { + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`91`); + expectSnapshot(throughputResponse.previousPeriod.length).toMatchInline(`91`); }); it('has the correct throughput', () => { - expectSnapshot(throughputResponse.throughput).toMatch(); + expectSnapshot(throughputResponse).toMatch(); }); } );