Saturday 19 September 2009

Logging to MongoDb and accessing log collections with Zend_Tool

Influenced by a recent blog post of a colleague of mine and by being kind of broke on a Saturday night; I tinkered with the just recently discovered MongoDb and hooked it into the Zend_Log environment by creating a dedicated Zend_Log_Writer. The following post will therefore present a peek at a prototypesque implementation of this writer and show how the afterwards accumulated log entries can be accessed and filtered with a custom Zend_Tool project provider.

Logging to a MongoDb database

The following steps assume that an instance of a MongoDb server is running and that the required PHP MongoDb module is also installed and loaded. To by-pass log entries to a MongoDb database there is a need to craft a proper Zend_Log_Writer. This can be achieved by extending the Zend_Log_Writer_Abstract class, injecting a Mongo connection instance and implementing the actual write functionality as shown in the next listing.
<?php
require_once 'Zend/Log/Writer/Abstract.php';

class Recordshelf_Log_Writer_MongoDb extends Zend_Log_Writer_Abstract
{
    private $_db;
    private $_connection;

   /**
    * @param Mongo $connection The MongoDb database connection
    * @param string $db The MongoDb database name
    * @param string $collection The collection name string the log entries 
    */
    public function __construct(Mongo $connection, $db, $collection)
    {
        $this->_connection = $connection;
        $this->_db = $this->_connection->selectDB($db)->createCollection(
            $collection
        );
    }
    public function setFormatter($formatter)
    {
        require_once 'Zend/Log/Exception.php';
        throw new Zend_Log_Exception(get_class() . ' does not support formatting');
    }
    public function shutdown()
    {
        $this->_db = null;
        $this->_connection->close();
    }
    protected function _write($event)
    {
        $this->_db->insert($event);
    }
   /**
    * Create a new instance of Recordshelf_Log_Writer_MongoDb
    * 
    * @param  array|Zen_Config $config
    * @return Recordshelf_Log_Writer_MongoDb
    * @throws Zend_Log_Exception
    * @since  Factory Interface available since release 1.10.0
    */
    static public function factory($config) 
    {
        $exceptionMessage = 'Recordshelf_Log_Writer_MongoDb does not currently '
            . 'implement a factory';
        throw new Zend_Exception($exceptionMessage);
    }
}
With the MongoDb writer available and added to the library directory of the application it's now possible to utilize this new storage backend as usual with the known Zend_Log component. The Mongo connection injected into the writer is configured via Zend_Config and initialized via the Zend_Application bootstrapping facility as shown in the listings below.

application/configs/application.ini
[production]
app.name = recordshelf

....

log.mongodb.db = zf_mongo
log.mongodb.collection = recordshelf_log
log.mongodb.server = localhost
log.priority = Zend_Log::CRIT

....
application/Bootstrap.php
<?php

class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
    protected $_logger;

    protected function _initConfig()
    {
        Zend_Registry::set('config', new Zend_Config($this->getOptions()));
    }

    protected function _initLogger()
    {
        $this->bootstrap(array('frontController', 'config'));
        $config = Zend_Registry::get('config');     

        $applicationName = $config->app->get('name', 'recordshelf');
        $mongoDbServer = $config->log->mongodb->get('server', '127.0.0.1');
        $mongoDbName = $config->log->mongodb->get('db', "{$applicationName}_logs");
        $mongoDbCollection = $config->log->mongodb->get('collection', 'entries');

        $logger = new Zend_Log();
        $writer = new Recordshelf_Log_Writer_MongoDb(new Mongo($mongoDbServer), 
        $mongoDbName, $mongoDbCollection);

        if ('production' === $this->getEnvironment()) {
            $priority = constant($config->log->get('priority', Zend_Log::CRIT));
            $filter = new Zend_Log_Filter_Priority($priority);
            $logger->addFilter($filter);
        }
        $logger->addWriter($writer);
        $this->_logger = $logger;
        Zend_Registry::set('log', $logger);
    }
}
controllers/ExampleController.php
<?php

class ExampleController extends Zend_Controller_Action
{
    private $_logger = null;

    public function init()
    {
        $this->_logger = Zend_Registry::get('log');
    }

    public function fooAction()
    {
        $this->_logger->log('A debug log message from within action ' . 
            $this->getRequest()->getActionName(), Zend_Log::DEBUG);
    }

    public function barAction()
    {
        $this->_logger->log('A debug log message from within ' . 
            __METHOD__, Zend_Log::DEBUG);
    }
}

Accessing the log database with a Zend_Tool project provider

After handling the application-wide logging with the MongoDb writer sooner or later the issue to access the gathered log entries will rise. For this mundane and recurring use case the ProjectProvider provider of the Zend_Tool framework is an acceptable candidate to hook a custom action into the Zend_Tool environment of a given project. Therefor a new Zend_Tool_Project Project provider is first scaffolded via the forthcoming command.
sudo zf create project-provider mongodb-logs filter
Second the generated provider skeleton its filter action is enliven with the logic to query the MongoDb database and the stored log collection. The action to come accepts three arguments to filter the stored log entry results by a specific date in the format of 'YYYY-MM-DD' and a given Zend_Log priority (currently limited to the constants defined in Zend_Log) in a specific application environment. The next listing shows the implementation of the import action of the MongodbLogsProvider project provider; which is clearly, as it's length indicates, in need for a clean-up task.

providers/Mongodb-logsProvider.php
<?php
require_once 'Zend/Tool/Project/Provider/Abstract.php';
require_once 'Zend/Tool/Project/Provider/Exception.php';
require_once 'Zend/Date.php';
require_once 'Zend/Validate/Date.php';
require_once 'Zend/Log.php';
require_once 'Zend/Config/Ini.php';

class MongodbLogsProvider extends Zend_Tool_Project_Provider_Abstract
{

    public function filter($date = null, $logPriority = null, 
        $env = 'development')
    {
        $ref = new Zend_Reflection_Class('Zend_Log');
        $logPriorities = $ref->getConstants();

        if (in_array(strtoupper($date), array_keys($logPriorities)) || 
            in_array(strtoupper($date), array_values($logPriorities))) {
            $logPriority = $date;
            $date = null;
        }
        if (!is_null($date)) {
            $validator = new Zend_Validate_Date();
            if (!$validator->isValid($date)) {
                $exceptionMessage = "Given date '{$date}' is not a valid date.";
                throw new Zend_Tool_Project_Provider_Exception($exceptionMessage);
            }
            $dateArray = array();
            list($dateArray['year'], $dateArray['month'], $dateArray['day']) = 
                explode('-', $date);
            $date = new Zend_Date($dateArray);
        } else {
            $date = new Zend_Date();
        }
        $date = $date->toString('Y-MM-dd');

        if (!is_null($logPriority)) {
            if (!is_numeric($logPriority)) {
                $logPriority = strtoupper($logPriority);
                if (!in_array($logPriority, array_keys($logPriorities))) {
                    $exceptionMessage = "Given priority '{$logPriority}' is not defined.";
                    throw new Zend_Tool_Project_Provider_Exception($exceptionMessage);
                } else {
                    $logPriority = $logPriorities[$logPriority];
                }
            }
        if (!in_array($logPriority, array_values($logPriorities))) {
            $exceptionMessage = "Given priority '{$logPriority}' is not defined.";
            throw new Zend_Tool_Project_Provider_Exception();
        }
            $priorities = array_flip($logPriorities);
            $priorityName = $priorities[$logPriority];
        }

        if ($env !== 'development' && $env !== 'production') {
            $exceptionMessage = "Unsupported environment '{$env}' provided.";
            throw new Zend_Tool_Project_Provider_Exception();
        }
        $config = new Zend_Config_Ini('./application/configs/application.ini', 
            $env);

        $applicationName = $config->app->get('name', 'recordshelf');
        $mongoDbServer = $config->log->mongodb->get('server', '127.0.0.1');
        $mongoDbName = $config->log->mongodb->get('db', "{$applicationName}_logs");
        $mongoDbCollection = $config->log->mongodb->get('collection', 'entries');

        try {
            $connection = new Mongo($mongoDbServer);
            $db = $connection->selectDB($mongoDbName)->createCollection(
            $mongoDbCollection);
        } catch (MongoConnectionException $e) {
            throw new Zend_Tool_Project_Provider_Exception($e->getMessage());
        }
        $dateRegex = new MongoRegex("/$date.*/i");

        if (is_null($logPriority)) {
            $query = array('timestamp' => $dateRegex);
            $appendContentForResults = "Found #amountOfEntries# log entrie(s) "
                . "on {$date}";
            $appendContentForNoResults = "Found no log entries on {$date}";
        } else {            
            $query = array('priority' => (int) $logPriority, 
                           'timestamp' => $dateRegex
                     );
            $appendContentForResults = "Found #amountOfEntries# log entrie(s) "
                . "for priority {$priorityName} on {$date}";
            $appendContentForNoResults = "Found no log entries for priority "
                . "{$priorityName} on {$date}";
        }

        $cursor = $db->find($query);
        $amountOfEntries = $cursor->count();

        if ($amountOfEntries > 0) {
            $content = str_replace('#amountOfEntries#', $amountOfEntries, 
                $appendContentForResults);
            $this->_registry->getResponse()->appendContent($content);
            foreach ($cursor as $id => $value) {
                $content = "{$id}: {$value['timestamp']} > ";
                if (is_null($logPriority)) {
                    $content.= "[{$value['priorityName']}] ";
                }
                $content.= "{$value['message']}";
                $this->_registry->getResponse()->appendContent($content);
            }
        } else {
            $content = $appendContentForNoResults;
            $this->_registry->getResponse()->appendContent($content);
        }
        $connection->close();
    }
}
The coming outro screenshots show two use cases for the filter action of the MongodbLogsProvider issued against the zf command line client. The first screenshot shows the use case where all log entries for the current day are queried, while the second one shows the use case where all log entries for a specific date and log priority are queried and fed back to the user.

All log entries of the current day

All CRIT log entries for a specific date

3 comments:

tauven said...

nice post.
just a little question why dont you use __METHOD__ in your logging call, instead of $this->getRequeste()->getActionName(). saves you two function calls everytime you log something. beside that, it is less to type ;-)

Raphael Stolt said...

I used $this->getRequest()->getActionName() in the ExampleController to get only the action name, but I can see why __METHOD__ is more suiteable as it also gives you the controller name hosting the action; and therefore makes it faster to jump to the right places. Changed the example code; it uses now both 'ways'.

Henrique Moody said...

Thanks for the great post.
http://pastebin.com/pevSxHdB

Att,
Henrique Moody