1. Фіксований формат логів
Якщо ви колись шукали баг у логах, то знаєте: спершу здається, що лог «усе пише», а за кілька хвилин зʼясовується, що він пише все, крім корисного. Проблема зазвичай не в кількості рядків, а в тому, що вони не стандартизовані: десь час стоїть ліворуч, десь праворуч, десь рівень записано словами, десь — цифрою, десь дані заховані в тексті («total: 100»), а десь їх узагалі немає.
Фіксований формат лог-рядка потрібен не заради краси. Він потрібен, щоб лог можна було швидко прочитати очима, легко відфільтрувати через grep і щоб автоматичні системи — алерти, збирачі логів — не ламалися від того, що ви без жодної потреби змінили фразу «Added book» на «Book added».
Ідея структурувати дані в metadata, а повідомлення залишити коротким, — це класика: так простіше шукати за конкретним полем, а не вгадувати потрібний шматок тексту.
Один запис логу — один рядок
Уявіть, що ви відкриваєте лог-файл, а там кожен «запис» насправді займає пʼять рядків, бо хтось залогував багаторядковий текст із перенесеннями. Око страждає, grep страждає, а збирач логів може взагалі вирішити, що це пʼять різних подій.
У багатьох системах агрегації логів є просте припущення: один рядок тексту — це один запис. Тож корисне й доволі практичне правило таке: намагайтеся не вставляти в лог-повідомлення перенесення рядків та інші керувальні символи. Це підвищує передбачуваність і спрощує пошук.
У нашому навчальному CLI це правило особливо важливе, бо найчастіша помилка новачка — вивести в лог «красивий багаторядковий дамп», а потім дивуватися, чому в реальному звіті все виглядає як каша.
2. Поля лог-запису: мінімальний каркас
Щоб логи були читабельними й корисними, ми заздалегідь домовляємося: кожен запис має фіксований набір полів і завжди йде в одному порядку. Тоді мозок швидко звикає до шаблону й починає помічати відхилення — помилки, попередження, дивні місця.
Нижче — зручна таблиця полів, які ми вважатимемо обов’язковим каркасом:
| Поле | Приклад | Навіщо потрібно |
|---|---|---|
|
|
Розуміти порядок подій і співвідносити їх із діями користувача |
|
|
Швидко відокремлювати «шум» від «важливого» |
|
|
Бачити, який саме компонент записує лог |
|
|
Знаходити місце в коді без гри в здогадки |
|
|
Коротке, стабільне позначення події |
|
|
Дані події у форматі |
Зверніть увагу на одну хитрість: повідомлення (message) ми хочемо робити максимально стабільним, майже як код події, а змінювані деталі — ID, довжини, кількості — виносити в metadata. Цей підхід дуже близький до «semantic logging / structured logging».
message і metadata: не перетворюємо лог на роман
Дуже хочеться зробити лог «людинозрозумілим» і запхати туди відразу все: назву книги, автора, рік, шлях до файла, розмір JSON, поточний лічильник, фазу Місяця… У момент написання здається, що ви дбаєте про майбутнє. А в майбутньому ви відкриваєте лог і бачите 200 рядків словесної мішанини. Ось тут і рятує структурний підхід.
Ідея така: повідомлення — коротке, метадані — окремими key=value. Замість пошуку за шматками тексту на кшталт "total: 100" можна шукати за конкретним ключем connections.total.
Крім того, є рекомендація ставитися до ключів метаданих як до JSON-ключів: використовувати camelCase і розділення через крапки (connection.id, connections.total). Так багато систем аналізу логів можуть інтерпретувати їх як «вкладену структуру».
Застосуймо це до нашого LibraryCLI. Наприклад, сервіс додавання книги може логувати подію так:
- message: "book.add"
- metadata: book.titleLength=..., book.year=...
let title = "The Swift Programming Language"
let year = 2023
let message = "book.add"
let metadata: [String: String] = [
"book.titleLength": "\(title.count)",
"book.year": "\(year)"
]
print(message) // book.add
print(metadata) // ["book.year": "2023", "book.titleLength": "30"]
Зверніть увагу: ми навмисно не кладемо в лог повний title. У реальному світі назва книги може бути ще й персональними даними (наприклад, якщо це нотатка користувача), а також може містити лапки, перенесення рядків та інші сюрпризи. Нам часто достатньо безпечної характеристики, наприклад довжини.
metadata: сортування ключів і стабільність виведення
Якщо ви друкуєте словник як є, порядок ключів може бути різним. Це ускладнює порівняння логів: навіть якщо події однакові, рядки будуть відрізнятися. Тому простий трюк для стабільності — сортувати ключі перед форматуванням.
Ми виводитимемо метадані в хвості рядка як набір key=value, розділений пробілами. Це легко читати очима й легко шукати за конкретним ключем.
func formatMetadata(_ metadata: [String: String]) -> String {
if metadata.isEmpty { return "" }
let keys = metadata.keys.sorted()
var parts: [String] = []
for key in keys {
if let value = metadata[key] {
parts.append("\(key)=\(value)")
}
}
return parts.joined(separator: " ")
}
print(formatMetadata(["b": "2", "a": "1"])) // a=1 b=2
Так, це трохи більше коду, ніж «просто вивести словник». Але це той рідкісний випадок, коли кілька рядків коду потім економлять години роздумів над питанням: чому логи ніби однакові, але не однакові.
3. Як формувати окремі поля
Timestamp в ISO‑8601
Коли ми додаємо час, хочеться двох речей. По-перше, щоб він сортувався лексикографічно, як рядок, так само, як і за часом. По-друге, щоб він був упізнаваним у будь-якій країні й у будь-якій консолі.
Для цього ідеально підходить ISO‑8601. У Foundation для цього є ISO8601DateFormatter.
Невеликий нюанс: форматери у Foundation зазвичай не дуже дешеві в створенні. Тому ми або створюємо його один раз і перевикористовуємо, або робимо його static.
import Foundation
struct Timestamp {
// Один formatter на весь процес — менше алокацій, менше смутку.
static let formatter = ISO8601DateFormatter()
static func nowString(date: Date = Date()) -> String {
formatter.string(from: date)
}
}
print(Timestamp.nowString()) // наприклад: 2026-01-16T22:15:03Z
У нашому CLI ми використовуватимемо timestamp саме як частину діагностичного рядка, а не як «красивий» користувацький вивід. Користувач не зобов’язаний любити ISO‑8601 — він узагалі не зобов’язаний знати, що це таке. А лог має бути нудним і передбачуваним.
Рівень і категорія
Коли ви дивитеся на лог у терміналі, ви зазвичай очима шукаєте, де почалися помилки або що робив сервіс. Тож рівень і категорію корисно тримати в компактному вигляді й в одному стилі. Наприклад, рівень — великими літерами, а категорія — як rawValue enum.
Тут важливе інше: у форматі рядка ми не повинні щоразу вигадувати новий стиль. Якщо сьогодні ви пишете warning, завтра WARN, а післязавтра ⚠️, то лог перетворюється на візуальний зоопарк.
Мініприклад назви рівня, який буде однаковим усюди:
enum LogLevel: Int {
case debug = 0, info = 1, warning = 2, error = 3
}
func levelTag(_ level: LogLevel) -> String {
switch level {
case .debug: return "DEBUG"
case .info: return "INFO"
case .warning: return "WARN"
case .error: return "ERROR"
}
}
На цьому етапі ми не обговорюємо кольорові логи, емодзі, підсвічування та інші прикраси. У CLI це часто приємно, але зараз наша мета — дисципліна формату, а не естетика термінала.
Location як координати події
Коли лог говорить «помилка читання файла», але не говорить, де саме в коді це сталося, починається улюблена гра розробника — пошук по проєкту й вгадування. Перевага #fileID, #line, #function у тому, що компілятор підставляє їх автоматично в місці виклику — тобто в найкориснішому місці.
У попередній лекції ми вже навчилися передавати їх через параметри за замовчуванням. Тут ми вирішуємо, як саме відобразити їх у рядку. Для навчального проєкту зручно:
- fileID:line — щоб швидко перейти до файла,
- потім function,
- потім уже текст події.
func formatLocation(fileID: String, line: Int, function: String) -> String {
"\(fileID):\(line) \(function)"
}
print(formatLocation(fileID: "LibraryCLI/main.swift", line: 10, function: "run()"))
// LibraryCLI/main.swift:10 run()
І ще раз нагадування про приватність: краще не логувати абсолютні шляхи до файлів — вони можуть видати деталі середовища. У цьому сенсі #fileID приємніший за #file. (Так, інколи й #fileID надто балакучий, але для навчального CLI це добрий баланс.)
4. Не логуємо секрети
Секрети в логах — це як часник у десерті. Додали зовсім трішки — і вже дивуєтеся, чому ніхто не хоче це їсти. Різниця лише в тому, що часник зазвичай не витікає у продакшн.
Важливо прийняти таку думку: лог — це не особистий щоденник розробника, а потік даних, який може опинитися де завгодно: у CI, у колеги, в тікеті, у системі збирання логів, а інколи й у користувача (CLI все ж таки!). Тож ми заздалегідь домовляємося: не пишемо в лог відкритим текстом те, що не можна показувати сторонній людині.
Що зазвичай належить до секретів і чутливих даних:
- токени, паролі, API-ключі;
- будь-які персональні дані (пошта, телефон), якщо вони не потрібні для діагностики;
- «сирий ввід користувача», якщо він потенційно приватний;
- вміст файлів/відповідей, якщо там можуть бути секрети.
Замість цього ми або не логуємо значення зовсім, або замінюємо його на <redacted>, або логуємо безпечну характеристику: довжину, кількість елементів, наявність чи відсутність, хеш (у нашому курсі про хеші ми зараз не заглиблюємося, тож залишимо це як ідею).
Найпростіша базова цеглинка — функція «замаскувати значення»:
func redacted(_ value: String) -> String {
"<redacted>"
}
let token = "SUPER_SECRET"
print("token=\(redacted(token))") // token=<redacted>
І ще один практичний момент, про який часто забувають: секрети можуть потрапити не лише в message, а й у metadata. Тож правило «не логуємо секрети» стосується всього запису цілком.
5. Єдиний builder і консольний логер
Коли форматування розкидане по коду, воно неминуче розповзається. Один розробник додасть |, другий — —, третій почне друкувати category=… і все, у вас уже три стилі логів в одному застосунку.
Тому робимо централізований будівник рядка: функцію або метод логера, яка приймає поля й повертає готовий рядок.
Формування рядка в одному місці
import Foundation
struct LogLineBuilder {
static let formatter = ISO8601DateFormatter()
static func makeLogLine(
date: Date,
level: String,
category: String,
location: String,
message: String,
metadata: [String: String]
) -> String {
let ts = formatter.string(from: date)
let meta = formatMetadata(metadata)
let metaPart = meta.isEmpty ? "" : " " + meta
return "\(ts) [\(level)] [\(category)] \(location) — \(message)\(metaPart)"
}
}
Тут важлива ідея: код, який викликає логер, не повинен вирішувати, як саме виглядає рядок. Він повинен описувати подію. Відображення — відповідальність логера або будівника.
Міні-реалізація ConsoleLogger
Тепер з’єднаємо все в нашій архітектурі. Контракт Logger у нас є з попередньої лекції. Ми пишемо реалізацію, яка виводить рядки в консоль і застосовує фільтр за мінімальним рівнем.
Розіб’ємо на маленькі шматки, щоб код залишався читабельним.
struct ConsoleLogger {
let minLevel: LogLevel
}
extension ConsoleLogger: Logger {
func log(_ level: LogLevel,
category: LogCategory,
message: String,
metadata: [String: String],
fileID: String,
line: Int,
function: String) {
guard level.rawValue >= minLevel.rawValue else { return }
let location = formatLocation(fileID: fileID, line: line, function: function)
let lineText = LogLineBuilder.makeLogLine(date: Date(),
level: levelTag(level),
category: category.rawValue,
location: location,
message: message,
metadata: metadata)
print(lineText)
}
}
Тут ми робимо важливу річ: бізнес-код не форматує рядок, він передає «що сталося» (message + metadata), а логер вирішує «як це виглядає в консолі». Це зберігає єдиний формат.
Приклад на LibraryCLI
Тепер візьмімо наш умовний LibraryService і покажемо, як правильно логувати подію «додали книгу». Зверніть увагу: ми спеціально не пишемо в лог сам title, а пишемо довжину. Це приклад дисципліни, а не заборона на будь-які рядки.
final class LibraryService {
private let logger: any Logger
init(logger: any Logger) {
self.logger = logger
}
func addBook(title: String) {
logger.info("book.add",
category: .service,
metadata: ["book.titleLength": "\(title.count)"])
}
}
Читається майже як речення: «info, подія book.add, категорія service, метадані такі-то». І, що важливо, виглядає однаково всюди.
6. Типові помилки
Помилка №1: «Кожна команда форматує лог по-своєму».
Це зазвичай починається невинно: в одному місці print("[LOG] ..."), в іншому [INFO], у третьому time=... level=.... За тиждень у вас три формати, за місяць — сім. Лікується лише централізованим форматуванням: одна реалізація логера або одна функція builder, яка відповідає за рядок.
Помилка №2: Логувати «гарно», але багаторядково.
Дуже хочеться вивести JSON «як є» і ще з відступами. Потім з’ясовується, що в лог-агрегаторі він розпався на десятки рядків і втратив сенс. Практична рекомендація — уникати перенесень рядків і керувальних символів у логах, бо багато систем вважають: один рядок — одна подія.
Помилка №3: Ховати важливі параметри в текст повідомлення.
Повідомлення «Added book with title X and year Y» здається інформативним, доки ви не спробуєте знайти всі випадки, коли year=2020. Структурний підхід пропонує залишати повідомлення коротким, а дані — у metadata, щоб їх можна було шукати за ключем.
Помилка №4: Не сортувати metadata і отримувати «нестабільні» рядки.
Якщо порядок ключів плаває, ви не можете нормально порівнювати логи між запусками — наприклад, очима або простим diff. Сортування ключів перед друком — дешевий спосіб зробити вивід стабільним.
Помилка №5: «Секрети в лог — на хвилинку, потім приберу».
Це класичний самообман. «Потім» зазвичай настає після того, як секрет уже потрапив у CI-логи, у тікет і в чужий скриншот. Якщо значення потенційно чутливе, або не логуйте його зовсім, або редагуйте (<redacted>), або замінюйте безпечною характеристикою (довжина, кількість). Це правило стосується і message, і metadata.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ