Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.35% covered (danger)
4.35%
4 / 92
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
LogstashFormatter
4.35% covered (danger)
4.35%
4 / 92
0.00% covered (danger)
0.00%
0 / 8
1440.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 format
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 formatV0
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 formatMonologV0
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
156
 formatV1
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 formatMonologV1
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
132
 fixKeyConflicts
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 normalizeException
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Logger\Monolog;
4
5/**
6 * Modified version of Monolog\Formatter\LogstashFormatter
7 *
8 * - Squash the base message array, the context and extra subarrays into one.
9 *   This can result in unfortunately named context fields overwriting other data (T145133).
10 * - Improve exception JSON-ification, which is done poorly by the standard class.
11 *
12 * @since 1.29
13 * @ingroup Debug
14 */
15class LogstashFormatter extends \Monolog\Formatter\LogstashFormatter {
16
17    public const V0 = 0;
18    public const V1 = 1;
19
20    /** @var array Keys which should not be used in log context */
21    protected $reservedKeys = [
22        // from LogstashFormatter
23        'message', 'channel', 'level', 'type',
24        // from WebProcessor
25        'url', 'ip', 'http_method', 'server', 'referrer',
26        // from WikiProcessor
27        'host', 'wiki', 'reqId', 'mwversion',
28        // from config magic
29        'normalized_message',
30    ];
31
32    /**
33     * @var int Logstash format version to use
34     */
35    protected $version;
36
37    /**
38     * TODO: See T247675 for removing this override.
39     *
40     * @param string $applicationName The application that sends the data, used as the "type"
41     * field of logstash
42     * @param string|null $systemName The system/machine name, used as the "source" field of
43     * logstash, defaults to the hostname of the machine
44     * @param string $extraKey The key for extra keys inside logstash "fields", defaults to ''
45     * @param string $contextKey The key for context keys inside logstash "fields", defaults
46     * @param int $version The logstash format version to use, defaults to V0
47     * to ''
48     */
49    public function __construct( string $applicationName, ?string $systemName = null,
50        string $extraKey = '', string $contextKey = 'ctxt_', $version = self::V0
51    ) {
52        $this->version = $version;
53        parent::__construct( $applicationName, $systemName, $extraKey, $contextKey );
54    }
55
56    public function format( array $record ): string {
57        $record = \Monolog\Formatter\NormalizerFormatter::format( $record );
58        if ( $this->version === self::V1 ) {
59            $message = $this->formatV1( $record );
60        } elseif ( $this->version === self::V0 ) {
61            $message = $this->formatV0( $record );
62        } else {
63            $message = __METHOD__ . ' unknown version ' . $this->version;
64        }
65
66        return $this->toJson( $message ) . "\n";
67    }
68
69    /**
70     * Prevent key conflicts
71     * @param array $record
72     * @return array
73     */
74    protected function formatV0( array $record ) {
75        if ( $this->contextKey !== '' ) {
76            return $this->formatMonologV0( $record );
77        }
78
79        $context = !empty( $record['context'] ) ? $record['context'] : [];
80        $record['context'] = [];
81        $formatted = $this->formatMonologV0( $record );
82
83        $formatted['@fields'] = $this->fixKeyConflicts( $formatted['@fields'], $context );
84
85        return $formatted;
86    }
87
88    /**
89     * Borrowed from monolog/monolog 1.25.3
90     * https://github.com/Seldaek/monolog/blob/1.x/src/Monolog/Formatter/LogstashFormatter.php#L87-L128
91     *
92     * @param array $record
93     * @return array
94     */
95    protected function formatMonologV0( array $record ) {
96        if ( empty( $record['datetime'] ) ) {
97            $record['datetime'] = gmdate( 'c' );
98        }
99        $message = [
100            '@timestamp' => $record['datetime'],
101            '@source' => $this->systemName,
102            '@fields' => [],
103        ];
104        if ( isset( $record['message'] ) ) {
105            $message['@message'] = $record['message'];
106        }
107        if ( isset( $record['channel'] ) ) {
108            $message['@tags'] = [ $record['channel'] ];
109            $message['@fields']['channel'] = $record['channel'];
110        }
111        if ( isset( $record['level'] ) ) {
112            $message['@fields']['level'] = $record['level'];
113        }
114        if ( $this->applicationName ) {
115            $message['@type'] = $this->applicationName;
116        }
117        if ( isset( $record['extra']['server'] ) ) {
118            $message['@source_host'] = $record['extra']['server'];
119        }
120        if ( isset( $record['extra']['url'] ) ) {
121            $message['@source_path'] = $record['extra']['url'];
122        }
123        if ( !empty( $record['extra'] ) ) {
124            foreach ( $record['extra'] as $key => $val ) {
125                $message['@fields'][$this->extraKey . $key] = $val;
126            }
127        }
128        if ( !empty( $record['context'] ) ) {
129            foreach ( $record['context'] as $key => $val ) {
130                $message['@fields'][$this->contextKey . $key] = $val;
131            }
132        }
133
134        return $message;
135    }
136
137    /**
138     * Prevent key conflicts
139     * @param array $record
140     * @return array
141     */
142    protected function formatV1( array $record ) {
143        if ( $this->contextKey ) {
144            return $this->formatMonologV1( $record );
145        }
146
147        $context = !empty( $record['context'] ) ? $record['context'] : [];
148        $record['context'] = [];
149        $formatted = $this->formatMonologV1( $record );
150
151        return $this->fixKeyConflicts( $formatted, $context );
152    }
153
154    /**
155     * Borrowed mostly from monolog/monolog 1.25.3
156     * https://github.com/Seldaek/monolog/blob/1.25.3/src/Monolog/Formatter/LogstashFormatter.php#L130-165
157     *
158     * @param array $record
159     * @return array
160     */
161    protected function formatMonologV1( array $record ) {
162        if ( empty( $record['datetime'] ) ) {
163            $record['datetime'] = gmdate( 'c' );
164        }
165        $message = [
166            '@timestamp' => $record['datetime'],
167            '@version' => 1,
168            'host' => $this->systemName,
169        ];
170        if ( isset( $record['message'] ) ) {
171            $message['message'] = $record['message'];
172        }
173        if ( isset( $record['channel'] ) ) {
174            $message['type'] = $record['channel'];
175            $message['channel'] = $record['channel'];
176        }
177        if ( isset( $record['level_name'] ) ) {
178            $message['level'] = $record['level_name'];
179        }
180        // level -> monolog_level is new in 2.0
181        // https://github.com/Seldaek/monolog/blob/2.0.2/src/Monolog/Formatter/LogstashFormatter.php#L86-L88
182        if ( isset( $record['level'] ) ) {
183            $message['monolog_level'] = $record['level'];
184        }
185        if ( $this->applicationName ) {
186            $message['type'] = $this->applicationName;
187        }
188        if ( !empty( $record['extra'] ) ) {
189            foreach ( $record['extra'] as $key => $val ) {
190                $message[$this->extraKey . $key] = $val;
191            }
192        }
193        if ( !empty( $record['context'] ) ) {
194            foreach ( $record['context'] as $key => $val ) {
195                $message[$this->contextKey . $key] = $val;
196            }
197        }
198
199        return $message;
200    }
201
202    /**
203     * Rename any context field that would otherwise overwrite a message key.
204     *
205     * @param array $fields Fields to be sent to logstash
206     * @param array $context Copy of the original $record['context']
207     * @return array Updated version of $fields
208     */
209    protected function fixKeyConflicts( array $fields, array $context ) {
210        foreach ( $context as $key => $val ) {
211            if (
212                in_array( $key, $this->reservedKeys, true ) &&
213                isset( $fields[$key] ) && $fields[$key] !== $val
214            ) {
215                $fields['logstash_formatter_key_conflict'][] = $key;
216                $key = 'c_' . $key;
217            }
218            $fields[$key] = $val;
219        }
220        return $fields;
221    }
222
223    /**
224     * Use a more user-friendly trace format than Monolog\Formatter\NormalizerFormatter.
225     *
226     * @param \Throwable $e
227     * @param int $depth
228     * @return array
229     */
230    protected function normalizeException( \Throwable $e, int $depth = 0 ) {
231        $data = [
232            'class' => get_class( $e ),
233            'message' => $e->getMessage(),
234            'code' => $e->getCode(),
235            'file' => $e->getFile() . ':' . $e->getLine(),
236            'trace' => \MWExceptionHandler::getRedactedTraceAsString( $e ),
237        ];
238
239        $previous = $e->getPrevious();
240        if ( $previous ) {
241            $data['previous'] = $this->normalizeException( $previous );
242        }
243
244        return $data;
245    }
246}