log プロパティ追加でカスタムログ先を注入可能に

hotwired/hotwire-native-ios

Hotwire Native iOSlog プロパティが追加され、debugLoggingEnabled が有効なときに任意の Logger 実装へデバッグログを流せるようになりました。これにより、テストや外部ロギングシステムへの統合がシンプルに実現します。

背景

デフォルトのロガーは OSLog に固定されていた ため、テスト時のログ捕捉や自前のロギングフレームワークへの委譲が面倒でした。クライアント側から出力先を差し替える要求が出てきたものの、既存 API を壊さずに拡張する方法が求められていました。本 PR はその要望に応える形で、設定だけでカスタムロガーを注入できるインターフェースを提供します。

技術的な変更

HotwireConfig.swiftlog プロパティが追加されました。logLogger? 型でデフォルトは nildebugLoggingEnableddidSet と同様に HotwireLogger.update(debugLoggingEnabled:log:) を呼び出し、設定変更を即時反映します。

public struct HotwireConfig {
    /// Enables detailed debug logging.
    public var debugLoggingEnabled = false {
        didSet {
            HotwireLogger.update(debugLoggingEnabled: debugLoggingEnabled, log: log)
        }
    }

    /// Inject a custom logger. Used only when `debugLoggingEnabled` is true.
    public var log: Logger? = nil {
        didSet {
            HotwireLogger.update(debugLoggingEnabled: debugLoggingEnabled, log: log)
        }
    }
    // … other properties omitted …
}

Source/Logging/HotwireLogger.swift は新規実装され、デバッグ有効性と注入ロガーに応じて内部 logger 変数を切り替えます。デフォルトは OSLog ラッパー、無効時は常に disabledLogger が使用されます。

import Foundation

enum HotwireLogger {
    static let enabledLogger = OSLogWrapper(subsystem: Bundle.module.bundleIdentifier!, category: "HotwireNative")
    static let disabledLogger = OSLogWrapper.disabled

    static func update(debugLoggingEnabled: Bool, log: Logger?) {
        if debugLoggingEnabled {
            logger = log ?? enabledLogger
        } else {
            logger = disabledLogger
        }
    }
}

var logger: Logger = HotwireLogger.disabledLogger

ロガー抽象化の核となる Logger プロトコルSource/Logging/Logger.swift に導入され、log(message:level:file:function:line:) と、レベル別のデフォルト実装を提供します。これにより、任意のロギング実装が Logger に準拠すれば差し替え可能です。

import Foundation

public protocol Logger {
    func log(message: String, level: LogLevel, file: String, function: String, line: UInt)
    func debug(_ message: String, file: String, function: String, line: UInt)
    func info(_ message: String, file: String, function: String, line: UInt)
    func warning(_ message: String, file: String, function: String, line: UInt)
    func error(_ message: String, file: String, function: String, line: UInt)
}

extension Logger {
    public func debug(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
        log(message: message, level: .debug, file: file, function: function, line: line)
    }
    public func info(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
        log(message: message, level: .info, file: file, function: function, line: line)
    }
    public func warning(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
        log(message: message, level: .warning, file: file, function: function, line: line)
    }
    public func error(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
        log(message: message, level: .error, file: file, function: function, line: line)
    }
}

LogLevel 列挙体Source/Logging/LogLevel.swift に追加され、debug, info, warning, error の4 つのレベルを定義します。OSLog へのマッピングは OSLogWrapper の内部拡張で行われます。

public enum LogLevel {
    case debug
    case info
    case warning
    case error
}

OSLogWrapperSource/Logging/OSLogWrapper.swift に実装され、Logger に準拠したデフォルトロガーとして機能します。disabled 静的プロパティは OSLog の無効化ロガーを生成し、HotwireLogger.disabledLogger として再利用されます。

import OSLog

public struct OSLogWrapper: Logger {
    private let osLogger: os.Logger

    public init(subsystem: String, category: String) {
        self.osLogger = os.Logger(subsystem: subsystem, category: category)
    }

    public static var disabled: Logger {
        OSLogWrapper(osLogger: os.Logger(.disabled))
    }

    public func log(message: String, level: LogLevel, file: String, function: String, line: UInt) {
        osLogger.log(level: level.osLogLevel, "\(message)")
    }

    private init(osLogger: os.Logger) {
        self.osLogger = osLogger
    }
}

private extension LogLevel {
    var osLogLevel: OSLogType {
        switch self {
        case .debug: return .debug
        case .info: return .info
        case .warning: return .error
        case .error: return .error
        }
    }
}

テストスイートが拡充され、CustomLoggerTests.swift でカスタムロガーが期待通りに動作すること、セッターの順序に依存しないこと、デバッグ無効時はロガーが呼ばれないことが検証されています。

import XCTest
@testable import HotwireNative

final class CustomLoggerTests: XCTestCase {
    private var spy: LoggerSpy!

    override func setUp() {
        super.setUp()
        spy = LoggerSpy()
        resetLoggingConfig()
    }

    override func tearDown() {
        resetLoggingConfig()
        spy = nil
        super.tearDown()
    }

    func test_customLogger_receivesMessages_whenDebugLoggingEnabled() {
        Hotwire.config.debugLoggingEnabled = true
        Hotwire.config.log = spy

        logger.debug("debug message")
        logger.info("info message")
        logger.warning("warning message")
        logger.error("error message")

        XCTAssertEqual(spy.messages(for: .debug), ["debug message"])
        XCTAssertEqual(spy.messages(for: .info), ["info message"])
        XCTAssertEqual(spy.messages(for: .warning), ["warning message"])
        XCTAssertEqual(spy.messages(for: .error), ["error message"])
    }

    func test_customLogger_isWired_regardlessOfSetterOrder() {
        Hotwire.config.log = spy
        Hotwire.config.debugLoggingEnabled = true
        logger.debug("message")
        XCTAssertEqual(spy.messages(for: .debug), ["message"])
    }

    func test_customLogger_isNotUsed_whenDebugLoggingDisabled() {
        Hotwire.config.debugLoggingEnabled = false
        Hotwire.config.log = spy
        logger.debug("debug message")
        logger.error("error message")
        XCTAssertTrue(spy.allMessages.isEmpty)
    }
}

設計判断

プロトコルベースの抽象化により、デフォルトは OSLogWrapper、任意の実装は Logger に準拠すれば差し替え可能という拡張性が確保されました。debugLoggingEnabledfalse のときは常に disabledLogger が使われ、無駄な I/O を防いでパフォーマンスへの影響を最小限に抑えています。API 変更は log プロパティの追加のみで済み、既存コードへの破壊的影響はなく後方互換性が保たれています。

まとめ

本 PR は log プロパティの追加により、デバッグログの出力先をアプリ側で自由に指定できるようにしました。デフォルトの OSLog を維持しつつ、プロトコルベースの設計でテスト容易性と外部ロギングシステムとの統合をシンプルに実現しています。debugLoggingEnabled が有効なときだけカスタムロガーが有効になるため、既存挙動とのスムーズな共存が保証されています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
50de0603

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
3回 (改善を経て承認)
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文・背景・技術的な変更・まとめが揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックのファイル名ハイライトは正しい形式ですが、PRリンクが `[PR #156](URL)` となっており、期待される `[ #156 ](URL)` 形式ではありません。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

技術者向けの記述で、初心者向けの過度な解説はありません。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論・各論・結論の段落構成になっており、トピックセンテンスで始まり、1段落1トピックが守られています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のコードブロックは Diff に記載された内容と一致しており、ファイル名も正しく対応しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている用語 (Logger, LogLevel, OSLogWrapper など) は PR と Diff に登場するものと一致し、誤用は見られません。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的な説明は Diff と PR の内容に基づいており、根拠のない主張はありません。

事実の突合 ⚠ WARNING

PR情報による主張の裏付け(ハルシネーション検出)

「パフォーマンスへの影響を最小限に抑えています」 等、PR では明示されていない性能に関する記述が含まれています。定量的な根拠が示されていないためハルシネーションの可能性があります。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR 番号 #156 など数値は正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR の目的(カスタムログ先の提供)を適切に要約しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

バージョンサポートやリリース日程など、PR に記載のない外部知識は含まれていません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現の歪曲はありません。