Sources/XCMetricsClient/XCMetrics.swift (122 lines of code) (raw):
// Copyright (c) 2020 Spotify AB.
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
import Foundation
import ArgumentParser
import XCLogParser
import MobiusCore
import MobiusExtras
/// A plugin executed by XCMetrics that collects custom metrics.
public struct XCMetricsPlugin: Equatable, Hashable {
/// The body implementation of a plugin which takes a dictionary of the environment variable provided to it and
/// should return a dictionary of key-value pairs to be attached to the build metadata of the build.
public typealias PluginBody = ([String: String]) -> [String: String]
let name: String
let body: PluginBody
/// Initializer for XCMetricsPlugin.
/// - Parameters:
/// - name: A unique name for this plugin.
/// - body: The closure that contains the implementation of this plugin.
public init(name: String, body: @escaping PluginBody) {
self.name = name
self.body = body
}
/// Equatable implementation for XCMetricsPlugin.
public static func == (lhs: XCMetricsPlugin, rhs: XCMetricsPlugin) -> Bool {
return lhs.name == rhs.name
}
/// Hashable implementation for XCMetricsPlugin.
public func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
}
/// A XCMetrics configuration which describes the plugins to be executed.
public class XCMetricsConfiguration {
private(set) var plugins = [XCMetricsPlugin]()
/// Default initializer for `XCMetricsConfiguration`.
public init() {
}
/// Adds a new plugin to the configuration.
/// - Parameter plugin: The plugin to be added.
public func add(plugin: XCMetricsPlugin) {
plugins.append(plugin)
}
}
struct Command {
let buildDirectory: String
let projectName: String
let timeout: Int
let serviceURL: String
let isCI: Bool
let skipNotes: Bool
let additionalHeaders: [String: String]
let truncLargeIssues: Bool
}
/// The entry point for XCMetrics that parses all argument if executed standalone or by another package that depends on it.
public struct XCMetrics: ParsableCommand {
/// The default configuration for the command.
public static var configuration = CommandConfiguration(
abstract: "Sends build metrics to the XCMetricsPublisher backend service."
)
/// The value of the Xcode $BUILD_DIR environment variable provided as argument. If not provided directly, XCMetrics will try to fetch it from the environment.
@Option(name: [.customLong("buildDir"), .customShort("b")], help: "The value of the Xcode $BUILD_DIR environment variable.")
public var buildDir: String?
/// The name of the current project provided as argument.
@Option(name: .shortAndLong, help: "The name of the current project.")
public var name: String
/// The timeout to wait for the Xcode's log to appear provided as argument. Default value is 5 seconds.
@Option(name: .shortAndLong, help: "The timeout to wait for the Xcode's log to appear.")
public var timeout: Int = 5
/// The URL of the service where to send metrics to provided as argument. In debug builds, this argument can be ommitted and the default local
/// URL will be used (http://localhost:8080/v1/metrics) in order to avoid uploading local data to the production service.
@Option(name: [.customLong("serviceURL"), .customShort("s")], help: "The URL of the service where to send metrics to.")
public var serviceURL: String?
/// If the metrics collected are coming from CI or not provided as argument. Default value is false.
@Option(name: [.customLong("isCI")], help: "If the metrics collected are coming from CI or not.")
public var isCI: Bool = false
/// If the Notes found in log should be skipped. Useful when there are thousands of notes to
/// reduce the size of the Database.
@Option(name: [.customLong("skipNotes")], help: "Notes found in logs won't be processed")
public var skipNotes: Bool = false
/// An optional authorization/token header **key** to be included in the upload request. Must be used in conjunction with `authorizationValue.`
@Option(name: [.customLong("authorizationKey"), .customShort("k")], help: "An optional authorization header key to be included in the upload request e.g 'Authorization' or 'x-api-key' etc. Must be used in conjunction with `authorizationValue`")
public var authorizationKey: String?
/// An optional authorization/token header **value** to be included in the upload request. Must be used in conjunction with `authorizationKey.`
@Option(name: [.customLong("authorizationValue"), .customShort("a")], help: "An optional authorization header value to be included in the upload request e.g 'Basic YWxhZGRpbjpvcGVuc2VzYW1l' or `hYDqG78OIUDIWKLdwjdwhdu8` etc. Must be used in conjunction with `authorizationKey`")
public var authorizationValue: String?
/// If an individual task have more than a 100 issues (Warnings, Notes and/or Errors) this flag will instruct the parser to
/// truncate them to a 100. This is useful to fix memory issues in the backend and speed up log processing.
@Option(name: [.customLong("truncateLargeIssues")], help: "If a task have more than a 100 issues (Warnings, Notes and/or Errors), the parser will truncate them to a 100")
public var truncLargeIssues: Bool = false
@Option(name: [.customLong("additionalHeaderJson")],
help: "Additional header in JSON format",
transform: JSONArgument.transformer)
public var additionalHeaderJson: [String: String] = [:]
private static let loop = XCMetricsLoop()
/// The default initializer for the `XCMetrics` object.
public init() {}
/// Runs XCMetrics with the provided configuration containing the optional custom plugins to be executed.
/// - Parameter configuration: `XCMetricsConfiguration`
public func run(with configuration: XCMetricsConfiguration) {
do {
let command = try fetchEnvironmentVariablesParameters()
XCMetrics.loop.startLoop(with: command, plugins: configuration.plugins)
} catch {
fatalError(error.localizedDescription)
}
}
/// Runs XCMetrics.
/// - Throws: Throws an error in case of missing or invalid required arguments.
public func run() throws {
let command = try fetchEnvironmentVariablesParameters()
XCMetrics.loop.startLoop(with: command)
}
func argumentError() -> ValidationError {
return ValidationError("""
A valid --name, --buildDir and --serviceURL are required.
If a $BUILD_DIR environment variable is defined, you can omit --buildDir.
The --timeout argument is optional and defaults to 5 seconds.
The --isCI argument is optional and defaults to false.
The --skipNotes argument is optional and defaults to false.
The --authorizationKey must be used in conjunction with --authorizationValue. One cannot be used without the other.
Type 'XCMetrics --help' for more information.
""")
}
/// Some parameters can be omitted and should be parsed from the current environment.
/// This method tries to fetch those values and treat them as if they were provided as normal parameters.
/// - Throws: A ValidationError in case a required value can be fetched.
/// - Returns: A Command object that encapsulates all required parameters.
private func fetchEnvironmentVariablesParameters() throws -> Command {
let processInfo = ProcessInfo()
var directoryBuild = ""
#if DEBUG
// Use default local debugging URL if one is not provided in debug.
let serviceURLValue = serviceURL ?? "http://localhost:8080/v1/metrics"
#else
guard let serviceURLValue = serviceURL else {
throw argumentError()
}
#endif
if let buildDirectoryValue = buildDir {
directoryBuild = buildDirectoryValue
} else if let buildDirectoryValue = processInfo.buildDir {
directoryBuild = buildDirectoryValue
} else {
throw argumentError()
}
let additionalHeader: [String: String] = try AdditionalHeaderFactory.make(authorizationKey: authorizationKey,
authorizationValue: authorizationValue,
additionalHeader: additionalHeaderJson)
let command = Command(
buildDirectory: directoryBuild,
projectName: name,
timeout: timeout,
serviceURL: serviceURLValue,
isCI: isCI,
skipNotes: skipNotes,
additionalHeaders: additionalHeader,
truncLargeIssues: truncLargeIssues
)
return command
}
}