mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
feat(slo): Introduce calendar aligned time window (#143190)
This commit is contained in:
parent
b5cb3f6a22
commit
c31c400174
33 changed files with 687 additions and 184 deletions
|
@ -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) => {
|
||||
|
|
|
@ -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) : [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -7,3 +7,4 @@
|
|||
|
||||
export * from './compute_error_budget';
|
||||
export * from './compute_sli';
|
||||
export * from './date_range';
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ const createSLORepositoryMock = (): jest.Mocked<SLORepository> => {
|
|||
|
||||
const createSLIClientMock = (): jest.Mocked<SLIClient> => {
|
||||
return {
|
||||
fetchDataForSLOTimeWindow: jest.fn(),
|
||||
fetchCurrentSLIData: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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' });
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
48
x-pack/plugins/observability/server/types/schema/duration.ts
Normal file
48
x-pack/plugins/observability/server/types/schema/duration.ts
Normal 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 };
|
|
@ -8,3 +8,5 @@
|
|||
export * from './slo';
|
||||
export * from './common';
|
||||
export * from './indicators';
|
||||
export * from './duration';
|
||||
export * from './time_window';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
Loading…
Add table
Add a link
Reference in a new issue