Питання на співбесіді: Помилки й логування
Питання з реальних співбесід з відповідями: 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/jsonLaravel повертає{"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: великі об'єкти й моделі - контекст серіалізується з кожним завданням і пишеться в кожен рядок логу. Ідентифікатори, а не сутності.
Не кожна помилка має зупиняти запит. Якщо не вдалося оновити рекомендації чи відправити аналітику, користувач усе одно має отримати сторінку - але розробник має про це дізнатися.
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 - майже завжди баг.
Сценарій: зовнішній 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 для каналів