@oneuptime/common 11.6.2 → 11.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/AnalyticsModels/SloHistory.ts +6 -3
- package/Models/DatabaseModels/ServiceLevelObjective.ts +9 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.ts +30 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +12 -4
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +2 -2
- package/Tests/Types/Monitor/MonitorCriteria.test.ts +155 -0
- package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +465 -0
- package/Tests/Types/Monitor/MonitorStep.test.ts +183 -0
- package/Tests/Types/Monitor/MonitorSteps.test.ts +183 -0
- package/Tests/Utils/Memory.test.ts +44 -0
- package/Tests/Utils/Slo/SloBurnRateRuleState.test.ts +124 -0
- package/Tests/Utils/Slo/SloDuration.test.ts +148 -0
- package/Tests/Utils/Slo/SloHealth.test.ts +334 -0
- package/Utils/Slo/SloBurnRateRuleState.ts +91 -0
- package/Utils/Slo/SloDuration.ts +165 -0
- package/Utils/Slo/SloEvaluation.ts +22 -0
- package/Utils/Slo/SloHealth.ts +302 -0
- package/build/dist/Models/AnalyticsModels/SloHistory.js +6 -3
- package/build/dist/Models/AnalyticsModels/SloHistory.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +11 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js +21 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +10 -4
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Utils/Slo/SloBurnRateRuleState.js +35 -0
- package/build/dist/Utils/Slo/SloBurnRateRuleState.js.map +1 -0
- package/build/dist/Utils/Slo/SloDuration.js +87 -0
- package/build/dist/Utils/Slo/SloDuration.js.map +1 -0
- package/build/dist/Utils/Slo/SloEvaluation.js +21 -0
- package/build/dist/Utils/Slo/SloEvaluation.js.map +1 -0
- package/build/dist/Utils/Slo/SloHealth.js +179 -0
- package/build/dist/Utils/Slo/SloHealth.js.map +1 -0
- package/package.json +1 -1
|
@@ -42,8 +42,11 @@ import Permission from "../../Types/Permission";
|
|
|
42
42
|
* dashboard charts do) or dedupe explicitly via
|
|
43
43
|
* `argMax(value, version) ... GROUP BY <identity>`.
|
|
44
44
|
*
|
|
45
|
-
* `metricName` values: "sli.percent",
|
|
46
|
-
* "
|
|
45
|
+
* `metricName` values written today: "sli.percent",
|
|
46
|
+
* "error.budget.remaining.percent" and "burn.rate" — the three gauges the
|
|
47
|
+
* evaluation worker persists per run. "good.count" / "total.count" are
|
|
48
|
+
* reserved for event-based (Metric) SLIs, which the worker does not
|
|
49
|
+
* evaluate yet; nothing writes or reads them.
|
|
47
50
|
*/
|
|
48
51
|
export default class SloHistory extends AnalyticsBaseModel {
|
|
49
52
|
public constructor() {
|
|
@@ -87,7 +90,7 @@ export default class SloHistory extends AnalyticsBaseModel {
|
|
|
87
90
|
isLowCardinality: true,
|
|
88
91
|
title: "Metric Name",
|
|
89
92
|
description:
|
|
90
|
-
"Which SLO series this row belongs to: sli.percent, error.budget.remaining.percent, burn.rate
|
|
93
|
+
"Which SLO series this row belongs to: sli.percent, error.budget.remaining.percent, or burn.rate.",
|
|
91
94
|
required: true,
|
|
92
95
|
type: TableColumnType.Text,
|
|
93
96
|
accessControl: {
|
|
@@ -12,6 +12,7 @@ import AccessControlColumn from "../../Types/Database/AccessControlColumn";
|
|
|
12
12
|
import ColumnLength from "../../Types/Database/ColumnLength";
|
|
13
13
|
import ColumnType from "../../Types/Database/ColumnType";
|
|
14
14
|
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
|
15
|
+
import EnableAuditLog from "../../Types/Database/EnableAuditLog";
|
|
15
16
|
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
|
|
16
17
|
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
|
|
17
18
|
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
|
|
@@ -72,6 +73,14 @@ const decimalTransformer: ValueTransformer = {
|
|
|
72
73
|
* an owner of the SLO they just created).
|
|
73
74
|
*/
|
|
74
75
|
@OperationalResource()
|
|
76
|
+
/*
|
|
77
|
+
* An SLO's target, window and monitor set are the definition of what
|
|
78
|
+
* "reliable enough" means for a service, so a change to any of them
|
|
79
|
+
* silently rewrites history: budgets recompute against the new
|
|
80
|
+
* definition on the worker's next tick. Recording those edits is how a
|
|
81
|
+
* team answers "did the SLO get better, or did someone move the target?".
|
|
82
|
+
*/
|
|
83
|
+
@EnableAuditLog()
|
|
75
84
|
@EnableDocumentation()
|
|
76
85
|
@AccessControlColumn("labels")
|
|
77
86
|
@TenantColumn("projectId")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
2
|
+
|
|
3
|
+
export class MigrationName1785066759532 implements MigrationInterface {
|
|
4
|
+
public name = "MigrationName1785066759532";
|
|
5
|
+
|
|
6
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
7
|
+
/*
|
|
8
|
+
* 1784200000000 added these two as `text`, but the entity declares them
|
|
9
|
+
* ColumnType.LongText, which maps to `varchar`. The generator wants to
|
|
10
|
+
* DROP and re-ADD the columns; an in-place TYPE change reaches the same
|
|
11
|
+
* schema without discarding the SNMP v3 credentials already stored on
|
|
12
|
+
* existing discovery scans.
|
|
13
|
+
*/
|
|
14
|
+
await queryRunner.query(
|
|
15
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3AuthKey" TYPE character varying`,
|
|
16
|
+
);
|
|
17
|
+
await queryRunner.query(
|
|
18
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3PrivKey" TYPE character varying`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
23
|
+
await queryRunner.query(
|
|
24
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3PrivKey" TYPE text`,
|
|
25
|
+
);
|
|
26
|
+
await queryRunner.query(
|
|
27
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3AuthKey" TYPE text`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -469,6 +469,7 @@ import { AddNetworkSiteAlerting1784897317860 } from "./1784897317860-AddNetworkS
|
|
|
469
469
|
import { AddNetworkDevicePollingColumns1784970388777 } from "./1784970388777-AddNetworkDevicePollingColumns";
|
|
470
470
|
import { AddNetworkSiteTypeTable1784986826214 } from "./1784986826214-AddNetworkSiteTypeTable";
|
|
471
471
|
import { AddServiceLevelObjective1784987015619 } from "./1784987015619-AddServiceLevelObjective";
|
|
472
|
+
import { MigrationName1785066759532 } from "./1785066759532-MigrationName";
|
|
472
473
|
|
|
473
474
|
export default [
|
|
474
475
|
InitialMigration,
|
|
@@ -942,4 +943,5 @@ export default [
|
|
|
942
943
|
AddNetworkDevicePollingColumns1784970388777,
|
|
943
944
|
AddNetworkSiteTypeTable1784986826214,
|
|
944
945
|
AddServiceLevelObjective1784987015619,
|
|
946
|
+
MigrationName1785066759532,
|
|
945
947
|
];
|
|
@@ -650,8 +650,15 @@ export class Service extends DatabaseService<Model> {
|
|
|
650
650
|
private validateWindowDays(value: unknown): number {
|
|
651
651
|
const windowDays: number = this.normalizeNumericInput(value);
|
|
652
652
|
|
|
653
|
-
|
|
654
|
-
|
|
653
|
+
/*
|
|
654
|
+
* The column is a Postgres integer, so a decimal that cleared only the
|
|
655
|
+
* range check would fail the INSERT with a raw driver error instead of
|
|
656
|
+
* this message.
|
|
657
|
+
*/
|
|
658
|
+
if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 366) {
|
|
659
|
+
throw new BadDataException(
|
|
660
|
+
"SLO window must be a whole number of days between 1 and 366.",
|
|
661
|
+
);
|
|
655
662
|
}
|
|
656
663
|
|
|
657
664
|
return windowDays;
|
|
@@ -660,13 +667,14 @@ export class Service extends DatabaseService<Model> {
|
|
|
660
667
|
private validateAtRiskThresholdPercentage(value: unknown): number {
|
|
661
668
|
const atRiskThresholdPercentage: number = this.normalizeNumericInput(value);
|
|
662
669
|
|
|
670
|
+
// Integer column — see validateWindowDays.
|
|
663
671
|
if (
|
|
664
|
-
!Number.
|
|
672
|
+
!Number.isInteger(atRiskThresholdPercentage) ||
|
|
665
673
|
atRiskThresholdPercentage < 0 ||
|
|
666
674
|
atRiskThresholdPercentage > 100
|
|
667
675
|
) {
|
|
668
676
|
throw new BadDataException(
|
|
669
|
-
"SLO at-risk threshold must be a percentage between 0 and 100.",
|
|
677
|
+
"SLO at-risk threshold must be a whole percentage between 0 and 100.",
|
|
670
678
|
);
|
|
671
679
|
}
|
|
672
680
|
|
|
@@ -62,9 +62,9 @@ const RESOLVED_STATE_ID: ObjectID = new ObjectID(
|
|
|
62
62
|
const TARGET_PERCENTAGE_ERROR_MESSAGE: string =
|
|
63
63
|
"SLO target must be greater than 0 and at most 99.999. A 100% target leaves no error budget.";
|
|
64
64
|
const WINDOW_DAYS_ERROR_MESSAGE: string =
|
|
65
|
-
"SLO window must be between 1 and 366
|
|
65
|
+
"SLO window must be a whole number of days between 1 and 366.";
|
|
66
66
|
const AT_RISK_THRESHOLD_ERROR_MESSAGE: string =
|
|
67
|
-
"SLO at-risk threshold must be a percentage between 0 and 100.";
|
|
67
|
+
"SLO at-risk threshold must be a whole percentage between 0 and 100.";
|
|
68
68
|
|
|
69
69
|
/*
|
|
70
70
|
* Every column getDueSlos must hand to the evaluation worker. Enumerated here
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import MonitorCriteria from "../../../Types/Monitor/MonitorCriteria";
|
|
2
|
+
import MonitorCriteriaInstance from "../../../Types/Monitor/MonitorCriteriaInstance";
|
|
3
|
+
import MonitorType from "../../../Types/Monitor/MonitorType";
|
|
4
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
5
|
+
import { JSONObject, ObjectType } from "../../../Types/JSON";
|
|
6
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* MonitorCriteria wraps the ordered list of MonitorCriteriaInstance that a
|
|
10
|
+
* monitor step evaluates. These tests lock in the default builder (which
|
|
11
|
+
* composes the online + offline instances), the validation contract (which
|
|
12
|
+
* delegates to each instance), and the toJSON/fromJSON round-trip.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ARG: {
|
|
16
|
+
monitorType: MonitorType;
|
|
17
|
+
monitorName: string;
|
|
18
|
+
onlineMonitorStatusId: ObjectID;
|
|
19
|
+
offlineMonitorStatusId: ObjectID;
|
|
20
|
+
defaultIncidentSeverityId: ObjectID;
|
|
21
|
+
defaultAlertSeverityId: ObjectID;
|
|
22
|
+
} = {
|
|
23
|
+
monitorType: MonitorType.Ping,
|
|
24
|
+
monitorName: "Gateway",
|
|
25
|
+
onlineMonitorStatusId: new ObjectID("100000000000000000000011"),
|
|
26
|
+
offlineMonitorStatusId: new ObjectID("100000000000000000000012"),
|
|
27
|
+
defaultIncidentSeverityId: new ObjectID("100000000000000000000013"),
|
|
28
|
+
defaultAlertSeverityId: new ObjectID("100000000000000000000014"),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("MonitorCriteria", () => {
|
|
32
|
+
describe("getDefaultMonitorCriteria", () => {
|
|
33
|
+
test("composes both an offline and an online instance for Ping", () => {
|
|
34
|
+
const criteria: MonitorCriteria =
|
|
35
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
36
|
+
|
|
37
|
+
// Ping has both an offline (Disallow) and an online criteria instance.
|
|
38
|
+
expect(criteria.data?.monitorCriteriaInstanceArray).toHaveLength(2);
|
|
39
|
+
for (const instance of criteria.data!.monitorCriteriaInstanceArray) {
|
|
40
|
+
expect(instance).toBeInstanceOf(MonitorCriteriaInstance);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the default Ping criteria passes validation", () => {
|
|
45
|
+
const criteria: MonitorCriteria =
|
|
46
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
47
|
+
expect(
|
|
48
|
+
MonitorCriteria.getValidationError(criteria, MonitorType.Ping),
|
|
49
|
+
).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("getValidationError", () => {
|
|
54
|
+
test("errors when data is missing", () => {
|
|
55
|
+
const criteria: MonitorCriteria = new MonitorCriteria();
|
|
56
|
+
criteria.data = undefined;
|
|
57
|
+
expect(
|
|
58
|
+
MonitorCriteria.getValidationError(criteria, MonitorType.Ping),
|
|
59
|
+
).toBe("Monitor Criteria is required");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("errors when the instance array is empty", () => {
|
|
63
|
+
const criteria: MonitorCriteria = new MonitorCriteria();
|
|
64
|
+
criteria.data = { monitorCriteriaInstanceArray: [] };
|
|
65
|
+
expect(
|
|
66
|
+
MonitorCriteria.getValidationError(criteria, MonitorType.Ping),
|
|
67
|
+
).toBe("Monitor Criteria is required");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("propagates a validation error from a contained instance", () => {
|
|
71
|
+
const criteria: MonitorCriteria =
|
|
72
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
73
|
+
// Corrupt a contained instance: strip its required name.
|
|
74
|
+
criteria.data!.monitorCriteriaInstanceArray[0]!.data!.name = "";
|
|
75
|
+
const error: string | null = MonitorCriteria.getValidationError(
|
|
76
|
+
criteria,
|
|
77
|
+
MonitorType.Ping,
|
|
78
|
+
);
|
|
79
|
+
expect(error).toContain("Name is required");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("getNewMonitorCriteriaAsJSON", () => {
|
|
84
|
+
test("wraps a single default instance", () => {
|
|
85
|
+
const json: JSONObject = MonitorCriteria.getNewMonitorCriteriaAsJSON();
|
|
86
|
+
expect(json["_type"]).toBe("MonitorCriteria");
|
|
87
|
+
const value: JSONObject = json["value"] as JSONObject;
|
|
88
|
+
expect(Array.isArray(value["monitorCriteriaInstanceArray"])).toBe(true);
|
|
89
|
+
expect(
|
|
90
|
+
(value["monitorCriteriaInstanceArray"] as Array<unknown>).length,
|
|
91
|
+
).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("toJSON / fromJSON round-trip", () => {
|
|
96
|
+
test("serializes with the MonitorCriteria type wrapper", () => {
|
|
97
|
+
const criteria: MonitorCriteria =
|
|
98
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
99
|
+
const json: JSONObject = criteria.toJSON();
|
|
100
|
+
expect(json["_type"]).toBe(ObjectType.MonitorCriteria);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("round-trips the instance count", () => {
|
|
104
|
+
const criteria: MonitorCriteria =
|
|
105
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
106
|
+
const restored: MonitorCriteria = MonitorCriteria.fromJSON(
|
|
107
|
+
criteria.toJSON(),
|
|
108
|
+
);
|
|
109
|
+
expect(restored.data?.monitorCriteriaInstanceArray).toHaveLength(2);
|
|
110
|
+
expect(restored.data?.monitorCriteriaInstanceArray[0]).toBeInstanceOf(
|
|
111
|
+
MonitorCriteriaInstance,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("toString returns a JSON string of the serialized value", () => {
|
|
116
|
+
const criteria: MonitorCriteria =
|
|
117
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
118
|
+
const parsed: JSONObject = JSON.parse(criteria.toString()) as JSONObject;
|
|
119
|
+
expect(parsed["_type"]).toBe(ObjectType.MonitorCriteria);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("fromJSON validation", () => {
|
|
124
|
+
test("returns the same instance when given a MonitorCriteria", () => {
|
|
125
|
+
const criteria: MonitorCriteria =
|
|
126
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
127
|
+
expect(MonitorCriteria.fromJSON(criteria as unknown as JSONObject)).toBe(
|
|
128
|
+
criteria,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("throws on a wrong _type", () => {
|
|
133
|
+
expect(() => {
|
|
134
|
+
return MonitorCriteria.fromJSON({ _type: "Nope", value: {} });
|
|
135
|
+
}).toThrow(BadDataException);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("throws when value is missing", () => {
|
|
139
|
+
expect(() => {
|
|
140
|
+
return MonitorCriteria.fromJSON({
|
|
141
|
+
_type: ObjectType.MonitorCriteria,
|
|
142
|
+
});
|
|
143
|
+
}).toThrow(BadDataException);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("throws when monitorCriteriaInstanceArray is missing", () => {
|
|
147
|
+
expect(() => {
|
|
148
|
+
return MonitorCriteria.fromJSON({
|
|
149
|
+
_type: ObjectType.MonitorCriteria,
|
|
150
|
+
value: {},
|
|
151
|
+
});
|
|
152
|
+
}).toThrow(BadDataException);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|