Sources/XCMetricsBackendLib/UploadMetrics/LogProcessing/LogParser.swift (300 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 XCLogParser
struct LogParser {
enum NoteType: String {
case main
case target
case step
}
enum BuildCategoryType: String {
case noop
case incremental
case clean
}
struct BuildCategorisation {
let buildCategory: BuildCategoryType
let buildCompiledCount: Int
let targetsCategory: [String: BuildCategoryType]
let targetsCompiledCount: [String: Int]
}
static func parseFromURL(
_ url: URL,
metricsRequest: UploadMetricsRequest,
machineName: String,
userId: String,
userIdSHA256: String,
isCI: Bool
) throws -> BuildMetrics {
let activityLog = try ActivityParser().parseActivityLogInURL(url, redacted: true, withoutBuildSpecificInformation: true)
let buildSteps = try ParserBuildSteps(machineName: machineName,
omitWarningsDetails: false,
omitNotesDetails: metricsRequest.extraInfo.skipNotes ?? false,
truncLargeIssues: metricsRequest.extraInfo.truncLargeIssues ?? false
)
.parse(activityLog: activityLog)
.flatten()
return toBuildMetrics(
buildSteps,
metricsRequest: metricsRequest,
userId: userId,
userIdSHA256: userIdSHA256,
isCI: isCI
)
}
private static func toBuildMetrics(
_ buildSteps: [BuildStep],
metricsRequest: UploadMetricsRequest,
userId: String,
userIdSHA256: String,
isCI: Bool
) -> BuildMetrics {
let buildInfo: BuildStep = buildSteps[0]
let buildIdentifier = buildInfo.identifier
var build = Build().withBuildStep(buildStep: buildInfo)
build.projectName = metricsRequest.extraInfo.projectName
build.userid = userId
build.userid256 = userIdSHA256
build.tag = metricsRequest.extraInfo.tag ?? ""
build.isCi = isCI
if let sleepTime = metricsRequest.extraInfo.sleepTime {
build.wasSuspended = Int64(round(buildInfo.startTimestamp)) < sleepTime
} else {
build.wasSuspended = false
}
let targetBuildSteps = buildSteps.filter { $0.type == .target }
var targets = targetBuildSteps.map { step in
return Target().withBuildStep(buildStep: step)
}
let steps = buildSteps.filter { $0.type == .detail && $0.detailStepType != .swiftAggregatedCompilation }
let detailsBuild = steps.filter { $0.detailStepType != .swiftCompilation }.map {
return Step().withBuildStep(buildStep: $0,
buildIdentifier: buildIdentifier,
targetIdentifier: $0.parentIdentifier)
}
var stepsBuild = detailsBuild + parseSwiftSteps(buildSteps: buildSteps, targets: targetBuildSteps, steps: steps, buildIdentifier: buildIdentifier)
// Categorize build based on all build steps in the build log except non-compilation or linking phases.
// Some tasks are ran by Xcode always, even on noop builds, so we want to filter them out and only
// consider the compilation and linking steps for our categorisation.
let buildCategorisation = parseBuildCategory(
with: targets,
steps: stepsBuild.filter { $0.type != "other" && $0.type != "scriptExecution" && $0.type != "copySwiftLibs" }
)
targets = targets.map { target -> Target in
let category = buildCategorisation.targetsCategory[target.id ?? ""]?.rawValue
let count = buildCategorisation.targetsCompiledCount[target.id ?? ""]
if let category = category, let count = count {
return target
.withCategory(category)
.withCompiledCount(Int32(count))
} else if let category = category {
return target
.withCategory(category)
} else if let count = count {
return target
.withCompiledCount(Int32(count))
}
return target
}
// TODO: pass the HardwareFactsFetcherImplementation().sleepTime from the client
build = build
.withCategory(buildCategorisation.buildCategory.rawValue)
.withCompiledCount(Int32(buildCategorisation.buildCompiledCount))
stepsBuild.sort {
if $0.targetIdentifier == $1.targetIdentifier {
return $0.startTimestamp > $1.startTimestamp
}
return $0.targetIdentifier > $1.targetIdentifier
}
let warnings = parseWarnings(buildSteps: buildSteps, targets: targetBuildSteps, steps: steps, buildIdentifier: buildIdentifier)
let errors = parseErrors(buildSteps: buildSteps, targets: targetBuildSteps, steps: steps, buildIdentifier: buildIdentifier)
let notes = parseNotes(buildSteps: buildSteps, targets: targetBuildSteps, steps: steps, buildIdentifier: buildIdentifier)
let functionBuildTimes = steps.compactMap { step in
step.swiftFunctionTimes?.map {
SwiftFunction()
.withBuildIdentifier(build.id ?? "")
.withStepIdentifier(step.identifier)
.withFunctionTime($0)
}
}.joined()
let typeChecks = steps.compactMap { step in
step.swiftTypeCheckTimes?.map {
SwiftTypeChecks()
.withBuildIdentifier(build.id ?? "")
.withStepIdentifier(step.identifier)
.withTypeCheck($0)
}
}.joined()
return BuildMetrics(build: build,
targets: targets,
steps: stepsBuild,
warnings: warnings,
errors: errors,
notes: notes,
swiftFunctions: Array(functionBuildTimes),
swiftTypeChecks: Array(typeChecks),
host: metricsRequest.buildHost.withBuildIdentifier(buildIdentifier),
xcodeVersion: metricsRequest.xcodeVersion?.withBuildIdentifier(buildIdentifier),
buildMetadata: metricsRequest.buildMetadata?.withBuildIdentifier(buildIdentifier))
.addDayToMetrics()
}
private static func parseSwiftSteps(
buildSteps: [BuildStep],
targets: [BuildStep],
steps: [BuildStep],
buildIdentifier: String
) -> [Step] {
let swiftAggregatedSteps = buildSteps.filter { $0.type == .detail
&& $0.detailStepType == .swiftAggregatedCompilation }
let swiftAggregatedStepsIds = swiftAggregatedSteps.reduce([String: String]()) {
dictionary, step -> [String: String] in
return dictionary.merging(zip([step.identifier], [step.parentIdentifier])) { (_, new) in new }
}
let targetsIds = targets.reduce([String: String]()) {
dictionary, target -> [String: String] in
return dictionary.merging(zip([target.identifier], [target.identifier])) { (_, new) in new }
}
return steps
.filter { $0.detailStepType == .swiftCompilation }
.compactMap { step -> Step? in
var targetId = step.parentIdentifier
// A swift step can have either a target as a parent or a swiftAggregatedCompilation
if targetsIds[step.parentIdentifier] == nil {
// If the parent is a swiftAggregatedCompilation we use the target id from that parent step
guard let swiftTargetId = swiftAggregatedStepsIds[step.parentIdentifier] else {
return nil
}
targetId = swiftTargetId
}
return Step().withBuildStep(buildStep: step, buildIdentifier: buildIdentifier, targetIdentifier: targetId)
}
}
private static func parseBuildCategory(with targets: [Target], steps: [Step]) -> BuildCategorisation {
var targetsCompiledCount = [String: Int]()
// Initialize map with all targets identifiers.
for target in targets {
targetsCompiledCount[target.id ?? ""] = 0
}
// Compute how many steps were not fetched from cache for each target.
for step in steps {
if !step.fetchedFromCache {
targetsCompiledCount[step.targetIdentifier, default: 0] += 1
}
}
// Compute how many steps in total were not fetched from cache.
let buildCompiledCount = Array<Int>(targetsCompiledCount.values).reduce(0, +)
// Classify each target based on how many steps were not fetched from cache and how many are actually present.
var targetsCategory = [String: BuildCategoryType]()
for (target, filesCompiledCount) in targetsCompiledCount {
// If the number of steps not fetched from cache in 0, it was a noop build.
// If the number of steps not fetched from cache is equal to the number of all steps in the target, it was a clean build.
// If anything in between, it was an incremental build.
// There's an edge case where some external run script phases don't have any files compiled and are classified
// as noop, but we're fine with that since further down we classify a clean build if at least 50% of the targets
// were built cleanly.
switch filesCompiledCount {
case 0: targetsCategory[target] = .noop
case steps.filter { $0.targetIdentifier == target }.count: targetsCategory[target] = .clean
default: targetsCategory[target] = .incremental
}
}
// If all targets are noop, we categorise the build as noop.
let isNoopBuild = Array<BuildCategoryType>(targetsCategory.values).allSatisfy { $0 == .noop }
// If at least 50% of the targets are clean, we categorise the build as clean.
let isCleanBuild = Array<BuildCategoryType>(targetsCategory.values).filter { $0 == .clean }.count > targets.count / 2
let buildCategory: BuildCategoryType
if isCleanBuild {
buildCategory = .clean
} else if isNoopBuild {
buildCategory = .noop
} else {
buildCategory = .incremental
}
return BuildCategorisation(
buildCategory: buildCategory,
buildCompiledCount: buildCompiledCount,
targetsCategory: targetsCategory,
targetsCompiledCount: targetsCompiledCount
)
}
private static func parseWarnings(
buildSteps: [BuildStep],
targets: [BuildStep],
steps: [BuildStep],
buildIdentifier: String
) -> [BuildWarning] {
let buildWarnings = buildSteps[0].warnings?.map {
BuildWarning()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(buildIdentifier)
.withParentType(NoteType.main.rawValue)
.withNotice($0)
}
let targetWarnings = targets.compactMap { target in
target.warnings?.map {
BuildWarning()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(target.identifier)
.withParentType(NoteType.target.rawValue)
.withNotice($0)
}
}.joined()
let stepsWarnings = steps.compactMap { step in
step.warnings?.map {
BuildWarning()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(step.identifier)
.withParentType(NoteType.step.rawValue)
.withNotice($0)
}
}.joined()
return (buildWarnings ?? []) + targetWarnings + stepsWarnings
}
private static func parseErrors(
buildSteps: [BuildStep],
targets: [BuildStep],
steps: [BuildStep],
buildIdentifier: String
) -> [BuildError] {
let buildErrors = buildSteps[0].errors?.map {
BuildError()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(buildIdentifier)
.withParentType(NoteType.main.rawValue)
.withNotice($0)
}
let targetErrors = targets.compactMap { target in
target.errors?.map {
BuildError()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(target.identifier)
.withParentType(NoteType.target.rawValue)
.withNotice($0)
}
}.joined()
let stepsErrors = steps.compactMap { step in
step.errors?.map {
BuildError()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(step.identifier)
.withParentType(NoteType.step.rawValue)
.withNotice($0)
}
}.joined()
return (buildErrors ?? []) + targetErrors + stepsErrors
}
private static func parseNotes(
buildSteps: [BuildStep],
targets: [BuildStep],
steps: [BuildStep],
buildIdentifier: String
) -> [BuildNote] {
let buildNotes = buildSteps[0].notes?.map {
BuildNote()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(buildIdentifier)
.withParentType(NoteType.main.rawValue)
.withNotice($0)
}
let targetNotes = targets.compactMap { target in
target.notes?.map {
BuildNote()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(target.identifier)
.withParentType(NoteType.target.rawValue)
.withNotice($0)
}
}.joined()
let stepsNotes = steps.compactMap { step in
step.notes?.map {
BuildNote()
.withBuildIdentifier(buildIdentifier)
.withParentIdentifier(step.identifier)
.withParentType(NoteType.step.rawValue)
.withNotice($0)
}
}.joined()
return (buildNotes ?? []) + targetNotes + stepsNotes
}
}