Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.93% covered (success)
92.93%
92 / 99
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstrumentConfigsFetcher
92.93% covered (success)
92.93%
92 / 99
72.73% covered (warning)
72.73%
8 / 11
28.28
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
 getInstrumentConfigs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExperimentConfigs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfigs
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
7
 postProcessResult
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getSampleConfig
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 areDependenciesMet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 incrementApiRequestsTotal
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 logApiRequestDuration
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getServerErrorLabel
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 getClientErrorLabels
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\MetricsPlatform;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\Http\HttpRequestFactory;
7use MediaWiki\Json\FormatJson;
8use MediaWiki\MainConfigNames;
9use MediaWiki\Status\Status;
10use MediaWiki\Status\StatusFormatter;
11use Psr\Log\LoggerInterface;
12use Wikimedia\ObjectCache\WANObjectCache;
13use Wikimedia\Stats\StatsFactory;
14
15class InstrumentConfigsFetcher {
16    private const VERSION = 1;
17    private const HTTP_TIMEOUT = 1;
18    private const INSTRUMENT = 1;
19    private const EXPERIMENT = 2;
20    public const MPIC_API_INSTRUMENTS_ENDPOINT = "/api/v1/instruments";
21    public const MPIC_API_EXPERIMENTS_ENDPOINT = "/api/v1/experiments";
22
23    /**
24     * Name of the main config key(s) for instrument configuration.
25     *
26     * @var array
27     */
28    public const CONSTRUCTOR_OPTIONS = [
29        'MetricsPlatformEnable',
30        'MetricsPlatformInstrumentConfiguratorBaseUrl',
31        MainConfigNames::DBname,
32    ];
33    private ServiceOptions $options;
34    private WANObjectCache $WANObjectCache;
35    private HttpRequestFactory $httpRequestFactory;
36    private LoggerInterface $logger;
37    private StatsFactory $statsFactory;
38    private StatusFormatter $statusFormatter;
39
40    public function __construct(
41        ServiceOptions $options,
42        WANObjectCache $WANObjectCache,
43        HttpRequestFactory $httpRequestFactory,
44        LoggerInterface $logger,
45        StatsFactory $statsFactory,
46        StatusFormatter $statusFormatter
47    ) {
48        $this->options = $options;
49        $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
50        $this->WANObjectCache = $WANObjectCache;
51        $this->httpRequestFactory = $httpRequestFactory;
52        $this->logger = $logger;
53        $this->statsFactory = $statsFactory;
54        $this->statusFormatter = $statusFormatter;
55    }
56
57    public function getInstrumentConfigs(): array {
58        return $this->getConfigs( 1 );
59    }
60
61    public function getExperimentConfigs(): array {
62        return $this->getConfigs( 2 );
63    }
64
65    /**
66     * Get the instruments and experiments configuration from the Metrics Platform Configurator API.
67     *
68     * @param int|null $flag Return only the specified kind of variables: self::INSTRUMENT or self::EXPERIMENT.
69     *   For internal use only.
70     * @return array[]
71     */
72    private function getConfigs( ?int $flag = null ): array {
73        if ( !$this->areDependenciesMet() ) {
74            $this->logger->warning( 'Dependencies not met for the Metrics Platform Instrument Configs Fetcher.' );
75            return [];
76        }
77        $config = $this->options;
78        $cache = $this->WANObjectCache;
79        $fname = __METHOD__;
80
81        // Check for which api endpoint should be queried and set corresponding cache key.
82        $type = $flag ?? self::INSTRUMENT;
83        $endpoint = ( $type > 1 ) ? self::MPIC_API_EXPERIMENTS_ENDPOINT : self::MPIC_API_INSTRUMENTS_ENDPOINT;
84        $cacheKey = ( $type === self::EXPERIMENT ) ? 'ExperimentConfigs' : 'InstrumentConfigs';
85
86        $result = $cache->getWithSetCallback(
87            $cache->makeKey( 'MetricsPlatform', $cacheKey, self::VERSION ),
88            $cache::TTL_MINUTE,
89            function () use ( $config, $endpoint, $fname ) {
90                $startTime = microtime( true );
91                $baseUrl = $config->get( 'MetricsPlatformInstrumentConfiguratorBaseUrl' );
92                $url = $baseUrl . $endpoint;
93                $request = $this->httpRequestFactory->create( $url, [ 'timeout' => self::HTTP_TIMEOUT ], $fname );
94                $status = $request->execute();
95                $labels = [];
96                if ( $status->isOK() ) {
97                    $labels[] = 'success';
98                    $json = $request->getContent();
99                } else {
100                    $errors = $status->getMessages( 'error' );
101                    $this->logger->warning( $this->statusFormatter->getWikiText( Status::wrap( $status ),
102                        [ 'error' => $errors, 'content' => $request->getContent() ] ) );
103
104                    $labels = $this->getClientErrorLabels( $errors );
105                    $labels[] = $this->getServerErrorLabel( $status->getValue() );
106
107                    $json = null;
108                }
109                // T368253 Use the Stats library for performance reporting.
110                foreach ( $labels as $label ) {
111                    $this->incrementApiRequestsTotal( $label );
112                }
113                $this->logApiRequestDuration( $startTime );
114
115                /*
116                HttpRequestFactory::create returns a MWHttpRequest object.
117                MWHttpRequest::execute returns a Status object which provides status
118                codes for more granular Stats reporting. For errors, we return null for
119                the json response which represents all the failure modes we care about:
120                the network being down (DNS resolution not working), connection timeout,
121                request timeout, etc.
122                */
123                if ( $json === null ) {
124                    $this->logger->warning( 'MPIC API is not working.' );
125                    return [];
126                }
127                return FormatJson::decode( $json, true );
128            },
129            [
130                'staleTTL' => $cache::TTL_DAY
131            ]
132        );
133
134        return $this->postProcessResult( $result );
135    }
136
137    /**
138     * Post-processes the result of successful request to MPIC by:
139     *
140     * 1. Filtering out disabled instruments/experiments (`status=0`)
141     *
142     * @param array $result An array of configs retrieved from MPIC
143     *  TODO: Add a link to the latest response format specification
144     * @return array
145     */
146    protected function postProcessResult( array $result ): array {
147        $dbName = $this->options->get( MainConfigNames::DBname );
148        $processedResult = [];
149
150        foreach ( $result as $config ) {
151            if ( !$config['status'] ) {
152                continue;
153            }
154
155            $config['sample'] = $this->getSampleConfig( $config, $dbName );
156
157            $processedResult[] = $config;
158        }
159
160        return $processedResult;
161    }
162
163    /**
164     * @param array $config
165     * @param string $dbName
166     * @return array
167     */
168    private function getSampleConfig( array $config, string $dbName ) {
169        $sampleConfig = [
170            'rate' => 0.0,
171            'unit' => 'session',
172        ];
173
174        if ( array_key_exists( 'sample_rate', $config ) ) {
175            $sampleRates = $config['sample_rate'];
176            $sampleConfig['rate'] = $sampleRates['default'];
177            unset( $sampleRates['default'] );
178
179            foreach ( $sampleRates as $rate => $wikis ) {
180                if ( in_array( $dbName, $wikis ) ) {
181                    $sampleConfig['rate'] = $rate;
182
183                    break;
184                }
185            }
186        }
187
188        if ( array_key_exists( 'sample_unit', $config ) ) {
189            $sampleConfig['unit'] = $config['sample_unit'];
190        }
191
192        return $sampleConfig;
193    }
194
195    public function areDependenciesMet(): bool {
196        return $this->options->get( 'MetricsPlatformEnable' );
197    }
198
199    /**
200     * Increment success/failure of MPIC api requests.
201     *
202     * @param string $label
203     */
204    private function incrementApiRequestsTotal( string $label ): void {
205        $this->statsFactory->getCounter( 'mpic_api_requests_total' )
206            ->setLabel( 'status', $label )
207            ->increment();
208    }
209
210    /**
211     * Record length of MPIC api requests.
212     *
213     * @param float $startTime
214     */
215    private function logApiRequestDuration( float $startTime ): void {
216        $this->statsFactory->getTiming( 'mpic_api_request_duration_seconds' )
217            ->observe( ( microtime( true ) - $startTime ) * 1000 );
218    }
219
220    /**
221     * Get error label based on http status code.
222     *
223     * @param int $statusCode
224     * @return string
225     */
226    private function getServerErrorLabel( int $statusCode ): string {
227        switch ( $statusCode ) {
228            case 400:
229                $label = 'bad-request';
230                break;
231            case 408:
232                $label = 'server-timeout';
233                break;
234            case 500:
235                $label = 'internal-server-error';
236                break;
237            default:
238                $label = 'failure';
239        }
240        return $label;
241    }
242
243    /**
244     * Get client error labels based on message keys and params.
245     *
246     * Error labels are crafted by the message key that is passed into the
247     * fatal method of the status property of the request object when connection
248     * exceptions are thrown by GuzzleHttpRequest::execute(). Examples include:
249     * - 'http-timed-out'
250     * - 'http-curl-error'
251     * - 'http-request-error'
252     * - 'http-internal-error'
253     *
254     * @param array $errors
255     * @return array
256     */
257    private function getClientErrorLabels( array $errors ): array {
258        $labels = [];
259        // Loop through error messages to aggregate counters of different types.
260        foreach ( $errors as $error ) {
261            $key = $error->getKey();
262
263            $paramValues = array_map( static function ( $param ) {
264                return $param->getValue();
265            }, $error->getParams() );
266            $paramString = implode( ', ', $paramValues );
267
268            $labels[] = $key . ': ' . $paramString;
269        }
270        return $labels;
271    }
272}