Реалізація збору діагностичних логів у мобільному додатку
Користувач повідомляє про проблему — але воспроізвести її на іншому пристрої не вдається. Без діагностичних логів залишається гадати. Правильно налаштована система логування дає змогу отримати повний контекст прямо з пристрою користувача: послідовність дій, мережеві запити, стан пам'яті.
Архітектура логування
In-memory кільцевий буфер
Постійна запис у файл для кожного лог-виклику — дорогостояча операція. Замість цього утримуємо кільцевий буфер в пам'яті й скидаємо на диск тільки при збиранні діагностики або крешу:
// Android: кільцевий буфер логів
class LogBuffer(private val capacity: Int = 500) {
private val buffer = ArrayDeque<LogEntry>(capacity)
@Synchronized
fun add(level: LogLevel, tag: String, message: String) {
if (buffer.size >= capacity) buffer.removeFirst()
buffer.addLast(LogEntry(
timestamp = System.currentTimeMillis(),
level = level,
tag = tag,
message = message
))
}
@Synchronized
fun getLast(count: Int): List<LogEntry> =
buffer.takeLast(minOf(count, buffer.size))
}
500 записів — достатньо, щоб відновити останні кілька хвилин роботи додатку. Більше — зазвичай надлишково й займає помітний обсяг пам'яті.
Timber tree для Android
class DiagnosticTree(private val buffer: LogBuffer) : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val level = when (priority) {
Log.DEBUG -> LogLevel.DEBUG
Log.INFO -> LogLevel.INFO
Log.WARN -> LogLevel.WARN
Log.ERROR -> LogLevel.ERROR
else -> LogLevel.VERBOSE
}
buffer.add(level, tag ?: "App", message)
// У Debug-збірці додатково пишемо в Android Logcat
if (BuildConfig.DEBUG) super.log(priority, tag, message, t)
}
}
// Application.onCreate()
Timber.plant(DiagnosticTree(logBuffer))
Timber.plant(if (BuildConfig.DEBUG) Timber.DebugTree() else SilentTree())
iOS — OSLog + in-memory buffer
// Кастомний Logger з буфером
class DiagnosticLogger {
private let osLog = Logger(subsystem: "com.example.app", category: "diagnostic")
private var buffer: [LogEntry] = []
private let maxEntries = 500
private let queue = DispatchQueue(label: "logger", qos: .utility)
func log(_ message: String, level: LogLevel = .info, file: String = #file, line: Int = #line) {
let entry = LogEntry(
timestamp: Date(),
level: level,
message: message,
location: "\(URL(fileURLWithPath: file).lastPathComponent):\(line)"
)
queue.async { [weak self] in
guard let self else { return }
if self.buffer.count >= self.maxEntries {
self.buffer.removeFirst()
}
self.buffer.append(entry)
}
// OSLog для Instruments і Console.app
osLog.log(level: level.osLogType, "\(message)")
}
}
#file і #line — автоматична привязка до місця виклику. У діагностичному звіті видно не тільки «що сталося», але й «де в коді».
Збереження у файл
При збиранні діагностики серіалізуємо буфер у файл:
suspend fun exportDiagnosticReport(): File = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "diagnostic_${System.currentTimeMillis()}.txt")
file.bufferedWriter().use { writer ->
writer.appendLine("=== App: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) ===")
writer.appendLine("=== Device: ${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE} ===")
writer.appendLine("=== Report generated: ${Date()} ===")
writer.appendLine()
logBuffer.getLast(500).forEach { entry ->
writer.appendLine("[${entry.levelTag}] ${entry.formattedTime} ${entry.tag}: ${entry.message}")
}
}
file
}
Обфускація чутливих даних
Логи не повинні містити токени, паролі або дані карток. Фільтрація на рівні logger-tree:
private val sensitivePatterns = listOf(
Regex("""Bearer\s+[\w\-._~+/]+=*"""), // Authorization header
Regex("""\b\d{13,19}\b"""), // Номери карт
Regex(""""password"\s*:\s*"[^"]*"""") // JSON-поле password
)
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
var sanitized = message
sensitivePatterns.forEach { pattern ->
sanitized = pattern.replace(sanitized, "[REDACTED]")
}
buffer.add(/* ... */, sanitized)
}
Відправка звіту користувачем
Файл діагностики прикріплюється до форми зворотного зв'язку або відправляється за запитом підтримки:
// iOS: Share sheet для відправки файла
let diagnosticURL = try await DiagnosticLogger.shared.exportReport()
let activityVC = UIActivityViewController(
activityItems: [diagnosticURL],
applicationActivities: nil
)
present(activityVC, animated: true)
Додатково — можливість відправки напрямки в support-тикет через API Zendesk/Freshdesk як attachment.
Орієнтири по строкам
Реалізація in-memory буфера, Timber tree / OSLog-обгортки й експорту файла — 3–5 днів. З обфускацією чутливих даних, інтеграцією з helpdesk і UI для користувача — до 1 тижня.







