Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.51% covered (warning)
50.51%
50 / 99
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pingback
51.02% covered (warning)
51.02%
50 / 98
50.00% covered (danger)
50.00%
4 / 8
78.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 run
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
5.02
 wasRecentlySent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 acquireLock
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getData
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getSystemInfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 fetchOrInsertId
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 postPingback
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Installer;
8
9use MediaWiki\Config\Config;
10use MediaWiki\Http\HttpRequestFactory;
11use MediaWiki\Json\FormatJson;
12use MediaWiki\MainConfigNames;
13use MediaWiki\Utils\MWCryptRand;
14use Psr\Log\LoggerInterface;
15use Wikimedia\ObjectCache\BagOStuff;
16use Wikimedia\Rdbms\DBError;
17use Wikimedia\Rdbms\IConnectionProvider;
18use Wikimedia\Timestamp\ConvertibleTimestamp;
19use Wikimedia\Timestamp\TimestampFormat as TS;
20
21/**
22 * Send information about this MediaWiki instance to mediawiki.org.
23 *
24 * This service uses two kinds of rows in the `update_log` database table:
25 *
26 * - ul_key `PingBack`, this holds a random identifier for this wiki,
27 *   created only once, when the first ping after wiki creation is sent.
28 * - ul_key `Pingback-<MW_VERSION>`, this holds a timestamp and is created
29 *   once after each MediaWiki upgrade, and then updated up to once a month.
30 *
31 * @internal For use by Setup.php only
32 * @since 1.28
33 */
34class Pingback {
35
36    /**
37     * @var string The name of the Legacy EventLogging schema that Pingback used to use.
38     */
39    private const LEGACY_EVENTLOGGING_SCHEMA = 'MediaWikiPingback';
40
41    /**
42     * @var string The versioned schema with which the Pingback events will be validated.
43     *
44     * All versions of the schema live at
45     * {@link https://schema.wikimedia.org/#!//secondary/jsonschema/analytics/legacy/mediawikipingback}.
46     */
47    private const EVENT_PLATFORM_SCHEMA_ID = '/analytics/legacy/mediawikipingback/1.0.0';
48
49    /**
50     * @var string The name of the Event Platform stream to submit the event to.
51     *
52     * By convention, we derive the name of an Event Platform stream corresponding to a Legacy
53     * EventLogging schema by prepending "eventlogging_" to it, i.e.
54     * "FooSchema" -> "eventlogging_FooSchema". This convention is codified in
55     * {@link https://gerrit.wikimedia.org/g/mediawiki/extensions/EventLogging/+/d47dbc10455bcb6dbc98a49fa169f75d6131c3da/includes/EventLogging.php#298}.
56     *
57     * @see Pingback::LEGACY_EVENTLOGGING_SCHEMA
58     */
59    private const EVENT_PLATFORM_STREAM = 'eventlogging_MediaWikiPingback';
60
61    /** @var string */
62    private const EVENT_PLATFORM_EVENT_INTAKE_SERVICE_URI =
63        'https://intake-analytics.wikimedia.org/v1/events?hasty=true';
64
65    /** @var LoggerInterface */
66    protected $logger;
67    /** @var Config */
68    protected $config;
69    /** @var IConnectionProvider */
70    protected $dbProvider;
71    /** @var BagOStuff */
72    protected $cache;
73    /** @var HttpRequestFactory */
74    protected $http;
75    /** @var string updatelog key (also used as cache/db lock key) */
76    protected $key;
77    /** @var string */
78    protected $eventIntakeUri;
79
80    /**
81     * @param Config $config
82     * @param IConnectionProvider $dbProvider
83     * @param BagOStuff $cache
84     * @param HttpRequestFactory $http
85     * @param LoggerInterface $logger
86     */
87    public function __construct(
88        Config $config,
89        IConnectionProvider $dbProvider,
90        BagOStuff $cache,
91        HttpRequestFactory $http,
92        LoggerInterface $logger,
93        string $eventIntakeUrl = self::EVENT_PLATFORM_EVENT_INTAKE_SERVICE_URI
94    ) {
95        $this->config = $config;
96        $this->dbProvider = $dbProvider;
97        $this->cache = $cache;
98        $this->http = $http;
99        $this->logger = $logger;
100        $this->key = 'Pingback-' . MW_VERSION;
101        $this->eventIntakeUri = $eventIntakeUrl;
102    }
103
104    /**
105     * Maybe send a ping.
106     *
107     * @throws DBError If identifier insert fails
108     * @throws DBError If timestamp upsert fails
109     */
110    public function run(): void {
111        if ( !$this->config->get( MainConfigNames::Pingback ) ) {
112            // disabled
113            return;
114        }
115        if ( $this->wasRecentlySent() ) {
116            // already sent recently
117            return;
118        }
119        if ( !$this->acquireLock() ) {
120            $this->logger->debug( __METHOD__ . ": couldn't acquire lock" );
121            return;
122        }
123
124        $data = $this->getData();
125        if ( !$this->postPingback( $data ) ) {
126            $this->logger->warning( __METHOD__ . ": failed to send; check 'http' log channel" );
127            return;
128        }
129
130        // Record the fact that we have sent a pingback for this MediaWiki version,
131        // so we don't submit data multiple times.
132        $dbw = $this->dbProvider->getPrimaryDatabase();
133        $timestamp = ConvertibleTimestamp::time();
134        $dbw->newInsertQueryBuilder()
135            ->insertInto( 'updatelog' )
136            ->row( [ 'ul_key' => $this->key, 'ul_value' => $timestamp ] )
137            ->onDuplicateKeyUpdate()
138            ->uniqueIndexFields( [ 'ul_key' ] )
139            ->set( [ 'ul_value' => $timestamp ] )
140            ->caller( __METHOD__ )->execute();
141        $this->logger->debug( __METHOD__ . ": pingback sent OK ({$this->key})" );
142    }
143
144    /**
145     * Was a pingback sent in the last month for this MediaWiki version?
146     */
147    private function wasRecentlySent(): bool {
148        $timestamp = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
149            ->select( 'ul_value' )
150            ->from( 'updatelog' )
151            ->where( [ 'ul_key' => $this->key ] )
152            ->caller( __METHOD__ )->fetchField();
153        if ( $timestamp === false ) {
154            return false;
155        }
156        // send heartbeat ping if the last ping was over a month ago
157        if ( ConvertibleTimestamp::time() - (int)$timestamp > 60 * 60 * 24 * 30 ) {
158            return false;
159        }
160        return true;
161    }
162
163    /**
164     * Acquire lock for sending a pingback
165     *
166     * This ensures only one thread can attempt to send a pingback at any given
167     * time and that we wait an hour before retrying failed attempts.
168     *
169     * @return bool Whether lock was acquired
170     */
171    private function acquireLock(): bool {
172        $cacheKey = $this->cache->makeKey( 'pingback', $this->key );
173        if ( !$this->cache->add( $cacheKey, 1, $this->cache::TTL_HOUR ) ) {
174            // throttled
175            return false;
176        }
177
178        $dbw = $this->dbProvider->getPrimaryDatabase();
179        if ( !$dbw->lock( $this->key, __METHOD__, 0 ) ) {
180            // already in progress
181            return false;
182        }
183
184        return true;
185    }
186
187    /**
188     * Get the event to be sent to the server.
189     *
190     * Note well that, as well as the pingback data, only those fields required by the Event Platform are set (see
191     * <https://wikitech.wikimedia.org/wiki/Event_Platform/Schemas/Guidelines#Required_fields>).
192     *
193     * @throws DBError If identifier insert fails
194     * @return array
195     */
196    protected function getData(): array {
197        $wiki = $this->fetchOrInsertId();
198
199        return [
200            'event' => self::getSystemInfo( $this->config ),
201            'schema' => self::LEGACY_EVENTLOGGING_SCHEMA,
202            'wiki' => $wiki,
203
204            // This would be added by
205            // https://gerrit.wikimedia.org/g/mediawiki/extensions/EventLogging/+/d47dbc10455bcb6dbc98a49fa169f75d6131c3da/includes/EventLogging.php#274
206            // onwards.
207            '$schema' => self::EVENT_PLATFORM_SCHEMA_ID,
208            'client_dt' => ConvertibleTimestamp::now( TS::ISO_8601 ),
209
210            // This would be added by
211            // https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/EventLogging/+/d47dbc10455bcb6dbc98a49fa169f75d6131c3da/includes/EventSubmitter/EventBusEventSubmitter.php#81
212            // onwards.
213            'meta' => [
214                'stream' => self::EVENT_PLATFORM_STREAM,
215            ],
216        ];
217    }
218
219    /**
220     * Collect basic data about this MediaWiki installation and return it
221     * as an associative array conforming to the MediaWikiPingback event schema at
222     * <https://gerrit.wikimedia.org/r/plugins/gitiles/schemas/event/secondary/+/refs/heads/master/jsonschema/analytics/legacy/mediawikipingback/>.
223     *
224     * Developers: If you're adding a new piece of data to this, please document
225     * this data at <https://www.mediawiki.org/wiki/Manual:$wgPingback>.
226     *
227     * @internal For use by Installer only to display which data we send.
228     * @param Config $config With `DBtype` set.
229     * @return array
230     */
231    public static function getSystemInfo( Config $config ): array {
232        $event = [
233            'database' => $config->get( MainConfigNames::DBtype ),
234            'MediaWiki' => MW_VERSION,
235            'PHP' => PHP_VERSION,
236            'OS' => PHP_OS . ' ' . php_uname( 'r' ),
237            'arch' => PHP_INT_SIZE === 8 ? 64 : 32,
238            'machine' => php_uname( 'm' ),
239        ];
240
241        if ( isset( $_SERVER['SERVER_SOFTWARE'] ) ) {
242            $event['serverSoftware'] = $_SERVER['SERVER_SOFTWARE'];
243        }
244
245        $limit = ini_get( 'memory_limit' );
246        if ( $limit && $limit !== "-1" ) {
247            $event['memoryLimit'] = $limit;
248        }
249
250        return $event;
251    }
252
253    /**
254     * Get a unique, stable identifier for this wiki
255     *
256     * If the identifier does not already exist, create it and save it in the
257     * database. The identifier is randomly-generated.
258     *
259     * @throws DBError If identifier insert fails
260     * @return string 32-character hex string
261     */
262    private function fetchOrInsertId(): string {
263        // We've already obtained a primary connection for the lock, and plan to do a write.
264        // But, still prefer reading this immutable value from a replica to reduce load.
265        $id = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
266            ->select( 'ul_value' )
267            ->from( 'updatelog' )
268            ->where( [ 'ul_key' => 'PingBack' ] )
269            ->caller( __METHOD__ )->fetchField();
270        if ( $id !== false ) {
271            return $id;
272        }
273
274        $dbw = $this->dbProvider->getPrimaryDatabase();
275        $id = $dbw->newSelectQueryBuilder()
276            ->select( 'ul_value' )
277            ->from( 'updatelog' )
278            ->where( [ 'ul_key' => 'PingBack' ] )
279            ->caller( __METHOD__ )->fetchField();
280        if ( $id !== false ) {
281            return $id;
282        }
283
284        $id = MWCryptRand::generateHex( 32 );
285        $dbw->newInsertQueryBuilder()
286            ->insertInto( 'updatelog' )
287            ->row( [ 'ul_key' => 'PingBack', 'ul_value' => $id ] )
288            ->caller( __METHOD__ )->execute();
289        return $id;
290    }
291
292    /**
293     * Serialize the pingback data and submit it to the Event Platform (see
294     * <https://wikitech.wikimedia.org/wiki/Event_Platform>).
295     *
296     * Compare:
297     * <https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/EventLogging/+/933b62f29d68f/includes/EventSubmitter/EventBusEventSubmitter.php#33>
298     *
299     * The schema for the event is located at:
300     * <https://schema.wikimedia.org/repositories/secondary/jsonschema/analytics/legacy/mediawikipingback/1.0.0>
301     *
302     * @param array $data Pingback data as an associative array
303     * @return bool
304     */
305    private function postPingback( array $data ): bool {
306        $request = $this->http->create( $this->eventIntakeUri, [
307            'method' => 'POST',
308            'postData' => FormatJson::encode( $data ),
309        ], __METHOD__ );
310        $request->setHeader( 'Content-Type', 'application/json' );
311
312        $result = $request->execute();
313
314        return $result->isGood();
315    }
316}
317
318/** @deprecated class alias since 1.41 */
319class_alias( Pingback::class, 'Pingback' );