JavaRush /Курсы /Swift SELF /Структура лог‑сообщения: формат, поля

Структура лог‑сообщения: формат, поля

Swift SELF
54 уровень , 4 лекция
Открыта

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

Если вы когда-нибудь искали баг по логам, вы знаете: первые пять минут кажется, что лог «всё пишет», а на шестой минуте выясняется, что он пишет всё, кроме полезного. Проблема обычно не в количестве строк, а в том, что строки не стандартизированы: где-то время слева, где-то справа, где-то уровень словами, где-то цифрой, где-то данные спрятаны в тексте («total: 100»), а где-то их вообще нет.

Фиксированный формат лог‑строки нужен не ради красоты. Он нужен, чтобы лог можно было быстро «просканировать глазами», легко отфильтровать grep’ом и чтобы автоматические системы (алерты, сборщики логов) не ломались от того, что вы поменяли фразу «Added book» на «Book added» в порыве литературного вдохновения.

Идея «структурировать данные в metadata, а сообщение оставить коротким» — классика, потому что так проще искать по конкретному полю, а не угадывать кусочки текста.

Одна запись лога — одна строка

Представьте, что вы открываете лог‑файл, а там каждая «запись» на самом деле занимает 5 строк, потому что кто-то залогировал многострочную строку с переносами. Глаз страдает, 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)"
    }
}

Здесь важна идея: код, который вызывает логгер, не должен решать, как именно выглядит строка. Он должен описывать событие. Отрисовка — ответственность логгера (или builder’а).

Мини‑реализация 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 SELF, 54 уровень, 4 лекция
Недоступна
Хвост метаданных
Хвост метаданных
1
Задача
Swift SELF, 54 уровень, 4 лекция
Недоступна
Координаты события
Координаты события
1
Задача
Swift SELF, 54 уровень, 4 лекция
Недоступна
Полная запись
Полная запись
1
Задача
Swift SELF, 54 уровень, 4 лекция
Недоступна
Гигиена логов
Гигиена логов
1
Опрос
Отладка Swift, 54 уровень, 4 лекция
Недоступен
Отладка Swift
Стратегии и логирование в Swift
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ