8. Логування і маскування

На кроці 7 збій уже видно (Whoops або безпечний fallback) і його хтось повідомляє — але лише в PHP error_log. Для демо це ок. У живому додатку з’являються три інші болі, які Whoops не вирішує:

  • Немає історії. error_log Apache зникає з рестартом контейнера, погано шукається, немає рівнів і ротації. Після інциденту важко відповісти «що було о 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

Перевірка в браузері

  1. Відкрийте http://localhost:8089/contact і надішліть валідну форму — зелений alert після збереження.
  2. Відкрийте http://localhost:8089/boom — Whoops або fallback 500 (як на кроці 7); у лог потрапить ERROR.
  3. Перегляньте 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.