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.
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 — checkvendor/open-telemetry/exporter-otlpif the named args don't line up. Prefer reading values fromconfig/otel.phprather than rawenv(), 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.
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.
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.SimpleLogRecordProcessor in dev removes the variable entirely.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).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/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.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.$record->level->getName() (e.g. Warning, Error). Backend queries are often case-sensitive on severity text — filter on severity_number instead when possible.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.shutdown() runs on WorkerStopping/Octane termination events.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.^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.How can I help you explore Laravel packages today?