[8.14] feat(slo): Consider empty slice as good slice for sli calculation purposes on timeslice budgeting method (#181888) (#182259)

# Backport

This will backport the following commits from `main` to `8.14`:
- [feat(slo): Consider empty slice as good slice for sli calculation
purposes on timeslice budgeting method
(#181888)](https://github.com/elastic/kibana/pull/181888)

<!--- Backport version: 8.9.8 -->

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

<!--BACKPORT [{"author":{"name":"Kevin
Delemme","email":"kevin.delemme@elastic.co"},"sourceCommit":{"committedDate":"2024-04-30T16:51:36Z","message":"feat(slo):
Consider empty slice as good slice for sli calculation purposes on
timeslice budgeting method
(#181888)","sha":"2d63f721a9ef2a5da6efaa54b098b6a109d1f103","branchLabelMapping":{"^v8.15.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["backport:skip","release_note:feature","ci:project-deploy-observability","Team:obs-ux-management","v8.15.0"],"number":181888,"url":"https://github.com/elastic/kibana/pull/181888","mergeCommit":{"message":"feat(slo):
Consider empty slice as good slice for sli calculation purposes on
timeslice budgeting method
(#181888)","sha":"2d63f721a9ef2a5da6efaa54b098b6a109d1f103"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.15.0","labelRegex":"^v8.15.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/181888","number":181888,"mergeCommit":{"message":"feat(slo):
Consider empty slice as good slice for sli calculation purposes on
timeslice budgeting method
(#181888)","sha":"2d63f721a9ef2a5da6efaa54b098b6a109d1f103"}}]}]
BACKPORT-->
This commit is contained in:
Kevin Delemme 2024-05-02 10:32:06 -04:00 committed by GitHub
parent 1cccb2984a
commit d137b4d7c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1170 additions and 1135 deletions

View file

@ -6,7 +6,7 @@
*/
import * as t from 'io-ts';
import { allOrAnyString, dateRangeSchema } from './common';
import { allOrAnyString } from './common';
const kqlQuerySchema = t.string;
@ -271,12 +271,6 @@ const syntheticsAvailabilityIndicatorSchema = t.type({
]),
});
const indicatorDataSchema = t.type({
dateRange: dateRangeSchema,
good: t.number,
total: t.number,
});
const indicatorTypesSchema = t.union([
apmTransactionDurationIndicatorTypeSchema,
apmTransactionErrorRateIndicatorTypeSchema,
@ -344,5 +338,4 @@ export {
indicatorSchema,
indicatorTypesArraySchema,
indicatorTypesSchema,
indicatorDataSchema,
};

View file

@ -5,16 +5,15 @@
* 2.0.
*/
import * as t from 'io-ts';
import {
apmTransactionDurationIndicatorSchema,
apmTransactionErrorRateIndicatorSchema,
indicatorDataSchema,
indicatorSchema,
indicatorTypesSchema,
kqlCustomIndicatorSchema,
metricCustomIndicatorSchema,
} from '@kbn/slo-schema';
import * as t from 'io-ts';
type APMTransactionErrorRateIndicator = t.TypeOf<typeof apmTransactionErrorRateIndicatorSchema>;
type APMTransactionDurationIndicator = t.TypeOf<typeof apmTransactionDurationIndicatorSchema>;
@ -22,7 +21,6 @@ type KQLCustomIndicator = t.TypeOf<typeof kqlCustomIndicatorSchema>;
type MetricCustomIndicator = t.TypeOf<typeof metricCustomIndicatorSchema>;
type Indicator = t.TypeOf<typeof indicatorSchema>;
type IndicatorTypes = t.TypeOf<typeof indicatorTypesSchema>;
type IndicatorData = t.TypeOf<typeof indicatorDataSchema>;
export type {
Indicator,
@ -31,5 +29,4 @@ export type {
APMTransactionDurationIndicator,
KQLCustomIndicator,
MetricCustomIndicator,
IndicatorData,
};

View file

@ -5,59 +5,32 @@
* 2.0.
*/
import { computeBurnRate } from './compute_burn_rate';
import { toDateRange } from './date_range';
import { createSLO } from '../../services/fixtures/slo';
import { ninetyDaysRolling } from '../../services/fixtures/time_window';
import { computeBurnRate } from './compute_burn_rate';
describe('computeBurnRate', () => {
it('computes 0 when total is 0', () => {
expect(
computeBurnRate(createSLO(), {
good: 10,
total: 0,
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(0);
it('computes 0 when sliValue is 1', () => {
const sliValue = 1;
expect(computeBurnRate(createSLO(), sliValue)).toEqual(0);
});
it('computes 0 when good is greater than total', () => {
expect(
computeBurnRate(createSLO(), {
good: 9999,
total: 1,
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(0);
it('computes 0 when sliValue is greater than 1', () => {
const sliValue = 1.21;
expect(computeBurnRate(createSLO(), sliValue)).toEqual(0);
});
it('computes the burn rate as 1x the error budget', () => {
expect(
computeBurnRate(createSLO({ objective: { target: 0.9 } }), {
good: 90,
total: 100,
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(1);
const sliValue = 0.9;
expect(computeBurnRate(createSLO({ objective: { target: 0.9 } }), sliValue)).toEqual(1);
});
it('computes the burn rate as 10x the error budget', () => {
expect(
computeBurnRate(createSLO({ objective: { target: 0.99 } }), {
good: 90,
total: 100,
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(10);
const sliValue = 0.9;
expect(computeBurnRate(createSLO({ objective: { target: 0.99 } }), sliValue)).toEqual(10);
});
it('computes the burn rate as 0.5x the error budget', () => {
expect(
computeBurnRate(createSLO({ objective: { target: 0.8 } }), {
good: 90,
total: 100,
dateRange: toDateRange(ninetyDaysRolling()),
})
).toEqual(0.5);
const sliValue = 0.9;
expect(computeBurnRate(createSLO({ objective: { target: 0.8 } }), sliValue)).toEqual(0.5);
});
});

View file

@ -6,19 +6,18 @@
*/
import { toHighPrecision } from '../../utils/number';
import { IndicatorData, SLODefinition } from '../models';
import { SLODefinition } from '../models';
/**
* A Burn Rate is computed with the Indicator Data retrieved from a specific lookback period
* A Burn Rate is computed with the sliValue retrieved from a specific lookback period
* It tells how fast we are consumming our error budget during a specific period
*/
export function computeBurnRate(slo: SLODefinition, sliData: IndicatorData): number {
const { good, total } = sliData;
if (total === 0 || good >= total) {
export function computeBurnRate(slo: SLODefinition, sliValue: number): number {
if (sliValue >= 1) {
return 0;
}
const errorBudget = 1 - slo.objective.target;
const errorRate = 1 - good / total;
const errorRate = 1 - sliValue;
return toHighPrecision(errorRate / errorBudget);
}

View file

@ -23,4 +23,8 @@ describe('computeSLI', () => {
it('returns rounds the value to 6 digits', () => {
expect(computeSLI(33, 90)).toEqual(0.366667);
});
it('returns the sli value using totalSlicesInRange when provided', () => {
expect(computeSLI(90, 100, 10_080)).toEqual(0.999008);
});
});

View file

@ -9,7 +9,14 @@ import { toHighPrecision } from '../../utils/number';
const NO_DATA = -1;
export function computeSLI(good: number, total: number): number {
export function computeSLI(good: number, total: number, totalSlicesInRange?: number): number {
// We calculate the sli based on the totalSlices in the dateRange, as
// 1 - error rate observed = 1 - (1 - SLI Observed) = SLI
// a slice without data will be considered as a good slice
if (totalSlicesInRange !== undefined && totalSlicesInRange > 0) {
return toHighPrecision(1 - (total - good) / totalSlicesInRange);
}
if (total === 0) {
return NO_DATA;
}

View file

@ -560,7 +560,8 @@ const getSloBurnRates = createSloServerRoute({
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
const { instanceId, windows, remoteName } = params.body;
const burnRates = await getBurnRates({
return await getBurnRates({
instanceId,
spaceId,
windows,
@ -572,7 +573,6 @@ const getSloBurnRates = createSloServerRoute({
logger,
},
});
return { burnRates };
},
});

View file

@ -6,13 +6,13 @@ Object {
"meta": Object {},
"summary": Object {
"errorBudget": Object {
"consumed": 0,
"consumed": 0.19842,
"initial": 0.05,
"isEstimated": false,
"remaining": 1,
"remaining": 0.80158,
},
"sliValue": -1,
"status": "NO_DATA",
"sliValue": 0.990079,
"status": "HEALTHY",
},
}
`;
@ -23,13 +23,13 @@ Object {
"meta": Object {},
"summary": Object {
"errorBudget": Object {
"consumed": 0,
"consumed": 100,
"initial": 0.001,
"isEstimated": false,
"remaining": 1,
"remaining": -99,
},
"sliValue": -1,
"status": "NO_DATA",
"sliValue": 0.9,
"status": "VIOLATED",
},
}
`;
@ -40,13 +40,13 @@ Object {
"meta": Object {},
"summary": Object {
"errorBudget": Object {
"consumed": 0,
"consumed": 0.19842,
"initial": 0.05,
"isEstimated": false,
"remaining": 1,
"remaining": 0.80158,
},
"sliValue": -1,
"status": "NO_DATA",
"sliValue": 0.990079,
"status": "HEALTHY",
},
}
`;

View file

@ -6,12 +6,11 @@
*/
import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import moment from 'moment';
import { ALL_VALUE } from '@kbn/slo-schema';
import moment from 'moment';
import { Duration, DurationUnit } from '../domain/models';
import { DefaultBurnRatesClient } from './burn_rates_client';
import { createSLO } from './fixtures/slo';
import { DefaultSLIClient } from './sli_client';
const commonEsResponse = {
took: 100,
@ -41,7 +40,7 @@ describe('SummaryClient', () => {
jest.useRealTimers();
});
describe('fetchSLIDataFrom', () => {
describe('burnRatesClient', () => {
const LONG_WINDOW = 'long_window';
const SHORT_WINDOW = 'short_window';
@ -65,10 +64,10 @@ describe('SummaryClient', () => {
to_as_string: '2022-12-31T23:54:00.000Z',
doc_count: 60,
total: {
value: 32169,
value: 5000,
},
good: {
value: 15748,
value: 4500,
},
},
],
@ -83,19 +82,19 @@ describe('SummaryClient', () => {
to_as_string: '2022-12-31T23:54:00.000Z',
doc_count: 5,
total: {
value: 2211,
value: 300,
},
good: {
value: 772,
value: 290,
},
},
],
},
},
});
const summaryClient = new DefaultSLIClient(esClientMock);
const client = new DefaultBurnRatesClient(esClientMock);
const result = await summaryClient.fetchSLIDataFrom(slo, ALL_VALUE, lookbackWindows);
const results = await client.calculate(slo, ALL_VALUE, lookbackWindows);
expect(esClientMock?.search?.mock?.lastCall?.[0]).toMatchObject({
aggs: {
@ -132,8 +131,16 @@ describe('SummaryClient', () => {
},
});
expect(result[LONG_WINDOW]).toMatchObject({ good: 15748, total: 32169 });
expect(result[SHORT_WINDOW]).toMatchObject({ good: 772, total: 2211 });
expect(results.find((result) => result.name === LONG_WINDOW)).toMatchObject({
name: LONG_WINDOW,
sli: 0.9,
burnRate: 100,
});
expect(results.find((result) => result.name === SHORT_WINDOW)).toMatchObject({
name: SHORT_WINDOW,
sli: 0.966667,
burnRate: 33.333,
});
});
});
@ -144,7 +151,7 @@ describe('SummaryClient', () => {
objective: {
target: 0.95,
timesliceTarget: 0.9,
timesliceWindow: new Duration(10, DurationUnit.Minute),
timesliceWindow: new Duration(5, DurationUnit.Minute),
},
});
@ -158,17 +165,17 @@ describe('SummaryClient', () => {
[LONG_WINDOW]: {
buckets: [
{
key: '2022-12-31T22:36:00.000Z-2022-12-31T23:36:00.000Z',
key: '2022-12-31T22:46:00.000Z-2022-12-31T23:46:00.000Z',
from: 1672526160000,
from_as_string: '2022-12-31T22:36:00.000Z',
from_as_string: '2022-12-31T22:46:00.000Z',
to: 1672529760000,
to_as_string: '2022-12-31T23:36:00.000Z',
doc_count: 60,
to_as_string: '2022-12-31T23:46:00.000Z',
doc_count: 12,
total: {
value: 32169,
value: 12,
},
good: {
value: 15748,
value: 10,
},
},
],
@ -176,26 +183,26 @@ describe('SummaryClient', () => {
[SHORT_WINDOW]: {
buckets: [
{
key: '2022-12-31T23:31:00.000Z-2022-12-31T23:36:00.000Z',
key: '2022-12-31T23:41:00.000Z-2022-12-31T23:46:00.000Z',
from: 1672529460000,
from_as_string: '2022-12-31T23:31:00.000Z',
from_as_string: '2022-12-31T23:41:00.000Z',
to: 1672529760000,
to_as_string: '2022-12-31T23:36:00.000Z',
doc_count: 5,
to_as_string: '2022-12-31T23:46:00.000Z',
doc_count: 1,
total: {
value: 2211,
value: 1,
},
good: {
value: 772,
value: 1,
},
},
],
},
},
});
const summaryClient = new DefaultSLIClient(esClientMock);
const client = new DefaultBurnRatesClient(esClientMock);
const result = await summaryClient.fetchSLIDataFrom(slo, ALL_VALUE, lookbackWindows);
const results = await client.calculate(slo, ALL_VALUE, lookbackWindows);
expect(esClientMock?.search?.mock?.lastCall?.[0]).toMatchObject({
aggs: {
@ -204,8 +211,8 @@ describe('SummaryClient', () => {
field: '@timestamp',
ranges: [
{
from: '2022-12-31T22:36:00.000Z',
to: '2022-12-31T23:36:00.000Z',
from: '2022-12-31T22:46:00.000Z',
to: '2022-12-31T23:46:00.000Z',
},
],
},
@ -227,8 +234,8 @@ describe('SummaryClient', () => {
field: '@timestamp',
ranges: [
{
from: '2022-12-31T23:31:00.000Z',
to: '2022-12-31T23:36:00.000Z',
from: '2022-12-31T23:41:00.000Z',
to: '2022-12-31T23:46:00.000Z',
},
],
},
@ -248,8 +255,16 @@ describe('SummaryClient', () => {
},
});
expect(result[LONG_WINDOW]).toMatchObject({ good: 15748, total: 32169 });
expect(result[SHORT_WINDOW]).toMatchObject({ good: 772, total: 2211 });
expect(results.find((result) => result.name === LONG_WINDOW)).toMatchObject({
name: LONG_WINDOW,
sli: 0.833333,
burnRate: 3.33334,
});
expect(results.find((result) => result.name === SHORT_WINDOW)).toMatchObject({
name: SHORT_WINDOW,
sli: 1,
burnRate: 0,
});
});
});
});

View file

@ -19,22 +19,23 @@ import {
occurrencesBudgetingMethodSchema,
timeslicesBudgetingMethodSchema,
} from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../common/constants';
import { DateRange, Duration, IndicatorData, SLODefinition } from '../domain/models';
import { InternalQueryError } from '../errors';
import { DateRange, Duration, SLODefinition } from '../domain/models';
import { computeBurnRate, computeSLI } from '../domain/services';
import { getDelayInSecondsFromSLO } from '../domain/services/get_delay_in_seconds_from_slo';
import { getLookbackDateRange } from '../domain/services/get_lookback_date_range';
export interface SLIClient {
fetchSLIDataFrom(
slo: SLODefinition,
instanceId: string,
lookbackWindows: LookbackWindow[]
): Promise<Record<WindowName, IndicatorData>>;
}
import { InternalQueryError } from '../errors';
import { computeTotalSlicesFromDateRange } from './utils/compute_total_slices_from_date_range';
type WindowName = string;
export interface BurnRatesClient {
calculate(
slo: SLODefinition,
instanceId: string,
lookbackWindows: LookbackWindow[],
remoteName?: string
): Promise<Array<{ burnRate: number; sli: number; name: WindowName }>>;
}
interface LookbackWindow {
name: WindowName;
@ -43,15 +44,15 @@ interface LookbackWindow {
type EsAggregations = Record<WindowName, AggregationsDateRangeAggregate>;
export class DefaultSLIClient implements SLIClient {
export class DefaultBurnRatesClient implements BurnRatesClient {
constructor(private esClient: ElasticsearchClient) {}
async fetchSLIDataFrom(
async calculate(
slo: SLODefinition,
instanceId: string,
lookbackWindows: LookbackWindow[],
remoteName?: string
): Promise<Record<WindowName, IndicatorData>> {
): Promise<Array<{ burnRate: number; sli: number; name: WindowName }>> {
const sortedLookbackWindows = [...lookbackWindows].sort((a, b) =>
a.duration.isShorterThan(b.duration) ? 1 : -1
);
@ -67,35 +68,23 @@ export class DefaultSLIClient implements SLIClient {
? `${remoteName}:${SLO_DESTINATION_INDEX_PATTERN}`
: SLO_DESTINATION_INDEX_PATTERN;
if (occurrencesBudgetingMethodSchema.is(slo.budgetingMethod)) {
const result = await this.esClient.search<unknown, EsAggregations>({
...commonQuery(slo, instanceId, longestDateRange),
index,
aggs: toLookbackWindowsAggregationsQuery(
longestDateRange.to,
sortedLookbackWindows,
delayInSeconds
),
});
const result = await this.esClient.search<unknown, EsAggregations>({
...commonQuery(slo, instanceId, longestDateRange),
index,
aggs: occurrencesBudgetingMethodSchema.is(slo.budgetingMethod)
? toLookbackWindowsAggregationsQuery(
longestDateRange.to,
sortedLookbackWindows,
delayInSeconds
)
: toLookbackWindowsSlicedAggregationsQuery(
longestDateRange.to,
sortedLookbackWindows,
delayInSeconds
),
});
return handleWindowedResult(result.aggregations, lookbackWindows);
}
if (timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)) {
const result = await this.esClient.search<unknown, EsAggregations>({
...commonQuery(slo, instanceId, longestDateRange),
index,
aggs: toLookbackWindowsSlicedAggregationsQuery(
longestDateRange.to,
sortedLookbackWindows,
delayInSeconds
),
});
return handleWindowedResult(result.aggregations, lookbackWindows);
}
assertNever(slo.budgetingMethod);
return handleWindowedResult(result.aggregations, lookbackWindows, slo);
}
}
@ -210,14 +199,14 @@ function toLookbackWindowsSlicedAggregationsQuery(
function handleWindowedResult(
aggregations: Record<WindowName, AggregationsDateRangeAggregate> | undefined,
lookbackWindows: LookbackWindow[]
): Record<WindowName, IndicatorData> {
lookbackWindows: LookbackWindow[],
slo: SLODefinition
): Array<{ burnRate: number; sli: number; name: WindowName }> {
if (aggregations === undefined) {
throw new InternalQueryError('Invalid aggregation response');
}
const indicatorDataPerLookbackWindow: Record<WindowName, IndicatorData> = {};
for (const lookbackWindow of lookbackWindows) {
return lookbackWindows.map((lookbackWindow) => {
const windowAggBuckets = aggregations[lookbackWindow.name]?.buckets ?? [];
if (!Array.isArray(windowAggBuckets) || windowAggBuckets.length === 0) {
throw new InternalQueryError('Invalid aggregation bucket response');
@ -229,12 +218,26 @@ function handleWindowedResult(
throw new InternalQueryError('Invalid aggregation sum bucket response');
}
indicatorDataPerLookbackWindow[lookbackWindow.name] = {
good,
total,
dateRange: { from: new Date(bucket.from_as_string!), to: new Date(bucket.to_as_string!) },
};
}
let sliValue;
if (timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)) {
const dateRange = {
from: new Date(bucket.from_as_string!),
to: new Date(bucket.to_as_string!),
};
const totalSlices = computeTotalSlicesFromDateRange(
dateRange,
slo.objective.timesliceWindow!
);
return indicatorDataPerLookbackWindow;
sliValue = computeSLI(good, total, totalSlices);
} else {
sliValue = computeSLI(good, total);
}
return {
name: lookbackWindow.name,
burnRate: computeBurnRate(slo, sliValue),
sli: sliValue,
};
});
}

View file

@ -8,9 +8,9 @@
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { Logger } from '@kbn/core/server';
import { GetSLOBurnRatesResponse } from '@kbn/slo-schema';
import { Duration } from '../domain/models';
import { computeBurnRate, computeSLI } from '../domain/services';
import { DefaultSLIClient } from './sli_client';
import { DefaultBurnRatesClient } from './burn_rates_client';
import { SloDefinitionClient } from './slo_definition_client';
import { KibanaSavedObjectsSLORepository } from './slo_repository';
@ -25,6 +25,15 @@ interface LookbackWindow {
duration: Duration;
}
interface Params {
sloId: string;
spaceId: string;
instanceId: string;
remoteName?: string;
windows: LookbackWindow[];
services: Services;
}
export async function getBurnRates({
sloId,
spaceId,
@ -32,28 +41,15 @@ export async function getBurnRates({
instanceId,
remoteName,
services,
}: {
sloId: string;
spaceId: string;
instanceId: string;
remoteName?: string;
windows: LookbackWindow[];
services: Services;
}) {
}: Params): Promise<GetSLOBurnRatesResponse> {
const { soClient, esClient, logger } = services;
const repository = new KibanaSavedObjectsSLORepository(soClient, logger);
const sliClient = new DefaultSLIClient(esClient);
const burnRatesClient = new DefaultBurnRatesClient(esClient);
const definitionClient = new SloDefinitionClient(repository, esClient, logger);
const { slo } = await definitionClient.execute(sloId, spaceId, remoteName);
const burnRates = await burnRatesClient.calculate(slo, instanceId, windows, remoteName);
const sliData = await sliClient.fetchSLIDataFrom(slo, instanceId, windows, remoteName);
return Object.keys(sliData).map((key) => {
return {
name: key,
burnRate: computeBurnRate(slo, sliData[key]),
sli: computeSLI(sliData[key].good, sliData[key].total),
};
});
return { burnRates };
}

View file

@ -11,7 +11,6 @@ import {
ALL_VALUE,
BudgetingMethod,
calendarAlignedTimeWindowSchema,
Duration,
DurationUnit,
FetchHistoricalSummaryParams,
fetchHistoricalSummaryResponseSchema,
@ -31,8 +30,10 @@ import {
Objective,
SLOId,
TimeWindow,
toCalendarAlignedTimeWindowMomentUnit,
} from '../domain/models';
import { computeSLI, computeSummaryStatus, toDateRange, toErrorBudget } from '../domain/services';
import { computeSLI, computeSummaryStatus, toErrorBudget } from '../domain/services';
import { computeTotalSlicesFromDateRange } from './utils/compute_total_slices_from_date_range';
interface DailyAggBucket {
key_as_string: string;
@ -108,12 +109,26 @@ export class DefaultHistoricalSummaryClient implements HistoricalSummaryClient {
const buckets = (result.responses[i].aggregations?.daily?.buckets as DailyAggBucket[]) || [];
if (rollingTimeWindowSchema.is(timeWindow)) {
historicalSummary.push({
sloId,
instanceId,
data: handleResultForRolling(objective, timeWindow, buckets),
});
continue;
if (timeslicesBudgetingMethodSchema.is(budgetingMethod)) {
historicalSummary.push({
sloId,
instanceId,
data: handleResultForRollingAndTimeslices(objective, timeWindow, buckets),
});
continue;
}
if (occurrencesBudgetingMethodSchema.is(budgetingMethod)) {
historicalSummary.push({
sloId,
instanceId,
data: handleResultForRollingAndOccurrences(objective, timeWindow, buckets),
});
continue;
}
assertNever(budgetingMethod);
}
if (calendarAlignedTimeWindowSchema.is(timeWindow)) {
@ -175,13 +190,13 @@ function handleResultForCalendarAlignedAndTimeslices(
dateRange: DateRange
): HistoricalSummary[] {
const initialErrorBudget = 1 - objective.target;
const totalSlices = computeTotalSlicesFromDateRange(dateRange, objective.timesliceWindow!);
return buckets.map((bucket: DailyAggBucket): HistoricalSummary => {
const good = bucket.cumulative_good?.value ?? 0;
const total = bucket.cumulative_total?.value ?? 0;
const sliValue = computeSLI(good, total);
const totalSlices = computeTotalSlicesFromDateRange(dateRange, objective.timesliceWindow!);
const consumedErrorBudget = (total - good) / (totalSlices * initialErrorBudget);
const sliValue = computeSLI(good, total, totalSlices);
const consumedErrorBudget = sliValue < 0 ? 0 : (1 - sliValue) / initialErrorBudget;
const errorBudget = toErrorBudget(initialErrorBudget, consumedErrorBudget);
return {
@ -193,7 +208,7 @@ function handleResultForCalendarAlignedAndTimeslices(
});
}
function handleResultForRolling(
function handleResultForRollingAndOccurrences(
objective: Objective,
timeWindow: TimeWindow,
buckets: DailyAggBucket[]
@ -210,6 +225,7 @@ function handleResultForRolling(
.map((bucket: DailyAggBucket): HistoricalSummary => {
const good = bucket.cumulative_good?.value ?? 0;
const total = bucket.cumulative_total?.value ?? 0;
const sliValue = computeSLI(good, total);
const consumedErrorBudget = sliValue < 0 ? 0 : (1 - sliValue) / initialErrorBudget;
const errorBudget = toErrorBudget(initialErrorBudget, consumedErrorBudget);
@ -223,6 +239,39 @@ function handleResultForRolling(
});
}
function handleResultForRollingAndTimeslices(
objective: Objective,
timeWindow: TimeWindow,
buckets: DailyAggBucket[]
): 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)
.map((bucket: DailyAggBucket): HistoricalSummary => {
const good = bucket.cumulative_good?.value ?? 0;
const total = bucket.cumulative_total?.value ?? 0;
const sliValue = computeSLI(good, total, totalSlices);
const consumedErrorBudget = sliValue < 0 ? 0 : (1 - sliValue) / initialErrorBudget;
const errorBudget = toErrorBudget(initialErrorBudget, consumedErrorBudget);
return {
date: new Date(bucket.key_as_string),
errorBudget,
sliValue,
status: computeSummaryStatus(objective, sliValue, errorBudget),
};
});
}
function generateSearchQuery({
sloId,
groupBy,
@ -343,20 +392,17 @@ function getDateRange(timeWindow: TimeWindow) {
};
}
if (calendarAlignedTimeWindowSchema.is(timeWindow)) {
return toDateRange(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() };
}
assertNever(timeWindow);
}
function computeTotalSlicesFromDateRange(dateRange: DateRange, timesliceWindow: Duration) {
const dateRangeDurationInUnit = moment(dateRange.to).diff(
dateRange.from,
toMomentUnitOfTime(timesliceWindow.unit)
);
return Math.ceil(dateRangeDurationInUnit / timesliceWindow!.value);
}
export function getFixedIntervalAndBucketsPerDay(durationInDays: number): {
fixedInterval: string;
bucketsPerDay: number;

View file

@ -14,7 +14,7 @@ export * from './get_slo';
export * from './historical_summary_client';
export * from './resource_installer';
export * from './slo_installer';
export * from './sli_client';
export * from './burn_rates_client';
export * from './slo_repository';
export * from './transform_manager';
export * from './summay_transform_manager';

View file

@ -6,7 +6,7 @@
*/
import { ResourceInstaller } from '../resource_installer';
import { SLIClient } from '../sli_client';
import { BurnRatesClient } from '../burn_rates_client';
import { SLORepository } from '../slo_repository';
import { SummaryClient } from '../summary_client';
import { SummarySearchClient } from '../summary_search_client';
@ -62,9 +62,9 @@ const createSummarySearchClientMock = (): jest.Mocked<SummarySearchClient> => {
};
};
const createSLIClientMock = (): jest.Mocked<SLIClient> => {
const createBurnRatesClientMock = (): jest.Mocked<BurnRatesClient> => {
return {
fetchSLIDataFrom: jest.fn(),
calculate: jest.fn(),
};
};
@ -75,5 +75,5 @@ export {
createSLORepositoryMock,
createSummaryClientMock,
createSummarySearchClientMock,
createSLIClientMock,
createBurnRatesClientMock,
};

View file

@ -13,7 +13,7 @@ import { createSLO } from './fixtures/slo';
import { sevenDaysRolling, weeklyCalendarAligned } from './fixtures/time_window';
import { DefaultSummaryClient } from './summary_client';
const commonEsResponse = {
const createEsResponse = (good: number = 90, total: number = 100) => ({
took: 100,
timed_out: false,
_shards: {
@ -25,19 +25,10 @@ const commonEsResponse = {
hits: {
hits: [],
},
};
const createEsResponse = (good: number = 90, total: number = 100) => ({
...commonEsResponse,
responses: [
{
...commonEsResponse,
aggregations: {
good: { value: good },
total: { value: total },
},
},
],
aggregations: {
good: { value: good },
total: { value: total },
},
});
describe('SummaryClient', () => {
@ -51,7 +42,7 @@ describe('SummaryClient', () => {
describe('with rolling and occurrences SLO', () => {
it('returns the summary', async () => {
const slo = createSLO({ timeWindow: sevenDaysRolling() });
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
esClientMock.search.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
const result = await summaryClient.computeSummary({ slo });
@ -86,7 +77,7 @@ describe('SummaryClient', () => {
const slo = createSLO({
timeWindow: weeklyCalendarAligned(),
});
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
esClientMock.search.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
await summaryClient.computeSummary({ slo });
@ -129,7 +120,7 @@ describe('SummaryClient', () => {
},
timeWindow: sevenDaysRolling(),
});
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
esClientMock.search.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
const result = await summaryClient.computeSummary({ slo });
@ -178,7 +169,7 @@ describe('SummaryClient', () => {
},
timeWindow: weeklyCalendarAligned(),
});
esClientMock.msearch.mockResolvedValueOnce(createEsResponse());
esClientMock.search.mockResolvedValueOnce(createEsResponse());
const summaryClient = new DefaultSummaryClient(esClientMock);
const result = await summaryClient.computeSummary({ slo });

View file

@ -5,21 +5,23 @@
* 2.0.
*/
import {
AggregationsSumAggregate,
AggregationsTopHitsAggregate,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ElasticsearchClient } from '@kbn/core/server';
import {
ALL_VALUE,
calendarAlignedTimeWindowSchema,
Duration,
occurrencesBudgetingMethodSchema,
timeslicesBudgetingMethodSchema,
toMomentUnitOfTime,
} from '@kbn/slo-schema';
import moment from 'moment';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../common/constants';
import { DateRange, Groupings, Meta, SLODefinition, Summary } from '../domain/models';
import { Groupings, Meta, SLODefinition, Summary } from '../domain/models';
import { computeSLI, computeSummaryStatus, toErrorBudget } from '../domain/services';
import { toDateRange } from '../domain/services/date_range';
import { getFlattenedGroupings } from './utils';
import { computeTotalSlicesFromDateRange } from './utils/compute_total_slices_from_date_range';
interface Params {
slo: SLODefinition;
@ -53,25 +55,15 @@ export class DefaultSummaryClient implements SummaryClient {
const instanceIdFilter = shouldIncludeInstanceIdFilter
? [{ term: { 'slo.instanceId': instanceId } }]
: [];
const extraGroupingsAgg = {
last_doc: {
top_hits: {
sort: [
{
'@timestamp': {
order: 'desc',
},
},
],
_source: {
includes: ['slo.groupings', 'monitor', 'observer', 'config_id'],
},
size: 1,
},
},
};
const result = await this.esClient.search({
const result = await this.esClient.search<
any,
{
good: AggregationsSumAggregate;
total: AggregationsSumAggregate;
last_doc: AggregationsTopHitsAggregate;
}
>({
index: remoteName
? `${remoteName}:${SLO_DESTINATION_INDEX_PATTERN}`
: SLO_DESTINATION_INDEX_PATTERN,
@ -90,9 +82,24 @@ export class DefaultSummaryClient implements SummaryClient {
],
},
},
// @ts-expect-error AggregationsAggregationContainer needs to be updated with top_hits
aggs: {
...(shouldIncludeInstanceIdFilter && extraGroupingsAgg),
...(shouldIncludeInstanceIdFilter && {
last_doc: {
top_hits: {
sort: [
{
'@timestamp': {
order: 'desc',
},
},
],
_source: {
includes: ['slo.groupings', 'monitor', 'observer', 'config_id'],
},
size: 1,
},
},
}),
...(timeslicesBudgetingMethodSchema.is(slo.budgetingMethod) && {
good: {
sum: { field: 'slo.isGoodSlice' },
@ -108,39 +115,32 @@ export class DefaultSummaryClient implements SummaryClient {
},
});
// @ts-ignore value is not type correctly
const good = result.aggregations?.good?.value ?? 0;
// @ts-ignore value is not type correctly
const total = result.aggregations?.total?.value ?? 0;
// @ts-expect-error AggregationsAggregationContainer needs to be updated with top_hits
const source = result.aggregations?.last_doc?.hits?.hits?.[0]?._source;
const groupings = source?.slo?.groupings;
const sliValue = computeSLI(good, total);
const initialErrorBudget = 1 - slo.objective.target;
let errorBudget;
if (
calendarAlignedTimeWindowSchema.is(slo.timeWindow) &&
timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)
) {
let sliValue;
if (timeslicesBudgetingMethodSchema.is(slo.budgetingMethod)) {
const totalSlices = computeTotalSlicesFromDateRange(
dateRange,
slo.objective.timesliceWindow!
);
const consumedErrorBudget =
sliValue < 0 ? 0 : (total - good) / (totalSlices * initialErrorBudget);
errorBudget = toErrorBudget(initialErrorBudget, consumedErrorBudget);
sliValue = computeSLI(good, total, totalSlices);
} else {
const consumedErrorBudget = sliValue < 0 ? 0 : (1 - sliValue) / initialErrorBudget;
errorBudget = toErrorBudget(
initialErrorBudget,
consumedErrorBudget,
calendarAlignedTimeWindowSchema.is(slo.timeWindow)
);
sliValue = computeSLI(good, total);
}
const initialErrorBudget = 1 - slo.objective.target;
const consumedErrorBudget = sliValue < 0 ? 0 : (1 - sliValue) / initialErrorBudget;
const errorBudget = toErrorBudget(
initialErrorBudget,
consumedErrorBudget,
calendarAlignedTimeWindowSchema.is(slo.timeWindow) &&
occurrencesBudgetingMethodSchema.is(slo.budgetingMethod)
);
return {
summary: {
sliValue,
@ -153,14 +153,6 @@ export class DefaultSummaryClient implements SummaryClient {
}
}
function computeTotalSlicesFromDateRange(dateRange: DateRange, timesliceWindow: Duration) {
const dateRangeDurationInUnit = moment(dateRange.to).diff(
dateRange.from,
toMomentUnitOfTime(timesliceWindow.unit)
);
return Math.ceil(dateRangeDurationInUnit / timesliceWindow!.value);
}
function getMetaFields(
slo: SLODefinition,
source: { monitor?: { id?: string }; config_id?: string; observer?: { name?: string } }

View file

@ -98,9 +98,10 @@ export function generateSummaryTransformForTimeslicesAndCalendarAligned(
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
totalSlicesInPeriod: '_totalSlicesInPeriod',
},
script:
'if (params.totalEvents == 0) { return -1 } else if (params.goodEvents >= params.totalEvents) { return 1 } else { return params.goodEvents / params.totalEvents }',
'if (params.totalEvents == 0) { return -1 } else if (params.goodEvents >= params.totalEvents) { return 1 } else { return 1 - (params.totalEvents - params.goodEvents) / params.totalSlicesInPeriod }',
},
},
errorBudgetInitial: {
@ -112,13 +113,11 @@ export function generateSummaryTransformForTimeslicesAndCalendarAligned(
errorBudgetConsumed: {
bucket_script: {
buckets_path: {
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
totalSlicesInPeriod: '_totalSlicesInPeriod',
sliValue: 'sliValue',
errorBudgetInitial: 'errorBudgetInitial',
},
script:
'if (params.totalEvents == 0) { return 0 } else { return (params.totalEvents - params.goodEvents) / (params.totalSlicesInPeriod * params.errorBudgetInitial) }',
'if (params.sliValue == -1) { return 0 } else { return (1 - params.sliValue) / params.errorBudgetInitial }',
},
},
errorBudgetRemaining: {

View file

@ -19,6 +19,10 @@ import { getGroupBy } from './common';
export function generateSummaryTransformForTimeslicesAndRolling(
slo: SLODefinition
): TransformPutTransformRequest {
const sliceDurationInSeconds = slo.objective.timesliceWindow!.asSeconds();
const timeWindowInSeconds = slo.timeWindow.duration.asSeconds();
const totalSlicesInWindow = Math.ceil(timeWindowInSeconds / sliceDurationInSeconds);
return {
transform_id: getSLOSummaryTransformId(slo.id, slo.revision),
dest: {
@ -71,8 +75,7 @@ export function generateSummaryTransformForTimeslicesAndRolling(
goodEvents: 'goodEvents',
totalEvents: 'totalEvents',
},
script:
'if (params.totalEvents == 0) { return -1 } else if (params.goodEvents >= params.totalEvents) { return 1 } else { return params.goodEvents / params.totalEvents }',
script: `if (params.totalEvents == 0) { return -1 } else if (params.goodEvents >= params.totalEvents) { return 1 } else { return 1 - (params.totalEvents - params.goodEvents) / ${totalSlicesInWindow} }`,
},
},
errorBudgetInitial: {

View file

@ -0,0 +1,17 @@
/*
* 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 { DateRange, Duration, toMomentUnitOfTime } from '../../domain/models';
export function computeTotalSlicesFromDateRange(dateRange: DateRange, timesliceWindow: Duration) {
const dateRangeDurationInUnit = moment(dateRange.to).diff(
dateRange.from,
toMomentUnitOfTime(timesliceWindow.unit)
);
return Math.ceil(dateRangeDurationInUnit / timesliceWindow!.value);
}