Skip to main content

ignition_core/client/
metrics.rs

1//! Metrics capability models (02-02, HLTH-07) — the verified
2//! `/data/api/v1/systemPerformance/` endpoints (02-RESEARCH §Metrics).
3//!
4//! PATH WARNING pinned by wiremock tests: the real endpoints are
5//! `systemPerformance/currentGauges|charts|threads`. The ignition-mcp
6//! client's `/data/api/v1/system/metrics` path is an invention — 404 on
7//! a real gateway — do not copy it.
8//!
9//! Scale honesty (the research rule "normalize in the model, not in
10//! users' eyes"): [`CurrentGauges::cpu`] is PERCENT (live: `4.88`);
11//! [`crate::client::status::Overview::cpu`] is a 0–1 FRACTION (live:
12//! `0.0031`). Same concept, two endpoints, two scales — documented at
13//! both fields, never silently converted.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19/// GET path of the current-gauges capability.
20pub(crate) const CURRENT_GAUGES_PATH: &str = "/data/api/v1/systemPerformance/currentGauges";
21/// GET path of the historic charts capability.
22pub(crate) const CHARTS_PATH: &str = "/data/api/v1/systemPerformance/charts";
23/// GET path of the thread-execution counts capability.
24pub(crate) const THREADS_PATH: &str = "/data/api/v1/systemPerformance/threads";
25
26/// GET `/data/api/v1/systemPerformance/currentGauges` — live captures:
27/// 8.3.6 `{cpu: 4.88, heapMemory: 240000000, maxMemory: 1073741824}`;
28/// 8.3.3 (b2026012009) serializes the heap gauge as a Java DOUBLE in
29/// scientific notation — raw wire (captured 2026-08-28):
30/// `{"cpu":1.2755618546264424,"heapMemory":2.85746728E8,"maxMemory":1073741824}`.
31/// serde_json refuses exponent/decimal forms for i64, so the memory
32/// gauges decode as f64 (byte counts ≤ ~9e15 are exact in f64 — no
33/// JVM-heap-scale precision loss); whole values serialize back as JSON
34/// INTEGERS ([`serialize_bytes_f64`]) so agent-visible `--json` output
35/// keeps the pre-f64 integer shape.
36///
37/// Sibling audit (06-07): `ThreadCounts` fields are Java longs on every
38/// captured build (plain JSON integers, never doubles — left alone);
39/// charts [`Datapoint::value`] is already f64; `histId`/`timestamp`
40/// are longs serialized as integers. The two memory gauges are the
41/// only double-typed gauge fields on the wire.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct CurrentGauges {
44    /// CPU utilization in PERCENT (0–100). NOT the 0–1 fraction
45    /// `/overview` reports — two endpoints, two scales, both documented.
46    pub cpu: f64,
47    /// Heap memory in use, bytes. f64 on the wire — 8.3.3 sends the
48    /// Java double in exponent form.
49    #[serde(rename = "heapMemory", serialize_with = "serialize_bytes_f64")]
50    pub heap_memory: f64,
51    /// Max heap (`-Xmx`), bytes. f64 on the wire (same 8.3.3 form).
52    #[serde(rename = "maxMemory", serialize_with = "serialize_bytes_f64")]
53    pub max_memory: f64,
54    /// Unknown keys (`nonHeapMemory`, …) round-trip.
55    #[serde(flatten)]
56    pub extra: BTreeMap<String, serde_json::Value>,
57}
58
59/// Serialize an f64 byte count as a JSON INTEGER whenever the value is
60/// whole (2^53 guard: f64's exact-integer floor) — heap byte counts
61/// are semantically integral; the f64 typing exists only because 8.3.3
62/// gateways serialize the gauges as Java doubles in exponent form.
63/// Keeps agent-visible JSON (and the round-trip unit test) on the
64/// pre-f64 integer shape for every whole value.
65fn serialize_bytes_f64<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
66where
67    S: serde::Serializer,
68{
69    if value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_992.0 {
70        serializer.serialize_i64(*value as i64)
71    } else {
72        serializer.serialize_f64(*value)
73    }
74}
75
76/// GET `/data/api/v1/systemPerformance/threads` — live capture:
77/// `{running: 32, waiting: 39, timedWaiting: 51, blocked: 0}`.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ThreadCounts {
80    /// Threads currently executing.
81    #[serde(default)]
82    pub running: i64,
83    /// Threads waiting to acquire a monitor.
84    #[serde(default)]
85    pub waiting: i64,
86    /// Threads in `Thread.sleep`/park-style waits.
87    #[serde(rename = "timedWaiting", default)]
88    pub timed_waiting: i64,
89    /// Threads blocked on monitor entry.
90    #[serde(default)]
91    pub blocked: i64,
92    /// Unknown keys round-trip.
93    #[serde(flatten)]
94    pub extra: BTreeMap<String, serde_json::Value>,
95}
96
97/// One historic datapoint of the charts capability.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct Datapoint {
100    /// History series id.
101    #[serde(rename = "histId", default)]
102    pub hist_id: i64,
103    /// Epoch **MILLISECONDS** (live: `1787346747022`).
104    pub timestamp: i64,
105    /// cpu series: PERCENT; memory series: bytes.
106    pub value: f64,
107}
108
109/// GET `/data/api/v1/systemPerformance/charts` — historic datapoints.
110///
111/// The WIRE shape nests the memory series:
112/// `{cpuChartDatapoints: […],
113///   memoryChartDatapoints: {heapMemoryDatapoints: […],
114///                            nonHeapMemoryDatapoints: […]}}`
115/// (openapi + live capture). The model is FLAT with serde renames onto
116/// the gateway-native series names, so deserialization accepts the
117/// nested wire body (a manual impl walks `memoryChartDatapoints`) while
118/// serialization stays a flat, agent-friendly camelCase shape.
119#[derive(Debug, Clone, PartialEq, Serialize)]
120pub struct PerformanceCharts {
121    /// CPU percent per sample.
122    #[serde(rename = "cpuChartDatapoints")]
123    pub cpu_datapoints: Vec<Datapoint>,
124    /// Heap memory bytes per sample.
125    #[serde(rename = "heapMemoryDatapoints")]
126    pub heap_memory_datapoints: Vec<Datapoint>,
127    /// Non-heap memory bytes per sample.
128    #[serde(rename = "nonHeapMemoryDatapoints")]
129    pub non_heap_memory_datapoints: Vec<Datapoint>,
130}
131
132impl<'de> Deserialize<'de> for PerformanceCharts {
133    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
134    where
135        D: serde::Deserializer<'de>,
136    {
137        /// The literal wire shape (nesting under `memoryChartDatapoints`).
138        #[derive(Deserialize)]
139        struct Wire {
140            #[serde(default, rename = "cpuChartDatapoints")]
141            cpu: Vec<Datapoint>,
142            #[serde(default, rename = "memoryChartDatapoints")]
143            memory: WireMemory,
144        }
145        #[derive(Deserialize, Default)]
146        struct WireMemory {
147            #[serde(default, rename = "heapMemoryDatapoints")]
148            heap: Vec<Datapoint>,
149            #[serde(default, rename = "nonHeapMemoryDatapoints")]
150            non_heap: Vec<Datapoint>,
151        }
152        let wire = Wire::deserialize(deserializer)?;
153        Ok(Self {
154            cpu_datapoints: wire.cpu,
155            heap_memory_datapoints: wire.memory.heap,
156            non_heap_memory_datapoints: wire.memory.non_heap,
157        })
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::{CurrentGauges, Datapoint, PerformanceCharts, ThreadCounts};
164
165    /// The exact live capture (02-RESEARCH §Metrics): cpu is PERCENT —
166    /// and stays percent on round-trip (no fraction conversion).
167    #[test]
168    fn current_gauges_parses_the_live_capture() {
169        let body = serde_json::json!({
170            "cpu": 4.88,
171            "heapMemory": 240000000i64,
172            "maxMemory": 1073741824i64
173        });
174        let gauges: CurrentGauges =
175            serde_json::from_value(body).expect("live gauges shape must parse");
176        assert!((gauges.cpu - 4.88).abs() < f64::EPSILON, "percent");
177        assert_eq!(gauges.heap_memory, 240000000.0);
178        assert_eq!(gauges.max_memory, 1073741824.0);
179
180        let round = serde_json::to_value(&gauges).expect("serialize");
181        assert_eq!(round["cpu"], 4.88, "cpu stays percent");
182        assert_eq!(round["heapMemory"], 240000000i64, "gateway-native key");
183        assert_eq!(round["maxMemory"], 1073741824i64);
184    }
185
186    /// 8.3.3 (b2026012009) serializes the heap gauge as a Java double
187    /// in SCIENTIFIC NOTATION (06-UAT test 4, wire-verified 2026-08-28)
188    /// — the exact raw body, parsed the way the wire parses it
189    /// (`from_str`, not `json!` — the macro pre-normalizes numbers, so
190    /// it cannot prove the exponent TEXT decodes). Exponent,
191    /// integer, and decimal-mantissa forms all decode; whole values
192    /// round-trip as JSON INTEGERS (the pre-f64 agent shape).
193    #[test]
194    fn current_gauges_decodes_exponent_form_java_doubles() {
195        let raw = r#"{"cpu":1.2755618546264424,"heapMemory":2.85746728E8,"maxMemory":1073741824}"#;
196        let gauges: CurrentGauges =
197            serde_json::from_str(raw).expect("8.3.3 exponent-form gauges must parse");
198        assert_eq!(gauges.heap_memory, 285746728.0);
199        assert_eq!(gauges.max_memory, 1073741824.0);
200
201        // Decimal-mantissa exponent form decodes the same way.
202        let decimal: CurrentGauges = serde_json::from_str(
203            r#"{"cpu":1.2,"heapMemory":2.8574672E8,"maxMemory":1.073741824E9}"#,
204        )
205        .expect("decimal-mantissa exponent form must parse");
206        assert_eq!(decimal.heap_memory, 285746720.0);
207        assert_eq!(decimal.max_memory, 1073741824.0);
208
209        // Whole values serialize back as JSON integers — 285746728,
210        // never 285746728.0 (agent shape unchanged by the f64 typing).
211        let round = serde_json::to_value(&gauges).expect("serialize");
212        assert_eq!(round["heapMemory"], 285746728i64);
213        assert_eq!(round["maxMemory"], 1073741824i64);
214    }
215
216    /// The exact live thread counts, including the camelCase
217    /// `timedWaiting` rename.
218    #[test]
219    fn thread_counts_parses_the_live_capture() {
220        let counts: ThreadCounts = serde_json::from_value(serde_json::json!({
221            "running": 32, "waiting": 39, "timedWaiting": 51, "blocked": 0
222        }))
223        .expect("live threads shape must parse");
224        assert_eq!(counts.running, 32);
225        assert_eq!(counts.waiting, 39);
226        assert_eq!(counts.timed_waiting, 51);
227        assert_eq!(counts.blocked, 0);
228
229        let round = serde_json::to_value(&counts).expect("serialize");
230        assert_eq!(round["timedWaiting"], 51, "gateway-native key");
231    }
232
233    /// The charts body deserializes from the NESTED wire shape (one
234    /// datapoint per series) and serializes FLAT under the
235    /// gateway-native series names.
236    #[test]
237    fn charts_parse_nested_wire_and_serialize_flat() {
238        let wire = serde_json::json!({
239            "cpuChartDatapoints": [
240                {"histId": 1, "timestamp": 1787346747022i64, "value": 4.88}
241            ],
242            "memoryChartDatapoints": {
243                "heapMemoryDatapoints": [
244                    {"histId": 2, "timestamp": 1787346747022i64, "value": 240000000.0}
245                ],
246                "nonHeapMemoryDatapoints": [
247                    {"histId": 3, "timestamp": 1787346747022i64, "value": 52000000.0}
248                ]
249            }
250        });
251        let charts: PerformanceCharts =
252            serde_json::from_value(wire).expect("nested wire shape must parse");
253        assert_eq!(charts.cpu_datapoints.len(), 1);
254        assert_eq!(charts.heap_memory_datapoints.len(), 1);
255        assert_eq!(charts.non_heap_memory_datapoints.len(), 1);
256        let Datapoint {
257            hist_id,
258            timestamp,
259            value,
260        } = &charts.cpu_datapoints[0];
261        assert_eq!((*hist_id, *timestamp), (1, 1787346747022));
262        assert!((*value - 4.88).abs() < f64::EPSILON);
263
264        let flat = serde_json::to_value(&charts).expect("serialize");
265        assert_eq!(flat["cpuChartDatapoints"][0]["histId"], 1);
266        assert!(
267            flat["heapMemoryDatapoints"].is_array() && flat["nonHeapMemoryDatapoints"].is_array(),
268            "memory series serialize flat under their gateway-native names"
269        );
270    }
271}