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

Ecs Logging Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Docs disagree with the codebase on major version and namespace. 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.
  • Everything in 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 timezone was a real bug fixed in 1.0.2 ("include time zone in @timestamp"). Set date_default_timezone_set('UTC') / APP_TIMEZONE=UTC anyway; mixed-offset timestamps make Logs UI range queries lie.
  • PII in 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).
  • Cost/volume. JSON records are larger than 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.
  • Octane context leakage. 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.
  • Debugging a "why is nothing in Kibana" report: 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.
  • Extension points are cheap and safe. Subclass 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.
  • Adoption risk is low but non-zero. ~20 stars, no dependents, single-maintainer Elastic project, surface area of roughly one formatter class. Treat it as vendored code: if it stalls on a future Monolog major, replacing it with a local class that json-encodes the same keys is an afternoon, not a migration. That's also the argument for not letting the ECS type objects leak into your domain layer — keep them at the logging boundary.
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.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style