Merge pull request #634 from flightphp/apm-enhancements

Added more events, added apm changes
pull/635/head v3.15.1
n0nag0n 3 weeks ago committed by GitHub
commit 4508d887c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -30,6 +30,9 @@ use flight\net\Route;
* @method void stop() Stops framework and outputs current response
* @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) Stops processing and returns a given response.
*
* # Class registration
* @method EventDispatcher eventDispatcher() Gets event dispatcher
*
* # Routing
* @method Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '')
* Routes a URL to a callback function with all applicable methods
@ -110,7 +113,6 @@ class Engine
{
$this->loader = new Loader();
$this->dispatcher = new Dispatcher();
$this->eventDispatcher = new EventDispatcher();
$this->init();
}
@ -160,6 +162,9 @@ class Engine
$this->dispatcher->setEngine($this);
// Register default components
$this->map('eventDispatcher', function () {
return EventDispatcher::getInstance();
});
$this->loader->register('request', Request::class);
$this->loader->register('response', Response::class);
$this->loader->register('router', Router::class);
@ -460,7 +465,9 @@ class Engine
// Here is the array callable $middlewareObject that we created earlier.
// It looks bizarre but it's really calling [ $class, $method ]($params)
// Which loosely translates to $class->$method($params)
$start = microtime(true);
$middlewareResult = $middlewareObject($params);
$this->triggerEvent('flight.middleware.executed', $route, $middleware, microtime(true) - $start);
if ($useV3OutputBuffering === true) {
$this->response()->write(ob_get_clean());
@ -573,12 +580,12 @@ class Engine
}
// Call route handler
$routeStart = microtime(true);
$continue = $this->dispatcher->execute(
$route->callback,
$params
);
$this->triggerEvent('flight.route.executed', $route);
$this->triggerEvent('flight.route.executed', $route, microtime(true) - $routeStart);
if ($useV3OutputBuffering === true) {
$response->write(ob_get_clean());
}
@ -631,6 +638,7 @@ class Engine
*/
public function _error(Throwable $e): void
{
$this->triggerEvent('flight.error', $e);
$msg = sprintf(
<<<'HTML'
<h1>500 Internal Server Error</h1>
@ -678,8 +686,6 @@ class Engine
}
$response->send();
$this->triggerEvent('flight.response.sent', $response);
}
}
@ -831,6 +837,8 @@ class Engine
$url = $base . preg_replace('#/+#', '/', '/' . $url);
}
$this->triggerEvent('flight.redirect', $url, $code);
$this->response()
->clearBody()
->status($code)
@ -854,7 +862,9 @@ class Engine
return;
}
$start = microtime(true);
$this->view()->render($file, $data);
$this->triggerEvent('flight.view.rendered', $file, microtime(true) - $start);
}
/**
@ -1019,7 +1029,7 @@ class Engine
*/
public function _onEvent(string $eventName, callable $callback): void
{
$this->eventDispatcher->on($eventName, $callback);
$this->eventDispatcher()->on($eventName, $callback);
}
/**
@ -1030,6 +1040,6 @@ class Engine
*/
public function _triggerEvent(string $eventName, ...$args): void
{
$this->eventDispatcher->trigger($eventName, ...$args);
$this->eventDispatcher()->trigger($eventName, ...$args);
}
}

@ -8,6 +8,7 @@ use flight\net\Response;
use flight\net\Router;
use flight\template\View;
use flight\net\Route;
use flight\core\EventDispatcher;
require_once __DIR__ . '/autoload.php';
@ -29,6 +30,9 @@ require_once __DIR__ . '/autoload.php';
* Unregisters a class to a framework method.
* @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler.
*
* # Class registration
* @method EventDispatcher eventDispatcher() Gets event dispatcher
*
* # Routing
* @method static Route route(string $pattern, callable|string $callback, bool $pass_route = false, string $alias = '')
* Maps a URL pattern to a callback with all applicable methods.

@ -6,9 +6,25 @@ namespace flight\core;
class EventDispatcher
{
/** @var self|null Singleton instance of the EventDispatcher */
private static ?self $instance = null;
/** @var array<string, array<int, callable>> */
protected array $listeners = [];
/**
* Singleton instance of the EventDispatcher.
*
* @return self
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register a callback for an event.
*
@ -42,4 +58,80 @@ class EventDispatcher
}
}
}
/**
* Check if an event has any registered listeners.
*
* @param string $event Event name
*
* @return bool True if the event has listeners, false otherwise
*/
public function hasListeners(string $event): bool
{
return isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0;
}
/**
* Get all listeners registered for a specific event.
*
* @param string $event Event name
*
* @return array<int, callable> Array of callbacks registered for the event
*/
public function getListeners(string $event): array
{
return $this->listeners[$event] ?? [];
}
/**
* Get a list of all events that have registered listeners.
*
* @return array<int, string> Array of event names
*/
public function getAllRegisteredEvents(): array
{
return array_keys($this->listeners);
}
/**
* Remove a specific listener for an event.
*
* @param string $event the event name
* @param callable $callback the exact callback to remove
*
* @return void
*/
public function removeListener(string $event, callable $callback): void
{
if (isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0) {
$this->listeners[$event] = array_filter($this->listeners[$event], function ($listener) use ($callback) {
return $listener !== $callback;
});
$this->listeners[$event] = array_values($this->listeners[$event]); // Re-index the array
}
}
/**
* Remove all listeners for a specific event.
*
* @param string $event the event name
*
* @return void
*/
public function removeAllListeners(string $event): void
{
if (isset($this->listeners[$event]) === true) {
unset($this->listeners[$event]);
}
}
/**
* Remove the current singleton instance of the EventDispatcher.
*
* @return void
*/
public static function resetInstance(): void
{
self::$instance = null;
}
}

@ -4,12 +4,40 @@ declare(strict_types=1);
namespace flight\database;
use flight\core\EventDispatcher;
use flight\util\Collection;
use PDO;
use PDOStatement;
class PdoWrapper extends PDO
{
/** @var bool $trackApmQueries Whether to track application performance metrics (APM) for queries. */
protected bool $trackApmQueries = false;
/** @var array<int,array<string,mixed>> $queryMetrics Metrics related to the database connection. */
protected array $queryMetrics = [];
/** @var array<string,string> $connectionMetrics Metrics related to the database connection. */
protected array $connectionMetrics = [];
/**
* Constructor for the PdoWrapper class.
*
* @param string $dsn The Data Source Name (DSN) for the database connection.
* @param string|null $username The username for the database connection.
* @param string|null $password The password for the database connection.
* @param array<string, mixed>|null $options An array of options for the PDO connection.
* @param bool $trackApmQueries Whether to track application performance metrics (APM) for queries.
*/
public function __construct(?string $dsn = null, ?string $username = '', ?string $password = '', ?array $options = null, bool $trackApmQueries = false)
{
parent::__construct($dsn, $username, $password, $options);
$this->trackApmQueries = $trackApmQueries;
if ($this->trackApmQueries === true) {
$this->connectionMetrics = $this->pullDataFromDsn($dsn);
}
}
/**
* Use this for INSERTS, UPDATES, or if you plan on using a SELECT in a while loop
*
@ -31,8 +59,19 @@ class PdoWrapper extends PDO
$processed_sql_data = $this->processInStatementSql($sql, $params);
$sql = $processed_sql_data['sql'];
$params = $processed_sql_data['params'];
$start = $this->trackApmQueries === true ? microtime(true) : 0;
$memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0;
$statement = $this->prepare($sql);
$statement->execute($params);
if ($this->trackApmQueries === true) {
$this->queryMetrics[] = [
'sql' => $sql,
'params' => $params,
'execution_time' => microtime(true) - $start,
'row_count' => $statement->rowCount(),
'memory_usage' => memory_get_usage() - $memory_start
];
}
return $statement;
}
@ -88,9 +127,20 @@ class PdoWrapper extends PDO
$processed_sql_data = $this->processInStatementSql($sql, $params);
$sql = $processed_sql_data['sql'];
$params = $processed_sql_data['params'];
$start = $this->trackApmQueries === true ? microtime(true) : 0;
$memory_start = $this->trackApmQueries === true ? memory_get_usage() : 0;
$statement = $this->prepare($sql);
$statement->execute($params);
$results = $statement->fetchAll();
if ($this->trackApmQueries === true) {
$this->queryMetrics[] = [
'sql' => $sql,
'params' => $params,
'execution_time' => microtime(true) - $start,
'row_count' => $statement->rowCount(),
'memory_usage' => memory_get_usage() - $memory_start
];
}
if (is_array($results) === true && count($results) > 0) {
foreach ($results as &$result) {
$result = new Collection($result);
@ -101,6 +151,56 @@ class PdoWrapper extends PDO
return $results;
}
/**
* Pulls the engine, database, and host from the DSN string.
*
* @param string $dsn The Data Source Name (DSN) string.
*
* @return array<string,string> An associative array containing the engine, database, and host.
*/
protected function pullDataFromDsn(string $dsn): array
{
// pull the engine from the dsn (sqlite, mysql, pgsql, etc)
preg_match('/^([a-zA-Z]+):/', $dsn, $matches);
$engine = $matches[1] ?? 'unknown';
if ($engine === 'sqlite') {
// pull the path from the dsn
preg_match('/sqlite:(.*)/', $dsn, $matches);
$dbname = basename($matches[1] ?? 'unknown');
$host = 'localhost';
} else {
// pull the database from the dsn
preg_match('/dbname=([^;]+)/', $dsn, $matches);
$dbname = $matches[1] ?? 'unknown';
// pull the host from the dsn
preg_match('/host=([^;]+)/', $dsn, $matches);
$host = $matches[1] ?? 'unknown';
}
return [
'engine' => $engine,
'database' => $dbname,
'host' => $host
];
}
/**
* Logs the executed queries through the event dispatcher.
*
* This method enables logging of all the queries executed by the PDO wrapper.
* It can be useful for debugging and monitoring purposes.
*
* @return void
*/
public function logQueries(): void
{
if ($this->trackApmQueries === true && $this->connectionMetrics !== [] && $this->queryMetrics !== []) {
EventDispatcher::getInstance()->trigger('flight.db.queries', $this->connectionMetrics, $this->queryMetrics);
$this->queryMetrics = []; // Reset after logging
}
}
/**
* Don't worry about this guy. Converts stuff for IN statements
*

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace flight\net;
use Exception;
use flight\core\EventDispatcher;
/**
* The Response class represents an HTTP response. The object
@ -426,6 +427,7 @@ class Response
}
}
$start = microtime(true);
// Only for the v3 output buffering.
if ($this->v2_output_buffering === false) {
$this->processResponseCallbacks();
@ -436,8 +438,9 @@ class Response
}
echo $this->body;
$this->sent = true;
EventDispatcher::getInstance()->trigger('flight.response.sent', $this, microtime(true) - $start);
}
/**

@ -13,6 +13,7 @@ use InvalidArgumentException;
use PharIo\Manifest\InvalidEmailException;
use tests\classes\Hello;
use PHPUnit\Framework\TestCase;
use tests\classes\ClassWithExceptionInConstruct;
use tests\classes\ContainerDefault;
use tests\classes\TesterClass;
use TypeError;
@ -329,4 +330,17 @@ class DispatcherTest extends TestCase
$this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#');
$result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']);
}
public function testContainerDicePdoWrapperTestBadParams()
{
$dice = new \Dice\Dice();
$this->dispatcher->setContainerHandler(function ($class, $params) use ($dice) {
return $dice->create($class, $params);
});
$this->expectException(Exception::class);
$this->expectExceptionMessage('This is an exception in the constructor');
$this->dispatcher->invokeCallable([ ClassWithExceptionInConstruct::class, '__construct' ]);
}
}

@ -845,31 +845,6 @@ class EngineTest extends TestCase
$this->expectOutputString('You got it boss!');
}
public function testContainerDicePdoWrapperTestBadParams() {
$engine = new Engine();
$dice = new \Dice\Dice();
$engine->registerContainerHandler(function ($class, $params) use ($dice) {
return $dice->create($class, $params);
});
$engine->route('/container', Container::class.'->testThePdoWrapper');
$engine->request()->url = '/container';
// php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException
if(version_compare(PHP_VERSION, '8.1.0') >= 0) {
$this->expectException(ErrorException::class);
$this->expectExceptionMessageMatches("/Passing null to parameter/");
} elseif(version_compare(PHP_VERSION, '8.0.0') >= 0) {
$this->expectException(PDOException::class);
$this->expectExceptionMessageMatches("/must be a valid data source name/");
} else {
$this->expectException(PDOException::class);
$this->expectExceptionMessageMatches("/invalid data source name/");
}
$engine->start();
}
public function testContainerDiceBadClass() {
$engine = new Engine();
$dice = new \Dice\Dice();

@ -16,6 +16,7 @@ class EventSystemTest extends TestCase
// Reset the Flight engine before each test to ensure a clean state
Flight::setEngine(new Engine());
Flight::app()->init();
Flight::eventDispatcher()->resetInstance(); // Clear any existing listeners
}
/**
@ -224,4 +225,124 @@ class EventSystemTest extends TestCase
$this->assertTrue($secondCalled, 'Second listener should be called');
$this->assertFalse($thirdCalled, 'Third listener should not be called after propagation stopped');
}
/**
* Test that hasListeners() correctly identifies events with listeners.
*/
public function testHasListeners()
{
$this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should not have listeners before registration');
Flight::onEvent('test.event', function () {
});
$this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners after registration');
}
/**
* Test that getListeners() returns the correct listeners for an event.
*/
public function testGetListeners()
{
$callback1 = function () {
};
$callback2 = function () {
};
$this->assertEmpty(Flight::eventDispatcher()->getListeners('test.event'), 'Event should have no listeners before registration');
Flight::onEvent('test.event', $callback1);
Flight::onEvent('test.event', $callback2);
$listeners = Flight::eventDispatcher()->getListeners('test.event');
$this->assertCount(2, $listeners, 'Event should have two registered listeners');
$this->assertSame($callback1, $listeners[0], 'First listener should match the first callback');
$this->assertSame($callback2, $listeners[1], 'Second listener should match the second callback');
}
/**
* Test that getListeners() returns an empty array for events with no listeners.
*/
public function testGetListenersForNonexistentEvent()
{
$listeners = Flight::eventDispatcher()->getListeners('nonexistent.event');
$this->assertIsArray($listeners, 'Should return an array for nonexistent events');
$this->assertEmpty($listeners, 'Should return an empty array for nonexistent events');
}
/**
* Test that getAllRegisteredEvents() returns all event names with registered listeners.
*/
public function testGetAllRegisteredEvents()
{
$this->assertEmpty(Flight::eventDispatcher()->getAllRegisteredEvents(), 'No events should be registered initially');
Flight::onEvent('test.event1', function () {
});
Flight::onEvent('test.event2', function () {
});
$events = Flight::eventDispatcher()->getAllRegisteredEvents();
$this->assertCount(2, $events, 'Should return all registered event names');
$this->assertContains('test.event1', $events, 'Should contain the first event');
$this->assertContains('test.event2', $events, 'Should contain the second event');
}
/**
* Test that removeListener() correctly removes a specific listener from an event.
*/
public function testRemoveListener()
{
$callback1 = function () {
return 'callback1';
};
$callback2 = function () {
return 'callback2';
};
Flight::onEvent('test.event', $callback1);
Flight::onEvent('test.event', $callback2);
$this->assertCount(2, Flight::eventDispatcher()->getListeners('test.event'), 'Event should have two listeners initially');
Flight::eventDispatcher()->removeListener('test.event', $callback1);
$listeners = Flight::eventDispatcher()->getListeners('test.event');
$this->assertCount(1, $listeners, 'Event should have one listener after removal');
$this->assertSame($callback2, $listeners[0], 'Remaining listener should be the second callback');
}
/**
* Test that removeAllListeners() correctly removes all listeners for an event.
*/
public function testRemoveAllListeners()
{
Flight::onEvent('test.event', function () {
});
Flight::onEvent('test.event', function () {
});
Flight::onEvent('another.event', function () {
});
$this->assertTrue(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have listeners before removal');
$this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should have listeners');
Flight::eventDispatcher()->removeAllListeners('test.event');
$this->assertFalse(Flight::eventDispatcher()->hasListeners('test.event'), 'Event should have no listeners after removal');
$this->assertTrue(Flight::eventDispatcher()->hasListeners('another.event'), 'Another event should still have listeners');
}
/**
* Test that trying to remove listeners for nonexistent events doesn't cause errors.
*/
public function testRemoveListenersForNonexistentEvent()
{
// Should not throw any errors
Flight::eventDispatcher()->removeListener('nonexistent.event', function () {
});
Flight::eventDispatcher()->removeAllListeners('nonexistent.event');
$this->assertTrue(true, 'Removing listeners for nonexistent events should not throw errors');
}
}

@ -5,8 +5,10 @@ declare(strict_types=1);
namespace tests;
use flight\database\PdoWrapper;
use flight\core\EventDispatcher;
use PDOStatement;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
class PdoWrapperTest extends TestCase
{
@ -120,4 +122,90 @@ class PdoWrapperTest extends TestCase
$rows = $this->pdo_wrapper->fetchAll('SELECT id FROM test WHERE id > ? AND name IN( ?) ', [ 0, 'one,two' ]);
$this->assertEquals(2, count($rows));
}
public function testPullDataFromDsn()
{
// Testing protected method using reflection
$reflection = new ReflectionClass($this->pdo_wrapper);
$method = $reflection->getMethod('pullDataFromDsn');
$method->setAccessible(true);
// Test SQLite DSN
$sqliteDsn = 'sqlite::memory:';
$sqliteResult = $method->invoke($this->pdo_wrapper, $sqliteDsn);
$this->assertEquals([
'engine' => 'sqlite',
'database' => ':memory:',
'host' => 'localhost'
], $sqliteResult);
// Test MySQL DSN
$mysqlDsn = 'mysql:host=localhost;dbname=testdb;charset=utf8';
$mysqlResult = $method->invoke($this->pdo_wrapper, $mysqlDsn);
$this->assertEquals([
'engine' => 'mysql',
'database' => 'testdb',
'host' => 'localhost'
], $mysqlResult);
// Test PostgreSQL DSN
$pgsqlDsn = 'pgsql:host=127.0.0.1;dbname=postgres';
$pgsqlResult = $method->invoke($this->pdo_wrapper, $pgsqlDsn);
$this->assertEquals([
'engine' => 'pgsql',
'database' => 'postgres',
'host' => '127.0.0.1'
], $pgsqlResult);
}
public function testLogQueries()
{
// Create a new PdoWrapper with tracking enabled
$trackingPdo = new PdoWrapper('sqlite::memory:', null, null, null, true);
// Create test table
$trackingPdo->exec('CREATE TABLE test_log (id INTEGER PRIMARY KEY, name TEXT)');
// Run some queries to populate metrics
$trackingPdo->runQuery('INSERT INTO test_log (name) VALUES (?)', ['test1']);
$trackingPdo->fetchAll('SELECT * FROM test_log');
// Setup event listener to capture triggered event
$eventTriggered = false;
$connectionData = null;
$queriesData = null;
$dispatcher = EventDispatcher::getInstance();
$dispatcher->on('flight.db.queries', function ($conn, $queries) use (&$eventTriggered, &$connectionData, &$queriesData) {
$eventTriggered = true;
$connectionData = $conn;
$queriesData = $queries;
});
// Call the logQueries method
$trackingPdo->logQueries();
// Assert that event was triggered
$this->assertTrue($eventTriggered);
$this->assertIsArray($connectionData);
$this->assertEquals('sqlite', $connectionData['engine']);
$this->assertIsArray($queriesData);
$this->assertCount(2, $queriesData); // Should have 2 queries (INSERT and SELECT)
// Verify query metrics structure for the first query
$this->assertArrayHasKey('sql', $queriesData[0]);
$this->assertArrayHasKey('params', $queriesData[0]);
$this->assertArrayHasKey('execution_time', $queriesData[0]);
$this->assertArrayHasKey('row_count', $queriesData[0]);
$this->assertArrayHasKey('memory_usage', $queriesData[0]);
// Clean up
$trackingPdo->exec('DROP TABLE test_log');
// Verify metrics are reset after logging
$reflection = new ReflectionClass($trackingPdo);
$property = $reflection->getProperty('queryMetrics');
$property->setAccessible(true);
$this->assertCount(0, $property->getValue($trackingPdo));
}
}

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace tests\classes;
class ClassWithExceptionInConstruct
{
public function __construct()
{
throw new \Exception('This is an exception in the constructor');
}
}
Loading…
Cancel
Save