Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
AuthManagerStatsdHandler
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
3 / 3
17
100.00% covered (success)
100.00%
1 / 1
 handle
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
12
 getEntryPoint
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getField
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Custom logger for counting certain events.
4 *
5 * (c) Wikimedia Foundation 2015, GPL
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 */
24
25namespace WikimediaEvents;
26
27use MediaWiki\MediaWikiServices;
28use Monolog\Handler\AbstractHandler;
29
30/**
31 * Counts authentication-related log events (those sent to the 'authevents'
32 * channel).
33 *
34 * Events can include the following data in their context:
35 *   - 'event': (string, required) the type of the event (e.g. 'login').
36 *   - 'eventType': (string) a subtype for more complex events.
37 *   - 'successful': (bool) whether the attempt was successful.
38 *   - 'status': (string) attempt status (such as an error message key).
39 *     Will be ignored unless 'successful' is false.
40 *
41 * Will result in a ping to a statsd counter that looks like
42 * <MediaWiki root>.authmanager.<event>.<type>.<entrypoint>.[success|failure].<status>
43 * Some segments will be omitted when the appropriate data is not present.
44 * <entrypoint> is 'web' or 'api' and filled automatically.
45 *
46 * Generic stats counters will also be incremented, depending on the event:
47 *   - 'authmanager_success_total'
48 *   - 'authmanager_error_total'
49 * With the following labels:
50 *   - 'entrypoint': as described above, 'web' or 'api'
51 *   - 'event': the type of the event
52 *   - 'subtype': (can be 'n/a' if no subtype is found)
53 *   - 'reason': failure reason, set only for errors
54 *
55 * Used to alert on sudden, unexplained changes in e.g. the number of login
56 * errors.
57 */
58class AuthManagerStatsdHandler extends AbstractHandler {
59
60    /**
61     * @inheritDoc
62     */
63    public function handle( array $record ): bool {
64        $event = $this->getField( 'event', $record['context'] );
65        $type = $this->getField( [ 'eventType', 'type' ], $record['context'] );
66        $entrypoint = $this->getEntryPoint();
67        $status = $this->getField( 'status', $record['context'] );
68        $successful = $this->getField( 'successful', $record['context'] );
69        $error = null;
70        if ( $successful === false ) {
71            $error = strval( $status );
72        }
73
74        // Sense-check in case this was invoked from some non-metrics-related
75        // code by accident
76        if (
77            ( $record['channel'] !== 'authevents' && $record['channel'] !== 'captcha' )
78            || !$event || !is_string( $event )
79            || ( $type && !is_string( $type ) )
80        ) {
81            return false;
82        }
83
84        // some key parts can be null and will be removed by array_filter
85        $keyParts = [ 'authmanager', $event, $type, $entrypoint ];
86        if ( $successful === true ) {
87            $keyParts[] = 'success';
88            $counterName = 'authmanager_success_total';
89        } elseif ( $successful === false ) {
90            $counterName = 'authmanager_error_total';
91            $keyParts[] = 'failure';
92            $keyParts[] = $error;
93        } else {
94            $counterName = 'authmanager_event_total';
95        }
96        $statsdKey = implode( '.', array_filter( $keyParts ) );
97
98        // use of this class is set up in operations/mediawiki-config so no nice dependency injection
99        $counter = MediaWikiServices::getInstance()->getStatsFactory()
100            ->withComponent( 'WikimediaEvents' )
101            ->getCounter( $counterName )
102            ->setLabel( 'entrypoint', $entrypoint )
103            ->setLabel( 'event', $event )
104            ->setLabel( 'subtype', $type ?? 'n/a' );
105        if ( $successful === false ) {
106            $counter->setLabel( 'reason', $error ?: 'n/a' );
107        }
108        $counter->copyToStatsdAt( $statsdKey )
109            ->increment();
110
111        // pass to next handler
112        return false;
113    }
114
115    /**
116     * @return string
117     */
118    protected function getEntryPoint() {
119        return defined( 'MW_API' ) ? 'api' : 'web';
120    }
121
122    /**
123     * Get a field from an array without triggering errors if it does not exist
124     * @param string|array $field Field name or list of field name + fallbacks
125     * @param array $data
126     * @return mixed Field value, or null if field was missing
127     */
128    protected function getField( $field, array $data ) {
129        foreach ( (array)$field as $key ) {
130            if ( isset( $data[$key] ) ) {
131                return $data[$key];
132            }
133        }
134        return null;
135    }
136}