Monitoring Laravel trên Production — CloudWatch, Prometheus & Grafana

· 14 min read

Deploy xong không có nghĩa là xong. Biết bao nhiêu lần bạn nhận được tin nhắn "Em ơi, web chậm lắm" hoặc "Sao đặt hàng không được?" mà bạn không hề biết có vấn đề cho đến khi người dùng phàn nàn?

Monitoring (giám sát) giúp bạn phát hiện vấn đề trước khi người dùng gặp phải. Bài viết này hướng dẫn cách thiết lập hệ thống observability toàn diện cho Laravel, từ đơn giản đến nâng cao.

Ba trụ cột của Observability

┌─────────────────────────────────────────┐
│            OBSERVABILITY                │
├──────────┬──────────┬───────────────────┤
│  LOGS    │ METRICS  │    TRACES         │
│          │          │                   │
│  "Chuyện │ "Bao     │  "Request đi      │
│  gì đã   │ nhiêu,   │  qua đâu, mất    │
│  xảy ra" │ bao lâu" │  bao lâu ở đâu"  │
│          │          │                   │
│ Monolog  │Prometheus│  OpenTelemetry    │
│CloudWatch│ Grafana  │  Jaeger/Zipkin    │
└──────────┴──────────┴───────────────────┘
  • Logs: Ghi lại sự kiện. "Request 404 lúc 15:30", "Payment failed cho user #123".
  • Metrics: Số liệu tổng hợp. "CPU 80%", "500 requests/giây", "Response time trung bình 200ms".
  • Traces: Theo dõi vòng đời 1 request qua nhiều service. "Request → Controller → Database (150ms) → Redis (5ms) → Response".

Phần 1: Logging — Nền tảng

Cấu hình Monolog cho Production

// config/logging.php
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['daily', 'cloudwatch'],
        'ignore_exceptions' => false,
    ],

    'daily' => [
        'driver' => 'daily',
        'path' => storage_path('logs/laravel.log'),
        'level' => env('LOG_LEVEL', 'info'),
        'days' => 14,
        'replace_placeholders' => true,
    ],

    'cloudwatch' => [
        'driver' => 'custom',
        'via' => App\Logging\CloudWatchLoggerFactory::class,
        'level' => env('LOG_LEVEL', 'info'),
        'retention' => 30,
        'group_name' => env('CLOUDWATCH_LOG_GROUP', '/laravel/production'),
        'stream_name' => env('CLOUDWATCH_LOG_STREAM', 'application'),
    ],
],

Tạo CloudWatch Logger

composer require aws/aws-sdk-php maxbanton/cwh
// app/Logging/CloudWatchLoggerFactory.php
namespace App\Logging;

use Aws\CloudWatchLogs\CloudWatchLogsClient;
use Maxbanton\Cwh\Handler\CloudWatch;
use Monolog\Formatter\JsonFormatter;
use Monolog\Logger;

class CloudWatchLoggerFactory
{
    public function __invoke(array $config): Logger
    {
        $client = new CloudWatchLogsClient([
            'region'  => config('services.aws.region', 'ap-southeast-1'),
            'version' => 'latest',
        ]);

        $handler = new CloudWatch(
            client: $client,
            group: $config['group_name'],
            stream: $config['stream_name'],
            retentionDays: $config['retention'],
            batchSize: 25,
        );

        // JSON format cho dễ query trên CloudWatch Insights
        $handler->setFormatter(new JsonFormatter());

        $logger = new Logger('cloudwatch');
        $logger->pushHandler($handler);

        return $logger;
    }
}

Tại sao JSON format? CloudWatch Logs Insights cho phép bạn query logs bằng SQL-like syntax. Với JSON format, bạn có thể:

-- Tìm tất cả errors trong 1 giờ qua
fields @timestamp, context.exception, message
| filter level = "ERROR"
| sort @timestamp desc
| limit 50

-- Đếm errors theo loại
fields context.exception
| filter level = "ERROR"
| stats count(*) as error_count by context.exception
| sort error_count desc

Structured Logging — Thêm Context

Đừng chỉ log message trống. Thêm context để debug dễ hơn:

// ❌ Khó debug
Log::error('Payment failed');

// ✅ Dễ debug
Log::error('Payment failed', [
    'user_id'    => $user->id,
    'order_id'   => $order->id,
    'amount'     => $order->total,
    'gateway'    => 'stripe',
    'error_code' => $e->getCode(),
    'error_msg'  => $e->getMessage(),
    'trace_id'   => request()->header('X-Request-ID'),
]);

Middleware gắn Request ID

// app/Http/Middleware/RequestId.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;

class RequestId
{
    public function handle(Request $request, Closure $next)
    {
        $requestId = $request->header('X-Request-ID', (string) Str::uuid());

        // Gắn vào mọi log entry
        Log::shareContext([
            'request_id' => $requestId,
            'ip'         => $request->ip(),
            'url'        => $request->fullUrl(),
            'method'     => $request->method(),
        ]);

        $response = $next($request);

        $response->headers->set('X-Request-ID', $requestId);

        return $response;
    }
}

Giải thích Log::shareContext(): Từ Laravel 10+, method này tự động thêm context vào tất cả log entries trong cùng request. Bạn không cần truyền $requestId vào từng nơi log nữa.

Phần 2: Metrics với Prometheus

Prometheus là hệ thống monitoring mã nguồn mở, hoạt động theo mô hình pull: Prometheus server định kỳ "kéo" (scrape) metrics từ ứng dụng của bạn.

Cài đặt

composer require promphp/prometheus_client_php

Tạo Metrics Service

// app/Services/MetricsService.php
namespace App\Services;

use Prometheus\CollectorRegistry;
use Prometheus\RenderTextFormat;
use Prometheus\Storage\InMemory;
use Prometheus\Storage\Redis;

class MetricsService
{
    private CollectorRegistry $registry;

    public function __construct()
    {
        // Dùng Redis để persist metrics giữa các requests
        // InMemory chỉ phù hợp cho testing
        $this->registry = new CollectorRegistry(
            new Redis([
                'host' => config('database.redis.default.host'),
                'port' => config('database.redis.default.port'),
            ])
        );
    }

    /**
     * Đếm tổng số HTTP requests theo method, path, status
     */
    public function recordHttpRequest(
        string $method,
        string $path,
        int $statusCode,
        float $duration
    ): void {
        // Counter: chỉ tăng, không bao giờ giảm
        $counter = $this->registry->getOrRegisterCounter(
            'laravel',
            'http_requests_total',
            'Tổng số HTTP requests',
            ['method', 'path', 'status']
        );
        $counter->inc([$method, $path, $statusCode]);

        // Histogram: phân phối response time
        $histogram = $this->registry->getOrRegisterHistogram(
            'laravel',
            'http_request_duration_seconds',
            'Response time (seconds)',
            ['method', 'path'],
            // Buckets: 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s
            [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
        );
        $histogram->observe($duration, [$method, $path]);
    }

    /**
     * Đo database query count và duration
     */
    public function recordDatabaseQuery(float $duration, string $connection): void
    {
        $histogram = $this->registry->getOrRegisterHistogram(
            'laravel',
            'database_query_duration_seconds',
            'Thời gian query database',
            ['connection'],
            [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5]
        );
        $histogram->observe($duration, [$connection]);
    }

    /**
     * Đếm queue jobs
     */
    public function recordQueueJob(string $job, string $status): void
    {
        $counter = $this->registry->getOrRegisterCounter(
            'laravel',
            'queue_jobs_total',
            'Tổng số queue jobs',
            ['job', 'status']
        );
        $counter->inc([$job, $status]);
    }

    /**
     * Gauge: giá trị hiện tại (lên/xuống)
     */
    public function setQueueSize(string $queue, int $size): void
    {
        $gauge = $this->registry->getOrRegisterGauge(
            'laravel',
            'queue_size',
            'Số jobs đang chờ trong queue',
            ['queue']
        );
        $gauge->set($size, [$queue]);
    }

    /**
     * Render metrics ở format Prometheus text
     */
    public function render(): string
    {
        $renderer = new RenderTextFormat();
        return $renderer->render($this->registry->getMetricFamilySamples());
    }
}

Giải thích các loại metric:

  • Counter: Chỉ tăng. Ví dụ: tổng requests, tổng errors. Reset = 0 khi restart.
  • Histogram: Đo phân phối giá trị. Ví dụ: response time. Prometheus tự tính percentile (p50, p95, p99).
  • Gauge: Giá trị hiện tại, có thể lên/xuống. Ví dụ: queue size, memory usage.

Middleware thu thập metrics

// app/Http/Middleware/CollectMetrics.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Services\MetricsService;

class CollectMetrics
{
    public function __construct(
        private MetricsService $metrics,
    ) {}

    public function handle(Request $request, Closure $next)
    {
        $start = microtime(true);

        $response = $next($request);

        $duration = microtime(true) - $start;

        // Normalize path để tránh cardinality explosion
        // /blog/my-post → /blog/{slug}
        $path = $this->normalizePath($request->route());

        $this->metrics->recordHttpRequest(
            method: $request->method(),
            path: $path,
            statusCode: $response->getStatusCode(),
            duration: $duration,
        );

        return $response;
    }

    private function normalizePath($route): string
    {
        if (!$route) {
            return 'unknown';
        }

        // Dùng route URI pattern thay vì actual path
        // Ví dụ: /blog/{slug} thay vì /blog/my-actual-post
        return '/' . ltrim($route->uri(), '/');
    }
}

Tại sao cần normalizePath()? Nếu bạn log đường dẫn thực (/blog/post-1, /blog/post-2, ...), Prometheus sẽ tạo hàng nghìn time series → tốn memory → chậm query. Đây gọi là cardinality explosion. Luôn nhóm vào pattern: /blog/{slug}.

Route endpoint cho Prometheus scrape

// routes/web.php
use App\Services\MetricsService;

Route::get('/metrics', function (MetricsService $metrics) {
    // Bảo vệ endpoint này!
    return response($metrics->render(), 200, [
        'Content-Type' => 'text/plain; version=0.0.4',
    ]);
})->middleware('auth.basic'); // Hoặc giới hạn IP

Lắng nghe Database Queries

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\DB;
use App\Services\MetricsService;

public function boot(): void
{
    // Chỉ bật ở production, có overhead nhẹ
    if (app()->isProduction()) {
        DB::listen(function ($query) {
            app(MetricsService::class)->recordDatabaseQuery(
                duration: $query->time / 1000, // ms → seconds
                connection: $query->connectionName,
            );
        });
    }
}

Phần 3: Grafana Dashboards

Grafana kết nối với Prometheus để hiển thị metrics dưới dạng biểu đồ đẹp mắt.

Cài đặt với Docker Compose

# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=30d'

  grafana:
    image: grafana/grafana:latest
    volumes:
      - grafana_data:/var/lib/grafana
      - ./monitoring/dashboards:/etc/grafana/provisioning/dashboards
      - ./monitoring/datasources:/etc/grafana/provisioning/datasources
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=your-secure-password
      - GF_INSTALL_PLUGINS=grafana-clock-panel

  # Node Exporter: metrics hệ thống (CPU, RAM, Disk)
  node-exporter:
    image: prom/node-exporter:latest
    ports:
      - "9100:9100"
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'

volumes:
  prometheus_data:
  grafana_data:

Prometheus Config

# monitoring/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  # Metrics từ Laravel app
  - job_name: 'laravel'
    metrics_path: /metrics
    basic_auth:
      username: prometheus
      password: your-metrics-password
    static_configs:
      - targets: ['your-app-server:80']
        labels:
          app: laravel-blog
          env: production

  # Metrics hệ thống
  - job_name: 'node'
    static_configs:
      - targets: ['node-exporter:9100']

  # Metrics Nginx
  - job_name: 'nginx'
    static_configs:
      - targets: ['nginx-exporter:9113']

  # Metrics MySQL
  - job_name: 'mysql'
    static_configs:
      - targets: ['mysql-exporter:9104']

  # Metrics Redis
  - job_name: 'redis'
    static_configs:
      - targets: ['redis-exporter:9121']

Grafana Datasource Auto-provisioning

# monitoring/datasources/prometheus.yml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

Các PromQL Queries hữu ích cho Dashboard

# 1. Request rate (requests/giây)
rate(laravel_http_requests_total[5m])

# 2. Error rate (% requests trả về 5xx)
sum(rate(laravel_http_requests_total{status=~"5.."}[5m]))
/
sum(rate(laravel_http_requests_total[5m])) * 100

# 3. Response time percentile 95
histogram_quantile(0.95, 
  rate(laravel_http_request_duration_seconds_bucket[5m])
)

# 4. Response time percentile 99
histogram_quantile(0.99, 
  rate(laravel_http_request_duration_seconds_bucket[5m])
)

# 5. Slowest endpoints
topk(10, 
  histogram_quantile(0.95, 
    sum by (path, le) (
      rate(laravel_http_request_duration_seconds_bucket[5m])
    )
  )
)

# 6. Database query rate
rate(laravel_database_query_duration_seconds_count[5m])

# 7. Slow queries (> 100ms)
rate(laravel_database_query_duration_seconds_bucket{le="0.1"}[5m])

# 8. Queue size hiện tại
laravel_queue_size

# 9. Failed jobs rate
rate(laravel_queue_jobs_total{status="failed"}[5m])

# 10. CPU usage (từ node-exporter)
100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)

Phần 4: Alerting — Thông báo khi có vấn đề

Prometheus Alert Rules

# monitoring/alerts.yml
groups:
  - name: laravel-alerts
    rules:
      # Error rate > 5% trong 5 phút
      - alert: HighErrorRate
        expr: |
          sum(rate(laravel_http_requests_total{status=~"5.."}[5m]))
          /
          sum(rate(laravel_http_requests_total[5m])) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Error rate cao bất thường"
          description: "Error rate đang ở {{ $value | humanizePercentage }}. Threshold: 5%"

      # Response time p95 > 2 giây
      - alert: HighLatency
        expr: |
          histogram_quantile(0.95,
            rate(laravel_http_request_duration_seconds_bucket[5m])
          ) > 2
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Response time cao"
          description: "P95 latency: {{ $value | humanizeDuration }}"

      # Queue size > 1000 jobs
      - alert: QueueBacklog
        expr: laravel_queue_size > 1000
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Queue đang bị backlog"
          description: "{{ $value }} jobs đang chờ xử lý"

      # Disk usage > 85%
      - alert: DiskSpaceLow
        expr: |
          (node_filesystem_size_bytes - node_filesystem_free_bytes)
          / node_filesystem_size_bytes > 0.85
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Disk sắp đầy"
          description: "Disk usage: {{ $value | humanizePercentage }}"

      # Server down
      - alert: ServerDown
        expr: up{job="laravel"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Laravel server không phản hồi"

Gửi Alert qua Slack

# monitoring/alertmanager.yml
global:
  resolve_timeout: 5m

route:
  group_by: ['alertname', 'severity']
  group_wait: 10s
  group_interval: 10s
  repeat_interval: 1h
  receiver: 'slack-notifications'
  routes:
    - match:
        severity: critical
      receiver: 'slack-critical'

receivers:
  - name: 'slack-notifications'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'
        channel: '#monitoring'
        title: '{{ .CommonAnnotations.summary }}'
        text: '{{ .CommonAnnotations.description }}'

  - name: 'slack-critical'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'
        channel: '#alerts-critical'
        title: '🚨 {{ .CommonAnnotations.summary }}'
        text: '{{ .CommonAnnotations.description }}'

Phần 5: Health Check Endpoint

Tạo một endpoint đơn giản để kiểm tra sức khỏe ứng dụng:

// routes/web.php
Route::get('/health', function () {
    $checks = [];
    $healthy = true;

    // Check database
    try {
        DB::connection()->getPdo();
        $checks['database'] = 'ok';
    } catch (\Exception $e) {
        $checks['database'] = 'failed: ' . $e->getMessage();
        $healthy = false;
    }

    // Check Redis
    try {
        Cache::store('redis')->put('health-check', true, 10);
        $checks['redis'] = 'ok';
    } catch (\Exception $e) {
        $checks['redis'] = 'failed: ' . $e->getMessage();
        $healthy = false;
    }

    // Check disk space
    $freeSpace = disk_free_space('/');
    $totalSpace = disk_total_space('/');
    $usagePercent = round((1 - $freeSpace / $totalSpace) * 100, 1);
    $checks['disk'] = $usagePercent . '% used';
    if ($usagePercent > 90) {
        $healthy = false;
    }

    // Check queue
    try {
        $queueSize = Queue::size('default');
        $checks['queue_size'] = $queueSize;
        if ($queueSize > 1000) {
            $healthy = false;
        }
    } catch (\Exception $e) {
        $checks['queue'] = 'failed';
        $healthy = false;
    }

    return response()->json([
        'status' => $healthy ? 'healthy' : 'unhealthy',
        'checks' => $checks,
        'timestamp' => now()->toISOString(),
    ], $healthy ? 200 : 503);
});

Phần 6: Tích hợp Laravel Pulse (Laravel 11+)

Nếu bạn không muốn setup Prometheus + Grafana, Laravel Pulse là giải pháp built-in đơn giản hơn:

composer require laravel/pulse
php artisan vendor:publish --provider="Laravel\Pulse\PulseServiceProvider"
php artisan migrate

Pulse tự động thu thập:

  • Slow requests
  • Slow queries
  • Slow jobs
  • Exceptions
  • Cache hits/misses
  • Queue throughput

Truy cập dashboard tại /pulse.

So sánh:

Laravel Pulse Prometheus + Grafana
Setup 5 phút 2-4 giờ
Custom metrics Hạn chế Không giới hạn
Long-term storage Database Prometheus TSDB
Alerting
Multi-server ⚠️
Phù hợp cho App nhỏ/vừa App lớn, team

Checklist Monitoring cho Production

  • Logging: JSON format, CloudWatch/ELK, structured context
  • Request ID: Mỗi request có ID duy nhất xuyên suốt
  • Metrics: Response time, error rate, throughput
  • Health check: /health endpoint cho ALB/uptime monitor
  • Alerting: Slack/email khi error rate cao hoặc server down
  • Dashboard: Grafana hoặc Pulse với các panel quan trọng
  • Log rotation: Không để log file ăn hết disk
  • Uptime monitoring: Dịch vụ bên ngoài (UptimeRobot, Pingdom)

Kết luận

Monitoring không phải "nice to have" — nó là bắt buộc cho mọi ứng dụng production. Bắt đầu đơn giản:

  1. Tuần 1: Structured logging + CloudWatch/stderr
  2. Tuần 2: Health check endpoint + UptimeRobot
  3. Tuần 3: Laravel Pulse hoặc basic Prometheus metrics
  4. Tuần 4: Grafana dashboards + Slack alerts

Bạn không cần setup tất cả trong ngày đầu. Nhưng ít nhất phải có logging có context và health check endpoint trước khi go live.

"Nếu bạn không thể đo được nó, bạn không thể cải thiện nó." — Peter Drucker (và mọi SRE engineer)

Bình luận