fix(slo): history details data (#183097)

This commit is contained in:
Kevin Delemme 2024-05-16 12:56:51 -04:00 committed by GitHub
parent be659de803
commit 2e9f1baf13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 3908 additions and 1103 deletions

View file

@ -14,6 +14,7 @@ import {
import {
allOrAnyString,
allOrAnyStringOrArray,
dateRangeSchema,
dateType,
summarySchema,
} from '../../schema/common';
@ -33,10 +34,7 @@ const fetchHistoricalSummaryParamsSchema = t.type({
}),
t.partial({
remoteName: t.string,
range: t.type({
from: t.string,
to: t.string,
}),
range: dateRangeSchema,
}),
])
),

View file

@ -13,8 +13,8 @@ const getPreviewDataParamsSchema = t.type({
t.type({
indicator: indicatorSchema,
range: t.type({
start: t.number,
end: t.number,
from: dateType,
to: dateType,
}),
}),
t.partial({

View file

@ -87,8 +87,8 @@ const groupSummarySchema = t.type({
});
const dateRangeSchema = t.type({
from: t.union([dateType, t.string]),
to: t.union([dateType, t.string]),
from: dateType,
to: dateType,
});
export {

View file

@ -64,10 +64,8 @@ export function ErrorRateChart({
viewMode={ViewMode.VIEW}
onBrushEnd={({ range }) => {
onBrushed?.({
from: range[0],
to: range[1],
fromUtc: moment(range[0]).format(),
toUtc: moment(range[1]).format(),
from: moment(range[0]).toDate(),
to: moment(range[1]).toDate(),
});
}}
noPadding

View file

@ -62,7 +62,7 @@ export const sloKeys = {
) => [...sloKeys.all, 'burnRates', sloId, instanceId, windows] as const,
preview: (
indicator: Indicator,
range: { start: number; end: number },
range: { from: Date; to: Date },
groupings?: Record<string, unknown>
) => [...sloKeys.all, 'preview', indicator, range, groupings] as const,
burnRateRules: (search: string) => [...sloKeys.all, 'burnRateRules', search],

View file

@ -24,8 +24,8 @@ export interface Params {
sloList: SLOWithSummaryResponse[];
shouldRefetch?: boolean;
range?: {
from: string;
to: string;
from: Date;
to: Date;
};
}
@ -45,7 +45,12 @@ export function useFetchHistoricalSummary({
revision: slo.revision,
objective: slo.objective,
budgetingMethod: slo.budgetingMethod,
range,
range: range
? {
from: range?.from.toISOString(),
to: range?.to.toISOString(),
}
: undefined,
}));
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({

View file

@ -35,7 +35,7 @@ export function useGetPreviewData({
groupings?: Record<string, unknown>;
objective?: Objective;
indicator: Indicator;
range: { start: number; end: number };
range: { from: Date; to: Date };
}): UseGetPreviewData {
const { http } = useKibana().services;

View file

@ -37,7 +37,7 @@ import moment from 'moment';
import React, { useRef } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { TimeBounds } from '../types';
import { getBrushData } from '../../../utils/slo/duration';
import { getBrushTimeBounds } from '../../../utils/slo/duration';
import { SloTabId } from './slo_details';
import { useGetPreviewData } from '../../../hooks/use_get_preview_data';
import { useKibana } from '../../../utils/kibana_react';
@ -47,10 +47,7 @@ import { getDiscoverLink } from '../../../utils/slo/get_discover_link';
export interface Props {
slo: SLOWithSummaryResponse;
range: {
start: number;
end: number;
};
range: { from: Date; to: Date };
selectedTabId: SloTabId;
onBrushed?: (timeBounds: TimeBounds) => void;
}
@ -234,7 +231,7 @@ export function EventsChartPanel({ slo, range, selectedTabId, onBrushed }: Props
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onBrushEnd={(brushArea) => {
onBrushed?.(getBrushData(brushArea));
onBrushed?.(getBrushTimeBounds(brushArea));
}}
/>
{annotation}

View file

@ -18,12 +18,10 @@ export interface Props {
slo: SLOWithSummaryResponse;
isAutoRefreshing: boolean;
selectedTabId: SloTabId;
range?: {
from: string;
to: string;
};
range?: { from: Date; to: Date };
onBrushed?: (timeBounds: TimeBounds) => void;
}
export function HistoricalDataCharts({
slo,
range,

View file

@ -4,23 +4,25 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSuperDatePicker,
OnTimeChangeProps,
OnRefreshProps,
EuiSpacer,
EuiSuperDatePicker,
OnRefreshProps,
OnTimeChangeProps,
} from '@elastic/eui';
import DateMath from '@kbn/datemath';
import { useKibana } from '../../../../utils/kibana_react';
import { HistoricalDataCharts } from '../historical_data_charts';
import { useBurnRateOptions } from '../../hooks/use_burn_rate_options';
import { SloTabId } from '../slo_details';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React, { useMemo, useState } from 'react';
import { BurnRates } from '../../../../components/slo/burn_rate/burn_rates';
import { useKibana } from '../../../../utils/kibana_react';
import { useBurnRateOptions } from '../../hooks/use_burn_rate_options';
import { TimeBounds } from '../../types';
import { EventsChartPanel } from '../events_chart_panel';
import { HistoricalDataCharts } from '../historical_data_charts';
import { SloTabId } from '../slo_details';
export interface Props {
slo: SLOWithSummaryResponse;
isAutoRefreshing: boolean;
@ -29,10 +31,8 @@ export interface Props {
export function SLODetailsHistory({ slo, isAutoRefreshing, selectedTabId }: Props) {
const { uiSettings } = useKibana().services;
const { burnRateOptions } = useBurnRateOptions(slo);
const [start, setStart] = useState('now-30d');
const [start, setStart] = useState(`now-${slo.timeWindow.duration}`);
const [end, setEnd] = useState('now');
const onTimeChange = (val: OnTimeChangeProps) => {
@ -42,19 +42,17 @@ export function SLODetailsHistory({ slo, isAutoRefreshing, selectedTabId }: Prop
const onRefresh = (val: OnRefreshProps) => {};
const absRange = useMemo(() => {
const range = useMemo(() => {
return {
from: new Date(DateMath.parse(start)!.valueOf()),
to: new Date(DateMath.parse(end, { roundUp: true })!.valueOf()),
absoluteFrom: DateMath.parse(start)!.valueOf(),
absoluteTo: DateMath.parse(end, { roundUp: true })!.valueOf(),
};
}, [start, end]);
const onBrushed = useCallback(({ fromUtc, toUtc }) => {
setStart(fromUtc);
setEnd(toUtc);
}, []);
const onBrushed = ({ from, to }: TimeBounds) => {
setStart(from.toISOString());
setEnd(to.toISOString());
};
return (
<>
@ -94,10 +92,7 @@ export function SLODetailsHistory({ slo, isAutoRefreshing, selectedTabId }: Prop
isAutoRefreshing={isAutoRefreshing}
burnRateOptions={burnRateOptions}
selectedTabId={selectedTabId}
range={{
from: absRange.from,
to: absRange.to,
}}
range={range}
onBrushed={onBrushed}
/>
</EuiFlexItem>
@ -105,19 +100,13 @@ export function SLODetailsHistory({ slo, isAutoRefreshing, selectedTabId }: Prop
slo={slo}
selectedTabId={selectedTabId}
isAutoRefreshing={isAutoRefreshing}
range={{
from: start,
to: end,
}}
range={range}
onBrushed={onBrushed}
/>
<EuiFlexItem>
<EventsChartPanel
slo={slo}
range={{
start: absRange.absoluteFrom,
end: absRange.absoluteTo,
}}
range={range}
selectedTabId={selectedTabId}
onBrushed={onBrushed}
/>

View file

@ -7,12 +7,13 @@
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React, { useEffect, useState } from 'react';
import { HistoricalDataCharts } from './historical_data_charts';
import { useBurnRateOptions } from '../hooks/use_burn_rate_options';
import { SLODetailsHistory } from './history/slo_details_history';
import { BurnRates } from '../../../components/slo/burn_rate/burn_rates';
import { useBurnRateOptions } from '../hooks/use_burn_rate_options';
import { EventsChartPanel } from './events_chart_panel';
import { HistoricalDataCharts } from './historical_data_charts';
import { SLODetailsHistory } from './history/slo_details_history';
import { Overview } from './overview/overview';
import { SloDetailsAlerts } from './slo_detail_alerts';
import { SloHealthCallout } from './slo_health_callout';
@ -22,7 +23,6 @@ export const TAB_ID_URL_PARAM = 'tabId';
export const OVERVIEW_TAB_ID = 'overview';
export const HISTORY_TAB_ID = 'history';
export const ALERTS_TAB_ID = 'alerts';
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
export type SloTabId = typeof OVERVIEW_TAB_ID | typeof ALERTS_TAB_ID | typeof HISTORY_TAB_ID;
@ -34,16 +34,16 @@ export interface Props {
export function SloDetails({ slo, isAutoRefreshing, selectedTabId }: Props) {
const { burnRateOptions } = useBurnRateOptions(slo);
const [range, setRange] = useState({
start: new Date().getTime() - DAY_IN_MILLISECONDS,
end: new Date().getTime(),
const [range, setRange] = useState<{ from: Date; to: Date }>({
from: moment().subtract(1, 'day').toDate(),
to: new Date(),
});
useEffect(() => {
let intervalId: any;
if (isAutoRefreshing) {
intervalId = setInterval(() => {
setRange({ start: new Date().getTime() - DAY_IN_MILLISECONDS, end: new Date().getTime() });
setRange({ from: moment().subtract(1, 'day').toDate(), to: new Date() });
}, 60 * 1000);
}

View file

@ -24,7 +24,7 @@ import moment from 'moment';
import React, { useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { getBrushData } from '../../../utils/slo/duration';
import { getBrushTimeBounds } from '../../../utils/slo/duration';
import { TimeBounds } from '../types';
import { useKibana } from '../../../utils/kibana_react';
import { ChartData } from '../../../typings';
@ -84,7 +84,7 @@ export function WideChart({ chart, data, id, isLoading, state, onBrushed }: Prop
pointerUpdateTrigger={'x'}
locale={i18n.getLocale()}
onBrushEnd={(brushArea) => {
onBrushed?.(getBrushData(brushArea));
onBrushed?.(getBrushTimeBounds(brushArea));
}}
/>
<Axis

View file

@ -11,8 +11,6 @@ export interface SloDetailsPathParams {
}
export interface TimeBounds {
from: number;
to: number;
fromUtc: string;
toUtc: string;
from: Date;
to: Date;
}

View file

@ -37,8 +37,8 @@ import { max, min } from 'lodash';
import moment from 'moment';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GoodBadEventsChart } from '../../../slos/components/common/good_bad_events_chart';
import { useKibana } from '../../../../utils/kibana_react';
import { GoodBadEventsChart } from '../../../slos/components/common/good_bad_events_chart';
import { useDebouncedGetPreviewData } from '../../hooks/use_preview';
import { useSectionFormValidation } from '../../hooks/use_section_form_validation';
import { CreateSLOForm } from '../../types';
@ -53,13 +53,11 @@ interface DataPreviewChartProps {
useGoodBadEventsChart?: boolean;
label?: string;
range?: {
start: number;
end: number;
from: Date;
to: Date;
};
}
const ONE_HOUR_IN_MILLISECONDS = 1 * 60 * 60 * 1000;
export function DataPreviewChart({
formatPattern,
threshold,
@ -81,8 +79,8 @@ export function DataPreviewChart({
});
const [defaultRange, _] = useState({
start: new Date().getTime() - ONE_HOUR_IN_MILLISECONDS,
end: new Date().getTime(),
from: moment().subtract(1, 'hour').toDate(),
to: new Date(),
});
const indicator = watch('indicator');
@ -92,7 +90,7 @@ export function DataPreviewChart({
isLoading: isPreviewLoading,
isSuccess,
isError,
} = useDebouncedGetPreviewData(isIndicatorSectionValid, indicator, range || defaultRange);
} = useDebouncedGetPreviewData(isIndicatorSectionValid, indicator, range ?? defaultRange);
const isMoreThan100 =
!ignoreMoreThan100 && previewData?.find((row) => row.sliValue && row.sliValue > 1) != null;

View file

@ -4,25 +4,24 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui';
import { FilterStateStore } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import {
ALL_VALUE,
SyntheticsAvailabilityIndicator,
QuerySchema,
FiltersSchema,
QuerySchema,
SyntheticsAvailabilityIndicator,
} from '@kbn/slo-schema';
import { FilterStateStore } from '@kbn/es-query';
import moment from 'moment';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { FieldSelector } from '../synthetics_common/field_selector';
import { formatAllFilters } from '../../helpers/format_filters';
import { CreateSLOForm } from '../../types';
import { DataPreviewChart } from '../common/data_preview_chart';
import { QueryBuilder } from '../common/query_builder';
import { GroupByCardinality } from '../common/group_by_cardinality';
import { formatAllFilters } from '../../helpers/format_filters';
const ONE_DAY_IN_MILLISECONDS = 1 * 60 * 60 * 1000 * 24;
import { QueryBuilder } from '../common/query_builder';
import { FieldSelector } from '../synthetics_common/field_selector';
export function SyntheticsAvailabilityIndicatorTypeForm() {
const { watch } = useFormContext<CreateSLOForm<SyntheticsAvailabilityIndicator>>();
@ -36,8 +35,8 @@ export function SyntheticsAvailabilityIndicatorTypeForm() {
]);
const [range, _] = useState({
start: new Date().getTime() - ONE_DAY_IN_MILLISECONDS,
end: new Date().getTime(),
from: moment().subtract(1, 'day').toDate(),
to: new Date(),
});
const filters = {

View file

@ -13,7 +13,7 @@ import { useGetPreviewData } from '../../../hooks/use_get_preview_data';
export function useDebouncedGetPreviewData(
isIndicatorValid: boolean,
indicator: Indicator,
range: { start: number; end: number }
range: { from: Date; to: Date }
) {
const serializedIndicator = JSON.stringify(indicator);
const [indicatorState, setIndicatorState] = useState<string>(serializedIndicator);

View file

@ -24,7 +24,7 @@ import { GetPreviewDataResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'
import moment from 'moment';
import React, { useRef } from 'react';
import { TimeBounds } from '../../../slo_details/types';
import { getBrushData } from '../../../../utils/slo/duration';
import { getBrushTimeBounds } from '../../../../utils/slo/duration';
import { useKibana } from '../../../../utils/kibana_react';
import { openInDiscover } from '../../../../utils/slo/get_discover_link';
@ -120,7 +120,7 @@ export function GoodBadEventsChart({
locale={i18n.getLocale()}
onElementClick={barClickHandler as ElementClickListener}
onBrushEnd={(brushArea) => {
onBrushed?.(getBrushData(brushArea));
onBrushed?.(getBrushTimeBounds(brushArea));
}}
/>
{annotation}

View file

@ -9,6 +9,7 @@ import moment from 'moment';
import { assertNever } from '@kbn/std';
import { BrushEvent } from '@elastic/charts';
import { Duration, DurationUnit } from '../../typings';
import { TimeBounds } from '../../pages/slo_details/types';
export function toDuration(duration: string): Duration {
const durationValue = duration.substring(0, duration.length - 1);
@ -44,9 +45,9 @@ export function toCalendarAlignedMomentUnitOfTime(unit: string): moment.unitOfTi
}
}
export function getBrushData(e: BrushEvent) {
const [from, to] = [Number(e.x?.[0]), Number(e.x?.[1])];
const [fromUtc, toUtc] = [moment(from).format(), moment(to).format()];
export function getBrushTimeBounds(e: BrushEvent): TimeBounds {
const from = moment(Number(e.x?.[0])).toDate();
const to = moment(Number(e.x?.[1])).toDate();
return { from, to, fromUtc, toUtc };
return { from, to };
}

View file

@ -12,12 +12,13 @@ import {
deleteSLOInstancesParamsSchema,
deleteSLOParamsSchema,
fetchHistoricalSummaryParamsSchema,
fetchHistoricalSummaryResponseSchema,
fetchSLOHealthParamsSchema,
findSloDefinitionsParamsSchema,
findSLOGroupsParamsSchema,
findSLOParamsSchema,
getPreviewDataParamsSchema,
getSLOBurnRatesParamsSchema,
fetchSLOHealthParamsSchema,
getSLOInstancesParamsSchema,
getSLOParamsSchema,
manageSLOParamsSchema,
@ -44,7 +45,6 @@ import {
KibanaSavedObjectsSLORepository,
UpdateSLO,
} from '../../services';
import { FetchHistoricalSummary } from '../../services/fetch_historical_summary';
import { FindSLODefinitions } from '../../services/find_slo_definitions';
import { getBurnRates } from '../../services/get_burn_rates';
import { getGlobalDiagnosis } from '../../services/get_diagnosis';
@ -513,9 +513,10 @@ const fetchHistoricalSummary = createSloServerRoute({
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const historicalSummaryClient = new DefaultHistoricalSummaryClient(esClient);
const fetchSummaryData = new FetchHistoricalSummary(historicalSummaryClient);
return await fetchSummaryData.execute(params.body);
const historicalSummary = await historicalSummaryClient.fetch(params.body);
return fetchHistoricalSummaryResponseSchema.encode(historicalSummary);
},
});

View file

@ -19,7 +19,6 @@ import {
occurrencesBudgetingMethodSchema,
timeslicesBudgetingMethodSchema,
} from '@kbn/slo-schema';
import { getEsDateRange } from './historical_summary_client';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../common/constants';
import { DateRange, Duration, SLODefinition } from '../domain/models';
import { computeBurnRate, computeSLI } from '../domain/services';
@ -99,7 +98,7 @@ function commonQuery(
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': getEsDateRange(dateRange),
'@timestamp': { gte: dateRange.from.toISOString(), lt: dateRange.to.toISOString() },
},
},
];

View file

@ -1,25 +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 {
FetchHistoricalSummaryParams,
FetchHistoricalSummaryResponse,
fetchHistoricalSummaryResponseSchema,
} from '@kbn/slo-schema';
import { HistoricalSummaryClient } from './historical_summary_client';
export class FetchHistoricalSummary {
constructor(private historicalSummaryClient: HistoricalSummaryClient) {}
public async execute(
params: FetchHistoricalSummaryParams
): Promise<FetchHistoricalSummaryResponse> {
const historicalSummary = await this.historicalSummaryClient.fetch(params);
return fetchHistoricalSummaryResponseSchema.encode(historicalSummary);
}
}

View file

@ -592,20 +592,19 @@ export class GetPreviewData {
// Timeslice metric so that the chart is as close to the evaluation as possible.
// Otherwise due to how the statistics work, the values might not look like
// they've breached the threshold.
const rangeDuration = moment(params.range.to).diff(params.range.from, 'ms');
const bucketSize =
params.indicator.type === 'sli.metric.timeslice' &&
params.range.end - params.range.start <= 86_400_000 &&
rangeDuration <= 86_400_000 &&
params.objective?.timesliceWindow
? params.objective.timesliceWindow.asMinutes()
: Math.max(
calculateAuto
.near(100, moment.duration(params.range.end - params.range.start, 'ms'))
?.asMinutes() ?? 0,
calculateAuto.near(100, moment.duration(rangeDuration, 'ms'))?.asMinutes() ?? 0,
1
);
const options: Options = {
instanceId: params.instanceId,
range: params.range,
range: { start: params.range.from.getTime(), end: params.range.to.getTime() },
groupBy: params.groupBy,
remoteName: params.remoteName,
groupings: params.groupings,

View file

@ -8,7 +8,8 @@
import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { ALL_VALUE } from '@kbn/slo-schema';
import moment from 'moment';
import { oneMinute, oneMonth, thirtyDays } from './fixtures/duration';
import { DateRange, SLODefinition } from '../domain/models';
import { oneMinute, oneMonth, sevenDays, thirtyDays } from './fixtures/duration';
import { createSLO } from './fixtures/slo';
import {
DefaultHistoricalSummaryClient,
@ -29,16 +30,33 @@ const commonEsResponse = {
},
};
const generateEsResponseForRollingSLO = (
rollingDays: number = 30,
good: number = 97,
total: number = 100
) => {
const { fixedInterval, bucketsPerDay } = getFixedIntervalAndBucketsPerDay(rollingDays);
const numberOfBuckets = rollingDays * bucketsPerDay;
const doubleDuration = rollingDays * 2;
const startDay = moment.utc().subtract(doubleDuration, 'day').startOf('day');
const bucketSize = fixedInterval === '1d' ? 24 : Number(fixedInterval.slice(0, -1));
const MINUTES_IN_DAY = 1440;
const generateEsResponseForRollingSLO = (slo: SLODefinition, overridedRange?: DateRange) => {
const rollingDurationInDays = slo.timeWindow.duration.asMinutes() / MINUTES_IN_DAY;
const timesliceInMin = slo.objective.timesliceWindow?.asMinutes();
const overridedRangeInDays = overridedRange
? moment(overridedRange.to).diff(moment(overridedRange.from), 'days')
: 0;
const { fixedInterval, bucketsPerDay } = getFixedIntervalAndBucketsPerDay(
overridedRangeInDays ? overridedRangeInDays : rollingDurationInDays
);
const fullDuration = overridedRange
? rollingDurationInDays + overridedRangeInDays
: rollingDurationInDays * 2;
const numberOfBuckets = fullDuration * bucketsPerDay;
const startDay = moment().subtract(fullDuration, 'day').startOf('day');
const bucketSizeInHour = moment
.duration(
fixedInterval.slice(0, -1),
fixedInterval.slice(-1) as moment.unitOfTime.DurationConstructor
)
.asHours();
const good = timesliceInMin ? Math.floor(((bucketSizeInHour * 60) / timesliceInMin) * 0.97) : 97;
const total = timesliceInMin ? Math.floor((bucketSizeInHour * 60) / timesliceInMin) : 100;
return {
...commonEsResponse,
responses: [
@ -51,11 +69,11 @@ const generateEsResponseForRollingSLO = (
.map((_, index) => ({
key_as_string: startDay
.clone()
.add(index * bucketSize, 'hours')
.add(index * bucketSizeInHour, 'hours')
.toISOString(),
key: startDay
.clone()
.add(index * bucketSize, 'hours')
.add(index * bucketSizeInHour, 'hours')
.format('x'),
doc_count: 1440,
total: {
@ -65,10 +83,16 @@ const generateEsResponseForRollingSLO = (
value: good,
},
cumulative_good: {
value: good * (index + 1),
value:
index < rollingDurationInDays * bucketsPerDay
? good * (index + 1)
: good * rollingDurationInDays * bucketsPerDay,
},
cumulative_total: {
value: total * (index + 1),
value:
index < rollingDurationInDays * bucketsPerDay
? total * (index + 1)
: total * rollingDurationInDays * bucketsPerDay,
},
})),
},
@ -137,13 +161,13 @@ describe('FetchHistoricalSummary', () => {
});
describe('Rolling and Occurrences SLOs', () => {
it('returns the summary', async () => {
it('returns the summary using the SLO timeWindow date range', async () => {
const slo = createSLO({
timeWindow: { type: 'rolling', duration: thirtyDays() },
objective: { target: 0.95 },
groupBy: ALL_VALUE,
});
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(30));
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(slo));
const client = new DefaultHistoricalSummaryClient(esClientMock);
const results = await client.fetch({
@ -163,20 +187,52 @@ describe('FetchHistoricalSummary', () => {
results[0].data.forEach((dailyResult) =>
expect(dailyResult).toMatchSnapshot({ date: expect.any(Date) })
);
});
expect(results[0].data).toHaveLength(180);
it('returns the summary using the provided date range', async () => {
const slo = createSLO({
timeWindow: { type: 'rolling', duration: sevenDays() },
objective: { target: 0.9 },
groupBy: ALL_VALUE,
});
const range: DateRange = {
from: new Date('2023-01-09T15:00:00.000Z'),
to: new Date('2023-01-13T15:00:00.000Z'),
};
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(slo, range));
const client = new DefaultHistoricalSummaryClient(esClientMock);
const results = await client.fetch({
list: [
{
timeWindow: slo.timeWindow,
groupBy: slo.groupBy,
budgetingMethod: slo.budgetingMethod,
objective: slo.objective,
revision: slo.revision,
sloId: slo.id,
instanceId: ALL_VALUE,
range,
},
],
});
results[0].data.forEach((dailyResult) =>
expect(dailyResult).toMatchSnapshot({ date: expect.any(Date) })
);
});
});
describe('Rolling and Timeslices SLOs', () => {
it('returns the summary', async () => {
it('returns the summary using the SLO timeWindow date range', async () => {
const slo = createSLO({
timeWindow: { type: 'rolling', duration: thirtyDays() },
budgetingMethod: 'timeslices',
objective: { target: 0.95, timesliceTarget: 0.9, timesliceWindow: oneMinute() },
groupBy: ALL_VALUE,
});
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(30));
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(slo));
const client = new DefaultHistoricalSummaryClient(esClientMock);
const results = await client.fetch({
@ -198,6 +254,40 @@ describe('FetchHistoricalSummary', () => {
);
expect(results[0].data).toHaveLength(180);
});
it('returns the summary using the provided date range', async () => {
const slo = createSLO({
timeWindow: { type: 'rolling', duration: thirtyDays() },
budgetingMethod: 'timeslices',
objective: { target: 0.95, timesliceTarget: 0.9, timesliceWindow: oneMinute() },
groupBy: ALL_VALUE,
});
const range: DateRange = {
from: new Date('2023-01-09T15:00:00.000Z'),
to: new Date('2023-01-13T15:00:00.000Z'),
};
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(slo, range));
const client = new DefaultHistoricalSummaryClient(esClientMock);
const results = await client.fetch({
list: [
{
timeWindow: slo.timeWindow,
groupBy: slo.groupBy,
budgetingMethod: slo.budgetingMethod,
objective: slo.objective,
revision: slo.revision,
sloId: slo.id,
instanceId: ALL_VALUE,
range,
},
],
});
results[0].data.forEach((dailyResult) =>
expect(dailyResult).toMatchSnapshot({ date: expect.any(Date) })
);
});
});
describe('Calendar Aligned and Timeslices SLOs', () => {
@ -275,7 +365,7 @@ describe('FetchHistoricalSummary', () => {
objective: { target: 0.95 },
groupBy: 'host',
});
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(30));
esClientMock.msearch.mockResolvedValueOnce(generateEsResponseForRollingSLO(slo));
const client = new DefaultHistoricalSummaryClient(esClientMock);
const results = await client.fetch({

View file

@ -63,13 +63,12 @@ export class DefaultHistoricalSummaryClient implements HistoricalSummaryClient {
constructor(private esClient: ElasticsearchClient) {}
async fetch(params: FetchHistoricalSummaryParams): Promise<HistoricalSummaryResponse> {
const dateRangeBySlo = params.list.reduce<Record<SLOId, DateRange>>(
(acc, { sloId, timeWindow, range }) => {
acc[sloId] = range ?? getDateRange(timeWindow);
return acc;
},
{}
);
const dateRangeBySlo = params.list.reduce<
Record<SLOId, { range: DateRange; queryRange: DateRange }>
>((acc, { sloId, timeWindow, range }) => {
acc[sloId] = getDateRange(timeWindow, range);
return acc;
}, {});
const searches = params.list.flatMap(
({ sloId, revision, budgetingMethod, instanceId, groupBy, timeWindow, remoteName }) => [
@ -113,7 +112,12 @@ export class DefaultHistoricalSummaryClient implements HistoricalSummaryClient {
historicalSummary.push({
sloId,
instanceId,
data: handleResultForRollingAndTimeslices(objective, timeWindow, buckets),
data: handleResultForRollingAndTimeslices(
objective,
timeWindow,
buckets,
dateRangeBySlo[sloId]
),
});
continue;
@ -123,7 +127,7 @@ export class DefaultHistoricalSummaryClient implements HistoricalSummaryClient {
historicalSummary.push({
sloId,
instanceId,
data: handleResultForRollingAndOccurrences(objective, timeWindow, buckets),
data: handleResultForRollingAndOccurrences(objective, buckets, dateRangeBySlo[sloId]),
});
continue;
}
@ -187,10 +191,10 @@ function handleResultForCalendarAlignedAndOccurrences(
function handleResultForCalendarAlignedAndTimeslices(
objective: Objective,
buckets: DailyAggBucket[],
dateRange: DateRange
dateRange: { range: DateRange; queryRange: DateRange }
): HistoricalSummary[] {
const initialErrorBudget = 1 - objective.target;
const totalSlices = computeTotalSlicesFromDateRange(dateRange, objective.timesliceWindow!);
const totalSlices = computeTotalSlicesFromDateRange(dateRange.range, objective.timesliceWindow!);
return buckets.map((bucket: DailyAggBucket): HistoricalSummary => {
const good = bucket.cumulative_good?.value ?? 0;
@ -210,18 +214,17 @@ function handleResultForCalendarAlignedAndTimeslices(
function handleResultForRollingAndOccurrences(
objective: Objective,
timeWindow: TimeWindow,
buckets: DailyAggBucket[]
buckets: DailyAggBucket[],
dateRange: { range: DateRange; queryRange: DateRange }
): HistoricalSummary[] {
const initialErrorBudget = 1 - objective.target;
const rollingWindowDurationInDays = moment
.duration(timeWindow.duration.value, toMomentUnitOfTime(timeWindow.duration.unit))
.asDays();
const { bucketsPerDay } = getFixedIntervalAndBucketsPerDay(rollingWindowDurationInDays);
return buckets
.slice(-bucketsPerDay * rollingWindowDurationInDays)
.filter(
(bucket) =>
moment(bucket.key_as_string).isSameOrAfter(dateRange.range.from) &&
moment(bucket.key_as_string).isSameOrBefore(dateRange.range.to)
)
.map((bucket: DailyAggBucket): HistoricalSummary => {
const good = bucket.cumulative_good?.value ?? 0;
const total = bucket.cumulative_total?.value ?? 0;
@ -242,20 +245,21 @@ function handleResultForRollingAndOccurrences(
function handleResultForRollingAndTimeslices(
objective: Objective,
timeWindow: TimeWindow,
buckets: DailyAggBucket[]
buckets: DailyAggBucket[],
dateRange: { range: DateRange; queryRange: DateRange }
): HistoricalSummary[] {
const initialErrorBudget = 1 - objective.target;
const rollingWindowDurationInDays = moment
.duration(timeWindow.duration.value, toMomentUnitOfTime(timeWindow.duration.unit))
.asDays();
const { bucketsPerDay } = getFixedIntervalAndBucketsPerDay(rollingWindowDurationInDays);
const totalSlices = Math.ceil(
timeWindow.duration.asSeconds() / objective.timesliceWindow!.asSeconds()
);
return buckets
.slice(-bucketsPerDay * rollingWindowDurationInDays)
.filter(
(bucket) =>
moment(bucket.key_as_string).isSameOrAfter(dateRange.range.from) &&
moment(bucket.key_as_string).isSameOrBefore(dateRange.range.to)
)
.map((bucket: DailyAggBucket): HistoricalSummary => {
const good = bucket.cumulative_good?.value ?? 0;
const total = bucket.cumulative_total?.value ?? 0;
@ -272,13 +276,6 @@ function handleResultForRollingAndTimeslices(
});
}
export const getEsDateRange = (dateRange: DateRange) => {
return {
gte: typeof dateRange.from === 'string' ? dateRange.from : dateRange.from.toISOString(),
lte: typeof dateRange.to === 'string' ? dateRange.to : dateRange.to.toISOString(),
};
};
function generateSearchQuery({
sloId,
groupBy,
@ -292,15 +289,19 @@ function generateSearchQuery({
sloId: string;
groupBy: GroupBy;
revision: number;
dateRange: DateRange;
dateRange: { range: DateRange; queryRange: DateRange };
timeWindow: TimeWindow;
budgetingMethod: BudgetingMethod;
}): MsearchMultisearchBody {
const unit = toMomentUnitOfTime(timeWindow.duration.unit);
const timeWindowDurationInDays = moment.duration(timeWindow.duration.value, unit).asDays();
const queryRangeDurationInDays = Math.ceil(
moment(dateRange.range.to).diff(dateRange.range.from, 'days')
);
const { fixedInterval, bucketsPerDay } =
getFixedIntervalAndBucketsPerDay(timeWindowDurationInDays);
getFixedIntervalAndBucketsPerDay(queryRangeDurationInDays);
const extraFilterByInstanceId =
!!groupBy && ![groupBy].flat().includes(ALL_VALUE) && instanceId !== ALL_VALUE
@ -316,7 +317,10 @@ function generateSearchQuery({
{ term: { 'slo.revision': revision } },
{
range: {
'@timestamp': getEsDateRange(dateRange),
'@timestamp': {
gte: dateRange.queryRange.from.toISOString(),
lte: dateRange.queryRange.to.toISOString(),
},
},
},
...extraFilterByInstanceId,
@ -329,8 +333,8 @@ function generateSearchQuery({
field: '@timestamp',
fixed_interval: fixedInterval,
extended_bounds: {
min: typeof dateRange.from === 'string' ? dateRange.from : dateRange.from.toISOString(),
max: 'now/d',
min: dateRange.queryRange.from.toISOString(),
max: dateRange.queryRange.to.toISOString(),
},
},
aggs: {
@ -382,26 +386,62 @@ function generateSearchQuery({
};
}
function getDateRange(timeWindow: TimeWindow) {
/**
* queryRange is used for the filter range on the query,
* while range is used for storing the actual range requested
* For a rolling window, the query range starts 1 timeWindow duration before the actual range from.
* For calednar window, the query range is the same as the range.
*
* @param timeWindow
* @param range
* @returns the request {range} and the query range {queryRange}
*
*/
function getDateRange(
timeWindow: TimeWindow,
range?: DateRange
): { range: DateRange; queryRange: DateRange } {
if (rollingTimeWindowSchema.is(timeWindow)) {
const unit = toMomentUnitOfTime(timeWindow.duration.unit as DurationUnit);
if (range) {
return {
range,
queryRange: {
from: moment(range.from)
.subtract(timeWindow.duration.value, unit)
.startOf('day')
.toDate(),
to: moment(range.to).startOf('minute').toDate(),
},
};
}
const now = moment();
return {
from: now
.clone()
.subtract(timeWindow.duration.value * 2, unit)
.startOf('day')
.toDate(),
to: now.startOf('minute').toDate(),
range: {
from: now.clone().subtract(timeWindow.duration.value, unit).startOf('day').toDate(),
to: now.clone().startOf('minute').toDate(),
},
queryRange: {
from: now
.clone()
.subtract(timeWindow.duration.value * 2, unit)
.startOf('day')
.toDate(),
to: now.clone().startOf('minute').toDate(),
},
};
}
if (calendarAlignedTimeWindowSchema.is(timeWindow)) {
const now = moment();
const unit = toCalendarAlignedTimeWindowMomentUnit(timeWindow);
const from = moment.utc(now).startOf(unit);
const to = moment.utc(now).endOf(unit);
return { from: from.toDate(), to: to.toDate() };
const calendarRange = { from: from.toDate(), to: to.toDate() };
return { range: calendarRange, queryRange: calendarRange };
}
assertNever(timeWindow);
@ -411,6 +451,9 @@ export function getFixedIntervalAndBucketsPerDay(durationInDays: number): {
fixedInterval: string;
bucketsPerDay: number;
} {
if (durationInDays <= 3) {
return { fixedInterval: '30m', bucketsPerDay: 48 };
}
if (durationInDays <= 7) {
return { fixedInterval: '1h', bucketsPerDay: 24 };
}

View file

@ -8,7 +8,6 @@
export * from './create_slo';
export * from './delete_slo';
export * from './delete_slo_instances';
export * from './fetch_historical_summary';
export * from './find_slo';
export * from './get_slo';
export * from './historical_summary_client';

View file

@ -16,7 +16,6 @@ import {
occurrencesBudgetingMethodSchema,
timeslicesBudgetingMethodSchema,
} from '@kbn/slo-schema';
import { getEsDateRange } from './historical_summary_client';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../common/constants';
import { Groupings, Meta, SLODefinition, Summary } from '../domain/models';
import { computeSLI, computeSummaryStatus, toErrorBudget } from '../domain/services';
@ -76,7 +75,10 @@ export class DefaultSummaryClient implements SummaryClient {
{ term: { 'slo.revision': slo.revision } },
{
range: {
'@timestamp': getEsDateRange(dateRange),
'@timestamp': {
gte: dateRange.from.toISOString(),
lte: dateRange.to.toISOString(),
},
},
},
...instanceIdFilter,