@skyhook-io/radar-app 1.13.4 → 1.13.5
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/package.json +1 -1
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.ts +4 -0
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
- package/src/components/diagnose/investigationEvidence.ts +831 -43
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +1 -0
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
projectInvestigationEvidence,
|
|
5
|
+
type InvestigationEvidenceTimelineItem,
|
|
6
|
+
} from "./investigationEvidence";
|
|
7
|
+
import {
|
|
8
|
+
metricsChangeCoverage,
|
|
9
|
+
metricsChangeMarkers,
|
|
10
|
+
metricsDomain,
|
|
11
|
+
metricsUnitForExpression,
|
|
12
|
+
} from "./investigationMetrics";
|
|
13
|
+
|
|
14
|
+
const target = {
|
|
15
|
+
kind: "Deployment",
|
|
16
|
+
group: "apps",
|
|
17
|
+
namespace: "shop",
|
|
18
|
+
name: "api",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function tool(
|
|
22
|
+
id: string,
|
|
23
|
+
name: string,
|
|
24
|
+
result: unknown,
|
|
25
|
+
patch: Partial<
|
|
26
|
+
Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }>
|
|
27
|
+
> = {},
|
|
28
|
+
): Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }> {
|
|
29
|
+
return {
|
|
30
|
+
kind: "tool",
|
|
31
|
+
id,
|
|
32
|
+
tool: name,
|
|
33
|
+
status: "done",
|
|
34
|
+
summary: JSON.stringify({ namespace: "shop", name: "api" }),
|
|
35
|
+
result: JSON.stringify(result),
|
|
36
|
+
isError: false,
|
|
37
|
+
radarEvidence: true,
|
|
38
|
+
...patch,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const window = { start: "2026-09-06T07:00:00Z", end: "2026-09-06T09:00:00Z" };
|
|
43
|
+
|
|
44
|
+
function rangeResult(selectors: unknown[]) {
|
|
45
|
+
return {
|
|
46
|
+
query:
|
|
47
|
+
'sum(container_memory_working_set_bytes{namespace="shop",workload="api",workload_type="deployment"})',
|
|
48
|
+
type: "range",
|
|
49
|
+
...window,
|
|
50
|
+
step: "60s",
|
|
51
|
+
series: [
|
|
52
|
+
{
|
|
53
|
+
labels: {},
|
|
54
|
+
dataPoints: [
|
|
55
|
+
{ timestamp: Date.parse(window.start) / 1000, value: 1 },
|
|
56
|
+
{ timestamp: Date.parse(window.end) / 1000, value: 2 },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
selectors,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const targetSelectors = [
|
|
65
|
+
{
|
|
66
|
+
metric: "container_memory_working_set_bytes",
|
|
67
|
+
matchers: [
|
|
68
|
+
{ label: "namespace", op: "=", value: "shop" },
|
|
69
|
+
{ label: "workload", op: "=", value: "api" },
|
|
70
|
+
{ label: "workload_type", op: "=", value: "deployment" },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function change(
|
|
76
|
+
kind: string,
|
|
77
|
+
name: string,
|
|
78
|
+
timestamp: string,
|
|
79
|
+
apiVersion = "v1",
|
|
80
|
+
) {
|
|
81
|
+
return {
|
|
82
|
+
kind,
|
|
83
|
+
apiVersion,
|
|
84
|
+
namespace: "shop",
|
|
85
|
+
name,
|
|
86
|
+
changeType: "update",
|
|
87
|
+
summary: `${kind} ${name} changed`,
|
|
88
|
+
timestamp,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const diagnoseBundle = {
|
|
93
|
+
resource: {
|
|
94
|
+
apiVersion: "apps/v1",
|
|
95
|
+
kind: "Deployment",
|
|
96
|
+
metadata: { namespace: "shop", name: "api" },
|
|
97
|
+
},
|
|
98
|
+
resourceContext: {
|
|
99
|
+
tier: "diagnostic",
|
|
100
|
+
uses: {
|
|
101
|
+
configMaps: [
|
|
102
|
+
{ kind: "ConfigMap", namespace: "shop", name: "api-config" },
|
|
103
|
+
],
|
|
104
|
+
secrets: [{ kind: "Secret", namespace: "shop", name: "api-secret" }],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
logsCurrent: [
|
|
108
|
+
{
|
|
109
|
+
pod: "api-7f6-abc",
|
|
110
|
+
container: "api",
|
|
111
|
+
logs: { lines: ["boot"], totalLines: 1, matchedLines: 0, fallback: true },
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
recentChanges: [
|
|
115
|
+
change("ConfigMap", "api-config", "2026-09-06T07:30:00Z"),
|
|
116
|
+
change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
|
|
117
|
+
change("Pod", "api-7f6-abc", "2026-09-06T08:11:00Z"),
|
|
118
|
+
change("Deployment", "worker", "2026-09-06T08:20:00Z", "apps/v1"),
|
|
119
|
+
change("ConfigMap", "api-config", "2026-09-06T06:30:00Z"),
|
|
120
|
+
change("ConfigMap", "api-config", "2026-09-06T07:30:00Z"),
|
|
121
|
+
],
|
|
122
|
+
pods: 1,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
describe("metricsUnitForExpression", () => {
|
|
126
|
+
const selector = (metric: string) => [{ metric, matchers: [] }];
|
|
127
|
+
|
|
128
|
+
it("keeps the unit every metric states through aggregations and wrappers", () => {
|
|
129
|
+
expect(
|
|
130
|
+
metricsUnitForExpression(
|
|
131
|
+
'sum(max by (pod,namespace,container) (container_memory_working_set_bytes{namespace="shop", workload="api"}))',
|
|
132
|
+
selector("container_memory_working_set_bytes"),
|
|
133
|
+
),
|
|
134
|
+
).toBe("bytes");
|
|
135
|
+
expect(
|
|
136
|
+
metricsUnitForExpression(
|
|
137
|
+
'sum(rate(container_network_receive_bytes_total{namespace="shop"}[5m])) by (pod)',
|
|
138
|
+
selector("container_network_receive_bytes_total"),
|
|
139
|
+
),
|
|
140
|
+
).toBe("bytes/s");
|
|
141
|
+
expect(
|
|
142
|
+
metricsUnitForExpression(
|
|
143
|
+
"increase(container_network_receive_bytes_total[1h])",
|
|
144
|
+
selector("container_network_receive_bytes_total"),
|
|
145
|
+
),
|
|
146
|
+
).toBe("bytes");
|
|
147
|
+
expect(
|
|
148
|
+
metricsUnitForExpression(
|
|
149
|
+
"max_over_time(http_request_duration_seconds[10m])",
|
|
150
|
+
selector("http_request_duration_seconds"),
|
|
151
|
+
),
|
|
152
|
+
).toBe("seconds");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("stays unitless for ratios, mixed metrics and bare numbers", () => {
|
|
156
|
+
expect(
|
|
157
|
+
metricsUnitForExpression('a_bytes / b_bytes{path="/api/v1"}', [
|
|
158
|
+
...selector("a_bytes"),
|
|
159
|
+
...selector("b_bytes"),
|
|
160
|
+
]),
|
|
161
|
+
).toBe("");
|
|
162
|
+
expect(
|
|
163
|
+
metricsUnitForExpression("a_bytes + b_seconds", [
|
|
164
|
+
...selector("a_bytes"),
|
|
165
|
+
...selector("b_seconds"),
|
|
166
|
+
]),
|
|
167
|
+
).toBe("");
|
|
168
|
+
expect(
|
|
169
|
+
metricsUnitForExpression(
|
|
170
|
+
"container_memory_working_set_bytes + kube_pod_info",
|
|
171
|
+
[
|
|
172
|
+
...selector("container_memory_working_set_bytes"),
|
|
173
|
+
...selector("kube_pod_info"),
|
|
174
|
+
],
|
|
175
|
+
),
|
|
176
|
+
).toBe("");
|
|
177
|
+
expect(metricsUnitForExpression("42", [])).toBe("");
|
|
178
|
+
expect(metricsUnitForExpression("1 / 2", [])).toBe("");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("drops the unit when a function counts or flags instead of measuring", () => {
|
|
182
|
+
for (const query of [
|
|
183
|
+
'count(container_memory_working_set_bytes{namespace="shop"})',
|
|
184
|
+
'count by (pod) (container_memory_working_set_bytes{namespace="shop"})',
|
|
185
|
+
"count without (container) (container_memory_working_set_bytes)",
|
|
186
|
+
"count_over_time(container_memory_working_set_bytes[1h])",
|
|
187
|
+
"absent(container_memory_working_set_bytes)",
|
|
188
|
+
"changes(http_request_duration_seconds[10m])",
|
|
189
|
+
"resets(container_network_receive_bytes_total[1h])",
|
|
190
|
+
"timestamp(container_memory_working_set_bytes)",
|
|
191
|
+
]) {
|
|
192
|
+
expect(
|
|
193
|
+
metricsUnitForExpression(
|
|
194
|
+
query,
|
|
195
|
+
selector(query.match(/[a-z_]+_(?:bytes|seconds)(?:_total)?/)![0]),
|
|
196
|
+
),
|
|
197
|
+
query,
|
|
198
|
+
).toBe("");
|
|
199
|
+
}
|
|
200
|
+
expect(
|
|
201
|
+
metricsUnitForExpression(
|
|
202
|
+
"topk(3, container_memory_working_set_bytes)",
|
|
203
|
+
selector("container_memory_working_set_bytes"),
|
|
204
|
+
),
|
|
205
|
+
).toBe("bytes");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not turn a matcher value containing a slash into a ratio", () => {
|
|
209
|
+
expect(
|
|
210
|
+
metricsUnitForExpression(
|
|
211
|
+
'container_memory_working_set_bytes{image="ghcr.io/example/api"}',
|
|
212
|
+
selector("container_memory_working_set_bytes"),
|
|
213
|
+
),
|
|
214
|
+
).toBe("bytes");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("names a unit for a bare metric with a unit suffix", () => {
|
|
218
|
+
expect(
|
|
219
|
+
metricsUnitForExpression(
|
|
220
|
+
'container_memory_working_set_bytes{namespace="shop"}',
|
|
221
|
+
selector("container_memory_working_set_bytes"),
|
|
222
|
+
),
|
|
223
|
+
).toBe("bytes");
|
|
224
|
+
expect(
|
|
225
|
+
metricsUnitForExpression(
|
|
226
|
+
"http_request_duration_seconds",
|
|
227
|
+
selector("http_request_duration_seconds"),
|
|
228
|
+
),
|
|
229
|
+
).toBe("seconds");
|
|
230
|
+
expect(
|
|
231
|
+
metricsUnitForExpression(
|
|
232
|
+
"rate(container_cpu_usage_seconds_total[5m])",
|
|
233
|
+
selector("container_cpu_usage_seconds_total"),
|
|
234
|
+
),
|
|
235
|
+
).toBe("");
|
|
236
|
+
expect(
|
|
237
|
+
metricsUnitForExpression("a_bytes / b_bytes", [
|
|
238
|
+
...selector("a_bytes"),
|
|
239
|
+
...selector("b_bytes"),
|
|
240
|
+
]),
|
|
241
|
+
).toBe("");
|
|
242
|
+
expect(
|
|
243
|
+
metricsUnitForExpression(
|
|
244
|
+
"kube_pod_container_status_restarts_total",
|
|
245
|
+
selector("kube_pod_container_status_restarts_total"),
|
|
246
|
+
),
|
|
247
|
+
).toBe("");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("metricsDomain", () => {
|
|
252
|
+
it("returns the range window in unix seconds and nothing for instant results", () => {
|
|
253
|
+
expect(metricsDomain({ mode: "range", ...window })).toEqual({
|
|
254
|
+
start: Date.parse(window.start) / 1000,
|
|
255
|
+
end: Date.parse(window.end) / 1000,
|
|
256
|
+
});
|
|
257
|
+
expect(metricsDomain({ mode: "instant" })).toBeUndefined();
|
|
258
|
+
expect(
|
|
259
|
+
metricsDomain({ mode: "range", start: window.end, end: window.start }),
|
|
260
|
+
).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("metricsChangeMarkers", () => {
|
|
265
|
+
function markersFor(
|
|
266
|
+
timelines: InvestigationEvidenceTimelineItem[][],
|
|
267
|
+
sourceId: string,
|
|
268
|
+
) {
|
|
269
|
+
const projection = projectInvestigationEvidence(
|
|
270
|
+
timelines.map((timeline) => ({ timeline })),
|
|
271
|
+
target,
|
|
272
|
+
);
|
|
273
|
+
const observation = projection.groups
|
|
274
|
+
.flatMap((group) => group.observations)
|
|
275
|
+
.find(
|
|
276
|
+
(item) =>
|
|
277
|
+
item.data.type === "metrics" && item.source.stepId === sourceId,
|
|
278
|
+
);
|
|
279
|
+
if (!observation) throw new Error("metrics observation missing");
|
|
280
|
+
return metricsChangeMarkers(projection.groups, observation);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
it("marks same-turn changes to the subject and its producer-established relatives, once each, inside the window", () => {
|
|
284
|
+
const markers = markersFor(
|
|
285
|
+
[
|
|
286
|
+
[
|
|
287
|
+
tool("diag", "diagnose", diagnoseBundle),
|
|
288
|
+
tool("prom", "query_prometheus", rangeResult(targetSelectors)),
|
|
289
|
+
],
|
|
290
|
+
],
|
|
291
|
+
"prom",
|
|
292
|
+
);
|
|
293
|
+
expect(markers).toEqual([
|
|
294
|
+
{
|
|
295
|
+
timestamp: Date.parse("2026-09-06T07:30:00Z") / 1000,
|
|
296
|
+
label: "ConfigMap api-config",
|
|
297
|
+
kind: "change",
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
timestamp: Date.parse("2026-09-06T08:10:00Z") / 1000,
|
|
301
|
+
label: "Deployment api",
|
|
302
|
+
kind: "change",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
timestamp: Date.parse("2026-09-06T08:11:00Z") / 1000,
|
|
306
|
+
label: "Pod api-7f6-abc",
|
|
307
|
+
kind: "change",
|
|
308
|
+
},
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("excludes a sibling workload's change even from a target-scoped change query", () => {
|
|
313
|
+
const markers = markersFor(
|
|
314
|
+
[
|
|
315
|
+
[
|
|
316
|
+
tool("diag", "diagnose", { ...diagnoseBundle, recentChanges: [] }),
|
|
317
|
+
tool(
|
|
318
|
+
"changes",
|
|
319
|
+
"get_changes",
|
|
320
|
+
{
|
|
321
|
+
changes: [
|
|
322
|
+
change(
|
|
323
|
+
"Deployment",
|
|
324
|
+
"worker",
|
|
325
|
+
"2026-09-06T08:20:00Z",
|
|
326
|
+
"apps/v1",
|
|
327
|
+
),
|
|
328
|
+
change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
summary: JSON.stringify({
|
|
333
|
+
kind: "Deployment",
|
|
334
|
+
namespace: "shop",
|
|
335
|
+
name: "api",
|
|
336
|
+
}),
|
|
337
|
+
},
|
|
338
|
+
),
|
|
339
|
+
tool("prom", "query_prometheus", rangeResult(targetSelectors)),
|
|
340
|
+
],
|
|
341
|
+
],
|
|
342
|
+
"prom",
|
|
343
|
+
);
|
|
344
|
+
expect(markers.map((marker) => marker.label)).toEqual(["Deployment api"]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("ignores broader change results and other turns", () => {
|
|
348
|
+
const markers = markersFor(
|
|
349
|
+
[
|
|
350
|
+
[tool("diag", "diagnose", diagnoseBundle)],
|
|
351
|
+
[
|
|
352
|
+
tool(
|
|
353
|
+
"broad",
|
|
354
|
+
"get_changes",
|
|
355
|
+
{
|
|
356
|
+
changes: [
|
|
357
|
+
change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
|
|
358
|
+
],
|
|
359
|
+
},
|
|
360
|
+
{ summary: JSON.stringify({ namespace: "shop" }) },
|
|
361
|
+
),
|
|
362
|
+
tool("prom", "query_prometheus", rangeResult(targetSelectors)),
|
|
363
|
+
],
|
|
364
|
+
],
|
|
365
|
+
"prom",
|
|
366
|
+
);
|
|
367
|
+
expect(markers).toEqual([]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("marks a Helm change without an apiVersion on the subject's own kind, never on an unrelated kind", () => {
|
|
371
|
+
const helmChange = {
|
|
372
|
+
source: "helm",
|
|
373
|
+
kind: "Deployment",
|
|
374
|
+
namespace: "shop",
|
|
375
|
+
name: "api",
|
|
376
|
+
changeType: "upgrade",
|
|
377
|
+
summary: "Helm release api upgraded to 2.4.1",
|
|
378
|
+
timestamp: "2026-09-06T08:00:00Z",
|
|
379
|
+
};
|
|
380
|
+
const markers = markersFor(
|
|
381
|
+
[
|
|
382
|
+
[
|
|
383
|
+
tool("diag", "diagnose", {
|
|
384
|
+
...diagnoseBundle,
|
|
385
|
+
recentChanges: [
|
|
386
|
+
helmChange,
|
|
387
|
+
{ ...helmChange, kind: "Service", changeType: "update" },
|
|
388
|
+
{ ...helmChange, name: "worker" },
|
|
389
|
+
],
|
|
390
|
+
}),
|
|
391
|
+
tool("prom", "query_prometheus", rangeResult(targetSelectors)),
|
|
392
|
+
],
|
|
393
|
+
],
|
|
394
|
+
"prom",
|
|
395
|
+
);
|
|
396
|
+
expect(markers).toEqual([
|
|
397
|
+
{
|
|
398
|
+
timestamp: Date.parse("2026-09-06T08:00:00Z") / 1000,
|
|
399
|
+
label: "Deployment api",
|
|
400
|
+
kind: "change",
|
|
401
|
+
},
|
|
402
|
+
]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("keeps the strict identity when a change carries an apiVersion", () => {
|
|
406
|
+
const markers = markersFor(
|
|
407
|
+
[
|
|
408
|
+
[
|
|
409
|
+
tool("diag", "diagnose", {
|
|
410
|
+
...diagnoseBundle,
|
|
411
|
+
recentChanges: [
|
|
412
|
+
change(
|
|
413
|
+
"Deployment",
|
|
414
|
+
"api",
|
|
415
|
+
"2026-09-06T08:00:00Z",
|
|
416
|
+
"example.io/v1",
|
|
417
|
+
),
|
|
418
|
+
],
|
|
419
|
+
}),
|
|
420
|
+
tool("prom", "query_prometheus", rangeResult(targetSelectors)),
|
|
421
|
+
],
|
|
422
|
+
],
|
|
423
|
+
"prom",
|
|
424
|
+
);
|
|
425
|
+
expect(markers).toEqual([]);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("never marks a chart that is not about the target", () => {
|
|
429
|
+
const markers = markersFor(
|
|
430
|
+
[
|
|
431
|
+
[
|
|
432
|
+
tool("diag", "diagnose", diagnoseBundle),
|
|
433
|
+
tool("prom", "query_prometheus", rangeResult([])),
|
|
434
|
+
],
|
|
435
|
+
],
|
|
436
|
+
"prom",
|
|
437
|
+
);
|
|
438
|
+
expect(markers).toEqual([]);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("units only survive expressions that keep the meaning", () => {
|
|
443
|
+
const bytes = [
|
|
444
|
+
{
|
|
445
|
+
metric: "container_memory_working_set_bytes",
|
|
446
|
+
matchers: [{ label: "namespace", op: "=", value: "shop" }],
|
|
447
|
+
},
|
|
448
|
+
];
|
|
449
|
+
const seconds = [
|
|
450
|
+
{
|
|
451
|
+
metric: "http_request_duration_seconds_bucket",
|
|
452
|
+
matchers: [{ label: "namespace", op: "=", value: "shop" }],
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
it("refuses a unit for expressions that produce a different quantity", () => {
|
|
457
|
+
// Each of these is a number the metric's name no longer describes: a
|
|
458
|
+
// boolean, a presence flag, a variance in bytes squared, a ratio.
|
|
459
|
+
for (const query of [
|
|
460
|
+
"container_memory_working_set_bytes > bool 0",
|
|
461
|
+
"group(container_memory_working_set_bytes)",
|
|
462
|
+
"stdvar(container_memory_working_set_bytes)",
|
|
463
|
+
"stddev(container_memory_working_set_bytes)",
|
|
464
|
+
"container_memory_working_set_bytes / 1024",
|
|
465
|
+
"container_memory_working_set_bytes * 2",
|
|
466
|
+
"count by (pod) (container_memory_working_set_bytes)",
|
|
467
|
+
"absent(container_memory_working_set_bytes)",
|
|
468
|
+
"changes(container_memory_working_set_bytes[5m])",
|
|
469
|
+
"predict_linear(container_memory_working_set_bytes[1h], 3600)",
|
|
470
|
+
]) {
|
|
471
|
+
expect(metricsUnitForExpression(query, bytes), query).toBe("");
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("keeps the unit through aggregation and the counter's own change", () => {
|
|
476
|
+
for (const query of [
|
|
477
|
+
"sum(container_memory_working_set_bytes)",
|
|
478
|
+
"max by (pod) (container_memory_working_set_bytes)",
|
|
479
|
+
"sum(increase(container_memory_working_set_bytes[1h]))",
|
|
480
|
+
"delta(container_memory_working_set_bytes[5m])",
|
|
481
|
+
"max_over_time(container_memory_working_set_bytes[1h])",
|
|
482
|
+
]) {
|
|
483
|
+
expect(metricsUnitForExpression(query, bytes), query).toBe("bytes");
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("refuses a unit for a histogram's counting parts", () => {
|
|
488
|
+
// A _bucket or _count series counts observations; only the `le` boundary
|
|
489
|
+
// carries the measured unit, and nothing here reads it. The quantile of
|
|
490
|
+
// those buckets is in seconds, but this cannot tell that from the
|
|
491
|
+
// expression, so it says nothing rather than something wrong.
|
|
492
|
+
expect(
|
|
493
|
+
metricsUnitForExpression(
|
|
494
|
+
"histogram_quantile(0.99, sum by (le) (http_request_duration_seconds_bucket))",
|
|
495
|
+
seconds,
|
|
496
|
+
),
|
|
497
|
+
).toBe("");
|
|
498
|
+
expect(
|
|
499
|
+
metricsUnitForExpression("sum(http_request_duration_seconds_count)", [
|
|
500
|
+
{
|
|
501
|
+
metric: "http_request_duration_seconds_count",
|
|
502
|
+
matchers: [{ label: "namespace", op: "=", value: "shop" }],
|
|
503
|
+
},
|
|
504
|
+
]),
|
|
505
|
+
).toBe("");
|
|
506
|
+
// The sum of the observed values does measure what was observed.
|
|
507
|
+
expect(
|
|
508
|
+
metricsUnitForExpression("sum(http_request_duration_seconds_sum)", [
|
|
509
|
+
{
|
|
510
|
+
metric: "http_request_duration_seconds_sum",
|
|
511
|
+
matchers: [{ label: "namespace", op: "=", value: "shop" }],
|
|
512
|
+
},
|
|
513
|
+
]),
|
|
514
|
+
).toBe("seconds");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("refuses the shapes an axis label cannot survive", () => {
|
|
518
|
+
const bytesOf = (metric: string) => [
|
|
519
|
+
{
|
|
520
|
+
metric,
|
|
521
|
+
matchers: [{ label: "namespace", op: "=", value: "shop" }],
|
|
522
|
+
},
|
|
523
|
+
];
|
|
524
|
+
// A set operator mixes two expressions whose units may differ; a second
|
|
525
|
+
// rate divides by time twice; a comment can hide a call from a reader and
|
|
526
|
+
// from this; a string can contain anything at all.
|
|
527
|
+
for (const [query, selectors] of [
|
|
528
|
+
[
|
|
529
|
+
"container_memory_working_set_bytes and rate(other_bytes_total[5m])",
|
|
530
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
531
|
+
],
|
|
532
|
+
[
|
|
533
|
+
"rate(container_memory_working_set_bytes[5m]) or container_memory_working_set_bytes",
|
|
534
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
535
|
+
],
|
|
536
|
+
[
|
|
537
|
+
"deriv(rate(container_memory_working_set_bytes[5m])[10m:])",
|
|
538
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
539
|
+
],
|
|
540
|
+
[
|
|
541
|
+
"container_memory_working_set_bytes-other_bytes",
|
|
542
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
543
|
+
],
|
|
544
|
+
] as const) {
|
|
545
|
+
expect(metricsUnitForExpression(query, selectors), query).toBe("");
|
|
546
|
+
}
|
|
547
|
+
// A `#` inside a label value is not a comment. Reading comments before
|
|
548
|
+
// strings let it swallow the rest of the query, so a dimensionless 0/1
|
|
549
|
+
// and a ratio both came back in bytes.
|
|
550
|
+
expect(
|
|
551
|
+
metricsUnitForExpression(
|
|
552
|
+
'container_memory_working_set_bytes{note="#"} > bool 0',
|
|
553
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
554
|
+
),
|
|
555
|
+
).toBe("");
|
|
556
|
+
expect(
|
|
557
|
+
metricsUnitForExpression(
|
|
558
|
+
'container_memory_working_set_bytes{note="#"} / container_memory_working_set_bytes',
|
|
559
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
560
|
+
),
|
|
561
|
+
).toBe("");
|
|
562
|
+
// A comment is not a call: stripping it leaves a plain metric in bytes.
|
|
563
|
+
expect(
|
|
564
|
+
metricsUnitForExpression(
|
|
565
|
+
"container_memory_working_set_bytes # rate(",
|
|
566
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
567
|
+
),
|
|
568
|
+
).toBe("bytes");
|
|
569
|
+
// A backtick string cannot smuggle a call past the reader either.
|
|
570
|
+
expect(
|
|
571
|
+
metricsUnitForExpression(
|
|
572
|
+
'label_replace(container_memory_working_set_bytes, "note", `rate(`, "pod", ".*")',
|
|
573
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
574
|
+
),
|
|
575
|
+
).toBe("bytes");
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("keeps the unit through ordering, and drops it for an angle", () => {
|
|
579
|
+
const bytesOf = (metric: string) => [
|
|
580
|
+
{
|
|
581
|
+
metric,
|
|
582
|
+
matchers: [{ label: "namespace", op: "=", value: "shop" }],
|
|
583
|
+
},
|
|
584
|
+
];
|
|
585
|
+
// Sorting reorders samples without touching their values.
|
|
586
|
+
for (const fn of ["sort", "sort_desc"]) {
|
|
587
|
+
expect(
|
|
588
|
+
metricsUnitForExpression(
|
|
589
|
+
`${fn}(container_memory_working_set_bytes)`,
|
|
590
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
591
|
+
),
|
|
592
|
+
).toBe("bytes");
|
|
593
|
+
}
|
|
594
|
+
expect(
|
|
595
|
+
metricsUnitForExpression(
|
|
596
|
+
'sort_by_label(container_memory_working_set_bytes, "pod")',
|
|
597
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
598
|
+
),
|
|
599
|
+
).toBe("bytes");
|
|
600
|
+
// atan2 is spelled as a word but is a binary operator, and returns an
|
|
601
|
+
// angle however many bytes went into it.
|
|
602
|
+
expect(
|
|
603
|
+
metricsUnitForExpression(
|
|
604
|
+
"container_memory_working_set_bytes atan2 container_memory_working_set_bytes",
|
|
605
|
+
bytesOf("container_memory_working_set_bytes"),
|
|
606
|
+
),
|
|
607
|
+
).toBe("");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("still turns a rate of bytes into bytes per second and a rate of seconds into nothing", () => {
|
|
611
|
+
expect(
|
|
612
|
+
metricsUnitForExpression(
|
|
613
|
+
"sum(rate(container_memory_working_set_bytes[5m]))",
|
|
614
|
+
bytes,
|
|
615
|
+
),
|
|
616
|
+
).toBe("bytes/s");
|
|
617
|
+
expect(
|
|
618
|
+
metricsUnitForExpression(
|
|
619
|
+
"sum(rate(container_cpu_usage_seconds_total[5m]))",
|
|
620
|
+
[
|
|
621
|
+
{
|
|
622
|
+
metric: "container_cpu_usage_seconds_total",
|
|
623
|
+
matchers: [{ label: "namespace", op: "=", value: "shop" }],
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
),
|
|
627
|
+
).toBe("");
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
describe("metricsChangeCoverage", () => {
|
|
632
|
+
function coverageFor(
|
|
633
|
+
timelines: InvestigationEvidenceTimelineItem[][],
|
|
634
|
+
sourceId: string,
|
|
635
|
+
) {
|
|
636
|
+
const projection = projectInvestigationEvidence(
|
|
637
|
+
timelines.map((timeline) => ({ timeline })),
|
|
638
|
+
target,
|
|
639
|
+
);
|
|
640
|
+
const observation = projection.groups
|
|
641
|
+
.flatMap((group) => group.observations)
|
|
642
|
+
.find(
|
|
643
|
+
(item) =>
|
|
644
|
+
item.data.type === "metrics" && item.source.stepId === sourceId,
|
|
645
|
+
);
|
|
646
|
+
if (!observation) throw new Error("metrics observation missing");
|
|
647
|
+
return metricsChangeCoverage(projection.groups, observation);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
it("reports a checked window when the turn captured changes and none landed in it", () => {
|
|
651
|
+
const coverage = coverageFor(
|
|
652
|
+
[
|
|
653
|
+
[
|
|
654
|
+
tool("diag", "diagnose", { ...diagnoseBundle, recentChanges: [] }),
|
|
655
|
+
tool(
|
|
656
|
+
"changes",
|
|
657
|
+
"get_changes",
|
|
658
|
+
{
|
|
659
|
+
changes: [
|
|
660
|
+
change("Deployment", "api", "2020-01-01T00:00:00Z", "apps/v1"),
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
summary: JSON.stringify({
|
|
665
|
+
kind: "Deployment",
|
|
666
|
+
namespace: "shop",
|
|
667
|
+
name: "api",
|
|
668
|
+
}),
|
|
669
|
+
},
|
|
670
|
+
),
|
|
671
|
+
tool("prom", "query_prometheus", rangeResult(targetSelectors)),
|
|
672
|
+
],
|
|
673
|
+
],
|
|
674
|
+
"prom",
|
|
675
|
+
);
|
|
676
|
+
expect(coverage.markers).toEqual([]);
|
|
677
|
+
expect(coverage.checked).toBe(true);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("does not call the window checked when only a broader change result was captured", () => {
|
|
681
|
+
const coverage = coverageFor(
|
|
682
|
+
[
|
|
683
|
+
[tool("diag", "diagnose", diagnoseBundle)],
|
|
684
|
+
[
|
|
685
|
+
tool(
|
|
686
|
+
"broad",
|
|
687
|
+
"get_changes",
|
|
688
|
+
{
|
|
689
|
+
changes: [
|
|
690
|
+
change("Deployment", "api", "2026-09-06T08:10:00Z", "apps/v1"),
|
|
691
|
+
],
|
|
692
|
+
},
|
|
693
|
+
{ summary: JSON.stringify({ namespace: "shop" }) },
|
|
694
|
+
),
|
|
695
|
+
tool("prom", "query_prometheus", rangeResult(targetSelectors)),
|
|
696
|
+
],
|
|
697
|
+
],
|
|
698
|
+
"prom",
|
|
699
|
+
);
|
|
700
|
+
expect(coverage.markers).toEqual([]);
|
|
701
|
+
expect(coverage.checked).toBe(false);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("does not call the window checked when the turn captured no changes at all", () => {
|
|
705
|
+
const coverage = coverageFor(
|
|
706
|
+
[[tool("prom", "query_prometheus", rangeResult(targetSelectors))]],
|
|
707
|
+
"prom",
|
|
708
|
+
);
|
|
709
|
+
expect(coverage.markers).toEqual([]);
|
|
710
|
+
expect(coverage.checked).toBe(false);
|
|
711
|
+
});
|
|
712
|
+
});
|