diff --git a/Workflow/Sources/ExternalLogging.swift b/Workflow/Sources/ExternalLogging.swift new file mode 100644 index 000000000..139f3355e --- /dev/null +++ b/Workflow/Sources/ExternalLogging.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Namespace for logging API used to propagate internal Workflow-related logging to external consumers +public enum ExternalLogging {} + +extension ExternalLogging { + /// Log level indicating 'severity' of the corresponding `LogEvent` + public enum LogLevel { + case info + case error + } + + /// A log event + public struct LogEvent { + public let message: String + public let level: LogLevel + } + + /// Wrapper that allows for propagating log events to outside consumers. + internal struct ExternalLogger { + private let implementation: (LogEvent) -> Void + + internal init(_ implementation: @escaping (LogEvent) -> Void) { + self.implementation = implementation + } + + internal func log(_ payload: LogEvent) { implementation(payload) } + } + + /// Shared external logger variable + internal static var logger: ExternalLogger? + + /// External logging bootstrapping method. + /// Call once with the desired log handler. + /// - Parameter logHandler: Callback to handle logging events. + public static func configure( + _ logHandler: @escaping (LogEvent) -> Void + ) { + assert( + logger == nil, + "Workflow external logger already configured." + ) + + logger = ExternalLogger(logHandler) + } +} + +extension ExternalLogging.LogEvent { + /// Convenience to create an info-level `LogEvent` + static func info(_ message: String) -> Self { + .init(message: message, level: .info) + } + + /// Convenience to create an error-level `LogEvent` + static func error(_ message: String) -> Self { + .init(message: message, level: .error) + } +} + +extension ExternalLogging { + // Logs an info message via the global logger (if set) + static func logInfo(_ message: @autoclosure () -> String) { + logger?.log(.info(message())) + } + + // Logs an error message via the global logger (if set) + static func logError(_ message: @autoclosure () -> String) { + logger?.log(.error(message())) + } +} diff --git a/Workflow/Sources/RenderContext.swift b/Workflow/Sources/RenderContext.swift index 9c6b1fda7..088e12b37 100644 --- a/Workflow/Sources/RenderContext.swift +++ b/Workflow/Sources/RenderContext.swift @@ -134,7 +134,13 @@ public class RenderContext: RenderContextType { } private func assertStillValid() { - assert(isValid, "A `RenderContext` instance was used outside of the workflow's `render` method. It is a programmer error to capture a context in a closure or otherwise cause it to be used outside of the `render` method.") + guard isValid else { + ExternalLogging.logError(""" + Detected an attempt to use an invalidated RenderContext for a workflow of type \(WorkflowType.self). + """) + assertionFailure("A `RenderContext` instance for a workflow of type \(WorkflowType.self) was used outside of the workflow's `render` method. It is a programmer error to capture a context in a closure or otherwise cause it to be used outside of the `render` method.") + return + } } } } diff --git a/Workflow/Sources/SubtreeManager.swift b/Workflow/Sources/SubtreeManager.swift index ca469a95f..a74e1072f 100644 --- a/Workflow/Sources/SubtreeManager.swift +++ b/Workflow/Sources/SubtreeManager.swift @@ -373,9 +373,13 @@ extension WorkflowNode.SubtreeManager { // If we're invalid and this is the first time `handle()` has // been called, then it's likely we've somehow been inadvertently // retained from the 'outside world'. Fail more loudly in this case. - assert(isReentrantCall, """ - [\(WorkflowType.self)]: Sink sent an action after it was invalidated. This action will be ignored. - """) + if !isReentrantCall { + var message: String { + "[\(WorkflowType.self)]: Sink sent an action after it was invalidated. This action will be ignored." + } + ExternalLogging.logError(message) + assertionFailure(message) + } } }