Logging SimpleSAMLPHP to Filebeat

By ZeiP, 11 August, 2025

We wanted to log SimpleSAMLPHP stuff to Filebeat in Amazee Lagoon environment. This could've worked with using the stderr logging method, but for some reason it didn't seem to consistently work and we wanted to divide the logs to fields to make them more usable.

In the end this was fairly easy to achieve: Creating a new Logger class and using that did the trick. First I tried to implement it inside a Drupal module, but obviously not all the SimpleSAMLPHP calls are made with a bootstrapped Drupal. As we don't have any non-Drupal custom modules I just created a patch in our Composer file including the new file as part of the SimpleSAMLPHP code base; a custom Composer module could've done the trick too, of course.

You can enable the new logger class by adding this into your configuration. The logging format is defined this way to make it possible to retrieve the trackid to the message; it seems that the logger doesn't in itself have access to the trackid.

use SimpleSAML\Logger\FilebeatLoggingHandler;

$config = [
  'logging.handler' => FilebeatLoggingHandler::class,
  'logging.format' => '%stat#%trackid#%msg',
  'logging.host' => 'application-logs.lagoon.svc',
  'logging.port' => '5140',
];
<?php
# src/SimpleSAML/Logger/FilebeatLoggingHandler.php

declare(strict_types=1);

namespace SimpleSAML\Logger;

use SimpleSAML\{Configuration, Logger, Utils};
use SimpleSAML\Logger\LoggingHandlerInterface;

/**
 * A logger that sends messages to Filebeat.
 *
 * @package SimpleSAMLphp
 */
class FilebeatLoggingHandler implements LoggingHandlerInterface
{
    protected string $host;
    protected int $port;
    protected $socket;
    protected string $processname;
    protected string $format;
    
    /**
     * This array contains the mappings from syslog log levels to names. Copied more or less directly from
     * SimpleSAML\Logger\ErrorLogLoggingHandler.
     *
     * @var array<int, string>
     */
    private static array $levelNames = [
        Logger::EMERG   => 'EMERGENCY',
        Logger::ALERT   => 'ALERT',
        Logger::CRIT    => 'CRITICAL',
        Logger::ERR     => 'ERROR',
        Logger::WARNING => 'WARNING',
        Logger::NOTICE  => 'NOTICE',
        Logger::INFO    => 'INFO',
        Logger::DEBUG   => 'DEBUG',
    ];
    
    /**
     * Build a new logging handler based on syslog.
     * @param \SimpleSAML\Configuration $config
     */
    public function __construct(Configuration $config)
    {
        $this->host = $config->getOptionalString('logging.host', '');
        // For some reason the logger doesn't work (SimpleSAML just omits it)
        // if the port setting is an int in the config file, so use string.
        $this->port = (int) $config->getOptionalString('logging.port', '');
        $this->socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
        // Remove any non-printable characters before storing
        $this->processname = preg_replace(
            '/[\x00-\x1F\x7F\xA0]/u',
            '',
            $config->getOptionalString('logging.processname', 'SimpleSAMLphp'),
        );
    }
    
    /**
     * @{inheritdoc}
     */
    public function setLogFormat(string $format): void {
        $this->format = $format;
    }
    
    /**
     * Log a message.
     *
     * @param int $level The log level.
     * @param string $string The formatted message to log.
     */
    public function log(int $level, string $string): void
    {
        // We can't access the trackid and stat straight, so get from msg.
        $data = explode('#', $string);
        $message = [
            'type' => getenv('LAGOON_PROJECT') . '-' . getenv('LAGOON_ENVIRONMENT'),
            'host' => getenv('HOSTNAME'),
            'application' => $this->processname,
            '@timestamp' => (new \DateTimeImmutable())->format('c'),
            'message' => $data[2],
            'trackid' => $data[1],
            'ip' => $_SERVER['REMOTE_ADDR'],
            'stat' => $data[0],
        ];
        if (array_key_exists($level, self::$levelNames)) {
            $message['level'] = self::$levelNames[$level];
        }
        $string = json_encode($message);
        $stuff = $string . '#' . $this->host . '#' . $this->port;
        socket_sendto($this->socket, $string, strlen($string), MSG_EOR, $this->host, $this->port);
    }
}