@skyhook-io/radar-app 1.13.4 → 1.14.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.
Files changed (230) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/RadarApp.tsx +5 -0
  4. package/src/api/client.authRedirect.test.ts +12 -1
  5. package/src/api/client.prometheusStatus.test.ts +24 -0
  6. package/src/api/client.ts +325 -16
  7. package/src/api/diagnose.ts +62 -15
  8. package/src/api/drain-plan.test.ts +86 -0
  9. package/src/api/quotas.test.ts +26 -0
  10. package/src/api/quotas.ts +20 -0
  11. package/src/api/workloadMetrics.test.ts +40 -0
  12. package/src/api/workloadMetrics.ts +68 -0
  13. package/src/components/CloudConnectFlow.tsx +179 -42
  14. package/src/components/CloudFunnelButton.tsx +107 -36
  15. package/src/components/ConnectionErrorView.test.tsx +30 -0
  16. package/src/components/ConnectionErrorView.tsx +31 -19
  17. package/src/components/SyncProgressPanel.tsx +73 -0
  18. package/src/components/audit/AuditView.tsx +2 -0
  19. package/src/components/cloudConnectHandoff.test.ts +140 -0
  20. package/src/components/cloudConnectHandoff.ts +124 -0
  21. package/src/components/diagnose/AgentCase.tsx +131 -0
  22. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  23. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  24. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  25. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  26. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  27. package/src/components/diagnose/InvestigationView.tsx +222 -5
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  29. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  30. package/src/components/diagnose/investigationCase.ts +439 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  48. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  49. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  50. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  51. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  52. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  53. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  54. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  55. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  56. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  57. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  58. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  59. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  60. package/src/components/diagnose/investigationMetrics.ts +393 -0
  61. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  62. package/src/components/diagnose/investigationState.test.ts +322 -0
  63. package/src/components/diagnose/investigationState.ts +147 -25
  64. package/src/components/diagnose/parts.test.tsx +482 -0
  65. package/src/components/diagnose/parts.tsx +418 -41
  66. package/src/components/diagnose/target.test.ts +19 -0
  67. package/src/components/diagnose/target.ts +3 -2
  68. package/src/components/gitops/GitOpsView.tsx +7 -0
  69. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  70. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  71. package/src/components/resource/PrometheusCharts.tsx +4 -3
  72. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
  74. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  75. package/src/components/resource/RightsizingStrip.tsx +15 -3
  76. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  77. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  78. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  79. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  80. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  81. package/src/components/resource/workloadMetricValues.ts +30 -0
  82. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  83. package/src/components/resources/PodFilePreview.tsx +394 -0
  84. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  85. package/src/components/resources/ResourcesView.tsx +44 -6
  86. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  87. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  88. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  90. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  91. package/src/components/settings/SettingsDialog.tsx +94 -14
  92. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  93. package/src/components/traffic/TrafficGraph.tsx +6 -6
  94. package/src/components/traffic/TrafficView.tsx +10 -9
  95. package/src/components/traffic/trafficFilters.test.ts +28 -1
  96. package/src/components/traffic/trafficFilters.ts +31 -0
  97. package/src/components/workload/WorkloadView.tsx +19 -8
  98. package/src/context/ConnectionContext.tsx +48 -3
  99. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  100. package/src/context/DiagnoseCustomization.tsx +15 -2
  101. package/src/index.css +17 -0
  102. package/src/index.ts +1 -0
  103. package/src/utils/navigation.ts +1 -1
  104. package/src/utils/shell-safe.test.ts +25 -1
  105. package/src/utils/shell-safe.ts +16 -0
  106. package/src/utils/topology-namespace.test.ts +64 -0
  107. package/src/utils/topology-namespace.ts +18 -0
  108. package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
  109. package/src/components/diagnose/investigationEvidence.ts +0 -4784
  110. package/src/components/gitops/GitOpsActions.tsx +0 -1
  111. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  112. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  113. package/src/components/gitops/SyncCountdown.tsx +0 -1
  114. package/src/components/gitops/index.ts +0 -4
  115. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  116. package/src/components/logs/JsonLogLine.tsx +0 -1
  117. package/src/components/logs/LogCore.tsx +0 -2
  118. package/src/components/logs/useLogBuffer.ts +0 -2
  119. package/src/components/logs/useLogSearch.ts +0 -1
  120. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  121. package/src/components/resources/drawer-components.tsx +0 -1
  122. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  191. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  198. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  199. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  200. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  201. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  202. package/src/components/resources/renderers/index.ts +0 -94
  203. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  204. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  205. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  206. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  207. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  208. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  209. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  210. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  211. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  212. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  213. package/src/components/resources/resource-utils-argo.ts +0 -2
  214. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  215. package/src/components/resources/resource-utils-eso.ts +0 -2
  216. package/src/components/resources/resource-utils-flux.ts +0 -2
  217. package/src/components/resources/resource-utils-istio.ts +0 -2
  218. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  219. package/src/components/resources/resource-utils-knative.ts +0 -2
  220. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  221. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  222. package/src/components/resources/resource-utils-traefik.ts +0 -1
  223. package/src/components/resources/resource-utils-trivy.ts +0 -2
  224. package/src/components/resources/resource-utils-velero.ts +0 -2
  225. package/src/components/shared/EditableYamlView.tsx +0 -24
  226. package/src/components/timeline/DiffViewer.tsx +0 -1
  227. package/src/components/traffic/index.ts +0 -3
  228. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  229. package/src/components/ui/MetricsChart.tsx +0 -1
  230. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -0,0 +1,1641 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ investigationEvidenceSubjectRef,
4
+ metricsScope,
5
+ projectInvestigationEvidence,
6
+ type InvestigationEvidenceTarget,
7
+ } from "../index";
8
+ import {
9
+ deployment,
10
+ groupsOf,
11
+ project,
12
+ target,
13
+ tool,
14
+ workloadLogsArgs,
15
+ workloadLogsPayload,
16
+ } from "../evidenceFixtures";
17
+
18
+ const firingInstance = {
19
+ state: "firing",
20
+ activeAt: "2026-09-07T07:58:00Z",
21
+ value: "1e+00",
22
+ labels: {
23
+ alertname: "KubePodCrashLooping",
24
+ namespace: "shop",
25
+ pod: "api-68c7b766dc-fmphn",
26
+ container: "api",
27
+ severity: "warning",
28
+ },
29
+ };
30
+
31
+ function alertingRule(
32
+ patch: Record<string, unknown> = {},
33
+ ): Record<string, unknown> {
34
+ return {
35
+ group: "kubernetes-apps",
36
+ type: "alerting",
37
+ name: "KubePodCrashLooping",
38
+ query:
39
+ 'max_over_time(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}[5m]) >= 1',
40
+ state: "firing",
41
+ health: "ok",
42
+ labels: { severity: "warning" },
43
+ annotations: {
44
+ summary: "Pod is crash looping.",
45
+ description:
46
+ "Pod {{ $labels.namespace }}/{{ $labels.pod }} ({{ $labels.container }}) is in waiting state (reason: CrashLoopBackOff).",
47
+ },
48
+ alerts: [firingInstance],
49
+ ...patch,
50
+ };
51
+ }
52
+
53
+ const rulesArgs = JSON.stringify({ state: "firing" });
54
+
55
+ /** A diagnose read that ties the given pods to the target as its log streams. */
56
+ function diagnoseWithPods(...pods: string[]) {
57
+ return tool("diagnose-pods", "diagnose", {
58
+ resource: deployment,
59
+ resourceContext: { tier: "basic" },
60
+ logsCurrent: pods.map((pod) => ({
61
+ pod,
62
+ container: "api",
63
+ logs: {
64
+ lines: ["ready"],
65
+ totalLines: 1,
66
+ matchedLines: 0,
67
+ fallback: true,
68
+ },
69
+ })),
70
+ });
71
+ }
72
+
73
+ describe("prometheus rules adapter", () => {
74
+ it("attributes workload-labelled alerts in saved plural-kind Hub runs", () => {
75
+ const projection = projectInvestigationEvidence(
76
+ [
77
+ {
78
+ timeline: [
79
+ tool("rules", "get_prometheus_rules", {
80
+ count: 1,
81
+ rules: [
82
+ alertingRule({
83
+ alerts: [
84
+ {
85
+ state: "firing",
86
+ labels: { namespace: "shop", deployment: "api" },
87
+ },
88
+ ],
89
+ }),
90
+ ],
91
+ }),
92
+ ],
93
+ },
94
+ ],
95
+ { ...target, kind: "deployments" },
96
+ );
97
+
98
+ expect(groupsOf(projection.groups, "alerts")[0].latest.relevance).toBe(
99
+ "target",
100
+ );
101
+ });
102
+
103
+ it("relates a rule to the target through its instances, never the rule alone", () => {
104
+ const projection = project([
105
+ diagnoseWithPods("api-68c7b766dc-fmphn"),
106
+ tool(
107
+ "rules",
108
+ "get_prometheus_rules",
109
+ {
110
+ count: 4,
111
+ rules: [
112
+ alertingRule(),
113
+ alertingRule({
114
+ name: "KubeDeploymentReplicasMismatch",
115
+ labels: { severity: "warning", namespace: "shop" },
116
+ alerts: [
117
+ {
118
+ state: "firing",
119
+ labels: {
120
+ namespace: "shop",
121
+ deployment: "api-worker",
122
+ },
123
+ },
124
+ ],
125
+ }),
126
+ alertingRule({
127
+ name: "TargetDown",
128
+ group: "general",
129
+ labels: { severity: "critical" },
130
+ alerts: [
131
+ {
132
+ state: "firing",
133
+ labels: { namespace: "monitoring", job: "node-exporter" },
134
+ },
135
+ ],
136
+ }),
137
+ alertingRule({
138
+ name: "SiblingCrash",
139
+ alerts: [
140
+ {
141
+ state: "firing",
142
+ labels: {
143
+ namespace: "shop",
144
+ pod: "api-worker-5d8f7c9b6-x2k9p",
145
+ },
146
+ },
147
+ ],
148
+ }),
149
+ ],
150
+ },
151
+ { summary: rulesArgs },
152
+ ),
153
+ ]);
154
+ const alerts = groupsOf(projection.groups, "alerts");
155
+ expect(alerts.map((group) => group.latest.relevance)).toEqual([
156
+ "target",
157
+ "broader",
158
+ "broader",
159
+ "broader",
160
+ ]);
161
+ expect(alerts.map((group) => group.latest.tier)).toEqual([
162
+ "supporting",
163
+ "context",
164
+ "context",
165
+ "context",
166
+ ]);
167
+ expect(alerts[0].latest.tone).toBe("alert");
168
+ expect(alerts[2].latest.tone).toBe("error");
169
+ expect(alerts[0].latest.title).toBe("KubePodCrashLooping firing");
170
+ expect(alerts[0].latest.summary).toBe(
171
+ "1 active instance, 1 naming Deployment shop/api · kubernetes-apps",
172
+ );
173
+ expect(alerts[0].latest.data).toMatchObject({
174
+ type: "alerts",
175
+ rule: { group: "kubernetes-apps", state: "firing", health: "ok" },
176
+ instances: [{ namesTarget: true, value: "1e+00" }],
177
+ annotations: { summary: "Pod is crash looping." },
178
+ });
179
+ expect(alerts[3].latest.data).toMatchObject({
180
+ instances: [{ namesTarget: false }],
181
+ });
182
+ expect(
183
+ groupsOf(projection.groups, "receipt").filter((group) =>
184
+ group.identity.startsWith("alerts:"),
185
+ ),
186
+ ).toHaveLength(0);
187
+ expect(
188
+ projection.limitations.filter((limitation) =>
189
+ limitation.source.startsWith("Alert"),
190
+ ),
191
+ ).toHaveLength(0);
192
+ });
193
+
194
+ it("uses rule-level labels only as producer-related context", () => {
195
+ const projection = project([
196
+ tool(
197
+ "rules",
198
+ "get_prometheus_rules",
199
+ {
200
+ count: 1,
201
+ rules: [
202
+ alertingRule({
203
+ name: "ApiDown",
204
+ labels: {
205
+ severity: "warning",
206
+ namespace: "shop",
207
+ deployment: "api",
208
+ },
209
+ alerts: [
210
+ {
211
+ state: "firing",
212
+ labels: { namespace: "shop", instance: "10.0.0.4:8080" },
213
+ },
214
+ ],
215
+ }),
216
+ ],
217
+ },
218
+ { summary: rulesArgs },
219
+ ),
220
+ ]);
221
+ const alert = groupsOf(projection.groups, "alerts")[0].latest;
222
+ expect(alert.relevance).toBe("producer-related");
223
+ expect(alert.tier).toBe("supporting");
224
+ expect(alert.summary).toBe("1 active instance · kubernetes-apps");
225
+ expect(groupsOf(projection.groups, "receipt")).toHaveLength(0);
226
+ });
227
+
228
+ it("matches workload labels per kind and pod labels only through producer-established pods", () => {
229
+ const run = (
230
+ target: InvestigationEvidenceTarget,
231
+ labels: object,
232
+ capturedPods: string[] = [],
233
+ ) =>
234
+ groupsOf(
235
+ projectInvestigationEvidence(
236
+ [
237
+ {
238
+ timeline: [
239
+ ...(capturedPods.length > 0
240
+ ? [
241
+ tool("diagnose-pods", "diagnose", {
242
+ resource: {
243
+ ...deployment,
244
+ kind: target.kind,
245
+ metadata: {
246
+ namespace: target.namespace,
247
+ name: target.name,
248
+ },
249
+ },
250
+ resourceContext: { tier: "basic" },
251
+ logsCurrent: capturedPods.map((pod) => ({
252
+ pod,
253
+ container: "app",
254
+ logs: {
255
+ lines: ["ready"],
256
+ totalLines: 1,
257
+ matchedLines: 0,
258
+ fallback: true,
259
+ },
260
+ })),
261
+ }),
262
+ ]
263
+ : []),
264
+ tool(
265
+ "rules",
266
+ "get_prometheus_rules",
267
+ {
268
+ count: 1,
269
+ rules: [
270
+ alertingRule({ alerts: [{ state: "firing", labels }] }),
271
+ ],
272
+ },
273
+ { summary: rulesArgs },
274
+ ),
275
+ ],
276
+ },
277
+ ],
278
+ target,
279
+ ).groups,
280
+ "alerts",
281
+ )[0].latest.relevance;
282
+ const statefulSet = { ...target, kind: "StatefulSet", name: "db" };
283
+ expect(run(target, { namespace: "shop", deployment: "api" })).toBe(
284
+ "target",
285
+ );
286
+ expect(run(target, { namespace: "other", deployment: "api" })).toBe(
287
+ "broader",
288
+ );
289
+ expect(run(target, { namespace: "shop", statefulset: "api" })).toBe(
290
+ "broader",
291
+ );
292
+ expect(
293
+ run(target, {
294
+ namespace: "shop",
295
+ workload: "api",
296
+ workload_type: "deployment",
297
+ }),
298
+ ).toBe("target");
299
+ expect(
300
+ run(target, {
301
+ namespace: "shop",
302
+ workload: "api",
303
+ workload_type: "statefulset",
304
+ }),
305
+ ).toBe("broader");
306
+ // A controller-shaped pod name proves nothing on its own: a sibling
307
+ // ReplicaSet `api-worker` produces `api-worker-abcde` too.
308
+ expect(
309
+ run(target, { namespace: "shop", pod: "api-68c7b766dc-fmphn" }),
310
+ ).toBe("broader");
311
+ expect(
312
+ run(target, { namespace: "shop", pod: "api-68c7b766dc-fmphn" }, [
313
+ "api-68c7b766dc-fmphn",
314
+ ]),
315
+ ).toBe("target");
316
+ expect(
317
+ run(target, { namespace: "shop", pod: "api-worker-abcde" }, [
318
+ "api-68c7b766dc-fmphn",
319
+ ]),
320
+ ).toBe("broader");
321
+ expect(run(statefulSet, { namespace: "shop", pod: "db-0" }, ["db-0"])).toBe(
322
+ "target",
323
+ );
324
+ expect(
325
+ run(statefulSet, { namespace: "other", pod: "db-0" }, ["db-0"]),
326
+ ).toBe("broader");
327
+ expect(
328
+ run(target, { pod: "api-68c7b766dc-fmphn" }, ["api-68c7b766dc-fmphn"]),
329
+ ).toBe("broader");
330
+ });
331
+
332
+ it("issues a scoped receipt only for a complete, unfiltered, evaluated active-state query", () => {
333
+ const unrelated = alertingRule({
334
+ name: "TargetDown",
335
+ alerts: [{ state: "firing", labels: { namespace: "monitoring" } }],
336
+ });
337
+ const receipt = (
338
+ payload: Record<string, unknown>,
339
+ args: Record<string, unknown>,
340
+ ) =>
341
+ groupsOf(
342
+ project([
343
+ tool("rules", "get_prometheus_rules", payload, {
344
+ summary: JSON.stringify(args),
345
+ }),
346
+ ]).groups,
347
+ "receipt",
348
+ );
349
+
350
+ const firing = receipt(
351
+ { count: 1, rules: [unrelated] },
352
+ { state: "firing" },
353
+ );
354
+ expect(firing).toHaveLength(1);
355
+ expect(firing[0].latest).toMatchObject({
356
+ tier: "checked",
357
+ relevance: "target",
358
+ title: "No firing alerts matched this Deployment",
359
+ summary: "Deployment shop/api",
360
+ data: {
361
+ type: "receipt",
362
+ checked: "alerts",
363
+ scope: "Deployment shop/api",
364
+ message:
365
+ "1 alerting rule returned (filter: state=firing), every instance in another namespace; none names Deployment shop/api.",
366
+ },
367
+ });
368
+
369
+ expect(
370
+ receipt(
371
+ {
372
+ count: 0,
373
+ rules: [],
374
+ note: "no rules matched — drop filters to see what exists, or the backend may have no rules configured",
375
+ },
376
+ { state: "pending" },
377
+ )[0].latest.data,
378
+ ).toMatchObject({
379
+ message: "0 alerting rules returned (filter: state=pending).",
380
+ });
381
+
382
+ // A name or group filter never looked at the other rules.
383
+ expect(
384
+ receipt(
385
+ { count: 1, rules: [unrelated] },
386
+ { state: "firing", name: "Target" },
387
+ ),
388
+ ).toHaveLength(0);
389
+ expect(
390
+ receipt({ count: 0, rules: [] }, { state: "firing", group: "general" }),
391
+ ).toHaveLength(0);
392
+ // An instance this projection cannot place is not evidence of absence.
393
+ expect(
394
+ receipt(
395
+ {
396
+ count: 1,
397
+ rules: [
398
+ alertingRule({
399
+ name: "Vague",
400
+ alerts: [
401
+ { state: "firing", labels: { instance: "10.0.0.4:9100" } },
402
+ ],
403
+ }),
404
+ ],
405
+ },
406
+ { state: "firing" },
407
+ ),
408
+ ).toHaveLength(0);
409
+ expect(
410
+ receipt(
411
+ {
412
+ count: 1,
413
+ rules: [
414
+ alertingRule({
415
+ name: "SameNamespace",
416
+ alerts: [
417
+ {
418
+ state: "firing",
419
+ labels: { namespace: "shop", pod: "other-abc" },
420
+ },
421
+ ],
422
+ }),
423
+ ],
424
+ },
425
+ { state: "firing" },
426
+ ),
427
+ ).toHaveLength(0);
428
+ expect(receipt({ count: 1, rules: [unrelated] }, {})).toHaveLength(0);
429
+ expect(
430
+ receipt({ count: 1, rules: [unrelated] }, { state: "inactive" }),
431
+ ).toHaveLength(0);
432
+ expect(
433
+ receipt(
434
+ { count: 1, rules: [alertingRule({ type: "recording", name: "x" })] },
435
+ { type: "record", state: "firing" },
436
+ ),
437
+ ).toHaveLength(0);
438
+ expect(
439
+ receipt(
440
+ {
441
+ count: 1,
442
+ rules: [unrelated],
443
+ truncated: true,
444
+ note: "narrow with name, group, state, or type filters",
445
+ },
446
+ { state: "firing" },
447
+ ),
448
+ ).toHaveLength(0);
449
+ expect(
450
+ receipt(
451
+ { count: 1, rules: [{ ...unrelated, health: "err" }] },
452
+ { state: "firing" },
453
+ ),
454
+ ).toHaveLength(0);
455
+ expect(
456
+ receipt(
457
+ { count: 1, rules: [{ ...unrelated, health: "degraded" }] },
458
+ { state: "firing" },
459
+ ),
460
+ ).toHaveLength(0);
461
+ expect(
462
+ receipt(
463
+ { count: 1, rules: [{ ...unrelated, health: undefined }] },
464
+ { state: "firing" },
465
+ ),
466
+ ).toHaveLength(0);
467
+ });
468
+
469
+ it("advances the card through alert state transitions and keeps distinct rules apart", () => {
470
+ const firingForTarget = alertingRule();
471
+ const resolved = alertingRule({ state: "inactive", alerts: [] });
472
+ const firingElsewhere = alertingRule({
473
+ alerts: [
474
+ {
475
+ state: "firing",
476
+ labels: { namespace: "shop", pod: "api-worker-5d8f7c9b6-x2k9p" },
477
+ },
478
+ ],
479
+ });
480
+ const run = (...later: Record<string, unknown>[]) =>
481
+ groupsOf(
482
+ project(
483
+ [
484
+ diagnoseWithPods("api-68c7b766dc-fmphn"),
485
+ tool(
486
+ "rules-1",
487
+ "get_prometheus_rules",
488
+ { count: 1, rules: [firingForTarget] },
489
+ { summary: rulesArgs },
490
+ ),
491
+ ],
492
+ [
493
+ tool(
494
+ "rules-2",
495
+ "get_prometheus_rules",
496
+ { count: later.length, rules: later },
497
+ { summary: JSON.stringify({}) },
498
+ ),
499
+ ],
500
+ ).groups,
501
+ "alerts",
502
+ );
503
+
504
+ const [resolvedGroup] = run(resolved);
505
+ expect(resolvedGroup.observations).toHaveLength(2);
506
+ expect(resolvedGroup.latest).toMatchObject({
507
+ title: "KubePodCrashLooping inactive",
508
+ relevance: "target",
509
+ tier: "context",
510
+ tone: "neutral",
511
+ summary: "No active instances · kubernetes-apps",
512
+ });
513
+ expect(resolvedGroup.latest).toBe(resolvedGroup.chronologicalLatest);
514
+ expect(resolvedGroup.historical).toBe(false);
515
+
516
+ const [movedGroup] = run(firingElsewhere);
517
+ expect(movedGroup.observations).toHaveLength(2);
518
+ expect(movedGroup.latest.relevance).toBe("broader");
519
+ expect(movedGroup.latest.summary).toBe(
520
+ "1 active instance · kubernetes-apps",
521
+ );
522
+
523
+ const distinct = groupsOf(
524
+ project([
525
+ tool(
526
+ "rules",
527
+ "get_prometheus_rules",
528
+ {
529
+ count: 2,
530
+ rules: [
531
+ alertingRule({ labels: { severity: "warning", team: "a" } }),
532
+ alertingRule({
533
+ query: "vector(1)",
534
+ labels: { severity: "critical", team: "b" },
535
+ }),
536
+ ],
537
+ },
538
+ { summary: rulesArgs },
539
+ ),
540
+ ]).groups,
541
+ "alerts",
542
+ );
543
+ expect(distinct).toHaveLength(2);
544
+ expect(distinct.every((group) => group.observations.length === 1)).toBe(
545
+ true,
546
+ );
547
+
548
+ // Identical definitions from different rule files arrive as identical
549
+ // rows: two rules this read cannot tell apart, so neither inherits the
550
+ // other's history on a later read.
551
+ const twin = () => alertingRule({ alerts: [], state: "inactive" });
552
+ const twins = groupsOf(
553
+ project(
554
+ [
555
+ diagnoseWithPods("api-68c7b766dc-fmphn"),
556
+ tool(
557
+ "rules-1",
558
+ "get_prometheus_rules",
559
+ { count: 2, rules: [alertingRule(), twin()] },
560
+ { summary: rulesArgs },
561
+ ),
562
+ ],
563
+ [
564
+ tool(
565
+ "rules-2",
566
+ "get_prometheus_rules",
567
+ { count: 2, rules: [alertingRule(), twin()] },
568
+ { summary: rulesArgs },
569
+ ),
570
+ ],
571
+ ).groups,
572
+ "alerts",
573
+ );
574
+ expect(twins).toHaveLength(4);
575
+ expect(twins.every((group) => group.observations.length === 1)).toBe(true);
576
+ expect(twins.map((group) => group.latest.relevance)).toEqual([
577
+ "target",
578
+ "broader",
579
+ "target",
580
+ "broader",
581
+ ]);
582
+ expect(twins.map((group) => group.latest.title)).toEqual([
583
+ "KubePodCrashLooping firing",
584
+ "KubePodCrashLooping inactive",
585
+ "KubePodCrashLooping firing",
586
+ "KubePodCrashLooping inactive",
587
+ ]);
588
+ });
589
+
590
+ it("never proves absence by namespace for a cluster-scoped target", () => {
591
+ const node = { kind: "Node", group: "", name: "worker-1" };
592
+ const run = (rules: Record<string, unknown>[]) =>
593
+ groupsOf(
594
+ projectInvestigationEvidence(
595
+ [
596
+ {
597
+ timeline: [
598
+ tool(
599
+ "rules",
600
+ "get_prometheus_rules",
601
+ { count: rules.length, rules },
602
+ { summary: rulesArgs },
603
+ ),
604
+ ],
605
+ },
606
+ ],
607
+ node,
608
+ ).groups,
609
+ "receipt",
610
+ );
611
+ expect(
612
+ run([
613
+ alertingRule({
614
+ name: "KubeNodeNotReady",
615
+ alerts: [
616
+ {
617
+ state: "firing",
618
+ labels: { node: "worker-1", namespace: "kube-system" },
619
+ },
620
+ ],
621
+ }),
622
+ ]),
623
+ ).toHaveLength(0);
624
+ expect(run([])).toHaveLength(1);
625
+ expect(run([])[0].latest.title).toBe("No firing alerts matched this Node");
626
+ });
627
+
628
+ it("keeps recording rules off the card list and flags truncation and evaluation health", () => {
629
+ const projection = project([
630
+ tool(
631
+ "rules",
632
+ "get_prometheus_rules",
633
+ {
634
+ count: 3,
635
+ rules: [
636
+ alertingRule({
637
+ type: "recording",
638
+ name: "namespace:container_cpu_usage_seconds_total:sum_rate",
639
+ alerts: undefined,
640
+ state: undefined,
641
+ }),
642
+ alertingRule({ health: "err", alerts: [] }),
643
+ alertingRule({
644
+ name: "KubeContainerWaiting",
645
+ state: "inactive",
646
+ health: "unknown",
647
+ alerts: [],
648
+ }),
649
+ ],
650
+ truncated: true,
651
+ note: "narrow with name, group, state, or type filters",
652
+ },
653
+ { summary: rulesArgs },
654
+ ),
655
+ ]);
656
+ const alerts = groupsOf(projection.groups, "alerts");
657
+ expect(alerts.map((group) => group.latest.title)).toEqual([
658
+ "KubePodCrashLooping firing",
659
+ "KubeContainerWaiting inactive",
660
+ ]);
661
+ expect(alerts[1].latest).toMatchObject({
662
+ tier: "context",
663
+ tone: "neutral",
664
+ relevance: "broader",
665
+ summary: "No active instances · kubernetes-apps",
666
+ });
667
+ expect(
668
+ projection.limitations.map((limitation) => [
669
+ limitation.source,
670
+ limitation.kind,
671
+ ]),
672
+ ).toEqual([
673
+ ["Alert rules", "truncated"],
674
+ ["Alert rule KubePodCrashLooping", "error"],
675
+ ["Alert rule KubeContainerWaiting", "unknown"],
676
+ ]);
677
+ expect(projection.limitations[0].message).toContain(
678
+ "narrow with name, group, state, or type filters",
679
+ );
680
+ expect(groupsOf(projection.groups, "receipt")).toHaveLength(0);
681
+ });
682
+
683
+ it("does not let sampled values churn revision history, and rejects malformed rules", () => {
684
+ const later = {
685
+ ...firingInstance,
686
+ value: "2e+00",
687
+ activeAt: "2026-09-07T08:10:00Z",
688
+ };
689
+ const projection = project(
690
+ [
691
+ tool(
692
+ "rules-1",
693
+ "get_prometheus_rules",
694
+ { count: 1, rules: [alertingRule()] },
695
+ { summary: rulesArgs },
696
+ ),
697
+ ],
698
+ [
699
+ tool(
700
+ "rules-2",
701
+ "get_prometheus_rules",
702
+ { count: 1, rules: [alertingRule({ alerts: [later] })] },
703
+ { summary: rulesArgs },
704
+ ),
705
+ ],
706
+ );
707
+ const group = groupsOf(projection.groups, "alerts")[0];
708
+ expect(group.observations).toHaveLength(2);
709
+ expect(group.observations[1].changedFromPrevious).toBe(false);
710
+
711
+ const malformed = project([
712
+ tool(
713
+ "rules-bad",
714
+ "get_prometheus_rules",
715
+ { count: 1, rules: [{ name: "NoGroup", type: "alerting" }] },
716
+ { summary: rulesArgs },
717
+ ),
718
+ tool(
719
+ "rules-bad-instance",
720
+ "get_prometheus_rules",
721
+ { count: 1, rules: [alertingRule({ alerts: [{ labels: {} }] })] },
722
+ { summary: rulesArgs },
723
+ ),
724
+ ]);
725
+ expect(malformed.groups).toHaveLength(0);
726
+ expect(malformed.limitations).toHaveLength(1);
727
+ expect(malformed.limitations[0].sources).toHaveLength(2);
728
+ expect(malformed.limitations[0].source).toBe("Alert rules");
729
+
730
+ const envelope = project([
731
+ tool(
732
+ "rules-count",
733
+ "get_prometheus_rules",
734
+ { count: 2, rules: [alertingRule()] },
735
+ { summary: rulesArgs },
736
+ ),
737
+ tool(
738
+ "rules-truncated",
739
+ "get_prometheus_rules",
740
+ { count: 0, rules: [], truncated: "true" },
741
+ { summary: rulesArgs },
742
+ ),
743
+ tool(
744
+ "rules-no-query",
745
+ "get_prometheus_rules",
746
+ { count: 1, rules: [alertingRule({ query: undefined })] },
747
+ { summary: rulesArgs },
748
+ ),
749
+ ]);
750
+ expect(envelope.groups).toHaveLength(0);
751
+ expect(envelope.limitations[0].sources).toHaveLength(3);
752
+ });
753
+ });
754
+
755
+ describe("query_prometheus evidence", () => {
756
+ const rangeSeries = [
757
+ {
758
+ labels: { pod: "api-7f6-abc" },
759
+ dataPoints: [
760
+ { timestamp: 1_788_679_043, value: 169_377_792 },
761
+ { timestamp: 1_788_679_068, value: 171_401_216 },
762
+ ],
763
+ },
764
+ ];
765
+ // The pods a diagnose bundle established as the target's own, so an agent
766
+ // query naming exactly them is target evidence. A prefix over the same
767
+ // namespace is not: "api-.*" also selects api-worker's pods.
768
+ const establishedPods = ["api-7f6-abc", "api-7f6-def"];
769
+ const podSetPattern = `^(${establishedPods.join("|")})$`;
770
+ const targetSelectors = [
771
+ {
772
+ metric: "container_memory_working_set_bytes",
773
+ matchers: [
774
+ { label: "namespace", op: "=", value: "shop" },
775
+ { label: "pod", op: "=~", value: podSetPattern },
776
+ ],
777
+ },
778
+ ];
779
+ const prefixSelectors = [
780
+ {
781
+ metric: "container_memory_working_set_bytes",
782
+ matchers: [
783
+ { label: "namespace", op: "=", value: "shop" },
784
+ { label: "pod", op: "=~", value: "api-.*" },
785
+ ],
786
+ },
787
+ ];
788
+ // A diagnose of the target that lists its pods, so a later query can be
789
+ // proved to be about them.
790
+ function podsBundle() {
791
+ return tool(
792
+ "diag-pods",
793
+ "diagnose",
794
+ { resource: deployment, pods: 2, podNames: establishedPods },
795
+ {
796
+ summary: JSON.stringify({
797
+ kind: "Deployment",
798
+ namespace: "shop",
799
+ name: "api",
800
+ }),
801
+ },
802
+ );
803
+ }
804
+ function promResult(patch: Record<string, unknown> = {}) {
805
+ return {
806
+ query: `sum(container_memory_working_set_bytes{namespace="shop",pod=~"${podSetPattern}"})`,
807
+ type: "range",
808
+ start: "2026-09-06T07:17:23Z",
809
+ end: "2026-09-06T09:17:23Z",
810
+ step: "25s",
811
+ resultType: "matrix",
812
+ seriesCount: 1,
813
+ series: rangeSeries,
814
+ selectors: targetSelectors,
815
+ ...patch,
816
+ };
817
+ }
818
+
819
+ it("keeps an unrecognised metric's own name as the title", () => {
820
+ const projection = project([
821
+ tool(
822
+ "prom",
823
+ "query_prometheus",
824
+ promResult({
825
+ query: 'sum(acme_widget_queue_depth{namespace="shop"})',
826
+ selectors: [
827
+ {
828
+ metric: "acme_widget_queue_depth",
829
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
830
+ },
831
+ ],
832
+ }),
833
+ ),
834
+ ]);
835
+ const [group] = groupsOf(projection.groups, "metrics");
836
+ expect(group.latest.title).toBe("acme_widget_queue_depth");
837
+ // No family label means the summary keeps its generic lead-in rather than
838
+ // repeating the name already in the title.
839
+ expect(group.latest.summary).toContain("Prometheus · 1 series");
840
+ });
841
+
842
+ it("keeps the generic title when the selectors name no metric", () => {
843
+ const projection = project([
844
+ tool(
845
+ "prom",
846
+ "query_prometheus",
847
+ promResult({
848
+ query: 'sum({namespace="shop"})',
849
+ selectors: [
850
+ {
851
+ metric: "",
852
+ matchers: [{ label: "namespace", op: "=", value: "shop" }],
853
+ },
854
+ ],
855
+ }),
856
+ ),
857
+ ]);
858
+ const [group] = groupsOf(projection.groups, "metrics");
859
+ expect(group.latest.title).toBe("Prometheus metrics");
860
+ expect(group.latest.summary).toBe("1 series · 2h window · 25s step");
861
+ });
862
+
863
+ it("charts a range query scoped to the target as target evidence", () => {
864
+ const projection = project([
865
+ podsBundle(),
866
+ tool("prom", "query_prometheus", promResult(), {
867
+ summary: JSON.stringify({ query: "sum(...)", type: "range" }),
868
+ }),
869
+ ]);
870
+ const [group] = groupsOf(projection.groups, "metrics");
871
+ expect(group).toBeDefined();
872
+ expect(group.latest.relevance).toBe("target");
873
+ expect(group.latest.tier).toBe("supporting");
874
+ expect(group.latest.tone).toBe("neutral");
875
+ expect(group.latest.title).toBe("Memory working set · Deployment shop/api");
876
+ expect(group.latest.summary).toBe(
877
+ "container_memory_working_set_bytes · 1 series · 2h window · 25s step",
878
+ );
879
+ const data = group.latest.data;
880
+ if (data.type !== "metrics") throw new Error("expected metrics");
881
+ expect(data.mode).toBe("range");
882
+ expect(data.origin).toBe("query");
883
+ expect(data.subject).toEqual({
884
+ kind: "Deployment",
885
+ group: "apps",
886
+ namespace: "shop",
887
+ name: "api",
888
+ });
889
+ expect(data.unit).toBe("bytes");
890
+ expect(data.series).toEqual(rangeSeries);
891
+ expect(investigationEvidenceSubjectRef(data)).toEqual(data.subject);
892
+ // The chart and the diagnose bundle that established its pods.
893
+ expect(projection.coverage.projected).toBe(2);
894
+ });
895
+
896
+ it("claims a unit only for a single bare metric that states one", () => {
897
+ const projection = project([
898
+ tool(
899
+ "bare",
900
+ "query_prometheus",
901
+ promResult({
902
+ query:
903
+ 'container_memory_working_set_bytes{namespace="shop",pod=~"api-.*"}',
904
+ }),
905
+ ),
906
+ tool(
907
+ "rate",
908
+ "query_prometheus",
909
+ promResult({
910
+ query:
911
+ 'rate(container_cpu_usage_seconds_total{namespace="shop",pod=~"api-.*"}[5m])',
912
+ selectors: [
913
+ {
914
+ metric: "container_cpu_usage_seconds_total",
915
+ matchers: targetSelectors[0].matchers,
916
+ },
917
+ ],
918
+ }),
919
+ ),
920
+ ]);
921
+ const units = groupsOf(projection.groups, "metrics").map((group) =>
922
+ group.latest.data.type === "metrics" ? group.latest.data.unit : "?",
923
+ );
924
+ expect(units).toEqual(["bytes", ""]);
925
+ });
926
+
927
+ it("renders an instant query as values, not a chart", () => {
928
+ const projection = project([
929
+ tool(
930
+ "instant",
931
+ "query_prometheus",
932
+ promResult({
933
+ type: "instant",
934
+ start: undefined,
935
+ end: undefined,
936
+ step: undefined,
937
+ resultType: "vector",
938
+ series: [
939
+ {
940
+ labels: { pod: "api-7f6-abc" },
941
+ dataPoints: [{ timestamp: 1_788_679_068, value: 3 }],
942
+ },
943
+ ],
944
+ }),
945
+ ),
946
+ ]);
947
+ const [group] = groupsOf(projection.groups, "metrics");
948
+ expect(group.latest.title).toBe("Memory working set");
949
+ expect(group.latest.summary).toBe(
950
+ "container_memory_working_set_bytes · 1 series",
951
+ );
952
+ expect(group.latest.data.type === "metrics" && group.latest.data.mode).toBe(
953
+ "instant",
954
+ );
955
+ });
956
+
957
+ it("reports a truncated result as a limitation with its cardinality, never as a chart", () => {
958
+ const projection = project([
959
+ tool(
960
+ "big",
961
+ "query_prometheus",
962
+ promResult({
963
+ series: [],
964
+ truncated: true,
965
+ summary: {
966
+ seriesCount: 812,
967
+ totalDataPoints: 194_880,
968
+ labelCardinality: { pod: 812, container: 3 },
969
+ suggestion: "topk(5, ...)",
970
+ },
971
+ note: "Result exceeded the 96 KiB cap.",
972
+ }),
973
+ ),
974
+ ]);
975
+ expect(groupsOf(projection.groups, "metrics")).toHaveLength(0);
976
+ expect(projection.limitations).toHaveLength(1);
977
+ expect(projection.limitations[0].kind).toBe("truncated");
978
+ expect(projection.limitations[0].message).toContain("812 series");
979
+ expect(projection.limitations[0].message).toContain(
980
+ "812 distinct pod values",
981
+ );
982
+ expect(projection.limitations[0].message).toContain("Result exceeded");
983
+ expect(projection.coverage.limited).toBe(1);
984
+ });
985
+
986
+ it("keeps a query that matched nothing as a fact about the window", () => {
987
+ const projection = project([
988
+ tool("none", "query_prometheus", promResult({ series: [] })),
989
+ ]);
990
+ const [group] = groupsOf(projection.groups, "metrics");
991
+ expect(group.latest.summary).toBe(
992
+ "container_memory_working_set_bytes · No series matched · 2h window · 25s step",
993
+ );
994
+ });
995
+
996
+ it("classifies scope from the producer's selectors, never from the expression text", () => {
997
+ const withNamespace = (
998
+ metric: string,
999
+ extra: Array<{ label: string; op: string; value: string }> = [],
1000
+ ) => ({
1001
+ metric,
1002
+ matchers: [{ label: "namespace", op: "=", value: "shop" }, ...extra],
1003
+ });
1004
+ const cases: Array<[string, Record<string, unknown>, string]> = [
1005
+ ["namespace only", { selectors: [withNamespace("up")] }, "broader"],
1006
+ [
1007
+ "workload label",
1008
+ {
1009
+ selectors: [
1010
+ withNamespace("kube_deployment_status_replicas_available", [
1011
+ { label: "deployment", op: "=", value: "api" },
1012
+ ]),
1013
+ ],
1014
+ },
1015
+ "target",
1016
+ ],
1017
+ [
1018
+ "pod named by the workload's prefix, never established",
1019
+ {
1020
+ selectors: [
1021
+ withNamespace("kube_pod_container_status_restarts_total", [
1022
+ { label: "pod", op: "=", value: "api-7f6-abc" },
1023
+ ]),
1024
+ ],
1025
+ },
1026
+ "producer-related",
1027
+ ],
1028
+ [
1029
+ "owner join over the namespace",
1030
+ {
1031
+ selectors: [
1032
+ withNamespace("kube_pod_owner", [
1033
+ { label: "owner_kind", op: "=", value: "ReplicaSet" },
1034
+ { label: "owner_name", op: "=~", value: "^(api-7f6)$" },
1035
+ ]),
1036
+ ],
1037
+ },
1038
+ "producer-related",
1039
+ ],
1040
+ [
1041
+ "container alone",
1042
+ {
1043
+ selectors: [
1044
+ withNamespace("container_memory_working_set_bytes", [
1045
+ { label: "container", op: "=", value: "api" },
1046
+ ]),
1047
+ ],
1048
+ },
1049
+ "broader",
1050
+ ],
1051
+ [
1052
+ "sibling workload",
1053
+ {
1054
+ selectors: [
1055
+ withNamespace("kube_deployment_status_replicas_available", [
1056
+ { label: "deployment", op: "=", value: "worker" },
1057
+ ]),
1058
+ ],
1059
+ },
1060
+ "producer-related",
1061
+ ],
1062
+ [
1063
+ "pod regex with an optional dash",
1064
+ {
1065
+ selectors: [
1066
+ withNamespace("kube_pod_container_status_restarts_total", [
1067
+ { label: "pod", op: "=~", value: "api-?.*" },
1068
+ ]),
1069
+ ],
1070
+ },
1071
+ "producer-related",
1072
+ ],
1073
+ [
1074
+ "pod regex alternation behind the target prefix",
1075
+ {
1076
+ selectors: [
1077
+ withNamespace("kube_pod_container_status_restarts_total", [
1078
+ { label: "pod", op: "=~", value: "api-.*|worker-.*" },
1079
+ ]),
1080
+ ],
1081
+ },
1082
+ "producer-related",
1083
+ ],
1084
+ [
1085
+ "workload alternation",
1086
+ {
1087
+ selectors: [
1088
+ withNamespace("kube_deployment_status_replicas_available", [
1089
+ { label: "deployment", op: "=~", value: "api|worker" },
1090
+ ]),
1091
+ ],
1092
+ },
1093
+ "producer-related",
1094
+ ],
1095
+ [
1096
+ "statefulset label on a deployment target",
1097
+ {
1098
+ selectors: [
1099
+ withNamespace("kube_statefulset_status_replicas_ready", [
1100
+ { label: "statefulset", op: "=", value: "api" },
1101
+ ]),
1102
+ ],
1103
+ },
1104
+ "producer-related",
1105
+ ],
1106
+ [
1107
+ "braced target numerator over a bare cluster denominator",
1108
+ {
1109
+ query:
1110
+ 'sum(container_memory_working_set_bytes{namespace="shop",pod=~"api-.*"}) / scalar(sum(machine_memory_bytes))',
1111
+ selectors: [
1112
+ ...targetSelectors,
1113
+ { metric: "machine_memory_bytes", matchers: [] },
1114
+ ],
1115
+ },
1116
+ "broader",
1117
+ ],
1118
+ [
1119
+ "other namespace",
1120
+ {
1121
+ selectors: [
1122
+ {
1123
+ metric: "up",
1124
+ matchers: [
1125
+ { label: "namespace", op: "=", value: "other" },
1126
+ { label: "pod", op: "=~", value: "api-.*" },
1127
+ ],
1128
+ },
1129
+ ],
1130
+ },
1131
+ "broader",
1132
+ ],
1133
+ ["empty selectors", { selectors: [] }, "broader"],
1134
+ [
1135
+ "unknown selectors",
1136
+ { selectors: targetSelectors, selectorsUnknown: true },
1137
+ "broader",
1138
+ ],
1139
+ ];
1140
+ for (const [name, patch, expected] of cases) {
1141
+ const projection = project([
1142
+ tool(`case-${name}`, "query_prometheus", promResult(patch)),
1143
+ ]);
1144
+ const [group] = groupsOf(projection.groups, "metrics");
1145
+ expect(group?.latest.relevance, name).toBe(expected);
1146
+ expect(
1147
+ group?.latest.data.type === "metrics"
1148
+ ? group.latest.data.subject
1149
+ : "missing",
1150
+ name,
1151
+ ).toEqual(
1152
+ expected === "target"
1153
+ ? {
1154
+ kind: "Deployment",
1155
+ group: "apps",
1156
+ namespace: "shop",
1157
+ name: "api",
1158
+ }
1159
+ : undefined,
1160
+ );
1161
+ expect(group?.latest.tier, name).toBe(
1162
+ expected === "broader" ? "context" : "supporting",
1163
+ );
1164
+ }
1165
+ });
1166
+
1167
+ // The diagnose prompt tells the agent to scope a query as `pod=~'^(a|b)$'`.
1168
+ // A Pod target has to recognise its own name written that way, or following
1169
+ // our own instruction demotes the target's own series.
1170
+ it("accepts the anchored set form the prompt asks for on a Pod target", () => {
1171
+ const podTarget = {
1172
+ kind: "Pod",
1173
+ group: "",
1174
+ namespace: "shop",
1175
+ name: "api-7f6-abc",
1176
+ };
1177
+ const podSelector = (value: string) => [
1178
+ {
1179
+ metric: "container_cpu_usage_seconds_total",
1180
+ matchers: [
1181
+ { label: "namespace", op: "=", value: "shop" },
1182
+ { label: "pod", op: "=~", value },
1183
+ ],
1184
+ },
1185
+ ];
1186
+ expect(metricsScope(podTarget, podSelector("api-7f6-abc"), false)).toBe(
1187
+ "target",
1188
+ );
1189
+ expect(metricsScope(podTarget, podSelector("^(api-7f6-abc)$"), false)).toBe(
1190
+ "target",
1191
+ );
1192
+ // A set that also names a neighbour is not evidence about this pod alone,
1193
+ // and a prefix still selects pods this one does not stand for.
1194
+ expect(
1195
+ metricsScope(
1196
+ podTarget,
1197
+ podSelector("^(api-7f6-abc|api-7f6-def)$"),
1198
+ false,
1199
+ ),
1200
+ ).toBe("producer-related");
1201
+ expect(metricsScope(podTarget, podSelector("api-7f6-.*"), false)).toBe(
1202
+ "producer-related",
1203
+ );
1204
+ });
1205
+
1206
+ it("treats an unescaped dot in a workload regex as naming more than the target", () => {
1207
+ const dotted = { ...target, name: "api.v2" };
1208
+ const selector = (value: string) => [
1209
+ {
1210
+ metric: "kube_deployment_status_replicas_available",
1211
+ matchers: [
1212
+ { label: "namespace", op: "=", value: "shop" },
1213
+ { label: "deployment", op: "=~", value },
1214
+ ],
1215
+ },
1216
+ ];
1217
+ expect(metricsScope(dotted, selector("api.v2"), false)).toBe(
1218
+ "producer-related",
1219
+ );
1220
+ expect(metricsScope(dotted, selector("api\\.v2"), false)).toBe("target");
1221
+ expect(
1222
+ metricsScope(
1223
+ dotted,
1224
+ [
1225
+ {
1226
+ metric: "up",
1227
+ matchers: [
1228
+ { label: "namespace", op: "=", value: "shop" },
1229
+ { label: "pod", op: "=~", value: "api\\.v2-.*" },
1230
+ ],
1231
+ },
1232
+ ],
1233
+ false,
1234
+ ),
1235
+ ).toBe("producer-related");
1236
+ expect(
1237
+ metricsScope(
1238
+ dotted,
1239
+ [
1240
+ {
1241
+ metric: "up",
1242
+ matchers: [
1243
+ { label: "namespace", op: "=", value: "shop" },
1244
+ { label: "pod", op: "=~", value: "api.v2-.*" },
1245
+ ],
1246
+ },
1247
+ ],
1248
+ false,
1249
+ ),
1250
+ ).toBe("producer-related");
1251
+ });
1252
+
1253
+ it("names a Pod target only by its exact name and accepts an anchored namespace regex", () => {
1254
+ const pod = { ...target, kind: "Pod", group: "", name: "api-7f6-abc" };
1255
+ const selector = (
1256
+ namespace: { op: string; value: string },
1257
+ podMatcher: { op: string; value: string },
1258
+ ) => [
1259
+ {
1260
+ metric: "container_memory_working_set_bytes",
1261
+ matchers: [
1262
+ { label: "namespace", ...namespace },
1263
+ { label: "pod", ...podMatcher },
1264
+ ],
1265
+ },
1266
+ ];
1267
+ const exactNs = { op: "=", value: "shop" };
1268
+ expect(
1269
+ metricsScope(
1270
+ pod,
1271
+ selector(exactNs, { op: "=~", value: "api-7f6-abc-.*" }),
1272
+ false,
1273
+ ),
1274
+ ).toBe("producer-related");
1275
+ expect(
1276
+ metricsScope(
1277
+ pod,
1278
+ selector(exactNs, { op: "=", value: "api-7f6-abc" }),
1279
+ false,
1280
+ ),
1281
+ ).toBe("target");
1282
+ expect(
1283
+ metricsScope(
1284
+ pod,
1285
+ selector(exactNs, { op: "=~", value: "api-7f6-abc" }),
1286
+ false,
1287
+ ),
1288
+ ).toBe("target");
1289
+ expect(
1290
+ metricsScope(
1291
+ pod,
1292
+ selector(exactNs, { op: "=", value: "api-7f6-abc-extra" }),
1293
+ false,
1294
+ ),
1295
+ ).toBe("producer-related");
1296
+ expect(
1297
+ metricsScope(
1298
+ target,
1299
+ selector({ op: "=~", value: "shop" }, { op: "=~", value: "api-.*" }),
1300
+ false,
1301
+ ),
1302
+ ).toBe("producer-related");
1303
+ expect(
1304
+ metricsScope(
1305
+ target,
1306
+ selector(
1307
+ { op: "=~", value: "shop" },
1308
+ {
1309
+ op: "=~",
1310
+ value: "^(api-7f6-abc)$",
1311
+ },
1312
+ ),
1313
+ false,
1314
+ new Set(["api-7f6-abc"]),
1315
+ ),
1316
+ ).toBe("target");
1317
+ expect(
1318
+ metricsScope(
1319
+ target,
1320
+ selector(
1321
+ { op: "=~", value: "shop|other" },
1322
+ { op: "=~", value: "api-.*" },
1323
+ ),
1324
+ false,
1325
+ ),
1326
+ ).toBe("broader");
1327
+ expect(
1328
+ metricsScope(
1329
+ target,
1330
+ selector(
1331
+ { op: "!=", value: "kube-system" },
1332
+ { op: "=~", value: "api-.*" },
1333
+ ),
1334
+ false,
1335
+ ),
1336
+ ).toBe("broader");
1337
+ });
1338
+
1339
+ // Membership can also be established by a workload-logs read, which the
1340
+ // main pass adapts into groups as it goes. Classifying a metrics query
1341
+ // against those groups while they are still being built made the verdict
1342
+ // depend on which tool the agent happened to call first: the same facts,
1343
+ // read in the other order, demoted the same chart.
1344
+ it("classifies a metrics query the same whichever order the agent worked in", () => {
1345
+ const loggedPod = "api-68c7b766dc-fmphn";
1346
+ const exactLoggedPod = {
1347
+ metric: "container_memory_working_set_bytes",
1348
+ matchers: [
1349
+ { label: "namespace", op: "=", value: "shop" },
1350
+ { label: "pod", op: "=~", value: `^(${loggedPod})$` },
1351
+ ],
1352
+ };
1353
+ const logs = () =>
1354
+ tool("wl-logs", "get_workload_logs", workloadLogsPayload, {
1355
+ summary: workloadLogsArgs,
1356
+ });
1357
+ const metrics = () =>
1358
+ tool(
1359
+ "prom",
1360
+ "query_prometheus",
1361
+ promResult({
1362
+ selectors: [exactLoggedPod],
1363
+ query: `sum(container_memory_working_set_bytes{namespace="shop",pod=~"^(${loggedPod})$"})`,
1364
+ }),
1365
+ );
1366
+
1367
+ const logsFirst = groupsOf(project([logs(), metrics()]).groups, "metrics");
1368
+ const metricsFirst = groupsOf(
1369
+ project([metrics(), logs()]).groups,
1370
+ "metrics",
1371
+ );
1372
+ expect(logsFirst[0].latest.relevance).toBe("target");
1373
+ expect(metricsFirst[0].latest.relevance).toBe(
1374
+ logsFirst[0].latest.relevance,
1375
+ );
1376
+
1377
+ // Across turns too: the read that names the pod may arrive in a later one.
1378
+ const acrossTurns = groupsOf(
1379
+ project([metrics()], [logs()]).groups,
1380
+ "metrics",
1381
+ );
1382
+ expect(acrossTurns[0].latest.relevance).toBe("target");
1383
+ });
1384
+
1385
+ it("proves pod membership from the bundle instead of the workload's name", () => {
1386
+ const withBundle = project([
1387
+ podsBundle(),
1388
+ tool("prom", "query_prometheus", promResult()),
1389
+ ]);
1390
+ expect(groupsOf(withBundle.groups, "metrics")[0].latest.relevance).toBe(
1391
+ "target",
1392
+ );
1393
+
1394
+ // The same exact set with no bundle to establish it, and the prefix that
1395
+ // would also select a sibling workload's pods: kept, never promoted.
1396
+ const unproven = project([tool("prom", "query_prometheus", promResult())]);
1397
+ expect(groupsOf(unproven.groups, "metrics")[0].latest.relevance).toBe(
1398
+ "producer-related",
1399
+ );
1400
+ const prefixed = project([
1401
+ podsBundle(),
1402
+ tool(
1403
+ "prom",
1404
+ "query_prometheus",
1405
+ promResult({ selectors: prefixSelectors }),
1406
+ ),
1407
+ ]);
1408
+ const prefixGroup = groupsOf(prefixed.groups, "metrics")[0];
1409
+ expect(prefixGroup.latest.relevance).toBe("producer-related");
1410
+ expect(
1411
+ prefixGroup.latest.data.type === "metrics" &&
1412
+ prefixGroup.latest.data.subject,
1413
+ ).toBeUndefined();
1414
+
1415
+ // A subset of the established pods is still about the target.
1416
+ const subset = project([
1417
+ podsBundle(),
1418
+ tool(
1419
+ "prom",
1420
+ "query_prometheus",
1421
+ promResult({
1422
+ selectors: [
1423
+ {
1424
+ metric: "container_memory_working_set_bytes",
1425
+ matchers: [
1426
+ { label: "namespace", op: "=", value: "shop" },
1427
+ { label: "pod", op: "=", value: "api-7f6-abc" },
1428
+ ],
1429
+ },
1430
+ ],
1431
+ }),
1432
+ ),
1433
+ ]);
1434
+ expect(groupsOf(subset.groups, "metrics")[0].latest.relevance).toBe(
1435
+ "target",
1436
+ );
1437
+ });
1438
+
1439
+ it("proves membership whatever order the agent ran its tools in", () => {
1440
+ // The agent may know the pod names from an earlier turn and chart them
1441
+ // before it calls diagnose. The same facts must read the same way.
1442
+ const queryFirst = project([
1443
+ tool("prom", "query_prometheus", promResult()),
1444
+ podsBundle(),
1445
+ ]);
1446
+ expect(groupsOf(queryFirst.groups, "metrics")[0].latest.relevance).toBe(
1447
+ "target",
1448
+ );
1449
+ });
1450
+
1451
+ it("will not establish target pods from a diagnose that never confirmed success", () => {
1452
+ // This set is what proves a Prometheus selector is about the target, so it
1453
+ // takes confirmed success — the same test the sources use — rather than
1454
+ // merely the absence of an error.
1455
+ const unconfirmed = {
1456
+ ...podsBundle(),
1457
+ isError: undefined as unknown as boolean,
1458
+ };
1459
+ const projection = project([
1460
+ unconfirmed,
1461
+ tool(
1462
+ "prom",
1463
+ "query_prometheus",
1464
+ promResult({
1465
+ query: `sum(container_memory_working_set_bytes{namespace="shop",pod=~"${podSetPattern}"})`,
1466
+ selectors: [
1467
+ {
1468
+ metric: "container_memory_working_set_bytes",
1469
+ matchers: [
1470
+ { label: "namespace", op: "=", value: "shop" },
1471
+ { label: "pod", op: "=~", value: podSetPattern },
1472
+ ],
1473
+ },
1474
+ ],
1475
+ }),
1476
+ ),
1477
+ ]);
1478
+ expect(groupsOf(projection.groups, "metrics")[0].latest.relevance).not.toBe(
1479
+ "target",
1480
+ );
1481
+ });
1482
+
1483
+ it("does not let a generic workload label claim another kind's series", () => {
1484
+ const workloadSelectors = (type?: string) => [
1485
+ {
1486
+ metric: "container_cpu_usage_seconds_total",
1487
+ matchers: [
1488
+ { label: "namespace", op: "=", value: "shop" },
1489
+ { label: "workload", op: "=", value: "api" },
1490
+ ...(type ? [{ label: "workload_type", op: "=", value: type }] : []),
1491
+ ],
1492
+ },
1493
+ ];
1494
+ const asDeployment = project([
1495
+ tool(
1496
+ "prom",
1497
+ "query_prometheus",
1498
+ promResult({ selectors: workloadSelectors("deployment") }),
1499
+ ),
1500
+ ]);
1501
+ expect(groupsOf(asDeployment.groups, "metrics")[0].latest.relevance).toBe(
1502
+ "target",
1503
+ );
1504
+ // A StatefulSet also called api in the same namespace is a different
1505
+ // workload, and the series says so.
1506
+ const asStatefulSet = project([
1507
+ tool(
1508
+ "prom",
1509
+ "query_prometheus",
1510
+ promResult({ selectors: workloadSelectors("statefulset") }),
1511
+ ),
1512
+ ]);
1513
+ expect(groupsOf(asStatefulSet.groups, "metrics")[0].latest.relevance).toBe(
1514
+ "producer-related",
1515
+ );
1516
+
1517
+ // The same exclusion written as a regex. Reading only `=` let a sibling's
1518
+ // chart take the target's identity and its change markers.
1519
+ const regexSelectors = (value: string) => [
1520
+ {
1521
+ metric: "container_cpu_usage_seconds_total",
1522
+ matchers: [
1523
+ { label: "namespace", op: "=", value: "shop" },
1524
+ { label: "workload", op: "=", value: "api" },
1525
+ { label: "workload_type", op: "=~", value },
1526
+ ],
1527
+ },
1528
+ ];
1529
+ for (const [value, relevance] of [
1530
+ ["statefulset", "producer-related"],
1531
+ ["^(statefulset)$", "producer-related"],
1532
+ // A set that includes the target kind could be the target, so it is no
1533
+ // conflict; an inexact regex proves nothing either way.
1534
+ ["^(deployment|statefulset)$", "target"],
1535
+ [".*", "target"],
1536
+ ] as const) {
1537
+ const projection = project([
1538
+ tool(
1539
+ "prom",
1540
+ "query_prometheus",
1541
+ promResult({ selectors: regexSelectors(value) }),
1542
+ ),
1543
+ ]);
1544
+ expect(groupsOf(projection.groups, "metrics")[0].latest.relevance).toBe(
1545
+ relevance,
1546
+ );
1547
+ }
1548
+ });
1549
+
1550
+ it("refuses an unescaped dot as proof that a pod set names the target", () => {
1551
+ const dotted = ["api.v2-0"];
1552
+ const bundle = tool(
1553
+ "diag-dotted",
1554
+ "diagnose",
1555
+ { resource: deployment, pods: 1, podNames: dotted },
1556
+ {
1557
+ summary: JSON.stringify({
1558
+ kind: "Deployment",
1559
+ namespace: "shop",
1560
+ name: "api",
1561
+ }),
1562
+ },
1563
+ );
1564
+ const podSelector = (value: string) => [
1565
+ {
1566
+ metric: "container_memory_working_set_bytes",
1567
+ matchers: [
1568
+ { label: "namespace", op: "=", value: "shop" },
1569
+ { label: "pod", op: "=~", value },
1570
+ ],
1571
+ },
1572
+ ];
1573
+ // `^(api.v2-0)$` also selects api-v2-0, so it names more than the pod it
1574
+ // appears to and cannot prove membership.
1575
+ const wildcard = project([
1576
+ bundle,
1577
+ tool(
1578
+ "prom",
1579
+ "query_prometheus",
1580
+ promResult({ selectors: podSelector("^(api.v2-0)$") }),
1581
+ ),
1582
+ ]);
1583
+ expect(groupsOf(wildcard.groups, "metrics")[0].latest.relevance).toBe(
1584
+ "producer-related",
1585
+ );
1586
+ const escaped = project([
1587
+ bundle,
1588
+ tool(
1589
+ "prom",
1590
+ "query_prometheus",
1591
+ promResult({ selectors: podSelector("^(api\\.v2-0)$") }),
1592
+ ),
1593
+ ]);
1594
+ expect(groupsOf(escaped.groups, "metrics")[0].latest.relevance).toBe(
1595
+ "target",
1596
+ );
1597
+ });
1598
+
1599
+ it("rejects a result without the producer's selector inventory", () => {
1600
+ const projection = project([
1601
+ tool("old", "query_prometheus", promResult({ selectors: undefined })),
1602
+ tool(
1603
+ "bad-series",
1604
+ "query_prometheus",
1605
+ promResult({
1606
+ series: [{ labels: {}, dataPoints: [{ timestamp: "x", value: 1 }] }],
1607
+ }),
1608
+ ),
1609
+ ]);
1610
+ expect(groupsOf(projection.groups, "metrics")).toHaveLength(0);
1611
+ expect(projection.limitations.map((item) => item.kind)).toEqual([
1612
+ "unknown",
1613
+ ]);
1614
+ expect(projection.limitations[0].source).toBe("Prometheus query");
1615
+ expect(projection.limitations[0].sources).toHaveLength(2);
1616
+ });
1617
+
1618
+ it("merges the same query into one card with revisions", () => {
1619
+ const projection = project(
1620
+ [tool("first", "query_prometheus", promResult())],
1621
+ [
1622
+ tool(
1623
+ "second",
1624
+ "query_prometheus",
1625
+ promResult({
1626
+ series: [
1627
+ {
1628
+ labels: { pod: "api-7f6-abc" },
1629
+ dataPoints: [{ timestamp: 1_788_679_043, value: 1 }],
1630
+ },
1631
+ ],
1632
+ }),
1633
+ ),
1634
+ ],
1635
+ );
1636
+ const groups = groupsOf(projection.groups, "metrics");
1637
+ expect(groups).toHaveLength(1);
1638
+ expect(groups[0].observations).toHaveLength(2);
1639
+ expect(groups[0].observations[1].changedFromPrevious).toBe(true);
1640
+ });
1641
+ });