JavaRush /Курси /Swift SELF /Структура лог-повідомлення: формат і поля

Структура лог-повідомлення: формат і поля

Swift SELF
Рівень 54 , Лекція 4
Відкрита

1. Фіксований формат логів

Якщо ви колись шукали баг у логах, то знаєте: спершу здається, що лог «усе пише», а за кілька хвилин зʼясовується, що він пише все, крім корисного. Проблема зазвичай не в кількості рядків, а в тому, що вони не стандартизовані: десь час стоїть ліворуч, десь праворуч, десь рівень записано словами, десь — цифрою, десь дані заховані в тексті («total: 100»), а десь їх узагалі немає.

Фіксований формат лог-рядка потрібен не заради краси. Він потрібен, щоб лог можна було швидко прочитати очима, легко відфільтрувати через grep і щоб автоматичні системи — алерти, збирачі логів — не ламалися від того, що ви без жодної потреби змінили фразу «Added book» на «Book added».

Ідея структурувати дані в metadata, а повідомлення залишити коротким, — це класика: так простіше шукати за конкретним полем, а не вгадувати потрібний шматок тексту.

Один запис логу — один рядок

Уявіть, що ви відкриваєте лог-файл, а там кожен «запис» насправді займає пʼять рядків, бо хтось залогував багаторядковий текст із перенесеннями. Око страждає, grep страждає, а збирач логів може взагалі вирішити, що це пʼять різних подій.

У багатьох системах агрегації логів є просте припущення: один рядок тексту — це один запис. Тож корисне й доволі практичне правило таке: намагайтеся не вставляти в лог-повідомлення перенесення рядків та інші керувальні символи. Це підвищує передбачуваність і спрощує пошук.

У нашому навчальному CLI це правило особливо важливе, бо найчастіша помилка новачка — вивести в лог «красивий багаторядковий дамп», а потім дивуватися, чому в реальному звіті все виглядає як каша.

2. Поля лог-запису: мінімальний каркас

Щоб логи були читабельними й корисними, ми заздалегідь домовляємося: кожен запис має фіксований набір полів і завжди йде в одному порядку. Тоді мозок швидко звикає до шаблону й починає помічати відхилення — помилки, попередження, дивні місця.

Нижче — зручна таблиця полів, які ми вважатимемо обов’язковим каркасом:

Поле Приклад Навіщо потрібно
timestamp
2026-01-16T22:15:03Z
Розуміти порядок подій і співвідносити їх із діями користувача
level
INFO, WARN, ERROR, DEBUG
Швидко відокремлювати «шум» від «важливого»
category
service
Бачити, який саме компонент записує лог
location
LibraryCLI/LibraryService.swift:42 addBook(title:)
Знаходити місце в коді без гри в здогадки
message
book.add
Коротке, стабільне позначення події
metadata
book.titleLength=12
Дані події у форматі
key=value

Зверніть увагу на одну хитрість: повідомлення (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.

1
Опитування
Налагодження Swift, рівень 54, лекція 4
Недоступний
Налагодження Swift
Стратегії та логування у Swift
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ