From 4745a1a2cb62352631ec520141e2f277d6e3e344 Mon Sep 17 00:00:00 2001 From: Daniel Morell Date: Fri, 17 Jan 2025 08:27:37 -0600 Subject: [PATCH 1/5] Added initial telemetry support. --- composer.json | 2 +- src/RollbarServiceProvider.php | 126 ++++++++++++++++++++------- src/TelemetryListener.php | 151 +++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 34 deletions(-) create mode 100644 src/TelemetryListener.php diff --git a/composer.json b/composer.json index 242b961..ab03398 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "require": { "php": "^8.1", "illuminate/support": "^10.0|^11.0", - "rollbar/rollbar": "^4.0" + "rollbar/rollbar": "v4.1.0-rc" }, "require-dev": { "orchestra/testbench": "^8.0", diff --git a/src/RollbarServiceProvider.php b/src/RollbarServiceProvider.php index df81520..7981038 100644 --- a/src/RollbarServiceProvider.php +++ b/src/RollbarServiceProvider.php @@ -1,5 +1,7 @@ app->singleton(RollbarLogger::class, function (Application $app) { + $config = $this->getConfigs($app); - $defaults = [ - 'environment' => $app->environment(), - 'root' => base_path(), - 'handle_exception' => true, - 'handle_error' => true, - 'handle_fatal' => true, - ]; - - $config = array_merge($defaults, $app['config']->get('logging.channels.rollbar', [])); - - $config['access_token'] = static::config('access_token'); - - if (empty($config['access_token'])) { - throw new InvalidArgumentException('Rollbar access token not configured'); - } - - $handleException = (bool) Arr::pull($config, 'handle_exception'); - $handleError = (bool) Arr::pull($config, 'handle_error'); - $handleFatal = (bool) Arr::pull($config, 'handle_fatal'); - - // Convert a request for the Rollbar agent to handle the logs to - // the format expected by `Rollbar::init`. - // @see https://github.com/rollbar/rollbar-php-laravel/issues/85 - $handler = Arr::get($config, 'handler'); - if ($handler === AgentHandler::class) { - $config['handler'] = 'agent'; - } - $config['framework'] = 'laravel ' . app()->version(); + $handleException = (bool)Arr::pull($config, 'handle_exception'); + $handleError = (bool)Arr::pull($config, 'handle_error'); + $handleFatal = (bool)Arr::pull($config, 'handle_fatal'); Rollbar::init($config, $handleException, $handleError, $handleFatal); return Rollbar::logger(); @@ -58,7 +41,7 @@ public function register(): void $this->app->singleton(MonologHandler::class, function (Application $app) { $level = static::config('level', 'debug'); - + $handler = new MonologHandler($app[RollbarLogger::class], $level); $handler->setApp($app); @@ -66,12 +49,31 @@ public function register(): void }); } + /** + * Boot is called after all services are registered. + * + * This is where we can start listening for events. + * + * @param RollbarLogger $logger This parameter is injected by the service container, and is required to ensure that + * the Rollbar logger is initialized. + * @return void + * + * @since 8.1.0 + */ + public function boot(RollbarLogger $logger): void + { + // Set up telemetry if it is enabled. + if (null !== Rollbar::getTelemeter()) { + $this->setupTelemetry($this->getConfigs($this->app)); + } + } + /** * Check if we should prevent the service from registering. * * @return boolean */ - public function stop() : bool + public function stop(): bool { $level = static::config('level'); @@ -85,8 +87,8 @@ public function stop() : bool /** * Return a rollbar logging config. * - * @param string $key The config key to lookup. - * @param mixed $default The default value to return if the config is not found. + * @param string $key The config key to lookup. + * @param mixed $default The default value to return if the config is not found. * * @return mixed */ @@ -98,8 +100,66 @@ protected static function config(string $key = '', mixed $default = null): mixed $envKey = 'ROLLBAR_TOKEN'; } - $logKey = empty($key) ? 'logging.channels.rollbar' : "logging.channels.rollbar.$key"; + $logKey = empty($key) ? 'logging.channels.rollbar' : 'logging.channels.rollbar.' . $key; return getenv($envKey) ?: Config::get($logKey, $default); } + + /** + * Returns the Rollbar configuration. + * + * @param Application $app The Laravel application. + * @return array + * + * @since 8.1.0 + * + * @throw InvalidArgumentException If the Rollbar access token is not configured. + */ + public function getConfigs(Application $app): array + { + $defaults = [ + 'environment' => $app->environment(), + 'root' => base_path(), + 'handle_exception' => true, + 'handle_error' => true, + 'handle_fatal' => true, + ]; + + $config = array_merge($defaults, $app['config']->get('logging.channels.rollbar', [])); + $config['access_token'] = static::config('access_token'); + + if (empty($config['access_token'])) { + throw new InvalidArgumentException('Rollbar access token not configured'); + } + // Convert a request for the Rollbar agent to handle the logs to + // the format expected by `Rollbar::init`. + // @see https://github.com/rollbar/rollbar-php-laravel/issues/85 + $handler = Arr::get($config, 'handler', MonologHandler::class); + if ($handler === AgentHandler::class) { + $config['handler'] = 'agent'; + } + $config['framework'] = 'laravel ' . $app->version(); + return $config; + } + + /** + * Sets up the telemetry event listeners. + * + * @param array $config + * @return void + * + * @since 8.1.0 + */ + protected function setupTelemetry(array $config): void + { + $this->telemetryListener = new TelemetryListener($this->app, $config); + + try { + $dispatcher = $this->app->make(Dispatcher::class); + } catch (BindingResolutionException $e) { + return; + } + + $this->telemetryListener->listen($dispatcher); + } } diff --git a/src/TelemetryListener.php b/src/TelemetryListener.php new file mode 100644 index 0000000..af815bf --- /dev/null +++ b/src/TelemetryListener.php @@ -0,0 +1,151 @@ + 'logMessageHandler', + RouteMatched::class => 'routeMatchedHandler', + QueryExecuted::class => 'queryExecutedHandler', + ]; + + private Container $container; + + private array $config; + + /** + * @var bool + */ + private bool $captureLogs; + private bool $captureRouting; + private bool $captureQueries; + private bool $captureDbParameters; + + /** + * @param Container $container The Laravel application container. + * @param array $config + */ + public function __construct(Container $container, array $config) + { + $this->container = $container; + $this->config = $config; + + $this->captureLogs = boolval($this->config['telemetry']['capture_logs'] ?? true); + $this->captureRouting = boolval($this->config['telemetry']['capture_routing'] ?? true); + $this->captureQueries = boolval($this->config['telemetry']['capture_db_queries'] ?? true); + // We do not want to capture query parameters by default, the developer must explicitly enable it. + $this->captureDbParameters = boolval($this->config['telemetry']['capture_db_query_parameters'] ?? false); + } + + /** + * Register the event listeners for the application. + * + * @param Dispatcher $dispatcher + * @return void + */ + public function listen(Dispatcher $dispatcher): void + { + foreach (self::BASE_EVENTS as $event => $handler) { + $dispatcher->listen($event, [$this, $handler]); + } + } + + /** + * Execute the event handler. + * + * This is used so that the handlers are not public methods. + * + * @param string $method The method to call. + * @param array $args The arguments to pass to the method. + * @return void + */ + public function __call(string $method, array $args): void + { + if (!method_exists($this, $method)) { + return; + } + + try { + $this->{$method}(...$args); + } catch (Exception $e) { + // Do nothing. + } + } + + /** + * Handler for log messages. + * + * @param MessageLogged $message + * @return void + */ + protected function logMessageHandler(MessageLogged $message): void + { + if (null === $message->message || !$this->captureLogs) { + return; + } + + Rollbar::captureTelemetryEvent( + EventType::Log, + // Telemetry does not support all PSR-3 or RFC-5424 levels, so we need to convert them. + Telemeter::getLevelFromPsrLevel($message->level), + array_merge( + $message->context, + ['message' => $message->message], + ), + ); + } + + protected function routeMatchedHandler(RouteMatched $matchedRoute): void + { + if (!$this->captureRouting) { + return; + } + $routePath = $matchedRoute->route->uri(); + + Rollbar::captureTelemetryEvent( + EventType::Manual, + EventLevel::Info, + [ + 'message' => 'Route matched', + 'route' => $routePath, + ], + ); + } + + protected function queryExecutedHandler(QueryExecuted $query): void + { + if (!$this->captureQueries) { + return; + } + + $meta = [ + 'message' => 'Query executed', + 'query' => $query->sql, + 'time' => $query->time, + 'connection' => $query->connectionName, + ]; + + if ($this->captureDbParameters) { + $meta['bindings'] = $query->bindings; + } + + Rollbar::captureTelemetryEvent(EventType::Manual, EventLevel::Info, $meta); + } +} From 29beaf4938eece94bc11f680560894061423ad86 Mon Sep 17 00:00:00 2001 From: Daniel Morell Date: Fri, 17 Jan 2025 08:30:04 -0600 Subject: [PATCH 2/5] Fixed and refactored the tests. --- composer.json | 5 ++++ phpunit.xml | 15 +++++----- tests/RollbarTest.php | 38 ++++++++----------------- tests/TestCase.php | 65 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 34 deletions(-) create mode 100644 tests/TestCase.php diff --git a/composer.json b/composer.json index ab03398..35f31ef 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,11 @@ "Rollbar\\Laravel\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Rollbar\\Laravel\\Tests\\": "tests/" + } + }, "extra": { "laravel": { "providers": [ diff --git a/phpunit.xml b/phpunit.xml index 2db586d..1fd74cf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,6 @@ - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + + + ./src + + ./tests/ - - - ./src - - diff --git a/tests/RollbarTest.php b/tests/RollbarTest.php index d86e673..18f83eb 100644 --- a/tests/RollbarTest.php +++ b/tests/RollbarTest.php @@ -2,31 +2,12 @@ namespace Rollbar\Laravel\Tests; -use Rollbar\Laravel\RollbarServiceProvider; use Rollbar\Laravel\MonologHandler; use Rollbar\Laravel\AgentHandler; use Rollbar\RollbarLogger; -use Monolog\Logger; -use Mockery; -class RollbarTest extends \Orchestra\Testbench\TestCase +class RollbarTest extends TestCase { - private string $access_token = 'B42nHP04s06ov18Dv8X7VI4nVUs6w04X'; - - protected function setUp(): void - { - putenv('ROLLBAR_TOKEN=' . $this->access_token); - - parent::setUp(); - - Mockery::close(); - } - - protected function getPackageProviders($app) - { - return [RollbarServiceProvider::class]; - } - public function testBinding() { $client = $this->app->make(RollbarLogger::class); @@ -55,13 +36,16 @@ public function testPassConfiguration() public function testCustomConfiguration() { - $this->app->config->set('logging.channels.rollbar.root', '/tmp'); - $this->app->config->set('logging.channels.rollbar.included_errno', E_ERROR); - $this->app->config->set('logging.channels.rollbar.environment', 'staging'); + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.root' => '/tmp', + 'logging.channels.rollbar.included_errno' => E_ERROR, + 'logging.channels.rollbar.environment' => 'staging', + ]); + /** @var RollbarLogger $client */ $client = $this->app->make(RollbarLogger::class); - $config = $client->extend([]); - + $config = $client->getConfig()->getConfigArray(); + $this->assertEquals('staging', $config['environment']); $this->assertEquals('/tmp', $config['root']); $this->assertEquals(E_ERROR, $config['included_errno']); @@ -69,14 +53,14 @@ public function testCustomConfiguration() public function testRollbarAgentConfigurationAdapter() { - $this->app->config->set('logging.channels.rollbar.handler', AgentHandler::class); + $this->refreshApplicationWithConfig(['logging.channels.rollbar.handler' => AgentHandler::class]); $client = $this->app->make(RollbarLogger::class); $config = $client->extend([]); $this->assertEquals( 'agent', - $config['handler'], + $config['handler'] ?? null, 'AgentHandler given as Laravel logging config handler should be given as "agent" to Rollbar::init' ); } diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..ef739c9 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,65 @@ +access_token); + + parent::setUp(); + + Mockery::close(); + } + + /** + * Set up the environment. + * + * @param $app + * @return void + */ + protected function defineEnvironment($app): void + { + $configs = array_merge( + [ + 'logging.channels.rollbar.driver' => 'rollbar', + 'logging.channels.rollbar.level' => 'debug', + 'logging.channels.rollbar.access_token' => env('ROLLBAR_TOKEN'), + ], + $this->appConfig, + ); + foreach ($configs as $key => $value) { + $app['config']->set($key, $value); + } + } + + /** + * @param Application $app The Laravel application. + * @return class-string[] The service providers to register. + */ + protected function getPackageProviders($app): array + { + return [RollbarServiceProvider::class]; + } + + /** + * Creates a new Laravel application with the given configuration and sets it as the active application. + * + * @param array $config The configuration to use. + * @return void + */ + protected function refreshApplicationWithConfig(array $config): void + { + $this->appConfig = $config; + $this->refreshApplication(); + } +} From cdbba2d1bf84569e19e412be03dfa31050756647 Mon Sep 17 00:00:00 2001 From: Daniel Morell Date: Fri, 17 Jan 2025 08:30:25 -0600 Subject: [PATCH 3/5] Added test suite to CI. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff09b27..a5442d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,12 @@ jobs: php-version: ${{ matrix.php }} extensions: curl + - name: Install dependencies + run: composer update --prefer-${{ matrix.dependency }} --prefer-dist --no-interaction + + - name: Execute Rollbar tests + run: composer test + - name: Create Laravel test app run: composer create-project laravel/laravel rollbar-test-app ${{ matrix.laravel }} From 6a456ae4fe0b7678e12d9b3dee8a20cd24488ce6 Mon Sep 17 00:00:00 2001 From: Daniel Morell Date: Fri, 17 Jan 2025 08:30:49 -0600 Subject: [PATCH 4/5] Removed tests for telemetry. --- tests/TelemetryListenerTest.php | 202 ++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/TelemetryListenerTest.php diff --git a/tests/TelemetryListenerTest.php b/tests/TelemetryListenerTest.php new file mode 100644 index 0000000..16bb4aa --- /dev/null +++ b/tests/TelemetryListenerTest.php @@ -0,0 +1,202 @@ +clearQueue(); + } + + public function testTelemetryDisabled(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry' => false, + ]); + + self::assertFalse($this->app['config']->get('logging.channels.rollbar.telemetry')); + self::assertNull(Rollbar::getTelemeter()); + } + + public function testTelemetryCapturesLaravelLogs(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry.capture_logs' => true, + ]); + + self::assertTrue($this->app['config']->get('logging.channels.rollbar.telemetry.capture_logs')); + + $this->app['events']->dispatch(new MessageLogged( + level: 'debug', + message: 'telemetry test message', + context: ['foo' => 'bar'] + )); + + $telemetryEvents = Rollbar::getTelemeter()->copyEvents(); + $lastItem = array_pop($telemetryEvents); + + self::assertSame(EventLevel::Debug, $lastItem->level); + self::assertSame('telemetry test message', $lastItem->body->message); + self::assertSame(['foo' => 'bar'], $lastItem->body->extra); + } + + public function testTelemetryDoesNotCaptureLaravelLogsWhenDisabled(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry.capture_logs' => false, + ]); + + self::assertFalse($this->app['config']->get('logging.channels.rollbar.telemetry.capture_logs')); + + $this->app['events']->dispatch(new MessageLogged( + level: 'debug', + message: 'telemetry test message', + context: ['foo' => 'bar'] + )); + + self::assertEmpty(Rollbar::getTelemeter()->copyEvents()); + } + + public function testTelemetryCapturesRouteMatched(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry.capture_routing' => true, + ]); + + self::assertTrue($this->app['config']->get('logging.channels.rollbar.telemetry.capture_routing')); + + $this->app['events']->dispatch(new RouteMatched( + route: new Route( + methods: ['GET'], + uri: 'test', + action: (fn() => 'test')(...), + ), + request: new Request(), + )); + + $telemetryEvents = Rollbar::getTelemeter()->copyEvents(); + $lastItem = array_pop($telemetryEvents); + + self::assertSame(EventLevel::Info, $lastItem->level); + self::assertSame('Route matched', $lastItem->body->message); + self::assertSame(['route' => 'test'], $lastItem->body->extra); + } + + public function testTelemetryDoesNotCaptureRouteMatchedWhenDisabled(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry.capture_routing' => false, + ]); + + self::assertFalse($this->app['config']->get('logging.channels.rollbar.telemetry.capture_routing')); + + $this->app['events']->dispatch(new RouteMatched( + route: new Route( + methods: ['GET'], + uri: 'test', + action: (fn() => 'test')(...), + ), + request: new Request(), + )); + + self::assertEmpty(Rollbar::getTelemeter()->copyEvents()); + } + + public function testTelemetryCapturesQueryExecuted(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry.capture_db_queries' => true, + 'logging.channels.rollbar.telemetry.capture_db_query_parameters' => true, + ]); + + self::assertTrue($this->app['config']->get('logging.channels.rollbar.telemetry.capture_db_queries')); + self::assertTrue($this->app['config']->get('logging.channels.rollbar.telemetry.capture_db_query_parameters')); + + $this->app['events']->dispatch(new QueryExecuted( + sql: 'SELECT * FROM test WHERE id = ?', + bindings: [1], + time: 0.1, + connection: new Connection( + pdo: (fn() => 'test')(...), + config: ['name' => 'connection_name'], + ), + )); + + $telemetryEvents = Rollbar::getTelemeter()->copyEvents(); + $lastItem = array_pop($telemetryEvents); + + self::assertSame(EventLevel::Info, $lastItem->level); + self::assertSame('Query executed', $lastItem->body->message); + self::assertSame([ + 'query' => 'SELECT * FROM test WHERE id = ?', + 'time' => 0.1, + 'connection' => 'connection_name', + 'bindings' => [1], + ], $lastItem->body->extra); + } + + public function testTelemetryDoesNotCaptureQueryExecutedWhenDisabled(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry.capture_db_queries' => false, + ]); + + self::assertFalse($this->app['config']->get('logging.channels.rollbar.telemetry.capture_db_queries')); + + $this->app['events']->dispatch(new QueryExecuted( + sql: 'SELECT * FROM test WHERE id = ?', + bindings: [1], + time: 0.1, + connection: new Connection( + pdo: (fn() => 'test')(...), + config: ['name' => 'connection_name'], + ), + )); + + self::assertEmpty(Rollbar::getTelemeter()->copyEvents()); + } + + public function testTelemetryDoesNotCaptureQueryParametersWhenDisabled(): void + { + $this->refreshApplicationWithConfig([ + 'logging.channels.rollbar.telemetry.capture_db_queries' => true, + 'logging.channels.rollbar.telemetry.capture_db_query_parameters' => false, + ]); + + self::assertTrue($this->app['config']->get('logging.channels.rollbar.telemetry.capture_db_queries')); + self::assertFalse($this->app['config']->get('logging.channels.rollbar.telemetry.capture_db_query_parameters')); + + $this->app['events']->dispatch(new QueryExecuted( + sql: 'SELECT * FROM test WHERE id = ?', + bindings: [1], + time: 0.1, + connection: new Connection( + pdo: (fn() => 'test')(...), + config: ['name' => 'connection_name'], + ), + )); + + $telemetryEvents = Rollbar::getTelemeter()->copyEvents(); + $lastItem = array_pop($telemetryEvents); + + self::assertSame(EventLevel::Info, $lastItem->level); + self::assertSame('Query executed', $lastItem->body->message); + self::assertSame([ + 'query' => 'SELECT * FROM test WHERE id = ?', + 'time' => 0.1, + 'connection' => 'connection_name', + ], $lastItem->body->extra); + } +} From fd6d17d13470b6e715119f63e5fbd4536342d7ce Mon Sep 17 00:00:00 2001 From: Daniel Morell Date: Fri, 17 Jan 2025 08:38:46 -0600 Subject: [PATCH 5/5] Fixed CI composer install. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5442d9..182ec13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: extensions: curl - name: Install dependencies - run: composer update --prefer-${{ matrix.dependency }} --prefer-dist --no-interaction + run: composer update --prefer-dist --no-interaction - name: Execute Rollbar tests run: composer test