@oneuptime/common 14.0.5 → 14.0.6
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/Server/Services/MetricService.ts +1 -1
- package/Server/Types/Database/QueryHelper.ts +54 -1
- package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
- package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
- package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
- package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
- package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
- package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
- package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
- package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
- package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
- package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
- package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
- package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
- package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
- package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
- package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
- package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
- package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
- package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
- package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
- package/Types/BaseDatabase/AggregationType.ts +7 -0
- package/Types/Dashboard/DashboardTemplates.ts +20 -7
- package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
- package/Types/Rum/WebVitals.ts +13 -0
- package/UI/Components/Input/Input.tsx +11 -0
- package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
- package/build/dist/Server/Services/MetricService.js +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +12 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +1 -1
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import AggregationType from "../../../Types/BaseDatabase/AggregationType";
|
|
2
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
3
|
+
import KubernetesClusterCpuTileRepair, {
|
|
4
|
+
BUSIEST_NODE_CPU_TILE_TITLE,
|
|
5
|
+
KUBERNETES_NODE_CPU_USAGE_METRIC,
|
|
6
|
+
KubernetesClusterCpuTileRepairResult,
|
|
7
|
+
LEGACY_CLUSTER_CPU_TILE_TITLE,
|
|
8
|
+
} from "../../../Utils/Dashboard/KubernetesClusterCpuTileRepair";
|
|
9
|
+
import { describe, expect, test } from "@jest/globals";
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The repair behind the RepairKubernetesDashboardClusterCpuTile migration.
|
|
13
|
+
* Existing Kubernetes dashboards hold a COPY of the template, so their
|
|
14
|
+
* "Cluster CPU (cores in use)" tile still sums k8s.node.cpu.usage over every
|
|
15
|
+
* node, scrape and bucket. What is pinned here: exactly which tiles are
|
|
16
|
+
* rewritten, to what, that nothing else on the dashboard moves, and that
|
|
17
|
+
* the Postgres filter the migration selects rows with agrees with the
|
|
18
|
+
* in-memory matcher.
|
|
19
|
+
*
|
|
20
|
+
* Fixtures are shaped like a stored dashboardViewConfig: plain jsonb, with
|
|
21
|
+
* componentId serialised as { _type: "ObjectID", value } and the
|
|
22
|
+
* misspelled `aggegationType` the fetch layer reads.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function makeValueTile(data: {
|
|
26
|
+
componentId?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
metricName?: string;
|
|
29
|
+
aggregation?: string;
|
|
30
|
+
extraArguments?: JSONObject;
|
|
31
|
+
extraFilterData?: JSONObject;
|
|
32
|
+
}): JSONObject {
|
|
33
|
+
return {
|
|
34
|
+
_type: "DashboardComponent",
|
|
35
|
+
componentType: "Value",
|
|
36
|
+
componentId: {
|
|
37
|
+
_type: "ObjectID",
|
|
38
|
+
value: data.componentId || "0b6a2c3e-0000-4000-8000-000000000001",
|
|
39
|
+
},
|
|
40
|
+
topInDashboardUnits: 11,
|
|
41
|
+
leftInDashboardUnits: 0,
|
|
42
|
+
widthInDashboardUnits: 4,
|
|
43
|
+
heightInDashboardUnits: 1,
|
|
44
|
+
minHeightInDashboardUnits: 1,
|
|
45
|
+
minWidthInDashboardUnits: 1,
|
|
46
|
+
arguments: {
|
|
47
|
+
title: data.title ?? LEGACY_CLUSTER_CPU_TILE_TITLE,
|
|
48
|
+
metricQueryConfig: {
|
|
49
|
+
metricQueryData: {
|
|
50
|
+
filterData: {
|
|
51
|
+
metricName: data.metricName ?? KUBERNETES_NODE_CPU_USAGE_METRIC,
|
|
52
|
+
aggegationType: data.aggregation ?? AggregationType.Sum,
|
|
53
|
+
...(data.extraFilterData || {}),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
trendDirection: "HigherIsWorse",
|
|
58
|
+
...(data.extraArguments || {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeConfig(components: Array<unknown>): JSONObject {
|
|
64
|
+
return {
|
|
65
|
+
_type: "DashboardViewConfig",
|
|
66
|
+
heightInDashboardUnits: 21,
|
|
67
|
+
variables: [
|
|
68
|
+
{
|
|
69
|
+
id: "cluster",
|
|
70
|
+
name: "Cluster",
|
|
71
|
+
type: "Telemetry Attribute",
|
|
72
|
+
attributeKey: "resource.k8s.cluster.name",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
components: components as Array<JSONObject>,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function componentsOf(config: JSONObject): Array<JSONObject> {
|
|
80
|
+
return config["components"] as Array<JSONObject>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function filterDataOf(component: JSONObject): JSONObject {
|
|
84
|
+
const args: JSONObject = component["arguments"] as JSONObject;
|
|
85
|
+
const metricQueryConfig: JSONObject = args["metricQueryConfig"] as JSONObject;
|
|
86
|
+
const metricQueryData: JSONObject = metricQueryConfig[
|
|
87
|
+
"metricQueryData"
|
|
88
|
+
] as JSONObject;
|
|
89
|
+
|
|
90
|
+
return metricQueryData["filterData"] as JSONObject;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* Postgres `@>` for jsonb, as documented (8.14.3): an object contains
|
|
95
|
+
* another when every key of the right side is present and contains its
|
|
96
|
+
* value; an array contains another when every element of the right side is
|
|
97
|
+
* contained by SOME element of the left; scalars must be equal. Used only to
|
|
98
|
+
* show that the migration's SQL filter and isLegacyTile pick the same rows.
|
|
99
|
+
*/
|
|
100
|
+
function jsonbContains(left: unknown, right: unknown): boolean {
|
|
101
|
+
if (Array.isArray(right)) {
|
|
102
|
+
return (
|
|
103
|
+
Array.isArray(left) &&
|
|
104
|
+
right.every((rightItem: unknown) => {
|
|
105
|
+
return left.some((leftItem: unknown) => {
|
|
106
|
+
return jsonbContains(leftItem, rightItem);
|
|
107
|
+
});
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (right && typeof right === "object") {
|
|
113
|
+
if (!left || typeof left !== "object" || Array.isArray(left)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return Object.entries(right as JSONObject).every(
|
|
118
|
+
([key, value]: [string, unknown]) => {
|
|
119
|
+
return (
|
|
120
|
+
Object.prototype.hasOwnProperty.call(left, key) &&
|
|
121
|
+
jsonbContains((left as JSONObject)[key], value)
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return left === right;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TEXT_HEADER: JSONObject = {
|
|
131
|
+
_type: "DashboardComponent",
|
|
132
|
+
componentType: "Text",
|
|
133
|
+
componentId: { _type: "ObjectID", value: "text-header" },
|
|
134
|
+
arguments: { text: "Resource Health", isBold: true },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const NETWORK_CHART: JSONObject = {
|
|
138
|
+
_type: "DashboardComponent",
|
|
139
|
+
componentType: "Chart",
|
|
140
|
+
componentId: { _type: "ObjectID", value: "network-chart" },
|
|
141
|
+
arguments: {
|
|
142
|
+
chartTitle: "Network I/O",
|
|
143
|
+
metricQueryConfig: {
|
|
144
|
+
metricQueryData: {
|
|
145
|
+
filterData: {
|
|
146
|
+
metricName: "k8s.pod.network.io",
|
|
147
|
+
aggegationType: AggregationType.Sum,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
describe("KubernetesClusterCpuTileRepair", () => {
|
|
155
|
+
describe("repair", () => {
|
|
156
|
+
test("rewrites the legacy tile to the busiest node's peak CPU", () => {
|
|
157
|
+
const result: KubernetesClusterCpuTileRepairResult | null =
|
|
158
|
+
KubernetesClusterCpuTileRepair.repair(
|
|
159
|
+
makeConfig([TEXT_HEADER, makeValueTile({}), NETWORK_CHART]),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(result).not.toBeNull();
|
|
163
|
+
expect(result!.repairedTileCount).toBe(1);
|
|
164
|
+
|
|
165
|
+
const tile: JSONObject = componentsOf(result!.config)[1]!;
|
|
166
|
+
expect((tile["arguments"] as JSONObject)["title"]).toBe(
|
|
167
|
+
BUSIEST_NODE_CPU_TILE_TITLE,
|
|
168
|
+
);
|
|
169
|
+
expect(filterDataOf(tile)["aggegationType"]).toBe(AggregationType.Max);
|
|
170
|
+
expect(filterDataOf(tile)["metricName"]).toBe(
|
|
171
|
+
KUBERNETES_NODE_CPU_USAGE_METRIC,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("changes only the title and the aggregation", () => {
|
|
176
|
+
const original: JSONObject = makeConfig([
|
|
177
|
+
TEXT_HEADER,
|
|
178
|
+
makeValueTile({
|
|
179
|
+
extraArguments: { warningThreshold: 3, criticalThreshold: 6 },
|
|
180
|
+
extraFilterData: { attributes: { "resource.k8s.node.name": "a" } },
|
|
181
|
+
}),
|
|
182
|
+
NETWORK_CHART,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const expected: JSONObject = JSON.parse(JSON.stringify(original));
|
|
186
|
+
const expectedTile: JSONObject = componentsOf(expected)[1]!;
|
|
187
|
+
(expectedTile["arguments"] as JSONObject)["title"] =
|
|
188
|
+
BUSIEST_NODE_CPU_TILE_TITLE;
|
|
189
|
+
filterDataOf(expectedTile)["aggegationType"] = AggregationType.Max;
|
|
190
|
+
|
|
191
|
+
expect(KubernetesClusterCpuTileRepair.repair(original)!.config).toEqual(
|
|
192
|
+
expected,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("never mutates the config it was given", () => {
|
|
197
|
+
const original: JSONObject = makeConfig([makeValueTile({})]);
|
|
198
|
+
const snapshot: string = JSON.stringify(original);
|
|
199
|
+
|
|
200
|
+
KubernetesClusterCpuTileRepair.repair(original);
|
|
201
|
+
|
|
202
|
+
expect(JSON.stringify(original)).toBe(snapshot);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("repairs every copy of the tile on one dashboard", () => {
|
|
206
|
+
const result: KubernetesClusterCpuTileRepairResult | null =
|
|
207
|
+
KubernetesClusterCpuTileRepair.repair(
|
|
208
|
+
makeConfig([
|
|
209
|
+
makeValueTile({ componentId: "first" }),
|
|
210
|
+
makeValueTile({ componentId: "second" }),
|
|
211
|
+
]),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(result!.repairedTileCount).toBe(2);
|
|
215
|
+
for (const tile of componentsOf(result!.config)) {
|
|
216
|
+
expect(filterDataOf(tile)["aggegationType"]).toBe(AggregationType.Max);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("is a no-op on its own output, so a second run writes nothing", () => {
|
|
221
|
+
const once: KubernetesClusterCpuTileRepairResult | null =
|
|
222
|
+
KubernetesClusterCpuTileRepair.repair(makeConfig([makeValueTile({})]));
|
|
223
|
+
|
|
224
|
+
expect(once).not.toBeNull();
|
|
225
|
+
expect(KubernetesClusterCpuTileRepair.repair(once!.config)).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/*
|
|
229
|
+
* The migration only corrects what the template put there. A user who
|
|
230
|
+
* renamed the tile, re-aggregated it, pointed it at another metric or
|
|
231
|
+
* drew the same query as a chart made that choice themselves.
|
|
232
|
+
*/
|
|
233
|
+
test.each([
|
|
234
|
+
["a renamed tile", makeValueTile({ title: "All Nodes CPU" })],
|
|
235
|
+
[
|
|
236
|
+
"a title that differs only in whitespace",
|
|
237
|
+
makeValueTile({ title: ` ${LEGACY_CLUSTER_CPU_TILE_TITLE}` }),
|
|
238
|
+
],
|
|
239
|
+
[
|
|
240
|
+
"a tile already re-aggregated to Avg",
|
|
241
|
+
makeValueTile({ aggregation: AggregationType.Avg }),
|
|
242
|
+
],
|
|
243
|
+
[
|
|
244
|
+
"a tile already re-aggregated to Max",
|
|
245
|
+
makeValueTile({ aggregation: AggregationType.Max }),
|
|
246
|
+
],
|
|
247
|
+
[
|
|
248
|
+
"a Sum tile over another metric",
|
|
249
|
+
makeValueTile({ metricName: "k8s.pod.cpu.usage" }),
|
|
250
|
+
],
|
|
251
|
+
[
|
|
252
|
+
"a chart with the same title, metric and Sum",
|
|
253
|
+
{ ...makeValueTile({}), componentType: "Chart" },
|
|
254
|
+
],
|
|
255
|
+
["a text widget", TEXT_HEADER],
|
|
256
|
+
])("leaves %s alone", (_label: string, component: JSONObject) => {
|
|
257
|
+
expect(
|
|
258
|
+
KubernetesClusterCpuTileRepair.repair(makeConfig([component])),
|
|
259
|
+
).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test.each([
|
|
263
|
+
["undefined", undefined],
|
|
264
|
+
["null", null],
|
|
265
|
+
["a string", "not a config"],
|
|
266
|
+
["an array", [makeValueTile({})]],
|
|
267
|
+
["a config without components", { _type: "DashboardViewConfig" }],
|
|
268
|
+
[
|
|
269
|
+
"a config whose components are not an array",
|
|
270
|
+
{ components: { 0: makeValueTile({}) } },
|
|
271
|
+
],
|
|
272
|
+
["a config with no components", makeConfig([])],
|
|
273
|
+
])("returns null for %s", (_label: string, config: unknown) => {
|
|
274
|
+
expect(KubernetesClusterCpuTileRepair.repair(config)).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("skips malformed components without throwing", () => {
|
|
278
|
+
const result: KubernetesClusterCpuTileRepairResult | null =
|
|
279
|
+
KubernetesClusterCpuTileRepair.repair(
|
|
280
|
+
makeConfig([
|
|
281
|
+
null,
|
|
282
|
+
"text",
|
|
283
|
+
42,
|
|
284
|
+
{ componentType: "Value" },
|
|
285
|
+
{ componentType: "Value", arguments: null },
|
|
286
|
+
{
|
|
287
|
+
componentType: "Value",
|
|
288
|
+
arguments: { title: LEGACY_CLUSTER_CPU_TILE_TITLE },
|
|
289
|
+
},
|
|
290
|
+
makeValueTile({}),
|
|
291
|
+
]),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(result!.repairedTileCount).toBe(1);
|
|
295
|
+
expect(componentsOf(result!.config).slice(0, 6)).toEqual([
|
|
296
|
+
null,
|
|
297
|
+
"text",
|
|
298
|
+
42,
|
|
299
|
+
{ componentType: "Value" },
|
|
300
|
+
{ componentType: "Value", arguments: null },
|
|
301
|
+
{
|
|
302
|
+
componentType: "Value",
|
|
303
|
+
arguments: { title: LEGACY_CLUSTER_CPU_TILE_TITLE },
|
|
304
|
+
},
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("getLegacyTileContainment", () => {
|
|
310
|
+
/*
|
|
311
|
+
* The migration reads only the dashboards Postgres matches with this
|
|
312
|
+
* document, then repairs them with isLegacyTile. If the two disagree,
|
|
313
|
+
* a dashboard is either never read or read and left unrepaired.
|
|
314
|
+
*/
|
|
315
|
+
test.each([
|
|
316
|
+
["the legacy tile", makeConfig([TEXT_HEADER, makeValueTile({})]), true],
|
|
317
|
+
[
|
|
318
|
+
"the legacy tile with user thresholds and filters",
|
|
319
|
+
makeConfig([
|
|
320
|
+
makeValueTile({
|
|
321
|
+
extraArguments: { warningThreshold: 3 },
|
|
322
|
+
extraFilterData: { attributes: { "resource.k8s.node.name": "a" } },
|
|
323
|
+
}),
|
|
324
|
+
]),
|
|
325
|
+
true,
|
|
326
|
+
],
|
|
327
|
+
[
|
|
328
|
+
"a repaired tile",
|
|
329
|
+
makeConfig([
|
|
330
|
+
makeValueTile({
|
|
331
|
+
title: BUSIEST_NODE_CPU_TILE_TITLE,
|
|
332
|
+
aggregation: AggregationType.Max,
|
|
333
|
+
}),
|
|
334
|
+
]),
|
|
335
|
+
false,
|
|
336
|
+
],
|
|
337
|
+
[
|
|
338
|
+
"the title on one tile and the Sum on another",
|
|
339
|
+
makeConfig([
|
|
340
|
+
makeValueTile({ aggregation: AggregationType.Avg }),
|
|
341
|
+
makeValueTile({ title: "All Nodes CPU" }),
|
|
342
|
+
]),
|
|
343
|
+
false,
|
|
344
|
+
],
|
|
345
|
+
[
|
|
346
|
+
"a chart with the legacy signature",
|
|
347
|
+
makeConfig([{ ...makeValueTile({}), componentType: "Chart" }]),
|
|
348
|
+
false,
|
|
349
|
+
],
|
|
350
|
+
[
|
|
351
|
+
"an unrelated dashboard",
|
|
352
|
+
makeConfig([TEXT_HEADER, NETWORK_CHART]),
|
|
353
|
+
false,
|
|
354
|
+
],
|
|
355
|
+
])(
|
|
356
|
+
"agrees with repair() on %s",
|
|
357
|
+
(_label: string, config: JSONObject, shouldMatch: boolean) => {
|
|
358
|
+
expect(
|
|
359
|
+
jsonbContains(
|
|
360
|
+
config,
|
|
361
|
+
KubernetesClusterCpuTileRepair.getLegacyTileContainment(),
|
|
362
|
+
),
|
|
363
|
+
).toBe(shouldMatch);
|
|
364
|
+
expect(KubernetesClusterCpuTileRepair.repair(config) !== null).toBe(
|
|
365
|
+
shouldMatch,
|
|
366
|
+
);
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getMonitorTypeCriteriaValidationError,
|
|
3
|
+
isMonitorTypeCriteriaValue,
|
|
4
|
+
} from "../../../Utils/Rules/MonitorTypeRuleCriteria";
|
|
5
|
+
import MonitorType from "../../../Types/Monitor/MonitorType";
|
|
6
|
+
import {
|
|
7
|
+
RuleCriteriaFilter,
|
|
8
|
+
RuleCriteriaOperator,
|
|
9
|
+
RuleCriteriaValue,
|
|
10
|
+
} from "../../../Types/Rules/RuleCriteria";
|
|
11
|
+
import { describe, expect, test } from "@jest/globals";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The gate that decides whether a rule's `monitorType` criterion may be saved.
|
|
15
|
+
* DatabaseService runs it on every write and the SLO monitor-rule engine runs
|
|
16
|
+
* it again when it matches monitors, so a criterion this lets through has to
|
|
17
|
+
* be one the engine can actually evaluate: a monitor type is an enum choice,
|
|
18
|
+
* which means an exact match either way and nothing else. A text operator
|
|
19
|
+
* slipping past here is a rule that silently matches nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function buildFilter(
|
|
23
|
+
operator: RuleCriteriaOperator,
|
|
24
|
+
value: RuleCriteriaValue,
|
|
25
|
+
): RuleCriteriaFilter {
|
|
26
|
+
return { field: "monitorType", operator: operator, value: value };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const OPERATOR_ERROR: string =
|
|
30
|
+
"Monitor type criteria only support Equals and Does not equal operators.";
|
|
31
|
+
const VALUE_ERROR: string =
|
|
32
|
+
"Monitor type criteria require a valid monitor type.";
|
|
33
|
+
|
|
34
|
+
describe("isMonitorTypeCriteriaValue", () => {
|
|
35
|
+
test("accepts every monitor type the product defines", () => {
|
|
36
|
+
const allTypes: Array<string> = Object.values(MonitorType);
|
|
37
|
+
expect(allTypes.length).toBeGreaterThan(0);
|
|
38
|
+
for (const monitorType of allTypes) {
|
|
39
|
+
expect(isMonitorTypeCriteriaValue(monitorType)).toBe(true);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects a string that is not a monitor type", () => {
|
|
44
|
+
expect(isMonitorTypeCriteriaValue("Website")).toBe(
|
|
45
|
+
Object.values(MonitorType).includes("Website" as MonitorType),
|
|
46
|
+
);
|
|
47
|
+
expect(isMonitorTypeCriteriaValue("NotAMonitorType")).toBe(false);
|
|
48
|
+
expect(isMonitorTypeCriteriaValue("")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("is case- and whitespace-sensitive, as an enum comparison must be", () => {
|
|
52
|
+
const [first] = Object.values(MonitorType) as Array<string>;
|
|
53
|
+
expect(isMonitorTypeCriteriaValue(first!)).toBe(true);
|
|
54
|
+
expect(isMonitorTypeCriteriaValue(first!.toLowerCase())).toBe(
|
|
55
|
+
first === first!.toLowerCase(),
|
|
56
|
+
);
|
|
57
|
+
expect(isMonitorTypeCriteriaValue(` ${first!}`)).toBe(false);
|
|
58
|
+
expect(isMonitorTypeCriteriaValue(`${first!} `)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("rejects every non-string value a criterion can carry", () => {
|
|
62
|
+
const nonStrings: Array<unknown> = [
|
|
63
|
+
undefined,
|
|
64
|
+
null,
|
|
65
|
+
0,
|
|
66
|
+
1,
|
|
67
|
+
true,
|
|
68
|
+
false,
|
|
69
|
+
[],
|
|
70
|
+
[Object.values(MonitorType)[0]],
|
|
71
|
+
{},
|
|
72
|
+
{ value: Object.values(MonitorType)[0] },
|
|
73
|
+
];
|
|
74
|
+
for (const value of nonStrings) {
|
|
75
|
+
expect(isMonitorTypeCriteriaValue(value)).toBe(false);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("does not accept an inherited Object property as a monitor type", () => {
|
|
80
|
+
// The lookup is a Set, not a plain object, so "toString" is not a member.
|
|
81
|
+
expect(isMonitorTypeCriteriaValue("toString")).toBe(false);
|
|
82
|
+
expect(isMonitorTypeCriteriaValue("constructor")).toBe(false);
|
|
83
|
+
expect(isMonitorTypeCriteriaValue("__proto__")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("getMonitorTypeCriteriaValidationError", () => {
|
|
88
|
+
const validType: MonitorType = Object.values(MonitorType)[0] as MonitorType;
|
|
89
|
+
|
|
90
|
+
test("passes Equals and NotEquals against a real monitor type", () => {
|
|
91
|
+
for (const operator of [
|
|
92
|
+
RuleCriteriaOperator.Equals,
|
|
93
|
+
RuleCriteriaOperator.NotEquals,
|
|
94
|
+
]) {
|
|
95
|
+
expect(
|
|
96
|
+
getMonitorTypeCriteriaValidationError(buildFilter(operator, validType)),
|
|
97
|
+
).toBeNull();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("passes every monitor type under Equals", () => {
|
|
102
|
+
for (const monitorType of Object.values(MonitorType)) {
|
|
103
|
+
expect(
|
|
104
|
+
getMonitorTypeCriteriaValidationError(
|
|
105
|
+
buildFilter(RuleCriteriaOperator.Equals, monitorType),
|
|
106
|
+
),
|
|
107
|
+
).toBeNull();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rejects every operator other than Equals and NotEquals", () => {
|
|
112
|
+
const rejected: Array<RuleCriteriaOperator> = Object.values(
|
|
113
|
+
RuleCriteriaOperator,
|
|
114
|
+
).filter((operator: RuleCriteriaOperator) => {
|
|
115
|
+
return (
|
|
116
|
+
operator !== RuleCriteriaOperator.Equals &&
|
|
117
|
+
operator !== RuleCriteriaOperator.NotEquals
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
expect(rejected.length).toBeGreaterThan(0);
|
|
121
|
+
|
|
122
|
+
for (const operator of rejected) {
|
|
123
|
+
expect(
|
|
124
|
+
getMonitorTypeCriteriaValidationError(buildFilter(operator, validType)),
|
|
125
|
+
).toBe(OPERATOR_ERROR);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("reports the operator first when both the operator and the value are wrong", () => {
|
|
130
|
+
expect(
|
|
131
|
+
getMonitorTypeCriteriaValidationError(
|
|
132
|
+
buildFilter(RuleCriteriaOperator.Contains, "NotAMonitorType"),
|
|
133
|
+
),
|
|
134
|
+
).toBe(OPERATOR_ERROR);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("rejects a value that is not a monitor type under a supported operator", () => {
|
|
138
|
+
for (const operator of [
|
|
139
|
+
RuleCriteriaOperator.Equals,
|
|
140
|
+
RuleCriteriaOperator.NotEquals,
|
|
141
|
+
]) {
|
|
142
|
+
expect(
|
|
143
|
+
getMonitorTypeCriteriaValidationError(
|
|
144
|
+
buildFilter(operator, "NotAMonitorType"),
|
|
145
|
+
),
|
|
146
|
+
).toBe(VALUE_ERROR);
|
|
147
|
+
expect(
|
|
148
|
+
getMonitorTypeCriteriaValidationError(buildFilter(operator, "")),
|
|
149
|
+
).toBe(VALUE_ERROR);
|
|
150
|
+
/*
|
|
151
|
+
* An array is what the HasAnyOf-style operators carry; it is not a
|
|
152
|
+
* monitor type even when every member of it is one.
|
|
153
|
+
*/
|
|
154
|
+
expect(
|
|
155
|
+
getMonitorTypeCriteriaValidationError(
|
|
156
|
+
buildFilter(operator, [validType]),
|
|
157
|
+
),
|
|
158
|
+
).toBe(VALUE_ERROR);
|
|
159
|
+
expect(
|
|
160
|
+
getMonitorTypeCriteriaValidationError(buildFilter(operator, true)),
|
|
161
|
+
).toBe(VALUE_ERROR);
|
|
162
|
+
expect(
|
|
163
|
+
getMonitorTypeCriteriaValidationError(buildFilter(operator, 0)),
|
|
164
|
+
).toBe(VALUE_ERROR);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("ignores the filter's field: the caller decides which field this applies to", () => {
|
|
169
|
+
expect(
|
|
170
|
+
getMonitorTypeCriteriaValidationError({
|
|
171
|
+
field: "somethingElse",
|
|
172
|
+
operator: RuleCriteriaOperator.Equals,
|
|
173
|
+
value: validType,
|
|
174
|
+
}),
|
|
175
|
+
).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the two errors are distinct, so a form can say which one it is", () => {
|
|
179
|
+
expect(OPERATOR_ERROR).not.toBe(VALUE_ERROR);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -15,6 +15,12 @@ enum AggregationType {
|
|
|
15
15
|
* over the raw column, which is the right thing for scalar columns.
|
|
16
16
|
*/
|
|
17
17
|
P50 = "P50",
|
|
18
|
+
/*
|
|
19
|
+
* The level Core Web Vitals are judged at: Google's good / poor
|
|
20
|
+
* thresholds for LCP, INP and CLS apply to the 75th percentile of page
|
|
21
|
+
* views, so a web-vital chart or monitor wants exactly this one.
|
|
22
|
+
*/
|
|
23
|
+
P75 = "P75",
|
|
18
24
|
P90 = "P90",
|
|
19
25
|
P95 = "P95",
|
|
20
26
|
P99 = "P99",
|
|
@@ -24,6 +30,7 @@ export default AggregationType;
|
|
|
24
30
|
|
|
25
31
|
export const PercentileAggregationLevels: Record<string, number> = {
|
|
26
32
|
[AggregationType.P50]: 0.5,
|
|
33
|
+
[AggregationType.P75]: 0.75,
|
|
27
34
|
[AggregationType.P90]: 0.9,
|
|
28
35
|
[AggregationType.P95]: 0.95,
|
|
29
36
|
[AggregationType.P99]: 0.99,
|
|
@@ -1742,6 +1742,18 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
|
|
|
1742
1742
|
* metrics and label them in cores ("2.3 cores"). The Kubernetes
|
|
1743
1743
|
* cluster overview page — which fetches `k8s.node.allocatable_cpu` —
|
|
1744
1744
|
* is where CPU is shown as a real "% of capacity".
|
|
1745
|
+
*
|
|
1746
|
+
* - "Cluster CPU (cores in use)" used to be a Value widget over
|
|
1747
|
+
* k8s.node.cpu.usage with `Sum` — the same trap as Pod Count. The
|
|
1748
|
+
* server sums every node's reading in each bucket and the Value widget
|
|
1749
|
+
* then sums the buckets, so a one-hour window of minute buckets and
|
|
1750
|
+
* 30s kubelet scrapes read 540 for three nodes on 1.5 cores each,
|
|
1751
|
+
* instead of 4.5. A Value widget can't express "sum across nodes at one
|
|
1752
|
+
* instant", so the tile is now the busiest node's peak (`Max`), which
|
|
1753
|
+
* is honest at any window and complements the per-node average in
|
|
1754
|
+
* row 1. Cluster-wide CPU against capacity lives on the cluster
|
|
1755
|
+
* overview page. Dashboards already created from this template are
|
|
1756
|
+
* repaired by the RepairKubernetesDashboardClusterCpuTile migration.
|
|
1745
1757
|
*/
|
|
1746
1758
|
const components: Array<DashboardBaseComponent> = [
|
|
1747
1759
|
// Row 0: Title
|
|
@@ -1875,22 +1887,23 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
|
|
|
1875
1887
|
}),
|
|
1876
1888
|
|
|
1877
1889
|
/*
|
|
1878
|
-
* Row 11-13:
|
|
1879
|
-
*
|
|
1890
|
+
* Row 11-13: busiest-node CPU tile and the network throughput chart.
|
|
1891
|
+
* This was a 0-100 CPU gauge, but the cores-valued
|
|
1880
1892
|
* `k8s.node.cpu.utilization` pinned it to nonsense (e.g. 711%) and
|
|
1881
1893
|
* the templated renderer can't divide by allocatable CPU to make a
|
|
1882
|
-
* real percentage
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
1894
|
+
* real percentage. It then became a `Sum` of cores, which multiplied
|
|
1895
|
+
* by the scrape count — see top-of-function comment. `Max` is the
|
|
1896
|
+
* highest reading any single node reported in the window. The old
|
|
1897
|
+
* "Memory Utilization" gauge over raw bytes is gone too.
|
|
1885
1898
|
*/
|
|
1886
1899
|
createValueComponent({
|
|
1887
|
-
title: "
|
|
1900
|
+
title: "Busiest Node CPU (cores, peak)",
|
|
1888
1901
|
top: 11,
|
|
1889
1902
|
left: 0,
|
|
1890
1903
|
width: 4,
|
|
1891
1904
|
metricConfig: {
|
|
1892
1905
|
metricName: "k8s.node.cpu.usage",
|
|
1893
|
-
aggregationType: MetricsAggregationType.
|
|
1906
|
+
aggregationType: MetricsAggregationType.Max,
|
|
1894
1907
|
},
|
|
1895
1908
|
trendDirection: DashboardValueTrendDirection.HigherIsWorse,
|
|
1896
1909
|
}),
|
|
@@ -215,15 +215,27 @@ export type SessionReplayWebVitalMetric =
|
|
|
215
215
|
export type SessionReplayWebVitalRating = "good" | "needs-improvement" | "poor";
|
|
216
216
|
|
|
217
217
|
/*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
|
|
218
|
+
* How the view an INP describes began: the document load ("hard") or a
|
|
219
|
+
* single-page-app route change ("soft").
|
|
220
|
+
*/
|
|
221
|
+
export type SessionReplayNavigationType = "hard" | "soft";
|
|
222
|
+
|
|
223
|
+
/* The input that produced an INP, as the browser classes it. */
|
|
224
|
+
export type SessionReplayInteractionType = "pointer" | "keyboard";
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* A web vital. LCP, FCP, CLS and TTFB are observed once per page load;
|
|
228
|
+
* INP once per VIEW (newer recorders), so a single-page app reports one
|
|
229
|
+
* INP for each route the user interacted on instead of one for the whole
|
|
230
|
+
* visit. Informational only: vitals never trigger an upload, and CLS's
|
|
231
|
+
* value is unitless while the others are milliseconds.
|
|
221
232
|
*/
|
|
222
233
|
export interface SessionReplayWebVitalPayload {
|
|
223
234
|
kind: "web-vital";
|
|
224
235
|
metric: SessionReplayWebVitalMetric;
|
|
225
236
|
value: number;
|
|
226
237
|
rating: SessionReplayWebVitalRating;
|
|
238
|
+
/* INP: the scrubbed URL of the view the interaction happened on. */
|
|
227
239
|
url?: string;
|
|
228
240
|
/*
|
|
229
241
|
* As on SessionReplayPerformanceBudgetPayload: when the measured entry
|
|
@@ -232,6 +244,20 @@ export interface SessionReplayWebVitalPayload {
|
|
|
232
244
|
* after the moment the number describes. Optional for older recorders.
|
|
233
245
|
*/
|
|
234
246
|
occurredAtUnixMs?: number;
|
|
247
|
+
|
|
248
|
+
/*
|
|
249
|
+
* INP attribution, all optional (older recorders and engines without
|
|
250
|
+
* the timing fields omit them). The three phases add up to roughly the
|
|
251
|
+
* value: waiting for the main thread, running the page's handlers, and
|
|
252
|
+
* rendering the next frame. The target is a structural selector built
|
|
253
|
+
* exactly as a click's is - never an attribute value or text.
|
|
254
|
+
*/
|
|
255
|
+
navigationType?: SessionReplayNavigationType;
|
|
256
|
+
interactionType?: SessionReplayInteractionType;
|
|
257
|
+
interactionTarget?: string;
|
|
258
|
+
inputDelayMs?: number;
|
|
259
|
+
processingDurationMs?: number;
|
|
260
|
+
presentationDelayMs?: number;
|
|
235
261
|
}
|
|
236
262
|
|
|
237
263
|
export type SessionReplayPerformancePayload =
|
package/Types/Rum/WebVitals.ts
CHANGED
|
@@ -86,6 +86,19 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
|
|
|
86
86
|
},
|
|
87
87
|
];
|
|
88
88
|
|
|
89
|
+
/*
|
|
90
|
+
* The metric attribute that names the route a web vital was measured on,
|
|
91
|
+
* tried in order for the RUM overview's per-route breakdown. `app.route`
|
|
92
|
+
* is what the Core Web Vitals docs tell instrumentation to set;
|
|
93
|
+
* `url.template` is OpenTelemetry's semantic-convention name for the same
|
|
94
|
+
* thing. Either must hold the route PATTERN ("/products/:id"), never the
|
|
95
|
+
* concrete URL - one series per product page is a cardinality problem.
|
|
96
|
+
*/
|
|
97
|
+
export const WebVitalRouteAttributeKeys: Array<string> = [
|
|
98
|
+
"app.route",
|
|
99
|
+
"url.template",
|
|
100
|
+
];
|
|
101
|
+
|
|
89
102
|
// A threshold the way a person would say it: "2.5 s", "800 ms", "0.1".
|
|
90
103
|
export function formatWebVitalThreshold(
|
|
91
104
|
value: number,
|