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