Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.04% covered (success)
98.04%
50 / 51
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthManagerStatsdHandler
98.04% covered (success)
98.04%
50 / 51
66.67% covered (warning)
66.67%
2 / 3
21
0.00% covered (danger)
0.00%
0 / 1
 getSulLabels
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 handle
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
15
 getEntryPoint
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
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\Context\RequestContext;
28use MediaWiki\Extension\CentralAuth\SharedDomainUtils;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\WikiMap\WikiMap;
31use Monolog\Handler\AbstractHandler;
32
33/**
34 * Counts authentication-related log events (those sent to the 'authevents'
35 * channel).
36 *
37 * Events can include the following data in their context:
38 *   - 'event': (string, required) the type of the event (e.g. 'login').
39 *   - 'eventType': (string) a subtype for more complex events.
40 *   - 'accountType': (string, optional), a performer account type, one of `named`, `temp`, `anon`
41 *   - 'successful': (bool) whether the attempt was successful.
42 *   - 'status': (string) attempt status (such as an error message key).
43 *     Will be ignored unless 'successful' is false.
44 *
45 * Will result in a ping to a statsd counter that looks like
46 * <MediaWiki root>.authmanager.<event>.<type>.<entrypoint>.[success|failure].<status>
47 * Some segments will be omitted when the appropriate data is not present.
48 * <entrypoint> is 'web' or 'api' and filled automatically.
49 *
50 * Generic stats counters will also be incremented, depending on the event:
51 *   - 'authmanager_success_total'
52 *   - 'authmanager_error_total'
53 * With the following labels:
54 *   - 'entrypoint': as described above, 'web' or 'api'
55 *   - 'event': the type of the event
56 *   - 'subtype': (can be 'n/a' if no subtype is found)
57 *   - 'reason': failure reason, set only for errors
58 *   - 'accountType': the account type if passed
59 *
60 * Used to alert on sudden, unexplained changes in e.g. the number of login
61 * errors.
62 */
63class AuthManagerStatsdHandler extends AbstractHandler {
64
65    /**
66     * @see https://phabricator.wikimedia.org/T375955
67     * @return array
68     */
69    private function getSulLabels(): array {
70        $services = MediaWikiServices::getInstance();
71        if ( !$services->getExtensionRegistry()->isLoaded( 'CentralAuth' ) ) {
72            return [];
73        }
74        /** @var SharedDomainUtils $sharedDomainUtils */
75        $sharedDomainUtils = $services->get( 'CentralAuth.SharedDomainUtils' );
76        $context = RequestContext::getMain();
77        $isSul3Enabled = $sharedDomainUtils->isSul3Enabled( $context->getRequest() );
78
79        return [
80            // Temporary - used to mark metrics with SUL3 label. Should be removed after full migration
81            'sul3' => $isSul3Enabled ? 'enabled' : 'disabled',
82            'domain' => $sharedDomainUtils->isSharedDomain() ? 'local' : 'shared'
83        ];
84    }
85
86    /**
87     * @inheritDoc
88     */
89    public function handle( array $record ): bool {
90        $event = $record['context']['event'] ?? null;
91        $type = $record['context']['eventType'] ?? $record['context']['type'] ?? null;
92        $entrypoint = $this->getEntryPoint();
93        $status = $record['context']['status'] ?? null;
94        $successful = $record['context']['successful'] ?? null;
95        $accountType = $record['context']['accountType'] ?? null;
96
97        $error = null;
98        if ( $successful === false ) {
99            $error = strval( $status );
100        }
101
102        // Sense-check in case this was invoked from some non-metrics-related
103        // code by accident
104        if (
105            ( $record['channel'] !== 'authevents' && $record['channel'] !== 'captcha' )
106            || !$event || !is_string( $event )
107            || ( $type && !is_string( $type ) )
108        ) {
109            return false;
110        }
111
112        // some key parts can be null and will be removed by array_filter
113        $keyParts = [ 'authmanager', $event, $type, $entrypoint ];
114        // captcha stream is used to check for captcha effectiveness and there is no need to
115        // differentiate between account types
116        if ( $accountType !== null && $record['channel'] === 'authevents' ) {
117            $keyParts[] = $accountType;
118        }
119        if ( $successful === true ) {
120            $keyParts[] = 'success';
121            $counterName = 'authmanager_success_total';
122        } elseif ( $successful === false ) {
123            $counterName = 'authmanager_error_total';
124            $keyParts[] = 'failure';
125            $keyParts[] = $error;
126        } else {
127            $counterName = 'authmanager_event_total';
128        }
129        $statsdKey = implode( '.', array_filter( $keyParts ) );
130
131        // use of this class is set up in operations/mediawiki-config so no nice dependency injection
132        // NOTE: stat labels cannot be conditional, all stats with same `$counterName` need to have
133        // the same set of labels within request - @see T377476
134        $counter = MediaWikiServices::getInstance()->getStatsFactory()
135            ->withComponent( 'WikimediaEvents' )
136            ->getCounter( $counterName )
137            ->setLabel( 'entrypoint', $entrypoint )
138            ->setLabel( 'event', $event )
139            ->setLabel( 'wiki', WikiMap::getCurrentWikiId() )
140            ->setLabel( 'subtype', $type ?? 'n/a' )
141            ->setLabel( 'accountType', $accountType ?? 'n/a' );
142
143        foreach ( $this->getSulLabels() as $label => $value ) {
144            $counter->setLabel( $label, $value );
145        }
146        if ( $successful === false ) {
147            $counter->setLabel( 'reason', $error ?: 'n/a' );
148        }
149
150        $counter->copyToStatsdAt( $statsdKey )
151            ->increment();
152
153        // pass to next handler
154        return false;
155    }
156
157    /**
158     * @return string
159     */
160    protected function getEntryPoint() {
161        return defined( 'MW_API' ) ? 'api' : 'web';
162    }
163
164}