From 229ece6adc2da6ec2d21193961fcc9592c949be2 Mon Sep 17 00:00:00 2001 From: Seungyeop Yeom Date: Sun, 9 Nov 2025 12:47:22 +0900 Subject: [PATCH] Add logging for actions and state changes --- Sources/OneWay/LoggingOptions.swift | 29 +++++++++++++ Sources/OneWay/Store.swift | 56 ++++++++++++++++++++++++++ Sources/OneWay/ViewStore.swift | 27 +++++++++++++ Tests/OneWayTests/StoreTests.swift | 13 ++++++ Tests/OneWayTests/ViewStoreTests.swift | 11 +++++ 5 files changed, 136 insertions(+) create mode 100644 Sources/OneWay/LoggingOptions.swift diff --git a/Sources/OneWay/LoggingOptions.swift b/Sources/OneWay/LoggingOptions.swift new file mode 100644 index 0000000..121077e --- /dev/null +++ b/Sources/OneWay/LoggingOptions.swift @@ -0,0 +1,29 @@ +// +// OneWay +// The MIT License (MIT) +// +// Copyright (c) 2022-2025 SeungYeop Yeom ( https://github.com/DevYeom ). +// + +import Foundation + +/// A set of options that determines what information is logged by a ``Store``. +public struct LoggingOptions: OptionSet, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// Logs all actions sent to the store. + public static let action = LoggingOptions(rawValue: 1 << 0) + + /// Logs all state changes in the store. + public static let state = LoggingOptions(rawValue: 1 << 1) + + /// Logs all actions and state changes. + public static let all: LoggingOptions = [.action, .state] + + /// Disables all logging. + public static let none: LoggingOptions = [] +} diff --git a/Sources/OneWay/Store.swift b/Sources/OneWay/Store.swift index 9b1f619..e80306b 100644 --- a/Sources/OneWay/Store.swift +++ b/Sources/OneWay/Store.swift @@ -8,6 +8,9 @@ #if canImport(Foundation) import Foundation #endif +#if canImport(OSLog) +import OSLog +#endif /// `Store` is an actor that holds and manages state values. /// @@ -32,6 +35,16 @@ where R.Action: Sendable, R.State: Sendable & Equatable { didSet { if oldValue != state { continuation.yield(state) + #if canImport(OSLog) && DEBUG + if loggingOptions.contains(.state) { + let timestamp = Date.now.formatted(iso8601FormatStyle) + logger.debug(""" + [\(timestamp)] State changed: + - \(String(describing: oldValue)) + + \(String(describing: self.state)) + """) + } + #endif } } } @@ -48,6 +61,10 @@ where R.Action: Sendable, R.State: Sendable & Equatable { private let reducer: R private let clock: C + #if canImport(OSLog) && DEBUG + private let logger = Logger(subsystem: "com.devyeom.oneway", category: "Store") + #endif + private var loggingOptions = LoggingOptions.none private let continuation: AsyncStream.Continuation private var isProcessing: Bool = false private var actionQueue: [Action] = [] @@ -63,16 +80,19 @@ where R.Action: Sendable, R.State: Sendable & Equatable { /// - reducer: The reducer responsible for transitioning the current state to the next /// state in response to actions. /// - state: The initial state used to create the store. + /// - loggingOptions: A set of options for logging. Defaults to `none`. /// - clock: The clock that determines how time-based effects (such as debounce or throttle) /// are scheduled. Defaults to `ContinuousClock`. public init( reducer: @Sendable @autoclosure () -> R, state: State, + loggingOptions: LoggingOptions = .none, clock: C = ContinuousClock() ) { self.initialState = state self.state = state self.reducer = reducer() + self.loggingOptions = loggingOptions self.clock = clock (states, continuation) = AsyncStream.makeStream() Task { await bindExternalEffect() } @@ -93,6 +113,12 @@ where R.Action: Sendable, R.State: Sendable & Equatable { isProcessing = true await Task.yield() for action in actionQueue { + #if canImport(OSLog) && DEBUG + if loggingOptions.contains(.action) { + let timestamp = Date.now.formatted(iso8601FormatStyle) + logger.debug("[\(timestamp)] Action: \(String(describing: action))") + } + #endif let effect = reducer.reduce(state: &state, action: action) let isThrottled = await throttleIfNeeded(for: effect) if !isThrottled { @@ -117,6 +143,26 @@ where R.Action: Sendable, R.State: Sendable & Equatable { throttleTimestamps.removeAll() } + /// Sets the logging options for the store to control what information is logged. + /// + /// You can use this method to dynamically change the logging behavior of the store after it + /// has been initialized. For example, you might want to enable logging only for certain + /// user interactions or when debugging a specific issue. + /// + /// ```swift + /// // Enable logging for both actions and state changes. + /// await store.debug(.all) + /// + /// // Disable all logging. + /// await store.debug(.none) + /// ``` + /// + /// - Parameter loggingOptions: A set of `LoggingOptions` that determines what information + /// is logged. + public func debug(_ loggingOptions: LoggingOptions) { + self.loggingOptions = loggingOptions + } + private func throttleIfNeeded(for effect: AnyEffect) async -> Bool { guard case let .throttle(id, interval, latest) = effect.method else { return false @@ -215,3 +261,13 @@ private struct EffectIDWrapper: Hashable, @unchecked Sendable { self.id = id } } + +#if canImport(OSLog) && DEBUG +private let iso8601FormatStyle = Date.ISO8601FormatStyle() + .year() + .month() + .day() + .timeZone(separator: .omitted) + .time(includingFractionalSeconds: true) + .timeSeparator(.colon) +#endif diff --git a/Sources/OneWay/ViewStore.swift b/Sources/OneWay/ViewStore.swift index fd7a542..5e9c15e 100644 --- a/Sources/OneWay/ViewStore.swift +++ b/Sources/OneWay/ViewStore.swift @@ -101,6 +101,33 @@ where R.Action: Sendable, R.State: Sendable & Equatable { await store.reset() } } + + /// Sets the logging options for the store to control what information is logged. + /// + /// You can use this method to dynamically change the logging behavior of the store after it + /// has been initialized. For example, you might want to enable logging only for certain + /// user interactions or when debugging a specific issue. + /// + /// ```swift + /// // Enable logging for both actions and state changes. + /// @StateObject private var store = ViewStore( + /// reducer: HomeReducer(), + /// state: HomeReducer.State() + /// ) + /// .debug(.all) + /// + /// // Disable all logging. + /// store.debug(.none) + /// ``` + /// + /// - Parameter loggingOptions: A set of `LoggingOptions` that determines what information + /// is logged. + public func debug(_ loggingOptions: LoggingOptions) -> Self { + Task { @MainActor in + await store.debug(loggingOptions) + } + return self + } } #if canImport(Combine) diff --git a/Tests/OneWayTests/StoreTests.swift b/Tests/OneWayTests/StoreTests.swift index 9be0f12..4853f9e 100644 --- a/Tests/OneWayTests/StoreTests.swift +++ b/Tests/OneWayTests/StoreTests.swift @@ -50,6 +50,7 @@ final class StoreTests: XCTestCase { } func test_sendSeveralActions() async { + await sut.debug(.all) await sut.send(.increment) await sut.send(.increment) await sut.send(.twice) @@ -236,6 +237,18 @@ final class StoreTests: XCTestCase { await clock.advance(by: .seconds(100)) await sut.expect(\.count, 4) } + + func test_logging_options() async { + let all = Store( + reducer: TestReducer(clock: TestClock()), + state: TestReducer.State(count: 0, text: ""), + loggingOptions: .all + ) + await all.debug(.all) + await all.debug(.none) + await all.debug(.action) + await all.debug(.state) + } } #if canImport(Combine) diff --git a/Tests/OneWayTests/ViewStoreTests.swift b/Tests/OneWayTests/ViewStoreTests.swift index 7c7038f..7015689 100644 --- a/Tests/OneWayTests/ViewStoreTests.swift +++ b/Tests/OneWayTests/ViewStoreTests.swift @@ -178,6 +178,17 @@ final class ViewStoreTests: XCTestCase { ] ) } + + func test_logging_options() async { + let _ = await ViewStore( + reducer: TestReducer(), + state: TestReducer.State(count: 0) + ) + .debug(.all) + .debug(.none) + .debug(.action) + .debug(.state) + } } private struct TestReducer: Reducer {