1<?php declare(strict_types=1);
2
3/*
4 * This file is part of the Monolog package.
5 *
6 * (c) Jordi Boggiano <j.boggiano@seld.be>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Monolog\Formatter;
13
14use Monolog\DateTimeImmutable;
15use Monolog\Utils;
16use Throwable;
17
18/**
19 * Normalizes incoming records to remove objects/resources so it's easier to dump to various targets
20 *
21 * @author Jordi Boggiano <j.boggiano@seld.be>
22 */
23class NormalizerFormatter implements FormatterInterface
24{
25    public const SIMPLE_DATE = "Y-m-d\TH:i:sP";
26
27    /** @var string */
28    protected $dateFormat;
29    /** @var int */
30    protected $maxNormalizeDepth = 9;
31    /** @var int */
32    protected $maxNormalizeItemCount = 1000;
33
34    /** @var int */
35    private $jsonEncodeOptions = Utils::DEFAULT_JSON_FLAGS;
36
37    /**
38     * @param string|null $dateFormat The format of the timestamp: one supported by DateTime::format
39     */
40    public function __construct(?string $dateFormat = null)
41    {
42        $this->dateFormat = null === $dateFormat ? static::SIMPLE_DATE : $dateFormat;
43        if (!function_exists('json_encode')) {
44            throw new \RuntimeException('PHP\'s json extension is required to use Monolog\'s NormalizerFormatter');
45        }
46    }
47
48    /**
49     * {@inheritDoc}
50     *
51     * @param mixed[] $record
52     */
53    public function format(array $record)
54    {
55        return $this->normalize($record);
56    }
57
58    /**
59     * {@inheritDoc}
60     */
61    public function formatBatch(array $records)
62    {
63        foreach ($records as $key => $record) {
64            $records[$key] = $this->format($record);
65        }
66
67        return $records;
68    }
69
70    public function getDateFormat(): string
71    {
72        return $this->dateFormat;
73    }
74
75    public function setDateFormat(string $dateFormat): self
76    {
77        $this->dateFormat = $dateFormat;
78
79        return $this;
80    }
81
82    /**
83     * The maximum number of normalization levels to go through
84     */
85    public function getMaxNormalizeDepth(): int
86    {
87        return $this->maxNormalizeDepth;
88    }
89
90    public function setMaxNormalizeDepth(int $maxNormalizeDepth): self
91    {
92        $this->maxNormalizeDepth = $maxNormalizeDepth;
93
94        return $this;
95    }
96
97    /**
98     * The maximum number of items to normalize per level
99     */
100    public function getMaxNormalizeItemCount(): int
101    {
102        return $this->maxNormalizeItemCount;
103    }
104
105    public function setMaxNormalizeItemCount(int $maxNormalizeItemCount): self
106    {
107        $this->maxNormalizeItemCount = $maxNormalizeItemCount;
108
109        return $this;
110    }
111
112    /**
113     * Enables `json_encode` pretty print.
114     */
115    public function setJsonPrettyPrint(bool $enable): self
116    {
117        if ($enable) {
118            $this->jsonEncodeOptions |= JSON_PRETTY_PRINT;
119        } else {
120            $this->jsonEncodeOptions &= ~JSON_PRETTY_PRINT;
121        }
122
123        return $this;
124    }
125
126    /**
127     * @param  mixed                $data
128     * @return null|scalar|array<array|scalar|null>
129     */
130    protected function normalize($data, int $depth = 0)
131    {
132        if ($depth > $this->maxNormalizeDepth) {
133            return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization';
134        }
135
136        if (null === $data || is_scalar($data)) {
137            if (is_float($data)) {
138                if (is_infinite($data)) {
139                    return ($data > 0 ? '' : '-') . 'INF';
140                }
141                if (is_nan($data)) {
142                    return 'NaN';
143                }
144            }
145
146            return $data;
147        }
148
149        if (is_array($data)) {
150            $normalized = [];
151
152            $count = 1;
153            foreach ($data as $key => $value) {
154                if ($count++ > $this->maxNormalizeItemCount) {
155                    $normalized['...'] = 'Over ' . $this->maxNormalizeItemCount . ' items ('.count($data).' total), aborting normalization';
156                    break;
157                }
158
159                $normalized[$key] = $this->normalize($value, $depth + 1);
160            }
161
162            return $normalized;
163        }
164
165        if ($data instanceof \DateTimeInterface) {
166            return $this->formatDate($data);
167        }
168
169        if (is_object($data)) {
170            if ($data instanceof Throwable) {
171                return $this->normalizeException($data, $depth);
172            }
173
174            if ($data instanceof \JsonSerializable) {
175                /** @var null|scalar|array<array|scalar|null> $value */
176                $value = $data->jsonSerialize();
177            } elseif (method_exists($data, '__toString')) {
178                /** @var string $value */
179                $value = $data->__toString();
180            } else {
181                // the rest is normalized by json encoding and decoding it
182                /** @var null|scalar|array<array|scalar|null> $value */
183                $value = json_decode($this->toJson($data, true), true);
184            }
185
186            return [Utils::getClass($data) => $value];
187        }
188
189        if (is_resource($data)) {
190            return sprintf('[resource(%s)]', get_resource_type($data));
191        }
192
193        return '[unknown('.gettype($data).')]';
194    }
195
196    /**
197     * @return mixed[]
198     */
199    protected function normalizeException(Throwable $e, int $depth = 0)
200    {
201        if ($e instanceof \JsonSerializable) {
202            return (array) $e->jsonSerialize();
203        }
204
205        $data = [
206            'class' => Utils::getClass($e),
207            'message' => $e->getMessage(),
208            'code' => (int) $e->getCode(),
209            'file' => $e->getFile().':'.$e->getLine(),
210        ];
211
212        if ($e instanceof \SoapFault) {
213            if (isset($e->faultcode)) {
214                $data['faultcode'] = $e->faultcode;
215            }
216
217            if (isset($e->faultactor)) {
218                $data['faultactor'] = $e->faultactor;
219            }
220
221            if (isset($e->detail)) {
222                if (is_string($e->detail)) {
223                    $data['detail'] = $e->detail;
224                } elseif (is_object($e->detail) || is_array($e->detail)) {
225                    $data['detail'] = $this->toJson($e->detail, true);
226                }
227            }
228        }
229
230        $trace = $e->getTrace();
231        foreach ($trace as $frame) {
232            if (isset($frame['file'])) {
233                $data['trace'][] = $frame['file'].':'.$frame['line'];
234            }
235        }
236
237        if ($previous = $e->getPrevious()) {
238            $data['previous'] = $this->normalizeException($previous, $depth + 1);
239        }
240
241        return $data;
242    }
243
244    /**
245     * Return the JSON representation of a value
246     *
247     * @param  mixed             $data
248     * @throws \RuntimeException if encoding fails and errors are not ignored
249     * @return string            if encoding fails and ignoreErrors is true 'null' is returned
250     */
251    protected function toJson($data, bool $ignoreErrors = false): string
252    {
253        return Utils::jsonEncode($data, $this->jsonEncodeOptions, $ignoreErrors);
254    }
255
256    /**
257     * @return string
258     */
259    protected function formatDate(\DateTimeInterface $date)
260    {
261        // in case the date format isn't custom then we defer to the custom DateTimeImmutable
262        // formatting logic, which will pick the right format based on whether useMicroseconds is on
263        if ($this->dateFormat === self::SIMPLE_DATE && $date instanceof DateTimeImmutable) {
264            return (string) $date;
265        }
266
267        return $date->format($this->dateFormat);
268    }
269
270    public function addJsonEncodeOption(int $option): self
271    {
272        $this->jsonEncodeOptions |= $option;
273
274        return $this;
275    }
276
277    public function removeJsonEncodeOption(int $option): self
278    {
279        $this->jsonEncodeOptions &= ~$option;
280
281        return $this;
282    }
283}
284