1. Гігієна логів
Після стартових логів уже видно, що мало просто читати інфраструктурні повідомлення. Власні логи теж мають бути короткими, безпечними та придатними для пошуку, інакше вони самі перетворюються на шум і заважають діагностиці.
Якщо логування — це «голос» вашого сервісу, то гігієна логів — це дикція, інтонація й відсутність звички кричати в мікрофон щосекунди. Без гігієни логи швидко перетворюються на кашу: корисні сигнали губляться, а розробник починає ненавидіти не баги, а сам факт, що в застосунку взагалі є консоль. У catalog-service це особливо помітно, бо сервіс невеликий: шум у маленькому сервісі звучить голосніше.
Уявіть, що ви намагаєтеся розібрати проблему за логами на проді — хай навіть у нас поки що є лише «навчальний прод» у голові. У вас є рівно три інструменти: очі, пошук за текстом і терпіння. І саме терпіння — ресурс кінцевий. Тому нормальні логи роблять дві речі: допомагають швидко відповідати на запитання «що сталося?» і «де саме?», а також не заважають, коли нічого страшного не відбувається.
У цій точці початківець-розробник часто впадає в дві крайнощі. Перша — логувати все підряд «про всяк випадок» і перетворювати лог на серіал на 200 сезонів. Друга — не логувати майже нічого, і тоді в разі проблеми ви йдете в налагоджувач, як у ліс із ліхтариком: наче світить, але ви вже заблукали.
2. Корисне повідомлення: подія, контекст, результат
Ми вже знаємо базу: лог має називати подію та давати 1–2 факти, за якими потім можна зрозуміти поведінку сервісу. Тут цього достатньо як нагадування. Далі важливіший інший фільтр: повідомлення має бути коротким, придатним для пошуку та не тягнути в лог зайві поля.
Тому робочий формат залишається простим: event, key=value, key=value. Навіть без окремої структури така форма вже робить повідомлення передбачуваним і читабельним.
На простому прикладі різниця все та сама:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class FeaturedLogDemo {
private static final Logger log = LoggerFactory.getLogger(FeaturedLogDemo.class);
void logFeaturedPrepared(int count, int limit) {
log.info("Готово"); // марно: немає ані події, ані контексту, ані результату
// Гарне повідомлення: подія + вимірювані факти (контекст/результат)
// Плейсхолдери {} допомагають тримати формат стабільним і зручним для пошуку.
log.info("Підготовлено добірку курсів, count={}, limit={}", count, limit);
// 2026-03-19T10:15:32.741 INFO ... Підготовлено добірку курсів, count=3, limit=4
}
}
Цього вже достатньо, щоб відрізнити сигнал від словесного сміття. Далі важливіше інше: чи не перетворюємо ми хороший лог на дамп об’єкта, потік повторів або джерело чутливих даних.
3. Ідентифікатори та прапорці замість дампа об’єкта
Коли логують об’єкт цілком (log.info("properties={}", properties)), здається, що це дуже інформативно: «ну там же все написано». На практиці це зазвичай постріл собі в ногу. По-перше, об’єкт може бути величезним (у нашому випадку CatalogProperties може містити список курсів). По-друге, toString у record або класі часто друкує все підряд, зокрема те, що ніхто не хоче читати під час кожного запуску. По-третє, це ще й ризик: ви не завжди контролюєте, що опиниться всередині рядка.
У catalog-service особливо спокушає роздрукувати CatalogProperties, бо це «головна конфігурація». Але правильна мета стартових повідомлень — не «показати все», а «дати коротку зведену інформацію»: скільки курсів завантажили, чи ввімкнено режим обслуговування, який ліміт featured і чи активні профілі. Саме ці факти реально допомагають у перші 10 секунд після старту.
Ось приклад «погано / краще» у стилі нашого проєкту:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class StartupLogExample {
private static final Logger log = LoggerFactory.getLogger(StartupLogExample.class);
void logStartup(int coursesCount, boolean maintenanceMode) {
// Погано: величезний і потенційно небезпечний рядок.
// Важливо: "багато даних" не дорівнює "багато користі".
// log.info("CatalogProperties = {}", properties);
// Краще: коротка зведена інформація з вимірюваними фактами, яку легко шукати та порівнювати між запусками.
log.info("Каталог завантажено, courses={}, maintenanceMode={}", coursesCount, maintenanceMode);
// ... Каталог завантажено, courses=12, maintenanceMode=false
}
}
Той самий принцип працює і для доменних об’єктів. Наприклад, CourseCard може містити shortDescription, дату, price та ще багато чого. Логувати весь CourseCard заради того, щоб побачити slug, — це як роздрукувати паспорт на п’яти аркушах, щоб дізнатися ім’я.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class CourseCardLogExample {
private static final Logger log = LoggerFactory.getLogger(CourseCardLogExample.class);
void logCourseFound(String slug, String title) {
// Логуємо лише те, що справді потрібно для діагностики: ідентифікатор і короткий людський контекст.
// Не друкуємо весь об’єкт CourseCard цілком.
log.debug("Курс знайдено, slug={}, title={}", slug, title);
// ... Курс знайдено, slug=spring-boot, title=Spring Boot
}
}
Окрема важлива думка: «менше даних» у логі часто означає «більше сенсу». Лог — це не архів усіх полів. Лог — це сигнал. Якщо вам потрібен архів усіх полів, це вже інше завдання й інша інфраструктура, а ми сьогодні будуємо сервіс, який приємно супроводжувати без зайвої важкості.
4. Чутливі дані в логах
Логи — штука підступна: вони живуть довго, копіюються далеко й читаються людьми, які не зобов’язані знати контекст вашого «тимчасового налагодження». Навіть якщо зараз у catalog-service немає логінів, паролів і токенів, звичка «логувати все підряд» легко переживе навчальний проєкт і переїде в наступний сервіс, де вже будуть справжні секрети. Тому правила безпеки краще виробити зараз, поки ставки низькі.
Що вважати чутливими даними? Не лише паролі. Токени, ключі, секрети, cookie-подібні значення, будь-які персональні дані користувача, а також внутрішні значення, які можуть допомогти атакувальнику, наприклад повний список змінних середовища. Навіть «просто конфігурація» може містити секрети, особливо якщо ви колись під’єднаєте зовнішній API.
Найчастіша помилка новачка виглядає так: «мені треба зрозуміти, чому це не працює» → «я роздрукую весь Environment або System.getenv()». У цей момент ви не діагностуєте, а влаштовуєте документальний серіал «як влаштований мій сервер», тільки без цензури.
Поганий і хороший підхід на рівні ідеї — без заглиблення в окремі security-механізми:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class SensitiveLogExample {
private static final Logger log = LoggerFactory.getLogger(SensitiveLogExample.class);
void logApiKey(String apiKey) {
// Погано: ви буквально поклали секрет у лог.
// log.info("Використовується API key={}", apiKey);
// Краще: логуємо лише факт наявності секрету, а не сам секрет.
// Це допомагає діагностиці ("ключ узагалі підхопився?") і не розкриває значення.
log.info("Зовнішню інтеграцію ввімкнено, apiKeyPresent={}", apiKey != null && !apiKey.isBlank());
// ... Зовнішню інтеграцію ввімкнено, apiKeyPresent=true
}
}
Іноді хочеться залишити хоч якусь перевірку, що значення правильне. Тоді можна логувати не саме значення, а, наприклад, довжину або маску. Маска — проста техніка: показуємо останні 4 символи, а решту приховуємо. Це не срібна куля, але це на порядок безпечніше, ніж «друкуємо все».
class MaskingExample {
String mask(String value) {
// Маскуємо все, окрім "хвоста", щоб у логах не опинився повний секрет.
// Важливо: навіть замасковані значення можуть бути чутливими — використовуйте з розумом.
if (value == null || value.length() <= 4) {
return "****";
}
return "****" + value.substring(value.length() - 4);
}
}
І ще одне важливе: навіть не намагаючись логувати секрети, ви можете випадково зробити це через дамп об’єкта. Наприклад, якщо у вас є config-клас, який містить і звичайні налаштування, і секретний ключ, то toString на record роздрукує все. Тому ми знову повертаємося до ідеї: логуємо лише потрібні поля, а не об’єкт цілком.
5. Шум і частота логів
Шум у логах найчастіше з’являється не тому, що повідомлення погане за змістом, а тому, що воно з’являється занадто часто. Повідомлення може бути ідеальним, але якщо воно друкується на кожен HTTP-запит, а запитів тисяча на хвилину, ви отримаєте лавину тексту. А лавина тексту — це теж втрата інформації: ви перестаєте помічати важливі рядки, бо вони тонуть у потоці.
У catalog-service багато методів викликатимуться на запит користувача, наприклад пошук курсів і фільтрація. У таких місцях INFO-лог на кожен виклик майже завжди надмірний. Набагато здоровіше тримати такі подробиці на DEBUG і вмикати їх точково за пакетом, коли вам справді потрібна діагностика.
Приклад хорошого «однорядкового» DEBUG-логу для фільтрації — без логування кожного курсу:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class CourseSearchLogExample {
private static final Logger log = LoggerFactory.getLogger(CourseSearchLogExample.class);
void logSearch(String track, String level, boolean featuredOnly, int resultCount) {
// Одного рядка достатньо, якщо в ньому є все потрібне для пошуку та відтворення сценарію.
// Важливо: тут немає списку курсів — лише параметри та підсумковий розмір результату.
log.debug("Пошук курсів виконано, track={}, level={}, featuredOnly={}, resultCount={}",
track, level, featuredOnly, resultCount);
// ... Пошук курсів виконано, track=SPRING, level=BASIC, featuredOnly=false, resultCount=8
}
}
Є ще один тонкий момент. Параметризовані повідомлення SLF4J рятують від конкатенації рядків, але аргументи все одно обчислюються до виклику методу. Якщо заради DEBUG ви збираєте важкий рядок, серіалізуєте великий список або будуєте великий звіт, таку роботу теж варто ставити під перевірку рівня, наприклад isDebugEnabled(). Принцип простий: вимкнений DEBUG не має тихо з’їдати CPU лише заради повідомлення, яке ніхто не побачить.
6. Дублювання помилок
Дублювання — це коли одна й та сама проблема логуються кілька разів по ланцюжку викликів. Типовий сценарій: репозиторій ловить виняток і логує, сервіс ловить і логує, контролер ловить і логує. У результаті у вас три рядки з однаковим stack trace або майже однаковим, і ви почуваєтеся так, ніби стоїте в кімнаті з луною: наче сказали один раз, а почули тричі.
Щоб не бути голослівними, намалюємо, як зазвичай іде запит усередині нашого сервісу:
flowchart TD
A[HTTP-запит] --> B[Контролер]
B --> C[Сервіс]
C --> D[Репозиторій]
D --> C
C --> B
B --> E[HTTP-відповідь]
Тепер додамо погану звичку: кожен шар ловить і логує одне й те саме.
sequenceDiagram
participant Ctrl as Контролер
participant Svc as Сервіс
participant Repo as Репозиторій
Ctrl->>Svc: findBySlug(slug)
Svc->>Repo: findBySlug(slug)
Repo-->>Svc: викидає RuntimeException
Repo->>Repo: log.error(...)
Svc->>Svc: log.error(...)
Ctrl->>Ctrl: log.error(...)
Що робити замість цього? Тут важливо зрозуміти правило: логувати варто там, де ви або остаточно обробляєте помилку й далі вона не піде вище, або додаєте унікальний контекст, якого більше ніде немає. Якщо ви просто «спіймали → залогували → кинули далі те саме», ви майже гарантовано створите дублікат.
Практичний підхід для нашого рівня курсу такий: якщо ви не виконуєте обробку винятку, зазвичай краще не ловити його лише заради логування. Нехай виняток підніметься нагору, і там буде одне зрозуміле джерело. Якщо ви все ж ловите виняток, то робіть це заради сенсу: додайте контекст, загорніть у свій виняток і, що найголовніше, вирішіть, чи логуватиметься він далі ще десь.
Приклад «ловимо і додаємо контекст» — і не дублюємо безглуздо:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class SingleErrorLogExample {
private static final Logger log = LoggerFactory.getLogger(SingleErrorLogExample.class);
void loadCatalog(String importLocation) {
try {
// ... читаємо конфігурацію / імпортуємо дані
} catch (RuntimeException ex) {
// Логуємо там, де маємо унікальний контекст (наприклад, importLocation),
// і де свідомо вирішуємо: чи буде цей виняток логуватися вище.
log.error("Не вдалося завантажити каталог, importLocation={}", importLocation, ex);
throw ex;
}
}
}
Тут контекст (importLocation) справді допомагає: без нього помилка гірша. Але якщо ви знаєте, що вище по стеку цю помилку логуватимуть ще раз, тоді краще змінити стратегію: або не логувати тут, або не логувати вище. «Логуємо все всюди» — це стратегія, яка робить лог довшим, але не розумнішим.
Ще одна часта пастка — логувати як ERROR очікувані випадки. Наприклад, «курс за slug не знайдено» для API-запиту — це часто нормальна клієнтська ситуація (не той URL), а не аварія сервера. Такий кейс або не логують зовсім, або логують на DEBUG, якщо це потрібно для розслідування дивних запитів, або максимум на INFO у спеціальних сценаріях. Ставити на це ERROR — все одно що викликати пожежників щоразу, коли чайник закипів.
7. Єдиний стиль повідомлень у catalog-service
Навіть якщо кожне окреме повідомлення нормальне, логи можуть виглядати дивно, якщо в них немає спільного стилю. Один пише українською, інший — англійською; один ставить крапки, інший — ні; один пише slug=spring-boot, інший — courseSlug: spring-boot, третій — spring-boot slug. У підсумку пошук працює гірше, око чіпляється за неоднорідність, а команда витрачає час на «як у нас заведено» замість розробки.
У навчальному catalog-service зручно вибрати простий стиль: коротка англійська подія + факти у форматі key=value через {}. Чому англійська? Тому що фреймворк і більшість бібліотек уже пишуть англійською, і змішувати мови в одному потоці часто незручно. Але це не закон, а вибір; важливіше — одноманітність.
Наприклад, для стартової зведеної інформації можна тримати одну зрозумілу форму:
import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class StartupStyleExample {
private static final Logger log = LoggerFactory.getLogger(StartupStyleExample.class);
void logStartup(String[] profiles, int courses, int featured, boolean maintenanceMode) {
// Один формат для всіх середовищ: легше очима порівнювати логи між local/stage/prod.
log.info("Підсумок запуску: profiles={}, courses={}, featured={}, maintenanceMode={}",
Arrays.toString(profiles), courses, featured, maintenanceMode);
// ... Підсумок запуску: profiles=[local], courses=12, featured=3, maintenanceMode=false
}
}
Зверніть увагу, чого ми не робимо. Ми не пишемо «Я зараз у класі StartupSummaryRunner виводжу...». Назву класу й так буде видно в лог-рядку через назву логера. Ми не розтягуємо повідомлення на 6 рядків. Ми не друкуємо весь список курсів. Ми даємо маленьку «шапку», яку приємно бачити на кожному старті.
Ще одне правило читабельності: порядок ключів. Якщо ви в одному повідомленні пишете courses, featured, maintenanceMode, то в іншому не треба писати maintenanceMode, featured, courses. Це дрібниця, але вона робить пошук і сканування очима простішими. Логи — це дуже людський інтерфейс, а людський мозок любить повторювані патерни.
Це корисно не лише для поточного звичайного текстового виводу. Навіть якщо формат логу стане більш структурованим, подія та назви ключів усе одно мають залишатися впізнаваними.
8. Типові помилки під час написання корисних і безпечних логів
На завершення зафіксуємо помилки, які в реальних проєктах трапляються постійно. Тут немає «ганьби» — це нормальні граблі. Завдання — не наступати на них регулярно і не тягнути ці звички далі по траєкторії.
Помилка № 1: логувати цілі об’єкти «про всяк випадок».
На старті це здається зручним: один log.info("props={}", props) — і «все видно». Але в підсумку ви отримуєте гігантські рядки, нестабільний формат (змінили toString — змінився лог), а інколи ще й витік даних. У catalog-service краще логувати зведену інформацію: кількість курсів, featured count, прапорці режимів, активні профілі.
Помилка № 2: друкувати Environment або змінні середовища цілком.
Це типова панічна діагностика: «нічого не працює — надрукую весь світ». Так ви додаєте в логи купу сміття та потенційно секрети. Правильніше вибрати 2–3 властивості, які справді перевіряєте, наприклад server.port, активні профілі, важливі прапорці застосунку, і вивести лише їх.
Помилка № 3: перетворювати INFO на потік кожного запиту.
Постійні INFO-повідомлення в місцях, які викликаються часто, наприклад під час фільтрації списку курсів на кожен запит, майже гарантовано створюють шум. Для таких деталей є DEBUG і пакетне налаштування логування. INFO краще залишати для рідкісних і важливих подій: старту, завантаження каталогу, помітних змін режиму.
Помилка № 4: використовувати ERROR як емоцію.
ERROR — не «мені не подобається», а «застосунок має проблему, він не зміг зробити те, що зобов’язаний». Якщо ситуація підозріла, але сервіс продовжує працювати, частіше підходить WARN. Якщо це нормальний, очікуваний випадок, наприклад «не знайдено курс за slug», ERROR зазвичай не потрібний взагалі.
Помилка № 5: дублювати одну й ту саму помилку на кількох шарах.
Коли репозиторій, сервіс і контролер логують один і той самий виняток, ви отримуєте три однакові stack trace і все одно не стаєте ближчими до причини. Краще вибрати одне місце логування: там, де помилка обробляється, або там, де додається справді унікальний контекст. Решта шарів нехай або пробросить виняток, або перетворить його без додаткового ехо-логування.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ