SuperAPI AI Directory

symfony-yoandev-observability

Deployment & operations

View repository

Updated 10-16 08:08

About this skill

Observability

Tier: on demand — channels and redaction matter from the first production deploy; correlation ids, the worker heartbeat and health probes are switched on by a real worker, a real orchestrator, a real incident.

An application that reaches production and then goes silent is not finished. This skill covers what the application emits and how it is structured: channels, levels, processors, redaction, alerts, health. Where logs go is infrastructure and out of scope — a file, stdout, a managed aggregator, a Grafana stack. The prod handler configuration (php://stderr, JSON) is already symfony-yoandev-deployment's; profiling and Stopwatch are symfony-yoandev-performance's; the dev server's log stream is symfony-yoandev-local-dev's.

The pact this suite already made

The standard mandates #[WithLogLevel] on expected business exceptions so they stop drowning real failures. That only pays off if something watches what remains. The ladder Symfony applies, from ErrorListener::resolveLogLevel():

Exception Level
Not an HttpExceptionInterface, or status ≥ 500 critical
HttpExceptionInterface with a 4xx status (a plain 404 included) error
Carries #[WithLogLevel] whatever the attribute says
Listed in framework.exceptions with a log_level that — the config wins over the attribute

So critical means "a request or a message died on something nobody planned for", and that is the level an alert handler subscribes to; error is not a useful threshold, because every 404 that excluded_http_codes does not filter lands there. framework.exceptions also takes a log_channel, moving a whole family of expected exceptions off the request channel entirely — log_channel: business with log_level: info produces business.INFO, verified on 8.1 (check config:dump-reference framework exceptions on an older version).

Channels

A single log stream stops being readable at the size where you would actually need it: on any real page Doctrine alone emits one record per statement, Messenger one per dispatch, the cache one per miss, and the ten lines your own code wrote are a rounding error. Channels are how you filter before the data leaves the process. Symfony ships them already — doctrine, messenger, request, security, cache, http_client, mailer, console, router, event, php, deprecation — so add your own with monolog.channels: ['deprecation', 'business', 'alert'] for anything worth watching separately, then inject that channel's logger. Four ways work, verified on monolog-bundle 4.0:

How Notes
#[WithMonologChannel('business')] on the class Clearest. Monolog 3.5 + monolog-bundle 3.9
#[Target('business')] on the LoggerInterface argument Argument-level, when one class needs two channels
Name the argument $businessLogger Works, invisible. Rename the variable and the channel silently changes
#[Autowire(service: 'monolog.logger.business')] The escape hatch when the alias does not exist

One trap, measured. #[Target] and #[Autowire(service:)] fail at compile time on a typo, listing every channel that does exist. #[WithMonologChannel] does not: a misspelt channel is silently created, escapes every handler filter, and nothing warns you. debug:container --tag=monolog.channel_logger lists them; a name you do not recognise is a typo.

fingers_crossed, and why a working request logs nothing

The prod recipe wraps the stream handler in fingers_crossed. It buffers everything and writes nothing until one record reaches action_level (error by default), then flushes the entire buffer — the debug trail that led to the failure — and keeps passing records through for the rest of the process. Two consequences people report as bugs: a successful request writes zero lines (so info calls tracing a happy path never appear in production), and a failing one dumps up to buffer_size (50 in the recipe) records at once, including every Doctrine statement with its bound parameter values — the point of the mechanism, and the biggest single source of secrets in logs.

In a Messenger worker the handler is reset between messages (kernel.reset, via Messenger's ResetServicesListener), so a failing message flushes its own trail and a successful one stays silent, exactly as in a request. passthru_level and excluded_http_codes: references/monolog.md.

One user action, several processes

A correlation id is what makes a request and the workers it spawned readable as one story. Three pieces: a holder service resolving the id from an X-Request-Id header or generating one (implementing ResetInterface, so a long-running worker does not reuse it); a Monolog processor putting it on every record; a Messenger middleware stamping outgoing envelopes and restoring the id in the worker.

#[AsMonologProcessor]
final readonly class CorrelationIdProcessor
{
    public function __construct(private CorrelationId $correlationId) {}

    public function __invoke(LogRecord $record): LogRecord
    {
        $record->extra['correlation_id'] = $this->correlationId->get();

        return $record;
    }
}

Measured end to end: an id generated in the dispatching process appeared on all 29 records the worker wrote while handling that message, including the framework's own messenger.CRITICAL. This needs no kernel listener — the holder reads RequestStack, which keeps it inside the "kernel listeners are a last resort" rule. Full code, plus the user/token processor: references/monolog.md. #[AsMonologProcessor] is monolog-bundle 3.8+ (priority:, Monolog 3.4 and monolog-bundle 3.11); below that, tag monolog.processor in YAML.

What must never be logged

A log ends up in a backup, in a third-party aggregator, and in a support screenshot. Passwords, tokens, API keys, session ids, card and bank numbers, and personal data beyond what you need to identify the actor do not belong there.

The mechanism is a redacting processor at low priority — it runs before the handlers' own PSR-3 interpolation, so {password} in a message string is redacted too (verified). The trap it cannot fix is a value you concatenated into the message yourself: put data in the context array, never in the message string. The leak to check first is doctrine.dbal.logging — it defaults to %kernel.debug%, so it is off in production, but anyone who switches it on to chase a slow query starts writing every bound parameter at debug, and fingers_crossed flushes those on the next error. Sinks and the processor code: references/sensitive-data.md.

The Messenger failure alert

symfony-yoandev-async states the rule — a message reaching the failure transport must raise an alert — without the mechanism. Here it is: what the framework already does, what a listener adds, and the sink that decides whether any of it is an alert.

The framework already logs it. SendFailedMessageForRetryListener writes messenger.CRITICAL: Error thrown while handling message … Removing from transport after N retries the moment retries are exhausted, before SendFailedMessageToFailureTransportListener (priority -100) moves the envelope. If critical already reaches a human, you are done.

A listener adds the structure — #[AsEventListener] on WorkerMessageFailedEvent, guarded on willRetry() so the three retries stay quiet:

// The canonical copy is references/alerting.md; this one is identical to it.
#[AsEventListener]
#[WithMonologChannel('alert')]
final readonly class AlertOnMessageSentToFailureTransport
{
    public function __construct(private LoggerInterface $logger) {}

    public function __invoke(WorkerMessageFailedEvent $event): void
    {
        if ($event->willRetry()) {
            return;
        }

        $envelope = $event->getEnvelope();

        $this->logger->critical('Message moved to the failure transport', [
            'message_class' => $envelope->getMessage()::class,
            'transport' => $event->getReceiverName(),
            'retries' => RedeliveryStamp::getRetryCountFromEnvelope($envelope),
            'exception' => $event->getThrowable(),
        ]);
    }
}

The sink is the part that decides whether it is an alert. And here is the finding that matters: do not send it by email. Verified — with SendEmailMessage routed to async as this standard requires, a symfony_mailer Monolog handler produces messenger.INFO: Sending message SendEmailMessage with async sender. The alert about the broken queue is queued on the broken queue. Use a sink that does not re-enter Messenger — a slackwebhook handler, error_log, Sentry, or critical on stderr with the platform's own alerting rule — and wrap a third-party sink in a fallbackgroup handler: an unreachable webhook throws, and Monolog lets that exception turn a logged error into a 500 (measured).

A listener cannot fire if no worker is running, which is the most common reason a queue rots. That needs the second mechanism: a worker heartbeat written on WorkerRunningEvent, plus a probe on queue depth — both in references/alerting.md, with the console commands (messenger:stats --format=json, messenger:failed:show --stats) for the shell-script version.

Health checks

A route returning 200 unconditionally tells the orchestrator the container has booted, not that the application works. Test the dependencies:

public function run(): bool
{
    return 1 === (int) $this->connection
        ->executeQuery($this->connection->getDatabasePlatform()->getDummySelectSQL())
        ->fetchOne();
}

Two deliberate exceptions to the standard live in this endpoint, and both are exceptions rather than oversights. The first is here: this is SQL outside a repository. It is allowed because it is not a query about the domain — there is no intent to name and no result to map, it is a connectivity probe — so it stays in a dedicated Health\ service where the exception remains visible instead of spreading. The second is below.

getDummySelectSQL() is portable where a literal SELECT 1 is not (Oracle needs FROM DUAL). Collect the checks with #[AutoconfigureTag] on a HealthCheck interface and #[AutowireLocator] in the controller — with an iterator each check is built by the foreach, outside any try, and one unbuildable check 500s the whole endpoint (measured). Return 503 when any check fails; a probe that always answers 200 is decorative.

That is the second exception: the standard says an action returns data and #[Serialize] decides the representation, but #[Serialize]'s code is fixed at attribute level, so it cannot express a status that depends on the result. A bare JsonResponse is therefore correct here — because the attribute genuinely cannot do it, not because it was more convenient — and nowhere else.

Verified running: {"status":"degraded","checks":{"database":"ok","failure_queue":"failed","worker":"ok"}} with HTTP 503. Full implementation, and which checks belong on a liveness versus a readiness probe: references/alerting.md.

Error tracking

Sentry or an equivalent adds what a log line cannot: the same exception grouped across occurrences, a release marker, the request and user attached, and a notification the first time a fingerprint appears. It replaces nothing — the logs are still the trail that led there. On monolog-bundle 4.0 the built-in sentry and raven handler types were removed; integrate through sentry/sentry-symfony and a service-type handler.

The one thing that matters when wiring it: do not send the exceptions #[WithLogLevel] already marked as expected. The log-level route does this for free — an SDK integrated as a Monolog handler at level: critical never sees the info the attribute produced. The SDK's own ignore list is the second lever; read its current config key with debug:config rather than copying one from a blog post.

When nothing shows up

Symptom Cause
Nothing in the prod log for a working request fingers_crossed — that is correct behaviour, not a broken handler
info calls never appear in production Same. They are buffered and discarded unless the request errors
A whole exception family drowns the log Missing #[WithLogLevel], or a 4xx logged at error — add excluded_http_codes
The alert handler never fires Its level is above what the record carries, or a channels: filter excludes it
Alert emails never arrive when the queue breaks They are queued on the queue that broke. Use a sink outside Messenger
A channel filter has no effect The channel name is a typo — debug:container --tag=monolog.channel_logger
A processor's field is missing from some records It is scoped to a channel or a handler — debug:container --tag=monolog.processor
Logs full of SQL parameters doctrine.dbal.logging was turned on, and fingers_crossed flushed the buffer
Health check returns 200 while the app is broken It tests nothing. Give it a real dependency check
The health endpoint 500s One check could not even be constructed — use #[AutowireLocator], not #[AutowireIterator]
The correlation id changes mid-worker The holder is not ResetInterface, or the middleware is not registered on the bus

Reference files

File When to read it
references/monolog.md Configuring channels and handlers, writing a processor, fingers_crossed options, per-environment setup, asserting in a test that something was logged
references/sensitive-data.md Before logging anything derived from user input, or when a log review turns up a secret
references/alerting.md Wiring the Messenger failure alert and its sink, the worker heartbeat, the queue-depth watchdog, health check endpoints