Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.04% covered (warning)
77.04%
104 / 135
44.44% covered (danger)
44.44%
8 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExcimerClient
77.04% covered (warning)
77.04%
104 / 135
44.44% covered (danger)
44.44%
8 / 18
75.90
0.00% covered (danger)
0.00%
0 / 1
 setup
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 singleton
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 activate
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
1.00
 shutdown
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 makeLink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getUrl
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 maybeActivate
30.77% covered (danger)
30.77%
4 / 13
0.00% covered (danger)
0.00%
0 / 1
23.26
 getIngestionUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getId
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getProfileId
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 jsonEncode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRequestInfo
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
9.16
 httpRequest
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 sendReport
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
7
 resetForTest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Wikimedia\ExcimerUI\Client;
4
5use ExcimerProfiler;
6
7class ExcimerClient {
8    private const DEFAULT_CONFIG = [
9        'url' => null,
10        'ingestionUrl' => null,
11        'activate' => 'always',
12        'period' => 0.001,
13        'timeout' => 1,
14        'hashKey' => null,
15        'profileIdLength' => 16,
16        'debugCallback' => null,
17        'errorCallback' => null,
18    ];
19
20    /** @var ExcimerClient|null */
21    private static $instance;
22
23    /** @var bool */
24    private $activated = false;
25
26    /** @var array */
27    private $config;
28
29    /** @var ExcimerProfiler|null */
30    private $excimer;
31
32    /** @var string|null */
33    private $id;
34
35    /** @var string|null */
36    private $profileId;
37
38    /**
39     * Initialise the profiler. This should be called as early as possible.
40     *
41     * @param array $config Associative array with the following keys:
42     *   - url: The URL for the ExcimerUI server's index.php. It can be reached
43     *     via an alias, it doesn't have to have "index.php" in it.
44     *   - ingestionUrl: The URL to post data to, if different from $config['url']
45     *     which would then be used for public link only.
46     *   - activate: Can be one of:
47     *     - "always" to always activate the profiler when setup() is called
48     *       (the default)
49     *     - "manual" to never activate on setup(). $profiler->activate() should
50     *       be called later.
51     *     - "query" to activate when the excimer_profile query string
52     *       parameter is passed
53     *   - period: The sampling period in seconds (default 0.001)
54     *   - timeout: The request timeout for ingestion requests, in seconds.
55     *     Use zero to enforce no timeout (default 0).
56     *     Values smaller than 0.001 (1ms) are not supported.
57     *   - hashKey: A secret key to be included in the hash, when mapping
58     *     request IDs to profile IDs. Defaults to no key.
59     *   - profileIdLength: The number of hexadecimal characters in a generated
60     *     profile ID. Default 16.
61     *   - debugCallback: A function to call back with debug messages. It takes
62     *     a single parameter which is the message string.
63     *   - errorCallback: A function to call back with error messages. It takes
64     *     a single parameter which is the message string. This is called on
65     *     request shutdown, so it is not feasible to show the message to the
66     *     user.
67     * @return self
68     */
69    public static function setup( $config ) {
70        if ( self::$instance ) {
71            throw new \LogicException( 'setup() can only be called once' );
72        }
73        self::$instance = new self( $config );
74        self::$instance->maybeActivate();
75        return self::$instance;
76    }
77
78    /**
79     * Get the instance previously created with self::setup()
80     *
81     * @return self
82     */
83    public static function singleton(): self {
84        if ( !self::$instance ) {
85            throw new \LogicException( 'setup() must be called before singleton()' );
86        }
87        return self::$instance;
88    }
89
90    /**
91     * Return true if setup() has been called and the instance is activated
92     * (i.e. the profiler is running).
93     *
94     * @return bool
95     */
96    public static function isActive(): bool {
97        return self::$instance && self::$instance->activated;
98    }
99
100    /**
101     * @internal
102     * @param array $config
103     */
104    public function __construct( $config ) {
105        $this->config = $config + self::DEFAULT_CONFIG;
106    }
107
108    /**
109     * Start the profiler and register a shutdown function which will post
110     * the results to the server.
111     */
112    public function activate() {
113        $this->activated = true;
114        $this->excimer = new ExcimerProfiler;
115        $this->excimer->setPeriod( $this->config['period'] );
116        $this->excimer->setEventType( EXCIMER_REAL );
117        $this->excimer->start();
118        register_shutdown_function( function () {
119            $this->shutdown();
120        } );
121    }
122
123    /**
124     * Shut down the profiler and send the results. This is normally called at
125     * the end of the request, but can be called manually.
126     */
127    public function shutdown() {
128        if ( !$this->excimer ) {
129            return;
130        }
131        $this->excimer->stop();
132        $this->sendReport();
133        $this->excimer = null;
134    }
135
136    /**
137     * Make a link to the profile for the current request, and any other
138     * requests which had the same ID set with setId().
139     *
140     * @param array $options
141     *   - text: The link text
142     *   - class: A string or array of strings which will be encoded and added
143     *     to the anchor class attribute.
144     * @return string
145     */
146    public function makeLink( $options = [] ) {
147        $text = $options['text'] ?? 'Performance profile';
148        $link = '<a href="' . htmlspecialchars( $this->getUrl() ) . '" ';
149        if ( isset( $options['class'] ) ) {
150            $class = $options['class'];
151            if ( is_array( $class ) ) {
152                $class = implode( ' ', $class );
153            }
154            $link .= 'class="' . htmlspecialchars( $class ) . '" ';
155        }
156        $link .= '>' . htmlspecialchars( $text, ENT_NOQUOTES ) . '</a>';
157        return $link;
158    }
159
160    /**
161     * Get the URL which will show the profiling results
162     *
163     * @return string
164     */
165    public function getUrl() {
166        $url = $this->config['url'] ?? null;
167        if ( $url === null ) {
168            throw new \RuntimeException( "No ingestion URL configured" );
169        }
170        $url = rtrim( $url, '/' );
171        return "$url/profile/" . rawurlencode( $this->getProfileId() );
172    }
173
174    /**
175     * Maybe activate the profiler, depending on config and request parameters.
176     */
177    private function maybeActivate() {
178        switch ( $this->config['activate'] ) {
179            case 'always':
180                self::activate();
181                break;
182            case 'manual':
183                break;
184            case 'query':
185                if ( isset( $_GET['excimer_id'] ) ) {
186                    $this->setId( $_GET['excimer_id'] );
187                }
188                if ( isset( $_GET['excimer_profile'] ) ) {
189                    self::activate();
190                }
191                break;
192            default:
193                throw new \InvalidArgumentException( 'Unknown activation type' );
194        }
195    }
196
197    /**
198     * Get the URL to post the profile to.
199     *
200     * @param string $id
201     * @return string
202     */
203    private function getIngestionUrl( $id ) {
204        $url = $this->config['ingestionUrl'] ?? $this->config['url'] ?? null;
205        if ( $url === null ) {
206            throw new \RuntimeException( "No ingestion URL configured" );
207        }
208        return rtrim( $url, '/' ) . '/ingest/' . rawurlencode( $id );
209    }
210
211    /**
212     * Set the ID which identifies the request. If multiple requests are
213     * profiled with the same ID, they will be merged and shown together
214     * in the UI.
215     *
216     * @param string $id
217     * @return void
218     */
219    public function setId( string $id ) {
220        $this->id = $id;
221        $this->profileId = null;
222    }
223
224    /**
225     * Get the request ID, or generate a random ID.
226     *
227     * @return string
228     */
229    private function getId() {
230        if ( $this->id === null ) {
231            $this->id = sprintf(
232                "%07x%07x%07x",
233                mt_rand() & 0xfffffff,
234                mt_rand() & 0xfffffff,
235                mt_rand() & 0xfffffff
236            );
237        }
238        return $this->id;
239    }
240
241    /**
242     * Get the profile ID derived from the request ID
243     *
244     * @return string
245     */
246    private function getProfileId() {
247        if ( $this->profileId === null ) {
248            if ( $this->config['hashKey'] !== null ) {
249                $hash = hash_hmac( 'sha512', $this->getId(), $this->config['hashKey'] );
250            } else {
251                $hash = hash( 'sha512', $this->getId() );
252            }
253            $this->profileId = substr( $hash, 0, $this->config['profileIdLength'] );
254        }
255        return $this->profileId;
256    }
257
258    /**
259     * Encode an array as JSON
260     *
261     * @param array $data
262     * @return string
263     * @throws \JsonException
264     */
265    private function jsonEncode( $data ) {
266        return json_encode(
267            $data,
268            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
269        );
270    }
271
272    /**
273     * Get an array of JSON-serializable request information to be attached to
274     * the profile.
275     *
276     * @return array
277     */
278    private function getRequestInfo() {
279        $info = [];
280        if ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ) {
281            $info['argv'] = implode( ' ', $GLOBALS['argv'] );
282        }
283        if ( isset( $_SERVER['REQUEST_URI'] ) ) {
284            $scheme = ( @$_SERVER['HTTPS'] === 'on' || @$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' )
285                ? 'https://'
286                : 'http://';
287            $info['url'] = $scheme . ( $_SERVER['HTTP_HOST'] ?? 'unknown' ) . $_SERVER['REQUEST_URI'];
288        }
289        return $info;
290    }
291
292    /**
293     * @param string $url
294     * @param array $data
295     * @return array{result:false|string,code:int,error:null|string}
296     */
297    protected function httpRequest( string $url, array $data ) {
298        $ch = curl_init( $url );
299        curl_setopt_array( $ch, [
300            CURLOPT_POSTFIELDS => $data,
301            CURLOPT_USERAGENT => 'ExcimerUI',
302            CURLOPT_TIMEOUT_MS => (int)( $this->config['timeout'] * 1000 ),
303            CURLOPT_RETURNTRANSFER => true,
304        ] );
305        $result = curl_exec( $ch );
306        $error = $result === false ? curl_error( $ch ) : null;
307        $code = curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
308        return [
309            'result' => $result,
310            'code' => $code,
311            'error' => $error,
312        ];
313    }
314
315    /**
316     * Send the profile result to the server
317     */
318    private function sendReport() {
319        $log = $this->excimer->getLog();
320        $speedscope = $log->getSpeedscopeData();
321        $info = $this->getRequestInfo();
322        $name = $info['argv'] ?? $info['url'] ?? '';
323        $speedscope['profiles'][0]['name'] = $name;
324        $data = [
325            'name' => $name,
326            'request' => $this->jsonEncode( $info ),
327            'requestId' => $this->getId(),
328            'period' => $this->config['period'],
329            'speedscope_deflated' => gzdeflate( $this->jsonEncode( $speedscope ) ),
330        ];
331        $t = -microtime( true );
332        $resp = $this->httpRequest(
333            $this->getIngestionUrl( $this->getProfileId() ),
334            $data
335        );
336        $t += microtime( true );
337
338        if ( $this->config['errorCallback'] ) {
339            if ( $resp['result'] === false ) {
340                $msg = 'ExcimerUI server error: ' . $resp['error'];
341            } elseif ( $resp['code'] >= 400 ) {
342                $msg = "ExcimerUI server error {$resp['code']}";
343                if ( preg_match( '~<h1>Excimer UI Error [0-9]+</h1>\n<p>\n(.*)\n</p>~',
344                    $resp['result'], $m )
345                ) {
346                    $msg .= "{$m[1]}";
347                }
348            } else {
349                $msg = null;
350            }
351            if ( $msg !== null ) {
352                ( $this->config['errorCallback'] )( $msg );
353            }
354        }
355        if ( $this->config['debugCallback'] ) {
356            ( $this->config['debugCallback'] )(
357                "Server returned response code {$resp['code']}. Total request time: " .
358                round( $t * 1000, 6 ) . ' ms.'
359            );
360        }
361    }
362
363    /**
364     * @internal
365     */
366    public static function resetForTest(): void {
367        if ( self::$instance ) {
368            self::$instance->excimer = null;
369            self::$instance = null;
370        }
371    }
372}