Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.14% covered (warning)
81.14%
142 / 175
23.08% covered (danger)
23.08%
3 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikimediaPageViewService
81.14% covered (warning)
81.14%
142 / 175
23.08% covered (danger)
23.08%
3 / 13
110.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOriginalRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supports
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getPageData
80.65% covered (warning)
80.65%
25 / 31
0.00% covered (danger)
0.00%
0 / 1
10.73
 getSiteData
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
12.89
 getTopPages
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
9.24
 getCacheExpiry
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 verifyApiOptions
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 getRequestUrl
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
8.02
 makeRequest
83.33% covered (warning)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
20.67
 getEmptyDateRange
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getStartEnd
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\PageViewInfo;
4
5use InvalidArgumentException;
6use MediaWiki\Http\HttpRequestFactory;
7use MediaWiki\Http\MWHttpRequest;
8use MediaWiki\Json\FormatJson;
9use MediaWiki\Language\RawMessage;
10use MediaWiki\Page\PageReference;
11use MediaWiki\Request\WebRequest;
12use MediaWiki\Status\Status;
13use MediaWiki\Title\TitleFormatter;
14use MediaWiki\Utils\MWTimestamp;
15use NullHttpRequestFactory;
16use Psr\Log\LoggerAwareInterface;
17use Psr\Log\LoggerInterface;
18use Psr\Log\LogLevel;
19use Psr\Log\NullLogger;
20use StatusValue;
21
22/**
23 * PageViewService implementation for Wikimedia wikis, using the pageview API
24 * @see https://wikitech.wikimedia.org/wiki/Analytics/PageviewAPI
25 */
26class WikimediaPageViewService implements PageViewService, LoggerAwareInterface {
27    /** @var LoggerInterface */
28    protected $logger;
29
30    /** @var string */
31    protected $pageAnalyticsEndpoint;
32    /** @var string */
33    protected $deviceAnalyticsEndpoint;
34    /** @var int|false Max number of pages to look up (false for unlimited) */
35    protected $lookupLimit;
36
37    /** @var string */
38    protected $project;
39    /** @var string 'all-access', 'desktop', 'mobile-app' or 'mobile-web' */
40    protected $access;
41    /** @var string 'all-agents', 'user', 'spider' or 'bot' */
42    protected $agent;
43    /** @var string 'hourly', 'daily' or 'monthly', allowing other options would make the interface too complex */
44    protected $granularity = 'daily';
45    /** @var int UNIX timestamp of 0:00 of the last day with complete data */
46    protected $lastCompleteDay;
47
48    /** @var array Cache for getEmptyDateRange() */
49    protected $range;
50
51    /** @var WebRequest|string[] The request that asked for this data; see the originalRequest
52     *    parameter of MediaWiki\Http\HttpRequestFactory::request()
53     */
54    protected $originalRequest;
55
56    /**
57     * @param HttpRequestFactory $httpRequestFactory
58     * @param TitleFormatter $titleFormatter
59     * @param string $pageAnalyticsEndpoint Endpoint for metrics/pageviews calls
60     * @param string $deviceAnalyticsEndpoint Endpoint for metrics/unique-devices calls
61     * @param array $apiOptions Associative array of API URL parameters
62     *   see https://wikimedia.org/api/rest_v1/#!/Pageviews_data
63     *   project is the only required parameter. Granularity, start and end are not supported.
64     * @param int|false $lookupLimit Max number of pages to look up (false for unlimited).
65     *   Data will be returned for no more than this many titles in a getPageData() call.
66     */
67    public function __construct(
68        private readonly HttpRequestFactory $httpRequestFactory,
69        private readonly TitleFormatter $titleFormatter,
70        $pageAnalyticsEndpoint,
71        $deviceAnalyticsEndpoint,
72        array $apiOptions,
73        $lookupLimit
74    ) {
75        $this->pageAnalyticsEndpoint = rtrim( $pageAnalyticsEndpoint, '/' );
76        $this->deviceAnalyticsEndpoint = rtrim( $deviceAnalyticsEndpoint, '/' );
77        $this->lookupLimit = $lookupLimit;
78        $apiOptions += [
79            'access' => 'all-access',
80            'agent' => 'user',
81        ];
82        $this->verifyApiOptions( $apiOptions );
83
84        $this->project = $apiOptions['project'];
85        $this->access = $apiOptions['access'];
86        $this->agent = $apiOptions['agent'];
87
88        // Skip the current day for which only partial information is available
89        $this->lastCompleteDay = strtotime( '0:0 1 day ago', MWTimestamp::time() );
90
91        $this->logger = new NullLogger();
92    }
93
94    public function setLogger( LoggerInterface $logger ): void {
95        $this->logger = $logger;
96    }
97
98    /**
99     * @param WebRequest|string[] $originalRequest See the 'originalRequest' parameter of
100     *   MediaWiki\Http\HttpRequestFactory::request().
101     */
102    public function setOriginalRequest( $originalRequest ) {
103        $this->originalRequest = $originalRequest;
104    }
105
106    /** @inheritDoc */
107    public function supports( $metric, $scope ) {
108        if ( $metric === self::METRIC_VIEW ) {
109            return true;
110        } elseif ( $metric === self::METRIC_UNIQUE ) {
111            return $scope === self::SCOPE_SITE && $this->access !== 'mobile-app';
112        }
113        return false;
114    }
115
116    /**
117     * @inheritDoc
118     */
119    public function getPageData( array $titles, $days, $metric = self::METRIC_VIEW ) {
120        if ( $metric !== self::METRIC_VIEW ) {
121            throw new InvalidArgumentException( 'Invalid metric: ' . $metric );
122        }
123        if ( !$titles ) {
124            return StatusValue::newGood( [] );
125        } elseif ( $this->lookupLimit !== false ) {
126            $titles = array_slice( $titles, 0, $this->lookupLimit );
127        }
128        if ( $days <= 0 ) {
129            throw new InvalidArgumentException( 'Invalid days: ' . $days );
130        }
131
132        $status = StatusValue::newGood();
133        $result = [];
134        foreach ( $titles as $title ) {
135            /** @var PageReference $title */
136            $prefixedDBkey = $this->titleFormatter->getPrefixedDBkey( $title );
137            $result[$prefixedDBkey] = $this->getEmptyDateRange( $days );
138            $requestStatus = $this->makeRequest(
139                $this->getRequestUrl( self::SCOPE_ARTICLE, $prefixedDBkey, $days ) );
140            if ( $requestStatus->isOK() ) {
141                $data = $requestStatus->getValue();
142                if ( isset( $data['items'] ) && is_array( $data['items'] ) ) {
143                    foreach ( $data['items'] as $item ) {
144                        $ts = $item['timestamp'];
145                        $day = substr( $ts, 0, 4 ) . '-' . substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 );
146                        $result[$prefixedDBkey][$day] = $item['views'];
147                    }
148                    $status->success[$prefixedDBkey] = true;
149                } else {
150                    $status->error( 'pvi-invalidresponse' );
151                    $status->success[$prefixedDBkey] = false;
152                }
153            } else {
154                $status->success[$prefixedDBkey] = false;
155            }
156            $status->merge( $requestStatus );
157        }
158        $status->successCount = count( array_filter( $status->success ) );
159        $status->failCount = count( $status->success ) - $status->successCount;
160        $status->setResult( (bool)$status->successCount, $result );
161        return $status;
162    }
163
164    /**
165     * @inheritDoc
166     */
167    public function getSiteData( $days, $metric = self::METRIC_VIEW ) {
168        if ( $metric !== self::METRIC_VIEW && $metric !== self::METRIC_UNIQUE ) {
169            throw new InvalidArgumentException( 'Invalid metric: ' . $metric );
170        } elseif ( $metric === self::METRIC_UNIQUE && $this->access === 'mobile-app' ) {
171            throw new InvalidArgumentException(
172                'Unique device counts for mobile apps are not supported' );
173        }
174        if ( $days <= 0 ) {
175            throw new InvalidArgumentException( 'Invalid days: ' . $days );
176        }
177        $result = $this->getEmptyDateRange( $days );
178        $status = $this->makeRequest( $this->getRequestUrl( $metric, null, $days ) );
179        if ( $status->isOK() ) {
180            $data = $status->getValue();
181            if ( isset( $data['items'] ) && is_array( $data['items'] ) ) {
182                foreach ( $data['items'] as $item ) {
183                    $ts = $item['timestamp'];
184                    $day = substr( $ts, 0, 4 ) . '-' . substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 );
185                    $count = $metric === self::METRIC_VIEW ? $item['views'] : $item['devices'];
186                    $result[$day] = $count;
187                }
188            } else {
189                $status->fatal( 'pvi-invalidresponse' );
190            }
191        }
192        $status->setResult( $status->isOK(), $result );
193        return $status;
194    }
195
196    /**
197     * @inheritDoc
198     */
199    public function getTopPages( $metric = self::METRIC_VIEW ) {
200        $result = [];
201        if ( $metric !== self::METRIC_VIEW ) {
202            throw new InvalidArgumentException( 'Invalid metric: ' . $metric );
203        }
204        $status = $this->makeRequest( $this->getRequestUrl( self::SCOPE_TOP ) );
205        if ( $status->isOK() ) {
206            $data = $status->getValue();
207            if ( isset( $data['items'] ) && is_array( $data['items'] ) && !$data['items'] ) {
208                // empty result set, no error; makeRequest generates this on 404
209            } elseif (
210                isset( $data['items'][0]['articles'] ) &&
211                is_array( $data['items'][0]['articles'] )
212            ) {
213                foreach ( $data['items'][0]['articles'] as $item ) {
214                    $result[$item['article']] = $item['views'];
215                }
216            } else {
217                $status->fatal( 'pvi-invalidresponse' );
218            }
219        }
220        $status->setResult( $status->isOK(), $result );
221        return $status;
222    }
223
224    /** @inheritDoc */
225    public function getCacheExpiry( $metric, $scope ) {
226        // data is valid until the end of the day
227        $endOfDay = strtotime( '0:0 next day', MWTimestamp::time() );
228        return $endOfDay - time();
229    }
230
231    /**
232     * @param array $apiOptions
233     * @throws InvalidArgumentException
234     */
235    protected function verifyApiOptions( array $apiOptions ) {
236        if ( !isset( $apiOptions['project'] ) ) {
237            throw new InvalidArgumentException( "'project' is required" );
238        } elseif ( !in_array( $apiOptions['access'],
239            [ 'all-access', 'desktop', 'mobile-app', 'mobile-web' ], true ) ) {
240            throw new InvalidArgumentException( 'Invalid access: ' . $apiOptions['access'] );
241        } elseif ( !in_array( $apiOptions['agent'],
242            [ 'all-agents', 'user', 'spider', 'bot' ], true ) ) {
243            throw new InvalidArgumentException( 'Invalid agent: ' . $apiOptions['agent'] );
244        } elseif ( isset( $apiOptions['granularity'] ) ) {
245            throw new InvalidArgumentException( 'Changing granularity is not supported' );
246        }
247    }
248
249    /**
250     * @param string $scope SCOPE_* constant or METRIC_UNIQUE
251     * @param string|null $prefixedDBkey
252     * @param int|null $days
253     * @return string
254     */
255    protected function getRequestUrl( $scope, ?string $prefixedDBkey = null, $days = null ) {
256        [ $start, $end ] = $this->getStartEnd( $days );
257        switch ( $scope ) {
258            case self::SCOPE_ARTICLE:
259                if ( $prefixedDBkey === null ) {
260                    throw new InvalidArgumentException( 'Title is required when using article scope' );
261                }
262                // Use plain urlencode instead of wfUrlencode because we need
263                // "/" to be encoded, which wfUrlencode doesn't.
264                $encodedTitle = urlencode( $prefixedDBkey );
265                // YYYYMMDD
266                $start = substr( $start, 0, 8 );
267                $end = substr( $end, 0, 8 );
268                return "$this->pageAnalyticsEndpoint/metrics/pageviews/per-article/$this->project/$this->access/"
269                    . "$this->agent/$encodedTitle/$this->granularity/$start/$end";
270            case self::METRIC_VIEW:
271            case self::SCOPE_SITE:
272            // YYYYMMDDHH
273                $start = substr( $start, 0, 10 );
274                $end = substr( $end, 0, 10 );
275                return "$this->pageAnalyticsEndpoint/metrics/pageviews/aggregate/$this->project/"
276                       . "$this->access/$this->agent/$this->granularity/$start/$end";
277            case self::SCOPE_TOP:
278                $year = substr( $end, 0, 4 );
279                $month = substr( $end, 4, 2 );
280                $day = substr( $end, 6, 2 );
281                return "$this->pageAnalyticsEndpoint/metrics/pageviews/top/$this->project/"
282                       . "$this->access/$year/$month/$day";
283            case self::METRIC_UNIQUE:
284                $access = match ( $this->access ) {
285                    'all-access' => 'all-sites',
286                    'desktop' => 'desktop-site',
287                    'mobile-web' => 'mobile-site',
288                };
289                // YYYYMMDD
290                $start = substr( $start, 0, 8 );
291                $end = substr( $end, 0, 8 );
292                return "$this->deviceAnalyticsEndpoint/metrics/unique-devices/$this->project/$access/"
293                    . "$this->granularity/$start/$end";
294            default:
295                throw new InvalidArgumentException( 'Invalid scope: ' . $scope );
296        }
297    }
298
299    /**
300     * @param string $url
301     * @return StatusValue
302     */
303    protected function makeRequest( $url ) {
304        if ( defined( 'MW_PHPUNIT_TEST' ) &&
305            class_exists( NullHttpRequestFactory::class ) &&
306            $this->httpRequestFactory instanceof NullHttpRequestFactory ) {
307            return StatusValue::newGood();
308        }
309        /** @var MWHttpRequest $request */
310        $request = $this->httpRequestFactory->create( $url, [ 'timeout' => 10 ], __METHOD__ );
311        if ( $this->originalRequest ) {
312            $request->setOriginalRequest( $this->originalRequest );
313        }
314        $status = $request->execute();
315        $parseStatus = FormatJson::parse( $request->getContent() ?? '', FormatJson::FORCE_ASSOC );
316        if ( $status->isOK() ) {
317            $status->merge( $parseStatus, true );
318        }
319
320        $apiErrorData = [];
321        if ( !$status->isOK() && $parseStatus->isOK() && is_array( $parseStatus->getValue() ) ) {
322            // hash of: type, title, method, uri, [detail]
323            $apiErrorData = $parseStatus->getValue();
324            if ( isset( $apiErrorData['detail'] ) && is_array( $apiErrorData['detail'] ) ) {
325                $apiErrorData['detail'] = implode( ', ', $apiErrorData['detail'] );
326            }
327        }
328        if (
329            $request->getStatus() === 404 &&
330            isset( $apiErrorData['type'] ) &&
331            $apiErrorData['type'] === 'https://mediawiki.org/wiki/HyperSwitch/errors/not_found'
332        ) {
333            // the pageview API will return with a 404 when the page has 0 views :/
334            $status = StatusValue::newGood( [ 'items' => [] ] );
335        }
336        if ( !$status->isGood() ) {
337            $error = Status::wrap( $status )->getWikiText( false, false, 'en' );
338            $severity = $status->isOK() ? LogLevel::INFO : LogLevel::ERROR;
339            $msg = $status->isOK()
340                ? 'Problems fetching {requesturl}: {error}'
341                : 'Failed fetching {requesturl}: {error}';
342            $prefixedApiErrorData = array_combine( array_map( static function ( $k ) {
343                return 'apierror_' . $k;
344            }, array_keys( $apiErrorData ) ), $apiErrorData );
345            $this->logger->log( $severity, $msg, [
346                'requesturl' => $url,
347                'error' => $error,
348            ] + $prefixedApiErrorData );
349        }
350        if ( !$status->isOK() && isset( $apiErrorData['detail'] ) ) {
351            $status->error( ( new RawMessage( '$1' ) )->params( $apiErrorData['detail'] ) );
352        }
353
354        return $status;
355    }
356
357    /**
358     * The pageview API omits dates if there is no data. Fill it with nulls to make client-side
359     * processing easier.
360     * @param int $days
361     * @return array YYYY-MM-DD => null
362     */
363    protected function getEmptyDateRange( $days ) {
364        if ( !$this->range ) {
365            $this->range = [];
366            // we only care about the date part, so add some hours to avoid errors when there is a
367            // leap second or some other weirdness
368            $end = $this->lastCompleteDay + 12 * 3600;
369            $start = $end - ( $days - 1 ) * 24 * 3600;
370            for ( $ts = $start; $ts <= $end; $ts += 24 * 3600 ) {
371                $this->range[gmdate( 'Y-m-d', $ts )] = null;
372            }
373        }
374        return $this->range;
375    }
376
377    /**
378     * Get start and end timestamp in YYYYMMDDHH format
379     * @param int $days
380     * @return string[]
381     */
382    protected function getStartEnd( $days ) {
383        $end = $this->lastCompleteDay + 12 * 3600;
384        $start = $end - ( $days - 1 ) * 24 * 3600;
385        return [ gmdate( 'Ymd', $start ) . '00', gmdate( 'Ymd', $end ) . '00' ];
386    }
387}