/
sberpay
/
AgeGate-iOS
Обзор
Документация
Войти
/
sberpay
/
AgeGate-iOS
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
develop
AgeGateSDK/Core/Logger.swift
303 строки
11 KB
SergeyGladkiy
[0.2.0] БДСЧ сценарий
30 июл 2026, 21:57
30 июл 2026, 21:57
cbcdfcb
Код
Авторство
О чём код?
// // Logger.swift // SPaySdk // // Created by Alexander Ipatov on 30.12.2022. // import UIKit import CryptoKit /// Категории логов. В отладочной сборке публичные — чтобы из тестового /// приложения можно было отфильтровать шум и оставить только нужное. #if SDKDEBUG public enum DebugLogLevel: String, CaseIterable, Codable { case flow = "Сценарий" case network = "Сеть" case lifecycle = "Жизненный цикл" case appSwitch = "Переходы" case biometry = "Биометрия" case storage = "Хранилище" case analytics = "Аналитика" case error = "Ошибки" case defaultLevel = "Прочее" } #else enum DebugLogLevel: String, CaseIterable, Codable { case flow = "Сценарий" case network = "Сеть" case lifecycle = "Жизненный цикл" case appSwitch = "Переходы" case biometry = "Биометрия" case storage = "Хранилище" case analytics = "Аналитика" case error = "Ошибки" case defaultLevel = "Прочее" } #endif /// Логирование с записью в файл. /// /// Пишем лог и в консоль, /// и в текстовый файл в Documents. Файл потом можно выгрузить и переслать — /// это единственный рабочий способ разобрать проблему у партнёра, до чьего /// устройства мы никогда не дотянемся. /// /// ВАЖНО: не писать сюда персональные и платёжные данные — номера карт, /// коды из СМС, токены. Файл лежит в песочнице приложения партнёра и /// попадает в выгрузку диагностики. Для сетевых тел есть `secureLogs`, /// он маскирует опасные ключи; выключать его можно только локально. enum SBLogger { // MARK: - Настройки /// Включается через `AgeGate.setup(logging: true)`. static var writeLogs = false /// Маскировать чувствительные поля в телах запросов и ответов. /// Выключать только на своей машине, никогда не в сборке для партнёра. static var secureLogs = true /// Какие категории писать. По умолчанию все. static var levels: [DebugLogLevel] = DebugLogLevel.allCases // MARK: - Файл static let sessionDate = Date().fileNameSafe static var logFileName: String { "AgeGate v\(Bundle.sdkVersion)(\(Bundle.sdkBuild)) \(sessionDate).txt" } static var logDirectory: URL? { FileManager.default .urls(for: .documentDirectory, in: .userDomainMask).first? .appendingPathComponent("AgeGateLogs") } static var logPath: URL? { logDirectory?.appendingPathComponent(logFileName) } private static var writer = LogWriter() // MARK: - Запись /// Сообщение для партнёра. Пишется всегда, независимо от `writeLogs`, — /// это то, что разработчик партнёра увидит в своей консоли. static func log(partner message: String) { NSLog("[AgeGate] \(message)") } static func log(_ level: DebugLogLevel = .defaultLevel, _ message: @autoclosure () -> String) { guard writeLogs, levels.contains(level) else { return } /// @autoclosure неубегающий — вычисляем здесь. let text = message() print(text) print("|\(level.rawValue) \(Date().readable)\n\(text)", to: &writer) } /// Удаляет все накопленные файлы логов. static func clearLogs() { guard let directory = logDirectory else { return } try? FileManager.default.removeItem(at: directory) } static func allLogFiles() -> [URL] { guard let directory = logDirectory else { return [] } let files = try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: nil) return files?.sorted { $0.lastPathComponent > $1.lastPathComponent } ?? [] } } // MARK: - Сеть extension SBLogger { static func logRequestStarted(_ request: URLRequest) { log(.network, """ 🌐 Запрос отправлен путь: \(request.url?.absoluteString ?? "—") метод: \(request.httpMethod ?? "—") заголовки: \(headers(request.allHTTPHeaderFields)) тело: \(body(from: request.httpBody)) """) } static func logRequestCompleted(_ request: URLRequest, response: URLResponse?, data: Data?, error: Error?) { var code = "—" var status = 0 if let http = response as? HTTPURLResponse { code = String(http.statusCode) status = http.statusCode } else if let error { code = "\(error._code) — \(error.localizedDescription)" } let mark = (200...299).contains(status) ? "🌐✅" : "🌐❌" log(.network, """ \(mark) Ответ с кодом \(code) путь: \(request.url?.absoluteString ?? "—") тело: \(body(from: data)) """) } static func logDecodingFailed<T>(for type: T.Type, error: DecodingError) { log(.network, """ 🌐❌ Не удалось разобрать ответ в \(type) \(error) """) } } // MARK: - Переход во внешнее приложение extension SBLogger { static func logAppSwitchStarted(_ url: URL) { /// Параметры ссылки разбираем отдельно: в них бывает code_challenge /// и прочее, что удобно видеть при разборе проблем с авторизацией. var parameters: [String: String] = [:] URLComponents(url: url, resolvingAgainstBaseURL: true)? .queryItems? .forEach { item in parameters[item.name] = secureLogs && Self.sensitiveKeys.contains(item.name) ? "<скрыто>" : (item.value ?? "") } log(.appSwitch, """ ↗️ Уходим во внешнее приложение схема: \(url.scheme ?? "—") параметры: \(parameters) """) } static func logAppSwitchReturned(_ url: URL) { log(.appSwitch, "↗️✅ Вернулись из внешнего приложения по схеме \(url.scheme ?? "—")") } static func logAppSwitchFailed(_ reason: String) { log(.appSwitch, "↗️❌ Переход не состоялся: \(reason)") } } // MARK: - Жизненный цикл extension SBLogger { enum ObjectState { case start(Any) case stop(Any) } static func log(_ state: ObjectState) { switch state { case .start(let object): log(.lifecycle, "🔄 \(String(describing: type(of: object))) создан") case .stop(let object): log(.lifecycle, "🔄 \(String(describing: type(of: object))) уничтожен") } } static func logScreen(_ name: String, event: String) { log(.lifecycle, "🚀 \(event) \(name)") } } // MARK: - Маскирование private extension SBLogger { /// Поля, которые нельзя писать в файл в открытом виде. static let sensitiveKeys: Set<String> = [ "token", "accessToken", "refreshToken", "idToken", "authCode", "code", "code_challenge", "otp", "smsCode", "pan", "cardNumber", "cvc", "cvv", "expiry", "phone", "phoneNumber", "clientId", "userId", "biometryTemplate", "faceId" ] static func headers(_ headers: [String: String]?) -> String { guard let headers, !headers.isEmpty else { return "—" } guard secureLogs else { return "\(headers)" } return "\(headers.mapValues { _ in "<скрыто>" }.keys.sorted())" } static func body(from data: Data?) -> String { guard let data, !data.isEmpty else { return "—" } guard let object = try? JSONSerialization.jsonObject(with: data) else { return "<не JSON, \(data.count) байт>" } let cleaned = secureLogs ? mask(object) : object guard let pretty = try? JSONSerialization.data(withJSONObject: cleaned, options: [.prettyPrinted, .sortedKeys]), let string = String(data: pretty, encoding: .utf8) else { return "—" } return string } /// Рекурсивно заменяет значения чувствительных ключей на заглушку. static func mask(_ object: Any) -> Any { if let dictionary = object as? [String: Any] { return dictionary.reduce(into: [String: Any]()) { result, pair in result[pair.key] = sensitiveKeys.contains(pair.key) ? "<скрыто>" : mask(pair.value) } } if let array = object as? [Any] { return array.map(mask) } return object } } /// Дописывает строки в текстовый файл. Подставляется в `print(_:to:)`. /// /// Открываем и закрываем файл на каждой записи: логов немного, а держать /// открытый дескриптор в SDK внутри чужого приложения не хочется — если /// приложение упадёт, незакрытый файл может остаться битым. struct LogWriter: TextOutputStream { private let queue = DispatchQueue(label: "AgeGate.log.writer") func write(_ string: String) { queue.sync { guard let directory = SBLogger.logDirectory, let path = SBLogger.logPath else { return } let manager = FileManager.default try? manager.createDirectory(at: directory, withIntermediateDirectories: true) guard let data = string.data(using: .utf8) else { return } if let handle = try? FileHandle(forWritingTo: path) { defer { try? handle.close() } try? handle.seekToEnd() try? handle.write(contentsOf: data) } else { try? data.write(to: path) } } } }