Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.06% covered (warning)
84.06%
58 / 69
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventLogging
85.29% covered (warning)
85.29%
58 / 68
58.33% covered (warning)
58.33%
7 / 12
24.68
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%
8 / 8
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\Deferred\DeferredUpdates;
16use MediaWiki\Extension\EventLogging\EventSubmitter\EventSubmitter;
17use MediaWiki\Extension\EventLogging\Libs\JsonSchemaValidation\JsonSchemaException;
18use MediaWiki\Extension\EventLogging\Libs\JsonSchemaValidation\JsonTreeRef;
19use MediaWiki\Extension\EventLogging\MetricsPlatform\MetricsClientFactory;
20use MediaWiki\Logger\LoggerFactory;
21use MediaWiki\MediaWikiServices;
22use Psr\Log\LoggerInterface;
23use RequestContext;
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        $url = wfExpandUrl( $url, PROTO_INTERNAL );
113        DeferredUpdates::addCallableUpdate( static function () use ( $url, $data, $fname ) {
114            $options = $data ? [ 'postData' => $data ] : [];
115            return MediaWikiServices::getInstance()->getHttpRequestFactory()
116                ->post( $url, $options, $fname );
117        } );
118
119        return true;
120    }
121
122    /**
123     * Legacy EventLogging entrypoint.
124     *
125     * NOTE: For forwards compatibility with Event Platform schemas,
126     * we hijack the wgEventLoggingSchemas revision to encode the
127     * $schema URI. If the value for a schema defined in
128     * EventLoggingSchemas is a string, it is assumed
129     * to be an Event Platform $schema URI, not a MW revision id.
130     * In this case, the event will be POSTed to EventGate.
131     *
132     * @param string $schemaName Schema name.
133     * @param int $revId
134     *        revision ID of schema.  $schemasInfo[$schemaName] will override this.
135     * @param array $eventData
136     *        Map of event keys/vals.
137     *        This is the 'event' field, as provided by the caller,
138     *        not an encapsulated real event.
139     * @param int $options This parameter is deprecated and no longer used.
140     * @return bool Whether the event was logged.
141     * @deprecated use EventLogging::submit() with new Event Platform based schemas.
142     * @see https://wikitech.wikimedia.org/wiki/Event_Platform/Instrumentation_How_To#In_PHP
143     */
144    public static function logEvent( $schemaName, $revId, $eventData, $options = 0 ) {
145        $config = MediaWikiServices::getInstance()->getMainConfig();
146
147        $eventLoggingConfig = Hooks::getEventLoggingConfig( $config );
148        $schemasInfo = $eventLoggingConfig['schemasInfo'];
149        $eventLoggingBaseUri = $eventLoggingConfig['baseUrl'];
150
151        // Get the configured revision id or $schema URI
152        // to use with events of a particular (legacy metawiki) EventLogging schema.
153        // $schemasInfo[$schemaName] overrides passed in $revId.
154        $revisionOrSchemaUri = $schemasInfo[$schemaName] ?? $revId ?? -1;
155
156        // Encapsulate and other event meta data to eventData.
157        $event = self::encapsulate(
158            $schemaName,
159            $revisionOrSchemaUri,
160            $eventData
161        );
162
163        if ( isset( $event['$schema'] ) ) {
164            // Assume that if $schema was set by self::encapsulate(), this
165            // event should be POSTed to EventGate via EventServiceClient submit()
166            self::submit( self::getLegacyStreamName( $schemaName ), $event );
167            return true;
168        } else {
169            // Else this will be sent to the legacy eventlogging backend
170            // via 'sendBeacon' by url encoding the json data into a query parameter.
171            if ( !$eventLoggingBaseUri ) {
172                return false;
173            }
174
175            $json = self::serializeEvent( $event );
176            $url = $eventLoggingBaseUri . '?' . rawurlencode( $json ) . ';';
177
178            return self::sendBeacon( $url );
179        }
180    }
181
182    /**
183     *
184     * Converts the encapsulated event from an object to a string.
185     *
186     * @param array $event Encapsulated event
187     * @return string $json
188     */
189    public static function serializeEvent( $event ) {
190        $eventData = $event['event'];
191
192        if ( count( $eventData ) === 0 ) {
193            // Ensure empty events are serialized as '{}' and not '[]'.
194            $eventData = (object)$eventData;
195        }
196        $event['event'] = $eventData;
197
198        // To make the resultant JSON easily extracted from a row of
199        // space-separated values, we replace literal spaces with unicode
200        // escapes. This is permitted by the JSON specs.
201        return str_replace( ' ', '\u0020', FormatJson::encode( $event ) );
202    }
203
204    /**
205     * Validates object against JSON Schema.
206     *
207     * @throws JsonSchemaException If the object fails to validate.
208     * @param array $object Object to be validated.
209     * @param array|null $schema Schema to validate against (default: JSON Schema).
210     * @return bool True.
211     */
212    public static function schemaValidate( $object, $schema = null ) {
213        if ( $schema === null ) {
214            // Default to JSON Schema
215            $json = file_get_contents( dirname( __DIR__ ) . '/schemas/schemaschema.json' );
216            $schema = FormatJson::decode( $json, true );
217        }
218
219        // We depart from the JSON Schema specification in disallowing by default
220        // additional event fields not mentioned in the schema.
221        // See <https://bugzilla.wikimedia.org/show_bug.cgi?id=44454> and
222        // <https://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.4>.
223        if ( !array_key_exists( 'additionalProperties', $schema ) ) {
224            $schema[ 'additionalProperties' ] = false;
225        }
226
227        $root = new JsonTreeRef( $object );
228        $root->attachSchema( $schema );
229        return $root->validate();
230    }
231
232    /**
233     * Randomise inclusion based on population size and a session ID.
234     * @param int $populationSize Return true one in this many times. This is 1/samplingRate.
235     * @param string $sessionId Hexadecimal value, only the first 8 characters are used
236     * @return bool True if the event should be included (sampled in), false if not (sampled out)
237     */
238    public static function sessionInSample( $populationSize, $sessionId ) {
239        $decimal = (int)base_convert( substr( $sessionId, 0, 8 ), 16, 10 );
240        return $decimal % $populationSize === 0;
241    }
242
243    /**
244     * This encapsulates the event data in a wrapper object with
245     * the default metadata for the current request.
246     *
247     * NOTE: for forwards compatibility with Event Platform schemas,
248     * we hijack the wgEventLoggingSchemas revision to encode the
249     * $schema URI. If the value for a schema defined in
250     * EventLoggingSchemas is a string, it is assumed
251     * to be an Event Platform $schema URI, not a MW revision id.
252     * In this case, the event will be prepared to be POSTed to EventGate.
253     *
254     * @param string $schemaName
255     * @param int|string $revisionOrSchemaUri
256     *        The revision id or a string $schema URI for use with Event Platform.
257     * @param array $eventData un-encapsulated event data
258     * @return array encapsulated event
259     */
260    private static function encapsulate( $schemaName, $revisionOrSchemaUri, $eventData ) {
261        global $wgDBname;
262
263        $event = [
264            'event'            => $eventData,
265            'schema'           => $schemaName,
266            'wiki'             => $wgDBname,
267        ];
268
269        if ( isset( $_SERVER[ 'HTTP_HOST' ] ) ) {
270            $event['webHost'] = $_SERVER['HTTP_HOST'];
271        }
272
273        if ( is_string( $revisionOrSchemaUri ) ) {
274            $event['$schema'] = $revisionOrSchemaUri;
275            // NOTE: `client_dt` is 'legacy' event time.  `dt` is the preferred event time field
276            // and is set in EventServiceClient.
277            $event['client_dt'] = wfTimestamp( TS_ISO_8601 );
278
279            // Note: some fields will have defaults set by eventgate-wikimedia.
280            // See:
281            // - https://gerrit.wikimedia.org/r/plugins/gitiles/eventgate-wikimedia/+/refs/heads/master/eventgate-wikimedia.js#358
282            // - https://wikitech.wikimedia.org/wiki/Event_Platform/Schemas/Guidelines#Automatically_populated_fields
283        } else {
284            $event['revision'] = $revisionOrSchemaUri;
285            $event['userAgent'] = $_SERVER[ 'HTTP_USER_AGENT' ] ?? '';
286        }
287
288        return $event;
289    }
290
291    /**
292     * Prepend "eventlogging_" to the schema name to create a stream name for a migrated legacy
293     * schema.
294     *
295     * @param string $schemaName
296     * @return string
297     */
298    private static function getLegacyStreamName( string $schemaName ): string {
299        return "eventlogging_$schemaName";
300    }
301
302}
303
304class_alias( EventLogging::class, 'EventLogging' );