Drupalwoo ☉

Building a custom Drupal logging channel for clearer site diagnostics

When a Drupal site misbehaves, the first place most developers reach for is the log report at /admin/reports/dblog. That built-in table captures watchdog events, but on a busy government portal or a high-traffic retail site it quickly becomes a wall of indistinguishable rows. A custom logging channel lets you carve out a dedicated stream for a single feature, integration, or subsystem, which makes filtering, retention, and downstream alerting far more practical.

Drupal's logging layer sits on top of Symfony's PSR-3 logger interface, which means every channel you create behaves consistently with the broader PHP ecosystem. You can write to the database through the dblog module, push messages to the operating system's syslog daemon, or hand them off to a third-party aggregator such as an ELK stack running on a local cloud zone. Choosing your own channel name simply tags each record so downstream tooling can route, filter, or silence it.

For Australian operators there are practical reasons to invest in this pattern. The Notifiable Data Breaches scheme under the Privacy Act 1988, along with the Australian Cyber Security Centre's Essential Eight, expects clearly auditable trails for any system touching personal information. A custom channel tagged with the relevant module or business unit turns a vague log entry into evidence you can hand to a compliance officer, a hosting partner like Serversaurus, or an internal security team.

This walk-through covers the moving pieces of a custom Drupal logging channel: how the channel name is registered, how the logger service is injected, how to write log records with the right severity and context, and how to route those records to a destination that suits the operation. The examples use a fictional payments module, but the same approach applies to any custom feature on a Drupal 9 or 10 site.

How Drupal's logger and channel system fits together

Every log call in Drupal passes through \Drupal\Core\Logger\LoggerChannel, which delegates to a factory that resolves the correct LoggerChannelInterface for the requested channel name. When you call \Drupal::logger('mymodule'), the factory first checks whether a channel called mymodule has been declared anywhere in the service container. If so, it returns that specialised logger; if not, it returns a generic default logger that still broadcasts to every backend defined by the active loggers.

The active loggers themselves are pluggable services tagged with logger, and they implement LoggerInterface from PSR-3. Common examples include dblog writing to the watchdog database table, syslog handing off to the host's syslog, and contributed modules that forward to file_log, graylog, or sentry. A custom channel does not need its own backend; it simply provides a stable identifier that those backends can act upon, including their own routing rules.

This separation is what allows a Drupal agency in Brisbane to run one audit pipeline for a Drupal South festival site while a financial services client in Sydney operates a parallel SIEM feed for the same codebase. The application code is unchanged; only the channel name and the routing configuration differ between deployments. The principle of dedicated channels producing cleaner signal shows up in unexpected places; see, for instance, taller-de-estampacion-con-hojas-y-verduras-para-crear-patrones-naturales for an interesting cross-disciplinary example of separation-of-streams in a very different context.

Declaring the channel in a module's services file

The first step in creating a custom channel is registering it in the module's *.services.yml. Drupal will not invent a channel on demand, but you can hint that one is expected and let the factory pick up the default behaviour, or you can bind the channel to a specific service that pre-applies defaults such as a default severity threshold or a request identifier.

A minimal declaration in mymodule.services.yml looks like this:

services:
  logger.channel.payment_gateway:
    parent: logger.channel_base
    arguments:
      - 'payment_gateway'

The logger.channel_base service gives you all the standard behaviours of a logger channel, including the default severity filter and the iterable list of active backends. The argument is the channel name itself, which is also the string you pass to \Drupal::logger() when writing a record. Naming conventions vary, but most Australian teams adopt lowercase with underscores, matching PSR-3 expectations and avoiding spaces in service identifiers.

If you want the channel to be more opinionated, you can extend LoggerChannel directly in a custom service and override log() to inject extra context, redact sensitive fields, or convert specific exceptions into higher-severity events. For most sites, the parent-class approach is enough, and you keep the door open for later subclassing.

For modules that need to expose several channels, you can repeat the pattern with different argument values. A weather alert module might declare weather_alert, weather_alert_pull, and weather_alert_push so downstream filters can distinguish between inbound and outbound traffic. The same applies to any custom integration with an external aggregator or any data exchange with a third-party service.

Injecting the logger service instead of calling the procedural wrapper

Drupal's procedural \Drupal::logger() helper works well in small modules, but it hides the dependency. For classes that are services themselves, constructor injection of the channel factory or the logger interface keeps the dependency explicit and makes the code easier to unit-test with a mock PSR-3 implementation.

A common pattern is to inject logger.channel.payment_gateway directly into a service that needs it. Inside a PaymentProcessor service, the constructor takes the logger as a typed argument, and the service definition in *.services.yml wires it in:

services:
  mymodule.payment_processor:
    class: Drupal\mymodule\PaymentProcessor
    arguments:
      - '@logger.channel.payment_gateway'

Once injected, the processor can call $this->logger->notice('Charge succeeded for order @id', ['@id' => $order_id]) from anywhere in its public methods. The @id placeholder is replaced by the context value, and the record carries the channel tag, the timestamp, the user identifier, the request URI, and the severity level automatically.

For controllers and form handlers, the same injection pattern works. A custom submit handler that needs to log a warning about a mistyped input can declare the logger as a create argument, just as it would declare an entity type manager or a current user service. Avoid calling \Drupal::logger() from static methods on classes that already have a container to draw from, since that pattern couples the code to the global state and complicates testing.

Writing records with the right severity and context

Drupal uses the eight RFC 5424 severities defined by PSR-3: emergency, alert, critical, error, warning, notice, info, and debug. Picking the right severity is what makes downstream alerting straightforward and what lets a security team tune its pager rules. A failed login attempt that is purely informational belongs at info; a recurring pattern of failed logins belongs at warning; a confirmed brute-force lockout belongs at critical.

Context is where most teams under-invest. PSR-3 placeholders use @ for escaped strings, % for placeholder substitution, and ! for raw HTML, and Drupal follows the same conventions as Symfony. Passing an associative array of context values lets you add structured data to every record: a transaction identifier, a remote IP, a session identifier, a customer reference, or a business unit code. Aggregators that understand JSON will pick those values up automatically and make them searchable across thousands of log records.

For sites that handle sensitive data under the Australian Privacy Principles, context is also a risk surface. Never log full credit card numbers, full Tax File Numbers, or full passport numbers, and consider hashing or truncating identifiers before they leave the application boundary. A simple utility like substr($identifier, 0, 3) . '***' . substr($identifier, -2) is often enough to satisfy an internal review while still leaving the record traceable.

Severity Numeric level Example use case Suggested destination
Emergency 0 Payment provider outage blocking checkout Pager, on-call rotation
Alert 1 Imminent disk failure on a staging server SMS, on-call rotation
Critical 3 Authentication service returning 500s Pager, dashboard banner
Error 4 Unhandled exception in a custom block plugin Database, error tracking
Warning 5 Cache stampede detected on a high-traffic page Log aggregator
Notice 6 Successful cron run, scheduled task completed Database
Info 7 User login, settings form submitted Database
Debug 8 Verbose flow trace during development File, off in production

Severities 0 through 4 typically deserve a paging or alerting path, while 5 through 8 are observational. Setting up the routing in this way means a custom channel can carry all eight severities without confusing the operations team.

Routing the channel to a specific destination

Once the channel exists and the code writes to it, the next decision is where the records should land. Drupal's logger system lets you declare multiple loggers, each implementing LoggerInterface, and the framework will call them in turn for every log call. By stacking a monolog module, a syslog configuration, and a third-party forwarder, you can have the same record appear locally, in syslog, and in a remote aggregator without changing a line of application code.

A common pattern on Australian infrastructure is to keep dblog enabled on staging for quick browsing and to disable it on production in favour of syslog. Production hosts then forward syslog to a centralised rsyslog instance on a private VPC, which in turn pipes into an ELK or Loki stack. Custom channels make the rsyslog filtering rules trivial: a match block on programname containing payment_gateway routes just those records to the payments dashboard, while everything else falls through to a generic bucket.

For SaaS destinations such as Sentry, Datadog, or New Relic, the contributed modules accept channel-aware configuration. You can raise the severity threshold for a noisy channel like cache_invalidation while leaving auth at the default level, so a busy site on a Digital Pacific or Panthur VPS does not blow out its monthly event allowance against noise from alerting. The channel becomes a throttle knob rather than a filter.

If you are running a fully managed hosting stack with a local agency, ask them which loggers are enabled by default. Many Australian hosts, including larger platforms and boutique Drupal shops like PreviousNext, already push watchdog events to a managed SIEM, and your declaration of a custom channel plugs straight into that pipeline without further setup. A short note in the runbook describing what each channel captures is usually enough to keep the operations team on the same page.