Monday, July 8, 2013

PHP Logging Class

The last post talked about a PHP Cron Controller, the problem is that because it's a fire and forget controller, it does not capture the output (including errors) of the jobs and do anything with it. That's why we need a way to log the output and the errors to a file for later review.

Since I prefer to do most everything in classes to keep functions and variables isolated,  that's what I did for this set of functions.

The first thing we need to do is to initiate the class. In this function we will be determining the name of the cron job that is to be logged. We can either do this by submitting the job name (process) or it can be determined by the file name that initiated the function. Obviously it's faster to submit the process name, but I like flexibility. If you capture the output of debug_backgrace function, you'll see that it has the file name of the function that called the current function. This is what we use to get the process name if it's not submitted to the class constructor.

The constructor also allows us to change the file that the logs are written into. For safety, we perform some tests on the file specified to make sure it exists and that we can write to it. If either of these tests fail, we use PHP to see if we can fix the problem. We could use shell commands, but my experience is that most servers don't allow PHP to execute shell commands so I'd say, don't bother unless you know you have the ability.

If after all the tests we determine that we can't write to the log file, there is no point in letting the later functions work so we set a flag (writeError) in the class so we can kill other functions immediately.
    public function __construct($process=null, $logFile='cron.log') {
        if($process == null || $process == '') {
            $callers = debug_backtrace();
            $file = $callers[0]['file'];
            $fA = explode('/', $file);
            $file = array_pop($fA);
            $fA = explode('.', $file);
            array_pop($fA);
            if(count($fA) > 1) {
                $this->process = implode('.', $fA);
            } else {
                $this->process = $fA[0];
            }
        } else {
            $this->process = $process;
        }
        
        $this->logFile = LOG_PATH . '/' . $logFile;
        if(!file_exists($this->logFile)) {
            if(file_put_contents($this->logFile, 'Log Created '.date('M d, Y H:i:s')."\n") === false) {
                $this->writeError = true;
            }
        } elseif(!is_writable($this->logFile)) {
            $t2 = @chmod($this->logFile, 0777);
            if($t2 === false) {
                $this->writeError = true;
            }
        }
    }
The class is initiated in the cron job by calling:
$log = new Log_Model('metrics');

Next we want to be able to actually write some useful lines to the log file. The only thing that this function requires is the string to output to the log file. We'll discuss the $die in a little bit. For safety, we perform a very basic test on the input to make sure it is a string and that it's not empty. If the constructor couldn't write to the log file, we don't even attempt to write to the file here, but we do let the other portions of the function work. For standards sake, I like to begin each line with the date and time then the process that this line relates to.

I like to use the file_put_contents function as opposed to using the file handle method because it's simpler. We still have the ability to do the append by adding the FILE_APPEND flag. Since multiple jobs could be writing to the log file at once, we add the LOCK_EX flag.

The die portion of this function has to do with the error handling but it can also be used in the cron job if for instance a database call yielded no results. But since this is less clear in the cron job, I like to implicitly put the exit in the job itself rather than a true in the function call like $log->writeLogLine('Log this line', ture).
    public function writeLogLine($str, $die=false) {
        if(!is_string($str) || $str == '') {
            return false;
        }
        $r = false;
        if($this->writeError == false) {
            $line = date('M d, Y H:i:s')."\t[$this->process]\t$str\n";
            if(file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX) !== false) {
                $r = true;
            }
        }
        if($die === true) {
            exit;
        }
        return $r;
    }

The last portion of the class is the error handling. To be fair, this is mostly stolen from the PHP documentation. I've done some minor changes in function where it will send the error output to the log file (via the writeLogLine function) and send the die command to the function if it is a fatal error.
    public function errorOut($errno, $errstr, $errfile, $errline) {
        if (!(error_reporting() & $errno)) {
            // This error code is not included in error_reporting
            return;
        }

        $die = false;
        $str = '';
        switch ($errno) {
            case E_USER_ERROR:
                $str .= "FATAL ERROR [$errno] $errstr in $errfile on line $errline\n";
                $die = true;
                break;

            case E_USER_WARNING:
                $str .= "WARNING [$errno] $errstr in $errfile on line $errline\n";
                break;

            case E_USER_NOTICE:
                $str .= "NOTICE [$errno] $errstr in $errfile on line $errline\n";
                break;

            default:
                $str .= "Unknown error type: [$errno] $errstr in $errfile on line $errline\n";
                break;
        }
        
        $this->writeLogLine($str, $die);

        // if you return false, it will still execute PHP's error handling
        return true;
    }

To enable the error handling, I prefer to explicitly call it in the cron job as opposed to doing it in the class constructor because it's much clearer in the cron job. To do this simply call:
set_error_handler(array($log, 'errorOut'));
The $log is the instance of the class and the errorOut is the function to use in the event of an error.

This method of handling logging and errors works well for jobs that work in the background that you don't typically see the output of. If you also wanted to see the output, it would be easy to add an echo into the writeLogLine function, but for my application, it was completely unnecessary.

No comments:

Post a Comment