Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
File
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
6 / 6
22
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 getFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormatter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeLog
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
8
 withExclusiveLock
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Log\Writer;
16
17use Pop\Log\Formatter;
18
19/**
20 * File log writer class
21 *
22 * @category   Pop
23 * @package    Pop\Log
24 * @author     Nick Sagona, III <dev@noladev.com>
25 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    5.0.0
28 */
29class File extends AbstractWriter
30{
31
32    /**
33     * Log file
34     * @var ?string
35     */
36    protected ?string $file = null;
37
38    /**
39     * Log file type
40     * @var ?string
41     */
42    protected ?string $type = null;
43
44    /**
45     * Formatter, null only when using the legacy xml/json whole-file-rewrite handling
46     * @var ?Formatter\FormatterInterface
47     */
48    protected ?Formatter\FormatterInterface $formatter = null;
49
50    /**
51     * Constructor
52     *
53     * Instantiate the file writer object
54     *
55     * @param  string                        $file
56     * @param  ?Formatter\FormatterInterface $formatter
57     */
58    public function __construct(string $file, ?Formatter\FormatterInterface $formatter = null)
59    {
60        if (!file_exists($file)) {
61            touch($file);
62        }
63
64        $parts = pathinfo($file);
65
66        $this->file = $file;
67        $this->type = $parts['extension'] ?? null;
68
69        $this->formatter = $formatter ?? match (strtolower($this->type ?? '')) {
70            'csv'             => new Formatter\Csv(),
71            'tsv'             => new Formatter\Tsv(),
72            'jsonl', 'ndjson' => new Formatter\NdJson(),
73            'xml', 'json'     => null,
74            default           => new Formatter\Line(),
75        };
76    }
77
78    /**
79     * Get file
80     * @return string
81     */
82    public function getFile(): string
83    {
84        return $this->file;
85    }
86
87    /**
88     * Get type
89     * @return ?string
90     */
91    public function getType(): ?string
92    {
93        return $this->type;
94    }
95
96    /**
97     * Get formatter
98     * @return ?Formatter\FormatterInterface
99     */
100    public function getFormatter(): ?Formatter\FormatterInterface
101    {
102        return $this->formatter;
103    }
104
105    /**
106     * Write to the log
107     *
108     * @param  string $level
109     * @param  string $message
110     * @param  array  $context
111     * @return File
112     */
113    public function writeLog(string $level, string $message, array $context = []): File
114    {
115        if ($this->isWithinLogLimit($level)) {
116            if ($this->formatter !== null) {
117                $entry = $this->formatter->format($level, $message, $context) . PHP_EOL;
118                file_put_contents($this->file, $entry, FILE_APPEND);
119            } else {
120                switch (strtolower($this->type)) {
121                    case 'xml':
122                        $messageContext = $this->getContext($context);
123
124                        $entry  = ($messageContext != '') ?
125                            '    <entry timestamp="' . $context['timestamp'] . '" priority="' .
126                            $level . '" name="' . $context['name'] . '" context="' . $messageContext .
127                            '"><![CDATA[' . $message . ']]></entry>' . PHP_EOL :
128                            '    <entry timestamp="' . $context['timestamp'] . '" priority="' .
129                            $level . '" name="' . $context['name'] . '"><![CDATA[' . $message . ']]></entry>' . PHP_EOL;
130
131                        $this->withExclusiveLock(function ($output) use ($entry) {
132                            if (strpos($output, '<?xml version') === false) {
133                                $output = '<?xml version="1.0" encoding="utf-8"?>' . PHP_EOL .
134                                    '<log>' . PHP_EOL . '</log>' . PHP_EOL;
135                            }
136                            return str_replace('</log>' . PHP_EOL, $entry . '</log>' . PHP_EOL, $output);
137                        });
138                        break;
139
140                    case 'json':
141                        $messageContext = $this->getContext($context);
142
143                        $newEntry = [
144                            'timestamp' => $context['timestamp'],
145                            'priority'  => $level,
146                            'name'      => $context['name'],
147                            'message'   => $message,
148                            'context'   => $messageContext
149                        ];
150
151                        $this->withExclusiveLock(function ($output) use ($newEntry) {
152                            $json = (strpos($output, '{') !== false) ?
153                                json_decode($output, true) : [];
154                            $json[] = $newEntry;
155                            return json_encode($json, JSON_PRETTY_PRINT);
156                        });
157                        break;
158                }
159            }
160        }
161
162        return $this;
163    }
164
165    /**
166     * Run a read-modify-write cycle against the log file under an exclusive lock, to prevent two
167     * concurrent writers from racing on file_get_contents()/file_put_contents() and silently losing
168     * one writer's entry. $transform receives the file's current content and must return the new
169     * content to write back.
170     *
171     * @param  callable $transform
172     * @throws Exception
173     * @return void
174     */
175    protected function withExclusiveLock(callable $transform): void
176    {
177        $handle = @fopen($this->file, 'c+');
178
179        if (($handle === false) || (!flock($handle, LOCK_EX))) {
180            if (is_resource($handle)) {
181                fclose($handle);
182            }
183            throw new Exception('Unable to acquire an exclusive lock on ' . $this->file);
184        }
185
186        $new = $transform(stream_get_contents($handle));
187
188        rewind($handle);
189        ftruncate($handle, 0);
190        fwrite($handle, $new);
191
192        flock($handle, LOCK_UN);
193        fclose($handle);
194    }
195
196}