MetricKit
As an undergraduate student, I had a radio show called “Goodbye, Blue Monday” (I was really into Vonnegut at the time). It was nothing glamorous — just a weekly, 2-hour slot at the end of the night before the station switched into automation.
If you happened to be driving through the hills of Pittsburgh, Pennsylvania late at night with your radio tuned to WRCT 88.3, you’d have heard an eclectic mix of Contemporary Classical, Acid Jazz, Italian Disco, and Bebop. That, and the stilting, dulcet baritone of a college kid doing his best impersonation of Tony Mowod.
Sitting there in the booth, waiting for tracks to play out before launching into an FCC-mandated PSA or on-the-hour station identification, I’d wonder: Is anyone out there listening? And if they were, did they like it? I could’ve been broadcasting static the whole time and been none the wiser.
The same thoughts come to mind whenever I submit a build to App Store Connect… but then I’ll remember that, unlike radio, you can actually know these things! And the latest improvements in Xcode 11 make it easier than ever to get an idea of how your apps are performing in the field.
We’ll cover everything you need to know in this week’s NSHipster article. So as they say on the radio: “Don’t touch that dial (it’s got jam on it)”.
MetricKit is a new framework in iOS 13 for collecting and processing battery and performance metrics. It was announced at WWDC this year along with XCTest Metrics and the Xcode Metrics Organizer as part of a coordinated effort to bring new insights to developers about how their apps are performing in the field.
Apple automatically collects metrics from apps installed on the App Store. You can view them in Xcode 11 by opening the Organizer (⌥⌘⇧O) and selecting the new Metrics tab.
MetricKit complement Xcode Organizer Metrics by providing a programmatic way to receive daily information about how your app is performing in the field. With this information, you can collect, aggregate, and analyze on your own in greater detail than you can through Xcode.
Understanding App Metrics
Metrics can help uncover issues you might not have seen while testing locally, and allow you to track changes across different versions of your app. For this initial release, Apple has focused on the two metrics that matter most to users: battery usage and performance.
Battery Usage
Battery life depends on a lot of different factors. Physical aspects like the age of the device and the number of charge cycles are determinative, but the way your phone is used matters, too. Things like CPU usage, the brightness of the display and the colors on the screen, and how often radios are used to fetch data or get your current location — all of these can have a big impact. But the main thing to keep in mind is that users care a lot about battery life.
Aside from how good the camera is, the amount of time between charges is the deciding factor when someone buys a new phone these days. So when their new, expensive phone doesn’t make it through the day, they’re going to be pretty unhappy.
Until recently, Apple’s taken most of the heat on battery issues. But since iOS 12 and its new Battery Usage screen in Settings, users now have a way to tell when their favorite app is to blame. Fortunately, with iOS 13 you now have everything you need to make sure your app doesn’t run afoul of reasonable energy usage.
Performance
Performance is another key factor in the overall user experience. Normally, we might look to stats like processor clock speed or frame rate as a measure of performance. But instead, Apple’s focusing on less abstract and more actionable metrics:
- Hang Rate
- How often is the main / UI thread blocked, such that the app is unresponsive to user input?
- Launch Time
- How long does an app take to become usable after the user taps its icon?
- Peak Memory & Memory at Suspension
- How much memory does the app use at its peak and just before entering the background?
- Disk Writes
- How often does the app write to disk, which — if you didn’t already know — is a comparatively slow operation (even with the flash storage on an iPhone!)
Using MetricKit
From the perspective of an API consumer,
it’s hard to imagine how MetricKit could be easier to incorporate.
All you need is for some part of your app to serve as
a metric subscriber
(an obvious choice is your App
),
and for it to be added to the shared MXMetric
:
import UIKit
import Metric Kit
@UIApplication Main
class App Delegate: UIResponder, UIApplication Delegate {
func application(_ application: UIApplication, did Finish Launching With Options launch Options: [UIApplication.Launch Options Key: Any]?) -> Bool {
MXMetric Manager.shared.add(self)
return true
}
func application Will Terminate(_ application: UIApplication) {
MXMetric Manager.shared.remove(self)
}
}
extension App Delegate: MXMetric Manager Subscriber {
func did Receive(_ payloads: [MXMetric Payload]) {
...
}
}
iOS automatically collects samples while your app is being used, and once per day (every 24 hours), it’ll send an aggregated report with those metrics.
To verify that your MXMetric
is having its delegate method called as expected,
select Simulate MetricKit Payloads from the Debug menu
while Xcode is running your app.
Annotating Critical Code Sections with Signposts
In addition to the baseline statistics collected for you,
you can use the
mx
function
to collect metrics around the most important parts of your code.
This signpost-backed API
captures CPU time, memory, and writes to disk.
For example, if part of your app did post-processing on audio streams, you might annotate those regions with metric signposts to determine the energy and performance impact of that work:
let audio Log Handle = MXMetric Manager.make Log Handle(category: "Audio")
func process Audio Stream() {
mx Signpost(.begin, log: audio Log Handle, name: "Process Audio Stream")
…
mx Signpost(.end, log: audio Log Handle, name: "Process Audio Stream")
}
Creating a Self-Hosted Web Service for Collecting App Metrics
Now that you have this information,
what do you do with it?
How do we fill that …
placeholder in our implementation of did
?
You could pass that along to some paid analytics or crash reporting service, but where’s the fun in that? Let’s build our own web service to collect these for further analysis:
Storing and Querying Metrics with PostgreSQL
The MXMetric
objects received by metrics manager subscribers
have a convenient
json
method
that generates something like this:
Expand for JSON Representation:
{
"location Activity Metrics": {
"cumulative Best Accuracy For Navigation Time": "20 sec",
"cumulative Best Accuracy Time": "30 sec",
"cumulative Hundred Meters Accuracy Time": "30 sec",
"cumulative Nearest Ten Meters Accuracy Time": "30 sec",
"cumulative Kilometer Accuracy Time": "20 sec",
"cumulative Three Kilometers Accuracy Time": "20 sec"
},
"cellular Condition Metrics": {
"cell Condition Time": {
"histogram Num Buckets": 3,
"histogram Value": {
"0": {
"bucket Count": 20,
"bucket Start": "1 bars",
"bucket End": "1 bars"
},
"1": {
"bucket Count": 30,
"bucket Start": "2 bars",
"bucket End": "2 bars"
},
"2": {
"bucket Count": 50,
"bucket Start": "3 bars",
"bucket End": "3 bars"
}
}
}
},
"meta Data": {
"app Build Version": "0",
"os Version": "i Phone OS 13.1.3 (17A878)",
"region Format": "US",
"device Type": "i Phone9,2"
},
"gpu Metrics": {
"cumulative GPUTime": "20 sec"
},
"memory Metrics": {
"peak Memory Usage": "200,000 k B",
"average Suspended Memory": {
"average Value": "100,000 k B",
"standard Deviation": 0,
"sample Count": 500
}
},
"signpost Metrics": [
{
"signpost Interval Data": {
"histogrammed Signpost Durations": {
"histogram Num Buckets": 3,
"histogram Value": {
"0": {
"bucket Count": 50,
"bucket Start": "0 ms",
"bucket End": "100 ms"
},
"1": {
"bucket Count": 60,
"bucket Start": "100 ms",
"bucket End": "400 ms"
},
"2": {
"bucket Count": 30,
"bucket Start": "400 ms",
"bucket End": "700 ms"
}
}
},
"signpost Cumulative CPUTime": "30,000 ms",
"signpost Average Memory": "100,000 k B",
"signpost Cumulative Logical Writes": "600 k B"
},
"signpost Category": "Test Signpost Category1",
"signpost Name": "Test Signpost Name1",
"total Signpost Count": 30
},
{
"signpost Interval Data": {
"histogrammed Signpost Durations": {
"histogram Num Buckets": 3,
"histogram Value": {
"0": {
"bucket Count": 60,
"bucket Start": "0 ms",
"bucket End": "200 ms"
},
"1": {
"bucket Count": 70,
"bucket Start": "201 ms",
"bucket End": "300 ms"
},
"2": {
"bucket Count": 80,
"bucket Start": "301 ms",
"bucket End": "500 ms"
}
}
},
"signpost Cumulative CPUTime": "50,000 ms",
"signpost Average Memory": "60,000 k B",
"signpost Cumulative Logical Writes": "700 k B"
},
"signpost Category": "Test Signpost Category2",
"signpost Name": "Test Signpost Name2",
"total Signpost Count": 40
}
],
"display Metrics": {
"average Pixel Luminance": {
"average Value": "50 apl",
"standard Deviation": 0,
"sample Count": 500
}
},
"cpu Metrics": {
"cumulative CPUTime": "100 sec"
},
"network Transfer Metrics": {
"cumulative Cellular Download": "80,000 k B",
"cumulative Wifi Download": "60,000 k B",
"cumulative Cellular Upload": "70,000 k B",
"cumulative Wifi Upload": "50,000 k B"
},
"disk IOMetrics": {
"cumulative Logical Writes": "1,300 k B"
},
"application Launch Metrics": {
"histogrammed Time To First Draw Key": {
"histogram Num Buckets": 3,
"histogram Value": {
"0": {
"bucket Count": 50,
"bucket Start": "1,000 ms",
"bucket End": "1,010 ms"
},
"1": {
"bucket Count": 60,
"bucket Start": "2,000 ms",
"bucket End": "2,010 ms"
},
"2": {
"bucket Count": 30,
"bucket Start": "3,000 ms",
"bucket End": "3,010 ms"
}
}
},
"histogrammed Resume Time": {
"histogram Num Buckets": 3,
"histogram Value": {
"0": {
"bucket Count": 60,
"bucket Start": "200 ms",
"bucket End": "210 ms"
},
"1": {
"bucket Count": 70,
"bucket Start": "300 ms",
"bucket End": "310 ms"
},
"2": {
"bucket Count": 80,
"bucket Start": "500 ms",
"bucket End": "510 ms"
}
}
}
},
"application Time Metrics": {
"cumulative Foreground Time": "700 sec",
"cumulative Background Time": "40 sec",
"cumulative Background Audio Time": "30 sec",
"cumulative Background Location Time": "30 sec"
},
"time Stamp End": "2019-10-22 06:59:00 +0000",
"application Responsiveness Metrics": {
"histogrammed App Hang Time": {
"histogram Num Buckets": 3,
"histogram Value": {
"0": {
"bucket Count": 50,
"bucket Start": "0 ms",
"bucket End": "100 ms"
},
"1": {
"bucket Count": 60,
"bucket Start": "100 ms",
"bucket End": "400 ms"
},
"2": {
"bucket Count": 30,
"bucket Start": "400 ms",
"bucket End": "700 ms"
}
}
}
},
"app Version": "1.0.0",
"time Stamp Begin": "2019-10-21 07:00:00 +0000"
}
As you can see,
there’s a lot baked into this representation.
Defining a schema for all of this information would be a lot of work,
and there’s no guarantee that this won’t change in the future.
So instead,
let’s embrace the NoSQL paradigm
(albeit responsibly, using Postgres)
by storing payloads in a JSONB
column:
CREATE TABLE IF NOT EXISTS metrics (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
payload JSONB NOT NULL
);
So easy!
We can extract individual fields from payloads using JSON operators like so:
SELECT (payload -> 'application Time Metrics'
->> 'cumulative Foreground Time')::INTERVAL
FROM metrics;
-- interval
-- ═══════════════════
-- @ 11 mins 40 secs
-- (1 row)
Advanced: Creating Views
JSON operators in PostgreSQL can be cumbersome to work with — especially for more complex queries. One way to help with that is to create a view (materialized or otherwise) to project the most important information to you in the most convenient representation:
CREATE VIEW key_performance_indicators AS
SELECT
id,
(payload -> 'app Version') AS app_version,
(payload -> 'meta Data' ->> 'device Type') AS device_type,
(payload -> 'meta Data' ->> 'region Format') AS region,
(payload -> 'application Time Metrics'
->> 'cumulative Foreground Time'
)::INTERVAL AS cumulative_foreground_time,
parse_byte_count(
payload -> 'memory Metrics'
->> 'peak Memory Usage'
) AS peak_memory_usage_bytes
FROM metrics;
With views, you can perform aggregate queries over all of your metrics JSON payloads with the convenience of a schema-backed relational database:
SELECT avg(cumulative_foreground_time)
FROM key_performance_indicators;
-- avg
-- ══════════════════
-- @ 9 mins 41 secs
SELECT app_version, percentile_disc(0.5)
WITHIN GROUP (ORDER BY peak_memory_usage_bytes)
AS median
FROM key_performance_indicators
GROUP BY app_version;
-- app_version │ median
-- ═════════════╪═══════════
-- "1.0.1" │ 192500000
-- "1.0.0" │ 204800000
Creating a Web Service
In this example, most of the heavy lifting is delegated to Postgres, making the server-side implementation rather boring. For completeness, here are some reference implementations in Ruby (Sinatra) and JavaScript (Express):
require 'sinatra/base'
require 'pg'
require 'sequel'
class App < Sinatra::Base
configure do
DB = Sequel.connect(ENV['DATABASE_URL'])
end
post '/collect' do
DB[:metrics].insert(payload: request.body.read)
status 204
end
end
import express from 'express';
import { Pool } from 'pg';
const db = new Pool(
connection String: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production'
);
const app = express();
app.post('/collect', (request, response) => {
db.query('INSERT INTO metrics (payload) VALUES ($1)', [request.body], (error, results) => {
if (error) {
throw error;
}
response.status(204);
})
});
app.listen(process.env.PORT || 5000)
Sending Metrics as JSON
Now that we have everything set up,
the final step is to implement
the required MXMetric
delegate method did
to pass that information along to our web service:
extension App Delegate: MXMetric Manager Subscriber {
func did Receive(_ payloads: [MXMetric Payload]) {
for payload in payloads {
let url = URL(string: "https://example.com/collect")!
var request = URLRequest(url: url)
request.http Method = "POST"
request.http Body = payload.json Representation()
let task = URLSession.shared.data Task(with: request)
task.priority = URLSession Task.low Priority
task.resume()
}
}
}
When you create something and put it out into the world, you lose your direct connection to it. That’s as true for apps as it is for college radio shows. Short of user research studies or invasive ad-tech, the truth is that we rarely have any clue about how people are using our software.
Metrics offer a convenient way to at least make sure that things aren’t too slow or too draining. And though they provide but a glimpse in the aggregate of how our apps are being enjoyed, it’s just enough to help us honor both our creation and our audience with a great user experience.