Files
clinicpro/.claude/prompt/project-wide-logging.md
T
hamed 803196108c feat(logging): Implement database logging with app_log table
- 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.
2026-06-29 20:01:03 +03:30

14 KiB
Raw Blame History

لاگینگ سراسری پروژه + نمایش لاگ‌ها در پنل ادمین

پروژه

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 نمی‌شوند.

دو مشکل:

  1. از ۲۰۴ فایل src/، فقط ۵ فایل اصلاً لاگ می‌زنند (PasswordAuthenticator, PreRegistrationController, ApiIrService, ExceptionSubscriber, PatientController). خیلی از catch blockها استثناء را بی‌صدا می‌خورند (مثلاً KavehNegarProvider::send() که catch (\Throwable) { return false; }). وقتی روی prod چیزی می‌شکند، رد قابل‌ردیابی نمی‌ماند.
  2. ادمین هیچ راهی برای دیدن لاگ‌ها از داخل پنل ندارد؛ باید به 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,
]);

هدف

  1. یک استاندارد لاگینگ در کل backend: همه‌ی نقاط مهم (یکپارچه‌سازی‌های بیرونی، catchهای بی‌صدا، گذارهای حالت مهم) با فرمت پیام غنیِ بالا لاگ بزنند.
  2. لاگ‌ها علاوه بر stderr، در دیتابیس هم persist شوند (سطح warning به بالا) تا قابل‌کوئری باشند.
  3. یک صفحه‌ی ادمین برای دیدن/فیلتر لاگ‌ها (دقیقاً مثل الگوی موجود 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 را نه) و یک تست endpoint listLogs.