├── .github └── workflows │ └── php.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── phpcs.xml.dist ├── phpunit.xml.dist ├── psalm.xml ├── src ├── Client.php ├── Connection.php └── Connection │ ├── Blackhole.php │ ├── ErrorLog.php │ ├── File.php │ ├── InMemory.php │ ├── InetSocket.php │ ├── TcpSocket.php │ ├── TcpSocketException.php │ └── UdpSocket.php └── tests ├── bootstrap.php ├── integration ├── file-test.php ├── tcp-test.php └── udp-test.php └── unit ├── ClientBatchTest.php ├── ClientTest.php ├── Connection ├── FileTest.php ├── InMemoryTest.php ├── TcpSocketExceptionTest.php ├── TcpSocketTest.php └── UdpSocketTest.php └── ConnectionMock.php /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: Build statsd-php 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: statsd-php (PHP ${{ matrix.php-versions }} on ${{ matrix.operating-system }}) 6 | runs-on: ${{ matrix.operating-system }} 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | operating-system: [ubuntu-latest] 11 | php-versions: ['7.3', '7.4', '8.0'] 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v1 16 | 17 | - name: Setup PHP, with composer and extensions 18 | uses: shivammathur/setup-php@v1 #https://github.com/shivammathur/setup-php 19 | with: 20 | php-version: ${{ matrix.php-versions }} 21 | extensions: mbstring 22 | coverage: xdebug #optional 23 | 24 | - name: Install dependencies 25 | run: composer install --prefer-dist --no-progress --no-suggest --no-interaction 26 | 27 | - name: phpcs 28 | run: vendor/bin/phpcs -s --standard=vendor/flyeralarm/php-code-validator/ruleset.xml src/ tests/ 29 | 30 | - name: psalm 31 | run: vendor/bin/psalm --show-info=false 32 | 33 | - name: phpunit 34 | run: vendor/bin/phpunit 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general 2 | .idea 3 | .DS_Store 4 | 5 | # test coverage report 6 | tests/coverage 7 | .phpunit.result.cache 8 | 9 | # integration test 10 | stats.log 11 | 12 | # composer 13 | /vendor 14 | /composer.lock 15 | /composer.phar 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog domnikl/statsd-php 2 | 3 | ## 3.0.1 4 | 5 | * fixed #67 UDPSocket handle DNS change 6 | 7 | ## 3.0.0 8 | 9 | * PHP 7.2 is the new minimum PHP version 10 | * added type hints and return type declarations 11 | 12 | ## 2.2.0 13 | 14 | * TcpSocket now throws a TCPSocketException if no connection could be established 15 | 16 | ## 2.0.0 17 | 18 | * renamed Socket classes: Socket is now a UdpSocket + there is a new TcpSocket class 19 | * batch messages are split to fit into the configured MTU 20 | * sampling all metrics must now be configured on the client - no longer in the connection 21 | * endTiming() returns the time measured 22 | * for development there is a new (simple) process for running integration tests and such using make 23 | 24 | ## 1.1.0 25 | 26 | * added support for [sets](https://github.com/etsy/statsd/blob/master/docs/metric_types.md#sets) 27 | * added support for [gauges](https://github.com/etsy/statsd/blob/master/docs/metric_types.md#gauges) 28 | * support batch-sending of metrics 29 | * support sampling of metrics 30 | 31 | ## 1.0.2 32 | 33 | * ignore errors when writing on the UDP sockets 34 | 35 | ## 1.0.1 36 | 37 | * ignore all exceptions and errors which are thrown when writing to the UDP socket 38 | 39 | ## 1.0.0 40 | 41 | * first version supporting counters, timings 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dominik Liebler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PHP_BIN=php 2 | COMPOSER_BIN=composer.phar 3 | PHPUNIT_BIN=vendor/bin/phpunit 4 | NETCAT=nc 5 | 6 | COVERAGE_DIR=tests/coverage 7 | 8 | default: test 9 | 10 | $(COMPOSER_BIN): 11 | wget https://raw.githubusercontent.com/composer/getcomposer.org/1b137f8bf6db3e79a38a5bc45324414a6b1f9df2/web/installer -O - -q | $(PHP_BIN) -- --quiet 12 | 13 | cleanup: 14 | rm -rf $(COVERAGE_DIR) && rm -f stats.log 15 | 16 | test: install cleanup 17 | $(PHPUNIT_BIN) --coverage-html tests/coverage 18 | 19 | install: $(COMPOSER_BIN) 20 | $(PHP_BIN) $(COMPOSER_BIN) install 21 | 22 | tcp-testserver: 23 | $(NETCAT) -tlnp 8126 24 | 25 | udp-testserver: 26 | $(NETCAT) -ulnp 8125 27 | 28 | tcp-integration: 29 | $(PHP_BIN) tests/integration/tcp-test.php 30 | 31 | udp-integration: 32 | $(PHP_BIN) tests/integration/udp-test.php 33 | 34 | file-integration: 35 | $(PHP_BIN) tests/integration/file-test.php 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # statsd-php 2 | 3 | ⚠️ This repo is abandoned and will no longer be maintained. Please use [slickdeals/statsd](https://github.com/Slickdeals/statsd-php) instead. 4 | 5 | A PHP client library for the statistics daemon ([statsd](https://github.com/etsy/statsd)) intended to send metrics from PHP applications. 6 | 7 | [![Build Status](https://github.com/domnikl/statsd-php/workflows/Build%20statsd-php/badge.svg)](https://github.com/domnikl/statsd-php/actions) 8 | [![Donate](https://img.shields.io/badge/donate-paypal-blue.svg?style=flat-square)](https://paypal.me/DominikLiebler) 9 | 10 | ## Installation 11 | 12 | The best way to install statsd-php is to use Composer and add the following to your project's `composer.json` file: 13 | 14 | ```javascript 15 | { 16 | "require": { 17 | "domnikl/statsd": "~3.0" 18 | } 19 | } 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```php 25 | setNamespace("test"); 31 | 32 | // simple counts 33 | $statsd->increment("foo.bar"); 34 | $statsd->decrement("foo.bar"); 35 | $statsd->count("foo.bar", 1000); 36 | ``` 37 | 38 | When establishing the connection to statsd and sending metrics, errors will be suppressed to prevent your application from crashing. 39 | 40 | If you run statsd in TCP mode, there is also a `\Domnikl\Statsd\Connection\TcpSocket` adapter that works like the `UdpSocket` except that it throws a `\Domnikl\Statsd\Connection\TcpSocketException` if no connection could be established. 41 | Please consider that unlike UDP, TCP is used for reliable networks and therefor exceptions (and errors) will not be suppressed in TCP mode. 42 | 43 | ### [Timings](https://github.com/etsy/statsd/blob/master/docs/metric_types.md#timing) 44 | 45 | ```php 46 | timing("foo.bar", 320); 49 | $statsd->time("foo.bar.bla", function() { 50 | // code to be measured goes here ... 51 | }); 52 | 53 | // more complex timings can be handled with startTiming() and endTiming() 54 | $statsd->startTiming("foo.bar"); 55 | // more complex code here ... 56 | $statsd->endTiming("foo.bar"); 57 | ``` 58 | 59 | ### Memory profiling 60 | 61 | ```php 62 | startMemoryProfile('memory.foo'); 65 | // some complex code goes here ... 66 | $statsd->endMemoryProfile('memory.foo'); 67 | 68 | // report peak usage 69 | $statsd->memory('foo.memory_peak_usage'); 70 | ``` 71 | 72 | ### [Gauges](https://github.com/etsy/statsd/blob/master/docs/metric_types.md#gauges) 73 | 74 | statsd supports gauges, arbitrary values which can be recorded. 75 | 76 | This method accepts both absolute (3) and delta (+11) values. 77 | 78 | *NOTE:* Negative values are treated as delta values, not absolute. 79 | 80 | ```php 81 | gauge('foobar', 3); 84 | 85 | // Pass delta values as a string. 86 | // Accepts both positive (+11) and negative (-4) delta values. 87 | $statsd->gauge('foobar', '+11'); 88 | ``` 89 | 90 | ### [Sets](https://github.com/etsy/statsd/blob/master/docs/metric_types.md#sets) 91 | 92 | statsd supports sets, so you can view the uniqueness of a given value. 93 | 94 | ```php 95 | set('userId', 1234); 97 | ``` 98 | 99 | ### disabling sending of metrics 100 | 101 | To disable sending any metrics to the statsd server, you can use the `Domnikl\Statsd\Connection\Blackhole` connection 102 | 
class instead of the default socket abstraction. This may be incredibly useful for feature flags. Another options is 103 | to use `Domnikl\Statsd\Connection\InMemory` connection class, that will collect your messages but won't actually send them. 104 | 105 | ## Authors 106 | 107 | Original author: Dominik Liebler 108 | Several other [contributors](https://github.com/domnikl/statsd-php/graphs/contributors) - Thank you! 109 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "domnikl/statsd", 3 | "type": "library", 4 | "description": "a PHP client for statsd", 5 | "keywords": [ 6 | "monitoring", 7 | "statsd", 8 | "statistics", 9 | "metrics", 10 | "udp" 11 | ], 12 | "homepage": "https://domnikl.github.com/statsd-php", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Dominik Liebler", 17 | "email": "liebler.dominik@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "php": ">= 7.3 || ^8" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^9", 25 | "flyeralarm/php-code-validator": "^3.2", 26 | "vimeo/psalm": "^3.4" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Domnikl\\Statsd\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Domnikl\\Test\\Statsd\\": "tests/unit" 36 | } 37 | }, 38 | "abandoned": "slickdeals/statsd" 39 | } 40 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | **/*.php 4 | vendor 5 | docs 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | 10 | tests/unit 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 71 | $this->namespace = $namespace; 72 | $this->sampleRateAllMetrics = $sampleRateAllMetrics; 73 | } 74 | 75 | /** 76 | * increments the key by 1 77 | * 78 | * @param string $key 79 | * @param float $sampleRate 80 | * @param array $tags 81 | */ 82 | public function increment(string $key, float $sampleRate = 1.0, array $tags = []): void 83 | { 84 | $this->count($key, 1, $sampleRate, $tags); 85 | } 86 | 87 | /** 88 | * decrements the key by 1 89 | * 90 | * @param string $key 91 | * @param float $sampleRate 92 | * @param array $tags 93 | */ 94 | public function decrement(string $key, float $sampleRate = 1.0, array $tags = []): void 95 | { 96 | $this->count($key, -1, $sampleRate, $tags); 97 | } 98 | /** 99 | * sends a count to statsd 100 | * 101 | * @param string $key 102 | * @param int|float $value 103 | * @param float $sampleRate 104 | * @param array $tags 105 | */ 106 | public function count(string $key, $value, float $sampleRate = 1.0, array $tags = []): void 107 | { 108 | $this->send($key, $value, 'c', $sampleRate, $tags); 109 | } 110 | 111 | /** 112 | * sends a timing to statsd (in ms) 113 | * 114 | * @param string $key 115 | * @param float $value the timing in ms 116 | * @param float $sampleRate 117 | * @param array $tags 118 | */ 119 | public function timing(string $key, float $value, float $sampleRate = 1.0, array $tags = []): void 120 | { 121 | $this->send($key, $value, 'ms', $sampleRate, $tags); 122 | } 123 | 124 | /** 125 | * starts the timing for a key 126 | * 127 | * @param string $key 128 | */ 129 | public function startTiming(string $key): void 130 | { 131 | $this->timings[$key] = gettimeofday(true); 132 | } 133 | 134 | /** 135 | * ends the timing for a key and sends it to statsd 136 | * 137 | * @param string $key 138 | * @param float $sampleRate 139 | * @param array $tags 140 | * 141 | * @return float|null 142 | */ 143 | public function endTiming(string $key, float $sampleRate = 1.0, array $tags = []): ?float 144 | { 145 | $end = gettimeofday(true); 146 | 147 | if (isset($this->timings[$key])) { 148 | $timing = ($end - $this->timings[$key]) * 1000; 149 | $this->timing($key, $timing, $sampleRate, $tags); 150 | unset($this->timings[$key]); 151 | 152 | return $timing; 153 | } 154 | 155 | return null; 156 | } 157 | 158 | /** 159 | * start memory "profiling" 160 | * 161 | * @param string $key 162 | */ 163 | public function startMemoryProfile(string $key): void 164 | { 165 | $this->memoryProfiles[$key] = memory_get_usage(); 166 | } 167 | 168 | /** 169 | * ends the memory profiling and sends the value to the server 170 | * 171 | * @param string $key 172 | * @param float $sampleRate 173 | * @param array $tags 174 | */ 175 | public function endMemoryProfile(string $key, float $sampleRate = 1.0, array $tags = []): void 176 | { 177 | $end = memory_get_usage(); 178 | 179 | if (array_key_exists($key, $this->memoryProfiles)) { 180 | $memory = ($end - $this->memoryProfiles[$key]); 181 | $this->memory($key, $memory, $sampleRate, $tags); 182 | 183 | unset($this->memoryProfiles[$key]); 184 | } 185 | } 186 | 187 | /** 188 | * report memory usage to statsd. if memory was not given report peak usage 189 | * 190 | * @param string $key 191 | * @param int $memory 192 | * @param float $sampleRate 193 | * @param array $tags 194 | */ 195 | public function memory(string $key, int $memory = null, float $sampleRate = 1.0, array $tags = []): void 196 | { 197 | if ($memory === null) { 198 | $memory = memory_get_peak_usage(); 199 | } 200 | 201 | $this->count($key, $memory, $sampleRate, $tags); 202 | } 203 | 204 | /** 205 | * executes a Closure and records it's execution time and sends it to statsd 206 | * returns the value the Closure returned 207 | */ 208 | public function time(string $key, Closure $block, float $sampleRate = 1.0, array $tags = []) 209 | { 210 | $this->startTiming($key); 211 | try { 212 | return $block(); 213 | } finally { 214 | $this->endTiming($key, $sampleRate, $tags); 215 | } 216 | } 217 | 218 | /** 219 | * sends a gauge, an arbitrary value to StatsD 220 | * 221 | * @param string $key 222 | * @param string|int $value 223 | * @param array $tags 224 | */ 225 | public function gauge(string $key, $value, array $tags = []): void 226 | { 227 | $this->send($key, $value, 'g', 1, $tags); 228 | } 229 | 230 | /** 231 | * sends a set member 232 | * 233 | * @param string $key 234 | * @param int $value 235 | * @param array $tags 236 | */ 237 | public function set(string $key, int $value, array $tags = []): void 238 | { 239 | $this->send($key, $value, 's', 1, $tags); 240 | } 241 | 242 | /** 243 | * actually sends a message to to the daemon and returns the sent message 244 | * 245 | * @param string $key 246 | * @param int|float|string $value 247 | * @param string $type 248 | * @param float $sampleRate 249 | * @param array $tags 250 | */ 251 | private function send(string $key, $value, string $type, float $sampleRate, array $tags = []): void 252 | { 253 | // override sampleRate if all metrics should be sampled 254 | if ($this->sampleRateAllMetrics < 1) { 255 | $sampleRate = $this->sampleRateAllMetrics; 256 | } 257 | 258 | if ($sampleRate < 1 && mt_rand() / mt_getrandmax() > $sampleRate) { 259 | return; 260 | } 261 | 262 | if (strlen($this->namespace) !== 0) { 263 | $key = $this->namespace . '.' . $key; 264 | } 265 | 266 | $message = $key . ':' . $value . '|' . $type; 267 | 268 | if ($sampleRate < 1) { 269 | $sampledData = $message . '|@' . $sampleRate; 270 | } else { 271 | $sampledData = $message; 272 | } 273 | 274 | if (!empty($tags)) { 275 | $sampledData .= '|#'; 276 | $tagArray = []; 277 | 278 | foreach ($tags as $key => $value) { 279 | $tagArray[] = ($key . ':' . $value); 280 | } 281 | 282 | $sampledData .= join(',', $tagArray); 283 | } 284 | 285 | if (!$this->isBatch) { 286 | $this->connection->send($sampledData); 287 | } else { 288 | $this->batch[] = $sampledData; 289 | } 290 | } 291 | 292 | /** 293 | * changes the global key namespace 294 | * 295 | * @param string $namespace 296 | */ 297 | public function setNamespace(string $namespace): void 298 | { 299 | $this->namespace = (string) $namespace; 300 | } 301 | 302 | /** 303 | * gets the global key namespace 304 | * 305 | * @return string 306 | */ 307 | public function getNamespace(): string 308 | { 309 | return $this->namespace; 310 | } 311 | 312 | /** 313 | * is batch processing running? 314 | * 315 | * @return bool 316 | */ 317 | public function isBatch(): bool 318 | { 319 | return $this->isBatch; 320 | } 321 | 322 | /** 323 | * start batch-send-recording 324 | */ 325 | public function startBatch(): void 326 | { 327 | $this->isBatch = true; 328 | } 329 | 330 | /** 331 | * ends batch-send-recording and sends the recorded messages to the connection 332 | */ 333 | public function endBatch(): void 334 | { 335 | $this->isBatch = false; 336 | $this->connection->sendMessages($this->batch); 337 | $this->batch = []; 338 | } 339 | 340 | /** 341 | * stops batch-recording and resets the batch 342 | */ 343 | public function cancelBatch(): void 344 | { 345 | $this->isBatch = false; 346 | $this->batch = []; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | send($message); 24 | } 25 | } 26 | 27 | public function close(): void 28 | { 29 | // do nothing 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Connection/File.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 29 | $this->mode = $mode; 30 | } 31 | 32 | private function open(): void 33 | { 34 | $this->handle = @fopen($this->filePath, $this->mode); 35 | } 36 | 37 | public function send(string $message): void 38 | { 39 | // prevent from sending empty or non-sense metrics 40 | if ($message === '') { 41 | return; 42 | } 43 | 44 | if (!$this->handle) { 45 | $this->open(); 46 | } 47 | 48 | if ($this->handle) { 49 | fwrite($this->handle, $message . PHP_EOL); 50 | } 51 | } 52 | 53 | public function sendMessages(array $messages): void 54 | { 55 | foreach ($messages as $message) { 56 | $this->send($message); 57 | } 58 | } 59 | 60 | public function close(): void 61 | { 62 | if (is_resource($this->handle)) { 63 | fclose($this->handle); 64 | } 65 | 66 | $this->handle = null; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Connection/InMemory.php: -------------------------------------------------------------------------------- 1 | messages[] = $message; 23 | } 24 | 25 | public function sendMessages(array $messages): void 26 | { 27 | foreach ($messages as $message) { 28 | $this->send($message); 29 | } 30 | } 31 | 32 | /** 33 | * Drops all messages that were collected. 34 | */ 35 | public function clear(): void 36 | { 37 | $this->messages = []; 38 | } 39 | 40 | /** 41 | * Returns messages that were collected until now. 42 | */ 43 | public function getMessages(): array 44 | { 45 | return $this->messages; 46 | } 47 | 48 | public function close(): void 49 | { 50 | $this->clear(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Connection/InetSocket.php: -------------------------------------------------------------------------------- 1 | host = $host; 65 | $this->port = $port; 66 | $this->persistent = $persistent; 67 | $this->maxPayloadSize = (int) $mtu - 68 | self::IP_HEADER_SIZE - 69 | $this->getProtocolHeaderSize() - 70 | strlen(self::LINE_DELIMITER); 71 | 72 | if ($timeout === null) { 73 | $this->timeout = (int) ini_get('default_socket_timeout'); 74 | } else { 75 | $this->timeout = $timeout; 76 | } 77 | } 78 | 79 | public function getHost(): string 80 | { 81 | return $this->host; 82 | } 83 | 84 | public function getPort(): int 85 | { 86 | return $this->port; 87 | } 88 | 89 | public function getTimeout(): int 90 | { 91 | return $this->timeout; 92 | } 93 | 94 | public function isPersistent(): bool 95 | { 96 | return $this->persistent; 97 | } 98 | 99 | /** 100 | * sends a message to the UDP socket 101 | * 102 | * @param string $message 103 | * 104 | * @codeCoverageIgnore 105 | * this is ignored because it writes to an actual socket and is not testable 106 | */ 107 | public function send(string $message): void 108 | { 109 | // prevent from sending empty or non-sense metrics 110 | if ($message === '') { 111 | return; 112 | } 113 | 114 | $this->sendMessages([$message]); 115 | } 116 | 117 | /** 118 | * sends multiple messages to statsd 119 | * 120 | * @param array $messages 121 | */ 122 | public function sendMessages(array $messages): void 123 | { 124 | if (count($messages) === 0) { 125 | return; 126 | } 127 | 128 | if (!$this->isConnected()) { 129 | $this->connect($this->host, $this->port, $this->timeout, $this->persistent); 130 | } 131 | 132 | foreach ($this->cutIntoMtuSizedPackets($messages) as $packet) { 133 | $this->writeToSocket($packet); 134 | } 135 | } 136 | 137 | /** 138 | * @param array $messages 139 | * 140 | * @return array 141 | */ 142 | private function cutIntoMtuSizedPackets(array $messages): array 143 | { 144 | if ($this->allowFragmentation()) { 145 | $message = join(self::LINE_DELIMITER, $messages) . self::LINE_DELIMITER; 146 | 147 | return str_split($message, $this->maxPayloadSize); 148 | } 149 | 150 | $delimiterLen = strlen(self::LINE_DELIMITER); 151 | $packets = []; 152 | $packet = ''; 153 | 154 | foreach ($messages as $message) { 155 | if (strlen($packet) + strlen($message) + $delimiterLen > $this->maxPayloadSize) { 156 | $packets[] = $packet; 157 | $packet = ''; 158 | } 159 | 160 | $packet .= $message . self::LINE_DELIMITER; 161 | } 162 | 163 | if (strlen($packet) > 0) { 164 | $packets[] = $packet; 165 | } 166 | 167 | return $packets; 168 | } 169 | 170 | /** 171 | * connect to the socket 172 | * 173 | * @param string $host 174 | * @param int $port 175 | * @param int $timeout 176 | * @param bool $persistent 177 | */ 178 | abstract protected function connect(string $host, int $port, int $timeout, bool $persistent): void; 179 | 180 | /* 181 | * checks whether the socket connection is alive 182 | */ 183 | abstract protected function isConnected(): bool; 184 | 185 | /** 186 | * writes a message to the socket 187 | * 188 | * @param string $message 189 | */ 190 | abstract protected function writeToSocket(string $message): void; 191 | 192 | abstract protected function getProtocolHeaderSize(): int; 193 | 194 | abstract protected function allowFragmentation(): bool; 195 | } 196 | -------------------------------------------------------------------------------- /src/Connection/TcpSocket.php: -------------------------------------------------------------------------------- 1 | socket === null || !is_resource($this->socket)) { 50 | throw new TcpSocketException($this->host, $this->port, 'Couldn\'t write to socket, socket is closed'); 51 | } 52 | 53 | fwrite($this->socket, $message); 54 | } 55 | 56 | /** 57 | * @param string $host 58 | * @param int $port 59 | * @param int $timeout 60 | * @param bool $persistent 61 | */ 62 | protected function connect(string $host, int $port, int $timeout, bool $persistent): void 63 | { 64 | $errorNumber = 0; 65 | $errorMessage = ''; 66 | 67 | $url = 'tcp://' . $host; 68 | 69 | if ($persistent) { 70 | $socket = @pfsockopen($url, $port, $errorNumber, $errorMessage, $timeout); 71 | } else { 72 | $socket = @fsockopen($url, $port, $errorNumber, $errorMessage, $timeout); 73 | } 74 | 75 | if ($socket === false) { 76 | throw new TcpSocketException($host, $port, $errorMessage); 77 | } 78 | 79 | $this->socket = $socket; 80 | } 81 | 82 | protected function isConnected(): bool 83 | { 84 | return is_resource($this->socket) && !feof($this->socket); 85 | } 86 | 87 | public function close(): void 88 | { 89 | if (is_resource($this->socket)) { 90 | fclose($this->socket); 91 | } 92 | 93 | $this->socket = null; 94 | } 95 | 96 | protected function getProtocolHeaderSize(): int 97 | { 98 | return self::HEADER_SIZE; 99 | } 100 | 101 | protected function allowFragmentation(): bool 102 | { 103 | return true; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Connection/TcpSocketException.php: -------------------------------------------------------------------------------- 1 | socket === null || !is_resource($this->socket)) { 48 | return; 49 | } 50 | 51 | // suppress all errors 52 | @fwrite($this->socket, $message); 53 | 54 | // sleeping for 10 millionths of a second dramatically improves UDP reliability 55 | usleep(10); 56 | } 57 | 58 | /** 59 | * @param string $host 60 | * @param int $port 61 | * @param int $timeout 62 | * @param bool $persistent 63 | */ 64 | protected function connect(string $host, int $port, int $timeout, bool $persistent = false): void 65 | { 66 | $errorNumber = 0; 67 | $errorMessage = ''; 68 | 69 | $url = 'udp://' . $host; 70 | 71 | if ($persistent) { 72 | $this->socket = @pfsockopen($url, $port, $errorNumber, $errorMessage, $timeout); 73 | } else { 74 | $this->socket = @fsockopen($url, $port, $errorNumber, $errorMessage, $timeout); 75 | } 76 | } 77 | 78 | /** 79 | * checks whether the socket connection is alive 80 | * 81 | * only tries to connect once 82 | * 83 | * ever after isConnected will return true, 84 | * because $this->socket is then false 85 | * 86 | * @return bool 87 | */ 88 | protected function isConnected(): bool 89 | { 90 | return is_resource($this->socket) && feof($this->socket) === false; 91 | } 92 | 93 | public function close(): void 94 | { 95 | if (is_resource($this->socket)) { 96 | fclose($this->socket); 97 | } 98 | 99 | $this->socket = null; 100 | } 101 | 102 | protected function getProtocolHeaderSize(): int 103 | { 104 | return self::HEADER_SIZE; 105 | } 106 | 107 | /** 108 | * message fragmention should not be allowed on UDP because packets 109 | * regularly arrive out-of-order, and if they are not split evenly on a 110 | * line delimiter, they will be combined in strange ways. 111 | * 112 | * @return bool 113 | */ 114 | protected function allowFragmentation(): bool 115 | { 116 | return false; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | startTiming('timing.while'); 12 | $statsd->increment('customer.signed_up', 10); 13 | sleep(2); 14 | $statsd->count('products.viewed', rand(1, 100)); 15 | $statsd->endTiming('timing.while'); 16 | 17 | $statsd->startBatch(); 18 | for ($i = 0; $i < 1000; $i++) { 19 | $statsd->increment('batch'); 20 | } 21 | $statsd->increment('batch.end'); 22 | $statsd->endBatch(); 23 | } 24 | -------------------------------------------------------------------------------- /tests/integration/tcp-test.php: -------------------------------------------------------------------------------- 1 | startTiming('timing.while'); 12 | $statsd->increment('customer.signed_up', 10); 13 | sleep(2); 14 | $statsd->count('products.viewed', rand(1, 100)); 15 | $statsd->endTiming('timing.while'); 16 | 17 | $statsd->startBatch(); 18 | for ($i = 0; $i < 1000; $i++) { 19 | $statsd->increment('batch'); 20 | } 21 | $statsd->increment('batch.end'); 22 | $statsd->endBatch(); 23 | } 24 | -------------------------------------------------------------------------------- /tests/integration/udp-test.php: -------------------------------------------------------------------------------- 1 | startTiming('timing.while'); 12 | sleep(2); 13 | $statsd->count('products.viewed', rand(1, 100)); 14 | $statsd->endTiming('timing.while'); 15 | 16 | $statsd->startBatch(); 17 | for ($i = 0; $i < 1000; $i++) { 18 | $statsd->increment('batch'); 19 | } 20 | $statsd->increment('batch.end'); 21 | $statsd->endBatch(); 22 | } 23 | -------------------------------------------------------------------------------- /tests/unit/ClientBatchTest.php: -------------------------------------------------------------------------------- 1 | connection = new ConnectionMock(); 25 | $this->client = new Client($this->connection); 26 | } 27 | 28 | public function testInit() 29 | { 30 | $this->assertFalse($this->client->isBatch()); 31 | } 32 | 33 | public function testStartBatch() 34 | { 35 | $this->client->startBatch(); 36 | $this->assertTrue($this->client->isBatch()); 37 | } 38 | 39 | public function testSendIsRecordingInBatch() 40 | { 41 | $this->client->startBatch(); 42 | $this->client->increment("foobar", 1); 43 | 44 | $message = $this->connection->getLastMessage(); 45 | $this->assertNull($message); 46 | } 47 | 48 | public function testEndBatch() 49 | { 50 | $this->client->startBatch(); 51 | $this->client->count("foobar", 1); 52 | $this->client->count("foobar", 2); 53 | $this->client->endBatch(); 54 | 55 | $this->assertFalse($this->client->isBatch()); 56 | $this->assertSame("foobar:1|c\nfoobar:2|c", $this->connection->getLastMessage()); 57 | 58 | // run a new batch => don't send the old values! 59 | 60 | $this->client->startBatch(); 61 | $this->client->count("baz", 100); 62 | $this->client->count("baz", 300); 63 | $this->client->endBatch(); 64 | 65 | $this->assertFalse($this->client->isBatch()); 66 | $this->assertSame("baz:100|c\nbaz:300|c", $this->connection->getLastMessage()); 67 | } 68 | 69 | public function testCancelBatch() 70 | { 71 | $this->client->startBatch(); 72 | $this->client->count("foobar", 4); 73 | $this->client->cancelBatch(); 74 | 75 | $this->assertFalse($this->client->isBatch()); 76 | $this->assertNull($this->connection->getLastMessage()); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/unit/ClientTest.php: -------------------------------------------------------------------------------- 1 | connection = new ConnectionMock(); 25 | $this->client = new Client($this->connection, 'test'); 26 | } 27 | 28 | public function testInit() 29 | { 30 | $client = new Client(new ConnectionMock()); 31 | $this->assertEquals('', $client->getNamespace()); 32 | } 33 | 34 | public function testNamespace() 35 | { 36 | $client = new Client(new ConnectionMock(), 'test.foo'); 37 | $this->assertEquals('test.foo', $client->getNamespace()); 38 | 39 | $client->setNamespace('bar.baz'); 40 | $this->assertEquals('bar.baz', $client->getNamespace()); 41 | } 42 | 43 | public function testCount() 44 | { 45 | $this->client->count('foo.bar', 100); 46 | $this->assertEquals( 47 | 'test.foo.bar:100|c', 48 | $this->connection->getLastMessage() 49 | ); 50 | } 51 | 52 | public function testCountWithFloatValue() 53 | { 54 | $this->client->count('foo.bar', 100.45); 55 | $this->assertEquals( 56 | 'test.foo.bar:100.45|c', 57 | $this->connection->getLastMessage() 58 | ); 59 | } 60 | 61 | /** 62 | * @group sampling 63 | */ 64 | public function testCountWithSamplingRate() 65 | { 66 | $client = new Client($this->connection, 'test', 9 / 10); 67 | for ($i = 0; $i < 10; $i++) { 68 | $client->count('foo.baz', 100, 1); 69 | } 70 | $this->assertEquals( 71 | 'test.foo.baz:100|c|@0.9', 72 | $this->connection->getLastMessage() 73 | ); 74 | } 75 | 76 | /** 77 | * @group sampling 78 | */ 79 | public function testCountWithSamplingRateAndTags() 80 | { 81 | $client = new Client($this->connection, 'test', 9 / 10); 82 | for ($i = 0; $i < 10; $i++) { 83 | $client->count('foo.baz', 100, 1, ['tag' => 'value']); 84 | } 85 | $this->assertEquals( 86 | 'test.foo.baz:100|c|@0.9|#tag:value', 87 | $this->connection->getLastMessage() 88 | ); 89 | } 90 | 91 | public function testIncrement() 92 | { 93 | $this->client->increment('foo.baz'); 94 | $this->assertEquals( 95 | 'test.foo.baz:1|c', 96 | $this->connection->getLastMessage() 97 | ); 98 | } 99 | 100 | /** 101 | * @group sampling 102 | */ 103 | public function testIncrementWithSamplingRate() 104 | { 105 | $client = new Client($this->connection, 'test', 0.9); 106 | for ($i = 0; $i < 10; $i++) { 107 | $client->increment('foo.baz', 1); 108 | } 109 | $this->assertEquals( 110 | 'test.foo.baz:1|c|@0.9', 111 | $this->connection->getLastMessage() 112 | ); 113 | } 114 | 115 | /** 116 | * @group sampling 117 | */ 118 | public function testIncrementWithSamplingRateAndTags() 119 | { 120 | $client = new Client($this->connection, 'test', 0.9); 121 | for ($i = 0; $i < 10; $i++) { 122 | $client->increment('foo.baz', 1, ['tag' => 'value']); 123 | } 124 | $this->assertEquals( 125 | 'test.foo.baz:1|c|@0.9|#tag:value', 126 | $this->connection->getLastMessage() 127 | ); 128 | } 129 | 130 | public function testDecrement() 131 | { 132 | $this->client->decrement('foo.baz'); 133 | $this->assertEquals( 134 | 'test.foo.baz:-1|c', 135 | $this->connection->getLastMessage() 136 | ); 137 | } 138 | 139 | /** 140 | * @group sampling 141 | */ 142 | public function testDecrementWithSamplingRate() 143 | { 144 | $client = new Client($this->connection, 'test', 0.9); 145 | for ($i = 0; $i < 10; $i++) { 146 | $client->decrement('foo.baz', 1); 147 | } 148 | $this->assertEquals( 149 | 'test.foo.baz:-1|c|@0.9', 150 | $this->connection->getLastMessage() 151 | ); 152 | } 153 | 154 | /** 155 | * @group sampling 156 | */ 157 | public function testDecrementWithSamplingRateAndTags() 158 | { 159 | $client = new Client($this->connection, 'test', 0.9); 160 | for ($i = 0; $i < 10; $i++) { 161 | $client->decrement('foo.baz', 1, ['tag' => 'value']); 162 | } 163 | $this->assertEquals( 164 | 'test.foo.baz:-1|c|@0.9|#tag:value', 165 | $this->connection->getLastMessage() 166 | ); 167 | } 168 | 169 | public function testCanMeasureTimingWithClosure() 170 | { 171 | $this->client->timing('foo.baz', 2000); 172 | $this->assertEquals( 173 | 'test.foo.baz:2000|ms', 174 | $this->connection->getLastMessage() 175 | ); 176 | } 177 | 178 | 179 | /** 180 | * @group sampling 181 | */ 182 | public function testTimingWithSamplingRate() 183 | { 184 | $client = new Client($this->connection, 'test', 0.9); 185 | for ($i = 0; $i < 10; $i++) { 186 | $client->timing('foo.baz', 2000, 1); 187 | } 188 | $this->assertEquals( 189 | 'test.foo.baz:2000|ms|@0.9', 190 | $this->connection->getLastMessage() 191 | ); 192 | } 193 | 194 | public function testCanMeasureTimingByStartingAndEndingTiming() 195 | { 196 | $key = 'foo.bar'; 197 | $this->client->startTiming($key); 198 | usleep(10000); 199 | $this->client->endTiming($key); 200 | 201 | // ranges between 1000 and 1001ms 202 | $this->assertMatchesRegularExpression( 203 | '/^test\.foo\.bar:[0-9]+(.[0-9]+)?\|ms$/', 204 | $this->connection->getLastMessage() 205 | ); 206 | } 207 | 208 | public function testEndTimingReturnsTiming() 209 | { 210 | $key = 'foo.bar'; 211 | $this->assertNull($this->client->endTiming($key)); 212 | 213 | $sleep = 10000; 214 | $this->client->startTiming($key); 215 | usleep($sleep); 216 | 217 | $this->assertGreaterThanOrEqual($sleep / 1000, $this->client->endTiming($key)); 218 | } 219 | 220 | /** 221 | * @group sampling 222 | */ 223 | public function testStartEndTimingWithSamplingRate() 224 | { 225 | $client = new Client($this->connection, 'test', 0.9); 226 | for ($i = 0; $i < 10; $i++) { 227 | $client->startTiming('foo.baz'); 228 | usleep(10000); 229 | $client->endTiming('foo.baz'); 230 | } 231 | 232 | // ranges between 1000 and 1001ms 233 | $this->assertMatchesRegularExpression( 234 | '/^test\.foo\.baz:1[0-9](.[0-9]+)?\|ms\|@0.9$/', 235 | $this->connection->getLastMessage() 236 | ); 237 | } 238 | 239 | public function testTimeClosure() 240 | { 241 | $evald = $this->client->time('foo', function () { 242 | return "foobar"; 243 | }); 244 | 245 | $this->assertEquals('foobar', $evald); 246 | $this->assertMatchesRegularExpression( 247 | '/test\.foo\.baz:100[0|1]{1}|ms|@0.1/', 248 | $this->connection->getLastMessage() 249 | ); 250 | } 251 | 252 | /** 253 | * @group memory 254 | */ 255 | public function testMemory() 256 | { 257 | $this->client->memory('foo.bar'); 258 | $this->assertMatchesRegularExpression( 259 | '/test\.foo\.bar:[0-9]{4,}|c/', 260 | $this->connection->getLastMessage() 261 | ); 262 | } 263 | 264 | /** 265 | * @group memory 266 | */ 267 | public function testMemoryProfile() 268 | { 269 | $this->client->startMemoryProfile('foo.bar'); 270 | /** @noinspection PhpUnusedLocalVariableInspection */ 271 | $memoryUsage = memory_get_usage(); 272 | /** @noinspection PhpUnusedLocalVariableInspection */ 273 | $foobar = "fooooooooooooooooooooooooooooooooooooooooooooooooooooooobar"; 274 | $this->client->endMemoryProfile('foo.bar'); 275 | 276 | $message = $this->connection->getLastMessage(); 277 | $this->assertMatchesRegularExpression('/test\.foo\.bar:[0-9]{4,}|c/', $message); 278 | 279 | preg_match('/test\.foo\.bar\:([0-9]*)|c/', $message, $matches); 280 | $this->assertGreaterThan(0, $matches[1]); 281 | } 282 | 283 | public function testGauge() 284 | { 285 | $this->client->gauge("foobar", 333); 286 | 287 | $message = $this->connection->getLastMessage(); 288 | $this->assertEquals('test.foobar:333|g', $message); 289 | } 290 | 291 | public function testGaugeWithTags() 292 | { 293 | $this->client->gauge("foobar", 333, ['tag' => 'value']); 294 | $message = $this->connection->getLastMessage(); 295 | $this->assertEquals('test.foobar:333|g|#tag:value', $message); 296 | } 297 | 298 | public function testGaugeCanReceiveFormattedNumber() 299 | { 300 | $this->client->gauge('foobar', '+11'); 301 | 302 | $message = $this->connection->getLastMessage(); 303 | $this->assertEquals('test.foobar:+11|g', $message); 304 | } 305 | 306 | public function testSet() 307 | { 308 | $this->client->set("barfoo", 666); 309 | 310 | $message = $this->connection->getLastMessage(); 311 | $this->assertEquals('test.barfoo:666|s', $message); 312 | } 313 | 314 | public function testSetWithTags() 315 | { 316 | $this->client->set("barfoo", 666, ['tag' => 'value', 'tag2' => 'value2']); 317 | $message = $this->connection->getLastMessage(); 318 | $this->assertEquals('test.barfoo:666|s|#tag:value,tag2:value2', $message); 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /tests/unit/Connection/FileTest.php: -------------------------------------------------------------------------------- 1 | send($metric); 17 | 18 | $handle = $this->getFileHandle($connection); 19 | rewind($handle); 20 | $this->assertEquals($metric . PHP_EOL, stream_get_contents($handle)); 21 | } 22 | 23 | /** 24 | * @param string $metric 25 | * @dataProvider dataForSendWrongData 26 | */ 27 | public function testSendWrongData(string $metric) 28 | { 29 | $connection = new File('php://memory'); 30 | $connection->send($metric); 31 | $this->assertNull($this->getFileHandle($connection)); 32 | } 33 | 34 | /** 35 | * @return string[] 36 | */ 37 | public function dataForSendWrongData() 38 | { 39 | return [ 40 | [''], 41 | ]; 42 | } 43 | 44 | public function testSendMessages() 45 | { 46 | $metrics = [ 47 | 'file.test.namespace.customer.signed_up:1|c', 48 | 'file.test.namespace.products.viewed:8|c', 49 | 'file.test.namespace.timing.while:2010.7848644257|ms', 50 | 'file.test.namespace.batch:1|c', 51 | ]; 52 | 53 | $connection = new File('php://memory'); 54 | $connection->sendMessages($metrics); 55 | $handle = $this->getFileHandle($connection); 56 | rewind($handle); 57 | $this->assertEquals(implode(PHP_EOL, $metrics) . PHP_EOL, stream_get_contents($handle)); 58 | } 59 | 60 | /** 61 | * @param File $file 62 | * @return resource|null 63 | */ 64 | private function getFileHandle($file) 65 | { 66 | $reflector = new \ReflectionClass($file); 67 | $reflectorProperty = $reflector->getProperty('handle'); 68 | $reflectorProperty->setAccessible(true); 69 | return $reflectorProperty->getValue($file); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/unit/Connection/InMemoryTest.php: -------------------------------------------------------------------------------- 1 | send('some message'); 16 | $this->assertCount(1, $connection->getMessages()); 17 | } 18 | 19 | public function testCollectMultipleMessages() 20 | { 21 | $connection = new InMemory(); 22 | $connection->sendMessages(['some message', 'even more messages']); 23 | $this->assertCount(2, $connection->getMessages()); 24 | } 25 | 26 | public function testCollectDifferentMessages() 27 | { 28 | $connection = new InMemory(); 29 | $connection->send('first message'); 30 | $connection->sendMessages(['some more message', 'even more messages']); 31 | $this->assertCount(3, $connection->getMessages()); 32 | } 33 | 34 | public function testClearMessages() 35 | { 36 | $connection = new InMemory(); 37 | $connection->send('first message'); 38 | $connection->clear(); 39 | $this->assertCount(0, $connection->getMessages()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/unit/Connection/TcpSocketExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('Couldn\'t connect to host "localhost:666": Connection refused', $e->getMessage()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/unit/Connection/TcpSocketTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('localhost', $connection->getHost()); 17 | $this->assertEquals(8125, $connection->getPort()); 18 | $this->assertEquals(10, $connection->getTimeout()); 19 | $this->assertTrue($connection->isPersistent()); 20 | } 21 | 22 | public function testInitDefaults() 23 | { 24 | $connection = new TcpSocket(); 25 | 26 | $this->assertEquals('localhost', $connection->getHost()); 27 | $this->assertEquals(8125, $connection->getPort()); 28 | $this->assertEquals(ini_get('default_socket_timeout'), $connection->getTimeout()); 29 | $this->assertFalse($connection->isPersistent()); 30 | } 31 | 32 | public function testThrowsExceptionWhenTryingToConnectToNotExistingServer() 33 | { 34 | $this->expectException(\Domnikl\Statsd\Connection\TcpSocketException::class); 35 | $this->expectExceptionMessage('Couldn\'t connect to host "localhost:66000":'); 36 | 37 | $connection = new TcpSocket('localhost', 66000, 1); 38 | $connection->send('foobar'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/unit/Connection/UdpSocketTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('localhost', $connection->getHost()); 16 | $this->assertEquals(8125, $connection->getPort()); 17 | $this->assertEquals(10, $connection->getTimeout()); 18 | $this->assertTrue($connection->isPersistent()); 19 | } 20 | 21 | public function testInitDefaults() 22 | { 23 | $connection = new UdpSocket(); 24 | $this->assertEquals('localhost', $connection->getHost()); 25 | $this->assertEquals(8125, $connection->getPort()); 26 | $this->assertEquals(ini_get('default_socket_timeout'), $connection->getTimeout()); 27 | $this->assertFalse($connection->isPersistent()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/unit/ConnectionMock.php: -------------------------------------------------------------------------------- 1 | sampleAllMetrics = (bool) $sampleAllMetrics; 31 | } 32 | 33 | public function send(string $message): void 34 | { 35 | $this->messages[] = $message; 36 | } 37 | 38 | /** 39 | * @return string|null 40 | */ 41 | public function getLastMessage() 42 | { 43 | $i = count($this->messages) - 1; 44 | 45 | if (isset($this->messages[$i])) { 46 | return $this->messages[$i]; 47 | } else { 48 | return null; 49 | } 50 | } 51 | 52 | public function sendMessages(array $messages): void 53 | { 54 | $this->messages[] = join("\n", $messages); 55 | } 56 | 57 | public function close(): void 58 | { 59 | // do nothing 60 | } 61 | } 62 | --------------------------------------------------------------------------------