Skip to content

Commit 0a12f00

Browse files
committed
refactor(stats): share cpuPercent and wrap the sample for encoding
Review asked for one calculation, no field mirroring on StatsReport, and tests that json carries cpuPercent. TOMLEncoder cannot merge two keyed containers, so encode flattens the wrapped sample in one pass.
1 parent b4c2972 commit 0a12f00

3 files changed

Lines changed: 234 additions & 53 deletions

File tree

‎Sources/ContainerCommands/Container/ContainerStats.swift‎

Lines changed: 63 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -153,58 +153,78 @@ extension Application {
153153
}
154154
}
155155

156-
private struct StatsSnapshot {
156+
fileprivate struct StatsSnapshot {
157157
let container: ContainerSnapshot
158158
let stats1: ContainerResource.ContainerStats
159159
let stats2: ContainerResource.ContainerStats
160+
161+
/// Percent of one core, matching `top` and `docker stats`: 400% is four cores
162+
/// saturated. Deliberately not divided by the container's CPU allocation.
163+
var cpuPercent: Double? {
164+
guard let first = stats1.cpuUsageUsec, let second = stats2.cpuUsageUsec else {
165+
return nil
166+
}
167+
return ContainerStats.calculateCPUPercent(
168+
cpuUsage1: .microseconds(first),
169+
cpuUsage2: .microseconds(second),
170+
timeInterval: ContainerStats.sampleInterval
171+
)
172+
}
160173
}
161174

162175
/// `collectStats` sleeps for this between samples and the percentage divides by it,
163176
/// so the two must not drift apart.
164-
private static let sampleInterval: Duration = .seconds(2)
177+
static let sampleInterval: Duration = .seconds(2)
165178

166179
/// The payload for the machine-readable formats: the second sample plus the CPU
167180
/// percentage derived from both.
168181
///
169182
/// `ContainerResource.ContainerStats` models a single sample, and a rate is not a
170183
/// property of one sample, so the derived value lives here rather than widening that
171-
/// type. Until now only `stats2` was rendered, which discarded both the percentage
172-
/// the table computes and the earlier sample a consumer would need to compute it.
173-
private struct StatsReport: Encodable {
174-
let id: String
175-
/// Percent of one core, matching `top` and `docker stats`: 400% is four cores
176-
/// saturated. Deliberately not divided by the container's CPU allocation.
184+
/// type. The sample is wrapped and flattened on encode so the stored shape stays
185+
/// additive (`cpuPercent` only) without copying every sample field onto this type.
186+
///
187+
/// Encoding goes through one keyed container. TOMLEncoder replaces `encoder.value`
188+
/// on each `container(keyedBy:)` call, so `stats.encode(to:)` followed by a second
189+
/// container would drop the sample fields.
190+
struct StatsReport: Encodable {
191+
let stats: ContainerResource.ContainerStats
177192
let cpuPercent: Double?
178-
let cpuUsageUsec: UInt64?
179-
let memoryUsageBytes: UInt64?
180-
let memoryLimitBytes: UInt64?
181-
let networkRxBytes: UInt64?
182-
let networkTxBytes: UInt64?
183-
let blockReadBytes: UInt64?
184-
let blockWriteBytes: UInt64?
185-
let numProcesses: UInt64?
186-
187-
init(snapshot: StatsSnapshot) {
188-
let latest = snapshot.stats2
189-
self.id = latest.id
190-
self.cpuUsageUsec = latest.cpuUsageUsec
191-
self.memoryUsageBytes = latest.memoryUsageBytes
192-
self.memoryLimitBytes = latest.memoryLimitBytes
193-
self.networkRxBytes = latest.networkRxBytes
194-
self.networkTxBytes = latest.networkTxBytes
195-
self.blockReadBytes = latest.blockReadBytes
196-
self.blockWriteBytes = latest.blockWriteBytes
197-
self.numProcesses = latest.numProcesses
198-
199-
if let first = snapshot.stats1.cpuUsageUsec, let second = latest.cpuUsageUsec {
200-
self.cpuPercent = ContainerStats.calculateCPUPercent(
201-
cpuUsage1: .microseconds(first),
202-
cpuUsage2: .microseconds(second),
203-
timeInterval: ContainerStats.sampleInterval
204-
)
205-
} else {
206-
self.cpuPercent = nil
207-
}
193+
194+
fileprivate init(snapshot: StatsSnapshot) {
195+
self.init(stats: snapshot.stats2, cpuPercent: snapshot.cpuPercent)
196+
}
197+
198+
init(stats: ContainerResource.ContainerStats, cpuPercent: Double?) {
199+
self.stats = stats
200+
self.cpuPercent = cpuPercent
201+
}
202+
203+
func encode(to encoder: Encoder) throws {
204+
var container = encoder.container(keyedBy: CodingKeys.self)
205+
try container.encode(stats.id, forKey: .id)
206+
try container.encodeIfPresent(stats.cpuUsageUsec, forKey: .cpuUsageUsec)
207+
try container.encodeIfPresent(stats.memoryUsageBytes, forKey: .memoryUsageBytes)
208+
try container.encodeIfPresent(stats.memoryLimitBytes, forKey: .memoryLimitBytes)
209+
try container.encodeIfPresent(stats.networkRxBytes, forKey: .networkRxBytes)
210+
try container.encodeIfPresent(stats.networkTxBytes, forKey: .networkTxBytes)
211+
try container.encodeIfPresent(stats.blockReadBytes, forKey: .blockReadBytes)
212+
try container.encodeIfPresent(stats.blockWriteBytes, forKey: .blockWriteBytes)
213+
try container.encodeIfPresent(stats.numProcesses, forKey: .numProcesses)
214+
try container.encodeIfPresent(cpuPercent, forKey: .cpuPercent)
215+
}
216+
217+
private enum CodingKeys: String, CodingKey {
218+
case id
219+
case cpuPercent
220+
case cpuUsageUsec
221+
case memoryUsageBytes
222+
case memoryLimitBytes
223+
case networkRxBytes
224+
case networkTxBytes
225+
case blockReadBytes
226+
case blockWriteBytes
227+
case numProcesses
208228
}
209229
}
210230

@@ -248,9 +268,9 @@ extension Application {
248268

249269
/// Calculate CPU percentage from two stat snapshots
250270
/// - Parameters:
251-
/// - cpuUsageUsec1: CPU usage in microseconds from first sample
252-
/// - cpuUsageUsec2: CPU usage in microseconds from second sample
253-
/// - timeDeltaUsec: Time delta between samples in microseconds
271+
/// - cpuUsage1: CPU usage from the first sample
272+
/// - cpuUsage2: CPU usage from the second sample
273+
/// - timeInterval: Wall-clock interval between samples
254274
/// - Returns: CPU percentage where 100% = one fully utilized core
255275
static func calculateCPUPercent(
256276
cpuUsage1: Duration,
@@ -287,17 +307,10 @@ extension Application {
287307

288308
for snapshot in statsData {
289309
var row = [snapshot.container.id]
290-
let stats1 = snapshot.stats1
291310
let stats2 = snapshot.stats2
292311

293-
if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec {
294-
let cpuPercent = Self.calculateCPUPercent(
295-
cpuUsage1: .microseconds(cpuUsageUsec1),
296-
cpuUsage2: .microseconds(cpuUsageUsec2),
297-
timeInterval: Self.sampleInterval
298-
)
299-
let cpuStr = String(format: "%.2f%%", cpuPercent)
300-
row.append(cpuStr)
312+
if let cpuPercent = snapshot.cpuPercent {
313+
row.append(String(format: "%.2f%%", cpuPercent))
301314
} else {
302315
row.append(notAvailable)
303316
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerResource
18+
import Foundation
19+
import Testing
20+
21+
@testable import ContainerCommands
22+
23+
private func makeSampleStats(
24+
id: String = "busy",
25+
memoryUsageBytes: UInt64? = 1024,
26+
memoryLimitBytes: UInt64? = 256 * 1024 * 1024,
27+
cpuUsageUsec: UInt64? = 2_000_000,
28+
networkRxBytes: UInt64? = 10,
29+
networkTxBytes: UInt64? = 20,
30+
blockReadBytes: UInt64? = 30,
31+
blockWriteBytes: UInt64? = 40,
32+
numProcesses: UInt64? = 1
33+
) -> ContainerResource.ContainerStats {
34+
ContainerResource.ContainerStats(
35+
id: id,
36+
memoryUsageBytes: memoryUsageBytes,
37+
memoryLimitBytes: memoryLimitBytes,
38+
cpuUsageUsec: cpuUsageUsec,
39+
networkRxBytes: networkRxBytes,
40+
networkTxBytes: networkTxBytes,
41+
blockReadBytes: blockReadBytes,
42+
blockWriteBytes: blockWriteBytes,
43+
numProcesses: numProcesses
44+
)
45+
}
46+
47+
private func jsonObject(from report: Application.ContainerStats.StatsReport) throws -> [String: Any] {
48+
let data = try JSONEncoder().encode(report)
49+
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
50+
}
51+
52+
struct CalculateCPUPercentTests {
53+
@Test
54+
func oneCoreFullyUtilizedIs100Percent() {
55+
let percent = Application.ContainerStats.calculateCPUPercent(
56+
cpuUsage1: .seconds(0),
57+
cpuUsage2: .seconds(2),
58+
timeInterval: Application.ContainerStats.sampleInterval
59+
)
60+
#expect(percent == 100.0)
61+
}
62+
63+
@Test
64+
func fourCoresSaturatedIs400Percent() {
65+
let percent = Application.ContainerStats.calculateCPUPercent(
66+
cpuUsage1: .seconds(0),
67+
cpuUsage2: .seconds(8),
68+
timeInterval: Application.ContainerStats.sampleInterval
69+
)
70+
#expect(percent == 400.0)
71+
}
72+
73+
@Test
74+
func unchangedUsageIsZero() {
75+
let percent = Application.ContainerStats.calculateCPUPercent(
76+
cpuUsage1: .milliseconds(500),
77+
cpuUsage2: .milliseconds(500),
78+
timeInterval: Application.ContainerStats.sampleInterval
79+
)
80+
#expect(percent == 0.0)
81+
}
82+
83+
@Test
84+
func usageDecreaseIsTreatedAsZero() {
85+
let percent = Application.ContainerStats.calculateCPUPercent(
86+
cpuUsage1: .seconds(4),
87+
cpuUsage2: .seconds(1),
88+
timeInterval: Application.ContainerStats.sampleInterval
89+
)
90+
#expect(percent == 0.0)
91+
}
92+
}
93+
94+
struct StatsReportEncodingTests {
95+
@Test
96+
func jsonIncludesCpuPercentAndForwardsEverySampleField() throws {
97+
let stats = makeSampleStats()
98+
let report = Application.ContainerStats.StatsReport(stats: stats, cpuPercent: 42.5)
99+
let sample = try #require(
100+
JSONSerialization.jsonObject(with: JSONEncoder().encode(stats)) as? [String: Any]
101+
)
102+
let encoded = try jsonObject(from: report)
103+
104+
for key in sample.keys {
105+
#expect(encoded[key] != nil, "StatsReport dropped sample field \(key)")
106+
}
107+
#expect(encoded["stats"] == nil, "sample should be flattened, not nested under stats")
108+
#expect(encoded["cpuPercent"] as? Double == 42.5)
109+
#expect(encoded["id"] as? String == "busy")
110+
111+
let decoded = try JSONDecoder().decode(
112+
ContainerResource.ContainerStats.self,
113+
from: JSONEncoder().encode(report)
114+
)
115+
#expect(decoded.id == stats.id)
116+
#expect(decoded.memoryUsageBytes == stats.memoryUsageBytes)
117+
#expect(decoded.cpuUsageUsec == stats.cpuUsageUsec)
118+
}
119+
120+
@Test
121+
func jsonOmitsNilCpuPercent() throws {
122+
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: nil)
123+
let encoded = try jsonObject(from: report)
124+
#expect(encoded["cpuPercent"] == nil)
125+
#expect(encoded["id"] as? String == "busy")
126+
}
127+
128+
@Test
129+
func renderJSONIncludesCpuPercent() throws {
130+
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: 12.25)
131+
let json = try Output.renderJSON([report])
132+
#expect(json.contains("\"cpuPercent\":12.25"))
133+
#expect(json.contains("\"id\":\"busy\""))
134+
}
135+
136+
@Test
137+
func renderYAMLIncludesCpuPercent() throws {
138+
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: 12.25)
139+
let yaml = try Output.renderYAML([report])
140+
#expect(yaml.contains("cpuPercent"))
141+
#expect(yaml.contains("busy"))
142+
#expect(yaml.contains("memoryUsageBytes"))
143+
}
144+
145+
@Test
146+
func renderTOMLIncludesCpuPercent() throws {
147+
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: 12.25)
148+
let toml = try Output.renderTOML([report])
149+
#expect(toml.contains("cpuPercent"))
150+
#expect(toml.contains("12.25"))
151+
#expect(toml.contains("busy"))
152+
#expect(toml.contains("memoryUsageBytes"))
153+
}
154+
}

‎Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,35 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17-
import ContainerResource
1817
import ContainerTestSupport
1918
import Foundation
2019
import Testing
2120

21+
/// Machine-readable stats payload, including the derived `cpuPercent` that
22+
/// `ContainerResource.ContainerStats` does not carry.
23+
private struct StatsJSON: Decodable {
24+
let id: String
25+
let cpuPercent: Double?
26+
let memoryUsageBytes: UInt64?
27+
let numProcesses: UInt64?
28+
}
29+
2230
@Suite
2331
struct TestCLIStatsCommand {
2432
@Test func testStatsNoStreamJSONFormat() async throws {
2533
try await ContainerFixture.with { f in
2634
let image = WarmupImage.alpine320.rawValue
2735
try await f.withContainer(image: image) { name in
2836
let result = try f.run(["stats", "--format", "json", "--no-stream", name]).check()
29-
let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData)
37+
let stats = try JSONDecoder().decode([StatsJSON].self, from: result.outputData)
3038
#expect(stats.count == 1, "expected stats for one container")
3139
#expect(stats[0].id == name, "container ID should match")
3240
let memoryUsageBytes = try #require(stats[0].memoryUsageBytes)
3341
let numProcesses = try #require(stats[0].numProcesses)
42+
let cpuPercent = try #require(stats[0].cpuPercent, "json should include cpuPercent")
3443
#expect(memoryUsageBytes > 0, "memory usage should be non-zero")
3544
#expect(numProcesses >= 1, "should have at least one process")
45+
#expect(cpuPercent >= 0, "cpuPercent should be non-negative, got \(cpuPercent)")
3646
}
3747
}
3848
}
@@ -94,11 +104,15 @@ struct TestCLIStatsCommand {
94104
try await f.withContainer(image: image, tag: "c1") { name1 in
95105
try await f.withContainer(image: image, tag: "c2") { name2 in
96106
let result = try f.run(["stats", "--format", "json", "--no-stream"]).check()
97-
let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData)
107+
let stats = try JSONDecoder().decode([StatsJSON].self, from: result.outputData)
98108
try #require(stats.count >= 2, "should have stats for at least 2 containers")
99109
let ids = stats.map { $0.id }
100110
#expect(ids.contains(name1), "should include first container")
101111
#expect(ids.contains(name2), "should include second container")
112+
for row in stats where ids.contains(row.id) {
113+
let cpuPercent = try #require(row.cpuPercent, "json should include cpuPercent for \(row.id)")
114+
#expect(cpuPercent >= 0, "cpuPercent should be non-negative, got \(cpuPercent)")
115+
}
102116
}
103117
}
104118
}

0 commit comments

Comments
 (0)