Collecting GitHub Action workflow metrics using Swift
Helm Pro yearly subscribers now get a 30% discount on RocketSim thanks to contingent pricing on the App Store.
This week at work I have been looking at collecting metrics from GitHub Actions Workflow runs. These metrics will help us monitor and plot the duration of our GitHub Action workflows over time.
To achieve this, I created a command line application which queries GitHub’s API and uses its response to calculate how long each workflow run takes.
The command line application is built using an executable target in a Swift Package with the help of Apple’s swift-argument-parser library. Specifically, the application makes use of the AsyncParsableCommand to run asynchronous code within an async/await
context.
Creating a Swift Package
The first step to create a command line tool with Swift is to create a Swift Package:
swift package init --type executable
Amongst other files, the command above generates a Package.swift
file with an executable target and product. To use swift-argument-parser, declare it as a package dependency and add it to the executableTarget
’s dependency list:
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "GithubWorkflowMetrics",
platforms: [.macOS(.v10_15)],
dependencies: [
.package(
url: "https://github.com/apple/swift-argument-parser.git",
from: "1.2.0"
)
],
targets: [
.executableTarget(
name: "GithubWorkflowMetrics",
dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]
)
]
)
swift-argument-parser only introduced
async/await
support in version 1.1.0. If you want to take advantage of these APIs, you will need to use a version higher or equal to 1.1.0.
Creating the entry point
The executable target needs to know what actions to perform when it runs. The entry point for the application is defined under Sources/GithubWorkflowMetrics/GithubWorkflowMetrics.swift
:
import ArgumentParser
import Foundation
@main
struct GithubWorkflowMetrics: AsyncParsableCommand {
@Argument(help: "The repository data should be parsed from in `user/repo` format")
var repository: String
@Option(help: "The bearer token to perform the request")
var token: String?
func run() async throws {
}
}
The code above declares a struct
conforming to AsyncParsableCommand
decorated with @main
to indicate it is the entry point for the executable target. The GithubWorkflowMetrics
struct
implements the async
run
method declared in the AsyncParsableCommand
protocol from swift-argument-parser and defines two properties:
- A repository to get workflow data from. This argument is required and, if not provided, will cause the application to fail. The
repository
property is decorated with the@Argument
property wrapper from swift-argument-parser to ensure it is passed as such. - A token string to authorise the request to GitHub’s api. This property is optional and should only be passed as an option when trying to access private repositories. The property is decorated with the
@Option
property wrapper from swift-argument-parser for this reason.
Calling GitHub’s API
GitHub’s API provides an endpoint to retrieve all workflow runs for a repository. The app can make use of URLSession’s await-friendly APIs to call the workflow runs endpoint and decode the response data from the API into a Decodable
type:
struct WorkflowsResponse: Decodable {
let workflowRuns: [Workflow]
}
struct Workflow: Decodable {
let name: String
let id: Int
let runStartedAt: Date
let updatedAt: Date
let conclusion: String?
}
extension JSONDecoder {
static let snakeCaseDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}
func run() async throws {
let url = URL(string: "https://api.github.com/repos/\(repository)/actions/runs?status=success&created=\(DateProvider.provide())")!
var urlRequest = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
if let token {
urlRequest
.setValue(
"Bearer \(token)",
forHTTPHeaderField: "Authorization"
)
}
let (data, _) = try await URLSession.shared.data(for: urlRequest)
let decodedData = try JSONDecoder
.snakeCaseDecoder
.decode(WorkflowsResponse.self, from: data)
}
The workflow runs endpoint in GitHub’s API returns JSON data with snake case formatted keys. The
snakeCaseDecoder
in the code snippet above usesconvertFromSnakeCase
as itskeyDecodingStrategy
to convert the snake case formatted keys from GitHub’s response back into theDecodable
type’s camel case formatted properties. ThesnakeCaseDecoder
also specifiesiso8601
as itsdateDecodingStrategy
to decode ISO8601 compliant dates from GitHub’s API’s response intoDate
types.
Generating the output
The application needs to output the metrics collected from GitHub’s API in a suitable format.
I decided to output a JSON
object with the following format:
{
"workflow_name": [
{
"date": "workflow_run_date",
"duration": "workflow_run_duration"
},
{
"date": "workflow_run_date",
"duration": "workflow_run_duration"
},
]
}
The Swift
code below performs the necessary operations to transform the data coming from GitHub’s API into the output object above:
struct Output: Encodable {
var dataPoints: [String: [WorkflowDataPoint]] = [:]
}
struct WorkflowDataPoint: Encodable {
let duration: TimeInterval
let date: Date
}
extension JSONEncoder {
static let snakeCaseEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = .prettyPrinted
return encoder
}()
}
func run() async throws {
// ...
// 1
decodedData
.workflowRuns
.reduce(into: [String: [WorkflowDataPoint]]()) { partialOutput, workflow in
let duration = workflow
.updatedAt
.timeIntervalSince(workflow.runStartedAt)
let dataPoint = WorkflowDataPoint(
duration: duration,
date: workflow.runStartedAt
)
if var timeIntervals = partialOutput[workflow.name] {
timeIntervals.append(dataPoint)
partialOutput[workflow.name] = timeIntervals
} else {
partialOutput[workflow.name] = [dataPoint]
}
}
// 2
let encodedOutput = try JSONEncoder
.snakeCaseEncoder
.encode(output)
// 3
if let prettyPrintedString = String(data: encodedOutput, encoding: .utf8) {
print(prettyPrintedString)
}
}
Let’s break down the code:
- The decoded data from GitHub’s API’s response is modified and reduced into a more suitable format. The output type is an
Encodable
struct
with two properties: the name of the workflow and a set of data points representing each of the runs associated with that workflow. In turn, each of the data points has the duration in seconds and the date when it was run. - The
Output
type is encoded intoData
using a customJSONEncoder
. This encoder sets an output formatting type of.prettyPrinted
to ensure data is easily readable by the application’s users. - The data is converted into a
String
which can be printed to the console.
Let’s test it!
The application takes in a repository name and, if such repository is private, a bearer token with the necessary permissions:
swift run GithubWorkflowMetrics polpielladev/reading-time
reading-time is an Open-Source public repository and, as such, it does not require a bearer token.
The command above yields the following result, which shows all runs for each workflow in the reading-time repository with their date and duration in seconds 🎉:
{
"Windows" : [
{
"date" : "2022-10-02T20:55:24Z",
"duration" : 430
},
{
"date" : "2022-10-02T19:06:44Z",
"duration" : 421
},
{
"date" : "2022-10-02T17:55:37Z",
"duration" : 391
},
{
"date" : "2022-10-02T15:59:51Z",
"duration" : 337
},
{
"date" : "2022-10-02T15:48:38Z",
"duration" : 355
}
],
"CI" : [
{
"date" : "2022-10-02T22:21:52Z",
"duration" : 399
},
{
"date" : "2022-10-02T22:14:18Z",
"duration" : 438
},
{
"date" : "2022-09-29T18:12:34Z",
"duration" : 347
},
{
"date" : "2022-09-29T17:04:53Z",
"duration" : 522
}
]
}