hexanet/monolog-extra-bundle
Symfony bundle that extends Monolog with extra logging tools, making it easier to enrich log records with additional context and configure richer logging pipelines in your application. It integrates with Symfony's service configuration for straightforward setup and minimal boilerplate.
This bundle is a small, single-purpose Symfony integration: it registers Monolog processors (client IP, request context, response/status context) and a request/response logger so that a log line contains the HTTP context without you passing it around manually. It is a legacy package (last "real" feature work 2018-11-14, 3 stars, 0 dependents) that still receives occasional metadata/patch releases — most recently v3.0.2 — so treat it as something you adopt only when inheriting a Symfony 2.8/3.x/4.1-era app, not something you add to a new project.
Install and register
composer require hexanet/monolog-extra-bundle
new Hexanet\MonologExtraBundle\HexanetMonologExtraBundle() to AppKernel::registerBundles().config/bundles.php. There is no Flex recipe, so you register it by hand.Pin the exact version in composer.json (e.g. "hexanet/monolog-extra-bundle": "3.0.2") rather than floating a caret constraint. The package ships patch releases that change the shape of log payload keys (see v3.0.2 below), and with no dependents there is no ecosystem pressure to keep such changes semver-major.
Where to look first
README.md — the config example is short and is effectively the documentation.src/DependencyInjection/Configuration.php — this, not the README, is the source of truth for the config tree and its node names. Read it before writing YAML.src/Resources/config/services.* — shows the concrete service IDs and which monolog.processor tags the bundle adds. Verify with bin/console debug:container --tag=monolog.processor.CHANGELOG.md / the GitHub releases page — the notes are terse to the point of being cryptic ("Additions key is not 'nomalized' anymore"), so read them as "the shape of what lands in record['extra'] changed" and diff the actual processor source between tags before upgrading.First use case: a dedicated request log
# config/packages/monolog.yaml
monolog:
channels: ['request']
handlers:
request:
type: stream
path: '%kernel.logs_dir%/request.log'
level: info
channels: ['request']
Then, wherever you need it, inject the channel-specific logger rather than the generic one:
# config/services.yaml
services:
_defaults:
bind:
Psr\Log\LoggerInterface $requestLogger: '@monolog.logger.request'
final class CheckoutController
{
public function __construct(private LoggerInterface $requestLogger) {}
public function __invoke(Request $request, Cart $cart): Response
{
$this->requestLogger->info('checkout.start', ['cart_id' => $cart->getId()]);
// ...
}
}
The point of the exercise: request.log lines are enriched with method/URI/route/client IP automatically, so a grep for checkout.start gives you the full HTTP context for free.
Attach processors per-handler, not globally. The bundle's processors are ordinary Monolog processors tagged monolog.processor. Prefer scoping them with the channel attribute so that console/worker logs don't carry useless request context:
services:
Hexanet\MonologExtraBundle\Processor\RequestProcessor:
tags:
- { name: monolog.processor, channel: request }
A global tag (no channel) is tempting but pollutes every record, including Messenger/CLI ones where there is no request.
Keep the request log "fail-only". The most common real-world wiring is a fingers_crossed handler: buffer request logs at debug level, only flush to disk when an error is logged. That gives you full request breadcrumbs on failure and near-zero noise on success:
monolog:
handlers:
request_buffer:
type: fingers_crossed
action_level: error
handler: request_file
channels: ['request']
request_file:
type: stream
path: '%kernel.logs_dir%/request.log'
level: debug
channels: ['request']
Correlate across channels. Add a request-ID processor early (the bundle's extra processors are a convenient place to hang this) that writes extra.request_id, then include it in the line formatter so app.log and request.log can be joined:
monolog:
handlers:
main:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
formatter: monolog.formatter.line
services:
monolog.formatter.line:
class: Monolog\Formatter\LineFormatter
arguments:
- "[%%datetime%%] [%%level_name%%] [%%extra.request_id%%] %%channel%%: %%message%% %%context%%\n"
Because the extra keys are not guaranteed stable across releases, keep the formatter tolerant: emit %%extra%% as a whole in JSON, or supply explicit array keys, rather than hard-coding a single key name in a bracket template that renders empty when a key moves.
Log responses from a kernel.response listener, not from the controller. Response status and timing only exist after the kernel has handled the request. The bundle's request/response logger already listens there — if you need your own fields (duration, memory, route), add a listener at a low priority after the bundle's and use RequestStack for the request side.
Use named channels per surface. request, security, api as separate channels keeps channels: ['!event'] style exclusion lists manageable and lets you route the API channel to a different handler/file than the web one.
Structured extra over string messages. Whatever the bundle puts in record['extra'], consume it defensively: keys are stable in practice within a pinned version, but v3.0.2 proves they are not frozen — an "additions" key that used to be normalized no longer is. Treat any downstream parser (ELK, Loki, Sentry breadcrumbs) as needing a tolerance layer: normalize key casing and nesting yourself on ingest, or map to a fixed schema in your own processor rather than reading the bundle's keys directly.
-vvv and diff the raw line; then update your Logstash/Loki field mappings or your formatter. Do not assume the key name you saw on 3.0.1 survives.LoggerInterfaces in the container. Autowiring Psr\Log\LoggerInterface does not resolve to the bundle's channel logger — you must use @monolog.logger.request (via bind or @ syntax) or a compiler-pass alias. This is the single most common "why is nothing in my request.log" bug.getCurrentRequest() returns null in CLI, Messenger workers, and long-running commands. Any processor that assumes a request will fatal there. If you copy the processor code into your own app, guard with if (null === $request) return $record;.kernel.request more than once. On Symfony 4.1+ use isMainRequest() (4.4+) / isMasterRequest() (older) or you will log fragments and ESI hits as separate entries.Authorization headers, cookies and query strings routinely contain credentials and PII. If you enable body logging via the bundle config, add a redaction list (password, token, _csrf, authorization) and truncate payloads — an unbounded body in a Monolog record will also balloon your log volume.StreamedResponse hasn't been generated when kernel.response fires, and BinaryFileResponse is pointless to capture. Log status, content-type and Content-Length instead.kernel.response is not "response delivered." It fires before the server actually writes to the socket; a client disconnect after that point won't be reflected in your log.RequestStack access is fine; a DB or HTTP call inside a processor is a latency bug that hides in plain sight.bin/console cache:clear after changing bundle configuration and confirm with debug:container --tag=monolog.processor that the tagged services exist.monolog.processor tag with a channel attribute — any __invoke(array $record): array works; (2) decorating monolog.logger.<channel>; (3) monolog.formatter for output shape. All three are stable Monolog contracts (unlike the bundle's own key naming), so the migration off this bundle is mechanical:final class RequestContextProcessor
{
public function __construct(private RequestStack $requests) {}
public function __invoke(array $record): array
{
$request = $this->requests->getCurrentRequest();
if ($request === null) {
return $record;
}
$record['extra']['request'] = [
'method' => $request->getMethod(),
'uri' => $request->getRequestUri(),
'route' => $request->attributes->get('_route'),
'ip' => $request->getClientIp(),
];
return $record;
}
}
That class replaces the bulk of the value this bundle provides, gives you full control over key naming (so a patch release can never silently reshape your log schema again), has no Symfony version ceiling, and is something you can unit test in isolation — which is the recommended exit path.
How can I help you explore Laravel packages today?