Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
35.00% |
21 / 60 |
|
0.00% |
0 / 3 |
CRAP | |
0.00% |
0 / 1 |
PrefUpdateInstrumentation | |
35.00% |
21 / 60 |
|
0.00% |
0 / 3 |
129.85 | |
0.00% |
0 / 1 |
onSaveUserOptions | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
72 | |||
createPrefUpdateEvent | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
6.02 | |||
isUserInitiated | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
42 |
1 | <?php |
2 | |
3 | namespace WikimediaEvents; |
4 | |
5 | use ExtensionRegistry; |
6 | use FormatJson; |
7 | use MediaWiki\Extension\BetaFeatures\BetaFeatures; |
8 | use MediaWiki\Extension\EventLogging\EventLogging; |
9 | use MediaWiki\Extension\EventLogging\Libs\UserBucketProvider\UserBucketProvider; |
10 | use MediaWiki\Logger\LoggerFactory; |
11 | use MediaWiki\MediaWikiServices; |
12 | use MediaWiki\User\Options\Hook\SaveUserOptionsHook; |
13 | use MediaWiki\User\UserIdentity; |
14 | use MediaWiki\Utils\MWTimestamp; |
15 | use RequestContext; |
16 | use 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 | */ |
27 | class 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 | } |