- Created migration to set up app_log table for storing application logs. - Added AppLog entity and repository for ORM handling of logs. - Developed DbLogger service to persist logs of level WARNING and above to the database while maintaining existing logging behavior. - Implemented tests for admin log retrieval and DbLogger functionality to ensure proper logging behavior. - Enhanced logging context sanitization for better error tracking.
14 KiB
لاگینگ سراسری پروژه + نمایش لاگها در پنل ادمین
پروژه
clinicpro (backend Symfony + پنل ادمین React). تک-ریپو، cross-repo نیست.
زمینه
الان پروژه monolog ندارد (در composer.lock فقط بهعنوان suggestion آمده، نصب نیست). در نتیجه Psr\Log\LoggerInterface به logger مینیمال Symfony (Symfony\Component\HttpKernel\Log\Logger) بایند میشود که فقط رشتهی message را به stderr مینویسد. روی Liara این یعنی لاگها فقط در liara logs دیده میشوند و هیچجا persist نمیشوند.
دو مشکل:
- از ۲۰۴ فایل
src/، فقط ۵ فایل اصلاً لاگ میزنند (PasswordAuthenticator,PreRegistrationController,ApiIrService,ExceptionSubscriber,PatientController). خیلی از catch blockها استثناء را بیصدا میخورند (مثلاًKavehNegarProvider::send()کهcatch (\Throwable) { return false; }). وقتی روی prod چیزی میشکند، رد قابلردیابی نمیماند. - ادمین هیچ راهی برای دیدن لاگها از داخل پنل ندارد؛ باید به shell سرور دسترسی داشته باشد.
نمونهی استانداردِ خوب که تازه در ExceptionSubscriber نوشته شده و باید الگوی کل پروژه شود (کلاس/پیام/محل در خودِ message تا روی پلتفرمهایی با logger پیشفرض هم دیده شود):
// src/Shared/EventSubscriber/ExceptionSubscriber.php (وضعیت فعلی، الگوی مرجع)
$this->logger->error(sprintf(
'Unhandled exception: %s: %s @ %s:%d [path=%s]',
$exception::class,
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
$event->getRequest()->getPathInfo(),
), [
'exception' => $exception,
]);
هدف
- یک استاندارد لاگینگ در کل backend: همهی نقاط مهم (یکپارچهسازیهای بیرونی، catchهای بیصدا، گذارهای حالت مهم) با فرمت پیام غنیِ بالا لاگ بزنند.
- لاگها علاوه بر stderr، در دیتابیس هم persist شوند (سطح
warningبه بالا) تا قابلکوئری باشند. - یک صفحهی ادمین برای دیدن/فیلتر لاگها (دقیقاً مثل الگوی موجود
SmsLog+SmsPage).
فایلهای مرتبط
| فایل | نقش |
|---|---|
config/services.yaml |
بایند کردن decorator لاگر |
src/Shared/Logging/DbLogger.php (جدید) |
decorator روی سرویس logger؛ forward به stderr + persist در DB |
src/Shared/Logging/AppLog.php (جدید) |
Entity جدول لاگ |
src/Shared/Logging/AppLogRepository.php (جدید) |
کوئری لیست برای ادمین (DQL array hydration) |
migrations/VersionXXChangeLog.php (جدید) |
ساخت جدول app_log |
src/Admin/Controller/AdminApiController.php |
افزودن endpoint GET /api/v1/admin/logs |
assets/admin/pages/LogsPage.tsx (جدید) |
صفحهی نمایش لاگ |
assets/admin/App.tsx |
افزودن route |
assets/admin/components/layout/Sidebar.tsx |
افزودن آیتم منو |
assets/admin/lib/api.ts + types/index.ts |
تابع fetch + تایپ |
docs/api/admin.md |
مستند endpoint جدید |
src/Sms/Provider/KavehNegarProvider.php, RanginehProvider.php, src/Payment/*, src/Shared/Service/ApiIrService.php |
نمونه نقاطی که باید لاگ اضافه شود |
الگوی مرجع برای جدول DB + صفحه ادمین:
src/Sms/Entity/SmsLog.phpوassets/admin/pages/SmsPage.tsx(همین حالا وجود دارند — از همان ساختار کپی کن).
وضعیت فعلی (نمونه catch بیصدا)
// src/Sms/Provider/KavehNegarProvider.php — خطا بیصدا خورده میشود
public function send(string $mobile, string $message): bool
{
try {
$resp = $this->httpClient->request('POST', self::BASE . '/' . $this->key() . '/sms/send.json', [...]);
$data = $resp->toArray();
return ($data['return']['status'] ?? 0) === 200;
} catch (\Throwable) {
return false; // ← هیچ ردی نمیماند
}
}
وظایف
۱. ساخت Entity جدول لاگ — src/Shared/Logging/AppLog.php
از الگوی SmsLog پیروی کن. ستونها:
| ستون | نوع | توضیح |
|---|---|---|
id |
int, auto | |
level |
string(16) | error / warning / critical / ... (PSR-3 level) |
message |
text | پیام غنی |
context |
text/json, nullable | json_encode شدهی context (بدون آبجکت exception خام؛ فقط trace کوتاه) |
channel |
string(32), nullable | کانال PSR (پیشفرض app) |
path |
string(255), nullable | مسیر request اگر در حال سرو بود |
createdAt |
int (unix timestamp) | حتماً Unix timestamp صحیح، نه DateTime object (الگوی کل پروژه) |
سپس doctrine:migrations:diff برای ساخت migration. (Entity تغییر کرد ⇒ migration لازم است.)
۲. Decorator لاگر — src/Shared/Logging/DbLogger.php
سرویس مینیمال logger را decorate کن تا همهی تزریقهای موجودِ LoggerInterface خودکار persist شوند. PSR-3 را پیاده کن (یا AbstractLogger را extend کن):
namespace App\Shared\Logging;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\RequestStack;
final class DbLogger implements LoggerInterface
{
// سطوحی که در DB ذخیره میشوند (info/debug فقط stderr).
private const PERSIST = [LogLevel::WARNING, LogLevel::ERROR, LogLevel::CRITICAL, LogLevel::ALERT, LogLevel::EMERGENCY];
public function __construct(
private readonly LoggerInterface $inner, // سرویس اصلی Symfony (stderr) — decorates: logger
private readonly Connection $conn, // DBAL خام، مستقل از EntityManager/transaction درخواست
private readonly RequestStack $requestStack,
) {}
public function log($level, \Stringable|string $message, array $context = []): void
{
$this->inner->log($level, $message, $context); // همیشه stderr (برای liara logs)
if (!in_array((string) $level, self::PERSIST, true)) {
return;
}
try {
$this->conn->insert('app_log', [
'level' => (string) $level,
'message' => (string) $message,
'context' => $context ? json_encode($this->sanitize($context), JSON_UNESCAPED_UNICODE) : null,
'channel' => 'app',
'path' => $this->requestStack->getCurrentRequest()?->getPathInfo(),
'created_at' => time(),
]);
} catch (\Throwable) {
// لاگکردن هرگز نباید خود درخواست را بشکند.
}
}
// emergency()/alert()/.../debug() همگی به log() فوروارد شوند.
private function sanitize(array $ctx): array
{
// آبجکت exception خام را به رشتهی کوتاه تبدیل کن (نه کل trace حجیم).
if (isset($ctx['exception']) && $ctx['exception'] instanceof \Throwable) {
$e = $ctx['exception'];
$ctx['exception'] = sprintf('%s: %s @ %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine());
}
return $ctx;
}
}
بایند در config/services.yaml:
App\Shared\Logging\DbLogger:
decorates: 'logger'
arguments:
$inner: '@.inner'
$conn: '@doctrine.dbal.default_connection'
نکات مهم decorator:
- از DBAL خام (
Connection::insert) استفاده کن، نهEntityManager. اگر درخواست داخل transaction شکستخورده باشد، نوشتن با EM هم میشکند (همان دام savepoint که قبلاً دیدیم). یک INSERT مستقل با connection خام مطمئنتر است. - اگر INSERT داخل transaction باز و rollbackشده گیر کرد، باز هم
try/catchجلوی شکستن درخواست را میگیرد. (اگر لازم شد، میتوان از یک connection ثانویه استفاده کرد — ولی اول همین ساده را پیاده کن.) - روی Liara fs فقط-خواندنی است؛ این طراحی به فایل وابسته نیست (DB + stderr) ⇒ سازگار.
۳. استانداردِ لاگ در نقاط مهم (کل backend)
فرمت پیام همهجا مطابق الگوی مرجع: sprintf('<عنوان>: %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()) + ['exception' => $e] در context.
نقاط حداقلی که باید لاگ اضافه شود:
- همهی catchهای بیصدا:
KavehNegarProvider::send(),RanginehProvider::send()و هرcatch (\Throwable) { return false/null; }دیگر:} catch (\Throwable $e) { $this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]); return false; } - یکپارچهسازیهای بیرونی:
src/Payment/*(درگاه ملت/سپ — نتیجهی verify/callback)،src/Shared/Service/ApiIrService.php(Shahkar/Iban — قبلاً LoggerInterface دارد، فقط پوشش را کامل کن). - گذارهای حالت مهم: تغییر وضعیت پرداخت، تغییر وضعیت نوبت (
Appointment status), تسویه (Settlement). سطحinfoبرای موفق،warning/errorبرای شکست. - جایی که AppException دامنهای throw میشود، لاگ تکراری نزن —
ExceptionSubscriberمتمرکز هندل میکند. فقط استثناهای غیرمنتظره/خوردهشده را لاگ کن.
برای پیدا کردن همهی catchهای بیصدا:
grep -rn "catch (\\\\Throwable)" src/وgrep -rn "catch (.*Exception .*e) {$" src/
۴. Endpoint ادمین — GET /api/v1/admin/logs
در AdminApiController (که از BaseController ارث میبرد و #[IsGranted('ROLE_ADMIN')] دارد). الگوی دقیقاً مثل بقیهی listهای admin:
- query params:
page(پیشفرض ۱)،limit(پیشفرض ۱۵)،level(فیلتر اختیاری)،search(روی message)،from/to(unix ts اختیاری). - با
EntityManager::createQueryBuilder()رویAppLogو->getArrayResult()(هرگز getter موجودیت در listهای admin). - خروجی با
$this->paginated($items, $total, $page, $limit).
#[OA\Get(path: '/api/v1/admin/logs', summary: 'List application logs (paginated)', security: [['bearerAuth' => []]], ...)]
#[Route('/api/v1/admin/logs', methods: ['GET'])]
public function listLogs(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$level = $request->query->get('level');
// ... QueryBuilder + فیلترها + getArrayResult()
return $this->paginated($items, $total, $page, $limit);
}
۵. صفحهی ادمین — assets/admin/pages/LogsPage.tsx
از SmsPage.tsx کپی کن (همان useQuery + <DataTable> + <Pagination>).
- query key:
['admin-logs', page, level, search]. - نوع پاسخ:
PaginatedResponse<AppLog>— items ازdata?.data، total ازdata?.meta?.totalRecords. - ستونها: زمان (با
formatDateTime()شمسی ازlib/utils)، سطح (با<StatusBadge>رنگی: error قرمز، warning زرد)، پیام، path. context را در یک modal/expand نشان بده. - فیلترها: dropdown سطح + input جستجو.
- تایپ
AppLogرا درtypes/index.tsاضافه کن؛ تابع fetch را درlib/api.ts. - route در
App.tsx:<Route path="logs" element={<RoleRoute roles={['admin']}><LogsPage /></RoleRoute>} />و آیتم منو درSidebar.tsx(فقط admin).
۶. مستندات
docs/api/admin.md را با endpoint جدید GET /api/v1/admin/logs (params، شکل پاسخ paginated، سطوح) بهروز کن. (قانون standing پروژه: تغییر API ⇒ بهروزرسانی docs/api/ در همان session.)
نکات مهم
- createdAt حتماً Unix timestamp (int) — نه DateTime؛ الگوی کل entityهای پروژه.
- listهای admin فقط
getArrayResult()— استفاده از getter موجودیت در listها خطا میدهد. - پاسخ paginated در فرانت: items از
data?.data، total ازdata?.meta?.totalRecords(دو-سطحی نیست). - JWT از
localStorage['clinicpro-auth']خوانده میشود (خودکار درapi.ts). - لاگینگ نباید درخواست را کند یا بشکند: فقط
warning+در DB، INSERT خام، همهچیز درtry/catch. - حلقهی بازخورد: چون DbLogger روی هر لاگ به DB مینویسد، مراقب باش خطای خودِ DB لاگ بینهایت نسازد —
catchداخل DbLogger این را میگیرد (لاگِ خطای persist را دوباره persist نکن، فقط stderr). - حجم جدول: برای آینده یک دستور پاکسازی (
messenger/cron یا یک command ساده برای حذف لاگهای قدیمیتر از N روز) در نظر بگیر — در این پرامپت اختیاری، فقط در docs ذکر کن. - بعد از تغییرات backend مرتبط با API، طبق hook پروژه، تستها و
docs/api/*.mdهمان session بهروز شوند. - تست: یک تست سبک برای
DbLogger(کهwarning+را insert میکند وinfoرا نه) و یک تست endpointlistLogs.