Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
50.51% |
50 / 99 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
| Pingback | |
51.02% |
50 / 98 |
|
50.00% |
4 / 8 |
78.87 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| run | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
5.02 | |||
| wasRecentlySent | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| acquireLock | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| getData | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| getSystemInfo | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
| fetchOrInsertId | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
| postPingback | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Installer; |
| 8 | |
| 9 | use MediaWiki\Config\Config; |
| 10 | use MediaWiki\Http\HttpRequestFactory; |
| 11 | use MediaWiki\Json\FormatJson; |
| 12 | use MediaWiki\MainConfigNames; |
| 13 | use MediaWiki\Utils\MWCryptRand; |
| 14 | use Psr\Log\LoggerInterface; |
| 15 | use Wikimedia\ObjectCache\BagOStuff; |
| 16 | use Wikimedia\Rdbms\DBError; |
| 17 | use Wikimedia\Rdbms\IConnectionProvider; |
| 18 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
| 19 | use 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 | */ |
| 34 | class 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 */ |
| 319 | class_alias( Pingback::class, 'Pingback' ); |