Files
swift-composable-architectu…/Sources/ComposableArchitecture/Debugging/ReducerInstrumentation.swift
2022-03-15 16:21:13 -04:00

120 lines
4.2 KiB
Swift

import Combine
import os.signpost
extension Reducer {
/// Instruments the reducer with
/// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data).
/// Each invocation of the reducer will be measured by an interval, and the lifecycle of its
/// effects will be measured with interval and event signposts.
///
/// To use, build your app for Instruments (I), create a blank instrument, and then use the "+"
/// icon at top right to add the signpost instrument. Start recording your app (red button at top
/// left) and then you should see timing information for every action sent to the store and every
/// effect executed.
///
/// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living
/// effects. For example, if you start an effect (e.g. a location manager) in `onAppear` and
/// forget to tear down the effect in `onDisappear`, it will clearly show in Instruments that the
/// effect never completed.
///
/// - Parameters:
/// - prefix: A string to print at the beginning of the formatted message for the signpost.
/// - log: An `OSLog` to use for signposts.
/// - Returns: A reducer that has been enhanced with instrumentation.
public func signpost(
_ prefix: String = "",
log: OSLog = OSLog(
subsystem: "co.pointfree.composable-architecture",
category: "Reducer Instrumentation"
)
) -> Self {
guard log.signpostsEnabled else { return self }
// NB: Prevent rendering as "N/A" in Instruments
let zeroWidthSpace = "\u{200B}"
let prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] "
return Self { state, action, environment in
var actionOutput: String!
if log.signpostsEnabled {
actionOutput = debugCaseOutput(action)
os_signpost(.begin, log: log, name: "Action", "%s%s", prefix, actionOutput)
}
let effects = self.run(&state, action, environment)
if log.signpostsEnabled {
os_signpost(.end, log: log, name: "Action")
return
effects
.effectSignpost(prefix, log: log, actionOutput: actionOutput)
.eraseToEffect()
}
return effects
}
}
}
extension Publisher where Failure == Never {
func effectSignpost(
_ prefix: String,
log: OSLog,
actionOutput: String
) -> Publishers.HandleEvents<Self> {
let sid = OSSignpostID(log: log)
return
self
.handleEvents(
receiveSubscription: { _ in
os_signpost(
.begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix,
actionOutput)
},
receiveOutput: { value in
os_signpost(
.event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput)
},
receiveCompletion: { completion in
switch completion {
case .finished:
os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix)
}
},
receiveCancel: {
os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix)
}
)
}
}
func debugCaseOutput(_ value: Any) -> String {
func debugCaseOutputHelp(_ value: Any) -> String {
let mirror = Mirror(reflecting: value)
switch mirror.displayStyle {
case .enum:
guard let child = mirror.children.first else {
let childOutput = "\(value)"
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
}
let childOutput = debugCaseOutputHelp(child.value)
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
case .tuple:
return mirror.children.map { label, value in
let childOutput = debugCaseOutputHelp(value)
return
"\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
}
.joined(separator: ", ")
default:
return ""
}
}
return (value as? CustomDebugStringConvertible)?.debugDescription
?? "\(type(of: value))\(debugCaseOutputHelp(value))"
}
private func isUnlabeledArgument(_ label: String) -> Bool {
label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil
}