Skip to content

Send Logs Somewhere

The firewall logs every decision through Monolog, so any Monolog handler works. Each entry under logger: is one destination, and you can stack as many as you need.

This page is the common setups. For every handler and every option, see Logging.

A file, to start with

logger:
  - class: Monolog\Handler\StreamHandler
    args:
      - /var/log/firewall/firewall.log
      - Monolog\Level::Info

A relative path resolves against the YAML file that declares it, not the working directory — so the same config writes to the same place under php -S, php-fpm and cron.

A file that does not grow forever

StreamHandler never rotates. On a busy site that is a disk-space incident waiting to happen, so prefer:

logger:
  - class: Monolog\Handler\RotatingFileHandler
    args:
      - /var/log/firewall/firewall.log
      - 14                          # keep two weeks
      - Monolog\Level::Info

Somewhere you can query it

A file answers "what happened just now". It does not answer "how often did this rule fire last month", which is the question that actually comes up. For that, log to a database table:

logger:
  - class: Kanopi\Firewall\Logging\DatabaseHandler
    args:
      - { dsn: "mysql://firewall:secret@localhost/security" }
      - firewall_log
      - Monolog\Level::Info

That has real costs — a write on the request path, a table that grows, and a schema that changes between releases. All three are covered in Database logging; read it before turning this on in production, and set up retention at the same time rather than later.

Paging a human, but only when it matters

Every handler takes its own level, so tee everything to a file and escalate only what deserves waking somebody:

logger:
  # Everything
  - class: Monolog\Handler\StreamHandler
    args:
      - /var/log/firewall/firewall.log
      - Monolog\Level::Info

  # Only the things that mean something is broken
  - class: Monolog\Handler\SlackWebhookHandler
    args:
      - "%env(SLACK_WEBHOOK_URL)%"
      - "#alerts"
      - "firewall"
      - true
      - null
      - false
      - true
      - Monolog\Level::Critical

A chatty alert channel is an ignored alert channel

Warning is the level the firewall uses for every observed-mode match and every panic switch reminder. Pointing Slack at Warning will page you continuously on a healthy site. Critical and above is the useful threshold for anything that interrupts a person.

Slack, Pushover, IFTTT and Telegram all need ext-curl. Mail handlers may need a transport package installed.

Every one of those blocks the request while it sends

SlackWebhookHandler, LogglyHandler, InsightOpsHandler, TelegramBotHandler and the rest make a synchronous HTTPS round trip inside write(), once per record — on a component that logs per decision. A log service having a slow afternoon becomes a slow site.

The firewall catches a handler that throws and reports it as a degraded backend. Nothing catches one that hangs, and a slow site is harder to diagnose than a failed one.

Two ways out, below, and the first one is better.

Better: write lines, let something else ship them

logger:
  - class: Monolog\Handler\StreamHandler
    args:
      - php://stdout
      - Monolog\Level::Info
    formatter:
      class: Monolog\Formatter\JsonFormatter

Then point Vector, Fluent Bit, Filebeat or promtail at it. Delivery, retries, batching and backpressure are handled by software built for exactly that, and the firewall's cost is a write() to a local stream.

For a containerised deployment this is simply the right answer, and it needs nothing from this package. Reach for the next section only when you cannot do it.

If you must send HTTP from PHP: defer it

logger:
  - class: Kanopi\Firewall\Logging\Handler\DeferredHandler
    args:
      # The handler to flush into, once the visitor has been served.
      - class: Monolog\Handler\LogglyHandler
        args: ["%env(LOGGLY_TOKEN)%", Monolog\Level::Warning]
      - 0                       # buffer limit; 0 holds everything
      - Monolog\Level::Warning
handle()   →  buffer in memory, return immediately
shutdown   →  fastcgi_finish_request()   ← the visitor is served here
           →  flush the buffer to the wrapped handler

A wrapper rather than a thirteenth HTTP handler: Monolog's are maintained, and reimplementing the Datadog, Loki and Splunk payload formats is a treadmill. What was missing is a way to get any of them off the request path.

It works with any handler, not only the HTTP ones — and on a framework that already closes the connection itself (Symfony's Response::send() calls fastcgi_finish_request()), the call here is a harmless no-op.

What deferring does not fix

It cannot bound how long the flush takes. After the response is sent a hung request is no longer a slow page, but it is still an FPM worker held out of the pool, and enough of them is an outage by another route. Set a timeout on the handler you wrap where it has one — SocketHandler has setConnectionTimeout() and setWritingTimeout(); the curl-based handlers expose none, which is worth knowing before choosing one.

Buffering trades durability for latency. A fatal that kills the process before shutdown loses the buffer. That is the right trade for a firewall log and the wrong one for an audit log.

Under CLI there is nothing to release. bin/firewall-check and firewall-doctor have no connection to close, so records are flushed at shutdown without the early release rather than dropped.

Wrapping handlers, generally

A wrapping handler takes another handler as its first argument, and until 2.31.0 YAML had no way to say that — so the answer for wrapping anything was "write PHP instead". Any nested {class, args} block is now built as a handler, which makes Monolog's own wrappers configurable too:

logger:
  # Keep debug records in memory; write them all only if something goes wrong.
  - class: Monolog\Handler\FingersCrossedHandler
    args:
      - class: Monolog\Handler\StreamHandler
        args: [/var/log/firewall/firewall.log, Monolog\Level::Debug]
      - Monolog\Level::Error

A nested block that is not a handler class is rejected the same way a top-level one is, and a handler whose constructor genuinely takes an array is unaffected.

Check it is actually writing

$ firewall-doctor config/firewall.yml

A handler that cannot reach its destination — an unwritable path, an unreachable database — is reported there rather than failing silently on the next request.

Then what

Every handler and option Logging
Keep secrets out of the log Sensitive value redaction
Wrap handlers (FingersCrossed, Buffer, Filter) Wrapping handlers, or inject your own logger
Stop a remote handler slowing the request Defer it
Trim an oversized log table firewall-log-prune config.yml
React to decisions in code instead of reading logs React to Decisions