Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
84.29% |
59 / 70 |
|
58.33% |
7 / 12 |
CRAP | |
0.00% |
0 / 1 |
EventLogging | |
85.51% |
59 / 69 |
|
58.33% |
7 / 12 |
24.61 | |
0.00% |
0 / 1 |
getLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEventSubmitter | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMetricsPlatformClient | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
resetMetricsPlatformClient | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
submit | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
sendBeacon | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
logEvent | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
3.00 | |||
serializeEvent | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
schemaValidate | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
sessionInSample | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
encapsulate | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
getLegacyStreamName | |
100.00% |
1 / 1 |
|
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 | |
12 | namespace MediaWiki\Extension\EventLogging; |
13 | |
14 | use FormatJson; |
15 | use MediaWiki\Context\RequestContext; |
16 | use MediaWiki\Deferred\DeferredUpdates; |
17 | use MediaWiki\Extension\EventLogging\EventSubmitter\EventSubmitter; |
18 | use MediaWiki\Extension\EventLogging\Libs\JsonSchemaValidation\JsonSchemaException; |
19 | use MediaWiki\Extension\EventLogging\Libs\JsonSchemaValidation\JsonTreeRef; |
20 | use MediaWiki\Extension\EventLogging\MetricsPlatform\MetricsClientFactory; |
21 | use MediaWiki\Logger\LoggerFactory; |
22 | use MediaWiki\MediaWikiServices; |
23 | use Psr\Log\LoggerInterface; |
24 | use RuntimeException; |
25 | use Wikimedia\MetricsPlatform\MetricsClient; |
26 | |
27 | class 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 | |
305 | class_alias( EventLogging::class, 'EventLogging' ); |