OpenTelemetry
Symfony
Logs

Logs

Forward your Symfony application logs to TracePath over the OpenTelemetry logs endpoint. You keep writing $logger->info(...) exactly as you do now; a Monolog handler copies each record into the OTLP logs pipeline, linked to the trace and span that was active when you logged it.

Install

composer require symfony/monolog-bundle open-telemetry/opentelemetry-logger-monolog

Symfony's PSR-3 logger comes from symfony/monolog-bundle. The symfony/skeleton starter does not include it, so install it first — without it $this->logger->info(...) goes nowhere and config/packages/monolog.yaml is ignored.

open-telemetry/opentelemetry-logger-monolog is the OpenTelemetry project's own Monolog handler. It is pure PHP and needs no extension.

Make sure the logs exporter is on in .env. This is already in the Quick Start env block:

OTEL_LOGS_EXPORTER=otlp

OTEL_EXPORTER_OTLP_ENDPOINT is the base URL. The logs exporter appends /v1/logs itself, so there is no extra endpoint to configure.

Wire the handler

Nothing registers OpenTelemetry\API\Logs\LoggerProviderInterface as a service, so you add the factory yourself or the container will not compile:

# config/packages/monolog.yaml
monolog:
    handlers:
        tracepath:
            type: service
            id: OpenTelemetry\Contrib\Logs\Monolog\Handler
            channels: ["!event"]
 
services:
    OpenTelemetry\API\Logs\LoggerProviderInterface:
        factory: ['OpenTelemetry\API\Globals', 'loggerProvider']
 
    OpenTelemetry\Contrib\Logs\Monolog\Handler:
        arguments:
            $loggerProvider: '@OpenTelemetry\API\Logs\LoggerProviderInterface'
            $level: !php/const Monolog\Level::Info
            $bubble: true

$level is the minimum Monolog level forwarded to TracePath. Drop it to Monolog\Level::Debug while you are verifying the pipeline, then raise it. On Monolog 2 the constant is Monolog\Logger::INFO instead of Monolog\Level::Info.

Run bin/console cache:clear afterwards. A handler added to monolog.yaml without clearing the cache does nothing and reports nothing.

⚠️

OTEL_PHP_AUTOLOAD_ENABLED must be a real process environment variable, as set in Step 4 of the Quick Start. This handler resolves the logger provider while the container builds the logger service, and OpenTelemetry PHP caches its providers on first access. If the SDK has not started by then it caches a no-op provider, and traces, metrics and logs all silently go nowhere for the rest of the request.

Why channels: ["!event"]

Symfony's event channel carries one Notified event "{event}" to listener "{listener}". record per listener per request. If symfony/stopwatch is installed — and most apps get it through doctrine/doctrine-bundle or the debug pack — a single request that logs 4 records of its own would ship around 26. Symfony's own default handlers exclude that channel for the same reason; the handler above copies the exclusion.

channels takes an exclude list (["!event", "!doctrine"]) or an include list (["app"]). Use it to drop any other chatty channel the same way. Logs count against your monthly ingest quota, so this is worth getting right before you turn export on in production.

Emit logs with trace context

Inject Symfony's standard Psr\Log\LoggerInterface as usual. Trace context attaches automatically:

<?php
// src/Controller/OrderController.php
namespace App\Controller;
 
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
 
class OrderController
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}
 
    #[Route('/orders', methods: ['POST'])]
    public function create(): Response
    {
        $this->logger->info('order received', ['order.id' => 'ord_123']);
 
        try {
            // ... business logic ...
            $this->logger->info('order processed', ['order.id' => 'ord_123']);
            return new JsonResponse(['status' => 'ok']);
        } catch (\Throwable $e) {
            $this->logger->error('order failed', [
                'order.id' => 'ord_123',
                'exception' => $e,
            ]);
            throw $e;
        }
    }
}

Because the controller runs inside the instrumented request span, every log emitted here carries that span's trace_id and span_id. Open the endpoint's trace in the dashboard and the Logs tab shows these records attached to it.

The same is true for logs emitted inside Messenger handlers, console commands, and any other code that runs under an active span.

Two things about the context array:

  • Each key becomes a log attribute you can filter on. ['order.id' => 'ord_123'] is stored as the attribute order.id.
  • A key named exception holding a Throwable is special. Pass the object, not $e->getMessage(), and the record gains exception.type, exception.message, and exception.stacktrace attributes.

Logging an exception this way does not create an Issue. Issues come from exceptions recorded on spans, which is covered in Exceptions. Do both when you want the error searchable in Logs and tracked as an Issue.

The Monolog channel becomes the log's scope name, so records from the doctrine or security channels are distinguishable from your own app records.

Severity mapping

Monolog levels map to OpenTelemetry severity numbers like this, and TracePath stores the level name it receives:

Monolog LevelOTLP Severity NumberShown in TracePath
DEBUG5DEBUG
INFO9INFO
NOTICE10NOTICE
WARNING13WARNING
ERROR17ERROR
CRITICAL18CRITICAL
ALERT19ALERT
EMERGENCY21EMERGENCY

The severity filter in the dashboard buckets by number, not by name. WARN+ starts at 13 and so matches WARNING. ERROR+ starts at 17 and so matches ERROR, CRITICAL, ALERT, and EMERGENCY.

Only records at or above the handler's $level are forwarded.

Test your integration

Add a route that logs at multiple levels, hit it once, and check the Logs page in the dashboard:

<?php
// src/Controller/LogTestController.php
namespace App\Controller;
 
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
 
class LogTestController
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}
 
    #[Route('/log-test', name: 'log_test')]
    public function test(): Response
    {
        $this->logger->debug('debug sample');
        $this->logger->info('info sample', ['request.id' => 'req_1']);
        $this->logger->warning('warning sample');
        $this->logger->error('error sample', ['order.id' => 'ord_1']);
        return new JsonResponse(['emitted' => 4]);
    }
}

Visit /log-test, then open Logs in the dashboard. With $level at Debug you should see all four records within a few seconds, each carrying the trace id of that request. At Info the debug sample record is filtered out before it reaches the handler, so you get three.

Symfony also logs on your behalf, so expect a framework record such as Matched route "log_test". on the same trace. If you see twenty or more extra Notified event records instead, the channels: ["!event"] filter above is missing or the cache was not cleared.

Nothing at all? Almost always one of three things: the cache was not cleared, OTEL_LOGS_EXPORTER=otlp is missing, or the SDK never started because OTEL_PHP_AUTOLOAD_ENABLED is in .env rather than the process environment.

Next Steps