elastic/ecs-logging
ECS Logging for PHP transforms application logs into structured logs that comply with Elastic Common Schema. Supports Monolog 3, works with Filebeat, Elasticsearch, and Kibana, and helps correlate logs with APM traces for better observability.
1. Install and check your Monolog major first
composer require elastic/ecs-logging
composer show monolog/monolog elastic/ecs-logging
The package is only a formatter (ElasticCommonSchemaFormatter) plus a handful of ECS value objects (Elastic\Types\*). The README states Monolog 3.x support; the docs setup.md page still says Monolog 2.x — resolve that ambiguity before wiring anything by confirming the namespace that actually exists in your vendor dir:
ls vendor/elastic/ecs-logging/src/Elastic/Monolog/
# v3 doc/README use: Elastic\Monolog\Formatter\ElasticCommonSchemaFormatter
# older setup page uses: Elastic\Monolog\v2\Formatter\ElasticCommonSchemaFormatter
Laravel 9+ ships Monolog 3, so this is usually fine; a legacy app pinned to Monolog 2 (composer why monolog/monolog) is the main blocker.
2. Where to look first
docs/Monolog_v3.md — the only real usage example.vendor/elastic/ecs-logging/src/Elastic/Monolog/Formatter/ElasticCommonSchemaFormatter.php — read it. It is essentially one format(LogRecord $record): string method that json-encodes @timestamp, log.level, message, ecs.version, log.logger and whatever is in context. Knowing it verbatim answers 90% of questions.src/Elastic/Types/ — Error, Service, User, Tracing. Thin JsonSerializable wrappers that emit correctly-named ECS fields.3. First use case (the one that pays for itself)
Ship a JSON log file that Filebeat/Elastic Agent can read and that Kibana's Logs view understands, so error.stack_trace, service.name, trace.id and log.level are all queryable fields instead of regex-scraped text. Minimal wiring in config/logging.php:
'ecs' => [
'driver' => 'daily',
'path' => storage_path('logs/ecs.json'),
'level' => env('LOG_LEVEL', 'info'),
'days' => 7,
'tap' => [App\Logging\EcsFormatterTap::class],
],
// app/Logging/EcsFormatterTap.php
namespace App\Logging;
use Elastic\Monolog\Formatter\ElasticCommonSchemaFormatter;
use Monolog\Logger;
class EcsFormatterTap
{
public function __invoke(Logger $logger): void
{
foreach ($logger->getHandlers() as $handler) {
$handler->setFormatter(new ElasticCommonSchemaFormatter());
}
}
}
Then LOG_CHANNEL=ecs. Output per line:
{"@timestamp":"2026-08-18T09:12:44.123456Z","log.level":"INFO","message":"Order placed","ecs.version":"1.2.0","log":{"logger":"production"},"labels":{"order_id":1234}}
Configure Filebeat 7.16+ with filestream + ndjson parser (overwrite_keys: true, add_error_key: true, expand_keys: true) and you have working structured logs in one deploy.
Channel strategy: JSON in every environment, human format out of the way. Keep a single (or stack with a console handler) channel for local php artisan tinker/serve readability and route only production/dev-that-ships-logs to ecs. Never put two formatters on the same handler and don't stack two handlers writing the same file — you'll get mixed JSON and line-formatted content that Filebeat will reject with error.type: json.
Container/K8s: log to stdout, not a file.
'ecs' => [
'driver' => 'monolog',
'handler' => Monolog\Handler\StreamHandler::class,
'handler_with' => ['stream' => 'php://stdout'],
'level' => env('LOG_LEVEL', 'info'),
'tap' => [App\Logging\EcsFormatterTap::class],
],
Elastic Agent's hints-based autodiscover reads pod annotations/labels; set co.elastic.logs/json.expand_keys: "true", overwrite_keys: "true", add_error_key: "true" on those pods so log.level becomes a nested object.
Enrich once per request, log many times. Build the ECS value objects in middleware and attach them via Laravel's context so every record carries them without touching call sites:
// app/Http/Middleware/EcsLogContext.php
public function handle($request, Closure $next)
{
$service = new Service();
$service->setName(config('app.name'));
$service->setVersion(config('app.version'));
Log::withContext([
'service' => $service,
'http' => [
'request' => ['method' => $request->method()],
'url' => ['path' => $request->path()],
],
'trace' => ['id' => $request->header('X-Trace-Id')],
]);
return $next($request);
}
context is merged by Monolog, so a later Log::info('x', ['labels' => ['queue' => 'emails']]) adds keys without losing the request-level ones.
Exceptions: wrap, don't pass the Throwable raw.
use Elastic\Types\Error as EcsError;
try {
$this->charge($order);
} catch (\Throwable $e) {
Log::error('Payment failed', ['error' => new EcsError($e)]);
throw $e;
}
Passing ['exception' => $e] yields either an empty object or an unrelated exception blob — Monolog's own normalizer doesn't produce ECS error.* fields, and $record->context is json-encoded as-is, so you get whatever json_encode manages on the exception object. Centralize this in App\Exceptions\Handler::report() (or a reportable closure per exception class) so you never do it by hand:
public function report(Throwable $e): void
{
Log::error($e->getMessage(), ['error' => new EcsError($e)]);
parent::report($e);
}
Tracing. new Tracing($traceId, $transactionId) emits trace.id / transaction.id, which is what actually enables log↔APM correlation in the Logs UI. If the Elastic APM PHP agent is installed, derive the IDs from elastic_apm_get_current_transaction() rather than inventing your own, and inject them in the same middleware — one source of truth.
Multi-tenant / environment isolation. Add labels.tenant_id, labels.environment, service.environment rather than nesting arbitrary objects; these are keyword-friendly and cheap to filter/aggregate in Kibana.
Test the shape, not the text. Assert JSON:
$line = collect(file($path))->last();
$record = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
$this->assertSame('ERROR', $record['log.level']);
$this->assertSame('RuntimeException', $record['error']['type']);
Log::spy() / Log::shouldReceive() still works because you're using the facade; only channel-level tests need the file.
docs/reference/setup.md says "currently only available for Monolog v2" and uses Elastic\Monolog\v2\Formatter\...; the README and docs/Monolog_v3.md say Monolog 3 with Elastic\Monolog\Formatter\.... Trust the vendor directory, not the guide, and pin elastic/ecs-logging in composer.json so a patch release can't silently switch majors on you.log.level, trace.id, ecs.version are dotted keys on purpose. Without expand_keys on the Filebeat/Agent side they stay literal dotted field names, which is fine for Elasticsearch mapping but breaks any pipeline/dashboard expecting nested log.level. Pick one convention and set expand_keys explicitly.labels must be a flat scalar (string/number/bool). labels is an ECS flat_object-ish field; nested arrays cause mapping explosions and field-limit pain. Version 1.0.1 added key sanitisation for labels.* keys — labels containing dots or spaces get rewritten, so don't rely on labels['my.key'] round-tripping exactly.ecs.version is hardcoded by the formatter (e.g. 1.2.0 in the samples) and will not match your Elastic stack's ECS version. It's informational only — don't build logic on it, and don't "fix" it by subclassing unless you also accept owning that divergence.@timestamp"). Set date_default_timezone_set('UTC') / APP_TIMEZONE=UTC anyway; mixed-offset timestamps make Logs UI range queries lie.user.*. User::setEmail() writes user.email verbatim. Use User::setHash() when you want correlatable-but-obfuscated identifiers; you're one careless Log::info away from emails in a long-retention index (GDPR blast radius).LineFormatter output, and debug-level context gets expensive: every record is json-encoded (and every ECS type object re-serialized) even if the handler later drops it — actually StreamHandler's level check happens before formatting, so gating by level does save the encode. Still, set LOG_LEVEL=info in prod and be deliberate about dumping whole request payloads into context.Log::withContext() mutates the shared Monolog logger. Verify your Octane version resets the log context per request; if unsure, prefer per-call context (Log::info($msg, [...])) or reset explicitly in a terminating listener. A tenant_id label leaking across requests in a multi-tenant app is a security incident, not a cosmetic bug.tail -f storage/logs/ecs-*.json | jq . (each line must be valid JSON — any stray echo/dump()/PHP warning interleaved into stdout breaks the ndjson parser), then check error.message / error.type: json fields that Filebeat adds via add_error_key, then confirm the index pattern maps @timestamp as the time field.ElasticCommonSchemaFormatter and override format() to inject app-wide fields (service.environment, host.name) instead of threading context everywhere; or add a Monolog processor that pushes ECS-shaped arrays into context. The Elastic\Types\* classes are trivial JsonSerializable wrappers — writing your own for http, url, or event is a 20-line job and doesn't depend on the package.How can I help you explore Laravel packages today?