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