Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[Swift Heroes 2023] Making developer tools with...

[Swift Heroes 2023] Making developer tools with Swift

Pol Piella Abadia

May 06, 2023
Tweet

More Decks by Pol Piella Abadia

Other Decks in Programming

Transcript

  1. Who am I? 👨💻 Senior Software Engineer at BBC ✍

    Weekly blogs at polpiella.dev 📬 Curator of the iOS CI Newsle tt er 🐝 Based in Manchester 🇪🇸 From Barcelona
  2. What we’ll build today 💻 Complex metrics system 🚀 Metrics

    from Xcode Cloud and GHA 📈 Dashboard plots and displays metrics 💡 Inspired by a real-world system 📦 Swift Package monorepo 🆓 Everything is Open Source! ❤
  3. Why Swift though? 💻 Often not fi rst choice for

    writing tooling 🚀 Powerful language with a mature ecosystem. ➕ Beyond Apple app development 📈 Tools are often maintained and built by mobile developers. ❤ Focus on the system, not the language!
  4. # ! /bin/sh # Create an empty directory mkdir metrics

    Creating a Swift Package Creating a Swift Package
  5. # ! /bin/sh # Create an empty directory mkdir metrics

    # Move into the new directory cd metrics Creating a Swift Package Creating a Swift Package
  6. # ! /bin/sh # Create an empty directory mkdir metrics

    # Move into the new directory cd metrics # Create a new executable package swift package init -- name Metrics Creating a Swift Package Creating a Swift Package
  7. . ├── Package.swift ├── README.md ├── Sources │ └── Metrics

    │ └── Metrics.swift └── Tests └── MetricsTests └── MetricsTests.swift Creating a Swift Package Creating a Swift Package # ! /bin/sh # Create an empty directory mkdir metrics # Move into the new directory cd metrics # Create a new executable package swift package init -- name Metrics
  8. using Swift… The metrics backend 💿 PostgresSQL database ➕ POST

    endpoint 🆙 GET endpoint ❌ DELETE endpoint 🎉 Built entirely with Swift 💧 Using Vapor
  9. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ ], targets: [ .target(name: "Metrics"), .testTarget(name: "MetricsTests", dependencies: ["Metrics"]) ] )
  10. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ ], targets: [ .target(name: "Metrics"), .testTarget(name: "MetricsTests", dependencies: ["Metrics"]) ] )
  11. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ ], targets: [ ] )
  12. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), ], targets: [ ] )
  13. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ ] )
  14. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ ], swiftSettings: [ ] ), ] )
  15. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Vapor", package: "vapor") ], swiftSettings: [ ] ), ] )
  16. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Vapor", package: "vapor") ], swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] ), ] )
  17. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Vapor", package: "vapor") ], swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] ), .executableTarget(name: "Run", dependencies: [.target(name: "App")]) ] )
  18. import Vapor import Fluent . ├── Package.swift ├── README.md ├──

    Sources │ └── App │ └── Model.swift Modelling data
  19. import Vapor import Fluent final class Metric: Model, Content {

    static let schema = "metrics" } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift
  20. import Vapor import Fluent final class Metric: Model, Content {

    static let schema = "metrics" @ID(key: .id) var id: UUID? @Field(key: "workflow") var workflow: String @Field(key: "duration") var duration: Int @Field(key: "date") var date: Date @Field(key: "repository") var repository: String @Field(key: "author") var author: String @Enum(key: "provider") var provider: Provider @Enum(key: "outcome") var outcome: Outcome init() {} } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift
  21. import Vapor import Fluent enum Provider: String, Codable { case

    xcodeCloud = "xcode-cloud" case githubActions = "github-actions" } enum Outcome: String, Codable { case success case failure case cancelled } final class Metric: Model, Content { static let schema = "metrics" @ID(key: .id) var id: UUID? @Field(key: "workflow") var workflow: String @Field(key: "duration") var duration: Int @Field(key: "date") var date: Date @Field(key: "repository") var repository: String @Field(key: "author") var author: String @Enum(key: "provider") var provider: Provider @Enum(key: "outcome") var outcome: Outcome init() {} } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift
  22. import Fluent struct CreateMetric: Migration { } . ├── Package.swift

    ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift Modelling data
  23. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  24. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  25. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") .id() .field("workflow", .string, .required) .field("duration", .int, .required) .field("date", .datetime, .required) .field("provider", .string, .required) .field("outcome", .string, .required) .field("repository", .string, .required) .field("author", .string, .required) } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  26. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") .id() .field("workflow", .string, .required) .field("duration", .int, .required) .field("date", .datetime, .required) .field("provider", .string, .required) .field("outcome", .string, .required) .field("repository", .string, .required) .field("author", .string, .required) .create() } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  27. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") .id() .field("workflow", .string, .required) .field("duration", .int, .required) .field("date", .datetime, .required) .field("provider", .string, .required) .field("outcome", .string, .required) .field("repository", .string, .required) .field("author", .string, .required) .create() } func revert(on database: Database) -> EventLoopFuture<Void> { database.schema("metrics").delete() } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  28. import Fluent import Vapor struct MetricsController: RouteCollection { } .

    ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift Defining the routes
  29. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  30. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  31. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) } func get(req: Request) async throws -> [Metric] { try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  32. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) api.get(":id", use: find) } func find(req: Request) async throws -> Metric { if let metric = try await Metric .find(req.parameters.get("id"), on: req.db) { return metric } else { throw Abort(.notFound) } } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  33. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) api.get(":id", use: find) api.post(use: create) } func create(req: Request) async throws -> Metric { let metric = try req.content.decode(Metric.self) try await metric.save(on: req.db) return metric } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  34. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) api.get(":id", use: find) api.post(use: create) api.delete(":id", use: delete) } func delete(req: Request) async throws -> HTTPStatus { guard let metric = try await Metric .find( req.parameters.get("id"), on: req.db ) else { throw Abort(.notFound) } try await metric.delete(on: req.db) return .noContent } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  35. import Fluent import Vapor func routes(_ app: Application) throws {

    try app.register(collection: MetricsController()) } . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift Registering the routes
  36. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { } . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift Finally…
  37. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get("DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  38. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get("DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) app.migrations.add(CreateMetric()) try app.autoMigrate().wait() } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  39. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get(“DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) app.migrations.add(CreateMetric()) try app.autoMigrate().wait() app.logger.logLevel = .debug } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  40. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get(“DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) app.migrations.add(CreateMetric()) try app.autoMigrate().wait() app.logger.logLevel = .debug try routes(app) } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  41. The run target . ├── Package.swift ├── README.md ├── Sources

    │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ └── Run │ └── main.swift
  42. The run target . ├── Package.swift ├── README.md ├── Sources

    │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ └── Run │ └── main.swift
  43. import App import Vapor var env = try Environment.detect() try

    LoggingSystem.bootstrap(from: &env) let app = Application(env) defer { app.shutdown() } try configure(app) try app.run() The run target . ├── Package.swift ├── README.md ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ └── Run │ └── main.swift
  44. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift 🍁 Creating a template
  45. 🍁 Creating a template . ├── Package.swift ├── README.md ├──

    Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  46. <html> <head> <script type="text/javascript" src="https: // www.gstatic.com/charts/loader.js" > </ script>

    <script src="https: // cdn.tailwindcss.com"> </ script> </ head> <body class="h-screen w-screen bg-gray-900"> <main> <div class="mx-auto my-8 text-3xl font-semibold max-w-5xl > <section class="flex flex-col gap-2"> <h1 class="text-5xl">QReate Metrics </ h1> <p class="text-sm font-normal"> A dashboard to aggregate all workflows data for y </ p> </ section> <section> <p class="text-2xl font-semibold mb-4">All workflow </ <div id="chart"> </ div> </ section> <section> <p class="text-2xl font-semibold mb-4">Recent build </ <div class="flex flex-col gap-6"> #for(metric in metrics): <div class="flex flex-col gap-1 text-lg round 🍁 Creating a template . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  47. <html> <head> <script type="text/javascript" src="https: // www.gstatic.com/charts/loader.js" > </ script>

    <script src="https: // cdn.tailwindcss.com"> </ script> </ head> <body class="h-screen w-screen bg-gray-900"> <main> <div class="mx-auto my-8 text-3xl font-semibold max-w-5xl > <section class="flex flex-col gap-2"> <h1 class="text-5xl">QReate Metrics </ h1> <p class="text-sm font-normal"> A dashboard to aggregate all workflows data for y </ p> </ section> <section> <p class="text-2xl font-semibold mb-4">All workflow </ <div id="chart"> </ div> </ section> <section> <p class="text-2xl font-semibold mb-4">Recent build </ <div class="flex flex-col gap-6"> #for(metric in metrics): <div class="flex flex-col gap-1 text-lg round 🍁 Creating a template . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  48. import Vapor struct FrontendController: RouteCollection { } . ├── Package.swift

    ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  49. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  50. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  51. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() return try await req.view.render( "Home", HomeContext(metrics: allMetrics) ) } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  52. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() return try await req.view.render( "Home", HomeContext(metrics: allMetrics) ) } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  53. import Vapor struct HomeContext: Encodable { let metrics: [Metric] }

    struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() return try await req.view.render( "Home", HomeContext(metrics: allMetrics) ) } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  54. • App has a single GHA work fl ow •

    Send metric points to the metrics service. • Straight after the work fl ow’s execution is done. • No back-end service • A work fl ow using the work fl ow_run event • Run a Swift CLI 🚀 GitHub Actions Metrics
  55. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ ], targets: [ ] )
  56. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ ] ) ] )
  57. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "GithubActionsMetricsCLI", targets: ["GithubActionsMetricsCLI"]) ], dependencies: [ ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ ] ) ] )
  58. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "GithubActionsMetricsCLI", targets: ["GithubActionsMetricsCLI"]) ], dependencies: [ .package(url: "https: // github.com/apple/swift-argument-parser.git", exact: "1.2.2") ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ ] ) ] )
  59. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "GithubActionsMetricsCLI", targets: ["GithubActionsMetricsCLI"]) ], dependencies: [ .package(url: "https: // github.com/apple/swift-argument-parser.git", exact: "1.2.2") ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser") ] ) ] )
  60. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  61. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── GithubActionsMetricsCLI │ │ └── GithubActionsMetricsCLI.swift │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  62. import Foundation import ArgumentParser @main struct GithubActionsMetricsCLI: AsyncParsableCommand { @Argument

    private var workflow: String @Argument private var updatedAt: String @Argument private var date: String @Argument private var repository: String @Argument private var outcome: String @Argument private var author: String func run() async throws { } }
  63. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } } extension Date { init?(from isoString: String) { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" if let date = dateFormatter.date(from: isoString) { self = date } else { return nil } } }
  64. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } }
  65. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) }
  66. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } } private func adapt(githubActionsOutcome: GithubActionOutcome) -> Outcome? { switch githubActionsOutcome { case .success: return .success case .failure: return .failure case .cancelled: return .cancelled default: return nil } }
  67. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } }
  68. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return } }
  69. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return } let payload = Payload( workflow: workflow, duration: Int(duration), date: startedAtDate, provider: .githubActions, author: author, outcome: outcome, repository: repository ) }
  70. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return } let payload = Payload( workflow: workflow, duration: Int(duration), date: startedAtDate, provider: .githubActions, author: author, outcome: outcome, repository: repository ) let analyticsService = Factory.make(with: analyticsEndpoint) _ = try await analyticsService.send(payload: payload) }
  71. Creating a workflow . ├── .github │ └── workflows │

    └── lint.yml │ └── workflow_run.yml
  72. . ├── .github │ └── workflows │ └── lint.yml │

    └── workflow_run.yml name: Log workflow run metrics Creating a workflow
  73. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  74. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  75. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest env: ANALYTICS_ENDPOINT: ${{ secrets.ANALYTICS_ENDPOINT }} Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  76. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest env: ANALYTICS_ENDPOINT: ${{ secrets.ANALYTICS_ENDPOINT }} steps: - uses: actions/checkout@v3 with: repository: polpielladev/metrics Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  77. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest env: ANALYTICS_ENDPOINT: ${{ secrets.ANALYTICS_ENDPOINT }} steps: - uses: actions/checkout@v3 with: repository: polpielladev/metrics - run: | swift run GithubActionsMetricsCLI \ "${{ github.event.workflow_run.name }}" \ "${{ github.event.workflow_run.updated_at }}" \ "${{ github.event.workflow_run.run_started_at }}" \ "${{ github.event.workflow_run.head_repository.name }}" \ "${{ github.event.workflow_run.conclusion }}" \ "${{ github.event.workflow_run.head_commit.author.name }}" Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  78. Another example Used by the Swift Package Index project to

    generate Pull Requests from issues using webhook logic directly in GHA work fl ows.
  79. Learn more I have an article on the topic where

    you can fi nd a more in- depth explanation of this section of the slides.
  80. • Send metrics from all Xcode Cloud work fl ows.

    • Straight after the work fl ow is done executing. • Using an Xcode Cloud webhook. • Serverless Swift lambda. Xcode Cloud metrics
  81. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ ], targets: [ ] )
  82. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ .package(url: "https: // github.com/swift-server/swift-aws-lambda-runtime.git", exact: "1.0.0-alpha.1"), .package(url: "https: // github.com/swift-server/swift-aws-lambda-events.git", exact: "0.1.0") ], targets: [ ] )
  83. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ .package(url: "https: // github.com/swift-server/swift-aws-lambda-runtime.git", exact: "1.0.0-alpha.1"), .package(url: "https: // github.com/swift-server/swift-aws-lambda-events.git", exact: "0.1.0") ], targets: [ .executableTarget( name: "XcodeCloudWebhook", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") ] ) ] )
  84. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "XcodeCloudWebhook", targets: ["XcodeCloudWebhook"]) ], dependencies: [ .package(url: "https: // github.com/swift-server/swift-aws-lambda-runtime.git", exact: "1.0.0-alpha.1"), .package(url: "https: // github.com/swift-server/swift-aws-lambda-events.git", exact: "0.1.0") ], targets: [ .executableTarget( name: "XcodeCloudWebhook", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") ] ) ] )
  85. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── XcodeCloudWebhook │ │ └── XcodeCloudWebhook.swift │ └── GithubActionsMetricsCLI │ │ └── GithubActionsMetricsCLI.swift │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  86. import AWSLambdaRuntime import AWSLambdaEvents import Foundation @main struct XcodeCloudWebhook: SimpleLambdaHandler

    { let decoder: JSONDecoder init() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(dateFormatter) self.decoder = decoder } }
  87. import AWSLambdaRuntime import AWSLambdaEvents import Foundation @main struct XcodeCloudWebhook: SimpleLambdaHandler

    { let decoder: JSONDecoder init() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(dateFormatter) self.decoder = decoder } func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { } }
  88. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData) else { return .init(statusCode: .ok, body: "Not handling the request ... ") } }
  89. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } }
  90. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } }
  91. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } let analyticsService = Factory.make(with: analyticsEndpoint) }
  92. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } let analyticsService = Factory.make(with: analyticsEndpoint) guard let startDate = payload.ciBuildRun.attributes.startedDate, let duration = payload .ciBuildRun .attributes .finishedDate? .timeIntervalSince(startDate), let outcome = Self.adapt(xcodeCloudOutcome: payload.ciBuildRun.attributes.completionStatus) else { return .init(statusCode: .ok, body: "Not handling this request ... ") } }
  93. guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not

    set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } let analyticsService = Factory.make(with: analyticsEndpoint) guard let startDate = payload.ciBuildRun.attributes.startedDate, let duration = payload .ciBuildRun .attributes .finishedDate? .timeIntervalSince(startDate), let outcome = Self.adapt(xcodeCloudOutcome: payload.ciBuildRun.attributes.completionStatus) else { return .init(statusCode: .ok, body: "Not handling this request ... ") } let analyticsPayload = Payload( workflow: payload.ciWorkflow.attributes.name, duration: Int(duration), date: startDate, provider: .xcodeCloud, author: payload.ciBuildRun.attributes.sourceCommit.author.displayName, outcome: outcome, repository: payload.scmRepository.attributes.repositoryName ) _ = try await analyticsService.send(payload: analyticsPayload) return .init(statusCode: .ok) }
  94. Let’s deploy the lambda! swift package \ --disable-sandbox archive \

    --swift-version 5.7 \ --output-path . \ --products XcodeCloudWebhook
  95. Let’s deploy the lambda! swift package \ --disable-sandbox archive \

    --swift-version 5.7 \ --output-path . \ --products XcodeCloudWebhook
  96. Let’s deploy the lambda! swift package \ --disable-sandbox archive \

    --swift-version 5.7 \ --output-path . \ --products XcodeCloudWebhook
  97. Learn more I have an article on the topic where

    you can fi nd a more in- depth explanation of this section of this section.
  98. 🎁 Let’s wrap up! ⚙ A full-stack website using Vapor

    🍎 An API using Vapor 💻 A command line tool for macOS, extensible to other platforms too! 🔗 A serverless Swift lambda using AWS 📦 Everything in the same Swift Package! ❤ Swift is great for developer tooling! 🤔 Great way to upskill!