Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.00% covered (danger)
35.00%
21 / 60
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PrefUpdateInstrumentation
35.00% covered (danger)
35.00%
21 / 60
0.00% covered (danger)
0.00%
0 / 3
129.85
0.00% covered (danger)
0.00%
0 / 1
 onSaveUserOptions
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 createPrefUpdateEvent
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
6.02
 isUserInitiated
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace WikimediaEvents;
4
5use ExtensionRegistry;
6use FormatJson;
7use MediaWiki\Extension\BetaFeatures\BetaFeatures;
8use MediaWiki\Extension\EventLogging\EventLogging;
9use MediaWiki\Extension\EventLogging\Libs\UserBucketProvider\UserBucketProvider;
10use MediaWiki\Logger\LoggerFactory;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
13use MediaWiki\User\UserIdentity;
14use MediaWiki\Utils\MWTimestamp;
15use RequestContext;
16use RuntimeException;
17
18/**
19 * Hooks and helper functions used for Wikimedia-related logging of user preference updates.
20 *
21 * Extracted from WikimediaEventsHooks by Sam Smith <samsmith@wikimedia.org> on 2020-02-10.
22 *
23 * @author Ori Livneh <ori@wikimedia.org>
24 * @author Matthew Flaschen <mflaschen@wikimedia.org>
25 * @author Benny Situ <bsitu@wikimedia.org>
26 */
27class PrefUpdateInstrumentation implements SaveUserOptionsHook {
28
29    /**
30     * @var string Bumped when the nature of the data collected in the log is changed.
31     */
32    private const MAJOR_VERSION = '2';
33
34    /**
35     * The maximum length of a property value tracked with VALUE_WELLKNOWN_SHORT.
36     *
37     * This is currently fairly liberal at 50 chars. Realistically anything even
38     * close to that is unlikely to be a non-user-generated value from a
39     * software-predefined choice. If you find values are being cropped, consider
40     * adding a dedicated aggregation type for them so that data analysts have
41     * an easier time working with the data, and to make graph-plotting easier
42     * as well.
43     *
44     * @var int
45     */
46    private const SHORT_MAX_LEN = 50;
47
48    /**
49     * Indicates that a property does only holds one of several well-known
50     * and predefined choices. By themselves these may be seen in public,
51     * and are not user-generated. Note that in relation to a user this is
52     * still considered personal information. For use in PROPERTY_TRACKLIST.
53     *
54     * @var int
55     */
56    private const VALUE_WELLKNOWN_SHORT = 1;
57
58    /**
59     * Indicates that a property holds potentially personal information formatted
60     * as a newline-separated list. The instrumentation will report the value
61     * as a count (zero or more). For use in PROPERTY_TRACKLIST.
62     *
63     * @var int
64     */
65    private const VALUE_NEWLINE_COUNT = 2;
66
67    /**
68     * Indicates that a property is a beta feature that managed by the
69     * BetaFeatures extension. For use in PROPERTY_TRACKLIST.
70     *
71     * @var int
72     */
73    private const VALUE_BETA_FEATURE = 3;
74
75    /**
76     * @var string[] List of preferences (aka user properties, aka user options)
77     * to track via EventLogging when they are changed (T249894)
78     */
79    private const PROPERTY_TRACKLIST = [
80        // Reading Web team
81        'skin' => self::VALUE_WELLKNOWN_SHORT,
82        'mfMode' => self::VALUE_WELLKNOWN_SHORT,
83        'mf_amc_optin' => self::VALUE_WELLKNOWN_SHORT,
84        'VectorSkinVersion' => self::VALUE_WELLKNOWN_SHORT,
85
86        // Editing team
87        'discussiontools-betaenable' => self::VALUE_BETA_FEATURE,
88        'betafeatures-auto-enroll'  => self::VALUE_WELLKNOWN_SHORT,
89        'discussiontools-topicsubscription' => self::VALUE_WELLKNOWN_SHORT,
90        'discussiontools-autotopicsub' => self::VALUE_WELLKNOWN_SHORT,
91        'discussiontools-visualenhancements' => self::VALUE_WELLKNOWN_SHORT,
92
93        // AHT
94        'echo-notifications-blacklist' => self::VALUE_NEWLINE_COUNT,
95        'email-blacklist' => self::VALUE_NEWLINE_COUNT,
96
97        // Growth team
98        'growthexperiments-help-panel-tog-help-panel' => self::VALUE_WELLKNOWN_SHORT,
99        'growthexperiments-homepage-enable' => self::VALUE_WELLKNOWN_SHORT,
100        'growthexperiments-homepage-pt-link' => self::VALUE_WELLKNOWN_SHORT,
101        'growthexperiments-mentor-away-timestamp' => self::VALUE_WELLKNOWN_SHORT,
102        'growthexperiments-homepage-mentorship-enabled' => self::VALUE_WELLKNOWN_SHORT,
103
104        // WMDE Technical Wishes team
105        'usecodemirror' => self::VALUE_WELLKNOWN_SHORT,
106        'popups' => self::VALUE_WELLKNOWN_SHORT,
107        'popupsreferencepreviews' => self::VALUE_BETA_FEATURE,
108
109        // Structured Data team
110        'echo-subscriptions-web-image-suggestions' => self::VALUE_WELLKNOWN_SHORT,
111        'echo-subscriptions-email-image-suggestions' => self::VALUE_WELLKNOWN_SHORT,
112        'echo-subscriptions-push-image-suggestions' => self::VALUE_WELLKNOWN_SHORT,
113
114        // Search Preview
115        'searchpreview' => self::VALUE_WELLKNOWN_SHORT,
116
117        // ParserMigration
118        'parsermigration-parsoid-readviews' => self::VALUE_WELLKNOWN_SHORT,
119
120        // Community Tech
121        'editrecovery' => self::VALUE_WELLKNOWN_SHORT,
122    ];
123
124    /**
125     * Log an event when a tracked user preference is changed.
126     *
127     * Note that logging must be explicitly enabled for a property in order for
128     * tracking to take place.
129     *
130     * This hook is triggered when User::saveOptions() is called after User::setOption().
131     * For example when submitting from Special:Preferences or Special:MobileOptions,
132     * or from any client-side interfaces that use api.php?action=options (ApiOptions)
133     * to store user preferences.
134     *
135     * @see https://meta.wikimedia.org/wiki/Schema:PrefUpdate
136     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SaveUserOptions
137     *
138     * @param UserIdentity $user The user whose options are being saved
139     * @param array &$modifiedOptions The options being saved
140     * @param array $originalOptions The original options being replaced
141     */
142    public function onSaveUserOptions(
143        UserIdentity $user,
144        array &$modifiedOptions,
145        array $originalOptions
146    ): void {
147        if ( !self::isUserInitiated() ) {
148            return;
149        }
150
151        // An empty $originalOptions array will almost certainly cause spurious PrefUpdates events to be issued,
152        // and indicates a likely bug in the preference handling code causing the save. Emit a warning and abort.
153        if ( !$originalOptions ) {
154            LoggerFactory::getInstance( 'WikimediaEvents' )->warning(
155                'WikimediaEventsHooks::onUserSaveOptions called with empty originalOptions array. ' .
156                'Aborting to avoid creating spurious PrefUpdate events.',
157                // Record a stack trace to help track down the source of this call.
158                // https://www.mediawiki.org/wiki/Manual:Structured_logging#Add_structured_data_to_logging_context
159                [ 'exception' => new RuntimeException() ]
160            );
161            return;
162        }
163
164        $now = MWTimestamp::now( TS_MW );
165        $betaLoaded = ExtensionRegistry::getInstance()->isLoaded( 'BetaFeatures' );
166
167        foreach ( $modifiedOptions as $optName => $optValue ) {
168            $trackType = self::PROPERTY_TRACKLIST[$optName] ?? null;
169            if ( $betaLoaded && $trackType === self::VALUE_BETA_FEATURE ) {
170                $optValue = BetaFeatures::isFeatureEnabled( $user, $optName );
171                $prevValue = BetaFeatures::isFeatureEnabled( $user, $optName, $originalOptions );
172            } else {
173                $prevValue = $originalOptions[$optName] ?? null;
174            }
175            // Use loose comparison because the implicit default form declared in PHP
176            // often uses integers and booleans, whereas the stored format often uses
177            // strings (e.g. "" vs false)
178            if ( $prevValue != $optValue ) {
179                $event = self::createPrefUpdateEvent( $user, $optName, $optValue, $now );
180                if ( $event !== false ) {
181                    EventLogging::submit(
182                        'eventlogging_PrefUpdate',
183                        [
184                            '$schema' => '/analytics/legacy/prefupdate/1.0.0',
185                            'event' => $event,
186                        ]
187                    );
188                }
189            }
190        }
191    }
192
193    /**
194     * Format a changed user preference as a PrefUpdate event, or false to send none.
195     *
196     * @param UserIdentity $user
197     * @param string $optName
198     * @param string $optValue
199     * @param string $now
200     * @return false|array
201     */
202    private static function createPrefUpdateEvent( UserIdentity $user, $optName, $optValue, $now ) {
203        $trackType = self::PROPERTY_TRACKLIST[$optName] ?? null;
204        if ( $trackType === null ) {
205            // Not meant to be tracked.
206            return false;
207        }
208
209        if ( $trackType === self::VALUE_WELLKNOWN_SHORT ||
210            $trackType === self::VALUE_BETA_FEATURE
211        ) {
212            if ( strlen( $optValue ) > self::SHORT_MAX_LEN ) {
213                trigger_error( "Unexpected value for $optName in PrefUpdate", E_USER_WARNING );
214                return false;
215            }
216            $trackedValue = $optValue;
217        } elseif ( $trackType === self::VALUE_NEWLINE_COUNT ) {
218            // NOTE!  PrefUpdate has been migrated to Event Platform,
219            // and is no longer using the metawiki based schema.  This -1 revision_id
220            // will be overridden by the value of the EventLogging Schemas extension attribute
221            // set in extension.json.
222            $trackedValue = count( preg_split( '/\n/', $optValue, -1, PREG_SPLIT_NO_EMPTY ) );
223        } else {
224            trigger_error( "Unknown handler for $optName in PrefUpdate", E_USER_WARNING );
225            return false;
226        }
227        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
228
229        return [
230            'version' => self::MAJOR_VERSION,
231            'userId' => $user->getId(),
232            'saveTimestamp' => $now,
233            'property' => $optName,
234            // Encode value as JSON.
235            // This is parseable and allows a consistent type for validation.
236            'value' => FormatJson::encode( $trackedValue ),
237            'isDefault' => $userOptionsLookup->getDefaultOption( $optName, $user ) == $optValue,
238            'bucketedUserEditCount' => UserBucketProvider::getUserEditCountBucket( $user ),
239        ];
240    }
241
242    /**
243     * Given the global state of the application, gets whether or not *we think* that the user
244     * initiated the preference update rather than, say, MediaWiki or an extension doing so.
245     *
246     * @return bool
247     */
248    private static function isUserInitiated(): bool {
249        // TODO (mattflaschen, 2013-06-13): Ideally this would be done more cleanly without looking
250        // explicitly at page names and URL parameters. Maybe a $userInitiated flag passed to
251        // User::saveSettings would work.
252
253        if (
254            defined( 'MW_API' )
255            && RequestContext::getMain()->getRequest()->getRawVal( 'action' ) === 'options'
256        ) {
257            return true;
258        }
259
260        $title = RequestContext::getMain()->getTitle();
261
262        if ( $title === null ) {
263            return false;
264        }
265
266        foreach ( [ 'Preferences', 'MobileOptions' ] as $page ) {
267            if ( $title->isSpecial( $page ) ) {
268                return true;
269            }
270        }
271
272        return false;
273    }
274}