Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Помилки й логування

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

9 питань

Коли під час запиту виникає виняток і ніхто його не перехопив, Laravel передає його обробнику винятків. Обробник робить дві незалежні речі:

Крок Що відбувається Для кого
report (звітування) запис у лог, відправка в Sentry, Flare, Bugsnag для розробників
render (рендеринг) перетворення винятку на HTTP-відповідь для користувача
виняток → report: laravel.log, Sentry
        → render: сторінка 500, JSON {"message": "..."}, редірект з помилками валідації

Чому це розділено: те, що бачить користувач, і те, що бачить розробник, мають бути різними. Користувачу - зрозуміле повідомлення без деталей, розробнику - повний стек викликів, контекст запиту, користувач.

Як Laravel рендерить різні винятки:

  • ValidationException - редірект назад з помилками й старим введенням, або 422 з JSON для API;
  • AuthenticationException - редірект на сторінку входу чи 401;
  • AuthorizationException - 403;
  • ModelNotFoundException (від findOrFail) - 404;
  • HttpException (від abort(404)) - відповідний код;
  • будь-що інше - 500.

Деякі винятки взагалі не звітуються: валідація, 404, помилки автентифікації - це очікувані ситуації, а не баги. Інакше лог заповнився б шумом.

JSON чи HTML: якщо запит очікує JSON (заголовок Accept: application/json), Laravel повертає помилку у форматі JSON.

APP_DEBUG:

  • true - сторінка помилки зі стеком викликів, кодом і змінними - лише для локальної розробки;
  • false - загальна сторінка «Server Error» без деталей. На продакшені тільки так.

Налаштовується все в bootstrap/app.php:

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->report(function (PaymentFailed $e) {
        // власне звітування
    });

    $exceptions->render(function (PaymentFailed $e, Request $request) {
        return response()->view('errors.payment', status: 402);
    });
})

Докладніше в документації: Помилки: обробка винятків

abort() кидає HttpException з указаним кодом - обробник винятків перетворює його на відповідь з цим статусом:

abort(404);
abort(403, 'Цей проєкт архівовано.');

abort_if(! $user->isAdmin(), 403);
abort_unless($post->isPublished(), 404);

Після abort() код далі не виконується - це виняток, а не return.

Коли що використовувати:

  • 404 - ресурсу немає чи користувачу не можна знати, що він існує;
  • 403 - ресурс є, але доступ заборонено. Для перевірки прав краще політики ($this->authorize(), Gate) - вони кидають той самий 403, але логіка прав зібрана в одному місці;
  • findOrFail() замість ручного abort(404) після find() - коротше й без забутої перевірки.

Власні сторінки помилок - просто Blade-шаблони за кодом статусу:

resources/views/errors/404.blade.php
resources/views/errors/403.blade.php
resources/views/errors/500.blade.php
resources/views/errors/503.blade.php   # режим обслуговування

Усередині доступний виняток:

<h1>{{ $exception->getMessage() ?: 'Сторінку не знайдено' }}</h1>

Запасні сторінки для групи кодів: 4xx.blade.php і 5xx.blade.php використовуються, якщо немає шаблону для конкретного коду.

Стандартні шаблони Laravel можна скопіювати як основу:

php artisan vendor:publish --tag=laravel-errors

Типові пастки:

  • сторінка 500 не повинна залежати від того, що могло зламатися: запитів до бази, сесії, складних компонентів. Якщо помилку спричинила база, сторінка помилки, що звертається до бази, впаде теж;
  • повідомлення в abort(403, '...') бачить користувач - жодних технічних деталей;
  • для API шаблони не використовуються: при Accept: application/json Laravel повертає {"message": "..."} з відповідним статусом.

Докладніше в документації: Помилки: власні сторінки HTTP-помилок

Laravel використовує Monolog, а писати в лог можна через фасад Log чи хелпер logger():

use Illuminate\Support\Facades\Log;

Log::info('Замовлення оформлено', ['order_id' => $order->id]);
Log::warning('Платіжний шлюз відповідає повільно', ['ms' => $elapsed]);
Log::error('Не вдалося надіслати рахунок', ['order_id' => $order->id]);

logger('Коротке налагоджувальне повідомлення');   // рівень debug

Рівні логування (за RFC 5424, від найважливішого):

Рівень Коли використовувати
emergency система непрацездатна
alert потрібна негайна дія (база недоступна)
critical критична помилка компонента
error помилка, що не зупиняє застосунок, але потребує уваги
warning щось незвичне, але не помилка (повторна спроба, повільна відповідь)
notice нормальна, але важлива подія
info звичайні події: вхід користувача, оформлення замовлення
debug подробиці для налагодження

Мінімальний рівень задається для каналу (змінна LOG_LEVEL). На продакшені зазвичай info чи warning: повідомлення debug просто відкидаються, не засмічуючи лог.

Другий аргумент - контекст: масив з даними, а не склеєний рядок.

// погано: неможливо шукати й фільтрувати
Log::info("Order {$order->id} paid by {$user->email}");

// добре: структуровані дані
Log::info('Order paid', ['order_id' => $order->id, 'user_id' => $user->id]);

Структуровані дані легко шукати в системах збору логів, і повідомлення лишається однаковим для всіх подій цього типу.

Куди пишеться: за замовчуванням у storage/logs/laravel.log (канал stack з single). Канали налаштовуються в config/logging.php.

Чого не писати в лог: паролі, токени, повні номери карток, персональні дані понад необхідне. Логи часто мають ширший доступ, ніж база даних, і зберігаються довше.

Не плутати з dd() і dump(): вони виводять у відповідь і для продакшену не підходять, а лог працює і в черзі, і в консольних командах, і без браузера.

Докладніше в документації: Логування: запис повідомлень

У Laravel 11+ обробник винятків налаштовується не в класі Handler, а в bootstrap/app.php через withExceptions:

use Illuminate\Foundation\Configuration\Exceptions;
use Psr\Log\LogLevel;

->withExceptions(function (Exceptions $exceptions): void {
    // власне звітування для конкретного типу
    $exceptions->report(function (InvalidOrderException $e) {
        Notification::route('slack', config('services.slack.ops'))->notify(new OrderAlert($e));
    });

    // не звітувати зовсім
    $exceptions->dontReport([
        CardDeclinedException::class,
    ]);

    // інший рівень логування для типу
    $exceptions->level(PDOException::class, LogLevel::CRITICAL);

    // додатковий контекст до кожного звіту
    $exceptions->context(fn () => [
        'tenant_id' => tenant()?->id,
    ]);
})

Важлива деталь report(): колбек виконується на додачу до стандартного логування. Щоб стандартне звітування не відбувалося, колбек має повернути false або ланцюжок має закінчуватися ->stop():

$exceptions->report(function (InvalidOrderException $e) {
    // ...
})->stop();

Тип винятку визначається з type hint параметра колбека - окремо вказувати клас не потрібно.

Інші способи не звітувати:

  • інтерфейс ShouldntReport на класі винятку - позначка прямо в класі;
  • dontReportWhen(fn (Throwable $e) => ...) - умова за вмістом винятку;
  • dontReportDuplicates() - той самий екземпляр винятку, переданий у report() кілька разів, звітується лише раз.

Контекст на рівні винятку: метод context() у самому класі винятку додає його дані до запису в логу:

class InvalidOrderException extends Exception
{
    public function __construct(private int $orderId) { parent::__construct('Invalid order'); }

    public function context(): array
    {
        return ['order_id' => $this->orderId];
    }
}

Laravel 13: dontRetry - список винятків, при яких завдання в черзі не повторюється, навіть якщо спроби ще лишилися. Наприклад, видалений клієнт у стороннього API: повтор нічого не змінить.

Практика: тримати в dontReport лише очікувані бізнес-ситуації. Помилка, яку ніхто не бачить, - це баг, про який ніхто не знає.

Докладніше в документації: Помилки: звітування про винятки

Замість реєструвати колбеки в bootstrap/app.php можна описати поведінку прямо в класі винятку - методами report() і render(). Обробник Laravel викличе їх сам.

namespace App\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

final class InsufficientBalanceException extends Exception
{
    public function __construct(
        public readonly int $required,
        public readonly int $available,
    ) {
        parent::__construct('Недостатньо коштів на балансі.');
    }

    public function report(): bool
    {
        // очікувана бізнес-ситуація, звітувати не потрібно
        return true;
    }

    public function render(Request $request): Response|JsonResponse
    {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => $this->getMessage(),
                'required' => $this->required,
                'available' => $this->available,
            ], 422);
        }

        return back()->withErrors(['balance' => $this->getMessage()]);
    }
}

Як працюють значення, що повертаються:

Метод Повертає Результат
report() false стандартне логування виконується
report() true чи нічого (void) виняток вважається обробленим, стандартного логування немає
render() відповідь її отримає користувач
render() false стандартний рендеринг Laravel

Навіщо власні винятки:

  • доменна мова: InsufficientBalanceException у коді й логах зрозуміліше, ніж RuntimeException('balance');
  • дані в полях: скільки потрібно, скільки є - а не розбір тексту повідомлення;
  • точкові catch: викликаючий код ловить саме цей випадок, а несподівані помилки летять далі;
  • одне місце для того, як ця ситуація виглядає для користувача.

Коли краще колбеки в bootstrap/app.php: для чужих винятків (з пакетів чи фреймворку), до класу яких немає доступу, і для спільної політики - наприклад, усі помилки API в одному форматі.

Порада щодо ієрархії: базовий виняток модуля (BillingException) і конкретні нащадки - тоді можна зловити все, що стосується модуля, одним catch, а звітування налаштувати для базового класу.

Докладніше в документації: Помилки: винятки з власним рендерингом

Context - сховище даних про поточний запит, завдання чи команду, яке Laravel автоматично додає до кожного запису в лог і передає в завдання черги, що були поставлені в цьому запиті.

use Illuminate\Support\Facades\Context;

// middleware
public function handle(Request $request, Closure $next): Response
{
    Context::add('url', $request->url());
    Context::add('trace_id', (string) Str::uuid());

    return $next($request);
}

Тепер будь-який запис у лог під час цього запиту містить ці дані:

Log::info('Користувача автентифіковано', ['auth_id' => Auth::id()]);
local.INFO: Користувача автентифіковано {"auth_id":27} {"url":"https://example.com/login","trace_id":"e04e1a11-..."}

Передача в черги - головна перевага. Завдання, поставлене в черзу під час запиту, отримує той самий контекст, і його логи містять той самий trace_id. Ланцюжок «запит → завдання → вкладене завдання» можна відстежити одним пошуком у логах.

Основні методи:

Context::add('key', 'value');             // перезаписати
Context::addIf('key', 'value');           // лише якщо ще немає
Context::push('breadcrumbs', 'first');    // стек значень
Context::get('key');
Context::has('key');
Context::forget('key');

Context::scope(function () {
    // тимчасовий контекст лише для цього блоку
}, ['import_id' => $import->id]);

Прихований контекст - передається в черги, але не пишеться в лог:

Context::addHidden('api_token', $token);
Context::getHidden('api_token');

Події dehydrating і hydrated дозволяють вирішувати, що відбувається при передачі в чергу: наприклад, зберегти локаль запиту й відновити її в завданні.

Чим це відрізняється від Log::withContext():

Log::withContext Context
потрапляє в лог так так (крім прихованого)
передається в черги ні так
можна прочитати в коді ні так (Context::get)

Що не класти в Context: великі об'єкти й моделі - контекст серіалізується з кожним завданням і пишеться в кожен рядок логу. Ідентифікатори, а не сутності.

Докладніше в документації: Context: як це працює

Не кожна помилка має зупиняти запит. Якщо не вдалося оновити рекомендації чи відправити аналітику, користувач усе одно має отримати сторінку - але розробник має про це дізнатися.

report() - передати виняток обробнику для звітування (лог, Sentry) і продовжити виконання:

public function isValid(string $value): bool
{
    try {
        // перевірка, що може кинути виняток
    } catch (Throwable $e) {
        report($e);

        return false;
    }
}

Без report() виняток у catch просто зникає - це «проковтування» помилки, яке роками ховає баги.

rescue() - виконати замикання, а при винятку звітувати й повернути значення за замовчуванням:

$recommendations = rescue(
    fn () => $this->recommender->for($user),
    [],                 // значення при помилці
);

// без звітування - третій аргумент
$preview = rescue(fn () => $this->renderPreview($post), null, report: false);

Значенням за замовчуванням може бути й замикання - воно виконається лише при помилці.

report_if() / report_unless():

report_if($response->failed(), new PaymentGatewayException($response->body()));

Дублікати: якщо той самий виняток передали в report() кілька разів (у сервісі й ще раз у контролері), у лозі з'являться дублікати. $exceptions->dontReportDuplicates() у bootstrap/app.php звітує кожен екземпляр лише раз.

Коли rescue - погана ідея:

  • помилки, від яких залежить коректність: оплата, збереження замовлення, перевірка прав. Тиха заміна на значення за замовчуванням тут перетворює помилку на неправильні дані;
  • широкий блок коду: rescue() навколо половини методу ховає і очікувані, і зовсім несподівані збої. Загортайте лише конкретну необов'язкову операцію;
  • без звітування (report: false) - лише коли помилка справді очікувана і не цікава.

Правило: якщо помилку ловите, то або обробляєте осмислено, або звітуєте. Порожній catch - майже завжди баг.

Докладніше в документації: Помилки: хелпер report

Сценарій: зовнішній API ліг, і кожен запит, що до нього звертається, кидає той самий виняток. За хвилину - десятки тисяч однакових звітів: лог росте на гігабайти, Sentry вичерпує квоту за годину, а справжні нові помилки тонуть у шумі.

throttle() у bootstrap/app.php - вибіркове звітування:

use Illuminate\Support\Lottery;
use Illuminate\Cache\RateLimiting\Limit;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->throttle(function (Throwable $e) {
        // кожен 1000-й звіт про часті помилки
        if ($e instanceof ApiMonitoringException) {
            return Lottery::odds(1, 1000);
        }

        // не більше 300 звітів на хвилину для збоїв зовнішніх сервісів
        if ($e instanceof BroadcastException) {
            return Limit::perMinute(300);
        }

        // ліміт окремо для кожного типу винятку
        return Limit::perMinute(300)->by($e::class);
    });
})

Два режими:

Lottery Limit
що робить звітує випадкову частку звітує не більше N за період
стан не потрібен лічильник у кеші (потрібен спільний драйвер, наприклад Redis)
для чого дуже часті, неважливі за кількістю сплески, при яких важливо бачити кожен перший випадок

by() визначає, що вважається «тим самим»: за класом винятку, за повідомленням, за ідентифікатором тенанта. Без by() усі винятки ділять один спільний ліміт.

Чого throttle не замінює:

  • circuit breaker - якщо сервіс лежить, не варто взагалі звертатися до нього тисячі разів: це і зайве навантаження, і повільні запити користувачів;
  • агрегацію в Sentry чи Flare: вони групують однакові помилки, але кожна подія все одно рахується в квоту - тому обмеження на боці застосунку економить гроші;
  • сповіщення: про те, що помилка стала частою, мають сповіщати метрики й моніторинг, а не кількість записів у лозі.

Ще кілька захистів від шуму:

  • dontReportDuplicates() - один виняток, переданий у report() кілька разів, звітується раз;
  • рівні: level(ConnectException::class, LogLevel::WARNING) - збій зовнішнього сервісу не повинен виглядати як error застосунку;
  • ротація логів (канал daily з days) і збір логів у централізоване сховище замість файлу на диску.

Перевірка: зробити штучний сплеск у staging і подивитися, скільки записів реально пішло в лог і в Sentry.

Докладніше в документації: Помилки: обмеження частоти звітів

Файл storage/logs/laravel.log зручний локально, але на продакшені з кількома серверами чи контейнерами логи мають збиратися централізовано (Loki, ELK, CloudWatch, Datadog), і читати їх буде не людина в tail, а система пошуку.

1. Канал у stderr для контейнерів:

// config/logging.php
'stderr' => [
    'driver' => 'monolog',
    'level' => env('LOG_LEVEL', 'info'),
    'handler' => StreamHandler::class,
    'handler_with' => ['stream' => 'php://stderr'],
    'formatter' => env('LOG_STDERR_FORMATTER'),
    'processors' => [PsrLogMessageProcessor::class],
],
LOG_CHANNEL=stderr
LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter

Docker збирає stderr контейнера, а JSON - по одному об'єкту на рядок - збирачі розбирають без регулярних виразів: рівень, повідомлення, контекст стають полями для фільтрації.

2. Процесори Monolog - додають дані до кожного запису:

'processors' => [
    PsrLogMessageProcessor::class,   // підставляє {placeholders} з контексту в повідомлення
    WebProcessor::class,             // url, метод, ip
    MemoryUsageProcessor::class,
],

3. tap - довільне налаштування логера каналу класом:

'stack' => [
    'driver' => 'stack',
    'tap' => [App\Logging\AddHostname::class],
    'channels' => ['stderr'],
],
final class AddHostname
{
    public function __invoke(Logger $logger): void
    {
        foreach ($logger->getHandlers() as $handler) {
            $handler->pushProcessor(function (LogRecord $record): LogRecord {
                return $record->with(extra: [...$record->extra, 'host' => gethostname()]);
            });
        }
    }
}

4. Стек каналів - різні рівні в різні місця:

'stack' => ['driver' => 'stack', 'channels' => ['stderr', 'slack'], 'ignore_exceptions' => false],
'slack' => ['driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'level' => 'critical'],

Що ще важливо:

  • кореляція: Context::add('trace_id', ...) у middleware - і всі записи запиту та його завдань у черзі пов'язані одним ідентифікатором;
  • канал deprecations - окремо від основного логу, щоб попередження про застарілий код не тонули й не засмічували його;
  • рівень на продакшені - info чи warning; debug швидко генерує гігабайти;
  • локальні файли без ротації на сервері рано чи пізно заповнять диск - daily з days або зовнішній збирач;
  • логи - не сповіщення: про критичні помилки має повідомляти система моніторингу помилок, а лог - для розслідування.

Докладніше в документації: Логування: налаштування Monolog для каналів