Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.58% covered (danger)
7.58%
15 / 198
7.14% covered (danger)
7.14%
1 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
GoogleAnalyticsPageViewService
7.58% covered (danger)
7.58%
15 / 198
7.14% covered (danger)
7.14%
1 / 14
2713.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 setLogger
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 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getPageData
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
462
 getSiteData
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 getTopPages
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
56
 extractExpressionsFromRequests
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getGAName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheExpiry
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 verifyApiOptions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getEmptyDateRange
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getStartEnd
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 pageTitleForMW
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createDimensions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\PageViewInfoGA;
4
5use Google\Client;
6use Google\Service\AnalyticsReporting;
7use Google\Service\AnalyticsReporting\DateRange;
8use Google\Service\AnalyticsReporting\Dimension;
9use Google\Service\AnalyticsReporting\DimensionFilter;
10use Google\Service\AnalyticsReporting\DimensionFilterClause;
11use Google\Service\AnalyticsReporting\GetReportsRequest;
12use Google\Service\AnalyticsReporting\Metric;
13use Google\Service\AnalyticsReporting\OrderBy;
14use Google\Service\AnalyticsReporting\ReportRequest;
15use Google\Service\AnalyticsReporting\ReportRow;
16use InvalidArgumentException;
17use MediaWiki\Extension\PageViewInfo\PageViewService;
18use Psr\Log\LoggerAwareInterface;
19use Psr\Log\LoggerInterface;
20use Psr\Log\NullLogger;
21use RuntimeException;
22use Status;
23use StatusValue;
24
25/**
26 * PageViewService implementation for wikis using the Google Analytics
27 * @see https://developers.google.com/analytics
28 */
29class GoogleAnalyticsPageViewService implements PageViewService, LoggerAwareInterface {
30    /** @var LoggerInterface */
31    protected $logger;
32
33    /** @var AnalyticsReporting */
34    protected $analytics;
35
36    /** @var string Profile(View) ID of the Google Analytics View. */
37    protected $profileId;
38
39    /** @var array */
40    protected $customMap;
41
42    /** @var bool */
43    protected $readCustomDimensions;
44
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    /** Google Analytics API restricts number of requests up to 5. */
52    public const MAX_REQUEST = 5;
53
54    /**
55     * @param array $options Associative array.
56     */
57    public function __construct( array $options ) {
58        $this->verifyApiOptions( $options );
59
60        // Skip the current day for which only partial information is available
61        $this->lastCompleteDay = strtotime( '0:0 1 day ago' );
62
63        $this->logger = new NullLogger();
64
65        $client = new Client();
66        $client->setApplicationName( 'PageViewInfo' );
67        if ( $options['credentialsFile'] ) {
68            $client->setAuthConfig( $options['credentialsFile'] );
69        }
70
71        $client->addScope( AnalyticsReporting::ANALYTICS_READONLY );
72        $this->analytics = new AnalyticsReporting( $client );
73
74        $this->profileId = $options['profileId'] ?? false;
75        $this->customMap = $options['customMap'] ?? false;
76        $this->readCustomDimensions = $options['readCustomDimensions'] ?? false;
77    }
78
79    /**
80     * @inheritDoc
81     */
82    public function setLogger( LoggerInterface $logger ) {
83        $this->logger = $logger;
84    }
85
86    /**
87     * @inheritDoc
88     */
89    public function supports( $metric, $scope ) {
90        return in_array( $metric, [ self::METRIC_VIEW, self::METRIC_UNIQUE ] ) &&
91            in_array( $scope, [ self::SCOPE_ARTICLE, self::SCOPE_TOP, self::SCOPE_SITE ] );
92    }
93
94    /**
95     * @inheritDoc
96     */
97    public function getPageData( array $titles, $days, $metric = self::METRIC_VIEW ) {
98        if ( !$titles ) {
99            return StatusValue::newGood( [] );
100        }
101        if ( $days <= 0 ) {
102            throw new InvalidArgumentException( 'Invalid days: ' . $days );
103        }
104
105        $readCustomDimensions = $this->readCustomDimensions;
106        $result = [];
107        $requests = [];
108        foreach ( $titles as $title ) {
109            $result[$title->getPrefixedDBkey()] = $this->getEmptyDateRange( $days );
110
111            // Create DateRange
112            $dateRange = new DateRange();
113            $dateRange->setStartDate( $days . 'daysAgo' );
114            $dateRange->setEndDate( "1daysAgo" );
115
116            // Create Metrics
117            $gaMetric = new Metric();
118            if ( $metric === self::METRIC_VIEW ) {
119                $gaMetric->setExpression( 'ga:pageviews' );
120            } elseif ( $metric === self::METRIC_UNIQUE ) {
121                $gaMetric->setExpression( 'ga:uniquePageviews' );
122            } else {
123                throw new InvalidArgumentException( 'Invalid metric: ' . $metric );
124            }
125
126            // Create DimensionFilter
127            $dimensionFilter = new DimensionFilter();
128            if ( $readCustomDimensions ) {
129                // Use custom dimensions instead of ga:pageTitle
130                $dimensionFilter->setDimensionName( $this->getGAName( 'mw:page_title' ) );
131                $dimensionFilter->setOperator( 'EXACT' );
132                $dimensionFilter->setExpressions( [ $title->getPrefixedDBkey() ] );
133            } else {
134                // Use regular expression to filter the title.
135                // This is not the ideal approach and maybe fails for some titles.
136                $dimensionFilter->setDimensionName( 'ga:pageTitle' );
137                $dimensionFilter->setOperator( 'REGEXP' );
138                $dimensionFilter->setExpressions( [
139                    '^' . str_replace( '_', ' ', $title->getPrefixedDBkey() ) . ' - [^-]+$' ] );
140            }
141            // Create DimensionFilterClause
142            $dimensionFilterClause = new DimensionFilterClause();
143            $dimensionFilterClause->setFilters( [ $dimensionFilter ] );
144
145            // Create ReportRequest
146            $request = new ReportRequest();
147            $request->setViewId( $this->profileId );
148            $request->setDateRanges( [ $dateRange ] );
149            $request->setMetrics( [ $gaMetric ] );
150            $request->setDimensions( $this->createDimensions( [
151                'ga:date',
152                $readCustomDimensions ? $this->getGAName( 'mw:page_title' ) : 'ga:pageTitle',
153            ] ) );
154            $request->setDimensionFilterClauses( [ $dimensionFilterClause ] );
155
156            $requests[] = $request;
157        }
158
159        $status = StatusValue::newGood();
160        for ( $i = 0; $i < count( $requests ); $i += self::MAX_REQUEST ) {
161            $reqs = array_slice( $requests, $i, self::MAX_REQUEST );
162            $body = new GetReportsRequest();
163            $body->setReportRequests( $reqs );
164
165            $reports = [];
166            try {
167                $reports = $this->analytics->reports->batchGet( $body )->getReports();
168            } catch ( \Google\Service\Exception $e ) {
169                foreach ( self::extractExpressionsFromRequests( $reqs ) as $exp ) {
170                    if ( !$readCustomDimensions ) {
171                        // $exp is a regular expression for title, strip.
172                        preg_match( '/\^(.+) - \[\^-\]\+\$/', $exp, $matches );
173                        if ( !$matches ) {
174                            continue;
175                        }
176                        $exp = $matches[1];
177                    }
178                    $status->success[$exp] = false;
179                }
180                $status->error( 'pvi-invalidresponse' );
181            }
182
183            foreach ( $reports as $rep ) {
184                $rows = $rep->getData()->getRows();
185                if ( !$rows || !is_array( $rows ) ) {
186                    continue;
187                }
188                foreach ( $rows as $row ) {
189                    if ( !( $row instanceof ReportRow ) ) {
190                        continue;
191                    }
192                    $ts = $row->getDimensions()[0];
193                    $day = substr( $ts, 0, 4 ) . '-' . substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 );
194                    $count = (int)$row->getMetrics()[0]->getValues()[0];
195                    $title = $row->getDimensions()[1];
196                    if ( !$readCustomDimensions ) {
197                        $title = $this->pageTitleForMW( $title );
198                    }
199                    $result[$title][$day] = $count;
200                    $status->success[$title] = true;
201                }
202            }
203        }
204
205        // Fills success even if the title is not included in responses.
206        // https://github.com/femiwiki/PageViewInfoGA/issues/46
207        foreach ( $titles as $title ) {
208            if ( !in_array( $title, $status->success ) ) {
209                $status->success[$title->getPrefixedDBkey()] = false;
210            }
211        }
212        $status->successCount = count( array_filter( $status->success ) );
213        $status->failCount = count( $status->success ) - $status->successCount;
214        $status->setResult( (bool)$status->successCount, $result );
215        return $status;
216    }
217
218    /**
219     * @inheritDoc
220     */
221    public function getSiteData( $days, $metric = self::METRIC_VIEW ) {
222        if ( $metric !== self::METRIC_VIEW && $metric !== self::METRIC_UNIQUE ) {
223            throw new InvalidArgumentException( 'Invalid metric: ' . $metric );
224        }
225        if ( $days <= 0 ) {
226            throw new InvalidArgumentException( 'Invalid days: ' . $days );
227        }
228        $result = $this->getEmptyDateRange( $days );
229
230        // Create the DateRange object.
231        $dateRange = new DateRange();
232        $dateRange->setStartDate( $days . 'daysAgo' );
233        $dateRange->setEndDate( '1daysAgo' );
234
235        // Create the Metrics object.
236        $gaMetric = new Metric();
237        if ( $metric === self::METRIC_VIEW ) {
238            $gaMetric->setExpression( 'ga:pageviews' );
239        } elseif ( $metric === self::METRIC_UNIQUE ) {
240            $gaMetric->setExpression( 'ga:uniquePageviews' );
241        } else {
242            throw new InvalidArgumentException( 'Invalid metric: ' . $metric );
243        }
244
245        // Create the Dimension object.
246        $dimension = new Dimension();
247        $dimension->setName( 'ga:date' );
248
249        // Create the ReportRequest object.
250        $request = new ReportRequest();
251        $request->setViewId( $this->profileId );
252        $request->setDateRanges( [ $dateRange ] );
253        $request->setMetrics( [ $gaMetric ] );
254        $request->setDimensions( [ $dimension ] );
255
256        $body = new GetReportsRequest();
257        $body->setReportRequests( [ $request ] );
258
259        $status = Status::newGood();
260        try {
261            $data = $this->analytics->reports->batchGet( $body );
262            $rows = $data->getReports()[0]->getData()->getRows();
263
264            foreach ( $rows as $row ) {
265                $ts = $row->dimensions[0];
266                $day = substr( $ts, 0, 4 ) . '-' . substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 );
267                $count = (int)$row->metrics[0]->values[0];
268                $result[$day] = $count;
269            }
270            $status->setResult( $status->isOK(), $result );
271        } catch ( RuntimeException $e ) {
272            $status->fatal( 'pvi-invalidresponse' );
273        }
274        return $status;
275    }
276
277    /**
278     * @inheritDoc
279     */
280    public function getTopPages( $metric = self::METRIC_VIEW ) {
281        $result = [];
282        if ( !in_array( $metric, [ self::METRIC_VIEW, self::METRIC_UNIQUE ] ) ) {
283            throw new InvalidArgumentException( 'Invalid metric: ' . $metric );
284        }
285
286        // Create the DateRange object.
287        $dateRange = new DateRange();
288        $dateRange->setStartDate( '2daysAgo' );
289        $dateRange->setEndDate( '1daysAgo' );
290
291        // Create the Metrics object and OrderBy object.
292        $gaMetric = new Metric();
293        $orderBy = new OrderBy();
294        $orderBy->setSortOrder( 'DESCENDING' );
295        if ( $metric === self::METRIC_VIEW ) {
296            $gaMetric->setExpression( 'ga:pageviews' );
297            $orderBy->setFieldName( 'ga:pageviews' );
298        } elseif ( $metric === self::METRIC_UNIQUE ) {
299            $gaMetric->setExpression( 'ga:uniquePageviews' );
300            $orderBy->setFieldName( 'ga:uniquePageviews' );
301        }
302
303        // Create the Dimension object.
304        $dimension = new Dimension();
305        $dimension->setName( $this->readCustomDimensions ? $this->getGAName( 'mw:page_title' ) : 'ga:pageTitle' );
306
307        // Create the ReportRequest object.
308        $request = new ReportRequest();
309        $request->setViewId( $this->profileId );
310        $request->setDateRanges( [ $dateRange ] );
311        $request->setMetrics( [ $gaMetric ] );
312        $request->setDimensions( [ $dimension ] );
313        $request->setOrderBys( [ $orderBy ] );
314
315        $body = new GetReportsRequest();
316        $body->setReportRequests( [ $request ] );
317
318        $status = Status::newGood();
319        try {
320            $data = $this->analytics->reports->batchGet( $body );
321            $rows = $data->getReports()[0]->getData()->getRows();
322
323            foreach ( $rows as $row ) {
324                $title = $row->dimensions[0];
325                $title = $this->pageTitleForMW( $title );
326                $count = (int)$row->metrics[0]->values[0];
327                $result[$title] = $count;
328            }
329            $status->setResult( $status->isOK(), $result );
330        } catch ( RuntimeException $e ) {
331            $status->fatal( 'pvi-invalidresponse' );
332        }
333        return $status;
334    }
335
336    /**
337     * @param ReportRequest[] $requests
338     * @return string[]
339     */
340    protected static function extractExpressionsFromRequests( $requests ) {
341        $exps = [];
342        foreach ( $requests as $req ) {
343            foreach ( $req->getDimensionFilterClauses() as $clause ) {
344                foreach ( $clause->getFilters() as $filter ) {
345                    foreach ( $filter->getExpressions() as $exp ) {
346                        $exps[] = $exp;
347                    }
348                }
349            }
350        }
351        return $exps;
352    }
353
354    /**
355     * @param string $mwName
356     * @return string
357     */
358    protected function getGAName( $mwName ) {
359        $flipped = array_flip( $this->customMap );
360        return 'ga:' . $flipped[$mwName];
361    }
362
363    /**
364     * @inheritDoc
365     */
366    public function getCacheExpiry( $metric, $scope ) {
367        // data is valid until the end of the day
368        $endOfDay = strtotime( '0:0 next day' );
369        return $endOfDay - time();
370    }
371
372    /**
373     * @param array $apiOptions
374     * @throws InvalidArgumentException
375     */
376    protected function verifyApiOptions( array $apiOptions ) {
377        if ( !isset( $apiOptions['credentialsFile'] ) ) {
378            throw new InvalidArgumentException( "'credentialsFile' is required" );
379        } elseif ( !isset( $apiOptions['profileId'] ) ) {
380            throw new InvalidArgumentException( "'profileId' is required" );
381        }
382    }
383
384    /**
385     * The API omits dates if there is no data. Fill it with nulls to make client-side
386     * processing easier.
387     * @param int $days
388     * @return array YYYY-MM-DD => null
389     */
390    protected function getEmptyDateRange( $days ) {
391        if ( !$this->range ) {
392            $this->range = [];
393            // we only care about the date part, so add some hours to avoid errors when there is a
394            // leap second or some other weirdness
395            $end = $this->lastCompleteDay + 12 * 3600;
396            $start = $end - ( $days - 1 ) * 24 * 3600;
397            for ( $ts = $start; $ts <= $end; $ts += 24 * 3600 ) {
398                $this->range[gmdate( 'Y-m-d', $ts )] = null;
399            }
400        }
401        return $this->range;
402    }
403
404    /**
405     * Get start and end timestamp in YYYYMMDDHH format
406     * @param int $days
407     * @return string[]
408     */
409    protected function getStartEnd( $days ) {
410        $end = $this->lastCompleteDay + 12 * 3600;
411        $start = $end - ( $days - 1 ) * 24 * 3600;
412        return [ gmdate( 'Ymd', $start ) . '00', gmdate( 'Ymd', $end ) . '00' ];
413    }
414
415    /**
416     * @param string $gaTitle
417     * @return string title text converted MediaWiki-friendly
418     */
419    protected static function pageTitleForMW( $gaTitle ) {
420        // TODO: Use "pagetitle" and "pagetitle-view-mainpage" messages
421        $title = preg_replace( '/ - [^-]+$/', '', $gaTitle );
422        $title = preg_replace( '/ /', '_', $title );
423
424        return $title;
425    }
426
427    /**
428     * @param string[] $names
429     * @return Dimension[]
430     */
431    protected function createDimensions( $names ) {
432        $dimensions = [];
433        foreach ( $names as $name ) {
434            $dimension = new Dimension();
435            $dimension->setName( $name );
436            $dimensions[] = $dimension;
437        }
438        return $dimensions;
439    }
440}