Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 72 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
UserImpactHandler | |
0.00% |
0 / 72 |
|
0.00% |
0 / 5 |
240 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
run | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
getUserImpact | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
110 | |||
getParamSettings | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
2 | |||
needsWriteAccess | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\Rest\Handler; |
4 | |
5 | use Exception; |
6 | use GrowthExperiments\UserImpact\ExpensiveUserImpact; |
7 | use GrowthExperiments\UserImpact\RefreshUserImpactJob; |
8 | use GrowthExperiments\UserImpact\UserImpactFormatter; |
9 | use GrowthExperiments\UserImpact\UserImpactLookup; |
10 | use GrowthExperiments\UserImpact\UserImpactStore; |
11 | use JobQueueGroup; |
12 | use MediaWiki\Deferred\DeferredUpdates; |
13 | use MediaWiki\ParamValidator\TypeDef\UserDef; |
14 | use MediaWiki\Rest\HttpException; |
15 | use MediaWiki\Rest\SimpleHandler; |
16 | use MediaWiki\User\UserFactory; |
17 | use MediaWiki\User\UserIdentity; |
18 | use Wikimedia\ParamValidator\ParamValidator; |
19 | use Wikimedia\Stats\IBufferingStatsdDataFactory; |
20 | |
21 | /** |
22 | * Handler for POST and GET requests /growthexperiments/v0/user-impact/{user} endpoint. |
23 | * Returns data about the user's impact on the wiki. POST is preferred for facilitating |
24 | * writes to the database cache. Requests made with GET will use the job queue for |
25 | * persisting impact data to the cache, which will take longer. |
26 | */ |
27 | class UserImpactHandler extends SimpleHandler { |
28 | |
29 | private UserImpactStore $userImpactStore; |
30 | private UserImpactLookup $userImpactLookup; |
31 | private UserImpactFormatter $userImpactFormatter; |
32 | private IBufferingStatsdDataFactory $statsdDataFactory; |
33 | private JobQueueGroup $jobQueueGroup; |
34 | private UserFactory $userFactory; |
35 | |
36 | /** |
37 | * @param UserImpactStore $userImpactStore |
38 | * @param UserImpactLookup $userImpactLookup |
39 | * @param UserImpactFormatter $userImpactFormatter |
40 | * @param IBufferingStatsdDataFactory $statsdDataFactory |
41 | * @param JobQueueGroup $jobQueueGroup |
42 | * @param UserFactory $userFactory |
43 | */ |
44 | public function __construct( |
45 | UserImpactStore $userImpactStore, |
46 | UserImpactLookup $userImpactLookup, |
47 | UserImpactFormatter $userImpactFormatter, |
48 | IBufferingStatsdDataFactory $statsdDataFactory, |
49 | JobQueueGroup $jobQueueGroup, |
50 | UserFactory $userFactory |
51 | ) { |
52 | $this->userImpactStore = $userImpactStore; |
53 | $this->userImpactLookup = $userImpactLookup; |
54 | $this->userImpactFormatter = $userImpactFormatter; |
55 | $this->statsdDataFactory = $statsdDataFactory; |
56 | $this->jobQueueGroup = $jobQueueGroup; |
57 | $this->userFactory = $userFactory; |
58 | } |
59 | |
60 | /** |
61 | * @param UserIdentity $user |
62 | * @return array |
63 | * @throws HttpException |
64 | * @throws Exception |
65 | */ |
66 | public function run( UserIdentity $user ) { |
67 | $start = microtime( true ); |
68 | $userImpact = $this->getUserImpact( $user ); |
69 | $validParams = $this->getValidatedParams(); |
70 | $pageviewsUrlDisplayLanguageCode = 'en'; |
71 | if ( $validParams[ 'lang' ] ) { |
72 | $pageviewsUrlDisplayLanguageCode = $validParams[ 'lang' ]; |
73 | } |
74 | $formattedJsonData = $this->userImpactFormatter->format( $userImpact, $pageviewsUrlDisplayLanguageCode ); |
75 | $this->statsdDataFactory->timing( |
76 | 'timing.growthExperiments.UserImpactHandler.run', microtime( true ) - $start |
77 | ); |
78 | return $formattedJsonData; |
79 | } |
80 | |
81 | /** |
82 | * @param UserIdentity $user |
83 | * @return ExpensiveUserImpact |
84 | * @throws HttpException |
85 | */ |
86 | private function getUserImpact( UserIdentity $user ): ExpensiveUserImpact { |
87 | if ( $this->getRequest()->getQueryParams()['regenerate'] ?? false ) { |
88 | $cachedUserImpact = null; |
89 | } else { |
90 | $cachedUserImpact = $this->userImpactStore->getExpensiveUserImpact( $user ); |
91 | } |
92 | if ( $cachedUserImpact ) { |
93 | $this->statsdDataFactory->increment( 'GrowthExperiments.UserImpactHandler.Cache.Hit' ); |
94 | } |
95 | |
96 | if ( $cachedUserImpact && $cachedUserImpact->isPageViewDataStale() ) { |
97 | // Page view data is stale; we will attempt to recalculate it. |
98 | $userImpact = null; |
99 | $this->statsdDataFactory->increment( 'GrowthExperiments.UserImpactHandler.Cache.HitStalePageViewData' ); |
100 | } else { |
101 | $userImpact = $cachedUserImpact; |
102 | } |
103 | |
104 | if ( !$userImpact ) { |
105 | $this->statsdDataFactory->increment( 'GrowthExperiments.UserImpactHandler.Cache.Miss' ); |
106 | // Rate limit check. |
107 | $performingUser = $this->userFactory->newFromUserIdentity( $this->getAuthority()->getUser() ); |
108 | if ( $performingUser->pingLimiter( 'growthexperimentsuserimpacthandler' ) ) { |
109 | if ( $cachedUserImpact ) { |
110 | $this->statsdDataFactory->increment( |
111 | 'GrowthExperiments.UserImpactHandler.PingLimiterTripped.StaleImpactData' |
112 | ); |
113 | // Performing user is over the rate limit for requesting data for other users, but we have stale |
114 | // data so just return that, rather than nothing. |
115 | return $cachedUserImpact; |
116 | } else { |
117 | $this->statsdDataFactory->increment( |
118 | 'GrowthExperiments.UserImpactHandler.PingLimiterTripped.NoData' |
119 | ); |
120 | throw new HttpException( 'Too many requests to refresh user impact data', 429 ); |
121 | } |
122 | } |
123 | $userImpact = $this->userImpactLookup->getExpensiveUserImpact( $user ); |
124 | if ( !$userImpact ) { |
125 | throw new HttpException( 'Impact data not found for user', 404 ); |
126 | } |
127 | // We want to write the updated data back to the cache table; doing that in a deferred update |
128 | // is preferable as we don't depend on job queue functioning quickly, but that isn't allowed |
129 | // on GET. So if a client is using a GET request, use the job queue, but otherwise (e.g. on |
130 | // Special:Homepage) we'll use a POST request and save using a deferred update. |
131 | if ( $this->getRequest()->getMethod() === 'GET' ) { |
132 | $this->jobQueueGroup->lazyPush( |
133 | new RefreshUserImpactJob( [ |
134 | 'impactDataBatch' => [ $user->getId() => json_encode( $userImpact ) ], |
135 | ] ) |
136 | ); |
137 | } else { |
138 | DeferredUpdates::addCallableUpdate( function () use ( $userImpact ) { |
139 | $this->userImpactStore->setUserImpact( $userImpact ); |
140 | } ); |
141 | } |
142 | } |
143 | |
144 | return $userImpact; |
145 | } |
146 | |
147 | /** @inheritDoc */ |
148 | public function getParamSettings() { |
149 | return [ |
150 | 'user' => [ |
151 | self::PARAM_SOURCE => 'path', |
152 | ParamValidator::PARAM_TYPE => 'user', |
153 | ParamValidator::PARAM_REQUIRED => true, |
154 | UserDef::PARAM_ALLOWED_USER_TYPES => [ 'id' ], |
155 | UserDef::PARAM_RETURN_OBJECT => true, |
156 | ], |
157 | 'lang' => [ |
158 | self::PARAM_SOURCE => 'query', |
159 | ParamValidator::PARAM_TYPE => 'string', |
160 | ParamValidator::PARAM_REQUIRED => false, |
161 | ], |
162 | 'regenerate' => [ |
163 | self::PARAM_SOURCE => 'query', |
164 | ParamValidator::PARAM_TYPE => 'boolean', |
165 | ParamValidator::PARAM_REQUIRED => false, |
166 | ], |
167 | ]; |
168 | } |
169 | |
170 | /** @inheritDoc */ |
171 | public function needsWriteAccess() { |
172 | return true; |
173 | } |
174 | |
175 | } |