8. Логування і маскування
На кроці 7 збій уже видно (Whoops або безпечний
fallback) і його хтось повідомляє — але лише в PHP
error_log. Для демо це ок. У живому додатку з’являються
три інші болі, які Whoops не вирішує:
-
Немає історії.
error_logApache зникає з рестартом контейнера, погано шукається, немає рівнів і ротації. Після інциденту важко відповісти «що було о 03:12». -
Логи лише про аварії. Успішний submit форми,
підозрілий login, повільний шлях — це не exception, але їх теж
хочеться бачити. Controller не повинен писати у файл напряму
(
file_put_contents) — потрібен спільний канал. -
Лог стає витоком. Щойно ви пишете
info('…', ['email' => $email])або в exception потрапляє тіло запиту — уstorage/logs/лежать персональні дані. Том Docker часто читає вся команда; бекап логів їде в S3. Без маскування «корисно для дебагу» = «витік PII».
Тому на одному кроці з’являються дві зв’язані
capability: addLogging() (куди писати) і
addMasking() + withMasking() на Logging
(що прибрати перед записом). Окремий крок «лише masking»
без логера не має сенсу — маскувати нічого. Окремі файли
валідації/SQL — інший біль, їх свідомо лишаємо на
Діагностичні логи.
Що додається і навіщо
-
addLogging()+toRotatingFile— явний sink: без handler logger fail-fast, а не «тихий» no-op -
setLogReporting(logger: true)— exceptions з ErrorHandling ідуть у той самий Monolog, що й ручний$logger->info()(плюс поки щеphpErrorLog, щоб порівняти канали) -
LoggerInterfaceуContactController— показати бізнес-подію в логу, не лише crash -
addMasking()— правила «що вважати секретом» (ключі context і/або regex у рядках); API — Masking brick -
withMasking()на Logging — opt-in: masker не чіпає логи, доки sink сам не попросить
Спочатку канал: Logging
Без файлу немає куди писати ні exceptions, ні
info. Тому спочатку capability Logging і sink.
Рівень INFO — щоб побачити бізнес-події; на проді
часто піднімають до WARNING / ERROR,
коли шум заважає. Канал recipes з’являється в рядку
логу — зручно, коли кілька додатків пишуть у спільне сховище.
ErrorHandling на кроці 7 уже викликав
setLogReporting(logger: false, …). Тут лише
перемикаємо logger: true: Whoops і fallback не
змінюються — змінюється місце, куди йде report. Повний API —
Logging brick.
$stack->addLogging()
->setLevel('INFO')
->setChannel('recipes')
->toRotatingFile($rootPath . 'storage/logs/app.log', 7)
->withMasking(); // після addMasking() — див. нижче
$stack->addErrorHandling()
->setDebug($debug, new PrettyPageHandler())
->setLogReporting(logger: true, phpErrorLog: true)
->withViewRenderer($rootPath . 'resources/views/errors/fallback');
Шлях до логу — literal, як views/sessions. Docker монтує проєкт,
тож storage/logs/app-*.log видно на хості без копіювання
з контейнера.
Бізнес-подія в логу
Якщо логер є лише для exceptions — ви ніколи не побачите «форма
пройшла, але лист не пішов». Після успішного insert controller
пише info. Context навмисно містить
email: без masking у файлі буде
{"email":"ada@example.com"} — саме той витік, який
ми зараз закриваємо. Так читач бачить різницю до/після,
а не вірить абзацу «маскуйте PII».
public function __construct(
private readonly ViewResponseFactoryInterface $view,
private readonly DatabaseInterface $database,
private readonly LoggerInterface $logger,
) {}
// у submit() після insert:
$this->logger->info('Contact form submitted', [
'email' => $validated['email'],
]);
Навіщо masking одразу, не «потім»
Спокуса відкласти masking: «спочатку навчимось писати логи».
На практиці перший же корисний info уже тягне
персональні поля — інакше в логу немає ідентифікатора користувача.
Якщо masking з’явиться через тиждень, тиждень логів уже
скомпрометовані (і часто вже з’їхали в бекап).
Concept не маскує «все підряд автоматично»: немає списку глобальних секретів у ядрі. Ви явно кажете, які ключі й які фрагменти рядка небезпечні. Це навмисно: чужий «розумний» masker або ховає занадто багато (немає чим дебажити), або замало (витік лишається).
withMasking() на Logging — окремий opt-in саме тому:
capability Masking може знадобитися й іншим sinks (query log на
наступному кроці), але app-логер підключає її лише коли ви
готові. Без withMasking() правила з
addMasking() просто не застосовуються до цього файлу.
Два способи ховати: ключ і рядок
Data Masker дивиться на дані двома шляхами — і обидва потрібні, бо секрети потрапляють у логи по-різному.
Key patterns (setKeyPatterns) —
коли context / масив уже структурований:
['email' => 'ada@…', 'password' => '…'].
Якщо ім’я ключа матчить regex, ціле значення
замінюється на ***. Не треба вгадувати формат email
чи пароля — достатньо домовитись про імена полів у вашому коді.
У рецепті /^email$/i ловить саме наш
info-context; /.*password.*/i і
/.*token.*/i — типовий запас на flash/debug context,
навіть якщо зараз їх ще немає.
String patterns (setPatterns) —
коли секрет уже «розчинився» в тексті повідомлення або в
trace: "User ada@example.com failed",
рядок з номером картки, password=secret у query string.
Тут regex шукає фрагмент і підставляє заміну
(у нас email → ***@***.***). Без цього key patterns
безсилі: у рядку немає ключа email.
Коли що обирати: структурований context від вашого коду → спочатку key patterns (простіше й надійніше). Логи від третіх бібліотек / exception message → додайте string patterns. Часто потрібні обидва.
$stack->addMasking()
->setPatterns([
// фрагмент у message / trace
'/[a-z0-9_\-\+\.]+@[a-z0-9\-]+\.[a-z]{2,}/i' => '***@***.***',
])
->setKeyPatterns([
// ціле значення в context за ім’ям ключа
'/^email$/i',
'/.*password.*/i',
'/.*token.*/i',
'/.*secret.*/i',
]);
Masking у Bootstrap стоїть перед Logging у файлі — щоб
читач одразу бачив: спочатку правила, потім sink, який їх просить.
Топологія require() на providers() і так
поставить залежності правильно; порядок у рецепті — для людей.
Повний фрагмент Bootstrap
Разом: правила → канал з withMasking() → ErrorHandling,
який репортить у цей канал. Приберіть withMasking()
і той самий POST знову залишить відкритий email у файлі — швидкий
спосіб перевірити, що маскує саме цей ланцюг, а не «магія Monolog».
$stack->addMasking()
->setPatterns([
'/[a-z0-9_\-\+\.]+@[a-z0-9\-]+\.[a-z]{2,}/i' => '***@***.***',
])
->setKeyPatterns([
'/^email$/i',
'/.*password.*/i',
'/.*token.*/i',
'/.*secret.*/i',
]);
$stack->addLogging()
->setLevel('INFO')
->setChannel('recipes')
->toRotatingFile($rootPath . 'storage/logs/app.log', 7)
->withMasking();
$stack->addErrorHandling()
->setDebug($debug, new PrettyPageHandler())
->setLogReporting(logger: true, phpErrorLog: true)
->withViewRenderer($rootPath . 'resources/views/errors/fallback');
Запуск і перевірка
Тег step-08-logging. Мета — переконатися, що email
не потрапив у відкритому вигляді в storage/logs/app-*.log.
CSRF/cookie — як на кроках 5 і 7.
Запуск
git checkout step-08-logging
rm -rf storage/cache storage/sessions storage/logs
composer install
cp docker-compose.yml.example docker-compose.yml
docker compose up -d --build
Перевірка в браузері
- Відкрийте http://localhost:8089/contact і надішліть валідну форму — зелений alert після збереження.
- Відкрийте http://localhost:8089/boom — Whoops або fallback 500 (як на кроці 7); у лог потрапить ERROR.
-
Перегляньте
storage/logs/app-*.logу терміналі — email має бути***, не відкритий рядок.
Перевірка з CLI
COOKIE=/tmp/c.txt
curl -s -c $COOKIE http://localhost:8089/contact >/dev/null
TOKEN=$(curl -s -b $COOKIE http://localhost:8089/contact \
| sed -n 's/.*name="_csrf_token" value="\([^"]*\)".*/\1/p')
curl -s -b $COOKIE -c $COOKIE -X POST http://localhost:8089/contact \
-d "name=Ada Lovelace&email=ada@example.com&message=Hello from logging&_csrf_token=$TOKEN"
tail -n 5 storage/logs/app-*.log
# … recipes.INFO: Contact form submitted {"email":"***"}
# НЕ ada@example.com
# без withMasking() було б: {"email":"ada@example.com"}
curl -s -o /dev/null http://localhost:8089/boom
tail -n 3 storage/logs/app-*.log
# ERROR теж проходить через той самий masker (message/trace)
Ланцюжок
POST /contact (valid)
→ insert DB
→ LoggerInterface::info('Contact form submitted', [email => ada@…])
→ Monolog processor: DataMasker
· key «email» ↔ /^email$/i → значення «***»
· (опційно) regex у message, якщо email там з’явиться
→ RotatingFileHandler → storage/logs/app-YYYY-MM-DD.log
GET /boom
→ RuntimeException
→ ExceptionReporter → той самий Logger (+ php error_log)
→ Whoops або fallback/500.php
Далі — 9. Діагностичні логи:
коли app.log уже безпечний, але треба окремо бачити
«чому formal failed» і «який SQL пішов» —
withLogging() / withQueryLogging()
на своїх bricks (за потреби знову з withMasking()).
Усі кроки — Огляд.
Brick — Logging.