Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.29% covered (warning)
84.29%
59 / 70
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventLogging
85.51% covered (warning)
85.51%
59 / 69
58.33% covered (warning)
58.33%
7 / 12
24.61
0.00% covered (danger)
0.00%
0 / 1
 getLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEventSubmitter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMetricsPlatformClient
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 resetMetricsPlatformClient
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 submit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sendBeacon
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 logEvent
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
3.00
 serializeEvent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 schemaValidate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 sessionInSample
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 encapsulate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 getLegacyStreamName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * PHP API for logging events.
4 *
5 * @file
6 * @ingroup Extensions
7 * @ingroup EventLogging
8 *
9 * @author Ori Livneh <ori@wikimedia.org>
10 */
11
12namespace MediaWiki\Extension\EventLogging;
13
14use FormatJson;
15use MediaWiki\Context\RequestContext;
16use MediaWiki\Deferred\DeferredUpdates;
17use MediaWiki\Extension\EventLogging\EventSubmitter\EventSubmitter;
18use MediaWiki\Extension\EventLogging\Libs\JsonSchemaValidation\JsonSchemaException;
19use MediaWiki\Extension\EventLogging\Libs\JsonSchemaValidation\JsonTreeRef;
20use MediaWiki\Extension\EventLogging\MetricsPlatform\MetricsClientFactory;
21use MediaWiki\Logger\LoggerFactory;
22use MediaWiki\MediaWikiServices;
23use Psr\Log\LoggerInterface;
24use RuntimeException;
25use Wikimedia\MetricsPlatform\MetricsClient;
26
27class EventLogging {
28
29    /**
30     * @var MetricsClient|null
31     */
32    private static $metricsPlatformClient;
33
34    /**
35     * Default logger.
36     *
37     * @internal
38     */
39    public static function getLogger(): LoggerInterface {
40        return LoggerFactory::getInstance( 'EventLogging' );
41    }
42
43    private static function getEventSubmitter(): EventSubmitter {
44        return MediaWikiServices::getInstance()->get( 'EventLogging.EventSubmitter' );
45    }
46
47    /**
48     * Gets the singleton instance of the Metrics Platform Client (MPC).
49     *
50     * @see https://wikitech.wikimedia.org/wiki/Metrics_Platform
51     */
52    public static function getMetricsPlatformClient(): MetricsClient {
53        if ( !self::$metricsPlatformClient ) {
54            /** @var MetricsClientFactory $metricsClientFactory */
55            $metricsClientFactory =
56                MediaWikiServices::getInstance()->getService( 'EventLogging.MetricsClientFactory' );
57
58            self::$metricsPlatformClient = $metricsClientFactory->newMetricsClient( RequestContext::getMain() );
59        }
60
61        return self::$metricsPlatformClient;
62    }
63
64    /**
65     * Resets the Metrics Platform Client for testing purposes. See also the warning and note
66     * against {@link MediaWikiServices::resetServiceForTesting()}.
67     *
68     * @internal
69     *
70     * @throws RuntimeException If called outside a PHPUnit test
71     */
72    public static function resetMetricsPlatformClient(): void {
73        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
74            throw new RuntimeException( __METHOD__ . ' may only be called during unit tests.' );
75        }
76
77        self::$metricsPlatformClient = null;
78    }
79
80    /**
81     * Submit an event according to the given stream's configuration.
82     *
83     * @param string $streamName
84     * @param array $event
85     * @param LoggerInterface|null $logger @deprecated since 1.40. All messages will be logged
86     *  via the `EventLogging.Logger` service
87     */
88    public static function submit(
89        string $streamName,
90        array $event,
91        ?LoggerInterface $logger = null
92    ): void {
93        if ( $logger ) {
94            wfDeprecatedMsg( __METHOD__ . ': $logger parameter is deprecated', '1.40' );
95        }
96
97        self::getEventSubmitter()->submit( $streamName, $event );
98    }
99
100    /**
101     * Transfer small data asynchronously using an HTTP POST.
102     * This is meant to match the Navigator.sendBeacon() API.
103     *
104     * @see https://w3c.github.io/beacon/#sec-sendBeacon-method
105     * @param string $url
106     * @param array $data
107     * @return bool
108     * @deprecated use submit with new Event Platform based schemas.
109     */
110    public static function sendBeacon( $url, array $data = [] ) {
111        $fname = __METHOD__;
112        $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
113        $url = $urlUtils->expand( $url, PROTO_INTERNAL ) ?? '';
114        DeferredUpdates::addCallableUpdate( static function () use ( $url, $data, $fname ) {
115            $options = $data ? [ 'postData' => $data ] : [];
116            return MediaWikiServices::getInstance()->getHttpRequestFactory()
117                ->post( $url, $options, $fname );
118        } );
119
120        return true;
121    }
122
123    /**
124     * Legacy EventLogging entrypoint.
125     *
126     * NOTE: For forwards compatibility with Event Platform schemas,
127     * we hijack the wgEventLoggingSchemas revision to encode the
128     * $schema URI. If the value for a schema defined in
129     * EventLoggingSchemas is a string, it is assumed
130     * to be an Event Platform $schema URI, not a MW revision id.
131     * In this case, the event will be POSTed to EventGate.
132     *
133     * @param string $schemaName Schema name.
134     * @param int $revId
135     *        revision ID of schema.  $schemasInfo[$schemaName] will override this.
136     * @param array $eventData
137     *        Map of event keys/vals.
138     *        This is the 'event' field, as provided by the caller,
139     *        not an encapsulated real event.
140     * @param int $options This parameter is deprecated and no longer used.
141     * @return bool Whether the event was logged.
142     * @deprecated use EventLogging::submit() with new Event Platform based schemas.
143     * @see https://wikitech.wikimedia.org/wiki/Event_Platform/Instrumentation_How_To#In_PHP
144     */
145    public static function logEvent( $schemaName, $revId, $eventData, $options = 0 ) {
146        $config = MediaWikiServices::getInstance()->getMainConfig();
147
148        $eventLoggingConfig = Hooks::getEventLoggingConfig( $config );
149        $schemasInfo = $eventLoggingConfig['schemasInfo'];
150        $eventLoggingBaseUri = $eventLoggingConfig['baseUrl'];
151
152        // Get the configured revision id or $schema URI
153        // to use with events of a particular (legacy metawiki) EventLogging schema.
154        // $schemasInfo[$schemaName] overrides passed in $revId.
155        $revisionOrSchemaUri = $schemasInfo[$schemaName] ?? $revId ?? -1;
156
157        // Encapsulate and other event meta data to eventData.
158        $event = self::encapsulate(
159            $schemaName,
160            $revisionOrSchemaUri,
161            $eventData
162        );
163
164        if ( isset( $event['$schema'] ) ) {
165            // Assume that if $schema was set by self::encapsulate(), this
166            // event should be POSTed to EventGate via EventServiceClient submit()
167            self::submit( self::getLegacyStreamName( $schemaName ), $event );
168            return true;
169        } else {
170            // Else this will be sent to the legacy eventlogging backend
171            // via 'sendBeacon' by url encoding the json data into a query parameter.
172            if ( !$eventLoggingBaseUri ) {
173                return false;
174            }
175
176            $json = self::serializeEvent( $event );
177            $url = $eventLoggingBaseUri . '?' . rawurlencode( $json ) . ';';
178
179            return self::sendBeacon( $url );
180        }
181    }
182
183    /**
184     *
185     * Converts the encapsulated event from an object to a string.
186     *
187     * @param array $event Encapsulated event
188     * @return string $json
189     */
190    public static function serializeEvent( $event ) {
191        $eventData = $event['event'];
192
193        if ( count( $eventData ) === 0 ) {
194            // Ensure empty events are serialized as '{}' and not '[]'.
195            $eventData = (object)$eventData;
196        }
197        $event['event'] = $eventData;
198
199        // To make the resultant JSON easily extracted from a row of
200        // space-separated values, we replace literal spaces with unicode
201        // escapes. This is permitted by the JSON specs.
202        return str_replace( ' ', '\u0020', FormatJson::encode( $event ) );
203    }
204
205    /**
206     * Validates object against JSON Schema.
207     *
208     * @throws JsonSchemaException If the object fails to validate.
209     * @param array $object Object to be validated.
210     * @param array|null $schema Schema to validate against (default: JSON Schema).
211     * @return bool True.
212     */
213    public static function schemaValidate( $object, $schema = null ) {
214        if ( $schema === null ) {
215            // Default to JSON Schema
216            $json = file_get_contents( dirname( __DIR__ ) . '/schemas/schemaschema.json' );
217            $schema = FormatJson::decode( $json, true );
218        }
219
220        // We depart from the JSON Schema specification in disallowing by default
221        // additional event fields not mentioned in the schema.
222        // See <https://bugzilla.wikimedia.org/show_bug.cgi?id=44454> and
223        // <https://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.4>.
224        if ( !array_key_exists( 'additionalProperties', $schema ) ) {
225            $schema[ 'additionalProperties' ] = false;
226        }
227
228        $root = new JsonTreeRef( $object );
229        $root->attachSchema( $schema );
230        return $root->validate();
231    }
232
233    /**
234     * Randomise inclusion based on population size and a session ID.
235     * @param int $populationSize Return true one in this many times. This is 1/samplingRate.
236     * @param string $sessionId Hexadecimal value, only the first 8 characters are used
237     * @return bool True if the event should be included (sampled in), false if not (sampled out)
238     */
239    public static function sessionInSample( $populationSize, $sessionId ) {
240        $decimal = (int)base_convert( substr( $sessionId, 0, 8 ), 16, 10 );
241        return $decimal % $populationSize === 0;
242    }
243
244    /**
245     * This encapsulates the event data in a wrapper object with
246     * the default metadata for the current request.
247     *
248     * NOTE: for forwards compatibility with Event Platform schemas,
249     * we hijack the wgEventLoggingSchemas revision to encode the
250     * $schema URI. If the value for a schema defined in
251     * EventLoggingSchemas is a string, it is assumed
252     * to be an Event Platform $schema URI, not a MW revision id.
253     * In this case, the event will be prepared to be POSTed to EventGate.
254     *
255     * @param string $schemaName
256     * @param int|string $revisionOrSchemaUri
257     *        The revision id or a string $schema URI for use with Event Platform.
258     * @param array $eventData un-encapsulated event data
259     * @return array encapsulated event
260     */
261    private static function encapsulate( $schemaName, $revisionOrSchemaUri, $eventData ) {
262        global $wgDBname;
263
264        $event = [
265            'event'            => $eventData,
266            'schema'           => $schemaName,
267            'wiki'             => $wgDBname,
268        ];
269
270        if ( isset( $_SERVER[ 'HTTP_HOST' ] ) ) {
271            $event['webHost'] = $_SERVER['HTTP_HOST'];
272        }
273
274        if ( is_string( $revisionOrSchemaUri ) ) {
275            $event['$schema'] = $revisionOrSchemaUri;
276            // NOTE: `client_dt` is 'legacy' event time.  `dt` is the preferred event time field
277            // and is set in EventServiceClient.
278            $event['client_dt'] = wfTimestamp( TS_ISO_8601 );
279
280            // Note: some fields will have defaults set by eventgate-wikimedia.
281            // See:
282            // - https://gerrit.wikimedia.org/r/plugins/gitiles/eventgate-wikimedia/+/refs/heads/master/eventgate-wikimedia.js#358
283            // - https://wikitech.wikimedia.org/wiki/Event_Platform/Schemas/Guidelines#Automatically_populated_fields
284        } else {
285            $event['revision'] = $revisionOrSchemaUri;
286            $event['userAgent'] = $_SERVER[ 'HTTP_USER_AGENT' ] ?? '';
287        }
288
289        return $event;
290    }
291
292    /**
293     * Prepend "eventlogging_" to the schema name to create a stream name for a migrated legacy
294     * schema.
295     *
296     * @param string $schemaName
297     * @return string
298     */
299    private static function getLegacyStreamName( string $schemaName ): string {
300        return "eventlogging_$schemaName";
301    }
302
303}
304
305class_alias( EventLogging::class, 'EventLogging' );