Skip to content

Commit 95c2d30

Browse files
authored
Add CPU usage to reported metrics (#26)
1 parent 6cea4c4 commit 95c2d30

File tree

2 files changed

+171
-64
lines changed

2 files changed

+171
-64
lines changed

Sources/SystemMetrics/SystemMetrics.swift

Lines changed: 148 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the Swift Metrics API open source project
44
//
5-
// Copyright (c) 2018-2020 Apple Inc. and the Swift Metrics API project authors
5+
// Copyright (c) 2018-2023 Apple Inc. and the Swift Metrics API project authors
66
// Licensed under Apache License v2.0
77
//
88
// See LICENSE.txt for license information
@@ -69,6 +69,7 @@ extension MetricsSystem {
6969
Gauge(label: self.labels.label(for: \.cpuSecondsTotal), dimensions: self.dimensions).record(metrics.cpuSeconds)
7070
Gauge(label: self.labels.label(for: \.maxFileDescriptors), dimensions: self.dimensions).record(metrics.maxFileDescriptors)
7171
Gauge(label: self.labels.label(for: \.openFileDescriptors), dimensions: self.dimensions).record(metrics.openFileDescriptors)
72+
Gauge(label: self.labels.label(for: \.cpuUsage), dimensions: self.dimensions).record(metrics.cpuUsage)
7273
}))
7374

7475
self.timer.schedule(deadline: .now() + self.timeInterval, repeating: self.timeInterval)
@@ -145,6 +146,8 @@ public enum SystemMetrics {
145146
let maxFileDescriptors: String
146147
/// Number of open file descriptors.
147148
let openFileDescriptors: String
149+
/// CPU usage percentage.
150+
let cpuUsage: String
148151

149152
/// Create a new `Labels` instance.
150153
///
@@ -156,14 +159,25 @@ public enum SystemMetrics {
156159
/// - cpuSecondsTotal: Total user and system CPU time spent in seconds.
157160
/// - maxFds: Maximum number of open file descriptors.
158161
/// - openFds: Number of open file descriptors.
159-
public init(prefix: String, virtualMemoryBytes: String, residentMemoryBytes: String, startTimeSeconds: String, cpuSecondsTotal: String, maxFds: String, openFds: String) {
162+
/// - cpuUsage: Total CPU usage percentage.
163+
public init(
164+
prefix: String,
165+
virtualMemoryBytes: String,
166+
residentMemoryBytes: String,
167+
startTimeSeconds: String,
168+
cpuSecondsTotal: String,
169+
maxFds: String,
170+
openFds: String,
171+
cpuUsage: String
172+
) {
160173
self.prefix = prefix
161174
self.virtualMemoryBytes = virtualMemoryBytes
162175
self.residentMemoryBytes = residentMemoryBytes
163176
self.startTimeSeconds = startTimeSeconds
164177
self.cpuSecondsTotal = cpuSecondsTotal
165178
self.maxFileDescriptors = maxFds
166179
self.openFileDescriptors = openFds
180+
self.cpuUsage = cpuUsage
167181
}
168182

169183
func label(for keyPath: KeyPath<Labels, String>) -> String {
@@ -173,7 +187,7 @@ public enum SystemMetrics {
173187

174188
/// System Metric data.
175189
///
176-
/// The current list of metrics exposed is taken from the Prometheus Client Library Guidelines
190+
/// The current list of metrics exposed is a superset of the Prometheus Client Library Guidelines:
177191
/// https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors
178192
public struct Data {
179193
/// Virtual memory size in bytes.
@@ -188,82 +202,121 @@ public enum SystemMetrics {
188202
var maxFileDescriptors: Int
189203
/// Number of open file descriptors.
190204
var openFileDescriptors: Int
205+
/// CPU usage percentage.
206+
var cpuUsage: Double
191207
}
192208

193209
#if os(Linux)
194-
internal static func linuxSystemMetrics() -> SystemMetrics.Data? {
195-
/// Minimal file reading implementation so we don't have to depend on Foundation.
196-
/// Designed only for the narrow use case of this library, reading `/proc/self/stat`.
197-
final class CFile {
198-
let path: String
210+
/// Minimal file reading implementation so we don't have to depend on Foundation.
211+
/// Designed only for the narrow use case of this library.
212+
final class CFile {
213+
let path: String
199214

200-
private var file: UnsafeMutablePointer<FILE>?
215+
private var file: UnsafeMutablePointer<FILE>?
201216

202-
init(_ path: String) {
203-
self.path = path
204-
}
217+
init(_ path: String) {
218+
self.path = path
219+
}
205220

206-
deinit {
207-
assert(self.file == nil)
208-
}
221+
deinit {
222+
assert(self.file == nil)
223+
}
209224

210-
func open() {
211-
guard let f = fopen(path, "r") else {
212-
return
213-
}
214-
self.file = f
225+
func open() {
226+
guard let f = fopen(path, "r") else {
227+
return
215228
}
229+
self.file = f
230+
}
216231

217-
func close() {
218-
if let f = self.file {
219-
self.file = nil
220-
let success = fclose(f) == 0
221-
assert(success)
222-
}
232+
func close() {
233+
if let f = self.file {
234+
self.file = nil
235+
let success = fclose(f) == 0
236+
assert(success)
223237
}
238+
}
224239

225-
func readLine() -> String? {
226-
guard let f = self.file else {
227-
return nil
228-
}
229-
var buff = [CChar](repeating: 0, count: 1024)
230-
let hasNewLine = buff.withUnsafeMutableBufferPointer { ptr -> Bool in
231-
guard fgets(ptr.baseAddress, Int32(ptr.count), f) != nil else {
232-
if feof(f) != 0 {
233-
return false
234-
} else {
235-
preconditionFailure("Error reading line")
236-
}
240+
func readLine() -> String? {
241+
guard let f = self.file else {
242+
return nil
243+
}
244+
var buff = [CChar](repeating: 0, count: 1024)
245+
let hasNewLine = buff.withUnsafeMutableBufferPointer { ptr -> Bool in
246+
guard fgets(ptr.baseAddress, Int32(ptr.count), f) != nil else {
247+
if feof(f) != 0 {
248+
return false
249+
} else {
250+
preconditionFailure("Error reading line")
237251
}
238-
return true
239-
}
240-
if !hasNewLine {
241-
return nil
242252
}
243-
return String(cString: buff)
253+
return true
244254
}
255+
if !hasNewLine {
256+
return nil
257+
}
258+
return String(cString: buff)
259+
}
245260

246-
func readFull() -> String {
247-
var s = ""
248-
func loop() -> String {
249-
if let l = readLine() {
250-
s += l
251-
return loop()
252-
}
253-
return s
261+
func readFull() -> String {
262+
var s = ""
263+
func loop() -> String {
264+
if let l = readLine() {
265+
s += l
266+
return loop()
254267
}
255-
return loop()
268+
return s
256269
}
270+
return loop()
257271
}
272+
}
258273

259-
let ticks = _SC_CLK_TCK
274+
/// A type that can calculate CPU usage for a given process.
275+
///
276+
/// CPU usage is calculated as the number of CPU ticks used by this process between measurements.
277+
/// - Note: the first measurement will be calculated since the process' start time, since there's no
278+
/// previous measurement to take as reference.
279+
private struct CPUUsageCalculator {
280+
/// The number of ticks after system boot that the last CPU usage stat was taken.
281+
private var previousTicksSinceSystemBoot: Int = 0
282+
/// The number of ticks the process actively used the CPU, as of the previous CPU usage measurement.
283+
private var previousCPUTicks: Int = 0
284+
285+
mutating func getUsagePercentage(ticksSinceSystemBoot: Int, cpuTicks: Int) -> Double {
286+
defer {
287+
self.previousTicksSinceSystemBoot = ticksSinceSystemBoot
288+
self.previousCPUTicks = cpuTicks
289+
}
290+
let ticksBetweenMeasurements = ticksSinceSystemBoot - self.previousTicksSinceSystemBoot
291+
let cpuTicksBetweenMeasurements = cpuTicks - self.previousCPUTicks
292+
return Double(cpuTicksBetweenMeasurements) * 100 / Double(ticksBetweenMeasurements)
293+
}
294+
}
260295

261-
let file = CFile("/proc/self/stat")
262-
file.open()
296+
private static let systemStartTimeInSecondsSinceEpoch: Int? = {
297+
let systemStatFile = CFile("/proc/stat")
298+
systemStatFile.open()
263299
defer {
264-
file.close()
300+
systemStatFile.close()
265301
}
302+
while let line = systemStatFile.readLine() {
303+
if line.starts(with: "btime"),
304+
let systemUptimeInSecondsSinceEpochString = line
305+
.split(separator: " ")
306+
.last?
307+
.split(separator: "\n")
308+
.first,
309+
let systemUptimeInSecondsSinceEpoch = Int(systemUptimeInSecondsSinceEpochString)
310+
{
311+
return systemUptimeInSecondsSinceEpoch
312+
}
313+
}
314+
return nil
315+
}()
266316

317+
private static var cpuUsageCalculator = CPUUsageCalculator()
318+
319+
internal static func linuxSystemMetrics() -> SystemMetrics.Data? {
267320
enum StatIndices {
268321
static let virtualMemoryBytes = 20
269322
static let residentMemoryBytes = 21
@@ -272,8 +325,26 @@ public enum SystemMetrics {
272325
static let stimeTicks = 12
273326
}
274327

328+
let ticks = _SC_CLK_TCK
329+
330+
let statFile = CFile("/proc/self/stat")
331+
statFile.open()
332+
defer {
333+
statFile.close()
334+
}
335+
336+
let uptimeFile = CFile("/proc/uptime")
337+
uptimeFile.open()
338+
defer {
339+
uptimeFile.close()
340+
}
341+
342+
// Read both files as close as possible to each other to get an accurate CPU usage metric.
343+
let statFileContents = statFile.readFull()
344+
let uptimeFileContents = uptimeFile.readFull()
345+
275346
guard
276-
let statString = file.readFull()
347+
let statString = statFileContents
277348
.split(separator: ")")
278349
.last
279350
else { return nil }
@@ -288,11 +359,25 @@ public enum SystemMetrics {
288359
let stimeTicks = Int(stats[safe: StatIndices.stimeTicks])
289360
else { return nil }
290361
let residentMemoryBytes = rss * _SC_PAGESIZE
291-
let startTimeSeconds = startTimeTicks / ticks
292-
let cpuSeconds = (utimeTicks / ticks) + (stimeTicks / ticks)
362+
let processStartTimeInSeconds = startTimeTicks / ticks
363+
let cpuTicks = utimeTicks + stimeTicks
364+
let cpuSeconds = cpuTicks / ticks
293365

294-
var _rlim = rlimit()
366+
guard let systemStartTimeInSecondsSinceEpoch = SystemMetrics.systemStartTimeInSecondsSinceEpoch else {
367+
return nil
368+
}
369+
let startTimeInSecondsSinceEpoch = systemStartTimeInSecondsSinceEpoch + processStartTimeInSeconds
370+
371+
var cpuUsage: Double = 0
372+
if cpuTicks > 0 {
373+
guard let uptimeString = uptimeFileContents.split(separator: " ").first,
374+
let uptimeSeconds = Float(uptimeString)
375+
else { return nil }
376+
let uptimeTicks = Int(ceilf(uptimeSeconds)) * ticks
377+
cpuUsage = SystemMetrics.cpuUsageCalculator.getUsagePercentage(ticksSinceSystemBoot: uptimeTicks, cpuTicks: cpuTicks)
378+
}
295379

380+
var _rlim = rlimit()
296381
guard withUnsafeMutablePointer(to: &_rlim, { ptr in
297382
getrlimit(__rlimit_resource_t(RLIMIT_NOFILE.rawValue), ptr) == 0
298383
}) else { return nil }
@@ -309,10 +394,11 @@ public enum SystemMetrics {
309394
return .init(
310395
virtualMemoryBytes: virtualMemoryBytes,
311396
residentMemoryBytes: residentMemoryBytes,
312-
startTimeSeconds: startTimeSeconds,
397+
startTimeSeconds: startTimeInSecondsSinceEpoch,
313398
cpuSeconds: cpuSeconds,
314399
maxFileDescriptors: maxFileDescriptors,
315-
openFileDescriptors: openFileDescriptors
400+
openFileDescriptors: openFileDescriptors,
401+
cpuUsage: cpuUsage
316402
)
317403
}
318404

Tests/SystemMetricsTests/SystemMetricsTests.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,41 @@ class SystemMetricsTest: XCTestCase {
3333
XCTAssertNotNil(metrics.cpuSeconds)
3434
XCTAssertNotNil(metrics.maxFileDescriptors)
3535
XCTAssertNotNil(metrics.openFileDescriptors)
36+
XCTAssertNotNil(metrics.cpuUsage)
3637
}
3738

3839
func testSystemMetricsLabels() throws {
39-
let labels = SystemMetrics.Labels(prefix: "pfx+", virtualMemoryBytes: "vmb", residentMemoryBytes: "rmb", startTimeSeconds: "sts", cpuSecondsTotal: "cpt", maxFds: "mfd", openFds: "ofd")
40+
let labels = SystemMetrics.Labels(
41+
prefix: "pfx+",
42+
virtualMemoryBytes: "vmb",
43+
residentMemoryBytes: "rmb",
44+
startTimeSeconds: "sts",
45+
cpuSecondsTotal: "cpt",
46+
maxFds: "mfd",
47+
openFds: "ofd",
48+
cpuUsage: "cpu"
49+
)
4050

4151
XCTAssertEqual(labels.label(for: \.virtualMemoryBytes), "pfx+vmb")
4252
XCTAssertEqual(labels.label(for: \.residentMemoryBytes), "pfx+rmb")
4353
XCTAssertEqual(labels.label(for: \.startTimeSeconds), "pfx+sts")
4454
XCTAssertEqual(labels.label(for: \.cpuSecondsTotal), "pfx+cpt")
4555
XCTAssertEqual(labels.label(for: \.maxFileDescriptors), "pfx+mfd")
4656
XCTAssertEqual(labels.label(for: \.openFileDescriptors), "pfx+ofd")
57+
XCTAssertEqual(labels.label(for: \.cpuUsage), "pfx+cpu")
4758
}
4859

4960
func testSystemMetricsConfiguration() throws {
50-
let labels = SystemMetrics.Labels(prefix: "pfx_", virtualMemoryBytes: "vmb", residentMemoryBytes: "rmb", startTimeSeconds: "sts", cpuSecondsTotal: "cpt", maxFds: "mfd", openFds: "ofd")
61+
let labels = SystemMetrics.Labels(
62+
prefix: "pfx_",
63+
virtualMemoryBytes: "vmb",
64+
residentMemoryBytes: "rmb",
65+
startTimeSeconds: "sts",
66+
cpuSecondsTotal: "cpt",
67+
maxFds: "mfd",
68+
openFds: "ofd",
69+
cpuUsage: "cpu"
70+
)
5171
let dimensions = [("app", "example"), ("environment", "production")]
5272
let configuration = SystemMetrics.Configuration(pollInterval: .microseconds(123_456_789), labels: labels, dimensions: dimensions)
5373

@@ -61,6 +81,7 @@ class SystemMetricsTest: XCTestCase {
6181
XCTAssertEqual(configuration.labels.label(for: \.cpuSecondsTotal), "pfx_cpt")
6282
XCTAssertEqual(configuration.labels.label(for: \.maxFileDescriptors), "pfx_mfd")
6383
XCTAssertEqual(configuration.labels.label(for: \.openFileDescriptors), "pfx_ofd")
84+
XCTAssertEqual(configuration.labels.label(for: \.cpuUsage), "pfx_cpu")
6485

6586
XCTAssertTrue(configuration.dimensions.contains(where: { $0 == ("app", "example") }))
6687
XCTAssertTrue(configuration.dimensions.contains(where: { $0 == ("environment", "production") }))

0 commit comments

Comments
 (0)