Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Opentelemetry Logger Monolog Laravel Package

open-telemetry/opentelemetry-logger-monolog

Monolog handler that bridges your Laravel or PHP application's logs with OpenTelemetry, exporting records through the OTLP logging SDK so they can be correlated with traces and metrics in your observability backend.

View on GitHub
Deep Wiki
Context7

Getting Started

This package is a single Monolog handler that turns each Monolog\LogRecord into an OpenTelemetry log record and hands it to a LoggerProvider. It does not ship an exporter, SDK, or Laravel service provider — you wire those yourself. Treat it as glue, not a full integration.

Minimum viable install (Laravel 10/11, Monolog 3), pinning 1.3.0 or later:

composer require open-telemetry/opentelemetry-logger-monolog:^1.3 \
                 open-telemetry/sdk \
                 open-telemetry/exporter-otlp \
                 php-http/discovery guzzlehttp/guzzle

1.3.0's headline change is "fix monolog handler hidden dependency" (#608) — the handler now declares a dependency it previously assumed was installed. On 1.2.0 and below, a leaner install could fatal with a class not found on an OpenTelemetry\API\... interface that open-telemetry/sdk happened to pull in transitively for most people. On ^1.3 you should not need to add open-telemetry/api by hand; if you're stuck on an older tag and see that fatal, that's the workaround.

1. Bind a LoggerProvider as a singleton (in AppServiceProvider::register()). The handler type-hints OpenTelemetry\API\Logs\LoggerProviderInterface, so the container must be able to resolve it:

use OpenTelemetry\API\Logs\LoggerProviderInterface;
use OpenTelemetry\Contrib\Otlp\LogsExporter;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Logs\LoggerProvider;
use OpenTelemetry\SDK\Logs\Processor\BatchLogRecordProcessor;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Common\Attribute\AttributesInterface;
use OpenTelemetry\SemConv\ResourceAttributes;

$this->app->singleton(LoggerProviderInterface::class, function () {
    return LoggerProvider::builder()
        ->setResource(ResourceInfo::create(Attributes::create([
            ResourceAttributes::SERVICE_NAME => config('app.name'),
            ResourceAttributes::DEPLOYMENT_ENVIRONMENT_NAME => config('app.env'),
        ])))
        ->addLogRecordProcessor(new BatchLogRecordProcessor(
            LogsExporter::fromConnectionString(
                endpoint: config('otel.logs_endpoint', 'http://localhost:4318/v1/logs'),
                protocol: 'http/json',
            )
        ))
        ->build();
});

The exact LogsExporter::fromConnectionString() signature moves between SDK releases — check vendor/open-telemetry/exporter-otlp if the named args don't line up. Prefer reading values from config/otel.php rather than raw env(), because the SDK and config-cache interact badly with .env.

2. Add a channel in config/logging.php:

'otel' => [
    'driver' => 'monolog',
    'handler' => \OpenTelemetry\Contrib\Logs\Monolog\Handler::class,
    'handler_with' => [
        'level' => \Monolog\Level::Debug->value,   // int, not the enum, is safest
    ],
],

Laravel's LogManager::createMonologDriver() does app->make($handler, $with), so the loggerProvider parameter is auto-resolved from the container binding above. Do not put 'loggerProvider' => app(...) in the config array — config files load before providers register.

3. Verify locally by swapping BatchLogRecordProcessor for SimpleLogRecordProcessor + ConsoleExporter, then:

Log::channel('otel')->info('hello', ['order_id' => 42]);

You should see an OTLP-shaped record with timestamp, severity, body, and attributes. Once that prints, point the exporter at your collector/backend.

Where to look first: the README quickstart, then src/Handler.php::write() in the contrib repo — it's ~40 lines and tells you exactly which Monolog fields map to which OTel fields (and which are silently dropped). That file is the real documentation for attribute behavior. The 1.3.0 diff is worth a glance too: it's one constructor/dependency fix plus chores, so if you're upgrading from 1.2.0 that's the whole surface area.


Implementation Patterns

Fan-out via a stack, not a replacement. Keep your existing file/stderr channel and add OTel alongside it. Logs are cheap to duplicate and invaluable when the collector is down:

'stack' => [
    'driver' => 'stack',
    'channels' => ['single', 'otel'],
    'ignore_exceptions' => false,
],

Use a dedicated otel channel rather than the default so you can raise/lower its level independently of your disk logging.

Push request context once, not per call. A middleware calling Log::withContext([...]) or Log::shareContext([...]) puts request_id, route, user_id, tenant into every subsequent record's context, which the handler flattens into OTel attributes. This is the single highest-leverage pattern — it's what makes logs correlatable in a backend UI.

Log structured events, not prose. Because message becomes the log body and context becomes attributes, write Log::info('order.created', ['order_id' => $id, 'total_cents' => $cents]) instead of interpolating into the string. Attributes are what your backend can filter and aggregate on; the body is just text.

Normalize context with a Monolog processor. OTel attribute values must be scalar or arrays of scalars. Exceptions, Eloquent models, Carbon instances, and nested arrays in context either blow up or get dropped. Register a processor on the channel that flattens and coerces:

'otel' => [
    // ...
    'processors' => [App\Logging\OtelContextProcessor::class],
],

Have the processor convert ['exception' => $e] into exception.type, exception.message, exception.stacktrace strings, dot.flatten nested arrays, and json_encode anything else.

Trace correlation. Depending on SDK version, trace/span IDs may not be attached automatically. A small processor reading Span::getCurrent()->getContext() and injecting trace_id/span_id into extra guarantees the link and costs nothing when there's no active span.

Flush the batcher explicitly. BatchLogRecordProcessor buffers in memory. In AppServiceProvider::boot():

$this->app->terminating(function () {
    $this->app->make(LoggerProviderInterface::class)->shutdown();
});

Also flush after queue jobs (Queue::after) and at the end of long artisan commands — otherwise the last batch of a worker's logs vanishes on restart.

Test with an in-memory exporter. In feature tests, bind LoggerProviderInterface to a provider using InMemoryExporter + SimpleLogRecordProcessor, then assert on getSpanConverter()/exported log records rather than scraping log files. This is the only reliable way to test that your processors produce the attributes you expect.

Roll out by environment. Enable the otel channel only where a collector exists (config('otel.enabled') gating the stack channels), so local dev and CI don't pay connection-refused latency on every log line.


Gotchas and Tips

  • 1.3.0 is a dependency-declaration fix, not a behavior change. #608 makes a previously-implicit requirement explicit; the remaining items in the changelog are psalm / psalm-plugin-phpunit chores. Nothing to migrate, but it explains why an older pin could fail to autoload on a minimal install. Run composer why open-telemetry/api if you're diagnosing that on 1.2.0.
  • The #1 failure is silent log loss. Batch processor + PHP-FPM request lifecycle = logs dropped at process exit. If "nothing shows up in the backend," check flush order before anything else. SimpleLogRecordProcessor in dev removes the variable entirely.
  • Container resolution is the second failure. If you get Unresolvable dependency resolving ... LoggerProviderInterface, your binding is registered too late, is scoped instead of singleton, or you passed a closure through handler_with (the container passes closures through as-is; it does not invoke them).
  • No closures in config/logging.php. php artisan config:cache will fail or silently break. Everything in that array must be serializable; resolution happens at channel-creation time via the container.
  • Monolog 3 constraint. Recent releases target monolog/monolog ^3.0 (Laravel 10+). On an older app pinned to Monolog 2, the handler won't install or will fatal on the LogRecord type — check the tag you need before upgrading the framework.
  • Recursion risk. The OTel SDK can emit its own diagnostics through a PSR-3 logger. If you bridge PSR-3 to Laravel's Log facade and that facade routes back into the OTel handler, you get an infinite loop or a stack overflow. Point the SDK's internal logger at stderr, never at your app logger.
  • bubble duplicates. Default is true. If the same record passes through multiple handlers in a stack you'll see doubles; set bubble: false only when you deliberately want to short-circuit, and remember Laravel merges handler_with over the top-level 'level' key — put the level in handler_with to avoid surprises.
  • Severity text is Monolog's, not OTel's. The handler typically uses $record->level->getName() (e.g. Warning, Error). Backend queries are often case-sensitive on severity text — filter on severity_number instead when possible.
  • Endpoint URL shape varies by protocol. gRPC wants host:4317; HTTP wants a full path ending in /v1/logs. A missing /v1/logs produces 404s that look like auth failures.
  • formatter in the channel config is ignored. The handler doesn't format to a string — it builds an OTel record. Setting 'formatter' => ... is harmless but misleading; drop it.
  • Don't log secrets or PII. This channel ships data off-host, over the network, to a third party. Anything your file logs tolerated (session blobs, request bodies) is now a compliance question. Scrub in the processor, not at call sites.
  • Long-running workers (Horizon, Octane, queue). The provider is built once and reused; watch for unbounded batch queues and stale resource attributes after deploy. Restart workers on deploy, and confirm shutdown() runs on WorkerStopping/Octane termination events.
  • Debugging the pipeline. Set OTEL_LOG_LEVEL=debug to see SDK-internal errors on stderr — this reveals exporter connection/auth problems that the handler swallows by design. Remove it in production.
  • Pin the tag, not the branch. The package is small and lightly adopted (single-digit stars, no dependents), and releases are infrequent and mostly dependency chores — but a contrib or SDK minor bump can still take logging down silently. Require ^1.3, cover your channel with one integration test, and keep the Handler::write() body in mind: it's short enough to vendor/copy if upstream stalls, which is less than a screen of code.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource