Administrator
发布于 2026-03-16 / 4 阅读
0
0

工业级 Logger

可以。下面给你一套 AIChats 可直接落地的完整版 Logger

目标是:

  • 统一入口

  • 分类清晰

  • 支持 OSLog

  • Debug 下自动 print

  • 支持日志级别

  • 支持 metadata

  • 支持错误打印

  • 支持简单埋点入口

  • 后续方便接入 Crashlytics / Sentry / 文件日志

我会尽量按你现在项目的风格来写,结构清晰、可扩展。


一、推荐目录结构

Infra/
└── Log/
    ├── AppLogger.swift
    ├── LogCategory.swift
    ├── LogLevel.swift
    ├── LogMetadata.swift
    ├── LogDestination.swift
    └── AnalyticsLogger.swift

如果你现在不想拆这么细,也可以先放在一个文件里,后面再拆。


二、完整代码

1)LogLevel.swift

import OSLog

enum LogLevel: String, Sendable, CaseIterable {
  case debug
  case info
  case notice
  case warning
  case error
  case critical

  var osLogType: OSLogType {
    switch self {
    case .debug:
      return .debug
    case .info:
      return .info
    case .notice:
      return .default
    case .warning:
      return .error
    case .error:
      return .fault
    case .critical:
      return .fault
    }
  }

  var emoji: String {
    switch self {
    case .debug: return "🟣"
    case .info: return "🔵"
    case .notice: return "🟢"
    case .warning: return "🟠"
    case .error: return "🔴"
    case .critical: return "🚨"
    }
  }

  var label: String {
    rawValue.uppercased()
  }
}


2)LogCategory.swift

enum LogCategory: String, Sendable, CaseIterable {
  case app
  case ui
  case network
  case auth
  case ai
  case avatar
  case chat
  case storage
  case analytics
  case crash
  case firebase
}


3)LogMetadata.swift

typealias LogMetadata = [String: AnySendable]

这里用一个 AnySendable 来承接各种值。


4)AnySendable.swift

struct AnySendable: @unchecked Sendable, CustomStringConvertible {
  let value: Any

  init(_ value: Any) {
    self.value = value
  }

  var description: String {
    String(describing: value)
  }
}


5)LogDestination.swift

这层是为了以后扩展:

  • Console

  • File

  • Crashlytics

  • Remote Log

protocol LogDestination: Sendable {
  func write(
    subsystem: String,
    category: LogCategory,
    level: LogLevel,
    message: String,
    metadata: LogMetadata?,
    file: String,
    function: String,
    line: Int
  )
}


6)OSLogDestination.swift

import Foundation
import OSLog

struct OSLogDestination: LogDestination {
  func write(
    subsystem: String,
    category: LogCategory,
    level: LogLevel,
    message: String,
    metadata: LogMetadata?,
    file: String,
    function: String,
    line: Int
  ) {
    let logger = Logger(
      subsystem: subsystem,
      category: category.rawValue
    )

    let finalMessage = Self.buildMessage(
      level: level,
      category: category,
      message: message,
      metadata: metadata,
      file: file,
      function: function,
      line: line
    )

    switch level {
    case .debug:
      logger.debug("\(finalMessage, privacy: .public)")
    case .info:
      logger.info("\(finalMessage, privacy: .public)")
    case .notice:
      logger.notice("\(finalMessage, privacy: .public)")
    case .warning:
      logger.warning("\(finalMessage, privacy: .public)")
    case .error, .critical:
      logger.error("\(finalMessage, privacy: .public)")
    }
  }

  private static func buildMessage(
    level: LogLevel,
    category: LogCategory,
    message: String,
    metadata: LogMetadata?,
    file: String,
    function: String,
    line: Int
  ) -> String {
    let fileName = URL(fileURLWithPath: file).lastPathComponent
    let metadataText: String

    if let metadata, !metadata.isEmpty {
      let pairs = metadata
        .map { "\($0.key)=\($0.value)" }
        .sorted()
        .joined(separator: ", ")
      metadataText = " | \(pairs)"
    } else {
      metadataText = ""
    }

    return "\(level.emoji) [\(level.label)] [\(category.rawValue)] \(message)\(metadataText) | \(fileName):\(line) \(function)"
  }
}


7)ConsolePrintDestination.swift

Debug 下很好用。

import Foundation

struct ConsolePrintDestination: LogDestination {
  func write(
    subsystem: String,
    category: LogCategory,
    level: LogLevel,
    message: String,
    metadata: LogMetadata?,
    file: String,
    function: String,
    line: Int
  ) {
    #if DEBUG
      let fileName = URL(fileURLWithPath: file).lastPathComponent
      let metadataText: String

      if let metadata, !metadata.isEmpty {
        let pairs = metadata
          .map { "\($0.key)=\($0.value)" }
          .sorted()
          .joined(separator: ", ")
        metadataText = " | \(pairs)"
      } else {
        metadataText = ""
      }

      print("\(level.emoji) [\(level.label)] [\(category.rawValue)] \(message)\(metadataText) | \(fileName):\(line) \(function)")
    #endif
  }
}


8)CrashDestination.swift

先留骨架,后续你可以接 Crashlytics / Sentry。

struct CrashDestination: LogDestination {
  func write(
    subsystem: String,
    category: LogCategory,
    level: LogLevel,
    message: String,
    metadata: LogMetadata?,
    file: String,
    function: String,
    line: Int
  ) {
    guard level == .error || level == .critical else { return }

    // 这里后续接 Crashlytics / Sentry
    // 例如:
    // Crashlytics.crashlytics().log(message)
    // SentrySDK.capture(message: message)

    #if DEBUG
      print("☁️ [CrashDestination] \(message)")
    #endif
  }
}


9)AppLogger.swift

这是核心。

import Foundation

struct AppLogger: Sendable {
  static let shared = AppLogger()

  let subsystem: String
  private let destinations: [any LogDestination]

  init(
    subsystem: String = "com.sinduke.AIChats",
    destinations: [any LogDestination] = [
      OSLogDestination(),
      ConsolePrintDestination(),
      CrashDestination()
    ]
  ) {
    self.subsystem = subsystem
    self.destinations = destinations
  }

  func log(
    _ level: LogLevel,
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    let messageValue = message()

    for destination in destinations {
      destination.write(
        subsystem: subsystem,
        category: category,
        level: level,
        message: messageValue,
        metadata: metadata,
        file: file,
        function: function,
        line: line
      )
    }
  }

  func debug(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    log(.debug, message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  func info(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    log(.info, message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  func notice(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    log(.notice, message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  func warning(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    log(.warning, message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  func error(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    log(.error, message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  func critical(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    log(.critical, message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  func error(
    _ error: Error,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    var mergedMetadata = metadata ?? [:]
    mergedMetadata["error_type"] = AnySendable(String(describing: type(of: error)))
    mergedMetadata["error_description"] = AnySendable(error.localizedDescription)

    log(
      .error,
      error.localizedDescription,
      category: category,
      metadata: mergedMetadata,
      file: file,
      function: function,
      line: line
    )
  }
}


10)Log.swift

这个是最顺手的入口。

enum Log {
  private static let logger = AppLogger.shared

  static func debug(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    logger.debug(message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  static func info(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    logger.info(message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  static func notice(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    logger.notice(message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  static func warning(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    logger.warning(message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  static func error(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    logger.error(message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  static func critical(
    _ message: @autoclosure () -> String,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    logger.critical(message(), category: category, metadata: metadata, file: file, function: function, line: line)
  }

  static func error(
    _ error: Error,
    category: LogCategory = .app,
    metadata: LogMetadata? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    logger.error(error, category: category, metadata: metadata, file: file, function: function, line: line)
  }
}


11)AnalyticsLogger.swift

简单埋点入口。

enum AnalyticsLog {
  static func event(
    _ name: String,
    parameters: [String: AnySendable]? = nil,
    file: String = #fileID,
    function: String = #function,
    line: Int = #line
  ) {
    Log.notice(
      "Analytics Event: \(name)",
      category: .analytics,
      metadata: parameters,
      file: file,
      function: function,
      line: line
    )

    // 这里后续可以接 FirebaseAnalytics / 自建埋点
    // 例如:
    // Analytics.logEvent(name, parameters: ...)
  }
}


三、怎么使用

普通日志

Log.info("App launched")


指定分类

Log.info("User authenticated", category: .auth)
Log.warning("Token will expire soon", category: .auth)
Log.error("Request failed", category: .network)


带 metadata

Log.info(
  "Send message",
  category: .chat,
  metadata: [
    "chat_id": AnySendable(chatId),
    "user_id": AnySendable(userId),
    "message_length": AnySendable(text.count)
  ]
)


打印 Error

do {
  try await fetchData()
} catch {
  Log.error(
    error,
    category: .network,
    metadata: [
      "endpoint": AnySendable("/chat/send")
    ]
  )
}


埋点

AnalyticsLog.event(
  "chat_send_clicked",
  parameters: [
    "chat_id": AnySendable(chatId),
    "avatar_id": AnySendable(avatarId)
  ]
)


四、给你几个 AIChats 场景示例

1. AI 请求开始

Log.info(
  "AI request started",
  category: .ai,
  metadata: [
    "model": AnySendable("gpt-4.1-mini"),
    "message_count": AnySendable(messages.count)
  ]
)

2. 流式返回中

Log.debug(
  "Streaming delta received",
  category: .ai,
  metadata: [
    "delta_length": AnySendable(delta.count)
  ]
)

3. Firebase 用户登录

Log.notice(
  "User signed in",
  category: .auth,
  metadata: [
    "uid": AnySendable(user.uid)
  ]
)

4. 聊天保存失败

Log.error(
  "Failed to save chat message",
  category: .chat,
  metadata: [
    "chat_id": AnySendable(chatId),
    "message_id": AnySendable(messageId)
  ]
)


五、为什么这版算“工业化”

因为它已经有这些特征:

  • 统一入口 Log

  • 分类 LogCategory

  • 级别 LogLevel

  • 元数据 metadata

  • 多目标输出 destinations

  • 兼容 OSLog

  • Debug 打印

  • Error 专门入口

  • 埋点入口

  • 后续可接:

    • File Logger

    • Crashlytics

    • Sentry

    • Remote Log Server


六、后续你最值得继续加的两个模块

1)FileLogDestination

把日志写入本地文件,方便导出和排查。

2)CrashlyticsDestination

把 .error / .critical 自动同步到 Firebase Crashlytics。


七、一个小优化建议

你现在项目里如果经常写:

AnySendable(...)

会有点烦。可以加一个辅助方法:

extension Dictionary where Key == String, Value == AnySendable {
  static func build(_ pairs: (String, Any)...) -> Self {
    Dictionary(uniqueKeysWithValues: pairs.map { ($0.0, AnySendable($0.1)) })
  }
}

这样就能写成:

Log.info(
  "Chat loaded",
  category: .chat,
  metadata: .build(
    ("chat_id", chatId),
    ("count", messages.count)
  )
)

这个会顺手很多。


如果你愿意,我下一条可以继续直接帮你补上 FileLogDestination 完整版,这样这套 Logger 就更像真正线上项目的基础设施了。


评论