feat(slo): Introduce calendar aligned time window (#143190)

This commit is contained in:
Kevin Delemme 2022-10-19 07:40:10 -04:00 committed by GitHub
parent b5cb3f6a22
commit c31c400174
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 687 additions and 184 deletions

View file

@ -11,9 +11,15 @@ import { isRight, isLeft } from 'fp-ts/lib/Either';
import { strictKeysRt } from '.';
import { jsonRt } from '../json_rt';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { isoToEpochRt } from '../iso_to_epoch_rt';
describe('strictKeysRt', () => {
it('correctly and deeply validates object keys', () => {
const timeWindowRt = t.union([
t.type({ duration: t.string }),
t.type({ start_time: isoToEpochRt }),
]);
const metricQueryRt = t.union(
[
t.type({
@ -181,6 +187,21 @@ describe('strictKeysRt', () => {
},
],
},
{
type: t.type({ body: timeWindowRt }),
passes: [
{ body: { duration: '1d' } },
{ body: { start_time: '2022-05-20T08:10:15.000Z' } },
],
fails: [
{ body: { duration: '1d', start_time: '2022-05-20T08:10:15.000Z' } },
{ body: { duration: '1d', unknownKey: '' } },
{ body: { start_time: '2022-05-20T08:10:15.000Z', unknownKey: '' } },
{ body: { unknownKey: '' } },
{ body: { start_time: 'invalid' } },
{ body: { duration: false } },
],
},
];
checks.forEach((check) => {

View file

@ -7,7 +7,7 @@
*/
import * as t from 'io-ts';
import { either } from 'fp-ts/lib/Either';
import { either, isRight } from 'fp-ts/lib/Either';
import { difference, isPlainObject, forEach } from 'lodash';
import { MergeType } from '../merge_rt';
@ -62,7 +62,7 @@ function getHandlingTypes(type: t.Mixed, key: string, value: object): t.Mixed[]
return getHandlingTypes(type.type, key, value);
case 'UnionType':
const matched = type.types.find((m) => m.is(value));
const matched = type.types.find((m) => isRight(m.decode(value)));
return matched ? getHandlingTypes(matched, key, value) : [];
}
}

View file

@ -5,12 +5,12 @@
* 2.0.
*/
import { createAPMTransactionErrorRateIndicator, createSLO } from '../../services/slo/fixtures/slo';
import { createSLO } from '../../services/slo/fixtures/slo';
import { computeErrorBudget } from './compute_error_budget';
describe('computeErrorBudget', () => {
it("returns default values when total events is '0'", () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO();
const errorBudget = computeErrorBudget(slo, { good: 100, total: 0 });
expect(errorBudget).toEqual({
@ -21,7 +21,7 @@ describe('computeErrorBudget', () => {
});
it("computes the error budget when 'good > total' events", () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO();
const errorBudget = computeErrorBudget(slo, { good: 9999, total: 9 });
expect(errorBudget).toEqual({
@ -32,7 +32,7 @@ describe('computeErrorBudget', () => {
});
it('computes the error budget with all good events', () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO();
const errorBudget = computeErrorBudget(slo, { good: 100, total: 100 });
expect(errorBudget).toEqual({
@ -43,7 +43,7 @@ describe('computeErrorBudget', () => {
});
it('computes the error budget when exactly consumed', () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO();
const errorBudget = computeErrorBudget(slo, { good: 999, total: 1000 });
expect(errorBudget).toEqual({
@ -54,7 +54,7 @@ describe('computeErrorBudget', () => {
});
it('computes the error budget with rounded values', () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO();
const errorBudget = computeErrorBudget(slo, { good: 333, total: 777 });
expect(errorBudget).toEqual({
@ -65,7 +65,7 @@ describe('computeErrorBudget', () => {
});
it('computes the error budget with no good events', () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO();
const errorBudget = computeErrorBudget(slo, { good: 0, total: 100 });
expect(errorBudget).toEqual({

View file

@ -0,0 +1,145 @@
/*
* 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 { TimeWindow } from '../../types/models/time_window';
import { Duration, DurationUnit } from '../../types/schema';
import { toDateRange } from './date_range';
const THIRTY_DAYS = new Duration(30, DurationUnit.d);
const WEEKLY = new Duration(1, DurationUnit.w);
const BIWEEKLY = new Duration(2, DurationUnit.w);
const MONTHLY = new Duration(1, DurationUnit.M);
const QUARTERLY = new Duration(1, DurationUnit.Q);
const NOW = new Date('2022-08-11T08:31:00.000Z');
describe('toDateRange', () => {
describe('for calendar aligned time window', () => {
it('throws when start_time is in the future', () => {
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
const timeWindow = aCalendarTimeWindow(WEEKLY, futureDate);
expect(() => toDateRange(timeWindow, NOW)).toThrow(
'Cannot compute date range with future starting time'
);
});
describe("with 'weekly' duration", () => {
it('computes the date range when starting the same day', () => {
const timeWindow = aCalendarTimeWindow(WEEKLY, new Date('2022-08-11T08:30:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-11T08:30:00.000Z'),
to: new Date('2022-08-18T08:30:00.000Z'),
});
});
it('computes the date range when starting a month ago', () => {
const timeWindow = aCalendarTimeWindow(WEEKLY, new Date('2022-07-05T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-09T08:00:00.000Z'),
to: new Date('2022-08-16T08:00:00.000Z'),
});
});
});
describe("with 'bi-weekly' duration", () => {
it('computes the date range when starting the same day', () => {
const timeWindow = aCalendarTimeWindow(BIWEEKLY, new Date('2022-08-11T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-11T08:00:00.000Z'),
to: new Date('2022-08-25T08:00:00.000Z'),
});
});
it('computes the date range when starting a month ago', () => {
const timeWindow = aCalendarTimeWindow(BIWEEKLY, new Date('2022-07-05T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-09T08:00:00.000Z'),
to: new Date('2022-08-23T08:00:00.000Z'),
});
});
});
describe("with 'monthly' duration", () => {
it('computes the date range when starting the same month', () => {
const timeWindow = aCalendarTimeWindow(MONTHLY, new Date('2022-08-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-01T08:00:00.000Z'),
to: new Date('2022-09-01T08:00:00.000Z'),
});
});
it('computes the date range when starting a month ago', () => {
const timeWindow = aCalendarTimeWindow(MONTHLY, new Date('2022-07-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-01T08:00:00.000Z'),
to: new Date('2022-09-01T08:00:00.000Z'),
});
});
});
describe("with 'quarterly' duration", () => {
it('computes the date range when starting the same quarter', () => {
const timeWindow = aCalendarTimeWindow(QUARTERLY, new Date('2022-07-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-07-01T08:00:00.000Z'),
to: new Date('2022-10-01T08:00:00.000Z'),
});
});
it('computes the date range when starting a quarter ago', () => {
const timeWindow = aCalendarTimeWindow(QUARTERLY, new Date('2022-03-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-06-01T08:00:00.000Z'),
to: new Date('2022-09-01T08:00:00.000Z'),
});
});
});
});
describe('for rolling time window', () => {
it("computes the date range using a '30days' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(THIRTY_DAYS), NOW)).toEqual({
from: new Date('2022-07-12T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'weekly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(WEEKLY), NOW)).toEqual({
from: new Date('2022-08-04T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'monthly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(MONTHLY), NOW)).toEqual({
from: new Date('2022-07-11T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'quarterly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(QUARTERLY), NOW)).toEqual({
from: new Date('2022-05-11T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
});
});
function aCalendarTimeWindow(duration: Duration, startTime: Date): TimeWindow {
return {
duration,
calendar: { start_time: startTime },
};
}
function aRollingTimeWindow(duration: Duration): TimeWindow {
return { duration, is_rolling: true };
}

View file

@ -0,0 +1,68 @@
/*
* 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 { assertNever } from '@kbn/std';
import moment from 'moment';
import type { TimeWindow } from '../../types/models/time_window';
import {
calendarAlignedTimeWindowSchema,
DurationUnit,
rollingTimeWindowSchema,
} from '../../types/schema';
export interface DateRange {
from: Date;
to: Date;
}
export const toDateRange = (timeWindow: TimeWindow, currentDate: Date = new Date()): DateRange => {
if (calendarAlignedTimeWindowSchema.is(timeWindow)) {
const unit = toMomentUnitOfTime(timeWindow.duration.unit);
const now = moment.utc(currentDate).startOf('minute');
const startTime = moment.utc(timeWindow.calendar.start_time);
const differenceInUnit = now.diff(startTime, unit);
if (differenceInUnit < 0) {
throw new Error('Cannot compute date range with future starting time');
}
const from = startTime.clone().add(differenceInUnit, unit);
const to = from.clone().add(timeWindow.duration.value, unit);
return { from: from.toDate(), to: to.toDate() };
}
if (rollingTimeWindowSchema.is(timeWindow)) {
const unit = toMomentUnitOfTime(timeWindow.duration.unit);
const now = moment(currentDate).startOf('minute');
return {
from: now.clone().subtract(timeWindow.duration.value, unit).toDate(),
to: now.toDate(),
};
}
assertNever(timeWindow);
};
const toMomentUnitOfTime = (unit: DurationUnit): moment.unitOfTime.Diff => {
switch (unit) {
case DurationUnit.d:
return 'days';
case DurationUnit.w:
return 'weeks';
case DurationUnit.M:
return 'months';
case DurationUnit.Q:
return 'quarters';
case DurationUnit.Y:
return 'years';
default:
assertNever(unit);
}
};

View file

@ -7,3 +7,4 @@
export * from './compute_error_budget';
export * from './compute_sli';
export * from './date_range';

View file

@ -31,6 +31,11 @@ export const slo: SavedObjectsType = {
properties: {
duration: { type: 'keyword' },
is_rolling: { type: 'boolean' },
calendar: {
properties: {
start_time: { type: 'date' },
},
},
},
},
budgeting_method: { type: 'keyword' },

View file

@ -31,7 +31,7 @@ describe('CreateSLO', () => {
describe('happy path', () => {
it('calls the expected services', async () => {
const sloParams = createSLOParams(createAPMTransactionErrorRateIndicator());
const sloParams = createSLOParams({ indicator: createAPMTransactionErrorRateIndicator() });
mockTransformManager.install.mockResolvedValue('slo-transform-id');
const response = await createSLO.execute(sloParams);
@ -51,7 +51,7 @@ describe('CreateSLO', () => {
describe('unhappy path', () => {
it('deletes the SLO when transform installation fails', async () => {
mockTransformManager.install.mockRejectedValue(new Error('Transform install error'));
const sloParams = createSLOParams(createAPMTransactionErrorRateIndicator());
const sloParams = createSLOParams({ indicator: createAPMTransactionErrorRateIndicator() });
await expect(createSLO.execute(sloParams)).rejects.toThrowError('Transform install error');
expect(mockRepository.deleteById).toBeCalled();
@ -60,7 +60,7 @@ describe('CreateSLO', () => {
it('removes the transform and deletes the SLO when transform start fails', async () => {
mockTransformManager.install.mockResolvedValue('slo-transform-id');
mockTransformManager.start.mockRejectedValue(new Error('Transform start error'));
const sloParams = createSLOParams(createAPMTransactionErrorRateIndicator());
const sloParams = createSLOParams({ indicator: createAPMTransactionErrorRateIndicator() });
await expect(createSLO.execute(sloParams)).rejects.toThrowError('Transform start error');
expect(mockTransformManager.uninstall).toBeCalledWith('slo-transform-id');

View file

@ -29,7 +29,7 @@ describe('DeleteSLO', () => {
describe('happy path', () => {
it('removes the transform, the roll up data and the SLO from the repository', async () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
mockRepository.findById.mockResolvedValueOnce(slo);
await deleteSLO.execute(slo.id);

View file

@ -6,6 +6,8 @@
*/
import uuid from 'uuid';
import { Duration, DurationUnit } from '../../../types/schema';
import {
APMTransactionDurationIndicator,
APMTransactionErrorRateIndicator,
@ -14,37 +16,6 @@ import {
} from '../../../types/models';
import { CreateSLOParams } from '../../../types/rest_specs';
const defaultSLO: Omit<SLO, 'indicator' | 'id' | 'created_at' | 'updated_at'> = {
name: 'irrelevant',
description: 'irrelevant',
time_window: {
duration: '7d',
is_rolling: true,
},
budgeting_method: 'occurrences',
objective: {
target: 0.999,
},
revision: 1,
};
export const createSLOParams = (indicator: Indicator): CreateSLOParams => ({
...defaultSLO,
indicator,
});
export const createSLO = (indicator: Indicator): SLO => {
const now = new Date();
return {
...defaultSLO,
id: uuid.v1(),
indicator,
revision: 1,
created_at: now,
updated_at: now,
};
};
export const createAPMTransactionErrorRateIndicator = (
params: Partial<APMTransactionErrorRateIndicator['params']> = {}
): Indicator => ({
@ -72,3 +43,44 @@ export const createAPMTransactionDurationIndicator = (
...params,
},
});
const defaultSLO: Omit<SLO, 'id' | 'revision' | 'created_at' | 'updated_at'> = {
name: 'irrelevant',
description: 'irrelevant',
time_window: {
duration: new Duration(7, DurationUnit.d),
is_rolling: true,
},
budgeting_method: 'occurrences',
objective: {
target: 0.999,
},
indicator: createAPMTransactionDurationIndicator(),
};
export const createSLOParams = (params: Partial<CreateSLOParams> = {}): CreateSLOParams => ({
...defaultSLO,
...params,
});
export const createSLO = (params: Partial<SLO> = {}): SLO => {
const now = new Date();
return {
...defaultSLO,
id: uuid.v1(),
revision: 1,
created_at: now,
updated_at: now,
...params,
};
};
export const createSLOWithCalendarTimeWindow = (params: Partial<SLO> = {}): SLO => {
return createSLO({
time_window: {
duration: new Duration(7, DurationUnit.d),
calendar: { start_time: new Date('2022-10-01T00:00:00.000Z') },
},
...params,
});
};

View file

@ -24,9 +24,9 @@ describe('GetSLO', () => {
describe('happy path', () => {
it('retrieves the SLO from the repository', async () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
mockRepository.findById.mockResolvedValueOnce(slo);
mockSLIClient.fetchDataForSLOTimeWindow.mockResolvedValueOnce({ good: 9999, total: 10000 });
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce({ good: 9999, total: 10000 });
const result = await getSLO.execute(slo.id);
@ -53,6 +53,7 @@ describe('GetSLO', () => {
duration: '7d',
is_rolling: true,
},
summary: {
sli_value: 0.9999,
error_budget: {
@ -61,8 +62,8 @@ describe('GetSLO', () => {
remaining: 0.9,
},
},
created_at: slo.created_at,
updated_at: slo.updated_at,
created_at: slo.created_at.toISOString(),
updated_at: slo.updated_at.toISOString(),
revision: slo.revision,
});
});

View file

@ -6,7 +6,7 @@
*/
import { ErrorBudget, SLO } from '../../types/models';
import { GetSLOResponse } from '../../types/rest_specs';
import { GetSLOResponse, getSLOResponseSchema } from '../../types/rest_specs';
import { SLORepository } from './slo_repository';
import { SLIClient } from './sli_client';
import { computeSLI, computeErrorBudget } from '../../domain/services';
@ -16,14 +16,14 @@ export class GetSLO {
public async execute(sloId: string): Promise<GetSLOResponse> {
const slo = await this.repository.findById(sloId);
const sliData = await this.sliClient.fetchDataForSLOTimeWindow(slo);
const sliData = await this.sliClient.fetchCurrentSLIData(slo);
const sliValue = computeSLI(sliData);
const errorBudget = computeErrorBudget(slo, sliData);
return this.toResponse(slo, sliValue, errorBudget);
}
private toResponse(slo: SLO, sliValue: number, errorBudget: ErrorBudget): GetSLOResponse {
return {
return getSLOResponseSchema.encode({
id: slo.id,
name: slo.name,
description: slo.description,
@ -40,6 +40,6 @@ export class GetSLO {
revision: slo.revision,
created_at: slo.created_at,
updated_at: slo.updated_at,
};
});
}
}

View file

@ -35,7 +35,7 @@ const createSLORepositoryMock = (): jest.Mocked<SLORepository> => {
const createSLIClientMock = (): jest.Mocked<SLIClient> => {
return {
fetchDataForSLOTimeWindow: jest.fn(),
fetchCurrentSLIData: jest.fn(),
};
};

View file

@ -7,7 +7,9 @@
import { ElasticsearchClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { SLO_DESTINATION_INDEX_NAME } from '../../assets/constants';
import { toDateRange } from '../../domain/services/date_range';
import { InternalQueryError } from '../../errors';
import { Duration, DurationUnit } from '../../types/schema';
import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo';
import { DefaultSLIClient } from './sli_client';
@ -18,9 +20,9 @@ describe('SLIClient', () => {
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
});
describe('fetchDataForSLOTimeWindow', () => {
describe('fetchCurrentSLIData', () => {
it('throws when aggregations failed', async () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
esClientMock.search.mockResolvedValueOnce({
took: 100,
timed_out: false,
@ -37,56 +39,132 @@ describe('SLIClient', () => {
});
const sliClient = new DefaultSLIClient(esClientMock);
await expect(sliClient.fetchDataForSLOTimeWindow(slo)).rejects.toThrowError(
await expect(sliClient.fetchCurrentSLIData(slo)).rejects.toThrowError(
new InternalQueryError('SLI aggregation query')
);
});
it('returns the aggregated good and total values for the SLO time window date range', async () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
esClientMock.search.mockResolvedValueOnce({
took: 100,
timed_out: false,
_shards: {
total: 0,
successful: 0,
skipped: 0,
failed: 0,
},
hits: {
hits: [],
},
aggregations: {
full_window: { buckets: [{ good: { value: 90 }, total: { value: 100 } }] },
},
describe('For a rolling time window SLO type', () => {
it('returns the aggregated good and total values', async () => {
const slo = createSLO({
time_window: {
duration: new Duration(7, DurationUnit.d),
is_rolling: true,
},
});
esClientMock.search.mockResolvedValueOnce({
took: 100,
timed_out: false,
_shards: {
total: 0,
successful: 0,
skipped: 0,
failed: 0,
},
hits: {
hits: [],
},
aggregations: {
full_window: { buckets: [{ good: { value: 90 }, total: { value: 100 } }] },
},
});
const sliClient = new DefaultSLIClient(esClientMock);
const result = await sliClient.fetchCurrentSLIData(slo);
expect(result).toEqual({ good: 90, total: 100 });
expect(esClientMock.search).toHaveBeenCalledWith(
expect.objectContaining({
index: `${SLO_DESTINATION_INDEX_NAME}*`,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
],
},
},
aggs: {
full_window: {
date_range: {
field: '@timestamp',
ranges: [{ from: 'now-7d/m', to: 'now/m' }],
},
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
},
},
})
);
});
const sliClient = new DefaultSLIClient(esClientMock);
});
const result = await sliClient.fetchDataForSLOTimeWindow(slo);
expect(result).toEqual({ good: 90, total: 100 });
expect(esClientMock.search).toHaveBeenCalledWith(
expect.objectContaining({
index: `${SLO_DESTINATION_INDEX_NAME}*`,
query: {
bool: {
filter: [{ term: { 'slo.id': slo.id } }, { term: { 'slo.revision': slo.revision } }],
describe('For a calendar aligned time window SLO type', () => {
it('returns the aggregated good and total values', async () => {
const slo = createSLO({
time_window: {
duration: new Duration(1, DurationUnit.M),
calendar: {
start_time: new Date('2022-09-01T00:00:00.000Z'),
},
},
aggs: {
full_window: {
date_range: {
field: '@timestamp',
ranges: [{ from: 'now-7d/m', to: 'now/m' }],
},
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
});
esClientMock.search.mockResolvedValueOnce({
took: 100,
timed_out: false,
_shards: {
total: 0,
successful: 0,
skipped: 0,
failed: 0,
},
hits: {
hits: [],
},
aggregations: {
full_window: { buckets: [{ good: { value: 90 }, total: { value: 100 } }] },
},
});
const sliClient = new DefaultSLIClient(esClientMock);
const result = await sliClient.fetchCurrentSLIData(slo);
const expectedDateRange = toDateRange(slo.time_window);
expect(result).toEqual({ good: 90, total: 100 });
expect(esClientMock.search).toHaveBeenCalledWith(
expect.objectContaining({
index: `${SLO_DESTINATION_INDEX_NAME}*`,
query: {
bool: {
filter: [
{ term: { 'slo.id': slo.id } },
{ term: { 'slo.revision': slo.revision } },
],
},
},
},
})
);
aggs: {
full_window: {
date_range: {
field: '@timestamp',
ranges: [
{
from: expectedDateRange.from.toISOString(),
to: expectedDateRange.to.toISOString(),
},
],
},
aggs: {
good: { sum: { field: 'slo.numerator' } },
total: { sum: { field: 'slo.denominator' } },
},
},
},
})
);
});
});
});
});

View file

@ -6,18 +6,21 @@
*/
import { ElasticsearchClient } from '@kbn/core/server';
import { assertNever } from '@kbn/std';
import { SLO_DESTINATION_INDEX_NAME } from '../../assets/constants';
import { toDateRange } from '../../domain/services/date_range';
import { InternalQueryError, NotSupportedError } from '../../errors';
import { IndicatorData, SLO } from '../../types/models';
import { calendarAlignedTimeWindowSchema, rollingTimeWindowSchema } from '../../types/schema';
export interface SLIClient {
fetchDataForSLOTimeWindow(slo: SLO): Promise<IndicatorData>;
fetchCurrentSLIData(slo: SLO): Promise<IndicatorData>;
}
export class DefaultSLIClient implements SLIClient {
constructor(private esClient: ElasticsearchClient) {}
async fetchDataForSLOTimeWindow(slo: SLO): Promise<IndicatorData> {
async fetchCurrentSLIData(slo: SLO): Promise<IndicatorData> {
if (slo.budgeting_method !== 'occurrences') {
throw new NotSupportedError(`Budgeting method: ${slo.budgeting_method}`);
}
@ -34,7 +37,7 @@ export class DefaultSLIClient implements SLIClient {
full_window: {
date_range: {
field: '@timestamp',
ranges: [fromSLOTimeWindowToRange(slo)],
ranges: [fromSLOTimeWindowToEsRange(slo)],
},
aggs: {
good: { sum: { field: 'slo.numerator' } },
@ -57,13 +60,22 @@ export class DefaultSLIClient implements SLIClient {
}
}
function fromSLOTimeWindowToRange(slo: SLO): { from: string; to: string } {
if (!slo.time_window.is_rolling) {
throw new NotSupportedError(`Time window: ${slo.time_window.is_rolling}`);
function fromSLOTimeWindowToEsRange(slo: SLO): { from: string; to: string } {
if (calendarAlignedTimeWindowSchema.is(slo.time_window)) {
const dateRange = toDateRange(slo.time_window);
return {
from: `${dateRange.from.toISOString()}`,
to: `${dateRange.to.toISOString()}`,
};
}
return {
from: `now-${slo.time_window.duration}/m`,
to: 'now/m',
};
if (rollingTimeWindowSchema.is(slo.time_window)) {
return {
from: `now-${slo.time_window.duration.value}${slo.time_window.duration.unit}/m`,
to: `now/m`,
};
}
assertNever(slo.time_window);
}

View file

@ -9,18 +9,18 @@ import { SavedObject } from '@kbn/core-saved-objects-common';
import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from '@kbn/core/server';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { SLO, StoredSLO } from '../../types/models';
import { SLO, sloSchema, StoredSLO } from '../../types/models';
import { SO_SLO_TYPE } from '../../saved_objects';
import { KibanaSavedObjectsSLORepository } from './slo_repository';
import { createAPMTransactionDurationIndicator, createSLO } from './fixtures/slo';
import { SLONotFound } from '../../errors';
const SOME_SLO = createSLO(createAPMTransactionDurationIndicator());
const SOME_SLO = createSLO({ indicator: createAPMTransactionDurationIndicator() });
function aStoredSLO(slo: SLO): SavedObject<StoredSLO> {
return {
id: slo.id,
attributes: slo,
attributes: sloSchema.encode(slo),
type: SO_SLO_TYPE,
references: [],
};
@ -62,15 +62,10 @@ describe('KibanaSavedObjectsSLORepository', () => {
const savedSLO = await repository.save(SOME_SLO);
expect(savedSLO).toEqual(SOME_SLO);
expect(soClientMock.create).toHaveBeenCalledWith(
SO_SLO_TYPE,
expect.objectContaining({
...SOME_SLO,
updated_at: expect.anything(),
created_at: expect.anything(),
}),
{ id: SOME_SLO.id, overwrite: true }
);
expect(soClientMock.create).toHaveBeenCalledWith(SO_SLO_TYPE, sloSchema.encode(SOME_SLO), {
id: SOME_SLO.id,
overwrite: true,
});
});
it('finds an existing SLO', async () => {

View file

@ -5,10 +5,14 @@
* 2.0.
*/
import * as t from 'io-ts';
import { fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
import { StoredSLO, SLO } from '../../types/models';
import { StoredSLO, SLO, sloSchema } from '../../types/models';
import { SO_SLO_TYPE } from '../../saved_objects';
import { SLONotFound } from '../../errors';
@ -22,18 +26,18 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
constructor(private soClient: SavedObjectsClientContract) {}
async save(slo: SLO): Promise<SLO> {
const savedSLO = await this.soClient.create<StoredSLO>(SO_SLO_TYPE, slo, {
const savedSLO = await this.soClient.create<StoredSLO>(SO_SLO_TYPE, toStoredSLO(slo), {
id: slo.id,
overwrite: true,
});
return savedSLO.attributes;
return toSLO(savedSLO.attributes);
}
async findById(id: string): Promise<SLO> {
try {
const slo = await this.soClient.get<StoredSLO>(SO_SLO_TYPE, id);
return slo.attributes;
return toSLO(slo.attributes);
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
throw new SLONotFound(`SLO [${id}] not found`);
@ -53,3 +57,16 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
}
}
}
function toStoredSLO(slo: SLO): StoredSLO {
return sloSchema.encode(slo);
}
function toSLO(storedSLO: StoredSLO): SLO {
return pipe(
sloSchema.decode(storedSLO),
fold(() => {
throw new Error('Invalid Stored SLO');
}, t.identity)
);
}

View file

@ -12,7 +12,7 @@ const generator = new ApmTransactionDurationTransformGenerator();
describe('APM Transaction Duration Transform Generator', () => {
it('returns the correct transform params with every specified indicator params', async () => {
const anSLO = createSLO(createAPMTransactionDurationIndicator());
const anSLO = createSLO({ indicator: createAPMTransactionDurationIndicator() });
const transform = generator.getTransformParams(anSLO);
expect(transform).toMatchSnapshot({
@ -29,14 +29,14 @@ describe('APM Transaction Duration Transform Generator', () => {
});
it("does not include the query filter when params are '*'", async () => {
const anSLO = createSLO(
createAPMTransactionDurationIndicator({
const anSLO = createSLO({
indicator: createAPMTransactionDurationIndicator({
environment: '*',
service: '*',
transaction_name: '*',
transaction_type: '*',
})
);
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.source.query).toMatchSnapshot();

View file

@ -12,7 +12,7 @@ const generator = new ApmTransactionErrorRateTransformGenerator();
describe('APM Transaction Error Rate Transform Generator', () => {
it('returns the correct transform params with every specified indicator params', async () => {
const anSLO = createSLO(createAPMTransactionErrorRateIndicator());
const anSLO = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
const transform = generator.getTransformParams(anSLO);
expect(transform).toMatchSnapshot({
@ -29,21 +29,23 @@ describe('APM Transaction Error Rate Transform Generator', () => {
});
it("uses default values when 'good_status_codes' is not specified", async () => {
const anSLO = createSLO(createAPMTransactionErrorRateIndicator({ good_status_codes: [] }));
const anSLO = createSLO({
indicator: createAPMTransactionErrorRateIndicator({ good_status_codes: [] }),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.pivot?.aggregations).toMatchSnapshot();
});
it("does not include the query filter when params are '*'", async () => {
const anSLO = createSLO(
createAPMTransactionErrorRateIndicator({
const anSLO = createSLO({
indicator: createAPMTransactionErrorRateIndicator({
environment: '*',
service: '*',
transaction_name: '*',
transaction_type: '*',
})
);
}),
});
const transform = generator.getTransformParams(anSLO);
expect(transform.source.query).toMatchSnapshot();

View file

@ -21,7 +21,11 @@ import {
TransformGenerator,
} from './transform_generators';
import { SLO, IndicatorTypes } from '../../types/models';
import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo';
import {
createAPMTransactionDurationIndicator,
createAPMTransactionErrorRateIndicator,
createSLO,
} from './fixtures/slo';
describe('TransformManager', () => {
let esClientMock: ElasticsearchClientMock;
@ -42,17 +46,7 @@ describe('TransformManager', () => {
const service = new DefaultTransformManager(generators, esClientMock, loggerMock);
await expect(
service.install(
createSLO({
type: 'slo.apm.transaction_error_rate',
params: {
environment: 'irrelevant',
service: 'irrelevant',
transaction_name: 'irrelevant',
transaction_type: 'irrelevant',
},
})
)
service.install(createSLO({ indicator: createAPMTransactionErrorRateIndicator() }))
).rejects.toThrowError('Unsupported SLO type: slo.apm.transaction_error_rate');
});
@ -65,16 +59,7 @@ describe('TransformManager', () => {
await expect(
transformManager.install(
createSLO({
type: 'slo.apm.transaction_duration',
params: {
environment: 'irrelevant',
service: 'irrelevant',
transaction_name: 'irrelevant',
transaction_type: 'irrelevant',
'threshold.us': 250000,
},
})
createSLO({ indicator: createAPMTransactionDurationIndicator() })
)
).rejects.toThrowError('Some error');
});
@ -86,7 +71,7 @@ describe('TransformManager', () => {
'slo.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
const transformId = await transformManager.install(slo);

View file

@ -30,7 +30,7 @@ describe('UpdateSLO', () => {
describe('without breaking changes', () => {
it('updates the SLO saved object without revision bump', async () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
mockRepository.findById.mockResolvedValueOnce(slo);
const newName = 'new slo name';
@ -48,7 +48,9 @@ describe('UpdateSLO', () => {
describe('with breaking changes', () => {
it('removes the obsolete data from the SLO previous revision', async () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator({ environment: 'development' }));
const slo = createSLO({
indicator: createAPMTransactionErrorRateIndicator({ environment: 'development' }),
});
mockRepository.findById.mockResolvedValueOnce(slo);
const newIndicator = createAPMTransactionErrorRateIndicator({ environment: 'production' });

View file

@ -9,7 +9,11 @@ import deepEqual from 'fast-deep-equal';
import { ElasticsearchClient } from '@kbn/core/server';
import { getSLOTransformId, SLO_INDEX_TEMPLATE_NAME } from '../../assets/constants';
import { UpdateSLOParams, UpdateSLOResponse } from '../../types/rest_specs';
import {
UpdateSLOParams,
UpdateSLOResponse,
updateSLOResponseSchema,
} from '../../types/rest_specs';
import { SLORepository } from './slo_repository';
import { TransformManager } from './transform_manager';
import { SLO } from '../../types/models';
@ -73,7 +77,7 @@ export class UpdateSLO {
}
private toResponse(slo: SLO): UpdateSLOResponse {
return {
return updateSLOResponseSchema.encode({
id: slo.id,
name: slo.name,
description: slo.description,
@ -83,6 +87,6 @@ export class UpdateSLO {
objective: slo.objective,
created_at: slo.created_at,
updated_at: slo.updated_at,
};
});
}
}

View file

@ -9,12 +9,11 @@ import * as t from 'io-ts';
import {
apmTransactionDurationIndicatorSchema,
apmTransactionErrorRateIndicatorSchema,
indicatorDataSchema,
indicatorSchema,
indicatorTypesSchema,
} from '../schema';
const indicatorDataSchema = t.type({ good: t.number, total: t.number });
type APMTransactionErrorRateIndicator = t.TypeOf<typeof apmTransactionErrorRateIndicatorSchema>;
type APMTransactionDurationIndicator = t.TypeOf<typeof apmTransactionDurationIndicatorSchema>;
type Indicator = t.TypeOf<typeof indicatorSchema>;

View file

@ -11,7 +11,7 @@ import {
dateType,
indicatorSchema,
objectiveSchema,
rollingTimeWindowSchema,
timeWindowSchema,
} from '../schema';
const sloSchema = t.type({
@ -19,7 +19,7 @@ const sloSchema = t.type({
name: t.string,
description: t.string,
indicator: indicatorSchema,
time_window: rollingTimeWindowSchema,
time_window: timeWindowSchema,
budgeting_method: budgetingMethodSchema,
objective: objectiveSchema,
revision: t.number,
@ -27,11 +27,9 @@ const sloSchema = t.type({
updated_at: dateType,
});
const storedSLOSchema = sloSchema;
export { sloSchema, storedSLOSchema };
export { sloSchema };
type SLO = t.TypeOf<typeof sloSchema>;
type StoredSLO = t.TypeOf<typeof storedSLOSchema>;
type StoredSLO = t.OutputOf<typeof sloSchema>;
export type { SLO, StoredSLO };

View file

@ -0,0 +1,13 @@
/*
* 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';
import { timeWindowSchema } from '../schema/time_window';
type TimeWindow = t.TypeOf<typeof timeWindowSchema>;
export type { TimeWindow };

View file

@ -6,15 +6,22 @@
*/
import * as t from 'io-ts';
import { dateType, errorBudgetSchema, indicatorSchema } from '../schema';
import { budgetingMethodSchema, objectiveSchema, rollingTimeWindowSchema } from '../schema/slo';
import {
budgetingMethodSchema,
dateType,
errorBudgetSchema,
indicatorSchema,
objectiveSchema,
timeWindowSchema,
} from '../schema';
const createSLOParamsSchema = t.type({
body: t.type({
name: t.string,
description: t.string,
indicator: indicatorSchema,
time_window: rollingTimeWindowSchema,
time_window: timeWindowSchema,
budgeting_method: budgetingMethodSchema,
objective: objectiveSchema,
}),
@ -41,7 +48,7 @@ const getSLOResponseSchema = t.type({
name: t.string,
description: t.string,
indicator: indicatorSchema,
time_window: rollingTimeWindowSchema,
time_window: timeWindowSchema,
budgeting_method: budgetingMethodSchema,
objective: objectiveSchema,
summary: t.type({
@ -61,7 +68,7 @@ const updateSLOParamsSchema = t.type({
name: t.string,
description: t.string,
indicator: indicatorSchema,
time_window: rollingTimeWindowSchema,
time_window: timeWindowSchema,
budgeting_method: budgetingMethodSchema,
objective: objectiveSchema,
}),
@ -72,7 +79,7 @@ const updateSLOResponseSchema = t.type({
name: t.string,
description: t.string,
indicator: indicatorSchema,
time_window: rollingTimeWindowSchema,
time_window: timeWindowSchema,
budgeting_method: budgetingMethodSchema,
objective: objectiveSchema,
created_at: dateType,
@ -81,11 +88,18 @@ const updateSLOResponseSchema = t.type({
type CreateSLOParams = t.TypeOf<typeof createSLOParamsSchema.props.body>;
type CreateSLOResponse = t.TypeOf<typeof createSLOResponseSchema>;
type GetSLOResponse = t.TypeOf<typeof getSLOResponseSchema>;
type GetSLOResponse = t.OutputOf<typeof getSLOResponseSchema>;
type UpdateSLOParams = t.TypeOf<typeof updateSLOParamsSchema.props.body>;
type UpdateSLOResponse = t.TypeOf<typeof updateSLOResponseSchema>;
type UpdateSLOResponse = t.OutputOf<typeof updateSLOResponseSchema>;
export { createSLOParamsSchema, deleteSLOParamsSchema, getSLOParamsSchema, updateSLOParamsSchema };
export {
createSLOParamsSchema,
deleteSLOParamsSchema,
getSLOParamsSchema,
getSLOResponseSchema,
updateSLOParamsSchema,
updateSLOResponseSchema,
};
export type {
CreateSLOParams,
CreateSLOResponse,

View file

@ -13,7 +13,7 @@ const ALL_VALUE = '*';
const allOrAnyString = t.union([t.literal(ALL_VALUE), t.string]);
const dateType = new t.Type<Date, string, unknown>(
'DateTime',
'DateType',
(input: unknown): input is Date => input instanceof Date,
(input: unknown, context: t.Context) =>
either.chain(t.string.validate(input, context), (value: string) => {

View file

@ -0,0 +1,48 @@
/*
* 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 { either } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
enum DurationUnit {
'd' = 'd',
'w' = 'w',
'M' = 'M',
'Q' = 'Q',
'Y' = 'Y',
}
class Duration {
constructor(public readonly value: number, public readonly unit: DurationUnit) {
if (isNaN(value) || value <= 0) {
throw new Error('invalid duration value');
}
if (!Object.values(DurationUnit).includes(unit as unknown as DurationUnit)) {
throw new Error('invalid duration unit');
}
}
}
const durationType = new t.Type<Duration, string, unknown>(
'Duration',
(input: unknown): input is Duration => input instanceof Duration,
(input: unknown, context: t.Context) =>
either.chain(t.string.validate(input, context), (value: string) => {
try {
const decoded = new Duration(
parseInt(value.slice(0, -1), 10),
value.slice(-1) as DurationUnit
);
return t.success(decoded);
} catch (err) {
return t.failure(input, context);
}
}),
(duration: Duration): string => `${duration.value}${duration.unit}`
);
export { Duration, DurationUnit, durationType };

View file

@ -8,3 +8,5 @@
export * from './slo';
export * from './common';
export * from './indicators';
export * from './duration';
export * from './time_window';

View file

@ -42,6 +42,8 @@ const apmTransactionErrorRateIndicatorSchema = t.type({
]),
});
const indicatorDataSchema = t.type({ good: t.number, total: t.number });
const indicatorTypesSchema = t.union([
apmTransactionDurationIndicatorTypeSchema,
apmTransactionErrorRateIndicatorTypeSchema,
@ -59,4 +61,5 @@ export {
apmTransactionErrorRateIndicatorTypeSchema,
indicatorSchema,
indicatorTypesSchema,
indicatorDataSchema,
};

View file

@ -0,0 +1,59 @@
/*
* 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';
import { fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { dateType } from './common';
import { Duration, DurationUnit } from './duration';
describe('Schema', () => {
describe('DateType', () => {
it('encodes', () => {
expect(dateType.encode(new Date('2022-06-01T08:00:00.000Z'))).toEqual(
'2022-06-01T08:00:00.000Z'
);
});
it('decodes', () => {
expect(
pipe(
dateType.decode('2022-06-01T08:00:00.000Z'),
fold((e) => {
throw new Error('irrelevant');
}, t.identity)
)
).toEqual(new Date('2022-06-01T08:00:00.000Z'));
});
it('fails decoding when invalid date', () => {
expect(() =>
pipe(
dateType.decode('invalid date'),
fold((e) => {
throw new Error('decode');
}, t.identity)
)
).toThrow(new Error('decode'));
});
});
describe('Duration', () => {
it('throws when value is negative', () => {
expect(() => new Duration(-1, DurationUnit.d)).toThrow('invalid duration value');
});
it('throws when value is zero', () => {
expect(() => new Duration(0, DurationUnit.d)).toThrow('invalid duration value');
});
it('throws when unit is not valid', () => {
expect(() => new Duration(1, 'z' as DurationUnit)).toThrow('invalid duration unit');
});
});
});

View file

@ -7,15 +7,10 @@
import * as t from 'io-ts';
const rollingTimeWindowSchema = t.type({
duration: t.string,
is_rolling: t.literal<boolean>(true),
});
const budgetingMethodSchema = t.literal('occurrences');
const objectiveSchema = t.type({
target: t.number,
});
export { rollingTimeWindowSchema, budgetingMethodSchema, objectiveSchema };
export { budgetingMethodSchema, objectiveSchema };

View file

@ -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';
import { dateType } from './common';
import { durationType } from './duration';
const rollingTimeWindowSchema = t.type({
duration: durationType,
is_rolling: t.literal<boolean>(true),
});
const calendarAlignedTimeWindowSchema = t.type({
duration: durationType,
calendar: t.type({ start_time: dateType }),
});
const timeWindowSchema = t.union([rollingTimeWindowSchema, calendarAlignedTimeWindowSchema]);
export { rollingTimeWindowSchema, calendarAlignedTimeWindowSchema, timeWindowSchema };