Collecting GitHub Action workflow metrics using Swift

Sponsored
RevenueCat logo
Develop with RocketSim, Ship with Helm.

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:

Terminal
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:

Package.swift
// 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:

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:

  1. 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.
  2. 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:

GithubWorkflowMetrics.swift
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 uses convertFromSnakeCase as its keyDecodingStrategy to convert the snake case formatted keys from GitHub’s response back into the Decodable type’s camel case formatted properties. The snakeCaseDecoder also specifies iso8601 as its dateDecodingStrategy to decode ISO8601 compliant dates from GitHub’s API’s response into Date 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:

Output.json
{
	"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:

GithubWorkflowMetrics.swift
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:

  1. 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.
  2. The Output type is encoded into Data using a custom JSONEncoder. This encoder sets an output formatting type of .prettyPrinted to ensure data is easily readable by the application’s users.
  3. 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:

Terminal
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 🎉:

Output.json
{
  "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
    }
  ]
}