OpenTelemetry + Laravel: Observability Production Vượt Xa Telescope

· 9 min read

Laravel Telescope tuyệt vời cho development. Pulse tốt cho metrics tổng quan. Nhưng cho production debugging ở quy mô lớn — tracing request qua queues, databases, external APIs — bạn cần OpenTelemetry.

OpenTelemetry (OTel) là tiêu chuẩn ngành cho observability. Thu thập traces, metrics, và logs rồi export sang bất kỳ backend nào (Jaeger, Grafana Tempo, Datadog). Vendor-agnostic — đổi backend không cần thay code.

Telescope vs Pulse vs OpenTelemetry

Feature Telescope Pulse OpenTelemetry
Mục đích Dev debugging Production dashboard Full observability
Traces Request-level Không Distributed tracing
Metrics Không Server metrics, slow queries Custom metrics, histograms
Logs Không Không Structured logs với trace context
Distributed Không Không Có (qua services, queues, APIs)
Backend Local DB Local DB Jaeger, Grafana, Datadog, v.v.
Production? Không nên

Khi nào nên dùng OTel: Khi bạn cần biết "request này chậm WHY" — nó mất 200ms ở database, 100ms ở cache miss, và 500ms chờ external API. OTel trace cho bạn thấy toàn bộ journey.

Ba Trụ Cột Observability

Traces  → Chuyện gì xảy ra từng bước (request → DB → cache → API → response)
Metrics → Các con số (request count, latency p99, error rate, queue depth)
Logs    → Chi tiết (error messages, context, stack traces)

OTel hợp nhất cả ba với correlation — trace ID liên kết logs và metrics với request flows cụ thể. Khi bạn thấy error rate tăng (metrics), tìm trace ID (logs), rồi xem full request flow (traces).

User Request → Trace ID: abc123
  ├── [Span] HTTP GET /api/posts/42           12ms
  │   ├── [Span] PostController.show          10ms
  │   │   ├── [Span] Cache::get post_42       0.5ms (miss)
  │   │   ├── [Span] MySQL SELECT             3ms
  │   │   └── [Span] Cache::put post_42       0.8ms
  │   └── [Span] View render                  2ms
  └── [Log] "Cache miss for post_42" → trace_id=abc123

Cài Đặt

composer require open-telemetry/sdk
composer require open-telemetry/exporter-otlp
composer require open-telemetry/contrib-auto-laravel
composer require open-telemetry/contrib-auto-pdo
composer require open-telemetry/contrib-auto-http-async

Giải thích packages:

  • sdk — OTel core SDK
  • exporter-otlp — export data sang OTLP protocol (Jaeger, Grafana, v.v.)
  • contrib-auto-laravel — auto-instrument Laravel: routes, middleware, exceptions
  • contrib-auto-pdo — auto-instrument database queries
  • contrib-auto-http-async — auto-instrument HTTP client calls

Environment Variables

OTEL_SERVICE_NAME=my-blog
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_PROPAGATORS=baggage,tracecontext

Local Development: Docker Compose Với Jaeger

# docker-compose.yml (thêm vào setup hiện tại)
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"  # Jaeger UI
      - "4317:4317"    # OTLP gRPC
      - "4318:4318"    # OTLP HTTP
    environment:
      COLLECTOR_OTLP_ENABLED: true

Truy cập Jaeger UI tại http://localhost:16686 — tìm service my-blog, xem traces.

Auto-Instrumentation

Với các packages contrib-auto-*, phần lớn instrumentation tự động:

✅ HTTP requests (route, method, status code, duration)
✅ Database queries (query, duration, connection)
✅ Cache operations (hit/miss, key, duration)
✅ Queue jobs (job name, duration, attempts)
✅ External HTTP calls (URL, method, status, duration)
✅ Exceptions (type, message, stack trace)

Không cần thêm code. Cài packages, set env vars, restart app → traces bắt đầu xuất hiện trong Jaeger.

Manual Tracing — Khi Auto Không Đủ

Auto-instrumentation cover infrastructure. Cho business logic quan trọng, thêm custom spans:

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\API\Trace\SpanKind;

class PostService
{
    public function getPost(string $slug): Post
    {
        $tracer = Globals::tracerProvider()->getTracer('blog');

        $span = $tracer->spanBuilder('PostService.getPost')
            ->setSpanKind(SpanKind::KIND_INTERNAL)
            ->setAttribute('post.slug', $slug)
            ->startSpan();

        $scope = $span->activate();

        try {
            // Cache check — auto-instrumented, nhưng ta thêm business context
            $post = Cache::remember("post_{$slug}", 3600, function () use ($slug, $tracer) {
                $dbSpan = $tracer->spanBuilder('PostService.loadFromMarkdown')
                    ->startSpan();

                try {
                    $post = $this->markdownService->getPost($slug);
                    $dbSpan->setAttribute('post.title', $post->title);
                    $dbSpan->setAttribute('post.word_count', str_word_count($post->body));

                    return $post;
                } finally {
                    $dbSpan->end();
                }
            });

            $span->setStatus(StatusCode::STATUS_OK);
            $span->setAttribute('post.cached', Cache::has("post_{$slug}"));
            return $post;
        } catch (\Exception $e) {
            $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
            $span->recordException($e);
            throw $e;
        } finally {
            $span->end();
            $scope->detach();
        }
    }
}

Giải thích:

  • spanBuilder() — tạo span mới (một bước trong trace)
  • setAttribute() — thêm metadata cho span (searchable trong Jaeger)
  • activate() — set span là "current" — child spans tự động nest
  • setStatus() — mark OK hoặc ERROR
  • recordException() — ghi exception details vào span
  • QUAN TRỌNG: Luôn gọi $span->end()$scope->detach() trong finally. Memory leak nếu quên.

Helper Trait Cho Code Sạch Hơn

trait Traceable
{
    protected function trace(string $name, callable $callback, array $attributes = []): mixed
    {
        $tracer = Globals::tracerProvider()->getTracer('blog');
        $span = $tracer->spanBuilder($name)->startSpan();
        $scope = $span->activate();

        foreach ($attributes as $key => $value) {
            $span->setAttribute($key, $value);
        }

        try {
            $result = $callback($span);
            $span->setStatus(StatusCode::STATUS_OK);
            return $result;
        } catch (\Throwable $e) {
            $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
            $span->recordException($e);
            throw $e;
        } finally {
            $span->end();
            $scope->detach();
        }
    }
}

// Sử dụng
class PostService
{
    use Traceable;

    public function getPost(string $slug): Post
    {
        return $this->trace('PostService.getPost', function ($span) use ($slug) {
            $span->setAttribute('post.slug', $slug);
            return Cache::remember("post_{$slug}", 3600, fn () => $this->loadPost($slug));
        });
    }
}

Structured Logging với Trace Context

Mỗi log line bao gồm trace_id → jump từ log entry trực tiếp sang full trace trong Jaeger:

// app/Logging/OtelFormatter.php

use Monolog\Formatter\JsonFormatter;
use Monolog\LogRecord;
use OpenTelemetry\API\Trace\Span;

class OtelFormatter extends JsonFormatter
{
    public function format(LogRecord $record): string
    {
        $span = Span::getCurrent();
        $context = $span->getContext();

        $record->extra['trace_id'] = $context->getTraceId();
        $record->extra['span_id'] = $context->getSpanId();
        $record->extra['service_name'] = config('app.name');

        return parent::format($record);
    }
}
// config/logging.php
'channels' => [
    'structured' => [
        'driver' => 'monolog',
        'handler' => StreamHandler::class,
        'formatter' => OtelFormatter::class,
        'with' => ['stream' => 'php://stderr'],
    ],
],

Kết quả log:

{
    "message": "Post not found",
    "level": 400,
    "context": {"slug": "nonexistent"},
    "extra": {
        "trace_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
        "span_id": "1234567890abcdef",
        "service_name": "my-blog"
    }
}

Copy trace_id → paste vào Jaeger search → xem toàn bộ request flow. Đây là superpower của correlated observability.

Custom Metrics

use OpenTelemetry\API\Globals;

class BlogMetrics
{
    private static $meter = null;

    private static function meter()
    {
        return self::$meter ??= Globals::meterProvider()->getMeter('blog');
    }

    public static function recordPageView(string $slug): void
    {
        self::meter()
            ->createCounter('blog.page_views', 'views', 'Total page views')
            ->add(1, ['slug' => $slug, 'locale' => app()->getLocale()]);
    }

    public static function recordResponseTime(float $durationMs, string $route): void
    {
        self::meter()
            ->createHistogram('blog.response_time', 'ms', 'Response time distribution')
            ->record($durationMs, ['route' => $route]);
    }

    public static function recordCacheHitRate(bool $hit, string $key): void
    {
        self::meter()
            ->createCounter('blog.cache', 'operations', 'Cache operations')
            ->add(1, ['result' => $hit ? 'hit' : 'miss', 'key_prefix' => explode('_', $key)[0]]);
    }

    public static function recordSearchQuery(string $query, int $resultCount): void
    {
        self::meter()
            ->createHistogram('blog.search_results', 'count', 'Search result count')
            ->record($resultCount, ['has_results' => $resultCount > 0]);
    }
}

// Sử dụng trong middleware
class RecordMetrics
{
    public function handle(Request $request, Closure $next): mixed
    {
        $start = microtime(true);
        $response = $next($request);
        $duration = (microtime(true) - $start) * 1000;

        BlogMetrics::recordResponseTime($duration, $request->route()?->getName() ?? 'unknown');

        return $response;
    }
}

Metric types:

  • Counter — chỉ tăng: page views, error count, requests total
  • Histogram — distribution: response time (p50, p90, p99), result count
  • UpDownCounter — tăng/giảm: active connections, queue depth

Gửi Sang Cloud Providers

Đổi backend chỉ cần thay env vars — code không thay đổi:

Datadog

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# Datadog Agent nhận OTLP trên port 4317
DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317

Grafana Cloud

OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20<base64_encoded_credentials>

AWS X-Ray (qua ADOT Collector)

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# AWS Distro for OpenTelemetry Collector chạy local, forward sang X-Ray

Performance Impact

OTel thêm overhead. Giảm thiểu:

# Sampling: chỉ trace 10% requests trên production
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

# Batch export (giảm network calls)
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512

Sampling strategies:

  • always_on — trace mọi request (development)
  • traceidratio — trace X% requests (production)
  • parentbased_traceidratio — trace X% top-level, nhưng trace 100% child spans nếu parent được traced

Kết Luận

OpenTelemetry cần setup nhiều hơn Telescope/Pulse, nhưng cho bạn observability production-grade:

  1. Traces cho thấy chính xác chuyện gì xảy ra — từng bước, từng millisecond
  2. Metrics cho thấy xu hướng — response time tăng? Error rate đột biến?
  3. Logs với trace context cho phép drill từ overview vào chi tiết
  4. Vendor-agnostic — đổi từ Jaeger sang Datadog không cần thay code

Bắt đầu thế nào:

  1. Cài auto-instrumentation packages + Jaeger Docker
  2. Xem traces trong Jaeger UI — đã có insights ngay lập tức
  3. Thêm custom spans cho business logic quan trọng
  4. Thêm custom metrics cho KPIs
  5. Configure sampling cho production
  6. Thêm structured logging với trace context

Bình luận