- 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.
230 lines
14 KiB
Markdown
230 lines
14 KiB
Markdown
# لاگینگ سراسری پروژه + نمایش لاگها در پنل ادمین
|
||
|
||
## پروژه
|
||
|
||
`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 پیشفرض هم دیده شود):
|
||
|
||
```php
|
||
// 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 بیصدا)
|
||
|
||
```php
|
||
// 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 کن):
|
||
|
||
```php
|
||
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`:
|
||
|
||
```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; }` دیگر:
|
||
```php
|
||
} 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)`.
|
||
|
||
```php
|
||
#[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`.
|
||
```
|