diff --git a/.github/workflows/ci-agent.yml b/.github/workflows/ci-agent.yml new file mode 100644 index 0000000..adb168d --- /dev/null +++ b/.github/workflows/ci-agent.yml @@ -0,0 +1,95 @@ +name: Agent CI + +on: + pull_request: + push: + branches: + - master + - release/** + +permissions: + contents: read + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + tests: + name: Tests (${{ matrix.os }}, ${{ matrix.php.version }}, ${{ matrix.dependencies }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + php: + - { version: '7.2', phpunit: '^8.5.40' } + - { version: '7.3', phpunit: '^9.6.21' } + - { version: '7.4', phpunit: '^9.6.21' } + - { version: '8.0', phpunit: '^9.6.21' } + - { version: '8.1', phpunit: '^9.6.21' } + - { version: '8.2', phpunit: '^9.6.21' } + - { version: '8.3', phpunit: '^9.6.21' } + - { version: '8.4', phpunit: '^9.6.21' } + - { version: '8.5', phpunit: '^9.6.25' } + dependencies: + - lowest + - highest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php.version }} + coverage: xdebug + + - name: Setup Problem Matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Determine Composer cache directory + id: composer-cache + run: echo "directory=$(composer config cache-dir)" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ runner.os }}-${{ matrix.php.version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-${{ matrix.php.version }}-${{ matrix.dependencies }}-composer- + + - name: Remove unused dependencies + run: composer config --unset platform.php + working-directory: ./agent + + # These dependencies are not used running the tests but can cause deprecation warnings so we remove them before running the tests + - name: Remove unused dependencies + run: composer remove phpstan/phpstan --dev --no-interaction --no-update + working-directory: ./agent + + - name: Set phpunit/phpunit version constraint + run: composer require phpunit/phpunit:'${{ matrix.php.phpunit }}' --dev --no-interaction --no-update + working-directory: ./agent + + - name: Install highest dependencies + run: composer update --no-progress --no-interaction --prefer-dist + if: ${{ matrix.dependencies == 'highest' }} + working-directory: ./agent + + - name: Install lowest dependencies + run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest + if: ${{ matrix.dependencies == 'lowest' }} + working-directory: ./agent + + - name: Run unit tests + run: vendor/bin/phpunit --testsuite unit --coverage-clover=coverage.xml + working-directory: ./agent diff --git a/agent/composer.json b/agent/composer.json index 326d155..6dd852f 100644 --- a/agent/composer.json +++ b/agent/composer.json @@ -16,7 +16,7 @@ "clue/mq-react": "^1.6", "react/http": "^1.11", "react/socket": "^1.16", - "sentry/sentry": "^4.15.0" + "sentry/sentry": "^4.19.1" }, "autoload": { "psr-4": { @@ -24,7 +24,7 @@ } }, "require-dev": { - "phpstan/phpstan": "1.12.5", + "phpstan/phpstan": "^1.12", "phpunit/phpunit": "^8.5|^9.6" }, "autoload-dev": { diff --git a/agent/composer.lock b/agent/composer.lock index 42195d8..efe8309 100644 --- a/agent/composer.lock +++ b/agent/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7118c5bbdfc78815b131962b530a06a", + "content-hash": "96c9e58e39082c47dc720fcd114c68b6", "packages": [ { "name": "clue/mq-react", @@ -565,16 +565,16 @@ }, { "name": "react/dns", - "version": "v1.13.0", + "version": "v1.14.0", "source": { "type": "git", "url": "https://github.com/reactphp/dns.git", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", "shasum": "" }, "require": { @@ -629,7 +629,7 @@ ], "support": { "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.13.0" + "source": "https://github.com/reactphp/dns/tree/v1.14.0" }, "funding": [ { @@ -637,20 +637,20 @@ "type": "open_collective" } ], - "time": "2024-06-13T14:18:03+00:00" + "time": "2025-11-18T19:34:28+00:00" }, { "name": "react/event-loop", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/reactphp/event-loop.git", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", "shasum": "" }, "require": { @@ -701,7 +701,7 @@ ], "support": { "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" }, "funding": [ { @@ -709,7 +709,7 @@ "type": "open_collective" } ], - "time": "2023-11-13T13:48:05+00:00" + "time": "2025-11-17T20:46:25+00:00" }, { "name": "react/http", @@ -877,16 +877,16 @@ }, { "name": "react/socket", - "version": "v1.16.0", + "version": "v1.17.0", "source": { "type": "git", "url": "https://github.com/reactphp/socket.git", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", "shasum": "" }, "require": { @@ -945,7 +945,7 @@ ], "support": { "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.16.0" + "source": "https://github.com/reactphp/socket/tree/v1.17.0" }, "funding": [ { @@ -953,7 +953,7 @@ "type": "open_collective" } ], - "time": "2024-07-26T10:38:09+00:00" + "time": "2025-11-19T20:47:34+00:00" }, { "name": "react/stream", @@ -1035,16 +1035,16 @@ }, { "name": "sentry/sentry", - "version": "4.15.2", + "version": "4.19.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "61a2d918e8424b6de4a2e265c15133a00c17db51" + "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/61a2d918e8424b6de4a2e265c15133a00c17db51", - "reference": "61a2d918e8424b6de4a2e265c15133a00c17db51", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3", + "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3", "shasum": "" }, "require": { @@ -1055,7 +1055,7 @@ "jean85/pretty-package-versions": "^1.5|^2.0.4", "php": "^7.2|^8.0", "psr/log": "^1.0|^2.0|^3.0", - "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0" + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" }, "conflict": { "raven/raven": "*" @@ -1068,7 +1068,6 @@ "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", "phpunit/phpunit": "^8.5|^9.6", - "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", "vimeo/psalm": "^4.17" }, "suggest": { @@ -1108,7 +1107,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.15.2" + "source": "https://github.com/getsentry/sentry-php/tree/4.19.1" }, "funding": [ { @@ -1120,7 +1119,7 @@ "type": "custom" } ], - "time": "2025-09-03T07:23:48+00:00" + "time": "2025-12-02T15:57:41+00:00" }, { "name": "symfony/options-resolver", @@ -1525,16 +1524,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.5", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" - }, + "version": "1.12.32", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", "shasum": "" }, "require": { @@ -1579,7 +1573,7 @@ "type": "github" } ], - "time": "2024-09-26T12:45:22+00:00" + "time": "2025-09-30T10:16:31+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1880,16 +1874,16 @@ }, { "name": "phpunit/phpunit", - "version": "8.5.46", + "version": "8.5.50", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "2da51ff4b15c95e8c060d7dd693fd5899f87a112" + "reference": "b0a92d13eaf276a963c3307744a266d8c907179c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2da51ff4b15c95e8c060d7dd693fd5899f87a112", - "reference": "2da51ff4b15c95e8c060d7dd693fd5899f87a112", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b0a92d13eaf276a963c3307744a266d8c907179c", + "reference": "b0a92d13eaf276a963c3307744a266d8c907179c", "shasum": "" }, "require": { @@ -1911,7 +1905,7 @@ "sebastian/comparator": "^3.0.6", "sebastian/diff": "^3.0.6", "sebastian/environment": "^4.2.5", - "sebastian/exporter": "^3.1.6", + "sebastian/exporter": "^3.1.8", "sebastian/global-state": "^3.0.6", "sebastian/object-enumerator": "^3.0.5", "sebastian/resource-operations": "^2.0.3", @@ -1958,7 +1952,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.46" + "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.50" }, "funding": [ { @@ -1982,7 +1976,7 @@ "type": "tidelift" } ], - "time": "2025-09-14T06:16:44+00:00" + "time": "2025-12-06T07:39:47+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -2256,16 +2250,16 @@ }, { "name": "sebastian/exporter", - "version": "3.1.7", + "version": "3.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "8c86ae3e84f69acff53b9d4b96614a68e3572901" + "reference": "64cfeaa341951ceb2019d7b98232399d57bb2296" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/8c86ae3e84f69acff53b9d4b96614a68e3572901", - "reference": "8c86ae3e84f69acff53b9d4b96614a68e3572901", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/64cfeaa341951ceb2019d7b98232399d57bb2296", + "reference": "64cfeaa341951ceb2019d7b98232399d57bb2296", "shasum": "" }, "require": { @@ -2321,7 +2315,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/3.1.7" + "source": "https://github.com/sebastianbergmann/exporter/tree/3.1.8" }, "funding": [ { @@ -2341,7 +2335,7 @@ "type": "tidelift" } ], - "time": "2025-09-22T05:03:57+00:00" + "time": "2025-09-24T05:55:14+00:00" }, { "name": "sebastian/global-state", @@ -2762,16 +2756,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -2800,7 +2794,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -2808,7 +2802,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], @@ -2824,5 +2818,5 @@ "platform-overrides": { "php": "7.2.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/agent/src/ControlServer.php b/agent/src/ControlServer.php new file mode 100644 index 0000000..5cd68f3 --- /dev/null +++ b/agent/src/ControlServer.php @@ -0,0 +1,110 @@ +uri = $uri; + $this->queue = $queue; + } + + public function run(): void + { + $httpServer = new HttpServer(function (ServerRequestInterface $request) { + $path = $request->getUri()->getPath(); + + if ($path === '/status') { + return $this->handleStatus(); + } + + if ($path === '/drain') { + return $this->handleDrain(); + } + + return new Response(404, ['Content-Type' => 'text/plain'], 'Not Found'); + }); + + $this->socket = new SocketServer($this->uri); + + $httpServer->listen($this->socket); + } + + /** + * Stops the control server. + */ + public function close(): void + { + if ($this->socket !== null) { + $this->socket->close(); + $this->socket = null; + } + } + + /** + * Returns the current queue status. + */ + private function handleStatus(): Response + { + $body = json_encode([ + 'queue_size' => \count($this->queue), + ]); + + return new Response(200, ['Content-Type' => 'application/json'], $body !== false ? $body : '{}'); + } + + /** + * Waits for the queue to drain and returns when empty. + * + * @return PromiseInterface + */ + private function handleDrain(): PromiseInterface + { + $deferred = new Deferred(); + + $checkInterval = 0.05; // 50ms + + $check = function () use (&$check, $deferred, $checkInterval): void { + if (\count($this->queue) === 0) { + $deferred->resolve(new Response(200, ['Content-Type' => 'text/plain'], 'ok')); + + return; + } + + Loop::addTimer($checkInterval, $check); + }; + + Loop::futureTick($check); + + return $deferred->promise(); + } +} diff --git a/agent/src/EnvelopeForwarder.php b/agent/src/EnvelopeForwarder.php index 6fd1c9b..254225e 100644 --- a/agent/src/EnvelopeForwarder.php +++ b/agent/src/EnvelopeForwarder.php @@ -7,12 +7,13 @@ use Psr\Http\Message\ResponseInterface; use React\Http\Browser; use React\Http\Message\ResponseException; -use React\Promise\Internal\FulfilledPromise; use React\Promise\PromiseInterface; use Sentry\Dsn; use Sentry\HttpClient\Response; use Sentry\Transport\RateLimiter; +use function React\Promise\resolve; + /** * @internal */ @@ -79,7 +80,7 @@ public function forward(Envelope $envelope): PromiseInterface // When the envelope is empty, we don't need to send it if ($envelope->isEmpty()) { - return new FulfilledPromise(); + return resolve(null); } $authHeader = [ diff --git a/agent/src/sentry-agent.php b/agent/src/sentry-agent.php index ecf9e21..3a3eae8 100755 --- a/agent/src/sentry-agent.php +++ b/agent/src/sentry-agent.php @@ -4,6 +4,7 @@ use React\EventLoop\Loop; use Sentry\Agent\Console\Log; +use Sentry\Agent\ControlServer; use Sentry\Agent\Envelope; use Sentry\Agent\EnvelopeForwarder; use Sentry\Agent\EnvelopeQueue; @@ -49,12 +50,13 @@ function printHelp(): void --upstream-concurrency=REQUESTS Configures the amount of concurrent requests the agent is allowed to make towards Sentry [default: "10"] --queue-limit=ENVELOPES How many envelopes we want to keep in memory before we start dropping them [default: "1000"] --drain-timeout=SECONDS Time to wait for the queue to drain on shutdown (in seconds) [default: "10.0"] + --control-server=ADDRESS Enable the HTTP control server on the specified address (e.g., "127.0.0.1:5149") -v, --verbose When supplied the agent will print debug messages to the console, otherwise only errors and info messages are printed HELP; } -$options = getopt('h', ['listen::', 'upstream-timeout::', 'upstream-concurrency::', 'queue-limit::', 'drain-timeout::', 'help']); +$options = getopt('h', ['listen::', 'upstream-timeout::', 'upstream-concurrency::', 'queue-limit::', 'drain-timeout::', 'control-server::', 'help']); if ($options === false) { Log::error('Failed to parse command line options.'); @@ -190,12 +192,34 @@ function (Envelope $envelope) use ($queue) { } ); -$server->run(); +try { + $server->run(); +} catch (RuntimeException $e) { + Log::error("Failed to start server on {$listenAddress}: {$e->getMessage()}"); + exit(1); +} + +$controlServerAddress = $getOption('control-server'); +$controlServer = null; + +if ($controlServerAddress !== null) { + Log::info("Starting control server on {$controlServerAddress}"); + + $controlServer = new ControlServer($controlServerAddress, $queue); + + try { + $controlServer->run(); + } catch (RuntimeException $e) { + Log::error("Failed to start control server on {$controlServerAddress}: {$e->getMessage()}"); + $server->close(); + exit(1); + } +} // Set up graceful shutdown handling $isShuttingDown = false; -$shutdown = function (int $signal) use ($server, $queue, $drainTimeout, &$isShuttingDown) { +$shutdown = function (int $signal) use ($server, $controlServer, $queue, $drainTimeout, &$isShuttingDown) { if ($isShuttingDown) { return; } @@ -207,6 +231,10 @@ function (Envelope $envelope) use ($queue) { $server->close(); + if ($controlServer !== null) { + $controlServer->close(); + } + $checkInterval = 0.1; $elapsed = 0.0; @@ -239,13 +267,11 @@ function (Envelope $envelope) use ($queue) { pcntl_signal(\SIGTERM, $shutdown); pcntl_signal(\SIGINT, $shutdown); pcntl_async_signals(true); -} elseif (\function_exists('sapi_windows_set_ctrl_handler')) { +} elseif (function_exists('sapi_windows_set_ctrl_handler')) { // Windows signal handling (PHP 7.4+) - sapi_windows_set_ctrl_handler(static function (int $event) use ($shutdown) { + sapi_windows_set_ctrl_handler(static function (int $event) use ($shutdown): void { // PHP_WINDOWS_EVENT_CTRL_C is only defined on Windows - $shutdown(\defined('PHP_WINDOWS_EVENT_CTRL_C') && $event === \PHP_WINDOWS_EVENT_CTRL_C ? \SIGINT : \SIGTERM); - - return true; // Signal handled, don't execute default handler + $shutdown(defined('PHP_WINDOWS_EVENT_CTRL_C') && $event === \PHP_WINDOWS_EVENT_CTRL_C ? \SIGINT : \SIGTERM); }); } diff --git a/agent/tests/AgentForwardingTest.php b/agent/tests/AgentForwardingTest.php new file mode 100644 index 0000000..4698e42 --- /dev/null +++ b/agent/tests/AgentForwardingTest.php @@ -0,0 +1,105 @@ +startTestServer(); + + $dsn = "http://e12d836b15bb49d7bbf99e64295d995b:@{$serverAddress}/200"; + + $envelope = $this->createEnvelope($dsn, 'Hello from agent test!'); + + $this->startTestAgent(); + $this->sendEnvelopeToAgent($envelope); + $this->stopTestAgent(); + + $serverOutput = $this->stopTestServer(); + + // Verify the envelope was forwarded correctly + $this->assertEquals(200, $serverOutput['status']); + $this->assertEquals(1, $serverOutput['request_count']); + $this->assertStringContainsString('Hello from agent test!', $serverOutput['body']); + $this->assertStringContainsString('"type":"event"', $serverOutput['body']); + + // Verify the correct headers were sent + $this->assertArrayHasKey('X-Sentry-Auth', $serverOutput['headers']); + $this->assertStringContainsString('sentry_key=e12d836b15bb49d7bbf99e64295d995b', $serverOutput['headers']['X-Sentry-Auth']); + $this->assertStringContainsString('sentry.php.agent', $serverOutput['headers']['X-Sentry-Auth']); + } + + public function testAgentForwardsMultipleEnvelopesToUpstream(): void + { + $serverAddress = $this->startTestServer(); + + $dsn = "http://publickey:@{$serverAddress}/200"; + + $this->startTestAgent(); + $this->sendEnvelopeToAgent($this->createEnvelope($dsn, 'First message')); + $this->sendEnvelopeToAgent($this->createEnvelope($dsn, 'Second message')); + $this->stopTestAgent(); + + $serverOutput = $this->stopTestServer(); + + $this->assertEquals(200, $serverOutput['status']); // this verifies the last response status + $this->assertEquals(2, $serverOutput['request_count']); + } + + public function testAgentRespectsRateLimiting(): void + { + $serverAddress = $this->startTestServer(); + + $this->startTestAgent(); + + // Use project ID 429 to trigger rate limiting response from test server + // The test server returns X-Sentry-Rate-Limits header for this project ID + $dsn = "http://publickey:@{$serverAddress}/429"; + + // Send first envelope - this will trigger rate limiting + $this->sendEnvelopeToAgent($this->createEnvelope($dsn, 'First message - triggers rate limit')); + + // Wait for the agent to process the first envelope and receive rate limit response + $this->waitForQueueDrain(); + + // Send second envelope - should be dropped by agent due to rate limiting + $this->sendEnvelopeToAgent($this->createEnvelope($dsn, 'Second message - should be dropped')); + + // Send third envelope - should also be dropped + $this->sendEnvelopeToAgent($this->createEnvelope($dsn, 'Third message - should be dropped')); + + $this->stopTestAgent(); + + $serverOutput = $this->stopTestServer(); + + // Only the first request should have been made to the server + // The subsequent envelopes should have been dropped by the agent due to rate limiting + $this->assertEquals(1, $serverOutput['request_count'], 'Only the first envelope should reach the server'); + $this->assertStringContainsString('First message', $serverOutput['body']); + } + + /** + * Create a test envelope using the Sentry PHP SDK. + */ + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } +} diff --git a/agent/tests/TestAgent.php b/agent/tests/TestAgent.php new file mode 100644 index 0000000..fa8e4a4 --- /dev/null +++ b/agent/tests/TestAgent.php @@ -0,0 +1,267 @@ +startTestAgent($upstreamAddress)` to start the agent. + * Then use `$this->sendEnvelopeToAgent($envelope)` to send an envelope. + * After you are done, call `$this->stopTestAgent()` to stop the agent. + */ +trait TestAgent +{ + /** + * @var resource|null the agent process handle + */ + protected $agentProcess; + + /** + * @var resource|null the agent stderr handle + */ + protected $agentStderr; + + /** + * @var int the port on which the agent is listening, this default value was somwhat randomly chosen + */ + protected $agentPort = 45248; + + /** + * @var int the port on which the control server is listening, this default value was somwhat randomly chosen + */ + protected $controlServerPort = 45249; + + /** + * Start the test agent. + * + * @return string the address the agent is listening on + */ + public function startTestAgent(): string + { + if ($this->agentProcess !== null) { + throw new \RuntimeException('There is already a test agent instance running.'); + } + + $pipes = []; + + $this->agentProcess = proc_open( + $command = \sprintf( + 'php %s --listen=127.0.0.1:%d --control-server=127.0.0.1:%d --upstream-timeout=5 --upstream-concurrency=1 --queue-limit=10 --verbose', + realpath(__DIR__ . '/../src/sentry-agent.php'), + $this->agentPort, + $this->controlServerPort + ), + [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ], + $pipes + ); + + $this->agentStderr = $pipes[2]; + + $pid = proc_get_status($this->agentProcess)['pid']; + + if (!\is_resource($this->agentProcess)) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + $address = "127.0.0.1:{$this->agentPort}"; + + // Wait for the agent to be ready to accept connections + $startTime = microtime(true); + $timeout = 5; // 5 seconds timeout + + while (true) { + $socket = @stream_socket_client("tcp://{$address}", $errno, $errstr, 1); + + if ($socket !== false) { + fclose($socket); + break; + } + + if (microtime(true) - $startTime > $timeout) { + $this->stopTestAgent(); + throw new \RuntimeException("Timeout waiting for test agent to start on {$address}"); + } + + usleep(10000); + } + + // Ensure the process is still running + if (!proc_get_status($this->agentProcess)['running']) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + // Wait for the control server to be ready by checking its /status endpoint + $controlServerAddress = "127.0.0.1:{$this->controlServerPort}"; + $streamContext = stream_context_create(['http' => ['timeout' => 1]]); + + while (true) { + $response = @file_get_contents("http://{$controlServerAddress}/status", false, $streamContext); + + if ($response !== false) { + break; + } + + if (microtime(true) - $startTime > $timeout) { + $this->stopTestAgent(); + throw new \RuntimeException("Timeout waiting for control server to start on {$controlServerAddress}"); + } + + usleep(10000); + } + + return $address; + } + + /** + * Send an envelope to the test agent. + * + * The envelope must be a string in Sentry envelope format. + */ + public function sendEnvelopeToAgent(string $envelope): void + { + if ($this->agentProcess === null) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $address = "127.0.0.1:{$this->agentPort}"; + $socket = stream_socket_client("tcp://{$address}", $errno, $errstr, 5); + + if ($socket === false) { + throw new \RuntimeException("Failed to connect to test agent: {$errstr} ({$errno})"); + } + + // The agent uses a 4-byte big-endian length prefix protocol + // The length includes the 4 bytes of the header itself + $length = \strlen($envelope) + 4; + $header = pack('N', $length); + + fwrite($socket, $header . $envelope); + + // Gracefully shutdown the write side, ensuring all data is sent before closing + stream_socket_shutdown($socket, \STREAM_SHUT_WR); + + fclose($socket); + } + + /** + * Wait for the agent queue to drain (all envelopes processed). + * + * This blocks until the queue is empty. + * + * @param float $timeout Maximum time to wait in seconds + * + * @throws \RuntimeException if timeout is reached or control server is unavailable + */ + public function waitForQueueDrain(float $timeout = 10.0): void + { + if ($this->agentProcess === null) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $controlServerAddress = "127.0.0.1:{$this->controlServerPort}"; + + $context = stream_context_create([ + 'http' => [ + 'timeout' => $timeout, + ], + ]); + + $result = @file_get_contents("http://{$controlServerAddress}/drain", false, $context); + + if ($result === false) { + throw new \RuntimeException("Failed to drain queue: control server at {$controlServerAddress} is unavailable"); + } + } + + /** + * Get the current queue size from the agent. + * + * @return int the number of envelopes in the queue + * + * @throws \RuntimeException if control server is unavailable + */ + public function getQueueSize(): int + { + if ($this->agentProcess === null) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $controlServerAddress = "127.0.0.1:{$this->controlServerPort}"; + + $result = @file_get_contents("http://{$controlServerAddress}/status"); + + if ($result === false) { + throw new \RuntimeException("Failed to get queue status: control server at {$controlServerAddress} is unavailable"); + } + + $status = json_decode($result, true); + + return $status['queue_size'] ?? 0; + } + + /** + * Stop the test agent and return stderr output. + * + * This waits for the queue to drain via the control server, then kills the process. + * + * @return string the stderr output from the agent + */ + public function stopTestAgent(): string + { + if (!$this->agentProcess) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + // Wait for the queue to drain before killing the process + $this->waitForQueueDrain(); + + for ($i = 0; $i < 20; ++$i) { + $status = proc_get_status($this->agentProcess); + + if (!$status['running']) { + break; + } + + $this->killAgentProcess($status['pid']); + + usleep(10000); + } + + if ($status['running']) { + throw new \RuntimeException('Could not kill test agent'); + } + + stream_set_blocking($this->agentStderr, false); + $stderrOutput = stream_get_contents($this->agentStderr); + + proc_close($this->agentProcess); + + $this->agentProcess = null; + $this->agentStderr = null; + + return $stderrOutput; + } + + private function killAgentProcess(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec("taskkill /pid {$pid} /f /t"); + } else { + // Kills any child processes + exec("pkill -P {$pid}"); + + // Kill the parent process + exec("kill {$pid}"); + } + + proc_terminate($this->agentProcess, 9); + } +} diff --git a/agent/tests/TestServer.php b/agent/tests/TestServer.php new file mode 100644 index 0000000..aaabba0 --- /dev/null +++ b/agent/tests/TestServer.php @@ -0,0 +1,166 @@ +startTestServer()` to start the server and get the address. + * After you have made your request, call `$this->stopTestServer()` to stop the server and get the output. + * + * Thanks to Stripe for the inspiration: https://github.com/stripe/stripe-php/blob/e0a960c8655b21b21c3ba2e5927f432eeda9105f/tests/TestServer.php + */ +trait TestServer +{ + /** + * @var string the path to the output file + */ + protected static $serverOutputFile = __DIR__ . '/fixtures/testserver/output.json'; + + /** + * @var string the path to the request count file + */ + protected static $serverRequestCountFile = __DIR__ . '/fixtures/testserver/request_count.txt'; + + /** + * @var resource|null the server process handle + */ + protected $serverProcess; + + /** + * @var resource|null the server stderr handle + */ + protected $serverStderr; + + /** + * @var int the port on which the server is listening, this default value was randomly chosen + */ + protected $serverPort = 44884; + + public function startTestServer(): string + { + if ($this->serverProcess !== null) { + throw new \RuntimeException('There is already a test server instance running.'); + } + + if (file_exists(self::$serverOutputFile)) { + unlink(self::$serverOutputFile); + } + + if (file_exists(self::$serverRequestCountFile)) { + unlink(self::$serverRequestCountFile); + } + + $pipes = []; + + $this->serverProcess = proc_open( + $command = \sprintf( + 'php -S localhost:%d -t %s', + $this->serverPort, + realpath(__DIR__ . '/fixtures/testserver') + ), + [2 => ['pipe', 'w']], + $pipes + ); + + $this->serverStderr = $pipes[2]; + + $pid = proc_get_status($this->serverProcess)['pid']; + + if (!\is_resource($this->serverProcess)) { + throw new \RuntimeException("Error starting test server on pid {$pid}, command failed: {$command}"); + } + + $address = "localhost:{$this->serverPort}"; + + $streamContext = stream_context_create(['http' => ['timeout' => 1]]); + + // Wait for the server to be ready to answer HTTP requests + while (true) { + $response = @file_get_contents("http://{$address}/ping", false, $streamContext); + + if ($response === 'pong') { + break; + } + + usleep(10000); + } + + // Ensure the process is still running + if (!proc_get_status($this->serverProcess)['running']) { + throw new \RuntimeException("Error starting test server on pid {$pid}, command failed: {$command}"); + } + + return $address; + } + + /** + * Stop the test server and return the output from the server. + * + * @return array{ + * body: string, + * status: int, + * server: array, + * headers: array, + * compressed: bool, + * request_count: int, + * } + */ + public function stopTestServer(): array + { + if (!$this->serverProcess) { + throw new \RuntimeException('There is no test server instance running.'); + } + + for ($i = 0; $i < 20; ++$i) { + $status = proc_get_status($this->serverProcess); + + if (!$status['running']) { + break; + } + + $this->killServerProcess($status['pid']); + + usleep(10000); + } + + if ($status['running']) { + throw new \RuntimeException('Could not kill test server'); + } + + if (!file_exists(self::$serverOutputFile)) { + stream_set_blocking($this->serverStderr, false); + $stderrOutput = stream_get_contents($this->serverStderr); + + echo $stderrOutput . \PHP_EOL; + + throw new \RuntimeException('Test server did not write output file'); + } + + proc_close($this->serverProcess); + + $this->serverProcess = null; + $this->serverStderr = null; + + return json_decode(file_get_contents(self::$serverOutputFile), true); + } + + private function killServerProcess(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec("taskkill /pid {$pid} /f /t"); + } else { + // Kills any child processes -- the php test server appears to start up a child. + exec("pkill -P {$pid}"); + + // Kill the parent process. + exec("kill {$pid}"); + } + + proc_terminate($this->serverProcess, 9); + } +} diff --git a/agent/tests/fixtures/testserver/.gitignore b/agent/tests/fixtures/testserver/.gitignore new file mode 100644 index 0000000..14d00dd --- /dev/null +++ b/agent/tests/fixtures/testserver/.gitignore @@ -0,0 +1,2 @@ +output.json +request_count.txt diff --git a/agent/tests/fixtures/testserver/index.php b/agent/tests/fixtures/testserver/index.php new file mode 100644 index 0000000..9c3cd13 --- /dev/null +++ b/agent/tests/fixtures/testserver/index.php @@ -0,0 +1,76 @@ +/envelope/`. +// We use the project ID to determine the status code so we need to extract it from the path +$path = trim(parse_url($_SERVER['REQUEST_URI'], \PHP_URL_PATH), '/'); + +if (strpos($path, 'ping') === 0) { + http_response_code(200); + + echo 'pong'; + + return; +} + +if (!preg_match('/api\/\d+\/envelope/', $path)) { + http_response_code(204); + + return; +} + +$projectId = (int) explode('/', $path)[1]; + +// Project IDs are used to control behavior: +// - 200: Normal 200 OK response +// - 429: Returns 429 with X-Sentry-Rate-Limits header for 'error' category (60 seconds) +// - Other: Use as HTTP status code directly +$status = $projectId; + +// Track request count +$requestCount = file_exists($requestCountFile) ? (int) file_get_contents($requestCountFile) : 0; +$requestCount++; +file_put_contents($requestCountFile, (string) $requestCount); + +$headers = getallheaders(); + +$rawBody = file_get_contents('php://input'); + +$compressed = false; + +if (!isset($headers['Content-Encoding'])) { + $body = $rawBody; +} elseif ($headers['Content-Encoding'] === 'gzip') { + $body = gzdecode($rawBody); + $compressed = true; +} else { + $body = '__unable to decode body__'; +} + +$output = [ + 'body' => $body, + 'status' => $status, + 'server' => $_SERVER, + 'headers' => $headers, + 'compressed' => $compressed, + 'request_count' => $requestCount, +]; + +file_put_contents($outputFile, json_encode($output, \JSON_PRETTY_PRINT)); + +header('X-Sentry-Test-Server-Status-Code: ' . $status); + +// Return rate limit headers for project ID 429 +if ($projectId === 429) { + // Format: retry_after:categories:scope:reason_code + // Rate limit 'error' category for 60 seconds + header('X-Sentry-Rate-Limits: 60:error::'); +} + +http_response_code($status); + +echo $body; diff --git a/bin/sentry-agent b/bin/sentry-agent index 39afa62..4a25b9d 100755 Binary files a/bin/sentry-agent and b/bin/sentry-agent differ diff --git a/bin/sentry-agent.sig b/bin/sentry-agent.sig index 3859eee..4e1b618 100644 --- a/bin/sentry-agent.sig +++ b/bin/sentry-agent.sig @@ -1 +1 @@ -FFC8683782E15055AD138282CE25F5DA03544D28FEBEAEDD35908E6DE6733B368D3679EB5F13AC88AB20AD8E88B11ACE54F78D41180E33F42B541265270618C1 +55220A0D691E6FD7973BFB12DAB5AC76C151196E508D84DAE6F9F40974C55E109CC6F07EC729C5F9CD2DBEAEDC5B74251ED810171FD31C59A6BAA97054DFEF6C diff --git a/composer.json b/composer.json index 2943f85..5d4e697 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "require": { "php": "^7.2|^8", "ext-json": "*", - "sentry/sentry": "^4.15.0" + "sentry/sentry": "^4.19.1" }, "autoload": { "psr-4": {