From bfa7f19b2d8bec25e2f2005b0dd86269701c83f1 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 17:33:45 +0100 Subject: [PATCH 1/6] build: require PHP 8.4 The image already runs PHP 8.5 (appwrite/base:1.1.1), and the lock was resolved with --ignore-platform-reqs against packages that need 8.4 (utopia-php/queue, utopia-php/pools, PHPUnit 13), so the ">=8.3.0" constraint no longer described what the code can run on. utopia-php/span 4.2, which the next commit adds for error reporting, also requires 8.4. --- composer.json | 2 +- composer.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 3774d9b..90e6f36 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "appwrite/docker-geo", "type": "project", "require": { - "php": ">=8.3.0", + "php": ">=8.4", "utopia-php/fetch": "0.5.*", "utopia-php/http": "^2.0@RC", "utopia-php/platform": "^1.0@RC", diff --git a/composer.lock b/composer.lock index dc04db8..db9194d 100644 --- a/composer.lock +++ b/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": "f46abbcdacdcd7c9a220cf6cc2f2ecfb", + "content-hash": "a8daef2fdd9916469a1aa4fba35dd900", "packages": [ { "name": "brick/math", @@ -4594,7 +4594,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.3.0" + "php": ">=8.4" }, "platform-dev": {}, "plugin-api-version": "2.9.0" From e246e46a93fb06d358fab20c1b1399bac0111bcd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 17:34:06 +0100 Subject: [PATCH 2/6] refactor: report errors through utopia-php/span instead of utopia-php/logger utopia-php/logger is being archived (appwrite/appwrite#13828) and the error handler was its only consumer here. Each request now opens a span; the error action sets the throwable on it with error.type, error.code, http.method and http.path (the old log tags), and finishes it once the response is sent. A shutdown hook finishes successful request spans. Exporters are registered at boot: a Stdout exporter prints server errors as one JSON line on stderr, replacing the Console::error lines, and when GEO_LOGGING_CONFIG holds a sentry://PROJECT_ID:KEY@HOST DSN, span's Sentry exporter reports them to Sentry with the same environment, release and server name the logger sent. Client errors are marked error.publish=false so, as before, only 5xx and code-0 errors leave the process. GEO_LOGGING_PROVIDER is removed: the DSN scheme already names the provider, and Sentry is the only exporter span ships. AppSignal, Raygun and LogOwl DSNs and the legacy "key;projectId" form now log a warning at boot and disable error reporting. Adds a unit suite (run in CI) that drives the error action and asserts on what a recording exporter receives. --- .env | 1 - .github/workflows/tests.yml | 3 + .phpunit.result.cache | 1 + composer.json | 5 +- composer.lock | 202 +++++++++++++++------ docker-compose.yml | 1 - phpunit.xml | 3 + src/Geo/Modules/Core/Http/Error.php | 71 ++------ src/Geo/Server/Server.php | 92 +++++----- tests/Unit/Modules/Core/Http/ErrorTest.php | 125 +++++++++++++ 10 files changed, 349 insertions(+), 155 deletions(-) create mode 100644 .phpunit.result.cache create mode 100644 tests/Unit/Modules/Core/Http/ErrorTest.php diff --git a/.env b/.env index b5cb7a1..ab3a863 100644 --- a/.env +++ b/.env @@ -1,4 +1,3 @@ GEO_ENV=development GEO_SECRET=geo-secret-key -GEO_LOGGING_PROVIDER= GEO_LOGGING_CONFIG= \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e812d06..d1285f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,6 +33,9 @@ jobs: docker compose logs geo exit 1 + - name: Run unit tests + run: docker compose run --rm --entrypoint vendor/bin/phpunit tests --testsuite unit + - name: Run E2E tests run: docker compose run --rm tests diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..8c535e4 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":2,"defects":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":7},"times":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":0.008,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":0.004,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":0.007}} \ No newline at end of file diff --git a/composer.json b/composer.json index 90e6f36..33f916a 100644 --- a/composer.json +++ b/composer.json @@ -8,10 +8,10 @@ "utopia-php/platform": "^1.0@RC", "utopia-php/di": "0.3.*", "utopia-php/system": "^0.10.1", - "utopia-php/logger": "^0.7.1", "utopia-php/dsn": "^0.2.1", "utopia-php/cli": "^0.24", - "utopia-php/console": "^0.1.1" + "utopia-php/console": "^0.1.1", + "utopia-php/span": "^4.2" }, "autoload": { "psr-4": { @@ -38,6 +38,7 @@ "lint": "./vendor/bin/pint --test --config pint.json", "format": "./vendor/bin/pint --config pint.json", "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "test:unit": "./vendor/bin/phpunit --testsuite unit", "test:e2e": "./vendor/bin/phpunit --testsuite e2e" }, "config": { diff --git a/composer.lock b/composer.lock index db9194d..2a0ad4d 100644 --- a/composer.lock +++ b/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": "a8daef2fdd9916469a1aa4fba35dd900", + "content-hash": "f2b25f41315c95c4a2d04768ddb4383a", "packages": [ { "name": "brick/math", @@ -1921,6 +1921,63 @@ }, "time": "2026-06-20T09:45:06+00:00" }, + { + "name": "utopia-php/client", + "version": "0.2.3", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/client.git", + "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/client/zipball/377dc1f79441ac1584f25dba57904ee1cf0f626b", + "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b", + "shasum": "" + }, + "require": { + "php": ">=8.5", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "utopia-php/pools": "^1.0", + "utopia-php/psr7": "^0.2", + "utopia-php/span": "^1.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "swoole/ide-helper": "^6.0" + }, + "suggest": { + "ext-curl": "Required to use the cURL HTTP client adapter.", + "ext-simplexml": "Required to decode XML responses with Response::xml().", + "ext-swoole": "Required to use the Swoole coroutine HTTP client adapter." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight PSR-18 HTTP client with cURL and Swoole coroutine backends", + "keywords": [ + "client", + "curl", + "http", + "php", + "psr-18", + "swoole", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/client/issues", + "source": "https://github.com/utopia-php/client/tree/0.2.3" + }, + "time": "2026-07-13T15:29:09+00:00" + }, { "name": "utopia-php/compression", "version": "0.1.6", @@ -2251,60 +2308,6 @@ }, "time": "2026-06-20T09:45:06+00:00" }, - { - "name": "utopia-php/logger", - "version": "0.7.1", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/logger.git", - "reference": "b19cf23c2d58cfbc31d1571531b61ca6a93fd852" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/logger/zipball/b19cf23c2d58cfbc31d1571531b61ca6a93fd852", - "reference": "b19cf23c2d58cfbc31d1571531b61ca6a93fd852", - "shasum": "" - }, - "require": { - "php": ">=8.0" - }, - "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "1.9.x-dev", - "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.0.1" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Logger\\": "src/Logger" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Utopia Logger library is simple and lite library for logging information, such as errors or warnings. This library is aiming to be as simple and easy to learn and use.", - "keywords": [ - "appsignal", - "errors", - "framework", - "logger", - "logging", - "logs", - "php", - "raygun", - "sentry", - "upf", - "utopia", - "warnings" - ], - "support": { - "issues": "https://github.com/utopia-php/logger/issues", - "source": "https://github.com/utopia-php/logger/tree/0.7.1" - }, - "time": "2025-11-12T08:27:12+00:00" - }, { "name": "utopia-php/platform", "version": "1.0.0-rc11", @@ -2405,6 +2408,52 @@ }, "time": "2026-06-20T09:45:06+00:00" }, + { + "name": "utopia-php/psr7", + "version": "0.2.2", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/psr7.git", + "reference": "cf70bec9ae4cda33bba01c8f7f2194d32adf899d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/psr7/zipball/cf70bec9ae4cda33bba01c8f7f2194d32adf899d", + "reference": "cf70bec9ae4cda33bba01c8f7f2194d32adf899d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-simplexml": "Required to decode XML responses with Response::xml()." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Psr7\\": "src/Psr7/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PSR-7 HTTP message implementations and PSR-17 factories for Utopia", + "keywords": [ + "http", + "php", + "psr-17", + "psr-7", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/psr7/issues", + "source": "https://github.com/utopia-php/psr7/tree/0.2.2" + }, + "time": "2026-09-02T15:57:19+00:00" + }, { "name": "utopia-php/queue", "version": "0.21.2", @@ -2517,6 +2566,49 @@ }, "time": "2026-06-20T09:45:06+00:00" }, + { + "name": "utopia-php/span", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/span.git", + "reference": "8505a2331772bd84ca60333e01ce6b6d068ee1dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/span/zipball/8505a2331772bd84ca60333e01ce6b6d068ee1dc", + "reference": "8505a2331772bd84ca60333e01ce6b6d068ee1dc", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-client": "^1.0", + "utopia-php/client": "^0.2 || ^0.3 || ^0.4", + "utopia-php/psr7": "^0.1 || ^0.2" + }, + "require-dev": { + "swoole/ide-helper": "^5.0" + }, + "suggest": { + "ext-swoole": "Required for coroutine-based storage" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Span\\": "src/Span/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Simple span tracing library for PHP with coroutine support", + "support": { + "issues": "https://github.com/utopia-php/span/issues", + "source": "https://github.com/utopia-php/span/tree/4.2.0" + }, + "time": "2026-08-28T00:40:32+00:00" + }, { "name": "utopia-php/system", "version": "0.10.1", diff --git a/docker-compose.yml b/docker-compose.yml index c139065..a897f20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,6 @@ services: - GEO_ENV - GEO_SECRET - GEO_DBIP_PATH - - GEO_LOGGING_PROVIDER - GEO_LOGGING_CONFIG tests: diff --git a/phpunit.xml b/phpunit.xml index eccb13e..b975dbf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,6 +7,9 @@ testdox="true" > + + tests/Unit + tests/E2E diff --git a/src/Geo/Modules/Core/Http/Error.php b/src/Geo/Modules/Core/Http/Error.php index bb65fe5..f0229e6 100644 --- a/src/Geo/Modules/Core/Http/Error.php +++ b/src/Geo/Modules/Core/Http/Error.php @@ -3,13 +3,11 @@ namespace Appwrite\Geo\Modules\Core\Http; use Throwable; -use Utopia\Console; use Utopia\Http\Http; use Utopia\Http\Response; use Utopia\Http\Route; -use Utopia\Logger\Log; -use Utopia\Logger\Logger; use Utopia\Platform\Action; +use Utopia\Span\Span; use Utopia\System\System; class Error extends Action @@ -27,14 +25,24 @@ public function __construct() ->groups(['*']) ->inject('route') ->inject('error') - ->inject('logger') ->inject('response') - ->callback(fn ($route, $error, $logger, $response) => $this->action($route, $error, $logger, $response)); + ->callback(fn ($route, $error, $response) => $this->action($route, $error, $response)); } - public function action(?Route $route, Throwable $error, ?Logger $logger, Response $response): void + public function action(?Route $route, Throwable $error, Response $response): void { - $this->logError(new Log(), $error, "httpError", $logger, $route); + $span = Span::current() ?? Span::init('http.request'); + $span->setError($error); + + // Client errors stay on the span but are not exported + $span->set('error.publish', $error->getCode() >= 500 || $error->getCode() === 0); + $span->set('error.type', \get_class($error)); + $span->set('error.code', $error->getCode()); + + if ($route) { + $span->set('http.method', $route->getMethod()); + $span->set('http.path', $route->getPath()); + } $version = System::getEnv('GEO_VERSION', 'UNKNOWN'); $message = $error->getMessage(); @@ -81,54 +89,7 @@ public function action(?Route $route, Throwable $error, ?Logger $logger, Respons ->setStatusCode($code); $response->json($output); - } - - protected function logError(Log $log, Throwable $error, string $action, ?Logger $logger = null, ?Route $route = null): void - { - $code = $error->getCode(); - $isServerError = $code >= 500 || $code === 0; - - if ($isServerError) { - Console::error('[Error] Type: ' . get_class($error)); - Console::error('[Error] Message: ' . $error->getMessage()); - Console::error('[Error] File: ' . $error->getFile()); - Console::error('[Error] Line: ' . $error->getLine()); - } - if ($logger && $isServerError) { - $version = (string) System::getEnv('GEO_VERSION', ''); - if (empty($version)) { - $version = 'UNKNOWN'; - } - - $log->setNamespace("geo"); - $log->setServer(\gethostname() !== false ? \gethostname() : null); - $log->setVersion($version); - $log->setType(Log::TYPE_ERROR); - $log->setMessage($error->getMessage()); - - if ($route) { - $log->addTag('method', $route->getMethod()); - $log->addTag('url', $route->getPath()); - } - - $log->addTag('code', \strval($error->getCode())); - $log->addTag('verboseType', get_class($error)); - - $log->addExtra('file', $error->getFile()); - $log->addExtra('line', $error->getLine()); - $log->addExtra('trace', $error->getTraceAsString()); - - $log->setAction($action); - - $log->setEnvironment(Http::isProduction() ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); - try { - $responseCode = $logger->addLog($log); - Console::info('Geo log pushed with status code: ' . $responseCode); - } catch (Throwable $th) { - Console::error('Error pushing log: ' . $th->getMessage()); - Console::error($th->getTraceAsString()); - } - } + $span->finish(); } } diff --git a/src/Geo/Server/Server.php b/src/Geo/Server/Server.php index a299c34..8d346ea 100644 --- a/src/Geo/Server/Server.php +++ b/src/Geo/Server/Server.php @@ -4,19 +4,21 @@ use Appwrite\Geo\Platform\Geo; use Exception; +use InvalidArgumentException; +use MaxMind\Db\Reader; +use Throwable; use Utopia\Console; use Utopia\DI\Container; use Utopia\DSN\DSN; use Utopia\Http\Adapter\Swoole\Server as SwooleServer; use Utopia\Http\Http; -use MaxMind\Db\Reader; -use Throwable; -use Utopia\Logger\Adapter\AppSignal; -use Utopia\Logger\Adapter\LogOwl; -use Utopia\Logger\Adapter\Raygun; -use Utopia\Logger\Adapter\Sentry; -use Utopia\Logger\Logger; +use Utopia\Http\Request; use Utopia\Platform\Service; +use Utopia\Span\Exporter\Sentry; +use Utopia\Span\Exporter\SentryField; +use Utopia\Span\Exporter\Stdout; +use Utopia\Span\Span; +use Utopia\Span\Storage\Coroutine; use Utopia\System\System; class Server @@ -31,6 +33,7 @@ public function __construct(?Http $http = null) Http::setMode(System::getEnv('GEO_ENV', Http::MODE_TYPE_PRODUCTION)); + $this->initSpan(); $this->initResources(); $http ??= new Http( @@ -56,6 +59,17 @@ protected function initHooks(): void $onStart->action(function () { Console::log('Server started'); }); + + Http::onRequest() + ->inject('request') + ->action(function (Request $request) { + Span::init('http.request'); + Span::add('http.method', $request->getMethod()); + }); + + Http::shutdown() + ->groups(['*']) + ->action(fn () => Span::current()?->finish()); } protected function initResources(): void @@ -68,46 +82,42 @@ protected function initResources(): void } return new Reader($path); }); + } - $this->resources->set('logger', function () { - $providerName = System::getEnv('GEO_LOGGING_PROVIDER', ''); - $providerConfig = System::getEnv('GEO_LOGGING_CONFIG', ''); - - try { - $loggingProvider = new DSN($providerConfig); - - $providerName = $loggingProvider->getScheme(); - $providerConfig = match ($providerName) { - 'sentry' => ['key' => $loggingProvider->getPassword(), 'projectId' => $loggingProvider->getUser() ?? '', 'host' => 'https://' . $loggingProvider->getHost()], - 'logowl' => ['ticket' => $loggingProvider->getUser() ?? '', 'host' => $loggingProvider->getHost()], - default => ['key' => $loggingProvider->getHost()], - }; - } catch (Throwable) { - $configChunks = \explode(";", $providerConfig); - - $providerConfig = match ($providerName) { - 'sentry' => ['key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => ''], - 'logowl' => ['ticket' => $configChunks[0], 'host' => ''], - default => ['key' => $providerConfig], - }; - } + protected function initSpan(): void + { + Span::setStorage(new Coroutine()); - $logger = null; + // Server failures only, as the error handler printed before spans + $sampler = static fn (Span $span): bool => $span->getError() !== null && $span->get('error.publish') !== false; - if (!empty($providerName) && Logger::hasProvider($providerName)) { - $adapter = match ($providerName) { - 'sentry' => new Sentry($providerConfig['projectId'] ?? '', $providerConfig['key'] ?? '', $providerConfig['host'] ?? ''), - 'logowl' => new LogOwl($providerConfig['ticket'] ?? '', $providerConfig['host'] ?? ''), - 'raygun' => new Raygun($providerConfig['key'] ?? ''), - 'appsignal' => new AppSignal($providerConfig['key'] ?? ''), - default => throw new Exception('Provider "' . $providerName . '" not supported.') - }; + $exporters = [new Stdout(sampler: $sampler)]; - $logger = new Logger($adapter); + // GEO_LOGGING_CONFIG: a sentry://PROJECT_ID:KEY@HOST DSN reports server errors to Sentry + $config = System::getEnv('GEO_LOGGING_CONFIG', ''); + if (!empty($config)) { + try { + $dsn = new DSN($config); + if ($dsn->getScheme() !== 'sentry') { + throw new InvalidArgumentException('Only the sentry:// scheme is supported'); + } + + $tags = ['http.method', 'http.path', 'error.type', 'error.code']; + $version = System::getEnv('GEO_VERSION', ''); + $exporters[] = new Sentry( + sampler: static fn (Span $span): bool => $span->get('error.publish') !== false, + dsn: 'https://' . $dsn->getPassword() . '@' . $dsn->getHost() . '/' . $dsn->getUser(), + environment: Http::isProduction() ? 'production' : 'staging', + release: empty($version) ? 'UNKNOWN' : $version, + serverName: \gethostname() ?: null, + classifier: static fn (string $key): SentryField => \in_array($key, $tags, true) ? SentryField::Tag : SentryField::Context, + ); + } catch (Throwable $error) { + Console::error('Invalid GEO_LOGGING_CONFIG, error reporting is disabled: ' . $error->getMessage()); } + } - return $logger; - }); + Span::setExporters(...$exporters); } protected function initPlatform(): void diff --git a/tests/Unit/Modules/Core/Http/ErrorTest.php b/tests/Unit/Modules/Core/Http/ErrorTest.php new file mode 100644 index 0000000..bf623f3 --- /dev/null +++ b/tests/Unit/Modules/Core/Http/ErrorTest.php @@ -0,0 +1,125 @@ + + */ + private array $exported = []; + + protected function setUp(): void + { + $this->exported = []; + + $exporter = new class ($this->exported) implements Exporter { + /** + * @param array $exported + */ + public function __construct(private array &$exported) + { + } + + public function export(Span $span): void + { + $this->exported[] = $span; + } + + public function sample(Span $span): bool + { + return true; + } + }; + + Span::setStorage(new Memory()); + Span::setExporters($exporter); + } + + protected function tearDown(): void + { + Span::setExporters(); + Span::setStorage(null); + } + + public function testServerErrorIsExportedWithItsThrowable(): void + { + $error = new Exception('GeoIP database file not found or not readable: /missing.mmdb'); + $response = $this->response(); + + Span::init('http.request'); + (new Error())->action(new Route('GET', '/v1/ips/:ip'), $error, $response); + + $this->assertCount(1, $this->exported); + $span = $this->exported[0]; + $this->assertSame($error, $span->getError()); + $this->assertSame('error', $span->get('level')); + $this->assertTrue($span->get('error.publish')); + $this->assertSame(Exception::class, $span->get('error.type')); + $this->assertSame(0, $span->get('error.code')); + $this->assertSame('GET', $span->get('http.method')); + $this->assertSame('/v1/ips/:ip', $span->get('http.path')); + $this->assertNull(Span::current()); + $this->assertSame(500, $response->getStatusCode()); + } + + public function testClientErrorIsMarkedUnpublished(): void + { + $error = new Exception('Invalid Geo server key', 401); + $response = $this->response(); + + Span::init('http.request'); + (new Error())->action(new Route('GET', '/v1/ips/:ip'), $error, $response); + + $this->assertCount(1, $this->exported); + $this->assertSame($error, $this->exported[0]->getError()); + $this->assertFalse($this->exported[0]->get('error.publish')); + $this->assertSame(401, $response->getStatusCode()); + } + + public function testErrorWithoutRequestSpanIsStillExported(): void + { + $error = new Exception('Not Found', 404); + + (new Error())->action(null, $error, $this->response()); + + $this->assertCount(1, $this->exported); + $this->assertSame($error, $this->exported[0]->getError()); + $this->assertNull($this->exported[0]->get('http.path')); + } + + private function response(): Response + { + return new class () extends Response { + public function write(string $content): bool + { + return true; + } + + public function end(?string $content = null): void + { + } + + protected function sendStatus(int $statusCode): void + { + } + + public function sendHeader(string $key, array $value): void + { + } + + protected function sendCookie(string $name, string $value, array $options): void + { + } + }; + } +} From 39e9d185dff0059f479fd19cd3faf0105db596cd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 17:34:06 +0100 Subject: [PATCH 3/6] test: read E2E response headers without the deprecated $http_response_header PHP 8.5 deprecates the implicit $http_response_header local, which made the E2E suite report two deprecations on every run. Use http_get_last_response_headers() instead. --- tests/E2E/Base.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/E2E/Base.php b/tests/E2E/Base.php index c3f94be..7423f00 100644 --- a/tests/E2E/Base.php +++ b/tests/E2E/Base.php @@ -41,13 +41,14 @@ protected function request(string $method, string $path, array $headers = []): a $body = \file_get_contents($url, false, $context); $statusCode = 0; $responseHeaders = []; + $headerLines = \http_get_last_response_headers(); - if (isset($http_response_header)) { + if (!empty($headerLines)) { // First line is status, e.g. "HTTP/1.1 200 OK" - if (\preg_match('/HTTP\/\S+\s+(\d+)/', $http_response_header[0], $matches)) { + if (\preg_match('/HTTP\/\S+\s+(\d+)/', $headerLines[0], $matches)) { $statusCode = (int) $matches[1]; } - foreach ($http_response_header as $header) { + foreach ($headerLines as $header) { if (\str_contains($header, ':')) { [$key, $value] = \explode(':', $header, 2); $responseHeaders[\strtolower(\trim($key))] = \trim($value); From 52cb4d217da476e2bed9f0ae8a7a8546391b4a7c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 19:55:27 +0100 Subject: [PATCH 4/6] test: cover the exporters the server configures at boot The error action tests used an exporter that sampled everything, so they stayed green if the production filter was removed. ServerTest boots Server with an FPM adapter and a recording PSR-18 client for span's Sentry exporter, runs requests through the real hooks inside a coroutine, and asserts a 500 is delivered while a 401, a 404 and a 200 are not. Server takes an optional client (passed to the Sentry exporter) and reuses the injected Http's resources container, so the test drives the same boot path. Stdout and Sentry now share one sampler; Stdout writes to the STDERR constant, which a test cannot capture in-process, so the Sentry delivery stands in for both. --- .phpunit.result.cache | 2 +- composer.json | 3 +- composer.lock | 2 +- src/Geo/Server/Server.php | 17 ++- tests/Unit/Modules/Core/Http/ErrorTest.php | 25 +--- tests/Unit/Response.php | 32 +++++ tests/Unit/Server/ServerTest.php | 130 +++++++++++++++++++++ 7 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 tests/Unit/Response.php create mode 100644 tests/Unit/Server/ServerTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 8c535e4..232abe3 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":7},"times":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":0.008,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":0.004,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":0.007}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":7,"Tests\\Unit\\Server\\ServerTest::testClientErrorIsNotDelivered":7,"Tests\\Unit\\Server\\ServerTest::testNotFoundIsNotDelivered":7},"times":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":0.003,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":0.001,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":0,"Tests\\Unit\\Server\\ServerTest::testServerErrorIsDeliveredToSentry":0.004,"Tests\\Unit\\Server\\ServerTest::testClientErrorIsNotDelivered":0.001,"Tests\\Unit\\Server\\ServerTest::testNotFoundIsNotDelivered":0.001,"Tests\\Unit\\Server\\ServerTest::testSuccessfulRequestIsNotDelivered":0}} \ No newline at end of file diff --git a/composer.json b/composer.json index 33f916a..8d84dd8 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "utopia-php/dsn": "^0.2.1", "utopia-php/cli": "^0.24", "utopia-php/console": "^0.1.1", - "utopia-php/span": "^4.2" + "utopia-php/span": "^4.2", + "psr/http-client": "^1.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 2a0ad4d..709486c 100644 --- a/composer.lock +++ b/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": "f2b25f41315c95c4a2d04768ddb4383a", + "content-hash": "7f709cb4a63cfafde02be3d05dbf9a63", "packages": [ { "name": "brick/math", diff --git a/src/Geo/Server/Server.php b/src/Geo/Server/Server.php index 8d346ea..e1488c0 100644 --- a/src/Geo/Server/Server.php +++ b/src/Geo/Server/Server.php @@ -6,6 +6,7 @@ use Exception; use InvalidArgumentException; use MaxMind\Db\Reader; +use Psr\Http\Client\ClientInterface; use Throwable; use Utopia\Console; use Utopia\DI\Container; @@ -27,13 +28,16 @@ class Server protected Container $resources; - public function __construct(?Http $http = null) + /** + * @param ClientInterface|null $client Transport for the Sentry exporter; defaults to span's cURL client + */ + public function __construct(?Http $http = null, ?ClientInterface $client = null) { - $this->resources = new Container(); + $this->resources = $http?->resources() ?? new Container(); Http::setMode(System::getEnv('GEO_ENV', Http::MODE_TYPE_PRODUCTION)); - $this->initSpan(); + $this->initSpan($client); $this->initResources(); $http ??= new Http( @@ -84,11 +88,11 @@ protected function initResources(): void }); } - protected function initSpan(): void + protected function initSpan(?ClientInterface $client): void { Span::setStorage(new Coroutine()); - // Server failures only, as the error handler printed before spans + // Only server failures leave the process: the error action marks client errors error.publish=false $sampler = static fn (Span $span): bool => $span->getError() !== null && $span->get('error.publish') !== false; $exporters = [new Stdout(sampler: $sampler)]; @@ -105,12 +109,13 @@ protected function initSpan(): void $tags = ['http.method', 'http.path', 'error.type', 'error.code']; $version = System::getEnv('GEO_VERSION', ''); $exporters[] = new Sentry( - sampler: static fn (Span $span): bool => $span->get('error.publish') !== false, + sampler: $sampler, dsn: 'https://' . $dsn->getPassword() . '@' . $dsn->getHost() . '/' . $dsn->getUser(), environment: Http::isProduction() ? 'production' : 'staging', release: empty($version) ? 'UNKNOWN' : $version, serverName: \gethostname() ?: null, classifier: static fn (string $key): SentryField => \in_array($key, $tags, true) ? SentryField::Tag : SentryField::Context, + client: $client, ); } catch (Throwable $error) { Console::error('Invalid GEO_LOGGING_CONFIG, error reporting is disabled: ' . $error->getMessage()); diff --git a/tests/Unit/Modules/Core/Http/ErrorTest.php b/tests/Unit/Modules/Core/Http/ErrorTest.php index bf623f3..f172490 100644 --- a/tests/Unit/Modules/Core/Http/ErrorTest.php +++ b/tests/Unit/Modules/Core/Http/ErrorTest.php @@ -5,7 +5,7 @@ use Appwrite\Geo\Modules\Core\Http\Error; use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Http\Response; +use Tests\Unit\Response; use Utopia\Http\Route; use Utopia\Span\Exporter\Exporter; use Utopia\Span\Span; @@ -99,27 +99,6 @@ public function testErrorWithoutRequestSpanIsStillExported(): void private function response(): Response { - return new class () extends Response { - public function write(string $content): bool - { - return true; - } - - public function end(?string $content = null): void - { - } - - protected function sendStatus(int $statusCode): void - { - } - - public function sendHeader(string $key, array $value): void - { - } - - protected function sendCookie(string $name, string $value, array $options): void - { - } - }; + return new Response(); } } diff --git a/tests/Unit/Response.php b/tests/Unit/Response.php new file mode 100644 index 0000000..0c7bda5 --- /dev/null +++ b/tests/Unit/Response.php @@ -0,0 +1,32 @@ + + */ + private array $delivered = []; + + private Http $http; + + protected function setUp(): void + { + Http::reset(); + \putenv('GEO_SECRET=' . self::SECRET); + \putenv('GEO_DBIP_PATH=/missing.mmdb'); + \putenv('GEO_LOGGING_CONFIG=sentry://123:public-key@sentry.example.com'); + + $this->delivered = []; + $client = new class ($this->delivered) implements ClientInterface { + /** + * @param array $delivered + */ + public function __construct(private array &$delivered) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->delivered[] = $request; + return new Psr7Response(200); + } + }; + + $this->http = new Http(new FPMServer(new Container()), 'UTC'); + new Server($this->http, $client); + } + + protected function tearDown(): void + { + Http::reset(); + Span::setExporters(); + Span::setStorage(null); + \putenv('GEO_SECRET'); + \putenv('GEO_DBIP_PATH'); + \putenv('GEO_LOGGING_CONFIG'); + } + + public function testServerErrorIsDeliveredToSentry(): void + { + $status = $this->request('/v1/ips/1.1.1.1', 'Bearer ' . self::SECRET); + + $this->assertSame(500, $status); + $this->assertCount(1, $this->delivered); + $this->assertSame('https://sentry.example.com/api/123/envelope/', (string) $this->delivered[0]->getUri()); + + $lines = \explode("\n", (string) $this->delivered[0]->getBody()); + $event = \json_decode($lines[2], true); + $this->assertIsArray($event); + $this->assertSame('GeoIP database file not found or not readable: /missing.mmdb', $event['message']); + $this->assertSame('Exception', $event['exception']['values'][0]['type']); + $this->assertSame('/v1/ips/:ip', $event['tags']['http.path']); + } + + public function testClientErrorIsNotDelivered(): void + { + $status = $this->request('/v1/ips/1.1.1.1', 'Bearer wrong-secret'); + + $this->assertSame(401, $status); + $this->assertSame([], $this->delivered); + } + + public function testNotFoundIsNotDelivered(): void + { + $status = $this->request('/v1/missing'); + + $this->assertSame(404, $status); + $this->assertSame([], $this->delivered); + } + + public function testSuccessfulRequestIsNotDelivered(): void + { + $status = $this->request('/v1/health'); + + $this->assertSame(200, $status); + $this->assertSame([], $this->delivered); + } + + /** + * Run one request through the server's hooks inside a coroutine, as Swoole does. + */ + private function request(string $uri, ?string $authorization = null): int + { + $response = new Response(); + + run(function () use ($uri, $authorization, $response) { + $request = (new Request())->setMethod('GET')->setURI($uri); + if ($authorization !== null) { + $request->setHeader('authorization', $authorization); + } + + $this->http->run($request, $response); + }); + + return $response->getStatusCode(); + } +} From ee628ddddde4b64e11f80bcfeeee1f3705705513 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 19:55:42 +0100 Subject: [PATCH 5/6] chore: stop tracking the PHPUnit result cache The refactor commit picked up .phpunit.result.cache from a local unit-test run. It is per-machine state, so remove it and ignore it. --- .gitignore | 1 + .phpunit.result.cache | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .phpunit.result.cache diff --git a/.gitignore b/.gitignore index 587012d..3b3f6da 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ composer.phar # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file # composer.lock test.http +.phpunit.result.cache diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index 232abe3..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"defects":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":7,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":7,"Tests\\Unit\\Server\\ServerTest::testClientErrorIsNotDelivered":7,"Tests\\Unit\\Server\\ServerTest::testNotFoundIsNotDelivered":7},"times":{"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testServerErrorIsExportedWithItsThrowable":0.003,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testClientErrorIsMarkedUnpublished":0.001,"Tests\\Unit\\Modules\\Core\\Http\\ErrorTest::testErrorWithoutRequestSpanIsStillExported":0,"Tests\\Unit\\Server\\ServerTest::testServerErrorIsDeliveredToSentry":0.004,"Tests\\Unit\\Server\\ServerTest::testClientErrorIsNotDelivered":0.001,"Tests\\Unit\\Server\\ServerTest::testNotFoundIsNotDelivered":0.001,"Tests\\Unit\\Server\\ServerTest::testSuccessfulRequestIsNotDelivered":0}} \ No newline at end of file From 38608863dae3653a47072046e1f436e5ebba83b9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 09:19:12 +0100 Subject: [PATCH 6/6] test: drop the unit suite Keep this PR to the logger-to-span move and the E2E suite the repository already runs. The unit tests needed two seams in Server that production never uses (an injectable PSR-18 client for the Sentry exporter and reusing a passed Http's resources), so those and the direct psr/http-client requirement go with them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 3 - composer.json | 4 +- composer.lock | 2 +- phpunit.xml | 3 - src/Geo/Server/Server.php | 13 +-- tests/Unit/Modules/Core/Http/ErrorTest.php | 104 ----------------- tests/Unit/Response.php | 32 ----- tests/Unit/Server/ServerTest.php | 130 --------------------- 8 files changed, 6 insertions(+), 285 deletions(-) delete mode 100644 tests/Unit/Modules/Core/Http/ErrorTest.php delete mode 100644 tests/Unit/Response.php delete mode 100644 tests/Unit/Server/ServerTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d1285f5..e812d06 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,9 +33,6 @@ jobs: docker compose logs geo exit 1 - - name: Run unit tests - run: docker compose run --rm --entrypoint vendor/bin/phpunit tests --testsuite unit - - name: Run E2E tests run: docker compose run --rm tests diff --git a/composer.json b/composer.json index 8d84dd8..3da13fd 100644 --- a/composer.json +++ b/composer.json @@ -11,8 +11,7 @@ "utopia-php/dsn": "^0.2.1", "utopia-php/cli": "^0.24", "utopia-php/console": "^0.1.1", - "utopia-php/span": "^4.2", - "psr/http-client": "^1.0" + "utopia-php/span": "^4.2" }, "autoload": { "psr-4": { @@ -39,7 +38,6 @@ "lint": "./vendor/bin/pint --test --config pint.json", "format": "./vendor/bin/pint --config pint.json", "check": "./vendor/bin/phpstan analyse -c phpstan.neon", - "test:unit": "./vendor/bin/phpunit --testsuite unit", "test:e2e": "./vendor/bin/phpunit --testsuite e2e" }, "config": { diff --git a/composer.lock b/composer.lock index 709486c..2a0ad4d 100644 --- a/composer.lock +++ b/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": "7f709cb4a63cfafde02be3d05dbf9a63", + "content-hash": "f2b25f41315c95c4a2d04768ddb4383a", "packages": [ { "name": "brick/math", diff --git a/phpunit.xml b/phpunit.xml index b975dbf..eccb13e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,9 +7,6 @@ testdox="true" > - - tests/Unit - tests/E2E diff --git a/src/Geo/Server/Server.php b/src/Geo/Server/Server.php index e1488c0..0929305 100644 --- a/src/Geo/Server/Server.php +++ b/src/Geo/Server/Server.php @@ -6,7 +6,6 @@ use Exception; use InvalidArgumentException; use MaxMind\Db\Reader; -use Psr\Http\Client\ClientInterface; use Throwable; use Utopia\Console; use Utopia\DI\Container; @@ -28,16 +27,13 @@ class Server protected Container $resources; - /** - * @param ClientInterface|null $client Transport for the Sentry exporter; defaults to span's cURL client - */ - public function __construct(?Http $http = null, ?ClientInterface $client = null) + public function __construct(?Http $http = null) { - $this->resources = $http?->resources() ?? new Container(); + $this->resources = new Container(); Http::setMode(System::getEnv('GEO_ENV', Http::MODE_TYPE_PRODUCTION)); - $this->initSpan($client); + $this->initSpan(); $this->initResources(); $http ??= new Http( @@ -88,7 +84,7 @@ protected function initResources(): void }); } - protected function initSpan(?ClientInterface $client): void + protected function initSpan(): void { Span::setStorage(new Coroutine()); @@ -115,7 +111,6 @@ protected function initSpan(?ClientInterface $client): void release: empty($version) ? 'UNKNOWN' : $version, serverName: \gethostname() ?: null, classifier: static fn (string $key): SentryField => \in_array($key, $tags, true) ? SentryField::Tag : SentryField::Context, - client: $client, ); } catch (Throwable $error) { Console::error('Invalid GEO_LOGGING_CONFIG, error reporting is disabled: ' . $error->getMessage()); diff --git a/tests/Unit/Modules/Core/Http/ErrorTest.php b/tests/Unit/Modules/Core/Http/ErrorTest.php deleted file mode 100644 index f172490..0000000 --- a/tests/Unit/Modules/Core/Http/ErrorTest.php +++ /dev/null @@ -1,104 +0,0 @@ - - */ - private array $exported = []; - - protected function setUp(): void - { - $this->exported = []; - - $exporter = new class ($this->exported) implements Exporter { - /** - * @param array $exported - */ - public function __construct(private array &$exported) - { - } - - public function export(Span $span): void - { - $this->exported[] = $span; - } - - public function sample(Span $span): bool - { - return true; - } - }; - - Span::setStorage(new Memory()); - Span::setExporters($exporter); - } - - protected function tearDown(): void - { - Span::setExporters(); - Span::setStorage(null); - } - - public function testServerErrorIsExportedWithItsThrowable(): void - { - $error = new Exception('GeoIP database file not found or not readable: /missing.mmdb'); - $response = $this->response(); - - Span::init('http.request'); - (new Error())->action(new Route('GET', '/v1/ips/:ip'), $error, $response); - - $this->assertCount(1, $this->exported); - $span = $this->exported[0]; - $this->assertSame($error, $span->getError()); - $this->assertSame('error', $span->get('level')); - $this->assertTrue($span->get('error.publish')); - $this->assertSame(Exception::class, $span->get('error.type')); - $this->assertSame(0, $span->get('error.code')); - $this->assertSame('GET', $span->get('http.method')); - $this->assertSame('/v1/ips/:ip', $span->get('http.path')); - $this->assertNull(Span::current()); - $this->assertSame(500, $response->getStatusCode()); - } - - public function testClientErrorIsMarkedUnpublished(): void - { - $error = new Exception('Invalid Geo server key', 401); - $response = $this->response(); - - Span::init('http.request'); - (new Error())->action(new Route('GET', '/v1/ips/:ip'), $error, $response); - - $this->assertCount(1, $this->exported); - $this->assertSame($error, $this->exported[0]->getError()); - $this->assertFalse($this->exported[0]->get('error.publish')); - $this->assertSame(401, $response->getStatusCode()); - } - - public function testErrorWithoutRequestSpanIsStillExported(): void - { - $error = new Exception('Not Found', 404); - - (new Error())->action(null, $error, $this->response()); - - $this->assertCount(1, $this->exported); - $this->assertSame($error, $this->exported[0]->getError()); - $this->assertNull($this->exported[0]->get('http.path')); - } - - private function response(): Response - { - return new Response(); - } -} diff --git a/tests/Unit/Response.php b/tests/Unit/Response.php deleted file mode 100644 index 0c7bda5..0000000 --- a/tests/Unit/Response.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ - private array $delivered = []; - - private Http $http; - - protected function setUp(): void - { - Http::reset(); - \putenv('GEO_SECRET=' . self::SECRET); - \putenv('GEO_DBIP_PATH=/missing.mmdb'); - \putenv('GEO_LOGGING_CONFIG=sentry://123:public-key@sentry.example.com'); - - $this->delivered = []; - $client = new class ($this->delivered) implements ClientInterface { - /** - * @param array $delivered - */ - public function __construct(private array &$delivered) - { - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - $this->delivered[] = $request; - return new Psr7Response(200); - } - }; - - $this->http = new Http(new FPMServer(new Container()), 'UTC'); - new Server($this->http, $client); - } - - protected function tearDown(): void - { - Http::reset(); - Span::setExporters(); - Span::setStorage(null); - \putenv('GEO_SECRET'); - \putenv('GEO_DBIP_PATH'); - \putenv('GEO_LOGGING_CONFIG'); - } - - public function testServerErrorIsDeliveredToSentry(): void - { - $status = $this->request('/v1/ips/1.1.1.1', 'Bearer ' . self::SECRET); - - $this->assertSame(500, $status); - $this->assertCount(1, $this->delivered); - $this->assertSame('https://sentry.example.com/api/123/envelope/', (string) $this->delivered[0]->getUri()); - - $lines = \explode("\n", (string) $this->delivered[0]->getBody()); - $event = \json_decode($lines[2], true); - $this->assertIsArray($event); - $this->assertSame('GeoIP database file not found or not readable: /missing.mmdb', $event['message']); - $this->assertSame('Exception', $event['exception']['values'][0]['type']); - $this->assertSame('/v1/ips/:ip', $event['tags']['http.path']); - } - - public function testClientErrorIsNotDelivered(): void - { - $status = $this->request('/v1/ips/1.1.1.1', 'Bearer wrong-secret'); - - $this->assertSame(401, $status); - $this->assertSame([], $this->delivered); - } - - public function testNotFoundIsNotDelivered(): void - { - $status = $this->request('/v1/missing'); - - $this->assertSame(404, $status); - $this->assertSame([], $this->delivered); - } - - public function testSuccessfulRequestIsNotDelivered(): void - { - $status = $this->request('/v1/health'); - - $this->assertSame(200, $status); - $this->assertSame([], $this->delivered); - } - - /** - * Run one request through the server's hooks inside a coroutine, as Swoole does. - */ - private function request(string $uri, ?string $authorization = null): int - { - $response = new Response(); - - run(function () use ($uri, $authorization, $response) { - $request = (new Request())->setMethod('GET')->setURI($uri); - if ($authorization !== null) { - $request->setHeader('authorization', $authorization); - } - - $this->http->run($request, $response); - }); - - return $response->getStatusCode(); - } -}