Skip to content

Commit b4c2972

Browse files
committed
feat(stats): report cpuPercent in the machine-readable formats
container stats already samples twice, sleeping two seconds between samples, and computes a CPU percentage from the pair for its table. Only the second sample was rendered for json, yaml and toml, so a consumer paid that latency and received neither the percentage nor the earlier sample needed to derive it. Getting a CPU percentage from the CLI meant invoking the command twice and diffing cpuUsageUsec by hand. Adds a StatsReport payload for the non-table formats: the latest sample plus the derived percentage. ContainerResource.ContainerStats models a single sample and a rate is not a property of one sample, so the derived value lives in the command rather than widening that type. The two-second interval is now a single constant. collectStats sleeps for it and the percentage divides by it, so the two must agree; they were separate literals before.
1 parent d6de569 commit b4c2972

1 file changed

Lines changed: 52 additions & 3 deletions

File tree

‎Sources/ContainerCommands/Container/ContainerStats.swift‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ extension Application {
102102

103103
let statsData = try await Self.collectStats(client: client, for: containersToShow)
104104

105-
try Output.render(payload: statsData.map { $0.stats2 }, format: format) {
105+
try Output.render(payload: statsData.map { StatsReport(snapshot: $0) }, format: format) {
106106
Self.statsTable(statsData)
107107
}
108108
}
@@ -159,6 +159,55 @@ extension Application {
159159
let stats2: ContainerResource.ContainerStats
160160
}
161161

162+
/// `collectStats` sleeps for this between samples and the percentage divides by it,
163+
/// so the two must not drift apart.
164+
private static let sampleInterval: Duration = .seconds(2)
165+
166+
/// The payload for the machine-readable formats: the second sample plus the CPU
167+
/// percentage derived from both.
168+
///
169+
/// `ContainerResource.ContainerStats` models a single sample, and a rate is not a
170+
/// 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.
177+
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+
}
208+
}
209+
}
210+
162211
private static func collectStats(client: ContainerClient, for containers: [ContainerSnapshot]) async throws -> [StatsSnapshot] {
163212
var snapshots: [StatsSnapshot] = []
164213

@@ -176,7 +225,7 @@ extension Application {
176225

177226
// Wait 2 seconds for CPU delta calculation
178227
if !snapshots.isEmpty {
179-
try await Task.sleep(for: .seconds(2))
228+
try await Task.sleep(for: Self.sampleInterval)
180229

181230
// Second sample
182231
for i in 0..<snapshots.count {
@@ -245,7 +294,7 @@ extension Application {
245294
let cpuPercent = Self.calculateCPUPercent(
246295
cpuUsage1: .microseconds(cpuUsageUsec1),
247296
cpuUsage2: .microseconds(cpuUsageUsec2),
248-
timeInterval: .seconds(2)
297+
timeInterval: Self.sampleInterval
249298
)
250299
let cpuStr = String(format: "%.2f%%", cpuPercent)
251300
row.append(cpuStr)

0 commit comments

Comments
 (0)